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.
- package/README.md +873 -0
- package/dist/args.d.ts +57 -0
- package/dist/args.d.ts.map +1 -0
- package/dist/args.js +134 -0
- package/dist/args.js.map +1 -0
- package/dist/assets/expected-objects.json +9432 -0
- package/dist/assets/migrations/20260911120000_core_companies.sql +260 -0
- package/dist/assets/migrations/20260911120100_accounts_journals.sql +211 -0
- package/dist/assets/migrations/20260911120200_contacts.sql +90 -0
- package/dist/assets/migrations/20260911120300_taxes.sql +146 -0
- package/dist/assets/migrations/20260911120400_entries.sql +338 -0
- package/dist/assets/migrations/20260911120500_documents.sql +279 -0
- package/dist/assets/migrations/20260911120600_payments_reconciliation.sql +251 -0
- package/dist/assets/migrations/20260911120700_bank.sql +178 -0
- package/dist/assets/migrations/20260911120800_analytics_attachments.sql +128 -0
- package/dist/assets/migrations/20260911120900_post_document.sql +279 -0
- package/dist/assets/migrations/20260911121000_reporting.sql +235 -0
- package/dist/assets/migrations/20260911121100_country_templates.sql +226 -0
- package/dist/assets/migrations/20260911121200_fec.sql +71 -0
- package/dist/assets/migrations/20260911130000_instance.sql +77 -0
- package/dist/assets/migrations/20260911130100_instance_members.sql +224 -0
- package/dist/assets/migrations/20260911140000_instance_admins.sql +112 -0
- package/dist/assets/migrations/20260911160000_tax_posting_templates_unique.sql +17 -0
- package/dist/assets/migrations/20260911170000_document_amount_paid.sql +82 -0
- package/dist/assets/migrations/20260911173000_post_payment.sql +122 -0
- package/dist/assets/migrations/20260911173100_sequence_counters_under_rls.sql +84 -0
- package/dist/assets/migrations/20260911183000_country_journal_defaults.sql +143 -0
- package/dist/assets/migrations/20260911193853_line_account_defaults.sql +274 -0
- package/dist/assets/migrations/20260911195054_products.sql +221 -0
- package/dist/assets/migrations/20260911210131_anon_surface.sql +85 -0
- package/dist/assets/migrations/20260912074712_country_packs.sql +318 -0
- package/dist/assets/migrations/20260912080311_report_code_and_region.sql +198 -0
- package/dist/assets/migrations/20260912081014_pack_certification_maintained.sql +31 -0
- package/dist/assets/migrations/20260912081015_pack_certification_backfill.sql +16 -0
- package/dist/assets/migrations/20260912090407_tax_report_boxes.sql +329 -0
- package/dist/assets/migrations/20260912091917_tax_on_base_value.sql +25 -0
- package/dist/assets/migrations/20260912091918_tax_engine_columns.sql +776 -0
- package/dist/assets/migrations/20260912094412_opening_and_closing.sql +759 -0
- package/dist/assets/migrations/20260912095825_charts_of_accounts.sql +326 -0
- package/dist/assets/migrations/20260912100412_financial_statements.sql +632 -0
- package/dist/assets/migrations/20260912104719_one_formula_evaluator.sql +209 -0
- package/dist/assets/migrations/20260912105720_entry_kind_appropriation.sql +26 -0
- package/dist/assets/migrations/20260912105721_appropriation_entry_kind.sql +444 -0
- package/dist/assets/migrations/20260912111751_document_rules.sql +274 -0
- package/dist/assets/migrations/20260912112132_cash_basis_vat_and_fx.sql +1189 -0
- package/dist/assets/migrations/20260913074512_modules.sql +493 -0
- package/dist/assets/migrations/20260913075903_asset_disposal_roles.sql +47 -0
- package/dist/assets/migrations/20260913083216_capabilities.sql +637 -0
- package/dist/assets/migrations/20260913083901_company_invitations.sql +262 -0
- package/dist/assets/migrations/20260913084402_user_preferences.sql +376 -0
- package/dist/assets/migrations/20260913084847_company_profile.sql +261 -0
- package/dist/assets/migrations/20260913085436_numbering_reads_the_pack.sql +235 -0
- package/dist/assets/migrations/20260913085932_api_keys.sql +303 -0
- package/dist/assets/migrations/20260913090216_fiscal_year_bounds.sql +159 -0
- package/dist/assets/migrations/20260913092527_entries_import.sql +219 -0
- package/dist/assets/migrations/20260913101536_null_safe_guards.sql +188 -0
- package/dist/assets/migrations/20260913102115_installer_is_named.sql +462 -0
- package/dist/assets/migrations/20260913102758_no_currency_default.sql +138 -0
- package/dist/assets/migrations/20260913103355_cash_basis_needs_a_box.sql +453 -0
- package/dist/assets/migrations/20260913104014_aged_balance_named_group.sql +79 -0
- package/dist/assets/migrations/20260913104232_foreign_key_indexes.sql +144 -0
- package/dist/assets/migrations/20260913105120_declared_no_reader.sql +41 -0
- package/dist/assets/migrations/20260913111407_pack_languages.sql +223 -0
- package/dist/assets/migrations/20260913112233_schema_comments_for_a_reader.sql +27 -0
- package/dist/assets/migrations/20260913114535_fec_opening_balances.sql +307 -0
- package/dist/assets/migrations/20260914103412_audit_log.sql +509 -0
- package/dist/assets/migrations/20260914111907_pack_upgrade.sql +428 -0
- package/dist/assets/migrations/20260914120500_rounding_reads_the_currency.sql +206 -0
- package/dist/assets/migrations/20260914121200_amounts_round_at_the_currency.sql +2403 -0
- package/dist/assets/migrations/20260914134325_schema_version_0_2_0.sql +36 -0
- package/dist/assets/migrations/20260914143915_accounts_in_use.sql +428 -0
- package/dist/assets/migrations/20260914144731_account_code_frozen.sql +99 -0
- package/dist/assets/migrations/20260914151207_schema_grants_its_own_rights.sql +284 -0
- package/dist/assets/migrations/20260914152840_pack_upgrade_records_its_own_line.sql +237 -0
- package/dist/assets/migrations/20260914163943_declaration_periodicity.sql +349 -0
- package/dist/assets/migrations/20260915094000_foreign_services_received.sql +37 -0
- package/dist/assets/migrations/20260915094500_mentions_read_a_foreign_service.sql +72 -0
- package/dist/assets/migrations/20260915153000_document_shares.sql +568 -0
- package/dist/assets/migrations/20260915160000_ec_sales_list.sql +181 -0
- package/dist/assets/migrations/20260915161842_pack_sources.sql +56 -0
- package/dist/assets/migrations/20260915170500_schema_version_0_3_0.sql +45 -0
- package/dist/assets/migrations/20260915174500_a_vat_category_is_a_code.sql +177 -0
- package/dist/assets/migrations/20260915180000_document_rule_references.sql +85 -0
- package/dist/assets/migrations/20260915181000_territories.sql +342 -0
- package/dist/assets/migrations/20260915181500_ec_sales_list_reads_the_territories.sql +222 -0
- package/dist/assets/migrations/20260915182000_intracom_triangular.sql +53 -0
- package/dist/assets/migrations/20260915182500_a_triangular_supply_says_reverse_charge.sql +99 -0
- package/dist/assets/migrations/20260915191200_a_document_knows_its_language.sql +633 -0
- package/dist/assets/migrations/20260915195000_a_price_that_holds_its_tax.sql +649 -0
- package/dist/assets/migrations/20260915200000_a_posting_names_its_boxes.sql +1004 -0
- package/dist/assets/migrations/20260916094500_a_company_files_more_than_one_declaration.sql +795 -0
- package/dist/assets/migrations/20260916103000_a_ledger_line_names_its_posting.sql +902 -0
- package/dist/assets/migrations/20260916123000_a_vocabulary_for_a_tax_that_is_not_a_vat.sql +144 -0
- package/dist/assets/migrations/20260916124000_a_box_can_be_a_rate_of_a_box.sql +525 -0
- package/dist/assets/migrations/20260916125000_a_tax_follows_the_territory.sql +1318 -0
- package/dist/assets/migrations/20260916126000_a_tax_point_and_its_exception.sql +1211 -0
- package/dist/assets/migrations/20260917090000_a_counterparty_that_learns.sql +561 -0
- package/dist/assets/migrations/20260917120000_what_the_money_pays.sql +545 -0
- package/dist/assets/migrations/20260917150000_a_filing_is_a_row.sql +488 -0
- package/dist/assets/migrations/20260917170000_when_it_is_due.sql +194 -0
- package/dist/assets/migrations/20260917180000_a_box_is_a_number_and_a_kind.sql +213 -0
- package/dist/assets/migrations/20260917190000_what_a_declaration_owes.sql +486 -0
- package/dist/assets/migrations/20260917200000_what_moved_after_it_went.sql +227 -0
- package/dist/assets/migrations/20260918090000_a_form_names_its_file.sql +27 -0
- package/dist/assets/migrations/20260918110000_a_deposit_and_what_came_back.sql +286 -0
- package/dist/assets/migrations/20260918113741_a_fourth_preset_is_named.sql +9 -0
- package/dist/assets/migrations/20260918113807_a_client_reads_and_hands_over.sql +96 -0
- package/dist/assets/migrations/20260918114322_two_definer_functions_check_their_caller.sql +75 -0
- package/dist/assets/migrations/20260918130000_every_company_somebody_keeps.sql +177 -0
- package/dist/assets/migrations/20260918140000_a_session_nobody_prepared.sql +40 -0
- package/dist/assets/migrations/20260918141107_a_line_keeps_the_tax_it_was_posted_with.sql +256 -0
- package/dist/assets/migrations/20260918141342_a_company_has_an_electronic_address.sql +66 -0
- package/dist/assets/migrations/20260918141605_an_invoice_reads_whole_from_the_views.sql +313 -0
- package/dist/assets/migrations/20260918141627_a_policy_asks_once.sql +132 -0
- package/dist/assets/migrations/20260918143352_a_suggestion_reads_the_words_once.sql +204 -0
- package/dist/assets/migrations/20260918143417_a_reference_of_the_caller_and_a_rehearsal.sql +115 -0
- package/dist/assets/migrations/20260918150712_a_company_leaves_with_its_books.sql +1119 -0
- package/dist/assets/migrations/20260918150931_a_statement_is_imported_once.sql +654 -0
- package/dist/assets/migrations/20260918161204_a_posted_document_does_not_move.sql +437 -0
- package/dist/assets/migrations/20260918161538_a_posted_entry_does_not_move.sql +252 -0
- package/dist/assets/migrations/20260918171946_an_entry_is_posted_by_post_entry.sql +289 -0
- package/dist/assets/migrations/20260918174312_schema_version_0_4_0.sql +53 -0
- package/dist/assets/modules/assets/module.json +13 -0
- package/dist/assets/modules/assets/supabase/migrations/20260913081447_assets.sql +1190 -0
- package/dist/assets/modules/assets/supabase/migrations/20260913104234_foreign_key_indexes.sql +47 -0
- package/dist/assets/modules/assets/supabase/migrations/20260913104500_assets_capabilities.sql +145 -0
- package/dist/assets/modules/assets/supabase/migrations/20260914122100_assets_round_at_the_currency.sql +564 -0
- package/dist/assets/modules/assets/supabase/migrations/20260914145012_assets_accounts_in_use.sql +37 -0
- package/dist/assets/modules/assets/supabase/migrations/20260914151530_assets_grants_its_own_rights.sql +63 -0
- package/dist/assets/modules/assets/supabase/migrations/20260918113912_assets_client_reads.sql +20 -0
- package/dist/assets/modules/assets/supabase/migrations/20260918150801_assets_leave_with_the_company.sql +41 -0
- package/dist/assets/modules/budgets/module.json +12 -0
- package/dist/assets/modules/budgets/supabase/migrations/20260913083012_budgets.sql +210 -0
- package/dist/assets/modules/budgets/supabase/migrations/20260913104233_foreign_key_indexes.sql +37 -0
- package/dist/assets/modules/budgets/supabase/migrations/20260913104501_budgets_capabilities.sql +72 -0
- package/dist/assets/modules/budgets/supabase/migrations/20260914122400_budgets_round_at_the_currency.sql +68 -0
- package/dist/assets/modules/budgets/supabase/migrations/20260914145048_budgets_accounts_in_use.sql +30 -0
- package/dist/assets/modules/budgets/supabase/migrations/20260914151812_budgets_grants_its_own_rights.sql +31 -0
- package/dist/assets/modules/budgets/supabase/migrations/20260918113954_budgets_client_reads.sql +20 -0
- package/dist/assets/modules/budgets/supabase/migrations/20260918150823_budgets_leave_with_the_company.sql +37 -0
- package/dist/assets/modules/schema/module.1.json +69 -0
- package/dist/assets/seed/00_currencies.sql +15 -0
- package/dist/assets/seed/00_territories.sql +280 -0
- package/dist/assets/seed/05_framework_generic.sql +110 -0
- package/dist/assets/seed/10_pack_be.sql +1284 -0
- package/dist/assets/seed/11_pack_fr.sql +1112 -0
- package/dist/assets/seed/12_pack_lu.sql +2484 -0
- package/dist/assets/seed/13_pack_ee.sql +733 -0
- package/dist/assets/seed/14_pack_gb.sql +796 -0
- package/dist/assets/seed/15_pack_us.sql +751 -0
- package/dist/assets/seed/90_demo_company.sql +368 -0
- package/dist/assets/seed/modules/assets/10_pack_be.sql +58 -0
- package/dist/assets/seed/modules/assets/11_pack_fr.sql +57 -0
- package/dist/assets/seed/modules/assets/14_pack_gb.sql +58 -0
- package/dist/assets/seed/modules/assets/15_pack_us.sql +60 -0
- package/dist/auth.d.ts +59 -0
- package/dist/auth.d.ts.map +1 -0
- package/dist/auth.js +134 -0
- package/dist/auth.js.map +1 -0
- package/dist/bin.d.ts +6 -0
- package/dist/bin.d.ts.map +1 -0
- package/dist/bin.js +8 -0
- package/dist/bin.js.map +1 -0
- package/dist/books.d.ts +60 -0
- package/dist/books.d.ts.map +1 -0
- package/dist/books.js +168 -0
- package/dist/books.js.map +1 -0
- package/dist/bootstrap.d.ts +236 -0
- package/dist/bootstrap.d.ts.map +1 -0
- package/dist/bootstrap.js +459 -0
- package/dist/bootstrap.js.map +1 -0
- package/dist/bundle.d.ts +27 -0
- package/dist/bundle.d.ts.map +1 -0
- package/dist/bundle.js +47 -0
- package/dist/bundle.js.map +1 -0
- package/dist/checklist.d.ts +35 -0
- package/dist/checklist.d.ts.map +1 -0
- package/dist/checklist.js +64 -0
- package/dist/checklist.js.map +1 -0
- package/dist/cli.d.ts +14 -0
- package/dist/cli.d.ts.map +1 -0
- package/dist/cli.js +318 -0
- package/dist/cli.js.map +1 -0
- package/dist/commands/company.d.ts +59 -0
- package/dist/commands/company.d.ts.map +1 -0
- package/dist/commands/company.js +287 -0
- package/dist/commands/company.js.map +1 -0
- package/dist/commands/contact.d.ts +9 -0
- package/dist/commands/contact.d.ts.map +1 -0
- package/dist/commands/contact.js +82 -0
- package/dist/commands/contact.js.map +1 -0
- package/dist/commands/demo.d.ts +19 -0
- package/dist/commands/demo.d.ts.map +1 -0
- package/dist/commands/demo.js +72 -0
- package/dist/commands/demo.js.map +1 -0
- package/dist/commands/doctor.d.ts +12 -0
- package/dist/commands/doctor.d.ts.map +1 -0
- package/dist/commands/doctor.js +68 -0
- package/dist/commands/doctor.js.map +1 -0
- package/dist/commands/document.d.ts +17 -0
- package/dist/commands/document.d.ts.map +1 -0
- package/dist/commands/document.js +217 -0
- package/dist/commands/document.js.map +1 -0
- package/dist/commands/init.d.ts +31 -0
- package/dist/commands/init.d.ts.map +1 -0
- package/dist/commands/init.js +535 -0
- package/dist/commands/init.js.map +1 -0
- package/dist/commands/login.d.ts +24 -0
- package/dist/commands/login.d.ts.map +1 -0
- package/dist/commands/login.js +128 -0
- package/dist/commands/login.js.map +1 -0
- package/dist/commands/migrate.d.ts +30 -0
- package/dist/commands/migrate.d.ts.map +1 -0
- package/dist/commands/migrate.js +115 -0
- package/dist/commands/migrate.js.map +1 -0
- package/dist/commands/module.d.ts +32 -0
- package/dist/commands/module.d.ts.map +1 -0
- package/dist/commands/module.js +285 -0
- package/dist/commands/module.js.map +1 -0
- package/dist/commands/pack.d.ts +22 -0
- package/dist/commands/pack.d.ts.map +1 -0
- package/dist/commands/pack.js +481 -0
- package/dist/commands/pack.js.map +1 -0
- package/dist/commands/payment.d.ts +15 -0
- package/dist/commands/payment.d.ts.map +1 -0
- package/dist/commands/payment.js +97 -0
- package/dist/commands/payment.js.map +1 -0
- package/dist/commands/register.d.ts +20 -0
- package/dist/commands/register.d.ts.map +1 -0
- package/dist/commands/register.js +134 -0
- package/dist/commands/register.js.map +1 -0
- package/dist/commands/status.d.ts +8 -0
- package/dist/commands/status.d.ts.map +1 -0
- package/dist/commands/status.js +134 -0
- package/dist/commands/status.js.map +1 -0
- package/dist/commands/whoami.d.ts +21 -0
- package/dist/commands/whoami.d.ts.map +1 -0
- package/dist/commands/whoami.js +133 -0
- package/dist/commands/whoami.js.map +1 -0
- package/dist/company.d.ts +16 -0
- package/dist/company.d.ts.map +1 -0
- package/dist/company.js +26 -0
- package/dist/company.js.map +1 -0
- package/dist/config.d.ts +24 -0
- package/dist/config.d.ts.map +1 -0
- package/dist/config.js +50 -0
- package/dist/config.js.map +1 -0
- package/dist/connection.d.ts +79 -0
- package/dist/connection.d.ts.map +1 -0
- package/dist/connection.js +116 -0
- package/dist/connection.js.map +1 -0
- package/dist/context.d.ts +47 -0
- package/dist/context.d.ts.map +1 -0
- package/dist/context.js +93 -0
- package/dist/context.js.map +1 -0
- package/dist/doctor.d.ts +45 -0
- package/dist/doctor.d.ts.map +1 -0
- package/dist/doctor.js +448 -0
- package/dist/doctor.js.map +1 -0
- package/dist/grants.d.ts +88 -0
- package/dist/grants.d.ts.map +1 -0
- package/dist/grants.js +174 -0
- package/dist/grants.js.map +1 -0
- package/dist/identity.d.ts +54 -0
- package/dist/identity.d.ts.map +1 -0
- package/dist/identity.js +132 -0
- package/dist/identity.js.map +1 -0
- package/dist/index.d.ts +44 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +43 -0
- package/dist/index.js.map +1 -0
- package/dist/inventory.d.ts +155 -0
- package/dist/inventory.d.ts.map +1 -0
- package/dist/inventory.js +303 -0
- package/dist/inventory.js.map +1 -0
- package/dist/migrations.d.ts +76 -0
- package/dist/migrations.d.ts.map +1 -0
- package/dist/migrations.js +226 -0
- package/dist/migrations.js.map +1 -0
- package/dist/module/read.d.ts +91 -0
- package/dist/module/read.d.ts.map +1 -0
- package/dist/module/read.js +172 -0
- package/dist/module/read.js.map +1 -0
- package/dist/output.d.ts +110 -0
- package/dist/output.d.ts.map +1 -0
- package/dist/output.js +173 -0
- package/dist/output.js.map +1 -0
- package/dist/pack/certification.d.ts +27 -0
- package/dist/pack/certification.d.ts.map +1 -0
- package/dist/pack/certification.js +38 -0
- package/dist/pack/certification.js.map +1 -0
- package/dist/pack/compile.d.ts +75 -0
- package/dist/pack/compile.d.ts.map +1 -0
- package/dist/pack/compile.js +801 -0
- package/dist/pack/compile.js.map +1 -0
- package/dist/pack/filing.d.ts +48 -0
- package/dist/pack/filing.d.ts.map +1 -0
- package/dist/pack/filing.js +56 -0
- package/dist/pack/filing.js.map +1 -0
- package/dist/pack/read.d.ts +766 -0
- package/dist/pack/read.d.ts.map +1 -0
- package/dist/pack/read.js +2336 -0
- package/dist/pack/read.js.map +1 -0
- package/dist/pack/schema.d.ts +26 -0
- package/dist/pack/schema.d.ts.map +1 -0
- package/dist/pack/schema.js +181 -0
- package/dist/pack/schema.js.map +1 -0
- package/dist/pack/territories.d.ts +98 -0
- package/dist/pack/territories.d.ts.map +1 -0
- package/dist/pack/territories.js +0 -0
- package/dist/pack/territories.js.map +1 -0
- package/dist/pack/upgrade.d.ts +86 -0
- package/dist/pack/upgrade.d.ts.map +1 -0
- package/dist/pack/upgrade.js +76 -0
- package/dist/pack/upgrade.js.map +1 -0
- package/dist/pack/vat-codes.d.ts +273 -0
- package/dist/pack/vat-codes.d.ts.map +1 -0
- package/dist/pack/vat-codes.js +513 -0
- package/dist/pack/vat-codes.js.map +1 -0
- package/dist/profiles.d.ts +67 -0
- package/dist/profiles.d.ts.map +1 -0
- package/dist/profiles.js +187 -0
- package/dist/profiles.js.map +1 -0
- package/dist/prompt.d.ts +40 -0
- package/dist/prompt.d.ts.map +1 -0
- package/dist/prompt.js +120 -0
- package/dist/prompt.js.map +1 -0
- package/dist/registry.d.ts +84 -0
- package/dist/registry.d.ts.map +1 -0
- package/dist/registry.js +104 -0
- package/dist/registry.js.map +1 -0
- package/dist/rest.d.ts +81 -0
- package/dist/rest.d.ts.map +1 -0
- package/dist/rest.js +245 -0
- package/dist/rest.js.map +1 -0
- package/dist/schema.d.ts +15 -0
- package/dist/schema.d.ts.map +1 -0
- package/dist/schema.js +15 -0
- package/dist/schema.js.map +1 -0
- package/dist/seeds.d.ts +27 -0
- package/dist/seeds.d.ts.map +1 -0
- package/dist/seeds.js +47 -0
- package/dist/seeds.js.map +1 -0
- package/dist/session.d.ts +39 -0
- package/dist/session.d.ts.map +1 -0
- package/dist/session.js +92 -0
- package/dist/session.js.map +1 -0
- package/dist/sql.d.ts +48 -0
- package/dist/sql.d.ts.map +1 -0
- package/dist/sql.js +112 -0
- package/dist/sql.js.map +1 -0
- package/dist/status.d.ts +94 -0
- package/dist/status.d.ts.map +1 -0
- package/dist/status.js +120 -0
- package/dist/status.js.map +1 -0
- package/dist/ui.d.ts +55 -0
- package/dist/ui.d.ts.map +1 -0
- package/dist/ui.js +114 -0
- package/dist/ui.js.map +1 -0
- package/package.json +29 -0
- 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
|