# 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-` (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(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-` 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.