feat(club-003): club POS integration: identify member and register cashback from sales

This commit is contained in:
Deploy
2026-08-26 20:16:13 +02:00
parent c263b44355
commit 66290a08ae
18 changed files with 553 additions and 99 deletions

View File

@@ -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 ?? {},
);

View File

@@ -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,
};
}
}

View File

@@ -241,6 +241,7 @@ export class ReceiveRestPaymentUseCase {
payments: paymentResults,
receipt,
createdAt: new Date(),
clubEarnedCents: 0,
};
} catch (error) {
await client.query('ROLLBACK');

View File

@@ -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 {