Skip to main content

Retail Sales FEL Workflow

Retail sales can certify FEL in two ways:

  1. Immediate path — after the sale is saved, OnCreateSaleEvent / OnUpdateSaleEvent call FelService.processSaleEvent when sale.generateElectronicTaxDocument === true.
  2. 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 type accountsReceivableReceipt.

The diagram below covers the immediate path. See FEL on A/R payment for the deferred module.

Prerequisites​

Configuration stepWhere
Business has FEL certifier config (felCertifierConfig on business)Business settings
Immediate path: payment method has generateElectronicTaxDocument = true on documentPaymentMethod for document type salePayment 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) setProduct catalog
Products have correct tax assignments (product_tax rows)Product catalog

Unit conventions​

FieldUnits
sale_detail.items[].unitPriceMajor (decimal)
sale_detail.items[].totalMajor
sale_detail.items[].discountDetail.unitPriceFinalMinor
sale_detail.items[].bundleDetail.unitPriceFinalMajor
sale.total_amountMajor

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:

  1. OnCreateSaleEvent / OnUpdateSaleEvent → triggers processSaleEvent which runs certifyDocument
  2. OnDocumentCertifiedEvent → triggers SalesService.handleOnDocumentCertifiedEvent, which writes the certified FEL fields and marks felStatus=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:

ConcernWhereEffect
Fiscal freezeBackend sale-mutation.guard.ts / useSaleIsFiscallyFrozenDocument body is read-only after a valid DTE
Output gatePWA useSaleFelGate + GET /sales/:id/fel-statusPrint 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):

kindMeaningPrint locked?
not-applicableNo snapshot yet, or gate off for a non-FEL saleno
certificate-requiredGate on, no DTE, sale did not request FELyes
certifyingFEL requested, still in flight, under 60syes
slowStill in flight, ≥ 60s; override offeredyes
timed-out≥ 240s; polling stoppedyes
failedfelStatus === failed; errorSummary is cashier-safeyes
warn-onlyDevice circuit breaker trippedno
overriddenThis sale’s manager override succeededno
certifiedhasValidDteno

isBlocking is true only for certificate-required / certifying / slow / timed-out / failed, and only when the entity parameter is on.

Retry and manager override​

ActionEndpointNotes
Retry certificationPOST /fel/sales/:id/retry400 if already certified; 409 if already processing. Response is almost always still processing — keep polling fel-status.
Release outputPOST /sales/:id/fel-overrideBody: businessId, managerEmployeeId, pin (4–6 digits), reason (max 500). Throttled to 5 / 15 minutes.

Override is application behavior in SalesService.overrideFelGate:

  1. Sale must belong to businessId (404 otherwise).
  2. hasValidDte → 400 (“already certified — there is nothing to override”).
  3. Server verifies the manager PIN (403 if invalid).
  4. Manager must have canApproveVoids (403 otherwise). The PWA modal is UX, not enforcement.
  5. Writes activity_log with sensitiveEventType: "fel_gate_override". A failed audit write fails the override (no silent unlock).
  6. 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​

SymptomCheck
Print stays locked after the DTE landsConfirm 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 FELExpected when REQUIRE_FEL_CERTIFICATE_FOR_SALE_OUTPUT is true/unset. Manager override or turn the parameter off.
Override returns 403PIN or canApproveVoids.
Override returns 400Sale already has a valid DTE.
Override succeeds then print locks again after reloadOverride is not persisted on the sale row. Wait for the DTE, or the device is not in warn-only.
Every register unlocked with a warningCircuit breaker at 3. Storage key above; a certified sale clears it.
Retry returns 409Certification 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​

LayerCodepathsResponsibility
Applicationapplication/sale-fel-on-payment.service.ts, application/events/on-accounts-receivable-invoice-paid.handler.tsDecide whether a paid sale invoice is eligible; set sale FEL flags; call FelService.processSaleEvent
Infrastructureinfrastructure/sale-fel-on-payment.processor.tsBull queue worker for the hourly sweep
Module wiringsale-fel-on-payment.module.ts, sale-fel-on-payment.constants.tsRegister 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:

  1. disable generateElectronicTaxDocument for the A/R payment method on document type sale (so checkout does not certify)
  2. enable generateElectronicTaxDocument for the settlement methods on document type accountsReceivableReceipt

Entry points​

Entry pointTriggerRole
OnAccountsReceivableInvoicePaidHandlerOnAccountsReceivableInvoicePaidEvent when entityType === "sale"Low-latency fast path
SaleFelOnPaymentProcessor job certify-paid-salesQueue 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 felStatus is pending, processing, or failed
  • batch size 200, ordered by invoice updatedAt ascending

Per-invoice guards inside certifyForPaidInvoice:

  • sale must exist and businessId must match the invoice
  • hasValidDte(sale) short-circuits — no duplicate DTE
  • non-stale processing sales are skipped (STALE_PROCESSING_MS from FelService)
  • zero visible receipt tenders → return (sweep retries later)
  • no opted-in receipt payment method → return

Issue date and sale flags​

Before calling FelService.processSaleEvent:

  1. set generateElectronicTaxDocument: true, felStatus: pending, clear felErrorMessage
  2. 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​

SymptomCheck
Paid credit sale never certifiesConfirm accountsReceivableReceipt payment-method flag is true for a tender used on the receipt
Sale certifies at checkout instead of on paymentImmediate path is still enabled (sale.generateElectronicTaxDocument / document type sale config)
Event fired but no DTE yetInspect tenders visibility and wait for the hourly sweep (sale-fel-on-payment / certify-paid-sales)
Stuck processingStale processing older than STALE_PROCESSING_MS is retried by the deferred service
Wrong sale or missing saleConfirm callers use entityId, not saleId
Duplicate certification attemptsExpected to no-op via hasValidDte once a valid DTE exists