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

@@ -75,6 +75,8 @@ export interface PosSaleResponse {
outstandingCents: number;
changeCents: number;
receipt: PosReceipt;
clubEarnedCents: number;
clubMemberCode?: string;
}
export interface PosPendingSale {

View File

@@ -144,6 +144,38 @@ export async function registerClubRoutes(
},
);
// ── POS / public resolve by member code ───────────────────────────────────
app.get(
'/club/resolve',
{
schema: {
tags: ['Club'],
summary: 'Resolve Club member by member code (for TPV)',
querystring: {
type: 'object',
required: ['memberCode'],
properties: { memberCode: { type: 'string', minLength: 1 } },
},
response: { 200: { type: 'object' }, 404: errorSchema, 409: errorSchema },
} as FastifySchema,
},
async (request, reply) => {
const query = request.query as { memberCode?: string };
const code = query.memberCode;
if (!code || typeof code !== 'string' || !code.trim()) {
throw new AppError(400, 'CLUB_INVALID_CODE', 'memberCode es obligatorio');
}
try {
const member = await clubs.resolveByCode(code.trim());
if (!member) throw new ClubMemberNotFoundError();
const config = await clubs.getPublicConfig();
return reply.send({ member: serializeMember(member), config: serializeSettings(config) });
} catch (error) {
throw mapClubError(error);
}
},
);
app.get(
'/admin/club/settings',
{

View File

@@ -82,6 +82,13 @@ export class ClubService {
});
}
async resolveByCode(code: string): Promise<ClubMember | null> {
if (!code || typeof code !== 'string') return null;
const normalized = code.trim().toUpperCase();
if (!normalized) return null;
return this.clubs.findMemberByCode(normalized);
}
async getMemberOrThrow(input: ClubResolveInput): Promise<ClubMember> {
const normalized = {
userId: input.userId ?? null,

View File

@@ -20,6 +20,7 @@ export interface ClubRepository {
createMemberWithDevice(input: CreateClubMemberCommand): Promise<ClubMember | null>;
findMemberByUserId(userId: string): Promise<ClubMember | null>;
findMemberByDeviceTokenHash(hash: string, touch?: boolean): Promise<ClubMember | null>;
findMemberByCode(code: string): Promise<ClubMember | null>;
resolveMember(input: ClubResolveInput & { deviceTokenHash?: string | null }): Promise<ClubMember | null>;
listTransactions(memberId: string, limit: number): Promise<ClubTransaction[]>;
recordTransaction(input: RecordClubTransactionCommand): Promise<RecordClubTransactionResult>;

View File

@@ -143,6 +143,16 @@ export class PgClubRepository implements ClubRepository {
return result.rows[0] ? toMember(result.rows[0]) : null;
}
async findMemberByCode(code: string): Promise<ClubMember | null> {
const result = await this.pool.query<MemberRow>(
`SELECT * FROM club_members
WHERE member_code = $1 AND status = 'active'
LIMIT 1`,
[code],
);
return result.rows[0] ? toMember(result.rows[0]) : null;
}
async findMemberByDeviceTokenHash(hash: string, touch = false): Promise<ClubMember | null> {
const result = await this.pool.query<MemberRow>(
`SELECT member.*

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 {