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 boxes of the periodic return and their totals
├── statements.json the balance sheet, the income statement and their rules
├── assets.json the section of the fixed assets module, where the country has one
├── 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
└── 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 by account type
├── pack.json
└── statements.json
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 fifty-three 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.
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.
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 thing under packs/ that reaches the network, which is
why it is an option and why the CI never passes it. It opens every URL of
every source register and names the ones that did
not answer, and it never changes the exit code. A link that is quiet today
is not a wrong pack: Légifrance refuses a request with no browser behind it —
all four of its entries come back 403 here — Riigi Teataja serves the same
page for a text and for a typo, and a ministry moves a form the week before a
deadline. A gate on any of that would fail a contributor's pull request for
something nobody in it did, and the cheapest way to make it green would be to
delete the link. So it reports a reading, and a maintainer decides.
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, as it read on the day this was written:
Packs (…)
generic Generic framework 1.1.1 · 2 statements · no country · certification maintained
no golden — A framework is not a country: this pack carries statements 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 |
at |
Austria | 63_pack_at.sql |
de, en | community |
au |
Australia | 40_pack_au.sql |
en | community |
be |
Belgium | 10_pack_be.sql |
fr, nl, de, en | maintained |
bf |
Burkina Faso | 23_pack_bf.sql |
fr | community |
bj |
Bénin | 22_pack_bj.sql |
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 |
cm |
Cameroun | 24_pack_cm.sql |
fr | community |
de |
Germany | 18_pack_de.sql |
de, en | community |
ee |
Estonia | 13_pack_ee.sql |
et, en | community |
es |
España | 19_pack_es.sql |
es, en | community |
fr |
France | 11_pack_fr.sql |
fr, en | maintained |
ga |
Gabon | 28_pack_ga.sql |
fr | community |
gb |
United Kingdom | 14_pack_gb.sql |
en | community |
gn |
Guinée | 29_pack_gn.sql |
fr | community |
gq |
Guinée équatoriale | 31_pack_gq.sql |
fr | community |
gw |
Guinée-Bissau | 30_pack_gw.sql |
fr | community |
hk |
Hong Kong | 52_pack_hk.sql |
en | community |
ie |
Ireland | 16_pack_ie.sql |
en | community |
it |
Italia | 37_pack_it.sql |
it, en | community |
jp |
日本 | 51_pack_jp.sql |
ja, en | community |
km |
Comores | 26_pack_km.sql |
fr | community |
kr |
대한민국 | 54_pack_kr.sql |
ko, en | community |
lu |
Luxembourg | 12_pack_lu.sql |
fr, de, en | community |
ml |
Mali | 32_pack_ml.sql |
fr | community |
mx |
México | 42_pack_mx.sql |
es, en | community |
ne |
Niger | 33_pack_ne.sql |
fr | community |
nl |
Nederland | 17_pack_nl.sql |
nl, en | community |
nz |
New Zealand | 41_pack_nz.sql |
en | community |
pl |
Polska | 64_pack_pl.sql |
pl | community |
pt |
Portugal | 43_pack_pt.sql |
pt, en | community |
sa |
Saudi Arabia | 71_pack_sa.sql |
en | community |
sg |
Singapore | 50_pack_sg.sql |
en | community |
sn |
Sénégal | 20_pack_sn.sql |
fr | 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 |
tw |
臺灣 | 53_pack_tw.sql |
zh, en | community |
us |
United States | 15_pack_us.sql |
en | community |
vn |
Việt Nam | 55_pack_vn.sql |
vi | 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.
It used to be the pack's rank in the alphabetical list of slugs, which is
stable exactly until a country is added in the middle of it: inserting ee
between be and fr moved France and Luxembourg one place each. Nothing sorts
anything now. ekwo pack check refuses a pack that declares no number, and
ekwo pack build refuses two packs claiming the same one.
Four kinds of file are generated, and check compares all four.
| 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. A number that has shipped never moves, whatever is added beside it |
packs/<cc>/assets.json, where the pack has one |
supabase/seed/modules/assets/<n>_pack_<cc>.sql, applied by the module migration runner and by nothing else |
| 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, legal_mention_templates,
country_defaults, country_packs and — for a module section —
assets.country_rules and assets.category_templates, 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 —
which matters more than it sounds: the seeds used to say
on conflict do nothing, so an instance installed last month received no
correction at all — not even for a company created afterwards, since a company
copies the templates when it is installed.
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 three generated files under golden/ are the deliberate exception —
vat_return.json, statements.json and trial_balance.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/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. |
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.
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 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.
Luxembourg is the case to read, because the eCDF return asks for the taxable amount of a sale twice: as turnover in section I, and in the rate breakdown of section II. The pack defines it in the breakdown, where the rate makes it unambiguous:
{ "code": "LU-S-17", "rate": 17, "scope": "sale",
"postings": { "invoice": [ { "type": "base", "box": "701" },
{ "type": "tax", "account": "461411", "box": "702" } ] } }
Section I then reads that box instead of being posted to. 472 is declared a
total, not a base:
{ "box": "472", "kind": "total", "name": "b) Autres ventes / recettes", "sequence": 170,
"plus": ["701", "901", "703", "903", "705", "905", "031",
"457", "014", "015", "016", "017", "481", "482", "226",
"018", "423", "424", "019", "419"] }
— the seven rate bases of section II, then the exemptions, the franchise
regimes and the operations taxed elsewhere — and 454 adds 471 and 472,
and 012 adds 454 to the two private-use boxes. One figure is posted, and
every line of the form that shows it again is derived from that one.
That is the first way, and it is the one to reach for: the second place is a sum of things the postings already wrote, so it is written down as a sum.
The second way is for a parent that is not a sum. Form KMD reports an intra-Community acquisition of goods in box 6.1, in box 6 around it and in box 1 around that — and box 6 is not the total of the boxes printed under it, because the rest of box 6 is services received, which the form never prints on its own. The British VAT Return does the same sideways: the value of a service received from a supplier established abroad goes in box 6, which is outputs, and in box 7, which is inputs, and neither box contains the other. There is no total to write in either case, so 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" } ] } }
One amount, printed in three boxes, and still one base posting and one
definition. box takes a string or a list of strings; the first of the list is
the box the posting is known by, and box_factor is the share reported — the
same share in each, because a form that prints one figure in three places
prints the same figure in all three. ekwo pack check refuses an empty list, a
box named twice by one posting, a box the form does not carry, and a box the
form declares a total: a total is added up from the boxes below it, so an
amount written straight into one would be counted twice.
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.
A hidden box has one use left after this, and it is the Belgian one: an
intermediate total the form works out inside a formula and does not print. A
hidden box that a posting writes into is the old workaround for exactly the
shape above, and there is no longer a reason for one.
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 whole table, and nothing here is anybody's opinion:
- UNCL5305 (UN/CEFACT D.16B, the OpenPEPPOL subset) for what each code
means.
Kis VAT exempt for EEA intra-community supply of goods and services, which settles a question the Ekwo vocabulary asks twice. - Technical guidance for tax codes in EN 16931, version 1 (European
Commission, Technical Advisory Group on Electronic Invoicing, use cases
dated 1 July 2024), whose six use cases give the pair for each case. Its
fourth is "export outside the EU", which is the Union's border because the
seller it addresses is established in a Member State; what UNCL5305 itself
says of
Gis free export item, VAT not charged, so the border is the one of whoever levies the tax. - The VATEX code list itself, which carries the pairing as a remark on
eight of its codes:
VATEX-EU-AEonly use with category code AE,VATEX-EU-ICwithK,VATEX-EU-GwithG,VATEX-EU-OwithO, andVATEX-EU-D,-F,-I,-JwithE.
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 |
That table is the Union's, and the last column applies where the Union's VAT
does. EN 16931 is a European standard and the VATEX list is published by the
European Commission: its own codes name articles of Directive 2006/112/EC, and
its national codes — VATEX-FR-CGI261-1 and the rest — belong to Member States
that publish them. So a pack for a country the common system of VAT does not
reach reads the table with two changes:
exemption_codeis null, and the article the line is exempt under goes inlegal_reference, where it was going to go anyway — and is required there wherever the category asks for a reason, because it is what the invoice states instead of a code. A VATEX code is refused by name, the national spelling included:VATEX-EU-Gon an export from a third country claims an article of a Directive that does not bind the seller.- The five
intracom_*treatments are refused outright. An intra-Community supply is an operation of the common system, and a country outside it makes none. vat_categoryis no longer required, unless the pack declares an e-invoicing profile. BT-151 is a term of an invoice governed by EN 16931. A country the common system does not reach whose sellers issue no such invoice has no category of anybody's to record, and asking for one is a European standard asking a question nobody reads the answer to: the first pack of a country with no value added tax at all had to writeS,EandOon codes no American invoice carries the field for. So the requirement follows the invoice. Whereeinvoicing.profilenames a profile —peppol-bis-3,factur-x-en16931,xrechnung, a PINT, each built on the semantic model of EN 16931 and each carrying BT-151 — a tax that can reach a sale still has to name a category. Where the pack names no profile, the column is free, the way it already is on a purchase-only tax.
Everything else holds: the column being free is not the column being unchecked.
A category a pack does name is still held to the treatment, to its reason and
to its rate, everywhere. The categories are UNCL5305, a UN/CEFACT list: E, G,
O and AE keep their meanings, a sale still has to name one, and the rate
rules of EN 16931 still fix what it may be charged at.
Should a country outside the Union one day publish reason codes of its own —
none has, and PINT is where it would surface — the column takes them, on one
condition: the pack's register declares that list, as the entry of
certification.sources that says so of itself with "reason_codes": true.
What is checked is that a list is named and that the value is a code rather
than a sentence, never that the code is in the list.
It is the entry that says so, and not the first standard in the register.
That flag exists because standard means "a technical norm or code list", which
the FASB Accounting Standards Codification and FRS 102 are as much as VATEX is:
reading the first one found made a pack that cites its country's accounting
standards declare a list of exemption reason codes without knowing it, and
silently authorised a code on any of its taxes. At most one entry of a register
may carry the flag, and it has to be a standard, because a published list of
codes is one; ekwo pack check refuses a second and refuses the flag on any
other kind. That is exactly what is checked of a VATEX code inside
the Union, where the check holds no copy of the list either.
Which side of that line a pack is on is not written anywhere in the code.
It is a row of territories, the reference table of the framework that
ec_sales_list() already reads — eu_vat_scope at the pack's released_at,
with full meaning the common system reaches the country. ekwo pack check
runs on a checkout with no database, so it parses
supabase/seed/00_territories.sql, which is the file the database itself is
seeded from; tests/vat_codes.test.ts holds its answer against
eu_vat_scope_of() for every territory on every date the table names. Two
consequences worth knowing:
- The day is
released_at, not today. A pack is a transcription of a law andreleased_atis the day it says that transcription is true. Asking today would make a pack pass in the morning and fail in the evening with nothing committed in between, on the day a State acceded or left. Asking a tax's ownvalid_fromwould be worse: the taxes of a pack span decades, so one pack would speak two regimes and a British rate of 1994 would be asked for a code from a list that did not exist. - A country
territoriescarries no row for is held to the table above. Every rule in this section narrows what a pack may say, and narrowing on a missing row would refuse a valid pack for a country nobody has added to the reference data yet. Silence is not a no. A pack of this repository never reaches that fallback, because the test suite refuses one whose country is not in the table.
Three more things in that table are decisions, and all three are worth reading before filling a column in.
A category is a term of the invoice, so on a purchase it is the
supplier's. A purchase tax describes how the buyer books and declares
somebody else's invoice, and BT-151 on that invoice was chosen by the seller.
An intra-Community acquisition is therefore K: the supplier made an
intra-Community supply, and rule BR-IC-10 binds them to K and
VATEX-EU-IC. It is not AE, which the guidance gives for a reverse charge
within one Member State — the case where the supplier is established in the
buyer's country and national law moves the liability. A pack that does not
want to record the seller's answer may leave the column null on a
purchase-only tax; a value that contradicts the treatment is refused either
way, and a tax that can reach a sale has to name one.
Where no invoice governed by the standard exists, the category is absent.
Import tax is assessed on a customs document, and a service received from a
supplier the Union's rules do not reach arrives on an invoice the Directive
does not govern. There is no seller's category to record, so import and
foreign_services_received carry none. S there would claim the supplier
levied the standard rate, which is the one thing that certainly did not
happen.
A triangular supply is K, not AE. intracom_triangular is the middle
supply of a triangular arrangement — A sells to B, B sells to C, the goods go
straight from A to C — that is, B's sale to C, relieved by article 141 of
Directive 2006/112/EC and reverse-charged to C by article 197. A's supply is an
ordinary intracom_goods and C's purchase an ordinary
intracom_acquisition_goods, so there is one value to add and not three. AE
is the guidance's case for a reverse charge within one Member State, where
the supplier is established in the buyer's country; this is the opposite shape,
the goods leaving the seller's State, and what the line is — in the words of
UNCL5305 — is VAT exempt for EEA intra-community supply of goods and
services. On the invoice it is the reverse-charge sentence that comes out
and not the intra-Union exemption: the supply is not exempt under article 138,
and article 226(11a) requires the mention that says the customer owes the tax.
A pack that declares such a tax gets triangular out of ec_sales_list() for
free, and the four recapitulative-statement bricks already have a column and a
code for it.
self_assessed is the treatment for a tax a buyer owes directly to an
administration under that administration's own law, and computes and declares
themselves. American use tax is the case it was written for: section 6201 of
California's Revenue and Taxation Code imposes the tax on the storage, use or
consumption of property bought from a retailer, and section 6202(a) makes the
buyer liable for it. Mechanically it looks like the reverse charge above, and
it is not one — there is no exempt supply behind it, no supplier who was
relieved of anything, no recapitulative statement, and nothing at all is
recovered at the other end, which is why such a tax usually carries a
tax_on_base posting as well. It carries no category and no reason code, for
the reason import and foreign_services_received carry none: a buyer
assessing a tax on themselves holds no invoice EN 16931 governs, and there may
be no supplier the levying State can reach at all. It is not refused inside
the common system — a Member State levying a duty of its own that a buyer
self-assesses would be describing this and not article 194 — and what tells the
two apart is the law each tax cites.
foreign_services_received is the treatment for a service bought from a
supplier who is not established in the buyer's country and charges no tax on
it, the buyer accounting for it themselves under the general
business-to-business rule — articles 44 and 196 of Directive 2006/112/EC. It
is the sibling of intracom_acquisition_services for a supplier the
intra-Union rules do not reach, and it says nothing about where that supplier
is: the rule turns on establishment, which is why the name is not
import_services. It is also why it is not import, which in this vocabulary
means goods declared to customs — a different mechanism behind a different
document. On the invoice, it is the reverse-charge sentence that comes out:
the same mechanism as a domestic reverse charge, under a different article.
The rate is checked on sales only. There the pack's rate is the BT-152 the
seller prints, and the business rules of EN 16931 fix it per category —
BR-S-05 wants a standard-rated line above zero, and BR-Z-05, BR-E-05,
BR-AE-05, BR-IC-05, BR-G-05 and BR-O-05 want zero. On a purchase the rate is
the one the buyer self-assesses at, 21 % on a line the supplier invoiced at
zero, so the same rule there would refuse every reverse charge ever written.
What the check does not hold is a copy of the VATEX list. The list grows,
two published renderings of it already disagree on which codes it carries, and
a code it has not heard of is not a contradiction. So a reason code is checked
for its shape — VATEX-EU-<article>, or VATEX-<country>-<article> for a
national one — and for the pairings the list itself states.
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.
Three exemptions of one American pack make the case. A sale for resale is untaxed because the seller holds a resale certificate — section 6091 of California's Revenue and Taxation Code presumes every receipt taxable until they take one from the purchaser, and Regulation 1668 says what it has to contain. A sale of food is untaxed under section 6359 unless it is hot, or carbonated, or alcoholic, or eaten where admission was charged. And a seller collects in a State at all only once a running total of sales into it has been crossed, which since South Dakota v. Wayfair is what economic nexus is. None of the three is a fact the ledger holds, and before this field a reader of the chart saw three zero-rated codes and one long sentence each.
{ "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. There is still no place on
a contact for a certificate and its validity, and no place anywhere for a
rolling total per territory. Both are larger than the pack format — one is
document management, the other is reporting — and
international.md keeps them on the list. A pack states
the code a bookkeeper reaches for once the answer is known, says what the
question was, and invents no rule that would pretend to know it.
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.
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: a pack that names none is refused, because
the column this compiles to used to default to "monthly or quarterly" and a
country that had never thought about its cadence filed on Belgium's without
anybody deciding that. A single string is still read, month_or_quarter
meaning the two cadences it names, so a pack written before the list keeps
working.
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 two are a reading
of the law and not a count of the list: the United Kingdom files its return
monthly, quarterly or annually and reg. 25(1) of the Value Added Tax
Regulations 1995 still makes three months the prescribed accounting period for
everybody, a month and a year being what the Commissioners allow or direct on
application. So packs/gb proposes quarter on a form filed on three cadences.
Belgium and France propose month for the same reason in reverse — each
country's code makes the monthly return the rule and the quarterly one an
authorisation granted on turnover — and Luxembourg proposes nothing, because
there the cadence follows turnover with no answer the law gives everybody.
Estonia's form is filed on one cadence and proposes it.
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 is where this used to be said, on the country rather than
on the form. It is still read, so a pack written before the move keeps working,
and a pack that says both and says two different things is refused: one fact,
one place.
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: Canadian tax follows
the buyer's province, so companies.region and contacts.region exist (ISO
3166-2 without the country prefix — QC, BC). Nothing reads them before the
Canadian pack; the declarative rules that turn a region into a suggested
tax, and the group tax that puts GST and QST on one line, are phase 1. 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 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. This is the second time docs/packs.md's
"adding a country touches packs/<cc>/ and nothing else" is not quite true: a
pack outside the common system adds a row to
supabase/seed/00_territories.sql, and
so does a pack that conditions a tax on a territory below the country.
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. docs/international.md carries
that gap under the United States, where it belongs.
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": {
"statement_formats": ["coda", "camt.053"],
"payment_formats": ["pain.001"]
}
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, because a null mandatory_from used to say two
different things — "there is no obligation" and "nobody looked up the date":
| 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 most of Europe is one of them and neither could be
said in a single word before. Belgium puts the fait générateur at the supply
(art. 16, § 1er and art. 22, § 1er) and derogates to the invoice (art. 17, § 1er
and art. 22bis, § 1er); Luxembourg is art. 21 and art. 24, par. 1er in the same
shape; the United Kingdom is VATA 1994 s. 6(2)–(3) and s. 6(4)–(5). All three
had declared invoice_date, which is the derogation given as if it were the
principle — and all three said so in their own citation, which is how it was
found. Estonia keeps the first of a supply and a payment (KMS § 11 lg 1, two
branches) and had declared delivery_date, which is the first branch alone.
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;
vat_return() puts a figure 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. Two limits are written down
in docs/international.md: the payment branch is honoured
only through cash_basis, because Ekwo has no prepayment document, and the
recapitulative statement still reads the entry date, because the moment an
intra-Community supply arises is a different article in every country.
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 — and, since invoice_if_issued and
earliest_of_delivery_or_payment exist, prefer a value that does not need the
excuse. The Luxembourg pack declares invoice_if_issued and its citation names
art. 21 as the principle and art. 24, par. 1er as the derogation, with the two
cases the derogation does not reach; a reader who opens the text is then not
left wondering whether the pack read the wrong article.
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. Belgium and France declare reversal_only, and cite why: both make
a validated entry irreversible (Code de droit économique, art. III.87, § 2;
PCG, art. 921-3), and France numbers its invoices continuously.
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.
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 Union |
exempt |
a line carries an exemption that is none of the above |
late_payment |
the document is one the seller issues |
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.
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
assistant 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 assets.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 — …" },
"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 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:
| 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 |
UPDATE_GOLDEN=1 npm test -- tests/golden.test.ts
rewrites those three 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; an intra-Union reverse
charge 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 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.
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.
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, and every line of it exists because the alternative was a figure that is wrong and looks right.
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.
The three code lists a tax tells one fact in have seven of their own, all of
them from the table under "What a tax says on the invoice", and every message
names the tax, its treatment, what it says, what the source says instead and
which source. A vat_category the treatment contradicts — an export that is
not G, an intra-Community acquisition that is AE rather than K. A
category on import or on foreign_services_received, where no invoice
governed by EN 16931 exists and there is nothing of a supplier's to record. No
category at all on a tax that can reach a sale, wherever an invoice of that
country carries BT-151. An exemption_code the VATEX list reserves for another category, and
one on a line that is taxed, which is exempt under nothing. A category that
asks for a reason and names none. A reason that is not shaped like a VATEX
code. And, on the sale side only, a rate the category forbids: a standard rate
of zero, or an exempt line at 21 %. A treatment declared on the side it does
not happen on — an import that is a sale, an export that is a purchase —
is refused in the same pass, because that is what makes the expected category
knowable.
Five more of them turn on one fact that is in territories and not in the
pack: whether the common system of VAT reaches the country on the day the
manifest's released_at names. Where it does not, an exemption_code taken
from the VATEX list is refused, because that list belongs to a system this
country is not in; a reason code from any other list is refused unless the
pack's register declares that list with "reason_codes": true, and refused
again if it is a sentence rather than a code; a line whose category asks for a
reason and that names neither a code nor a legal_reference is refused, because
the article is what the invoice states there; any of the five intracom_*
treatments is refused outright; and the requirement to name a vat_category at
all is lifted, unless the pack declares an e-invoicing profile whose invoices
carry BT-151. Every one of those messages names the country,
the day and the eu_vat_scope the table gives it, so a reader is never left
wondering why a code they read in the standard was turned down.
A cash-basis tax has four of its own: it has to name its transition account; it
takes exactly one tax posting per document kind; it takes no tax_on_base
posting, because a share nobody gets back is a cost and a cost is not deferred
to a payment; and its tax posting has to name a box, or the amount waits on
the transition account for ever.
The territories a tax names have three more, and the first two are the reason
those fields are foreign keys rather than strings. A code — in any key of
applies_when, or in jurisdiction — that territories carries no row for: a
territory nobody can look up is a place two packs can spell two ways. An
applies_when.seller_in outside the pack's own country, because a pack is keyed
on the country its companies file under and its taxes are the ones their seller
owes; the buyer and the place of supply are deliberately unconstrained, since a
supply is taxed where it lands. And the four refusals above move with the
territory: where a tax names seller_in, the regime it is held to is the one of
that territory on the day the manifest speaks of, so a tax applying where
eu_vat_scope is goods is inside the common system for a supply or an
acquisition of goods and outside it for everything else.
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 and reviewed are both claims that
somebody keeps this current and neither 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 pack and nothing on a community one. 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 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 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; and the exemption from a golden is required, in at least a
sentence.
The module sections. For 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.
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, so an application can
answer "where do these rules come from" without reading the pack, and the key
travels with the rule it belongs to in tax_templates.source_key and
tax_report_box_templates.source_key. The MCP tool describe_pack returns
all of it. What a register entry is not is a foreign key: a pack is
upserted one statement at a time and the register lives in a jsonb column, so
the check that a key resolves is ekwo pack check's, before the seed is
written.
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 |
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 because the register landed after four packs did, and 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 |
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.
There is deliberately no status that means "certified by Ekwo". Writing a
pack and testing that it holds together is not reviewing it. Certified
describes a professional reading a pack against the law, and nothing else. The
value ekwo that used to exist is deprecated, refused by the schema, and moved
to maintained by migration 20260912081015. Belgium and France are
maintained; every other pack is community — the
table of packs is generated from the manifests
and says it pack by pack.
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 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.
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.
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/assets/<n>_pack_xx.sql if your pack carries an assets
section — 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. packs/us/ adds four: the three states its codes belong to and
the one its last invoice ships to. The rows grow with the packs and not with the
world: a state nothing says anything about has no row.
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, analytics, fixed assets, payroll, and the translation of the application itself. 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.