feat(F-189): completed feature

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

View File

@@ -7257,13 +7257,15 @@
"description": "Accept validated negative quantities to cancel purchases fully or partially and issue linked return receipts.",
"priority": "high",
"risk": "high",
"status": "pending",
"status": "done",
"created_at": "2026-08-22",
"gates": {
"reviewer": false,
"security": false,
"qa": false
}
"reviewer": true,
"security": true,
"qa": true,
"close": true
},
"completed_at": "2026-08-22T21:01:59Z"
},
{
"id": "F-190",

View File

@@ -159,8 +159,14 @@ Por cada asignación se crea:
El efectivo esperado aumenta por el importe aplicado, no por el efectivo entregado; el cambio no cuenta como ingreso ni efectivo retenido.
## Devoluciones
`POST /pos/sales/:id/returns` registra devoluciones parciales o totales que conservan el UUID de la venta, restauran el stock y emiten un ticket asociado con el prefijo `R-`. La operación queda registrada en `pos_returns`, `reporting_payment_lines` (status `refund`/`partial_refund`), `orders_order_history` y el log de auditoría. Las devoluciones solo operan sobre ventas `pos` y exigen la caja (`x-terminal-id`) y el rol POS. El endpoint antiguo `POST /pos/sales/:id/refund` queda desregistrado.
`GET /pos/sales/:id/items` expone las líneas de la orden con `quantity` y `returnedQuantity` para alimentar el modal de devolución.
## Próximas ampliaciones
- 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.

View File

@@ -5,6 +5,7 @@ import DiscountPanel from '@/components/DiscountPanel';
import FreeItemModal from '@/components/FreeItemModal';
import PaymentModal from '@/components/PaymentModal';
import ReceiptModal from '@/components/ReceiptModal';
import ReturnModal from '@/components/ReturnModal';
import { posApi } from '@/lib/api-client';
import { generateIdempotencyKey } from '@/lib/idempotency';
import { formatPrice } from '@/lib/money';
@@ -112,6 +113,9 @@ export default function RegisterPage() {
const [loadingPending, setLoadingPending] = useState(false);
const [restPaymentFor, setRestPaymentFor] = useState<PosPendingSale | null>(null);
const [processingRest, setProcessingRest] = useState(false);
const [returnOrder, setReturnOrder] = useState<{ orderId: string; receipt: PosReceipt } | null>(
null,
);
const loadConfig = useCallback(async () => {
setConfigError('');
@@ -1163,6 +1167,26 @@ export default function RegisterPage() {
receipt={receipt}
initialEmail={customer?.email ?? receipt.customerEmail ?? ''}
onDelivered={resetCashier}
onReturn={
config?.session && config?.terminal
? (orderId, latest) => {
setReturnOrder({ orderId, receipt: latest });
}
: undefined
}
/>
)}
{returnOrder && config?.session && config?.terminal && (
<ReturnModal
receipt={returnOrder.receipt}
orderId={returnOrder.orderId}
cashSessionId={config.session.id}
terminalId={config.terminal.id}
onClose={() => setReturnOrder(null)}
onReturned={(latest) => {
setReturnOrder(null);
setReceipt(latest);
}}
/>
)}
</div>

View File

@@ -9,12 +9,14 @@ interface ReceiptModalProps {
receipt: PosReceipt;
initialEmail?: string;
onDelivered: () => void;
onReturn?: (orderId: string, receipt: PosReceipt) => void;
}
export default function ReceiptModal({
receipt,
initialEmail = '',
onDelivered,
onReturn,
}: ReceiptModalProps) {
const [email, setEmail] = useState(initialEmail);
const [sending, setSending] = useState(false);
@@ -166,6 +168,15 @@ export default function ReceiptModal({
>
🖨 Imprimir ticket
</button>
{onReturn && !receipt.isReturn && (
<button
type="button"
onClick={() => onReturn(receipt.orderId, receipt)}
className="min-h-12 w-full rounded-xl border border-amber-300 bg-amber-50 font-bold text-amber-800"
>
Devolver artículos
</button>
)}
<form onSubmit={emailReceipt} className="flex gap-2">
<label className="sr-only" htmlFor="receipt-email">
Email del ticket

View File

@@ -0,0 +1,233 @@
'use client';
import { useEffect, useMemo, useState } from 'react';
import { posApi } from '@/lib/api-client';
import { formatPrice } from '@/lib/money';
import { generateIdempotencyKey } from '@/lib/idempotency';
import type { PosReceipt, PosSaleResponse } from '@/types/checkout';
interface OrderItem {
id: string;
name: string;
quantity: number;
returnedQuantity: number;
freeItem: boolean;
unitPriceCents: number;
discountCents: number;
}
interface ReturnModalProps {
receipt: PosReceipt;
orderId: string;
cashSessionId: string;
terminalId: string;
onClose: () => void;
onReturned: (newReceipt: PosReceipt) => void;
}
export default function ReturnModal({
receipt,
orderId,
cashSessionId,
terminalId,
onClose,
onReturned,
}: ReturnModalProps) {
const [items, setItems] = useState<OrderItem[]>([]);
const [lines, setLines] = useState<Record<string, number>>({});
const [reason, setReason] = useState('Cambio/devolución');
const [error, setError] = useState('');
const [loading, setLoading] = useState(true);
const [saving, setSaving] = useState(false);
useEffect(() => {
void (async () => {
try {
const data = await posApi.listOrderItems<{ items: OrderItem[] }>(orderId);
setItems(
data.items.map((item) => ({
id: item.id,
name: item.name,
quantity: item.quantity,
returnedQuantity: item.returnedQuantity,
freeItem: item.freeItem,
unitPriceCents: item.unitPriceCents,
discountCents: item.discountCents,
})),
);
setLines(
Object.fromEntries(data.items.map((item) => [item.id, 0])),
);
} catch (err) {
setError(err instanceof Error ? err.message : 'No se pudieron cargar las líneas');
} finally {
setLoading(false);
}
})();
}, [orderId]);
const totalRefund = useMemo(() => {
return items.reduce((sum, item) => {
const requested = lines[item.id] ?? 0;
if (requested <= 0) return sum;
const perUnit = Math.max(item.unitPriceCents - item.discountCents, 0);
return sum + perUnit * requested;
}, 0);
}, [lines, items]);
const submit = async () => {
setError('');
const itemsPayload = items
.map((item) => ({ orderItemId: item.id, returnedQuantity: lines[item.id] ?? 0 }))
.filter((line) => line.returnedQuantity > 0);
if (itemsPayload.length === 0) {
setError('Selecciona al menos una unidad para devolver');
return;
}
setSaving(true);
try {
const result = await posApi.createReturn<PosSaleResponse>(orderId, {
idempotencyKey: generateIdempotencyKey(),
cashSessionId,
terminalId,
reason: reason.trim() || 'Devolución',
items: itemsPayload,
});
onReturned(result.receipt);
} catch (err) {
setError(err instanceof Error ? err.message : 'No se pudo registrar la devolución');
} finally {
setSaving(false);
}
};
return (
<div
className="fixed inset-0 z-[70] flex items-center justify-center bg-black/40 p-4"
role="dialog"
aria-modal="true"
aria-labelledby="return-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">
Devolución · ticket {receipt.receiptNumber}
</p>
<h2 id="return-title" className="text-2xl font-bold text-gray-900">
Devolver artículos
</h2>
</div>
<button
type="button"
onClick={onClose}
aria-label="Cerrar"
className="text-2xl text-gray-400 hover:text-gray-700"
>
</button>
</div>
{loading ? (
<p className="text-sm text-gray-500">Cargando</p>
) : (
<>
<ul className="space-y-2">
{items.map((item) => {
const requested = lines[item.id] ?? 0;
const max = item.quantity - item.returnedQuantity;
const step = item.freeItem ? max : 1;
return (
<li
key={item.id}
className="flex items-center justify-between rounded-xl border border-gray-200 bg-gray-50 p-3 text-sm"
>
<div>
<p className="font-medium text-gray-800">{item.name}</p>
<p className="text-xs text-gray-500">
Vendidas {item.quantity} · devueltas {item.returnedQuantity}
{item.freeItem ? ' · artículo libre' : ''}
</p>
</div>
<div className="flex items-center gap-2">
<button
type="button"
onClick={() =>
setLines((current) => ({
...current,
[item.id]: Math.max(0, requested - step),
}))
}
disabled={requested <= 0}
className="h-8 w-8 rounded-lg border text-lg font-bold disabled:opacity-40"
>
</button>
<span className="w-8 text-center text-lg font-bold">{requested}</span>
<button
type="button"
onClick={() =>
setLines((current) => ({
...current,
[item.id]: Math.min(max, requested + step),
}))
}
disabled={requested === max}
className="h-8 w-8 rounded-lg border text-lg font-bold disabled:opacity-40"
>
+
</button>
</div>
</li>
);
})}
</ul>
<label className="block text-sm font-medium text-gray-700">
Motivo
<input
type="text"
required
maxLength={500}
value={reason}
onChange={(event) => setReason(event.target.value)}
className="mt-1 w-full rounded-xl border border-gray-300 px-3 py-2"
/>
</label>
<div className="rounded-xl bg-amber-50 p-3 text-sm">
<p className="flex justify-between font-bold">
<span>Reembolso estimado</span>
<span>{formatPrice(totalRefund)}</span>
</p>
</div>
{error && (
<p className="text-sm font-medium text-red-600" aria-live="polite">
{error}
</p>
)}
<div className="flex gap-2">
<button
type="button"
onClick={onClose}
disabled={saving}
className="flex-1 rounded-xl border border-gray-300 py-3 text-sm font-bold disabled:opacity-50"
>
Cancelar
</button>
<button
type="button"
onClick={() => void submit()}
disabled={saving || totalRefund === 0}
className="flex-1 rounded-xl bg-amber-500 py-3 text-sm font-bold text-white disabled:opacity-50"
>
{saving ? 'Procesando…' : 'Confirmar devolución'}
</button>
</div>
</>
)}
</div>
</div>
);
}

View File

@@ -66,6 +66,15 @@ export const posApi = {
method: 'POST',
body: JSON.stringify(data),
}),
/** Apply a partial/full return to a POS sale. */
createReturn: <T>(orderId: string, data: unknown) =>
apiFetch<T>(`/pos/sales/${encodeURIComponent(orderId)}/returns`, {
method: 'POST',
body: JSON.stringify(data),
}),
/** List order items for a sale (used by the return UI). */
listOrderItems: <T>(orderId: string) =>
apiFetch<T>(`/pos/sales/${encodeURIComponent(orderId)}/items`),
/** List POS sales for the session, optionally filtered by state. */
listSales: <T>(params?: { state?: 'PENDING' | 'COMPLETED'; sessionId?: string }) => {
const qs = new URLSearchParams();

View File

@@ -61,6 +61,8 @@ export interface PosReceipt {
header: string | null;
returnPolicy: string;
footer: string | null;
originalReceiptNumber?: string;
isReturn?: boolean;
}
export interface PosSaleResponse {

View File

@@ -0,0 +1,94 @@
/**
* F-189 — POS returns per-line counters + pos_returns table.
*
* 1. Adds `orders_items.returned_quantity integer NOT NULL DEFAULT 0` with
* `CHECK (returned_quantity >= 0 AND returned_quantity <= quantity)`.
* 2. Creates `pos_returns` as the historical ledger of returns with a unique
* `(order_id, idempotency_key)` index for replay.
* 3. Allows `'return'` in `inventory_movements.operation` so cashiers can
* see the movement in inventory history.
*
* @param {import('node-pg-migrate').MigrationBuilder} pgm
*/
export const up = (pgm) => {
// ── orders_items.returned_quantity ─────────────────────────────────────
pgm.sql(`
DO $$
BEGIN
IF NOT EXISTS (
SELECT 1 FROM information_schema.columns
WHERE table_name = 'orders_items' AND column_name = 'returned_quantity'
) THEN
ALTER TABLE orders_items
ADD COLUMN returned_quantity integer NOT NULL DEFAULT 0;
END IF;
END $$
`);
pgm.sql(`ALTER TABLE orders_items DROP CONSTRAINT IF EXISTS orders_items_returned_quantity_check`);
pgm.sql(`
ALTER TABLE orders_items
ADD CONSTRAINT orders_items_returned_quantity_check
CHECK (returned_quantity >= 0 AND returned_quantity <= quantity)
`);
// ── inventory_movements.operation — allow 'return' ─────────────────────
pgm.sql(`ALTER TABLE inventory_movements DROP CONSTRAINT IF EXISTS inventory_movements_operation_check`);
pgm.sql(`
ALTER TABLE inventory_movements
ADD CONSTRAINT inventory_movements_operation_check
CHECK (operation IN ('reserve','release','confirm','set_available','return'))
`);
// ── pos_returns ─────────────────────────────────────────────────────────
pgm.createTable('pos_returns', {
id: { type: 'uuid', primaryKey: true, default: pgm.func('gen_random_uuid()') },
order_id: { type: 'uuid', notNull: true, references: 'orders_orders(id)', onDelete: 'RESTRICT' },
idempotency_key: { type: 'text', notNull: true },
terminal_id: { type: 'uuid', notNull: true, references: 'pos_terminals(id)', onDelete: 'RESTRICT' },
cash_session_id: { type: 'uuid', notNull: true, references: 'pos_cash_sessions(id)', onDelete: 'RESTRICT' },
store_id: { type: 'uuid', notNull: true, references: 'pos_stores(id)', onDelete: 'RESTRICT' },
user_id: { type: 'uuid', notNull: true, references: 'backoffice_users(id)', onDelete: 'RESTRICT' },
total_cents: { type: 'integer', notNull: true, check: 'total_cents > 0' },
status: {
type: 'text',
notNull: true,
check: "status IN ('refund','partial_refund')",
},
reason: { type: 'text', notNull: true },
created_at: { type: 'timestamptz', notNull: true, default: pgm.func('now()') },
});
pgm.sql(`
CREATE UNIQUE INDEX IF NOT EXISTS pos_returns_idempotency_unique_idx
ON pos_returns (order_id, idempotency_key)
`);
pgm.createIndex('pos_returns', ['store_id', 'created_at'], {
name: 'pos_returns_store_created_idx',
});
pgm.createIndex('pos_returns', 'terminal_id', {
name: 'pos_returns_terminal_idx',
where: 'terminal_id IS NOT NULL',
});
};
export const down = (pgm) => {
pgm.dropTable('pos_returns', { ifExists: true });
pgm.sql(`ALTER TABLE inventory_movements DROP CONSTRAINT IF EXISTS inventory_movements_operation_check`);
pgm.sql(`
ALTER TABLE inventory_movements
ADD CONSTRAINT inventory_movements_operation_check
CHECK (operation IN ('reserve','release','confirm','set_available'))
`);
pgm.sql(`ALTER TABLE orders_items DROP CONSTRAINT IF EXISTS orders_items_returned_quantity_check`);
pgm.sql(`
DO $$
BEGIN
IF EXISTS (
SELECT 1 FROM information_schema.columns
WHERE table_name = 'orders_items' AND column_name = 'returned_quantity'
) THEN
ALTER TABLE orders_items DROP COLUMN returned_quantity;
END IF;
END $$
`);
};

View File

@@ -0,0 +1,338 @@
import { createHash } from 'node:crypto';
import argon2 from 'argon2';
import type pg from 'pg';
import { afterAll, beforeAll, describe, expect, it } from 'vitest';
import { buildApp } from '../build-app.js';
import { CreatePosSaleUseCase } from '../../modules/pos/index.js';
import { createPool } from '../../infrastructure/db/pool.js';
import {
getTestDbUrl,
recreateDatabase,
runMigrations,
} from '../../infrastructure/db/tests/db-test-support.js';
const hasDb = Boolean(process.env.TEST_DATABASE_URL);
const STORE_ID = '00000000-0000-0000-0000-000000000001';
const TERMINAL_ID = '20000000-0000-4000-8000-000000000189';
const SESSION_ID = '30000000-0000-4000-8000-000000000189';
const USER_ID = '10000000-0000-4000-8000-000000000189';
const SESSION_TOKEN = 'f189-session';
const PRODUCT_ID = '40000000-0000-4000-8000-000000000189';
const VARIANT_ID = '50000000-0000-4000-8000-000000000189';
function tokenHash(token: string): string {
return createHash('sha256').update(token).digest('hex');
}
function cookie(token: string): string {
return `backoffice_session=${token}`;
}
describe.skipIf(!hasDb)('F-189 POS returns (real PostgreSQL)', () => {
const url = hasDb ? getTestDbUrl() : '';
let pool: pg.Pool;
let app: Awaited<ReturnType<typeof buildApp>>;
beforeAll(async () => {
await recreateDatabase(url);
await runMigrations(url, 'up');
pool = createPool(url);
const passwordHash = await argon2.hash('cashier-189');
await pool.query(
`INSERT INTO backoffice_users (id, email, password_hash, role)
VALUES ($1, 'cashier-f189@example.test', $2, 'pos_cashier')`,
[USER_ID, passwordHash],
);
await pool.query(
`INSERT INTO backoffice_sessions (user_id, token_hash, expires_at)
VALUES ($1, $2, now() + interval '1 hour')`,
[USER_ID, tokenHash(SESSION_TOKEN)],
);
await pool.query(
`INSERT INTO pos_terminals (id, store_id, name, binding_code, bound_at)
VALUES ($1, $2, 'Caja F-189', 'F189CODE', now())`,
[TERMINAL_ID, STORE_ID],
);
await pool.query(
`INSERT INTO pos_cash_sessions (
id, terminal_id, store_id, user_id, opening_cash_cents, expected_cash_cents
) VALUES ($1, $2, $3, $4, 1000, 1000)`,
[SESSION_ID, TERMINAL_ID, STORE_ID, USER_ID],
);
await pool.query(
`INSERT INTO catalog_products (id, name, slug, state)
VALUES ($1, 'Producto F-189', 'producto-f189', 'active')`,
[PRODUCT_ID],
);
await pool.query(
`INSERT INTO catalog_product_variants (id, product_id, sku, ean)
VALUES ($1, $2, 'SKU-F189', '8412345678903')`,
[VARIANT_ID, PRODUCT_ID],
);
await pool.query(
`INSERT INTO pricing_variant_prices (variant_id, net_unit_amount_cents, vat_rate)
VALUES ($1, 1000, 'reduced')`,
[VARIANT_ID],
);
await pool.query(
`INSERT INTO inventory_stock (variant_id, store_id, available)
VALUES ($1, $2, 10)`,
[VARIANT_ID, STORE_ID],
);
app = await buildApp({ pool, cookieSecure: false });
});
afterAll(async () => {
await app.close();
await pool.end();
});
it('partially returns a sale, restores partial stock and stays PARTIALLY_REFUNDED', async () => {
const useCase = new CreatePosSaleUseCase(pool);
const sale = await useCase.execute({
idempotencyKey: 'f189-sale-partial',
cashSessionId: SESSION_ID,
terminalId: TERMINAL_ID,
userId: USER_ID,
items: [{ kind: 'stock', variantId: VARIANT_ID, quantity: 3, discountCents: 0 }],
payments: [{ methodCode: 'cash', amountCents: 3000, tenderedCents: 3000 }],
});
expect(sale.state).toBe('COMPLETED');
const items = await pool.query<{ id: string }>(
`SELECT id FROM orders_items WHERE order_id = $1`,
[sale.orderId],
);
const firstItemId = items.rows[0]?.id;
expect(firstItemId).toBeTruthy();
const stockBefore = await pool.query<{ available: number }>(
`SELECT available FROM inventory_stock WHERE variant_id = $1 AND store_id = $2`,
[VARIANT_ID, STORE_ID],
);
expect(Number(stockBefore.rows[0]?.available)).toBe(7);
const response = await app.inject({
method: 'POST',
url: `/pos/sales/${sale.orderId}/returns`,
headers: { cookie: cookie(SESSION_TOKEN), 'x-terminal-id': TERMINAL_ID },
payload: {
idempotencyKey: 'f189-rest-partial',
cashSessionId: SESSION_ID,
terminalId: TERMINAL_ID,
reason: 'Cliente cambió de opinión',
items: [{ orderItemId: firstItemId!, returnedQuantity: 1 }],
},
});
expect(response.statusCode).toBe(201);
const body = response.json();
expect(body.state).toBe('PARTIALLY_REFUNDED');
expect(body.refundedCents).toBe(1000);
expect(body.receipt.receiptNumber.startsWith('R-')).toBe(true);
expect(body.receipt.totalCents).toBe(-1000);
expect(body.receipt.items[0].quantity).toBe(1);
expect(body.receipt.items[0].totalCents).toBe(-1000);
const order = await pool.query<{ state: string }>(
`SELECT state FROM orders_orders WHERE id = $1`,
[sale.orderId],
);
expect(order.rows[0]?.state).toBe('PARTIALLY_REFUNDED');
const itemRow = await pool.query<{ returned_quantity: number }>(
`SELECT returned_quantity FROM orders_items WHERE id = $1`,
[firstItemId!],
);
expect(Number(itemRow.rows[0]?.returned_quantity)).toBe(1);
const stockAfter = await pool.query<{ available: number }>(
`SELECT available FROM inventory_stock WHERE variant_id = $1 AND store_id = $2`,
[VARIANT_ID, STORE_ID],
);
expect(Number(stockAfter.rows[0]?.available)).toBe(8);
const event = await pool.query<{ event_type: string }>(
`SELECT event_type FROM orders_order_history WHERE order_id = $1 AND event_type = 'RETURNED'`,
[sale.orderId],
);
expect(event.rows.length).toBe(1);
const reporting = await pool.query<{ status: string; amount_cents: number }>(
`SELECT status, amount_cents FROM reporting_payment_lines
WHERE order_id = $1 AND provider = 'pos_return'`,
[sale.orderId],
);
expect(reporting.rows[0]?.status).toBe('partial_refund');
expect(Number(reporting.rows[0]?.amount_cents)).toBe(1000);
const session = await pool.query<{ expected_cash_cents: number }>(
`SELECT expected_cash_cents FROM pos_cash_sessions WHERE id = $1`,
[SESSION_ID],
);
expect(Number(session.rows[0]?.expected_cash_cents)).toBeLessThanOrEqual(4000);
});
it('fully returns a sale and transitions to REFUNDED', async () => {
const useCase = new CreatePosSaleUseCase(pool);
const sale = await useCase.execute({
idempotencyKey: 'f189-sale-full',
cashSessionId: SESSION_ID,
terminalId: TERMINAL_ID,
userId: USER_ID,
items: [
{ kind: 'stock', variantId: VARIANT_ID, quantity: 1, discountCents: 0 },
{ kind: 'free', name: 'Mano de obra', unitPriceCents: 500, quantity: 1 },
],
payments: [{ methodCode: 'cash', amountCents: 1500, tenderedCents: 1500 }],
});
expect(sale.state).toBe('COMPLETED');
const items = await pool.query<{ id: string; is_free_item: boolean }>(
`SELECT id, is_free_item FROM orders_items WHERE order_id = $1 ORDER BY is_free_item DESC, id`,
[sale.orderId],
);
const stockItemId = items.rows.find((r) => !r.is_free_item)?.id;
const freeItemId = items.rows.find((r) => r.is_free_item)?.id;
expect(stockItemId).toBeTruthy();
expect(freeItemId).toBeTruthy();
const response = await app.inject({
method: 'POST',
url: `/pos/sales/${sale.orderId}/returns`,
headers: { cookie: cookie(SESSION_TOKEN), 'x-terminal-id': TERMINAL_ID },
payload: {
idempotencyKey: 'f189-rest-full',
cashSessionId: SESSION_ID,
terminalId: TERMINAL_ID,
reason: 'Devolución completa',
items: [
{ orderItemId: stockItemId!, returnedQuantity: 1 },
{ orderItemId: freeItemId!, returnedQuantity: 1 },
],
},
});
expect(response.statusCode).toBe(201);
const body = response.json();
expect(body.state).toBe('REFUNDED');
expect(body.refundedCents).toBe(1500);
const stock = await pool.query<{ available: number }>(
`SELECT available FROM inventory_stock WHERE variant_id = $1 AND store_id = $2`,
[VARIANT_ID, STORE_ID],
);
expect(Number(stock.rows[0]?.available)).toBe(8);
const reporting = await pool.query<{ status: string }>(
`SELECT status FROM reporting_payment_lines
WHERE order_id = $1 AND provider = 'pos_return'`,
[sale.orderId],
);
expect(reporting.rows[0]?.status).toBe('refund');
});
it('rejects over-return with POS_RETURN_EXCEEDS_AVAILABLE', async () => {
const useCase = new CreatePosSaleUseCase(pool);
const sale = await useCase.execute({
idempotencyKey: 'f189-sale-overreturn',
cashSessionId: SESSION_ID,
terminalId: TERMINAL_ID,
userId: USER_ID,
items: [{ kind: 'stock', variantId: VARIANT_ID, quantity: 1, discountCents: 0 }],
payments: [{ methodCode: 'cash', amountCents: 1000, tenderedCents: 1000 }],
});
const items = await pool.query<{ id: string }>(
`SELECT id FROM orders_items WHERE order_id = $1`,
[sale.orderId],
);
const itemId = items.rows[0]!.id;
const response = await app.inject({
method: 'POST',
url: `/pos/sales/${sale.orderId}/returns`,
headers: { cookie: cookie(SESSION_TOKEN), 'x-terminal-id': TERMINAL_ID },
payload: {
idempotencyKey: 'f189-rest-over',
cashSessionId: SESSION_ID,
terminalId: TERMINAL_ID,
reason: 'Exceso',
items: [{ orderItemId: itemId, returnedQuantity: 5 }],
},
});
expect(response.statusCode).toBe(409);
expect(response.json().error.code).toBe('POS_RETURN_EXCEEDS_AVAILABLE');
});
it('replays the same idempotency key without duplicating rows', async () => {
const useCase = new CreatePosSaleUseCase(pool);
const sale = await useCase.execute({
idempotencyKey: 'f189-sale-replay',
cashSessionId: SESSION_ID,
terminalId: TERMINAL_ID,
userId: USER_ID,
items: [{ kind: 'stock', variantId: VARIANT_ID, quantity: 2, discountCents: 0 }],
payments: [{ methodCode: 'cash', amountCents: 2000, tenderedCents: 2000 }],
});
const items = await pool.query<{ id: string }>(
`SELECT id FROM orders_items WHERE order_id = $1`,
[sale.orderId],
);
const itemId = items.rows[0]!.id;
const first = await app.inject({
method: 'POST',
url: `/pos/sales/${sale.orderId}/returns`,
headers: { cookie: cookie(SESSION_TOKEN), 'x-terminal-id': TERMINAL_ID },
payload: {
idempotencyKey: 'f189-rest-replay',
cashSessionId: SESSION_ID,
terminalId: TERMINAL_ID,
reason: 'Idem',
items: [{ orderItemId: itemId, returnedQuantity: 1 }],
},
});
expect(first.statusCode).toBe(201);
expect(first.json().returnId).toBeTruthy();
const second = await app.inject({
method: 'POST',
url: `/pos/sales/${sale.orderId}/returns`,
headers: { cookie: cookie(SESSION_TOKEN), 'x-terminal-id': TERMINAL_ID },
payload: {
idempotencyKey: 'f189-rest-replay',
cashSessionId: SESSION_ID,
terminalId: TERMINAL_ID,
reason: 'Idem',
items: [{ orderItemId: itemId, returnedQuantity: 1 }],
},
});
expect(second.statusCode).toBe(201);
expect(second.json().returnId).toBeTruthy();
expect(second.json().returnId).toBe(first.json().returnId);
console.log('FIRST', first.json());
console.log('SECOND', second.json());
const returns = await pool.query<{ count: number }>(
`SELECT COUNT(*)::int AS count FROM pos_returns WHERE order_id = $1`,
[sale.orderId],
);
expect(returns.rows[0]?.count).toBe(1);
const stock = await pool.query<{ available: number }>(
`SELECT available FROM inventory_stock WHERE variant_id = $1 AND store_id = $2`,
[VARIANT_ID, STORE_ID],
);
expect(Number(stock.rows[0]?.available)).toBe(6);
});
it('returns 404 on the legacy /refund route', async () => {
const response = await app.inject({
method: 'POST',
url: '/pos/sales/00000000-0000-4000-8000-000000000000/refund',
headers: { cookie: cookie(SESSION_TOKEN), 'x-terminal-id': TERMINAL_ID },
payload: { refundAmountCents: 100, reason: 'legacy' },
});
expect(response.statusCode).toBe(404);
});
});

View File

@@ -13,6 +13,7 @@ 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 { ApplyPosReturnUseCase } from '../application/apply-pos-return.js';
import { buildPosReceipt } from '../application/build-pos-receipt.js';
import { sendTransactionalEmail } from '../../notifications/index.js';
import { Argon2PasswordHasher } from '../../identity/index.js';
@@ -64,6 +65,7 @@ export async function registerPosRoutes(app: FastifyInstance, deps: PosRouteDeps
const sessionRepo = new PgCashSessionRepository(pool);
const createPosSale = new CreatePosSaleUseCase(pool);
const receiveRestPayment = new ReceiveRestPaymentUseCase(pool);
const applyPosReturn = new ApplyPosReturnUseCase(pool);
const listStores = new ListStoresUseCase(storeRepo);
const listTerminals = new ListTerminalsUseCase(terminalRepo);
@@ -1558,56 +1560,82 @@ export async function registerPosRoutes(app: FastifyInstance, deps: PosRouteDeps
},
);
// ── POS-012: Refund + receipt print + analytics ────────────────────────────
// ── POS-012: POS returns + receipt print + analytics ──────────────────────
app.post<{ Params: { id: string } }>(
'/pos/sales/:id/refund',
'/pos/sales/:id/returns',
{
schema: {
tags: ['POS Terminal'],
summary: 'Refund a POS sale',
summary: 'Apply a partial or full return to a POS sale',
params: { type: 'object', properties: { id: { type: 'string', format: 'uuid' } } },
body: {
type: 'object',
required: ['refundAmountCents', 'reason'],
required: ['idempotencyKey', 'cashSessionId', 'terminalId', 'reason', 'items'],
properties: {
refundAmountCents: { type: 'integer', minimum: 1 },
reason: { type: 'string', minLength: 1 },
idempotencyKey: { type: 'string', minLength: 1, maxLength: 128 },
cashSessionId: { type: 'string', format: 'uuid' },
terminalId: { type: 'string', format: 'uuid' },
reason: { type: 'string', minLength: 1, maxLength: 500 },
items: {
type: 'array',
minItems: 1,
items: {
type: 'object',
required: ['orderItemId', 'returnedQuantity'],
properties: {
orderItemId: { type: 'string', format: 'uuid' },
returnedQuantity: { type: 'integer', minimum: 1 },
},
},
response: { 400: errorSchema, 401: errorSchema, 403: errorSchema, 404: errorSchema },
},
},
},
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 { id } = request.params;
const body = (request.body ?? {}) as { refundAmountCents?: number; reason?: string };
const order = await pool.query<{ id: string; total_cents: number }>(
'SELECT id, total_cents FROM orders_orders WHERE id = $1',
[id],
const body = parseJson(
z.object({
idempotencyKey: z.string().min(1).max(128),
cashSessionId: z.string().uuid(),
terminalId: z.string().uuid(),
reason: z.string().min(1).max(500),
items: z
.array(
z.object({
orderItemId: z.string().uuid(),
returnedQuantity: z.number().int().min(1),
}),
)
.min(1),
}),
request.body ?? {},
);
if (!order.rows[0]) throw new AppError(404, 'NOT_FOUND', 'Order not found');
if ((body.refundAmountCents ?? 0) > (order.rows[0].total_cents ?? 0))
throw new AppError(400, 'REFUND_EXCEEDS', 'Refund exceeds amount');
await pool.query(
`INSERT INTO payments_transactions (provider, provider_event_id, provider_payment_id, order_id, amount_cents, currency, status, raw) VALUES ($1, $2, $3, $4, $5, $6, $7, $8)`,
[
'pos_refund',
`ref-${id}`,
`ref-${Date.now()}`,
id,
body.refundAmountCents,
'EUR',
'COMPLETED',
JSON.stringify({ reason: body.reason, by: user.id }),
],
const boundTerminalId = request.headers['x-terminal-id'];
if (typeof boundTerminalId !== 'string' || boundTerminalId !== body.terminalId) {
throw new AppError(
403,
'POS_TERMINAL_MISMATCH',
'La devolución no pertenece al terminal vinculado',
);
await pool.query(
`INSERT INTO orders_order_events (order_id, event, actor_id, metadata) VALUES ($1, 'REFUNDED', $2, $3)`,
[id, user.id, JSON.stringify({ amt: body.refundAmountCents, reason: body.reason })],
);
return reply.send({ ok: true, refundedCents: body.refundAmountCents });
}
const result = await applyPosReturn.execute({
orderId: id,
userId: user.id,
...body,
lines: body.items,
});
return reply.code(201).send(result);
},
);
@@ -1630,6 +1658,57 @@ export async function registerPosRoutes(app: FastifyInstance, deps: PosRouteDeps
},
);
app.get<{ Params: { id: string } }>(
'/pos/sales/:id/items',
{
schema: {
tags: ['POS Terminal'],
summary: 'List order items of a POS sale (for returns)',
params: { type: 'object', properties: { id: { type: 'string', format: 'uuid' } } },
response: { 401: errorSchema, 404: errorSchema },
} as FastifySchema,
},
async (request, reply) => {
const user = await authenticate(request);
requireAnyRole(user, ['admin', 'pos_manager', 'pos_cashier'] as ReadonlyArray<Role>);
const { id } = parseJson(idParamSchema, request.params);
const order = await pool.query<{ id: string; source: string }>(
`SELECT id, source FROM orders_orders WHERE id = $1`,
[id],
);
if (!order.rows[0] || order.rows[0].source !== 'pos') {
throw new AppError(404, 'POS_SALE_NOT_FOUND', 'Venta no encontrada');
}
const itemRows = await pool.query<{
id: string;
name: string;
sku: string;
quantity: number;
returned_quantity: number;
is_free_item: boolean;
unit_price_cents: number;
discount_cents: number;
}>(
`SELECT id, name, sku, quantity, returned_quantity, is_free_item,
unit_price_cents, discount_cents
FROM orders_items WHERE order_id = $1 ORDER BY created_at, id`,
[id],
);
return reply.send({
items: itemRows.rows.map((row) => ({
id: row.id,
name: row.name,
sku: row.sku,
quantity: Number(row.quantity),
returnedQuantity: Number(row.returned_quantity),
freeItem: row.is_free_item,
unitPriceCents: Number(row.unit_price_cents),
discountCents: Number(row.discount_cents),
})),
});
},
);
app.post<{ Params: { id: string } }>(
'/pos/sales/:id/receipt/email',
{

View File

@@ -0,0 +1,368 @@
import type pg from 'pg';
import { AppError } from '../../../shared/errors.js';
import type { PosReturnInput, PosReturnResult } from '../domain/pos-sale.js';
import { buildPosReturnReceipt } from './build-pos-receipt.js';
interface OrderRow {
id: string;
source: string;
state: string;
terminal_id: string;
cash_session_id: string;
store_id: string;
total_cents: string;
}
interface OrderItemRow {
id: string;
quantity: number;
returned_quantity: number;
is_free_item: boolean;
variant_id: string | null;
unit_price_cents: number;
discount_cents: number;
tax_cents: number;
}
interface SessionRow {
id: string;
terminal_id: string;
store_id: string;
status: string;
}
interface ReplayReturnRow {
id: string;
total_cents: string;
status: string;
}
/**
* Apply a partial or full return to a POS sale.
*
* - Locks the order and stock rows.
* - Restores inventory for returned stock lines.
* - Updates per-line `returned_quantity` counters.
* - Emits a single `reporting_payment_lines` row (`status='refund'` or `'partial_refund'`).
* - Adjusts `pos_cash_sessions.expected_cash_cents` only when cash is refunded.
* - Transitions the order to `PARTIALLY_REFUNDED` or `REFUNDED`.
* - Records audit + history events. Returns the return receipt payload.
*/
export class ApplyPosReturnUseCase {
constructor(private readonly pool: pg.Pool) {}
async execute(input: PosReturnInput): Promise<PosReturnResult> {
if (input.lines.length === 0) {
throw new AppError(400, 'POS_RETURN_EMPTY', 'Selecciona al menos una línea a devolver');
}
for (const line of input.lines) {
if (!Number.isInteger(line.returnedQuantity) || line.returnedQuantity <= 0) {
throw new AppError(
400,
'POS_RETURN_INVALID_QUANTITY',
'La cantidad devuelta debe ser un entero positivo',
);
}
}
const client = await this.pool.connect();
try {
// Idempotent replay check uses a small dedicated transaction so the
// read does not stay open while we do the (possibly heavyweight)
// mutation work.
const replay = await client.query<ReplayReturnRow>(
`SELECT id, total_cents, status
FROM pos_returns
WHERE order_id = $1 AND idempotency_key = $2`,
[input.orderId, input.idempotencyKey],
);
if (replay.rows[0]) {
const previous = replay.rows[0];
const receipt = await buildPosReturnReceipt(
client,
input.orderId,
Number(previous.total_cents),
);
return {
orderId: input.orderId,
returnId: previous.id,
idempotencyKey: input.idempotencyKey,
state: previous.status === 'refund' ? 'REFUNDED' : 'PARTIALLY_REFUNDED',
refundedCents: Number(previous.total_cents),
receipt,
};
}
await client.query('BEGIN');
const orderRow = await client.query<OrderRow>(
`SELECT id, source, state, terminal_id, cash_session_id, store_id, total_cents
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' && order.state !== 'PARTIALLY_REFUNDED') {
throw new AppError(
409,
'POS_RETURN_NOT_ALLOWED',
'La venta no admite devoluciones en su estado actual',
);
}
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 itemRows = await client.query<OrderItemRow>(
`SELECT id, quantity, returned_quantity, is_free_item, variant_id,
unit_price_cents, discount_cents, tax_cents
FROM orders_items
WHERE order_id = $1
FOR UPDATE`,
[input.orderId],
);
const itemsById = new Map(itemRows.rows.map((row) => [row.id, row]));
let totalRefundCents = 0;
const stockUpdates: Array<{
variantId: string;
storeId: string;
quantity: number;
}> = [];
for (const line of input.lines) {
const item = itemsById.get(line.orderItemId);
if (!item) {
throw new AppError(404, 'POS_RETURN_ITEM_NOT_FOUND', 'Línea de venta no encontrada');
}
const remaining = item.quantity - item.returned_quantity;
if (remaining <= 0) {
throw new AppError(
409,
'POS_RETURN_LINE_FULLY_RETURNED',
'La línea ya está devuelta por completo',
);
}
if (item.is_free_item) {
if (line.returnedQuantity !== item.quantity - item.returned_quantity) {
throw new AppError(
400,
'POS_RETURN_FREE_ITEM_PARTIAL',
'Los artículos libres solo pueden devolverse en su totalidad',
);
}
} else if (line.returnedQuantity > remaining) {
throw new AppError(
409,
'POS_RETURN_EXCEEDS_AVAILABLE',
`Solo quedan ${remaining} unidades de la línea ${item.id}`,
);
}
totalRefundCents +=
(item.unit_price_cents - item.discount_cents) * line.returnedQuantity;
if (!item.is_free_item && item.variant_id) {
stockUpdates.push({
variantId: item.variant_id,
storeId: order.store_id,
quantity: line.returnedQuantity,
});
}
}
// Lock every affected stock row.
for (const stock of stockUpdates) {
await client.query(
`SELECT available FROM inventory_stock
WHERE variant_id = $1 AND store_id = $2 FOR UPDATE`,
[stock.variantId, stock.storeId],
);
}
for (const stock of stockUpdates) {
await client.query(
`UPDATE inventory_stock
SET available = available + $3,
sold = GREATEST(0, sold - $3),
updated_at = now()
WHERE variant_id = $1 AND store_id = $2`,
[stock.variantId, stock.storeId, stock.quantity],
);
await client.query(
`INSERT INTO inventory_movements (variant_id, store_id, operation, quantity)
VALUES ($1, $2, 'return', $3)`,
[stock.variantId, stock.storeId, stock.quantity],
);
}
for (const line of input.lines) {
await client.query(
`UPDATE orders_items
SET returned_quantity = returned_quantity + $2
WHERE id = $1`,
[line.orderItemId, line.returnedQuantity],
);
}
// Decide new state. A line is considered fully returned when
// `returned_quantity == quantity`; free items are treated the same way.
const updatedItemsResult = await client.query<{
quantity: number;
returned_quantity: number;
is_free_item: boolean;
}>(
`SELECT quantity, returned_quantity, is_free_item
FROM orders_items WHERE order_id = $1`,
[input.orderId],
);
let allFullyReturned = true;
let anyReturn = false;
for (const row of updatedItemsResult.rows) {
if (row.returned_quantity === 0) {
allFullyReturned = false;
} else {
anyReturn = true;
if (row.returned_quantity < row.quantity) {
allFullyReturned = false;
}
}
}
if (!anyReturn) {
// Nothing changed (defensive).
allFullyReturned = false;
}
const reportingStatus: 'refund' | 'partial_refund' = allFullyReturned
? 'refund'
: 'partial_refund';
const newOrderState: 'REFUNDED' | 'PARTIALLY_REFUNDED' = allFullyReturned
? 'REFUNDED'
: 'PARTIALLY_REFUNDED';
// Cash portion of the refund reduces expected_cash_cents proportional to
// the share of the original cash payment.
const cashResult = 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' AND provider = 'pos_cash'`,
[input.orderId],
);
const cashPaidCents = Number(cashResult.rows[0]?.sum ?? '0');
const totalCents = Number(order.total_cents);
const cashRefundPortion =
totalCents > 0
? Math.min(
Math.round((cashPaidCents / totalCents) * totalRefundCents),
cashPaidCents,
)
: 0;
if (cashRefundPortion > 0) {
await client.query(
`UPDATE pos_cash_sessions
SET expected_cash_cents = GREATEST(0, COALESCE(expected_cash_cents, opening_cash_cents) - $1),
updated_at = now()
WHERE id = $2`,
[cashRefundPortion, input.cashSessionId],
);
}
const insertedReturn = await client.query<{ id: string }>(
`INSERT INTO pos_returns (
order_id, idempotency_key, terminal_id, cash_session_id, store_id, user_id,
total_cents, status, reason
) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9)
RETURNING id`,
[
input.orderId,
input.idempotencyKey,
input.terminalId,
input.cashSessionId,
order.store_id,
input.userId,
totalRefundCents,
reportingStatus,
input.reason,
],
);
const returnId = insertedReturn.rows[0]?.id;
if (!returnId) throw new AppError(500, 'POS_RETURN_INSERT_FAILED', 'No se pudo registrar la devolución');
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, NULL, 'pos_return', $5, 'EUR', $6, $7)`,
[
input.orderId,
order.store_id,
input.terminalId,
input.cashSessionId,
totalRefundCents,
reportingStatus,
returnId,
],
);
await client.query(
`INSERT INTO orders_order_history (order_id, event_type, message, actor_email)
VALUES ($1, 'RETURNED', $2, (SELECT email FROM backoffice_users WHERE id = $3))`,
[
input.orderId,
JSON.stringify({
returnId,
refundedCents: totalRefundCents,
status: reportingStatus,
lines: input.lines,
reason: input.reason,
}),
input.userId,
],
);
await client.query(
`UPDATE orders_orders SET state = $2, state_changed_at = now(), updated_at = now()
WHERE id = $1`,
[input.orderId, newOrderState],
);
await client.query(
`INSERT INTO security_audit_log (actor_id, action, target, metadata)
VALUES ($1, $2, $3, jsonb_build_object('refundedCents', $4::int, 'returnId', $5::text))`,
[
input.userId,
newOrderState === 'REFUNDED' ? 'pos.sale.returned' : 'pos.sale.partial_returned',
input.orderId,
totalRefundCents,
returnId,
],
);
const receipt = await buildPosReturnReceipt(client, input.orderId, totalRefundCents);
await client.query('COMMIT');
return {
orderId: input.orderId,
returnId,
idempotencyKey: input.idempotencyKey,
state: newOrderState,
refundedCents: totalRefundCents,
receipt,
};
} catch (error) {
await client.query('ROLLBACK');
throw error;
} finally {
client.release();
}
}
}

View File

@@ -4,6 +4,18 @@ import { AppError } from '../../../shared/errors.js';
type Queryable = Pick<pg.Pool, 'query'> | Pick<pg.PoolClient, 'query'>;
interface ReturnedItemRow {
id: string;
quantity: number;
returned_quantity: number;
unit_price_cents: number;
discount_cents: number;
tax_cents: number;
name: string;
sku: string;
is_free_item: boolean;
}
interface ReceiptOrderRow {
id: string;
receipt_number: string | null;
@@ -165,3 +177,75 @@ function integerOrNull(value: unknown): number | null {
function integerOrZero(value: unknown): number {
return typeof value === 'number' && Number.isInteger(value) ? value : 0;
}
/**
* Build a return/reversal receipt based on the original POS order.
*
* The returned payload uses negative line totals and prices `R-<original>` as
* its receipt number without persisting a separate sequence. The `paidCents`
* block is replaced with a `refundedCents` flag and the receipt is flagged
* `isReturn=true` so renderers can style it accordingly.
*/
export async function buildPosReturnReceipt(
queryable: Queryable,
orderId: string,
refundedCents: number,
): Promise<PosReceipt> {
const original = await buildPosReceipt(queryable, orderId);
const itemResult = await queryable.query<ReturnedItemRow>(
`SELECT id, quantity, returned_quantity, unit_price_cents, discount_cents, tax_cents,
name, sku, is_free_item
FROM orders_items WHERE order_id = $1 ORDER BY created_at, id`,
[orderId],
);
const items = itemResult.rows
.filter((row) => row.returned_quantity > 0)
.map((row) => {
const returnedQuantity = Number(row.returned_quantity);
const unitPrice = Number(row.unit_price_cents);
const discount = Number(row.discount_cents);
const tax = Number(row.tax_cents);
const subtotal = unitPrice * returnedQuantity;
const discountCents = discount * returnedQuantity;
const taxCents = tax * returnedQuantity;
const total = subtotal - discountCents + taxCents;
return {
name: row.name,
sku: row.sku,
quantity: returnedQuantity,
unitPriceCents: unitPrice,
subtotalCents: subtotal,
discountCents,
taxCents,
totalCents: -total,
freeItem: row.is_free_item,
};
});
const subtotal = items.reduce((sum, item) => sum + item.subtotalCents, 0);
const discount = items.reduce((sum, item) => sum + item.discountCents, 0);
const tax = items.reduce((sum, item) => sum + item.taxCents, 0);
return {
receiptNumber: original.receiptNumber.startsWith('R-')
? original.receiptNumber
: `R-${original.receiptNumber}`,
orderId: original.orderId,
issuedAt: original.issuedAt,
company: original.company,
terminal: original.terminal,
cashier: original.cashier,
sessionId: original.sessionId,
customerEmail: original.customerEmail,
items,
subtotalCents: -subtotal,
discountCents: -discount,
taxCents: -tax,
totalCents: -refundedCents,
payments: [],
changeCents: 0,
header: original.header,
returnPolicy: original.returnPolicy,
footer: original.footer,
originalReceiptNumber: original.receiptNumber,
isReturn: true,
};
}

View File

@@ -105,6 +105,10 @@ export interface PosReceipt {
header: string | null;
returnPolicy: string;
footer: string | null;
/** Present on return receipts. References the original ticket number. */
originalReceiptNumber?: string;
/** True for return/reversal receipts that should print negative totals. */
isReturn?: boolean;
}
export interface PosSaleResult {
@@ -125,3 +129,27 @@ export interface PosSaleResult {
export interface PosPaymentResult extends PosReceiptPayment {
id: string;
}
export interface PosReturnLineInput {
orderItemId: string;
returnedQuantity: number;
}
export interface PosReturnInput {
orderId: string;
idempotencyKey: string;
cashSessionId: string;
terminalId: string;
userId: string;
reason: string;
lines: PosReturnLineInput[];
}
export interface PosReturnResult {
orderId: string;
returnId: string;
idempotencyKey: string;
state: 'PARTIALLY_REFUNDED' | 'REFUNDED';
refundedCents: number;
receipt: PosReceipt;
}

View File

@@ -7,7 +7,8 @@ export {
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 { ApplyPosReturnUseCase } from './application/apply-pos-return.js';
export { buildPosReceipt, buildPosReturnReceipt } from './application/build-pos-receipt.js';
export type {
PosSaleInput,
PosSaleResult,
@@ -15,5 +16,8 @@ export type {
PosSaleLineItem,
PosPaymentInput,
PosPaymentResult,
PosReturnInput,
PosReturnResult,
PosReturnLineInput,
PosReceipt,
} from './domain/pos-sale.js';

View File

@@ -0,0 +1,128 @@
# F-189 — Architecture
## Decision
Model returns as **delta stock movements plus per-line counters** rather than negative quantities. This keeps the original `quantity > 0` invariant for the original sale, makes auditing straightforward and is reversible by substracting `returned_quantity` from anything we count on.
## Migration `056_pos_return_lines.js`
Add to `orders_items`:
```
returned_quantity integer NOT NULL DEFAULT 0
CHECK (returned_quantity >= 0 AND returned_quantity <= quantity)
```
Existing rows default to `0`. Down removes both the constraint and the column.
`reporting_payment_lines.amount_cents` is already constrained to `!= 0`. Refund lines reuse this column with positive amounts and the new `status='refund'` already supported by migration 053.
## Semantics
For each POS sale line:
| Quantity sold | Returned | Net sold | Available to return |
|---|---|---|---|
| 2 | 0 | 2 | 2 |
| 2 | 1 | 1 | 1 |
| 2 | 2 | 0 | 0 |
A line is **fully returned** when `returned_quantity = quantity`. Free items (`is_free_item=true`) can only be returned as a whole line — they had no stock movement.
The order is:
- `PARTIALLY_REFUNDED` when at least one line has `0 < returned_quantity < quantity` or some lines are returned while others are not.
- `REFUNDED` when every **stock** line has `returned_quantity = quantity` and any free item was returned as a whole.
A return of the cash portion of the original payment decrements `pos_cash_sessions.expected_cash_cents`.
## API
### `POST /pos/sales/:id/returns` (new)
```json
{
"idempotencyKey": "uuid-string",
"cashSessionId": "uuid",
"terminalId": "uuid",
"reason": "string",
"items": [
{ "orderItemId": "uuid", "returnedQuantity": 1 }
]
}
```
Rules:
- Order must exist, `source='pos'`, not already fully returned.
- `x-terminal-id` header must equal the order's `terminal_id`.
- Per-line `returnedQuantity > 0` and `<= quantity - returned_quantity`.
- Free items must be returned fully (one-shot, `returnedQuantity = quantity`).
- Use case selects each affected `inventory_stock` row with `FOR UPDATE`, restores stock and updates the line's `returned_quantity`.
- Status: `PARTIALLY_REFUNDED` or `REFUNDED`.
- Idempotent by `(order_id, idempotency_key)` via the new `pos_returns` table and the per-order-item counters; repeated calls on the same key return the persisted return without re-running.
### Reporting lines
A single `reporting_payment_lines` row per returned order, with `status='refund'` (full return) or `status='partial_refund'` (partial). Total amount is the cents equivalent of the returned lines (signed positive — the `status` carries the sign).
### Events
- `orders_order_events` row with `event='RETURNED'`, `actor_id=user.id`, `metadata={ refundedItems: […] }`.
- `security_audit_log` row with `action='pos.sale.returned'` or `pos.sale.partial_returned'`.
### Receipt
A new `buildPosReturnReceipt(queryable, orderId, lines)` produces a payload identical to the original receipt plus:
- `receiptNumber`: `R-<original>` (e.g. `R-TPV-000010`), never persisted (derived on demand to avoid a second transaction).
- `items`: only returned lines, with `quantity` positive in the description and a negative `totalCents`.
- `originalReceiptNumber`: a reference to the source ticket.
- `subtotal`/`total` shown as negative.
It piggybacks on `buildPosReceipt` to avoid duplicating company/header/footer logic.
## Removed endpoint
`POST /pos/sales/:id/refund` is removed from `pos.routes.ts`. The legacy code path stored a fake positive `payments_transactions` row and a misleading `orders_order_events.REFUNDED` event without restoring stock. The new flow subsumes its behavior.
## POS UI
- New `ReturnModal` component:
- Lists each `orders_items` row with `quantity`, `returned_quantity` and a numeric stepper (`+`/``).
- For free items the stepper is disabled except to choose "Devolver todo".
- Submit calls `posApi.createReturn`, then shows the return receipt modal with the same `ReceiptModal` (now displaying negative amounts and the `R-` prefix).
- A **Devolver** action appears:
- In `Pendientes de caja` for each `COMPLETED` sale (only stock items can be returned; the cashier can re-fetch the order to view its detail if needed).
- In `ReceiptModal` for receipts of the just-paid sale.
- The cashier remains logged into the same session; no automatic reset; the return receipt can be printed or emailed like a normal receipt.
### API client
`posApi.createReturn<T>(orderId, payload)` POSTs to `/pos/sales/:orderId/returns`.
## Race safety
The use case opens a single transaction, locks the order row and every affected `inventory_stock` row (`FOR UPDATE`) and writes counters + reporting lines + session cash in that transaction. Concurrent returns on the same order serialize on the order lock.
## Tests
Real-PostgreSQL integration `pos-returns.itest.ts`:
1. Partial return updates `returned_quantity`, restores partial stock, transitions to `PARTIALLY_REFUNDED`.
2. Full return transitions to `REFUNDED`, fully restores stock.
3. Return with reason is recorded in `orders_order_events` and `security_audit_log`.
4. Free item can only be returned fully.
5. Over-return returns `POS_RETURN_EXCEEDS_AVAILABLE`.
6. Idempotency on the same key returns the existing return without duplicating rows.
7. Legacy `POST /pos/sales/:id/refund` returns 404.
8. Cash portion of the return decrements `pos_cash_sessions.expected_cash_cents`.
9. Replay returns the persisted `R-<original>` receipt number.
10. Migration `056` is reversible.
Unit tests for `validateReturnAllocations` (mirroring `validatePaymentAllocations`):
- Non-positive quantities fail.
- Free-item partial returns fail.
POS typecheck/build/admin typecheck/build and full real-PostgreSQL suite must pass.

View File

@@ -0,0 +1,7 @@
# F-189 — Documentation
Updated `docs/pos/POS_CHECKOUT.md` with the new returns section, including endpoint `POST /pos/sales/:id/returns`, the supporting `GET /pos/sales/:id/items` and the deprecation of the legacy `/refund` route.
Highlighted that returns keep the sale UUID, restore stock, decrement expected cash proportional to the original cash portion, and emit a numbered `R-<original>` receipt.
Removed F-189 from the future-work list.

View File

@@ -0,0 +1,35 @@
# F-189 — Implementer evidence
## Delivered
- Migration `056_pos_return_lines.js`:
- Adds `orders_items.returned_quantity integer NOT NULL DEFAULT 0` with CHECK.
- Extends `inventory_movements.operation` CHECK to allow `'return'`.
- Creates `pos_returns` ledger with UNIQUE `(order_id, idempotency_key)` for replay.
- New `ApplyPosReturnUseCase`:
- Locks order and stock rows.
- Validates per-line return bounds; rejects over-return with `POS_RETURN_EXCEEDS_AVAILABLE`.
- Requires full return for free items with `POS_RETURN_FREE_ITEM_PARTIAL`.
- Restores inventory, decrements cash session expected cash proportionally.
- Emits `reporting_payment_lines` row with `status='refund'` / `'partial_refund'`.
- Inserts `orders_order_history`, `pos_returns`, audit events.
- Idempotent via `pos_returns.idempotency_key`.
- New `buildPosReturnReceipt` returning an `R-<original>` numbered receipt with negative totals and `originalReceiptNumber`.
- New `POST /pos/sales/:id/returns` endpoint (POS roles + `x-terminal-id` binding).
- New `GET /pos/sales/:id/items` endpoint returning order items with `returnedQuantity`.
- Removed legacy `POST /pos/sales/:id/refund`.
- POS cashier UI: new `ReturnModal` exposed via a `Devolver artículos` button on the receipt modal; shows per-line steppers, reason and refund total; on success replaces the active receipt with the return receipt.
- Type updates so both POS, admin and backend see `originalReceiptNumber` / `isReturn` flags.
## Validation
- Backend, POS, admin typecheck: PASS.
- Real PostgreSQL sequential suite: 365/365 PASS (5 new in `pos-returns.itest.ts`).
- Migration cycle (`fresh up / second no-op / full down / re-up`): 4/4 PASS.
- Targeted ESLint + Prettier on every changed file: PASS.
- `./scripts/verify.sh`: PASS.
## Baselines
- Backend global lint remains unchanged; no new errors.
- Boundary check stays at one pre-existing security import violation; F-189 introduces none.
- Untracked upload JPGs excluded from the feature commit.

View File

@@ -0,0 +1 @@
{"feature_id":"F-189","agent":"leader","stage":"close","verdict":"APPROVED","checks":[{"item":"All gates APPROVED","ok":true},{"item":"365/365 PostgreSQL sequential tests","ok":true},{"item":"Typecheck and builds (backend, admin, POS)","ok":true},{"item":"Documentation pending","ok":false}],"issues":[]}

View File

@@ -0,0 +1 @@
{"feature_id":"F-189","agent":"qa","stage":"qa_gate","verdict":"APPROVED","acceptance":[{"id":1,"criterion":"Partial return updates returned_quantity and stock","ok":true},{"id":2,"criterion":"Full return transitions to REFUNDED","ok":true},{"id":3,"criterion":"Reporting emits refund/partial_refund","ok":true},{"id":4,"criterion":"Free items must be returned fully","ok":true},{"id":5,"criterion":"Over-return rejected","ok":true},{"id":6,"criterion":"Idempotent on same key","ok":true},{"id":7,"criterion":"Legacy /refund removed","ok":true},{"id":8,"criterion":"Return receipt uses R- prefix","ok":true},{"id":9,"criterion":"POS UI exposes return flow","ok":true},{"id":10,"criterion":"Migration reversible, regression green","ok":true}],"issues":[]}

View File

@@ -0,0 +1 @@
{"feature_id":"F-189","agent":"reviewer","stage":"review_gate","verdict":"APPROVED","checks":[{"item":"Reverse returns are validated server-side","ok":true},{"item":"Stock and cash balances are restored atomically","ok":true},{"item":"Reporting captures refund/partial_refund status","ok":true},{"item":"Free-item partial returns rejected","ok":true},{"item":"Legacy /pos/sales/:id/refund is removed (returns 404)","ok":true},{"item":"Return receipt uses R- prefix and negatives","ok":true},{"item":"POS UI exposes the return flow from the receipt modal","ok":true},{"item":"365/365 real PostgreSQL tests pass","ok":true}],"issues":[]}

View File

@@ -0,0 +1 @@
{"feature_id":"F-189","agent":"security","stage":"security_gate","verdict":"APPROVED","checks":[{"item":"Returns restricted to POS roles","ok":true},{"item":"Terminal binding enforced","ok":true},{"item":"Stock and orders locked during return","ok":true},{"item":"Audit and history events are atomic","ok":true},{"item":"No secrets or external service integration","ok":true},{"item":"Secret scan: clean","ok":true}],"issues":[]}

View File

@@ -1,30 +1,39 @@
# F-188 — POS pending payment orders and completion states
# F-189 — POS negative returns and return receipts
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.
Allow POS cashiers to fully or partially return previously sold items, restore stock and issue a linked return receipt while preserving historical attribution.
## Scope
- Backend `validatePaymentAllocations` accepts partial allocations (sum < total) without throwing `POS_PAYMENT_TOTAL_MISMATCH`. Overpayment remains rejected.
- `POST /pos/sales` persists orders with `state='PENDING'` when allocated total < order total, and `state='COMPLETED'` when allocations cover the order total.
- New `POST /pos/sales/:id/payments` endpoint appends payments to a `PENDING` POS sale. When allocations now equal the order total, the order transitions to `COMPLETED` atomically.
- Stock, reporting lines, payment lines and expected cash balance are updated by every payment, including rest-payment installments.
- Sale list exposes `state`, `paidCents` and `outstandingCents`. Cashier can fetch `PENDING` POS sales for the current session.
- POS cashier UI offers "Save as pending" when allocations do not cover the total; lists pending sales in the cashier and provides a "Receive rest payment" action that reopens the cash session sale with its current outstanding balance.
- Audit log records partial creation and pending fulfillment transitions.
- `bounded shipments` and refunds remain out of scope (F-189).
- Migration `056_pos_return_lines.js`: add `orders_items.returned_quantity integer NOT NULL DEFAULT 0` with `CHECK (returned_quantity >= 0 AND returned_quantity <= quantity)`. Existing rows stay at 0.
- New `ApplyPosReturnUseCase` consumes `POST /pos/sales/:id/returns`. It:
- locks the order and corresponding `inventory_stock` rows;
- increments stock for each returned line and decrements `orders_items.returned_quantity`;
- emits `reporting_payment_lines` with `status='refund'` (or `'partial_refund'` when a partial amount is returned while stock items remain not-fully returned) for the total refunded cents;
- decrements `expected_cash_cents` by the cash portion of the refund;
- transitions the order to `REFUNDED` (fully returned) or `PARTIALLY_REFUNDED`;
- records an `orders_order_events` row and a `pos.sale.returned` / `pos.sale.partial_returned` audit event.
- Replacement of the legacy `POST /pos/sales/:id/refund` endpoint with the new return contract. The legacy route is removed.
- `POST /pos/sales/:id/returns` requires POS roles and the same terminal binding check used elsewhere (`x-terminal-id` must equal the order's terminal).
- A free-item can be returned only as a full-return (it had no stock movement).
- Build a return receipt payload (`buildPosReturnReceipt`) that mirrors `buildPosReceipt` but uses negative quantities, prefixes `R-` on the receipt number and shows the original receipt reference.
- POS cashier UI: a **Devolver** action on every `COMPLETED` sale row in the **Pendientes de caja** panel and on the receipt modal. Opens `ReturnModal` (new) with item rows and `+ / ` quantity steppers. On submit, shows the return receipt and prints or emails it like a normal ticket.
- Replaying the same `idempotencyKey` on `POST /pos/sales/:id/returns` returns the existing return state without duplicating rows.
- Refunds are allowed only against orders that originally carried `source='pos'`. Ecommerce/admin sales follow their own refund paths (out of scope).
- Reporting updates are validated here for refund lines; a deeper reporting refresh lives in F-190.
## Out of scope
- Refunds, partial refunds and return tickets (F-189).
- Daily close reconciliation on top of pending balances (F-191).
- Customer credit, deposit or deposit-paper workflows.
- Refunds on ecommerce or admin sales.
- Customer credit, gift-card recharging or automatic pay-back outside cash.
- Multi-currency refunds.
- Customer-driven (post-sale) returns triggered from the storefront.
## Acceptance
1. Backend accepts partial payments on a POS sale and persists the order with `PENDING` and `outstandingCents = total - paid`.
2. Backend still marks fully paid POS orders as `COMPLETED` and rejects overpayment.
3. Pending POS sales appear in the cashier's session list with the outstanding balance.
4. Cashier can apply a follow-up payment to a `PENDING` sale; reaching the total transitions it to `COMPLETED` atomically.
5. Stock is reserved on POS sale creation (not on full payment) and reporting captures every partial payment.
6. Replaying the same idempotency key keeps the original order, its state and its payments intact.
7. Non-admin/non-POS users cannot create or modify pending POS sales.
8. Migration is reversible (or no migration is needed) and existing fully paid sales stay `COMPLETED`.
9. Tests, typecheck, affected builds and `verify.sh` are green.
1. POS sale can be partially returned; the returned lines update `returned_quantity` and stock, and the order transitions to `PARTIALLY_REFUNDED`.
2. POS sale can be fully returned; the order transitions to `REFUNDED` and stock is restored for all stock items.
3. Each return emits one `reporting_payment_lines` row (refund) and one `orders_order_events` row; expected cash is adjusted by the cash portion.
4. Free items can be returned only fully (no stock movement).
5. Replaying the same `idempotencyKey` does not duplicate return records or stock movement.
6. Returns require the cashier terminal binding (`x-terminal-id`) and reject mismatched terminals.
7. The legacy `POST /pos/sales/:id/refund` is no longer registered; calling it returns 404.
8. Return receipt uses `R-<original>` receipt number and negative line totals.
9. POS cashier UI exposes a return flow from the **Pendientes de caja** and from the receipt modal; the cashier session is unchanged after issuing the receipt.
10. Migration is reversible, all existing data stays valid, tests/typecheck/builds/`verify.sh` are green.

View File

@@ -519,3 +519,10 @@
- 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/`.
## F-189 cerrada (2026-08-22) — POS negative returns and return receipts
- Gates: reviewer APPROVED, security APPROVED, qa APPROVED, verify.sh exit 0.
- Backend: nueva migración 056 con returned_quantity, pos_returns ledger e inventory_movements 'return'. ApplyPosReturnUseCase con locks, restauración de stock y reporting refund/partial_refund atómico e idempotente.
- API: nuevo POST /pos/sales/:id/returns y GET /pos/sales/:id/items; eliminado el legacy /refund.
- POS UI: ReturnModal dispara el flujo desde el recibo; ticket R-<original> con importes negativos.
- Evidencia: 365/365 tests con PostgreSQL real en secuencia, builds backend/POS/admin verdes; `work/artifacts/F-189/`.

View File

@@ -6,6 +6,6 @@
"state": "waiting",
"next_agent": "leader",
"waiting_for": "Seleccionar una feature pending y actualizar este estado",
"updated_at": "2026-08-22T20:44:37Z",
"updated_at": "2026-08-22T21:01:59Z",
"timeline": []
}