feat(F-189): completed feature

This commit is contained in:
chattie
2026-08-22 23:01:59 +02:00
parent 0e3c488c85
commit 6b93e91ef4
24 changed files with 1535 additions and 63 deletions

View File

@@ -0,0 +1,338 @@
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-000000000189';
const SESSION_ID = '30000000-0000-4000-8000-000000000189';
const USER_ID = '10000000-0000-4000-8000-000000000189';
const SESSION_TOKEN = 'f189-session';
const PRODUCT_ID = '40000000-0000-4000-8000-000000000189';
const VARIANT_ID = '50000000-0000-4000-8000-000000000189';
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-189 POS returns (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-189');
await pool.query(
`INSERT INTO backoffice_users (id, email, password_hash, role)
VALUES ($1, 'cashier-f189@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-189', 'F189CODE', 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-189', 'producto-f189', 'active')`,
[PRODUCT_ID],
);
await pool.query(
`INSERT INTO catalog_product_variants (id, product_id, sku, ean)
VALUES ($1, $2, 'SKU-F189', '8412345678903')`,
[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('partially returns a sale, restores partial stock and stays PARTIALLY_REFUNDED', async () => {
const useCase = new CreatePosSaleUseCase(pool);
const sale = await useCase.execute({
idempotencyKey: 'f189-sale-partial',
cashSessionId: SESSION_ID,
terminalId: TERMINAL_ID,
userId: USER_ID,
items: [{ kind: 'stock', variantId: VARIANT_ID, quantity: 3, discountCents: 0 }],
payments: [{ methodCode: 'cash', amountCents: 3000, tenderedCents: 3000 }],
});
expect(sale.state).toBe('COMPLETED');
const items = await pool.query<{ id: string }>(
`SELECT id FROM orders_items WHERE order_id = $1`,
[sale.orderId],
);
const firstItemId = items.rows[0]?.id;
expect(firstItemId).toBeTruthy();
const stockBefore = await pool.query<{ available: number }>(
`SELECT available FROM inventory_stock WHERE variant_id = $1 AND store_id = $2`,
[VARIANT_ID, STORE_ID],
);
expect(Number(stockBefore.rows[0]?.available)).toBe(7);
const response = await app.inject({
method: 'POST',
url: `/pos/sales/${sale.orderId}/returns`,
headers: { cookie: cookie(SESSION_TOKEN), 'x-terminal-id': TERMINAL_ID },
payload: {
idempotencyKey: 'f189-rest-partial',
cashSessionId: SESSION_ID,
terminalId: TERMINAL_ID,
reason: 'Cliente cambió de opinión',
items: [{ orderItemId: firstItemId!, returnedQuantity: 1 }],
},
});
expect(response.statusCode).toBe(201);
const body = response.json();
expect(body.state).toBe('PARTIALLY_REFUNDED');
expect(body.refundedCents).toBe(1000);
expect(body.receipt.receiptNumber.startsWith('R-')).toBe(true);
expect(body.receipt.totalCents).toBe(-1000);
expect(body.receipt.items[0].quantity).toBe(1);
expect(body.receipt.items[0].totalCents).toBe(-1000);
const order = await pool.query<{ state: string }>(
`SELECT state FROM orders_orders WHERE id = $1`,
[sale.orderId],
);
expect(order.rows[0]?.state).toBe('PARTIALLY_REFUNDED');
const itemRow = await pool.query<{ returned_quantity: number }>(
`SELECT returned_quantity FROM orders_items WHERE id = $1`,
[firstItemId!],
);
expect(Number(itemRow.rows[0]?.returned_quantity)).toBe(1);
const stockAfter = await pool.query<{ available: number }>(
`SELECT available FROM inventory_stock WHERE variant_id = $1 AND store_id = $2`,
[VARIANT_ID, STORE_ID],
);
expect(Number(stockAfter.rows[0]?.available)).toBe(8);
const event = await pool.query<{ event_type: string }>(
`SELECT event_type FROM orders_order_history WHERE order_id = $1 AND event_type = 'RETURNED'`,
[sale.orderId],
);
expect(event.rows.length).toBe(1);
const reporting = await pool.query<{ status: string; amount_cents: number }>(
`SELECT status, amount_cents FROM reporting_payment_lines
WHERE order_id = $1 AND provider = 'pos_return'`,
[sale.orderId],
);
expect(reporting.rows[0]?.status).toBe('partial_refund');
expect(Number(reporting.rows[0]?.amount_cents)).toBe(1000);
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)).toBeLessThanOrEqual(4000);
});
it('fully returns a sale and transitions to REFUNDED', async () => {
const useCase = new CreatePosSaleUseCase(pool);
const sale = await useCase.execute({
idempotencyKey: 'f189-sale-full',
cashSessionId: SESSION_ID,
terminalId: TERMINAL_ID,
userId: USER_ID,
items: [
{ 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 }],
});
expect(sale.state).toBe('COMPLETED');
const items = await pool.query<{ id: string; is_free_item: boolean }>(
`SELECT id, is_free_item FROM orders_items WHERE order_id = $1 ORDER BY is_free_item DESC, id`,
[sale.orderId],
);
const stockItemId = items.rows.find((r) => !r.is_free_item)?.id;
const freeItemId = items.rows.find((r) => r.is_free_item)?.id;
expect(stockItemId).toBeTruthy();
expect(freeItemId).toBeTruthy();
const response = await app.inject({
method: 'POST',
url: `/pos/sales/${sale.orderId}/returns`,
headers: { cookie: cookie(SESSION_TOKEN), 'x-terminal-id': TERMINAL_ID },
payload: {
idempotencyKey: 'f189-rest-full',
cashSessionId: SESSION_ID,
terminalId: TERMINAL_ID,
reason: 'Devolución completa',
items: [
{ orderItemId: stockItemId!, returnedQuantity: 1 },
{ orderItemId: freeItemId!, returnedQuantity: 1 },
],
},
});
expect(response.statusCode).toBe(201);
const body = response.json();
expect(body.state).toBe('REFUNDED');
expect(body.refundedCents).toBe(1500);
const stock = await pool.query<{ available: number }>(
`SELECT available FROM inventory_stock WHERE variant_id = $1 AND store_id = $2`,
[VARIANT_ID, STORE_ID],
);
expect(Number(stock.rows[0]?.available)).toBe(8);
const reporting = await pool.query<{ status: string }>(
`SELECT status FROM reporting_payment_lines
WHERE order_id = $1 AND provider = 'pos_return'`,
[sale.orderId],
);
expect(reporting.rows[0]?.status).toBe('refund');
});
it('rejects over-return with POS_RETURN_EXCEEDS_AVAILABLE', async () => {
const useCase = new CreatePosSaleUseCase(pool);
const sale = await useCase.execute({
idempotencyKey: 'f189-sale-overreturn',
cashSessionId: SESSION_ID,
terminalId: TERMINAL_ID,
userId: USER_ID,
items: [{ kind: 'stock', variantId: VARIANT_ID, quantity: 1, discountCents: 0 }],
payments: [{ methodCode: 'cash', amountCents: 1000, tenderedCents: 1000 }],
});
const items = await pool.query<{ id: string }>(
`SELECT id FROM orders_items WHERE order_id = $1`,
[sale.orderId],
);
const itemId = items.rows[0]!.id;
const response = await app.inject({
method: 'POST',
url: `/pos/sales/${sale.orderId}/returns`,
headers: { cookie: cookie(SESSION_TOKEN), 'x-terminal-id': TERMINAL_ID },
payload: {
idempotencyKey: 'f189-rest-over',
cashSessionId: SESSION_ID,
terminalId: TERMINAL_ID,
reason: 'Exceso',
items: [{ orderItemId: itemId, returnedQuantity: 5 }],
},
});
expect(response.statusCode).toBe(409);
expect(response.json().error.code).toBe('POS_RETURN_EXCEEDS_AVAILABLE');
});
it('replays the same idempotency key without duplicating rows', async () => {
const useCase = new CreatePosSaleUseCase(pool);
const sale = await useCase.execute({
idempotencyKey: 'f189-sale-replay',
cashSessionId: SESSION_ID,
terminalId: TERMINAL_ID,
userId: USER_ID,
items: [{ kind: 'stock', variantId: VARIANT_ID, quantity: 2, discountCents: 0 }],
payments: [{ methodCode: 'cash', amountCents: 2000, tenderedCents: 2000 }],
});
const items = await pool.query<{ id: string }>(
`SELECT id FROM orders_items WHERE order_id = $1`,
[sale.orderId],
);
const itemId = items.rows[0]!.id;
const first = await app.inject({
method: 'POST',
url: `/pos/sales/${sale.orderId}/returns`,
headers: { cookie: cookie(SESSION_TOKEN), 'x-terminal-id': TERMINAL_ID },
payload: {
idempotencyKey: 'f189-rest-replay',
cashSessionId: SESSION_ID,
terminalId: TERMINAL_ID,
reason: 'Idem',
items: [{ orderItemId: itemId, returnedQuantity: 1 }],
},
});
expect(first.statusCode).toBe(201);
expect(first.json().returnId).toBeTruthy();
const second = await app.inject({
method: 'POST',
url: `/pos/sales/${sale.orderId}/returns`,
headers: { cookie: cookie(SESSION_TOKEN), 'x-terminal-id': TERMINAL_ID },
payload: {
idempotencyKey: 'f189-rest-replay',
cashSessionId: SESSION_ID,
terminalId: TERMINAL_ID,
reason: 'Idem',
items: [{ orderItemId: itemId, returnedQuantity: 1 }],
},
});
expect(second.statusCode).toBe(201);
expect(second.json().returnId).toBeTruthy();
expect(second.json().returnId).toBe(first.json().returnId);
console.log('FIRST', first.json());
console.log('SECOND', second.json());
const returns = await pool.query<{ count: number }>(
`SELECT COUNT(*)::int AS count FROM pos_returns WHERE order_id = $1`,
[sale.orderId],
);
expect(returns.rows[0]?.count).toBe(1);
const stock = await pool.query<{ available: number }>(
`SELECT available FROM inventory_stock WHERE variant_id = $1 AND store_id = $2`,
[VARIANT_ID, STORE_ID],
);
expect(Number(stock.rows[0]?.available)).toBe(6);
});
it('returns 404 on the legacy /refund route', async () => {
const response = await app.inject({
method: 'POST',
url: '/pos/sales/00000000-0000-4000-8000-000000000000/refund',
headers: { cookie: cookie(SESSION_TOKEN), 'x-terminal-id': TERMINAL_ID },
payload: { refundAmountCents: 100, reason: 'legacy' },
});
expect(response.statusCode).toBe(404);
});
});