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

@@ -11,6 +11,7 @@ import { formatPrice } from '@/lib/money';
import type {
PaymentAllocation,
PaymentMethod,
PosPendingSale,
PosReceipt,
PosSaleResponse,
} from '@/types/checkout';
@@ -107,6 +108,10 @@ export default function RegisterPage() {
const [creatingCustomer, setCreatingCustomer] = useState(false);
const [processing, setProcessing] = useState(false);
const [error, setError] = useState('');
const [pendingSales, setPendingSales] = useState<PosPendingSale[]>([]);
const [loadingPending, setLoadingPending] = useState(false);
const [restPaymentFor, setRestPaymentFor] = useState<PosPendingSale | null>(null);
const [processingRest, setProcessingRest] = useState(false);
const loadConfig = useCallback(async () => {
setConfigError('');
@@ -125,6 +130,29 @@ export default function RegisterPage() {
void loadConfig();
}, [loadConfig]);
const loadPendingSales = useCallback(async () => {
if (!config?.session || config.session.status !== 'OPEN') {
setPendingSales([]);
return;
}
setLoadingPending(true);
try {
const data = await posApi.listSales<{ items: PosPendingSale[] }>({
state: 'PENDING',
sessionId: config.session.id,
});
setPendingSales(data.items ?? []);
} catch {
setPendingSales([]);
} finally {
setLoadingPending(false);
}
}, [config?.session?.id, config?.session?.status]);
useEffect(() => {
void loadPendingSales();
}, [loadPendingSales]);
useEffect(() => {
if (!config?.session || config.session.status !== 'OPEN') return;
void posApi
@@ -340,8 +368,12 @@ export default function RegisterPage() {
setError('Carrito vacío');
return;
}
if (remainingCents !== 0 || paidCents !== totals.total) {
setError('Asigna el total entre las formas de pago antes de confirmar');
if (payments.length === 0 || paidCents <= 0) {
setError('Asigna al menos un pago antes de confirmar');
return;
}
if (paidCents > totals.total) {
setError('Los pagos asignados superan el total');
return;
}
setProcessing(true);
@@ -374,6 +406,8 @@ export default function RegisterPage() {
...(customer ? { customerId: customer.id } : {}),
});
setReceipt(result.receipt);
setRestPaymentFor(null);
void loadPendingSales();
} catch (err) {
setError(err instanceof Error ? err.message : 'No se pudo confirmar la venta');
} finally {
@@ -381,6 +415,40 @@ export default function RegisterPage() {
}
};
const openRestPayment = (sale: PosPendingSale) => {
setRestPaymentFor(sale);
setError('');
};
const submitRestPayment = async (allocation: PaymentAllocation) => {
if (!config?.session || !restPaymentFor) return;
setProcessingRest(true);
setError('');
try {
const result = await posApi.payRest<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 = () => {
setReceipt(null);
setCart([]);
@@ -530,6 +598,44 @@ export default function RegisterPage() {
return (
<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">
<div className="mb-3">
<div className="mb-2 flex items-center gap-2">
@@ -939,16 +1045,34 @@ export default function RegisterPage() {
</button>
))}
</div>
<button
type="button"
onClick={() => void confirmSale()}
disabled={
processing || cart.length === 0 || remainingCents !== 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"
>
{processing ? 'Confirmando…' : 'Confirmar y cerrar ticket'}
</button>
<div className="mt-3 grid grid-cols-2 gap-2">
<button
type="button"
onClick={() => void confirmSale()}
disabled={
processing ||
cart.length === 0 ||
payments.length === 0 ||
paidCents <= 0 ||
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>
{showDiscountPanel && selectedItem && (
@@ -976,6 +1100,64 @@ export default function RegisterPage() {
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 && (
<ReceiptModal
receipt={receipt}

View File

@@ -60,6 +60,20 @@ export const posApi = {
/** Atomically confirm a fully allocated sale. */
createSale: <T>(data: unknown) =>
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. */
emailReceipt: <T>(orderId: string, email: string) =>
apiFetch<T>(`/pos/sales/${encodeURIComponent(orderId)}/receipt/email`, {

View File

@@ -66,7 +66,23 @@ export interface PosReceipt {
export interface PosSaleResponse {
orderId: string;
receiptNumber: string;
state: 'PENDING' | 'COMPLETED';
totalCents: number;
paidCents: number;
outstandingCents: number;
changeCents: number;
receipt: PosReceipt;
}
export interface PosPendingSale {
id: string;
state: 'PENDING' | 'COMPLETED';
totalCents: number;
paidCents: number;
outstandingCents: number;
subtotalCents: number;
discountCents: number;
createdAt: string;
cashierEmail?: string | null;
receiptNumber?: string | null;
}

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 { CloseCashSessionUseCase } from '../application/close-cash-session.js';
import { CreatePosSaleUseCase } from '../application/create-pos-sale.js';
import { ReceiveRestPaymentUseCase } from '../application/receive-rest-payment.js';
import { buildPosReceipt } from '../application/build-pos-receipt.js';
import { sendTransactionalEmail } from '../../notifications/index.js';
import { Argon2PasswordHasher } from '../../identity/index.js';
@@ -62,6 +63,7 @@ export async function registerPosRoutes(app: FastifyInstance, deps: PosRouteDeps
const paymentMethodRepo = new PgPaymentMethodRepository(pool);
const sessionRepo = new PgCashSessionRepository(pool);
const createPosSale = new CreatePosSaleUseCase(pool);
const receiveRestPayment = new ReceiveRestPaymentUseCase(pool);
const listStores = new ListStoresUseCase(storeRepo);
const listTerminals = new ListTerminalsUseCase(terminalRepo);
@@ -1321,6 +1323,8 @@ export async function registerPosRoutes(app: FastifyInstance, deps: PosRouteDeps
type: 'object',
properties: {
sessionId: { type: 'string', format: 'uuid' },
state: { type: 'string', enum: ['PENDING', 'COMPLETED'] },
storeId: { type: 'string', format: 'uuid' },
limit: { type: 'integer', minimum: 1, maximum: 100, default: 20 },
},
},
@@ -1330,25 +1334,129 @@ export async function registerPosRoutes(app: FastifyInstance, deps: PosRouteDeps
async (request, reply) => {
const user = await authenticate(request);
requireAnyRole(user, ['admin', 'pos_manager', 'pos_cashier'] as ReadonlyArray<Role>);
const { sessionId, limit = 20 } = request.query as { sessionId?: string; limit?: number };
let query = `SELECT o.id, o.total_cents AS "totalCents", o.subtotal_cents AS "subtotalCents",
o.discount_cents AS "discountCents", o.created_at AS "createdAt",
u.email AS "userEmail"
FROM orders_orders o
LEFT JOIN identity_users u ON u.id = o.user_id
WHERE o.idempotency_key IS NOT NULL`;
const params: unknown[] = [];
const params = request.query as {
sessionId?: string;
state?: string;
storeId?: string;
limit?: number;
};
const sessionId = params.sessionId;
const state = params.state;
const storeId = params.storeId;
const limit = Math.min(Math.max(params.limit ?? 20, 1), 100);
const conditions: string[] = [`o.idempotency_key IS NOT NULL`];
const values: unknown[] = [];
if (sessionId) {
params.push(sessionId);
query += ` AND o.id IN (SELECT order_id FROM pos_cash_sessions WHERE id = $${params.length})`;
values.push(sessionId);
conditions.push(`o.cash_session_id = $${values.length}`);
}
params.push(limit);
query += ` ORDER BY o.created_at DESC LIMIT $${params.length}`;
const result = await pool.query(query, params);
if (state) {
values.push(state);
conditions.push(`o.state = $${values.length}`);
}
if (storeId) {
values.push(storeId);
conditions.push(`o.store_id = $${values.length}`);
}
values.push(limit);
const query = `
SELECT o.id, o.state,
o.total_cents AS "totalCents", o.subtotal_cents AS "subtotalCents",
o.discount_cents AS "discountCents", o.created_at AS "createdAt",
o.store_id AS "storeId", o.terminal_id AS "terminalId",
o.cash_session_id AS "cashSessionId",
COALESCE(payments.sum_paid, 0)::int AS "paidCents",
(o.total_cents - COALESCE(payments.sum_paid, 0))::int AS "outstandingCents",
u.email AS "userEmail"
FROM orders_orders o
LEFT JOIN identity_users u ON u.id = o.user_id
LEFT JOIN (
SELECT order_id, SUM(amount_cents) AS sum_paid
FROM payments_transactions
WHERE status = 'succeeded'
GROUP BY order_id
) payments ON payments.order_id = o.id
WHERE ${conditions.join(' AND ')}
ORDER BY o.created_at DESC
LIMIT $${values.length}
`;
const result = await pool.query(query, values);
return reply.send({ items: result.rows });
},
);
app.post<{ Params: { id: string } }>(
'/pos/sales/:id/payments',
{
schema: {
tags: ['POS Terminal'],
summary: 'Apply additional payments to a pending POS sale',
params: { type: 'object', properties: { id: { type: 'string', format: 'uuid' } } },
body: {
type: 'object',
required: ['idempotencyKey', 'cashSessionId', 'terminalId', 'payments'],
properties: {
idempotencyKey: { type: 'string', minLength: 1, maxLength: 128 },
cashSessionId: { type: 'string', format: 'uuid' },
terminalId: { type: 'string', format: 'uuid' },
payments: { type: 'array', minItems: 1, items: { type: 'object' } },
},
},
response: {
400: errorSchema,
401: errorSchema,
403: errorSchema,
404: errorSchema,
409: errorSchema,
},
} as FastifySchema,
},
async (request, reply) => {
const user = await authenticate(request);
requireAnyRole(user, ['admin', 'pos_manager', 'pos_cashier'] as ReadonlyArray<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 } }>(
'/pos/sales/:id/void',
{

View File

@@ -103,13 +103,11 @@ export function validatePaymentAllocations(
};
});
const allocatedCents = validated.reduce((sum, payment) => sum + payment.amountCents, 0);
if (allocatedCents !== totalCents) {
if (allocatedCents > totalCents) {
throw new AppError(
400,
'POS_PAYMENT_TOTAL_MISMATCH',
allocatedCents < totalCents
? 'Falta importe por asignar antes de confirmar'
: 'Los pagos asignados superan el total',
'POS_PAYMENT_OVERPAYMENT',
'Los pagos asignados superan el total de la venta',
);
}
return validated;
@@ -290,7 +288,7 @@ export class CreatePosSaleUseCase {
`INSERT INTO orders_orders (
user_id, idempotency_key, state, subtotal_cents, discount_cents, tax_cents,
total_cents, source, terminal_id, cash_session_id, store_id, receipt_number
) VALUES ($1, $2, 'COMPLETED', $3, $4, $5, $6, 'pos', $7, $8, $9, $10)
) VALUES ($1, $2, 'PENDING', $3, $4, $5, $6, 'pos', $7, $8, $9, $10)
RETURNING id, created_at`,
[
input.customerId ?? null,
@@ -392,6 +390,20 @@ export class CreatePosSaleUseCase {
if (payment.kind === 'cash') cashAppliedCents += payment.amountCents;
paymentResults.push({ id: paymentId, ...payment });
}
// Recompute initial paidCents from current payments only when this is a
// first-time creation. Rest-payment use case has its own loader.
const initialPaidCents = payments.reduce((sum, p) => sum + p.amountCents, 0);
const orderState: 'PENDING' | 'COMPLETED' =
initialPaidCents < totalCents ? 'PENDING' : 'COMPLETED';
if (orderState === 'COMPLETED') {
await client.query(
`UPDATE orders_orders SET state = 'COMPLETED',
state_changed_at = now(),
updated_at = now()
WHERE id = $1`,
[order.id],
);
}
if (cashAppliedCents > 0) {
await client.query(
@@ -409,7 +421,10 @@ export class CreatePosSaleUseCase {
orderId: order.id,
idempotencyKey: input.idempotencyKey,
receiptNumber,
state: orderState,
totalCents,
paidCents: initialPaidCents,
outstandingCents: totalCents - initialPaidCents,
changeCents: payments.reduce((sum, payment) => sum + payment.changeCents, 0),
items,
payments: paymentResults,
@@ -426,6 +441,12 @@ export class CreatePosSaleUseCase {
private async loadResult(orderId: string, idempotencyKey: string): Promise<PosSaleResult> {
const receipt = await buildPosReceipt(this.pool, orderId);
const orderRows = await this.pool.query<{ state: string }>(
`SELECT state FROM orders_orders WHERE id = $1`,
[orderId],
);
const orderState: 'PENDING' | 'COMPLETED' =
orderRows.rows[0]?.state === 'COMPLETED' ? 'COMPLETED' : 'PENDING';
const paymentRows = await this.pool.query<{
id: string;
amount_cents: number;
@@ -459,11 +480,15 @@ export class CreatePosSaleUseCase {
quantity: item.quantity,
vatRate: null,
}));
const paidCents = payments.reduce((sum, p) => sum + p.amountCents, 0);
return {
orderId,
idempotencyKey,
receiptNumber: receipt.receiptNumber,
state: orderState,
totalCents: receipt.totalCents,
paidCents,
outstandingCents: receipt.totalCents - paidCents,
changeCents: receipt.changeCents,
items,
payments,

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;
idempotencyKey: string;
receiptNumber: string;
state: 'PENDING' | 'COMPLETED';
totalCents: number;
paidCents: number;
outstandingCents: number;
changeCents: number;
items: PosSaleLineItem[];
payments: PosPaymentResult[];

View File

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

View File

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