feat(F-189): completed feature
This commit is contained in:
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
|
||||
|
||||
- Backend `validatePaymentAllocations` accepts partial allocations (sum < total) without throwing `POS_PAYMENT_TOTAL_MISMATCH`. Overpayment remains rejected.
|
||||
- `POST /pos/sales` persists orders with `state='PENDING'` when allocated total < order total, and `state='COMPLETED'` when allocations cover the order total.
|
||||
- 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.
|
||||
- Stock, reporting lines, payment lines and expected cash balance are updated by every payment, including rest-payment installments.
|
||||
- Sale list exposes `state`, `paidCents` and `outstandingCents`. Cashier can fetch `PENDING` POS sales for the current session.
|
||||
- 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.
|
||||
- Audit log records partial creation and pending fulfillment transitions.
|
||||
- `bounded shipments` and refunds remain out of scope (F-189).
|
||||
- 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.
|
||||
- New `ApplyPosReturnUseCase` consumes `POST /pos/sales/:id/returns`. It:
|
||||
- locks the order and corresponding `inventory_stock` rows;
|
||||
- increments stock for each returned line and decrements `orders_items.returned_quantity`;
|
||||
- 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;
|
||||
- decrements `expected_cash_cents` by the cash portion of the refund;
|
||||
- transitions the order to `REFUNDED` (fully returned) or `PARTIALLY_REFUNDED`;
|
||||
- records an `orders_order_events` row and a `pos.sale.returned` / `pos.sale.partial_returned` audit event.
|
||||
- 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
|
||||
- Refunds, partial refunds and return tickets (F-189).
|
||||
- Daily close reconciliation on top of pending balances (F-191).
|
||||
- Customer credit, deposit or deposit-paper workflows.
|
||||
- Refunds on ecommerce or admin sales.
|
||||
- Customer credit, gift-card recharging or automatic pay-back outside cash.
|
||||
- Multi-currency refunds.
|
||||
- Customer-driven (post-sale) returns triggered from the storefront.
|
||||
|
||||
## Acceptance
|
||||
1. Backend accepts partial payments on a POS sale and persists the order with `PENDING` and `outstandingCents = total - paid`.
|
||||
2. Backend still marks fully paid POS orders as `COMPLETED` and rejects overpayment.
|
||||
3. Pending POS sales appear in the cashier's session list with the outstanding balance.
|
||||
4. Cashier can apply a follow-up payment to a `PENDING` sale; reaching the total transitions it to `COMPLETED` atomically.
|
||||
5. Stock is reserved on POS sale creation (not on full payment) and reporting captures every partial payment.
|
||||
6. Replaying the same idempotency key keeps the original order, its state and its payments intact.
|
||||
7. Non-admin/non-POS users cannot create or modify pending POS sales.
|
||||
8. Migration is reversible (or no migration is needed) and existing fully paid sales stay `COMPLETED`.
|
||||
9. Tests, typecheck, affected builds and `verify.sh` are green.
|
||||
1. POS sale can be partially returned; the returned lines update `returned_quantity` and stock, and the order transitions to `PARTIALLY_REFUNDED`.
|
||||
2. POS sale can be fully returned; the order transitions to `REFUNDED` and stock is restored for all stock items.
|
||||
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. Free items can be returned only fully (no stock movement).
|
||||
5. Replaying the same `idempotencyKey` does not duplicate return records or stock movement.
|
||||
6. Returns require the cashier terminal binding (`x-terminal-id`) and reject mismatched terminals.
|
||||
7. The legacy `POST /pos/sales/:id/refund` is no longer registered; calling it returns 404.
|
||||
8. Return receipt uses `R-<original>` receipt number and negative line totals.
|
||||
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.
|
||||
- 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/`.
|
||||
|
||||
## 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",
|
||||
"next_agent": "leader",
|
||||
"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": []
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user