Saltar al contenido principal

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​

ConceptDescription
SaleA retail transaction document with items, payments, customer info, and tax data
SaleDetailJSON field containing the items array (line items with product, quantity, price, tax, optional per-line notes)
PaymentDetailJSON field containing payment method entries
CartDiscountDetailJSON field for cart-level discount snapshots
CostDetailJSON snapshot of inventory costs at time of sale creation
Session linkingSales are automatically linked to the cashier's open cash register session

Sale Status Flow​

draft → submitted → reviewed → completed
→ cancelled
→ voided

Sale Types​

  • sale — Standard sale
  • return — Return/credit note
  • exchange — Exchange transaction

API Endpoints​

CRUD​

MethodPathDescription
POST/salesCreate a sale
GET/salesList sales (paginated, filtered)
GET/sales/:idGet sale by ID
PATCH/sales/:idUpdate a sale. Optional expectedUpdatedAt pins the write; frozen fiscal fields are refused after SAT certification (see below)
DELETE/sales/:idDelete a sale

Document Generation​

MethodPathDescription
GET/sales/:id/pdfDownload PDF (binary)
GET/sales/:id/pdf/contentGet PDF as base64
GET/sales/:id/printHTML print preview
POST/sales/:id/public-linkCreate time-limited signed URL
GET/sales/:id/template-dataGet template rendering data

Discounts​

MethodPathDescription
POST/sales/:id/items/discountApply line-level discount
DELETE/sales/:id/items/discountRemove line-level discount
POST/sales/:id/discountApply cart-level discount
DELETE/sales/:id/discountRemove cart-level discount

Bundles​

MethodPathDescription
GET/sales/:id/bundles/evaluateEvaluate eligible bundles
POST/sales/:id/bundles/applyApply bundle to existing items
DELETE/sales/:id/bundles/removeRemove bundle application
POST/sales/:id/bundles/add-comboAdd combo bundle (atomic)

FEL status and output override​

MethodPathDescription
GET/sales/:id/fel-statusNarrow pollable FEL projection (businessId required). Cache-Control: no-store. 404 if the sale is missing or in another business
POST/sales/:id/fel-overrideManager 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​

  1. Resolves default currency from business if not provided
  2. Generates a document number (sequence)
  3. Validates product variant references
  4. Validates header total matches line item totals
  5. Groups items by location/product and fetches inventory costs
  6. Creates cost detail snapshot
  7. Auto-links to open cash register session (or validates explicit sessionId)
  8. Emits sale.create event

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 cartDiscountDetail column.
  • 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 felAuthorization is 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:

FrozenAllowed after certification
saleDetail, cartDiscountDetail, totalAmount, totalBaseAmountstatus (cancellation sets CANCELLED)
taxId, taxName, taxAddress, taxpayerType, customerIddocumentLink, documentCommunication
currencyId, currencyCode, exchangeRatethe fel* columns themselves
locationId, saleDate, costDetailupdatedBy

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 sendsResult
Omitted / emptyWrite proceeds unconditionally (existing clients keep working; they can still clobber a concurrent edit)
Valid ISO 8601Compare-and-swap on date_trunc('milliseconds', coalesce(updatedAt, createdAt))
Invalid timestamp400
Pin does not match409 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)​

ParameterTypeDescription
businessIdUUIDFilter by business
statusstringFilter by sale status
documentNumberstringFilter by document number
serviceBookingIdUUIDFilter by linked service booking
createdAtFrom / createdAtToISO dateFilter by creation date range
saleDateFrom / saleDateToISO dateFilter by sale date range
searchstringSearch by taxId, taxName, taxAddress, locationName
size / pagenumberPagination (default: 10 per page)
orderBy / orderstringSort by taxId, taxName, taxAddress, or locationName

Bruno API Collection​

All endpoints are available as Bruno requests in:

api-client/flowpos/collections/sales/

Design Decisions​

  1. Service decomposition: The main SalesService delegates to SaleDiscountService, SaleBundleService, and SaleDocumentService to maintain SRP while keeping a single entry point for the controller.

  2. Repository injection token: Uses SALES_REPOSITORY Symbol token for proper port/adapter pattern, allowing the infrastructure implementation to be swapped.

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

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

  5. 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 touches status or documentLink.