Files
mercadodevida/work/artifacts/F-189/architect.md
2026-08-22 23:02:00 +02:00

5.7 KiB
Raw Blame History

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)

{
  "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.