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

129 lines
5.7 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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