feat(club-003): club POS integration: identify member and register cashback from sales
This commit is contained in:
@@ -1433,6 +1433,8 @@ export async function registerPosRoutes(app: FastifyInstance, deps: PosRouteDeps
|
||||
payments: { type: 'array', minItems: 0, items: { type: 'object' } },
|
||||
customerId: { type: 'string', format: 'uuid' },
|
||||
posLabel: { type: 'string', maxLength: 100 },
|
||||
/** Club member UUID — if provided and sale completes, cashback is accumulated. */
|
||||
clubMemberId: { type: 'string', format: 'uuid' },
|
||||
},
|
||||
},
|
||||
response: {
|
||||
@@ -1485,6 +1487,7 @@ export async function registerPosRoutes(app: FastifyInstance, deps: PosRouteDeps
|
||||
payments: z.array(payment).min(0),
|
||||
customerId: z.string().uuid().optional(),
|
||||
posLabel: z.string().max(100).optional(),
|
||||
clubMemberId: z.string().uuid().optional(),
|
||||
}),
|
||||
request.body ?? {},
|
||||
);
|
||||
|
||||
@@ -138,6 +138,8 @@ export class CreatePosSaleUseCase {
|
||||
|
||||
async execute(input: PosSaleInput): Promise<PosSaleResult> {
|
||||
const client = await this.pool.connect();
|
||||
let clubEarnedCents = 0;
|
||||
let clubMemberCode: string | undefined;
|
||||
try {
|
||||
await client.query('BEGIN');
|
||||
|
||||
@@ -433,6 +435,47 @@ export class CreatePosSaleUseCase {
|
||||
WHERE id = $1`,
|
||||
[order.id],
|
||||
);
|
||||
|
||||
// ── Club cashback accumulation ────────────────────────────────────────────
|
||||
if (input.clubMemberId) {
|
||||
try {
|
||||
const settingsRow = await client.query<{ value: string }>(
|
||||
`SELECT value FROM store_settings WHERE key = 'club_enabled' LIMIT 1`,
|
||||
);
|
||||
if (settingsRow.rows[0]?.value === 'true') {
|
||||
const cashbackRow = await client.query<{ value: string }>(
|
||||
`SELECT value FROM store_settings WHERE key = 'club_cashback_bps' LIMIT 1`,
|
||||
);
|
||||
const cashbackBps = Number(cashbackRow.rows[0]?.value ?? '0');
|
||||
if (cashbackBps > 0) {
|
||||
clubEarnedCents = Math.round((totalCents * cashbackBps) / 10_000);
|
||||
if (clubEarnedCents > 0) {
|
||||
const memberRow = await client.query<{ member_code: string }>(
|
||||
`SELECT member_code FROM club_members WHERE id = $1 AND status = 'active' LIMIT 1`,
|
||||
[input.clubMemberId],
|
||||
);
|
||||
clubMemberCode = memberRow.rows[0]?.member_code;
|
||||
await client.query(
|
||||
`INSERT INTO club_transactions (
|
||||
member_id, sale_id, type, amount_cents, balance_delta_cents, idempotency_key, metadata
|
||||
) VALUES ($1, $2, 'earn', $3, $4, $5, $6::jsonb)
|
||||
ON CONFLICT (idempotency_key) DO NOTHING`,
|
||||
[
|
||||
input.clubMemberId,
|
||||
order.id,
|
||||
totalCents,
|
||||
clubEarnedCents,
|
||||
`club-earn-${order.id}`,
|
||||
JSON.stringify({ source: 'pos', orderState }),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// Club cashback failure must not block the sale.
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (cashAppliedCents > 0) {
|
||||
@@ -460,6 +503,8 @@ export class CreatePosSaleUseCase {
|
||||
payments: paymentResults,
|
||||
receipt,
|
||||
createdAt: order.created_at,
|
||||
clubEarnedCents,
|
||||
...(clubMemberCode ? { clubMemberCode } : {}),
|
||||
};
|
||||
} catch (error) {
|
||||
await client.query('ROLLBACK');
|
||||
@@ -511,6 +556,14 @@ export class CreatePosSaleUseCase {
|
||||
vatRate: null,
|
||||
}));
|
||||
const paidCents = payments.reduce((sum, p) => sum + p.amountCents, 0);
|
||||
// Load club earned cashback from ledger if any.
|
||||
const clubTxRow = await this.pool.query<{ balance_delta_cents: number }>(
|
||||
`SELECT balance_delta_cents FROM club_transactions
|
||||
WHERE sale_id = $1 AND type = 'earn' AND idempotency_key = $2
|
||||
LIMIT 1`,
|
||||
[orderId, `club-earn-${orderId}`],
|
||||
);
|
||||
const clubEarnedCents = clubTxRow.rows[0]?.balance_delta_cents ?? 0;
|
||||
return {
|
||||
orderId,
|
||||
idempotencyKey,
|
||||
@@ -524,6 +577,7 @@ export class CreatePosSaleUseCase {
|
||||
payments,
|
||||
receipt,
|
||||
createdAt: receipt.issuedAt,
|
||||
clubEarnedCents,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -241,6 +241,7 @@ export class ReceiveRestPaymentUseCase {
|
||||
payments: paymentResults,
|
||||
receipt,
|
||||
createdAt: new Date(),
|
||||
clubEarnedCents: 0,
|
||||
};
|
||||
} catch (error) {
|
||||
await client.query('ROLLBACK');
|
||||
|
||||
@@ -59,6 +59,8 @@ export interface PosSaleInput {
|
||||
customerId?: string;
|
||||
/** Optional label for pending sales without customer */
|
||||
posLabel?: string;
|
||||
/** Club member ID — if provided and sale completes, cashback is accumulated to the member's ledger. */
|
||||
clubMemberId?: string;
|
||||
}
|
||||
|
||||
export interface PosReceiptItem {
|
||||
@@ -128,6 +130,10 @@ export interface PosSaleResult {
|
||||
payments: PosPaymentResult[];
|
||||
receipt: PosReceipt;
|
||||
createdAt: Date;
|
||||
/** Cashback accumulated to the Club member's ledger (0 if no clubMemberId or sale is PENDING). */
|
||||
clubEarnedCents: number;
|
||||
/** Club member code when a clubMemberId was provided. */
|
||||
clubMemberCode?: string;
|
||||
}
|
||||
|
||||
export interface PosPaymentResult extends PosReceiptPayment {
|
||||
|
||||
Reference in New Issue
Block a user