
Finance Substrate
- 3 installs
- 1 repo stars
- Updated May 23, 2026
- broomva/finance-substrate
Finance Substrate is a Claude Code skill for self-hosted personal finance and Colombian tax management that imports bank and certificate data and projects a Form 210 tax return locally.
About
This skill is a self-hosted personal finance and Colombian tax management substrate. It imports bank exports and tax certificates, fetches TRM exchange rates, and projects a full Form 210 tax return mapped to DIAN row numbers. A developer or resident uses it to model tax liability, optimize deductions and even automate DIAN form filling via browser automation, keeping all data local.
- Imports Colombian bank transactions and tax certificates into a local ledger
- Projects Form 210 tax liability at ~95% accuracy with DIAN row mapping
- Parses password-protected bank PDFs; no paid aggregators, all data local
Finance Substrate by the numbers
- 3 all-time installs (skills.sh)
- Ranked #847 of 1,106 Finance & Trading skills by installs in the Skillselion catalog
- Data as of Jul 8, 2026 (Skillselion catalog sync)
finance-substrate capabilities & compatibility
$0; no paid services, no API keys, uses free open data APIs and local execution
- Capabilities
- tax projection · transaction import · deduction optimization · net worth calculation
- Works with
- gmail
- Use cases
- data analysis · web scraping
- Pricing
- Free
What finance-substrate says it does
Personal finance and tax management substrate for Colombian residents.
Zero external paid services — all data stays local.
Colombian financial institution PDFs are typically password-protected with the account holder's **cédula de ciudadanía (CC) number**.
tax_projection.py # Form 210 tax engine (95% accuracy)
npx skills add https://github.com/broomva/finance-substrate --skill finance-substrateAdd your badge
Show developers this skill is listed on Skillselion. Paste this into your README.
| Installs | 3 |
|---|---|
| repo stars | ★ 1 |
| Last updated | May 23, 2026 |
| Repository | broomva/finance-substrate ↗ |
What it does
Use it to import Colombian bank and tax data locally and project a Form 210 tax return with deduction optimization.
Who is it for?
Colombian residents modeling and filing Form 210, optimizing AFC and voluntary pension deductions, and tracking patrimonio
Skip if: Non-Colombian tax regimes or investment analysis and trade execution (see investment-management)
When should I use this skill?
Importing Colombian bank or tax data, projecting Form 210, or optimizing deductions
What you get
A local ledger and Form 210 projection mapped to DIAN rows with deduction optimization and self-healing data checks
- unified transaction ledger
- Form 210 projection
- deduction optimization report
By the numbers
- 15 skill modes
- 9 Gmail institutions covered
- Form 210 wizard has 15 steps
Files
Finance Substrate
Self-hosted personal finance and Colombian tax management. No paid aggregators — imports from bank exports, parses tax certificates, and uses free open data APIs.
Data Sources
Automated (no manual export needed)
| Source | Method | Data | Cost |
|---|---|---|---|
| Gmail (9 institutions) | GWS CLI (gws gmail) | Salary payments, bank notifications, certificates, PILA confirmations | Free |
| DIAN MUISCA | agent-browser automation | Exogena XLSX, e-invoices XLSX, RUT PDF | Free |
| TRM (USD/COP) | datos.gov.co REST API | Daily exchange rates | Free |
Semi-automated (PDF parsing from user-provided files)
| Source | Method | Data | Cost |
|---|---|---|---|
| Davivienda | PDF certificate + CSV export | Cuentas, AFC, rendimientos, GMF | Free |
| Nubank Colombia | PDF certificate + CSV export | Rendimientos, retención, GMF | Free |
| Nequi | PDF certificate | Saldo, rendimientos | Free |
| RappiPay / RappiCard | PDF certificate | Saldo, deuda TC, cashback | Free |
| Skandia | PDF certificate (multi-page) | Pensión oblig/vol, cesantías, retiros | Free |
| Colmedica | PDF certificate | Medicina prepagada (Art. 387) | Free |
| Banco de Bogotá | PDF certificate | Intereses crédito | Free |
| Acciones & Valores | PDF certificate | Acciones, dividendos, FIC | Free |
| DolarApp/ARQ | Manual entry | USD balance (patrimonio exterior) | Free |
| PILA planillas | PDF from Compensar | SS contributions (salud, pensión, ARL, FSP) | Free |
Gmail institution coverage
| Institution | Email sender | Messages/year | Key data |
|---|---|---|---|
| Thera (salary) | thera | ~24 | USD payment amounts, dates |
| Davivienda | davivienda | ~16 | Monthly extractos (PDF), certs |
| Skandia | skandia | ~20 | Pension fund notifications |
| Nu Colombia | nu.com.co | ~20 | Account & rendimientos |
| Nequi | nequi | ~20 | Transaction confirmations |
| Compensar | compensar | ~18 | PILA payment confirmations |
| RappiPay | rappipay | ~11 | Payment notifications |
| Banco Falabella | falabella | ~15 | TC consumos, certs |
| DIAN | dian.gov.co | ~9 | Firma electrónica, RUT, códigos |
See references/gmail-data-sources.md for GWS CLI patterns, query syntax, and integration strategies.
PDF Password Convention
Colombian financial institution PDFs are typically password-protected with the account holder's cédula de ciudadanía (CC) number. When parsing certificates:
1. First attempt: open without password (some PDFs like Davivienda are unprotected) 2. Second attempt: use CC number as password 3. The password is never stored in the skill repo or data files — prompted at runtime or passed via --password flag
Institutions known to use CC-based PDF passwords:
- Skandia (pension obligatoria, voluntaria, cesantías)
- Colmedica (medicina prepagada)
- Nu Colombia (certificado tributario, reporte anual de costos)
- Nequi (certificado tributario)
- RappiCard / RappiCuenta (certificados tributarios)
- Banco de Bogotá (certificado tributario)
- Acciones & Valores (bursátil + fondos)
Institutions with unprotected PDFs:
- Davivienda (certificado tributario, extractos)
- DIAN (borrador, comprobantes)
- Finanseguro
Tax Declaration Strategy (Form 210)
Income Structure — Foreign Salary
For Colombian residents earning USD salary from a foreign employer:
1. Ingresos brutos (R32): Gross COP value of all salary payments, converted at the official TRM rate on each payment date 2. INCR (R33): Social security contributions as independent:
- Pension obligatoria (16% of IBC = 40% of gross)
- Salud obligatoria (12.5% of IBC)
- ARL (~0.522% of IBC, risk level I)
- Fondo Solidaridad Pensional (1% of IBC if income > 4 SMLMV)
- Source: pension fund certificate + monthly planillas
3. FX losses: Deductible as cost of earning foreign income (difference between TRM and actual received amount)
Deduction Strategy — Maximizing R35 + R37
The primary tax optimization levers for persona natural:
1. AFC contributions (R35): Cuenta AFC (Ahorro Fomento a la Construcción)
- Aportes directos and aportes con contingente are both deductible
- Withdrawals for housing (destino vivienda) are tax-free under Art. 126-4 ET
- This is typically the single largest deduction available
- Source: bank AFC certificate (page showing aportes and retiros)
2. Voluntary pension (R35): Fondo de pensiones voluntarias
- Aportes with retención contingente are deductible
- Combined AFC + voluntary pension cap: 30% of gross income or 3,800 UVT (Art. 126-1 ET)
- Withdrawals before 10 years (or without meeting pension requirements) trigger retención contingente (7%)
- Source: pension fund voluntary certificate
3. 25% renta exenta (R36): Art. 206.10 ET
- Automatic deduction of 25% on rentas de trabajo
- Capped at 790 UVT/month
4. Medicina prepagada (R39): Art. 387 ET
- Deductible up to 16 UVT/month
- Source: prepaid health provider certificate
5. E-invoice 1% deduction (R39): Art. 336 numeral 5 ET
- 1% of purchases paid electronically (tarjeta débito/crédito)
- Source: DIAN facturas electrónicas recibidas report
6. GMF deduction (R39): Art. 115 ET
- 50% of GMF (4x1000) paid is deductible
- Source: all bank certificates report GMF separately
7. Limitation (R41): Total exentas + deducciones capped at 40% of R34 or 5,040 UVT
- Strategy: maximize R35 (AFC + pension voluntaria) first since it's the most impactful before the cap binds
Patrimonio Strategy
Track all assets for R29 (patrimonio bruto):
- Bank accounts: all savings/checking account saldos at Dec 31
- Investment funds: pension voluntaria saldo, FICs
- Stocks: valor nominal of holdings
- Pension: cesantías saldo
- Vehicle: avalúo catastral from municipality
- Real estate: escritura value or avalúo
Deudas (R30): credit card balances, outstanding loans at Dec 31
Retenciones (R132)
Sum all retenciones from certificates:
- Pension fund: retención contingente + retención sobre rendimientos
- Banks: retención en la fuente on rendimientos financieros
- Other: any retención reported by third parties in exogena
Tip: the exogena report from DIAN aggregates retenciones reported by all third parties — use it to validate against individual certificates.
Anticipo Strategy
- R130: Anticipo paid in prior year (reduces current year saldo a pagar)
- R133: New anticipo = max(0, 75% of R126 - R132)
- First declaration year: 25%. Second year: 50%. Third year onwards: 75%
- Higher retenciones in current year reduce the anticipo for next year
Rendimientos Financieros (Rentas de Capital)
- Componente inflacionario: 50.88% of rendimientos (2024) are non-taxable (INCR)
- Banks report both total rendimientos and the non-taxable portion
- Source: each bank's certificate has a rendimientos + "no gravados" breakdown
- Don't forget: pension fund valorización counts as capital income
Skill Modes
1. import — Ingest bank transactions
Import CSV/OFX/XLSX from any supported bank into the unified ledger.
Scripts:
scripts/import_csv.py— Generic CSV/OFX parser with bank profiles inimporters/*.jsonscripts/import_declaracion.py— Bulk import from~/Dropbox/Declaracion/directory:- Bank consolidated XLSX (from bank PDF extraction scripts)
- Salary XLSX with USD→COP conversion details
- DIAN exogena XLSX (third-party reported data)
- DIAN e-invoices XLSX
- Transfer CSVs (international + national email notifications)
2. certificates — Parse tax certificates
Import certificados tributarios from financial institutions.
Script: scripts/import_certificates.py --year 2024 --password <CC>
Extracts data from all certificates in ~/Dropbox/Declaracion/<year>/ using regex patterns. Handles password-protected PDFs. Outputs aggregated tax inputs mapped to Form 210 rows.
3. trm — Fetch exchange rates
Script: scripts/fetch_trm.py
Fetches TRM from datos.gov.co free API. Supports single date, range, or last N days.
4. tax — Tax projection (Form 210)
Script: scripts/tax_projection.py --year 2024 --anticipo <amount>
Projects full Form 210 using salary data, certificates, and exogena:
- Maps output to actual DIAN row numbers (R29-R136)
- Models INCR from social security contributions
- Applies AFC + voluntary pension + 25% exempt income + medicina prepagada
- Calculates anticipo renta for following year
- Compares against DIAN borrador when available
5. categorize — Classify transactions
Apply or update categorization rules on uncategorized transactions.
6. summary — Financial reports
Aggregate transactions by category, account, currency with multi-period comparison.
7. dian-scrape — MUISCA data download
Browser automation for DIAN portal data extraction (requires agent-browser skill).
Capabilities (post-login):
- Download exogena XLSX (third-party reported income, patrimony, withholdings)
- Download e-invoices received report
- Download withholding certificates
- Retrieve RUT PDF
- Query filed declarations and payment history
Login flow: Uses agent-browser to navigate the WebIdentidadLogin SPA → OAuth callback → JSF portal. Document type dropdown must be clicked (not select-ed) to avoid form reset. Sessions can be persisted with agent-browser state save.
See references/dian-muisca-automation.md for the complete technical reference including URL patterns, error codes, security measures, and legal considerations.
8. dian-fill — Form 210 Declaration Filing
Automate filling and submitting the declaración de renta (Form 210) on DIAN MUISCA using agent-browser.
Script: scripts/fill_form210.py --year 2024 --anticipo 19287000
How it works: 1. Runs tax_projection.py to compute all casilla values 2. Maps each casilla to the correct wizard step via templates/form-210-schema.json 3. Generates step-by-step agent-browser commands:
- Navigates to each step via JS click on step counter elements
- Snapshots to discover field refs (refs change between sessions)
- Fills editable casillas with projected values (DIAN-rounded to thousands)
- Skips auto-computed casillas (DIAN calculates them)
Output modes:
--dry-run— Human-readable table of casilla → value mappings- Default — Shell script with
agent-browsercommands (pipe tobash) --json— Machine-readable mapping for programmatic use
Form 210 wizard structure (15 steps):
| Step | Section | Key casillas |
|---|---|---|
| 1 | Datos Declarante | 5-12, 24 (actividad económica), 286 (género) |
| 2 | Deducciones sin limitantes | 28, 297 (e-invoice), 245, 247, 249, 251, 299 |
| 3 | Patrimonio | 29, 30, 31 |
| 4 | Rentas de trabajo | 32-42 |
| 5 | Rentas de trabajo (no relación laboral) | 43-57 |
| 6 | Rentas de capital | 58-73 |
| 7 | Rentas no laborales | 74-90 |
| 8 | Cédula general | 91-97 |
| 9 | Pensiones | 99-103 |
| 10 | Dividendos | 104-120 |
| 11 | Liquidación privada | 121-133 |
| 12 | Ganancias ocasionales | 112-115 |
| 13 | Anticipo | 130, 133 |
| 14 | Saldo a pagar / favor | 134-141 |
| 15 | Firma y presentación | Electronic signature + submit |
Typical workflow:
# 1. Open headed browser (manual login required for now)
agent-browser --headed --session dian open "https://muisca.dian.gov.co/"
# Log in manually in the browser window
# 2. Navigate to Form 210 creation
# Dashboard → "Presentar Declaración de Renta" → Select year → Crear
# 3. Generate fill commands from tax projection
python3 scripts/fill_form210.py --year 2024 --dry-run # Preview values
python3 scripts/fill_form210.py --year 2024 > /tmp/fill-210.sh # Generate script
# 4. Execute step by step (review each step before advancing)
# Each step: snapshot → fill casillas → verify → next step
# The script uses placeholder refs (<REF_casilla_NNN>) that must be
# replaced with actual @eN refs after each snapshot
# 5. After all steps: Firmar → Presentar → PagarImportant notes:
- DIAN rounds all values to thousands (no decimals) — the script does this automatically
- Field refs (
@eN) change between sessions — always snapshot before filling - For already-filed years (like 2024), DIAN only allows "Corrección" — not new "Inicial"
- The Firmar step requires electronic signature (manual intervention)
- Always review values before submitting — the script fills a draft, it does not auto-submit
See templates/form-210-schema.json for the complete casilla-to-ET-article mapping.
9. optimize — Deduction Optimizer
Script: scripts/optimize_deductions.py --gross-income <COP> --year 2024
Finds the optimal AFC + voluntary pension contribution split to minimize tax.
What it computes:
- Maximum useful contribution before the 1,340 UVT cap binds (Art. 336 Num. 3, Ley 2277/2022)
- Tax savings vs. zero contributions
- Marginal benefit analysis (tax saved per additional $1M COP)
- Warning when the global cap is the binding constraint
Key constraint hierarchy: 1. AFC + voluntary pension ≤ 30% of gross income or 3,800 UVT (Art. 126-1 + 126-4) 2. Total exentas + deducciones ≤ 40% of renta líquida or 1,340 UVT (Art. 336) 3. 25% renta exenta ≤ 790 UVT/year (Art. 206 Num. 10) 4. The 1,340 UVT global cap is typically the binding constraint for incomes above ~$150M COP
10. self-heal — Validation and Anomaly Detection
Script: scripts/self_heal.py --year 2024
Detects data quality issues and signals improvements for agents to act on.
Checks performed:
- Parser health: validates all
parsers/*.jsondefinitions (schema, methods, no duplicates) - Extraction confidence: flags parsers that extracted mostly zeros (PDF format likely changed)
- Expected non-zero fields: catches silent failures (e.g., rendimientos = $0 for a bank that should have them)
- Cross-source validation: compares retenciones and pension amounts across certificates, exogena, and planillas — flags >5% discrepancies
- XLSX schema validation: checks that DIAN export column structure matches expectations before import
- Projection sanity: verifies arithmetic (R34 = R32 - R33), cap compliance (R41 ≤ 1,340 UVT), no negatives
Self-healing loop: 1. Run self_heal.py after every import cycle 2. Issues logged to .control/improvement-log.jsonl with severity and suggestions 3. Agents (Claude or any LLM) read the log and take corrective action:
unknown_institution→ create newparsers/<name>.jsonlow_extraction_confidence→ update parser regex patterns for changed PDF formatexpected_nonzero_field→ investigate specific field in parser definitionretenciones_mismatch→ check for missing certificate or exogena updateexogena_schema_drift→ update column mappings in import script
4. After fix, re-run self_heal.py to verify the issue is resolved
For agents reading this skill: When invoked, always run python3 scripts/self_heal.py --year <year> after importing data. If issues are found, read .control/improvement-log.jsonl for the latest signals and fix the root cause before proceeding with tax projection or form filling. The control metalayer in .control/commands.yaml defines the actuators (create-parser, update-parser, fix-xlsx-schema) that map to each signal type.
11. budget — Monthly Budget Planner
Script: scripts/budget_planner.py --monthly-usd 8000 --year 2025
Computes monthly budget allocation for a Colombian resident earning USD salary.
Allocates:
- Parafiscales PILA (fixed monthly obligation)
- AFC contribution (monthly portion of annual optimal amount)
- Voluntary pension (monthly portion)
- Tax savings fund (saldo a pagar / months to deadline)
- Available for living expenses (remainder)
Outputs a formatted table with COP, USD, and percentage of income for each category.
12. patrimonio — Net Worth Calculator
Script: scripts/patrimonio_calc.py --year 2024 --detail
Aggregates patrimonio from all data sources for Form 210 R29/R30/R31.
Sources:
- Certificates: bank account saldos, investment funds, pension funds
- Exogena: real estate (Marval), vehicle (Bogotá avalúo), stocks (Ecopetrol), third-party reported saldos
- Manual entries:
--add-asset "Cash" 5000000 --add-debt "Loan" 3000000
Deduplication: When the same asset appears in both certificates and exogena, uses the higher value and flags the overlap.
13. report — Accounting Report Generator
Generates comprehensive accounting and tax report in Markdown format, covering:
- Executive summary (year-over-year comparison)
- Monthly salary detail with TRM conversion
- Social security (parafiscales) breakdown
- Patrimonio (assets and liabilities)
- Form 210 comparative table with ET article references
- Deduction strategy analysis
- Monthly budget plan with tax savings fund
- Filing calendar and data source inventory
14. gmail — Email Document Collector
Script: scripts/gmail_collector.py --year 2025
Searches Gmail for tax-relevant documents from all financial institutions using GWS CLI (gws).
Sources searched:
- Thera — salary payment confirmations (extracts USD amount and employer)
- Davivienda — extractos, certificados, transaction notifications
- Nu Colombia — account notifications
- Nequi — transaction notifications
- RappiPay/RappiCard — payment notifications
- Skandia — pension fund notifications
- Compensar — PILA/parafiscales confirmations
- DIAN — official notifications (firma electrónica, RUT, códigos)
- Banco Falabella — certificates and statements
Capabilities:
- Search by year with automatic date filtering
- Extract salary amounts from Thera payment emails ($USD parsed from body)
- Download PDF/XLSX attachments to
~/Dropbox/Declaracion/<year>/Gmail/ - Filter by single source:
--source thera
Requires: GWS CLI installed and authenticated (npx skills add googleworkspace/cli, gws auth login)
15. invoice — DIAN e-invoicing
Issue UBL 2.1 invoices via DIAN SOAP (requires facho + digital certificate).
File Structure
finance-substrate/
├── SKILL.md # This file
├── skill.json # Schema definition
├── scripts/
│ ├── import_csv.py # Bank CSV/OFX parser
│ ├── import_declaracion.py # Bulk import from Declaracion/
│ ├── import_certificates.py # Tax certificate PDF parser
│ ├── import_planillas.py # PILA social security planilla parser
│ ├── parse_engine.py # Declarative parser interpreter
│ ├── fetch_trm.py # TRM API client
│ ├── tax_projection.py # Form 210 tax engine (95% accuracy)
│ ├── fill_form210.py # Agent-browser commands for DIAN wizard
│ ├── optimize_deductions.py # AFC + vol. pension optimizer
│ ├── budget_planner.py # Monthly budget allocation with tax savings
│ ├── patrimonio_calc.py # Net worth calculator (R29/R30/R31)
│ ├── gmail_collector.py # Gmail search for tax docs & salary payments
│ └── self_heal.py # Validation, anomaly detection, self-healing
├── importers/
│ ├── davivienda.json # Column mappings & date format
│ ├── nubank.json # Column mappings & date format
│ ├── nequi.json # Column mappings & date format
│ └── arq.json # Column mappings & date format
├── templates/
│ ├── tax-tables-2024.json # DIAN tax brackets (UVT $47,065)
│ ├── tax-tables-2025.json # DIAN tax brackets (UVT $49,799)
│ ├── tax-tables-2026.json # DIAN tax brackets (projected)
│ └── categories.json # Default category taxonomy (40+ rules)
├── references/
│ ├── dian-calendar.md # Filing deadlines by NIT suffix
│ ├── dian-muisca-automation.md # MUISCA browser automation reference
│ ├── estatuto-tributario.md # ET article citations for all Form 210 rows
│ ├── gmail-data-sources.md # Gmail/GWS CLI patterns for all institutions
│ └── data-architecture.md # Document flow and storage governance
├── reports/
│ └── financial-report-2025.md # Comprehensive annual report with projections
└── README.md # Setup & usageData Directory (user-local, not in skill repo)
~/.finance-substrate/
├── ledger/
│ ├── transactions.jsonl # Append-only unified transaction log
│ ├── accounts.json # Account registry
│ └── rules.json # Categorization rules
├── tax/
│ ├── salary-history.jsonl # Monthly salary payments with FX details
│ ├── certificates.jsonl # Parsed institution certificates
│ ├── exogena.jsonl # DIAN third-party reports
│ ├── withholdings.jsonl # Retenciones tracking
│ └── projections/ # Saved tax projections
├── fx/
│ └── trm-history.jsonl # TRM rate history
└── invoices/
├── issued/ # Outgoing e-invoices
└── received/
└── e-invoices.jsonl # DIAN e-invoices receivedRelated Skills
- [wealth-management](https://github.com/broomva/wealth-management) — Compounds on this skill for long-term wealth building. Reads certificates, patrimonio, and salary data to run compound growth projections, Monte Carlo simulations, goal-based planning, and asset allocation optimization.
- [investment-management](https://github.com/broomva/investment-management) — Full-stack investment analysis and execution. Security screening, philosophy-based scoring, market data, backtesting, portfolio optimization, and trade execution across stocks, crypto, and prediction markets.
Dependencies
- Python 3.10+
openpyxl— Excel parsing (required for declaracion import)pymupdf— PDF text extraction (required for certificate parsing)facho— DIAN SOAP client (optional, only for e-invoicing)agent-browserskill (optional, only for DIAN scraping)- No paid services. No API keys. All data stays local.
version: 1
gates:
- name: validate-parsers
command: "python3 scripts/parse_engine.py --validate"
description: "Validate all parser definitions against schema"
frequency: before_commit
blocking: true
- name: self-heal
command: "python3 scripts/self_heal.py --year {year}"
description: "Run full self-healing validation (parsers, extractions, cross-validation, projection)"
frequency: after_import
blocking: false
sensors:
- name: import-certificates
command: "python3 scripts/import_certificates.py --year {year} --password {pw}"
description: "Parse all certificates and report results"
- name: parser-coverage
command: "python3 scripts/parse_engine.py --coverage"
description: "Report parser coverage and tax_summary keys"
- name: validate-xlsx
command: "python3 scripts/self_heal.py --validate-xlsx {path}"
description: "Validate XLSX schema before importing"
- name: cross-validate
command: "python3 scripts/self_heal.py --year {year}"
description: "Cross-validate certificates vs exogena vs planillas"
actuators:
- name: create-parser
description: "Create a new parsers/<institution>.json following templates/parser-schema.json"
trigger: "unknown_institution in improvement-log.jsonl"
side_effect: "Creates parsers/<name>.json"
- name: update-parser
description: "Update field extraction pattern when PDF format changes"
trigger: "low_extraction_confidence or expected_nonzero_field in improvement-log.jsonl"
side_effect: "Modifies parsers/<name>.json"
- name: fix-xlsx-schema
description: "Update import script column mappings when DIAN changes XLSX format"
trigger: "exogena_schema_drift or einvoice_schema_drift in improvement-log.jsonl"
side_effect: "Modifies importers/*.json or import_declaracion.py"
{"event": "low_extraction_confidence", "severity": "warning", "timestamp": "2026-03-19T20:21:58.964218+00:00", "parser": "nu-colombia", "confidence": 0.25, "total_fields": 4, "zero_fields": 3, "suggestion": "Parser 'nu-colombia' extracted mostly zeros — PDF format may have changed"}
{"event": "expected_nonzero_field", "severity": "warning", "timestamp": "2026-03-19T20:21:58.964383+00:00", "parser": "nu-colombia", "field": "rendimientos_gravados", "value": 0.0, "suggestion": "Field 'rendimientos_gravados' is 0 for nu-colombia — check if PDF format changed"}
{"event": "expected_nonzero_field", "severity": "warning", "timestamp": "2026-03-19T20:21:58.964433+00:00", "parser": "nu-colombia", "field": "retencion_renta", "value": 0.0, "suggestion": "Field 'retencion_renta' is 0 for nu-colombia — check if PDF format changed"}
{"event": "pension_source_discrepancy", "severity": "info", "timestamp": "2026-03-19T20:21:58.966117+00:00", "sources": {"certificates": 17883129, "exogena": 17883128, "planillas": 16320000}, "max_diff": 1563129, "suggestion": "Pension amounts differ across sources — certificates report fund-level, planillas report payment-level"}
version: 1
profile: governed
categories:
- id: parser_accuracy
name: Certificate Parser Accuracy
setpoints: [S1, S2, S3]
- id: coverage
name: Institution & Form 210 Coverage
setpoints: [S4, S5]
- id: agent_improvement
name: Agent Self-Improvement
setpoints: [S6, S7]
setpoints:
- id: S1
category: parser_accuracy
description: "All parser definitions validate against schema"
target: 1.0
measurement: "parsers_valid / parsers_total"
severity: blocking
- id: S2
category: parser_accuracy
description: "Zero 'Unknown' results in certificate import"
target: 0
measurement: "count of 'skipped' results from import_certificates"
severity: informational
- id: S3
category: parser_accuracy
description: "Aggregated retenciones within 1% of DIAN exogena total"
target: 0.99
measurement: "abs(certs_retenciones - exogena_retenciones) / exogena_retenciones"
severity: blocking
- id: S4
category: coverage
description: "Every parser produces at least one tax_summary field"
target: 1.0
measurement: "parsers_with_summary / parsers_total"
severity: blocking
- id: S5
category: coverage
description: "Tax summary maps to all required Form 210 inputs"
target: 1.0
measurement: "mapped_fields / required_form210_fields"
severity: informational
- id: S6
category: agent_improvement
description: "Unknown institution triggers improvement signal"
target: 1.0
measurement: "signals_logged / unknown_encounters"
severity: informational
- id: S7
category: agent_improvement
description: "Agent-created parser definitions pass validation"
target: 1.0
measurement: "agent_created_valid / agent_created_total"
severity: blocking
{
"version": 1,
"last_audit_at": null,
"controller_mode": "governed",
"parsers": {
"total": 10,
"valid": 0,
"last_validated_at": null
},
"last_import": {
"year": null,
"matched": 0,
"unknown": 0
},
"agent_improvements": {
"parsers_created": 0,
"parsers_updated": 0,
"last_improvement_at": null
}
}
__pycache__/
reports/
{
"bank": "arq",
"label": "ARQ (ex-DolarApp) USD Account",
"default_account": "arq-usd",
"currency": "USD",
"encoding": "utf-8",
"delimiter": ",",
"skip_rows": 0,
"date_format": "%Y-%m-%d",
"decimal_separator": ".",
"thousands_separator": ",",
"columns": {
"date": "date",
"description": "description",
"amount": "amount"
},
"notes": "ARQ (formerly DolarApp) does not currently offer CSV export. This profile is a placeholder for manual entry or future export support. If exporting via screenshot-to-CSV conversion, ensure columns match this mapping."
}
{
"bank": "davivienda",
"label": "Davivienda Cuenta de Ahorros / Corriente",
"default_account": "davivienda-savings",
"currency": "COP",
"encoding": "utf-8",
"delimiter": ",",
"skip_rows": 0,
"date_format": "%d/%m/%Y",
"decimal_separator": ",",
"thousands_separator": ".",
"columns": {
"date": "Fecha",
"description": "Descripcion",
"debit": "Debito",
"credit": "Credito"
},
"notes": "Davivienda CSV exports use DD/MM/YYYY dates, Colombian number formatting (. for thousands, , for decimals), and split debit/credit columns. Export from: DaviPlata app > Movimientos > Descargar extracto, or Davivienda web > Cuentas > Extracto > Descargar CSV."
}
{
"bank": "nequi",
"label": "Nequi Billetera Digital",
"default_account": "nequi-wallet",
"currency": "COP",
"encoding": "utf-8",
"delimiter": ",",
"skip_rows": 0,
"date_format": "%d/%m/%Y",
"decimal_separator": ",",
"thousands_separator": ".",
"columns": {
"date": "Fecha",
"description": "Descripción",
"debit": "Valor debito",
"credit": "Valor credito"
},
"notes": "Nequi CSV exports follow Colombian conventions. Export from: Nequi app > Movimientos > Descargar extracto. Column names may vary between PDF-parsed and direct CSV exports — adjust mappings if needed."
}
{
"bank": "nubank",
"label": "Nu Colombia Tarjeta de Credito",
"default_account": "nubank-credit",
"currency": "COP",
"encoding": "utf-8",
"delimiter": ",",
"skip_rows": 0,
"date_format": "%Y-%m-%d",
"decimal_separator": ".",
"thousands_separator": ",",
"columns": {
"date": "date",
"description": "title",
"amount": "amount"
},
"notes": "Nubank Colombia CSV: ISO date format, single amount column (negative = purchase, positive = payment). Export from: Nu app > Tarjeta > Fatura > Compartir > CSV. Column names may vary — adjust if needed."
}
{
"version": 1,
"institution": {
"id": "acciones-valores-fondos",
"name": "ACCIONES & VALORES (FONDOS)",
"cert_type": "certificado-tributario"
},
"identification": {
"require_any": ["fondos de inversión colectiva", "FIC"],
"reject_any": ["RESUMEN DE OPERACIONES"],
"case_sensitive": false
},
"priority": 89,
"password_protected": true,
"sections": [
{
"id": "fondos",
"label": "Investment fund balances",
"fields": [
{ "key": "fic_saldo", "method": "regex", "pattern": "FIC.*?\\$\\s*([\\d.,]+)", "group": 1, "scope": "full_text" }
]
}
],
"computed_fields": [],
"tax_summary_mapping": {
"patrimonio_fondos": "sections.fondos.fic_saldo"
}
}
{
"version": 1,
"institution": {
"id": "acciones-valores",
"name": "ACCIONES & VALORES (BURSÁTIL)",
"cert_type": "certificado-tributario"
},
"identification": {
"require_any": ["RESUMEN DE OPERACIONES", "SALDO EN CAJA"],
"reject_any": ["SKANDIA", "DAVIVIENDA", "fondos de inversión colectiva"],
"case_sensitive": false
},
"priority": 90,
"password_protected": true,
"sections": [
{
"id": "bursatil",
"label": "Stock brokerage data",
"fields": [
{ "key": "saldo_caja", "method": "regex", "pattern": "SALDO EN CAJA:\\s*([\\d.,]+)", "group": 1, "scope": "full_text" },
{ "key": "dividendos", "method": "regex", "pattern": "Dividendos.*?Totales\\s*\\n([\\d.,]+)", "group": 1, "scope": "full_text", "flags": "DOTALL" },
{ "key": "valor_acciones", "method": "regex", "pattern": "Valor neto\\s*\\n.*?([\\d.,]+)\\s*\\nTotal", "group": 1, "scope": "full_text", "flags": "DOTALL" }
]
}
],
"computed_fields": [],
"tax_summary_mapping": {
"patrimonio_caja": "sections.bursatil.saldo_caja",
"dividendos": "sections.bursatil.dividendos",
"patrimonio_acciones": "sections.bursatil.valor_acciones"
}
}
{
"version": 1,
"institution": {
"id": "banco-bogota",
"name": "BANCO DE BOGOTÁ",
"cert_type": "certificado-tributario"
},
"identification": {
"require_any": ["BANCO DE BOGOT"],
"reject_any": [],
"case_sensitive": false
},
"priority": 80,
"password_protected": true,
"sections": [
{
"id": "credito",
"label": "Credit obligations",
"fields": [
{ "key": "intereses_pagados", "method": "regex", "pattern": "intereses.*?suma de:\\s*\\$([\\d.,]+)", "group": 1, "flags": "IGNORECASE DOTALL" }
]
}
],
"computed_fields": [],
"tax_summary_mapping": {
"intereses_pagados_deducible": "sections.credito.intereses_pagados"
}
}
{
"version": 1,
"institution": {
"id": "colmedica",
"name": "COLMEDICA MEDICINA PREPAGADA",
"cert_type": "certificado-tributario"
},
"identification": {
"require_any": ["COLMÉDICA", "COLMEDICA"],
"reject_any": [],
"case_sensitive": false
},
"priority": 50,
"password_protected": true,
"sections": [
{
"id": "prepagada",
"label": "Prepaid health insurance value",
"fields": [
{ "key": "valor", "method": "regex", "pattern": "TITULAR\\s*\\n?([\\d.,]+)", "group": 1 }
]
}
],
"computed_fields": [],
"tax_summary_mapping": {
"medicina_prepagada_deduccion_r39": "sections.prepagada.valor"
}
}
{
"version": 1,
"institution": {
"id": "davivienda",
"name": "BANCO DAVIVIENDA S.A.",
"cert_type": "certificado-tributario"
},
"identification": {
"require_any": ["DAVIVIENDA"],
"reject_any": ["RAPPICARD"],
"case_sensitive": false
},
"priority": 30,
"password_protected": false,
"sections": [
{
"id": "cuentas_totales",
"label": "Savings account totals",
"anchor": {
"pattern": "TOTALES\\n\\d+\\n",
"type": "regex"
},
"fields": [
{ "key": "saldo", "method": "dollar_amounts_after_anchor", "index": 0 },
{ "key": "rendimientos", "method": "dollar_amounts_after_anchor", "index": 1 },
{ "key": "rendimientos_no_gravados", "method": "dollar_amounts_after_anchor", "index": 2 },
{ "key": "retencion_renta", "method": "dollar_amounts_after_anchor", "index": 3 },
{ "key": "gmf_retenido", "method": "dollar_amounts_after_anchor", "index": 4 }
]
},
{
"id": "afc",
"label": "AFC account (Ahorro Fomento a la Construcción)",
"anchor": {
"pattern": "CERTIFICADO PRODUCTO CUENTA AFC",
"type": "string_find"
},
"fields": [
{ "key": "aportes_directos", "method": "label_then_amount", "label": "Aportes Directos" },
{ "key": "aportes_contingente", "method": "label_then_amount", "label": "Aportes con Contingente" },
{ "key": "aportes_sin_contingente", "method": "label_then_amount", "label": "Aportes sin Contingente" },
{ "key": "rendimientos", "method": "label_then_amount", "label": "Rendimientos de los aportes" },
{ "key": "retiros_vivienda", "method": "label_then_amount", "label": "Retiros con destino vivienda" },
{ "key": "saldo_fin", "method": "regex", "pattern": "Saldo a 31/12/\\d{4}\\s*\\n?\\$?([\\d.,]+)", "group": 1 }
]
}
],
"computed_fields": [
{ "key": "total_aportes_afc", "expression": "sections.afc.aportes_directos + sections.afc.aportes_contingente" }
],
"tax_summary_mapping": {
"rendimientos_gravados": "sections.cuentas_totales.rendimientos - sections.cuentas_totales.rendimientos_no_gravados",
"rendimientos_no_gravados": "sections.cuentas_totales.rendimientos_no_gravados",
"retencion_renta": "sections.cuentas_totales.retencion_renta",
"gmf_total": "sections.cuentas_totales.gmf_retenido",
"gmf_deducible_50pct": "sections.cuentas_totales.gmf_retenido * 0.50",
"patrimonio_cuentas": "sections.cuentas_totales.saldo",
"aportes_afc_r35": "computed.total_aportes_afc"
}
}
{
"version": 1,
"institution": {
"id": "nequi",
"name": "NEQUI (BANCOLOMBIA)",
"cert_type": "certificado-tributario"
},
"identification": {
"require_any": ["NEQUI"],
"reject_any": [],
"case_sensitive": false
},
"priority": 70,
"password_protected": true,
"sections": [
{
"id": "deposito",
"label": "Low-amount deposit account",
"fields": [
{ "key": "intereses", "method": "label_then_amount", "label": "Intereses pagados" },
{ "key": "saldo", "method": "regex", "pattern": "Saldo.*?[Dd]epósito.*?\\n\\$([\\d.,]+)", "group": 1 }
]
}
],
"computed_fields": [],
"tax_summary_mapping": {
"patrimonio_cuenta": "sections.deposito.saldo"
}
}
{
"version": 1,
"institution": {
"id": "nu-colombia",
"name": "NU COLOMBIA",
"cert_type": "certificado-tributario"
},
"identification": {
"require_any": ["NU.", "NU COLOMBIA", "NU FINANCIERA"],
"reject_any": [],
"case_sensitive": false
},
"priority": 60,
"password_protected": true,
"sections": [
{
"id": "cuenta",
"label": "Savings account data",
"fields": [
{ "key": "gmf", "method": "regex", "pattern": "GMF o 4x1000\\)\\s*\\n\\$([\\d.,]+)", "group": 1 },
{ "key": "retencion", "method": "regex", "pattern": "Retención en la fuente\\s*\\n\\$([\\d.,]+)", "group": 1 },
{ "key": "rendimientos_totales", "method": "regex", "pattern": "Rendimientos totales del año\\s*\\n\\$([\\d.,]+)", "group": 1 },
{ "key": "rendimientos_no_gravables", "method": "regex", "pattern": "Rendimientos no gravables[^$]*\\$([\\d.,]+)", "group": 1, "flags": "DOTALL" }
]
}
],
"computed_fields": [],
"tax_summary_mapping": {
"rendimientos_gravados": "sections.cuenta.rendimientos_totales - sections.cuenta.rendimientos_no_gravables",
"rendimientos_no_gravados": "sections.cuenta.rendimientos_no_gravables",
"retencion_renta": "sections.cuenta.retencion",
"gmf_total": "sections.cuenta.gmf",
"gmf_deducible_50pct": "sections.cuenta.gmf * 0.50"
}
}
{
"version": 1,
"institution": {
"id": "planilla-pila",
"name": "PLANILLA PILA (Social Security)",
"cert_type": "planilla-pila"
},
"identification": {
"require_any": ["PLANILLA INTEGRADA", "AUTOLIQUIDACION DE APORTES"],
"reject_any": [],
"case_sensitive": false
},
"priority": 5,
"password_protected": false,
"sections": [
{
"id": "subsistemas",
"label": "Social security subsystem totals",
"fields": [
{ "key": "salud", "method": "regex", "pattern": "Salud\\n\\d+\\n([\\d.]+)", "group": 1 },
{ "key": "pension", "method": "regex", "pattern": "Pensión\\n\\d+\\n([\\d.]+)", "group": 1 },
{ "key": "riesgos_laborales", "method": "regex", "pattern": "Riesgos Laborales\\n\\d+\\n([\\d.]+)", "group": 1 },
{ "key": "total", "method": "regex", "pattern": "TOTALES\\n\\d+\\n[\\d.]+\\n([\\d.]+)", "group": 1 }
]
},
{
"id": "cotizante",
"label": "Contributor detail",
"fields": [
{ "key": "ibc_pension", "method": "regex", "pattern": "230901\\n([\\d.]+)", "group": 1, "scope": "full_text" },
{ "key": "cotizacion_pension", "method": "regex", "pattern": "Cotización \\nObligatoria\\n.*?\\n.*?([\\d.]+)\\n", "group": 1, "flags": "DOTALL" },
{ "key": "fsp_solidaridad", "method": "regex", "pattern": "Aporte FSP.*?Solidaridad.*?\\n.*?\\n.*?\\n.*?([\\d.]+)", "group": 1, "flags": "DOTALL" },
{ "key": "fsp_subsistencia", "method": "regex", "pattern": "Aporte FSP.*?Subsistencia.*?\\n.*?\\n.*?\\n.*?\\n.*?([\\d.]+)", "group": 1, "flags": "DOTALL" }
]
},
{
"id": "periodo",
"label": "Payment period",
"fields": [
{ "key": "periodo", "method": "regex", "pattern": "(\\d{4}-\\d{2})", "group": 1, "scope": "full_text" },
{ "key": "fecha_pago", "method": "regex", "pattern": "FECHA PAGO.*?\\n.*?(\\d{2}/\\d{2}/\\d{4})", "group": 1, "scope": "full_text", "flags": "DOTALL" }
]
}
],
"computed_fields": [],
"tax_summary_mapping": {
"salud_obligatoria": "sections.subsistemas.salud",
"pension_obligatoria": "sections.subsistemas.pension",
"riesgos_laborales": "sections.subsistemas.riesgos_laborales",
"total_seguridad_social": "sections.subsistemas.total"
}
}
{
"version": 1,
"institution": {
"id": "rappicard",
"name": "RAPPICARD",
"cert_type": "certificado-tributario"
},
"identification": {
"require_any": ["RAPPICARD"],
"reject_any": [],
"case_sensitive": false
},
"priority": 10,
"password_protected": true,
"sections": [
{
"id": "tarjeta",
"label": "Credit card data (values after Totales row)",
"anchor": {
"pattern": "Totales\\n",
"type": "regex"
},
"fields": [
{ "key": "saldo", "method": "dollar_amounts_after_anchor", "index": 0 },
{ "key": "pagos", "method": "dollar_amounts_after_anchor", "index": 1 },
{ "key": "intereses", "method": "dollar_amounts_after_anchor", "index": 2 },
{ "key": "gmf", "method": "dollar_amounts_after_anchor", "index": 3 },
{ "key": "consumos", "method": "dollar_amounts_after_anchor", "index": 4 }
]
},
{
"id": "cashback",
"label": "Cashback redemption",
"fields": [
{ "key": "valor", "method": "regex", "pattern": "\\$([\\d.,]+)\\s*\\n.*?[Cc]ashback", "group": 1, "scope": "full_text", "flags": "DOTALL" }
]
}
],
"computed_fields": [],
"tax_summary_mapping": {
"deuda_patrimonio": "sections.tarjeta.saldo",
"gmf_total": "sections.tarjeta.gmf",
"gmf_deducible_50pct": "sections.tarjeta.gmf * 0.50"
}
}
{
"version": 1,
"institution": {
"id": "rappicuenta",
"name": "RAPPIPAY (RAPPICUENTA)",
"cert_type": "certificado-tributario"
},
"identification": {
"require_any": ["RAPPICUENTA", "RAPPIPAY"],
"reject_any": ["RAPPICARD"],
"case_sensitive": false
},
"priority": 11,
"password_protected": true,
"sections": [
{
"id": "cuenta",
"label": "Savings account",
"anchor": {
"pattern": "Cuenta ahorros",
"type": "string_find"
},
"fields": [
{ "key": "saldo", "method": "label_then_amount", "label": "Cuenta ahorros" },
{ "key": "rendimientos", "method": "regex", "pattern": "Rendimientos\\nfinancieros[^$]*\\$([\\d.,]+)", "group": 1, "scope": "full_text", "flags": "DOTALL" },
{ "key": "rendimientos_no_gravables", "method": "regex", "pattern": "no\\ngravados\\*?\\s*\\$([\\d.,]+)", "group": 1, "scope": "full_text" },
{ "key": "gmf", "method": "regex", "pattern": "GMF\\*{0,2}\\s*\\$([\\d.,]+)", "group": 1, "scope": "full_text" }
]
}
],
"computed_fields": [],
"tax_summary_mapping": {
"patrimonio_cuenta": "sections.cuenta.saldo",
"rendimientos_gravados": "sections.cuenta.rendimientos - sections.cuenta.rendimientos_no_gravables",
"rendimientos_no_gravados": "sections.cuenta.rendimientos_no_gravables",
"gmf_total": "sections.cuenta.gmf",
"gmf_deducible_50pct": "sections.cuenta.gmf * 0.50"
}
}
{
"version": 1,
"institution": {
"id": "skandia",
"name": "SKANDIA",
"cert_type": "certificado-tributario"
},
"identification": {
"require_any": ["SKANDIA"],
"reject_any": [],
"case_sensitive": false
},
"priority": 40,
"password_protected": true,
"sections": [
{
"id": "pension_obligatoria",
"label": "Mandatory pension (may have multiple fund sections)",
"fields": [
{ "key": "aportes_independientes", "method": "sum_all_regex_matches", "pattern": "Obligatorios independientes\\s*\\n([\\d.,]+)", "group": 1, "scope": "full_text" },
{ "key": "fondo_solidaridad", "method": "sum_all_regex_matches", "pattern": "Fondo de Solidaridad Pensional\\s*\\n([\\d.,]+)", "group": 1, "scope": "full_text" }
]
},
{
"id": "voluntaria",
"label": "Voluntary pension (Multifund or similar)",
"anchor": {
"pattern": "Fondo Voluntario de Pensi",
"type": "string_find"
},
"fields": [
{ "key": "aportes_con_retencion", "method": "line_after_label", "label": "Aportes realizados durante el año con retención contingente" },
{ "key": "aportes_sin_retencion", "method": "line_after_label", "label": "Aportes realizados durante el año sin retención contingente" },
{ "key": "valorizacion", "method": "line_after_label", "label": "Valorización de aportes" },
{ "key": "retiro_aportes_contingente", "method": "regex", "pattern": "Retiro de aportes efectuados con retención contingente\\s*\\n([\\d.,]+)", "group": 1 },
{ "key": "retiro_rendimientos", "method": "regex", "pattern": "Retiro de rendimientos sin cumplimiento.*?\\n([\\d.,]+)", "group": 1 },
{ "key": "retencion_contingente", "method": "line_after_label", "label": "Retención contingente practicada" },
{ "key": "retencion_rendimientos", "method": "regex", "pattern": "Retención.*?sobre rendimientos\\s*\\n([\\d.,]+)", "group": 1 },
{ "key": "saldo_fin", "method": "regex", "pattern": "Saldo a diciembre 31 de {year}\\s*\\n([\\d.,]+)", "group": 1 }
]
},
{
"id": "cesantias",
"label": "Severance fund (cesantías)",
"anchor": {
"pattern": "Fondo de Cesant",
"type": "string_find"
},
"fields": [
{ "key": "saldo", "method": "regex", "pattern": "2017 en adelante.*?\\n([\\d.,]+)", "group": 1 },
{ "key": "rendimientos", "method": "line_after_label", "label": "Rendimientos causados durante el año" }
]
}
],
"computed_fields": [
{ "key": "pension_oblig_total", "expression": "sections.pension_obligatoria.aportes_independientes + sections.pension_obligatoria.fondo_solidaridad" },
{ "key": "retencion_total", "expression": "sections.voluntaria.retencion_contingente + sections.voluntaria.retencion_rendimientos" }
],
"tax_summary_mapping": {
"pension_oblig_plus_fsp": "computed.pension_oblig_total",
"pension_voluntaria_aportes": "sections.voluntaria.aportes_con_retencion",
"retencion_total": "computed.retencion_total",
"retiro_aportes_capital_r58": "sections.voluntaria.retiro_aportes_contingente",
"retiro_rendimientos_capital_r58": "sections.voluntaria.retiro_rendimientos",
"patrimonio_cesantias": "sections.cesantias.saldo",
"patrimonio_voluntaria": "sections.voluntaria.saldo_fin",
"rendimientos_cesantias": "sections.cesantias.rendimientos",
"rendimientos_voluntaria": "sections.voluntaria.valorizacion"
}
}
finance-substrate
Personal finance and Colombian tax management. Zero paid dependencies.
Setup
# No external packages needed for core functionality
# Optional: DIAN e-invoicing support
pip install fachoData is stored locally at ~/.finance-substrate/ — created automatically on first run.
Usage
Import bank transactions
python3 scripts/import_csv.py --bank davivienda --file ~/Downloads/extracto.csv
python3 scripts/import_csv.py --bank nubank --file ~/Downloads/nu-fatura.csv
python3 scripts/import_csv.py --bank nequi --file ~/Downloads/nequi-movimientos.csvFetch TRM (USD/COP exchange rate)
python3 scripts/fetch_trm.py # Latest rate
python3 scripts/fetch_trm.py --days 30 # Last 30 days
python3 scripts/fetch_trm.py --date 2026-03-15 # Specific dateTax projection
python3 scripts/tax_projection.py --year 2026Bank Export Instructions
Davivienda
App > Cuentas > Extracto > Descargar CSV
Nubank Colombia
App > Tarjeta > Fatura > Compartir > CSV
Nequi
App > Movimientos > Descargar extracto
ARQ (ex-DolarApp)
No export available yet. Use manual entry or screenshot-to-CSV.
Importer Profiles
Column mappings for each bank are in importers/*.json. If your bank's CSV format changes, edit the profile — no code changes needed.
Categorization
Default rules in templates/categories.json cover common Colombian merchants. Custom rules are saved to ~/.finance-substrate/ledger/rules.json via the categorize skill mode.
Data Architecture — Finance Substrate
Document Flow
Gmail (automated via GWS CLI)
gws gmail → scripts/gmail_collector.py
├── Thera salary payment emails → salary amounts in USD
├── Davivienda extracto PDFs → bank statements
├── Skandia/Nu/Nequi/Rappi notifications → certificate alerts
├── Compensar PILA confirmations → parafiscales verification
└── DIAN notifications → filing reminders, RUT copies
DIAN MUISCA (automated via agent-browser)
agent-browser → scripts/import_certificates.py
├── Exogena XLSX → third-party reported data
├── E-invoices XLSX → electronic invoice history
└── RUT PDF → taxpayer registration
Source documents (user provides)
~/Dropbox/Declaracion/<year>/
├── Certificado Tributario *.pdf ← Bank/institution tax certificates
├── Planillas/Planilla *.pdf ← Monthly PILA social security slips
├── Reporte Informacion Exogena.xlsx ← DIAN exogena (manual download)
├── Reporte Facturas Electronicas.xlsx ← DIAN e-invoices (manual download)
├── Reporte Salarios.xlsx ← Salary payment history
├── Extractos Davivienda/ ← Bank statement PDFs + consolidated xlsx
├── Borrador Declaración*.pdf ← DIAN draft declaration (for calibration)
└── MUISCA/ ← Auto-downloaded from DIAN portal
├── reporteExogena<year>.xlsx
├── Reporte-Facturas-Electronicas-<year>.xlsx
└── RUT-Copia.pdf
↓
Import scripts parse source documents
scripts/import_certificates.py --password <CC>
scripts/import_declaracion.py --year <year>
scripts/import_planillas.py --year <year>
↓
Processed data (local, not in repo)
~/.finance-substrate/
├── ledger/
│ ├── transactions.jsonl ← Bank transactions (deduped, categorized)
│ ├── accounts.json ← Account registry
│ └── rules.json ← Auto-categorization rules
├── tax/
│ ├── salary-history.jsonl ← Monthly salary with FX details
│ ├── certificates.jsonl ← Parsed institution certificates
│ ├── exogena.jsonl ← DIAN third-party reported data
│ ├── planillas.jsonl ← Monthly social security payments
│ └── withholdings.jsonl ← Retenciones tracking
├── fx/
│ └── trm-history.jsonl ← USD/COP exchange rate cache
├── invoices/
│ └── received/
│ └── e-invoices.jsonl ← DIAN electronic invoices received
└── dian-session.json ← MUISCA browser session (SENSITIVE)
↓
Tax engine computes
scripts/tax_projection.py --year <year>
scripts/optimize_deductions.py
↓
Output: Form 210 values
scripts/fill_form210.py → agent-browser commands for DIAN wizardStorage Locations
| Location | Contents | Backup | Sensitive? |
|---|---|---|---|
~/Dropbox/Declaracion/<year>/ | Source PDFs, XLSX, certificates | Dropbox sync | Yes (PII in PDFs) |
~/Dropbox/Declaracion/<year>/MUISCA/ | Auto-downloaded DIAN exports | Dropbox sync | Yes |
~/.finance-substrate/ | Processed JSONL data | Not backed up — regenerable from source docs | Yes (parsed PII) |
~/.finance-substrate/dian-session.json | MUISCA auth cookies | Not backed up | Highly sensitive — contains session tokens |
~/.agents/skills/finance-substrate/ | Skill code (installed) | Git repo at broomva/finance-substrate | No PII |
MUISCA Download Convention
When downloading from DIAN MUISCA via agent-browser, files land in ~/Downloads/. After download, move them to ~/Dropbox/Declaracion/<year>/MUISCA/ with clear names:
| DIAN filename | Renamed to |
|---|---|
reporteExogena<year>.xlsx | reporteExogena<year>.xlsx |
report.xlsx | Reporte-Facturas-Electronicas-<year>.xlsx |
<number>.pdf (RUT) | RUT-Copia.pdf |
Data Regeneration
All processed data in ~/.finance-substrate/ can be regenerated from source documents:
# Regenerate everything from source PDFs and XLSX
python3 scripts/import_declaracion.py --year 2024
python3 scripts/import_certificates.py --year 2024 --password <CC>
python3 scripts/import_planillas.py --year 2024
python3 scripts/self_heal.py --year 2024
python3 scripts/tax_projection.py --year 2024Year-by-Year Directory Structure
~/Dropbox/Declaracion/
├── 2021/
├── 2022/
├── 2023/
│ ├── Certificado *.pdf
│ ├── international_transfers_2023.csv
│ ├── national_transfers_2023.csv
│ └── Pagos Parafiscales/
├── 2024/
│ ├── Certificado Tributario *.pdf (10 institutions)
│ ├── Planillas/Planilla * 2024.pdf (12 months)
│ ├── Extractos Davivienda/ (12 months + consolidated xlsx)
│ ├── Reporte Informacion Exogena.xlsx
│ ├── Reporte Facturas Electronicas.xlsx
│ ├── Reporte Salarios.xlsx
│ ├── Borrador Declaración de Renta 2024.pdf
│ ├── Comprobante Pago DIAN Renta 2025.pdf
│ └── MUISCA/ (auto-downloaded)
└── 2025/
└── (in progress)Security Notes
- Never commit
~/.finance-substrate/contents to git - Never commit source PDFs or XLSX to git
- The DIAN session file (
dian-session.json) contains auth tokens — delete after use or encrypt - PDF passwords (CC number) should be prompted at runtime, never stored in scripts
- The skill repo (
broomva/finance-substrate) contains zero PII — verified bygrepsweep
DIAN Filing Calendar — Persona Natural
Declaración de Renta (Año Gravable 2025, Presentación 2026)
Filing dates are determined by the last two digits of your NIT or cédula de ciudadanía.
Run: python3 scripts/tax_projection.py --year 2026 to see your specific deadline.
See templates/tax-tables-2026.json → filing_calendar.deadlines for the full schedule.
Key Dates
| Obligation | Period | Deadline Range |
|---|---|---|
| Renta persona natural | AG 2025 | Aug 12 – Oct 22, 2026 |
| Activos en el exterior | AG 2025 | Same as renta |
| IVA bimestral | Bim 1-6, 2026 | 2 months after bimestre end |
| Retención en la fuente | Monthly 2026 | ~10th of following month |
| ICA (Bogotá) | Bim 1-6, 2026 | Per SHD calendar |
UVT Values
| Year | UVT (COP) | Source |
|---|---|---|
| 2024 | 44,007 | DIAN Resolución 001264 |
| 2025 | 47,065 | DIAN Resolución 001233 |
| 2026 | 49,799 | Projected (verify when published) |
Useful DIAN Links
- MUISCA portal:
https://muisca.dian.gov.co/WebArquitectura/DefLoginOld.faces - RUT consultation:
https://www.dian.gov.co/tramitesservicios/Paginas/RUT.aspx - E-invoicing portal:
https://catalogo-vpfe.dian.gov.co/ - Tax calendar:
https://www.dian.gov.co/impuestos/personas/Paginas/plazos.aspx
DIAN MUISCA Browser Automation Reference
Technical reference for automating access to the DIAN MUISCA portal using agent-browser.
Portal Architecture
| Layer | Technology | URL Pattern |
|---|---|---|
| Login SPA | Custom JS (WSO2-style OAuth) | muisca.dian.gov.co/WebIdentidadLogin/ |
| Auth callback | REST STS | /IdentidadRest_LoginFiltro/api/sts/v1/auth/callback |
| Portal | JavaServer Faces (JSF) | muisca.dian.gov.co/WebArquitectura/DefLogin.faces |
| Session | JSESSIONID cookie + JSF ViewState | Server-side state saving |
Login Flow
The login URL encodes an OAuth-like request as base64 JSON in the ideRequest parameter:
{
"clientId": "Wo0aKAlB7vRP_16frPI1x9ZphBEa",
"redirect_uri": "http://muisca.dian.gov.co/IdentidadRest_LoginFiltro/api/sts/v1/auth/callback?redirect_uri=...",
"params": {"tipoUsuario": "muisca"}
}On successful auth, the SPA redirects through the STS callback to the JSF portal at DefLogin.faces.
Error code 10001 in the ideRequest = invalid credentials.
Working Login Sequence (agent-browser)
# 1. Open MUISCA — auto-redirects to WebIdentidadLogin SPA
agent-browser open "https://muisca.dian.gov.co/"
agent-browser wait --load networkidle
agent-browser wait 2000
# 2. Open document type dropdown (must click to expand, not use select)
agent-browser snapshot -i
agent-browser click @e8 # listbox ref
agent-browser snapshot -i # Re-snapshot to see options
# 3. Select "Cédula de ciudadanía" by clicking the option directly
# IMPORTANT: Do NOT use `select` command — it resets other form fields
agent-browser click @e17 # "Cédula de ciudadanía" option ref
agent-browser wait 1000 # Wait for field enable animation
# 4. Fill credentials
agent-browser fill @e9 "<CC_NUMBER>"
agent-browser fill @e10 '<PASSWORD>' # Single quotes for special chars (!@#)
# 5. Accept terms
agent-browser check @e15 # "Acepto el tratamiento de datos personales"
# 6. Submit
agent-browser click @e12 # "Ingresar" button
# 7. Wait for redirect to JSF portal
agent-browser wait --url "**/DefLogin.faces"
# 8. Save session for reuse
agent-browser state save dian-auth.jsonKnown Issues
select resets form fields
The agent-browser select command on the document type listbox triggers a form reset in the SPA, clearing other fields. Always use `click` on the option element directly.
Special characters in password
Passwords with !, $, or backticks cause shell expansion issues.
- Use single quotes around the password in
fillcommand - If that fails, use JS eval with
--stdin:
agent-browser eval --stdin <<'EOF'
const input = document.querySelector('input[type="password"]');
const setter = Object.getOwnPropertyDescriptor(HTMLInputElement.prototype, 'value').set;
setter.call(input, 'password_here');
input.dispatchEvent(new Event('input', { bubbles: true }));
input.dispatchEvent(new Event('change', { bubbles: true }));
EOF- Note: JS-set values may not trigger SPA framework bindings. Prefer
fill.
Password expiration
DIAN passwords expire periodically. If codigo_error: 10001 appears, verify credentials manually at muisca.dian.gov.co before debugging automation.
Angular SPA login workaround
The login SPA uses Angular with ngModel bindings. Playwright's fill sets the DOM value and shows ng-reflect-model correctly, but the Angular form validation doesn't trigger submission reliably via programmatic click. Recommended workflow: 1. Open headed browser: agent-browser --headed --session dian open "https://muisca.dian.gov.co/" 2. Log in manually in the visible browser window 3. Save session: agent-browser --session dian state save ~/.finance-substrate/dian-session.json 4. All subsequent operations are fully automated
Post-Login Navigation
Dashboard URL: https://muisca.dian.gov.co/WebDashboard/DefDashboard.faces
| Service | URL | Data |
|---|---|---|
| Dashboard | /WebDashboard/DefDashboard.faces | Main portal with all links |
| RUT copy | Click "Obtener copia RUT" Submit button | Downloads PDF automatically |
| Filed documents | /WebDiligenciamiento/DefConsDocumentos.faces | Tax returns |
| Payment receipts | /WebDiligenciamiento/DefConsultaYPagoRecibos.faces | Payment history |
| Form filing | /WebDiligenciamiento/DefDiligenciamientoFormularios.faces | Fill tax forms |
| Exogena | Click "Consultar información Exógena" Submit | XLSX download after year select |
| E-invoices | Click "Consulta Facturas electrónicas" Submit | XLSX download after year select |
Tested Download Sequences (Validated March 2026)
Exogena (reporteExogena{year}.xlsx)
# From dashboard — click the Submit button next to "Consultar información Exógena"
agent-browser snapshot -i
agent-browser click @e18 # Submit button for exogena
agent-browser wait 3000
agent-browser snapshot -i -C # Find Aceptar in terms dialog
agent-browser click @e20 # "Aceptar" (Submit in dialog)
agent-browser wait 3000
agent-browser snapshot -i # Find year combobox
agent-browser select @e24 "2024" # Select year
agent-browser wait 500
agent-browser click @e25 # Submit next to year → triggers XLSX download
# File appears in ~/Downloads/reporteExogena2024.xlsxE-Invoices (report.xlsx)
# From dashboard — click Submit next to "Consulta Facturas electrónicas"
agent-browser click @e17 # Submit for e-invoices
agent-browser wait 5000 # Terms dialog loads
agent-browser snapshot -i -C
agent-browser click @e18 # "Aceptar" in terms dialog
agent-browser wait 3000
agent-browser snapshot -i # Year combobox appears
agent-browser select @e21 "2024"
agent-browser wait 500
agent-browser click @e22 # Submit → triggers XLSX download
# File appears in ~/Downloads/report.xlsxRUT (PDF)
# From dashboard — click Submit next to "Obtener copia RUT"
agent-browser click @e9 # Submit for RUT
agent-browser wait 8000 # PDF downloads automatically
# File appears in ~/Downloads/<numero>.pdfNotes on JSF dialog flow
All data downloads follow the same pattern: 1. Click Submit button on dashboard → terms dialog opens as modal overlay 2. Click "Aceptar" (Submit inside dialog) → year selector appears 3. Select year from combobox → click Submit → file downloads 4. Dialog closes, back to dashboard
Ref numbers (@eN) change between sessions — always snapshot -i before interacting.
Form 210 — Declaración de Renta Filing
Creating a Draft
# From dashboard — click "Presentar Declaración de Renta"
agent-browser snapshot -i
agent-browser click @e26 # "Presentar Declaración de Renta" link
# On the "Declaraciones presentadas" page — click sidebar icon 2
agent-browser eval 'document.querySelectorAll(".step-counter")[1]?.click() || document.querySelectorAll(".stepper-item")[1]?.click()'
# Or navigate directly:
agent-browser open "https://muisca.dian.gov.co/WebDilIngresoFormRenta210/#/ingreso/crearFormulario"
agent-browser wait 3000
# Select year and create
agent-browser snapshot -i
agent-browser select @e6 "2024" # Year dropdown
agent-browser wait 1000
agent-browser click @e4 # "Crear" button
agent-browser wait 8000
# NOTE: If year already has "Inicial", DIAN returns error:
# "Ya ha presentado una declaración... debe diligenciar una corrección"
# In that case, select a different year or use "Corrección" modeAnswering Preliminary Questions
# Step 0: Residency question
agent-browser snapshot -i
agent-browser click @e7 # ">183 días" = tax resident
agent-browser wait 2000
agent-browser find text "Siguiente" click # Confirm resident status
# Step 1: Datos Declarante (pre-filled from RUT)
agent-browser snapshot -i
agent-browser select @e16 "2 - Masculino" # Casilla 286: Género
agent-browser select @e18 "0010 - ASALARIADOS" # Casilla 24: Actividad
agent-browser click @e19 # "Siguiente"
agent-browser wait 3000Navigating Wizard Steps
The Form 210 has 15 steps. Navigate between them by clicking the step counters:
# Jump to step N (0-indexed)
agent-browser eval 'document.querySelectorAll(".step-counter")[N].click()'
agent-browser wait 3000
agent-browser snapshot -i # Get field refs for this stepFilling Casillas
# After snapshot, find the textbox for a casilla and fill it
# Example: Step 2, casilla 297 (e-invoice value)
agent-browser fill @e10 "32602363" # Casilla 297
agent-browser press Tab # Trigger auto-compute of casilla 28
# Example: Step 3, casilla 29 (patrimonio bruto)
agent-browser fill @eNN "254417000" # Replace @eNN with actual ref
# DIAN auto-computes derived casillas (disabled fields)
# Only fill editable casillas — skip computed onesUsing the Filler Script
# Generate fill commands from the tax projection
python3 scripts/fill_form210.py --year 2024 --dry-run # Preview
python3 scripts/fill_form210.py --year 2024 > /tmp/fill.sh # Script
# The script outputs agent-browser commands with placeholder refs
# Replace <REF_casilla_NNN> with actual @eN refs after each snapshotSaving, Signing, and Submitting
# Save draft (floppy icon in top-right)
agent-browser click @e4 # Save icon (ref varies)
# NOTE: Save may fail if mandatory fields in current section are empty
# After all 14 steps are filled:
# Step 15: Firmar → requires electronic signature (manual)
# Then: Presentar → confirms submission
# Then: Pagar → payment via PSE or receiptKey Behaviors
- DIAN rounds all values to nearest thousand ($000)
- Some casillas auto-compute when you Tab out of related fields
- The "Siguiente" button validates the current step before advancing
- Clicking step counters directly skips validation (useful for jumping ahead)
- Draft auto-saves when navigating between steps (not guaranteed)
- For corrections: the form pre-fills with values from the Inicial filing
Security Measures
| Measure | Present? | Impact |
|---|---|---|
| CAPTCHA on login | No | Login is automatable |
| CAPTCHA on public queries | Yes (RUT status) | Blocks unauthenticated scraping |
| Virtual keyboard | Yes (optional) | Not blocking — DOM fill bypasses it |
| 2FA/verification code | On sensitive ops only | Email/SMS code, 15 min validity |
| Session timeout | ~15-30 min inactivity | Re-login needed |
| F5 BigIP load balancer | Yes | Potential connection throttling |
Legal Considerations
- Accessing your own account with automation is defensible under Colombia's
habeas data rights (Art. 15 Constitution, Ley 1581/2012)
- Ley 1273/2009 Art. 269A: Criminalizes "unauthorized" access — your own credentials
on your own account = authorized
- DIAN ToU don't explicitly prohibit automation
- Best practice: reasonable request rates, no CAPTCHA bypassing, own account only
Estatuto Tributario — Reference for Form 210 Calculations
Legal basis for every calculation in the finance-substrate tax engine. Año gravable 2024. UVT = $47,065 COP (Resolución DIAN 000187 de 2023, Art. 868 ET).
R33 — Ingresos No Constitutivos de Renta (INCR)
| Component | ET Article | Rule | Rate |
|---|---|---|---|
| Pension obligatoria | Art. 55 | All mandatory pension contributions are INCR | 16% of IBC |
| Salud obligatoria | Art. 56 (Ley 1819/2016 Art. 14) | All mandatory health contributions are INCR | 12.5% of IBC |
| FSP (Solidaridad + Subsistencia) | Ley 100/1993 Art. 25 | Mandatory FSP contributions are INCR | 1% of IBC (if IBC ≥ 4 SMLMV) |
| ARL | Art. 107 ET | NOT INCR — deductible as cost/expense | 0.522% (Nivel I) |
| Voluntary pension aportes | Art. 126-1 | Contributions to voluntary funds are renta exenta (not INCR) | Up to 30% income / 3,800 UVT |
IBC for Independents
- IBC = 40% of gross monthly income (excluding IVA)
- Legal basis: Decreto 1273 de 2018 (operational), originally Art. 135 Ley 1753/2015
- Floor: 1 SMLMV ($1,300,000). Ceiling: 25 SMLMV ($32,500,000)
CRITICAL: ARL is NOT INCR
ARL contributions are deductible as a business expense (Art. 107 ET), not as INCR under Arts. 55-56.
R35 — AFC + Voluntary Pension (Rentas Exentas)
| Component | ET Article | Limit |
|---|---|---|
| AFC (Ahorro Fomento Construcción) | Art. 126-4 | Combined with voluntary pension |
| Voluntary pension contributions | Art. 126-1 (Ley 2010/2019 Art. 31) | Combined with AFC |
| Combined cap | Art. 126-1 + 126-4 | 30% of annual income OR 3,800 UVT ($178,847,000), whichever is LOWER |
Permanence requirement (Art. 126-1, 126-4)
- Funds must remain for minimum 10 years, OR be used for housing, OR withdrawn at pension age/death/disability
- Non-compliant withdrawal: retroactive taxation + retención
R36 — 25% Renta Exenta
| Rule | ET Article | Limit |
|---|---|---|
| 25% of labor income is exempt | Art. 206 Numeral 10 (modified by Ley 2277/2022 Art. 2) | 790 UVT annually ($37,181,350) |
Post-reform change (Ley 2277/2022)
- Before: 240 UVT/month = 2,880 UVT/year
- After (AG 2023+): 790 UVT/year (flat annual cap)
For independents (Art. 206 Parágrafo 5)
- Can claim 25% if income qualifies as rentas de trabajo (honorarios)
- Cannot claim both 25% exemption AND actual cost deductions
R41 — Global Limitation
| Rule | ET Article | Limit |
|---|---|---|
| Total exentas + deducciones cap | Art. 336 Numeral 3 (Ley 2277/2022 Art. 7) | 40% of renta líquida OR 1,340 UVT ($63,067,100), whichever is LOWER |
CRITICAL: Ley 2277/2022 Change
- Before reform: 5,040 UVT ($237,207,600)
- After reform (AG 2023+): 1,340 UVT ($63,067,100)
- This is the single most impactful change for high-income earners
Dependientes (Art. 336 Parágrafo, Ley 2277)
- 72 UVT per dependent ($3,388,680) × max 4 dependents = 288 UVT ($13,554,720)
- This is ADDITIONAL to the 1,340 UVT cap — not subject to it
R39 — Deducciones Imputables
| Deduction | ET Article | Limit |
|---|---|---|
| Medicina prepagada | Art. 387 | 16 UVT/month ($752,960) = 192 UVT/year ($9,036,480) |
| GMF (4x1000) | Art. 115 | 50% of GMF paid is deductible. No causal requirement |
| E-invoice 1% | Art. 336 Numeral 5 (Ley 2277/2022) | 1% of purchases with factura electrónica, cap 240 UVT ($11,295,600) |
| Intereses vivienda | Art. 119 | Up to 1,200 UVT/year |
| Dependientes | Art. 387 | 10% of gross income, cap 32 UVT/month |
E-invoice 1% conditions (Art. 336 Num. 5)
- Purchase must NOT be claimed as cost, deduction, or other benefit
- Factura must be validated by DIAN
- Payment via debit/credit card or electronic means
- IVA is included in the 1% base (Concepto DIAN 379/2024)
R58 — Rentas de Capital
| Component | ET Article | Treatment |
|---|---|---|
| Rendimientos financieros | Arts. 38, 40-1, 41 | 50.88% is INCR (componente inflacionario AG 2024, Decreto 771/2025) |
| Voluntary pension retiros (compliant) | Art. 126-1 | Renta exenta (10+ years, housing, pension age) |
| Voluntary pension retiros (non-compliant) | Art. 126-1 | Taxable + 7% retención (contributions post Jan 2017) |
| RAIS voluntary retiros (non-compliant) | Art. 55 inciso 3 | Taxable + 35% retención |
Componente inflacionario AG 2024
- 50.88% of rendimientos is INCR (Decreto 771 de 2025)
- Only 49.12% is taxable
- Applies to personas naturales not obligated to keep accounting books
R121 — Tax Brackets (Art. 241 ET)
| Range (UVT) | Rate | Base tax (UVT) |
|---|---|---|
| 0 – 1,090 | 0% | 0 |
| 1,090 – 1,700 | 19% | 0 |
| 1,700 – 4,100 | 28% | 115.9 |
| 4,100 – 8,670 | 33% | 787.9 |
| 8,670 – 18,970 | 35% | 2,296 |
| 18,970 – 31,000 | 37% | 5,901 |
| > 31,000 | 39% | 10,352 |
R130/R133 — Anticipo de Renta (Arts. 807-811 ET)
| Declaration year | Rate |
|---|---|
| 1st year | 25% |
| 2nd year | 50% |
| 3rd+ year | 75% |
Formula: Anticipo = (Impuesto neto × Rate) − Retenciones del año If negative, anticipo = 0.
Two methods (taxpayer chooses):
- Method A: Apply rate to current year's net tax
- Method B: Apply rate to average of two preceding years' net tax
R132 — Retenciones
| Source | Rate | ET Article |
|---|---|---|
| Rendimientos CDT | 4% | Art. 395 |
| Rendimientos cuentas ahorro | 7% (above 0.055 UVT/day threshold) | Art. 395 |
| Voluntary pension non-compliant withdrawal | 7% | Art. 126-1 |
| RAIS voluntary non-compliant withdrawal | 35% | Art. 55 |
Sources
Gmail Data Sources for Tax & Finance
Reference for extracting tax-relevant data from Gmail using GWS CLI (gws).
Setup
npx skills add googleworkspace/cli -y -g
gws auth login # Opens browser for Google OAuthSource Registry
Salary Income
| Source | Query | Data extracted | Tax relevance |
|---|---|---|---|
| Thera | from:thera received payment | USD amount, employer name, payment date | R32 ingresos brutos (salary in USD) |
Thera email format:
Subject: You just received a payment!
Body: "You received USD XXXX for your contract with <Employer> on Thera."Parser extracts: USD (\d+) from body text. Payments may be bi-monthly or monthly depending on contract.
Known issues:
- Some payments may show different amounts (bonuses, partial months)
- Employer name may change if contract is renewed
- Email subject is consistent: "You just received a payment!"
Bank Statements & Certificates
| Source | Query | Key emails |
|---|---|---|
| Davivienda | from:davivienda (extracto OR certificado OR transaccion) | Monthly extractos (PDF), certificado tributario, transaction alerts |
| Nu Colombia | from:nu.com.co | Account notifications, rendimientos, certificado anual |
| Nequi | from:nequi | Transaction confirmations, certificado tributario |
| RappiPay/RappiCard | from:rappipay OR from:rappicard | Payment notifications, certificado tributario |
| Banco Falabella | from:falabella (certificado OR extracto) | Certificado tributario, extracto TC |
| Skandia | from:skandia | Pension fund notifications, extractos, certificado tributario |
Davivienda extracto format:
- Subject:
Extractos Portafolio Banco Davivienda YYYYMMDD - Usually has PDF attachment with monthly statement
- Sent monthly around the 3rd-5th of the following month
Social Security
| Source | Query | Data extracted |
|---|---|---|
| Compensar | from:compensar (planilla OR pago OR parafiscal) | PILA payment confirmations |
Tax Authority
| Source | Query | Key emails |
|---|---|---|
| DIAN | from:dian.gov.co | Firma electrónica, RUT copy, verification codes, exogena notifications |
GWS CLI Patterns
Search messages
gws gmail users messages list --params '{"userId": "me", "q": "from:davivienda extracto after:2025/01/01", "maxResults": 10}'Get message details
gws gmail users messages get --params '{"userId": "me", "id": "<MSG_ID>", "format": "metadata"}'Get message body (full)
gws gmail users messages get --params '{"userId": "me", "id": "<MSG_ID>", "format": "full"}'
# Body is base64url-encoded in payload.parts[].body.dataDownload attachment
gws gmail users messages attachments get --params '{"userId": "me", "messageId": "<MSG_ID>", "id": "<ATTACHMENT_ID>"}'
# Returns base64url-encoded data in .data fieldPaginate all results
gws gmail users messages list --params '{"userId": "me", "q": "from:thera after:2025/01/01"}' --page-all --page-limit 5Integration Strategy
Annual tax preparation workflow
# 1. Collect all salary payment emails → verify income total
python3 scripts/gmail_collector.py --year 2025 --source thera
# 2. Download bank statements with attachments
python3 scripts/gmail_collector.py --year 2025 --source davivienda --download-attachments
# 3. Collect all sources for completeness check
python3 scripts/gmail_collector.py --year 2025
# 4. Cross-reference Gmail salary total vs salary-history.jsonl
# Compare Gmail payment count × avg amount against ledger total
# Gap: some payments in late Dec may be dated Jan, or format varies
# 5. Download certificates when available (Jan-Mar of following year)
python3 scripts/gmail_collector.py --year 2025 --source skandia --download-attachments
python3 scripts/gmail_collector.py --year 2025 --source davivienda --download-attachmentsAutomated monthly monitoring
The collector can be run monthly to track:
- New salary payments received
- Bank statement availability
- DIAN notifications requiring action
- Compensar PILA payment confirmations
GWS CLI for Other Services
Beyond Gmail, GWS CLI can enhance the skill with:
Google Sheets — Tax tracking spreadsheet
# Create a spreadsheet for tracking monthly income/expenses
gws sheets spreadsheets create --json '{"properties": {"title": "Tax Tracker 2025"}}'
# Write salary data
gws sheets spreadsheets values update --params '{"spreadsheetId": "...", "range": "A1"}' --json '{"values": [["Month", "USD", "TRM", "COP"]]}'Google Calendar — Tax deadline reminders
# Create reminders for filing deadlines
gws calendar events insert --params '{"calendarId": "primary"}' --json '{
"summary": "DIAN Renta AG 2025 - Fecha límite (CC ...70)",
"start": {"date": "2026-09-30"},
"end": {"date": "2026-10-01"},
"reminders": {"useDefault": false, "overrides": [{"method": "email", "minutes": 10080}]}
}'Google Drive — Document organization
# Upload tax documents to Drive for backup
gws drive files create --params '{"name": "Certificado-Davivienda-2025.pdf", "parents": ["<FOLDER_ID>"]}' --upload ./cert.pdf
# Search for tax documents already in Drive
gws drive files list --params '{"q": "name contains '\''certificado'\'' and mimeType='\''application/pdf'\''", "pageSize": 10}'#!/usr/bin/env python3
"""
Monthly budget planner for Colombian residents earning USD salary.
Allocates income across parafiscales, AFC/pension contributions,
tax savings fund, and living expenses.
Usage:
python3 budget_planner.py --monthly-usd 8000 --year 2025
python3 budget_planner.py --monthly-usd 8000 --year 2025 --deadline 2026-10-01
python3 budget_planner.py --monthly-usd 8000 --year 2025 --parafiscales 2360000
"""
import argparse
import json
import sys
from datetime import date, datetime
from pathlib import Path
TEMPLATES_DIR = Path(__file__).parent.parent / "templates"
DATA_DIR = Path.home() / ".finance-substrate"
def load_current_trm() -> float:
"""Get the most recent TRM from cache."""
trm_file = DATA_DIR / "fx" / "trm-history.jsonl"
if not trm_file.exists():
return 4000.0
latest = None
with open(trm_file) as f:
for line in f:
line = line.strip()
if line:
rec = json.loads(line)
if latest is None or rec["date"] > latest["date"]:
latest = rec
return latest["valor"] if latest else 4000.0
def load_tax_tables(year: int) -> dict:
tables_file = TEMPLATES_DIR / f"tax-tables-{year}.json"
if not tables_file.exists():
candidates = sorted(TEMPLATES_DIR.glob("tax-tables-*.json"), reverse=True)
if candidates:
tables_file = candidates[0]
else:
return {"uvt_value": 47065}
with open(tables_file) as f:
return json.load(f)
def calculate_tax(taxable_uvt: float, brackets: list) -> float:
tax = 0.0
for bracket in brackets:
lower = bracket["from_uvt"]
upper = bracket.get("to_uvt", float("inf"))
rate = bracket["rate"]
base_tax = bracket.get("base_tax_uvt", 0)
if taxable_uvt > lower:
taxable_in_bracket = min(taxable_uvt, upper) - lower
tax = base_tax + (taxable_in_bracket * rate)
return tax
def estimate_annual_tax(
gross_annual_cop: float,
ss_incr: float,
afc_annual: float,
vol_pension_annual: float,
fixed_deductions: float,
uvt: float,
brackets: list,
) -> dict:
"""Quick tax estimate without full projection."""
renta_liquida = max(0, gross_annual_cop - ss_incr - vol_pension_annual)
exempt_25pct = min(renta_liquida * 0.25, 790 * uvt)
total_exentas = afc_annual + exempt_25pct
total_deducciones = fixed_deductions
raw = total_exentas + total_deducciones
cap = min(renta_liquida * 0.40, 1340 * uvt)
limited = min(raw, cap)
taxable = max(0, renta_liquida - limited)
taxable_uvt = taxable / uvt
tax_uvt = calculate_tax(taxable_uvt, brackets)
tax_cop = tax_uvt * uvt
anticipo = max(0, tax_cop * 0.75)
return {
"gross_annual": round(gross_annual_cop),
"renta_liquida": round(renta_liquida),
"exentas_limited": round(limited),
"taxable": round(taxable),
"tax": round(tax_cop),
"anticipo_next": round(anticipo),
"total_to_pay": round(tax_cop + anticipo),
}
def main():
parser = argparse.ArgumentParser(description="Monthly budget planner")
parser.add_argument("--monthly-usd", type=float, required=True, help="Monthly salary in USD")
parser.add_argument("--year", type=int, default=2025, help="Tax year")
parser.add_argument("--parafiscales", type=float, default=2360000, help="Monthly PILA payment (COP)")
parser.add_argument("--afc-annual", type=float, default=0, help="Annual AFC target (0 = auto from optimizer)")
parser.add_argument("--vol-pension-annual", type=float, default=20000000, help="Annual voluntary pension")
parser.add_argument("--fixed-deductions", type=float, default=4600000, help="Annual fixed deductions (medicina + GMF + e-inv)")
parser.add_argument("--anticipo-anterior", type=float, default=0, help="Anticipo from prior year")
parser.add_argument("--retenciones", type=float, default=1400000, help="Estimated annual retenciones")
parser.add_argument("--deadline", type=str, default=None, help="Filing deadline date (YYYY-MM-DD), auto-computes months remaining")
parser.add_argument("--months-to-deadline", type=int, default=12, help="Months until filing deadline (fallback if --deadline not given)")
parser.add_argument("--trm", type=float, default=0, help="Override TRM rate (0 = use latest)")
parser.add_argument("--json", action="store_true")
args = parser.parse_args()
# Resolve months to deadline: --deadline takes precedence over --months-to-deadline
deadline_date = None
if args.deadline:
try:
deadline_date = datetime.strptime(args.deadline, "%Y-%m-%d").date()
except ValueError:
print(f"Error: --deadline must be YYYY-MM-DD, got '{args.deadline}'", file=sys.stderr)
sys.exit(1)
today = date.today()
if deadline_date <= today:
print(f"Error: --deadline {args.deadline} is in the past", file=sys.stderr)
sys.exit(1)
months_remaining = (deadline_date.year - today.year) * 12 + (deadline_date.month - today.month)
# Count partial months as a full month
if today.day > 1 and deadline_date.day >= today.day:
pass # already counted
months_remaining = max(1, months_remaining)
args.months_to_deadline = months_remaining
tables = load_tax_tables(args.year)
uvt = tables.get("uvt_value", 47065)
brackets = tables.get("brackets_renta", [])
trm = args.trm if args.trm > 0 else load_current_trm()
monthly_cop = args.monthly_usd * trm
annual_cop = monthly_cop * 12
# Social security INCR
ss_monthly = args.parafiscales
ss_annual = ss_monthly * 12
# Auto-compute optimal AFC if not specified
if args.afc_annual == 0:
# Simplified optimizer: fill up to 1,340 UVT cap
renta_liq = annual_cop - ss_annual - args.vol_pension_annual
exempt_25 = min(renta_liq * 0.25, 790 * uvt)
cap = min(renta_liq * 0.40, 1340 * uvt)
headroom = max(0, cap - exempt_25 - args.fixed_deductions)
afc_annual = min(headroom, annual_cop * 0.30, 3800 * uvt - args.vol_pension_annual)
afc_annual = max(0, afc_annual)
else:
afc_annual = args.afc_annual
afc_monthly = afc_annual / 12
vol_pension_monthly = args.vol_pension_annual / 12
# Tax estimate
tax_est = estimate_annual_tax(
annual_cop, ss_annual + args.vol_pension_annual,
afc_annual, args.vol_pension_annual,
args.fixed_deductions, uvt, brackets,
)
saldo_pagar = max(0, tax_est["tax"] + tax_est["anticipo_next"] - args.anticipo_anterior - args.retenciones)
monthly_tax_savings = saldo_pagar / max(1, args.months_to_deadline)
available = monthly_cop - ss_monthly - afc_monthly - vol_pension_monthly - monthly_tax_savings
result = {
"monthly_usd": args.monthly_usd,
"trm": round(trm, 2),
"monthly_cop": round(monthly_cop),
"annual_cop": round(annual_cop),
"uvt": uvt,
"allocations": {
"parafiscales": {"cop": round(ss_monthly), "usd": round(ss_monthly / trm), "pct": round(ss_monthly / monthly_cop * 100, 1)},
"afc": {"cop": round(afc_monthly), "usd": round(afc_monthly / trm), "pct": round(afc_monthly / monthly_cop * 100, 1), "annual": round(afc_annual)},
"vol_pension": {"cop": round(vol_pension_monthly), "usd": round(vol_pension_monthly / trm), "pct": round(vol_pension_monthly / monthly_cop * 100, 1), "annual": round(args.vol_pension_annual)},
"tax_savings": {"cop": round(monthly_tax_savings), "usd": round(monthly_tax_savings / trm), "pct": round(monthly_tax_savings / monthly_cop * 100, 1), "total": round(saldo_pagar)},
"available": {"cop": round(available), "usd": round(available / trm), "pct": round(available / monthly_cop * 100, 1)},
},
"tax_estimate": tax_est,
"saldo_pagar": round(saldo_pagar),
"months_to_deadline": args.months_to_deadline,
"deadline": deadline_date.isoformat() if deadline_date else None,
}
if args.json:
print(json.dumps(result, indent=2))
return
a = result["allocations"]
print(f"\n{'='*65}")
print(f" PLAN PRESUPUESTAL MENSUAL — {args.year}")
if deadline_date:
print(f" Fecha límite: {deadline_date.isoformat()}")
print(f"{'='*65}")
print(f" Ingreso: ${args.monthly_usd:,.0f} USD × TRM {trm:,.2f} = ${monthly_cop:,.0f} COP")
print(f" UVT {args.year}: ${uvt:,.0f}")
print()
print(f" {'Categoría':<30s} {'COP':>12s} {'USD':>8s} {'%':>6s}")
print(f" {'-'*58}")
for name, label in [
("parafiscales", "Parafiscales PILA"),
("afc", f"AFC (${a['afc']['annual']:,.0f}/año)"),
("vol_pension", f"Pensión vol. (${a['vol_pension']['annual']:,.0f}/año)"),
("tax_savings", f"Ahorro renta (${a['tax_savings']['total']:,.0f} total)"),
("available", "** Disponible **"),
]:
d = a[name]
marker = ">>>" if name == "available" else " "
print(f"{marker} {label:<30s} ${d['cop']:>11,.0f} ${d['usd']:>7,.0f} {d['pct']:>5.1f}%")
print(f"\n {'─'*58}")
print(f" TOTAL ${monthly_cop:>11,.0f} ${args.monthly_usd:>7,.0f} 100.0%")
print(f"\n Impuesto estimado AG {args.year}: ${tax_est['tax']:,.0f}")
print(f" Anticipo siguiente: ${tax_est['anticipo_next']:,.0f}")
print(f" Saldo a pagar proyectado: ${saldo_pagar:,.0f}")
print(f" Meses para ahorrar: {args.months_to_deadline}")
if args.months_to_deadline < 6:
print(f"\n ⚠ ALERTA: Plazo ajustado — solo {args.months_to_deadline} meses para acumular ${saldo_pagar:,.0f} COP")
if available < 0:
print(f"\n ⚠ ALERTA: El presupuesto está en déficit por ${abs(available):,.0f} COP/mes")
print(f" Considere reducir AFC o pensión voluntaria.")
if __name__ == "__main__":
main()
#!/usr/bin/env python3
"""
Fetch TRM (Tasa Representativa del Mercado) from datos.gov.co open data API.
Usage:
python3 fetch_trm.py # Today's rate
python3 fetch_trm.py --date 2026-03-15 # Specific date
python3 fetch_trm.py --from 2026-03-01 --to 2026-03-19 # Date range
python3 fetch_trm.py --days 30 # Last N days
"""
import argparse
import json
import sys
import urllib.request
import urllib.parse
from datetime import datetime, timedelta
from pathlib import Path
DATA_DIR = Path.home() / ".finance-substrate"
TRM_FILE = DATA_DIR / "fx" / "trm-history.jsonl"
API_BASE = "https://www.datos.gov.co/resource/32sa-8pi3.json"
def ensure_dirs():
(DATA_DIR / "fx").mkdir(parents=True, exist_ok=True)
if not TRM_FILE.exists():
TRM_FILE.touch()
def fetch_trm(date_from: str | None = None, date_to: str | None = None, limit: int = 10) -> list:
"""Fetch TRM rates from datos.gov.co."""
params = {
"$order": "vigenciadesde DESC",
"$limit": str(limit),
}
if date_from and date_to:
params["$where"] = (
f"vigenciadesde >= '{date_from}T00:00:00.000' "
f"AND vigenciadesde <= '{date_to}T23:59:59.999'"
)
params["$limit"] = "1000"
elif date_from:
params["$where"] = f"vigenciadesde >= '{date_from}T00:00:00.000'"
url = f"{API_BASE}?{urllib.parse.urlencode(params)}"
try:
req = urllib.request.Request(url, headers={"Accept": "application/json"})
with urllib.request.urlopen(req, timeout=15) as resp:
data = json.loads(resp.read().decode())
except Exception as e:
print(f"Error fetching TRM: {e}", file=sys.stderr)
sys.exit(1)
results = []
for entry in data:
rate = {
"date": entry.get("vigenciadesde", "")[:10],
"vigencia_hasta": entry.get("vigenciahasta", "")[:10],
"valor": float(entry.get("valor", 0)),
}
results.append(rate)
return results
def load_existing_dates() -> set:
"""Load dates already in TRM history."""
dates = set()
if TRM_FILE.exists():
with open(TRM_FILE) as f:
for line in f:
line = line.strip()
if line:
entry = json.loads(line)
dates.add(entry.get("date"))
return dates
def save_rates(rates: list):
"""Append new rates to TRM history, skip duplicates."""
existing = load_existing_dates()
new_count = 0
with open(TRM_FILE, "a") as f:
for rate in rates:
if rate["date"] not in existing:
f.write(json.dumps(rate, ensure_ascii=False) + "\n")
existing.add(rate["date"])
new_count += 1
return new_count
def main():
parser = argparse.ArgumentParser(description="Fetch TRM (USD/COP) from datos.gov.co")
parser.add_argument("--date", help="Specific date (YYYY-MM-DD)")
parser.add_argument("--from", dest="date_from", help="Start date (YYYY-MM-DD)")
parser.add_argument("--to", dest="date_to", help="End date (YYYY-MM-DD)")
parser.add_argument("--days", type=int, help="Fetch last N days")
parser.add_argument("--no-save", action="store_true", help="Print only, don't save to history")
args = parser.parse_args()
ensure_dirs()
if args.date:
rates = fetch_trm(date_from=args.date, date_to=args.date, limit=1)
elif args.days:
d_from = (datetime.now() - timedelta(days=args.days)).strftime("%Y-%m-%d")
d_to = datetime.now().strftime("%Y-%m-%d")
rates = fetch_trm(date_from=d_from, date_to=d_to)
elif args.date_from:
d_to = args.date_to or datetime.now().strftime("%Y-%m-%d")
rates = fetch_trm(date_from=args.date_from, date_to=d_to)
else:
# Default: latest rate
rates = fetch_trm(limit=1)
if not rates:
print("No TRM data found for the specified date(s).")
sys.exit(0)
# Print results
for r in sorted(rates, key=lambda x: x["date"]):
print(f"{r['date']} TRM: ${r['valor']:,.2f} COP/USD")
# Save to history
if not args.no_save:
new = save_rates(rates)
if new:
print(f"\n{new} new rate(s) saved to {TRM_FILE}")
if __name__ == "__main__":
main()
#!/usr/bin/env python3
"""
Form 210 filler — generates agent-browser commands to fill the DIAN MUISCA
Form 210 wizard from tax projection output.
Reads the projection from tax_projection.py (or a saved JSON file) and emits
a shell script with agent-browser commands that navigate the 15-step wizard,
snapshot each step to get fresh field refs, and fill every editable casilla.
Usage:
# Run projection inline and generate fill script:
python3 fill_form210.py --year 2024 > fill.sh
# Use a previously saved projection JSON:
python3 fill_form210.py --input projection.json > fill.sh
# Override defaults:
python3 fill_form210.py --year 2024 --genero 2 --ciiu 6201 --anticipo 0
# Dry-run: print the mapping without generating shell commands:
python3 fill_form210.py --year 2024 --dry-run
"""
import argparse
import json
import sys
import textwrap
from pathlib import Path
SCRIPT_DIR = Path(__file__).parent
SCHEMA_FILE = SCRIPT_DIR.parent / "templates" / "form-210-schema.json"
# ---------------------------------------------------------------------------
# Mapping: tax_projection output key --> (casilla, wizard step index)
#
# Only EDITABLE casillas that have a data_source in tax_projection are mapped.
# Auto-calculated (disabled) casillas are skipped by the filler.
# ---------------------------------------------------------------------------
PROJECTION_MAP = {
# Step 3 — Patrimonio
"form_210.patrimonio.R29_patrimonio_bruto": (29, 2),
# R30 deudas — not in current projection; defaults to 0
# R31 auto-calculated
# Step 4 — Rentas de trabajo
"form_210.rentas_trabajo.R32_ingresos_brutos": (32, 3),
"form_210.rentas_trabajo.R33_incr": (33, 3),
# R34 auto-calculated
"form_210.rentas_trabajo.R35_aportes_afc": (35, 3),
"form_210.rentas_trabajo.R36_otras_rentas_exentas": (36, 3),
# R37 auto-calculated
"form_210.rentas_trabajo.R38_intereses_vivienda": (38, 3),
"form_210.rentas_trabajo.R39_otras_deducciones": (39, 3),
# R40, R41, R42 auto-calculated
# Step 6 — Rentas de capital
"form_210.rentas_capital.R58_ingresos_brutos": (58, 5),
"form_210.rentas_capital.R59_incr": (59, 5),
# R61, R73 auto-calculated
# Step 11 — Liquidacion
"form_210.impuesto.R130_anticipo_anterior": (130, 10),
"form_210.impuesto.R132_retenciones": (132, 10),
}
def resolve_projection_value(projection: dict, dotpath: str):
"""Traverse a nested dict using a dotted path like 'form_210.patrimonio.R29_...'."""
parts = dotpath.split(".")
node = projection
for p in parts:
if isinstance(node, dict) and p in node:
node = node[p]
else:
return None
return node
def dian_round(value) -> int:
"""DIAN rounds all values to nearest thousand (no decimals)."""
if value is None:
return 0
v = int(round(float(value), -3))
return max(v, 0)
def load_projection(args) -> dict:
"""Load or compute the tax projection."""
if args.input:
with open(args.input) as f:
return json.load(f)
# Import and run tax_projection inline
sys.path.insert(0, str(SCRIPT_DIR))
from tax_projection import project_tax
return project_tax(
year=args.year,
uvt_override=args.uvt,
anticipo_anterior=args.anticipo,
nit_suffix=args.nit_suffix,
)
def load_schema() -> dict:
"""Load the form-210-schema.json."""
with open(SCHEMA_FILE) as f:
return json.load(f)
def build_fill_plan(projection: dict, genero: str, ciiu: str) -> list:
"""
Build an ordered list of fill actions grouped by wizard step.
Each action is a dict:
{step, casilla, label, field_type, value, action}
where action is one of: fill_text, select_combo, select_radio.
"""
plan = []
# ── Step 1: Datos Declarante ──────────────────────────────────
plan.append({
"step": 0, "casilla": 286,
"label": "Genero",
"field_type": "combobox",
"value": genero,
"action": "select_combo",
})
plan.append({
"step": 0, "casilla": 24,
"label": "Actividad economica principal",
"field_type": "combobox",
"value": ciiu,
"action": "select_combo",
})
# ── Step 2: Deduccion imputable sin limitantes ────────────────
einvoices = projection.get("einvoices", {})
has_einvoices = einvoices.get("count", 0) > 0
total_facturado = dian_round(einvoices.get("total_facturado", 0))
plan.append({
"step": 1, "casilla": 299,
"label": "Factura electronica",
"field_type": "radio",
"value": "Si" if has_einvoices else "No",
"action": "select_radio",
})
if has_einvoices:
plan.append({
"step": 1, "casilla": 297,
"label": "Valor compras con factura electronica",
"field_type": "textbox",
"value": total_facturado,
"action": "fill_text",
})
# Default No for the remaining yes/no questions
for cas, label in [
(245, "Victima del conflicto armado"),
(247, "Beneficiario convenio doble tributacion"),
(249, "Residente fiscal"),
(251, "Obligado a llevar contabilidad"),
]:
plan.append({
"step": 1, "casilla": cas,
"label": label,
"field_type": "radio",
"value": "No",
"action": "select_radio",
})
# ── Steps 3-14: Mapped casillas from projection ──────────────
for dotpath, (casilla, step_idx) in sorted(PROJECTION_MAP.items(), key=lambda x: (x[1][1], x[1][0])):
raw = resolve_projection_value(projection, dotpath)
value = dian_round(raw)
# Extract the label from the key (e.g. R32_ingresos_brutos -> R32 Ingresos brutos)
key_tail = dotpath.rsplit(".", 1)[-1]
label = key_tail.replace("_", " ")
plan.append({
"step": step_idx,
"casilla": casilla,
"label": label,
"field_type": "textbox",
"value": value,
"action": "fill_text",
})
return plan
def emit_shell_script(plan: list, dry_run: bool = False):
"""
Emit a bash script with agent-browser commands.
The generated script:
1. Groups actions by wizard step
2. Clicks the step counter to navigate
3. Snapshots to get fresh field refs
4. Fills each field
5. Clicks Siguiente to advance
The script is idempotent — re-running skips already-filled fields
(agent-browser type commands overwrite field content).
"""
lines = []
lines.append("#!/usr/bin/env bash")
lines.append("# ================================================================")
lines.append("# DIAN Form 210 Filler — Auto-generated by fill_form210.py")
lines.append("# ================================================================")
lines.append("#")
lines.append("# INSTRUCTIONS:")
lines.append("# 1. Open the DIAN MUISCA Form 210 wizard in agent-browser")
lines.append("# 2. Run this script (or paste commands one step at a time)")
lines.append("# 3. Review each step before clicking Siguiente")
lines.append("# 4. Field refs change between sessions — if a ref fails,")
lines.append("# re-run the snapshot command for that step and update the ref")
lines.append("#")
lines.append("# This script uses placeholder refs (<REF_casilla_NNN>).")
lines.append("# After each snapshot, replace them with the actual refs from")
lines.append("# the snapshot output.")
lines.append("#")
lines.append("# DIAN rounds all values to nearest thousand (no decimals).")
lines.append("# ================================================================")
lines.append("")
lines.append("set -euo pipefail")
lines.append("")
if dry_run:
lines.append("# DRY RUN — no commands will be executed")
lines.append("")
# Group by step
steps = {}
for action in plan:
s = action["step"]
steps.setdefault(s, []).append(action)
step_titles = {
0: "Datos del Declarante",
1: "Deduccion Imputable sin limitantes",
2: "Patrimonio",
3: "Rentas de trabajo",
4: "Rentas de trabajo no relacion laboral",
5: "Rentas de capital",
6: "Rentas no laborales",
7: "Cedula general",
8: "Cedula de pensiones",
9: "Dividendos y participaciones",
10: "Liquidacion privada",
11: "Ganancias ocasionales",
12: "Anticipo y saldo a favor",
13: "Saldo a pagar o a favor",
14: "Firmar y presentar",
}
for step_idx in sorted(steps.keys()):
actions = steps[step_idx]
title = step_titles.get(step_idx, f"Step {step_idx + 1}")
lines.append(f"# ── Step {step_idx + 1}: {title} {'─' * max(1, 50 - len(title))}")
lines.append("")
# Navigate to step
lines.append(f"# Navigate to step {step_idx + 1}")
lines.append(f'agent-browser js "document.querySelectorAll(\'.step-counter\')[{step_idx}].click()"')
lines.append("")
# Snapshot to get field refs
lines.append(f"# Snapshot step {step_idx + 1} to get field refs")
lines.append("agent-browser snapshot")
lines.append("")
lines.append("# Fill fields (replace <REF_casilla_NNN> with actual refs from snapshot)")
for act in actions:
cas = act["casilla"]
label = act["label"]
value = act["value"]
field_type = act["field_type"]
ref_placeholder = f"<REF_casilla_{cas}>"
lines.append(f"# Casilla {cas}: {label}")
if act["action"] == "fill_text":
if dry_run:
lines.append(f"# -> Would fill casilla {cas} with {value}")
else:
lines.append(f'agent-browser click "{ref_placeholder}"')
lines.append(f'agent-browser clear-and-type "{ref_placeholder}" "{value}"')
elif act["action"] == "select_combo":
if dry_run:
lines.append(f"# -> Would select '{value}' in combobox casilla {cas}")
else:
lines.append(f'agent-browser click "{ref_placeholder}"')
lines.append(f'agent-browser select "{ref_placeholder}" "{value}"')
elif act["action"] == "select_radio":
if dry_run:
lines.append(f"# -> Would select radio '{value}' for casilla {cas}")
else:
# Radio buttons: click the option matching the value
lines.append(f'agent-browser click "{ref_placeholder}_{value}"')
lines.append("")
# Advance to next step
lines.append(f"# Advance from step {step_idx + 1}")
lines.append('agent-browser click "Siguiente"')
lines.append("")
return "\n".join(lines)
def print_dry_run_table(plan: list):
"""Print a human-readable table of all fill actions."""
print(f"\n{'='*80}")
print(" FORM 210 FILL PLAN (dry run)")
print(f"{'='*80}")
print(f" {'Step':>4} {'Casilla':>7} {'Action':<14} {'Value':>15} Label")
print(f" {'─'*4} {'─'*7} {'─'*14} {'─'*15} {'─'*30}")
for act in plan:
step_display = act["step"] + 1
value_str = str(act["value"])
if isinstance(act["value"], (int, float)) and act["value"] > 0:
value_str = f"${act['value']:,.0f}"
print(f" {step_display:>4} {act['casilla']:>7} {act['action']:<14} {value_str:>15} {act['label']}")
print(f"\n Total actions: {len(plan)}")
print()
def print_mapping_json(plan: list):
"""Print the fill plan as JSON for programmatic consumption."""
output = []
for act in plan:
output.append({
"step": act["step"] + 1,
"casilla": act["casilla"],
"label": act["label"],
"field_type": act["field_type"],
"value": act["value"],
"action": act["action"],
})
print(json.dumps(output, indent=2, ensure_ascii=False))
def main():
parser = argparse.ArgumentParser(
description="Generate agent-browser commands to fill DIAN Form 210"
)
parser.add_argument("--year", type=int, default=2024,
help="Tax year (default: 2024)")
parser.add_argument("--uvt", type=float,
help="Override UVT value")
parser.add_argument("--anticipo", type=float, default=0,
help="Anticipo renta from prior year (R130)")
parser.add_argument("--nit-suffix",
help="Last 2 digits of CC/NIT for deadline lookup")
parser.add_argument("--input", type=str,
help="Path to a saved tax projection JSON (skip running projection)")
parser.add_argument("--genero", type=str, default="2 - Masculino",
help="Casilla 286 value (default: '2 - Masculino')")
parser.add_argument("--ciiu", type=str, default="6201",
help="Casilla 24 CIIU code (default: 6201 — software development)")
parser.add_argument("--dry-run", action="store_true",
help="Print fill plan table without generating shell commands")
parser.add_argument("--json", action="store_true",
help="Output fill plan as JSON")
parser.add_argument("--output", type=str,
help="Write shell script to file instead of stdout")
args = parser.parse_args()
# Load or compute projection
projection = load_projection(args)
# Build fill plan
plan = build_fill_plan(projection, genero=args.genero, ciiu=args.ciiu)
if args.dry_run:
print_dry_run_table(plan)
return
if args.json:
print_mapping_json(plan)
return
# Generate shell script
script = emit_shell_script(plan)
if args.output:
out_path = Path(args.output)
out_path.write_text(script)
out_path.chmod(0o755)
print(f"Fill script written to {out_path}", file=sys.stderr)
else:
print(script)
if __name__ == "__main__":
main()
#!/usr/bin/env python3
"""
Collect tax-relevant documents and payment data from Gmail using GWS CLI.
Searches for:
- Bank statements (extractos) from Davivienda, Nu, Nequi
- Tax certificates from all institutions
- DIAN notifications
- Salary payment confirmations from Thera
- Planilla/parafiscales confirmations from Compensar
Usage:
python3 gmail_collector.py --year 2025
python3 gmail_collector.py --year 2025 --download-attachments
python3 gmail_collector.py --year 2025 --source thera # Just salary payments
"""
import argparse
import base64
import json
import os
import re
import subprocess
from pathlib import Path
DATA_DIR = Path.home() / ".finance-substrate"
DECL_DIR = Path.home() / "Dropbox" / "Declaracion"
def gws_cmd(service: str, resource: str, method: str, params: dict) -> dict:
"""Execute a GWS CLI command and return parsed JSON."""
# Split resource into separate args (e.g., "users messages" -> "users", "messages")
cmd = ["gws", service] + resource.split() + [method, "--params", json.dumps(params)]
result = subprocess.run(cmd, capture_output=True, text=True, timeout=30)
if result.returncode != 0:
return {"error": result.stderr}
try:
return json.loads(result.stdout)
except json.JSONDecodeError:
return {"error": result.stdout}
def gws_gmail_get(msg_id: str, fmt: str = "metadata") -> dict:
return gws_cmd("gmail", "users messages", "get", {
"userId": "me", "id": msg_id, "format": fmt,
})
def search_messages(query: str, max_results: int = 50) -> list:
"""Search Gmail and return message IDs."""
data = gws_cmd("gmail", "users messages", "list", {
"userId": "me", "q": query, "maxResults": max_results,
})
return [m["id"] for m in data.get("messages", [])]
def get_message_details(msg_id: str) -> dict:
"""Get message subject, from, date, and attachment info."""
msg = gws_gmail_get(msg_id, "metadata")
if "error" in msg:
return msg
headers = {h["name"]: h["value"] for h in msg.get("payload", {}).get("headers", [])}
parts = msg.get("payload", {}).get("parts", [])
attachments = []
for p in parts:
if p.get("filename"):
attachments.append({
"filename": p["filename"],
"mimeType": p.get("mimeType", ""),
"size": p.get("body", {}).get("size", 0),
"attachmentId": p.get("body", {}).get("attachmentId", ""),
})
for sp in p.get("parts", []):
if sp.get("filename"):
attachments.append({
"filename": sp["filename"],
"mimeType": sp.get("mimeType", ""),
"size": sp.get("body", {}).get("size", 0),
"attachmentId": sp.get("body", {}).get("attachmentId", ""),
})
return {
"id": msg_id,
"date": headers.get("Date", ""),
"from": headers.get("From", ""),
"subject": headers.get("Subject", ""),
"attachments": attachments,
}
def get_message_body(msg_id: str) -> str:
"""Get plain text body of a message."""
msg = gws_gmail_get(msg_id, "full")
if "error" in msg:
return ""
parts = msg.get("payload", {}).get("parts", [msg.get("payload", {})])
for p in (parts if isinstance(parts, list) else [parts]):
body = p.get("body", {}).get("data", "")
if body and p.get("mimeType", "").startswith("text/plain"):
return base64.urlsafe_b64decode(body).decode("utf-8", errors="replace")
for sp in p.get("parts", []):
body = sp.get("body", {}).get("data", "")
if body and sp.get("mimeType", "").startswith("text/plain"):
return base64.urlsafe_b64decode(body).decode("utf-8", errors="replace")
return ""
def download_attachment(msg_id: str, attachment_id: str, filename: str, output_dir: Path):
"""Download an email attachment."""
data = gws_cmd("gmail", "users messages attachments", "get", {
"userId": "me", "messageId": msg_id, "id": attachment_id,
})
if "error" in data:
print(f" Error downloading {filename}: {data['error']}")
return
file_data = base64.urlsafe_b64decode(data.get("data", ""))
output_dir.mkdir(parents=True, exist_ok=True)
output_path = output_dir / filename
output_path.write_bytes(file_data)
print(f" Downloaded: {output_path} ({len(file_data):,} bytes)")
# ─── Source-specific collectors ────────────────────────────────────
SOURCES = {
"thera": {
"query": "from:thera received payment",
"label": "Thera salary payments",
"parser": "thera",
},
"davivienda": {
"query": "from:davivienda (extracto OR certificado OR transaccion)",
"label": "Davivienda bank statements & certificates",
"parser": "bank",
},
"nu": {
"query": "from:nu.com.co",
"label": "Nu Colombia notifications",
"parser": "bank",
},
"nequi": {
"query": "from:nequi",
"label": "Nequi notifications",
"parser": "bank",
},
"rappi": {
"query": "from:rappipay OR from:rappicard",
"label": "RappiPay/RappiCard notifications",
"parser": "bank",
},
"skandia": {
"query": "from:skandia",
"label": "Skandia pension notifications",
"parser": "bank",
},
"compensar": {
"query": "from:compensar (planilla OR pago OR parafiscal)",
"label": "Compensar PILA confirmations",
"parser": "pila",
},
"dian": {
"query": "from:dian.gov.co",
"label": "DIAN notifications",
"parser": "dian",
},
"falabella": {
"query": "from:falabella (certificado OR extracto)",
"label": "Banco Falabella certificates",
"parser": "bank",
},
}
def parse_thera_payment(body: str) -> dict | None:
"""Extract payment amount from Thera email."""
m = re.search(r"USD\s+([\d,]+(?:\.\d{2})?)", body)
if m:
amount = float(m.group(1).replace(",", ""))
employer = re.search(r"contract with (.+?) on Thera", body)
return {
"amount_usd": amount,
"employer": employer.group(1) if employer else "Unknown",
}
return None
def collect_source(source_key: str, year: int, download: bool = False) -> list:
"""Collect messages from a specific source."""
source = SOURCES[source_key]
query = f'{source["query"]} after:{year}/01/01 before:{year + 1}/01/01'
print(f"\n [{source_key}] {source['label']}")
msg_ids = search_messages(query)
print(f" Found {len(msg_ids)} messages")
results = []
for mid in msg_ids[:20]: # Cap at 20 per source
details = get_message_details(mid)
if "error" in details:
continue
entry = {
"source": source_key,
"date": details["date"],
"subject": details["subject"],
"attachments": len(details["attachments"]),
}
# Parse source-specific data
if source["parser"] == "thera":
body = get_message_body(mid)
payment = parse_thera_payment(body)
if payment:
entry["payment"] = payment
results.append(entry)
# Download attachments if requested
if download and details["attachments"]:
output_dir = DECL_DIR / str(year) / "Gmail" / source_key
for att in details["attachments"]:
if att["attachmentId"]:
download_attachment(mid, att["attachmentId"], att["filename"], output_dir)
return results
def main():
parser = argparse.ArgumentParser(description="Collect tax documents from Gmail")
parser.add_argument("--year", type=int, default=2025)
parser.add_argument("--source", choices=list(SOURCES.keys()) + ["all"], default="all")
parser.add_argument("--download-attachments", action="store_true")
parser.add_argument("--json", action="store_true")
args = parser.parse_args()
print(f"=== Gmail Tax Document Collector — {args.year} ===")
sources = SOURCES.keys() if args.source == "all" else [args.source]
all_results = {}
for source_key in sources:
results = collect_source(source_key, args.year, args.download_attachments)
all_results[source_key] = results
# Show Thera payment summary
if source_key == "thera":
payments = [r["payment"] for r in results if "payment" in r]
if payments:
total_usd = sum(p["amount_usd"] for p in payments)
print(f" Salary total: ${total_usd:,.0f} USD ({len(payments)} payments)")
if args.json:
print(json.dumps(all_results, indent=2, default=str))
else:
print(f"\n{'='*50}")
print(f" Summary — {args.year}")
print(f"{'='*50}")
for source_key, results in all_results.items():
att_count = sum(r["attachments"] for r in results)
print(f" {source_key:<15s}: {len(results):>3d} messages, {att_count:>3d} attachments")
if __name__ == "__main__":
main()
#!/usr/bin/env python3
"""
Parse tax certificates from Colombian financial institutions using the
declarative parser engine (parsers/*.json).
Falls back to legacy regex parsers if no declarative definition matches.
Usage:
python3 import_certificates.py --year 2024 --password <CC_NUMBER>
python3 import_certificates.py --year 2024 --dir ~/Dropbox/Declaracion/2024
"""
import argparse
import hashlib
import json
import os
from pathlib import Path
DATA_DIR = Path.home() / ".finance-substrate"
CERTS_FILE = DATA_DIR / "tax" / "certificates.jsonl"
try:
import fitz # type: ignore[import-untyped]
HAS_FITZ = True
except ImportError:
HAS_FITZ = False
# Import the declarative engine
sys_path_added = False
try:
from parse_engine import load_parsers, parse_certificate, log_improvement_signal, parse_co_amount
except ImportError:
import sys
sys.path.insert(0, str(Path(__file__).parent))
sys_path_added = True
from parse_engine import load_parsers, parse_certificate, log_improvement_signal, parse_co_amount
def ensure_dirs():
(DATA_DIR / "tax").mkdir(parents=True, exist_ok=True)
if not CERTS_FILE.exists():
CERTS_FILE.touch()
def save_cert(cert: dict) -> bool:
existing = set()
lines = []
if CERTS_FILE.exists():
with open(CERTS_FILE) as f:
lines = f.readlines()
for line in lines:
if line.strip():
existing.add(json.loads(line.strip()).get("id", ""))
if cert["id"] in existing:
with open(CERTS_FILE, "w") as f:
for line in lines:
entry = json.loads(line.strip()) if line.strip() else {}
if entry.get("id") == cert["id"]:
f.write(json.dumps(cert, ensure_ascii=False) + "\n")
else:
f.write(line)
return False
else:
with open(CERTS_FILE, "a") as f:
f.write(json.dumps(cert, ensure_ascii=False) + "\n")
return True
def extract_pdf_text(path: str, password: str | None = None) -> str:
if not HAS_FITZ:
print(f" Warning: pymupdf not installed, cannot parse {path}")
return ""
try:
doc = fitz.open(path)
if doc.is_encrypted:
if password:
if not doc.authenticate(password):
print(f" Warning: wrong password for {os.path.basename(path)}")
doc.close()
return ""
else:
print(f" Skipping locked PDF (no password): {os.path.basename(path)}")
doc.close()
return ""
text = ""
for i in range(len(doc)):
text += doc[i].get_text() + "\n"
doc.close()
return text
except Exception as e:
print(f" Error reading {os.path.basename(path)}: {e}")
return ""
def aggregate_certificates(year: int) -> dict:
"""Aggregate all certificate tax_summary data into Form 210 inputs."""
certs = []
if CERTS_FILE.exists():
with open(CERTS_FILE) as f:
for line in f:
if line.strip():
c = json.loads(line.strip())
if c.get("year") == year and c.get("type") == "certificado-tributario":
certs.append(c)
agg = {
"rendimientos_gravados": 0,
"rendimientos_no_gravados": 0,
"retencion_renta_total": 0,
"gmf_deducible_50pct": 0,
"patrimonio_cuentas": 0,
"patrimonio_inversiones": 0,
"deudas": 0,
"pension_obligatoria_incr": 0,
"aportes_afc_r35": 0,
"pension_voluntaria_r35": 0,
"medicina_prepagada_r39": 0,
"dividendos": 0,
}
for c in certs:
ts = c.get("tax_summary", {})
agg["rendimientos_gravados"] += ts.get("rendimientos_gravados", 0)
agg["rendimientos_no_gravados"] += ts.get("rendimientos_no_gravados", 0)
agg["retencion_renta_total"] += ts.get("retencion_renta", 0) + ts.get("retencion_total", 0)
agg["gmf_deducible_50pct"] += ts.get("gmf_deducible_50pct", 0)
agg["patrimonio_cuentas"] += ts.get("patrimonio_cuenta", 0) + ts.get("patrimonio_cuentas", 0)
agg["patrimonio_inversiones"] += (
ts.get("patrimonio_fondos", 0) + ts.get("patrimonio_caja", 0) +
ts.get("patrimonio_acciones", 0) + ts.get("patrimonio_cesantias", 0) +
ts.get("patrimonio_voluntaria", 0)
)
agg["deudas"] += ts.get("deuda_patrimonio", 0)
agg["pension_obligatoria_incr"] += ts.get("pension_oblig_plus_fsp", 0)
agg["aportes_afc_r35"] += ts.get("aportes_afc_r35", 0)
agg["pension_voluntaria_r35"] += ts.get("pension_voluntaria_aportes", 0)
agg["medicina_prepagada_r39"] += ts.get("medicina_prepagada_deduccion_r39", 0)
agg["dividendos"] += ts.get("dividendos", 0)
return agg
def import_certificates(year: int, cert_dir: Path, password: str | None = None) -> list:
ensure_dirs()
results = []
# Load declarative parser definitions
parser_defs = load_parsers()
if parser_defs:
print(f" Loaded {len(parser_defs)} parser definitions\n")
pdf_files = sorted(cert_dir.glob("Certificado*.pdf")) + sorted(cert_dir.glob("Reporte*.pdf"))
if not pdf_files:
print(f" No certificate PDFs found in {cert_dir}")
return results
for pdf_path in pdf_files:
text = extract_pdf_text(str(pdf_path), password)
if not text:
continue
# Try declarative engine first
cert, parser_id = parse_certificate(text, year, parser_defs)
if cert:
is_new = save_cert(cert)
results.append((cert["entity"], pdf_path.name, "new" if is_new else "updated", parser_id))
else:
# Log improvement signal for agent consumption
log_improvement_signal("unknown_institution", {
"pdf_filename": pdf_path.name,
"text_preview": text[:500],
"text_length": len(text),
})
results.append(("Unknown", pdf_path.name, "skipped", None))
return results
def main():
parser = argparse.ArgumentParser(description="Import tax certificates from PDFs")
parser.add_argument("--year", type=int, default=2024)
parser.add_argument("--dir", help="Directory with certificate PDFs")
parser.add_argument("--password", help="PDF password (usually cédula number)")
args = parser.parse_args()
cert_dir = Path(args.dir) if args.dir else Path.home() / "Dropbox" / "Declaracion" / str(args.year)
if not cert_dir.exists():
print(f"Error: {cert_dir} does not exist")
return
print(f"=== Importing certificates for {args.year} from {cert_dir} ===\n")
results = import_certificates(args.year, cert_dir, args.password)
for entry in results:
entity, filename, status, parser_id = entry
parser_tag = f" [{parser_id}]" if parser_id else ""
print(f" [{status:>7}] {entity}{parser_tag} <- {filename}")
agg = aggregate_certificates(args.year)
print(f"\n=== Aggregated Tax Inputs ===")
for key, val in agg.items():
print(f" {key:.<40s} ${val:>15,.0f}")
print(f"\nCertificates saved to: {CERTS_FILE}")
if __name__ == "__main__":
main()
{
"name": "finance-substrate",
"version": "0.1.0",
"description": "Personal finance and Colombian tax management — import bank transactions, track withholdings, fetch TRM rates, project taxes, issue DIAN e-invoices. Zero paid dependencies.",
"entrypoint": "scripts/import_csv.py",
"inputs_schema": {
"type": "object",
"additionalProperties": false,
"properties": {
"mode": {
"type": "string",
"enum": [
"import",
"trm",
"categorize",
"summary",
"tax",
"withholding",
"dian-scrape",
"invoice"
],
"description": "Skill mode: import (bank CSV/OFX), trm (exchange rates), categorize (classify transactions), summary (financial report), tax (DIAN projection), withholding (record retención), dian-scrape (MUISCA automation), invoice (e-invoicing)."
},
"bank": {
"type": "string",
"enum": [
"davivienda",
"nubank",
"nequi",
"arq"
],
"description": "Bank identifier for import mode."
},
"file_path": {
"type": "string",
"description": "Path to CSV/OFX file for import, or PDF for withholding certificate parsing."
},
"date": {
"type": "string",
"description": "Date (YYYY-MM-DD) or range (YYYY-MM-DD:YYYY-MM-DD) for TRM fetch or summary period."
},
"period": {
"type": "string",
"enum": [
"month",
"quarter",
"year",
"custom"
],
"description": "Report period for summary mode."
},
"year": {
"type": "integer",
"description": "Tax year for projection (default: current year)."
},
"currency": {
"type": "string",
"enum": [
"COP",
"USD"
],
"default": "COP",
"description": "Primary display currency."
},
"withholding": {
"type": "object",
"properties": {
"source": {
"type": "string",
"description": "Company or entity name."
},
"nit": {
"type": "string",
"description": "NIT of the withholding agent."
},
"tax_type": {
"type": "string",
"enum": [
"renta",
"ica",
"iva"
]
},
"period": {
"type": "string",
"description": "Period covered (e.g., 2026-01, 2026-Q1, 2026)."
},
"base_amount": {
"type": "number"
},
"rate": {
"type": "number"
},
"withheld_amount": {
"type": "number"
}
},
"description": "Withholding certificate data for withholding mode."
},
"invoice": {
"type": "object",
"properties": {
"client_name": {
"type": "string"
},
"client_nit": {
"type": "string"
},
"items": {
"type": "array",
"items": {
"type": "object",
"properties": {
"description": {
"type": "string"
},
"quantity": {
"type": "number"
},
"unit_price": {
"type": "number"
},
"iva_rate": {
"type": "number",
"default": 0.19
}
},
"required": [
"description",
"quantity",
"unit_price"
]
}
}
},
"description": "Invoice data for invoice mode."
},
"dian_target": {
"type": "string",
"enum": [
"rut",
"withholdings",
"returns",
"invoices-received"
],
"description": "What to extract from DIAN MUISCA for dian-scrape mode."
},
"limit": {
"type": "integer",
"default": 20,
"description": "Number of uncategorized transactions to show in categorize mode."
}
},
"required": [
"mode"
],
"allOf": [
{
"if": {
"properties": {
"mode": {
"const": "import"
}
},
"required": [
"mode"
]
},
"then": {
"required": [
"bank",
"file_path"
]
}
},
{
"if": {
"properties": {
"mode": {
"const": "withholding"
}
},
"required": [
"mode"
]
},
"then": {
"anyOf": [
{
"required": [
"withholding"
]
},
{
"required": [
"file_path"
]
}
]
}
},
{
"if": {
"properties": {
"mode": {
"const": "invoice"
}
},
"required": [
"mode"
]
},
"then": {
"required": [
"invoice"
]
}
},
{
"if": {
"properties": {
"mode": {
"const": "dian-scrape"
}
},
"required": [
"mode"
]
},
"then": {
"required": [
"dian_target"
]
}
}
]
}
}
Related skills
FAQ
Does it use paid services?
No. It has no paid aggregators, no API keys, and all data stays local, using free open data APIs like datos.gov.co.
How does it handle protected bank PDFs?
It tries opening unprotected, then uses the account holder's cedula (CC) number as the password, which is never stored in the repo.
How accurate is the tax projection?
The tax_projection.py engine is documented at 95% accuracy and compares against the DIAN borrador when available.