Retail Sales FEL Workflow
Retail sales can certify FEL in two ways:
- Immediate path — after the sale is saved,
OnCreateSaleEvent/OnUpdateSaleEventcallFelService.processSaleEventwhensale.generateElectronicTaxDocument === true. - Deferred path (FEL on A/R payment) — when the sale's accounts-receivable invoice becomes fully paid,
apps/backend/src/sale-fel-on-payment/certifies FEL for merchants who opted in under document typeaccountsReceivableReceipt.
The diagram below covers the immediate path. See FEL on A/R payment for the deferred module.
Prerequisites
| Configuration step | Where |
|---|---|
Business has FEL certifier config (felCertifierConfig on business) | Business settings |
Immediate path: payment method has generateElectronicTaxDocument = true on documentPaymentMethod for document type sale | Payment method config |
Deferred path: same flag on document type accountsReceivableReceipt (defaults are all false — fully opt-in) | Payment method config |
Products have unitOfMeasure and type (goods vs. service) set | Product catalog |
Products have correct tax assignments (product_tax rows) | Product catalog |
Unit conventions
| Field | Units |
|---|---|
sale_detail.items[].unitPrice | Major (decimal) |
sale_detail.items[].total | Major |
sale_detail.items[].discountDetail.unitPriceFinal | Minor |
sale_detail.items[].bundleDetail.unitPriceFinal | Major |
sale.total_amount | Major |
Flow
flowchart TD
A([Cashier builds cart\nin PWA SaleForm]) --> B{Payment method has\ngenerateElectronicTaxDocument=true?}
B -- no --> C[POST /sales\ngenerateElectronicTaxDocument=false\nno buyer tax fields required]
B -- yes --> D[Cashier enters buyer NIT\nor leaves blank for CF\nClient-side NIT validation]
D --> E[POST /sales\ngenerateElectronicTaxDocument=true\ntaxId, taxName, taxAddress, taxpayerType\nsaleDetail.items with full product data]
C --> F[SalesService.create\nsale row inserted\nwith saleDetail JSONB]
E --> F
F --> G[OnCreateSaleEvent emitted\nasynchronously via EventEmitter2]
G --> H[FelService.processSaleEvent handler]
H --> I{sale.generateElectronicTaxDocument\n=== true?}
I -- no --> Z([Skip FEL])
I -- yes --> J
J{Business has\nfelCertifierConfig?}
J -- no --> Z
J -- yes --> K
K[Build receiver\nsale.taxId ?? 'CF'\nsale.taxName ?? 'CONSUMIDOR FINAL'\nsale.taxAddress ?? 'CIUDAD'\nsale.taxpayerType ?? 'NIT']
K --> L[toFelInvoiceItemInputFromSaleDetail\nfor each saleDetail.items entry\ngoodOrService from product type\nunitOfMeasure from product uom\ntax rows from product_tax join]
L --> M[buildFelInvoiceItems\nshared pure function\ntax rows, discount lines\nbundle handling, rounding]
M --> N[FelService.certifyDocument\nSerie, Numero, Autorizacion\nFecha_DTE, felCertificationDate\nfelAcknowledgmentOfReceipt]
N -- success --> O[OnDocumentCertifiedEvent emitted]
N -- certifier error --> P[try/catch swallowed\nSentry log\nsale stays in DB\nfelStatus=failed\nfelErrorMessage set]
O --> Q[SalesService.handleOnDocumentCertifiedEvent]
Q --> R[UPDATE sale SET\nfel_authorization\nfel_serial_number\nfel_number\nfel_date_dte\nfel_certification_date\nfel_acknowledgment_of_receipt\nfel_status=certified\nfel_error_message=null]
R --> S[PWA refetches / receives WS update]
S --> T{Print FEL invoice?}
T -- yes --> U[POST /document-print-jobs\ndocumentType=FEL_INVOICE\nsourceKind=sale\nsourceId=saleId]
U --> V[buildFelInvoicePayloadFromSale\nloads sale + business + location\nfrom sale.saleDetail JSONB\nassembles certifier fields]
V --> W([FEL invoice printed])
T -- no --> W2([Done])
Event bus vs. inline persistence
Unlike the restaurant bill flow (which persists FEL data inline inside certifyPaidBillIfRequired), the retail flow uses two event bus hops:
OnCreateSaleEvent/OnUpdateSaleEvent→ triggersprocessSaleEventwhich runscertifyDocumentOnDocumentCertifiedEvent→ triggersSalesService.handleOnDocumentCertifiedEvent, which writes the certified FEL fields and marksfelStatus=certified
This means there is a brief window after the sale is saved where felStatus may still be pending or processing. The certified sale fields are written by the sales module, not by the provider adapter. In the current retail sale handler, felAuthorization, serial/number/date fields, acknowledgment, felStatus, and felErrorMessage are updated; do not rely on retail sale.isExportedToFel or sale.felUuid being set by this event.
After certification: do not mutate the sale
Once hasValidDte(sale) is true (certified and a non-empty felAuthorization), the sale is fiscally frozen. Totals, lines, tax identity, currency, location, sale date, and cost snapshot cannot change. Adjust with a credit note (nota de crédito), not PATCH /sales/:id or discount/bundle endpoints.
Cancellation still sets status to CANCELLED; that column is excluded from the freeze. Details, HTTP codes, and the optional expectedUpdatedAt pin: Sales module — certified-sale immutability.
PWA output gate
Certification is async. The cashier must not print or share a sale as if it were a signed DTE while felStatus is still pending, and must not edit a sale whose SAT XML already exists. Those are two different gates that share hasValidDte:
| Concern | Where | Effect |
|---|---|---|
| Fiscal freeze | Backend sale-mutation.guard.ts / useSaleIsFiscallyFrozen | Document body is read-only after a valid DTE |
| Output gate | PWA useSaleFelGate + GET /sales/:id/fel-status | Print Preview / Options / Share stay locked until a valid DTE (or a recorded manager override) |
SaleForm mounts one useSaleFelGate instance (not per tab) so polling and toasts survive tab switches. Toolbar, checkout, and modals read that same result.
When the gate is on
Entity parameter REQUIRE_FEL_CERTIFICATE_FOR_SALE_OUTPUT (business, boolean). Missing row → true (requireFelCertificateForSaleOutput in entityParameter.ts). When true, every sale — including ones that never requested FEL — stays locked until hasValidDte or a manager override. When false, non-FEL sales are not-applicable and print is unlocked.
Polling
GET /sales/:id/fel-status?businessId= is a narrow projection (Cache-Control: no-store). A sale in another business returns 404 (existence is not leaked). Query key: ["sale", saleId, "fel-status"]. staleTime is 0.
The cert clock starts when generateElectronicTaxDocument becomes true — not when the draft id is assigned — so a long cart does not burn the timeout before Complete Sale.
Backoff (elapsedMs → interval): <10s → 1.5s, <30s → 3s, <60s → 5s, then 10s until 240s, then polling stops. Soft timeout at 60s (slow); hard timeout at 240s (timed-out). Terminal FEL statuses stop polling.
After Complete Sale, primeSaleFelStatusAfterSubmit writes a PENDING snapshot into the same query cache and invalidates it so a stale generate: false draft poll cannot leave print locked against a sale that just requested FEL.
useSaleIsFiscallyFrozen observes that cache with no interval of its own. A seed that already has a DTE freezes immediately; a poll that has not answered yet never unfreezes a seeded certified sale.
Route hydration (isSaleHydrationStale in salePageNavigation.ts) reloads when status, felStatus, felAuthorization, or generateElectronicTaxDocument moved — a history-state snapshot of an open draft must not unlock a sale that has since certified.
Gate states
Discriminated union SaleFelGateState (kind):
kind | Meaning | Print locked? |
|---|---|---|
not-applicable | No snapshot yet, or gate off for a non-FEL sale | no |
certificate-required | Gate on, no DTE, sale did not request FEL | yes |
certifying | FEL requested, still in flight, under 60s | yes |
slow | Still in flight, ≥ 60s; override offered | yes |
timed-out | ≥ 240s; polling stopped | yes |
failed | felStatus === failed; errorSummary is cashier-safe | yes |
warn-only | Device circuit breaker tripped | no |
overridden | This sale’s manager override succeeded | no |
certified | hasValidDte | no |
isBlocking is true only for certificate-required / certifying / slow / timed-out / failed, and only when the entity parameter is on.
Retry and manager override
| Action | Endpoint | Notes |
|---|---|---|
| Retry certification | POST /fel/sales/:id/retry | 400 if already certified; 409 if already processing. Response is almost always still processing — keep polling fel-status. |
| Release output | POST /sales/:id/fel-override | Body: businessId, managerEmployeeId, pin (4–6 digits), reason (max 500). Throttled to 5 / 15 minutes. |
Override is application behavior in SalesService.overrideFelGate:
- Sale must belong to
businessId(404otherwise). hasValidDte→400(“already certified — there is nothing to override”).- Server verifies the manager PIN (
403if invalid). - Manager must have
canApproveVoids(403otherwise). The PWA modal is UX, not enforcement. - Writes
activity_logwithsensitiveEventType: "fel_gate_override". A failed audit write fails the override (no silent unlock). - Does not change
felStatus. Certification can still complete afterwards.
PWA override is per-sale React state (overridden). Reloading the page re-locks unless a DTE has landed or the device is in warn-only.
Device circuit breaker
Three consecutive slow certifications on the same browser (localStorage key flowpos:felGate:circuitBreaker:v1, threshold 3) switch later sales to warn-only so a certifier outage does not stall every register behind a 60s wall. A live hasValidDte on a sale resets the count to 0. Storage failures (private mode / quota) mean the breaker does not persist across reloads.
Output-gate troubleshooting
| Symptom | Check |
|---|---|
| Print stays locked after the DTE lands | Confirm fel-status returns non-empty felAuthorization and felStatus === certified. A certified event with empty authorization is not a valid DTE. |
| Print locked on a sale that never requested FEL | Expected when REQUIRE_FEL_CERTIFICATE_FOR_SALE_OUTPUT is true/unset. Manager override or turn the parameter off. |
Override returns 403 | PIN or canApproveVoids. |
Override returns 400 | Sale already has a valid DTE. |
| Override succeeds then print locks again after reload | Override is not persisted on the sale row. Wait for the DTE, or the device is not in warn-only. |
| Every register unlocked with a warning | Circuit breaker at 3. Storage key above; a certified sale clears it. |
Retry returns 409 | Certification already in progress — poll, do not resubmit immediately. |
PWA work for the gate and hydration reload: PR #752. Backend freeze: PR #750.
Shared item builder
Both the retail and restaurant flows converge on the same pure function:
toFelInvoiceItemInputFromSaleDetail ──┐
├──► buildFelInvoiceItems ──► InvoiceItem[]
toFelInvoiceItemInputFromOrderItem ──┘
buildFelInvoiceItems in apps/backend/src/fel/application/fel-invoice-item.builder.ts contains all tax row construction, line-discount aggregation, bundle handling, and amount rounding. Neither adapter duplicates this logic.
FEL on A/R payment (deferred certification)
Source: apps/backend/src/sale-fel-on-payment/.
Use this path when a credit sale should not receive a DTE at checkout, but should be certified after the A/R invoice is fully paid.
Hexagonal map
| Layer | Codepaths | Responsibility |
|---|---|---|
| Application | application/sale-fel-on-payment.service.ts, application/events/on-accounts-receivable-invoice-paid.handler.ts | Decide whether a paid sale invoice is eligible; set sale FEL flags; call FelService.processSaleEvent |
| Infrastructure | infrastructure/sale-fel-on-payment.processor.ts | Bull queue worker for the hourly sweep |
| Module wiring | sale-fel-on-payment.module.ts, sale-fel-on-payment.constants.ts | Register queue, repeatable cron job, providers |
Domain FEL document building remains in FelService / the FEL provider port. This module only owns the when of certification for paid retail credit sales.
Opt-in configuration
Deferred certification re-reads document_payment_method at payment time:
documentType = accountsReceivableReceipt(DocumentType.ACCOUNTS_RECEIVABLE_RECEIPT)generateElectronicTaxDocument = true- matching one of the payment methods used on the posted receipt tenders
Seed defaults for accountsReceivableReceipt set generateElectronicTaxDocument: false for every method, so existing businesses are unchanged until a merchant enables the flag in Settings → Payment Methods.
Do not confuse this with the immediate-path config on document type sale. A typical "certify only after payment" setup is:
- disable
generateElectronicTaxDocumentfor the A/R payment method on document typesale(so checkout does not certify) - enable
generateElectronicTaxDocumentfor the settlement methods on document typeaccountsReceivableReceipt
Entry points
| Entry point | Trigger | Role |
|---|---|---|
OnAccountsReceivableInvoicePaidHandler | OnAccountsReceivableInvoicePaidEvent when entityType === "sale" | Low-latency fast path |
SaleFelOnPaymentProcessor job certify-paid-sales | Queue sale-fel-on-payment, cron 0 * * * * | Source of truth / crash recovery |
Both call SaleFelOnPaymentService.certifyForPaidInvoice. The service never throws to callers: failures are logged and the sale is marked felStatus=failed when possible.
Eligibility and guards
The sweep selects A/R invoices where:
entityType = "sale"status = "paid"- joined sale
felStatusispending,processing, orfailed - batch size
200, ordered by invoiceupdatedAtascending
Per-invoice guards inside certifyForPaidInvoice:
- sale must exist and
businessIdmust match the invoice hasValidDte(sale)short-circuits — no duplicate DTE- non-stale
processingsales are skipped (STALE_PROCESSING_MSfromFelService) - zero visible receipt tenders → return (sweep retries later)
- no opted-in receipt payment method → return
Issue date and sale flags
Before calling FelService.processSaleEvent:
- set
generateElectronicTaxDocument: true,felStatus: pending, clearfelErrorMessage - pass
{ issueDate: latestPaymentDate ?? sale.createdAt }so the DTE issue date reflects payment when tenders are available
Certified fields are still persisted by the sales module via OnDocumentCertifiedEvent, same as the immediate path.
Critical pitfall: use invoice.entityId, not invoice.saleId
Retail A/R rows written by AccountsReceivableInvoicesService.processSaleEvent store the originating sale id in entityId with entityType: "sale". They do not populate saleId (that column is used by platform-billing / subscriptions for a separately generated sale).
Always pass invoice.entityId into certification. Using invoice.saleId for retail credit sales will look up the wrong row or none at all.
Troubleshooting deferred FEL
| Symptom | Check |
|---|---|
| Paid credit sale never certifies | Confirm accountsReceivableReceipt payment-method flag is true for a tender used on the receipt |
| Sale certifies at checkout instead of on payment | Immediate path is still enabled (sale.generateElectronicTaxDocument / document type sale config) |
| Event fired but no DTE yet | Inspect tenders visibility and wait for the hourly sweep (sale-fel-on-payment / certify-paid-sales) |
Stuck processing | Stale processing older than STALE_PROCESSING_MS is retried by the deferred service |
| Wrong sale or missing sale | Confirm callers use entityId, not saleId |
| Duplicate certification attempts | Expected to no-op via hasValidDte once a valid DTE exists |