Skip to content

Tax Filings (ITBMS & Retentions)

Backend domain: app/graphql/tax_filings/
Migration: add_tax_filings, add_tax_rate_id_to_invoice_details
RBAC Path: TAX_FILINGS


What is this feature?

Panama's tax authority (DGI) requires businesses to:

  1. Declare ITBMS (VAT) monthly — report output tax collected on sales minus input tax paid on purchases, and remit the net to the DGI.
  2. Withhold ISR (income tax) and ITBMS from payments to certain suppliers or clients — and issue a formal Retention Certificate to the counterpart as proof.

Cifras automates both by:

  • Tagging every invoice line item with a TaxRate (e.g. 7% ITBMS).
  • Aggregating those line items into a per-period ITBMS Worksheet (itbmsWorksheet).
  • Letting a bookkeeper review the worksheet, generate a PDF, and mark the period as Filed (with an optional payment journal entry).
  • Generating numbered Retention Certificates per withheld transaction.

Core concepts

TaxRate

A TaxRate represents one fiscal rate code — e.g. ITBMS at 7% — and ties it to GL accounts.

Field Purpose
code Enum key (ITBMS_7, ITBMS_15, ISR_RETENTION, etc.) — immutable
rate Decimal multiplier (e.g. 0.0700 for 7%)
valid_from / valid_to Validity window — use these to retire a rate without deleting it
account_payable_id GL account where output tax liability accumulates
account_receivable_id GL account where input tax credit accumulates
withholding_account_id GL account for withholding payable

Rate codes:

Code Meaning
ITBMS_EXEMPT Zero-rated (food, medicine, exports)
ITBMS_7 Standard 7% VAT
ITBMS_10 10% VAT (alcohol, hotels)
ITBMS_15 15% VAT (tobacco, casinos)
ISR_RETENTION Income-tax withholding on supplier payments
ITBMS_RETENTION_50 50% ITBMS withholding
ITBMS_RETENTION_100 100% ITBMS withholding
ITBMS_UNCLASSIFIED Sentinel for legacy lines without a tax_rate_id

TaxPeriod

A TaxPeriod is a filing window — one month for ITBMS/retentions, one year for ISR.

Field Purpose
period_type ITBMS_MONTHLY, RETENTION_MONTHLY, or ISR_ANNUAL
start_date / end_date Date range that governs which invoices are included
status OPENFILEDAMENDED
filed_at / filed_by_id Audit trail of who filed and when
declaration_file_id FK to a manually-uploaded declaration PDF (optional)
payment_ledger_entry_id FK to the JE that records the tax payment to DGI

RetentionCertificate

One certificate per withheld document. Auto-numbered (RC-00001, RC-00002, …).

Field Purpose
certificate_number Sequential, unique, immutable after creation
entity_kind CLIENT or SUPPLIER
entity_id UUID of that client/supplier
source_type / source_id The invoice or supplier invoice the withholding came from
tax_code Which retention rule applied
amount_subject Taxable base
retention_amount Amount withheld
pdf_file_id FK to a generated PDF certificate

How invoices tie into tax filings

The bridge between invoices and the worksheet is tax_rate_id on each detail (line-item) row.

erDiagram
    TaxRate {
        uuid id PK
        string code
        numeric rate
        date valid_from
        date valid_to
        uuid account_payable_id FK
        uuid account_receivable_id FK
        uuid withholding_account_id FK
    }

    InvoiceDetail {
        uuid id PK
        uuid invoice_id FK
        numeric sub_total_amount
        numeric tax_amount
        uuid tax_rate_id FK
    }

    SupplierInvoiceDetail {
        uuid id PK
        uuid supplier_invoice_id FK
        numeric sub_total_amount
        numeric tax_amount
        uuid tax_rate_id FK
    }

    TaxPeriod {
        uuid id PK
        string period_type
        date start_date
        date end_date
        string status
    }

    RetentionCertificate {
        uuid id PK
        string certificate_number
        uuid period_id FK
        string entity_kind
        uuid entity_id
        uuid source_id
        numeric amount_subject
        numeric retention_amount
    }

    TaxRate ||--o{ InvoiceDetail : "rates"
    TaxRate ||--o{ SupplierInvoiceDetail : "rates"
    TaxPeriod ||--o{ RetentionCertificate : "contains"

When the bookkeeper runs itbmsWorksheet(periodId), the service:

  1. Queries all InvoiceDetail rows whose parent invoice falls within [period.start_date, period.end_date], joined to TaxRate via tax_rate_id.
  2. Does the same for SupplierInvoiceDetail.
  3. Groups both result sets by TaxRate.code.
  4. Accumulates sub_total_amountgross, tax_amounttax per code.
  5. Returns one ITBMSWorksheetRow per code:
    • Invoice details → gross_sales / output_tax (what you collected)
    • Supplier invoice details → gross_purchases / input_tax (what you paid)
  6. Computes net_payable = total_output_tax − total_input_tax.

Lines without a tax_rate_id are bucketed under ITBMS_UNCLASSIFIED — they show up in totals but signal to the bookkeeper that those lines need to be reclassified before filing.


End-to-end lifecycle

flowchart TD
    A([1. Seed TaxRates\nonce per rate code]) --> B

    B([2. Open Invoices\nduring the month]) --> C

    C([3. Each detail line\ngets tax_rate_id]) --> D

    D([4. Create TaxPeriod\nOPEN]) --> E

    E([5. Call itbmsWorksheet\nreview aggregated rows]) --> F

    F{Lines with\nITBMS_UNCLASSIFIED?}
    F -- Yes --> G([Fix line items\nre-save with tax_rate_id])
    G --> E

    F -- No --> H([6. Generate PDF\ntaxDeclarationPdfUrl])

    H --> I([7. fileTaxPeriod\noptional payment JE])

    I --> J([Period status → FILED\nLedger entry posted])

    J --> K([8. Issue Retention Certificates\ngenerateRetentionCertificate\nper withheld document])

    K --> L([Download PDF per certificate\nretentionCertificatePdfUrl])

Step-by-step guide

Step 1 — Seed tax rates (one-time setup)

Create one TaxRate row per code that your business uses. Link each to the correct GL accounts.

mutation {
  createTaxRate(taxRate: {
    code: ITBMS_7
    rate: "0.0700"
    validFrom: "2024-01-01"
    accountPayableId: "<uuid of ITBMS Payable account>"
    accountReceivableId: "<uuid of ITBMS Input Credit account>"
  }) {
    id code rate validFrom
  }
}

Changing a rate

Tax rate code is immutable. If the DGI changes a rate, do not update the existing row. Instead:

  1. updateTaxRate to set valid_to on the current row.
  2. createTaxRate with the same code, the new rate, and a valid_from equal to the effective date.

Step 2 — Tag invoice lines as they are created

When the user creates or edits an invoice line, the frontend sets tax_rate_id to the appropriate TaxRate.id. This is what makes the worksheet meaningful.

  • A sales invoice line with tax_rate_id = <ITBMS_7 id> contributes to output_tax.
  • A supplier invoice line with the same ID contributes to input_tax.
  • Lines left with tax_rate_id = null appear as ITBMS_UNCLASSIFIED in the worksheet — a deliberate warning, not silent omission.

Step 3 — Create a Tax Period

Open a period for the month you want to file.

mutation {
  createTaxPeriod(
    periodType: ITBMS_MONTHLY
    startDate: "2026-05-01"
    endDate: "2026-05-31"
  ) {
    id status startDate endDate
  }
}

The period starts as OPEN. You can create periods in advance; they don't lock anything until you call fileTaxPeriod.


Step 4 — Review the ITBMS Worksheet

query {
  itbmsWorksheet(periodId: "<uuid>") {
    periodStart
    periodEnd
    totalOutput
    totalInput
    netPayable
    rows {
      rateCode
      grossSales
      outputTax
      grossPurchases
      inputTax
    }
  }
}

The response looks like:

Rate Code Gross Sales Output Tax Gross Purchases Input Tax
ITBMS_7 10,000.00 700.00 3,000.00 210.00
ITBMS_15 2,000.00 300.00 0.00 0.00
ITBMS_UNCLASSIFIED 500.00 0.00 200.00 0.00
  • Total output: 1,000.00
  • Total input: 210.00
  • Net payable: 790.00

If ITBMS_UNCLASSIFIED rows appear, go back to the invoices and set tax_rate_id on the un-tagged lines before filing.


Step 5 — Generate the Declaration PDF

query {
  taxDeclarationPdfUrl(periodId: "<uuid>")
}

Returns a presigned DigitalOcean URL (short-lived). Hand it directly to the browser as a download. The PDF is a backup reference document — the official filing must still be submitted through DGI's e-Tax 2.0 portal.


Step 6 — File the Period

mutation {
  fileTaxPeriod(
    periodId: "<uuid>"
    paymentLines: [
      {
        accountId: "<ITBMS Payable account UUID>"
        debitAmount: "790.00"
        creditAmount: "0.00"
        memo: "ITBMS May 2026 — payment to DGI"
      },
      {
        accountId: "<Cash / Bank account UUID>"
        debitAmount: "0.00"
        creditAmount: "790.00"
        memo: "ITBMS May 2026 — payment to DGI"
      }
    ]
  ) {
    id status filedAt paymentLedgerEntryId
  }
}

What happens on the backend:

  1. Validates period.status == OPEN — raises ValueError if already FILED.
  2. If paymentLines are provided, creates a LedgerEntry dated at period.end_date and stores the entry's ID in payment_ledger_entry_id.
  3. Sets status = FILED, filed_at = now(), filed_by_id = current_user.

Filed periods are locked

Once a period is FILED it cannot be re-filed. An amend flow (creating a corrective period) is the intended path for corrections — it is not yet implemented as a mutation, so contact engineering if you need to amend a filed period.


Step 7 — Issue Retention Certificates

Retention certificates are generated per document (one per invoice where a withholding occurred), not per period. Typically triggered from the supplier-invoice or client-invoice detail screen.

mutation {
  generateRetentionCertificate(
    periodId: "<uuid>"
    entityKind: SUPPLIER
    entityId: "<supplier UUID>"
    sourceType: SUPPLIER_INVOICES
    sourceId: "<supplier invoice UUID>"
    taxCode: ISR_RETENTION
    amountSubject: "5000.00"
    retentionAmount: "150.00"
  ) {
    id certificateNumber retentionAmount
  }
}

The certificate is auto-numbered (RC-00001, …), immutable once created (no edit mutation).

To download the certificate PDF:

query {
  retentionCertificatePdfUrl(certificateId: "<uuid>")
}

To list all certificates for a period:

query {
  retentionCertificates(periodId: "<uuid>") {
    certificateNumber entityKind taxCode amountSubject retentionAmount
  }
}

Period status state machine

stateDiagram-v2
    [*] --> OPEN : createTaxPeriod
    OPEN --> FILED : fileTaxPeriod
    FILED --> AMENDED : amendTaxPeriod
    AMENDED --> FILED : fileTaxPeriod

Retention summary

retentionSummary gives a rolled-up view of all certificates in a period, grouped by entity kind and tax code — useful for the period overview screen.

query {
  retentionSummary(periodId: "<uuid>") {
    entityKind
    taxCode
    totalSubject
    totalRetained
    count
  }
}

Amend flow

If corrections are needed after filing (e.g. a late supplier invoice arrives), use amendTaxPeriod to reopen the period, then re-issue fileTaxPeriod once the corrections are in.

# Step 1 — unlock the filed period
mutation {
  amendTaxPeriod(periodId: "<uuid>") {
    id status
  }
}

# Step 2 — fix the underlying invoices / retention certificates, then re-file
mutation {
  fileTaxPeriod(periodId: "<uuid>", paymentLines: [...]) {
    id status filedAt
  }
}

amendTaxPeriod only accepts periods in FILED status — calling it on an OPEN or already-AMENDED period raises a ValueError.


ISR Annual worksheet

For the annual income-tax declaration, isrAnnualWorksheet aggregates all retention certificates in the period that carry a withholding code (ISR_RETENTION, ITBMS_RETENTION_50, ITBMS_RETENTION_100), grouped by counterpart entity and tax code.

query {
  isrAnnualWorksheet(periodId: "<uuid>") {
    periodStart
    periodEnd
    totalSubject
    totalRetained
    rows {
      entityKind
      entityId
      taxCode
      totalSubject
      totalRetained
      certificateCount
    }
  }
}

Each row = one entity + one withholding code. The bookkeeper uses this to complete the DGI annual ISR return and verify that issued certificates match the withheld totals.


Error states

Error Trigger Frontend message
ValueError: period is already filed fileTaxPeriod on a FILED period "This period was already filed. Use Amend to reopen it first."
ValueError: can only be amended when FILED amendTaxPeriod on an OPEN or AMENDED period "Only a filed period can be amended."
NotFoundError Invalid period_id or certificate_id "Record not found."

Full GraphQL surface

Queries

taxRates: [TaxRate!]!
taxRate(rateId: UUID!): TaxRate!

taxPeriods: [TaxPeriod!]!
taxPeriod(periodId: UUID!): TaxPeriod!
itbmsWorksheet(periodId: UUID!): ITBMSWorksheet!
isrAnnualWorksheet(periodId: UUID!): ISRAnnualWorksheet!
retentionSummary(periodId: UUID!): [RetentionSummaryRow!]!
retentionCertificates(periodId: UUID!): [RetentionCertificate!]!

retentionCertificatePdfUrl(certificateId: UUID!): String
taxDeclarationPdfUrl(periodId: UUID!): String

ISRAnnualWorksheet: periodStart, periodEnd, totalSubject, totalRetained, rows: [ISRAnnualWorksheetRow!]!

ISRAnnualWorksheetRow: entityKind, entityId, taxCode, totalSubject, totalRetained, certificateCount

Includes all retention codes: ISR_RETENTION, ITBMS_RETENTION_50, ITBMS_RETENTION_100. Each row is one entity + tax code combination so the bookkeeper can see exactly how much was withheld from each counterpart.

Mutations

createTaxRate(taxRate: TaxRateCreateInput!): TaxRate!
updateTaxRate(taxRate: TaxRateUpdateInput!): TaxRate!
deleteTaxRate(rateId: UUID!): Boolean!

createTaxPeriod(
  periodType: TaxPeriodType!
  startDate: Date!
  endDate: Date!
): TaxPeriod!

fileTaxPeriod(
  periodId: UUID!
  paymentLines: [TaxPaymentLineInput!]
): TaxPeriod!

amendTaxPeriod(periodId: UUID!): TaxPeriod!

generateRetentionCertificate(
  periodId: UUID!
  entityKind: TaxEntityKind!
  entityId: UUID!
  sourceType: SourceType!
  sourceId: UUID!
  taxCode: TaxRateCode!
  amountSubject: Decimal!
  retentionAmount: Decimal!
): RetentionCertificate!

TaxPaymentLineInput: accountId, debitAmount, creditAmount, memo?


Open follow-ups

  • Per-line tax_rate_id backfill — invoice/supplier-invoice lines created before migration add_tax_rate_id_to_invoice_details have null tax_rate_id and appear as ITBMS_UNCLASSIFIED in the worksheet. They need to be re-saved with an explicit rate before the worksheet breakdown is accurate.