feat(F-189): completed feature
This commit is contained in:
@@ -7257,13 +7257,15 @@
|
|||||||
"description": "Accept validated negative quantities to cancel purchases fully or partially and issue linked return receipts.",
|
"description": "Accept validated negative quantities to cancel purchases fully or partially and issue linked return receipts.",
|
||||||
"priority": "high",
|
"priority": "high",
|
||||||
"risk": "high",
|
"risk": "high",
|
||||||
"status": "pending",
|
"status": "done",
|
||||||
"created_at": "2026-08-22",
|
"created_at": "2026-08-22",
|
||||||
"gates": {
|
"gates": {
|
||||||
"reviewer": false,
|
"reviewer": true,
|
||||||
"security": false,
|
"security": true,
|
||||||
"qa": false
|
"qa": true,
|
||||||
}
|
"close": true
|
||||||
|
},
|
||||||
|
"completed_at": "2026-08-22T21:01:59Z"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"id": "F-190",
|
"id": "F-190",
|
||||||
|
|||||||
@@ -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.
|
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
|
## 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-190: auditoría completa de actualización/refresco de reporting.
|
||||||
- F-191: cierre de terminal y cierre diario conciliando efectivo, tarjetas, devoluciones y pendientes.
|
- F-191: cierre de terminal y cierre diario conciliando efectivo, tarjetas, devoluciones y pendientes.
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import DiscountPanel from '@/components/DiscountPanel';
|
|||||||
import FreeItemModal from '@/components/FreeItemModal';
|
import FreeItemModal from '@/components/FreeItemModal';
|
||||||
import PaymentModal from '@/components/PaymentModal';
|
import PaymentModal from '@/components/PaymentModal';
|
||||||
import ReceiptModal from '@/components/ReceiptModal';
|
import ReceiptModal from '@/components/ReceiptModal';
|
||||||
|
import ReturnModal from '@/components/ReturnModal';
|
||||||
import { posApi } from '@/lib/api-client';
|
import { posApi } from '@/lib/api-client';
|
||||||
import { generateIdempotencyKey } from '@/lib/idempotency';
|
import { generateIdempotencyKey } from '@/lib/idempotency';
|
||||||
import { formatPrice } from '@/lib/money';
|
import { formatPrice } from '@/lib/money';
|
||||||
@@ -112,6 +113,9 @@ export default function RegisterPage() {
|
|||||||
const [loadingPending, setLoadingPending] = useState(false);
|
const [loadingPending, setLoadingPending] = useState(false);
|
||||||
const [restPaymentFor, setRestPaymentFor] = useState<PosPendingSale | null>(null);
|
const [restPaymentFor, setRestPaymentFor] = useState<PosPendingSale | null>(null);
|
||||||
const [processingRest, setProcessingRest] = useState(false);
|
const [processingRest, setProcessingRest] = useState(false);
|
||||||
|
const [returnOrder, setReturnOrder] = useState<{ orderId: string; receipt: PosReceipt } | null>(
|
||||||
|
null,
|
||||||
|
);
|
||||||
|
|
||||||
const loadConfig = useCallback(async () => {
|
const loadConfig = useCallback(async () => {
|
||||||
setConfigError('');
|
setConfigError('');
|
||||||
@@ -1163,6 +1167,26 @@ export default function RegisterPage() {
|
|||||||
receipt={receipt}
|
receipt={receipt}
|
||||||
initialEmail={customer?.email ?? receipt.customerEmail ?? ''}
|
initialEmail={customer?.email ?? receipt.customerEmail ?? ''}
|
||||||
onDelivered={resetCashier}
|
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>
|
</div>
|
||||||
|
|||||||
@@ -9,12 +9,14 @@ interface ReceiptModalProps {
|
|||||||
receipt: PosReceipt;
|
receipt: PosReceipt;
|
||||||
initialEmail?: string;
|
initialEmail?: string;
|
||||||
onDelivered: () => void;
|
onDelivered: () => void;
|
||||||
|
onReturn?: (orderId: string, receipt: PosReceipt) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function ReceiptModal({
|
export default function ReceiptModal({
|
||||||
receipt,
|
receipt,
|
||||||
initialEmail = '',
|
initialEmail = '',
|
||||||
onDelivered,
|
onDelivered,
|
||||||
|
onReturn,
|
||||||
}: ReceiptModalProps) {
|
}: ReceiptModalProps) {
|
||||||
const [email, setEmail] = useState(initialEmail);
|
const [email, setEmail] = useState(initialEmail);
|
||||||
const [sending, setSending] = useState(false);
|
const [sending, setSending] = useState(false);
|
||||||
@@ -166,6 +168,15 @@ export default function ReceiptModal({
|
|||||||
>
|
>
|
||||||
🖨️ Imprimir ticket
|
🖨️ Imprimir ticket
|
||||||
</button>
|
</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">
|
<form onSubmit={emailReceipt} className="flex gap-2">
|
||||||
<label className="sr-only" htmlFor="receipt-email">
|
<label className="sr-only" htmlFor="receipt-email">
|
||||||
Email del ticket
|
Email del ticket
|
||||||
|
|||||||
233
project/apps/pos/src/components/ReturnModal.tsx
Normal file
233
project/apps/pos/src/components/ReturnModal.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -66,6 +66,15 @@ export const posApi = {
|
|||||||
method: 'POST',
|
method: 'POST',
|
||||||
body: JSON.stringify(data),
|
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. */
|
/** List POS sales for the session, optionally filtered by state. */
|
||||||
listSales: <T>(params?: { state?: 'PENDING' | 'COMPLETED'; sessionId?: string }) => {
|
listSales: <T>(params?: { state?: 'PENDING' | 'COMPLETED'; sessionId?: string }) => {
|
||||||
const qs = new URLSearchParams();
|
const qs = new URLSearchParams();
|
||||||
|
|||||||
@@ -61,6 +61,8 @@ export interface PosReceipt {
|
|||||||
header: string | null;
|
header: string | null;
|
||||||
returnPolicy: string;
|
returnPolicy: string;
|
||||||
footer: string | null;
|
footer: string | null;
|
||||||
|
originalReceiptNumber?: string;
|
||||||
|
isReturn?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface PosSaleResponse {
|
export interface PosSaleResponse {
|
||||||
|
|||||||
94
project/migrations/056_pos_return_lines.js
Normal file
94
project/migrations/056_pos_return_lines.js
Normal 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 $$
|
||||||
|
`);
|
||||||
|
};
|
||||||
338
project/src/app/tests/pos-returns.itest.ts
Normal file
338
project/src/app/tests/pos-returns.itest.ts
Normal 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);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -13,6 +13,7 @@ import { OpenCashSessionUseCase } from '../application/open-cash-session.js';
|
|||||||
import { CloseCashSessionUseCase } from '../application/close-cash-session.js';
|
import { CloseCashSessionUseCase } from '../application/close-cash-session.js';
|
||||||
import { CreatePosSaleUseCase } from '../application/create-pos-sale.js';
|
import { CreatePosSaleUseCase } from '../application/create-pos-sale.js';
|
||||||
import { ReceiveRestPaymentUseCase } from '../application/receive-rest-payment.js';
|
import { ReceiveRestPaymentUseCase } from '../application/receive-rest-payment.js';
|
||||||
|
import { ApplyPosReturnUseCase } from '../application/apply-pos-return.js';
|
||||||
import { buildPosReceipt } from '../application/build-pos-receipt.js';
|
import { buildPosReceipt } from '../application/build-pos-receipt.js';
|
||||||
import { sendTransactionalEmail } from '../../notifications/index.js';
|
import { sendTransactionalEmail } from '../../notifications/index.js';
|
||||||
import { Argon2PasswordHasher } from '../../identity/index.js';
|
import { Argon2PasswordHasher } from '../../identity/index.js';
|
||||||
@@ -64,6 +65,7 @@ export async function registerPosRoutes(app: FastifyInstance, deps: PosRouteDeps
|
|||||||
const sessionRepo = new PgCashSessionRepository(pool);
|
const sessionRepo = new PgCashSessionRepository(pool);
|
||||||
const createPosSale = new CreatePosSaleUseCase(pool);
|
const createPosSale = new CreatePosSaleUseCase(pool);
|
||||||
const receiveRestPayment = new ReceiveRestPaymentUseCase(pool);
|
const receiveRestPayment = new ReceiveRestPaymentUseCase(pool);
|
||||||
|
const applyPosReturn = new ApplyPosReturnUseCase(pool);
|
||||||
|
|
||||||
const listStores = new ListStoresUseCase(storeRepo);
|
const listStores = new ListStoresUseCase(storeRepo);
|
||||||
const listTerminals = new ListTerminalsUseCase(terminalRepo);
|
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 } }>(
|
app.post<{ Params: { id: string } }>(
|
||||||
'/pos/sales/:id/refund',
|
'/pos/sales/:id/returns',
|
||||||
{
|
{
|
||||||
schema: {
|
schema: {
|
||||||
tags: ['POS Terminal'],
|
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' } } },
|
params: { type: 'object', properties: { id: { type: 'string', format: 'uuid' } } },
|
||||||
body: {
|
body: {
|
||||||
type: 'object',
|
type: 'object',
|
||||||
required: ['refundAmountCents', 'reason'],
|
required: ['idempotencyKey', 'cashSessionId', 'terminalId', 'reason', 'items'],
|
||||||
properties: {
|
properties: {
|
||||||
refundAmountCents: { type: 'integer', minimum: 1 },
|
idempotencyKey: { type: 'string', minLength: 1, maxLength: 128 },
|
||||||
reason: { type: 'string', minLength: 1 },
|
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,
|
} as FastifySchema,
|
||||||
},
|
},
|
||||||
async (request, reply) => {
|
async (request, reply) => {
|
||||||
const user = await authenticate(request);
|
const user = await authenticate(request);
|
||||||
requireAnyRole(user, ['admin', 'pos_manager', 'pos_cashier'] as ReadonlyArray<Role>);
|
requireAnyRole(user, ['admin', 'pos_manager', 'pos_cashier'] as ReadonlyArray<Role>);
|
||||||
const { id } = request.params;
|
const { id } = request.params;
|
||||||
const body = (request.body ?? {}) as { refundAmountCents?: number; reason?: string };
|
const body = parseJson(
|
||||||
const order = await pool.query<{ id: string; total_cents: number }>(
|
z.object({
|
||||||
'SELECT id, total_cents FROM orders_orders WHERE id = $1',
|
idempotencyKey: z.string().min(1).max(128),
|
||||||
[id],
|
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');
|
const boundTerminalId = request.headers['x-terminal-id'];
|
||||||
if ((body.refundAmountCents ?? 0) > (order.rows[0].total_cents ?? 0))
|
if (typeof boundTerminalId !== 'string' || boundTerminalId !== body.terminalId) {
|
||||||
throw new AppError(400, 'REFUND_EXCEEDS', 'Refund exceeds amount');
|
throw new AppError(
|
||||||
await pool.query(
|
403,
|
||||||
`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_TERMINAL_MISMATCH',
|
||||||
[
|
'La devolución no pertenece al terminal vinculado',
|
||||||
'pos_refund',
|
);
|
||||||
`ref-${id}`,
|
}
|
||||||
`ref-${Date.now()}`,
|
const result = await applyPosReturn.execute({
|
||||||
id,
|
orderId: id,
|
||||||
body.refundAmountCents,
|
userId: user.id,
|
||||||
'EUR',
|
...body,
|
||||||
'COMPLETED',
|
lines: body.items,
|
||||||
JSON.stringify({ reason: body.reason, by: user.id }),
|
});
|
||||||
],
|
return reply.code(201).send(result);
|
||||||
);
|
|
||||||
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 });
|
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -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 } }>(
|
app.post<{ Params: { id: string } }>(
|
||||||
'/pos/sales/:id/receipt/email',
|
'/pos/sales/:id/receipt/email',
|
||||||
{
|
{
|
||||||
|
|||||||
368
project/src/modules/pos/application/apply-pos-return.ts
Normal file
368
project/src/modules/pos/application/apply-pos-return.ts
Normal 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();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -4,6 +4,18 @@ import { AppError } from '../../../shared/errors.js';
|
|||||||
|
|
||||||
type Queryable = Pick<pg.Pool, 'query'> | Pick<pg.PoolClient, 'query'>;
|
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 {
|
interface ReceiptOrderRow {
|
||||||
id: string;
|
id: string;
|
||||||
receipt_number: string | null;
|
receipt_number: string | null;
|
||||||
@@ -165,3 +177,75 @@ function integerOrNull(value: unknown): number | null {
|
|||||||
function integerOrZero(value: unknown): number {
|
function integerOrZero(value: unknown): number {
|
||||||
return typeof value === 'number' && Number.isInteger(value) ? value : 0;
|
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,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|||||||
@@ -105,6 +105,10 @@ export interface PosReceipt {
|
|||||||
header: string | null;
|
header: string | null;
|
||||||
returnPolicy: string;
|
returnPolicy: string;
|
||||||
footer: string | null;
|
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 {
|
export interface PosSaleResult {
|
||||||
@@ -125,3 +129,27 @@ export interface PosSaleResult {
|
|||||||
export interface PosPaymentResult extends PosReceiptPayment {
|
export interface PosPaymentResult extends PosReceiptPayment {
|
||||||
id: string;
|
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;
|
||||||
|
}
|
||||||
|
|||||||
@@ -7,7 +7,8 @@ export {
|
|||||||
type ValidatedPayment,
|
type ValidatedPayment,
|
||||||
} from './application/create-pos-sale.js';
|
} from './application/create-pos-sale.js';
|
||||||
export { ReceiveRestPaymentUseCase } from './application/receive-rest-payment.js';
|
export { 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 {
|
export type {
|
||||||
PosSaleInput,
|
PosSaleInput,
|
||||||
PosSaleResult,
|
PosSaleResult,
|
||||||
@@ -15,5 +16,8 @@ export type {
|
|||||||
PosSaleLineItem,
|
PosSaleLineItem,
|
||||||
PosPaymentInput,
|
PosPaymentInput,
|
||||||
PosPaymentResult,
|
PosPaymentResult,
|
||||||
|
PosReturnInput,
|
||||||
|
PosReturnResult,
|
||||||
|
PosReturnLineInput,
|
||||||
PosReceipt,
|
PosReceipt,
|
||||||
} from './domain/pos-sale.js';
|
} from './domain/pos-sale.js';
|
||||||
|
|||||||
128
work/artifacts/F-189/architect.md
Normal file
128
work/artifacts/F-189/architect.md
Normal 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.
|
||||||
7
work/artifacts/F-189/documenter.md
Normal file
7
work/artifacts/F-189/documenter.md
Normal 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.
|
||||||
35
work/artifacts/F-189/implementer.md
Normal file
35
work/artifacts/F-189/implementer.md
Normal 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.
|
||||||
1
work/artifacts/F-189/leader-close.json
Normal file
1
work/artifacts/F-189/leader-close.json
Normal 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":[]}
|
||||||
1
work/artifacts/F-189/qa.json
Normal file
1
work/artifacts/F-189/qa.json
Normal 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":[]}
|
||||||
1
work/artifacts/F-189/reviewer.json
Normal file
1
work/artifacts/F-189/reviewer.json
Normal 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":[]}
|
||||||
1
work/artifacts/F-189/security.json
Normal file
1
work/artifacts/F-189/security.json
Normal 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":[]}
|
||||||
@@ -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
|
## Scope
|
||||||
|
- 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.
|
||||||
- Backend `validatePaymentAllocations` accepts partial allocations (sum < total) without throwing `POS_PAYMENT_TOTAL_MISMATCH`. Overpayment remains rejected.
|
- New `ApplyPosReturnUseCase` consumes `POST /pos/sales/:id/returns`. It:
|
||||||
- `POST /pos/sales` persists orders with `state='PENDING'` when allocated total < order total, and `state='COMPLETED'` when allocations cover the order total.
|
- locks the order and corresponding `inventory_stock` rows;
|
||||||
- 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.
|
- increments stock for each returned line and decrements `orders_items.returned_quantity`;
|
||||||
- Stock, reporting lines, payment lines and expected cash balance are updated by every payment, including rest-payment installments.
|
- 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;
|
||||||
- Sale list exposes `state`, `paidCents` and `outstandingCents`. Cashier can fetch `PENDING` POS sales for the current session.
|
- decrements `expected_cash_cents` by the cash portion of the refund;
|
||||||
- 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.
|
- transitions the order to `REFUNDED` (fully returned) or `PARTIALLY_REFUNDED`;
|
||||||
- Audit log records partial creation and pending fulfillment transitions.
|
- records an `orders_order_events` row and a `pos.sale.returned` / `pos.sale.partial_returned` audit event.
|
||||||
- `bounded shipments` and refunds remain out of scope (F-189).
|
- 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
|
## Out of scope
|
||||||
- Refunds, partial refunds and return tickets (F-189).
|
- Refunds on ecommerce or admin sales.
|
||||||
- Daily close reconciliation on top of pending balances (F-191).
|
- Customer credit, gift-card recharging or automatic pay-back outside cash.
|
||||||
- Customer credit, deposit or deposit-paper workflows.
|
- Multi-currency refunds.
|
||||||
|
- Customer-driven (post-sale) returns triggered from the storefront.
|
||||||
|
|
||||||
## Acceptance
|
## Acceptance
|
||||||
1. Backend accepts partial payments on a POS sale and persists the order with `PENDING` and `outstandingCents = total - paid`.
|
1. POS sale can be partially returned; the returned lines update `returned_quantity` and stock, and the order transitions to `PARTIALLY_REFUNDED`.
|
||||||
2. Backend still marks fully paid POS orders as `COMPLETED` and rejects overpayment.
|
2. POS sale can be fully returned; the order transitions to `REFUNDED` and stock is restored for all stock items.
|
||||||
3. Pending POS sales appear in the cashier's session list with the outstanding balance.
|
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. Cashier can apply a follow-up payment to a `PENDING` sale; reaching the total transitions it to `COMPLETED` atomically.
|
4. Free items can be returned only fully (no stock movement).
|
||||||
5. Stock is reserved on POS sale creation (not on full payment) and reporting captures every partial payment.
|
5. Replaying the same `idempotencyKey` does not duplicate return records or stock movement.
|
||||||
6. Replaying the same idempotency key keeps the original order, its state and its payments intact.
|
6. Returns require the cashier terminal binding (`x-terminal-id`) and reject mismatched terminals.
|
||||||
7. Non-admin/non-POS users cannot create or modify pending POS sales.
|
7. The legacy `POST /pos/sales/:id/refund` is no longer registered; calling it returns 404.
|
||||||
8. Migration is reversible (or no migration is needed) and existing fully paid sales stay `COMPLETED`.
|
8. Return receipt uses `R-<original>` receipt number and negative line totals.
|
||||||
9. Tests, typecheck, affected builds and `verify.sh` are green.
|
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.
|
||||||
|
|||||||
@@ -519,3 +519,10 @@
|
|||||||
- Backend: validatePaymentAllocations acepta under-allocation y rechaza overpayments; reporting, payments_transactions y expected_cash_cents acumulan en cada pago.
|
- 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.
|
- 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/`.
|
- 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/`.
|
||||||
|
|||||||
@@ -6,6 +6,6 @@
|
|||||||
"state": "waiting",
|
"state": "waiting",
|
||||||
"next_agent": "leader",
|
"next_agent": "leader",
|
||||||
"waiting_for": "Seleccionar una feature pending y actualizar este estado",
|
"waiting_for": "Seleccionar una feature pending y actualizar este estado",
|
||||||
"updated_at": "2026-08-22T20:44:37Z",
|
"updated_at": "2026-08-22T21:01:59Z",
|
||||||
"timeline": []
|
"timeline": []
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user