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:
- Declare ITBMS (VAT) monthly — report output tax collected on sales minus input tax paid on purchases, and remit the net to the DGI.
- 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 |
OPEN → FILED → AMENDED |
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:
- Queries all
InvoiceDetailrows whose parent invoice falls within[period.start_date, period.end_date], joined toTaxRateviatax_rate_id. - Does the same for
SupplierInvoiceDetail. - Groups both result sets by
TaxRate.code. - Accumulates
sub_total_amount→ gross,tax_amount→ tax per code. - Returns one
ITBMSWorksheetRowper code:- Invoice details →
gross_sales/output_tax(what you collected) - Supplier invoice details →
gross_purchases/input_tax(what you paid)
- Invoice details →
- 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:
updateTaxRateto setvalid_toon the current row.createTaxRatewith the samecode, the newrate, and avalid_fromequal 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 tooutput_tax. - A supplier invoice line with the same ID contributes to
input_tax. - Lines left with
tax_rate_id = nullappear asITBMS_UNCLASSIFIEDin 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¶
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:
- Validates
period.status == OPEN— raisesValueErrorif alreadyFILED. - If
paymentLinesare provided, creates aLedgerEntrydated atperiod.end_dateand stores the entry's ID inpayment_ledger_entry_id. - 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:
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_idbackfill — invoice/supplier-invoice lines created before migrationadd_tax_rate_id_to_invoice_detailshavenulltax_rate_id and appear asITBMS_UNCLASSIFIEDin the worksheet. They need to be re-saved with an explicit rate before the worksheet breakdown is accurate.