feat(club-003): club POS integration: identify member and register cashback from sales
This commit is contained in:
@@ -75,6 +75,8 @@ export interface PosSaleResponse {
|
||||
outstandingCents: number;
|
||||
changeCents: number;
|
||||
receipt: PosReceipt;
|
||||
clubEarnedCents: number;
|
||||
clubMemberCode?: string;
|
||||
}
|
||||
|
||||
export interface PosPendingSale {
|
||||
|
||||
@@ -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',
|
||||
{
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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>;
|
||||
|
||||
@@ -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.*
|
||||
|
||||
@@ -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