chore(checkpoint): save club core backend and pending pos fixes

This commit is contained in:
Deploy
2026-08-26 17:58:45 +02:00
parent cf3c906ed2
commit 49dfd00406
44 changed files with 2241 additions and 219 deletions

View File

@@ -35,6 +35,7 @@ import { createPromotionService, registerPromotionsRoutes } from '../modules/pro
import { registerCartRoutes } from '../modules/cart/index.js';
import { registerShippingRoutes } from '../modules/shipping/index.js';
import { registerOrdersRoutes } from '../modules/orders/index.js';
import { registerClubRoutes } from '../modules/club/index.js';
import { registerPosRoutes } from '../modules/pos/index.js';
import { registerCheckoutRoutes } from '../modules/checkout/index.js';
import { registerPaymentsRoutes } from '../modules/payments/index.js';
@@ -320,6 +321,13 @@ export async function buildApp(deps: BuildAppDeps = {}): Promise<FastifyInstance
});
});
await app.register(async (instance) => {
await registerClubRoutes(instance, {
pool: deps.pool as pg.Pool,
authenticate: combinedAuth,
});
});
// POS routes (POS-004)
if (deps.pool && combinedAuth) {
await app.register(async (instance) => {

View File

@@ -0,0 +1,191 @@
import type { DestinationStream } from 'pino';
import type pg from 'pg';
import { afterAll, beforeAll, describe, expect, it } from 'vitest';
import { buildApp } from '../build-app.js';
import { createPool } from '../../infrastructure/db/pool.js';
import { createLogger } from '../../infrastructure/logging/logger.js';
import {
getTestDbUrl,
recreateDatabase,
runMigrations,
} from '../../infrastructure/db/tests/db-test-support.js';
import { createClubService, CLUB_DEVICE_COOKIE_NAME } from '../../modules/club/index.js';
import { SESSION_COOKIE_NAME } from '../../modules/identity/index.js';
const hasDb = Boolean(process.env.TEST_DATABASE_URL);
function silentLogger() {
const destination: DestinationStream = { write: () => undefined };
return createLogger({ level: 'info', destination });
}
function cookieValue(setCookieHeader: string | string[] | undefined): string {
const raw = Array.isArray(setCookieHeader) ? setCookieHeader[0] : setCookieHeader;
expect(raw).toBeDefined();
const pair = (raw as string).split(';')[0] as string;
return pair.slice(pair.indexOf('=') + 1);
}
describe.skipIf(!hasDb)('club backend phase 1 (real PostgreSQL)', () => {
const url = hasDb ? getTestDbUrl() : '';
let pool: pg.Pool;
let app: Awaited<ReturnType<typeof buildApp>>;
let adminCookie = '';
beforeAll(async () => {
await recreateDatabase(url);
await runMigrations(url, 'up');
pool = createPool(url);
app = await buildApp({ logger: silentLogger(), pool, cookieSecure: true });
const user = { email: 'club-admin@example.com', password: 'correct horse battery staple' };
const registered = await app.inject({
method: 'POST',
url: '/auth/register',
headers: { 'content-type': 'application/json' },
payload: user,
});
const id = (registered.json() as { id: string }).id;
await pool.query(
`UPDATE identity_users
SET role = $1,
email_confirmed = true,
confirmed_at = now(),
confirmation_token = null
WHERE id = $2`,
['admin', id],
);
const login = await app.inject({
method: 'POST',
url: '/auth/login',
headers: { 'content-type': 'application/json' },
payload: user,
});
adminCookie = cookieValue(login.headers['set-cookie']);
});
afterAll(async () => {
await app?.close();
await pool?.end();
});
it('creates an anonymous member, reuses device identity and lists movements', async () => {
const config = await app.inject({ method: 'GET', url: '/club/config' });
expect(config.statusCode).toBe(200);
expect(config.json()).toMatchObject({ clubEnabled: true, cashbackBps: 200 });
const joined = await app.inject({ method: 'POST', url: '/club/join' });
expect(joined.statusCode).toBe(201);
expect(String(joined.headers['set-cookie'])).toContain(`${CLUB_DEVICE_COOKIE_NAME}=`);
const payload = joined.json() as {
created: boolean;
deviceToken: string;
member: { id: string; memberCode: string; currentBalanceCents: number };
};
expect(payload.created).toBe(true);
expect(payload.deviceToken).toMatch(/^[A-Za-z0-9_-]{40,}$/);
expect(payload.member.memberCode).toMatch(/^MDV-[A-Z0-9]{8}$/);
expect(payload.member.currentBalanceCents).toBe(0);
const me = await app.inject({
method: 'GET',
url: '/club/me',
headers: { 'x-club-device-token': payload.deviceToken },
});
expect(me.statusCode).toBe(200);
expect(me.json()).toMatchObject({ member: { id: payload.member.id, memberCode: payload.member.memberCode } });
const repeatedJoin = await app.inject({
method: 'POST',
url: '/club/join',
cookies: { [CLUB_DEVICE_COOKIE_NAME]: payload.deviceToken },
});
expect(repeatedJoin.statusCode).toBe(200);
expect(repeatedJoin.json()).toMatchObject({ created: false, member: { id: payload.member.id } });
const club = createClubService(pool);
const first = await club.recordTransaction({
memberId: payload.member.id,
type: 'bonus',
amountCents: 100,
balanceDeltaCents: 100,
idempotencyKey: 'club-bonus-1',
metadata: { reason: 'welcome' },
});
expect(first.created).toBe(true);
expect(first.member.currentBalanceCents).toBe(100);
const replay = await club.recordTransaction({
memberId: payload.member.id,
type: 'bonus',
amountCents: 100,
balanceDeltaCents: 100,
idempotencyKey: 'club-bonus-1',
metadata: { reason: 'welcome' },
});
expect(replay.created).toBe(false);
expect(replay.member.currentBalanceCents).toBe(100);
const movements = await app.inject({
method: 'GET',
url: '/club/movements?limit=10',
headers: { 'x-club-device-token': payload.deviceToken },
});
expect(movements.statusCode).toBe(200);
expect(movements.json()).toMatchObject({
member: { id: payload.member.id, currentBalanceCents: 100 },
items: [
{
type: 'bonus',
amountCents: 100,
balanceDeltaCents: 100,
idempotencyKey: 'club-bonus-1',
},
],
});
});
it('reads and updates admin club settings and blocks new joins when disabled', async () => {
const current = await app.inject({
method: 'GET',
url: '/admin/club/settings',
cookies: { [SESSION_COOKIE_NAME]: adminCookie },
});
expect(current.statusCode).toBe(200);
expect(current.json()).toMatchObject({
clubEnabled: true,
cashbackBps: 200,
cashbackPercentage: 2,
allowAnonymousMembers: true,
allowRecoveryCodes: true,
minimumRedeemAmountCents: 500,
});
const updated = await app.inject({
method: 'PATCH',
url: '/admin/club/settings',
headers: { 'content-type': 'application/json' },
cookies: { [SESSION_COOKIE_NAME]: adminCookie },
payload: {
clubEnabled: false,
cashbackPercentage: 3.5,
allowAnonymousMembers: false,
allowRecoveryCodes: false,
minimumRedeemAmountCents: 700,
},
});
expect(updated.statusCode).toBe(200);
expect(updated.json()).toMatchObject({
clubEnabled: false,
cashbackBps: 350,
cashbackPercentage: 3.5,
allowAnonymousMembers: false,
allowRecoveryCodes: false,
minimumRedeemAmountCents: 700,
});
const blocked = await app.inject({ method: 'POST', url: '/club/join' });
expect(blocked.statusCode).toBe(409);
expect(blocked.json()).toMatchObject({ error: { code: 'CLUB_DISABLED' } });
});
});

View File

@@ -79,25 +79,25 @@ describe.skipIf(!hasDb)('F-186 POS checkout and receipts (real PostgreSQL)', ()
],
payments: [
{ methodCode: 'cash', amountCents: 500, tenderedCents: 1000 },
{ methodCode: 'card', amountCents: 700 },
{ methodCode: 'card', amountCents: 770 },
],
};
const result = await useCase.execute(input);
expect(result.totalCents).toBe(1200);
expect(result.totalCents).toBe(1270);
expect(result.changeCents).toBe(500);
expect(result.receiptNumber).toBe('TPV-000001');
expect(result.receipt.items).toEqual(
expect.arrayContaining([
expect.objectContaining({ name: 'Producto test', freeItem: false, totalCents: 700 }),
expect.objectContaining({ name: 'Producto test', freeItem: false, totalCents: 770, taxCents: 70 }),
expect.objectContaining({ name: 'Servicio libre', freeItem: true, totalCents: 500 }),
]),
);
expect(result.receipt.payments).toEqual(
expect.arrayContaining([
expect.objectContaining({ methodCode: 'cash', amountCents: 500, changeCents: 500 }),
expect.objectContaining({ methodCode: 'card', amountCents: 700 }),
expect.objectContaining({ methodCode: 'card', amountCents: 770 }),
]),
);

View File

@@ -95,7 +95,7 @@ describe.skipIf(!hasDb)('F-189 POS returns (real PostgreSQL)', () => {
terminalId: TERMINAL_ID,
userId: USER_ID,
items: [{ kind: 'stock', variantId: VARIANT_ID, quantity: 3, discountCents: 0 }],
payments: [{ methodCode: 'cash', amountCents: 3000, tenderedCents: 3000 }],
payments: [{ methodCode: 'cash', amountCents: 3300, tenderedCents: 3300 }],
});
expect(sale.state).toBe('COMPLETED');
@@ -127,11 +127,11 @@ describe.skipIf(!hasDb)('F-189 POS returns (real PostgreSQL)', () => {
expect(response.statusCode).toBe(201);
const body = response.json();
expect(body.state).toBe('PARTIALLY_REFUNDED');
expect(body.refundedCents).toBe(1000);
expect(body.refundedCents).toBe(1100);
expect(body.receipt.receiptNumber.startsWith('R-')).toBe(true);
expect(body.receipt.totalCents).toBe(-1000);
expect(body.receipt.totalCents).toBe(-1100);
expect(body.receipt.items[0].quantity).toBe(1);
expect(body.receipt.items[0].totalCents).toBe(-1000);
expect(body.receipt.items[0].totalCents).toBe(-1100);
const order = await pool.query<{ state: string }>(
`SELECT state FROM orders_orders WHERE id = $1`,
@@ -183,7 +183,7 @@ describe.skipIf(!hasDb)('F-189 POS returns (real PostgreSQL)', () => {
{ kind: 'stock', variantId: VARIANT_ID, quantity: 1, discountCents: 0 },
{ kind: 'free', name: 'Mano de obra', unitPriceCents: 500, quantity: 1 },
],
payments: [{ methodCode: 'cash', amountCents: 1500, tenderedCents: 1500 }],
payments: [{ methodCode: 'cash', amountCents: 1600, tenderedCents: 1600 }],
});
expect(sale.state).toBe('COMPLETED');
@@ -214,7 +214,7 @@ describe.skipIf(!hasDb)('F-189 POS returns (real PostgreSQL)', () => {
expect(response.statusCode).toBe(201);
const body = response.json();
expect(body.state).toBe('REFUNDED');
expect(body.refundedCents).toBe(1500);
expect(body.refundedCents).toBe(1600);
const stock = await pool.query<{ available: number }>(
`SELECT available FROM inventory_stock WHERE variant_id = $1 AND store_id = $2`,
@@ -238,7 +238,7 @@ describe.skipIf(!hasDb)('F-189 POS returns (real PostgreSQL)', () => {
terminalId: TERMINAL_ID,
userId: USER_ID,
items: [{ kind: 'stock', variantId: VARIANT_ID, quantity: 1, discountCents: 0 }],
payments: [{ methodCode: 'cash', amountCents: 1000, tenderedCents: 1000 }],
payments: [{ methodCode: 'cash', amountCents: 1100, tenderedCents: 1100 }],
});
const items = await pool.query<{ id: string }>(
@@ -271,7 +271,7 @@ describe.skipIf(!hasDb)('F-189 POS returns (real PostgreSQL)', () => {
terminalId: TERMINAL_ID,
userId: USER_ID,
items: [{ kind: 'stock', variantId: VARIANT_ID, quantity: 2, discountCents: 0 }],
payments: [{ methodCode: 'cash', amountCents: 2000, tenderedCents: 2000 }],
payments: [{ methodCode: 'cash', amountCents: 2200, tenderedCents: 2200 }],
});
const items = await pool.query<{ id: string }>(