ekwo-os 0.4.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (361) hide show
  1. package/README.md +873 -0
  2. package/dist/args.d.ts +57 -0
  3. package/dist/args.d.ts.map +1 -0
  4. package/dist/args.js +134 -0
  5. package/dist/args.js.map +1 -0
  6. package/dist/assets/expected-objects.json +9432 -0
  7. package/dist/assets/migrations/20260911120000_core_companies.sql +260 -0
  8. package/dist/assets/migrations/20260911120100_accounts_journals.sql +211 -0
  9. package/dist/assets/migrations/20260911120200_contacts.sql +90 -0
  10. package/dist/assets/migrations/20260911120300_taxes.sql +146 -0
  11. package/dist/assets/migrations/20260911120400_entries.sql +338 -0
  12. package/dist/assets/migrations/20260911120500_documents.sql +279 -0
  13. package/dist/assets/migrations/20260911120600_payments_reconciliation.sql +251 -0
  14. package/dist/assets/migrations/20260911120700_bank.sql +178 -0
  15. package/dist/assets/migrations/20260911120800_analytics_attachments.sql +128 -0
  16. package/dist/assets/migrations/20260911120900_post_document.sql +279 -0
  17. package/dist/assets/migrations/20260911121000_reporting.sql +235 -0
  18. package/dist/assets/migrations/20260911121100_country_templates.sql +226 -0
  19. package/dist/assets/migrations/20260911121200_fec.sql +71 -0
  20. package/dist/assets/migrations/20260911130000_instance.sql +77 -0
  21. package/dist/assets/migrations/20260911130100_instance_members.sql +224 -0
  22. package/dist/assets/migrations/20260911140000_instance_admins.sql +112 -0
  23. package/dist/assets/migrations/20260911160000_tax_posting_templates_unique.sql +17 -0
  24. package/dist/assets/migrations/20260911170000_document_amount_paid.sql +82 -0
  25. package/dist/assets/migrations/20260911173000_post_payment.sql +122 -0
  26. package/dist/assets/migrations/20260911173100_sequence_counters_under_rls.sql +84 -0
  27. package/dist/assets/migrations/20260911183000_country_journal_defaults.sql +143 -0
  28. package/dist/assets/migrations/20260911193853_line_account_defaults.sql +274 -0
  29. package/dist/assets/migrations/20260911195054_products.sql +221 -0
  30. package/dist/assets/migrations/20260911210131_anon_surface.sql +85 -0
  31. package/dist/assets/migrations/20260912074712_country_packs.sql +318 -0
  32. package/dist/assets/migrations/20260912080311_report_code_and_region.sql +198 -0
  33. package/dist/assets/migrations/20260912081014_pack_certification_maintained.sql +31 -0
  34. package/dist/assets/migrations/20260912081015_pack_certification_backfill.sql +16 -0
  35. package/dist/assets/migrations/20260912090407_tax_report_boxes.sql +329 -0
  36. package/dist/assets/migrations/20260912091917_tax_on_base_value.sql +25 -0
  37. package/dist/assets/migrations/20260912091918_tax_engine_columns.sql +776 -0
  38. package/dist/assets/migrations/20260912094412_opening_and_closing.sql +759 -0
  39. package/dist/assets/migrations/20260912095825_charts_of_accounts.sql +326 -0
  40. package/dist/assets/migrations/20260912100412_financial_statements.sql +632 -0
  41. package/dist/assets/migrations/20260912104719_one_formula_evaluator.sql +209 -0
  42. package/dist/assets/migrations/20260912105720_entry_kind_appropriation.sql +26 -0
  43. package/dist/assets/migrations/20260912105721_appropriation_entry_kind.sql +444 -0
  44. package/dist/assets/migrations/20260912111751_document_rules.sql +274 -0
  45. package/dist/assets/migrations/20260912112132_cash_basis_vat_and_fx.sql +1189 -0
  46. package/dist/assets/migrations/20260913074512_modules.sql +493 -0
  47. package/dist/assets/migrations/20260913075903_asset_disposal_roles.sql +47 -0
  48. package/dist/assets/migrations/20260913083216_capabilities.sql +637 -0
  49. package/dist/assets/migrations/20260913083901_company_invitations.sql +262 -0
  50. package/dist/assets/migrations/20260913084402_user_preferences.sql +376 -0
  51. package/dist/assets/migrations/20260913084847_company_profile.sql +261 -0
  52. package/dist/assets/migrations/20260913085436_numbering_reads_the_pack.sql +235 -0
  53. package/dist/assets/migrations/20260913085932_api_keys.sql +303 -0
  54. package/dist/assets/migrations/20260913090216_fiscal_year_bounds.sql +159 -0
  55. package/dist/assets/migrations/20260913092527_entries_import.sql +219 -0
  56. package/dist/assets/migrations/20260913101536_null_safe_guards.sql +188 -0
  57. package/dist/assets/migrations/20260913102115_installer_is_named.sql +462 -0
  58. package/dist/assets/migrations/20260913102758_no_currency_default.sql +138 -0
  59. package/dist/assets/migrations/20260913103355_cash_basis_needs_a_box.sql +453 -0
  60. package/dist/assets/migrations/20260913104014_aged_balance_named_group.sql +79 -0
  61. package/dist/assets/migrations/20260913104232_foreign_key_indexes.sql +144 -0
  62. package/dist/assets/migrations/20260913105120_declared_no_reader.sql +41 -0
  63. package/dist/assets/migrations/20260913111407_pack_languages.sql +223 -0
  64. package/dist/assets/migrations/20260913112233_schema_comments_for_a_reader.sql +27 -0
  65. package/dist/assets/migrations/20260913114535_fec_opening_balances.sql +307 -0
  66. package/dist/assets/migrations/20260914103412_audit_log.sql +509 -0
  67. package/dist/assets/migrations/20260914111907_pack_upgrade.sql +428 -0
  68. package/dist/assets/migrations/20260914120500_rounding_reads_the_currency.sql +206 -0
  69. package/dist/assets/migrations/20260914121200_amounts_round_at_the_currency.sql +2403 -0
  70. package/dist/assets/migrations/20260914134325_schema_version_0_2_0.sql +36 -0
  71. package/dist/assets/migrations/20260914143915_accounts_in_use.sql +428 -0
  72. package/dist/assets/migrations/20260914144731_account_code_frozen.sql +99 -0
  73. package/dist/assets/migrations/20260914151207_schema_grants_its_own_rights.sql +284 -0
  74. package/dist/assets/migrations/20260914152840_pack_upgrade_records_its_own_line.sql +237 -0
  75. package/dist/assets/migrations/20260914163943_declaration_periodicity.sql +349 -0
  76. package/dist/assets/migrations/20260915094000_foreign_services_received.sql +37 -0
  77. package/dist/assets/migrations/20260915094500_mentions_read_a_foreign_service.sql +72 -0
  78. package/dist/assets/migrations/20260915153000_document_shares.sql +568 -0
  79. package/dist/assets/migrations/20260915160000_ec_sales_list.sql +181 -0
  80. package/dist/assets/migrations/20260915161842_pack_sources.sql +56 -0
  81. package/dist/assets/migrations/20260915170500_schema_version_0_3_0.sql +45 -0
  82. package/dist/assets/migrations/20260915174500_a_vat_category_is_a_code.sql +177 -0
  83. package/dist/assets/migrations/20260915180000_document_rule_references.sql +85 -0
  84. package/dist/assets/migrations/20260915181000_territories.sql +342 -0
  85. package/dist/assets/migrations/20260915181500_ec_sales_list_reads_the_territories.sql +222 -0
  86. package/dist/assets/migrations/20260915182000_intracom_triangular.sql +53 -0
  87. package/dist/assets/migrations/20260915182500_a_triangular_supply_says_reverse_charge.sql +99 -0
  88. package/dist/assets/migrations/20260915191200_a_document_knows_its_language.sql +633 -0
  89. package/dist/assets/migrations/20260915195000_a_price_that_holds_its_tax.sql +649 -0
  90. package/dist/assets/migrations/20260915200000_a_posting_names_its_boxes.sql +1004 -0
  91. package/dist/assets/migrations/20260916094500_a_company_files_more_than_one_declaration.sql +795 -0
  92. package/dist/assets/migrations/20260916103000_a_ledger_line_names_its_posting.sql +902 -0
  93. package/dist/assets/migrations/20260916123000_a_vocabulary_for_a_tax_that_is_not_a_vat.sql +144 -0
  94. package/dist/assets/migrations/20260916124000_a_box_can_be_a_rate_of_a_box.sql +525 -0
  95. package/dist/assets/migrations/20260916125000_a_tax_follows_the_territory.sql +1318 -0
  96. package/dist/assets/migrations/20260916126000_a_tax_point_and_its_exception.sql +1211 -0
  97. package/dist/assets/migrations/20260917090000_a_counterparty_that_learns.sql +561 -0
  98. package/dist/assets/migrations/20260917120000_what_the_money_pays.sql +545 -0
  99. package/dist/assets/migrations/20260917150000_a_filing_is_a_row.sql +488 -0
  100. package/dist/assets/migrations/20260917170000_when_it_is_due.sql +194 -0
  101. package/dist/assets/migrations/20260917180000_a_box_is_a_number_and_a_kind.sql +213 -0
  102. package/dist/assets/migrations/20260917190000_what_a_declaration_owes.sql +486 -0
  103. package/dist/assets/migrations/20260917200000_what_moved_after_it_went.sql +227 -0
  104. package/dist/assets/migrations/20260918090000_a_form_names_its_file.sql +27 -0
  105. package/dist/assets/migrations/20260918110000_a_deposit_and_what_came_back.sql +286 -0
  106. package/dist/assets/migrations/20260918113741_a_fourth_preset_is_named.sql +9 -0
  107. package/dist/assets/migrations/20260918113807_a_client_reads_and_hands_over.sql +96 -0
  108. package/dist/assets/migrations/20260918114322_two_definer_functions_check_their_caller.sql +75 -0
  109. package/dist/assets/migrations/20260918130000_every_company_somebody_keeps.sql +177 -0
  110. package/dist/assets/migrations/20260918140000_a_session_nobody_prepared.sql +40 -0
  111. package/dist/assets/migrations/20260918141107_a_line_keeps_the_tax_it_was_posted_with.sql +256 -0
  112. package/dist/assets/migrations/20260918141342_a_company_has_an_electronic_address.sql +66 -0
  113. package/dist/assets/migrations/20260918141605_an_invoice_reads_whole_from_the_views.sql +313 -0
  114. package/dist/assets/migrations/20260918141627_a_policy_asks_once.sql +132 -0
  115. package/dist/assets/migrations/20260918143352_a_suggestion_reads_the_words_once.sql +204 -0
  116. package/dist/assets/migrations/20260918143417_a_reference_of_the_caller_and_a_rehearsal.sql +115 -0
  117. package/dist/assets/migrations/20260918150712_a_company_leaves_with_its_books.sql +1119 -0
  118. package/dist/assets/migrations/20260918150931_a_statement_is_imported_once.sql +654 -0
  119. package/dist/assets/migrations/20260918161204_a_posted_document_does_not_move.sql +437 -0
  120. package/dist/assets/migrations/20260918161538_a_posted_entry_does_not_move.sql +252 -0
  121. package/dist/assets/migrations/20260918171946_an_entry_is_posted_by_post_entry.sql +289 -0
  122. package/dist/assets/migrations/20260918174312_schema_version_0_4_0.sql +53 -0
  123. package/dist/assets/modules/assets/module.json +13 -0
  124. package/dist/assets/modules/assets/supabase/migrations/20260913081447_assets.sql +1190 -0
  125. package/dist/assets/modules/assets/supabase/migrations/20260913104234_foreign_key_indexes.sql +47 -0
  126. package/dist/assets/modules/assets/supabase/migrations/20260913104500_assets_capabilities.sql +145 -0
  127. package/dist/assets/modules/assets/supabase/migrations/20260914122100_assets_round_at_the_currency.sql +564 -0
  128. package/dist/assets/modules/assets/supabase/migrations/20260914145012_assets_accounts_in_use.sql +37 -0
  129. package/dist/assets/modules/assets/supabase/migrations/20260914151530_assets_grants_its_own_rights.sql +63 -0
  130. package/dist/assets/modules/assets/supabase/migrations/20260918113912_assets_client_reads.sql +20 -0
  131. package/dist/assets/modules/assets/supabase/migrations/20260918150801_assets_leave_with_the_company.sql +41 -0
  132. package/dist/assets/modules/budgets/module.json +12 -0
  133. package/dist/assets/modules/budgets/supabase/migrations/20260913083012_budgets.sql +210 -0
  134. package/dist/assets/modules/budgets/supabase/migrations/20260913104233_foreign_key_indexes.sql +37 -0
  135. package/dist/assets/modules/budgets/supabase/migrations/20260913104501_budgets_capabilities.sql +72 -0
  136. package/dist/assets/modules/budgets/supabase/migrations/20260914122400_budgets_round_at_the_currency.sql +68 -0
  137. package/dist/assets/modules/budgets/supabase/migrations/20260914145048_budgets_accounts_in_use.sql +30 -0
  138. package/dist/assets/modules/budgets/supabase/migrations/20260914151812_budgets_grants_its_own_rights.sql +31 -0
  139. package/dist/assets/modules/budgets/supabase/migrations/20260918113954_budgets_client_reads.sql +20 -0
  140. package/dist/assets/modules/budgets/supabase/migrations/20260918150823_budgets_leave_with_the_company.sql +37 -0
  141. package/dist/assets/modules/schema/module.1.json +69 -0
  142. package/dist/assets/seed/00_currencies.sql +15 -0
  143. package/dist/assets/seed/00_territories.sql +280 -0
  144. package/dist/assets/seed/05_framework_generic.sql +110 -0
  145. package/dist/assets/seed/10_pack_be.sql +1284 -0
  146. package/dist/assets/seed/11_pack_fr.sql +1112 -0
  147. package/dist/assets/seed/12_pack_lu.sql +2484 -0
  148. package/dist/assets/seed/13_pack_ee.sql +733 -0
  149. package/dist/assets/seed/14_pack_gb.sql +796 -0
  150. package/dist/assets/seed/15_pack_us.sql +751 -0
  151. package/dist/assets/seed/90_demo_company.sql +368 -0
  152. package/dist/assets/seed/modules/assets/10_pack_be.sql +58 -0
  153. package/dist/assets/seed/modules/assets/11_pack_fr.sql +57 -0
  154. package/dist/assets/seed/modules/assets/14_pack_gb.sql +58 -0
  155. package/dist/assets/seed/modules/assets/15_pack_us.sql +60 -0
  156. package/dist/auth.d.ts +59 -0
  157. package/dist/auth.d.ts.map +1 -0
  158. package/dist/auth.js +134 -0
  159. package/dist/auth.js.map +1 -0
  160. package/dist/bin.d.ts +6 -0
  161. package/dist/bin.d.ts.map +1 -0
  162. package/dist/bin.js +8 -0
  163. package/dist/bin.js.map +1 -0
  164. package/dist/books.d.ts +60 -0
  165. package/dist/books.d.ts.map +1 -0
  166. package/dist/books.js +168 -0
  167. package/dist/books.js.map +1 -0
  168. package/dist/bootstrap.d.ts +236 -0
  169. package/dist/bootstrap.d.ts.map +1 -0
  170. package/dist/bootstrap.js +459 -0
  171. package/dist/bootstrap.js.map +1 -0
  172. package/dist/bundle.d.ts +27 -0
  173. package/dist/bundle.d.ts.map +1 -0
  174. package/dist/bundle.js +47 -0
  175. package/dist/bundle.js.map +1 -0
  176. package/dist/checklist.d.ts +35 -0
  177. package/dist/checklist.d.ts.map +1 -0
  178. package/dist/checklist.js +64 -0
  179. package/dist/checklist.js.map +1 -0
  180. package/dist/cli.d.ts +14 -0
  181. package/dist/cli.d.ts.map +1 -0
  182. package/dist/cli.js +318 -0
  183. package/dist/cli.js.map +1 -0
  184. package/dist/commands/company.d.ts +59 -0
  185. package/dist/commands/company.d.ts.map +1 -0
  186. package/dist/commands/company.js +287 -0
  187. package/dist/commands/company.js.map +1 -0
  188. package/dist/commands/contact.d.ts +9 -0
  189. package/dist/commands/contact.d.ts.map +1 -0
  190. package/dist/commands/contact.js +82 -0
  191. package/dist/commands/contact.js.map +1 -0
  192. package/dist/commands/demo.d.ts +19 -0
  193. package/dist/commands/demo.d.ts.map +1 -0
  194. package/dist/commands/demo.js +72 -0
  195. package/dist/commands/demo.js.map +1 -0
  196. package/dist/commands/doctor.d.ts +12 -0
  197. package/dist/commands/doctor.d.ts.map +1 -0
  198. package/dist/commands/doctor.js +68 -0
  199. package/dist/commands/doctor.js.map +1 -0
  200. package/dist/commands/document.d.ts +17 -0
  201. package/dist/commands/document.d.ts.map +1 -0
  202. package/dist/commands/document.js +217 -0
  203. package/dist/commands/document.js.map +1 -0
  204. package/dist/commands/init.d.ts +31 -0
  205. package/dist/commands/init.d.ts.map +1 -0
  206. package/dist/commands/init.js +535 -0
  207. package/dist/commands/init.js.map +1 -0
  208. package/dist/commands/login.d.ts +24 -0
  209. package/dist/commands/login.d.ts.map +1 -0
  210. package/dist/commands/login.js +128 -0
  211. package/dist/commands/login.js.map +1 -0
  212. package/dist/commands/migrate.d.ts +30 -0
  213. package/dist/commands/migrate.d.ts.map +1 -0
  214. package/dist/commands/migrate.js +115 -0
  215. package/dist/commands/migrate.js.map +1 -0
  216. package/dist/commands/module.d.ts +32 -0
  217. package/dist/commands/module.d.ts.map +1 -0
  218. package/dist/commands/module.js +285 -0
  219. package/dist/commands/module.js.map +1 -0
  220. package/dist/commands/pack.d.ts +22 -0
  221. package/dist/commands/pack.d.ts.map +1 -0
  222. package/dist/commands/pack.js +481 -0
  223. package/dist/commands/pack.js.map +1 -0
  224. package/dist/commands/payment.d.ts +15 -0
  225. package/dist/commands/payment.d.ts.map +1 -0
  226. package/dist/commands/payment.js +97 -0
  227. package/dist/commands/payment.js.map +1 -0
  228. package/dist/commands/register.d.ts +20 -0
  229. package/dist/commands/register.d.ts.map +1 -0
  230. package/dist/commands/register.js +134 -0
  231. package/dist/commands/register.js.map +1 -0
  232. package/dist/commands/status.d.ts +8 -0
  233. package/dist/commands/status.d.ts.map +1 -0
  234. package/dist/commands/status.js +134 -0
  235. package/dist/commands/status.js.map +1 -0
  236. package/dist/commands/whoami.d.ts +21 -0
  237. package/dist/commands/whoami.d.ts.map +1 -0
  238. package/dist/commands/whoami.js +133 -0
  239. package/dist/commands/whoami.js.map +1 -0
  240. package/dist/company.d.ts +16 -0
  241. package/dist/company.d.ts.map +1 -0
  242. package/dist/company.js +26 -0
  243. package/dist/company.js.map +1 -0
  244. package/dist/config.d.ts +24 -0
  245. package/dist/config.d.ts.map +1 -0
  246. package/dist/config.js +50 -0
  247. package/dist/config.js.map +1 -0
  248. package/dist/connection.d.ts +79 -0
  249. package/dist/connection.d.ts.map +1 -0
  250. package/dist/connection.js +116 -0
  251. package/dist/connection.js.map +1 -0
  252. package/dist/context.d.ts +47 -0
  253. package/dist/context.d.ts.map +1 -0
  254. package/dist/context.js +93 -0
  255. package/dist/context.js.map +1 -0
  256. package/dist/doctor.d.ts +45 -0
  257. package/dist/doctor.d.ts.map +1 -0
  258. package/dist/doctor.js +448 -0
  259. package/dist/doctor.js.map +1 -0
  260. package/dist/grants.d.ts +88 -0
  261. package/dist/grants.d.ts.map +1 -0
  262. package/dist/grants.js +174 -0
  263. package/dist/grants.js.map +1 -0
  264. package/dist/identity.d.ts +54 -0
  265. package/dist/identity.d.ts.map +1 -0
  266. package/dist/identity.js +132 -0
  267. package/dist/identity.js.map +1 -0
  268. package/dist/index.d.ts +44 -0
  269. package/dist/index.d.ts.map +1 -0
  270. package/dist/index.js +43 -0
  271. package/dist/index.js.map +1 -0
  272. package/dist/inventory.d.ts +155 -0
  273. package/dist/inventory.d.ts.map +1 -0
  274. package/dist/inventory.js +303 -0
  275. package/dist/inventory.js.map +1 -0
  276. package/dist/migrations.d.ts +76 -0
  277. package/dist/migrations.d.ts.map +1 -0
  278. package/dist/migrations.js +226 -0
  279. package/dist/migrations.js.map +1 -0
  280. package/dist/module/read.d.ts +91 -0
  281. package/dist/module/read.d.ts.map +1 -0
  282. package/dist/module/read.js +172 -0
  283. package/dist/module/read.js.map +1 -0
  284. package/dist/output.d.ts +110 -0
  285. package/dist/output.d.ts.map +1 -0
  286. package/dist/output.js +173 -0
  287. package/dist/output.js.map +1 -0
  288. package/dist/pack/certification.d.ts +27 -0
  289. package/dist/pack/certification.d.ts.map +1 -0
  290. package/dist/pack/certification.js +38 -0
  291. package/dist/pack/certification.js.map +1 -0
  292. package/dist/pack/compile.d.ts +75 -0
  293. package/dist/pack/compile.d.ts.map +1 -0
  294. package/dist/pack/compile.js +801 -0
  295. package/dist/pack/compile.js.map +1 -0
  296. package/dist/pack/filing.d.ts +48 -0
  297. package/dist/pack/filing.d.ts.map +1 -0
  298. package/dist/pack/filing.js +56 -0
  299. package/dist/pack/filing.js.map +1 -0
  300. package/dist/pack/read.d.ts +766 -0
  301. package/dist/pack/read.d.ts.map +1 -0
  302. package/dist/pack/read.js +2336 -0
  303. package/dist/pack/read.js.map +1 -0
  304. package/dist/pack/schema.d.ts +26 -0
  305. package/dist/pack/schema.d.ts.map +1 -0
  306. package/dist/pack/schema.js +181 -0
  307. package/dist/pack/schema.js.map +1 -0
  308. package/dist/pack/territories.d.ts +98 -0
  309. package/dist/pack/territories.d.ts.map +1 -0
  310. package/dist/pack/territories.js +0 -0
  311. package/dist/pack/territories.js.map +1 -0
  312. package/dist/pack/upgrade.d.ts +86 -0
  313. package/dist/pack/upgrade.d.ts.map +1 -0
  314. package/dist/pack/upgrade.js +76 -0
  315. package/dist/pack/upgrade.js.map +1 -0
  316. package/dist/pack/vat-codes.d.ts +273 -0
  317. package/dist/pack/vat-codes.d.ts.map +1 -0
  318. package/dist/pack/vat-codes.js +513 -0
  319. package/dist/pack/vat-codes.js.map +1 -0
  320. package/dist/profiles.d.ts +67 -0
  321. package/dist/profiles.d.ts.map +1 -0
  322. package/dist/profiles.js +187 -0
  323. package/dist/profiles.js.map +1 -0
  324. package/dist/prompt.d.ts +40 -0
  325. package/dist/prompt.d.ts.map +1 -0
  326. package/dist/prompt.js +120 -0
  327. package/dist/prompt.js.map +1 -0
  328. package/dist/registry.d.ts +84 -0
  329. package/dist/registry.d.ts.map +1 -0
  330. package/dist/registry.js +104 -0
  331. package/dist/registry.js.map +1 -0
  332. package/dist/rest.d.ts +81 -0
  333. package/dist/rest.d.ts.map +1 -0
  334. package/dist/rest.js +245 -0
  335. package/dist/rest.js.map +1 -0
  336. package/dist/schema.d.ts +15 -0
  337. package/dist/schema.d.ts.map +1 -0
  338. package/dist/schema.js +15 -0
  339. package/dist/schema.js.map +1 -0
  340. package/dist/seeds.d.ts +27 -0
  341. package/dist/seeds.d.ts.map +1 -0
  342. package/dist/seeds.js +47 -0
  343. package/dist/seeds.js.map +1 -0
  344. package/dist/session.d.ts +39 -0
  345. package/dist/session.d.ts.map +1 -0
  346. package/dist/session.js +92 -0
  347. package/dist/session.js.map +1 -0
  348. package/dist/sql.d.ts +48 -0
  349. package/dist/sql.d.ts.map +1 -0
  350. package/dist/sql.js +112 -0
  351. package/dist/sql.js.map +1 -0
  352. package/dist/status.d.ts +94 -0
  353. package/dist/status.d.ts.map +1 -0
  354. package/dist/status.js +120 -0
  355. package/dist/status.js.map +1 -0
  356. package/dist/ui.d.ts +55 -0
  357. package/dist/ui.d.ts.map +1 -0
  358. package/dist/ui.js +114 -0
  359. package/dist/ui.js.map +1 -0
  360. package/package.json +29 -0
  361. package/schema/output.1.json +613 -0
@@ -0,0 +1,2336 @@
1
+ /**
2
+ * Reading a pack: the files, the CSV, and what the schema says about them.
3
+ *
4
+ * A pack is `packs/<cc>/`: a manifest, a chart of accounts as CSV, taxes as
5
+ * JSON, and — accepted here, compiled later — the declaration boxes, the
6
+ * financial statements and the translations. Nothing in it executes.
7
+ */
8
+ import { createHash } from 'node:crypto';
9
+ import { readFile, readdir } from 'node:fs/promises';
10
+ import { existsSync } from 'node:fs';
11
+ import { dirname, join } from 'node:path';
12
+ import { fileURLToPath } from 'node:url';
13
+ import { validate } from './schema.js';
14
+ import { taxCodes, TREATMENT_CODES } from './vat-codes.js';
15
+ import { euVatScopeOf, readTerritories, territoryOf, territoryWithin, } from './territories.js';
16
+ /**
17
+ * The chart a pack has when it declares none, and the name of the framework
18
+ * pack. Both are mechanism words: `default` is not a country and `generic` is
19
+ * not a language — a pack says its chart is called PCMN, in its own data.
20
+ */
21
+ export const DEFAULT_CHART = 'default';
22
+ export const GENERIC_PACK = 'generic';
23
+ /** The register of a pack: the entries, with the deprecated bare titles dropped. */
24
+ export function sourcesOf(certification) {
25
+ return (certification?.sources ?? []).filter((source) => typeof source !== 'string');
26
+ }
27
+ /**
28
+ * Which VAT the country of a pack levies, as the three code lists see it.
29
+ *
30
+ * Two of those lists are the Union's — EN 16931 is a European standard and the
31
+ * VATEX list is published by the European Commission — so whether a pack may
32
+ * carry a code from them is a question about its country. The answer is a row
33
+ * of `territories`, read from the seed the database reads, and never a country
34
+ * written into this repository's code.
35
+ *
36
+ * **The day is the pack's `released_at`, not today.** A pack is a transcription
37
+ * of a country's law, and `released_at` is the day it says that transcription
38
+ * is true; it is in the manifest, it is inside the pack's checksum, and asking
39
+ * it makes `ekwo pack check` answer the same thing about the same commit for
40
+ * ever. Today would not: a pack that passes in the morning and fails in the
41
+ * evening with nothing committed in between is the one thing a check must
42
+ * never be, and the day a State acceded or left is exactly when it would
43
+ * happen. A tax's own `valid_from` would be worse still — the taxes of a pack
44
+ * span decades, so one pack would speak two regimes at once and a British rate
45
+ * of 1994 would be asked for a VATEX code from a list that did not exist. A
46
+ * pack whose manifest names no day falls back on today, because a pack that
47
+ * does not say when it speaks of is speaking of now.
48
+ *
49
+ * **A table that says nothing is not a table saying no.** `eu_vat_scope_of()`
50
+ * answers `none` for a code it does not carry, which is right for its own
51
+ * question — a supply to a place the Union has never heard of is not an
52
+ * intra-Community one. It is not right for this one. Every refusal below
53
+ * *narrows* what a pack may say, and narrowing on the strength of a missing
54
+ * row would refuse a valid pack for a country somebody has not added to the
55
+ * reference data yet. So a country the table carries no row for is held to the
56
+ * table as published — the Union's — and the gap is closed where it belongs:
57
+ * `tests/territories.test.ts` refuses a pack of this repository whose country
58
+ * is not in `territories`, and the test beside it says the same of the seed the
59
+ * CLI reads.
60
+ *
61
+ * `full` and nothing else counts as being in the system **for a pack**. The
62
+ * third value, `goods`, is Northern Ireland, and it is not a fact about a pack
63
+ * at all: the Union's rules reach goods there and not services, so half the
64
+ * table applies and which half depends on the tax. A pack whose country is
65
+ * such a territory is therefore still held to the rules of a country outside
66
+ * the system, which refuses a code rather than accepting a wrong one — and a
67
+ * *tax* that names the territory it applies in is judged on that territory
68
+ * instead, by `regimeOfTerritory` below.
69
+ *
70
+ * **Whether BT-151 is read is a second question, and the manifest answers the
71
+ * other half of it.** The category codes are UNCL5305, a UN/CEFACT list, and
72
+ * they hold wherever a pack is; being *asked* for one is about whether an
73
+ * invoice governed by the standard exists. Inside the common system it does.
74
+ * Outside it, it does where the pack declares an e-invoicing profile —
75
+ * `peppol-bis-3`, `factur-x-en16931`, `xrechnung`, a PINT — every one of which
76
+ * is built on the semantic model of EN 16931 and carries the field. Where the
77
+ * pack declares neither, as the first pack of a country with no value added
78
+ * tax did, nothing reads BT-151 and nothing demands it.
79
+ *
80
+ * **Which list a reason code may come from is named by the entry itself.**
81
+ * `reasonList` was the title of the first entry of the register whose `kind`
82
+ * was `standard`, and `standard` covers a code list *and* an accounting
83
+ * standard, so a pack naming FRS 102 or the FASB Codification was silently
84
+ * declaring a list of exemption reason codes. An entry now says so with
85
+ * `reason_codes`, at most one per pack, and `sourceRegister` refuses the rest.
86
+ */
87
+ export async function vatRegime(manifest, root) {
88
+ const territories = await readTerritories(root ?? repoRootDir());
89
+ const released = manifest.released_at ?? '';
90
+ const on = /^\d{4}-\d{2}-\d{2}$/.test(released)
91
+ ? released
92
+ : new Date().toISOString().slice(0, 10);
93
+ // The entry that says it is the reason code list, and not whichever
94
+ // `standard` was written down first: an accounting standard is a `standard`
95
+ // too, and reading it as this one authorised a code nobody published.
96
+ const list = sourcesOf(manifest.certification).find((source) => source.reason_codes === true);
97
+ const reasonList = list?.title ?? null;
98
+ const day = manifest.released_at === on ? `${on}, the day this pack speaks of` : on;
99
+ // Every e-invoicing profile the format names is built on the semantic model
100
+ // of EN 16931 and carries BT-151, so a pack that declares one is a pack
101
+ // whose sellers put a category on an invoice somebody reads. A pack that
102
+ // declares none, in a country the common system does not reach, issues no
103
+ // invoice the standard governs and is not asked for one.
104
+ const profile = (manifest['einvoicing']?.profile ?? null);
105
+ const hasProfile = typeof profile === 'string' && profile.trim() !== '';
106
+ if (territoryOf(manifest.country, territories) === null) {
107
+ return {
108
+ commonSystem: true,
109
+ because: `territories carries no row for ${manifest.country}, so where its VAT stands is unknown`,
110
+ readsCategories: true,
111
+ reasonList,
112
+ };
113
+ }
114
+ const scope = euVatScopeOf(manifest.country, on, territories);
115
+ return {
116
+ commonSystem: scope === 'full',
117
+ because: `${manifest.country} is ${scope === 'full' ? 'in' : 'outside'} the common system of VAT on ` +
118
+ `${day} (territories gives it eu_vat_scope ${scope})`,
119
+ readsCategories: scope === 'full' || hasProfile,
120
+ reasonList,
121
+ };
122
+ }
123
+ /**
124
+ * The day a pack speaks of: its `released_at`, or today where it names none.
125
+ *
126
+ * Lifted out of `vatRegime` so that a tax's own territory can be asked the
127
+ * same question on the same day. The argument for the day being the manifest's
128
+ * and not today's is above, and it holds identically here.
129
+ */
130
+ function packDay(manifest) {
131
+ const released = manifest.released_at ?? '';
132
+ return /^\d{4}-\d{2}-\d{2}$/.test(released)
133
+ ? released
134
+ : new Date().toISOString().slice(0, 10);
135
+ }
136
+ /**
137
+ * Which VAT reaches **one tax**, when the tax names the territory it applies
138
+ * in.
139
+ *
140
+ * This is the half of Northern Ireland a pack could not have. `packs/gb/` is
141
+ * keyed on a country the common system left on 31 December 2020, so every tax
142
+ * in it is outside the system — and a tax that says `seller_in: "XI"` is not:
143
+ * `eu_vat_scope` of `XI` is `goods`, which means the Union's rules reach a
144
+ * supply or an acquisition of goods there and nothing else. So the answer is
145
+ * the territory's scope, narrowed by what the treatment is about:
146
+ *
147
+ * `full` in, whatever the treatment is
148
+ * `goods` in exactly where `TREATMENT_CODES` marks the treatment as goods
149
+ * `none` out
150
+ *
151
+ * The **seller's** territory decides it, because the regime asked about here
152
+ * is the one governing the invoice the seller issues. A tax that conditions
153
+ * only the buyer or only the place of supply is left to its pack's country,
154
+ * which is where it was before this existed.
155
+ */
156
+ function regimeOfTerritory(territory, treatment, on, territories, reasonList, packReadsCategories) {
157
+ const scope = euVatScopeOf(territory, on, territories);
158
+ const goods = TREATMENT_CODES[treatment]?.goods === true;
159
+ const inside = scope === 'full' || (scope === 'goods' && goods);
160
+ const detail = scope === 'goods' && !goods
161
+ ? ', which reaches supplies of goods and not this treatment'
162
+ : '';
163
+ return {
164
+ commonSystem: inside,
165
+ because: `this tax applies in ${territory}, which is ${inside ? 'in' : 'outside'} the common system ` +
166
+ `of VAT on ${on} (territories gives it eu_vat_scope ${scope}${detail})`,
167
+ // Whether BT-151 is read is the pack's question and not the territory's:
168
+ // a category is asked for on an invoice governed by EN 16931, which is
169
+ // every invoice inside the system and, outside it, every invoice of a pack
170
+ // that declares an e-invoicing profile. A tax whose territory is inside
171
+ // the system is asked for one either way.
172
+ readsCategories: inside || packReadsCategories,
173
+ reasonList,
174
+ };
175
+ }
176
+ /** A rule of a country that cites nothing: both halves null, never absent. */
177
+ const NO_REFERENCE = { legal_reference: null, source: null };
178
+ export class PackError extends Error {
179
+ }
180
+ /** Repository root: the folder that holds both `packs/` and `supabase/seed/`. */
181
+ export function repoRootDir() {
182
+ let dir = dirname(fileURLToPath(import.meta.url));
183
+ for (let depth = 0; depth < 8; depth += 1) {
184
+ if (existsSync(join(dir, 'packs')) && existsSync(join(dir, 'supabase', 'seed')))
185
+ return dir;
186
+ dir = dirname(dir);
187
+ }
188
+ throw new PackError('packs_not_found: `ekwo pack` builds the country packs of a checkout of the repository, ' +
189
+ 'and neither packs/ nor supabase/seed/ was found above this file. ' +
190
+ 'A published installation does not need it: the compiled seeds ship with the package.');
191
+ }
192
+ export function packsDir(root = repoRootDir()) {
193
+ return join(root, 'packs');
194
+ }
195
+ export function seedOutputDir(root = repoRootDir()) {
196
+ return join(root, 'supabase', 'seed');
197
+ }
198
+ /**
199
+ * The seed number each pack of a checkout declares, by slug.
200
+ *
201
+ * Read on its own, one field out of each manifest, rather than through
202
+ * `readPack`: naming the seed file of a pack must not depend on every other
203
+ * pack in the checkout being valid, or `ekwo pack build be` would fail because
204
+ * somebody's work in progress next door does not compile yet.
205
+ */
206
+ export async function declaredSeedSequences(dir = packsDir()) {
207
+ const declared = new Map();
208
+ for (const slug of await listPacks(dir)) {
209
+ const path = join(dir, slug, 'pack.json');
210
+ if (!existsSync(path))
211
+ continue;
212
+ const manifest = (await readJson(path));
213
+ if (typeof manifest.seed_sequence === 'number')
214
+ declared.set(slug, manifest.seed_sequence);
215
+ }
216
+ return declared;
217
+ }
218
+ /** The packs of this repository, by directory name, alphabetically. */
219
+ export async function listPacks(dir = packsDir()) {
220
+ const entries = await readdir(dir, { withFileTypes: true });
221
+ return entries
222
+ .filter((e) => e.isDirectory() && /^[a-z]{2}$/.test(e.name))
223
+ .map((e) => e.name)
224
+ .sort();
225
+ }
226
+ /** The published schema, read from `packs/schema/pack.1.json`. */
227
+ export async function readSchema(dir = packsDir()) {
228
+ return JSON.parse(await readFile(join(dir, 'schema', 'pack.1.json'), 'utf8'));
229
+ }
230
+ /**
231
+ * Reads, validates and normalises one pack.
232
+ *
233
+ * Validation is the published schema for every document, then the three
234
+ * things a schema cannot say: a role, a posting and a journal role must name
235
+ * something the pack itself carries.
236
+ */
237
+ export async function readPack(slug, dir = packsDir()) {
238
+ const root = join(dir, slug);
239
+ const schema = await readSchema(dir);
240
+ const defs = (schema['$defs'] ?? {});
241
+ const issues = [];
242
+ const warnings = [];
243
+ const deferred = [];
244
+ const manifest = (await readJson(join(root, 'pack.json')));
245
+ issues.push(...validate(manifest, schema, schema));
246
+ // The charts. A pack that declares none has exactly one, `default`, whose
247
+ // accounts are in accounts.csv — which is what every pack written before
248
+ // charts existed says, without changing a line of it.
249
+ const declared = manifest.charts ?? [
250
+ { code: DEFAULT_CHART, name: DEFAULT_CHART, accounts: 'accounts.csv', default: true },
251
+ ];
252
+ const charts = [];
253
+ for (const entry of declared) {
254
+ const file = entry.accounts;
255
+ if (!existsSync(join(root, file))) {
256
+ issues.push({ path: `pack.json charts.${entry.code}`, message: `${file} does not exist` });
257
+ continue;
258
+ }
259
+ const rows = parseCsv(await readFile(join(root, file), 'utf8'), `${slug}/${file}`);
260
+ const accounts = rows.map((row, index) => {
261
+ const account = {
262
+ code: row['code'] ?? '',
263
+ parent: emptyToNull(row['parent']),
264
+ type: row['type'] ?? '',
265
+ reconcilable: parseBoolean(row['reconcilable'], `${slug}/${file} line ${index + 2}`),
266
+ name: row['name'] ?? '',
267
+ sequence: parseInteger(row['sequence'], `${slug}/${file} line ${index + 2}`, (index + 1) * 10),
268
+ };
269
+ issues.push(...validate(account, defs['accounts_csv'] ?? {}, schema, `${file}[${index + 2}]`));
270
+ return account;
271
+ });
272
+ charts.push({
273
+ code: entry.code,
274
+ name: entry.name,
275
+ name_i18n: {},
276
+ file,
277
+ accounts,
278
+ is_default: entry.default === true,
279
+ audience: entry.audience ?? null,
280
+ statements: entry.statements ?? [],
281
+ certification: entry.certification ?? null,
282
+ legal_reference: entry.legal_reference ?? null,
283
+ source: entry.source ?? null,
284
+ });
285
+ }
286
+ charts.sort((a, b) => (a.is_default === b.is_default ? a.code.localeCompare(b.code) : a.is_default ? -1 : 1));
287
+ const accounts = charts.find((c) => c.is_default)?.accounts ?? charts[0]?.accounts ?? [];
288
+ const rawTaxes = (await readJson(join(root, 'taxes.json')));
289
+ issues.push(...validate(rawTaxes, defs['taxes'] ?? {}, schema, 'taxes.json'));
290
+ const taxes = rawTaxes.map((raw, index) => normaliseTax(raw, index));
291
+ let report = null;
292
+ if (existsSync(join(root, 'tax_report.json'))) {
293
+ const raw = await readJson(join(root, 'tax_report.json'));
294
+ issues.push(...validate(raw, defs['tax_report'] ?? {}, schema, 'tax_report.json'));
295
+ report = normaliseReport(raw);
296
+ // A pack written before the proposal moved onto the form still carries it
297
+ // in `defaults.vat_period`. It is read from there and never written back:
298
+ // one fact, one place, and the older spelling keeps working.
299
+ report.period_default ??= manifest.defaults['vat_period'] ?? null;
300
+ }
301
+ const reportCode = report?.code ?? null;
302
+ let statements = [];
303
+ if (existsSync(join(root, 'statements.json'))) {
304
+ const raw = await readJson(join(root, 'statements.json'));
305
+ issues.push(...validate(raw, defs['statements'] ?? {}, schema, 'statements.json'));
306
+ statements = normaliseStatements(raw, charts);
307
+ }
308
+ const documents = normaliseDocumentRules(manifest);
309
+ // The section of a module. A pack that carries none simply has no country
310
+ // rule for that module, and the module refuses by name where it needs one.
311
+ let assets = null;
312
+ if (existsSync(join(root, 'assets.json'))) {
313
+ const raw = await readJson(join(root, 'assets.json'));
314
+ issues.push(...validate(raw, defs['module_assets'] ?? {}, schema, 'assets.json'));
315
+ assets = normaliseAssets(raw);
316
+ issues.push(...assetReferences(assets, manifest));
317
+ }
318
+ // The golden scenario. Read after the taxes, the charts and the form,
319
+ // because every reference it makes is checked against them.
320
+ const goldenExemption = (manifest['golden']?.exempt ?? null) || null;
321
+ let golden = null;
322
+ const goldenPath = join(root, 'golden', 'scenario.json');
323
+ if (existsSync(goldenPath)) {
324
+ const raw = await readJson(goldenPath);
325
+ issues.push(...validate(raw, defs['golden'] ?? {}, schema, 'golden/scenario.json'));
326
+ golden = normaliseGolden(raw);
327
+ if (goldenExemption !== null) {
328
+ issues.push({
329
+ path: 'pack.json golden',
330
+ message: 'claims an exemption and the pack carries golden/scenario.json. Drop one of the two.',
331
+ });
332
+ }
333
+ issues.push(...goldenReferences(golden, charts, manifest, taxes, statements, accounts));
334
+ }
335
+ else if (goldenExemption === null) {
336
+ issues.push({
337
+ path: `packs/${slug}`,
338
+ message: 'carries no golden/scenario.json. A country pack is replayed against one year of books ' +
339
+ 'before anyone trusts its figures; see docs/packs.md, "Golden scenario". A pack that ' +
340
+ 'cannot have one says why in pack.json, under "golden": { "exempt": "…" }.',
341
+ });
342
+ }
343
+ // The languages. Read last, because a label is checked against the section
344
+ // it belongs to, and every section has to exist first.
345
+ const labels = {
346
+ pack_name: {},
347
+ charts: {},
348
+ accounts: {},
349
+ journals: {},
350
+ taxes: {},
351
+ tax_report_boxes: {},
352
+ statement_lines: {},
353
+ legal_mentions: {},
354
+ asset_categories: {},
355
+ };
356
+ const languages = [];
357
+ const i18nDir = join(root, 'i18n');
358
+ if (existsSync(i18nDir)) {
359
+ for (const file of (await readdir(i18nDir)).filter((f) => f.endsWith('.json')).sort()) {
360
+ const translations = (await readJson(join(i18nDir, file)));
361
+ issues.push(...validate(translations, defs['i18n'] ?? {}, schema, `i18n/${file}`));
362
+ const language = translations['language'] ?? file.replace(/\.json$/, '');
363
+ languages.push(language);
364
+ const section = (name) => (translations[name] ?? {});
365
+ if (typeof translations['pack_name'] === 'string') {
366
+ labels.pack_name[language] = translations['pack_name'];
367
+ }
368
+ // A key names something the pack carries, or it is a typo nobody would
369
+ // ever see: a label under a code that does not exist reaches no reader.
370
+ const byCode = (name, known, what) => {
371
+ for (const [code, label] of Object.entries(section(name))) {
372
+ if (!known.has(code)) {
373
+ issues.push({ path: `i18n/${file} ${name}`, message: `${code} is not ${what} of this pack` });
374
+ continue;
375
+ }
376
+ (labels[name][code] ??= {})[language] = label;
377
+ }
378
+ };
379
+ byCode('charts', new Set(charts.map((c) => c.code)), 'a chart');
380
+ byCode('accounts', codesOfCharts(charts), 'an account');
381
+ byCode('journals', new Set(manifest.journals.map((j) => j.code)), 'a journal');
382
+ byCode('taxes', new Set(taxes.map((t) => t.code)), 'a tax');
383
+ byCode('legal_mentions', new Set(documents.mentions.map((m) => m.code)), 'a legal mention');
384
+ byCode('asset_categories', new Set((assets?.categories ?? []).map((c) => c.code)), 'a fixed-asset category');
385
+ // A box is translated by the same reference the formulas use: `54`, or
386
+ // `08:tax` where the form carries a base and a tax on one line.
387
+ for (const [ref, label] of Object.entries(section('tax_report_boxes'))) {
388
+ const resolved = resolveBoxRef(ref, report?.boxes ?? []);
389
+ if (typeof resolved === 'string') {
390
+ issues.push({ path: `i18n/${file} ${ref}`, message: resolved });
391
+ continue;
392
+ }
393
+ (labels.tax_report_boxes[`${resolved.box}|${resolved.kind}`] ??= {})[language] = label;
394
+ }
395
+ // A statement line is translated by `<statement>:<line>`, which is how a
396
+ // line is named everywhere else once two statements carry a line `20`.
397
+ for (const [ref, label] of Object.entries(section('statement_lines'))) {
398
+ const [statementCode, lineCode] = ref.includes(':') ? ref.split(':') : [undefined, undefined];
399
+ const line = statements
400
+ .find((st) => st.code === statementCode)
401
+ ?.lines.find((l) => l.code === lineCode);
402
+ if (line === undefined) {
403
+ issues.push({
404
+ path: `i18n/${file} ${ref}`,
405
+ message: 'is not a line of any statement of this pack; write <statement>:<line>',
406
+ });
407
+ continue;
408
+ }
409
+ (labels.statement_lines[`${statementCode}:${lineCode}`] ??= {})[language] = label;
410
+ }
411
+ }
412
+ }
413
+ // A language the manifest declares is a promise that every label exists in
414
+ // it. A language that is only a file may be partial, and falls back.
415
+ issues.push(...languageCoverage(manifest, languages, labels, charts, taxes, statements, report, documents, assets));
416
+ // The rows that carry a translation get theirs from the language files, so
417
+ // that one file is the whole of one language.
418
+ for (const chart of charts)
419
+ chart.name_i18n = labels.charts[chart.code] ?? {};
420
+ for (const mention of documents.mentions)
421
+ mention.text_i18n = labels.legal_mentions[mention.code] ?? {};
422
+ for (const category of assets?.categories ?? []) {
423
+ category.name_i18n = labels.asset_categories[category.code] ?? {};
424
+ }
425
+ // A posting with a box belongs to a form. The pack names one in
426
+ // `tax_report.json`; a posting may override it the day a country files two.
427
+ for (const tax of taxes) {
428
+ for (const postings of Object.values(tax.postings)) {
429
+ for (const posting of postings) {
430
+ if (posting.report === null && posting.boxes.length > 0)
431
+ posting.report = reportCode;
432
+ }
433
+ }
434
+ }
435
+ issues.push(...crossReferences(manifest, charts, taxes));
436
+ // The three code lists a tax tells the same fact in: its treatment, its
437
+ // EN 16931 category and its VATEX reason. Nothing in the ledger reads the
438
+ // last two, so nothing else would ever notice them disagreeing. Two of the
439
+ // three lists are the Union's, so which of them reach this pack at all is
440
+ // read from `territories` first — and, where a tax names the territory it
441
+ // applies in, from that territory rather than from the pack's country, which
442
+ // is what makes a territory of limited scope expressible at all.
443
+ const territories = await readTerritories(repoRootDir());
444
+ const packRegime = await vatRegime(manifest);
445
+ issues.push(...territoryReferences(taxes, territories));
446
+ issues.push(...sellerTerritory(manifest, taxes, territories));
447
+ const regimes = new Map();
448
+ for (const tax of taxes) {
449
+ if (tax.applies_seller_territory === null)
450
+ continue;
451
+ if (territoryOf(tax.applies_seller_territory, territories) === null)
452
+ continue;
453
+ regimes.set(tax.code, regimeOfTerritory(tax.applies_seller_territory, tax.treatment, packDay(manifest), territories, packRegime.reasonList, packRegime.readsCategories));
454
+ }
455
+ issues.push(...taxCodes(taxes, (tax) => regimes.get(tax.code) ?? packRegime));
456
+ issues.push(...reportReferences(report, taxes));
457
+ issues.push(...proposedPeriod(manifest, report));
458
+ issues.push(...statementReferences(statements, charts));
459
+ issues.push(...documentReferences(documents));
460
+ // The register, and every rule that points into it. Last of the cross-checks,
461
+ // because a source is named by a tax, a box, a statement line and a mention,
462
+ // and all four have to have been read before the references can be resolved.
463
+ const register = sourceRegister(manifest, charts, taxes, report, statements, documents, assets);
464
+ issues.push(...register.issues);
465
+ warnings.push(...register.warnings);
466
+ if (issues.length > 0) {
467
+ // The cause before the consequence. A label problem is almost always
468
+ // downstream of a structural one — take a box out of the declaration and
469
+ // every language stops resolving it — so the structure is listed first and
470
+ // the reader is not made to scroll past forty translations to reach the one
471
+ // line that explains them.
472
+ const ordered = [
473
+ ...issues.filter((i) => !i.path.startsWith('i18n/')),
474
+ ...issues.filter((i) => i.path.startsWith('i18n/')),
475
+ ];
476
+ const shown = ordered.slice(0, 20).map((i) => ` ${i.path}: ${i.message}`);
477
+ const more = issues.length > shown.length ? `\n … and ${issues.length - shown.length} more` : '';
478
+ throw new PackError(`pack_invalid: packs/${slug} — ${issues.length} problem(s)\n${shown.join('\n')}${more}`);
479
+ }
480
+ return {
481
+ slug,
482
+ dir: root,
483
+ manifest,
484
+ charts,
485
+ accounts,
486
+ taxes,
487
+ statements,
488
+ // The pack's own language first, then the ones the manifest declares, in
489
+ // the order it declares them — not the order `readdir` happens to return.
490
+ // This list is what an installer shows a human being.
491
+ languages: [
492
+ ...(manifest.defaults.language === undefined ? [] : [manifest.defaults.language]),
493
+ ...(manifest.languages ?? []),
494
+ ...languages.filter((l) => !(manifest.languages ?? []).includes(l)),
495
+ ],
496
+ labels,
497
+ documents,
498
+ report,
499
+ reportCode,
500
+ assets,
501
+ golden,
502
+ goldenExemption,
503
+ checksum: await checksum(root),
504
+ deferred,
505
+ sources: register.sources,
506
+ warnings,
507
+ };
508
+ }
509
+ /**
510
+ * The source register, and every reference the pack makes to it.
511
+ *
512
+ * `legal_reference` says which article a rule comes from and has been required
513
+ * on a tax and on a box since the format existed. What it never said is where
514
+ * that article can be read, so a reviewer opening a pack had a citation and a
515
+ * search engine. The register answers that once — a key, a title, the official
516
+ * publisher, an absolute link and the day somebody opened it — and every rule
517
+ * names a key instead of repeating a URL.
518
+ *
519
+ * Four things are refused here, and one is only warned about.
520
+ *
521
+ * A **duplicate key** is refused, because a reference would resolve to
522
+ * whichever entry happened to come first. A **key nothing declares** is
523
+ * refused: it reads as a source and is a typo. A pack that is not `community`
524
+ * and carries **no entry at all** is refused, because `maintained` and
525
+ * `reviewed` are claims that somebody keeps this current, and neither is
526
+ * sayable about a list of titles. And on a **reviewed** pack every tax and
527
+ * every box has to name a key — the reviewer read something, and this is where
528
+ * they say what.
529
+ *
530
+ * On a `maintained` pack that last one is a warning. The register arrived
531
+ * after four packs did; failing them the day it landed would have made the
532
+ * feature the reason the repository was red, and the gap it names is a link
533
+ * that is missing, never a figure that is wrong.
534
+ *
535
+ * The shape of an entry — the fields, the key, the https URL, the closed
536
+ * vocabulary of `kind` — is the published schema's job and is checked there,
537
+ * so an editor validating against `pack.1.json` refuses the same things.
538
+ */
539
+ function sourceRegister(manifest, charts, taxes, report, statements, documents, assets) {
540
+ const issues = [];
541
+ const warnings = [];
542
+ const status = manifest.certification?.status ?? 'community';
543
+ // A chart may say how much it in particular has been read, and name the
544
+ // texts that reading went through. Those texts are in the same register: a
545
+ // key is unique in a pack, not in a section of one.
546
+ const declared = [
547
+ ...(manifest.certification?.sources ?? []).map((entry) => ({ where: 'certification.sources', entry })),
548
+ ...charts.flatMap((chart) => (chart.certification?.sources ?? []).map((entry) => ({
549
+ where: `charts.${chart.code}.certification.sources`,
550
+ entry,
551
+ }))),
552
+ ];
553
+ const sources = [];
554
+ const byKey = new Map();
555
+ for (const { where, entry } of declared) {
556
+ if (typeof entry === 'string') {
557
+ warnings.push(`pack.json ${where}: "${entry}" is a title with nowhere to read it. ` +
558
+ 'The register takes an object — key, title, publisher, url, consulted_on, kind — ' +
559
+ 'and the bare string is deprecated; see docs/packs.md, "The register of sources".');
560
+ continue;
561
+ }
562
+ if (byKey.has(entry.key)) {
563
+ issues.push({
564
+ path: `pack.json ${where}`,
565
+ message: `two sources claim the key ${entry.key}; a reference would resolve to whichever came first`,
566
+ });
567
+ continue;
568
+ }
569
+ byKey.set(entry.key, entry);
570
+ sources.push(entry);
571
+ }
572
+ // The list an exemption reason code of this country comes from, where the
573
+ // VATEX list of EN 16931 does not reach it. One entry says so of itself:
574
+ // reading the first `standard` instead made every pack that cites an
575
+ // accounting standard declare a list of reason codes without knowing it.
576
+ const reasonLists = sources.filter((source) => source.reason_codes === true);
577
+ if (reasonLists.length > 1) {
578
+ issues.push({
579
+ path: 'pack.json certification.sources',
580
+ message: `${reasonLists.map((source) => source.key).join(' and ')} each claim to publish this ` +
581
+ "country's exemption reason codes; BT-121 comes from one list, and a pack that names two " +
582
+ 'has not said which',
583
+ });
584
+ }
585
+ for (const entry of reasonLists) {
586
+ if (entry.kind !== 'standard') {
587
+ issues.push({
588
+ path: 'pack.json certification.sources',
589
+ message: `${entry.key} carries reason_codes and its kind is ${entry.kind}; a published list of ` +
590
+ 'codes is a standard, which is what that kind is for',
591
+ });
592
+ }
593
+ }
594
+ if (status !== 'community' && sources.length === 0) {
595
+ issues.push({
596
+ path: 'pack.json certification.sources',
597
+ message: `a ${status} pack carries a register of sources: a key, a title, the publisher and an ` +
598
+ 'absolute https link per text. Nobody can maintain or review what they cannot open.',
599
+ });
600
+ }
601
+ // Every place the format lets a legal reference name where it is read.
602
+ const references = [
603
+ // Only a rule that is declared and cites an article: a country that says
604
+ // nothing owes no source, and a rule that cites nothing has nowhere for a
605
+ // source to point — the check below is the one that catches that.
606
+ ...documentRules(documents)
607
+ .filter((rule) => rule.declared && rule.reference.legal_reference !== null)
608
+ .map((rule) => ({
609
+ path: rule.path,
610
+ source: rule.reference.source,
611
+ // A rule of a country is held to what a tax and a box are held to: a
612
+ // reviewed pack says which text it read, a maintained one is told it
613
+ // did not. What an invoice must carry is as reviewable as a rate.
614
+ kind: 'rule',
615
+ })),
616
+ ...charts.map((chart) => ({ path: `pack.json charts.${chart.code}`, source: chart.source, kind: 'other' })),
617
+ ...taxes.map((tax) => ({ path: `taxes.json ${tax.code}`, source: tax.source, kind: 'tax' })),
618
+ ...(report === null ? [] : [{ path: `tax_report.json ${report.code}`, source: report.source, kind: 'other' }]),
619
+ ...(report?.boxes ?? []).map((box) => ({
620
+ path: `tax_report.json ${box.box}:${box.kind}`,
621
+ source: box.source,
622
+ kind: 'box',
623
+ })),
624
+ ...statements.flatMap((statement) => [
625
+ { path: `statements.json ${statement.code}`, source: statement.source, kind: 'other' },
626
+ ...statement.lines.map((line) => ({
627
+ path: `statements.json ${statement.code}.${line.code}`,
628
+ source: line.source,
629
+ kind: 'other',
630
+ })),
631
+ ]),
632
+ ...documents.mentions.map((mention) => ({
633
+ path: `pack.json documents.mentions.${mention.code}`,
634
+ source: mention.source,
635
+ kind: 'other',
636
+ })),
637
+ ...(assets === null ? [] : [{ path: 'assets.json', source: assets.source, kind: 'other' }]),
638
+ ...(assets?.categories ?? []).map((category) => ({
639
+ path: `assets.json ${category.code}`,
640
+ source: category.source,
641
+ kind: 'other',
642
+ })),
643
+ ];
644
+ for (const reference of references) {
645
+ if (reference.source === null)
646
+ continue;
647
+ if (byKey.has(reference.source))
648
+ continue;
649
+ issues.push({
650
+ path: reference.path,
651
+ message: `names the source ${reference.source}, which this pack's register does not carry. ` +
652
+ (sources.length === 0
653
+ ? 'The register is empty.'
654
+ : `It holds: ${sources.map((s) => s.key).join(', ')}.`),
655
+ });
656
+ }
657
+ // A rule that cites no article at all. One step before the check above: that
658
+ // one asks where a reference is read, this one asks whether there is a
659
+ // reference. A word — `gapless_per_year`, `30`, `invoice_date` — looks the
660
+ // same whether somebody read a decree or guessed, which is exactly why the
661
+ // citation has to be written down.
662
+ const uncited = documentRules(documents).filter((rule) => rule.declared && rule.reference.legal_reference === null);
663
+ if (status === 'reviewed') {
664
+ for (const rule of uncited) {
665
+ issues.push({
666
+ path: rule.path,
667
+ message: `${rule.what} and cites no article; a reviewed pack says which text imposes it: ` +
668
+ `add "legal_reference" under ${rule.under}`,
669
+ });
670
+ }
671
+ }
672
+ else if (uncited.length > 0) {
673
+ warnings.push(`${uncited.length} document rule(s) declare a country's law and cite no article: ` +
674
+ `${uncited.map((rule) => rule.path).join(', ')}. ` +
675
+ 'A reviewed pack is refused for this; any other is told.');
676
+ }
677
+ // A reviewer read something before they put their name on a rate or a grid.
678
+ // Saying which text is the difference between a review and a signature.
679
+ const unsourced = references.filter((reference) => reference.source === null &&
680
+ (reference.kind === 'tax' || reference.kind === 'box' || reference.kind === 'rule'));
681
+ if (status === 'reviewed') {
682
+ for (const reference of unsourced) {
683
+ issues.push({
684
+ path: reference.path,
685
+ message: 'a reviewed pack says which text its legal reference is in: add "source": "<key>"',
686
+ });
687
+ }
688
+ }
689
+ else if (status === 'maintained' && unsourced.length > 0) {
690
+ warnings.push(`${unsourced.length} tax(es), box(es) and document rule(s) carry a legal reference and name no source: ` +
691
+ `${unsourced
692
+ .slice(0, 3)
693
+ .map((reference) => reference.path)
694
+ .join(', ')}${unsourced.length > 3 ? ', …' : ''}. ` +
695
+ 'A reviewed pack is refused for this; a maintained one is told.');
696
+ }
697
+ return { sources, issues, warnings };
698
+ }
699
+ /**
700
+ * The four rules of a country that are a word rather than a row, each with the
701
+ * citation the pack wrote beside it and whether the pack declared the rule at
702
+ * all.
703
+ *
704
+ * `declared` is the whole difficulty. A country that says nothing about the
705
+ * numbering of its invoices owes nobody an article, and a pack that leaves the
706
+ * section out is not an incomplete pack — it is a pack about a country whose
707
+ * law has not been read yet, which `country_defaults` holds as null and a
708
+ * reader raises on by name. So the demand for a citation attaches to the rule
709
+ * being *declared*, never to the section existing.
710
+ */
711
+ function documentRules(documents) {
712
+ return [
713
+ {
714
+ path: 'pack.json documents.numbering',
715
+ what: 'says how an invoice of this country is numbered',
716
+ under: 'documents.references.numbering',
717
+ declared: documents.numbering_gapless !== null || documents.number_format !== null,
718
+ reference: documents.numbering_reference,
719
+ },
720
+ {
721
+ path: 'pack.json documents.legal_payment_days',
722
+ what: 'sets the payment term the law imposes in the absence of an agreement',
723
+ under: 'documents.references.payment_terms',
724
+ declared: documents.legal_payment_days !== null,
725
+ reference: documents.payment_terms_reference,
726
+ },
727
+ {
728
+ path: 'pack.json documents.tax_point',
729
+ what: 'fixes when the tax becomes chargeable',
730
+ under: 'documents.references.tax_point',
731
+ declared: documents.tax_point_rule !== null,
732
+ reference: documents.tax_point_reference,
733
+ },
734
+ {
735
+ path: 'pack.json einvoicing.profile',
736
+ what: 'names the structured invoice this country expects',
737
+ under: 'einvoicing',
738
+ declared: documents.einvoice_profile !== null,
739
+ reference: documents.einvoice_reference,
740
+ },
741
+ ];
742
+ }
743
+ function normaliseAssets(raw) {
744
+ const depreciation = (raw['depreciation'] ?? {});
745
+ const disposal = (raw['disposal'] ?? null);
746
+ const categories = (raw['categories'] ?? []);
747
+ return {
748
+ prorata_straight_line: String(depreciation['prorata_straight_line'] ?? ''),
749
+ prorata_declining: String(depreciation['prorata_declining'] ?? ''),
750
+ // `actual` is the calendar and not a country's answer: a pack that says
751
+ // nothing counts the days that exist. A commercial year of twelve
752
+ // thirty-day months is a convention, so it is declared.
753
+ day_count: String(depreciation['day_count'] ?? 'actual'),
754
+ declining_cap_percent: depreciation['declining_cap_percent'] === undefined || depreciation['declining_cap_percent'] === null
755
+ ? null
756
+ : Number(depreciation['declining_cap_percent']),
757
+ declining_switch_to_linear: depreciation['declining_switch_to_linear'] !== false,
758
+ disposal_style: disposal === null ? null : String(disposal['style']),
759
+ legal_reference: depreciation['legal_reference'] ??
760
+ disposal?.['legal_reference'] ??
761
+ null,
762
+ source: depreciation['source'] ??
763
+ disposal?.['source'] ??
764
+ null,
765
+ categories: categories.map((category, index) => ({
766
+ code: String(category['code']),
767
+ name: String(category['name']),
768
+ name_i18n: (category['name_i18n'] ?? {}),
769
+ method: String(category['method']),
770
+ duration_months: Number(category['duration_months']),
771
+ coefficient: category['coefficient'] === undefined || category['coefficient'] === null
772
+ ? null
773
+ : Number(category['coefficient']),
774
+ prorata: category['prorata'] ?? null,
775
+ account_type: category['account_type'] ?? null,
776
+ sequence: Number(category['sequence'] ?? (index + 1) * 10),
777
+ legal_reference: category['legal_reference'] ?? null,
778
+ source: category['source'] ?? null,
779
+ })),
780
+ };
781
+ }
782
+ /**
783
+ * What an `assets.json` obliges the rest of the pack to say.
784
+ *
785
+ * The same shape as `closingRules`, and for the same reason: a disposal style
786
+ * is a promise about which accounts exist, and a pack that makes it without
787
+ * naming them is a company finding out on the day it sells a van. The role
788
+ * codes themselves are checked against every chart by `crossReferences`, so
789
+ * what is left here is which roles a style needs.
790
+ */
791
+ function assetReferences(assets, manifest) {
792
+ const issues = [];
793
+ const roles = manifest.defaults.roles;
794
+ const named = (role) => roles[role] !== undefined && roles[role] !== null;
795
+ if (assets.disposal_style === 'net_result' && !named('asset_disposal_gain')) {
796
+ issues.push({
797
+ path: 'defaults.roles.asset_disposal_gain',
798
+ message: 'a pack that disposes on the net result has to name the account the gain lands on',
799
+ });
800
+ }
801
+ if (assets.disposal_style === 'gross') {
802
+ for (const role of ['asset_disposal_proceeds', 'asset_disposal_value']) {
803
+ if (!named(role)) {
804
+ issues.push({
805
+ path: `defaults.roles.${role}`,
806
+ message: 'a pack that disposes gross has to name it: the value sold and the proceeds are two lines',
807
+ });
808
+ }
809
+ }
810
+ }
811
+ const seen = new Set();
812
+ for (const category of assets.categories) {
813
+ if (seen.has(category.code)) {
814
+ issues.push({ path: `assets.json ${category.code}`, message: 'duplicate category code' });
815
+ }
816
+ seen.add(category.code);
817
+ if (category.method === 'declining_balance' && category.coefficient === null) {
818
+ issues.push({
819
+ path: `assets.json ${category.code}`,
820
+ message: 'a declining balance with no coefficient is a straight line nobody asked for',
821
+ });
822
+ }
823
+ if (category.method !== 'declining_balance' && category.coefficient !== null) {
824
+ issues.push({
825
+ path: `assets.json ${category.code}`,
826
+ message: `a ${category.method} category takes no coefficient`,
827
+ });
828
+ }
829
+ if (category.legal_reference === null) {
830
+ issues.push({
831
+ path: `assets.json ${category.code}`,
832
+ message: 'a usual duration comes from somewhere; name the source',
833
+ });
834
+ }
835
+ }
836
+ return issues;
837
+ }
838
+ /**
839
+ * The framework pack: statements by account type, no country, no chart.
840
+ *
841
+ * It is read and compiled beside the country packs because it is the same
842
+ * kind of thing — declarative lines an accountant can read — and because the
843
+ * fallback that gives any chart a readable balance sheet should not be the one
844
+ * object of the system that lives in a migration.
845
+ */
846
+ export async function readFrameworkPack(slug = GENERIC_PACK, dir = packsDir()) {
847
+ const root = join(dir, slug);
848
+ const schema = await readSchema(dir);
849
+ const defs = (schema['$defs'] ?? {});
850
+ const issues = [];
851
+ const manifest = (await readJson(join(root, 'pack.json')));
852
+ issues.push(...validate(manifest, defs['framework'] ?? {}, schema));
853
+ const raw = await readJson(join(root, 'statements.json'));
854
+ issues.push(...validate(raw, defs['statements'] ?? {}, schema, 'statements.json'));
855
+ const statements = normaliseStatements(raw, []);
856
+ for (const statement of statements) {
857
+ for (const line of statement.lines) {
858
+ for (const rule of line.rules) {
859
+ if (rule.kind !== 'account_type') {
860
+ issues.push({
861
+ path: `statements.json ${statement.code}.${line.code}`,
862
+ message: `a ${rule.kind} rule names a chart, and this framework has none. ` +
863
+ 'The generic statements are what the eighteen account types buy: account_type rules only.',
864
+ });
865
+ }
866
+ }
867
+ }
868
+ }
869
+ issues.push(...statementReferences(statements, []));
870
+ // A framework has no chart, no tax and no journal, so no company can be
871
+ // installed on it and no scenario replayed through it. That is a reason and
872
+ // it is written down: the rule is that a pack without a golden says why.
873
+ if ((manifest.golden?.exempt ?? '') === '') {
874
+ issues.push({
875
+ path: `packs/${slug}`,
876
+ message: 'carries no golden scenario and gives no reason. Add "golden": { "exempt": "…" } to pack.json.',
877
+ });
878
+ }
879
+ if (issues.length > 0) {
880
+ const shown = issues.slice(0, 20).map((i) => ` ${i.path}: ${i.message}`);
881
+ const more = issues.length > shown.length ? `\n … and ${issues.length - shown.length} more` : '';
882
+ throw new PackError(`pack_invalid: packs/${slug} — ${issues.length} problem(s)\n${shown.join('\n')}${more}`);
883
+ }
884
+ return {
885
+ slug,
886
+ dir: root,
887
+ manifest,
888
+ statements,
889
+ goldenExemption: manifest.golden?.exempt ?? null,
890
+ checksum: await checksum(root),
891
+ };
892
+ }
893
+ function codesOfCharts(charts) {
894
+ const codes = new Set();
895
+ for (const chart of charts)
896
+ for (const account of chart.accounts)
897
+ codes.add(account.code);
898
+ return codes;
899
+ }
900
+ /**
901
+ * What a declared language owes the reader.
902
+ *
903
+ * `languages` in the manifest is a promise: somebody who sets their books to
904
+ * Dutch sees Dutch everywhere, not a chart of accounts in Dutch and a
905
+ * declaration form in French. So a declared language must have its file, and
906
+ * that file must carry every label the pack shows a user — the charts, the
907
+ * accounts of every chart, the journals, the taxes, the boxes of the
908
+ * declaration, the lines of every statement, the legal mentions of an
909
+ * invoice, and the fixed-asset categories where the pack has them.
910
+ *
911
+ * A language that is only a file under `i18n/` and is not declared is not
912
+ * held to this: it may be partial, and a key it does not carry falls back to
913
+ * the pack's own label. That is the way to contribute a language one section
914
+ * at a time without promising a reader something the pack cannot keep.
915
+ */
916
+ function languageCoverage(manifest, found, labels, charts, taxes, statements, report, documents, assets) {
917
+ const issues = [];
918
+ const own = manifest.defaults.language;
919
+ const sections = [
920
+ ['charts', charts.map((c) => c.code)],
921
+ ['accounts', [...codesOfCharts(charts)].sort()],
922
+ ['journals', manifest.journals.map((j) => j.code)],
923
+ ['taxes', taxes.map((t) => t.code)],
924
+ ['tax_report_boxes', (report?.boxes ?? []).map((b) => `${b.box}|${b.kind}`)],
925
+ [
926
+ 'statement_lines',
927
+ statements.flatMap((st) => st.lines.map((line) => `${st.code}:${line.code}`)),
928
+ ],
929
+ ['legal_mentions', documents.mentions.map((m) => m.code)],
930
+ ['asset_categories', (assets?.categories ?? []).map((c) => c.code)],
931
+ ];
932
+ for (const language of manifest.languages ?? []) {
933
+ if (language === own) {
934
+ issues.push({
935
+ path: 'pack.json languages',
936
+ message: `${language} is the language the pack itself is written in (defaults.language); do not list it again`,
937
+ });
938
+ continue;
939
+ }
940
+ if (!found.includes(language)) {
941
+ issues.push({
942
+ path: 'pack.json languages',
943
+ message: `${language} is declared and packs/${manifest.country.toLowerCase()}/i18n/${language}.json does not exist`,
944
+ });
945
+ continue;
946
+ }
947
+ if (labels.pack_name[language] === undefined) {
948
+ issues.push({ path: `i18n/${language}.json`, message: 'pack_name is missing' });
949
+ }
950
+ for (const [name, keys] of sections) {
951
+ const held = labels[name];
952
+ const missing = keys.filter((key) => held[key]?.[language] === undefined);
953
+ if (missing.length === 0)
954
+ continue;
955
+ const shown = missing.slice(0, 8).join(', ');
956
+ const more = missing.length > 8 ? `, and ${missing.length - 8} more` : '';
957
+ issues.push({
958
+ path: `i18n/${language}.json ${name}`,
959
+ message: `${missing.length} of ${keys.length} missing: ${shown}${more}`,
960
+ });
961
+ }
962
+ }
963
+ return issues;
964
+ }
965
+ /**
966
+ * sha256 of the whole pack: every file, by relative path, path and bytes both.
967
+ * It lands in `country_packs.checksum`, so an instance can be compared to a
968
+ * pack without shipping the pack.
969
+ */
970
+ /**
971
+ * A fingerprint of the pack as somebody wrote it.
972
+ *
973
+ * `golden/scenario.json` is in it — a scenario is a decision about what a
974
+ * country's books look like, and moving it moves the pack. The expectation
975
+ * files beside it are not: they are what the engine made of that scenario,
976
+ * regenerated by `UPDATE_GOLDEN=1`, and a build artefact does not belong in
977
+ * the fingerprint of its own source. A hash that moved because the statements
978
+ * function gained a line would tell every operator that Belgium had changed.
979
+ */
980
+ const GOLDEN_EXPECTATIONS = /^golden\/(?!scenario\.json$)/;
981
+ async function checksum(dir) {
982
+ const hash = createHash('sha256');
983
+ for (const file of await filesUnder(dir)) {
984
+ if (GOLDEN_EXPECTATIONS.test(file))
985
+ continue;
986
+ hash.update(file);
987
+ hash.update('\0');
988
+ hash.update(await readFile(join(dir, file)));
989
+ hash.update('\0');
990
+ }
991
+ return hash.digest('hex');
992
+ }
993
+ async function filesUnder(dir, prefix = '') {
994
+ const entries = await readdir(join(dir, prefix), { withFileTypes: true });
995
+ const out = [];
996
+ for (const entry of entries.sort((a, b) => (a.name < b.name ? -1 : 1))) {
997
+ const relative = prefix === '' ? entry.name : `${prefix}/${entry.name}`;
998
+ if (entry.isDirectory())
999
+ out.push(...(await filesUnder(dir, relative)));
1000
+ else
1001
+ out.push(relative);
1002
+ }
1003
+ return out.sort();
1004
+ }
1005
+ /**
1006
+ * The boxes a posting names, from either shape of the field: one string, a
1007
+ * list of them, or nothing. The order is the pack's own, because the first is
1008
+ * the box the posting is known by and a pack that reorders its list is saying
1009
+ * something.
1010
+ */
1011
+ function postingBoxes(raw) {
1012
+ if (Array.isArray(raw))
1013
+ return raw.map((b) => String(b));
1014
+ if (typeof raw === 'string')
1015
+ return [raw];
1016
+ return [];
1017
+ }
1018
+ /**
1019
+ * One key of a tax's `applies_when`, or null where the tax names none.
1020
+ *
1021
+ * The schema has already refused a key that is not one of the three and a
1022
+ * value that is not a territory code; this only has to say which of the three
1023
+ * is being asked for.
1024
+ */
1025
+ function appliesWhen(raw, key) {
1026
+ const when = raw['applies_when'];
1027
+ if (typeof when !== 'object' || when === null)
1028
+ return null;
1029
+ const value = when[key];
1030
+ return typeof value === 'string' ? value : null;
1031
+ }
1032
+ function normaliseTax(raw, index) {
1033
+ const postings = (raw['postings'] ?? {});
1034
+ const kind = (name) => (postings[name] ?? []).map((p, position) => ({
1035
+ type: p['type'],
1036
+ factor: typeof p['factor'] === 'number' ? p['factor'] : 100,
1037
+ account: p['account'] ?? null,
1038
+ box: postingBoxes(p['box'])[0] ?? null,
1039
+ boxes: postingBoxes(p['box']),
1040
+ box_factor: typeof p['box_factor'] === 'number' ? p['box_factor'] : 100,
1041
+ report: p['report'] ?? null,
1042
+ sequence: typeof p['sequence'] === 'number' ? p['sequence'] : (position + 1) * 10,
1043
+ }));
1044
+ return {
1045
+ code: String(raw['code']),
1046
+ name: String(raw['name']),
1047
+ description: raw['description'] ?? null,
1048
+ kind: raw['kind'] ?? 'vat',
1049
+ amount_type: raw['amount_type'] ?? 'percent',
1050
+ rate: Number(raw['rate']),
1051
+ scope: String(raw['scope']),
1052
+ treatment: String(raw['treatment']),
1053
+ valid_from: String(raw['valid_from']),
1054
+ valid_to: raw['valid_to'] ?? null,
1055
+ legal_reference: raw['legal_reference'] ?? null,
1056
+ source: raw['source'] ?? null,
1057
+ vat_category: raw['vat_category'] ?? null,
1058
+ exemption_code: raw['exemption_code'] ?? null,
1059
+ conditions: Array.isArray(raw['conditions']) ? raw['conditions'].map(String) : [],
1060
+ recoverable: typeof raw['recoverable'] === 'boolean' ? raw['recoverable'] : true,
1061
+ price_include: typeof raw['price_include'] === 'boolean' ? raw['price_include'] : false,
1062
+ jurisdiction: raw['jurisdiction'] ?? null,
1063
+ applies_seller_territory: appliesWhen(raw, 'seller_in'),
1064
+ applies_buyer_territory: appliesWhen(raw, 'buyer_in'),
1065
+ applies_supply_territory: appliesWhen(raw, 'supply_in'),
1066
+ cash_basis: typeof raw['cash_basis'] === 'boolean' ? raw['cash_basis'] : false,
1067
+ cash_basis_transition_account: raw['cash_basis_transition_account'] ?? null,
1068
+ sequence: typeof raw['sequence'] === 'number' ? raw['sequence'] : (index + 1) * 10,
1069
+ postings: { invoice: kind('invoice'), credit_note: kind('credit_note') },
1070
+ ...(Array.isArray(raw['group']) ? { group: raw['group'] } : {}),
1071
+ };
1072
+ }
1073
+ /**
1074
+ * The cadences a form declares, from either shape of the field.
1075
+ *
1076
+ * An empty list is what a pack that says nothing gets — not a cadence guessed
1077
+ * for it. `month_or_quarter` used to be the column's default, so every country
1078
+ * that had not spoken filed Belgium's and France's return without anybody
1079
+ * deciding that. `ekwo pack check` names the omission instead.
1080
+ */
1081
+ const PERIOD_ORDER = ['month', 'quarter', 'year'];
1082
+ function normalisePeriods(raw) {
1083
+ const listed = raw === undefined || raw === null
1084
+ ? []
1085
+ : Array.isArray(raw)
1086
+ ? raw.map(String)
1087
+ : String(raw) === 'month_or_quarter'
1088
+ ? ['month', 'quarter']
1089
+ : [String(raw)];
1090
+ const unique = [...new Set(listed)];
1091
+ return unique.sort((a, b) => (PERIOD_ORDER.indexOf(a) === -1 ? PERIOD_ORDER.length : PERIOD_ORDER.indexOf(a)) -
1092
+ (PERIOD_ORDER.indexOf(b) === -1 ? PERIOD_ORDER.length : PERIOD_ORDER.indexOf(b)));
1093
+ }
1094
+ function normaliseReport(raw) {
1095
+ const boxes = (raw['boxes'] ?? []).map((box, index) => ({
1096
+ box: String(box['box']),
1097
+ kind: box['kind'],
1098
+ name: String(box['name']),
1099
+ sequence: typeof box['sequence'] === 'number' ? box['sequence'] : (index + 1) * 10,
1100
+ print_sequence: typeof box['print_sequence'] === 'number' ? box['print_sequence'] : null,
1101
+ plus: box['plus'] ?? [],
1102
+ minus: box['minus'] ?? [],
1103
+ rate: typeof box['rate'] === 'number' ? box['rate'] : null,
1104
+ rate_of: box['rate_of'] ?? null,
1105
+ floor_zero: box['floor_zero'] === true,
1106
+ hidden: box['hidden'] === true,
1107
+ xml_element: box['xml_element'] ?? null,
1108
+ legal_reference: box['legal_reference'] ?? null,
1109
+ source: box['source'] ?? null,
1110
+ }));
1111
+ return {
1112
+ code: String(raw['code']),
1113
+ name: String(raw['name'] ?? raw['code']),
1114
+ periods: normalisePeriods(raw['period']),
1115
+ period_default: raw['period_default'] ?? null,
1116
+ valid_from: String(raw['valid_from'] ?? '1970-01-01'),
1117
+ valid_to: raw['valid_to'] ?? null,
1118
+ legal_reference: raw['legal_reference'] ?? null,
1119
+ source: raw['source'] ?? null,
1120
+ deadline: normaliseDeadline(raw['deadline']),
1121
+ file_format: raw['file_format'] ?? null,
1122
+ boxes,
1123
+ };
1124
+ }
1125
+ function normaliseDeadline(raw) {
1126
+ if (raw === null || typeof raw !== 'object')
1127
+ return null;
1128
+ const d = raw;
1129
+ return {
1130
+ rule: d['rule'],
1131
+ day: d['day'] ?? null,
1132
+ plus_days: d['plus_days'] ?? null,
1133
+ legal_reference: String(d['legal_reference'] ?? ''),
1134
+ source: d['source'] ?? null,
1135
+ };
1136
+ }
1137
+ function normaliseGolden(raw) {
1138
+ const year = (raw['fiscal_year'] ?? {});
1139
+ return {
1140
+ name: String(raw['name'] ?? ''),
1141
+ chart: raw['chart'] ?? null,
1142
+ territory: raw['territory'] ?? null,
1143
+ language: raw['language'] ?? null,
1144
+ fiscalYear: {
1145
+ name: String(year['name'] ?? ''),
1146
+ start: String(year['start'] ?? ''),
1147
+ end: String(year['end'] ?? ''),
1148
+ },
1149
+ periods: (raw['periods'] ?? []).map((p) => ({
1150
+ code: String(p['code']),
1151
+ from: String(p['from']),
1152
+ to: String(p['to']),
1153
+ })),
1154
+ statements: raw['statements'] ?? [],
1155
+ contacts: (raw['contacts'] ?? []).map((c) => ({
1156
+ ref: String(c['ref']),
1157
+ name: String(c['name']),
1158
+ type: c['type'],
1159
+ country: String(c['country']),
1160
+ territory: c['territory'] ?? null,
1161
+ vat_number: c['vat_number'] ?? null,
1162
+ auxiliary_code: c['auxiliary_code'] ?? null,
1163
+ })),
1164
+ documents: (raw['documents'] ?? []).map((d) => ({
1165
+ ref: String(d['ref']),
1166
+ type: d['type'],
1167
+ contact: String(d['contact']),
1168
+ date: String(d['date']),
1169
+ due_date: d['due_date'] ?? null,
1170
+ supply_territory: d['supply_territory'] ?? null,
1171
+ why: String(d['why'] ?? ''),
1172
+ lines: (d['lines'] ?? []).map((l) => ({
1173
+ name: String(l['name']),
1174
+ quantity: typeof l['quantity'] === 'number' ? l['quantity'] : 1,
1175
+ unit_price: Number(l['unit_price']),
1176
+ discount_percent: typeof l['discount_percent'] === 'number' ? l['discount_percent'] : 0,
1177
+ tax: l['tax'] ?? null,
1178
+ account: String(l['account']),
1179
+ })),
1180
+ })),
1181
+ payments: (raw['payments'] ?? []).map((p) => ({
1182
+ ref: String(p['ref']),
1183
+ direction: p['direction'],
1184
+ date: String(p['date']),
1185
+ amount: Number(p['amount']),
1186
+ contact: String(p['contact']),
1187
+ journal: String(p['journal']),
1188
+ match: p['match'] ?? null,
1189
+ why: String(p['why'] ?? ''),
1190
+ })),
1191
+ };
1192
+ }
1193
+ /**
1194
+ * What the scenario names has to exist, and when it happened has to be inside
1195
+ * the year it is filed for.
1196
+ *
1197
+ * A golden whose references are loose fails later, in a test, with a message
1198
+ * from Postgres about a null account. Here it fails with the name of the tax
1199
+ * nobody declared, which is the same defect found a minute earlier by the
1200
+ * person who can still fix it.
1201
+ */
1202
+ function goldenReferences(golden, charts, manifest, taxes, statements, accounts) {
1203
+ const issues = [];
1204
+ const where = 'golden/scenario.json';
1205
+ const chart = golden.chart === null
1206
+ ? charts.find((c) => c.is_default)
1207
+ : charts.find((c) => c.code === golden.chart);
1208
+ if (chart === undefined) {
1209
+ issues.push({
1210
+ path: `${where} chart`,
1211
+ message: `${String(golden.chart)} is not a chart of this pack (${charts.map((c) => c.code).join(', ')})`,
1212
+ });
1213
+ }
1214
+ const codes = new Set((chart?.accounts ?? accounts).map((a) => a.code));
1215
+ const taxCodes = new Map(taxes.map((t) => [t.code, t]));
1216
+ const journals = new Set(manifest.journals.map((j) => j.code));
1217
+ const { start, end } = golden.fiscalYear;
1218
+ if (start >= end) {
1219
+ issues.push({ path: `${where} fiscal_year`, message: `${start} is not before ${end}` });
1220
+ }
1221
+ const inYear = (path, date) => {
1222
+ if (date < start || date > end) {
1223
+ issues.push({ path, message: `${date} falls outside the financial year ${start}..${end}` });
1224
+ }
1225
+ };
1226
+ for (const period of golden.periods) {
1227
+ if (period.from > period.to) {
1228
+ issues.push({ path: `${where} periods.${period.code}`, message: `${period.from} is after ${period.to}` });
1229
+ }
1230
+ inYear(`${where} periods.${period.code}.from`, period.from);
1231
+ inYear(`${where} periods.${period.code}.to`, period.to);
1232
+ }
1233
+ const known = new Set(statements.map((st) => st.code));
1234
+ for (const code of golden.statements) {
1235
+ if (!known.has(code)) {
1236
+ issues.push({ path: `${where} statements`, message: `${code} is not a statement of this pack` });
1237
+ }
1238
+ }
1239
+ const contacts = new Set();
1240
+ for (const contact of golden.contacts) {
1241
+ if (contacts.has(contact.ref)) {
1242
+ issues.push({ path: `${where} contacts.${contact.ref}`, message: 'duplicate ref' });
1243
+ }
1244
+ contacts.add(contact.ref);
1245
+ }
1246
+ const documents = new Map();
1247
+ for (const document of golden.documents) {
1248
+ const at = `${where} documents.${document.ref}`;
1249
+ if (documents.has(document.ref))
1250
+ issues.push({ path: at, message: 'duplicate ref' });
1251
+ documents.set(document.ref, document);
1252
+ if (!contacts.has(document.contact)) {
1253
+ issues.push({ path: at, message: `contact ${document.contact} is not declared by this scenario` });
1254
+ }
1255
+ inYear(`${at}.date`, document.date);
1256
+ const sale = document.type.startsWith('sale');
1257
+ for (const [index, line] of document.lines.entries()) {
1258
+ if (!codes.has(line.account)) {
1259
+ issues.push({
1260
+ path: `${at}.lines[${index}]`,
1261
+ message: `account ${line.account} is not in chart ${chart?.code ?? '?'}`,
1262
+ });
1263
+ }
1264
+ if (line.tax === null)
1265
+ continue;
1266
+ const tax = taxCodes.get(line.tax);
1267
+ if (tax === undefined) {
1268
+ issues.push({ path: `${at}.lines[${index}]`, message: `tax ${line.tax} is not a tax of this pack` });
1269
+ continue;
1270
+ }
1271
+ // A purchase tax on a sale posts nothing and reports nothing: the
1272
+ // scenario would run and its return would be quietly short.
1273
+ if (tax.scope !== 'both' && tax.scope !== (sale ? 'sale' : 'purchase')) {
1274
+ issues.push({
1275
+ path: `${at}.lines[${index}]`,
1276
+ message: `tax ${line.tax} is scoped ${tax.scope} and this document is a ${sale ? 'sale' : 'purchase'}`,
1277
+ });
1278
+ }
1279
+ if (document.date < tax.valid_from || (tax.valid_to !== null && document.date > tax.valid_to)) {
1280
+ issues.push({
1281
+ path: `${at}.lines[${index}]`,
1282
+ message: `tax ${line.tax} is not in force on ${document.date}`,
1283
+ });
1284
+ }
1285
+ }
1286
+ }
1287
+ const seenPayments = new Set();
1288
+ for (const payment of golden.payments) {
1289
+ const at = `${where} payments.${payment.ref}`;
1290
+ if (seenPayments.has(payment.ref))
1291
+ issues.push({ path: at, message: 'duplicate ref' });
1292
+ seenPayments.add(payment.ref);
1293
+ if (!contacts.has(payment.contact)) {
1294
+ issues.push({ path: at, message: `contact ${payment.contact} is not declared by this scenario` });
1295
+ }
1296
+ if (!journals.has(payment.journal)) {
1297
+ issues.push({ path: at, message: `journal ${payment.journal} is not a journal of this pack` });
1298
+ }
1299
+ inYear(`${at}.date`, payment.date);
1300
+ if (payment.match === null)
1301
+ continue;
1302
+ const settled = documents.get(payment.match);
1303
+ if (settled === undefined) {
1304
+ issues.push({ path: at, message: `matches ${payment.match}, which is not a document of this scenario` });
1305
+ continue;
1306
+ }
1307
+ // A matching is between two sides of the same third-party account, so a
1308
+ // customer receipt cannot settle a supplier bill however the amounts add up.
1309
+ const expected = settled.type.startsWith('sale') ? 'inbound' : 'outbound';
1310
+ if (payment.direction !== expected) {
1311
+ issues.push({
1312
+ path: at,
1313
+ message: `is ${payment.direction} and settles ${settled.type} ${settled.ref}, which needs an ${expected} payment`,
1314
+ });
1315
+ }
1316
+ if (payment.date < settled.date) {
1317
+ issues.push({ path: at, message: `is dated before the document it settles (${settled.date})` });
1318
+ }
1319
+ }
1320
+ return issues;
1321
+ }
1322
+ /**
1323
+ * The tokens a document number may be built from, and the one that counts.
1324
+ *
1325
+ * `{CODE}` is the series, `{YYYY}` or `{YY}` the year, `{MM}` the month, and
1326
+ * a run of `N` is the counter, zero-padded to its own length. Nothing reads
1327
+ * the pattern yet — `next_entry_number()` produces `CODE/YYYY/NNNN` — so the
1328
+ * check here is that the pattern is *readable*: no token nobody defined, and
1329
+ * a counter somewhere, because a number without one is not a number.
1330
+ */
1331
+ const NUMBER_FORMAT_TOKEN = /\{([^{}]*)\}/g;
1332
+ const KNOWN_NUMBER_TOKEN = /^(CODE|YYYY|YY|MM|N+)$/;
1333
+ /** The two numbering styles that forbid a hole. The other two allow one. */
1334
+ const GAPLESS_NUMBERING = new Set(['gapless_per_year', 'gapless']);
1335
+ /**
1336
+ * One `{ legal_reference, source }` of the manifest, or the pair of nulls that
1337
+ * stands for a rule citing nothing. The shape is the schema's business — a
1338
+ * `legal_reference` that is present is a non-empty string there — so this only
1339
+ * has to survive the section being absent altogether.
1340
+ */
1341
+ function ruleReference(raw) {
1342
+ if (raw === null || typeof raw !== 'object')
1343
+ return { ...NO_REFERENCE };
1344
+ const entry = raw;
1345
+ return {
1346
+ legal_reference: entry['legal_reference'] ?? null,
1347
+ source: entry['source'] ?? null,
1348
+ };
1349
+ }
1350
+ /**
1351
+ * `documents`, `einvoicing` and `bank`, normalised into the row they compile
1352
+ * to. A section left out is not an error and not a default: every field comes
1353
+ * out null, and `country_defaults` holds null, and a reader that needs the
1354
+ * value raises rather than borrowing another country's answer.
1355
+ */
1356
+ function normaliseDocumentRules(manifest) {
1357
+ const documents = (manifest['documents'] ?? {});
1358
+ const einvoicing = (manifest['einvoicing'] ?? {});
1359
+ const bank = (manifest['bank'] ?? {});
1360
+ const numbering = documents['numbering'];
1361
+ const mentions = (documents['mentions'] ?? []).map((mention, index) => ({
1362
+ code: String(mention['code']),
1363
+ applies_when: String(mention['applies_when']),
1364
+ text: String(mention['text']),
1365
+ text_i18n: mention['text_i18n'] ?? {},
1366
+ sequence: typeof mention['sequence'] === 'number' ? mention['sequence'] : (index + 1) * 10,
1367
+ valid_from: String(mention['valid_from'] ?? '1970-01-01'),
1368
+ valid_to: mention['valid_to'] ?? null,
1369
+ legal_reference: mention['legal_reference'] ?? null,
1370
+ source: mention['source'] ?? null,
1371
+ }));
1372
+ const references = (documents['references'] ?? {});
1373
+ return {
1374
+ numbering_gapless: numbering === undefined ? null : GAPLESS_NUMBERING.has(numbering),
1375
+ number_format: documents['number_format'] ?? null,
1376
+ legal_payment_days: documents['legal_payment_days'] ?? null,
1377
+ late_payment_reference: documents['late_payment_reference'] ?? null,
1378
+ tax_point_rule: documents['tax_point'] ?? null,
1379
+ numbering_reference: ruleReference(references['numbering']),
1380
+ payment_terms_reference: ruleReference(references['payment_terms']),
1381
+ tax_point_reference: ruleReference(references['tax_point']),
1382
+ einvoice_profile: einvoicing['profile'] ?? null,
1383
+ einvoice_mandatory_from: einvoicing['mandatory_from'] ?? null,
1384
+ // `einvoicing` carries its citation flat, beside the profile, because the
1385
+ // section is one rule: there is nothing else in it to tell apart.
1386
+ einvoice_reference: ruleReference(einvoicing),
1387
+ party_scheme: einvoicing['party_scheme'] ?? null,
1388
+ vat_scheme: einvoicing['vat_scheme'] ?? null,
1389
+ bank_statement_formats: bank['statement_formats'] ?? [],
1390
+ payment_formats: bank['payment_formats'] ?? [],
1391
+ fiscal_year_default: manifest.defaults['fiscal_year_default'] ?? null,
1392
+ mentions,
1393
+ };
1394
+ }
1395
+ /**
1396
+ * What the schema cannot say about the document rules.
1397
+ *
1398
+ * The closed vocabularies — the condition of a mention, the tax point, the
1399
+ * bank formats, the four digits of an ISO 6523 scheme, the shape of a date —
1400
+ * are all in `packs/schema/pack.1.json`, so they are checked before this runs
1401
+ * and an editor sees them too. What is left is the handful of things one
1402
+ * field cannot know about another.
1403
+ */
1404
+ function documentReferences(rules) {
1405
+ const issues = [];
1406
+ const where = 'pack.json documents';
1407
+ const seen = new Set();
1408
+ for (const mention of rules.mentions) {
1409
+ if (seen.has(mention.code)) {
1410
+ issues.push({ path: `${where}.mentions.${mention.code}`, message: 'duplicate mention code' });
1411
+ }
1412
+ seen.add(mention.code);
1413
+ if (mention.valid_to !== null && mention.valid_to < mention.valid_from) {
1414
+ issues.push({
1415
+ path: `${where}.mentions.${mention.code}`,
1416
+ message: `valid_to ${mention.valid_to} is before valid_from ${mention.valid_from}`,
1417
+ });
1418
+ }
1419
+ if (mention.legal_reference === null) {
1420
+ issues.push({
1421
+ path: `${where}.mentions.${mention.code}`,
1422
+ message: 'names no legal_reference; a sentence the law requires cites the article that requires it',
1423
+ });
1424
+ }
1425
+ }
1426
+ const format = rules.number_format;
1427
+ if (format !== null) {
1428
+ let counters = 0;
1429
+ for (const [, token] of format.matchAll(NUMBER_FORMAT_TOKEN)) {
1430
+ const name = token ?? '';
1431
+ if (!KNOWN_NUMBER_TOKEN.test(name)) {
1432
+ issues.push({
1433
+ path: `${where}.number_format`,
1434
+ message: `{${name}} is not a token; write {CODE}, {YYYY}, {YY}, {MM} or a run of N for the counter`,
1435
+ });
1436
+ continue;
1437
+ }
1438
+ if (name.startsWith('N'))
1439
+ counters += 1;
1440
+ }
1441
+ if (counters !== 1) {
1442
+ issues.push({
1443
+ path: `${where}.number_format`,
1444
+ message: counters === 0
1445
+ ? 'carries no counter; write {NNNN} where the sequence goes'
1446
+ : `carries ${counters} counters, and a number is drawn from one`,
1447
+ });
1448
+ }
1449
+ }
1450
+ // A date for an obligation nobody named, or the other way round: both are a
1451
+ // half-declared pack, and both come out as a column that cannot be read.
1452
+ if (rules.einvoice_mandatory_from !== null && rules.einvoice_profile === null) {
1453
+ issues.push({
1454
+ path: 'pack.json einvoicing',
1455
+ message: 'mandatory_from names a day an obligation starts, and no profile says what becomes obligatory',
1456
+ });
1457
+ }
1458
+ return issues;
1459
+ }
1460
+ /**
1461
+ * Which kind of box a posting feeds. A form knows `base`, `tax` and `total`;
1462
+ * a posting knows `base`, `tax` and `tax_on_base`. The share of a tax that
1463
+ * nobody recovers reports on the **base** side — it is a cost sitting on a
1464
+ * base account, which is exactly why the Belgian grids 82 and 83 carry it
1465
+ * together with the base. `vat_return()` says the same thing the other way
1466
+ * round, deriving the kind of a ledger line from `tax_line`, which a
1467
+ * `tax_on_base` line does not set.
1468
+ */
1469
+ function declarationKind(type) {
1470
+ return type === 'tax' ? 'tax' : 'base';
1471
+ }
1472
+ /**
1473
+ * `statements.json`, normalised.
1474
+ *
1475
+ * A statement belongs to the chart that names it: listed by exactly one chart
1476
+ * it is that chart's, listed by several or by none it fits every chart of the
1477
+ * country. One list, in `pack.json`, decides both what a chart reports on and
1478
+ * what a statement applies to — there is no second place to keep in step.
1479
+ */
1480
+ function normaliseStatements(raw, charts) {
1481
+ const owners = (code) => charts.filter((c) => c.statements.includes(code)).map((c) => c.code);
1482
+ return (raw['statements'] ?? []).map((statement) => {
1483
+ const code = String(statement['code']);
1484
+ const named = owners(code);
1485
+ const lines = (statement['lines'] ?? []).map((line, index) => ({
1486
+ code: String(line['code']),
1487
+ parent: line['parent'] ?? null,
1488
+ name: String(line['name']),
1489
+ sequence: typeof line['sequence'] === 'number' ? line['sequence'] : (index + 1) * 10,
1490
+ sign: (line['sign'] === -1 ? -1 : 1),
1491
+ declares_sign: line['sign'] !== undefined,
1492
+ is_total: line['is_total'] === true,
1493
+ plus: line['plus'] ?? [],
1494
+ minus: line['minus'] ?? [],
1495
+ xbrl: line['xbrl'] ?? null,
1496
+ legal_reference: line['legal_reference'] ?? null,
1497
+ source: line['source'] ?? null,
1498
+ rules: (line['rules'] ?? []).map((rule, position) => ({
1499
+ kind: rule['kind'],
1500
+ code_from: rule['code_from'] ?? null,
1501
+ code_to: rule['code_to'] ?? null,
1502
+ account_type: rule['account_type'] ?? null,
1503
+ side: (rule['side'] ?? 'any'),
1504
+ sequence: typeof rule['sequence'] === 'number' ? rule['sequence'] : (position + 1) * 10,
1505
+ })),
1506
+ }));
1507
+ return {
1508
+ code,
1509
+ kind: String(statement['kind']),
1510
+ framework: statement['framework'] ?? null,
1511
+ taxonomy: statement['taxonomy'] ?? null,
1512
+ name: String(statement['name'] ?? code),
1513
+ valid_from: String(statement['valid_from'] ?? '1970-01-01'),
1514
+ valid_to: statement['valid_to'] ?? null,
1515
+ legal_reference: statement['legal_reference'] ?? null,
1516
+ source: statement['source'] ?? null,
1517
+ chart_code: named.length === 1 ? named[0] : null,
1518
+ lines,
1519
+ };
1520
+ });
1521
+ }
1522
+ /** Does this rule catch this account code? The same comparison the SQL makes. */
1523
+ function ruleCatches(rule, code) {
1524
+ const from = rule.code_from ?? '';
1525
+ const to = rule.code_to ?? '';
1526
+ switch (rule.kind) {
1527
+ case 'account_code':
1528
+ return code === from;
1529
+ case 'code_prefix':
1530
+ return code.slice(0, from.length) === from;
1531
+ case 'code_range':
1532
+ return code.slice(0, from.length) >= from && code.slice(0, to.length) <= to;
1533
+ default:
1534
+ return false;
1535
+ }
1536
+ }
1537
+ /**
1538
+ * What the schema cannot say about a statement.
1539
+ *
1540
+ * The first four are the rules a declaration form already lives by: a line
1541
+ * code is unique, a parent exists, a formula names lines of the same
1542
+ * statement, and it never names a total computed after it.
1543
+ *
1544
+ * The fifth is the one that makes a statement tie out: **every leaf account of
1545
+ * every chart it applies to reaches exactly one line**. Two lines may share an
1546
+ * account only when their sides exclude each other — a suspense account is a
1547
+ * receivable in debit and a payable in credit, and that is one account on one
1548
+ * line at a time. A heading account, one with children in the chart, is
1549
+ * allowed to reach none: it straddles the lines its children are split over,
1550
+ * and nothing is ever posted to it.
1551
+ */
1552
+ /**
1553
+ * Shape of a fact key, without knowing any taxonomy: parts separated by `|`,
1554
+ * each one a `prefix:member`, the metric first and then the domain members, no
1555
+ * prefix twice. `met:am1|bas:m9|rst:m2` is a key; a lone `met:am1` names every
1556
+ * amount of the model, and a prefix given twice is two members of one
1557
+ * dimension, which no fact has.
1558
+ *
1559
+ * What a key means is the taxonomy's business, not this file's: the checker
1560
+ * refuses what cannot be a key, and the uniqueness rule above catches the key
1561
+ * that means two things at once.
1562
+ */
1563
+ function xbrlKeyProblem(key) {
1564
+ const parts = key.split('|');
1565
+ if (parts.some((part) => part !== part.trim() || part === '')) {
1566
+ return 'a fact key is parts separated by "|", with nothing empty or padded';
1567
+ }
1568
+ if (parts.length < 2) {
1569
+ return 'a fact key is a metric and at least one domain member, e.g. "met:am1|bas:m9"';
1570
+ }
1571
+ const seen = new Set();
1572
+ for (const part of parts) {
1573
+ if (!/^[a-z]+:[a-z0-9]+$/.test(part)) {
1574
+ return `"${part}" is not a qualified name such as "bas:m9"`;
1575
+ }
1576
+ const prefix = part.slice(0, part.indexOf(':'));
1577
+ if (seen.has(prefix)) {
1578
+ return `two parts in the domain "${prefix}"; a fact has one member per dimension`;
1579
+ }
1580
+ seen.add(prefix);
1581
+ }
1582
+ return null;
1583
+ }
1584
+ function statementReferences(statements, charts) {
1585
+ const issues = [];
1586
+ const seenStatements = new Set();
1587
+ for (const statement of statements) {
1588
+ const where = `statements.json ${statement.code}`;
1589
+ if (seenStatements.has(statement.code)) {
1590
+ issues.push({ path: where, message: 'duplicate statement code' });
1591
+ }
1592
+ seenStatements.add(statement.code);
1593
+ const byCode = new Map();
1594
+ // A fact key names one fact of the taxonomy, so it names one line. Two
1595
+ // lines carrying the same key means the key is missing the member that
1596
+ // separates them — the two sides of a balance sheet share every member but
1597
+ // one, and `met:am1|bas:m25` alone is both totals at once.
1598
+ const byXbrl = new Map();
1599
+ // A fact key is written against one version of one taxonomy. The library
1600
+ // that reads the keys carries the table of that version; against another,
1601
+ // a key resolves onto a different line and nothing says so. So a statement
1602
+ // that carries keys has to name what they were written against. Which
1603
+ // library implements that taxonomy is no business of this file: the
1604
+ // end-to-end test is where the two are put in the same room.
1605
+ if (statement.lines.some((line) => line.xbrl !== null) && statement.taxonomy === null) {
1606
+ issues.push({
1607
+ path: where,
1608
+ message: 'lines carry fact keys but the statement names no taxonomy; add "taxonomy": "<name>:<version>"',
1609
+ });
1610
+ }
1611
+ for (const line of statement.lines) {
1612
+ if (byCode.has(line.code)) {
1613
+ issues.push({ path: `${where} ${line.code}`, message: 'duplicate line code' });
1614
+ }
1615
+ byCode.set(line.code, line);
1616
+ if (line.xbrl !== null) {
1617
+ const malformed = xbrlKeyProblem(line.xbrl);
1618
+ if (malformed !== null) {
1619
+ issues.push({ path: `${where} ${line.code}.xbrl`, message: malformed });
1620
+ }
1621
+ const other = byXbrl.get(line.xbrl);
1622
+ if (other !== undefined) {
1623
+ issues.push({
1624
+ path: `${where} ${line.code}.xbrl`,
1625
+ message: `${line.xbrl} already names line ${other}. A fact key names one fact: ` +
1626
+ 'add the member that separates the two lines.',
1627
+ });
1628
+ }
1629
+ byXbrl.set(line.xbrl, line.code);
1630
+ }
1631
+ if (line.is_total && line.rules.length > 0) {
1632
+ issues.push({
1633
+ path: `${where} ${line.code}`,
1634
+ message: 'a total is computed from other lines; it takes no rule of its own',
1635
+ });
1636
+ }
1637
+ if (!line.is_total && line.plus.length + line.minus.length > 0) {
1638
+ issues.push({
1639
+ path: `${where} ${line.code}`,
1640
+ message: 'only a total is computed from other lines; mark it is_total or drop the formula',
1641
+ });
1642
+ }
1643
+ // A sign on a computed line is applied a second time. Every line a
1644
+ // formula names already carries the sign the scheme reads it with —
1645
+ // `financial_statement()` applies it when it sums the line from the
1646
+ // ledger — and the evaluator then multiplies the total by the total's
1647
+ // own sign, so a scheme that flips a credit line and flips the subtotal
1648
+ // above it gets the figure back the way it started. It cost the
1649
+ // Luxembourg pack a wrong set of golden figures, caught by reading them
1650
+ // rather than by any check. `minus` is how a total subtracts.
1651
+ if (line.declares_sign && line.plus.length + line.minus.length > 0) {
1652
+ issues.push({
1653
+ path: `${where} ${line.code}`,
1654
+ message: 'a computed line takes no sign of its own: the lines it names already carry theirs, ' +
1655
+ 'and a sign here is applied to them a second time. Use minus to subtract.',
1656
+ });
1657
+ }
1658
+ }
1659
+ for (const line of statement.lines) {
1660
+ if (line.parent !== null && !byCode.has(line.parent)) {
1661
+ issues.push({ path: `${where} ${line.code}`, message: `parent ${line.parent} is not a line of this statement` });
1662
+ }
1663
+ for (const [list, refs] of [
1664
+ ['plus', line.plus],
1665
+ ['minus', line.minus],
1666
+ ]) {
1667
+ for (const ref of refs) {
1668
+ const target = byCode.get(ref);
1669
+ if (target === undefined) {
1670
+ issues.push({ path: `${where} ${line.code}.${list}`, message: `${ref} is not a line of this statement` });
1671
+ continue;
1672
+ }
1673
+ if (ref === line.code) {
1674
+ issues.push({ path: `${where} ${line.code}.${list}`, message: `${ref} is the line itself` });
1675
+ }
1676
+ }
1677
+ }
1678
+ }
1679
+ // Totals are evaluated in the order they depend on each other, not the
1680
+ // order they are printed in — a balance sheet prints a subtotal above the
1681
+ // lines it adds up. What that cannot survive is a cycle.
1682
+ const resolved = new Set(statement.lines.filter((l) => !l.is_total).map((l) => l.code));
1683
+ let pending = statement.lines.filter((l) => l.is_total);
1684
+ for (;;) {
1685
+ const ready = pending.filter((l) => [...l.plus, ...l.minus].every((ref) => resolved.has(ref)));
1686
+ if (ready.length === 0)
1687
+ break;
1688
+ for (const line of ready)
1689
+ resolved.add(line.code);
1690
+ pending = pending.filter((l) => !resolved.has(l.code));
1691
+ }
1692
+ if (pending.length > 0 && pending.every((l) => [...l.plus, ...l.minus].every((ref) => byCode.has(ref)))) {
1693
+ issues.push({
1694
+ path: where,
1695
+ message: `the totals ${pending.map((l) => l.code).join(', ')} depend on each other and on nothing else. ` +
1696
+ 'A total is computed from lines that can be computed without it.',
1697
+ });
1698
+ }
1699
+ // No account on two lines of the same statement. Two lines may share one
1700
+ // only when their sides exclude each other: a suspense account is a
1701
+ // receivable while it is in debit and a payable while it is in credit, and
1702
+ // that is still one line at a time.
1703
+ const applicable = charts.filter((c) => statement.chart_code === null || statement.chart_code === c.code);
1704
+ for (const chart of applicable) {
1705
+ for (const account of chart.accounts) {
1706
+ const hits = statement.lines.filter((line) => line.rules.some((rule) => ruleCatches(rule, account.code)));
1707
+ if (hits.length < 2)
1708
+ continue;
1709
+ const sides = hits.flatMap((line) => line.rules.filter((rule) => ruleCatches(rule, account.code)).map((rule) => rule.side));
1710
+ const exclusive = sides.length === 2 && sides.includes('debit') && sides.includes('credit');
1711
+ if (!exclusive) {
1712
+ issues.push({
1713
+ path: `${where} ${chart.code}`,
1714
+ message: `account ${account.code} reaches ${hits.length} lines (${hits.map((l) => l.code).join(', ')}). ` +
1715
+ 'Two lines may share an account only when one takes it in debit and the other in credit.',
1716
+ });
1717
+ }
1718
+ }
1719
+ }
1720
+ }
1721
+ // And nothing falls off the edge: every account a chart can be posted to
1722
+ // reaches a line of *some* statement of that chart. A heading — an account
1723
+ // with children — may reach none, because it straddles the lines its
1724
+ // children are split over and nothing is posted to it. This is the check
1725
+ // that makes a balance sheet tie out, and the reason `unmapped_accounts()`
1726
+ // answers empty on a company that never left the pack.
1727
+ for (const chart of charts) {
1728
+ const covering = statements.filter((st) => st.chart_code === null || st.chart_code === chart.code);
1729
+ if (covering.length === 0)
1730
+ continue;
1731
+ const parents = new Set(chart.accounts.map((a) => a.parent).filter((c) => c !== null));
1732
+ for (const account of chart.accounts) {
1733
+ if (account.type === 'off_balance')
1734
+ continue;
1735
+ if (parents.has(account.code))
1736
+ continue;
1737
+ const found = covering.some((st) => st.lines.some((line) => line.rules.some((rule) => ruleCatches(rule, account.code))));
1738
+ if (!found) {
1739
+ issues.push({
1740
+ path: `statements.json ${chart.code}`,
1741
+ message: `account ${account.code} (${account.name}) reaches no line of any statement of this chart`,
1742
+ });
1743
+ }
1744
+ }
1745
+ }
1746
+ // A chart may only name statements the pack carries.
1747
+ const known = new Set(statements.map((s) => s.code));
1748
+ for (const chart of charts) {
1749
+ for (const code of chart.statements) {
1750
+ if (!known.has(code)) {
1751
+ issues.push({ path: `pack.json charts.${chart.code}`, message: `${code} is not a statement of this pack` });
1752
+ }
1753
+ }
1754
+ }
1755
+ return issues;
1756
+ }
1757
+ /**
1758
+ * A reference in a plus/minus list, or in an i18n key, to the one box it
1759
+ * names. Bare, it has to match exactly one box of the form; qualified
1760
+ * (`08:tax`), it names the kind itself — the French CA3 carries a base and a
1761
+ * tax on line 08 and the Belgian form never does. Returns the box, or the
1762
+ * sentence that says why it does not resolve.
1763
+ */
1764
+ export function resolveBoxRef(ref, boxes) {
1765
+ const [code, kind] = ref.includes(':') ? ref.split(':') : [ref, undefined];
1766
+ const matches = boxes.filter((b) => b.box === code && (kind === undefined || b.kind === kind));
1767
+ if (matches.length === 0) {
1768
+ return kind === undefined
1769
+ ? `${ref} is not a box of this form`
1770
+ : `${ref} is not a ${kind} box of this form`;
1771
+ }
1772
+ if (matches.length > 1) {
1773
+ return `${ref} is ambiguous: this form carries it as ${matches
1774
+ .map((b) => b.kind)
1775
+ .join(' and ')}. Write ${matches.map((b) => `${code}:${b.kind}`).join(' or ')}.`;
1776
+ }
1777
+ return matches[0];
1778
+ }
1779
+ /**
1780
+ * What the schema cannot say about a declaration form: a formula only names
1781
+ * boxes of the same form, it names them without ambiguity, it never names
1782
+ * itself, and it never names a total that is computed after it — the totals
1783
+ * are evaluated once, in the order the form declares them.
1784
+ */
1785
+ /**
1786
+ * The cadence the pack proposes, against the cadences its form accepts.
1787
+ *
1788
+ * `defaults.vat_period` is what a company of this country files on unless it
1789
+ * says otherwise, and it is wired onto `companies.vat_period` at install. A
1790
+ * pack may leave it out, and three of the four here do: Belgium, France and
1791
+ * Luxembourg all make the cadence follow turnover, so proposing one of two
1792
+ * lawful answers would be choosing a filing deadline for a company the pack
1793
+ * knows nothing about. What a pack may not do is propose a cadence its own
1794
+ * form does not accept.
1795
+ */
1796
+ function proposedPeriod(manifest, report) {
1797
+ const proposed = manifest.defaults['vat_period'];
1798
+ if (proposed === undefined)
1799
+ return [];
1800
+ if (report !== null && report.period_default !== null && report.period_default !== proposed) {
1801
+ return [
1802
+ {
1803
+ path: 'defaults.vat_period',
1804
+ message: `${proposed}, where ${report.code} says it is filed every ${report.period_default}. ` +
1805
+ 'The form is where a proposal belongs now; say it once, in tax_report.json.',
1806
+ },
1807
+ ];
1808
+ }
1809
+ if (report === null) {
1810
+ return [
1811
+ {
1812
+ path: 'defaults.vat_period',
1813
+ message: `${proposed}, but this pack carries no declaration form to file on that cadence`,
1814
+ },
1815
+ ];
1816
+ }
1817
+ if (!report.periods.includes(proposed)) {
1818
+ return [
1819
+ {
1820
+ path: 'defaults.vat_period',
1821
+ message: `${proposed} is not a cadence ${report.code} is filed on ` +
1822
+ `(${report.periods.join(', ') || 'none declared'})`,
1823
+ },
1824
+ ];
1825
+ }
1826
+ return [];
1827
+ }
1828
+ function reportReferences(report, taxes) {
1829
+ if (report === null)
1830
+ return [];
1831
+ const issues = [];
1832
+ const where = 'tax_report.json';
1833
+ // How often the form is filed. There is no default for this and there must
1834
+ // not be one: `tax_report_templates.period` carried `month_or_quarter` as a
1835
+ // column default, so a pack that had never thought about its cadence filed
1836
+ // on Belgium's, and nothing anywhere said so.
1837
+ if (report.periods.length === 0) {
1838
+ issues.push({
1839
+ path: where,
1840
+ message: 'the form names no cadence; add "period": ["month", "quarter"] — how often it is filed, ' +
1841
+ 'which nothing can work out on its behalf',
1842
+ });
1843
+ }
1844
+ for (const period of report.periods) {
1845
+ if (!PERIOD_ORDER.includes(period)) {
1846
+ issues.push({
1847
+ path: `${where} period`,
1848
+ message: `${period} is not a cadence; use ${PERIOD_ORDER.join(', ')}`,
1849
+ });
1850
+ }
1851
+ }
1852
+ // What the form is filed on unless the company has asked for something else.
1853
+ // Optional, and refused only when it is a cadence the form is not filed on:
1854
+ // whether the law gives a default is a reading of the law, which a legal
1855
+ // reference states and a list length cannot.
1856
+ if (report.period_default !== null && !report.periods.includes(report.period_default)) {
1857
+ issues.push({
1858
+ path: `${where} period_default`,
1859
+ message: `${report.period_default} is not a cadence ${report.code} is filed on ` +
1860
+ `(${report.periods.join(', ') || 'none declared'})`,
1861
+ });
1862
+ }
1863
+ const seen = new Set();
1864
+ for (const box of report.boxes) {
1865
+ const key = `${box.box}|${box.kind}`;
1866
+ if (seen.has(key)) {
1867
+ issues.push({ path: `${where} ${box.box}`, message: `duplicate ${box.kind} box` });
1868
+ }
1869
+ seen.add(key);
1870
+ if (box.kind !== 'total' && (box.plus.length > 0 || box.minus.length > 0)) {
1871
+ issues.push({
1872
+ path: `${where} ${box.box}`,
1873
+ message: 'only a total is computed from other boxes; a base or a tax box is summed from the ledger',
1874
+ });
1875
+ }
1876
+ // A rate is the other way a box is computed, so it is held to the same
1877
+ // three things: only a computed box carries one, it carries one way of
1878
+ // computing and not two, and the percentage and the box it applies to are
1879
+ // declared together.
1880
+ if (box.kind !== 'total' && box.rate !== null) {
1881
+ issues.push({
1882
+ path: `${where} ${box.box}`,
1883
+ message: 'only a total is a rate of another box; a base or a tax box is summed from the ledger',
1884
+ });
1885
+ }
1886
+ if (box.rate !== null && (box.plus.length > 0 || box.minus.length > 0)) {
1887
+ issues.push({
1888
+ path: `${where} ${box.box}`,
1889
+ message: 'a box is a list of boxes or a rate of one box, never both: two ways of computing one ' +
1890
+ 'figure is the expression language this format does without',
1891
+ });
1892
+ }
1893
+ if ((box.rate === null) !== (box.rate_of === null)) {
1894
+ issues.push({
1895
+ path: `${where} ${box.box}`,
1896
+ message: box.rate === null
1897
+ ? 'rate_of names a box and no rate is applied to it; declare the two together'
1898
+ : 'rate is a percentage of nothing; name the box it applies to with rate_of',
1899
+ });
1900
+ }
1901
+ }
1902
+ for (const box of report.boxes) {
1903
+ for (const [list, refs] of [
1904
+ ['plus', box.plus],
1905
+ ['minus', box.minus],
1906
+ ['rate_of', box.rate_of === null ? [] : [box.rate_of]],
1907
+ ]) {
1908
+ for (const ref of refs) {
1909
+ const target = resolveBoxRef(ref, report.boxes);
1910
+ if (typeof target === 'string') {
1911
+ issues.push({ path: `${where} ${box.box}.${list}`, message: target });
1912
+ continue;
1913
+ }
1914
+ if (target.box === box.box && target.kind === box.kind) {
1915
+ issues.push({ path: `${where} ${box.box}.${list}`, message: `${ref} is the box itself` });
1916
+ }
1917
+ }
1918
+ }
1919
+ }
1920
+ // A computed box is worked out when the boxes it names have been, not when
1921
+ // the form prints it — `evaluate_totals()` has resolved by dependency since
1922
+ // it became the one evaluator, and a form prints a subtotal above what it
1923
+ // adds up. What that cannot survive is a cycle, and a cycle found here names
1924
+ // the boxes while the person who can fix them is still reading the pack.
1925
+ // At runtime it is `formula_cycle`, which is a message and never a loop.
1926
+ const computed = report.boxes.filter((b) => b.kind === 'total' && (b.plus.length + b.minus.length > 0 || b.rate !== null));
1927
+ const sources = (b) => [
1928
+ ...b.plus,
1929
+ ...b.minus,
1930
+ ...(b.rate_of === null ? [] : [b.rate_of]),
1931
+ ];
1932
+ const resolved = new Set(report.boxes.filter((b) => !computed.includes(b)).map((b) => `${b.box}|${b.kind}`));
1933
+ /** Whether a reference names a box already worked out, whatever its kind. */
1934
+ const ready = (ref) => {
1935
+ const target = resolveBoxRef(ref, report.boxes);
1936
+ return typeof target === 'string' || resolved.has(`${target.box}|${target.kind}`);
1937
+ };
1938
+ let pending = [...computed];
1939
+ for (;;) {
1940
+ const settled = pending.filter((b) => sources(b).every(ready));
1941
+ if (settled.length === 0)
1942
+ break;
1943
+ for (const b of settled)
1944
+ resolved.add(`${b.box}|${b.kind}`);
1945
+ pending = pending.filter((b) => !resolved.has(`${b.box}|${b.kind}`));
1946
+ }
1947
+ if (pending.length > 0) {
1948
+ issues.push({
1949
+ path: where,
1950
+ message: `the boxes ${pending.map((b) => b.box).join(', ')} depend on each other and on nothing else. ` +
1951
+ 'A computed box is worked out from boxes that can be worked out without it.',
1952
+ });
1953
+ }
1954
+ // Every box a tax posts to has to exist on the form the posting names, or
1955
+ // the amount lands nowhere and the return is short without saying so. A
1956
+ // posting may name several — the form prints one figure in boxes that are
1957
+ // not sums of one another — and each of them is held to the same three
1958
+ // things: it is a box of this form, it is of the kind the posting writes,
1959
+ // and it is named once.
1960
+ for (const tax of taxes) {
1961
+ for (const [kind, postings] of Object.entries(tax.postings)) {
1962
+ for (const posting of postings) {
1963
+ if (posting.report !== report.code)
1964
+ continue;
1965
+ const seen = new Set();
1966
+ for (const box of posting.boxes) {
1967
+ if (seen.has(box)) {
1968
+ issues.push({
1969
+ path: `taxes.json ${tax.code}.${kind}`,
1970
+ message: `box ${box} is named twice by one posting; a posting reports an amount to a box once`,
1971
+ });
1972
+ continue;
1973
+ }
1974
+ seen.add(box);
1975
+ // A total is added up from the boxes below it, so a posting that
1976
+ // wrote into one would be counted twice: once by itself and once
1977
+ // by the sum.
1978
+ const target = resolveBoxRef(`${box}:${declarationKind(posting.type)}`, report.boxes);
1979
+ if (typeof target === 'string') {
1980
+ issues.push({ path: `taxes.json ${tax.code}.${kind}`, message: `box ${target}` });
1981
+ }
1982
+ }
1983
+ }
1984
+ }
1985
+ }
1986
+ return issues;
1987
+ }
1988
+ /** What no schema can check: a code has to name something this pack carries. */
1989
+ /**
1990
+ * Every territory a tax names, held against the reference table.
1991
+ *
1992
+ * Three refusals, and the first is the reason the columns are foreign keys in
1993
+ * the schema: a territory code nobody can look up is a string, and a string is
1994
+ * what `jurisdiction` has been since the day it was added — four packs could
1995
+ * have spelled California four ways and nothing would have said so.
1996
+ *
1997
+ * The second is `jurisdiction` itself, now that there is a table to check it
1998
+ * against. It stays a different field from `applies_when`: it says who levies
1999
+ * the tax, which is a term of the invoice and of a report, where `applies_when`
2000
+ * says when the tax can be reached at all. `US-P-0` is the case that keeps them
2001
+ * apart — a purchase not subject to the tax, levied by nobody, recorded against
2002
+ * the state whose return it belongs to.
2003
+ *
2004
+ * The third is about the seller only. A pack is keyed on the country its
2005
+ * companies file under, so a tax of that pack is a tax its seller owes: a
2006
+ * `seller_in` outside the pack's country is a pack claiming another country's
2007
+ * law. The buyer and the place of supply are deliberately unconstrained —
2008
+ * a supply is taxed where it lands, and where it lands is the whole point.
2009
+ */
2010
+ function territoryReferences(taxes, territories) {
2011
+ const issues = [];
2012
+ for (const tax of taxes) {
2013
+ const named = [
2014
+ ['applies_when.seller_in', tax.applies_seller_territory],
2015
+ ['applies_when.buyer_in', tax.applies_buyer_territory],
2016
+ ['applies_when.supply_in', tax.applies_supply_territory],
2017
+ ['jurisdiction', tax.jurisdiction],
2018
+ ];
2019
+ for (const [field, code] of named) {
2020
+ if (code === null)
2021
+ continue;
2022
+ if (territoryOf(code, territories) !== null)
2023
+ continue;
2024
+ issues.push({
2025
+ path: `taxes.json ${tax.code}`,
2026
+ message: `${field} names ${code}, which territories carries no row for. A territory a tax names ` +
2027
+ 'is a row of supabase/seed/00_territories.sql, so that a reader can look it up and a ' +
2028
+ 'second pack cannot spell it differently',
2029
+ });
2030
+ }
2031
+ }
2032
+ return issues;
2033
+ }
2034
+ /** The same rule, where the pack's own country is what the seller is held to. */
2035
+ function sellerTerritory(manifest, taxes, territories) {
2036
+ const issues = [];
2037
+ for (const tax of taxes) {
2038
+ const code = tax.applies_seller_territory;
2039
+ if (code === null)
2040
+ continue;
2041
+ if (territoryOf(code, territories) === null)
2042
+ continue; // already reported
2043
+ if (territoryWithin(code, manifest.country, territories))
2044
+ continue;
2045
+ issues.push({
2046
+ path: `taxes.json ${tax.code}`,
2047
+ message: `applies_when.seller_in names ${code}, which is not inside ${manifest.country}. A pack is ` +
2048
+ "keyed on the country its companies file under, so its taxes are the ones their seller owes",
2049
+ });
2050
+ }
2051
+ return issues;
2052
+ }
2053
+ function crossReferences(manifest, charts, taxes) {
2054
+ const issues = [];
2055
+ const journals = new Set(manifest.journals.map((j) => j.code));
2056
+ // Exactly one default chart, and no code claimed twice inside one chart.
2057
+ const defaults = charts.filter((c) => c.is_default);
2058
+ if (defaults.length !== 1) {
2059
+ issues.push({
2060
+ path: 'pack.json charts',
2061
+ message: defaults.length === 0
2062
+ ? 'no chart is the default; `ekwo init` would have nothing to install when nobody names one'
2063
+ : `${defaults.length} charts are the default (${defaults.map((c) => c.code).join(', ')}); exactly one is`,
2064
+ });
2065
+ }
2066
+ const chartCodes = new Set();
2067
+ for (const chart of charts) {
2068
+ if (chartCodes.has(chart.code)) {
2069
+ issues.push({ path: `pack.json charts.${chart.code}`, message: 'duplicate chart code' });
2070
+ }
2071
+ chartCodes.add(chart.code);
2072
+ const codes = new Set();
2073
+ for (const account of chart.accounts) {
2074
+ if (codes.has(account.code)) {
2075
+ issues.push({ path: `${chart.file} ${account.code}`, message: 'duplicate account code' });
2076
+ }
2077
+ codes.add(account.code);
2078
+ }
2079
+ for (const account of chart.accounts) {
2080
+ if (account.parent !== null && !codes.has(account.parent)) {
2081
+ issues.push({
2082
+ path: `${chart.file} ${account.code}`,
2083
+ message: `parent ${account.parent} is not in this chart`,
2084
+ });
2085
+ }
2086
+ }
2087
+ }
2088
+ // A role and a tax account have to exist in *every* chart: the taxes, the
2089
+ // journals and the roles of a country are common to its charts, so a chart
2090
+ // that misses one is a company that installs with no payable account or a
2091
+ // VAT posting with nowhere to book.
2092
+ for (const chart of charts) {
2093
+ const codes = new Set(chart.accounts.map((a) => a.code));
2094
+ for (const [role, code] of Object.entries(manifest.defaults.roles)) {
2095
+ if (code !== null && code !== undefined && !codes.has(code)) {
2096
+ issues.push({ path: `defaults.roles.${role}`, message: `${code} is not in chart ${chart.code}` });
2097
+ }
2098
+ }
2099
+ for (const tax of taxes) {
2100
+ if (tax.cash_basis_transition_account !== null && !codes.has(tax.cash_basis_transition_account)) {
2101
+ issues.push({
2102
+ path: `taxes.json ${tax.code}`,
2103
+ message: `cash_basis_transition_account ${tax.cash_basis_transition_account} is not in chart ${chart.code}`,
2104
+ });
2105
+ }
2106
+ for (const [kind, postings] of Object.entries(tax.postings)) {
2107
+ for (const posting of postings) {
2108
+ if (posting.account !== null && !codes.has(posting.account)) {
2109
+ issues.push({
2110
+ path: `taxes.json ${tax.code}.${kind}`,
2111
+ message: `account ${posting.account} is not in chart ${chart.code}`,
2112
+ });
2113
+ }
2114
+ }
2115
+ }
2116
+ }
2117
+ }
2118
+ for (const [role, code] of Object.entries(manifest.defaults.journal_roles ?? {})) {
2119
+ if (code !== undefined && !journals.has(code)) {
2120
+ issues.push({ path: `defaults.journal_roles.${role}`, message: `${code} is not a journal of this pack` });
2121
+ }
2122
+ }
2123
+ issues.push(...closingRules(manifest, journals));
2124
+ const seen = new Set();
2125
+ for (const tax of taxes) {
2126
+ if (seen.has(tax.code))
2127
+ issues.push({ path: `taxes.json ${tax.code}`, message: 'duplicate code' });
2128
+ seen.add(tax.code);
2129
+ if (tax.group !== undefined) {
2130
+ issues.push({
2131
+ path: `taxes.json ${tax.code}`,
2132
+ message: 'a tax group is reserved for phase 1 and the core does not carry it yet',
2133
+ });
2134
+ }
2135
+ // A tax that falls due on collection waits somewhere, and the place it
2136
+ // waits is a fact about the chart, so the pack says it. `post_document`
2137
+ // refuses such a tax at posting; this refuses it where it can be read.
2138
+ if (tax.cash_basis && tax.cash_basis_transition_account === null) {
2139
+ issues.push({
2140
+ path: `taxes.json ${tax.code}`,
2141
+ message: 'a tax that falls due on collection has to name the account it waits on',
2142
+ });
2143
+ }
2144
+ // A price that holds its tax is divided by one plus the rate, and a fixed
2145
+ // amount has no rate to divide by: "the price includes 0.50" is a discount,
2146
+ // not a tax. The schema refuses it too, and this is the reading that says
2147
+ // so before a seed is written.
2148
+ if (tax.price_include && tax.amount_type !== 'percent') {
2149
+ issues.push({
2150
+ path: `taxes.json ${tax.code}`,
2151
+ message: 'a price that already holds its tax needs a rate to take it back out, and a fixed amount is not one',
2152
+ });
2153
+ }
2154
+ for (const [kind, postings] of Object.entries(tax.postings)) {
2155
+ const bases = postings.filter((p) => p.type === 'base');
2156
+ if (bases.length > 1) {
2157
+ issues.push({ path: `taxes.json ${tax.code}.${kind}`, message: 'more than one base posting' });
2158
+ }
2159
+ if (tax.cash_basis) {
2160
+ // One posting per side, or the transition lines of a document cannot
2161
+ // be told apart when the matching sends each of them on. A tax whose
2162
+ // postings net out has nothing waiting to collect anyway.
2163
+ if (postings.filter((p) => p.type === 'tax').length > 1) {
2164
+ issues.push({
2165
+ path: `taxes.json ${tax.code}.${kind}`,
2166
+ message: 'a tax that falls due on collection takes one tax posting',
2167
+ });
2168
+ }
2169
+ if (postings.some((p) => p.type === 'tax_on_base')) {
2170
+ issues.push({
2171
+ path: `taxes.json ${tax.code}.${kind}`,
2172
+ message: 'a share nobody gets back is a cost, and a cost is not deferred to a payment',
2173
+ });
2174
+ }
2175
+ // And it needs a box to fall due *into*. `settle_cash_basis_tax()`
2176
+ // only ever moves a line that carries a box amount, and a posting
2177
+ // with no box produces none — so the tax would sit on the transition
2178
+ // account for ever, settled by nothing and declared by nothing, with
2179
+ // no error anywhere. Refused here, where the pack can be corrected.
2180
+ if (postings.some((p) => p.type === 'tax' && p.box === null)) {
2181
+ issues.push({
2182
+ path: `taxes.json ${tax.code}.${kind}`,
2183
+ message: 'a tax that falls due on collection has to name the box it falls due into, or the amount waits on the transition account for ever',
2184
+ });
2185
+ }
2186
+ }
2187
+ for (const posting of postings) {
2188
+ if (posting.type === 'tax' && posting.account === null) {
2189
+ issues.push({ path: `taxes.json ${tax.code}.${kind}`, message: 'a tax posting needs an account' });
2190
+ }
2191
+ if (posting.type !== 'tax' && posting.account !== null) {
2192
+ issues.push({
2193
+ path: `taxes.json ${tax.code}.${kind}`,
2194
+ message: `a ${posting.type} posting takes no account: it lands on the account of the document line`,
2195
+ });
2196
+ }
2197
+ }
2198
+ }
2199
+ }
2200
+ return issues;
2201
+ }
2202
+ /**
2203
+ * What a `closing_style` obliges the rest of the pack to say.
2204
+ *
2205
+ * The schema carries no default for any of it — a default closing style is
2206
+ * one country's mechanism applied to every country that has not spoken, and
2207
+ * `OPN` is the journal code Belgium and France happen to use. So a pack that
2208
+ * declares a style has to name the accounts and the journal that style needs,
2209
+ * and `ekwo pack check` says which one is missing rather than letting
2210
+ * `close_fiscal_year` find out on somebody's year end.
2211
+ */
2212
+ function closingRules(manifest, journals) {
2213
+ const issues = [];
2214
+ const style = manifest.defaults['closing_style'];
2215
+ if (style === undefined)
2216
+ return issues;
2217
+ const roles = manifest.defaults.roles;
2218
+ const needed = style === 'retained_earnings'
2219
+ ? ['retained_earnings']
2220
+ : ['current_year_result_profit', 'current_year_result_loss'];
2221
+ for (const role of needed) {
2222
+ if (roles[role] === undefined || roles[role] === null) {
2223
+ issues.push({
2224
+ path: `defaults.roles.${role}`,
2225
+ message: `a pack that closes with ${style} has to name it`,
2226
+ });
2227
+ }
2228
+ }
2229
+ const opening = manifest.defaults.journal_roles?.['opening'];
2230
+ if (opening === undefined) {
2231
+ issues.push({
2232
+ path: 'defaults.journal_roles.opening',
2233
+ message: 'a pack that declares a closing_style has to name the journal its opening and year-end entries go on',
2234
+ });
2235
+ }
2236
+ else if (journals.has(opening)) {
2237
+ const journal = manifest.journals.find((j) => j.code === opening);
2238
+ if (journal !== undefined && journal.type !== 'opening') {
2239
+ issues.push({
2240
+ path: 'defaults.journal_roles.opening',
2241
+ message: `${opening} is of type ${journal.type}, and the opening journal has to be of type opening`,
2242
+ });
2243
+ }
2244
+ }
2245
+ return issues;
2246
+ }
2247
+ async function readJson(path) {
2248
+ const text = await readFile(path, 'utf8');
2249
+ try {
2250
+ return JSON.parse(text);
2251
+ }
2252
+ catch (error) {
2253
+ throw new PackError(`pack_unreadable: ${path} — ${error.message}`);
2254
+ }
2255
+ }
2256
+ /**
2257
+ * The CSV subset a chart of accounts is written in: a header line, one row
2258
+ * per account, no newline inside a field, a field quoted only when it holds a
2259
+ * comma or a quote, a quote doubled inside a quoted field. Forty lines,
2260
+ * because anything richer is a format nobody can review in a diff.
2261
+ */
2262
+ export function parseCsv(text, file) {
2263
+ const lines = text.replace(/\r\n/g, '\n').split('\n').filter((line) => line.length > 0);
2264
+ if (lines.length === 0)
2265
+ throw new PackError(`pack_invalid: ${file} is empty`);
2266
+ const header = splitCsvLine(lines[0], file, 1);
2267
+ return lines.slice(1).map((line, index) => {
2268
+ const fields = splitCsvLine(line, file, index + 2);
2269
+ if (fields.length !== header.length) {
2270
+ throw new PackError(`pack_invalid: ${file} line ${index + 2} has ${fields.length} field(s), the header has ${header.length}`);
2271
+ }
2272
+ const row = {};
2273
+ header.forEach((name, position) => {
2274
+ row[name] = fields[position];
2275
+ });
2276
+ return row;
2277
+ });
2278
+ }
2279
+ function splitCsvLine(line, file, number) {
2280
+ const fields = [];
2281
+ let field = '';
2282
+ let quoted = false;
2283
+ for (let index = 0; index < line.length; index += 1) {
2284
+ const char = line[index];
2285
+ if (quoted) {
2286
+ if (char === '"') {
2287
+ if (line[index + 1] === '"') {
2288
+ field += '"';
2289
+ index += 1;
2290
+ }
2291
+ else {
2292
+ quoted = false;
2293
+ }
2294
+ }
2295
+ else {
2296
+ field += char;
2297
+ }
2298
+ continue;
2299
+ }
2300
+ if (char === '"') {
2301
+ if (field.length > 0)
2302
+ throw new PackError(`pack_invalid: ${file} line ${number}: a quote opens mid-field`);
2303
+ quoted = true;
2304
+ continue;
2305
+ }
2306
+ if (char === ',') {
2307
+ fields.push(field);
2308
+ field = '';
2309
+ continue;
2310
+ }
2311
+ field += char;
2312
+ }
2313
+ if (quoted)
2314
+ throw new PackError(`pack_invalid: ${file} line ${number}: a quoted field never closes`);
2315
+ fields.push(field);
2316
+ return fields;
2317
+ }
2318
+ function emptyToNull(value) {
2319
+ return value === undefined || value === '' ? null : value;
2320
+ }
2321
+ function parseBoolean(value, where) {
2322
+ if (value === 'true')
2323
+ return true;
2324
+ if (value === 'false' || value === undefined || value === '')
2325
+ return false;
2326
+ throw new PackError(`pack_invalid: ${where}: "${value}" is not true or false`);
2327
+ }
2328
+ function parseInteger(value, where, fallback) {
2329
+ if (value === undefined || value === '')
2330
+ return fallback;
2331
+ const parsed = Number(value);
2332
+ if (!Number.isInteger(parsed))
2333
+ throw new PackError(`pack_invalid: ${where}: "${value}" is not a whole number`);
2334
+ return parsed;
2335
+ }
2336
+ //# sourceMappingURL=read.js.map