5.7 KiB
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_REFUNDEDwhen at least one line has0 < returned_quantity < quantityor some lines are returned while others are not.REFUNDEDwhen every stock line hasreturned_quantity = quantityand 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)
{
"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-idheader must equal the order'sterminal_id.- Per-line
returnedQuantity > 0and<= quantity - returned_quantity. - Free items must be returned fully (one-shot,
returnedQuantity = quantity). - Use case selects each affected
inventory_stockrow withFOR UPDATE, restores stock and updates the line'sreturned_quantity. - Status:
PARTIALLY_REFUNDEDorREFUNDED. - Idempotent by
(order_id, idempotency_key)via the newpos_returnstable 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_eventsrow withevent='RETURNED',actor_id=user.id,metadata={ refundedItems: […] }.security_audit_logrow withaction='pos.sale.returned'orpos.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, withquantitypositive in the description and a negativetotalCents.originalReceiptNumber: a reference to the source ticket.subtotal/totalshown 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
ReturnModalcomponent:- Lists each
orders_itemsrow withquantity,returned_quantityand 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 sameReceiptModal(now displaying negative amounts and theR-prefix).
- Lists each
- A Devolver action appears:
- In
Pendientes de cajafor eachCOMPLETEDsale (only stock items can be returned; the cashier can re-fetch the order to view its detail if needed). - In
ReceiptModalfor receipts of the just-paid sale.
- In
- 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:
- Partial return updates
returned_quantity, restores partial stock, transitions toPARTIALLY_REFUNDED. - Full return transitions to
REFUNDED, fully restores stock. - Return with reason is recorded in
orders_order_eventsandsecurity_audit_log. - Free item can only be returned fully.
- Over-return returns
POS_RETURN_EXCEEDS_AVAILABLE. - Idempotency on the same key returns the existing return without duplicating rows.
- Legacy
POST /pos/sales/:id/refundreturns 404. - Cash portion of the return decrements
pos_cash_sessions.expected_cash_cents. - Replay returns the persisted
R-<original>receipt number. - Migration
056is 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.