feat(F-188): completed feature

This commit is contained in:
chattie
2026-08-22 22:44:37 +02:00
parent c5e5b4c48c
commit 0e3c488c85
21 changed files with 1081 additions and 71 deletions

View File

@@ -0,0 +1,259 @@
import { createHash } from 'node:crypto';
import argon2 from 'argon2';
import type pg from 'pg';
import { afterAll, beforeAll, describe, expect, it } from 'vitest';
import { buildApp } from '../build-app.js';
import { CreatePosSaleUseCase } from '../../modules/pos/index.js';
import { createPool } from '../../infrastructure/db/pool.js';
import {
getTestDbUrl,
recreateDatabase,
runMigrations,
} from '../../infrastructure/db/tests/db-test-support.js';
const hasDb = Boolean(process.env.TEST_DATABASE_URL);
const STORE_ID = '00000000-0000-0000-0000-000000000001';
const TERMINAL_ID = '20000000-0000-4000-8000-000000000188';
const SESSION_ID = '30000000-0000-4000-8000-000000000188';
const USER_ID = '10000000-0000-4000-8000-000000000188';
const SESSION_TOKEN = 'f188-session';
const PRODUCT_ID = '40000000-0000-4000-8000-000000000188';
const VARIANT_ID = '50000000-0000-4000-8000-000000000188';
function tokenHash(token: string): string {
return createHash('sha256').update(token).digest('hex');
}
function cookie(token: string): string {
return `backoffice_session=${token}`;
}
describe.skipIf(!hasDb)('F-188 POS pending payments (real PostgreSQL)', () => {
const url = hasDb ? getTestDbUrl() : '';
let pool: pg.Pool;
let app: Awaited<ReturnType<typeof buildApp>>;
beforeAll(async () => {
await recreateDatabase(url);
await runMigrations(url, 'up');
pool = createPool(url);
const passwordHash = await argon2.hash('cashier-188');
await pool.query(
`INSERT INTO backoffice_users (id, email, password_hash, role)
VALUES ($1, 'cashier-f188@example.test', $2, 'pos_cashier')`,
[USER_ID, passwordHash],
);
await pool.query(
`INSERT INTO backoffice_sessions (user_id, token_hash, expires_at)
VALUES ($1, $2, now() + interval '1 hour')`,
[USER_ID, tokenHash(SESSION_TOKEN)],
);
await pool.query(
`INSERT INTO pos_terminals (id, store_id, name, binding_code, bound_at)
VALUES ($1, $2, 'Caja F-188', 'F188CODE', now())`,
[TERMINAL_ID, STORE_ID],
);
await pool.query(
`INSERT INTO pos_cash_sessions (
id, terminal_id, store_id, user_id, opening_cash_cents, expected_cash_cents
) VALUES ($1, $2, $3, $4, 1000, 1000)`,
[SESSION_ID, TERMINAL_ID, STORE_ID, USER_ID],
);
await pool.query(
`INSERT INTO catalog_products (id, name, slug, state)
VALUES ($1, 'Producto F-188', 'producto-f188', 'active')`,
[PRODUCT_ID],
);
await pool.query(
`INSERT INTO catalog_product_variants (id, product_id, sku, ean)
VALUES ($1, $2, 'SKU-F188', '8412345678902')`,
[VARIANT_ID, PRODUCT_ID],
);
await pool.query(
`INSERT INTO pricing_variant_prices (variant_id, net_unit_amount_cents, vat_rate)
VALUES ($1, 1000, 'reduced')`,
[VARIANT_ID],
);
await pool.query(
`INSERT INTO inventory_stock (variant_id, store_id, available)
VALUES ($1, $2, 10)`,
[VARIANT_ID, STORE_ID],
);
app = await buildApp({ pool, cookieSecure: false });
});
afterAll(async () => {
await app.close();
await pool.end();
});
it('persists a partially paid POS order as PENDING with outstandingCents', async () => {
const useCase = new CreatePosSaleUseCase(pool);
const result = await useCase.execute({
idempotencyKey: 'f188-partial-1',
cashSessionId: SESSION_ID,
terminalId: TERMINAL_ID,
userId: USER_ID,
items: [{ kind: 'stock', variantId: VARIANT_ID, quantity: 1, discountCents: 0 }],
payments: [{ methodCode: 'cash', amountCents: 600, tenderedCents: 600 }],
});
expect(result.state).toBe('PENDING');
expect(result.outstandingCents).toBe(400);
expect(result.paidCents).toBe(600);
const stored = await pool.query<{ state: string; total_cents: string }>(
`SELECT state, total_cents FROM orders_orders WHERE id = $1`,
[result.orderId],
);
expect(stored.rows[0]?.state).toBe('PENDING');
expect(Number(stored.rows[0]?.total_cents)).toBe(1000);
const paidBefore = await pool.query<{ sum: string | null }>(
`SELECT COALESCE(SUM(amount_cents), 0)::text AS sum FROM payments_transactions
WHERE order_id = $1 AND status = 'succeeded'`,
[result.orderId],
);
expect(Number(paidBefore.rows[0]?.sum)).toBe(600);
});
it('completes a PENDING order via the rest-payment endpoint and accumulates reporting', async () => {
const useCase = new CreatePosSaleUseCase(pool);
const before = await pool.query<{ expected_cash_cents: number }>(
`SELECT expected_cash_cents FROM pos_cash_sessions WHERE id = $1`,
[SESSION_ID],
);
const expectedBefore = Number(before.rows[0]?.expected_cash_cents ?? 0);
const partial = await useCase.execute({
idempotencyKey: 'f188-partial-2',
cashSessionId: SESSION_ID,
terminalId: TERMINAL_ID,
userId: USER_ID,
items: [{ kind: 'stock', variantId: VARIANT_ID, quantity: 1, discountCents: 0 }],
payments: [{ methodCode: 'cash', amountCents: 500, tenderedCents: 500 }],
});
expect(partial.state).toBe('PENDING');
const rest = await app.inject({
method: 'POST',
url: `/pos/sales/${partial.orderId}/payments`,
headers: { cookie: cookie(SESSION_TOKEN), 'x-terminal-id': TERMINAL_ID },
payload: {
idempotencyKey: 'f188-rest-2',
cashSessionId: SESSION_ID,
terminalId: TERMINAL_ID,
payments: [{ methodCode: 'cash', amountCents: 500, tenderedCents: 500 }],
},
});
expect(rest.statusCode).toBe(201);
const body = rest.json();
expect(body.state).toBe('COMPLETED');
expect(body.outstandingCents).toBe(0);
expect(body.paidCents).toBe(1000);
const reporting = await pool.query<{ count: number }>(
`SELECT COUNT(*)::int AS count FROM reporting_payment_lines WHERE order_id = $1`,
[partial.orderId],
);
expect(reporting.rows[0]?.count).toBe(2);
const session = await pool.query<{ expected_cash_cents: number }>(
`SELECT expected_cash_cents FROM pos_cash_sessions WHERE id = $1`,
[SESSION_ID],
);
expect(Number(session.rows[0]?.expected_cash_cents)).toBe(expectedBefore + 1000);
});
it('rejects overpayment on rest-payment and keeps the order PENDING', async () => {
const useCase = new CreatePosSaleUseCase(pool);
const partial = await useCase.execute({
idempotencyKey: 'f188-partial-3',
cashSessionId: SESSION_ID,
terminalId: TERMINAL_ID,
userId: USER_ID,
items: [{ kind: 'stock', variantId: VARIANT_ID, quantity: 1, discountCents: 0 }],
payments: [{ methodCode: 'cash', amountCents: 700, tenderedCents: 700 }],
});
const overpay = await app.inject({
method: 'POST',
url: `/pos/sales/${partial.orderId}/payments`,
headers: { cookie: cookie(SESSION_TOKEN), 'x-terminal-id': TERMINAL_ID },
payload: {
idempotencyKey: 'f188-rest-overpay',
cashSessionId: SESSION_ID,
terminalId: TERMINAL_ID,
payments: [{ methodCode: 'cash', amountCents: 500, tenderedCents: 500 }],
},
});
expect(overpay.statusCode).toBe(400);
expect(overpay.json().error.code).toBe('POS_PAYMENT_OVERPAYMENT');
const stored = await pool.query<{ state: string }>(
`SELECT state FROM orders_orders WHERE id = $1`,
[partial.orderId],
);
expect(stored.rows[0]?.state).toBe('PENDING');
});
it('lists POS sales with state and outstandingCents via GET /pos/sales', async () => {
const listed = await app.inject({
method: 'GET',
url: `/pos/sales?state=PENDING&sessionId=${SESSION_ID}`,
headers: { cookie: cookie(SESSION_TOKEN), 'x-terminal-id': TERMINAL_ID },
});
expect(listed.statusCode).toBe(200);
const body = listed.json();
expect(body.items.length).toBeGreaterThan(0);
expect(body.items[0]).toMatchObject({ state: 'PENDING' });
expect(typeof body.items[0].outstandingCents).toBe('number');
});
it('replays the same rest-payment idempotency key without duplicating rows', async () => {
const useCase = new CreatePosSaleUseCase(pool);
// Create a PENDING sale that stays PENDING after a single rest-payment.
const partial = await useCase.execute({
idempotencyKey: 'f188-partial-4',
cashSessionId: SESSION_ID,
terminalId: TERMINAL_ID,
userId: USER_ID,
items: [{ kind: 'stock', variantId: VARIANT_ID, quantity: 1, discountCents: 0 }],
payments: [{ methodCode: 'cash', amountCents: 400, tenderedCents: 400 }],
});
expect(partial.state).toBe('PENDING');
const first = await app.inject({
method: 'POST',
url: `/pos/sales/${partial.orderId}/payments`,
headers: { cookie: cookie(SESSION_TOKEN), 'x-terminal-id': TERMINAL_ID },
payload: {
idempotencyKey: 'f188-rest-idemp',
cashSessionId: SESSION_ID,
terminalId: TERMINAL_ID,
payments: [{ methodCode: 'cash', amountCents: 100, tenderedCents: 100 }],
},
});
expect(first.statusCode).toBe(201);
expect(first.json().state).toBe('PENDING');
const second = await app.inject({
method: 'POST',
url: `/pos/sales/${partial.orderId}/payments`,
headers: { cookie: cookie(SESSION_TOKEN), 'x-terminal-id': TERMINAL_ID },
payload: {
idempotencyKey: 'f188-rest-idemp',
cashSessionId: SESSION_ID,
terminalId: TERMINAL_ID,
payments: [{ methodCode: 'cash', amountCents: 100, tenderedCents: 100 }],
},
});
expect(second.statusCode).toBe(201);
expect(second.json().orderId).toBe(first.json().orderId);
expect(second.json().paidCents).toBe(first.json().paidCents);
const reporting = await pool.query<{ count: number }>(
`SELECT COUNT(*)::int AS count FROM reporting_payment_lines WHERE order_id = $1`,
[partial.orderId],
);
expect(reporting.rows[0]?.count).toBe(2);
});
});