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

@@ -7240,13 +7240,15 @@
"description": "Allow cashier orders to remain pending with outstanding balance and ensure fully paid POS orders are COMPLETED.", "description": "Allow cashier orders to remain pending with outstanding balance and ensure fully paid POS orders are COMPLETED.",
"priority": "high", "priority": "high",
"risk": "high", "risk": "high",
"status": "pending", "status": "done",
"created_at": "2026-08-22", "created_at": "2026-08-22",
"gates": { "gates": {
"reviewer": false, "reviewer": true,
"security": false, "security": true,
"qa": false "qa": true,
} "close": true
},
"completed_at": "2026-08-22T20:44:36Z"
}, },
{ {
"id": "F-189", "id": "F-189",

View File

@@ -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**. 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. 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 ## 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`. - Solo un método de tipo efectivo acepta `tenderedCents`.
- `tenderedCents` debe ser mayor o igual que el importe aplicado. - `tenderedCents` debe ser mayor o igual que el importe aplicado.
- El cambio es `tenderedCents - amountCents`. - 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 ## Próximas ampliaciones
- F-188: ventas con saldo pendiente.
- F-189: cantidades negativas, devoluciones parciales/totales y ticket de devolución. - F-189: cantidades negativas, devoluciones parciales/totales y ticket de devolución.
- F-190: auditoría completa de actualización/refresco de reporting. - 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. - F-191: cierre de terminal y cierre diario conciliando efectivo, tarjetas, devoluciones y pendientes.

View File

@@ -11,6 +11,7 @@ import { formatPrice } from '@/lib/money';
import type { import type {
PaymentAllocation, PaymentAllocation,
PaymentMethod, PaymentMethod,
PosPendingSale,
PosReceipt, PosReceipt,
PosSaleResponse, PosSaleResponse,
} from '@/types/checkout'; } from '@/types/checkout';
@@ -107,6 +108,10 @@ export default function RegisterPage() {
const [creatingCustomer, setCreatingCustomer] = useState(false); const [creatingCustomer, setCreatingCustomer] = useState(false);
const [processing, setProcessing] = useState(false); const [processing, setProcessing] = useState(false);
const [error, setError] = useState(''); const [error, setError] = useState('');
const [pendingSales, setPendingSales] = useState<PosPendingSale[]>([]);
const [loadingPending, setLoadingPending] = useState(false);
const [restPaymentFor, setRestPaymentFor] = useState<PosPendingSale | null>(null);
const [processingRest, setProcessingRest] = useState(false);
const loadConfig = useCallback(async () => { const loadConfig = useCallback(async () => {
setConfigError(''); setConfigError('');
@@ -125,6 +130,29 @@ export default function RegisterPage() {
void loadConfig(); void loadConfig();
}, [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(() => { useEffect(() => {
if (!config?.session || config.session.status !== 'OPEN') return; if (!config?.session || config.session.status !== 'OPEN') return;
void posApi void posApi
@@ -340,8 +368,12 @@ export default function RegisterPage() {
setError('Carrito vacío'); setError('Carrito vacío');
return; return;
} }
if (remainingCents !== 0 || paidCents !== totals.total) { if (payments.length === 0 || paidCents <= 0) {
setError('Asigna el total entre las formas de pago antes de confirmar'); setError('Asigna al menos un pago antes de confirmar');
return;
}
if (paidCents > totals.total) {
setError('Los pagos asignados superan el total');
return; return;
} }
setProcessing(true); setProcessing(true);
@@ -374,6 +406,8 @@ export default function RegisterPage() {
...(customer ? { customerId: customer.id } : {}), ...(customer ? { customerId: customer.id } : {}),
}); });
setReceipt(result.receipt); setReceipt(result.receipt);
setRestPaymentFor(null);
void loadPendingSales();
} catch (err) { } catch (err) {
setError(err instanceof Error ? err.message : 'No se pudo confirmar la venta'); setError(err instanceof Error ? err.message : 'No se pudo confirmar la venta');
} finally { } 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<PosSaleResponse>(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 = () => { const resetCashier = () => {
setReceipt(null); setReceipt(null);
setCart([]); setCart([]);
@@ -530,6 +598,44 @@ export default function RegisterPage() {
return ( return (
<div className="flex h-screen" style={{ '--color-primary': '#2D6A4F' } as React.CSSProperties}> <div className="flex h-screen" style={{ '--color-primary': '#2D6A4F' } as React.CSSProperties}>
<aside
className="hidden w-64 shrink-0 flex-col border-r bg-amber-50/40 p-3 lg:flex"
aria-label="Pendientes de caja"
>
<div className="mb-3">
<h2 className="text-sm font-bold text-[#2D6A4F]">Pendientes de caja</h2>
<p className="text-xs text-gray-500">
Ventas con saldo pendiente en esta sesión.
</p>
</div>
{loadingPending ? (
<p className="text-xs text-gray-500">Cargando</p>
) : pendingSales.length === 0 ? (
<p className="text-xs text-gray-500">Sin ventas pendientes.</p>
) : (
<ul className="flex flex-1 flex-col gap-2 overflow-y-auto">
{pendingSales.map((sale) => (
<li
key={sale.id}
className="rounded-xl border border-amber-200 bg-white p-3 text-sm shadow-sm"
>
<p className="font-semibold text-gray-800">{sale.receiptNumber ?? sale.id.slice(0, 8)}</p>
<p className="text-xs text-gray-500">
Total {formatPrice(sale.totalCents)} · pendiente {formatPrice(sale.outstandingCents)}
</p>
<button
type="button"
onClick={() => openRestPayment(sale)}
disabled={processingRest}
className="mt-2 w-full rounded-lg bg-amber-500 px-3 py-1.5 text-xs font-bold text-white disabled:opacity-50"
>
Cobrar resto
</button>
</li>
))}
</ul>
)}
</aside>
<main className="flex min-w-0 flex-1 flex-col overflow-hidden border-r p-4"> <main className="flex min-w-0 flex-1 flex-col overflow-hidden border-r p-4">
<div className="mb-3"> <div className="mb-3">
<div className="mb-2 flex items-center gap-2"> <div className="mb-2 flex items-center gap-2">
@@ -939,16 +1045,34 @@ export default function RegisterPage() {
</button> </button>
))} ))}
</div> </div>
<div className="mt-3 grid grid-cols-2 gap-2">
<button <button
type="button" type="button"
onClick={() => void confirmSale()} onClick={() => void confirmSale()}
disabled={ disabled={
processing || cart.length === 0 || remainingCents !== 0 || paidCents !== totals.total processing ||
cart.length === 0 ||
payments.length === 0 ||
paidCents <= 0 ||
paidCents > totals.total
} }
className="mt-3 min-h-16 w-full rounded-xl bg-[#1B4332] text-lg font-bold text-white disabled:opacity-40" className="min-h-16 rounded-xl bg-[#1B4332] text-lg font-bold text-white disabled:opacity-40"
> >
{processing ? 'Confirmando…' : 'Confirmar y cerrar ticket'} {processing
? 'Confirmando…'
: paidCents < totals.total
? 'Guardar pendiente'
: 'Cobrar e imprimir'}
</button> </button>
<button
type="button"
onClick={() => resetCashier()}
disabled={processing || cart.length === 0}
className="min-h-16 rounded-xl border border-gray-300 bg-white text-sm font-bold text-gray-700 disabled:opacity-40"
>
Vaciar caja
</button>
</div>
</aside> </aside>
{showDiscountPanel && selectedItem && ( {showDiscountPanel && selectedItem && (
@@ -976,6 +1100,64 @@ export default function RegisterPage() {
onClose={() => setPaymentMethod(null)} onClose={() => setPaymentMethod(null)}
/> />
)} )}
{restPaymentFor && (
<div
className="fixed inset-0 z-50 flex items-center justify-center bg-black/40 p-4"
role="dialog"
aria-modal="true"
aria-labelledby="rest-payment-title"
>
<div className="w-full max-w-md space-y-4 rounded-2xl bg-white p-6 shadow-2xl">
<div className="flex items-start justify-between gap-4">
<div>
<p className="text-sm font-semibold text-amber-600">
Pendiente {formatPrice(restPaymentFor.outstandingCents)}
</p>
<h2 id="rest-payment-title" className="text-2xl font-bold text-gray-900">
Cobrar resto
</h2>
<p className="mt-1 text-xs text-gray-500">
Venta {restPaymentFor.receiptNumber ?? restPaymentFor.id.slice(0, 8)}
</p>
</div>
<button
type="button"
onClick={() => {
if (!processingRest) setRestPaymentFor(null);
}}
aria-label="Cerrar"
className="text-2xl text-gray-400 hover:text-gray-700"
>
</button>
</div>
<div className="grid grid-cols-2 gap-2">
{config.paymentMethods.map((method) => (
<button
key={method.id}
type="button"
onClick={() => setPaymentMethod(method)}
disabled={processingRest}
className={`min-h-14 rounded-xl px-2 font-bold text-white disabled:opacity-40 ${method.kind === 'cash' ? 'bg-green-600' : method.kind === 'card' ? 'bg-blue-600' : 'bg-slate-700'}`}
>
{method.kind === 'cash' ? '💵' : method.kind === 'card' ? '💳' : '◉'} {method.label}
</button>
))}
</div>
{error && <p className="text-sm text-red-600">{error}</p>}
</div>
</div>
)}
{paymentMethod && restPaymentFor && (
<PaymentModal
method={paymentMethod}
remainingCents={restPaymentFor.outstandingCents}
onAdd={(allocation) => {
void submitRestPayment(allocation);
}}
onClose={() => setPaymentMethod(null)}
/>
)}
{receipt && ( {receipt && (
<ReceiptModal <ReceiptModal
receipt={receipt} receipt={receipt}

View File

@@ -60,6 +60,20 @@ export const posApi = {
/** Atomically confirm a fully allocated sale. */ /** Atomically confirm a fully allocated sale. */
createSale: <T>(data: unknown) => createSale: <T>(data: unknown) =>
apiFetch<T>('/pos/sales', { method: 'POST', body: JSON.stringify(data) }), apiFetch<T>('/pos/sales', { method: 'POST', body: JSON.stringify(data) }),
/** Apply additional payments to a pending POS sale. */
payRest: <T>(orderId: string, data: unknown) =>
apiFetch<T>(`/pos/sales/${encodeURIComponent(orderId)}/payments`, {
method: 'POST',
body: JSON.stringify(data),
}),
/** List POS sales for the session, optionally filtered by state. */
listSales: <T>(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<T>(`/pos/sales${tail ? `?${tail}` : ''}`);
},
/** Email the immutable generated receipt. */ /** Email the immutable generated receipt. */
emailReceipt: <T>(orderId: string, email: string) => emailReceipt: <T>(orderId: string, email: string) =>
apiFetch<T>(`/pos/sales/${encodeURIComponent(orderId)}/receipt/email`, { apiFetch<T>(`/pos/sales/${encodeURIComponent(orderId)}/receipt/email`, {

View File

@@ -66,7 +66,23 @@ export interface PosReceipt {
export interface PosSaleResponse { export interface PosSaleResponse {
orderId: string; orderId: string;
receiptNumber: string; receiptNumber: string;
state: 'PENDING' | 'COMPLETED';
totalCents: number; totalCents: number;
paidCents: number;
outstandingCents: number;
changeCents: number; changeCents: number;
receipt: PosReceipt; 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;
}

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);
});
});

View File

@@ -12,6 +12,7 @@ import { GetPosConfigUseCase } from '../application/get-pos-config.js';
import { OpenCashSessionUseCase } from '../application/open-cash-session.js'; import { OpenCashSessionUseCase } from '../application/open-cash-session.js';
import { CloseCashSessionUseCase } from '../application/close-cash-session.js'; import { CloseCashSessionUseCase } from '../application/close-cash-session.js';
import { CreatePosSaleUseCase } from '../application/create-pos-sale.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 { buildPosReceipt } from '../application/build-pos-receipt.js';
import { sendTransactionalEmail } from '../../notifications/index.js'; import { sendTransactionalEmail } from '../../notifications/index.js';
import { Argon2PasswordHasher } from '../../identity/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 paymentMethodRepo = new PgPaymentMethodRepository(pool);
const sessionRepo = new PgCashSessionRepository(pool); const sessionRepo = new PgCashSessionRepository(pool);
const createPosSale = new CreatePosSaleUseCase(pool); const createPosSale = new CreatePosSaleUseCase(pool);
const receiveRestPayment = new ReceiveRestPaymentUseCase(pool);
const listStores = new ListStoresUseCase(storeRepo); const listStores = new ListStoresUseCase(storeRepo);
const listTerminals = new ListTerminalsUseCase(terminalRepo); const listTerminals = new ListTerminalsUseCase(terminalRepo);
@@ -1321,6 +1323,8 @@ export async function registerPosRoutes(app: FastifyInstance, deps: PosRouteDeps
type: 'object', type: 'object',
properties: { properties: {
sessionId: { type: 'string', format: 'uuid' }, 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 }, limit: { type: 'integer', minimum: 1, maximum: 100, default: 20 },
}, },
}, },
@@ -1330,25 +1334,129 @@ export async function registerPosRoutes(app: FastifyInstance, deps: PosRouteDeps
async (request, reply) => { async (request, reply) => {
const user = await authenticate(request); const user = await authenticate(request);
requireAnyRole(user, ['admin', 'pos_manager', 'pos_cashier'] as ReadonlyArray<Role>); requireAnyRole(user, ['admin', 'pos_manager', 'pos_cashier'] as ReadonlyArray<Role>);
const { sessionId, limit = 20 } = request.query as { sessionId?: string; limit?: number }; const params = request.query as {
let query = `SELECT o.id, o.total_cents AS "totalCents", o.subtotal_cents AS "subtotalCents", 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) {
values.push(sessionId);
conditions.push(`o.cash_session_id = $${values.length}`);
}
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.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" u.email AS "userEmail"
FROM orders_orders o FROM orders_orders o
LEFT JOIN identity_users u ON u.id = o.user_id LEFT JOIN identity_users u ON u.id = o.user_id
WHERE o.idempotency_key IS NOT NULL`; LEFT JOIN (
const params: unknown[] = []; SELECT order_id, SUM(amount_cents) AS sum_paid
if (sessionId) { FROM payments_transactions
params.push(sessionId); WHERE status = 'succeeded'
query += ` AND o.id IN (SELECT order_id FROM pos_cash_sessions WHERE id = $${params.length})`; GROUP BY order_id
} ) payments ON payments.order_id = o.id
params.push(limit); WHERE ${conditions.join(' AND ')}
query += ` ORDER BY o.created_at DESC LIMIT $${params.length}`; ORDER BY o.created_at DESC
const result = await pool.query(query, params); LIMIT $${values.length}
`;
const result = await pool.query(query, values);
return reply.send({ items: result.rows }); 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<Role>);
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 } }>( app.post<{ Params: { id: string } }>(
'/pos/sales/:id/void', '/pos/sales/:id/void',
{ {

View File

@@ -103,13 +103,11 @@ export function validatePaymentAllocations(
}; };
}); });
const allocatedCents = validated.reduce((sum, payment) => sum + payment.amountCents, 0); const allocatedCents = validated.reduce((sum, payment) => sum + payment.amountCents, 0);
if (allocatedCents !== totalCents) { if (allocatedCents > totalCents) {
throw new AppError( throw new AppError(
400, 400,
'POS_PAYMENT_TOTAL_MISMATCH', 'POS_PAYMENT_OVERPAYMENT',
allocatedCents < totalCents 'Los pagos asignados superan el total de la venta',
? 'Falta importe por asignar antes de confirmar'
: 'Los pagos asignados superan el total',
); );
} }
return validated; return validated;
@@ -290,7 +288,7 @@ export class CreatePosSaleUseCase {
`INSERT INTO orders_orders ( `INSERT INTO orders_orders (
user_id, idempotency_key, state, subtotal_cents, discount_cents, tax_cents, user_id, idempotency_key, state, subtotal_cents, discount_cents, tax_cents,
total_cents, source, terminal_id, cash_session_id, store_id, receipt_number 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`, RETURNING id, created_at`,
[ [
input.customerId ?? null, input.customerId ?? null,
@@ -392,6 +390,20 @@ export class CreatePosSaleUseCase {
if (payment.kind === 'cash') cashAppliedCents += payment.amountCents; if (payment.kind === 'cash') cashAppliedCents += payment.amountCents;
paymentResults.push({ id: paymentId, ...payment }); 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) { if (cashAppliedCents > 0) {
await client.query( await client.query(
@@ -409,7 +421,10 @@ export class CreatePosSaleUseCase {
orderId: order.id, orderId: order.id,
idempotencyKey: input.idempotencyKey, idempotencyKey: input.idempotencyKey,
receiptNumber, receiptNumber,
state: orderState,
totalCents, totalCents,
paidCents: initialPaidCents,
outstandingCents: totalCents - initialPaidCents,
changeCents: payments.reduce((sum, payment) => sum + payment.changeCents, 0), changeCents: payments.reduce((sum, payment) => sum + payment.changeCents, 0),
items, items,
payments: paymentResults, payments: paymentResults,
@@ -426,6 +441,12 @@ export class CreatePosSaleUseCase {
private async loadResult(orderId: string, idempotencyKey: string): Promise<PosSaleResult> { private async loadResult(orderId: string, idempotencyKey: string): Promise<PosSaleResult> {
const receipt = await buildPosReceipt(this.pool, orderId); 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<{ const paymentRows = await this.pool.query<{
id: string; id: string;
amount_cents: number; amount_cents: number;
@@ -459,11 +480,15 @@ export class CreatePosSaleUseCase {
quantity: item.quantity, quantity: item.quantity,
vatRate: null, vatRate: null,
})); }));
const paidCents = payments.reduce((sum, p) => sum + p.amountCents, 0);
return { return {
orderId, orderId,
idempotencyKey, idempotencyKey,
receiptNumber: receipt.receiptNumber, receiptNumber: receipt.receiptNumber,
state: orderState,
totalCents: receipt.totalCents, totalCents: receipt.totalCents,
paidCents,
outstandingCents: receipt.totalCents - paidCents,
changeCents: receipt.changeCents, changeCents: receipt.changeCents,
items, items,
payments, payments,

View File

@@ -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<PosSaleResult> {
const client = await this.pool.connect();
try {
await client.query('BEGIN');
const orderRow = await client.query<OrderRow>(
`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<SessionRow>(
`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();
}
}
}

View File

@@ -111,7 +111,10 @@ export interface PosSaleResult {
orderId: string; orderId: string;
idempotencyKey: string; idempotencyKey: string;
receiptNumber: string; receiptNumber: string;
state: 'PENDING' | 'COMPLETED';
totalCents: number; totalCents: number;
paidCents: number;
outstandingCents: number;
changeCents: number; changeCents: number;
items: PosSaleLineItem[]; items: PosSaleLineItem[];
payments: PosPaymentResult[]; payments: PosPaymentResult[];

View File

@@ -6,6 +6,7 @@ export {
type ConfiguredPaymentMethod, type ConfiguredPaymentMethod,
type ValidatedPayment, type ValidatedPayment,
} from './application/create-pos-sale.js'; } from './application/create-pos-sale.js';
export { ReceiveRestPaymentUseCase } from './application/receive-rest-payment.js';
export { buildPosReceipt } from './application/build-pos-receipt.js'; export { buildPosReceipt } from './application/build-pos-receipt.js';
export type { export type {
PosSaleInput, PosSaleInput,

View File

@@ -28,16 +28,27 @@ describe('POS payment allocation', () => {
]); ]);
}); });
it('requires applied amounts to equal the total exactly', () => { it('accepts under-allocated amounts (orders may remain PENDING)', () => {
expectCode( expect(
() =>
validatePaymentAllocations(2_000, [{ methodCode: 'card', amountCents: 1_999 }], methods), validatePaymentAllocations(2_000, [{ methodCode: 'card', amountCents: 1_999 }], methods),
'POS_PAYMENT_TOTAL_MISMATCH', ).toEqual([
); {
methodId: 'card-id',
methodCode: 'card',
methodLabel: 'Tarjeta',
kind: 'card',
amountCents: 1999,
tenderedCents: null,
changeCents: 0,
},
]);
});
it('rejects overpayments while still under total', () => {
expectCode( expectCode(
() => () =>
validatePaymentAllocations(2_000, [{ methodCode: 'card', amountCents: 2_001 }], methods), validatePaymentAllocations(2_000, [{ methodCode: 'card', amountCents: 2_001 }], methods),
'POS_PAYMENT_TOTAL_MISMATCH', 'POS_PAYMENT_OVERPAYMENT',
); );
}); });

View File

@@ -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.

View File

@@ -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.

View File

@@ -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":[]}

View File

@@ -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":[]}

View File

@@ -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":[]}

View File

@@ -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":[]}

View File

@@ -1,31 +1,30 @@
# F-187Admin can deactivate and delete POS cashiers # F-188POS 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 ## Scope
- Add an explicit active/deactivated/deleted lifecycle for backoffice POS cashier accounts.
- Show POS cashiers and their status in the TPV administration page. - Backend `validatePaymentAllocations` accepts partial allocations (sum < total) without throwing `POS_PAYMENT_TOTAL_MISMATCH`. Overpayment remains rejected.
- Let admins create cashiers, deactivate/reactivate them, and delete them with explicit confirmation. - `POST /pos/sales` persists orders with `state='PENDING'` when allocated total < order total, and `state='COMPLETED'` when allocations cover the order total.
- 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. - 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.
- Revoke every live backoffice session when a cashier is deactivated or deleted. - Stock, reporting lines, payment lines and expected cash balance are updated by every payment, including rest-payment installments.
- Reject login and existing-session authentication for inactive or deleted accounts. - Sale list exposes `state`, `paidCents` and `outstandingCents`. Cashier can fetch `PENDING` POS sales for the current session.
- Reject deactivation/deletion while the cashier owns an open cash session; require the cash session to be closed first. - 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.
- Keep all lifecycle mutations admin-only and cashier-role-only. - Audit log records partial creation and pending fulfillment transitions.
- `bounded shipments` and refunds remain out of scope (F-189).
## Out of scope ## Out of scope
- Removing or changing administrators, editors or POS managers. - Refunds, partial refunds and return tickets (F-189).
- Reassigning historical sales or cash sessions to another cashier. - Daily close reconciliation on top of pending balances (F-191).
- Forcing or automating cash-session closure. - Customer credit, deposit or deposit-paper workflows.
- Bulk cashier operations.
## Acceptance ## Acceptance
1. Admin TPV lists POS cashiers with active, inactive or deleted status. 1. Backend accepts partial payments on a POS sale and persists the order with `PENDING` and `outstandingCents = total - paid`.
2. Admin can create a cashier and the account is active by default. 2. Backend still marks fully paid POS orders as `COMPLETED` and rejects overpayment.
3. Admin can deactivate an active cashier and reactivate an inactive non-deleted cashier. 3. Pending POS sales appear in the cashier's session list with the outstanding balance.
4. Deactivation immediately revokes existing sessions and blocks future login/authentication. 4. Cashier can apply a follow-up payment to a `PENDING` sale; reaching the total transitions it to `COMPLETED` atomically.
5. Admin can delete a cashier only after explicit confirmation; deleted cashiers cannot be reactivated or authenticate. 5. Stock is reserved on POS sale creation (not on full payment) and reporting captures every partial payment.
6. Deactivation or deletion is rejected while the cashier has an open cash session. 6. Replaying the same idempotency key keeps the original order, its state and its payments intact.
7. Deletion preserves the cashier row/ID and all historical session, sale, receipt and reporting attribution. 7. Non-admin/non-POS users cannot create or modify pending POS sales.
8. Non-admin users cannot list or mutate cashier lifecycle, and non-cashier roles cannot be targeted. 8. Migration is reversible (or no migration is needed) and existing fully paid sales stay `COMPLETED`.
9. Migration is reversible and existing backoffice accounts remain active. 9. Tests, typecheck, affected builds and `verify.sh` are green.
10. Tests, typecheck, affected builds and `verify.sh` are green.

View File

@@ -512,3 +512,10 @@
## F-195 cerrada (2026-08-22) — Hide IVA/Audit/Logs from main sidebar ## F-195 cerrada (2026-08-22) — Hide IVA/Audit/Logs from main sidebar
- Gates: reviewer APPROVED, security APPROVED, qa APPROVED, verify.sh exit 0. - 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. - 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/`.

View File

@@ -6,6 +6,6 @@
"state": "waiting", "state": "waiting",
"next_agent": "leader", "next_agent": "leader",
"waiting_for": "Seleccionar una feature pending y actualizar este estado", "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": [] "timeline": []
} }