Sales Module
Overview
The Sales module manages the full lifecycle of retail sale documents. It handles creation, retrieval, update, deletion, PDF/print generation, public link sharing, line-level and cart-level discounts, bundle operations, and electronic tax document (FEL) certification. Per-line item notes from the POS appear on sale PDFs, thermal receipts, and the public QR validate page when present.
Architecture
The module follows Hexagonal Architecture with four layers:
sales/
├── domain/
│ ├── sales-repository.domain.ts # Repository port (interface + injection token)
│ ├── sale-mutation.guard.ts # Fiscal freeze + version-pin predicates
│ └── sale-item.types.ts # SaleDetailItem, SaleDetailPayload types
├── application/
│ ├── sales.service.ts # Core orchestrator (CRUD, events, FEL status/override)
│ ├── sale-discount.service.ts # Line & cart discount operations
│ ├── sale-bundle.service.ts # Bundle evaluate/apply/remove/combo
│ ├── sale-document.service.ts # PDF, preview, public link, template data
│ ├── sale-pricing.service.ts # Price list resolution (feature-flagged)
│ └── events/
│ ├── on-create-sale.event.ts
│ └── on-update-sale.event.ts
├── infrastructure/
│ └── sales.repository.ts # Kysely DB adapter (implements ISalesRepository)
└── interfaces/
├── sales.controller.ts # REST controller
├── dtos/
│ ├── create-sale.dto.ts
│ ├── update-sale.dto.ts
│ ├── apply-sale-discount.dto.ts
│ ├── apply-sale-bundle.dto.ts
│ ├── generate-sale-link.dto.ts
│ └── sale-fel-override.dto.ts
└── query/
└── paginate-sales.query.ts
Domain owns freeze predicates and the version token (isSaleFiscallyFrozen, assertNoFrozenFieldChanges, getSaleVersionToken). Application services decide which operations are field-level vs blanket. Infrastructure implements the compare-and-swap UPDATE. Controllers/DTOs expose expectedUpdatedAt and HTTP 400 / 409.
Dependency Flow
Controller → SalesService → SaleDiscountService
→ SaleBundleService
→ SaleDocumentService
→ ISalesRepository (port)
↓
SalesRepository (adapter)
Domain Concepts
| Concept | Description |
|---|---|
| Sale | A retail transaction document with items, payments, customer info, and tax data |
| SaleDetail | JSON field containing the items array (line items with product, quantity, price, tax, optional per-line notes) |
| PaymentDetail | JSON field containing payment method entries |
| CartDiscountDetail | JSON field for cart-level discount snapshots |
| CostDetail | JSON snapshot of inventory costs at time of sale creation |
| Session linking | Sales are automatically linked to the cashier's open cash register session |
Sale Status Flow
draft → submitted → reviewed → completed
→ cancelled
→ voided
Sale Types
sale— Standard salereturn— Return/credit noteexchange— Exchange transaction
API Endpoints
CRUD
| Method | Path | Description |
|---|---|---|
| POST | /sales | Create a sale |
| GET | /sales | List sales (paginated, filtered) |
| GET | /sales/:id | Get sale by ID |
| PATCH | /sales/:id | Update a sale. Optional expectedUpdatedAt pins the write; frozen fiscal fields are refused after SAT certification (see below) |
| DELETE | /sales/:id | Delete a sale |
Document Generation
| Method | Path | Description |
|---|---|---|
| GET | /sales/:id/pdf | Download PDF (binary) |
| GET | /sales/:id/pdf/content | Get PDF as base64 |
| GET | /sales/:id/print | HTML print preview |
| POST | /sales/:id/public-link | Create time-limited signed URL |
| GET | /sales/:id/template-data | Get template rendering data |
Discounts
| Method | Path | Description |
|---|---|---|
| POST | /sales/:id/items/discount | Apply line-level discount |
| DELETE | /sales/:id/items/discount | Remove line-level discount |
| POST | /sales/:id/discount | Apply cart-level discount |
| DELETE | /sales/:id/discount | Remove cart-level discount |
Bundles
| Method | Path | Description |
|---|---|---|
| GET | /sales/:id/bundles/evaluate | Evaluate eligible bundles |
| POST | /sales/:id/bundles/apply | Apply bundle to existing items |
| DELETE | /sales/:id/bundles/remove | Remove bundle application |
| POST | /sales/:id/bundles/add-combo | Add combo bundle (atomic) |
FEL status and output override
| Method | Path | Description |
|---|---|---|
| GET | /sales/:id/fel-status | Narrow pollable FEL projection (businessId required). Cache-Control: no-store. 404 if the sale is missing or in another business |
| POST | /sales/:id/fel-override | Manager PIN + canApproveVoids release of the PWA print/share gate. Does not change felStatus. 5 requests / 15 minutes |
Retry of a failed/pending certification is POST /fel/sales/:id/retry on the FEL controller, not /sales. Cashier polling, lock states, and the device circuit breaker: Retail sales FEL workflow — PWA output gate.
Key Behaviors
Sale Creation
- Resolves default currency from business if not provided
- Generates a document number (sequence)
- Validates product variant references
- Validates header total matches line item totals
- Groups items by location/product and fetches inventory costs
- Creates cost detail snapshot
- Auto-links to open cash register session (or validates explicit sessionId)
- Emits
sale.createevent
Discount Application
- Line-level: Applied to a specific item by index. Recomputes item amount, taxes, and document totals.
- Cart-level: Applied to the whole sale. Stored in
cartDiscountDetailcolumn. - Stacking order: price_list → price_rule → discount_rule → manual → override
- Both operations create audit records via
DiscountsService.
Bundle Operations
- Evaluate: Pre-checks eligible bundles for untagged sale items
- Apply: Tags items with
bundleApplicationId, applies savings to first item, creates audit record - Remove: Restores original prices from DB, clears bundle fields, deletes audit record
- Combo builder: Atomically adds new items from component selections and applies the bundle
- Cascade cleanup: When a sale update removes items belonging to a bundle, the bundle is automatically removed and prices restored
FEL Integration
The service listens for OnDocumentCertifiedEvent and updates the sale record with electronic tax document data (authorization number, serial, dates).
Certified-sale immutability (fiscal freeze)
Once SAT has signed a DTE, the sale row that claims to be that document must stop moving, or reprints, reports, and reconciliations disagree with the XML. This is domain behavior in sale-mutation.guard.ts; HTTP status codes and DTO fields are the interface adapter.
A sale is fiscally frozen when hasValidDte(sale) is true:
felStatus === certified- and
felAuthorizationis a non-empty string after trim
felStatus === certified alone is not enough. A certifier can return an empty result while the backend still emits a certified event; that sale has no legal document to protect and must stay retryable.
Frozen columns (FISCALLY_FROZEN_SALE_FIELDS) are the values reproduced inside the DTE:
| Frozen | Allowed after certification |
|---|---|
saleDetail, cartDiscountDetail, totalAmount, totalBaseAmount | status (cancellation sets CANCELLED) |
taxId, taxName, taxAddress, taxpayerType, customerId | documentLink, documentCommunication |
currencyId, currencyCode, exchangeRate | the fel* columns themselves |
locationId, saleDate, costDetail | updatedBy |
PATCH /sales/:id is field-level. Only an actual change to a frozen column is refused (400). Re-sending the same value is allowed so full-document payloads from existing clients keep working. JSONB comparison is strict: a re-serialized saleDetail with the same data but a different key order counts as a change. Numeric columns compare by value (335 and "335" are the same).
Discount and bundle writes are blanket. POST/DELETE /sales/:id/discount, /sales/:id/items/discount, /sales/:id/bundles/apply, /remove, and /add-combo exist only to rewrite the document body. There is no harmless version of those on a certified sale; they throw 400 with SAT / credit-note (nota de crédito) guidance. GET /sales/:id/bundles/evaluate is a read and is not frozen.
Adjust a certified invoice with a credit note, not by editing the sale. See Retail sales FEL workflow.
Extra location check. Independently of hasValidDte, SalesService.updateSale refuses a locationId change when felStatus === certified (400). That path can fire even if authorization is empty.
Optimistic concurrency (expectedUpdatedAt)
UpdateSaleDTO.expectedUpdatedAt is a write precondition, not a persisted column. The controller strips it from the entity before the repository SET.
| Caller sends | Result |
|---|---|
| Omitted / empty | Write proceeds unconditionally (existing clients keep working; they can still clobber a concurrent edit) |
| Valid ISO 8601 | Compare-and-swap on date_trunc('milliseconds', coalesce(updatedAt, createdAt)) |
| Invalid timestamp | 400 |
| Pin does not match | 409 Sale Version Conflict — reload and reapply; do not resend the same body |
The version token is updatedAt, or createdAt when the sale has never been edited (updatedAt is nullable with no default). Truncation to milliseconds matches JS Date precision: PostgreSQL microseconds never reach the client and cannot be echoed back.
409 body shape from SaleVersionConflictError:
{
"message": "Sale <id> changed since it was read (expected version <iso>, current <iso>). Reload the sale and reapply the change — re-sending this body would discard someone else's edit.",
"error": "Sale Version Conflict",
"statusCode": 409,
"expectedUpdatedAt": "2026-09-13T01:31:23.136Z",
"currentUpdatedAt": "2026-09-13T01:32:00.000Z"
}
The POS client pins through apps/frontend-pwa/src/services/saleVersionRegistry.ts (in-memory per tab). Discount and bundle services pin internally with getSaleVersionToken after the freeze check. A client that omits the pin is still accepted.
The PWA freeze UI (useSaleIsFiscallyFrozen) uses the same hasValidDte predicate as the backend. It shares the fel-status query cache with the print/share gate (useSaleFelGate) so a missing poll cannot unfreeze a sale that already has a DTE in the hydration seed. See Retail sales FEL workflow — PWA output gate.
Shipped in PR #750. PWA gate and hydration reload: PR #752.
Price Lists (Feature-Flagged)
Set ENABLE_PRICE_LISTS=true to enable price list resolution during sale item pricing. When disabled, base product/service prices are used directly.
Query Parameters (GET /sales)
| Parameter | Type | Description |
|---|---|---|
businessId | UUID | Filter by business |
status | string | Filter by sale status |
documentNumber | string | Filter by document number |
serviceBookingId | UUID | Filter by linked service booking |
createdAtFrom / createdAtTo | ISO date | Filter by creation date range |
saleDateFrom / saleDateTo | ISO date | Filter by sale date range |
search | string | Search by taxId, taxName, taxAddress, locationName |
size / page | number | Pagination (default: 10 per page) |
orderBy / order | string | Sort by taxId, taxName, taxAddress, or locationName |
Bruno API Collection
All endpoints are available as Bruno requests in:
api-client/flowpos/collections/sales/
Design Decisions
-
Service decomposition: The main
SalesServicedelegates toSaleDiscountService,SaleBundleService, andSaleDocumentServiceto maintain SRP while keeping a single entry point for the controller. -
Repository injection token: Uses
SALES_REPOSITORYSymbol token for proper port/adapter pattern, allowing the infrastructure implementation to be swapped. -
Snapshot-based pricing: Discounts and bundles store immutable snapshots (
discountDetail,bundleDetail) so the sale record is self-contained and auditable without needing to look up rules at read time. -
Cascade bundle cleanup: When items are removed from a sale that belong to a bundle, the bundle is automatically invalidated and prices restored. This prevents orphaned bundle references.
-
Fiscal freeze vs version pin: Freeze protects SAT XML from diverging from the sale row (
400). The version pin protects two cashiers from overwriting each other (409). They compose: a certified sale still accepts a pinned PATCH that only touchesstatusordocumentLink.