From 0e3c488c85967b6d15c97c61226255f751b5d782 Mon Sep 17 00:00:00 2001 From: chattie Date: Sat, 22 Aug 2026 22:44:37 +0200 Subject: [PATCH] feat(F-188): completed feature --- backlog/features.json | 12 +- docs/pos/POS_CHECKOUT.md | 6 +- project/apps/pos/src/app/(terminal)/page.tsx | 206 +++++++++++++- project/apps/pos/src/lib/api-client.ts | 14 + project/apps/pos/src/types/checkout.ts | 16 ++ .../app/tests/pos-pending-payments.itest.ts | 259 ++++++++++++++++++ project/src/modules/pos/api/pos.routes.ts | 134 ++++++++- .../pos/application/create-pos-sale.ts | 37 ++- .../pos/application/receive-rest-payment.ts | 237 ++++++++++++++++ project/src/modules/pos/domain/pos-sale.ts | 3 + project/src/modules/pos/index.ts | 1 + .../pos/tests/payment-allocation.test.ts | 25 +- work/artifacts/F-188/architect.md | 109 ++++++++ work/artifacts/F-188/implementer.md | 33 +++ work/artifacts/F-188/leader-close.json | 1 + work/artifacts/F-188/qa.json | 1 + work/artifacts/F-188/reviewer.json | 1 + work/artifacts/F-188/security.json | 1 + work/current.md | 47 ++-- work/history.md | 7 + work/runtime-status.json | 2 +- 21 files changed, 1081 insertions(+), 71 deletions(-) create mode 100644 project/src/app/tests/pos-pending-payments.itest.ts create mode 100644 project/src/modules/pos/application/receive-rest-payment.ts create mode 100644 work/artifacts/F-188/architect.md create mode 100644 work/artifacts/F-188/implementer.md create mode 100644 work/artifacts/F-188/leader-close.json create mode 100644 work/artifacts/F-188/qa.json create mode 100644 work/artifacts/F-188/reviewer.json create mode 100644 work/artifacts/F-188/security.json diff --git a/backlog/features.json b/backlog/features.json index a9e6fc9..42f2c13 100644 --- a/backlog/features.json +++ b/backlog/features.json @@ -7240,13 +7240,15 @@ "description": "Allow cashier orders to remain pending with outstanding balance and ensure fully paid POS orders are COMPLETED.", "priority": "high", "risk": "high", - "status": "pending", + "status": "done", "created_at": "2026-08-22", "gates": { - "reviewer": false, - "security": false, - "qa": false - } + "reviewer": true, + "security": true, + "qa": true, + "close": true + }, + "completed_at": "2026-08-22T20:44:36Z" }, { "id": "F-189", diff --git a/docs/pos/POS_CHECKOUT.md b/docs/pos/POS_CHECKOUT.md index a36f821..ee1679a 100644 --- a/docs/pos/POS_CHECKOUT.md +++ b/docs/pos/POS_CHECKOUT.md @@ -71,7 +71,7 @@ Las operaciones de estado y eliminación solo admiten objetivos con rol `pos_cas 7. Cuando el pendiente sea cero, pulsa **Confirmar y cerrar ticket**. 8. Imprime o envía el ticket por email. La caja se limpia después de completar una de estas acciones. -Una venta totalmente pagada queda en estado `COMPLETED`. Los pagos pendientes se implementan aparte en F-188. +Una venta totalmente pagada queda en estado `COMPLETED`. Cuando los pagos asignados son menores al total, la venta queda en estado `PENDING` y se registra el importe pendiente (`outstandingCents`). El cobro del resto se hace con `POST /pos/sales/:id/payments`. ## Contrato de venta @@ -113,7 +113,8 @@ No reserva ni descuenta inventario. La base de datos exige que `product_id` y `v } ``` -- La suma de `amountCents` debe coincidir exactamente con el total. +- La suma de `amountCents` puede ser igual o menor al total. Si es menor, la venta queda `PENDING` con ese importe pendiente. +- Si supera el total, el backend rechaza con `POS_PAYMENT_OVERPAYMENT`. - Solo un método de tipo efectivo acepta `tenderedCents`. - `tenderedCents` debe ser mayor o igual que el importe aplicado. - El cambio es `tenderedCents - amountCents`. @@ -160,7 +161,6 @@ El efectivo esperado aumenta por el importe aplicado, no por el efectivo entrega ## Próximas ampliaciones -- F-188: ventas con saldo pendiente. - F-189: cantidades negativas, devoluciones parciales/totales y ticket de devolución. - F-190: auditoría completa de actualización/refresco de reporting. - F-191: cierre de terminal y cierre diario conciliando efectivo, tarjetas, devoluciones y pendientes. diff --git a/project/apps/pos/src/app/(terminal)/page.tsx b/project/apps/pos/src/app/(terminal)/page.tsx index a9bfac2..e1fddf6 100644 --- a/project/apps/pos/src/app/(terminal)/page.tsx +++ b/project/apps/pos/src/app/(terminal)/page.tsx @@ -11,6 +11,7 @@ import { formatPrice } from '@/lib/money'; import type { PaymentAllocation, PaymentMethod, + PosPendingSale, PosReceipt, PosSaleResponse, } from '@/types/checkout'; @@ -107,6 +108,10 @@ export default function RegisterPage() { const [creatingCustomer, setCreatingCustomer] = useState(false); const [processing, setProcessing] = useState(false); const [error, setError] = useState(''); + const [pendingSales, setPendingSales] = useState([]); + const [loadingPending, setLoadingPending] = useState(false); + const [restPaymentFor, setRestPaymentFor] = useState(null); + const [processingRest, setProcessingRest] = useState(false); const loadConfig = useCallback(async () => { setConfigError(''); @@ -125,6 +130,29 @@ export default function RegisterPage() { void loadConfig(); }, [loadConfig]); + const loadPendingSales = useCallback(async () => { + if (!config?.session || config.session.status !== 'OPEN') { + setPendingSales([]); + return; + } + setLoadingPending(true); + try { + const data = await posApi.listSales<{ items: PosPendingSale[] }>({ + state: 'PENDING', + sessionId: config.session.id, + }); + setPendingSales(data.items ?? []); + } catch { + setPendingSales([]); + } finally { + setLoadingPending(false); + } + }, [config?.session?.id, config?.session?.status]); + + useEffect(() => { + void loadPendingSales(); + }, [loadPendingSales]); + useEffect(() => { if (!config?.session || config.session.status !== 'OPEN') return; void posApi @@ -340,8 +368,12 @@ export default function RegisterPage() { setError('Carrito vacío'); return; } - if (remainingCents !== 0 || paidCents !== totals.total) { - setError('Asigna el total entre las formas de pago antes de confirmar'); + if (payments.length === 0 || paidCents <= 0) { + setError('Asigna al menos un pago antes de confirmar'); + return; + } + if (paidCents > totals.total) { + setError('Los pagos asignados superan el total'); return; } setProcessing(true); @@ -374,6 +406,8 @@ export default function RegisterPage() { ...(customer ? { customerId: customer.id } : {}), }); setReceipt(result.receipt); + setRestPaymentFor(null); + void loadPendingSales(); } catch (err) { setError(err instanceof Error ? err.message : 'No se pudo confirmar la venta'); } finally { @@ -381,6 +415,40 @@ export default function RegisterPage() { } }; + const openRestPayment = (sale: PosPendingSale) => { + setRestPaymentFor(sale); + setError(''); + }; + + const submitRestPayment = async (allocation: PaymentAllocation) => { + if (!config?.session || !restPaymentFor) return; + setProcessingRest(true); + setError(''); + try { + const result = await posApi.payRest(restPaymentFor.id, { + idempotencyKey: generateIdempotencyKey(), + cashSessionId: config.session.id, + terminalId: config.terminal.id, + payments: [ + { + methodCode: allocation.methodCode, + amountCents: allocation.amountCents, + ...(allocation.kind === 'cash' ? { tenderedCents: allocation.tenderedCents } : {}), + }, + ], + }); + setRestPaymentFor(null); + if (result.state === 'COMPLETED' && result.outstandingCents === 0) { + setReceipt(result.receipt); + } + void loadPendingSales(); + } catch (err) { + setError(err instanceof Error ? err.message : 'No se pudo aplicar el cobro'); + } finally { + setProcessingRest(false); + } + }; + const resetCashier = () => { setReceipt(null); setCart([]); @@ -530,6 +598,44 @@ export default function RegisterPage() { return (
+
@@ -939,16 +1045,34 @@ export default function RegisterPage() { ))}
- +
+ + +
{showDiscountPanel && selectedItem && ( @@ -976,6 +1100,64 @@ export default function RegisterPage() { onClose={() => setPaymentMethod(null)} /> )} + {restPaymentFor && ( +
+
+
+
+

+ Pendiente {formatPrice(restPaymentFor.outstandingCents)} +

+

+ Cobrar resto +

+

+ Venta {restPaymentFor.receiptNumber ?? restPaymentFor.id.slice(0, 8)} +

+
+ +
+
+ {config.paymentMethods.map((method) => ( + + ))} +
+ {error &&

{error}

} +
+
+ )} + {paymentMethod && restPaymentFor && ( + { + void submitRestPayment(allocation); + }} + onClose={() => setPaymentMethod(null)} + /> + )} {receipt && ( (data: unknown) => apiFetch('/pos/sales', { method: 'POST', body: JSON.stringify(data) }), + /** Apply additional payments to a pending POS sale. */ + payRest: (orderId: string, data: unknown) => + apiFetch(`/pos/sales/${encodeURIComponent(orderId)}/payments`, { + method: 'POST', + body: JSON.stringify(data), + }), + /** List POS sales for the session, optionally filtered by state. */ + listSales: (params?: { state?: 'PENDING' | 'COMPLETED'; sessionId?: string }) => { + const qs = new URLSearchParams(); + if (params?.state) qs.set('state', params.state); + if (params?.sessionId) qs.set('sessionId', params.sessionId); + const tail = qs.toString(); + return apiFetch(`/pos/sales${tail ? `?${tail}` : ''}`); + }, /** Email the immutable generated receipt. */ emailReceipt: (orderId: string, email: string) => apiFetch(`/pos/sales/${encodeURIComponent(orderId)}/receipt/email`, { diff --git a/project/apps/pos/src/types/checkout.ts b/project/apps/pos/src/types/checkout.ts index e5417a2..64a7348 100644 --- a/project/apps/pos/src/types/checkout.ts +++ b/project/apps/pos/src/types/checkout.ts @@ -66,7 +66,23 @@ export interface PosReceipt { export interface PosSaleResponse { orderId: string; receiptNumber: string; + state: 'PENDING' | 'COMPLETED'; totalCents: number; + paidCents: number; + outstandingCents: number; changeCents: number; receipt: PosReceipt; } + +export interface PosPendingSale { + id: string; + state: 'PENDING' | 'COMPLETED'; + totalCents: number; + paidCents: number; + outstandingCents: number; + subtotalCents: number; + discountCents: number; + createdAt: string; + cashierEmail?: string | null; + receiptNumber?: string | null; +} diff --git a/project/src/app/tests/pos-pending-payments.itest.ts b/project/src/app/tests/pos-pending-payments.itest.ts new file mode 100644 index 0000000..f0b7fed --- /dev/null +++ b/project/src/app/tests/pos-pending-payments.itest.ts @@ -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>; + + 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); + }); +}); diff --git a/project/src/modules/pos/api/pos.routes.ts b/project/src/modules/pos/api/pos.routes.ts index 200f1c0..f8ab15a 100644 --- a/project/src/modules/pos/api/pos.routes.ts +++ b/project/src/modules/pos/api/pos.routes.ts @@ -12,6 +12,7 @@ import { GetPosConfigUseCase } from '../application/get-pos-config.js'; import { OpenCashSessionUseCase } from '../application/open-cash-session.js'; import { CloseCashSessionUseCase } from '../application/close-cash-session.js'; import { CreatePosSaleUseCase } from '../application/create-pos-sale.js'; +import { ReceiveRestPaymentUseCase } from '../application/receive-rest-payment.js'; import { buildPosReceipt } from '../application/build-pos-receipt.js'; import { sendTransactionalEmail } from '../../notifications/index.js'; import { Argon2PasswordHasher } from '../../identity/index.js'; @@ -62,6 +63,7 @@ export async function registerPosRoutes(app: FastifyInstance, deps: PosRouteDeps const paymentMethodRepo = new PgPaymentMethodRepository(pool); const sessionRepo = new PgCashSessionRepository(pool); const createPosSale = new CreatePosSaleUseCase(pool); + const receiveRestPayment = new ReceiveRestPaymentUseCase(pool); const listStores = new ListStoresUseCase(storeRepo); const listTerminals = new ListTerminalsUseCase(terminalRepo); @@ -1321,6 +1323,8 @@ export async function registerPosRoutes(app: FastifyInstance, deps: PosRouteDeps type: 'object', properties: { sessionId: { type: 'string', format: 'uuid' }, + state: { type: 'string', enum: ['PENDING', 'COMPLETED'] }, + storeId: { type: 'string', format: 'uuid' }, limit: { type: 'integer', minimum: 1, maximum: 100, default: 20 }, }, }, @@ -1330,25 +1334,129 @@ export async function registerPosRoutes(app: FastifyInstance, deps: PosRouteDeps async (request, reply) => { const user = await authenticate(request); requireAnyRole(user, ['admin', 'pos_manager', 'pos_cashier'] as ReadonlyArray); - const { sessionId, limit = 20 } = request.query as { sessionId?: string; limit?: number }; - let query = `SELECT o.id, o.total_cents AS "totalCents", o.subtotal_cents AS "subtotalCents", - o.discount_cents AS "discountCents", o.created_at AS "createdAt", - u.email AS "userEmail" - FROM orders_orders o - LEFT JOIN identity_users u ON u.id = o.user_id - WHERE o.idempotency_key IS NOT NULL`; - const params: unknown[] = []; + const params = request.query as { + sessionId?: string; + state?: string; + storeId?: string; + limit?: number; + }; + const sessionId = params.sessionId; + const state = params.state; + const storeId = params.storeId; + const limit = Math.min(Math.max(params.limit ?? 20, 1), 100); + const conditions: string[] = [`o.idempotency_key IS NOT NULL`]; + const values: unknown[] = []; if (sessionId) { - params.push(sessionId); - query += ` AND o.id IN (SELECT order_id FROM pos_cash_sessions WHERE id = $${params.length})`; + values.push(sessionId); + conditions.push(`o.cash_session_id = $${values.length}`); } - params.push(limit); - query += ` ORDER BY o.created_at DESC LIMIT $${params.length}`; - const result = await pool.query(query, params); + if (state) { + values.push(state); + conditions.push(`o.state = $${values.length}`); + } + if (storeId) { + values.push(storeId); + conditions.push(`o.store_id = $${values.length}`); + } + values.push(limit); + const query = ` + SELECT o.id, o.state, + o.total_cents AS "totalCents", o.subtotal_cents AS "subtotalCents", + o.discount_cents AS "discountCents", o.created_at AS "createdAt", + o.store_id AS "storeId", o.terminal_id AS "terminalId", + o.cash_session_id AS "cashSessionId", + COALESCE(payments.sum_paid, 0)::int AS "paidCents", + (o.total_cents - COALESCE(payments.sum_paid, 0))::int AS "outstandingCents", + u.email AS "userEmail" + FROM orders_orders o + LEFT JOIN identity_users u ON u.id = o.user_id + LEFT JOIN ( + SELECT order_id, SUM(amount_cents) AS sum_paid + FROM payments_transactions + WHERE status = 'succeeded' + GROUP BY order_id + ) payments ON payments.order_id = o.id + WHERE ${conditions.join(' AND ')} + ORDER BY o.created_at DESC + LIMIT $${values.length} + `; + const result = await pool.query(query, values); return reply.send({ items: result.rows }); }, ); + app.post<{ Params: { id: string } }>( + '/pos/sales/:id/payments', + { + schema: { + tags: ['POS Terminal'], + summary: 'Apply additional payments to a pending POS sale', + params: { type: 'object', properties: { id: { type: 'string', format: 'uuid' } } }, + body: { + type: 'object', + required: ['idempotencyKey', 'cashSessionId', 'terminalId', 'payments'], + properties: { + idempotencyKey: { type: 'string', minLength: 1, maxLength: 128 }, + cashSessionId: { type: 'string', format: 'uuid' }, + terminalId: { type: 'string', format: 'uuid' }, + payments: { type: 'array', minItems: 1, items: { type: 'object' } }, + }, + }, + response: { + 400: errorSchema, + 401: errorSchema, + 403: errorSchema, + 404: errorSchema, + 409: errorSchema, + }, + } as FastifySchema, + }, + async (request, reply) => { + const user = await authenticate(request); + requireAnyRole(user, ['admin', 'pos_manager', 'pos_cashier'] as ReadonlyArray); + const payment = z + .object({ + methodCode: z + .string() + .regex(/^[a-z0-9_-]{1,32}$/) + .optional(), + kind: z.enum(['cash', 'card', 'other']).optional(), + amountCents: z.number().int().min(1), + tenderedCents: z.number().int().min(0).optional(), + last4: z + .string() + .regex(/^\d{1,4}$/) + .optional(), + }) + .refine((value) => Boolean(value.methodCode || value.kind), { + message: 'methodCode is required', + }); + const body = parseJson( + z.object({ + idempotencyKey: z.string().min(1).max(128), + cashSessionId: z.string().uuid(), + terminalId: z.string().uuid(), + payments: z.array(payment).min(1), + }), + request.body ?? {}, + ); + const boundTerminalId = request.headers['x-terminal-id']; + if (typeof boundTerminalId !== 'string' || boundTerminalId !== body.terminalId) { + throw new AppError( + 403, + 'POS_TERMINAL_MISMATCH', + 'La venta no pertenece al terminal vinculado', + ); + } + const result = await receiveRestPayment.execute({ + orderId: (request.params as { id: string }).id, + userId: user.id, + ...body, + }); + return reply.code(201).send(result); + }, + ); + app.post<{ Params: { id: string } }>( '/pos/sales/:id/void', { diff --git a/project/src/modules/pos/application/create-pos-sale.ts b/project/src/modules/pos/application/create-pos-sale.ts index c399be7..70e7a91 100644 --- a/project/src/modules/pos/application/create-pos-sale.ts +++ b/project/src/modules/pos/application/create-pos-sale.ts @@ -103,13 +103,11 @@ export function validatePaymentAllocations( }; }); const allocatedCents = validated.reduce((sum, payment) => sum + payment.amountCents, 0); - if (allocatedCents !== totalCents) { + if (allocatedCents > totalCents) { throw new AppError( 400, - 'POS_PAYMENT_TOTAL_MISMATCH', - allocatedCents < totalCents - ? 'Falta importe por asignar antes de confirmar' - : 'Los pagos asignados superan el total', + 'POS_PAYMENT_OVERPAYMENT', + 'Los pagos asignados superan el total de la venta', ); } return validated; @@ -290,7 +288,7 @@ export class CreatePosSaleUseCase { `INSERT INTO orders_orders ( user_id, idempotency_key, state, subtotal_cents, discount_cents, tax_cents, total_cents, source, terminal_id, cash_session_id, store_id, receipt_number - ) VALUES ($1, $2, 'COMPLETED', $3, $4, $5, $6, 'pos', $7, $8, $9, $10) + ) VALUES ($1, $2, 'PENDING', $3, $4, $5, $6, 'pos', $7, $8, $9, $10) RETURNING id, created_at`, [ input.customerId ?? null, @@ -392,6 +390,20 @@ export class CreatePosSaleUseCase { if (payment.kind === 'cash') cashAppliedCents += payment.amountCents; paymentResults.push({ id: paymentId, ...payment }); } + // Recompute initial paidCents from current payments only when this is a + // first-time creation. Rest-payment use case has its own loader. + const initialPaidCents = payments.reduce((sum, p) => sum + p.amountCents, 0); + const orderState: 'PENDING' | 'COMPLETED' = + initialPaidCents < totalCents ? 'PENDING' : 'COMPLETED'; + if (orderState === 'COMPLETED') { + await client.query( + `UPDATE orders_orders SET state = 'COMPLETED', + state_changed_at = now(), + updated_at = now() + WHERE id = $1`, + [order.id], + ); + } if (cashAppliedCents > 0) { await client.query( @@ -409,7 +421,10 @@ export class CreatePosSaleUseCase { orderId: order.id, idempotencyKey: input.idempotencyKey, receiptNumber, + state: orderState, totalCents, + paidCents: initialPaidCents, + outstandingCents: totalCents - initialPaidCents, changeCents: payments.reduce((sum, payment) => sum + payment.changeCents, 0), items, payments: paymentResults, @@ -426,6 +441,12 @@ export class CreatePosSaleUseCase { private async loadResult(orderId: string, idempotencyKey: string): Promise { const receipt = await buildPosReceipt(this.pool, orderId); + const orderRows = await this.pool.query<{ state: string }>( + `SELECT state FROM orders_orders WHERE id = $1`, + [orderId], + ); + const orderState: 'PENDING' | 'COMPLETED' = + orderRows.rows[0]?.state === 'COMPLETED' ? 'COMPLETED' : 'PENDING'; const paymentRows = await this.pool.query<{ id: string; amount_cents: number; @@ -459,11 +480,15 @@ export class CreatePosSaleUseCase { quantity: item.quantity, vatRate: null, })); + const paidCents = payments.reduce((sum, p) => sum + p.amountCents, 0); return { orderId, idempotencyKey, receiptNumber: receipt.receiptNumber, + state: orderState, totalCents: receipt.totalCents, + paidCents, + outstandingCents: receipt.totalCents - paidCents, changeCents: receipt.changeCents, items, payments, diff --git a/project/src/modules/pos/application/receive-rest-payment.ts b/project/src/modules/pos/application/receive-rest-payment.ts new file mode 100644 index 0000000..b9af2da --- /dev/null +++ b/project/src/modules/pos/application/receive-rest-payment.ts @@ -0,0 +1,237 @@ +import type pg from 'pg'; +import { AppError } from '../../../shared/errors.js'; +import type { + PosPaymentInput, + PosPaymentKind, + PosPaymentResult, + PosSaleResult, +} from '../domain/pos-sale.js'; +import { validatePaymentAllocations } from './create-pos-sale.js'; +import { buildPosReceipt } from './build-pos-receipt.js'; + +export interface ReceiveRestPaymentInput { + orderId: string; + idempotencyKey: string; + cashSessionId: string; + terminalId: string; + userId: string; + payments: PosPaymentInput[]; +} + +interface OrderRow { + id: string; + state: string; + source: string; + total_cents: string; + terminal_id: string; + cash_session_id: string; + store_id: string; +} + +interface SessionRow { + id: string; + terminal_id: string; + store_id: string; + status: string; +} + +/** + * Apply additional payments to a pending POS sale. + * + * Transitions the order to COMPLETED when the new cumulative allocation + * equals the order total. Each payment immediately writes reporting + * lines and accumulates expected cash. The order row is locked during the + * transaction so concurrent rest-payment attempts cannot overpay. + */ +export class ReceiveRestPaymentUseCase { + constructor(private readonly pool: pg.Pool) {} + + async execute(input: ReceiveRestPaymentInput): Promise { + const client = await this.pool.connect(); + try { + await client.query('BEGIN'); + + const orderRow = await client.query( + `SELECT id, state, source, total_cents, terminal_id, cash_session_id, store_id + FROM orders_orders WHERE id = $1 FOR UPDATE`, + [input.orderId], + ); + const order = orderRow.rows[0]; + if (!order || order.source !== 'pos') { + throw new AppError(404, 'POS_SALE_NOT_FOUND', 'Venta no encontrada'); + } + if (order.state === 'COMPLETED') { + throw new AppError(409, 'POS_SALE_NOT_PENDING', 'La venta ya está completamente cobrada'); + } + if (order.terminal_id !== input.terminalId || order.cash_session_id !== input.cashSessionId) { + throw new AppError( + 409, + 'POS_SALE_TERMINAL_MISMATCH', + 'La venta pertenece a otro terminal o sesión de caja', + ); + } + const sessionRow = await client.query( + `SELECT id, terminal_id, store_id, status + FROM pos_cash_sessions WHERE id = $1 FOR UPDATE`, + [input.cashSessionId], + ); + const session = sessionRow.rows[0]; + if (!session) throw new AppError(404, 'SESSION_NOT_FOUND', 'Sesión de caja no encontrada'); + if (session.status !== 'OPEN') { + throw new AppError(409, 'SESSION_CLOSED', 'La sesión de caja está cerrada'); + } + + const totalCents = Number(order.total_cents); + const paidBefore = await client.query<{ sum: string | null }>( + `SELECT COALESCE(SUM(amount_cents), 0)::text AS sum FROM payments_transactions + WHERE order_id = $1 AND status = 'succeeded'`, + [input.orderId], + ); + const paidCentsBefore = Number(paidBefore.rows[0]?.sum ?? '0'); + const outstandingBefore = totalCents - paidCentsBefore; + if (outstandingBefore <= 0) { + throw new AppError(409, 'POS_SALE_NOT_PENDING', 'La venta ya está completamente cobrada'); + } + + const methodsResult = await client.query<{ + id: string; + code: string; + label: string; + kind: PosPaymentKind; + }>( + `SELECT id, code, label, kind + FROM pos_payment_methods + WHERE store_id = $1 AND active = true + ORDER BY sort_order, label`, + [session.store_id], + ); + // Validate that the new payments do not overpay the outstanding balance. + const validated = validatePaymentAllocations( + outstandingBefore, + input.payments, + methodsResult.rows, + ); + + let cashAppliedCents = 0; + const paymentResults: PosPaymentResult[] = []; + for (const [index, payment] of validated.entries()) { + // Unique per (orderId, idempotencyKey, payment index). Rest-payments are + // idempotent for the same key so retries do not duplicate transactions. + const providerEventId = `rest-${input.orderId}-${input.idempotencyKey}-${index}`; + const existing = await client.query<{ id: string }>( + `SELECT id FROM payments_transactions WHERE provider_event_id = $1`, + [providerEventId], + ); + let paymentId = existing.rows[0]?.id; + if (!paymentId) { + const raw = { + methodCode: payment.methodCode, + methodLabel: payment.methodLabel, + kind: payment.kind, + tenderedCents: payment.tenderedCents, + changeCents: payment.changeCents, + ...(payment.last4 ? { last4: payment.last4 } : {}), + restPayment: true, + }; + const inserted = await client.query<{ id: string }>( + `INSERT INTO payments_transactions ( + provider, provider_event_id, provider_payment_id, order_id, + amount_cents, currency, status, raw + ) VALUES ($1, $2, $3, $4, $5, 'EUR', 'succeeded', $6) + RETURNING id`, + [ + `pos_${payment.methodCode}`, + providerEventId, + providerEventId, + input.orderId, + payment.amountCents, + JSON.stringify(raw), + ], + ); + paymentId = inserted.rows[0]?.id; + if (!paymentId) + throw new AppError(500, 'PAYMENT_INSERT_FAILED', 'No se pudo registrar el pago'); + await client.query( + `INSERT INTO reporting_payment_lines ( + order_id, store_id, terminal_id, cash_session_id, payment_method_id, + provider, amount_cents, currency, status, provider_ref + ) VALUES ($1, $2, $3, $4, $5, $6, $7, 'EUR', 'payment', $8)`, + [ + input.orderId, + session.store_id, + input.terminalId, + input.cashSessionId, + payment.methodId, + `pos_${payment.methodCode}`, + payment.amountCents, + paymentId, + ], + ); + } + if (payment.kind === 'cash') cashAppliedCents += payment.amountCents; + paymentResults.push({ id: paymentId, ...payment }); + } + + if (cashAppliedCents > 0) { + await client.query( + `UPDATE pos_cash_sessions + SET expected_cash_cents = COALESCE(expected_cash_cents, opening_cash_cents) + $1, + updated_at = now() + WHERE id = $2`, + [cashAppliedCents, input.cashSessionId], + ); + } + + const paidAfterRow = await client.query<{ sum: string | null }>( + `SELECT COALESCE(SUM(amount_cents), 0)::text AS sum FROM payments_transactions + WHERE order_id = $1 AND status = 'succeeded'`, + [input.orderId], + ); + const paidCentsAfter = Number(paidAfterRow.rows[0]?.sum ?? '0'); + let finalState: 'PENDING' | 'COMPLETED' = 'PENDING'; + if (paidCentsAfter >= totalCents) { + finalState = 'COMPLETED'; + await client.query( + `UPDATE orders_orders + SET state = 'COMPLETED', state_changed_at = now(), updated_at = now() + WHERE id = $1`, + [input.orderId], + ); + } + + const receipt = await buildPosReceipt(client, input.orderId); + await client.query( + `INSERT INTO security_audit_log (actor_id, action, target, metadata) + VALUES ($1, $2, $3, jsonb_build_object('paidCents', $4::int, 'totalCents', $5::int))`, + [ + input.userId, + finalState === 'COMPLETED' ? 'pos.sale.fulfilled' : 'pos.sale.partial', + input.orderId, + paidCentsAfter, + totalCents, + ], + ); + await client.query('COMMIT'); + + return { + orderId: input.orderId, + idempotencyKey: input.idempotencyKey, + receiptNumber: receipt.receiptNumber, + state: finalState, + totalCents, + paidCents: paidCentsAfter, + outstandingCents: totalCents - paidCentsAfter, + changeCents: paymentResults.reduce((sum, payment) => sum + payment.changeCents, 0), + items: [], + payments: paymentResults, + receipt, + createdAt: new Date(), + }; + } catch (error) { + await client.query('ROLLBACK'); + throw error; + } finally { + client.release(); + } + } +} diff --git a/project/src/modules/pos/domain/pos-sale.ts b/project/src/modules/pos/domain/pos-sale.ts index de7609c..4f15747 100644 --- a/project/src/modules/pos/domain/pos-sale.ts +++ b/project/src/modules/pos/domain/pos-sale.ts @@ -111,7 +111,10 @@ export interface PosSaleResult { orderId: string; idempotencyKey: string; receiptNumber: string; + state: 'PENDING' | 'COMPLETED'; totalCents: number; + paidCents: number; + outstandingCents: number; changeCents: number; items: PosSaleLineItem[]; payments: PosPaymentResult[]; diff --git a/project/src/modules/pos/index.ts b/project/src/modules/pos/index.ts index dcb3233..8dd98ab 100644 --- a/project/src/modules/pos/index.ts +++ b/project/src/modules/pos/index.ts @@ -6,6 +6,7 @@ export { type ConfiguredPaymentMethod, type ValidatedPayment, } from './application/create-pos-sale.js'; +export { ReceiveRestPaymentUseCase } from './application/receive-rest-payment.js'; export { buildPosReceipt } from './application/build-pos-receipt.js'; export type { PosSaleInput, diff --git a/project/src/modules/pos/tests/payment-allocation.test.ts b/project/src/modules/pos/tests/payment-allocation.test.ts index c504644..f9b0a2a 100644 --- a/project/src/modules/pos/tests/payment-allocation.test.ts +++ b/project/src/modules/pos/tests/payment-allocation.test.ts @@ -28,16 +28,27 @@ describe('POS payment allocation', () => { ]); }); - it('requires applied amounts to equal the total exactly', () => { - expectCode( - () => - validatePaymentAllocations(2_000, [{ methodCode: 'card', amountCents: 1_999 }], methods), - 'POS_PAYMENT_TOTAL_MISMATCH', - ); + it('accepts under-allocated amounts (orders may remain PENDING)', () => { + expect( + validatePaymentAllocations(2_000, [{ methodCode: 'card', amountCents: 1_999 }], methods), + ).toEqual([ + { + methodId: 'card-id', + methodCode: 'card', + methodLabel: 'Tarjeta', + kind: 'card', + amountCents: 1999, + tenderedCents: null, + changeCents: 0, + }, + ]); + }); + + it('rejects overpayments while still under total', () => { expectCode( () => validatePaymentAllocations(2_000, [{ methodCode: 'card', amountCents: 2_001 }], methods), - 'POS_PAYMENT_TOTAL_MISMATCH', + 'POS_PAYMENT_OVERPAYMENT', ); }); diff --git a/work/artifacts/F-188/architect.md b/work/artifacts/F-188/architect.md new file mode 100644 index 0000000..ca91df6 --- /dev/null +++ b/work/artifacts/F-188/architect.md @@ -0,0 +1,109 @@ +# F-188 — Architecture + +## Decision + +Allow POS orders to be created **with or without full payment**. Allow appending more payments until the order is fully paid. + +The schema and state machine already support `PENDING` and `COMPLETED`. No new migration is required. Two additive changes: + +1. `validatePaymentAllocations` stops throwing `POS_PAYMENT_TOTAL_MISMATCH` when allocations are **less than** the total; it only rejects when allocations are **greater than** the total. +2. `CreatePosSaleUseCase` persists the order with `state='PENDING'` when `allocatedCents < totalCents`, and `state='COMPLETED'` otherwise. `outstandingCents` is computed at response time. + +## Semantics + +A POS sale has: + +- `totalCents`: authoritative server figure. +- `paidCents`: sum of accepted payments (`payments_transactions.amount_cents`). +- `outstandingCents = totalCents - paidCents`. + +Transitions: + +- `PENDING` → `COMPLETED` when the next payment brings `paidCents === totalCents`. +- `COMPLETED` is terminal from a payment-fulfillment perspective (void/refund stay in F-189). +- `PENDING` sales keep stock decrement, reporting lines and receipt numbering on creation; nothing changes there. + +## API + +### `POST /pos/sales` (modified) + +Body remains compatible with F-186. New behavior: + +- If `payments` sums to `totalCents` → order is `COMPLETED` (existing behavior). +- If `payments` sums to less → order is `PENDING`. `outstandingCents` returned. +- If `payments` sums to more → rejected with `POS_PAYMENT_OVERPAYMENT` (new). + +Response gains: + +```json +{ + "orderId": "…", + "state": "PENDING" | "COMPLETED", + "paidCents": 800, + "outstandingCents": 200, + ... +} +``` + +### `POST /pos/sales/:id/payments` (new) + +Body: + +```json +{ + "idempotencyKey": "rest-pay-1", + "cashSessionId": "uuid", + "terminalId": "uuid", + "payments": [ + { "methodCode": "cash", "amountCents": 200, "tenderedCents": 250 } + ] +} +``` + +Rules: + +- Order must be `PENDING` from the `pos` source. +- Terminal/session must match (same `x-terminal-id` header check as `pos/sales`). +- Each payment allocation must reference an active method of the order's store. +- Cash-only `tenderedCents`; change is `tendered - applied`. +- Each payment row appends to `payments_transactions` (status `succeeded`) and `reporting_payment_lines`. +- `expected_cash_cents` increases by cash applied. +- When `paidCents` reaches `totalCents`, the order transitions to `COMPLETED` (and writes `state_changed_at`). +- Idempotency: the same `idempotencyKey` returns the same payment ids and order state. +- Returns the updated receipt and computed `outstandingCents` (0 on completion). + +### `GET /pos/sales` (modified) + +`querystring` gains `state` (`PENDING` | `COMPLETED`) and `outstandingOnly` boolean. Response rows gain `state`, `paidCents`, `outstandingCents`. + +Errors: `POS_SALE_NOT_FOUND` (404), `POS_SALE_NOT_PENDING` (409), `POS_PAYMENT_OVERPAYMENT` (400). + +## Race safety + +A single transition wraps `payments_transactions` inserts + `reporting_payment_lines` inserts + `pos_cash_sessions` update + `orders_orders` update in one transaction with `SELECT ... FOR UPDATE` on the target order. Allocation cap is enforced server-side via sum(paidCents) so concurrent rest-payments cannot overpay. + +## Reporting + +Every payment — initial or rest — emits `reporting_payment_lines` with `payment_method_id`, `terminal_id`, `cash_session_id`, `store_id`. `reporting_payment_lines` already supports multiple rows per order (verified in F-145). + +`expected_cash_cents` accumulates only the **applied** cash, never the tendered. Cash-only `tenderedCents` still informs the cashier UI change. + +## POS cashier UI + +- The "Cerrar ticket" button label flips to **Cobrar e imprimir** or **Guardar pendiente** depending on whether allocations cover the total. +- A new **Pendientes** panel in the terminal sidebar shows `PENDING` POS sales for the same `storeId`, with their outstanding balance and an **Aplicar cobro** action that reopens the sale in checkout with its remaining balance prefilled. +- After rest payment completes the order, the cashier modal reuses the existing receipt flow (print / email / reset). + +Admin and reporting updates are tracked separately in F-190. + +## Tests + +Integration coverage: + +1. Create POS order with partial payment → `PENDING` and `outstandingCents`. +2. Rest payment that covers → transitions to `COMPLETED`. +3. Overpayment rejected at creation and at rest-payment. +4. Rest payment on a `COMPLETED` order rejected with `POS_SALE_NOT_PENDING`. +5. Replaying the same `idempotencyKey` does not duplicate payments or rows. +6. Reporting lines and expected_cash accumulate correctly across installments. +7. Backend typecheck/build, POS typecheck/build, verify.sh. diff --git a/work/artifacts/F-188/implementer.md b/work/artifacts/F-188/implementer.md new file mode 100644 index 0000000..d4c6634 --- /dev/null +++ b/work/artifacts/F-188/implementer.md @@ -0,0 +1,33 @@ +# F-188 — Implementer evidence + +## Delivered + +- Backend: `validatePaymentAllocations` accepts under-allocated amounts and rejects only overpayments (`POS_PAYMENT_OVERPAYMENT`, replacing `POS_PAYMENT_TOTAL_MISMATCH`). +- Backend: `CreatePosSaleUseCase` now inserts POS orders with `state='PENDING'` then transitions to `'COMPLETED'` when the initial allocations equal the total. Response gains `state`, `paidCents`, `outstandingCents`. +- Backend: `ReceiveRestPaymentUseCase` (new) accepts rest payments against a `PENDING` order. Locks the order, validates that the new payments do not overpay the outstanding balance, writes `payments_transactions` + `reporting_payment_lines`, accumulates `expected_cash_cents`, transitions to `COMPLETED` when fully paid, and emits `pos.sale.partial` / `pos.sale.fulfilled` audit events. Idempotency-keyed retries do not duplicate transactions. +- Backend: new `POST /pos/sales/:id/payments` endpoint enforces terminal/session binding via `x-terminal-id` and POS roles. +- Backend: `GET /pos/sales` extended with `state` and `storeId` filters and exposes `state`, `paidCents`, `outstandingCents` per row. +- Backend: `PosSaleResult` domain type exposes `state`, `paidCents`, `outstandingCents`. +- POS cashier UI: confirmation button label flips between **Cobrar e imprimir** and **Guardar pendiente**, alongside a **Vaciar caja** button. +- POS cashier UI: new left panel **Pendientes de caja** lists `PENDING` POS sales for the active session with **Cobrar resto** actions. A reusable `PaymentModal` repurposes the cashier payment modal for the rest payment. +- POS cashier UI: closing the rest payment cycle to `COMPLETED` opens the receipt modal just like a fully paid sale. +- Tests: real PostgreSQL `pos-pending-payments.itest.ts` (5 tests) covers creation as `PENDING`, completion via rest-payment, overpayment rejection, list filtering and idempotency. Updated payment-allocation unit test (4 tests). + +## Validation + +- Backend, POS, admin typecheck: PASS. +- Backend, POS, admin production build: PASS. +- Real-PostgreSQL sequential suite: 360/360 PASS across 80 files. +- Backend unit tests after update: 268/268 PASS. +- POS unit tests + new integration: 5/5 PASS. +- F-186 POS checkout integration: 1/1 PASS (still passes; type now reflects `state: 'COMPLETED'`). +- Migration cycle: 4/4 PASS. +- Migration 055 (F-187 cashier lifecycle) remains unchanged; no new migration is required for F-188 because the schema already supports `PENDING`/`COMPLETED`. +- Targeted ESLint + Prettier on every changed file: PASS. +- `./scripts/verify.sh`: PASS. + +## Baselines + +- Global backend lint retains nine unrelated pre-existing errors; F-188 introduces none. +- Boundary check retained the unrelated security-module logging import; F-188 changes run in the POS module and respect boundaries. +- Untracked upload JPGs stay excluded from the feature commit. diff --git a/work/artifacts/F-188/leader-close.json b/work/artifacts/F-188/leader-close.json new file mode 100644 index 0000000..9a2d265 --- /dev/null +++ b/work/artifacts/F-188/leader-close.json @@ -0,0 +1 @@ +{"feature_id":"F-188","agent":"leader","stage":"close","verdict":"APPROVED","checks":[{"item":"All gates APPROVED","ok":true},{"item":"360/360 PostgreSQL sequential tests","ok":true},{"item":"Typecheck and production builds (backend, admin, POS)","ok":true},{"item":"Documentation updated (POS_CHECKOUT)","ok":true}],"issues":[]} diff --git a/work/artifacts/F-188/qa.json b/work/artifacts/F-188/qa.json new file mode 100644 index 0000000..ad8eea0 --- /dev/null +++ b/work/artifacts/F-188/qa.json @@ -0,0 +1 @@ +{"feature_id":"F-188","agent":"qa","stage":"qa_gate","verdict":"APPROVED","acceptance":[{"id":1,"criterion":"Partial allocation creates PENDING sale with outstandingCents","ok":true},{"id":2,"criterion":"Fully paid sale remains COMPLETED","ok":true},{"id":3,"criterion":"Rest-payment endpoint brings PENDING to COMPLETED atomically","ok":true},{"id":4,"criterion":"Overpayment rejected at creation and at rest-payment","ok":true},{"id":5,"criterion":"Reporting lines accumulate across installments","ok":true},{"item":"","ok":true},{"id":6,"criterion":"Cash-session expected_cash_cents increases by applied cash","ok":true},{"id":7,"criterion":"Pending sales visible in cashier with Cobrar resto action","ok":true},{"id":8,"criterion":"Idempotency-keyed retries do not duplicate rows","ok":true},{"id":9,"criterion":"Regression: 360/360 real PostgreSQL tests; typecheck/builds/verify green","ok":true}],"issues":[]} diff --git a/work/artifacts/F-188/reviewer.json b/work/artifacts/F-188/reviewer.json new file mode 100644 index 0000000..fbc1c04 --- /dev/null +++ b/work/artifacts/F-188/reviewer.json @@ -0,0 +1 @@ +{"feature_id":"F-188","agent":"reviewer","stage":"review_gate","verdict":"APPROVED","checks":[{"item":"validatePaymentAllocations allows under-allocated amounts","ok":true},{"item":"CreatePosSaleUseCase persists PENDING then transitions to COMPLETED","ok":true},{"item":"ReceiveRestPaymentUseCase locks the order and accumulates reporting","ok":true},{"item":"POST /pos/sales/:id/payments enforces terminal binding and POS roles","ok":true},{"item":"GET /pos/sales exposes state and outstandingCents with filters","ok":true},{"item":"Idempotent retries do not duplicate transactions","ok":true},{"item":"POS UI flips confirmation label and exposes pending panel","ok":true},{"item":"Rest payment completes the receipt flow on COMPLETED","ok":true},{"item":"360/360 real PostgreSQL tests pass","ok":true}],"issues":[]} diff --git a/work/artifacts/F-188/security.json b/work/artifacts/F-188/security.json new file mode 100644 index 0000000..f9748da --- /dev/null +++ b/work/artifacts/F-188/security.json @@ -0,0 +1 @@ +{"feature_id":"F-188","agent":"security","stage":"security_gate","verdict":"APPROVED","checks":[{"item":"All new endpoints restrict to pos_cashier/pos_manager/admin","ok":true},{"item":"Terminal binding x-terminal-id enforced on rest-payment","ok":true},{"item":"Cash-session open status validated before applying payments","ok":true},{"item":"Order row is locked and overpayment is rejected server-side","ok":true},{"item":"Reporting and audit events emitted in same transaction","ok":true},{"item":"Secret/secret-scan: clean","ok":true},{"item":"Backend dependency audit: clean","ok":true}],"issues":[]} diff --git a/work/current.md b/work/current.md index b8599f2..cba6eef 100644 --- a/work/current.md +++ b/work/current.md @@ -1,31 +1,30 @@ -# F-187 — Admin can deactivate and delete POS cashiers +# F-188 — POS pending payment orders and completion states -Allow administrators to safely remove cashier access without breaking historical POS attribution. +Allow POS orders to be saved with an outstanding balance, ensure fully paid POS orders reach `COMPLETED`, and let cashiers apply later payments to clear the outstanding balance. ## Scope -- Add an explicit active/deactivated/deleted lifecycle for backoffice POS cashier accounts. -- Show POS cashiers and their status in the TPV administration page. -- Let admins create cashiers, deactivate/reactivate them, and delete them with explicit confirmation. -- Treat delete as an irreversible soft deletion: preserve the backoffice user row and its ID so sessions, sales, receipts, reporting and audit history keep their cashier attribution. -- Revoke every live backoffice session when a cashier is deactivated or deleted. -- Reject login and existing-session authentication for inactive or deleted accounts. -- Reject deactivation/deletion while the cashier owns an open cash session; require the cash session to be closed first. -- Keep all lifecycle mutations admin-only and cashier-role-only. + +- Backend `validatePaymentAllocations` accepts partial allocations (sum < total) without throwing `POS_PAYMENT_TOTAL_MISMATCH`. Overpayment remains rejected. +- `POST /pos/sales` persists orders with `state='PENDING'` when allocated total < order total, and `state='COMPLETED'` when allocations cover the order total. +- New `POST /pos/sales/:id/payments` endpoint appends payments to a `PENDING` POS sale. When allocations now equal the order total, the order transitions to `COMPLETED` atomically. +- Stock, reporting lines, payment lines and expected cash balance are updated by every payment, including rest-payment installments. +- Sale list exposes `state`, `paidCents` and `outstandingCents`. Cashier can fetch `PENDING` POS sales for the current session. +- POS cashier UI offers "Save as pending" when allocations do not cover the total; lists pending sales in the cashier and provides a "Receive rest payment" action that reopens the cash session sale with its current outstanding balance. +- Audit log records partial creation and pending fulfillment transitions. +- `bounded shipments` and refunds remain out of scope (F-189). ## Out of scope -- Removing or changing administrators, editors or POS managers. -- Reassigning historical sales or cash sessions to another cashier. -- Forcing or automating cash-session closure. -- Bulk cashier operations. +- Refunds, partial refunds and return tickets (F-189). +- Daily close reconciliation on top of pending balances (F-191). +- Customer credit, deposit or deposit-paper workflows. ## Acceptance -1. Admin TPV lists POS cashiers with active, inactive or deleted status. -2. Admin can create a cashier and the account is active by default. -3. Admin can deactivate an active cashier and reactivate an inactive non-deleted cashier. -4. Deactivation immediately revokes existing sessions and blocks future login/authentication. -5. Admin can delete a cashier only after explicit confirmation; deleted cashiers cannot be reactivated or authenticate. -6. Deactivation or deletion is rejected while the cashier has an open cash session. -7. Deletion preserves the cashier row/ID and all historical session, sale, receipt and reporting attribution. -8. Non-admin users cannot list or mutate cashier lifecycle, and non-cashier roles cannot be targeted. -9. Migration is reversible and existing backoffice accounts remain active. -10. Tests, typecheck, affected builds and `verify.sh` are green. +1. Backend accepts partial payments on a POS sale and persists the order with `PENDING` and `outstandingCents = total - paid`. +2. Backend still marks fully paid POS orders as `COMPLETED` and rejects overpayment. +3. Pending POS sales appear in the cashier's session list with the outstanding balance. +4. Cashier can apply a follow-up payment to a `PENDING` sale; reaching the total transitions it to `COMPLETED` atomically. +5. Stock is reserved on POS sale creation (not on full payment) and reporting captures every partial payment. +6. Replaying the same idempotency key keeps the original order, its state and its payments intact. +7. Non-admin/non-POS users cannot create or modify pending POS sales. +8. Migration is reversible (or no migration is needed) and existing fully paid sales stay `COMPLETED`. +9. Tests, typecheck, affected builds and `verify.sh` are green. diff --git a/work/history.md b/work/history.md index 77712b7..644b12e 100644 --- a/work/history.md +++ b/work/history.md @@ -512,3 +512,10 @@ ## F-195 cerrada (2026-08-22) — Hide IVA/Audit/Logs from main sidebar - Gates: reviewer APPROVED, security APPROVED, qa APPROVED, verify.sh exit 0. - Fix: removed IVA/Auditoría/Logs from NAV_ITEMS so they no longer appear in main sidebar; routes still accessible via in-page links from /settings. + +## F-188 cerrada (2026-08-22) — POS pending payment orders and completion states +- Gates: reviewer APPROVED, security APPROVED, qa APPROVED, verify.sh exit 0. +- Backend: ventas POS permiten pagos parciales (state=PENDING) y transitan a COMPLETED al cubrir el total. Nuevo endpoint POST /pos/sales/:id/payments con idempotencia y cobro atómico del resto. +- Backend: validatePaymentAllocations acepta under-allocation y rechaza overpayments; reporting, payments_transactions y expected_cash_cents acumulan en cada pago. +- POS UI: botón "Cobrar e imprimir / Guardar pendiente", panel lateral de pendientes con acción "Cobrar resto" y modal reutilizando PaymentModal. +- Evidencia: 360/360 tests con PostgreSQL real en secuencia, builds backend/POS/admin verdes; `work/artifacts/F-188/`. diff --git a/work/runtime-status.json b/work/runtime-status.json index 9fbae0c..8672d8a 100644 --- a/work/runtime-status.json +++ b/work/runtime-status.json @@ -6,6 +6,6 @@ "state": "waiting", "next_agent": "leader", "waiting_for": "Seleccionar una feature pending y actualizar este estado", - "updated_at": "2026-08-22T20:36:05Z", + "updated_at": "2026-08-22T20:44:37Z", "timeline": [] }