Browse the docs

DocsCountries

Country packs

A country is data. Everything Belgium or France adds to Ekwo — a chart of accounts, journals, VAT codes, the accounts each of them posts to, the boxes of the periodic return — is a set of files under packs/<cc>/, compiled into one SQL seed that is committed. There is no module per country, no Python hook, and no field in the format through which a pack could run anything.

The decision behind this, with the alternatives that were weighed, is in decision 0025.

What a pack is

packs/be/
├── pack.json          manifest: version, certification, defaults, roles, journals, charts
├── accounts.csv       the default chart: code, parent, type, reconcilable, name, sequence
├── accounts.asbl.csv  a second chart, named by charts[] in the manifest
├── taxes.json         taxes and their postings, per kind of document
├── tax_report.json    the declaration forms — one, or a list — their boxes and totals
├── statements.json    the balance sheet, the income statement and their rules
├── analysis.json      which accounts make the revenue, the equity, the assets… of an analysis
├── fixed_assets.json  the section of the fixed assets module, where the country has one
├── corporate_tax.json the section of the corporate income tax module, where the country has one
├── turnover_tax.json  the regimes where a tax on turnover replaces VAT, where the country has one
├── vat_schemes.json  the VAT schemes: which forms each files, ceilings, instalments
├── golden/
│   ├── scenario.json  one year of books: documents, payments, the periods filed
│   ├── vat_return.json    what the declaration comes to, period by period
│   ├── statements.json    what every line of every statement comes to
│   ├── trial_balance.json every account that moved, to the cent
│   ├── analysis.json      what every aggregate of the analysis comes to, where the pack has one
│   └── corporate_tax.json companies whose tax was worked out by hand, beside the section
└── i18n/
    ├── README.md      where each language's wording comes from
    ├── nl.json        every label of the pack, in one more language
    ├── de.json
    └── en.json

packs/generic/         a pack with no country: statements and an analysis by account type
├── pack.json
├── statements.json
└── analysis.json      the aggregates a company is read on when its country pack defines none

A country has charts, not a chart

pack.json declares them, exactly one of them the default:

"charts": [
  { "code": "default", "name": "PCMN — plan comptable minimum normalisé",
    "accounts": "accounts.csv", "default": true, "audience": "companies",
    "statements": ["BE-BNB-ABBR-BS", "BE-BNB-ABBR-IS", "BE-BNB-ABBR-AF"] },
  { "code": "asbl", "name": "PCMN — associations et fondations",
    "accounts": "accounts.asbl.csv", "audience": "nonprofits", "statements": [],
    "certification": { "status": "community" } }
]

A pack that declares no charts has one chart called default whose accounts are in accounts.csv, which is what every pack written before this said.

The journals, the taxes and the declaration form are common to the charts of a country. An association buys, sells and banks through the same journals as a company and files the same VAT return; only the accounts differ, and the statements that present them. So do the roles — receivable, payable, suspense — which stay in defaults.roles, and ekwo pack check refuses a pack whose role codes and tax posting accounts are not in every chart it ships.

ekwo init --chart <code> picks one; without the flag, an interactive install asks only when the pack offers several, and a non-interactive one refuses and lists them. ekwo status prints the chart each company keeps its books on.

Financial statements

statements.json holds one entry per scheme: a code, a kind (balance_sheet, income_statement, allocation), a framework, and its lines. A line is either summed from the ledger, through rules, or computed from other lines, through plus and minus — there is no expression language, as there is none for a declaration form.

{ "code": "40/41", "parent": "29/58", "name": "Créances à un an au plus",
  "sequence": 150, "xbrl": "met:am1|bas:m9|rst:m2",
  "rules": [ { "kind": "code_range", "code_from": "40", "code_to": "41" },
             { "kind": "code_range", "code_from": "499", "code_to": "499", "side": "debit" } ] }

Four kinds of rule: account_code names one code, code_range and code_prefix compare the head of the code (40..41 takes 400000 and 411000 and stops at 42), account_type is what the generic framework is made of. side splits one account between two lines — a suspense account is a receivable while it is in debit and a payable while it is in credit — and is the only way two lines may share an account.

sign multiplies the debit-minus-credit balance so the line reads the way the scheme prints it: 1 on an asset or an expense, -1 on a liability, equity or income line.

xbrl is the fact key of the line in the taxonomy the country files in, and it is optional: null where nothing could be verified. The three Belgian schemes name all eighty-seven of theirs. A taxonomy such as the Belgian CBSO has no element per reporting code — it is dimensional, so a line is a metric plus a set of domain members, written metric first and separated by |. One key, one fact, therefore one line: ekwo pack check refuses two lines of a statement carrying the same key, which is how a key missing a member shows up. The two sides of a balance sheet are the case to watch — met:am1|bas:m25 is the total of the assets and the total of the liabilities until part:m1 or part:m3 says which.

The totals of a scheme and the totals of a declaration form are worked out by the same function, evaluate_totals(), in the order they depend on each other. A statement prints its whole frame and a return omits a box that comes to nothing, which is one argument to it and not a second evaluator.

A balance_sheet reads balances cumulative to the end of the period; an income_statement reads the movements inside it, less the entries close_fiscal_year() marks kind = 'closing', so a closed year still reports what it earned. A balance sheet keeps those entries: they are what carries the result onto the line it shows.

A chart names the statements it reports on. A statement named by exactly one chart belongs to that chart; one named by several, or by none, fits every chart of the country. A chart that names none falls back to packs/generic/, the country-less pack whose rules are all account_type — which is what the eighteen account types buy, and what gives a British or American chart with no legal codes a balance sheet that ties out.

A sign belongs to a line summed from the ledger, and to no other. sign: -1 is how a scheme prints a credit balance as a positive figure, and financial_statement() applies it when it sums the line. A total is then worked out from lines that already read that way, so a sign on the total would be applied to them a second time: flip a credit line and flip the subtotal above it, and the figure comes back the way it started, silently. It cost the Luxembourg pack a wrong set of golden figures. ekwo pack check refuses the two together; minus is how a total subtracts.

A balance sheet says how it shows the result of the year in progress. Until a year is closed its result stands on the income and expense accounts, and the balance sheet carries it onto the line the close would leave it on. result_in_progress on the statement says what that line is: alone when it is the result of the year and nothing else — the French DI takes 120 and 129 — and combined when it also takes other balances — the Belgian rubric 14 takes 140 and 141, the result brought forward; a pack that closes into retained earnings carries every earlier year on the same line. A combined balance sheet names the label of the sub-line in result_in_progress_name, in the language of the pack, and translates it in i18n/<lang>.json under result_in_progress, by statement code. financial_statement_of_kind() then shows the result in progress as a row of its own under its line — key <line>:result_in_progress, parent the line, no total adds it — and financial_statement(), which is what a filing reads, is unchanged. The value is not a preference: ekwo pack check works out which lines receive the result and what else they take, from the scheme and the chart, and refuses a balance sheet that leaves it out or says the opposite, naming the accounts that decide it.

ekwo pack check refuses a statement whose totals form a cycle, a line that is both summed and computed, a computed line carrying a sign, two lines that catch one account on the same side, two lines that carry the same fact key, and — the check that makes a balance sheet balance — a chart with an account that reaches no line of any of its statements. A heading, an account with children, may reach none: it straddles the lines its children are split over and nothing is posted to it.

Every file is validated against packs/schema/pack.1.json, a JSON Schema draft 2020-12 that describes all of them: the manifest is the root, the others are $defs. Two formats and no third: JSON for anything with a shape, CSV for the chart, which is flat, long, and what accounting software exchanges — a reviewer reads one line per account in a diff, and an accountant opens it in a spreadsheet.

accounts.csv is a strict subset of CSV: a header line, no newline inside a field, a field quoted only when it holds a comma or a quote, a quote doubled inside a quoted field. The parser is forty lines and refuses anything else.

The analysis section

analysis.json says what the accounts of the chart mean to an analysis of the books: which of them make the revenue, the net result, the equity, the total and the current assets, the cash, what customers owe and what is owed to suppliers. A statement cannot say it — its lines are the national rubrics, and 21/28 means nothing to a screen that draws a current ratio in every country — and the core may not, because an account code is the pack's. The function that reads the section is financial_analysis(), and analysis.md is what it returns and how each aggregate is read.

{
  "$schema": "https://ekwo.ai/schemas/pack/1.json",
  "aggregates": [
    { "code": "revenue",
      "legal_reference": "… classe 70 « Chiffre d'affaires » …", "source": "pcmn",
      "rules": [ { "kind": "code_range", "code_from": "70", "code_to": "70" } ] },
    { "code": "current_liabilities",
      "legal_reference": "… rubriques 42/48 et 492/3 …", "source": "comptes-annuels",
      "rules": [ { "kind": "code_range", "code_from": "42", "code_to": "48" },
                 { "kind": "code_range", "code_from": "499", "code_to": "499", "side": "credit" },
                 { "kind": "code_range", "code_from": "492", "code_to": "493" } ] }
  ]
}

The aggregates are a closed list, the sixteen of analysis.md: code is an enum of the schema, and the core's analysis_aggregates holds the same list with how each is read and its sign. A pack never says whether revenue is a credit or which way equity is printed; it says which accounts. Leave out an aggregate the chart cannot express rather than approximating it.

The rules are a statement line's, the same object and the same test — code_range, code_prefix, account_code, account_type, and side to take an account only while its balance is a debit or a credit. Model a mixed class the way the pack's balance sheet splits it: a bank account in debit is cash, in credit a debt; class 42 to 47 in debit is a receivable, in credit a liability. An account reaches an aggregate once, whatever the number of its rules that take it, and aggregates overlap by design — cash is inside the current assets, which are inside the total.

Mirror the statements. The balance-sheet aggregates of a pack are lines its balance sheet already prints: total_assets is the total of the assets, equity the equity rubric, trade_receivables the customers line. Take exactly the accounts those lines take, side included, and the golden shows the same figure on both. Then total_assets is equity plus everything else the balance sheet holds, which the golden runner asserts for every pack carrying the section.

Each aggregate cites its text — legal_reference, required: the class of the chart or the rubric of the statement it mirrors, as the country cites it — and names the register entry it is read in, in source.

charts lists the charts of the pack the rules are written for, and is left out for the default chart alone. A second chart — one for associations — has accounts of its own, and gets an analysis when somebody writes one for it.

Adding it to a pack

  1. Write packs/<cc>/analysis.json with revenue, net_result, equity and total_assets at least, from the pack's own statements as above.
  2. Raise schema_min to 20261014152111, the migration that created analysis_templates and analysis_rules, and raise the pack's version.
  3. ekwo pack build <cc>, then UPDATE_GOLDEN=1 npm test -- tests/golden.test.ts, which writes golden/analysis.json. Read it beside golden/statements.json: an aggregate that mirrors a line comes to that line's figure.

Nothing outside the folder changes. A pack without the section compiles exactly as before, and its companies are read through the framework's section below — by account type, with source = 'framework' on every row.

The framework's analysis

packs/generic/analysis.json is the section of the framework pack, and the reading financial_analysis() falls back to for a company whose country pack defines none for its chart. It is the analysis twin of packs/generic/statements.json: rules by account_type only — the framework has no chart, so a code means nothing to it — and no charts and no source; each aggregate cites the sections of IFRS for SMEs it follows and the account types it reads in legal_reference. The schema holds it to $defs.framework_analysis, and ekwo pack check refuses a rule of another kind and an account type that is not one of the eighteen.

It defines thirteen aggregates and leaves out three — staff_costs, financial_result and income_tax — because they sit inside expense and income_other with everything else, and no account type tells them apart. A company read through the framework gets no row for them, which a screen shows as not read rather than zero. analysis.md lists which account types make each aggregate.

ekwo pack build generic compiles it into supabase/seed/05_framework_generic.sql, beside the generic statements: rows with no country and no chart, owned by the framework's code from pack.json.

Compiling

ekwo pack list           # the packs this checkout carries, and their certification
ekwo pack describe be    # everything one pack says; every pack with no country named
ekwo pack build be       # writes supabase/seed/10_pack_be.sql, and the lists of packs
ekwo pack build --all
ekwo pack check be       # validate one pack, compare its seed and the lists of packs
ekwo pack check --all    # exit 1 if a committed seed or list is not the output of the packs
ekwo pack check --all --links   # … and open every URL of every source register

All of them run in a checkout of the repository and touch no database: they walk up from the command's own file looking for a directory holding both packs/ and supabase/seed/, and say so plainly when there is none — a published installation carries the compiled seeds and no packs/ folder, and there is nothing there to build.

--links is the one option that reaches the network: it opens every URL of every source register and names the ones that did not answer. It reports and never changes the exit code — official sites move pages and turn away requests with no browser behind them — so the CI never passes it and a maintainer reads the result.

ekwo pack list prints every pack of the checkout — the framework first, then each country with its version, its charts and their accounts, its taxes, its statements, its languages, its certification and its golden scenario. An excerpt:

Packs (…)
  generic  Generic framework 1.2.0 · 2 statements · no country · certification maintained
          no golden — A framework is not a country: this pack carries statements and an analysis by account type and nothing else …
  be  Belgium 1.15.0 · 2 chart(s), 706 accounts · 22 taxes · 3 statements · fr, nl, de, en · certification maintained · golden: 12 documents, 4 payments, 4 period(s)
          filing — BE-VAT-PERIODIC (31 boxes) · cadence: month · deadline: in the pack · file: vat-consignment · settles to: 451900
          default (default) — PCMN — plan comptable minimum normalisé, 355 accounts, BE-BNB-ABBR-BS, BE-BNB-ABBR-IS, BE-BNB-ABBR-AF
          asbl — PCMN — associations et fondations, 351 accounts, generic statements only
  …

The packs of this checkout

The table below is generated: ekwo pack build writes it from the manifests under packs/, and ekwo pack check refuses it when it no longer says what they say. ekwo pack describe <cc> is everything one of them says.

Pack Country Seed Languages Certification
ae United Arab Emirates 60_pack_ae.sql en community
ag Antigua and Barbuda 400_pack_ag.sql en community
al Albania 114_pack_al.sql sq, en community
am Հայաստան 128_pack_am.sql hy, en community
ao Angola 136_pack_ao.sql pt community
ar Argentina 45_pack_ar.sql es community
at Austria 63_pack_at.sql de, en community
au Australia 40_pack_au.sql en community
az Azərbaycan 129_pack_az.sql az, en community
ba Bosna i Hercegovina 113_pack_ba.sql bs, en community
bb Barbados 201_pack_bb.sql en community
bd Bangladesh 126_pack_bd.sql en, bn community
be Belgium 10_pack_be.sql fr, nl, de, en proven
bf Burkina Faso 23_pack_bf.sql fr community
bg България 73_pack_bg.sql bg community
bh البحرين 117_pack_bh.sql ar, en community
bi Burundi 215_pack_bi.sql fr community
bj Bénin 22_pack_bj.sql fr community
bm Bermuda 138_pack_bm.sql en community
bn Brunei Darussalam 144_pack_bn.sql en community
bo Bolivia 102_pack_bo.sql es, en community
br Brasil 148_pack_br.sql pt, en community
bs The Bahamas 401_pack_bs.sql en community
bt Bhutan 212_pack_bt.sql en community
bw Botswana 303_pack_bw.sql en community
ca Canada 58_pack_ca.sql en, fr community
cd République démocratique du Congo 34_pack_cd.sql fr community
cf Centrafrique 25_pack_cf.sql fr community
cg Congo 27_pack_cg.sql fr community
ch Schweiz 62_pack_ch.sql de, fr community
ci Côte d’Ivoire 21_pack_ci.sql fr community
cl Chile 46_pack_cl.sql es community
cm Cameroun 24_pack_cm.sql fr community
cn 中国 109_pack_cn.sql zh, en community
co Colombia 47_pack_co.sql es community
cr Costa Rica 105_pack_cr.sql es community
cv Cabo Verde 408_pack_cv.sql pt community
cy Cyprus 79_pack_cy.sql en community
cz Czechia 68_pack_cz.sql cs community
de Germany 18_pack_de.sql de, en community
dj Djibouti 216_pack_dj.sql fr community
dk Danmark 65_pack_dk.sql da, en community
dm Dominica 202_pack_dm.sql en community
do Dominican Republic 104_pack_do.sql es community
dz Algérie 86_pack_dz.sql fr community
ec Ecuador 101_pack_ec.sql es community
ee Estonia 13_pack_ee.sql et, en proven
eg مصر 83_pack_eg.sql ar, en community
es España 19_pack_es.sql es, en community
et Ethiopia 133_pack_et.sql en community
fi Finland 67_pack_fi.sql fi, en community
fj Fiji 222_pack_fj.sql en community
fr France 11_pack_fr.sql fr, en proven
ga Gabon 28_pack_ga.sql fr community
gb United Kingdom 14_pack_gb.sql en community
gd Grenada 203_pack_gd.sql en community
ge საქართველო 110_pack_ge.sql ka, en community
gg Guernsey 141_pack_gg.sql en community
gh Ghana 119_pack_gh.sql en community
gm The Gambia 300_pack_gm.sql en community
gn Guinée 29_pack_gn.sql fr community
gq Guinée équatoriale 31_pack_gq.sql fr community
gr Ελλάδα 76_pack_gr.sql el, en community
gt Guatemala 106_pack_gt.sql es community
gw Guinée-Bissau 30_pack_gw.sql fr community
gy Guyana 402_pack_gy.sql en community
hk Hong Kong 52_pack_hk.sql en community
hn Honduras 130_pack_hn.sql es community
hr Croatia 74_pack_hr.sql hr community
hu Magyarország 70_pack_hu.sql hu community
id Indonesia 38_pack_id.sql id community
ie Ireland 16_pack_ie.sql en community
il Israel 87_pack_il.sql he, en community
in India 108_pack_in.sql en community
is Ísland 81_pack_is.sql is community
it Italia 37_pack_it.sql it, en community
je Jersey 146_pack_je.sql en community
jm Jamaica 200_pack_jm.sql en community
jo Jordan 219_pack_jo.sql ar, en community
jp 日本 51_pack_jp.sql ja, en community
ke Kenya 89_pack_ke.sql en community
kg Кыргызская Республика 206_pack_kg.sql ru, en community
kh កម្ពុជា 407_pack_kh.sql km, en community
ki Kiribati 223_pack_ki.sql en community
km Comores 26_pack_km.sql fr community
kn Saint Kitts and Nevis 403_pack_kn.sql en community
kr 대한민국 54_pack_kr.sql ko, en community
kw Kuwait 143_pack_kw.sql ar, en community
ky Cayman Islands 139_pack_ky.sql en community
kz Қазақстан 124_pack_kz.sql kk, ru, en community
la Lao PDR 213_pack_la.sql en community
lc Saint Lucia 204_pack_lc.sql en community
li Liechtenstein 221_pack_li.sql de community
lk Sri Lanka 127_pack_lk.sql en community
ls Lesotho 208_pack_ls.sql en community
lt Lietuva 77_pack_lt.sql lt community
lu Luxembourg 12_pack_lu.sql fr, de, en community
lv Latvija 78_pack_lv.sql lv community
ma Maroc 84_pack_ma.sql fr community
mc Monaco 411_pack_mc.sql fr community
md Moldova 111_pack_md.sql ro, en community
me Crna Gora 116_pack_me.sql sr, en community
mg Madagascar 409_pack_mg.sql fr community
mk Северна Македонија 115_pack_mk.sql mk, en community
ml Mali 32_pack_ml.sql fr community
mn Монгол Улс 209_pack_mn.sql mn, en community
mo 澳門 145_pack_mo.sql zh, pt, en community
mr Mauritanie 410_pack_mr.sql fr community
mt Malta 80_pack_mt.sql en community
mu Mauritius 134_pack_mu.sql en, fr community
mw Malawi 217_pack_mw.sql en community
mx México 42_pack_mx.sql es, en community
my Malaysia 39_pack_my.sql ms community
mz Moçambique 135_pack_mz.sql pt community
na Namibia 304_pack_na.sql en community
ne Niger 33_pack_ne.sql fr community
ng Nigeria 49_pack_ng.sql en community
ni Nicaragua 132_pack_ni.sql es community
nl Nederland 17_pack_nl.sql nl, en community
no Norge 66_pack_no.sql nb community
np Nepal 214_pack_np.sql en community
nz New Zealand 41_pack_nz.sql en community
om Oman 118_pack_om.sql ar, en community
pa Panamá 107_pack_pa.sql es community
pe Perú 48_pack_pe.sql es, en community
pg Papua New Guinea 412_pack_pg.sql en community
ph Philippines 44_pack_ph.sql en community
pk Pakistan 125_pack_pk.sql en community
pl Polska 64_pack_pl.sql pl community
pt Portugal 43_pack_pt.sql pt, en community
py Paraguay 103_pack_py.sql es community
qa Qatar 142_pack_qa.sql ar, en community
ro Romania 72_pack_ro.sql ro community
rs Srbija 112_pack_rs.sql sr, en community
rw Rwanda 122_pack_rw.sql en, fr community
sa Saudi Arabia 71_pack_sa.sql en community
sc Seychelles 218_pack_sc.sql en community
se Sverige 61_pack_se.sql sv community
sg Singapore 50_pack_sg.sql en community
si Slovenia 75_pack_si.sql sl, en community
sk Slovensko 69_pack_sk.sql sk community
sl Sierra Leone 301_pack_sl.sql en community
sn Sénégal 20_pack_sn.sql fr community
sr Suriname 205_pack_sr.sql nl, en community
sv El Salvador 131_pack_sv.sql es community
sz Eswatini 207_pack_sz.sql en community
td Tchad 35_pack_td.sql fr community
tg Togo 36_pack_tg.sql fr community
th ประเทศไทย 56_pack_th.sql th, en community
tj Ҷумҳурии Тоҷикистон (Республика Таджикистан) 210_pack_tj.sql ru, en community
tm Туркменистан 406_pack_tm.sql ru, en community
tn Tunisie 85_pack_tn.sql fr community
to Tonga 227_pack_to.sql en community
tr Türkiye 82_pack_tr.sql tr community
tt Trinidad and Tobago 405_pack_tt.sql en community
tw 臺灣 53_pack_tw.sql zh, en community
tz Tanzania 120_pack_tz.sql en community
ua Україна 57_pack_ua.sql uk, en community
ug Uganda 121_pack_ug.sql en community
us United States 15_pack_us.sql en community
uy Uruguay 100_pack_uy.sql es community
uz Ўзбекистон Республикаси 211_pack_uz.sql uz, en community
vc Saint Vincent and the Grenadines 404_pack_vc.sql en community
vg British Virgin Islands 140_pack_vg.sql en community
vn Việt Nam 55_pack_vn.sql vi community
vu Vanuatu 228_pack_vu.sql en community
ws Samoa 225_pack_ws.sql en community
xk Kosova 123_pack_xk.sql sq, en community
za South Africa 88_pack_za.sql en community
zm Zambia 137_pack_zm.sql en community
zw Zimbabwe 147_pack_zw.sql en community

The SQL is a build artefact, like docs/schema.md. The source is the pack; the output is committed so that supabase db push and psql -f install a country without the CLI ever running; the CI's hygiene job runs ekwo pack check --all so the two cannot drift. Never edit a generated seed: the next pack build overwrites it and the CI refuses it in the meantime.

The number a seed carries is the pack's own

seed_sequence in the manifest. It is required, and it is immutable once published: the seeds are applied in file-name order and listed by name in supabase/config.toml, so a release that renamed one would rename a file an installation already holds — harmless, because the seeds upsert, and baffling to anyone reading the list a year later.

ekwo pack check refuses a pack that declares no number, and ekwo pack build refuses two packs claiming the same one.

Five kinds of file are generated, and check compares all five.

Source Output
packs/generic/ supabase/seed/05_framework_generic.sql
packs/<cc>/ supabase/seed/<n>_pack_<cc>.sql, where <n> is the number the manifest declares in seed_sequence — the table above gives each pack's. Packs take 10 to 89, then 100 to 899; 90 to 99 are kept for what runs after every pack (the demo company at 90). A number that has shipped never moves, whatever is added beside it
packs/<cc>/fixed_assets.json, where the pack has one supabase/seed/modules/fixed_assets/<n>_pack_<cc>.sql, applied by the module migration runner and by nothing else
packs/<cc>/corporate_tax.json, where the pack has one supabase/seed/modules/corporate_tax/<n>_pack_<cc>.sql, applied the same way
every pack, together the lists that name them outside packs/: [db.seed] sql_paths in supabase/config.toml, the psql -f lines of the README, the /packs/<cc>/ lines of .github/CODEOWNERS and the table above. Only the block between generated:<name> and /generated is written; the prose around it is not. A handle written on a pack's CODEOWNERS line is kept, and a new pack gets the owner of *

The compiler writes chart_templates, account_templates, journal_templates, tax_templates, tax_posting_templates, tax_report_templates, tax_report_box_templates, statement_templates, statement_line_templates, statement_line_rules, analysis_templates, analysis_rules, legal_mention_templates, country_defaults, country_packs and — for a module section — fixed_assets.country_rules and fixed_assets.category_templates, or the seven reference tables of the tax schema, and nothing that belongs to a company. Every insert upserts on the natural key — (country, chart_code, code) for an account, (country, code) for the rest — so an installation that applies a newer seed receives its corrections.

What an upsert cannot do is remove. A template deleted from a pack stays in the database, which is the rule anyway: nothing is ever deleted from a pack. An account is deprecated, a tax gets a valid_to, a form version gets a new valid_from.

The seed carries a checksum of the pack

country_packs.checksum is a sha256 over every file of packs/<cc>/, its path and its bytes. It is what lets an installation say which pack it is holding rather than which version number somebody typed, and it has one consequence worth knowing while you work: changing any file of a pack makes the committed seed stale, including a file the compiler never reads, such as i18n/README.md. ekwo pack build again and commit the result.

The generated files under golden/ are the deliberate exception — vat_return.json, statements.json, trial_balance.json and, where the pack has an analysis section, analysis.json are outside the checksum, and golden/scenario.json is inside it. A scenario is a decision about what a country's books look like, and moving it moves the pack; the expectations are what the engine made of that scenario, so a checksum that moved because the statements function gained a line would tell every operator that Belgium had changed.

What a stale seed looks like

Checking
  · 05_framework_generic.sql
✗ 10_pack_be.sql is not the output of packs/be
          golden: 12 documents, 4 payments, 4 period(s) of Exercice 2026
  · modules/fixed_assets/10_pack_be.sql
  · 21_pack_ci.sql
          golden: 12 documents, 4 payments, 4 period(s) of Exercice 2026
  …
✗ supabase/config.toml (generated:seeds) is not what packs/ says
  · README.md (generated:seeds)
  · README.md (generated:countries)
  · docs/packs.md (generated:packs)
  · .github/CODEOWNERS (generated:packs)

  Run `ekwo pack build --all` and commit the result.

Exit 1. A pack that fails validation rather than drifting exits 1 as well, and stops there: the packs after it are not checked.

Checking
✗ pack_invalid: packs/be — 2 problem(s)
  taxes.json[0]: missing "legal_reference"
  taxes.json BE-S-12.invoice: box ZZ:base is not a base box of this form

Problems are collected and reported together rather than one per run, in <file> <where>: <what> form, structure first and the translations last, with the first twenty printed and the rest counted. A usage fault — an unknown pack, no country and no --all — exits 2 instead, and names what the checkout carries.

What a tax says, and where its postings land

A tax in taxes.json says how much and what kind; its postings say where the money goes, per kind of document.

Field What it decides
kind vat, gst, sales_tax, withholding, other. A label for the reports, never an input to the calculation. Defaults to vat.
recoverable false when the buyer never gets the tax back — American sales tax, Canadian PST, a wholly non-deductible VAT.
price_include The unit price of a line carrying this tax already holds it (British, Australian and most retail). The engine adds the tax to the gross of the tax group, rounds it once as BR-CO-14 requires, takes it back off to get the base, and shares that base over the lines in proportion to their gross with the remainder on the last — so the document totals the price that was quoted, to the unit. A discount applies to the gross, before the conversion. Only a percentage tax may declare it: a fixed amount has no rate to divide by, and ekwo pack check refuses one.
base_includes_tax The taxable base of this tax contains the tax itself, and the rate is the rate of the law applied to that base: on a net amount N the tax is N × rate / (100 − rate), on a price that holds it (price_include) the price × rate / 100 — exact, rounded once on the tax group. The box of the base posting reports the base plus the tax, shared over the ledger lines so that it adds up. Only a percentage below 100 may declare it; ekwo pack check refuses the rest. An EN 16931 invoice cannot carry such a tax (BR-CO-17). Decision 0077.
jurisdiction ISO 3166-2 with the country prefix (CA-QC, US-CA) for a tax levied by a state. Null in Europe.
cash_basis, cash_basis_transition_account The tax falls due when the invoice is paid, not when it is issued. post_document books it — and the base it is computed on — on the transition account and on no declaration box; the matching moves the settled share to the account and the box it is declared on. A cash-basis tax has to name its transition account and takes one tax posting per document kind, with no tax_on_base: ekwo pack check refuses the rest.

A posting has one of three types:

  • base — the taxed amount itself. It carries no account: the account is the one the document line names.
  • tax — an amount on a tax account, which it must name.
  • tax_on_base — a share of the tax that is not recoverable. It carries no account either, and for the same reason as base: non-deductible VAT is part of what the thing cost, so it lands on the accounts of the lines it taxes, split in proportion to their bases.

factor is the share of the amount that reaches the ledger, box_factor the share reported in the box, and the two are independent because a box is filled with the sign and the fraction the form expects. The Belgian vehicle tax uses both:

{
  "code": "BE-P-21-50-I",
  "rate": 21,
  "scope": "purchase",
  "legal_reference": "Code de la TVA, art. 45, par. 2",
  "postings": {
    "invoice": [
      { "type": "base", "box": "83" },
      { "type": "tax", "factor": 50, "account": "411000", "box": "59", "box_factor": 50 },
      { "type": "tax_on_base", "factor": 50, "box": "83", "box_factor": 50 }
    ]
  }
}

On a 1 000 € car: 1 000 on the vehicle, 105 on the deductible VAT account, 105 more on the vehicle, 1 210 owed to the supplier. Grid 83 reports 1 105 — Belgium asks for the base plus the non-deductible VAT, which is what the form's « TVA déductible non comprise » means. A wholly non-deductible tax is the same shape with one tax_on_base posting at 100 % and no tax posting. France needs no box on its tax_on_base posting at all: the CA3 carries no grid for the base of a purchase.

The postings of one side share out the tax of the group, which is rounded once (EN 16931 BR-CO-14); the last posting of each side takes the remainder, so two halves of 0,63 come out as 0,32 and 0,31 rather than 0,32 twice.

On a purchase a positive factor books on the side of the base, the debit, and a negative one on the other side. A tax the buyer self-assesses — a reverse charge, an acquisition from another Member State, a service from abroad — therefore puts the positive posting on the tax it deducts and -100 on the tax it owes, as DE-P-DRITT-19 does (2244 and 6813). ekwo pack check refuses the reverse: a purchase tax whose two tax postings are one asset and one liability account, with the positive factor on the liability.

defaults.rounding_method and defaults.cash_rounding_unit belong to the same rule: a pack says how its country rounds, and a pack that says nothing gets the column's own default. The compiler writes default rather than a value of its own, so there is exactly one place where the mechanism is decided and no country is anybody's fallback.

rounding_method is read. It is half of the pair round_amount() applies to every amount the schema writes; the other half is decimal_places on the currency the pack names in defaults.currency, which says that the yen has none and the dinar has three. A pack that declares half_even therefore changes what its country's ledger holds, and the four methods — half up, half even, down, up — are all applied to the absolute value, so a credit note is always its invoice with the sign flipped. cash_rounding_unit is still declared and read by nothing: the socle has no cash-payment path to round a total on.

A service bought from a supplier abroad

A software subscription, hosting or an API billed from abroad is the most common purchase of a young company, and in most countries the buyer owes the tax on it: a reverse charge, a self-assessment, a withholding of the supplier's tax. A pack that says nothing about it lets the books carry no tax at all. So a pack whose taxes include a rated purchase VAT, GST or sales tax says something about it, in one of two ways:

  • a purchase tax with treatment foreign_services_received — or, in a Member State, intracom_acquisition_services for a supplier of another Member State, the return often splitting the two — posted like DE-P-DRITT-19: the tax owed credited, the tax deducted debited. A tax the buyer may deduct only later (AR-P-EXT, a period after) or not at all (SN-P-NR-18-ND, a cost) keeps the same treatment and says so in its postings;
  • or a not_taxed entry in pack.json, where the country's law leaves the case untaxed in the buyer's hands, with the article that says so and the key of its text in the register:
"not_taxed": [
  { "treatment": "foreign_services_received",
    "legal_reference": "Ley N.° 843, art. 1, inciso b) — el IVA grava [...] las prestaciones realizadas en el territorio de la Nación [...]",
    "source": "ley-843" }
]

ekwo pack check refuses a pack that does neither, an entry naming a treatment one of the pack's own purchase taxes carries, and an intracom_* treatment outside the common system of the European Union's VAT. ekwo pack describe and the MCP tool describe_pack return the entries beside the treatments the taxes carry. Where the law taxes the case only for some buyers — a business whose purchases all serve taxable supplies owes nothing, one with exempt supplies pays — the pack carries the tax for the case where it is due and its README says when a purchase takes no code.

A base is written once, and printed as often as the form likes

A tax carries one base posting per kind of document, so a taxable amount has one definition. ekwo pack check refuses a second one. A form that prints the same base twice is therefore never a tax with two base postings, and it is printed twice in one of two ways: by a total when the second place is a sum, and by the posting itself when it is not.

By a total, when the second place is a sum: a return that asks for a sale's taxable amount both in a rate breakdown and in a turnover line defines it once in the breakdown, and declares the turnover line a total adding up the breakdown boxes.

By the posting itself, when the second place is not a sum — a box printed inside a parent box that also holds other things, or one amount reported both as an output and as an input. The posting names every box it prints in:

{ "code": "EE-P-ICG-24", "rate": 24, "scope": "purchase",
  "treatment": "intracom_acquisition_goods",
  "postings": { "invoice": [ { "type": "base", "box": ["1", "6", "61"] },
                             { "type": "tax", "account": "2311", "box": "5" },
                             { "type": "tax", "factor": -100, "account": "2310", "box": "4" } ] } }

box takes a string or a list; the first is the box the posting is known by, and box_factor applies to each. ekwo pack check refuses an empty list, a box named twice, a box the form does not carry, and a box the form declares a total.

When you meet it, ask whether the second place is a sum. If it is — the turnover line above a rate breakdown, the box that adds the four boxes printed under it — declare a total naming what the postings wrote. If it is not, name both boxes on the posting. What is never right is two base postings: two definitions of one figure, kept in step by whoever reads the pack next, and pack check refuses them before that can start.

What a tax says on the invoice: treatment, category and reason

A tax says the same fact three times over. treatment is Ekwo's own word for what the operation is. vat_category is BT-118 and BT-151 of EN 16931, from the UNCL5305 subset the standard publishes. exemption_code is BT-121, from the VATEX list. Neither of the last two is read by the ledger or by the declaration — they are read by whoever renders the invoice — so until ekwo pack check learned the correspondence, a pack could declare an export taxed at the standard rate and every test in this repository would pass.

Three published sources decide the table, and nothing in it is anybody's opinion: UNCL5305 (the OpenPEPPOL subset) for what each category means, the European Commission's technical guidance for tax codes in EN 16931 for the pair each use case takes, and the VATEX code list, which states the pairing on its own codes (VATEX-EU-AE only with AE, VATEX-EU-IC with K, and so on).

treatment Side vat_category exemption_code
domestic either S above zero, Z at zero none
domestic_reverse_charge either AE VATEX-EU-AE
self_assessed purchase none none
intracom_goods sale K VATEX-EU-IC
intracom_services sale K VATEX-EU-IC
intracom_triangular sale K VATEX-EU-IC
intracom_acquisition_goods purchase K, or none VATEX-EU-IC
intracom_acquisition_services purchase K, or none VATEX-EU-IC
foreign_services_received purchase none none
export sale G VATEX-EU-G
import purchase none none
exempt either E the article claimed, from the VATEX list
not_subject either O VATEX-EU-O

A service to a taxable person has two codes, not one. Article 44 of Directive 2006/112/EC puts it where the customer is established, in another Member State or outside the European Union alike. The first is intracom_services, category K, in the European statement of services; the second is outside the scope of the seller's VAT, not_subject, category O, on a line of its own and in no statement. A pack inside the common system that carries the first carries the second, and tests/place_of_supply_services.test.ts refuses one that does not, apart from the packs it still lists by name while they are corrected wave by wave. node scripts/inventory-place-of-supply.mjs prints where every pack stands.

The last column applies where the European Union's VAT does. The VATEX list names articles of Directive 2006/112/EC, so for a country the common system does not reach:

  • exemption_code is null, and the article the line is exempt under goes in legal_reference — required wherever the category asks for a reason.
  • The five intracom_* treatments are refused. A country outside the common system makes no intra-Community supply.
  • vat_category is required only if the pack declares an e-invoicing profile (einvoicing.profile: peppol-bis-3, factur-x-en16931, a PINT…), since BT-151 is a term of an invoice built on EN 16931. A category a pack does name is still held to its treatment, its reason and its rate.

Should such a country publish reason codes of its own, the pack's register declares that list on one standard entry with "reason_codes": true, and the column takes its codes.

Which side of that line a pack is on is never written in code: it is the row of territories for the pack's country (eu_vat_scope) on the day of the pack's released_at. ekwo pack check reads it from supabase/seed/00_territories.sql, the file the database is seeded from.

A few decisions behind the table are worth knowing before filling a column in:

  • On a purchase, the category is the supplier's. It is the BT-151 the seller's invoice carries, so an intra-Community acquisition is K, not AE (AE is a reverse charge within one Member State). A purchase-only tax may leave it null; a contradicting value is refused either way.
  • Where no invoice governed by the standard exists, there is no category: import (a customs document), foreign_services_received and self_assessed carry none.
  • A triangular supply is K. intracom_triangular is the middle supply of an A → B → C arrangement; on the invoice it is the reverse-charge sentence that comes out.
  • self_assessed is a tax a buyer owes directly under the levying authority's own law — a use tax, for example — with no relieved supply behind it. foreign_services_received is a service from a supplier not established in the buyer's country, reverse-charged under the general business-to-business rule.
  • The rate is checked on sales only, against the EN 16931 rules for each category (S above zero; Z, E, AE, K, G, O at zero). On a purchase the rate is the one the buyer self-assesses at.
  • A reason code is checked for its shape (VATEX-EU-<article> or VATEX-<country>-<article>) and for the pairings the list states, not against a copy of the list.

What an exemption depends on

A tax code is the answer. Sometimes the question is not in the books either, and conditions is where a pack says which question it was.

A sale for resale is untaxed because the buyer handed over a certificate; a seller collects in a territory only once its sales there cross a threshold. Neither is a fact the ledger holds.

{ "code": "US-CA-S-RESALE", "rate": 0, "scope": "sale", "treatment": "exempt",
  "conditions": ["buyer_certificate"],
  "legal_reference": "Revenue and Taxation Code, section 6091 — …" }

Five words, and the vocabulary is closed:

conditions The answer turns on
buyer_certificate a document the buyer hands the seller, which the seller has to hold and be able to produce
buyer_status a quality of the buyer the statute names — a government, a body the law exempts as such
transport_evidence proof that what was sold went where the exemption requires it to have gone
seller_threshold a running total the seller crossed, or has not: economic nexus, a distance-selling limit, a small-business franchise
supply_nature what is supplied, classified more finely than any ledger holds it

It says what the question is and never how to answer it. There is no value beside a word here: no threshold amount, no certificate number, no date, no operator and no expression. That is the first invariant of this format and this field is where it would have been easiest to break — a conditions that could carry sales_into_territory > 500000 would be a pack that evaluates, and the next country would want a second operator. The amount a threshold is set at and the contents a certificate must have are in the article the tax already cites, which is where a reviewer reads them.

Nothing in the core reads it, exactly like treatment: it documents the tax so that an application can put the question to a human being instead of pretending to answer it, and what the tax does is said by its postings. It compiles to tax_templates.conditions and stops there, the way source_key does — this is the pack's transcription of a country's rule, not a property of the tax a company went on to edit, and taxes carries country and code, which is the join.

What it does not record is the evidence itself — a certificate held on a contact, a running total per territory. A pack states the code a bookkeeper reaches for once the answer is known, and says what the question was.

Which declaration a box belongs to

A box number is unique inside one form and nowhere else. Belgium and France each file one periodic return, so 59 has never been ambiguous; a Canadian company files the federal GST/HST return and the Québec one at the same time, and line 101 of one is not line 101 of the other. Every posting therefore carries a report_code, which the compiler fills from the code of tax_report.json — BE-VAT-PERIODIC, FR-CA3 — and which a posting may override with "report": "…" when a country files more than one.

A tax whose postings name no box at all is on no form of the pack: the return does not print it and settle_filing() does not clear it. That is the place for a tax declared on a return the pack does not carry — a self-assessed tax on a service bought from abroad, a withholding — and it stays on the liability its tax posting names until an entry, or the return that declares it, clears it. Say which in the pack's README.

The boxes of a declaration, and how a total is computed

tax_report.json is one form — BE-VAT-PERIODIC, FR-CA3 — and its boxes. A base or a tax box is summed from what the postings wrote on the ledger; a total is computed from the others:

{ "box": "71", "kind": "total", "name": "TVA à payer à l'État", "sequence": 320,
  "plus": ["XX"], "minus": ["YY"], "floor_zero": true }

There is no expression language: a list to add, a list to subtract and a floor at zero. That covers the Belgian 71/72, the French 16, 23, 25 and 28, and the British box 5; the one other shape a form writes out, a rate applied to another box, is below. hidden marks an intermediate total the form does not print — vat_return() returns it with the flag rather than dropping it.

How often the form is filed is part of the form. period is the list of cadences it accepts — ["month", "quarter"] in Belgium, France and Luxembourg, ["month"] in Estonia, ["month", "bimonth", "year"] in Ireland — and there is no default for it. Six cadences exist, each a whole number of months anchored on 1 January: month, bimonth (January–February, March–April…), quarter, four_month (from January, May, September), half_year (from January, July) and year. A form lists only the ones a text of its register allows, and a pack that names none is refused.

What the form is filed on unless the company has asked for something else is period_default, one of the cadences period lists. Declare it where the law of that country gives one answer to everybody, and say which text does in legal_reference; leave it out where the cadence depends on a fact about the company, because proposing one of several lawful answers there is choosing a filing deadline for somebody the pack knows nothing about. The United Kingdom, for example, proposes quarter on a form filed on three cadences, because its regulations make three months the prescribed period for everybody; a country where the cadence follows turnover proposes nothing.

When the form is due is deadline, a rule rather than a date, in a closed vocabulary of three words, each with the text that sets it in legal_reference:

rule The date it produces
day_of_month_after_period the day of the month that follows the period (Belgium's and Estonia's twentieth, Luxembourg's fourteenth)
last_day_of_month_after_period the last day of that month (California; the United Kingdom with plus_days: 7)
depends_on_taxpayer none: the administration assigns the day per filer, and the pack says so and cites the text that assigns it (France, where it falls between the fifteenth and the twenty-fourth by place of filing, legal form and registration number)

plus_days adds an extension to the first two and is refused on the third, which has no date to add it to; day is required by the first and refused by the others. Leave deadline out only when nobody has read the text yet: that is the one meaning a missing deadline has, and the country page prints it as a gap. filing_deadline() answers null for both — the core cannot know which slice of a per-taxpayer schedule a company falls in.

The unit the form is filed in is rounding, and almost every form leaves it out: its figures are filed at the decimals of its currency. A form filed in coarser units says so, with the text that sets it —

"rounding": {
  "unit": 1,
  "legal_reference": "CDTFA-401-A — \"please round cents to the nearest whole dollar\"",
  "source": "cdtfa-401-a"
}

unit is a power of ten (1, 10, 1000); ekwo pack check and the database both refuse anything else, because a unit of 0.05 is a coin and belongs to defaults.cash_rounding_unit. The ledger keeps its cents and vat_return() answers the exact figures; prepare_filing() freezes each box at the unit, from its own exact figure, and filing_drift() compares at the same unit, so the cents a ledger holds are not a drift. A frozen box has no scale of its own: a return in a currency without decimals freezes 1000000, not 1000000.00.

defaults.vat_period, the former place for this, is still read; a pack that says both and says two different things is refused.

Which cadence a given company files a given declaration on is a row of company_filing_periods, settled at install — one row per form, because a company is subject to several declarations and the cadence of one says nothing about the cadence of another. ekwo init asks once per form the pack declares and preselects nothing but the form's own period_default; --vat-period answers for the periodic return outside a terminal and --filing-period <report_code>=<cadence>, repeatable, for any of them. A company that has not decided is recorded as not having decided. companies.vat_period is still published and is the mirror of the row for the country's periodic return. vat_return() then refuses a period the company does not file that form on — and only when the refusal is certain: a cadence is recorded for this declaration, the form is filed on it, the dates are themselves a whole cadence of that form, and the two differ. A fortnight, a half-year asked of a form not filed half-yearly, and a form the company has recorded nothing about all go through. ec_sales_list() applies the same guard to the form it is told it is preparing, and refuses nothing when it is told none.

A reference is bare (54) where the form carries the box once, and qualified (08:tax) where it carries a base and a tax on the same line, as the CA3 does. ekwo pack check refuses:

  • a reference to a box the form does not carry;
  • a bare reference that would match two kinds — qualify it;
  • a total that names itself, and a box that is a rate of itself;
  • boxes that are worked out from each other, by name — the cycle a dependency order cannot survive. Declaring a total after the total it names is not a cycle and is not refused: a form prints what it likes in the order it likes;
  • a formula or a rate on a box that is summed from the ledger;
  • a rate beside a plus or a minus list, which would be two ways of computing one figure, and a rate without a rate_of or the reverse;
  • the same box declared twice with the same kind;
  • a tax that posts to a box the form does not declare;
  • a form that names no cadence, and a proposed cadence the form is not filed on.

The form is reference data and is never copied into a company: a chart of accounts is customisable, a form is not. A new version of a form is a new code with its own valid_from, like a new VAT rate is a new tax code, and vat_return() takes the one in force at the end of the period.

A total is a list of boxes, or a rate of one box

A total is a list of boxes in every European return, because a European return reports a tax the ledger already worked out, document by document. A sales tax return does not: it works the taxable total out for the period and then states each tax line as a multiplication in as many words. CDTFA-401-A says multiply line 12 by 0.06, and the pack says the same thing with two named fields:

{ "box": "13", "kind": "total", "name": "State tax 6.00 percent", "sequence": 200,
  "rate": 6.0, "rate_of": "12" }

rate is a percentage and rate_of is the box it applies to, resolved exactly as a plus reference is — bare, or qualified with its kind. A rate is not an expression: there is nothing to parse, nothing a pack could execute, and a reviewer reads six per cent of line 12 in the diff. It is the one arithmetic a declaration form actually writes out, which is why it is the only one the format gained.

A box is a list or a rate, never both, and only a computed box carries either: ekwo pack check and a check constraint both refuse the rest. Where the second line of a form is a sum of what the postings already wrote, it is a plus list and not a rate — the rule of "a base is written once" above is unchanged.

sequence is where a box is declared, print_sequence where it is printed

The order the totals are worked out in is neither: it is the boxes each one names. evaluate_totals() resolves by dependency, so a form may print a subtotal above the boxes it adds up — the eCDF sections do, and CDTFA-401-A prints line 11 on page 1 and computes it from Sections A and B on page 3 — and the pack says both orders instead of choosing one:

{ "box": "11", "kind": "total", "name": "Total nontaxable transactions reported",
  "sequence": 180, "print_sequence": 40, "plus": ["45"] }

print_sequence is optional and means sequence when it is left out, which is what every pack written before it says. vat_return() answers the resolved value beside sequence, so a renderer orders by one column. A cycle — a box worked out from itself, or two worked out from each other — is refused by ekwo pack check, by name; at runtime it is formula_cycle, and never a loop.

Which province a party is in

defaults.region is the other half of the same story: where tax follows the buyer's province or state, companies.region and contacts.region hold it (ISO 3166-2 without the country prefix). The core never chooses a tax for anyone, in any country.

Where a tax applies: applies_when on a tax

A tax may say where the parties have to be, and that is a different question from the one above: region is a province, a short list a Canadian company picks from, and this is the body of tax law a party is under. California levies a sales tax and the United States does not; Northern Ireland is inside the common system of VAT for supplies of goods and Great Britain is outside it altogether, under one registration. Neither can be composed out of a country and a region — XI is not spelled GB-NI in any register the European Union uses — so a territory is a column of its own and a key of territories.

{ "code": "US-CA-S-725", "rate": 7.25, "scope": "sale",
  "jurisdiction": "US-CA",
  "applies_when": { "seller_in": "US-CA", "supply_in": "US-CA" },
  "postings": { "invoice": [ … ] } }

Four keys, and every one present has to hold:

applies_when The tax applies only where
seller_in the seller is in this territory
buyer_in the buyer is in this territory
supply_in the supply takes place in this territory
supply_vs_seller same: the supply lies inside the seller's territory; other: it lies outside it

There is no operator, no negation, no disjunction and no nesting — the same closed vocabulary the mentions' own applies_when is. What a pack can say is that a party is in a place, and that the supply is or is not where the seller is. The fourth key is a relation between two facts the document carries, not a negation of a territory: California's section 6396 exempts a sale shipped to a point outside the State, and US-CA-S-SHIPPED says so with "supply_vs_seller": "other".

supply_vs_seller is read at the level of the seller. A seller in US-CA shipping to US-AZ is other; one whose territory is a whole country has no level to be compared at, and post_document() refuses the document with no_party_territory, asking for the seller's territory_code. ekwo pack check refuses the key outright on a pack whose country has no territory inside it in territories, because no document of its companies could ever meet it.

A condition is satisfied by the territory named and by every territory inside it. territories.parent_code draws the tree, so seller_in: "GB" reaches a seller in Northern Ireland and seller_in: "XI" does not reach one in Great Britain. That is what lets one country's pack carry two sets of taxes and offer each where it applies.

Except where the parent's own tax stops. The tree is geography, and the Canary Islands are in Spain; Spanish VAT does not apply there (Ley 37/1992, art. 3). territories.outside_parent_tax records that, row by row and with the national text in the row's legal_reference, and the engine judges a tax's conditions with territory_within_for_tax(), which stops at such a row. So supply_in: "ES" refuses a supply in ES-CN, ES-CE or ES-ML without the pack writing a word about them. The flag is reference data of the release; a pack never sets it.

Where each party is is resolved by the core and never guessed at by a pack:

  • The seller and the buyer are the company on one side and the contact on the other, decided by the kind of document — a purchase invoice is somebody else's sale. Each resolves to territory_code on its own row, and failing that to its country: companies.fiscal_country, contacts.country.
  • The supply is documents.supply_territory_code, failing that documents.delivery_country — BG-15 of EN 16931, which is where the goods went and not where the invoice was addressed — and failing that the buyer's territory, because a supply nobody said anything else about is delivered to the person who bought it.

The engine refuses; it does not choose, and it keeps what it saw. post_document() writes the three answers on the document — seller_territory_code, buyer_territory_code, supply_territory_resolved — where they are frozen with the rest of a posted document. It compares the conditions of every tax on the document with the three answers above and raises tax_territory_mismatch where they disagree, naming the tax, the party, the territory asked for and the territory found. Where a tax asks about a party the document says nothing about, it raises no_party_territory and names what would answer. The core still picks no tax for anybody, in any country: what it does is refuse one that cannot apply.

A territory a tax names has to be a row of territories. ekwo pack check refuses a code the table does not carry, and so does the column — the three are foreign keys. It refuses a jurisdiction the table does not carry for the same reason, now that there is a table to check it against, and it refuses a seller_in outside the pack's own country, because a pack is keyed on the country its companies file under. A pack outside the common system, or one that conditions a tax on a territory below the country, therefore adds rows to supabase/seed/00_territories.sql.

The regime a tax is held to follows the territory too. Whether the VATEX list of EN 16931 reaches a tax used to be a question about the pack's country; where a tax names seller_in, it is a question about that territory. A tax applying in a territory whose eu_vat_scope is goods is inside the common system exactly where its treatment is a supply or an acquisition of goods, and outside it everywhere else — which is the Protocol on Ireland/Northern Ireland stated as a check.

What it cannot say. A tax cannot name a place a party is not in. A supply the contract requires to be shipped out of the taxing territory is therefore still written as an ordinary exemption with the article in legal_reference; the condition exists, the word for it does not.

What a country puts on an invoice

Three sections of the manifest — documents, einvoicing and bank — compile into twenty-three columns of country_defaults and into legal_mention_templates. None of them has a default: a pack that says nothing leaves null, and a reader that needs the value says which one is missing rather than borrowing another country's law. One null is read, and said to be: a pack silent on posted_edit_policy keeps a posted document as it was posted, which is the stricter answer rather than another country's.

"documents": {
  "numbering": "gapless_per_year",
  "number_format": "{CODE}/{YYYY}/{NNNN}",
  "legal_payment_days": 30,
  "late_payment_reference": "…où le taux et l'indemnité sont fixés",
  "tax_point": "invoice_if_issued",
  "posted_edit_policy": "reversal_only",
  "references": {
    "numbering": {
      "legal_reference": "Arrêté royal n° 1 du 29 décembre 1992, art. 5, § 1er, 1° — …",
      "source": "ar-1"
    },
    "payment_terms": {
      "legal_reference": "Loi du 2 août 2002, art. 4, § 1er — …",
      "source": "retard-de-paiement"
    },
    "tax_point": {
      "legal_reference": "Code de la TVA, art. 16, § 1er et art. 22, § 1er pour le principe, art. 17, § 1er et art. 22bis, § 1er pour la dérogation — …",
      "source": "code-tva"
    },
    "posted_edit_policy": {
      "legal_reference": "Code de droit économique, art. III.87, § 2 et art. III.88 — …",
      "source": "pcmn"
    }
  },
  "mentions": [
    {
      "code": "reverse_charge",
      "applies_when": "reverse_charge",
      "text": "Autoliquidation — taxe à acquitter par le cocontractant.",
      "sequence": 10,
      "legal_reference": "Arrêté royal n° 1 du 29 décembre 1992, art. 20"
    }
  ]
},
"einvoicing": {
  "profile": "peppol-bis-3",
  "mandatory_from": "2026-01-01",
  "obligation": "mandatory",
  "party_scheme": "0208",
  "vat_scheme": "9925",
  "legal_reference": "Loi du 6 février 2024 modifiant le Code de la TVA, art. 53, § 2 — …",
  "source": "facturation-electronique"
},
"bank": {
  "account_scheme": "iban",
  "statement_formats": ["coda", "camt.053"],
  "payment_formats": ["pain.001"]
}

How an account is identified. bank.account_scheme says what the banks of the country call an account, and it is what ekwo init and create_bank_account ask for. An IBAN is one answer among several, and about half the world does not have one:

Scheme The identifier is Typical of
iban an IBAN, with its check digits countries of the IBAN registry
aba-routing-account a 9-digit ABA routing number, then the account number the United States
sort-code-account a 6-digit sort code, then the account number the United Kingdom
bsb-account a 6-digit BSB, then the account number Australia
ifsc-account an 11-character IFSC, then the account number India
clabe 18 digits, the last a check digit Mexico
zengin a bank code, a branch code, then the account number Japan
transit-institution-account a transit number, an institution number, then the account number Canada
account-number a number as the bank wrote it the fallback

A pack that declares nothing is asked for account-number and never for an IBAN. The keys are the ones of BANK_ACCOUNT_SCHEMES in @ekwo-ai/core, which holds the one validator of each; the schema of the pack accepts exactly those keys, and tests/bank_account_schemes.test.ts keeps the two equal. Adding a scheme is adding it to the registry, to the schema and to a test — it is a reader and not a pack — in the core, and in read_bank_account_identifier() of the schema, which create_bank_account() asks; tests/bank_account_identifier_sql.test.ts holds the two readers to the same answers. The value is stored in country_defaults.bank_account_scheme, and a pack that declares it needs schema_min of 20260929141500 or later.

The zones a country belongs to. zones is a list of lower-case names at the root of the manifest — ["european-union"] for a Member State — and it is how a country says it shares rules with others. Nothing in the core reads a zone; what only exists inside one (intra-community VAT, the recapitulative statement, VIES, the One-Stop Shop) is described on docs/zones/<zone>.md, and ekwo pack check refuses a zone with no page there. A country that stands alone declares none.

The number. numbering is gapless_per_year, gapless, sequential or free, and the first two compile to numbering_gapless = true. Whether the counter restarts each year is readable in number_format, which carries the year or does not. The pattern is four tokens and literal text around them:

Token Is
{CODE} the journal or series code
{YYYY}, {YY} the year of the document
{MM} the month
{NNNN} the counter, zero-padded to as many N as are written

Nothing reads the pattern yet — next_entry_number() builds CODE/YYYY/NNNN — so a pack declares what its numbers look like, and the day a numbering engine consumes a format it produces the same numbers it always did. ekwo pack check refuses a token nobody defined and a pattern with no counter, or with two.

Whether the profile is obligatory is einvoicing.obligation, in a closed vocabulary of three words, so that "no obligation" and "nobody looked up the date" are never confused:

Value Says mandatory_from
mandatory a statute obliges companies to exchange the profile between themselves required: the day it starts
on_request no statute obliges every company, but a seller has to issue one when a buyer the law entitles to ask does — Estonia since 1 July 2025 refused: the day the right began goes in the legal reference
none at released_at, no statute obliges companies to exchange electronic invoices between themselves — the United Kingdom, where an obligation is announced for 2029 and not legislated refused

An obligation towards the public sector alone, or one announced and not legislated, is written in legal_reference and does not change the word. none may stand without a profile, for a country with no profile to name — the United States — and the country page then prints that answer instead of a missing profile. A pack that leaves obligation out is described as mandatory where it names a date, and as saying nothing where it does not; only a declared word reaches country_defaults.einvoice_obligation.

The tax point is the country's general rule, in a closed vocabulary of five words:

Value The rule it says
invoice_date the invoice fixes it, and nothing displaces it
delivery_date the supply fixes it
payment_date collection fixes it
invoice_if_issued the supply is the principle, and an invoice displaces it where the country requires one
earliest_of_delivery_or_payment whichever of a supply and a payment came first

The last two exist because many countries put the tax point at the supply and let an invoice displace it, or take the earlier of a supply and a payment.

Declare the principle and its derogation together, not the derogation alone. A word that names half a rule reads exactly like one that names all of it, and the reader who finds out is the one filing a return.

A tax that departs from the country rule says so itself, with cash_basis — which is how France taxes goods on delivery (CGI art. 269, 1, a and 2, a) and services on collection (art. 269, 2, c) without the country model contradicting itself, and why France declares delivery_date and is right to.

What the engine does with it. tax_point_of() is the only function that reads the column, and it turns the word plus a document's own dates into one date. post_document() writes that date onto documents.tax_point_date — EN 16931's BT-7, which a document may also state itself, and a stated fact outranks a rule — and onto every ledger line a tax posting writes. Each line keeps the day it counts for a return in entry_lines.declared_on — its tax point, or its entry's date where it has none — and vat_return() reads the period on that column, so a figure lands in the period its tax fell due in rather than the period its entry was booked in. A document that says nothing new answers exactly as it did: null means the entry's own date. The payment branch of a rule is honoured through cash_basis, and the recapitulative statement reads the entry date.

The schemes are ISO 6523 identifier codes, four digits, and there are two because they are not the same identifier: party_scheme is how a party is addressed on the network (0208 the Belgian enterprise number, 0009 the French SIRET) and vat_scheme is the VAT identifier (9925, 9957). Where a country has two registration identifiers, declare the one its invoices carry and name the other in the legal reference.

The article behind each rule is documents.references, one entry per rule and not one for the section. A tax and a box of a declaration form are rows and carry their own legal_reference and source; the rules here are words — gapless_per_year, 30, invoice_date — with nowhere to write either, and a word looks exactly the same whether somebody read the decree or guessed. Three entries, because they are three articles of two or three different texts in every country covered so far: numbering answers for the style and the pattern together, payment_terms for legal_payment_days alone (the interest and the indemnity stay in late_payment_reference, which is a sentence to print rather than a citation), and tax_point for the tax point. einvoicing writes its legal_reference and source flat, beside the profile, because the section is one rule. All four pairs compile into country_defaults, beside the rule each belongs to.

Where the value declared is a derogation rather than the country's principle, say so in the reference.

Whether a posted document goes back to draft is posted_edit_policy, and it is the one document rule that loosens rather than binds. reversal_only: a posted document is undone by a credit note that names it, cancel_document(), and in no other way. unpost_if_untouched: it may also go back to draft, through unpost_document(), while nothing about it has left — never sent nor on Peppol, not settled, not credited, no declaration gone over it, its period open and, where numbering is gapless, its number the last one its journal drew, which then goes back to the counter. Say nothing and the country reads as reversal_only. Declare unpost_if_untouched only with the article that allows it under references.posted_edit_policy — ekwo pack check refuses it without one, on every status — and say in it why the irreversibility your own accounting law gives a recorded entry does not reach a document nobody received.

The bank formats are a known list rather than free text — camt.052, camt.053, camt.054, mt940, mt942, coda, cfonb120, ofx, qif, bai2, csv for statements; pain.001, pain.008, cfonb160, mt101, ach, bacs, eft, csv for payments — because a parser is written against a format and not against a name somebody typed. The list grows with the country that needs it.

Fiscalisation: einvoicing.fiscalisation

Some countries require the invoicing software itself to leave, for every invoice it issues, a record that cannot be changed, chained to the previous one by a hash, often shown on the invoice as a QR code and a sentence, and sent to the tax authority at once or later — a chained-record regime. Others require no chain, no QR code and no sentence, only that the data of the documents reach the authority — a reporting regime. Others again have every invoice submitted to the authority before it is issued, and the invoice is valid only once the authority has validated it — a clearance regime. A pack says which in an optional object of its einvoicing section: with hash, the records are chained; with clearance, the invoices are cleared, and nothing is chained; with neither, they are the documents' data reported, the brick format names writes the report, and the core keeps no chain for it. Left out, the country has no such regime; nothing is defaulted.

"fiscalisation": {
  "mandatory_from": [{ "who": "large", "from": "2030-01-01" }, { "who": "others", "from": "2031-01-01" }],
  "format": "a-record-format",
  "hash": { "algorithm": "SHA-256", "encoding": "hex-upper", "separator": "&",
            "fields": { "alta": ["…", "…"], "anulacion": ["…"] } },
  "qr": { "services": [{ "modes": ["realtime", "batch"], "environment": "production", "url": "https://…" }],
          "size_mm": { "min": 30, "max": 40 }, "error_correction": "M" },
  "mention": [{ "text": "…", "alternatives": ["…"], "modes": ["realtime", "batch"] }],
  "submission": { "mode": "realtime", "modes": ["realtime", "batch", "none"],
                  "batch_max": 1000, "interval_seconds": 60, "retry_seconds": 3600 },
  "events": { "summary_every_hours": 6, "modes": ["none"] },
  "tax_attributes": { "<tax code>": { "<attribute>": "<value>" } },
  "line_attributes": { "<attribute>": "<value>" },
  "legal_reference": "…", "source": "<register key>"
}

A reporting regime, with a day only announced:

"fiscalisation": {
  "announced_from": "2031-01-01", "announced_by": "…", "announced_on": "2030-06-01",
  "format": "a-report-format",
  "submission": { "mode": "batch", "channel": "accredited_platform",
                  "cadence": [{ "who": "monthly_filer", "data": "transactions", "period": "ten_days",
                                "due": { "rule": "days_after_period", "days": 10 } }] },
  "reported": [{ "what": "sales_to_non_taxable_persons", "by": ["supplier"] }],
  "legal_reference": "…", "source": "<register key>"
}

A clearance regime, phased by size, with a threshold of exemption that has moved:

"fiscalisation": {
  "mandatory_from": [
    { "who": "turnover_above_100", "from": "2030-01-01", "turnover_above": { "amount": 100, "currency": "XXX" } },
    { "who": "turnover_above_10", "from": "2031-01-01", "turnover_above": { "amount": 10, "currency": "XXX" } }
  ],
  "exemption": [
    { "turnover_below": { "amount": 1, "currency": "XXX" }, "valid_from": "2030-06-01", "legal_reference": "…", "source": "<key>" },
    { "turnover_below": { "amount": 3, "currency": "XXX" }, "valid_from": "2031-06-01", "legal_reference": "…", "source": "<key>" }
  ],
  "format": "a-clearance-format",
  "clearance": { "verification": "returned", "cancellation_window": "PT72H", "rejection_window": "PT72H" },
  "submission": { "mode": "realtime", "channel": "authority_service" },
  "legal_reference": "…", "source": "<register key>"
}
Field What it says
mandatory_from One date, or one per category of taxpayer (who, a short name the legal reference explains). Where the law phases the obligation by size, every step carries turnover_above — the annual turnover or revenue above which a taxpayer is in it, an amount and its currency — and may carry its own legal_reference and source
exemption The thresholds below which a taxpayer is exempted, as a dated history, oldest first: turnover_below (an amount and its currency), valid_from (the first day it holds, as the text dates it), legal_reference and source, all four required. Each holds until the next one's valid_from; when the threshold moves, an entry is added and none is removed, so a past period keeps its answer
clearance Exclusive of hash. verification — where the link the invoice carries to the authority's verification comes from: returned by the authority with its validation, computed by the issuer from the invoice and the authority's answer, or none — and, as ISO 8601 durations counted from the validation (PT72H, P15D), cancellation_window, how long the issuer may still cancel a validated invoice, and rejection_window, how long the buyer may reject it; each left out where the law grants none, and then the database refuses it. Where verification is computed, verification_url gives the link per environment (production, test), {identifier} for the identifier the authority gave and any other {name} for that value of its answer; printed names what the invoice prints of its validation beside the QR code, each field of the validation (identifier, validated_at, reference, submitted_at) under a label, a key of the invoice labels of @ekwo-ai/invoice-pdf, and a heading among them
announced_from The day an obligation is announced to start while no text in force sets it, with announced_by and announced_on: who announced it, and when. Exclusive of mandatory_from; an announced day binds nobody, and fiscalisationStatus() counts no sale against it
format The brick that writes the record, by the key of FISCALISATION_FORMATS in @ekwo-ai/core; one no brick writes is refused by name when a record is asked for, format_without_brick
hash Left out for a reporting regime. The digest (SHA-256, SHA-384, SHA-512), its code in the authority's list and its encoding, the separator of the name=value pairs hashed, and the ordered fields covered — one list, or one per kind of record (alta, anulacion). The core reads the fields back out of the brick's text and the database recomputes the digest
qr The verification service — url, or services per mode and environment — the parameters, the standard, the error correction level, the size and quiet zone in millimetres, the words printed beside it
mention The sentence the invoice carries, with the alternatives the law accepts, per mode where it differs
submission The default mode and every mode the law allows: realtime (each record as it is made), batch (in messages), none (kept, handed over on request); the size of a batch, the wait between sendings, the longest a pending record waits; channel, who carries the records (a short name); cadence, where the law sets periods: per category of taxpayer (who) and kind of data (data), the period one sending covers (day, week, ten_days, month, two_months, quarter, half_year, year) and when it is due — days_after_period with days, or month_after_period_from_day with day, from that day to the end of the month after the period
events How often a summary event is kept, and in which modes
reported What a reporting regime reports — a scope, what, a short name the legal reference explains — and who reports it, by: supplier, customer or both (the supplier when left out). A sibling of events: those are the records a system keeps of its own life, these the operations the authority is told of
tax_attributes What the brick needs on a tax line that its category does not decide alone, by code of a tax of this pack. Under a clearance regime, what the electronic invoice itself needs: the brick of the pack's e-invoicing profile reads them on the rows of document_tax_summary — the tax type of the format's own list, for instance, or the sentence of an exemption (exemption_reason), which a line of the books may override with its own
line_attributes What the brick needs on every line that the books do not record, where the format's own list has an entry for whatever is not one of its named cases — a classification Others. Given on each line of a document the brick writes; left out, nothing is given. A line that is one of the named cases says so in its own attributes (document_lines.attributes, pre-filled from products.attributes), which win. The legal reference says why the entry is the right one and which lines it is wrong for

Every sub-object may carry its own legal_reference and source; the block carries both, and ekwo pack check refuses it without them. It also refuses an obligation both in force and announced, an announcement without who made it and when or made after the day it announces, a cadence for a regime that sends no batch or naming one kind of data of one category twice, a due rule without its number, a scope reported twice, a day that does not exist, a mode named that submission.modes does not list, a batch_max where batch is not allowed, a QR size whose minimum is above its maximum, no production service for a mode the law allows, a field named twice in a hash list, a tax_attributes code the pack does not carry and an empty line_attributes; a computed verification link without its verification_url, or one given for a link that is not computed, and a printed label the invoice layout does not have; a hash beside clearance, the mode none under a clearance regime (an invoice the authority never receives is never validated), steps of a phased obligation whose thresholds do not fall as the days move on, some steps sized and others not, thresholds stated in two currencies, and thresholds of exemption out of order. The block compiles, normalised (kind chained, clearance or reporting, clearance, exemption and the members of a sized step present only where the pack declares them, so a block that declares none compiles as before, mandatory_from always a list, hash null unless the regime chains, hash.fields always by kind, * for every kind), into country_defaults.einvoice_fiscalisation, which the einvoicing module reads when it chains a record. fiscaliseDocument() refuses a reporting regime by name — format_without_brick while no brick writes its format, fiscal_regime_without_chain once one does — and fiscalisationStatus() counts posted sales as missing a record only under a chained regime in force, from its earliest mandatory_from. Under a clearance regime in force it counts the posted sales without the authority's validation, from the same day: the books do not say a company's turnover, so the status names the threshold of exemption that holds today and leaves to the company whether it is bound. The submission and every answer are recorded by einvoicing.record_clearance_submission() and einvoicing.record_clearance_answer(), which check every transition against the windows of the pack (modules/einvoicing); the call to the authority is the host's. The schema names no country: every value of a regime is in its pack, with its article.

The identifiers a cleared invoice asks of both parties beyond a VAT number and a registration number — a tax identification number, a registration for a tax that is not a VAT — are not pack data: they are facts of each company and each contact, kept in companies.identifiers and contacts.identifiers by the code of their scheme, {"<scheme>": "<identifier>"}, as the format names the scheme. The pack's README says which schemes its format reads, and the brick reports by name the one a party lacks.

What was sold, by the classification of the country: classifications

A country whose documents name the goods by a nomenclature — a customs code of the Harmonized System, a national extension of it at eight or ten digits, the list of an authority's own e-invoicing format — declares in pack.json the attribute that holds the code, with the pattern of its codes and the text that prescribes it (decision 0078):

"classifications": [
  {
    "attribute": "hs_code",
    "name": "Harmonized System heading",
    "pattern": "^[0-9]{6}$",
    "legal_reference": "The article that asks for the code on an invoice",
    "source": "a-key-of-the-register"
  }
]

attribute is the name the brick of the format reads in a line's attributes, in snake case. pattern is anchored (^…$) and kept to the classes, ranges and quantifiers Postgres and JavaScript read alike; ekwo pack check refuses an unanchored or broken one and an attribute declared twice.

The codes are facts of each sale, not pack data: a product carries them in products.attributes and a document line in document_lines.attributes, {"hs_code": "<code>"}, with every other attribute its format asks. A line that names a product takes the attributes it does not carry. The database refuses a code off the pattern of its classification (classification_code_invalid); a posted line keeps its codes. A pack that declares no classification says nothing in the seed, and the attributes of its lines are checked by their shape alone.

The mentions, and when each applies

applies_when is a closed vocabulary and never an expression: an accountant reads the value and knows what it means, and a pack that could write a condition would be a pack that executes.

applies_when Applies when
always every document of the country
reverse_charge a line carries a tax the customer owes: a domestic reverse charge, a service received from a supplier who is not established here, or the middle supply of a triangular arrangement — three articles behind one sentence
intra_eu_goods a line carries an intra-Union supply or acquisition of goods
intra_eu_services the same, for services
export a line carries a supply outside the European Union
exempt a line carries an exemption that is none of the above
late_payment the document is a sale invoice to a business (contacts.is_company), never a credit note, and its company has not turned the mention off (companies.invoice_late_payment_mention)
cash_basis a line carries a tax that falls due on collection
small_business never selected — see below

document_legal_mentions(document_id) is the view that decides. It joins on the company's fiscal country, on the document's own date — so a reprint of an old invoice carries the wording of its own year — and on the treatments of the taxes the lines already carry, which is why nothing extra has to be recorded on a document for its mentions to come out right.

small_business is the exception and it is deliberate: a franchise regime is a property of the seller and the core records no such column, so the sentence is in the table for a renderer that knows the regime and the view never selects it. The day the regime becomes a column, the view gains one branch.

A mention cites the article that requires it. ekwo pack check refuses one that does not, a duplicate code, and a validity that runs backwards. Retiring a wording is a valid_to and a new row, never an edit — the rule that governs a tax rate and a box of a form governs a sentence too.

The words of an invoice the law names: documents.invoice_labels

The words of an invoice's layout — Invoice, Bill to, Due date, Amount due — are the same from one country to another, and @ekwo-ai/invoice-pdf carries them once per language: « Facture » is written once for every French-speaking country. A few words are not the layout's: the law of a country names them, and names them its own way. A Belgian legal person prints its « numéro d'entreprise » on every invoice; the French set of the layout calls the same line « Numéro d'immatriculation ». The pack names those, and only those, each with the article that names it:

"documents": {
  "invoice_labels": {
    "registrationNumber": {
      "text": "Numéro d'entreprise",
      "legal_reference": "Code des sociétés et des associations, art. 2:20, 4°",
      "source": "csa-indications"
    }
  }
}

A key is a key of the layout's words (InvoiceLabels), among the twelve the schema lists: the title of the document and of a credit note, the tax point, the VAT and registration numbers, the share capital, and the words of the tax — its rate, its summary, its base, its amount and the totals with and without it. The text is in defaults.language; the other languages translate it in their file, under the same key:

{ "language": "nl", "invoice_labels": { "registrationNumber": "Ondernemingsnummer" } }

A declared language covers every word the manifest names, like every other section; a word the manifest does not name is refused in a language file. The words compile into country_defaults.invoice_labels, by language then by key, the pack's own language among the others, and the PDF of a document (ekwo doc pdf, render_invoice_pdf) prints the pack's word for the document's language, then the layout's, then English; its answer lists the words that came from the pack (pack_labels). A pack that names words needs schema_min of 20261029010000 or later.

Versions, and what a company holds

pack.json.version is semver:

Change Version
A label, a translation, a legal source, a legal reference patch
An account, a tax, a box, a statement line; a validity that closes minor
A new declaration form, a new statement framework major

The generated seed writes one row in country_packs: the version this installation holds, the certification status, and a sha256 of every file of the pack. install_country_template writes company_packs: which version that company copied, and when. ekwo status prints both and says so when a company is behind.

Where a company stands, and moving it

Two commands read an installation rather than a checkout, so they need a connection and know nothing about packs/.

ekwo pack status --db-url "$EKWO_DB_URL"
Packs loaded here (…)
  BE Belgique  1.5.0
  FR France    1.4.0
  …

Companies (2)
  · Example One  BE/default  copied 1.0.0, loaded 1.5.0
        4 addition(s), 1 closure(s) — applied by rule; 2 to review
  · Example Two  FR/default  copied 1.4.0, loaded 1.4.0 — up to date

It changes nothing and exits 1 while a company is behind, so a cron can ask.

ekwo pack upgrade "Example One" --db-url "$EKWO_DB_URL"
Example One — BE pack 1.0.0 → 1.5.0
  · tax BE-P-21-50-I — added from the pack
  · tax BE-S-06 — added from the pack
  · tax BE-S-21 — validity closed on 2026-12-31
  4 change(s) applied: an addition and a closed validity take nothing away.

To review (2)
  Not applied. Read them, then run again with --apply if the pack is right.
  ! account 700000 — differs: {"pack":{…},"company":{…}}
  ! tax_posting BE-S-21 — differs: {"pack":[…],"company":[…]}

Yours, not the pack's (1)
  Never applied by an upgrade: nothing is removed from a company's books.
  account 999500 — held here, not in the pack

Version
  still 1.0.0: 2 difference(s) are waiting to be decided.

The difference is computed by natural key — an account code, a journal code, a tax code — and every difference falls into one of three rules:

Rule What an upgrade does
addition The pack has something the company does not. Copied in.
closure The pack has closed the validity of a tax the company still holds open. Applied — this is how a rate change reaches a company: the old one stops, the new one is an addition.
review Everything else: a label that differs, a rate that differs on the same code, a posting that books somewhere else. Listed and never applied without --apply.

A row the company holds and the pack does not is listed under its own heading and is never applied, whatever is asked: nothing is removed from a company's books by an upgrade. An account may carry entries and a tax may be on a posted document, and a pack that retires a code retires it with a valid_to.

The recorded version moves only when nothing is left waiting. A company that still holds a difference it has not decided on has not finished upgrading, and moving the number would hide that difference at the next run.

The same three rules are in the schema — pack_upgrade_diff(company) and pack_upgrade(company, country, apply) — so an application, a module or an agent asking the same question gets the same answer, and every upgrade writes a pack_upgraded row into audit_log naming what it applied and what it left. Installing again in the meantime adds what is missing and changes nothing that exists.

Immutable once published: the country of a pack; the code of an account and its type — reclassifying a code would reclassify history; the code of a tax and what it means; the identifier of a box inside a version of a form. A new VAT rate is a new tax code plus a valid_to on the old one, never an edit, which is how the return of a past period keeps giving the same answer.

Languages

The pack's own files are written in defaults.language. Every other language is one file, i18n/<lang>.json, and that file is the only place a translation lives — the manifest carries no second wording, and neither does fixed_assets.json or corporate_tax.json. One file per language means a contributor edits one file and a reviewer reads one file.

{
  "language": "nl",
  "pack_name": "België",
  "charts":           { "default": "MAR — minimum algemeen rekeningenstelsel" },
  "accounts":         { "400000": "Handelsdebiteuren" },
  "journals":         { "SAL": "Verkoopdagboek" },
  "taxes":            { "BE-S-21": "Verkoop 21 %" },
  "tax_report_boxes": { "54": "Btw op de handelingen van de roosters 01, 02 en 03" },
  "statement_lines":  { "BE-BNB-ABBR-BS:10/15": "EIGEN VERMOGEN" },
  "legal_mentions":   { "reverse_charge": "Btw verlegd — …" },
  "invoice_labels":   { "registrationNumber": "Ondernemingsnummer" },
  "asset_categories": { "machinery": "Installaties, machines en uitrusting" }
}

A box is keyed by the same reference its formulas use — 59, or 08:tax where a form carries a base and a tax on one line. A statement line is keyed <statement>:<line>, because two statements may both carry a line 20.

The manifest declares which languages the pack publishes, and that declaration is a promise rather than a description:

"languages": ["nl", "de", "en"]

ekwo pack check fails, naming every missing key, if a declared language stops covering the accounts of every chart, the journals, the taxes, the boxes, the statement lines, the legal mentions, the words of an invoice or the asset categories. A file that is not declared may be partial — a key it does not carry falls back to the pack's own label — which is how a language is contributed one section at a time. A label under a code the pack does not carry is refused either way.

The labels land in name_i18n / text_i18n on every template table, and install_country_template(company, country, language) copies the chosen one into name while keeping the whole object beside it, so a company installed in Dutch can be read in English later without importing anything again. languages.md is the whole mechanism, including how a reader's own preference comes before the company's.

Golden scenario

A pack that compiles says nothing about whether it adds up. A tax that posts to the wrong grid and a grid that expects the wrong postings agree with each other: the seed builds, every unit test passes, and the return is wrong. packs/<cc>/golden/ is the second opinion — one year of books somebody wrote down, and beside it the declaration, the statements and the trial balance the engine makes of them, to the cent, in the pack's own currency.

scenario.json is the input, and it is declarative like everything else in a pack. Nothing in it executes and nothing in it is an amount that was computed:

{
  "name": "Une année d'un commerce belge assujetti, déclarant par trimestre",
  "chart": "default",
  "fiscal_year": { "name": "Exercice 2026", "start": "2026-01-01", "end": "2026-12-31" },
  "periods": [{ "code": "2026-T1", "from": "2026-01-01", "to": "2026-03-31" }],
  "statements": ["BE-BNB-ABBR-BS", "BE-BNB-ABBR-IS"],
  "contacts": [{ "ref": "client-be", "name": "Atelier Lumen SRL", "type": "customer", "country": "BE" }],
  "documents": [
    { "ref": "V1", "type": "sale_invoice", "contact": "client-be", "date": "2026-01-20",
      "why": "Une vente au taux normal : la grille 03 et la TVA due de la grille 54.",
      "lines": [{ "name": "Marchandises", "quantity": 10, "unit_price": 1000,
                  "tax": "BE-S-21", "account": "700000" }] }
  ],
  "payments": [
    { "ref": "E1", "direction": "inbound", "date": "2026-02-20", "amount": 12100,
      "contact": "client-be", "journal": "BNK", "match": "V1",
      "why": "Un encaissement qui solde exactement sa facture." }
  ]
}

why is required on every document and every payment. A golden grows by accident otherwise: somebody adds a line to make a figure move, and two years later nobody can say what the document was for or whether dropping it would lose anything.

Three optional fields say where the parties are, and a pack needs them only where its taxes ask. territory beside chart is the territory the company of the scenario is established in; territory on a contact is the party's own; and supply_territory on a document is where the goods went, when it is neither the buyer's territory nor the country. packs/us/ uses all three, because the Californian codes are conditioned on a Californian delivery and the year has sales into three other states. A pack whose country taxes uniformly leaves all three out and nothing changes.

Three expectation files sit beside the scenario and are generated, never written by hand, and a fourth where the pack has an analysis section:

File Holds
vat_return.json every box of the return that came to something, per period, keyed <box>:<kind>
statements.json every line of every statement named, over the financial year
trial_balance.json every account that moved or ended with a balance: debit, credit, closing
analysis.json every aggregate the pack defines, over the financial year, with the accounts that make it
UPDATE_GOLDEN=1 npm test -- tests/golden.test.ts

rewrites those files and never the scenario — a runner that could rewrite its own inputs proves nothing. The diff is then the thing to review.

What only this pack can claim

A fourth file may sit beside the three, and it is the only one under golden/ written by hand: expectations.json. It holds what a pack states that neither its own files nor a generic test can check — a fact key of a national filing taxonomy, an arithmetic a law writes into a declaration form. The rest of a pack checks itself: a test that compared statements.json to a copy of statements.json would prove nothing.

{
  "note": "Claims about this pack that no other pack can make.",
  "statement_facts": {
    "BE-BNB-ABBR-BS": { "20/58": "met:am1|bas:m25|part:m1" }
  }
}

Every block is optional, and a pack that carries no file at all is read as an empty one. One runner in tests/ loads whichever blocks a pack states and checks them against the pack and against the seed, so a contributor adds a claim by editing their own pack and never a test.

Like the other files under golden/ beside scenario.json, it is outside the pack checksum: it is evidence about the pack, not part of it, so adding one does not change country_packs.checksum and does not make a seed stale.

ekwo pack check validates the scenario, against the schema and against the pack: the chart, every account code, every tax code and its scope and its validity, every journal, every statement, and every match that has to name a document of the scenario and a payment going the right way. It refuses fewer than ten documents. The full list is under "What ekwo pack check refuses".

tests/golden.test.ts is one runner for every pack. It reads packs/, installs a company on each from what that pack's scenario declares, replays the documents and the payments through post_document, post_payment and reconcile, and compares. Nothing in it knows a country — what it asks of a scenario, it asks of the pack: both directions, more than one positive rate, a credit note, a matched payment and an unmatched one; a reverse charge between Member States of the European Union on each side the pack offers one; a tax due on collection and a partly recoverable tax where the pack has them. A country with no cash-basis tax is not asked for one.

One assertion is not read from a file: the posted ledger balances. A golden regenerated from a broken engine would agree with itself; double entry would not.

A pack with no golden is refused — by readPack, so by ekwo pack check and by the CI — unless the manifest says why:

"golden": { "exempt": "A framework is not a country: this pack carries statements and an analysis by account type and nothing else …" }

which ekwo pack check and ekwo pack list print. packs/generic/ is the one exemption in this repository, and the reason is structural: a framework has no chart, no journal, no tax and no currency, so no company can be installed on it and there is no year of books to replay. Its lines are proved where they are reachable — through a chart that names no statement of its own and falls back to it — and its analysis through a company whose country pack defines none.

What a golden does not prove. That the figures are the law. A box expected wrongly and a posting written wrongly pass together, which is the whole reason the two things under "Certification, and who may say what" exist: a source on every tax and every box, and a status that says out loud how much anyone has read. It is also worth knowing what the balance sheet of an open year looks like — the result is not on it until close_fiscal_year() puts it there, so assets exceed liabilities by exactly the result of the income statement, in every country.

A tax on turnover in the place of VAT: turnover_tax.json

A country that puts small companies under a regime where one tax on what they sold replaces VAT declares each regime in turnover_tax.json (decision 0080):

{
  "regimes": [
    {
      "code": "XX-TURNOVER",
      "name": "The name of the regime",
      "replaces": ["vat"],
      "window": { "months": 12, "includes_period": false, "short": "annualise" },
      "expense_account": "the code of an expense account of every chart",
      "legal_reference": "The article that sets the regime",
      "source": "a-key-of-the-register",
      "report": {
        "code": "XX-TURNOVER-RETURN",
        "name": "The name of its return",
        "periods": ["month"],
        "period_default": "month",
        "valid_from": "the first day of the form",
        "legal_reference": "The article that sets the return",
        "deadline": { "rule": "day_of_month_after_period", "day": 20, "legal_reference": "…" }
      },
      "brackets": [
        { "valid_from": "…", "up_to": "180000.00", "rate": "4", "legal_reference": "…" },
        { "valid_from": "…", "rate": "7.3", "deduction": "5940.00", "legal_reference": "…" }
      ]
    }
  ]
}

The tax of a period is its turnover × (base × rate / 100 − deduction) / base. The base is the turnover of the window — the months before the period, or ending with it under includes_period — and without a window, the period's own turnover. A bracket takes the bases up to its up_to, inclusive; the last one has none. A flat rate is one bracket with no up_to and no deduction. Amounts and rates are decimal strings, and there is no expression: ekwo pack check refuses a table whose thresholds do not rise or that does not end with exactly one open bracket, an expense_account a chart lacks, and a report that is the periodic return of tax_report.json.

short says what the window of a company active for fewer months than it counts becomes: annualise (the turnover of the months it has, times the window's months over their number; in its first period, the period's own turnover annualised) or as_is.

A company declares its regime in company_tax_regimes, by whole financial years; under it, a document line carrying a tax of a kind in replaces is refused at posting. turnover_tax_return() computes a period, post_turnover_tax() posts it against defaults.roles.tax_payable and prepares the filing of the regime's form, and upcoming_filings() lists that form in the place of the periodic return. A pack without the file says nothing in its seed.

VAT schemes: vat_schemes.json

A country where a company files different declarations depending on its VAT scheme declares the schemes in vat_schemes.json (decision 0086), and its forms as a list in tax_report.json:

[
  { "code": "XX-MONTHLY", "name": "…", "period": ["month", "quarter"], "boxes": [ … ] },
  {
    "code": "XX-ANNUAL", "name": "…", "role": "annual", "reads_postings_of": "XX-MONTHLY",
    "period": ["year"], "period_default": "year",
    "boxes": [
      { "box": "A1", "kind": "base", "name": "…", "from": ["01:base"], "legal_reference": "…", "source": "…" },
      { "box": "A9", "kind": "tax", "name": "Instalments", "reads": "instalments_due", "legal_reference": "…", "source": "…" }
    ]
  }
]

A list carries at most one form whose role is periodic — the one the postings are for, and the one a single-form pack has without saying so — and every other form says it is annual. A form that restates another names it in reads_postings_of, and each of its base or tax boxes names the boxes of that form it adds up in from; a tax box may instead reads the payments on account of the declaration, instalments_paid or instalments_due. A pack whose tax_report.json holds one object compiles to exactly what it did.

[
  {
    "code": "simplified", "name": "…", "legal_reference": "…", "source": "…",
    "forms": [ { "report": "XX-ANNUAL", "periods": ["year"], "period_default": "year" } ],
    "ceilings": [
      { "measure": "turnover_previous_year", "activity": "goods", "amount": "500000",
        "currency": "XXX", "valid_from": "…", "legal_reference": "…", "source": "…" }
    ],
    "instalments": {
      "report": "XX-ANNUAL", "base": { "report": "XX-ANNUAL", "box": "B1:total" },
      "minimum_base": "1000", "legal_reference": "…", "source": "…",
      "schedule": [ { "month": 7, "percent": "55" }, { "month": 12, "percent": "40" } ]
    }
  },
  { "code": "exemption", "name": "…", "charges_vat": false, "forms": [], "legal_reference": "…", "source": "…" }
]

measure is turnover_previous_year, turnover_current_year or tax_previous_year; activity is all, goods or services. A ceiling without its legal_reference and source is refused by the schema, and one in another currency than the pack's by ekwo pack check, which also refuses a scheme naming a form tax_report.json does not carry or a cadence that form is not filed on, instalments on account of a form the scheme does not file, a base box the form does not have, and a schedule adding up to more than 100 %. A form of the pack that no scheme names is outside every scheme and filed as before. A pack without the file has no scheme.

The corporate tax section

packs/<cc>/corporate_tax.json is what a country says about the tax on a company's profit. It is the section of the corporate income tax module (modules/corporate-tax), compiled into supabase/seed/modules/corporate_tax/ and applied only where that module is installed. A pack without the file says nothing, and a company of that country is refused by name — no_corporate_tax_rules — rather than given a neighbour's rates.

The computation it feeds is always the same, and the file is its seven inputs:

accounting result  →  adjustments  →  losses  →  base  →  rates  →  credits  →  tax
   result            adjustment_rules  loss_carryforward    rates      credits
{
  "tax":    { "code": "XX-CIT", "name": "…", "legal_reference": "…", "source": "…" },
  "result": { "statement": "XX-IS", "line": "PBT", "legal_reference": "…" },
  "accounts": { "expense": "695000", "payable": "444000", "receivable": null, "legal_reference": "…" },
  "parameters":        [ … ],
  "adjustment_rules":  [ … ],
  "rates":             [ … ],
  "loss_carryforward": [ … ],
  "prepayments":       [ … ],
  "credits":           [ … ]
}

One discipline above the others: a figure nobody verified is left out. Every rate, rule, limit, schedule and credit carries a legal_reference and a valid_from, both required by the schema, and a source naming the entry of the register where the text was read. A rule that is missing makes an estimate too high by something a reader can see is missing; a figure that was guessed makes it wrong in a way nobody can see. The Belgian pack carries the prepayment rates of one assessment year and not of the one before, because only one was read on the administration's own page.

Where the computation starts: result

One line of one income statement of the pack. The module asks financial_statement() for it, so the accounting result is the socle's own figure and a closed year still reads as the year it was.

A country whose statement prints a result before income tax names that line, and the tax charge never enters the computation — Belgium names 9903. A country that prints only the net result names it and adds the charge back by a rule on its accounts — France names HN, the figure form 2058-A starts from, and carries a rule on 695. Both are one decision, written once.

A chart that does not report on that statement — the Belgian chart of associations — cannot be estimated, and says so: no_result_statement.

The accounts: accounts

expense, payable and, where the chart keeps one apart, receivable. ekwo pack check refuses a code missing from any chart of the pack. The provision of a computation is booked on expense against payable; receivable is declared and not read yet: the company books its payments in advance itself.

What a company declares: parameters

{ "code": "small_company", "type": "boolean",
  "name": "La société est une petite société au sens de l'article 1:24 …",
  "legal_reference": "Code des impôts sur les revenus 1992, art. 215, al. 2", "source": "…" }

A judgement (boolean) or an amount (amount), stated by the company for each financial year in tax.company_parameters. Nothing infers one. A turnover ceiling is a declared amount even though a ledger holds a turnover: the law's figure is restated to twelve months and summed over a group, and the books know neither. The name is the question, written to be answered with a yes, a no or a figure.

Where a threshold is indexed every year and the indexed figure could not be read, the parameter becomes the judgement itself — the company is not excluded by the condition of article … — and the pack carries no amount. The Belgian pack does this for the director remuneration condition from assessment year 2027.

What is added back and deducted: adjustment_rules

{ "code": "restaurant", "name": "Frais de restaurant — quotité non déductible",
  "direction": "add_back", "percent": 31,
  "valid_from": "2025-12-31", "valid_on": "period_end",
  "legal_reference": "Code des impôts sur les revenus 1992, art. 53, 8°bis", "source": "…" }
Field Is
direction add_back for an expense the tax refuses, deduction for an income it leaves out or an allowance the books do not carry
percent the share of the base the rule moves — the refused share, so a restaurant bill deductible for 69 % is a rule of 31
formula instead of percent, where the share depends on what the company states about the expense — see below
accounts the accounts of the pack's chart whose whole balance falls under the rule, in the vocabulary of a statement rule: account_code, code_prefix, code_range

accounts is for an account that holds nothing else. The French chart keeps fines on 671200 and the tax charge on 695, so both rules name their account and apply by themselves. The Belgian chart keeps restaurants and receptions on one account, 69 % and 50 % deductible: no rule names it, and a company says which amount is which. Naming an account that mixes two treatments adds back too much, silently.

Without accounts, a rule waits for the company: tax.adjustments names an account of its own whose balance is the base, or states an amount for a year.

A formula is a closed vocabulary, not an expression. One variable the adjustment states, a straight line, an optional coefficient picked by a word the adjustment states, a rounding, a floor, a ceiling, and steps:

"formula": {
  "yields": "deductible_percent",
  "variable": "co2_g_km", "intercept": 120, "slope": -0.5,
  "coefficient": { "parameter": "fuel", "values": { "diesel": 1, "other": 0.95 } },
  "decimals": 1, "min": 50, "max": 100,
  "steps": [ { "from": 200, "percent": 40 } ]
}

reads: 120 − 0,5 × coefficient × grams, rounded to a tenth, at least 50 and at most 100, and 40 from 200 grams up. yields says what the line gives — deductible_percent, and the rule moves what is left of a hundred, or adjustment_percent, the share moved. A rule that needs two variables is a design discussion, not a second vocabulary: the French ceiling on car depreciation is left to the company to state for that reason.

The rates: rates

{ "code": "reduced-sme", "name": "Taux réduit des petites et moyennes entreprises",
  "rate": 15, "up_to": 42500, "up_to_prorata": "months",
  "conditions": [
    { "parameter": "turnover_twelve_months", "test": "at_most", "amount": 10000000 },
    { "parameter": "capital_fully_paid_up", "test": "is_true" } ],
  "valid_from": "2022-12-31", "valid_on": "period_end",
  "legal_reference": "Code général des impôts, art. 219, I, b", "source": "…" }

A rate with up_to takes the slice of the base below that amount; the one without takes what is left. up_to_prorata: "months" shares a threshold stated for twelve months over the months the year has.

Conditions are tests on declared parameters, all of which have to be met: is_true, is_false, at_least, at_most, below, above. Two more words cover what the first sections needed and nothing else:

  • or_at_least: "taxable_base" — the amount fails the test and is still at least the taxable base of the year, which is how Belgium words its remuneration condition;
  • waived_by: ["…"] — boolean parameters any one of which, declared true, lifts the condition: a company in its first years, an approved cooperative.

A parameter that was not declared is not met. The estimate says so in a line of its own, rate_not_applied, with the parameter — not_declared: small_company — so a company is never quietly taxed at the wrong rate.

A pack carries one rate that applies unconditionally to the whole base, or it is refused: a reduced rate needs the ordinary one beside it.

Losses, prepayments, credits

loss_carryforward is a limit: floor and percent_above together — France, 1 000 000 and 50 — or both null for none, and years where a loss lapses. Losses are used oldest first. A pack that says nothing refuses a company that carries a loss, by name.

prepayments is read by the plan of prepayments of the module, tax.prepayment_plan(). Two methods cover the two shapes met so far — surcharge_on_shortfall, where nothing is compulsory and each instalment paid in time earns a credit_percent against a surcharge_percent, and share_of_reference_tax, where each instalment is a share_percent of a reference year's tax and none is asked under exempt_up_to.

credits says that a credit exists, whether what exceeds the tax is paid back (refundable), and the article. The amount is the company's to declare. The first sections carry an empty list: the shape is published, and no credit was cited yet.

A date, and the day it is read on

Every dated entry has valid_from, an optional valid_to, and valid_on:

valid_on Reads the validity on For a law that applies to
period_start (the default) the first day of the financial year financial years opened from a date
period_end the last day financial years closed from a date — which is also how an assessment year is said: Belgian assessment year 2026 is "valid_from": "2025-12-31", "valid_on": "period_end"

A figure that changes is a new entry, never an edit: the same code, a new valid_from, and a valid_to the day before on the old one. The Belgian ceiling on the cars of 2023 to 2025 is four entries of one code — 75 %, 50 %, 25 %, then nothing deductible. ekwo pack check refuses two versions in force on one day, and versions of one code that disagree about valid_on.

The worked examples: golden/corporate_tax.json

A section comes with at least three fictitious companies whose tax was worked out by hand, and ekwo pack check refuses a section without them: a company with a profit, a year that ends in a loss, and, where a rate has conditions, a company kept out of that rate. Each carries the journal entries of its year, what it declares, the losses it carried in, and under expected every line tax.estimate() has to return — with the arithmetic written out, one step per sentence, in computation:

"computation": [
  "Restaurant : 3 127,45 × 31 % = 969,5095, soit 969,51.",
  "Taux réduit, conditions remplies : 100 000,00 × 20 % = 20 000,00.",
  "Taux ordinaire sur le solde : 39 361,77 × 25 % = 9 840,4425, soit 9 840,44."
]

modules/corporate-tax/tests/golden.test.ts replays every one of them to the cent, for every pack that carries the section, and prints that arithmetic when a figure disagrees. Like the other files under golden/ apart from the scenario, it is outside the pack checksum: it is evidence about the pack.

The translations

i18n/<lang>.json gains one section, corporate_tax, keyed by what is labelled: tax, then parameter:<code>, rule:<code>, rate:<code> and credit:<code>. A rule with four dated versions is translated once. A language the manifest declares has to cover every key.

What ekwo pack check refuses

Two passes, and the first one is where nearly everything is caught. Every file is validated against packs/schema/pack.1.json — its shape, its required fields, its closed vocabularies — and then against the rest of the pack, which is what a schema cannot do: a tax may not post to a box the form does not carry, and only the pack knows which boxes the form carries. The second pass compiles and compares the committed seeds. A pack that fails the first never reaches the second.

The list below is the whole of it. It is long because a pack is data somebody will file a return with.

The manifest, and the charts. A chart whose accounts file does not exist. No chart marked "default": true, or more than one — ekwo init would have nothing to install when nobody names one. Two charts with the same code. A chart naming a statement the pack does not carry.

The chart of accounts. The same account code twice in one chart. A parent that is not in the same chart. A row missing code, type or name, or carrying an account_type that is not one of the eighteen. The CSV itself: an empty file, a row with a different number of fields from the header, a quote that opens mid-field, a quoted field that never closes, a reconcilable that is not true or false, a sequence that is not a whole number.

The roles and the journals. A code in defaults.roles that is missing from any one chart of the pack, named with the chart it is missing from — a second chart is exactly where this goes wrong. A defaults.journal_roles entry naming something that is not a journal. A role name the schema does not know, which is how a typo in a role is caught rather than silently ignored.

The closing style. A pack that declares closing_style and does not name the accounts that style needs: retained_earnings for the first, current_year_result_profit and current_year_result_loss for the other two. No journal_roles.opening. An opening journal that exists but is not of journal type opening — which is the mistake worth naming, because the code resolves and the year-end entries would land on a journal that carries ordinary traffic.

The taxes and their postings. Two taxes with the same code. A group, which is reserved for the country that stacks two taxes on one line and which the core does not carry yet. More than one base posting on a document kind. A tax posting with no account, or a base or tax_on_base posting carrying one — those two land on the account of the line they tax, so an account on them is a misunderstanding worth stopping. An account, or a cash_basis_transition_account, that is missing from a chart. A posting whose box the declaration form does not carry — each of them, where the posting names several — an empty list of them, the same box named twice by one posting, or a box the form declares a total.

treatment, vat_category and exemption_code are held to the table under What a tax says on the invoice: a category the treatment contradicts, a category where no invoice governed by EN 16931 exists, a missing category on a tax that can reach a sale, a reason code reserved for another category or shaped wrongly, a reason missing where the category asks for one, a rate the category forbids on a sale, and a treatment declared on the side it does not happen on. Every message names the tax, what it says, what the source says instead and which source.

Where the common system of VAT does not reach the country (per territories, on the day of released_at), the rules of that section apply: a VATEX code is refused, another list's code only with a register entry declaring "reason_codes": true, an exempt line needs a code or a legal_reference, the intracom_* treatments are refused, and a category is asked for only with an e-invoicing profile. Every message names the country, the day and the scope the table gives it.

A cash-basis tax has four of its own: it names its transition account, takes exactly one tax posting per document kind and no tax_on_base, and its tax posting names a box.

A pack whose taxes include a rated purchase VAT, GST or sales tax and that says nothing about a service bought from a supplier abroad — see A service bought from a supplier abroad. A not_taxed entry naming a treatment one of the pack's own purchase taxes carries, the same treatment twice, an intracom_* treatment outside the common system of the European Union's VAT, or a source the register does not hold.

A territory a tax names — in applies_when or jurisdiction — has to be a row of territories; applies_when.seller_in has to be inside the pack's own country; and where a tax names seller_in, the regime it is held to is that territory's.

The declaration form. The same box declared twice with the same kind. A formula on a base or a tax box, which is summed from the ledger. A reference to a box the form does not carry, or to a kind it does not carry. A bare reference that matches two kinds — 08 where the form carries 08:base and 08:tax — which is refused with both spellings offered rather than resolved to one of them. A total that names itself. A total that names a total computed at the same sequence or later.

The statements. Two statements with the same code; two lines of one statement with the same code. A parent that is not a line of the same statement. A formula naming a line of another statement, or its own line. A line that is both summed from the ledger and computed from other lines, in either direction. Totals that depend only on each other. One account reaching two lines other than as a debit side and a credit side. An account of a chart that reaches no line of any statement covering that chart — the check that makes a balance sheet balance.

The fact keys have five of their own. A statement carrying any xbrl key must name its taxonomy; a key is at least a metric and one domain member, each part a qualified name such as bas:m9, with no empty or padded part and no dimension twice; and two lines of one statement may not carry the same key, which is how a key missing a member shows up.

The register of sources. Two entries claiming one key — a reference would resolve to whichever came first. An entry that is not shaped like one: a field missing, a field nobody defined, a kind outside the six, a url that is not absolute and https. A source naming a key the pack's register does not carry, which reads as a text and is a typo; the message lists the keys there are. A pack whose status is not community and whose register holds no entry anybody can open, because maintained, proven and reviewed are all claims that somebody keeps this current and none is sayable about a list of titles. And on a reviewed pack, a tax or a box that names no source at all — that one is a warning on a maintained or a proven pack and nothing on a community one. A proven pack whose certification.evidence holds no entry with the result reproduced, because the status is the claim that one did. A bare string in the register is a warning everywhere: it is the shape the field had before, and a pack written against it still compiles.

The legal mentions and the document rules. Two mentions with the same code. A valid_to before its valid_from. A mention with no legal_reference — a sentence the law requires cites the article that requires it. A number_format carrying a token nobody defined, or no counter, or two. An einvoicing.mandatory_from with no profile: a day an obligation starts and nothing that says what becomes obligatory. An obligation of mandatory with no mandatory_from, one of on_request or none with one, and any obligation with no legal_reference. A source under documents.references or in einvoicing naming a key the register does not carry. And on a reviewed pack, a document rule that is declared and cites no article at all — that one is a warning on every other status, including community, because a country whose law nobody has written down yet is the normal state of a new pack and not a defect of it. The exception is posted_edit_policy: unpost_if_untouched, which lets a posted entry be taken away: it is refused without an article on every status.

The zones and the bank scheme. A zones entry with no docs/zones/<zone>.md beside the pack, and a bank.account_scheme that BANK_ACCOUNT_SCHEMES does not carry (the schema refuses it before anything else runs).

The bank formats, as a warning. A bank.statement_formats entry that no brick of packages/formats/ reads is printed once per format, and never fails the check: the name is true — the banks of the country send that file — and the debt is kept by name in tests/bank_statement_formats.test.ts, which is what fails when a new one appears unaccounted for.

The languages. A label under a code the pack does not carry — an account, a journal, a tax, a chart, a mention, an asset category, a box, a statement line — in any i18n file, declared or not. And for a language the manifest declares: the file has to exist, it has to carry pack_name, and it has to cover every key of every section, with the missing ones named and counted. A declared language that is the pack's own defaults.language is refused too: the pack is already written in it.

The language of a translation file is read from its language field and not from its name, so i18n/nl.json declaring "language": "fr" registers as French. A file that is not declared may be partial, which is how a language is contributed one section at a time.

The golden scenario. No golden/scenario.json and no golden.exempt in the manifest; or both at once. Fewer than ten documents. A chart that is not a chart of the pack. A financial year that does not start before it ends, a period running backwards, and any date — a period bound, a document, a payment — falling outside the financial year. A duplicate ref among the contacts, the documents or the payments. A document naming a contact the scenario does not declare. A line naming an account that is not in the installed chart, a tax that is not a tax of the pack, a tax whose scope contradicts the side of the document, or a tax not in force on the document's own date. A payment on a journal the pack does not carry, a match naming no document of the scenario, a payment going the wrong way for what it settles, or a payment dated before the document it settles.

check validates the scenario and does not replay it. The replay is tests/golden.test.ts, and the two are different questions: whether the scenario is sayable, and whether the engine still makes the same figures of it.

The analysis section. An aggregate outside the closed list, or one defined twice. A section that leaves out revenue, net_result, equity or total_assets. An aggregate with no legal_reference, or a source the register does not hold. A rule of a kind a statement line does not know, or missing what its kind takes — both bounds of a code_range, the code of the others, the type of an account_type rule. A charts entry that is not a chart of the pack. And a rule that reaches no account of a chart it is written for: a range typed one digit off reads as a definition and catches nothing, and an aggregate that is silently nil is the figure this check is there to stop.

The framework pack, packs/generic/, has two of its own: every rule has to be an account_type rule, because the other three kinds name a chart and a framework has none — in statements.json and in analysis.json alike, where the account type must also be one of the eighteen; and the exemption from a golden is required, in at least a sentence.

The module sections. For fixed_assets: a disposal style that does not name the accounts it needs — net_result the gain, gross both the proceeds and the value — two categories with the same code, a declining balance with no coefficient, a coefficient on a method that is not declining, and a category with no legal_reference, because a usual duration comes from somewhere.

For corporate_tax: a result naming a statement the pack does not carry, one that is not an income statement, or a line that statement does not have. An account of accounts missing from any chart of the pack, as for a role. A rule that carries both a percent and a formula, or neither, or a formula together with accounts; an account_code matcher naming an account that is not in every chart reporting on the result statement; a code_to on anything but a code_range. A condition naming a parameter the section does not declare, a comparison asked of a boolean or is_true asked of an amount, a comparison with no amount, and a waived_by that is not a boolean parameter. Two versions of one rule, rate or credit — or two loss rules, or two prepayment schedules — in force on the same day, a valid_to before its valid_from, and versions of one code that read their validity on different days of the year. No rate that applies unconditionally to the whole base. A floor without a percent_above. An instalment of share_of_reference_tax with no share_percent, a surcharge_on_shortfall with no surcharge_percent or an instalment with no credit_percent. A label in i18n/ under a key the section does not carry. And a section with no golden/corporate_tax.json beside it. A legal_reference and a valid_from are required by the schema on every rule, rate, limit, schedule and credit, so a pack that cannot cite a figure cannot write it.

What it does not refuse, and why that is worth knowing. An extra column in the CSV is ignored rather than rejected. A posting whose report names a form other than the one in tax_report.json is not cross-checked, because the pack carries one form; the day a country files two, it will be. A pack with no certification block compiles as community without a word, and by and on are not refused on a pack that is not reviewed — the status is a claim a person makes, and .github/CODEOWNERS is what puts a name next to it.

The register of sources

A legal_reference says which article a rule claims. It has been required on every tax and every box since the format existed, and it is half an answer: a reviewer holding Arrêté royal n° 20 du 20 juillet 1970, tableau A has a citation and a search engine. The other half is where that text can be read, and it belongs in one place rather than on four hundred rules.

certification.sources is that place. Each entry is a text:

"certification": {
  "status": "maintained",
  "sources": [
    {
      "key": "ar-20",
      "title": "Arrêté royal n° 20 du 20 juillet 1970 fixant les taux de la taxe sur la valeur ajoutée et déterminant la répartition des biens et des services selon ces taux",
      "publisher": "SPF Justice — Moniteur belge (Justel)",
      "url": "https://www.ejustice.just.fgov.be/eli/arrete/1970/07/20/1970072012/justel",
      "consulted_on": "2026-09-15",
      "kind": "regulation"
    }
  ]
}

and every rule of the pack names the key of the text its own reference is in:

{ "code": "BE-S-21", "rate": 21, "scope": "sale",
  "legal_reference": "Arrêté royal n° 20 du 20 juillet 1970, art. 1er, § 1er — taux normal",
  "source": "ar-20" }

The article stays on the rule and the link stays in the register. A publisher that reorganises its site is then one line of the pack to change rather than a sweep across every tax that cites the same statute, and a reviewer opening a pack has the reading list before they have read a rule.

Field Is
key how the rest of the pack names this text. Unique inside the pack, lower case, and stable once published
title the text as it is cited, in the pack's own language
publisher who publishes it officially: SPF Finances, Légifrance, Legilux, Riigi Teataja, Maksu- ja Tolliamet, the European Commission. A reviewer checks the publisher before the link
url absolute and https, and a permanent identifier wherever the publisher has one — an ELI on Legilux and on the Moniteur belge, a LEGITEXT on Légifrance, a short alias such as /akt/kms on Riigi Teataja
consulted_on the day somebody opened it and read what it served
kind one of six, below
reason_codes optional, and true on the one entry that publishes this country's exemption reason codes — see What a tax says on the invoice. At most one per register, and it has to be a standard

Six kinds, and the vocabulary is closed so that a register reads the same across countries:

kind Is
law a consolidated legal text — a code, a statute
regulation the decree or order that applies it
form a declaration form, or the administrative notice that explains its boxes
standard a technical norm or a code list: EN 16931, UNCL5305, VATEX, an XSD
portal where the declaration is actually filed — Intervat, eCDF, e-MTA, an impots.gouv.fr professional space
guidance an official administrative comment or circular: the French BOFiP, an Estonian juhend

A source may sit beside any legal_reference the format carries — a tax, a box, a chart, a statement and its lines, a sentence of an invoice, a rule of the fixed-assets section. It is optional everywhere and named nowhere twice.

The register never holds a copy of the text. A quotation ages without anybody noticing, and a pack carrying one would be a second, unversioned edition of a statute. A pack says where the law is; the law says what it says.

A bare string is the deprecated form. The field used to be a list of titles, and those are still read so that a pack written before the register goes on compiling. ekwo pack check warns on each one and refuses none:

! packs/xx: pack.json certification.sources: "Code de la TVA, art. 37" is a title
  with nowhere to read it. The register takes an object — key, title, publisher,
  url, consulted_on, kind — and the bare string is deprecated.

A chart that carries its own certification may name the texts that reading went through, and they join the same register: a key is unique in a pack, not in a section of one. The Belgian asbl chart is the case — it declares the Code des sociétés et des associations, which the rest of the pack does not need.

The register compiles into country_packs.sources, and the key travels with each rule in tax_templates.source_key and tax_report_box_templates.source_key, so an application can answer "where do these rules come from" without reading the pack. The MCP tool describe_pack returns all of it.

Certification, and who may say what

A golden test proves that a pack is internally coherent. It does not prove that it is legally right, and no test can. Three things carry the rest, and all three are enforced rather than encouraged.

Every tax and every declaration box names where it comes from. legal_reference is a required field, not a convention: ekwo pack check refuses a pack that leaves one out, on a tax or on a box, and "TODO" is not a source. A box nobody can trace to a source is a box nobody can review. Beside it, source names the text that article is in, out of the register the manifest carries in certification.sources — see The register of sources.

What the status costs in sources is the other half of what the status claims:

Status The register Every tax and every box
community anything, including the deprecated bare titles nothing asked
maintained at least one text somebody can open, or the pack is refused a warning, naming the first few and counting the rest
proven the same the same
reviewed the same refused where one of them names no source

The third row is the substance of a review. A professional who reads a pack against the law reads something, and a status that let them leave that unsaid would be a signature rather than a review. The second is a warning rather than a refusal: a missing link is a link that is missing, never a figure that is wrong.

The manifest says out loud how much anyone has read it, and ekwo init prints it in as many words before anyone books anything:

Status What it means Who sets it
community contributed, not read by an accountant whoever proposes the pack. It is the honest answer for a new pack, and the right one until the row below happens
maintained maintained by Ekwo, not yet reviewed by an accountant the maintainers, on a pack they keep up to date themselves. Nobody else, because nobody else is committing to keep it current
proven proven on the real books of a company, not yet reviewed by an accountant: the pack reproduced, to the unit they were filed in, the statements that company filed and the administration accepted, after its books were imported the maintainers, with the evidence in the same pull request — see What a proven pack shows
reviewed read by a named professional, on the date shown the reviewer, in the pull request that carries their name. Not the pack's author, unless the author is the professional and says so under their own name

A pack with no certification block at all compiles as community, which is the safe reading of silence.

The scale is ordered: community, maintained, proven, reviewed.

There is deliberately no status that means "certified by Ekwo". Writing a pack and testing that it holds together is not reviewing it, and neither is reproducing one company's filing: only a professional reading a pack against the law is. The table of packs is generated from the manifests and gives the status of each.

What a proven pack shows

proven sits between maintained and reviewed. A real company's books are imported into a throwaway installation, the pack computes that company's statements, and they are compared line by line with what the company filed and the administration accepted. Where they match to the unit they were filed in, the pack has reproduced a real filing. That proves the cases that company uses: its legal form, its regime, the accounts it moves. It says nothing about a regime it does not use, which is why the status is never called certified or validated, and why an operator is still warned on ekwo init.

What was compared is written in the manifest, one entry per scope, the scopes that could not be tested included:

"certification": {
  "status": "proven",
  "evidence": [
    {
      "scope": "financial_statements",
      "result": "reproduced",
      "fiscal_year": 2025,
      "filing": "French liasse fiscale 2033 (régime simplifié)",
      "summary": "On the books of a French SAS under the simplified regime, imported from another system, every amount of the bilan and the compte de résultat it filed matches to the euro.",
      "checked_on": "2026-10-10"
    },
    {
      "scope": "vat_return",
      "result": "not_tested",
      "fiscal_year": 2025,
      "filing": "French CA3 VAT return",
      "summary": "Not tested: neither the filed returns nor any VAT posting are in the imported books.",
      "checked_on": "2026-10-10"
    }
  ],
  "sources": [ … ]
}
Field What it holds
scope what was compared, in snake_case: income_statement, balance_sheet, financial_statements, vat_return…
result reproduced (every line compared matches), partly_reproduced (the summary says which lines do not, and why) or not_tested (the summary says why)
fiscal_year the financial year, by the calendar year it ends in
filing the kind of filing, as the country names it
summary one plain sentence
checked_on the day of the comparison

An entry names the kind of filing and never the company: no name, no registration number, no amount that would identify it. ekwo pack check refuses a proven pack, or chart, whose evidence holds no reproduced entry; a pack whose proof only partly landed stays maintained, and may carry its evidence there. ekwo pack describe <cc> prints the entries, and the seed header lists them. The seed also writes them to country_packs.evidence, so an installation answers for itself — the MCP tool describe_pack returns them — without a clone of this repository. A pack that carries evidence needs schema_min of 20261012050145 or later; a pack that carries none writes nothing there and keeps the schema it needed.

What a reviewer signs

reviewed carries two more fields, and they are the whole substance of it:

"certification": {
  "status": "reviewed",
  "by": "A. Example, chartered accountant, IEC/IAB 00000",
  "on": "2027-03-14",
  "sources": [ … the register of texts they worked from … ]
}

by is a person, named, with whatever qualification makes the name mean something in their country. Not a firm, not a team, not a handle: the point of the field is that somebody can be asked. on is the date they read it, and it is there because a pack that was right in March may not be right in October — a reviewed pack whose date is three years old tells an operator exactly as much as it should.

What a review is: a good-faith reading of the chart, the taxes, the boxes of the declaration and the statement mappings against the rules the reviewer applies in practice, given to the community for free. What it is not: an engagement letter, an audit, or a guarantee, for the reviewer or for anyone else. DISCLAIMER.md says so in the same words, and an operator reads that one.

A review is recorded in the pack it reviewed, in the pull request that makes the change, alongside a version bump — a status is part of what a version says. It is never edited afterwards to a later date without a later reading.

CODEOWNERS: the name next to the pack

.github/CODEOWNERS carries one line per pack. It is not certification and it does not claim to be: it is who gets asked when a pull request moves that pack. A rate, a box or a chart is right or wrong against a law, and the person who knows is the person who applies it.

/packs/schema/          @Ekwo-ai/maintainers

# generated:packs
/packs/be/              @Ekwo-ai/maintainers
/packs/ci/              @Ekwo-ai/maintainers
…
# /generated

The lines between the two markers are written by ekwo pack build: a pack added to packs/ gets its line with the owner of the * line, and a pack removed loses it. The owner a line names is kept — to own the pack you contributed, put your handle on its line, once; the next build leaves it there.

The schema is deliberately not owned by any one pack's owner: a change there changes what every country is allowed to say. A pack a contributor owns is listed with their handle, and its status stays community until a professional has read it — the two are different questions and the file says so at the top.

Adding a country in a day

A country is data, so adding one is a day of reading the law and half an hour of tooling. What follows is the whole path, in order, with the command that tells you whether you are still on it. Copy Belgium or France — whichever resembles your country's accounting more — and change what differs.

Nothing here needs a database. Every command runs in a checkout, and the pull request you open at the end contains the pack, and the seed and the lists ekwo pack build writes from it.

0. Open the four things you are going to transcribe

Before a line of JSON, find where your country publishes what you are about to copy, and keep the tabs open. Four of them, and they are the four kinds of source most packs need:

  1. The consolidated text of your VAT law or its equivalent, on the site that publishes it officially — not a commentary, not a law firm's copy. Look for a permanent identifier: an ELI, a code identifier, a short alias.
  2. The decree or order that fixes the rates and the chart of accounts, which is usually a different text from the law.
  3. The declaration form and its notice. This is what the boxes of tax_report.json are, and the notice is what tells you which amount goes in which box.
  4. The portal where the return is filed. It is not a legal source and it is in the register anyway: whoever installs your pack will need it, and the register is the one place in the pack where they will look.

Each of them becomes an entry of certification.sources with a key you will use for the rest of the day — see The register of sources. Write them as you open them, with consulted_on set to today, and never write a URL you have not opened. A link nobody followed is worth less than no link: it reads as checked.

This is step 0 rather than step 9 because every later step cites one of these texts, and the citation is cheaper to write while the tab is open than to reconstruct from a rate you no longer remember reading.

If your country is outside the common system of VAT, there is one more thing to do before step 1, and it is the only edit a pack ever makes outside its own folder. ekwo pack check decides whether the VATEX list of EN 16931 reaches your taxes by looking your country up in territories, and a country the table carries no row for is held to the European Union's table — so an exempt sale with no exemption_code would be refused and you would be told to write a code that names an article of a Directive your seller is not bound by. Add a row to supabase/seed/00_territories.sql: your ISO code, eu_vat_scope none, no window, no vat_prefix, and a legal_reference saying why the common system does not reach you. tests/territories.test.ts refuses a pack whose country the table does not carry, so the check is enforced from both sides.

1. Copy a pack and give it its identity

cp -r packs/be packs/xx        # `xx` being your ISO 3166-1 alpha-2 code, lower case
rm -rf packs/xx/golden packs/xx/i18n

Open packs/xx/pack.json and set country (upper case), name, version to 0.1.0, released_at, and certification to { "status": "community", "sources": [ … the register from step 0 … ] }. Leave schema_min where it is: it is the migration your pack needs, not a number you choose. Set seed_sequence to the first number no pack has taken — ls supabase/seed/ shows what is in use — and never change it afterwards: a published number never changes, because it is the name of a file other people's installations already hold. Empty the languages array for now — a declared language is a promise that ekwo pack check holds you to, and you will make it in step 8.

Set defaults.currency, and defaults.language to the language you are going to write the pack's own labels in.

2. The chart of accounts

accounts.csv, one line per account: code, parent, type, reconcilable, name, sequence. Two things decide whether the rest of the day goes well.

Give every account one of the eighteen account_type values. Nothing is guessed from a code prefix anywhere in Ekwo, in any country, ever — 411 is customers on the French chart and recoverable VAT on the Belgian one. The type is what the generic financial statements read, what the ageing reads and what a close reads.

Make the headings headings. An account with children is a heading: nothing is posted to it, and the statement check below expects it to reach no line.

If your country has a second chart — associations, sole traders, a small-company variant — add it as a second file and declare both under charts, exactly one of them "default": true. The journals, the taxes and the declaration form are common to the charts of a country; only the accounts differ, and the statements that present them.

3. The roles, the journals and the close

In defaults.roles, name the accounts the installer wires: receivable and payable are required, and suspense, rounding, retained_earnings, sales, purchase, bank and cash are what a company needs to be installed and to book. If the pack declares a periodic return, name tax_payable — the account settle_filing() carries a declared period's net to — and tax_receivable for a period that ends in a credit, both reconcilable and apart from the accounts the taxes post to. Leave tax_receivable out only where the chart keeps one control account for both signs; say which article makes the credit a claim on the administration in the pack's README, since a role carries no citation of its own. Add fx_gain and fx_loss if any company of your country will ever invoice in another currency: a matching that realises a difference is refused by name when they are missing, and only then.

Every code has to exist in every chart your pack ships. readPack refuses the pack before the compiler writes any SQL, and it names the chart the code is missing from.

Then say how the year is closed: defaults.closing_style, its result accounts, and defaults.journal_roles.opening, which must name a journal of type opening. The table below says which style a chart needs.

Which closing style a chart needs

Style The result goes Chosen when
retained_earnings straight into retained earnings the chart has no current-year result account — Balance Sheet Format 1 of the British small companies regime has five items under capital and reserves and none of them is the result of the year (United Kingdom, United States)
result_accounts into a current-year result account on the balance sheet the chart keeps the result of the year apart until a meeting allocates it (France: 120 and 129)
appropriation_accounts through an appropriation account of the income statement, then to retained earnings the statutory income statement ends on an appropriation section (Belgium: 693 and 793, to 140 and 141)

The accounts a style names have to be of the right kind, and the close says so rather than finding out later: appropriation_accounts needs accounts that do not carry forward, the other two need accounts that do. What a general meeting then decides — a dividend, a reserve — is never part of a close, in any country.

There is no default. Leave closing_style out and close_fiscal_year refuses with no_closing_defaults; leave journal_roles.opening out and it refuses with no_opening_journal. Nothing falls back on a Belgian or a French value, because a default closing style would be one country's mechanism given to every country that has not spoken. ekwo pack check refuses a pack that declares a style without the accounts it needs, and an opening journal that is not of journal type opening, so the gap is found when the pack is written and not on somebody's year end.

A final close, where the law requires one. A close is reversible: reopen_fiscal_year() undoes it to correct the year. Some laws require the books of a year to be frozen for good by a set day; say so under defaults.final_close, with the article:

"final_close": {
  "due": "before_next_fiscal_year_ends",
  "legal_reference": "the article that requires it, as the country cites it",
  "source": "a key of certification.sources"
}

before_next_fiscal_year_ends is the one rule so far: the final close is carried out at the latest before the following year ends. It compiles into country_defaults.final_close_due, and fiscal_year_final_close() gives the day for one year. Leave it out where the law asks for nothing: the final close is still offered by close_fiscal_year_finally(), and never asked for. The pack needs schema_min of 20261018130000 or later.

Who a declaration is paid to. defaults.tax_payee names the administration that collects the tax the periodic return declares — the payee of the debt settle_filing() carries to roles.tax_payable:

"tax_payee": {
  "name": "the administration's official name, in the pack's language",
  "source": "a key of certification.sources"
}

Both are required. The source is an entry of the register published by that administration — its portal, a form, its guidance — where the name can be read; a ministry, a law firm or a summary elsewhere is not one. A pack whose register holds no such entry leaves tax_payee out rather than writing a name from memory. It compiles into country_defaults.tax_payee_name, and tax_payee() creates the company's contact for the administration under it, on the accounts settling uses. The pack needs schema_min of 20261023124650 or later.

The same administration in another language of the pack. In a country of several languages the administration publishes its name in each — SPF Finances, FOD Financiën, FOD Finanzen, FPS Finance — and a company keeping its books in one of them pays the administration under that name. A language file carries it beside pack_name, in the shape of defaults.tax_payee:

{
  "language": "nl",
  "pack_name": "België",
  "tax_payee": { "name": "FOD Financiën", "source": "intervat-nl" }
}

The source is an entry of the same register, published by the administration in that language — usually the page of its portal in that language; a page of another publisher that names the administration in that language is not one. tax_payee is optional in every language file, declared or not: a language that gives none falls back to defaults.tax_payee, and ekwo pack check does not ask for it. It refuses a source the register does not hold, and a translation in a pack whose manifest names no administration. The names compile into country_defaults.tax_payee_name_i18n, by language; tax_payee() creates the company's contact under the name of the company's language (companies.language), else under the pack's own, and recognises an existing contact under any of them. A pack that translates the name needs schema_min of 20261023160200 or later.

4. The taxes, and where each one posts

taxes.json. One entry per code, and a code is a rate at a date: a new rate is a new code plus a valid_to on the old one, never an edit, which is how the return of a past period keeps giving the same answer.

Each tax carries a legal_reference, and it is required — the article, not the word "TODO" — and a source naming which text of step 0 that article is in. Its postings say where the money goes, per kind of document, out of the three posting types described above. Start with the plain cases in both directions, then the ones that are actually specific to your country: a partly deductible tax, a reverse charge, a tax due on collection.

treatment, vat_category and exemption_code are three tellings of one fact, and ekwo pack check refuses them when they disagree — so the table under "What a tax says on the invoice" is the fastest way to fill the last two in. Read the two decisions under it before you fill a purchase-side tax: the category there is the one the supplier's invoice carries, and where no invoice governed by EN 16931 exists there is none to record. And read the paragraph above them first if your country is not in the common system of VAT: there, exemption_code stays null on every tax, the article goes in legal_reference, the intracom_* treatments do not exist, and vat_category is asked for only if you declare an e-invoicing profile.

Where a code is the answer to a question the books do not hold — a certificate the buyer signed, a threshold the seller crossed — say which question in conditions, from the five words under What an exemption depends on. It is a word and never a test: the figure a threshold is set at belongs in the article, and the article is already in legal_reference.

5. The declaration form

tax_report.json is one form and its boxes. A base or a tax box is summed from what the postings wrote on the ledger; a total is a list to add, a list to subtract and a floor at zero — or, where the form states a line as a multiplication, a rate and the rate_of box it applies to. There is no expression language, in any country. The totals are worked out in the order they depend on each other, so declare the boxes in whatever order reads best and give a box a print_sequence where the administration prints it somewhere else. Every box carries its own legal_reference and, like a tax, the source of the text it is in — usually the form itself, which is the entry of step 0 the boxes actually came from.

Then go back to taxes.json and put a box on each posting. The two files are checked against each other, which is the first place a country pack usually turns out to be wrong.

Each tax takes one base posting per kind of document. Where your form prints the same taxable amount somewhere else — a turnover line above a rate breakdown, a memo box inside a box — that second place is a total naming the first where it is a sum, and one more box on the posting's own box list where it is not. Never a second posting: see "A base is written once, and printed as often as the form likes".

6. The financial statements

statements.json, one entry per scheme your country prescribes, with the rules that sum each line from the ledger. If your country prescribes none — or you are not ready to map them — leave the file out and let your chart fall back to packs/generic/, the country-less pack whose rules are all account_type. That is what the eighteen account types buy, and it gives a chart with no legal codes a balance sheet that ties out.

Add xbrl fact keys only for a taxonomy you can verify, and leave them null otherwise. A wrong key is worse than no key.

Once the statements are in, the analysis section is a short file more: which of those lines are the revenue, the equity, the total and the current assets. It is optional, and it is what gives the companies of the country an analysis.

7. Check what you have so far

npm run build
node packages/cli/dist/bin.js pack check xx

Read every line it gives you. The common first run:

Checking
✗ pack_invalid: packs/be — 2 problem(s)
  taxes.json[0]: missing "legal_reference"
  taxes.json BE-S-12.invoice: box ZZ:base is not a base box of this form

The rules are listed one by one above, under "What ekwo pack check refuses". The one that catches the most is the last: every postable account of a chart has to reach a line of one of that chart's statements. An account that reaches none is an account that would vanish off a balance sheet, and the error names it.

8. The other languages

One file per language, i18n/<lang>.json, and that file is the only place a translation lives. Add the languages your country's books are actually kept in to manifest.languages once they are complete: a declared language has to cover the accounts of every chart, the journals, the taxes, the boxes, the statement lines, the legal mentions and the asset categories, and pack.json declaring one it does not cover is a pack that does not build. A file that is not declared may be partial, which is how a language is contributed one section at a time.

9. A year of books, and the figures it produces

This is the step. Everything above can be internally consistent and wrong.

Write golden/scenario.json: a financial year, its periods, the contacts, at least ten documents and the payments that settle some of them. Every document and every payment carries a why — one sentence saying what it is there to prove. A golden grows by accident otherwise: somebody adds a line to make a figure move, and two years later nobody can say whether dropping it would lose anything.

Then:

UPDATE_GOLDEN=1 npm test -- tests/golden.test.ts

which writes golden/vat_return.json, golden/statements.json and golden/trial_balance.json, and never the scenario. Read what it wrote. That reading is the step: a figure you cannot explain is a defect in your pack, found before anybody files anything with it. The runner knows no country — what it asks of your scenario, it asks of your pack: both directions, more than one positive rate, a credit note, a matched payment and an unmatched one, and an intra-Union reverse charge, a tax due on collection or a partly recoverable tax only where your pack has one.

One assertion is not read from a file: the posted ledger balances. A golden regenerated from a broken engine would agree with itself; double entry would not.

If your country's filing taxonomy names facts your statements carry, or its form owes an arithmetic to a text, write them into golden/expectations.json — see "What only this pack can claim". That file and the pack folder are the only two places you write: no test of this repository has to change for your pack to be checked.

10. Compile, open the pull request

node packages/cli/dist/bin.js pack build xx
node packages/cli/dist/bin.js pack check --all

build writes supabase/seed/<n>_pack_xx.sql — and supabase/seed/modules/fixed_assets/<n>_pack_xx.sql if your pack carries a fixed_assets section, supabase/seed/modules/corporate_tax/<n>_pack_xx.sql if it carries a corporate_tax one — and then every list that names the packs outside packs/: the seed goes into [db.seed] sql_paths of supabase/config.toml and into the psql -f lines of the README, your country into the README's list and into the table of packs, and packs/xx/ gets its line in .github/CODEOWNERS. You edit none of them; check refuses them the day they stop saying what packs/ says. The module seed stays out of sql_paths deliberately, because it is applied by the module migration runner, on installations that carry the module.

Commit what it wrote with the pack. It is a build artefact that is committed on purpose, so that supabase db push and psql -f install your country without the CLI ever running.

Then run

node packages/cli/dist/bin.js pack check xx --links

once, by hand, and read what it says: it opens every URL of your register and names the ones that did not answer. It fails nothing and the CI never runs it — a publisher that turns away anything without a browser is not a wrong pack — so open the ones it names yourself before deciding anything.

If you are going to own the pack, put your handle on the line build wrote for packs/xx/ in .github/CODEOWNERS — the next build keeps it. Add a line to CHANGELOG.md under [Unreleased], and open the pull request. Set certification.status honestly: community is the right answer until an accountant has read it, and an issue titled "Review: " is how one is asked to.

The two places a pack is written

Adding a country touches two places, and nothing else:

  1. packs/<cc>/ — the manifest, the charts, the taxes, the declaration form, the statements, the document rules, the translations, and the seed compiled from them.
  2. packs/<cc>/golden/ — the scenario, the three generated expectation files, and expectations.json where the pack states what only it can state.

Everything else the pull request changes is written by ekwo pack build: the seed under supabase/seed/, and the generated blocks of supabase/config.toml, README.md, .github/CODEOWNERS and this page. A contributor runs the command and commits its output, exactly as for the seed.

A pack whose currency no pack used before adds its row — the ISO 4217 code, its name, its symbol and its decimals — to supabase/seed/00_currencies.sql. A member State of OHADA finds all three already written: its code in members in packs/ohada/manifest.json, which is how it receives the chart the members share, its currency and its territory — so it touches its own folder and nothing else. packs/ohada/README.md is that path.

A country outside the common system of VAT adds a third, and exactly one row of it: supabase/seed/00_territories.sql, for the reason step 0 gives. It is framework reference data and not a country literal in the core — the table exists precisely so that no function holds a list of countries — and it is still a file outside packs/, so a pull request that adds one says so.

A pack that conditions a tax on a territory below the country adds rows to the same file, one per territory it names — and one per territory its golden scenario delivers to, because documents.supply_territory_code is a foreign key as well. Once a pack conditions a tax on one territory of a country, every territory of the same level in that country is a place its documents can deliver to and its contacts can be in, so the file carries all of them: packs/us/ taxes three states, and the file carries the fifty and the District of Columbia, as it carries the thirteen provinces and territories of Canada and the States and Union territories of India. The rows grow with the packs and not with the world: a country no pack divides has no row below it.

Nothing under tests/ or modules/*/tests/. A test about the core walks listPacks() and reads its expectation from the pack — a role from the manifest, an account from a chart, a box from the form — so a pack that lands is checked the day it lands rather than the day somebody remembers to add it to a list. tests/helpers/packs.ts is where that starts: allPacks to loop over everything, packWhere('taxes on collection', …) to pick the pack that carries the property under test rather than the country that happens to have it, and somePack for a test that needs a pack and does not care which.

A guard keeps it that way:

node scripts/check-no-country-literals-in-tests.mjs

It runs in CI and fails on three things: a country code inside an expect(…) next to the word country, a list of two or more country codes or pack slugs anywhere, and a pack named by hand where listPacks() would have found it. Setting up may still name a country — a scenario books invoices somewhere, and saying where is how it stays readable. Expecting one may not. Where a literal is genuinely right, one comment says so and why:

// country-literal: the demo company is Belgian, and this reads its books

What is not in a pack

The engine: posting, matching, the returns, the installer. Also deliberately out — the rates of American sales tax (thousands of jurisdictions, monthly changes; the form belongs here, a maintained rate feed does not), the XML of the filing formats (format libraries in packages/formats/), analytics, payroll, and the translation of the application itself. Fixed assets and corporate income tax are optional pack sections read by their modules. A handful of stable rates published by the administration is data and belongs in the pack — Canada's fifteen GST/HST combinations would be — and a feed that moves every month is not.

Rendered from docs/packs.md, in the repository, when the site was built. Read or change it on GitHub