Skip to content

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 immutableupdate_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:

  1. invoice.adhoc_receptor_fe_type — if explicitly set on the input.
  2. client.receptor_type_for_dgi — the registered client's stored type.
  3. 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_codeInvoiceDetail.cpbs_code (integer, set per line).
  • CPBS unit name — resolved from InvoiceDetail.cpbs_unit_of_measure_id via 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:

  1. client.invoice_retention == True — the client is a retention agent.
  2. A codigo_retencion is 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

  1. invoice.codigo_retencion (per-invoice override).
  2. client_invoice_default.codigo_retencion (client default).
  3. None → no retention.

The retention code enum

CodigoRetencion

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

"totals": {
  ...,
  "retention": { "code": 2, "amount": 3.50 }
}

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 as 1 (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 PaymentMethodEFECTIVO, 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_natureOperationNature.VENTA (1 — sale).
  • operationOperationType.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: raises InvalidInvoiceGenerationModeError("Cannot update transactions for internal invoices") for any invoice with mode == DGI. The DGI-authorized invoice is locked.
  • CreditEventHandler.pre_insert: raises InvalidCreditGenerationModeError if mode == DGI and invoice_id is None.
  • CreditEventHandler.pre_delete: only allows delete for PROFORM and DGI_IMPORT. Voiding a DGI credit goes through void_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:

  1. CreditEventHandler.pre_insert raises InvalidCreditGenerationModeError("Invoice ID must be provided for non-internal credit notes") if missing.
  2. CreditPacService.generate_pac_credit_note raises PacError("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 issueDate here is currently set to pendulum.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:

  1. The RUC is stripped of whitespace.
  2. RucVerificationService.verify_ruc is tried first as NATURAL. If that fails, retried as JURIDICO.
  3. If both fail, fall back to (NATURAL, CONSUMIDOR_FINAL).
  4. 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 code values 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.