feat(F-188): completed feature
This commit is contained in:
@@ -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",
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
@@ -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>
|
||||||
<button
|
<div className="mt-3 grid grid-cols-2 gap-2">
|
||||||
type="button"
|
<button
|
||||||
onClick={() => void confirmSale()}
|
type="button"
|
||||||
disabled={
|
onClick={() => void confirmSale()}
|
||||||
processing || cart.length === 0 || remainingCents !== 0 || paidCents !== totals.total
|
disabled={
|
||||||
}
|
processing ||
|
||||||
className="mt-3 min-h-16 w-full rounded-xl bg-[#1B4332] text-lg font-bold text-white disabled:opacity-40"
|
cart.length === 0 ||
|
||||||
>
|
payments.length === 0 ||
|
||||||
{processing ? 'Confirmando…' : 'Confirmar y cerrar ticket'}
|
paidCents <= 0 ||
|
||||||
</button>
|
paidCents > totals.total
|
||||||
|
}
|
||||||
|
className="min-h-16 rounded-xl bg-[#1B4332] text-lg font-bold text-white disabled:opacity-40"
|
||||||
|
>
|
||||||
|
{processing
|
||||||
|
? 'Confirmando…'
|
||||||
|
: paidCents < totals.total
|
||||||
|
? 'Guardar pendiente'
|
||||||
|
: 'Cobrar e imprimir'}
|
||||||
|
</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}
|
||||||
|
|||||||
@@ -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`, {
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
|
|||||||
259
project/src/app/tests/pos-pending-payments.itest.ts
Normal file
259
project/src/app/tests/pos-pending-payments.itest.ts
Normal 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);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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;
|
||||||
o.discount_cents AS "discountCents", o.created_at AS "createdAt",
|
state?: string;
|
||||||
u.email AS "userEmail"
|
storeId?: string;
|
||||||
FROM orders_orders o
|
limit?: number;
|
||||||
LEFT JOIN identity_users u ON u.id = o.user_id
|
};
|
||||||
WHERE o.idempotency_key IS NOT NULL`;
|
const sessionId = params.sessionId;
|
||||||
const params: unknown[] = [];
|
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) {
|
if (sessionId) {
|
||||||
params.push(sessionId);
|
values.push(sessionId);
|
||||||
query += ` AND o.id IN (SELECT order_id FROM pos_cash_sessions WHERE id = $${params.length})`;
|
conditions.push(`o.cash_session_id = $${values.length}`);
|
||||||
}
|
}
|
||||||
params.push(limit);
|
if (state) {
|
||||||
query += ` ORDER BY o.created_at DESC LIMIT $${params.length}`;
|
values.push(state);
|
||||||
const result = await pool.query(query, params);
|
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 });
|
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',
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
237
project/src/modules/pos/application/receive-rest-payment.ts
Normal file
237
project/src/modules/pos/application/receive-rest-payment.ts
Normal 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();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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[];
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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),
|
).toEqual([
|
||||||
'POS_PAYMENT_TOTAL_MISMATCH',
|
{
|
||||||
);
|
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',
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
109
work/artifacts/F-188/architect.md
Normal file
109
work/artifacts/F-188/architect.md
Normal 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.
|
||||||
33
work/artifacts/F-188/implementer.md
Normal file
33
work/artifacts/F-188/implementer.md
Normal 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.
|
||||||
1
work/artifacts/F-188/leader-close.json
Normal file
1
work/artifacts/F-188/leader-close.json
Normal 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":[]}
|
||||||
1
work/artifacts/F-188/qa.json
Normal file
1
work/artifacts/F-188/qa.json
Normal 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":[]}
|
||||||
1
work/artifacts/F-188/reviewer.json
Normal file
1
work/artifacts/F-188/reviewer.json
Normal 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":[]}
|
||||||
1
work/artifacts/F-188/security.json
Normal file
1
work/artifacts/F-188/security.json
Normal 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":[]}
|
||||||
@@ -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
|
## 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.
|
|
||||||
|
|||||||
@@ -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/`.
|
||||||
|
|||||||
@@ -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": []
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user