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
- Write
packs/<cc>/analysis.jsonwithrevenue,net_result,equityandtotal_assetsat least, from the pack's own statements as above. - Raise
schema_minto20261014152111, the migration that createdanalysis_templatesandanalysis_rules, and raise the pack's version. ekwo pack build <cc>, thenUPDATE_GOLDEN=1 npm test -- tests/golden.test.ts, which writesgolden/analysis.json. Read it besidegolden/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 asbase: 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_servicesfor a supplier of another Member State, the return often splitting the two — posted likeDE-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_taxedentry inpack.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_codeis null, and the article the line is exempt under goes inlegal_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_categoryis 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, notAE(AEis 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_receivedandself_assessedcarry none. - A triangular supply is
K.intracom_triangularis the middle supply of an A → B → C arrangement; on the invoice it is the reverse-charge sentence that comes out. self_assessedis 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_receivedis 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 (
Sabove zero;Z,E,AE,K,G,Oat 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>orVATEX-<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
plusor aminuslist, which would be two ways of computing one figure, and aratewithout arate_ofor 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_codeon its own row, and failing that to its country:companies.fiscal_country,contacts.country. - The supply is
documents.supply_territory_code, failing thatdocuments.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:
- 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.
- The decree or order that fixes the rates and the chart of accounts, which is usually a different text from the law.
- The declaration form and its notice. This is what the boxes of
tax_report.jsonare, and the notice is what tells you which amount goes in which box. - 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:
The two places a pack is written
Adding a country touches two places, and nothing else:
packs/<cc>/— the manifest, the charts, the taxes, the declaration form, the statements, the document rules, the translations, and the seed compiled from them.packs/<cc>/golden/— the scenario, the three generated expectation files, andexpectations.jsonwhere 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.