Invoicing Scenarios¶
This page is the rulebook. Every conditional that changes the shape of the PAC payload, every required field that's only required sometimes, every gotcha that's bitten us before.
If you're trying to figure out "what does the payload look like for X?" — start here.
At-a-glance matrix¶
| Trigger | Effect | Where |
|---|---|---|
receptor_fe_type == GOBIERNO |
Every line item must carry a cpbs_code (else raises). |
PacInvoiceFactory, PacCreditNoteFactory |
receptor_fe_type == CONSUMIDOR_FINAL |
RUC field is only included if it matches a strict pattern. Otherwise stripped. | informacion_receptor.py |
tax_rate == 0 |
Line item ITBMS rate code becomes "00" (exempt). |
GrupoITBMS.from_detail |
client.invoice_retention == True + codigo_retencion set |
Totales.retention is added with computed amount. |
PacInvoiceFactory._create_totals |
invoice.codigo_retencion set |
Overrides the client-default retention code. | InvoicePacService |
invoice.office_id set |
idOffice is included in payload (Alanube routes to that branch). |
PacInvoice.id_office |
invoice.adhoc_client_ruc set |
The ad-hoc RUC is verified (not the client's). Receiver name/RUC overridden. | InvoicePacService._resolve_ruc_types |
invoice.adhoc_client_name set |
Receiver name on payload uses ad-hoc value. | InformacionReceptor |
invoice.emails_ad_hoc non-empty |
CAFE email delivery uses ad-hoc list, not client emails. | InformacionReceptor |
mode == PROFORM |
No PAC call at all. Document is internal. | InvoiceEventHandler.pre_insert |
mode == DGI after creation |
Invoice is immutable — update_invoice is blocked. |
InvoiceEventHandler.pre_update |
mode == DGI (credit) |
Credit must reference an invoice (invoice_id). Else raises. |
CreditEventHandler.pre_insert |
Credit, mode == DGI |
Cannot delete; only PROFORM/DGI_IMPORT credits are deletable. | CreditEventHandler.pre_delete |
| Document is a credit note | documentType is hard-coded to "04" (NOTA_CREDITO_FE). |
PacCreditNoteFactory._create_general_data |
The rest of this page expands each scenario with the why and the how.
Receptor type¶
The receiver of an invoice falls into one of four buckets. Each maps to a 2-digit type code on the PAC payload and changes which fields must be present.
| Enum | Code | Real-world | RUC handling |
|---|---|---|---|
CONTRIBUYENTE |
"01" |
Registered taxpayer (B2B) | RUC required, validated upstream. |
CONSUMIDOR_FINAL |
"02" |
Walk-in / retail customer | RUC optional. Only included if it matches ^[a-zA-Z0-9]{1,3}-[0-9]{1,4}-[0-9]{1,6}$. Otherwise omitted. |
GOBIERNO |
"03" |
Government entity | RUC required. CPBS required on every line. See CPBS rule. |
EXTRANJERO |
"04" |
Foreign / export buyer | RUC optional. PAC payload sets destination=2 (vs 1 domestic). |
Resolution priority¶
For each invoice, the receptor type is decided in this order:
invoice.adhoc_receptor_fe_type— if explicitly set on the input.client.receptor_type_for_dgi— the registered client's stored type.CONSUMIDOR_FINAL— the catch-all default.
When the ad-hoc RUC path runs (see below), the type returned by Alanube's check-digit endpoint replaces step 2.
Receiver address: the 100-char rule¶
The PAC payload requires the receiver address to be exactly 100 characters. The factory pads or truncates as needed:
address_str = primary_address.street if primary_address else "PANAMA, PANAMA"
address = address_str[:100].ljust(100)
Country defaults to "PA". Location code defaults to "8-8-1" if none is set on the client. There is no validation that the location code is correct for the address.
CPBS (government clients)¶
Rule: When receptor_fe_type == GOBIERNO, every line item must have a non-null cpbs_code and a non-null CPBS unit-of-measure name.
If either is missing, the factory raises ValueError(AP3063_MESSAGE):
No informado código de producto en la Codificación Panameña de Bienes y Servicios
en caso de venta a la Administración Pública
This blocks the submission — the request never goes to Alanube.
Where the data comes from¶
cpbs_code—InvoiceDetail.cpbs_code(integer, set per line).- CPBS unit name — resolved from
InvoiceDetail.cpbs_unit_of_measure_idvia the unit-of-measure loader.
Tooling for the frontend¶
The catalog is exposed via the goodsAndServices GraphQL query, cached for 24h. UIs typically render a CPBS picker only when the client is flagged as GOBIERNO.
Non-government invoices¶
CPBS is optional for CONTRIBUYENTE, CONSUMIDOR_FINAL, and EXTRANJERO. If cpbs_code is set on those line items it's still emitted on the payload; if absent it's silently omitted.
ITBMS (sales tax) rate codes¶
Each line item carries a 2-digit ITBMS rate code in GrupoITBMS.rate. The mapping is fixed:
| Tax % | Rate code | Use case |
|---|---|---|
| 0% | "00" |
Exempt items (basic foods, medicines, education, etc.) |
| 7% | "01" |
Standard rate for most goods/services |
| 10% | "02" |
Alcohol, hospitality |
| 15% | "03" |
Tobacco |
| anything else | "00" |
Defaults to exempt — be careful, no error fires |
The lookup happens in GrupoITBMS.from_detail:
if tax_rate == 0: rate_code = "00"
elif tax_rate == 7: rate_code = "01"
elif tax_rate == 10: rate_code = "02"
elif tax_rate == 15: rate_code = "03"
else: rate_code = "00" # silent fallback
Exempt items¶
There is no separate "exempt" flag on items. An item is exempt iff its tax_rate == 0. This means:
- Tax-exempt clients are not handled at the client level — exemption must be set per line item.
- A nonsensical rate (e.g., 8%) won't error; it'll silently submit as exempt. Validate upstream.
Mixed-rate invoices¶
Each line is independent. A single invoice can carry exempt food alongside 7% goods alongside 10% alcohol. The PAC Totales block aggregates by rate.
Retention¶
Retention (retención) is a withholding mechanism: on certain payments, the buyer holds back a portion of the ITBMS and remits it directly to the DGI on behalf of the seller.
When retention applies¶
Both conditions must be true:
client.invoice_retention == True— the client is a retention agent.- A
codigo_retencionis resolvable for this invoice.
If client.invoice_retention == True but no retention code can be resolved, retention is silently omitted from the payload — no error. This is a known footgun; if the client is supposed to retain but no code is set, the payload may go through under-reported. Validate at the input layer.
Codigo retencion lookup order¶
invoice.codigo_retencion(per-invoice override).client_invoice_default.codigo_retencion(client default).None→ no retention.
The retention code enum¶
| Code | Name | Rate | When |
|---|---|---|---|
1 |
PAGO_SERVICIO_PROFESIONAL |
100% | Professional services (legal, accounting, consulting). |
2 |
PAGO_VENTA_BIENES_SERVICIOS |
50% | Standard sale of goods/services. |
3 |
PAGO_NO_DOMICILIADO |
100% | Payment to a non-domiciled (foreign) party. |
4 |
PAGO_COMPRA_BIENES_SERVICIOS |
50% | Generic purchase of goods/services. |
7 |
PAGO_COMERCIO_AFILIADO |
50% | Affiliated commerce. |
8 |
OTROS |
0% | Catch-all that records a code but holds back nothing. |
The "rate" is the % of ITBMS that is retained.
Retention amount formula¶
amount = (codigo_retencion.retention_rate * balance.tax_amount).quantize(
Decimal("0.01"), rounding=ROUND_HALF_UP
)
The retention amount is computed from the ITBMS portion of the total, not the full invoice amount. So a $100 invoice with $7 ITBMS and code 2 (50%) yields a $3.50 retention.
Payload shape¶
When retention is None, the field is omitted entirely (exclude_none=True).
Payment method (the gotcha)¶
⚠️ Heads up: the PAC payload always reports the payment method as
CREDITO("01") and the payment time as1(immediate / contado), regardless of how the invoice was actually paid.
This is intentional — the PAC's payment method field is informational for the receipt and not what the DGI uses to track collection. Actual payment records are kept separately in the payments module.
If you're debugging "why does my CAFE always say cash?", that's why. Don't try to plumb the real payment method into the factory unless the DGI's regime changes to require it.
The full enum exists in PaymentMethod — EFECTIVO, TARJETA_CREDITO, TRANSFERENCIA_DEPOSITO_CUENTA_BANCARIA, CHEQUE, YAPPY, etc. — and even has a code mapping table. None of it is wired into the PAC factory.
Document type¶
Each invoice gets a documentType on the PAC payload. The defaults work for almost everything; the corner cases matter when you're doing imports, exports, or refunds.
| Enum | Code | Use |
|---|---|---|
FACTURA_OPERACION_INTERNA |
"01" |
Default. Domestic sale. |
FACTURA_IMPORTACION |
"02" |
Import invoice. |
FACTURA_EXPORTACION |
"03" |
Export sale. |
NOTA_CREDITO_FE |
"04" |
Credit note (always used by PacCreditNoteFactory). |
NOTA_DEBITO_FE |
"05" |
Debit note. |
NOTA_CREDITO_GENERICA |
"06" |
Generic (non-FE) credit note. |
NOTA_DEBITO_GENERICA |
"07" |
Generic debit note. |
FACTURA_ZONA_FRANCA |
"08" |
Free-zone invoice. |
REEMBOLSO |
"09" |
Reimbursement. |
FACTURA_OPERACION_EXTRANJERA |
"10" |
Foreign-source operation. |
PacInvoiceFactory defaults to "01". PacCreditNoteFactory hard-codes "04" — credit notes can't be any other type.
The remaining types (export, import, free zone) are not yet wired through the factories. If you need them, you'll have to thread the value through _create_general_data. Nothing currently reads client_invoice_default.document_type or similar.
Operation nature & operation type¶
These two fields ride along on DatosGenerales. Both are hard-coded to defaults:
operation_nature→OperationNature.VENTA(1— sale).operation→OperationType.SALIDA_O_VENTA(1— outgoing/sale).
The enums support exports, returns, consignment, etc., but the factory ignores them.
Generation mode (PROFORM vs DGI vs DGI_IMPORT)¶
The mode is the first decision and the most consequential. It governs whether we even talk to PAC, and how mutable the document is afterwards.
| Mode | Calls PAC? | CUFE source | Mutable? | Deletable? |
|---|---|---|---|---|
PROFORM |
No | Internal sequence | Yes | Yes |
DGI |
Yes | From PAC response | No | No (credits) / via void (invoices) |
DGI_IMPORT |
No (already authorized externally) | From source system | Yes (metadata) | Yes |
Behavioral rules enforced in event handlers¶
InvoiceEventHandler.pre_update: raisesInvalidInvoiceGenerationModeError("Cannot update transactions for internal invoices")for any invoice withmode == DGI. The DGI-authorized invoice is locked.CreditEventHandler.pre_insert: raisesInvalidCreditGenerationModeErrorifmode == DGIandinvoice_id is None.CreditEventHandler.pre_delete: only allows delete forPROFORMandDGI_IMPORT. Voiding aDGIcredit goes throughvoid_credit.
When you can promote PROFORM → DGI¶
For credits: convertCreditToDgi(creditId) runs the PAC submission and flips the mode. Invoices have a similar conversion path through the standard mutation surface — verify in invoice_mutations.py before assuming.
Credit note specifics¶
On top of everything that applies to invoices, credit notes have their own rules.
Required: invoice_id (when DGI)¶
A DGI credit must reference an invoice. Two checks enforce this:
CreditEventHandler.pre_insertraisesInvalidCreditGenerationModeError("Invoice ID must be provided for non-internal credit notes")if missing.CreditPacService.generate_pac_credit_noteraisesPacError("Credit note must reference an invoice")if it slips through.
The referenced-invoice block¶
The PAC payload includes a referencedDocuments array. The factory always emits one entry of type "CUFE":
{
"issueDate": "<timestamp>",
"emissionType": "CUFE",
"cufeIdentification": "<original invoice CUFE>"
}
⚠️ The
issueDatehere is currently set topendulum.now("America/Panama"), not the original invoice's issue date. This may or may not be intentional — confirm with the DGI's regime when shipping anything sensitive.
The original invoice must be DGI-authorized¶
You can't credit a document that doesn't legally exist. If the source invoice is PROFORM, the credit's CUFE reference will be the proforma's internal number, which is meaningless to the DGI. Block this at the input layer.
Ad-hoc receivers¶
Three ad-hoc fields on the invoice override what's normally pulled from the registered client:
| Field | What it overrides |
|---|---|
adhoc_client_name |
Receiver name on the PAC payload. |
adhoc_client_ruc |
Receiver RUC + the verification flow. |
adhoc_receptor_fe_type |
The receptor type code. |
emails_ad_hoc |
Email addresses for CAFE delivery. |
The ad-hoc RUC verification path¶
When adhoc_client_ruc is set:
- The RUC is stripped of whitespace.
RucVerificationService.verify_rucis tried first asNATURAL. If that fails, retried asJURIDICO.- If both fail, fall back to
(NATURAL, CONSUMIDOR_FINAL). - If the registered client has no RUC of its own, the verified RUC is persisted back to the client (with
ruc_verified=True). Subsequent invoices for that client use the registered RUC.
This matters: an ad-hoc invoice for a "walk-in" customer who later becomes a regular client will silently upgrade the client record. Be deliberate.
Item descriptions¶
PacUtilsService.get_line_detail_desc formats each line's description. The rules are layered:
flowchart TD
A{item is None?} -- yes --> B[item_ad_hoc or note or 'No description']
A -- no --> C{use_client_item_number
and note set?}
C -- yes --> D[note verbatim]
C -- no --> E{use_client_item_number
and item.client_item_number?}
E -- yes --> F["item.client_item_number - description"]
E -- no --> G["item.item_number - description"]
The use_client_item_number flag is a tenant setting (CompanySettingKey.USE_CLIENT_ITEM_NUMBER_FOR_PDF_EXPORT, default False). When on, the description leads with the client's item number rather than your internal SKU.
When there's no Item row at all (line was added by free-text), the description falls through to item_ad_hoc, then note, then a literal "No description" placeholder. Avoid the placeholder by ensuring at least one of those is set.
Office and billing point¶
Each tenant has a global pac_billing_point (default "001") and one or more offices.
When invoice.office_id is set, the matching Office.pac_office_id is sent as idOffice on the payload. Alanube routes the document to that branch's billing point. When office_id is null, idOffice is omitted and the main office is used.
Multi-office tenants:
- All offices must already be registered with Alanube (see Onboarding).
- Different offices can have different
codevalues that show up on the printed CAFE. - Office
location(province/district/corregimiento) is set during onboarding and not per invoice.
Discounts, transport, insurance¶
Per-line price modifiers in GrupoPrecio:
| Field | Currently used? | Source |
|---|---|---|
transfer |
Yes | detail.unit_price |
discount |
Yes | detail.discount_rate × detail.unit_price, rounded to 4 decimals. 0 if no rate. |
transport |
No | Reserved on the model, never populated. |
insurance |
No | Reserved on the model, never populated. |
Both unused fields are emitted with exclude_none=True, so they don't appear on the payload. If you need to support per-line shipping or insurance, you'd extend GrupoPrecio.from_detail.
Numeration¶
Every PAC submission has a 10-digit numeration in DatosGenerales. This is not the CUFE — it's just an Alanube-side document number.
Formula:
tenant_ascii = sum(ord(c) for c in tenant_id) # 2 digits, padded
random_part = secrets.randbelow(1000) # 3 digits, padded
sequence = await counter.next(model) # 5 digits, padded
numeration = f"{tenant_ascii:02}{random_part:03}{sequence:05}"[:10]
The random component dodges collisions in distributed scenarios. The sequence counter is per-tenant + per-model (invoices have their own counter, credits have theirs). The truncation to 10 chars is defensive — if tenant_ascii overflows two digits, the sequence eats the spare.
Currency¶
Single currency: PAB / USD (Panama uses USD). There is no currency field on Invoice or PacInvoice. All amounts are Decimal quantized to two places. Multi-currency is not supported.
If you ever need it, plan for a much larger refactor — the DGI's regime is denominated in PAB and the PAC API doesn't expose a currency field.
Service vs product items¶
The factory does not distinguish. Both go through ItemPac.from_detail with the same fields: description, code, unit, quantity, prices, ITBMS, optional CPBS. If you have a service that should display differently, do it in the description, not in a separate code path.
Validation errors quick reference¶
| Error | Where | Trigger |
|---|---|---|
ValueError(AP3063_MESSAGE) |
PacInvoiceFactory, PacCreditNoteFactory |
GOBIERNO client + line item missing cpbs_code. |
MissingRetentionCodeError |
InvoicePacService |
Client requires retention but no code resolvable. |
PacError("Credit note must reference an invoice") |
CreditPacService |
DGI credit submission with no invoice_id. |
InvalidCreditGenerationModeError("Invoice ID must be provided…") |
CreditEventHandler.pre_insert |
DGI credit input with no invoice_id. |
InvalidInvoiceGenerationModeError("Cannot update transactions for internal invoices") |
InvoiceEventHandler.pre_update |
Editing a DGI invoice. |
InvalidCreditGenerationModeError("Cannot update internal credit notes") |
CreditEventHandler.pre_update |
Editing a DGI credit. |
InvalidCreditGenerationModeError("Only proforma or externally imported credit notes can be deleted.") |
CreditEventHandler.pre_delete |
Deleting a DGI credit. |
PacError(<alanube message>) |
InvoicePacService, CreditPacService |
Alanube returned a 4xx with a structured error. |
For the full picture of what's persisted on each failure, see Error Handling.