Retail Return Module
Feature: 014-exchange-return | Backend module:
apps/backend/src/retail-return/
Overview
The Retail Return module handles return and exchange operations for retail sales in FlowPOS. It manages the full lifecycle from draft creation through inventory reconciliation.
- Returns: Customer returns items from a previous sale → refund is issued
- Exchanges: Customer returns items and purchases new ones → net settlement
Both transaction types are stored as rows in the sale table with type = 'return' or type = 'exchange', linked to the original sale via parent_sale_id.
Architecture
retail-return/
├── domain/ # Ports & type definitions
│ ├── return-transaction-repository.domain.ts
│ ├── return-policy-repository.domain.ts
│ ├── return-reason-repository.domain.ts
│ ├── approval-audit-log-repository.domain.ts
│ ├── return-transaction.types.ts
│ └── exchange-transaction.types.ts
├── application/ # Business logic (use cases)
│ ├── return-transaction.service.ts # Return flow orchestration
│ ├── exchange-transaction.service.ts # Exchange flow orchestration
│ ├── return-line-builder.service.ts # Shared: line creation & validation
│ ├── sale-validation.service.ts # Shared: sale lookup & policy checks
│ ├── return-inventory.service.ts # Inventory ledger & stock updates
│ ├── return-policy.service.ts # Policy CRUD & resolution
│ ├── return-reason.service.ts # Reason CRUD & uniqueness
│ └── approval.service.ts # Manager PIN verification & audit
├── infrastructure/ # Kysely repository implementations
│ ├── return-transaction.repository.ts
│ ├── return-policy.repository.ts
│ ├── return-reason.repository.ts
│ └── approval-audit-log.repository.ts
└── interfaces/ # HTTP controllers, DTOs, queries
├── return-transaction.controller.ts
├── exchange-transaction.controller.ts
├── return-reason.controller.ts
├── return-policy.controller.ts
├── dtos/ (10 DTOs)
└── query/ (2 query parameter classes)
Shared Services
Two services extract logic that was previously duplicated between ReturnTransactionService and ExchangeTransactionService:
| Service | Responsibility |
|---|---|
ReturnLineBuilderService | Validates return quantities, looks up reasons, determines restock action, calculates refund amounts, builds SaleLineItem objects, recalculates totals |
SaleValidationService | Looks up original sales by receipt/ID, validates business ownership and sale status, checks return windows, validates no-receipt policy |
Domain Concepts
Transaction Types
| Type | sale.type | Description |
|---|---|---|
| Return | return | Items returned → refund issued |
| Exchange | exchange | Items returned + new items purchased → net settlement |
Transaction Lifecycle
draft → completed (committed successfully)
draft → cancelled (cancelled by user or timeout)
Item Condition & Restock Action
| Condition | Reason affectsRestocking | Restock Action | Stock Bucket |
|---|---|---|---|
resellable | any | restock | quantity |
damaged | any | quarantine | quarantined |
opened | true | restock | quantity |
opened | false | scrap | (no stock update) |
Policy Resolution
Policies are resolved with location-specific precedence:
- Active policy matching
businessId+locationId(if provided) - Active business-wide policy (
locationId IS NULL) - Most recently created if multiple match
API Endpoints
Returns (/returns)
| Method | Path | Description |
|---|---|---|
POST | /returns | Initiate a draft return |
POST | /returns/:id/lines | Add a return line item |
GET | /returns/:id/refund-calculation | Calculate refund breakdown |
GET | /returns/:id/requires-approval | Check approval requirement |
POST | /returns/:id/commit | Finalize return (atomic) |
GET | /returns/:id | Get return details |
DELETE | /returns/:id | Cancel draft return |
GET | /returns/sales/:receiptNumber/lookup | Look up original sale |
Exchanges (/exchanges)
| Method | Path | Description |
|---|---|---|
POST | /exchanges | Initiate a draft exchange |
POST | /exchanges/:id/return-lines | Add return line (item out) |
POST | /exchanges/:id/sale-lines | Add sale line (item in) |
GET | /exchanges/:id/settlement | Calculate net settlement |
GET | /exchanges/:id/requires-approval | Check approval requirement |
POST | /exchanges/:id/commit | Finalize exchange (atomic) |
GET | /exchanges/:id | Get exchange details |
DELETE | /exchanges/:id | Cancel draft exchange |
Return Reasons (/return-reasons)
| Method | Path | Description |
|---|---|---|
POST | /return-reasons | Create reason (code unique per business) |
GET | /return-reasons | List with pagination/search |
GET | /return-reasons/:id | Get by ID |
PATCH | /return-reasons/:id | Update |
DELETE | /return-reasons/:id | Soft delete |
Return Policies (/return-policies)
| Method | Path | Description |
|---|---|---|
POST | /return-policies | Create policy |
GET | /return-policies | List with pagination |
GET | /return-policies/applicable | Resolve applicable policy |
GET | /return-policies/:id | Get by ID |
PATCH | /return-policies/:id | Update |
DELETE | /return-policies/:id | Soft delete |
Concurrency & Data Integrity
The commit flow uses several mechanisms to prevent data corruption:
- Pre-validation outside the transaction (fast-fail, no locks held)
- Status re-check inside the transaction (prevents double-commit)
FOR UPDATErow lock on the original sale (serializes concurrent returns)- Quantity re-validation under lock (prevents over-returning)
- Idempotency keys on inventory ledger entries (prevents duplicate stock updates)
Store Credit Events
Store credit issuance is emitted as an event after the DB transaction commits, ensuring the credit is only created if the return succeeds.
Key Design Decisions
-
JSON-based line items: Return/exchange lines are stored in
sale.sale_detailJSON alongside original sale lines, discriminated bylineType: 'return' | 'sale'. This avoids schema proliferation while maintaining auditability. -
Reusing the
saletable: Returns and exchanges are stored assalerows withtype = 'return' | 'exchange'and aparent_sale_idFK. This simplifies reporting and cash register session reconciliation. -
Policy-driven behaviour: Return windows, approval thresholds, refund method rules, and cross-location behaviour are all configurable per business/location via
return_policy. -
Soft deletes: Return reasons and policies use
active = falserather than physical deletion to preserve referential integrity.