FEL Provider Port
Source-backed guide for the FEL/SAT provider pattern in apps/backend/src/fel/.
Use this page when adding or troubleshooting electronic invoicing providers. For product workflows, see the retail and restaurant FEL workflow pages under dev/fel/.
Intent
The FEL module keeps tax-document use cases independent from provider HTTP details. Application services build and validate invoice, credit note, debit note, and cancellation documents; infrastructure adapters handle provider-specific request formats.
This separation matters because Guatemala FEL providers do not expose identical APIs:
- direct certifiers such as
digifactandinfilecertify XML payloads rpafelapiis an intermediary adapter and receives a JSON DTE payload through the same provider port parameter- cancellation can use the standard document certification path or the RPA
voidCertificateendpoint, depending on runtime routing
Do not describe provider URL formats, token refresh behavior, or HTTP retries as domain behavior. Those are adapter concerns.
Hexagonal map
| Layer | Codepaths | Responsibility |
|---|---|---|
| Domain | domain/fel.interface.ts, domain/electronic-certification-provider.interface.ts, repository ports | FEL document shapes, provider contract, validation result types, persistence contracts |
| Application | application/fel.service.ts, fel-credit-note.service.ts, fel-debit-note.service.ts, fel-cancellation.service.ts, provider.service.ts, event handlers | Build documents, choose provider path, enforce use-case rules, emit domain events |
| Infrastructure | infrastructure/provider-digifact.service.ts, provider-infile.service.ts, provider-rpafelapi.service.ts, Kysely repositories | Provider HTTP calls, token handling, XML/JSON transport details, persistence adapters |
| Interfaces | interfaces/fel.controller.ts, DTOs, pagination queries | HTTP routes, Swagger metadata, request parsing |
Provider contract
All provider adapters implement ElectronicCertificationProvider:
export interface ElectronicCertificationProvider {
certifyDocument(
params: IProviderCertifyDocumentParameters,
): Promise<CertifierResponse | undefined>;
getSharedInfo(params: IProviderGetSharedInfoParameters): Promise<unknown>;
}
ProviderService maps provider names to adapters:
| Provider key | Adapter |
|---|---|
digifact | ProviderDigifactService |
infile | ProviderInfileService |
rpafelapi | ProviderRpaFelApiService |
Unknown provider names fail fast with Unknown provider: (<name>).
For merchant-selectable status and direct-vs-RPA constraints, see
FEL Provider Matrix.
ProviderRpaFelApiService also exposes adapter-specific methods that are not part of ElectronicCertificationProvider:
getToken()for RPA token refreshvoidCertificate()for RPA cancellation requests
Keep new cross-provider behavior on the port only when every supported provider can satisfy the contract. Provider-only capabilities should stay behind adapter-specific application routing.
Certification routing
FelService.certifyDocument chooses the certification path before it calls the provider port.
Direct certifier path
When USE_RPA_FEL_API !== "true":
- Load the business by
document.businessData.id. - Extract the certifier config from
business.felCertifierConfig. - Resolve provider API URL from
GuatemalanCertifiersUrls. - Decrypt the configured certifier token.
- Reject expired JWT-style tokens because direct certifier refresh is not implemented in this path.
- Convert the document to XML with
XmlConversionService. - Resolve
document.felProviderData.providerNamethroughProviderService. - Call
provider.certifyDocument({ taxId, xmlContent, apiUrl, token }).
RPAfelApi path
When USE_RPA_FEL_API === "true":
- Build the DTE JSON payload from the document using
XmlToDteJsonService. - Inject certifier credentials into the DTE payload.
- Resolve
rpafelapithroughProviderService. - Pass
JSON.stringify(dteJson)in thexmlContentfield of the provider port.
rpafelapi is an intermediary, not the fiscal certifier itself. The business certifier still determines fiscal provider-specific fields that are placed in the DTE payload.
Shared-info routing
FelService.getSharedInfo follows the same runtime routing flag:
- When
USE_RPA_FEL_API === "true", shared-info requests resolve therpafelapiprovider. - Otherwise, shared-info requests resolve the business certifier provider from
business.felCertifierConfig.
Do not assume NIT lookups always hit the direct certifier. This matters for MCP get_taxpayer_info, which delegates to FelService.getSharedInfo.
Document workflows
Sale invoice certification
Sales events call FelService.processSaleEvent when a sale is submitted or reviewed and sale.generateElectronicTaxDocument === true. On success, the service emits fel.documentCertified; the sales module persists FEL fields on the sale. On failure, the sale flow continues and FEL status is marked failed.
Retail credit sales can also certify after the A/R invoice is fully paid via apps/backend/src/sale-fel-on-payment/:
- fast path:
OnAccountsReceivableInvoicePaidHandleronOnAccountsReceivableInvoicePaidEvent(entityType === "sale") - source-of-truth sweep: Bull queue
sale-fel-on-payment, jobcertify-paid-sales, cron0 * * * * - opt-in:
documentPaymentMethodrows withdocumentType = accountsReceivableReceiptandgenerateElectronicTaxDocument = true - sale lookup uses
invoice.entityId(notinvoice.saleId)
Full workflow, config contrast with the immediate path, and troubleshooting: Retail Sales FEL Workflow — FEL on A/R payment.
Credit notes (NCRE)
FelCreditNoteService:
- validates that the original sale invoice is certified
- checks the provided original invoice UUID against
sale.felAuthorization - prevents the credit amount from exceeding the original sale total
- builds and certifies the credit note
- emits
fel.creditNote.certified
Inventory listens to the certified credit-note event for reversal behavior.
Debit notes (NDEB)
FelDebitNoteService follows the same certified-invoice and UUID checks as credit notes. Tax parity validation is still marked as a TODO in FelValidationService; do not claim that new-tax prevention is enforced until that TODO is implemented.
Cancellations (ANULACION)
Retail cancellation requests use FelCancellationService:
- load the sale and original invoice UUID from
sale.felAuthorization - validate cancellation eligibility
- persist a pending cancellation
- certify the cancellation
Validation currently enforces:
- original invoice must be certified
- cancellation date must be in the same calendar month/year as the sale date
- cancellation is blocked when credit notes already exist for the sale
Cancellation routing is asymmetric:
| Workflow | Routing decision | Adapter call |
|---|---|---|
Retail cancellation with USE_RPA_FEL_API === "true" | RPA path | ProviderRpaFelApiService.voidCertificate() |
| Retail cancellation with any other flag value | Direct path | FelService.certifyDocument() with document type ANULACION |
| Restaurant order-bill cancellation | Always RPA; the flag is not read | ProviderRpaFelApiService.voidCertificate() |
Both RPA void paths send the certified invoice UUID (felAuthorization / originalInvoiceUuid) as NumeroDocumentoAAnular. They do not send felSerialNumber-felNumber.
Restaurant bill cancellation uses the separate cancelOrderBill application path. It therefore requires the RPA adapter to be reachable even when invoice certification uses a direct certifier. Retail cancellation emits fel.cancellation.certified; restaurant bill cancellation emits fel.order-bill-cancellation.certified.
locationId on list responses (not stored)
fel_cancellation has no location_id column. GET /fel/cancellations (and MCP list_cancellations, which calls the same application method) adds locationId: string | null at read time:
- retail:
sale.locationIdwheresale.id = felCancellation.saleId - restaurant:
order.locationIdviaorderBillwhereorderBill.id = felCancellation.orderBillId nullwhen the cancelled document is gone or carried no location
This is a read-model join in the repository adapter, not domain state. The PWA uses it to format cancellation dates in the branch timezone.
GET /fel/cancellations/:id and MCP get_cancellation use findById and do not include locationId. Do not assume detail and list shapes match.
Shipped in PR #748.
RPA authorization by operation
RPA token resolution is infrastructure behavior implemented by resolveRpaFelApiAuthToken:
- Resolve the certifier password from
encryptedFelPassword, with a legacy fallback to a non-JWT value inencryptedFelToken. - When the username and password are available, call
ProviderRpaFelApiService.getToken()and prefer the returned JWT. - If token retrieval is unavailable or fails, use a stored JWT only when it is not expired.
- Invoice certification and shared-info lookup may use the password as a final Authorization fallback because their request bodies also carry certifier credentials.
voidCertificatedoes not carry the password in its request body. Both retail and restaurant void paths therefore require a JWT and never use the password as the Authorization token.
This difference explains a common failure mode: invoice certification can succeed while cancellation fails. For a void failure, verify that the business billing configuration has the certifier username and password needed by getToken(), or a valid stored JWT. extractFelCertifierConfig also requires felCertifier and encryptedFelToken before token resolution begins.
Mint certifier JWT (billing UI)
POST /fel/businesses/:businessId/token (FelService.mintCertifierToken) is an interfaces/application helper for the billing form's Get new token action. It is not part of ElectronicCertificationProvider.
Behavior from source:
- Requires
PolicyResource.Business+PolicyAction.Update, active membership, and throttling (5requests /60s). - Calls
ProviderRpaFelApiService.getToken(username, password, { bypassCache: true }). - Username/password come from the request body, or from stored
felUsername/ decryptedencryptedFelPassword. - Digifact usernames
GT.{paddedNIT}.{USER}are compared tobusiness.taxIdviaassertFelUsernameMatchesIssuerNit. Non-comparable shapes (for example emails) do not block. - Returns
{ token, expiresAt }and does not persist the JWT. The merchant must save billing settings to store the Token field. expiresAtis decoded from the JWTexpclaim, ornullif the token has no expiry.
Do not describe this mint endpoint as domain certification behavior. It only obtains an RPA/Digifact JWT for the operator to paste into config.
Public HTTP surface
Base controller: FelController at /fel.
| Area | Routes |
|---|---|
| Core utilities | POST /fel/convert-to-xml, POST /fel/certify-document, POST /fel/get-shared-info |
| Certifier JWT mint | POST /fel/businesses/:businessId/token (@Permission(Business, Update), throttled) |
| Sale retry | POST /fel/sales/:id/retry (@Permission(Sale, Update)) |
| Credit notes | GET/POST /fel/credit-notes, GET /fel/credit-notes/:id, GET /fel/credit-notes/sale/:saleId, POST /fel/credit-notes/:id/certify, PDF/print endpoints |
| Debit notes | Same pattern under /fel/debit-notes |
| Cancellations | Same pattern under /fel/cancellations |
POST /fel/sales/:id/retry and POST /fel/businesses/:businessId/token have explicit @Permission decorators in this controller. Other routes still run through the backend's global authentication guard unless separately marked public.
Common pitfalls
business.felCertifierConfigmust contain the provider config expected by the selected path.- Direct certifier JWTs are not refreshed by
FelService; expired JWTs fail fast. getSharedInfousesrpafelapiwhenUSE_RPA_FEL_API === "true"; otherwise it uses the configured business certifier.- RPA cancellation uses the FEL UUID for
NumeroDocumentoAAnular, notSerie-Numero. - Restaurant order-bill cancellation always uses RPA
voidCertificate, regardless of the invoice-routing flag. - Paginated cancellation rows include
locationId;GET /fel/cancellations/:iddoes not. - A password fallback can keep RPA invoice certification working, but RPA void operations require a JWT from
getToken()or a valid stored JWT. POST /fel/businesses/:businessId/tokenmints a JWT and does not save it; billing Save is required to persist Token.- Digifact username NIT must match Tax ID when both sides parse as NITs (
GT.{paddedNIT}.{USER}). - The provider port accepts
xmlContent, butrpafelapicurrently receives JSON serialized into that field. - Credit-note validation caps a single note against the original sale total; it does not currently enforce an aggregate cap across multiple credit notes.
- Debit-note tax parity is still a TODO in
FelValidationService; do not document debit notes as enforcing "no new taxes." - FEL provider network failures are surfaced as gateway-style errors; inspect provider response bodies only when an HTTP response exists.
Related docs
- FEL Provider Matrix
- FEL certifier registry — which certifiers appear in billing UI vs full SAT registry
- FEL Module Architecture
- Retail sales FEL workflow
- Restaurant FEL workflow