ekwo-os 0.4.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (361) hide show
  1. package/README.md +873 -0
  2. package/dist/args.d.ts +57 -0
  3. package/dist/args.d.ts.map +1 -0
  4. package/dist/args.js +134 -0
  5. package/dist/args.js.map +1 -0
  6. package/dist/assets/expected-objects.json +9432 -0
  7. package/dist/assets/migrations/20260911120000_core_companies.sql +260 -0
  8. package/dist/assets/migrations/20260911120100_accounts_journals.sql +211 -0
  9. package/dist/assets/migrations/20260911120200_contacts.sql +90 -0
  10. package/dist/assets/migrations/20260911120300_taxes.sql +146 -0
  11. package/dist/assets/migrations/20260911120400_entries.sql +338 -0
  12. package/dist/assets/migrations/20260911120500_documents.sql +279 -0
  13. package/dist/assets/migrations/20260911120600_payments_reconciliation.sql +251 -0
  14. package/dist/assets/migrations/20260911120700_bank.sql +178 -0
  15. package/dist/assets/migrations/20260911120800_analytics_attachments.sql +128 -0
  16. package/dist/assets/migrations/20260911120900_post_document.sql +279 -0
  17. package/dist/assets/migrations/20260911121000_reporting.sql +235 -0
  18. package/dist/assets/migrations/20260911121100_country_templates.sql +226 -0
  19. package/dist/assets/migrations/20260911121200_fec.sql +71 -0
  20. package/dist/assets/migrations/20260911130000_instance.sql +77 -0
  21. package/dist/assets/migrations/20260911130100_instance_members.sql +224 -0
  22. package/dist/assets/migrations/20260911140000_instance_admins.sql +112 -0
  23. package/dist/assets/migrations/20260911160000_tax_posting_templates_unique.sql +17 -0
  24. package/dist/assets/migrations/20260911170000_document_amount_paid.sql +82 -0
  25. package/dist/assets/migrations/20260911173000_post_payment.sql +122 -0
  26. package/dist/assets/migrations/20260911173100_sequence_counters_under_rls.sql +84 -0
  27. package/dist/assets/migrations/20260911183000_country_journal_defaults.sql +143 -0
  28. package/dist/assets/migrations/20260911193853_line_account_defaults.sql +274 -0
  29. package/dist/assets/migrations/20260911195054_products.sql +221 -0
  30. package/dist/assets/migrations/20260911210131_anon_surface.sql +85 -0
  31. package/dist/assets/migrations/20260912074712_country_packs.sql +318 -0
  32. package/dist/assets/migrations/20260912080311_report_code_and_region.sql +198 -0
  33. package/dist/assets/migrations/20260912081014_pack_certification_maintained.sql +31 -0
  34. package/dist/assets/migrations/20260912081015_pack_certification_backfill.sql +16 -0
  35. package/dist/assets/migrations/20260912090407_tax_report_boxes.sql +329 -0
  36. package/dist/assets/migrations/20260912091917_tax_on_base_value.sql +25 -0
  37. package/dist/assets/migrations/20260912091918_tax_engine_columns.sql +776 -0
  38. package/dist/assets/migrations/20260912094412_opening_and_closing.sql +759 -0
  39. package/dist/assets/migrations/20260912095825_charts_of_accounts.sql +326 -0
  40. package/dist/assets/migrations/20260912100412_financial_statements.sql +632 -0
  41. package/dist/assets/migrations/20260912104719_one_formula_evaluator.sql +209 -0
  42. package/dist/assets/migrations/20260912105720_entry_kind_appropriation.sql +26 -0
  43. package/dist/assets/migrations/20260912105721_appropriation_entry_kind.sql +444 -0
  44. package/dist/assets/migrations/20260912111751_document_rules.sql +274 -0
  45. package/dist/assets/migrations/20260912112132_cash_basis_vat_and_fx.sql +1189 -0
  46. package/dist/assets/migrations/20260913074512_modules.sql +493 -0
  47. package/dist/assets/migrations/20260913075903_asset_disposal_roles.sql +47 -0
  48. package/dist/assets/migrations/20260913083216_capabilities.sql +637 -0
  49. package/dist/assets/migrations/20260913083901_company_invitations.sql +262 -0
  50. package/dist/assets/migrations/20260913084402_user_preferences.sql +376 -0
  51. package/dist/assets/migrations/20260913084847_company_profile.sql +261 -0
  52. package/dist/assets/migrations/20260913085436_numbering_reads_the_pack.sql +235 -0
  53. package/dist/assets/migrations/20260913085932_api_keys.sql +303 -0
  54. package/dist/assets/migrations/20260913090216_fiscal_year_bounds.sql +159 -0
  55. package/dist/assets/migrations/20260913092527_entries_import.sql +219 -0
  56. package/dist/assets/migrations/20260913101536_null_safe_guards.sql +188 -0
  57. package/dist/assets/migrations/20260913102115_installer_is_named.sql +462 -0
  58. package/dist/assets/migrations/20260913102758_no_currency_default.sql +138 -0
  59. package/dist/assets/migrations/20260913103355_cash_basis_needs_a_box.sql +453 -0
  60. package/dist/assets/migrations/20260913104014_aged_balance_named_group.sql +79 -0
  61. package/dist/assets/migrations/20260913104232_foreign_key_indexes.sql +144 -0
  62. package/dist/assets/migrations/20260913105120_declared_no_reader.sql +41 -0
  63. package/dist/assets/migrations/20260913111407_pack_languages.sql +223 -0
  64. package/dist/assets/migrations/20260913112233_schema_comments_for_a_reader.sql +27 -0
  65. package/dist/assets/migrations/20260913114535_fec_opening_balances.sql +307 -0
  66. package/dist/assets/migrations/20260914103412_audit_log.sql +509 -0
  67. package/dist/assets/migrations/20260914111907_pack_upgrade.sql +428 -0
  68. package/dist/assets/migrations/20260914120500_rounding_reads_the_currency.sql +206 -0
  69. package/dist/assets/migrations/20260914121200_amounts_round_at_the_currency.sql +2403 -0
  70. package/dist/assets/migrations/20260914134325_schema_version_0_2_0.sql +36 -0
  71. package/dist/assets/migrations/20260914143915_accounts_in_use.sql +428 -0
  72. package/dist/assets/migrations/20260914144731_account_code_frozen.sql +99 -0
  73. package/dist/assets/migrations/20260914151207_schema_grants_its_own_rights.sql +284 -0
  74. package/dist/assets/migrations/20260914152840_pack_upgrade_records_its_own_line.sql +237 -0
  75. package/dist/assets/migrations/20260914163943_declaration_periodicity.sql +349 -0
  76. package/dist/assets/migrations/20260915094000_foreign_services_received.sql +37 -0
  77. package/dist/assets/migrations/20260915094500_mentions_read_a_foreign_service.sql +72 -0
  78. package/dist/assets/migrations/20260915153000_document_shares.sql +568 -0
  79. package/dist/assets/migrations/20260915160000_ec_sales_list.sql +181 -0
  80. package/dist/assets/migrations/20260915161842_pack_sources.sql +56 -0
  81. package/dist/assets/migrations/20260915170500_schema_version_0_3_0.sql +45 -0
  82. package/dist/assets/migrations/20260915174500_a_vat_category_is_a_code.sql +177 -0
  83. package/dist/assets/migrations/20260915180000_document_rule_references.sql +85 -0
  84. package/dist/assets/migrations/20260915181000_territories.sql +342 -0
  85. package/dist/assets/migrations/20260915181500_ec_sales_list_reads_the_territories.sql +222 -0
  86. package/dist/assets/migrations/20260915182000_intracom_triangular.sql +53 -0
  87. package/dist/assets/migrations/20260915182500_a_triangular_supply_says_reverse_charge.sql +99 -0
  88. package/dist/assets/migrations/20260915191200_a_document_knows_its_language.sql +633 -0
  89. package/dist/assets/migrations/20260915195000_a_price_that_holds_its_tax.sql +649 -0
  90. package/dist/assets/migrations/20260915200000_a_posting_names_its_boxes.sql +1004 -0
  91. package/dist/assets/migrations/20260916094500_a_company_files_more_than_one_declaration.sql +795 -0
  92. package/dist/assets/migrations/20260916103000_a_ledger_line_names_its_posting.sql +902 -0
  93. package/dist/assets/migrations/20260916123000_a_vocabulary_for_a_tax_that_is_not_a_vat.sql +144 -0
  94. package/dist/assets/migrations/20260916124000_a_box_can_be_a_rate_of_a_box.sql +525 -0
  95. package/dist/assets/migrations/20260916125000_a_tax_follows_the_territory.sql +1318 -0
  96. package/dist/assets/migrations/20260916126000_a_tax_point_and_its_exception.sql +1211 -0
  97. package/dist/assets/migrations/20260917090000_a_counterparty_that_learns.sql +561 -0
  98. package/dist/assets/migrations/20260917120000_what_the_money_pays.sql +545 -0
  99. package/dist/assets/migrations/20260917150000_a_filing_is_a_row.sql +488 -0
  100. package/dist/assets/migrations/20260917170000_when_it_is_due.sql +194 -0
  101. package/dist/assets/migrations/20260917180000_a_box_is_a_number_and_a_kind.sql +213 -0
  102. package/dist/assets/migrations/20260917190000_what_a_declaration_owes.sql +486 -0
  103. package/dist/assets/migrations/20260917200000_what_moved_after_it_went.sql +227 -0
  104. package/dist/assets/migrations/20260918090000_a_form_names_its_file.sql +27 -0
  105. package/dist/assets/migrations/20260918110000_a_deposit_and_what_came_back.sql +286 -0
  106. package/dist/assets/migrations/20260918113741_a_fourth_preset_is_named.sql +9 -0
  107. package/dist/assets/migrations/20260918113807_a_client_reads_and_hands_over.sql +96 -0
  108. package/dist/assets/migrations/20260918114322_two_definer_functions_check_their_caller.sql +75 -0
  109. package/dist/assets/migrations/20260918130000_every_company_somebody_keeps.sql +177 -0
  110. package/dist/assets/migrations/20260918140000_a_session_nobody_prepared.sql +40 -0
  111. package/dist/assets/migrations/20260918141107_a_line_keeps_the_tax_it_was_posted_with.sql +256 -0
  112. package/dist/assets/migrations/20260918141342_a_company_has_an_electronic_address.sql +66 -0
  113. package/dist/assets/migrations/20260918141605_an_invoice_reads_whole_from_the_views.sql +313 -0
  114. package/dist/assets/migrations/20260918141627_a_policy_asks_once.sql +132 -0
  115. package/dist/assets/migrations/20260918143352_a_suggestion_reads_the_words_once.sql +204 -0
  116. package/dist/assets/migrations/20260918143417_a_reference_of_the_caller_and_a_rehearsal.sql +115 -0
  117. package/dist/assets/migrations/20260918150712_a_company_leaves_with_its_books.sql +1119 -0
  118. package/dist/assets/migrations/20260918150931_a_statement_is_imported_once.sql +654 -0
  119. package/dist/assets/migrations/20260918161204_a_posted_document_does_not_move.sql +437 -0
  120. package/dist/assets/migrations/20260918161538_a_posted_entry_does_not_move.sql +252 -0
  121. package/dist/assets/migrations/20260918171946_an_entry_is_posted_by_post_entry.sql +289 -0
  122. package/dist/assets/migrations/20260918174312_schema_version_0_4_0.sql +53 -0
  123. package/dist/assets/modules/assets/module.json +13 -0
  124. package/dist/assets/modules/assets/supabase/migrations/20260913081447_assets.sql +1190 -0
  125. package/dist/assets/modules/assets/supabase/migrations/20260913104234_foreign_key_indexes.sql +47 -0
  126. package/dist/assets/modules/assets/supabase/migrations/20260913104500_assets_capabilities.sql +145 -0
  127. package/dist/assets/modules/assets/supabase/migrations/20260914122100_assets_round_at_the_currency.sql +564 -0
  128. package/dist/assets/modules/assets/supabase/migrations/20260914145012_assets_accounts_in_use.sql +37 -0
  129. package/dist/assets/modules/assets/supabase/migrations/20260914151530_assets_grants_its_own_rights.sql +63 -0
  130. package/dist/assets/modules/assets/supabase/migrations/20260918113912_assets_client_reads.sql +20 -0
  131. package/dist/assets/modules/assets/supabase/migrations/20260918150801_assets_leave_with_the_company.sql +41 -0
  132. package/dist/assets/modules/budgets/module.json +12 -0
  133. package/dist/assets/modules/budgets/supabase/migrations/20260913083012_budgets.sql +210 -0
  134. package/dist/assets/modules/budgets/supabase/migrations/20260913104233_foreign_key_indexes.sql +37 -0
  135. package/dist/assets/modules/budgets/supabase/migrations/20260913104501_budgets_capabilities.sql +72 -0
  136. package/dist/assets/modules/budgets/supabase/migrations/20260914122400_budgets_round_at_the_currency.sql +68 -0
  137. package/dist/assets/modules/budgets/supabase/migrations/20260914145048_budgets_accounts_in_use.sql +30 -0
  138. package/dist/assets/modules/budgets/supabase/migrations/20260914151812_budgets_grants_its_own_rights.sql +31 -0
  139. package/dist/assets/modules/budgets/supabase/migrations/20260918113954_budgets_client_reads.sql +20 -0
  140. package/dist/assets/modules/budgets/supabase/migrations/20260918150823_budgets_leave_with_the_company.sql +37 -0
  141. package/dist/assets/modules/schema/module.1.json +69 -0
  142. package/dist/assets/seed/00_currencies.sql +15 -0
  143. package/dist/assets/seed/00_territories.sql +280 -0
  144. package/dist/assets/seed/05_framework_generic.sql +110 -0
  145. package/dist/assets/seed/10_pack_be.sql +1284 -0
  146. package/dist/assets/seed/11_pack_fr.sql +1112 -0
  147. package/dist/assets/seed/12_pack_lu.sql +2484 -0
  148. package/dist/assets/seed/13_pack_ee.sql +733 -0
  149. package/dist/assets/seed/14_pack_gb.sql +796 -0
  150. package/dist/assets/seed/15_pack_us.sql +751 -0
  151. package/dist/assets/seed/90_demo_company.sql +368 -0
  152. package/dist/assets/seed/modules/assets/10_pack_be.sql +58 -0
  153. package/dist/assets/seed/modules/assets/11_pack_fr.sql +57 -0
  154. package/dist/assets/seed/modules/assets/14_pack_gb.sql +58 -0
  155. package/dist/assets/seed/modules/assets/15_pack_us.sql +60 -0
  156. package/dist/auth.d.ts +59 -0
  157. package/dist/auth.d.ts.map +1 -0
  158. package/dist/auth.js +134 -0
  159. package/dist/auth.js.map +1 -0
  160. package/dist/bin.d.ts +6 -0
  161. package/dist/bin.d.ts.map +1 -0
  162. package/dist/bin.js +8 -0
  163. package/dist/bin.js.map +1 -0
  164. package/dist/books.d.ts +60 -0
  165. package/dist/books.d.ts.map +1 -0
  166. package/dist/books.js +168 -0
  167. package/dist/books.js.map +1 -0
  168. package/dist/bootstrap.d.ts +236 -0
  169. package/dist/bootstrap.d.ts.map +1 -0
  170. package/dist/bootstrap.js +459 -0
  171. package/dist/bootstrap.js.map +1 -0
  172. package/dist/bundle.d.ts +27 -0
  173. package/dist/bundle.d.ts.map +1 -0
  174. package/dist/bundle.js +47 -0
  175. package/dist/bundle.js.map +1 -0
  176. package/dist/checklist.d.ts +35 -0
  177. package/dist/checklist.d.ts.map +1 -0
  178. package/dist/checklist.js +64 -0
  179. package/dist/checklist.js.map +1 -0
  180. package/dist/cli.d.ts +14 -0
  181. package/dist/cli.d.ts.map +1 -0
  182. package/dist/cli.js +318 -0
  183. package/dist/cli.js.map +1 -0
  184. package/dist/commands/company.d.ts +59 -0
  185. package/dist/commands/company.d.ts.map +1 -0
  186. package/dist/commands/company.js +287 -0
  187. package/dist/commands/company.js.map +1 -0
  188. package/dist/commands/contact.d.ts +9 -0
  189. package/dist/commands/contact.d.ts.map +1 -0
  190. package/dist/commands/contact.js +82 -0
  191. package/dist/commands/contact.js.map +1 -0
  192. package/dist/commands/demo.d.ts +19 -0
  193. package/dist/commands/demo.d.ts.map +1 -0
  194. package/dist/commands/demo.js +72 -0
  195. package/dist/commands/demo.js.map +1 -0
  196. package/dist/commands/doctor.d.ts +12 -0
  197. package/dist/commands/doctor.d.ts.map +1 -0
  198. package/dist/commands/doctor.js +68 -0
  199. package/dist/commands/doctor.js.map +1 -0
  200. package/dist/commands/document.d.ts +17 -0
  201. package/dist/commands/document.d.ts.map +1 -0
  202. package/dist/commands/document.js +217 -0
  203. package/dist/commands/document.js.map +1 -0
  204. package/dist/commands/init.d.ts +31 -0
  205. package/dist/commands/init.d.ts.map +1 -0
  206. package/dist/commands/init.js +535 -0
  207. package/dist/commands/init.js.map +1 -0
  208. package/dist/commands/login.d.ts +24 -0
  209. package/dist/commands/login.d.ts.map +1 -0
  210. package/dist/commands/login.js +128 -0
  211. package/dist/commands/login.js.map +1 -0
  212. package/dist/commands/migrate.d.ts +30 -0
  213. package/dist/commands/migrate.d.ts.map +1 -0
  214. package/dist/commands/migrate.js +115 -0
  215. package/dist/commands/migrate.js.map +1 -0
  216. package/dist/commands/module.d.ts +32 -0
  217. package/dist/commands/module.d.ts.map +1 -0
  218. package/dist/commands/module.js +285 -0
  219. package/dist/commands/module.js.map +1 -0
  220. package/dist/commands/pack.d.ts +22 -0
  221. package/dist/commands/pack.d.ts.map +1 -0
  222. package/dist/commands/pack.js +481 -0
  223. package/dist/commands/pack.js.map +1 -0
  224. package/dist/commands/payment.d.ts +15 -0
  225. package/dist/commands/payment.d.ts.map +1 -0
  226. package/dist/commands/payment.js +97 -0
  227. package/dist/commands/payment.js.map +1 -0
  228. package/dist/commands/register.d.ts +20 -0
  229. package/dist/commands/register.d.ts.map +1 -0
  230. package/dist/commands/register.js +134 -0
  231. package/dist/commands/register.js.map +1 -0
  232. package/dist/commands/status.d.ts +8 -0
  233. package/dist/commands/status.d.ts.map +1 -0
  234. package/dist/commands/status.js +134 -0
  235. package/dist/commands/status.js.map +1 -0
  236. package/dist/commands/whoami.d.ts +21 -0
  237. package/dist/commands/whoami.d.ts.map +1 -0
  238. package/dist/commands/whoami.js +133 -0
  239. package/dist/commands/whoami.js.map +1 -0
  240. package/dist/company.d.ts +16 -0
  241. package/dist/company.d.ts.map +1 -0
  242. package/dist/company.js +26 -0
  243. package/dist/company.js.map +1 -0
  244. package/dist/config.d.ts +24 -0
  245. package/dist/config.d.ts.map +1 -0
  246. package/dist/config.js +50 -0
  247. package/dist/config.js.map +1 -0
  248. package/dist/connection.d.ts +79 -0
  249. package/dist/connection.d.ts.map +1 -0
  250. package/dist/connection.js +116 -0
  251. package/dist/connection.js.map +1 -0
  252. package/dist/context.d.ts +47 -0
  253. package/dist/context.d.ts.map +1 -0
  254. package/dist/context.js +93 -0
  255. package/dist/context.js.map +1 -0
  256. package/dist/doctor.d.ts +45 -0
  257. package/dist/doctor.d.ts.map +1 -0
  258. package/dist/doctor.js +448 -0
  259. package/dist/doctor.js.map +1 -0
  260. package/dist/grants.d.ts +88 -0
  261. package/dist/grants.d.ts.map +1 -0
  262. package/dist/grants.js +174 -0
  263. package/dist/grants.js.map +1 -0
  264. package/dist/identity.d.ts +54 -0
  265. package/dist/identity.d.ts.map +1 -0
  266. package/dist/identity.js +132 -0
  267. package/dist/identity.js.map +1 -0
  268. package/dist/index.d.ts +44 -0
  269. package/dist/index.d.ts.map +1 -0
  270. package/dist/index.js +43 -0
  271. package/dist/index.js.map +1 -0
  272. package/dist/inventory.d.ts +155 -0
  273. package/dist/inventory.d.ts.map +1 -0
  274. package/dist/inventory.js +303 -0
  275. package/dist/inventory.js.map +1 -0
  276. package/dist/migrations.d.ts +76 -0
  277. package/dist/migrations.d.ts.map +1 -0
  278. package/dist/migrations.js +226 -0
  279. package/dist/migrations.js.map +1 -0
  280. package/dist/module/read.d.ts +91 -0
  281. package/dist/module/read.d.ts.map +1 -0
  282. package/dist/module/read.js +172 -0
  283. package/dist/module/read.js.map +1 -0
  284. package/dist/output.d.ts +110 -0
  285. package/dist/output.d.ts.map +1 -0
  286. package/dist/output.js +173 -0
  287. package/dist/output.js.map +1 -0
  288. package/dist/pack/certification.d.ts +27 -0
  289. package/dist/pack/certification.d.ts.map +1 -0
  290. package/dist/pack/certification.js +38 -0
  291. package/dist/pack/certification.js.map +1 -0
  292. package/dist/pack/compile.d.ts +75 -0
  293. package/dist/pack/compile.d.ts.map +1 -0
  294. package/dist/pack/compile.js +801 -0
  295. package/dist/pack/compile.js.map +1 -0
  296. package/dist/pack/filing.d.ts +48 -0
  297. package/dist/pack/filing.d.ts.map +1 -0
  298. package/dist/pack/filing.js +56 -0
  299. package/dist/pack/filing.js.map +1 -0
  300. package/dist/pack/read.d.ts +766 -0
  301. package/dist/pack/read.d.ts.map +1 -0
  302. package/dist/pack/read.js +2336 -0
  303. package/dist/pack/read.js.map +1 -0
  304. package/dist/pack/schema.d.ts +26 -0
  305. package/dist/pack/schema.d.ts.map +1 -0
  306. package/dist/pack/schema.js +181 -0
  307. package/dist/pack/schema.js.map +1 -0
  308. package/dist/pack/territories.d.ts +98 -0
  309. package/dist/pack/territories.d.ts.map +1 -0
  310. package/dist/pack/territories.js +0 -0
  311. package/dist/pack/territories.js.map +1 -0
  312. package/dist/pack/upgrade.d.ts +86 -0
  313. package/dist/pack/upgrade.d.ts.map +1 -0
  314. package/dist/pack/upgrade.js +76 -0
  315. package/dist/pack/upgrade.js.map +1 -0
  316. package/dist/pack/vat-codes.d.ts +273 -0
  317. package/dist/pack/vat-codes.d.ts.map +1 -0
  318. package/dist/pack/vat-codes.js +513 -0
  319. package/dist/pack/vat-codes.js.map +1 -0
  320. package/dist/profiles.d.ts +67 -0
  321. package/dist/profiles.d.ts.map +1 -0
  322. package/dist/profiles.js +187 -0
  323. package/dist/profiles.js.map +1 -0
  324. package/dist/prompt.d.ts +40 -0
  325. package/dist/prompt.d.ts.map +1 -0
  326. package/dist/prompt.js +120 -0
  327. package/dist/prompt.js.map +1 -0
  328. package/dist/registry.d.ts +84 -0
  329. package/dist/registry.d.ts.map +1 -0
  330. package/dist/registry.js +104 -0
  331. package/dist/registry.js.map +1 -0
  332. package/dist/rest.d.ts +81 -0
  333. package/dist/rest.d.ts.map +1 -0
  334. package/dist/rest.js +245 -0
  335. package/dist/rest.js.map +1 -0
  336. package/dist/schema.d.ts +15 -0
  337. package/dist/schema.d.ts.map +1 -0
  338. package/dist/schema.js +15 -0
  339. package/dist/schema.js.map +1 -0
  340. package/dist/seeds.d.ts +27 -0
  341. package/dist/seeds.d.ts.map +1 -0
  342. package/dist/seeds.js +47 -0
  343. package/dist/seeds.js.map +1 -0
  344. package/dist/session.d.ts +39 -0
  345. package/dist/session.d.ts.map +1 -0
  346. package/dist/session.js +92 -0
  347. package/dist/session.js.map +1 -0
  348. package/dist/sql.d.ts +48 -0
  349. package/dist/sql.d.ts.map +1 -0
  350. package/dist/sql.js +112 -0
  351. package/dist/sql.js.map +1 -0
  352. package/dist/status.d.ts +94 -0
  353. package/dist/status.d.ts.map +1 -0
  354. package/dist/status.js +120 -0
  355. package/dist/status.js.map +1 -0
  356. package/dist/ui.d.ts +55 -0
  357. package/dist/ui.d.ts.map +1 -0
  358. package/dist/ui.js +114 -0
  359. package/dist/ui.js.map +1 -0
  360. package/package.json +29 -0
  361. package/schema/output.1.json +613 -0
@@ -0,0 +1,1119 @@
1
+ -- Ekwo OS — a company leaves an installation with its books, and arrives in
2
+ -- another one alive.
3
+ --
4
+ -- A firm keeps the books of N companies in one installation. That is the
5
+ -- normal case and the schema was shaped for it. It is also a trap if nothing
6
+ -- else is said: `pg_dump` takes the whole installation or nothing, so the one
7
+ -- company whose owner changes accountant cannot be handed its ledger — only
8
+ -- the firm's goodwill can. "The client owns the books" was a sentence.
9
+ --
10
+ -- This migration makes it a function, in four parts.
11
+ --
12
+ -- **1. What belongs to a company is read from the catalogue, not from a
13
+ -- list.** A table is *of a company* when it carries `company_id`, or reaches
14
+ -- `companies` by a foreign key, directly or through another such table.
15
+ -- `company_scoped_tables()` works that out from `pg_constraint` and
16
+ -- `pg_attribute`. `company_archive_registry` then says, table by table, one of
17
+ -- two things: `exported`, or `excluded` with a sentence saying why. A table
18
+ -- that is of a company and says neither is *unclassified*, and an export
19
+ -- refuses to run while one exists — so the day somebody adds a table and
20
+ -- forgets this file, the archive does not quietly become incomplete. It stops,
21
+ -- by name. A test asks the same question of every checkout.
22
+ --
23
+ -- A module has its own tables and follows the same rule without the socle
24
+ -- knowing it: the convention is `<schema>.archive_tables()`, looked up the way
25
+ -- `disable_module()` looks up `<schema>.can_disable()`. A function in the
26
+ -- module's schema rather than rows in the socle's table, because a module
27
+ -- migration may be applied on a socle that predates this file, where the table
28
+ -- does not exist yet.
29
+ --
30
+ -- **2. Leaving needs a right of its own, `company.export`, and runs as the
31
+ -- person who holds it.** Every export function is `security invoker`: it reads
32
+ -- what row level security lets its caller read, and there is no exemption for
33
+ -- the installer or for `service_role` — whoever holds the database already has
34
+ -- `pg_dump`. What row level security would do on its own, though, is hand back
35
+ -- *fewer rows and say nothing*: a member whose `bank.read` was revoked would
36
+ -- leave with books that have no bank in them and a manifest that looks whole.
37
+ -- So each table is counted twice — as the caller, and by a definer function
38
+ -- that answers a number and nothing else — and a difference is
39
+ -- `export_incomplete`, by table.
40
+ --
41
+ -- The two readers are `stable`, so they read the snapshot of the statement
42
+ -- that calls them, and `export_company()` calls both in one statement: an
43
+ -- archive whose lines were read a moment after their entries is one that fails
44
+ -- on arrival, after the company has left. Read table by table, they need a
45
+ -- repeatable read transaction around them, which is what the CLI opens.
46
+ --
47
+ -- `owner` holds it, and so does `client`. The reasoning is in
48
+ -- `docs/decisions.md` under this date; the short form is that a right to leave
49
+ -- which only the firm can exercise is not a right of the client.
50
+ --
51
+ -- **3. The archive is rows, as JSON, table by table.** One object per row with
52
+ -- the column names the schema documents; decimals as strings, so that no
53
+ -- reader rounds an amount by parsing it; timestamps in UTC; rows in primary
54
+ -- key order. The checksum of a table is the sha256 of its rows written one per
55
+ -- line, which is also exactly the file the CLI writes — `shasum` checks an
56
+ -- archive without Ekwo. `docs/company-archive.md` is the format.
57
+ --
58
+ -- **4. Arriving is one call, by whoever may create a company here.**
59
+ -- `import_company()` does not replay the books through `post_entry()`: a
60
+ -- posted entry is immutable and numbered, and replaying it would draw new
61
+ -- numbers, stamp today's date on every act of the audit trail and ask the
62
+ -- period locks for permission to rebuild what they protect. It inserts the
63
+ -- rows, with the user triggers of the tables it fills switched off for the
64
+ -- length of its own transaction — which takes a lock that makes every other
65
+ -- writer of those tables wait, so nobody books without guards meanwhile —
66
+ -- foreign keys left on, and then checks what the triggers would have
67
+ -- guaranteed: every row belongs to the company that arrived, no reference
68
+ -- crosses into a company that was already here, every entry balances and
69
+ -- agrees with its lines, the matching agrees with the matched amounts, no
70
+ -- counter is behind a number or a letter already used, no two financial years
71
+ -- overlap. One failure and nothing of it stays.
72
+ --
73
+ -- It is `security definer`, because nobody holds a right on a company that
74
+ -- does not exist yet, and it checks its caller on its first line: the
75
+ -- installer, or an administrator of this installation — the two who may call
76
+ -- `create_company()` — written `is not true`, because `if not NULL` does not
77
+ -- raise: `is_installer()` answered NULL on a connection that never set
78
+ -- `ekwo.installing`, until `20260918140000`, and the first draft of this
79
+ -- function is how that was found. Identifiers are kept. A company that is already here is
80
+ -- refused, which is also the answer to running the same import twice.
81
+ --
82
+ -- What does not travel, and why, is in the registry rows below and in the
83
+ -- documentation: the people (`company_members`, `company_invitations`), the
84
+ -- secrets (`api_keys`, `document_shares`), a user's own preferences. The
85
+ -- identifiers of the people who acted stay on the rows as a trace — none of
86
+ -- those columns is a foreign key — and name nobody in the new installation.
87
+ -- The bytes of the attachments are not in the database and are not in the
88
+ -- archive: the manifest lists them, path, size and checksum.
89
+
90
+ -- ---------------------------------------------------------------------------
91
+ -- The right
92
+ -- ---------------------------------------------------------------------------
93
+
94
+ insert into capabilities (code, area, description) values
95
+ ('company.export', 'company',
96
+ 'Leave with the books: read every row of the company into an archive another installation can take in. It reads, and reads everything, which is why it is not part of any reading capability.')
97
+ on conflict (code) do nothing;
98
+
99
+ insert into role_capabilities (role, capability) values
100
+ ('owner'::member_role, 'company.export'),
101
+ ('client'::member_role, 'company.export')
102
+ on conflict do nothing;
103
+
104
+ -- ---------------------------------------------------------------------------
105
+ -- Versions
106
+ -- ---------------------------------------------------------------------------
107
+
108
+ create or replace function version_at_least(p_version text, p_floor text)
109
+ returns boolean
110
+ language sql
111
+ immutable
112
+ as $$
113
+ select string_to_array(p_version, '.')::integer[] >= string_to_array(p_floor, '.')::integer[];
114
+ $$;
115
+
116
+ comment on function version_at_least(text, text) is
117
+ 'Whether a three-part version is at or above another, number by number: 0.10.0 is above 0.9.0, which a comparison of text gets wrong.';
118
+
119
+ -- ---------------------------------------------------------------------------
120
+ -- The registry
121
+ -- ---------------------------------------------------------------------------
122
+
123
+ create table company_archive_registry (
124
+ table_schema text not null default 'public',
125
+ table_name text not null,
126
+ disposition text not null,
127
+ reason text,
128
+ via_column text,
129
+ via_table text,
130
+ load_order integer,
131
+ primary key (table_schema, table_name),
132
+ constraint company_archive_registry_disposition
133
+ check (disposition in ('exported', 'excluded')),
134
+ constraint company_archive_registry_excluded_says_why
135
+ check (disposition = 'exported' or length(coalesce(reason, '')) >= 20),
136
+ constraint company_archive_registry_exported_has_an_order
137
+ check (disposition = 'excluded' or load_order is not null),
138
+ constraint company_archive_registry_via_is_a_pair
139
+ check ((via_column is null) = (via_table is null))
140
+ );
141
+
142
+ comment on table company_archive_registry is
143
+ 'What an archive of one company does with each table of the socle that belongs to a company: `exported`, or `excluded` with the reason. A table of a company that is not named here stops every export. Modules answer for their own tables through `<schema>.archive_tables()`.';
144
+ comment on column company_archive_registry.reason is
145
+ 'Why the table stays behind. Required on an excluded table; on an exported one, what a reader should know about it.';
146
+ comment on column company_archive_registry.via_column is
147
+ 'For a table with no `company_id`: the column that leads to one that has it.';
148
+ comment on column company_archive_registry.via_table is
149
+ 'The table `via_column` points at, schema included. It carries `company_id`.';
150
+ comment on column company_archive_registry.load_order is
151
+ 'The order an import fills the tables in. A reference that points forward, or at its own table, is filled in a second pass and has to be nullable.';
152
+
153
+ alter table company_archive_registry enable row level security;
154
+
155
+ create policy company_archive_registry_select on company_archive_registry
156
+ for select to authenticated using (true);
157
+
158
+ comment on policy company_archive_registry_select on company_archive_registry is
159
+ 'Reference data of the installation: which tables an archive carries is not a secret, and an export runs as its caller.';
160
+
161
+ revoke all on table company_archive_registry from public, anon;
162
+ grant select on table company_archive_registry to authenticated, service_role;
163
+
164
+ insert into company_archive_registry (table_name, disposition, load_order, via_column, via_table, reason) values
165
+ ('companies', 'exported', 10, null, null, null),
166
+ ('company_packs', 'exported', 20, null, null, null),
167
+ ('company_modules', 'exported', 21, null, null, 'Which modules the company had on. `enabled_by` is a trace, not a user of the new installation.'),
168
+ ('company_filing_periods', 'exported', 22, null, null, null),
169
+ ('matching_settings', 'exported', 23, null, null, null),
170
+ ('matching_sequences', 'exported', 24, null, null, 'The counter of the matching letters, so the next one continues the series.'),
171
+ ('fiscal_years', 'exported', 30, null, null, null),
172
+ ('accounts', 'exported', 31, null, null, null),
173
+ ('bank_accounts', 'exported', 32, null, null, null),
174
+ ('journals', 'exported', 33, null, null, null),
175
+ ('journal_sequences', 'exported', 34, 'journal_id', 'public.journals', 'The counters of the numbered books, so the next entry continues the series.'),
176
+ ('analytic_axes', 'exported', 35, null, null, null),
177
+ ('analytic_values', 'exported', 36, null, null, null),
178
+ ('taxes', 'exported', 37, null, null, null),
179
+ ('tax_postings', 'exported', 38, null, null, null),
180
+ ('contacts', 'exported', 39, null, null, null),
181
+ ('contact_patterns', 'exported', 40, null, null, null),
182
+ ('products', 'exported', 41, null, null, null),
183
+ ('entries', 'exported', 50, null, null, null),
184
+ ('entry_lines', 'exported', 51, null, null, null),
185
+ ('entry_line_analytics', 'exported', 52, null, null, null),
186
+ ('documents', 'exported', 53, null, null, null),
187
+ ('document_lines', 'exported', 54, null, null, null),
188
+ ('payments', 'exported', 55, null, null, null),
189
+ ('reconciliations', 'exported', 56, null, null, null),
190
+ ('bank_statements', 'exported', 57, null, null, null),
191
+ ('bank_transactions', 'exported', 58, null, null, null),
192
+ ('attachments', 'exported', 60, null, null, 'The rows, not the bytes: `storage_path`, `byte_size` and `checksum` say which files to carry across by other means.'),
193
+ ('tax_filings', 'exported', 61, null, null, null),
194
+ ('tax_filing_boxes', 'exported', 62, 'filing_id', 'public.tax_filings', 'The figures a declaration was frozen with.'),
195
+ ('tax_filing_deposits', 'exported', 63, 'filing_id', 'public.tax_filings', 'The proof a declaration went: when, by which channel, under which reference, and what came back.'),
196
+ ('audit_log', 'exported', 90, null, null, 'The trail of the company, with the identifiers of whoever acted kept as a trace. Row ids are local to an installation and are drawn again on arrival.'),
197
+ ('api_keys', 'excluded', null, null, null, 'A credential of the installation it was issued in. A key that worked in two places would be a secret nobody can withdraw.'),
198
+ ('company_invitations', 'excluded', null, null, null, 'A pending invitation is a token of this installation and the address of a person who has not agreed to be anywhere else.'),
199
+ ('company_members', 'excluded', null, null, null, 'Who may read a company is decided where it lives. The users of one installation do not exist in another.'),
200
+ ('document_shares', 'excluded', null, null, null, 'A shared link is a secret and points at this installation. It stays valid here and means nothing elsewhere.'),
201
+ ('user_preferences', 'excluded', null, null, null, 'Belongs to a person, not to a company: it only names the company somebody opens first.')
202
+ on conflict (table_schema, table_name) do nothing;
203
+
204
+ -- ---------------------------------------------------------------------------
205
+ -- What the catalogue says belongs to a company
206
+ -- ---------------------------------------------------------------------------
207
+
208
+ create or replace function company_scoped_tables()
209
+ returns table (table_schema text, table_name text, has_company_id boolean)
210
+ language sql
211
+ stable
212
+ set search_path = public, pg_temp
213
+ as $$
214
+ with recursive reached (oid) as (
215
+ select 'public.companies'::regclass::oid
216
+ union
217
+ select c.conrelid
218
+ from pg_constraint c
219
+ join reached r on c.confrelid = r.oid
220
+ where c.contype = 'f'
221
+ ),
222
+ named (oid) as (
223
+ select a.attrelid
224
+ from pg_attribute a
225
+ where a.attname = 'company_id' and a.attnum > 0 and not a.attisdropped
226
+ )
227
+ select n.nspname::text, k.relname::text,
228
+ exists (select 1 from named m where m.oid = k.oid)
229
+ from pg_class k
230
+ join pg_namespace n on n.oid = k.relnamespace
231
+ where k.relkind in ('r', 'p')
232
+ and n.nspname !~ '^pg_' and n.nspname <> 'information_schema'
233
+ and (k.oid in (select oid from reached) or k.oid in (select oid from named))
234
+ order by 1, 2;
235
+ $$;
236
+
237
+ comment on function company_scoped_tables() is
238
+ 'Every table that belongs to a company, read from the catalogue: it carries `company_id`, or a foreign key leads from it to `companies`, directly or through another such table.';
239
+
240
+ create or replace function company_archive_tables()
241
+ returns table (
242
+ table_schema text,
243
+ table_name text,
244
+ disposition text,
245
+ reason text,
246
+ via_column text,
247
+ via_table text,
248
+ load_order integer
249
+ )
250
+ language plpgsql
251
+ stable
252
+ set search_path = public, pg_temp
253
+ as $$
254
+ declare
255
+ v_module record;
256
+ v_rank integer := 0;
257
+ begin
258
+ return query
259
+ select r.table_schema, r.table_name, r.disposition, r.reason,
260
+ r.via_column, r.via_table, r.load_order
261
+ from company_archive_registry r;
262
+
263
+ -- A module comes after the socle, always: it depends on `public` by foreign
264
+ -- key and the dependency points one way.
265
+ for v_module in select m.code, m.schema_name from modules m order by m.code loop
266
+ v_rank := v_rank + 1;
267
+ if to_regprocedure(format('%I.archive_tables()', v_module.schema_name)) is not null then
268
+ return query execute format(
269
+ 'select %L::text, a.table_name, a.disposition, a.reason, a.via_column, a.via_table,
270
+ %s + a.load_order
271
+ from %I.archive_tables() a',
272
+ v_module.schema_name, v_rank * 1000, v_module.schema_name);
273
+ end if;
274
+ end loop;
275
+ end;
276
+ $$;
277
+
278
+ comment on function company_archive_tables() is
279
+ 'The registry of the socle and what every installed module answers through `<schema>.archive_tables()`, as one list. Modules load after the socle.';
280
+
281
+ create or replace function company_archive_unclassified()
282
+ returns table (table_schema text, table_name text, problem text)
283
+ language sql
284
+ stable
285
+ set search_path = public, pg_temp
286
+ as $$
287
+ select s.table_schema, s.table_name,
288
+ 'belongs to a company and is neither exported nor excluded'::text
289
+ from company_scoped_tables() s
290
+ where not exists (select 1 from company_archive_tables() t
291
+ where t.table_schema = s.table_schema and t.table_name = s.table_name)
292
+ union all
293
+ select t.table_schema, t.table_name, 'is classified and does not exist'::text
294
+ from company_archive_tables() t
295
+ where to_regclass(format('%I.%I', t.table_schema, t.table_name)) is null
296
+ union all
297
+ select t.table_schema, t.table_name,
298
+ 'is exported, carries no company_id and names no table that leads to one'::text
299
+ from company_archive_tables() t
300
+ join company_scoped_tables() s
301
+ on s.table_schema = t.table_schema and s.table_name = t.table_name
302
+ where t.disposition = 'exported' and not s.has_company_id and t.via_table is null
303
+ and (t.table_schema, t.table_name) <> ('public', 'companies')
304
+ order by 1, 2;
305
+ $$;
306
+
307
+ comment on function company_archive_unclassified() is
308
+ 'What stands between this installation and an honest archive: a table of a company nobody classified, a classification of a table that is gone, an exported table with no way to a company. Empty is the only answer an export accepts.';
309
+
310
+ create or replace function company_archive_predicate(p_schema text, p_table text, p_alias text)
311
+ returns text
312
+ language plpgsql
313
+ stable
314
+ set search_path = public, pg_temp
315
+ as $$
316
+ declare
317
+ v_row record;
318
+ v_target text;
319
+ begin
320
+ if (p_schema, p_table) = ('public', 'companies') then
321
+ return format('%I.id = $1', p_alias);
322
+ end if;
323
+
324
+ select * into v_row from company_archive_tables() t
325
+ where t.table_schema = p_schema and t.table_name = p_table;
326
+ if not found then
327
+ raise exception 'unknown_table: %.% is not a table an archive knows', p_schema, p_table;
328
+ end if;
329
+
330
+ if v_row.via_table is null then
331
+ return format('%I.company_id = $1', p_alias);
332
+ end if;
333
+
334
+ -- The column the foreign key lands on, from the catalogue rather than from
335
+ -- the habit of calling it `id`.
336
+ select a.attname into v_target
337
+ from pg_constraint c
338
+ join pg_attribute f on f.attrelid = c.conrelid and f.attnum = c.conkey[1]
339
+ join pg_attribute a on a.attrelid = c.confrelid and a.attnum = c.confkey[1]
340
+ where c.contype = 'f'
341
+ and c.conrelid = to_regclass(format('%I.%I', p_schema, p_table))
342
+ and c.confrelid = to_regclass(v_row.via_table)
343
+ and cardinality(c.conkey) = 1
344
+ and f.attname = v_row.via_column;
345
+ if v_target is null then
346
+ raise exception 'unknown_table: %.% says it reaches a company through % to %, and no foreign key does that',
347
+ p_schema, p_table, v_row.via_column, v_row.via_table;
348
+ end if;
349
+
350
+ return format('%I.%I in (select via.%I from %s via where via.company_id = $1)',
351
+ p_alias, v_row.via_column, v_target, to_regclass(v_row.via_table)::text);
352
+ end;
353
+ $$;
354
+
355
+ comment on function company_archive_predicate(text, text, text) is
356
+ 'The SQL condition that keeps the rows of one company in one table, with the company as `$1`. One place, read by the export, by the count and by the checks of an import.';
357
+
358
+ -- ---------------------------------------------------------------------------
359
+ -- Leaving
360
+ -- ---------------------------------------------------------------------------
361
+
362
+ create or replace function company_archive_row_count(p_company_id uuid, p_table text)
363
+ returns bigint
364
+ language plpgsql
365
+ stable
366
+ security definer
367
+ set search_path = public, pg_temp
368
+ as $$
369
+ declare
370
+ v_schema text := split_part(p_table, '.', 1);
371
+ v_name text := split_part(p_table, '.', 2);
372
+ v_count bigint;
373
+ begin
374
+ if has_capability(p_company_id, 'company.export') is not true then
375
+ raise exception 'not_allowed: counting the rows of this company needs company.export'
376
+ using errcode = '42501';
377
+ end if;
378
+ if not exists (select 1 from company_archive_tables() t
379
+ where t.table_schema = v_schema and t.table_name = v_name
380
+ and t.disposition = 'exported') then
381
+ raise exception 'unknown_table: % is not a table an archive carries', p_table;
382
+ end if;
383
+
384
+ execute format('select count(*) from %I.%I t where %s',
385
+ v_schema, v_name, company_archive_predicate(v_schema, v_name, 't'))
386
+ into v_count using p_company_id;
387
+ return v_count;
388
+ end;
389
+ $$;
390
+
391
+ comment on function company_archive_row_count(uuid, text) is
392
+ 'How many rows one table holds for one company, whatever the caller may read of them. Definer, guarded by company.export, and it answers a number and nothing else: it is what lets an export notice that row level security handed it less than there is.';
393
+
394
+ create or replace function note_company_export(p_company_id uuid)
395
+ returns void
396
+ language plpgsql
397
+ security definer
398
+ set search_path = public, pg_temp
399
+ as $$
400
+ declare
401
+ v_name text;
402
+ begin
403
+ if has_capability(p_company_id, 'company.export') is not true then
404
+ raise exception 'not_allowed: leaving with the books of this company needs company.export'
405
+ using errcode = '42501';
406
+ end if;
407
+
408
+ select c.name into v_name from companies c where c.id = p_company_id;
409
+ perform audit_record(p_company_id, 'companies', p_company_id, coalesce(v_name, p_company_id::text),
410
+ 'update', 'company_exported', null,
411
+ jsonb_build_object('socle_version', ekwo_schema_version()));
412
+ end;
413
+ $$;
414
+
415
+ comment on function note_company_export(uuid) is
416
+ 'Writes `company_exported` on the audit trail. Called by `export_company()` and by `ekwo company export` before they read. A record of the act, not a control: a member can always read, table by table, what they may read. Definer because nobody writes the trail; guarded by company.export.';
417
+
418
+ create or replace function assert_may_export_company(p_company_id uuid)
419
+ returns void
420
+ language plpgsql
421
+ stable
422
+ set search_path = public, pg_temp
423
+ as $$
424
+ begin
425
+ if has_capability(p_company_id, 'company.export') is not true then
426
+ raise exception 'not_allowed: leaving with the books of this company needs company.export'
427
+ using errcode = '42501';
428
+ end if;
429
+ -- Under row level security, or not at all. A role that bypasses it — the
430
+ -- backend role of a Supabase project, the owner of the database — reads
431
+ -- every company at once, so "what the caller may read" means nothing for it
432
+ -- and the comparison below would compare a number with itself. Whoever holds
433
+ -- such a role has `pg_dump`; what they do not get is an archive that says a
434
+ -- person took it.
435
+ if exists (select 1 from pg_roles r where r.rolname = current_user and (r.rolbypassrls or r.rolsuper)) then
436
+ raise exception 'not_allowed: an archive is read under row level security, and % is not subject to it', current_user
437
+ using errcode = '42501';
438
+ end if;
439
+ end;
440
+ $$;
441
+
442
+ comment on function assert_may_export_company(uuid) is
443
+ 'The two conditions of leaving: company.export on the company, and a caller that row level security applies to. Invoker, so `current_user` is the role that asked.';
444
+
445
+ create or replace function export_company_table(p_company_id uuid, p_table text)
446
+ returns setof jsonb
447
+ language plpgsql
448
+ stable
449
+ set search_path = public, pg_temp
450
+ set timezone = 'UTC'
451
+ as $$
452
+ declare
453
+ v_schema text := split_part(p_table, '.', 1);
454
+ v_name text := split_part(p_table, '.', 2);
455
+ v_problem record;
456
+ v_where text;
457
+ v_decimals text;
458
+ v_order text;
459
+ v_seen bigint;
460
+ v_held bigint;
461
+ begin
462
+ perform assert_may_export_company(p_company_id);
463
+
464
+ select * into v_problem from company_archive_unclassified() limit 1;
465
+ if found then
466
+ raise exception 'unclassified_table: %.% % — an archive that might be incomplete is not written. Classify it in company_archive_registry, or in the archive_tables() of its module.',
467
+ v_problem.table_schema, v_problem.table_name, v_problem.problem
468
+ using errcode = '55006';
469
+ end if;
470
+
471
+ if not exists (select 1 from company_archive_tables() t
472
+ where t.table_schema = v_schema and t.table_name = v_name
473
+ and t.disposition = 'exported') then
474
+ raise exception 'unknown_table: % is not a table an archive carries', p_table;
475
+ end if;
476
+
477
+ v_where := company_archive_predicate(v_schema, v_name, 't');
478
+
479
+ execute format('select count(*) from %I.%I t where %s', v_schema, v_name, v_where)
480
+ into v_seen using p_company_id;
481
+ v_held := company_archive_row_count(p_company_id, p_table);
482
+ if v_seen <> v_held then
483
+ raise exception 'export_incomplete: % holds % rows for this company and you may read % of them. An archive is whole or it is not written: ask for the reading capability you lack — or, for the table of a module, for the module to be enabled.',
484
+ p_table, v_held, v_seen
485
+ using errcode = '42501';
486
+ end if;
487
+
488
+ -- Decimals leave as text. `12.50` is a valid JSON number and most readers
489
+ -- hand it back as a float; an archive of a ledger must not depend on which.
490
+ select coalesce(' || jsonb_build_object(' ||
491
+ string_agg(format('%L, t.%I::text', a.attname, a.attname), ', ' order by a.attnum) || ')', '')
492
+ into v_decimals
493
+ from pg_attribute a
494
+ where a.attrelid = to_regclass(format('%I.%I', v_schema, v_name))
495
+ and a.attnum > 0 and not a.attisdropped
496
+ and a.atttypid = 'numeric'::regtype;
497
+
498
+ select string_agg(format('t.%I', a.attname), ', ' order by array_position(i.indkey::smallint[], a.attnum))
499
+ into v_order
500
+ from pg_index i
501
+ join pg_attribute a on a.attrelid = i.indrelid and a.attnum = any (i.indkey)
502
+ where i.indrelid = to_regclass(format('%I.%I', v_schema, v_name)) and i.indisprimary;
503
+ if v_order is null then
504
+ raise exception 'unknown_table: % has no primary key, so its rows have no order to be written in', p_table;
505
+ end if;
506
+
507
+ return query execute format('select to_jsonb(t)%s from %I.%I t where %s order by %s',
508
+ v_decimals, v_schema, v_name, v_where, v_order)
509
+ using p_company_id;
510
+ end;
511
+ $$;
512
+
513
+ comment on function export_company_table(uuid, text) is
514
+ 'The rows of one table for one company, one JSON object each, in primary key order: decimals as text, timestamps in UTC. Runs as its caller, needs company.export, refuses when the caller may read fewer rows than the table holds, and refuses while any table of a company is unclassified. Stable, so it reads the snapshot of the statement that calls it: called table after table, it needs a repeatable read transaction around the calls for the tables to agree with each other and with the manifest — which is what the CLI opens — or use export_company(), which is one statement.';
515
+
516
+ create or replace function export_company_manifest(p_company_id uuid)
517
+ returns jsonb
518
+ language plpgsql
519
+ stable
520
+ set search_path = public, pg_temp
521
+ set timezone = 'UTC'
522
+ as $$
523
+ declare
524
+ v_company companies%rowtype;
525
+ v_table record;
526
+ v_tables jsonb := '[]'::jsonb;
527
+ v_rows bigint;
528
+ v_checksum text;
529
+ begin
530
+ perform assert_may_export_company(p_company_id);
531
+
532
+ select * into v_company from companies c where c.id = p_company_id;
533
+ if not found then
534
+ raise exception 'unknown_company: % is not a company you may read', p_company_id
535
+ using errcode = 'P0002';
536
+ end if;
537
+
538
+ for v_table in
539
+ select t.table_schema || '.' || t.table_name as name
540
+ from company_archive_tables() t
541
+ where t.disposition = 'exported'
542
+ order by t.load_order, t.table_schema, t.table_name
543
+ loop
544
+ select count(*),
545
+ encode(sha256(convert_to(coalesce(string_agg(x.r::text || E'\n', '' order by x.n), ''), 'UTF8')), 'hex')
546
+ into v_rows, v_checksum
547
+ from export_company_table(p_company_id, v_table.name) with ordinality as x(r, n);
548
+
549
+ v_tables := v_tables || jsonb_build_object(
550
+ 'name', v_table.name,
551
+ 'file', 'data/' || v_table.name || '.jsonl',
552
+ 'rows', v_rows,
553
+ 'sha256', v_checksum);
554
+ end loop;
555
+
556
+ return jsonb_build_object(
557
+ 'format', 'ekwo.company-archive',
558
+ 'format_version', 1,
559
+ 'exported_at', to_jsonb(now()),
560
+ 'exported_by', auth.uid(),
561
+ 'socle_version', ekwo_schema_version(),
562
+ 'origin_instance', (select i.instance_id from instance i),
563
+ 'company', jsonb_build_object(
564
+ 'id', v_company.id,
565
+ 'name', v_company.name,
566
+ 'country', v_company.country,
567
+ 'fiscal_country', v_company.fiscal_country,
568
+ 'currency_code', v_company.currency_code),
569
+ 'packs', coalesce((
570
+ select jsonb_agg(jsonb_build_object('country', p.country, 'version', p.version, 'chart_code', p.chart_code)
571
+ order by p.country)
572
+ from company_packs p where p.company_id = p_company_id), '[]'::jsonb),
573
+ 'modules', coalesce((
574
+ select jsonb_agg(jsonb_build_object('code', m.code, 'version', m.version) order by m.code)
575
+ from company_modules cm join modules m on m.code = cm.module_code
576
+ where cm.company_id = p_company_id), '[]'::jsonb),
577
+ 'tables', v_tables,
578
+ 'excluded', coalesce((
579
+ select jsonb_agg(jsonb_build_object('name', t.table_schema || '.' || t.table_name, 'reason', t.reason)
580
+ order by t.table_schema, t.table_name)
581
+ from company_archive_tables() t where t.disposition = 'excluded'), '[]'::jsonb),
582
+ 'files', jsonb_build_object(
583
+ 'transported', false,
584
+ 'list', coalesce((
585
+ select jsonb_agg(jsonb_build_object(
586
+ 'attachment_id', a.id,
587
+ 'storage_path', a.storage_path,
588
+ 'file_name', a.file_name,
589
+ 'mime_type', a.mime_type,
590
+ 'byte_size', a.byte_size,
591
+ 'checksum', a.checksum) order by a.id)
592
+ from attachments a where a.company_id = p_company_id), '[]'::jsonb)));
593
+ end;
594
+ $$;
595
+
596
+ comment on function export_company_manifest(uuid) is
597
+ 'What an archive of this company is: the format and its version, the socle, the packs and the modules an installation needs to take it in, every table with its row count and the sha256 of its rows, the tables left behind with the reason, and the list of the files the attachments point at — which the archive does not carry.';
598
+
599
+ create or replace function export_company_tables(p_company_id uuid)
600
+ returns jsonb
601
+ language sql
602
+ stable
603
+ set search_path = public, pg_temp
604
+ set timezone = 'UTC'
605
+ as $$
606
+ select coalesce(jsonb_object_agg(
607
+ t.table_schema || '.' || t.table_name,
608
+ coalesce((select jsonb_agg(x.r order by x.n)
609
+ from export_company_table(p_company_id, t.table_schema || '.' || t.table_name)
610
+ with ordinality as x(r, n)),
611
+ '[]'::jsonb)), '{}'::jsonb)
612
+ from company_archive_tables() t
613
+ where t.disposition = 'exported';
614
+ $$;
615
+
616
+ comment on function export_company_tables(uuid) is
617
+ 'Every exported table of one company as one object, keyed by table name. The `tables` half of export_company().';
618
+
619
+ create or replace function export_company(p_company_id uuid)
620
+ returns jsonb
621
+ language plpgsql
622
+ set search_path = public, pg_temp
623
+ set timezone = 'UTC'
624
+ as $$
625
+ declare
626
+ v_archive jsonb;
627
+ begin
628
+ perform note_company_export(p_company_id);
629
+ -- One statement, so one snapshot: a volatile function sees a new one at each
630
+ -- statement, and an archive whose manifest was computed a moment before its
631
+ -- rows, or whose lines were read a moment after their entries, is one that
632
+ -- fails on arrival — after the company has left. The two readers are stable
633
+ -- and share the snapshot of this select.
634
+ select jsonb_build_object('manifest', export_company_manifest(p_company_id),
635
+ 'tables', export_company_tables(p_company_id))
636
+ into v_archive;
637
+ return v_archive;
638
+ end;
639
+ $$;
640
+
641
+ comment on function export_company(uuid) is
642
+ 'The whole archive as one document, read in one snapshot: `manifest`, and `tables` keyed by table name. It is what `import_company()` takes, and it writes `company_exported` on the audit trail. A large company is better read table by table, which is what the CLI does; this is the same rows in one answer.';
643
+
644
+ -- ---------------------------------------------------------------------------
645
+ -- Arriving
646
+ -- ---------------------------------------------------------------------------
647
+
648
+ create or replace function import_company(p_archive jsonb, p_owner_user_id uuid default null)
649
+ returns jsonb
650
+ language plpgsql
651
+ security definer
652
+ set search_path = public, pg_temp
653
+ as $$
654
+ declare
655
+ v_manifest jsonb := p_archive -> 'manifest';
656
+ v_tables jsonb := coalesce(p_archive -> 'tables', '{}'::jsonb);
657
+ v_company uuid;
658
+ v_owner uuid := coalesce(p_owner_user_id, auth.uid());
659
+ v_item jsonb;
660
+ v_problem record;
661
+ v_table record;
662
+ v_class regclass;
663
+ v_rows jsonb;
664
+ v_count bigint;
665
+ v_checksum text;
666
+ v_columns text[];
667
+ v_deferred text[];
668
+ v_unknown text;
669
+ v_select text;
670
+ v_silenced regclass[] := '{}';
671
+ v_second jsonb := '[]'::jsonb;
672
+ v_were_off jsonb := '[]'::jsonb;
673
+ v_pass jsonb;
674
+ v_fk record;
675
+ v_total bigint := 0;
676
+ v_name text;
677
+ v_found text;
678
+ begin
679
+ -- The guard, first and in the open: the two callers who may create a company
680
+ -- here, and nobody else. A definer function that checks nobody is how two
681
+ -- doors were found open on 18 September.
682
+ --
683
+ -- `is not true`, not `not`: a helper that answers NULL turns `if not … and
684
+ -- not …` into an `if NULL`, which does not raise. `is_installer()` did, until
685
+ -- `20260918140000`, for the backend role through the API. The guard is
686
+ -- written so that it holds whatever the helpers answer.
687
+ if is_installer() is not true and is_instance_admin() is not true then
688
+ raise exception 'not_instance_admin: taking a company into this installation is an instance-level act'
689
+ using errcode = '42501';
690
+ end if;
691
+
692
+ if v_manifest is null or v_manifest ->> 'format' is distinct from 'ekwo.company-archive' then
693
+ raise exception 'not_an_archive: this document does not say it is an ekwo.company-archive';
694
+ end if;
695
+ if v_manifest ->> 'format_version' is distinct from '1' then
696
+ raise exception 'unknown_archive_version: this installation reads version 1 of the format, and the archive says %',
697
+ coalesce(v_manifest ->> 'format_version', 'nothing');
698
+ end if;
699
+ if not coalesce(version_at_least(ekwo_schema_version(), v_manifest ->> 'socle_version'), false) then
700
+ raise exception 'socle_too_old: the archive was written by socle % and this installation is %. Run `ekwo migrate` first.',
701
+ v_manifest ->> 'socle_version', ekwo_schema_version()
702
+ using errcode = '55006';
703
+ end if;
704
+
705
+ -- What the company needs is read from its rows as well as from the manifest:
706
+ -- the manifest is a summary somebody could have edited, the rows are what
707
+ -- will be here afterwards.
708
+ for v_item in
709
+ select jsonb_build_object('country', e ->> 'country', 'version', e ->> 'version')
710
+ from jsonb_array_elements(coalesce(v_tables -> 'public.company_packs', '[]'::jsonb)) e
711
+ union
712
+ select jsonb_build_object('country', m ->> 'country', 'version', m ->> 'version')
713
+ from jsonb_array_elements(coalesce(v_manifest -> 'packs', '[]'::jsonb)) m
714
+ loop
715
+ select p.version into v_found from country_packs p where p.country = v_item ->> 'country';
716
+ if not found then
717
+ raise exception 'pack_missing: the company holds the % pack and this installation does not', v_item ->> 'country'
718
+ using errcode = '55006';
719
+ end if;
720
+ if not coalesce(version_at_least(v_found, v_item ->> 'version'), false) then
721
+ raise exception 'pack_too_old: the company is on version % of the % pack and this installation holds %',
722
+ v_item ->> 'version', v_item ->> 'country', v_found
723
+ using errcode = '55006';
724
+ end if;
725
+ end loop;
726
+
727
+ for v_item in
728
+ select jsonb_build_object('code', m ->> 'code', 'version', m ->> 'version')
729
+ from jsonb_array_elements(coalesce(v_manifest -> 'modules', '[]'::jsonb)) m
730
+ union
731
+ select jsonb_build_object('code', e ->> 'module_code', 'version', null)
732
+ from jsonb_array_elements(coalesce(v_tables -> 'public.company_modules', '[]'::jsonb)) e
733
+ loop
734
+ select m.version into v_found from modules m where m.code = v_item ->> 'code';
735
+ if not found then
736
+ raise exception 'module_missing: the company uses the % module and this installation does not carry it', v_item ->> 'code'
737
+ using errcode = '55006';
738
+ end if;
739
+ if v_item ->> 'version' is not null and not version_at_least(v_found, v_item ->> 'version') then
740
+ raise exception 'module_too_old: the company used version % of the % module and this installation holds %',
741
+ v_item ->> 'version', v_item ->> 'code', v_found
742
+ using errcode = '55006';
743
+ end if;
744
+ end loop;
745
+
746
+ v_company := (v_manifest #>> '{company,id}')::uuid;
747
+ if v_company is null then
748
+ raise exception 'not_an_archive: the manifest names no company';
749
+ end if;
750
+ -- Identifiers are kept, so a company is here or it is not. This is also what
751
+ -- a second run of the same import meets.
752
+ if exists (select 1 from companies c where c.id = v_company) then
753
+ raise exception 'company_already_here: % is a company of this installation. An import never merges: a company arrives whole, once.', v_company
754
+ using errcode = '23505';
755
+ end if;
756
+
757
+ select * into v_problem from company_archive_unclassified() limit 1;
758
+ if found then
759
+ raise exception 'unclassified_table: %.% % — this installation cannot say what an archive is made of',
760
+ v_problem.table_schema, v_problem.table_name, v_problem.problem
761
+ using errcode = '55006';
762
+ end if;
763
+
764
+ -- The manifest and the tables say the same thing, and every table is one
765
+ -- this installation exports itself.
766
+ for v_name in select jsonb_object_keys(v_tables) loop
767
+ if not exists (select 1 from jsonb_array_elements(v_manifest -> 'tables') m where m ->> 'name' = v_name) then
768
+ raise exception 'archive_corrupt: the archive carries rows of % and its manifest does not list it', v_name;
769
+ end if;
770
+ end loop;
771
+ for v_item in select jsonb_array_elements(coalesce(v_manifest -> 'tables', '[]'::jsonb)) loop
772
+ v_name := v_item ->> 'name';
773
+ if not exists (select 1 from company_archive_tables() t
774
+ where t.table_schema || '.' || t.table_name = v_name and t.disposition = 'exported') then
775
+ raise exception 'unknown_table: the archive carries %, which this installation does not know as a table of a company', v_name
776
+ using errcode = '55006';
777
+ end if;
778
+ v_rows := coalesce(v_tables -> v_name, '[]'::jsonb);
779
+ select count(*),
780
+ encode(sha256(convert_to(coalesce(string_agg(x.r::text || E'\n', '' order by x.n), ''), 'UTF8')), 'hex')
781
+ into v_count, v_checksum
782
+ from jsonb_array_elements(v_rows) with ordinality as x(r, n);
783
+ if v_count is distinct from (v_item ->> 'rows')::bigint or v_checksum is distinct from v_item ->> 'sha256' then
784
+ raise exception 'archive_corrupt: % does not match its manifest (% rows against %, or another checksum)',
785
+ v_name, v_count, v_item ->> 'rows';
786
+ end if;
787
+ end loop;
788
+
789
+ if jsonb_array_length(coalesce(v_tables -> 'public.companies', '[]'::jsonb)) <> 1 then
790
+ raise exception 'foreign_row: an archive holds one company, and this one holds % rows of public.companies',
791
+ jsonb_array_length(coalesce(v_tables -> 'public.companies', '[]'::jsonb));
792
+ end if;
793
+
794
+ -- First pass: the rows, in the order the registry names.
795
+ for v_table in
796
+ select t.table_schema, t.table_name, t.load_order,
797
+ t.table_schema || '.' || t.table_name as name
798
+ from company_archive_tables() t
799
+ where t.disposition = 'exported'
800
+ order by t.load_order, t.table_schema, t.table_name
801
+ loop
802
+ v_rows := coalesce(v_tables -> v_table.name, '[]'::jsonb);
803
+ continue when jsonb_array_length(v_rows) = 0;
804
+ v_class := to_regclass(format('%I.%I', v_table.table_schema, v_table.table_name));
805
+ v_total := v_total + jsonb_array_length(v_rows);
806
+
807
+ -- A column this installation does not have is data it would drop in
808
+ -- silence. It refuses instead.
809
+ select k.key into v_unknown
810
+ from (select distinct jsonb_object_keys(e) as key from jsonb_array_elements(v_rows) e) k
811
+ where not exists (select 1 from pg_attribute a
812
+ where a.attrelid = v_class and a.attname = k.key
813
+ and a.attnum > 0 and not a.attisdropped)
814
+ limit 1;
815
+ if v_unknown is not null then
816
+ raise exception 'unknown_column: %.% is in the archive and not in this installation', v_table.name, v_unknown
817
+ using errcode = '55006';
818
+ end if;
819
+
820
+ -- Every row has the columns of the first. The list of columns written is
821
+ -- read from one row; a key that only a later row carries would be dropped
822
+ -- in silence, and one it lacks would be written as null over a default.
823
+ if exists (select 1 from jsonb_array_elements(v_rows) e
824
+ where (select array_agg(k order by k) from jsonb_object_keys(e) k)
825
+ is distinct from
826
+ (select array_agg(k order by k) from jsonb_object_keys(v_rows -> 0) k)) then
827
+ raise exception 'archive_corrupt: the rows of % do not all have the same columns', v_table.name;
828
+ end if;
829
+
830
+ -- Every row says which company it is of, and it is the one that arrives.
831
+ if v_table.name = 'public.companies' then
832
+ if jsonb_array_length(v_rows) <> 1 or (v_rows -> 0 ->> 'id') is distinct from v_company::text then
833
+ raise exception 'foreign_row: an archive holds one company, the one its manifest names';
834
+ end if;
835
+ elsif exists (select 1 from pg_attribute a
836
+ where a.attrelid = v_class and a.attname = 'company_id' and not a.attisdropped) then
837
+ if exists (select 1 from jsonb_array_elements(v_rows) e
838
+ where e ->> 'company_id' is distinct from v_company::text) then
839
+ raise exception 'foreign_row: % holds a row of another company than the one the archive names', v_table.name;
840
+ end if;
841
+ end if;
842
+
843
+ -- A reference to a table that loads later, or to its own table, waits for
844
+ -- the second pass. Derived from the foreign keys, so a new one needs no
845
+ -- line here; it has to be nullable, and a test says so before a user does.
846
+ select coalesce(array_agg(distinct a.attname), '{}') into v_deferred
847
+ from pg_constraint c
848
+ join unnest(c.conkey) as k(attnum) on true
849
+ join pg_attribute a on a.attrelid = c.conrelid and a.attnum = k.attnum
850
+ join pg_class rc on rc.oid = c.confrelid
851
+ join pg_namespace rn on rn.oid = rc.relnamespace
852
+ join company_archive_tables() r
853
+ on r.table_schema = rn.nspname and r.table_name = rc.relname and r.disposition = 'exported'
854
+ where c.contype = 'f' and c.conrelid = v_class
855
+ and r.load_order >= v_table.load_order
856
+ and a.attname <> 'company_id';
857
+
858
+ if exists (select 1 from pg_attribute a
859
+ where a.attrelid = v_class and a.attname = any (v_deferred) and a.attnotnull) then
860
+ raise exception 'cannot_order: % holds a required reference to a table that loads after it', v_table.name;
861
+ end if;
862
+
863
+ -- What is written: every column of the table the archive names, but the
864
+ -- generated ones, which compute themselves, and an identity, which is
865
+ -- local to an installation and is drawn again.
866
+ select array_agg(a.attname order by a.attnum) into v_columns
867
+ from pg_attribute a
868
+ where a.attrelid = v_class and a.attnum > 0 and not a.attisdropped
869
+ and a.attgenerated = '' and a.attidentity = ''
870
+ and (v_rows -> 0) ? a.attname;
871
+
872
+ select string_agg(case when c = any (v_deferred) then format('null as %I', c) else format('r.%I', c) end, ', ')
873
+ into v_select
874
+ from unnest(v_columns) as c;
875
+
876
+ -- The guards of a table are written for a person booking one thing at a
877
+ -- time, in order, today. They are switched off on the tables being filled
878
+ -- and nowhere else, inside this transaction, under a lock that makes every
879
+ -- other writer of the table wait — and only where there is one to switch
880
+ -- off, so `audit_log`, whose only trigger refuses updates and deletes,
881
+ -- is never touched.
882
+ if exists (select 1 from pg_trigger g
883
+ where g.tgrelid = v_class and not g.tgisinternal
884
+ and ((g.tgtype & 4) <> 0 or ((g.tgtype & 16) <> 0 and cardinality(v_deferred) > 0))) then
885
+ -- A trigger an operator had set otherwise — off, or firing on a replica
886
+ -- — is put back the way it was.
887
+ v_were_off := v_were_off || coalesce((
888
+ select jsonb_agg(jsonb_build_object(
889
+ 'class', v_class::text, 'name', g.tgname,
890
+ 'verb', case g.tgenabled when 'D' then 'disable trigger'
891
+ when 'R' then 'enable replica trigger'
892
+ else 'enable always trigger' end))
893
+ from pg_trigger g
894
+ where g.tgrelid = v_class and not g.tgisinternal and g.tgenabled <> 'O'), '[]'::jsonb);
895
+ execute format('alter table %s disable trigger user', v_class);
896
+ v_silenced := v_silenced || v_class;
897
+ end if;
898
+
899
+ -- In the order of the archive, so that an identity drawn again — the ids
900
+ -- of the trail — follows the order the rows were written in.
901
+ execute format('insert into %s (%s) select %s from jsonb_array_elements($1) with ordinality as e(v, n)
902
+ cross join lateral jsonb_populate_record(null::%s, e.v) as r order by e.n',
903
+ v_class, (select string_agg(format('%I', c), ', ') from unnest(v_columns) c),
904
+ v_select, v_class)
905
+ using v_rows;
906
+
907
+ if cardinality(v_deferred) > 0 then
908
+ v_second := v_second || jsonb_build_object('table', v_table.name, 'class', v_class::text,
909
+ 'columns', to_jsonb(v_deferred));
910
+ end if;
911
+ end loop;
912
+
913
+ -- Second pass: the references that pointed forward.
914
+ for v_pass in select jsonb_array_elements(v_second) loop
915
+ select array_agg(c) into v_deferred
916
+ from jsonb_array_elements_text(v_pass -> 'columns') c
917
+ where (v_tables -> (v_pass ->> 'table') -> 0) ? c;
918
+ continue when v_deferred is null;
919
+ execute format(
920
+ 'update %s t set %s from jsonb_populate_recordset(null::%s, $1) r where %s and (%s)',
921
+ v_pass ->> 'class',
922
+ (select string_agg(format('%I = r.%I', c, c), ', ') from unnest(v_deferred) c),
923
+ v_pass ->> 'class',
924
+ -- By the primary key, read from the catalogue rather than assumed to be `id`.
925
+ (select string_agg(format('t.%I = r.%I', a.attname, a.attname), ' and ')
926
+ from pg_index i
927
+ join pg_attribute a on a.attrelid = i.indrelid and a.attnum = any (i.indkey)
928
+ where i.indrelid = (v_pass ->> 'class')::regclass and i.indisprimary),
929
+ (select string_agg(format('r.%I is not null', c), ' or ') from unnest(v_deferred) c))
930
+ using v_tables -> (v_pass ->> 'table');
931
+ end loop;
932
+
933
+ foreach v_class in array v_silenced loop
934
+ execute format('alter table %s enable trigger user', v_class);
935
+ end loop;
936
+ for v_pass in select jsonb_array_elements(v_were_off) loop
937
+ execute format('alter table %s %s %I', v_pass ->> 'class', v_pass ->> 'verb', v_pass ->> 'name');
938
+ end loop;
939
+
940
+ -- What the triggers would have guaranteed, asked of the result.
941
+
942
+ -- 1. Everything that arrived is of this company: a row that hung itself on
943
+ -- a parent of another company is missing from this count.
944
+ for v_table in
945
+ select t.table_schema, t.table_name, t.table_schema || '.' || t.table_name as name
946
+ from company_archive_tables() t where t.disposition = 'exported'
947
+ loop
948
+ execute format('select count(*) from %I.%I t where %s', v_table.table_schema, v_table.table_name,
949
+ company_archive_predicate(v_table.table_schema, v_table.table_name, 't'))
950
+ into v_count using v_company;
951
+ if v_count <> jsonb_array_length(coalesce(v_tables -> v_table.name, '[]'::jsonb)) then
952
+ raise exception 'foreign_row: % holds % rows of this company after the import and the archive carried %',
953
+ v_table.name, v_count, jsonb_array_length(coalesce(v_tables -> v_table.name, '[]'::jsonb));
954
+ end if;
955
+ end loop;
956
+
957
+ -- 2. No reference leaves the company. Most foreign keys of the schema carry
958
+ -- `company_id` and refuse this themselves; the ones that do not are why
959
+ -- this is asked of all of them.
960
+ for v_fk in
961
+ select c.conrelid::regclass as child, c.confrelid::regclass as parent,
962
+ cn.nspname as child_schema, ck.relname as child_table,
963
+ pn.nspname as parent_schema, pk.relname as parent_table,
964
+ ca.attname as child_column, pa.attname as parent_column
965
+ from pg_constraint c
966
+ join pg_class ck on ck.oid = c.conrelid
967
+ join pg_namespace cn on cn.oid = ck.relnamespace
968
+ join pg_class pk on pk.oid = c.confrelid
969
+ join pg_namespace pn on pn.oid = pk.relnamespace
970
+ join pg_attribute ca on ca.attrelid = c.conrelid and ca.attnum = c.conkey[1]
971
+ join pg_attribute pa on pa.attrelid = c.confrelid and pa.attnum = c.confkey[1]
972
+ join company_archive_tables() ct
973
+ on ct.table_schema = cn.nspname and ct.table_name = ck.relname and ct.disposition = 'exported'
974
+ join company_archive_tables() pt
975
+ on pt.table_schema = pn.nspname and pt.table_name = pk.relname and pt.disposition = 'exported'
976
+ where c.contype = 'f' and cardinality(c.conkey) = 1
977
+ loop
978
+ execute format(
979
+ 'select count(*) from %s t join %s p on p.%I = t.%I where %s and not (%s)',
980
+ v_fk.child, v_fk.parent, v_fk.parent_column, v_fk.child_column,
981
+ company_archive_predicate(v_fk.child_schema, v_fk.child_table, 't'),
982
+ company_archive_predicate(v_fk.parent_schema, v_fk.parent_table, 'p'))
983
+ into v_count using v_company;
984
+ if v_count > 0 then
985
+ raise exception 'foreign_row: %.% points at a row of another company in % (% rows)',
986
+ v_fk.child, v_fk.child_column, v_fk.parent, v_count;
987
+ end if;
988
+ end loop;
989
+
990
+ -- 3. The ledger: an entry agrees with its lines, and a posted one balances.
991
+ select e.number into v_found
992
+ from entries e
993
+ left join lateral (select coalesce(sum(l.debit), 0) as debit, coalesce(sum(l.credit), 0) as credit
994
+ from entry_lines l where l.entry_id = e.id) s on true
995
+ where e.company_id = v_company
996
+ and (e.total_debit <> s.debit or e.total_credit <> s.credit
997
+ or (e.state = 'posted' and s.debit <> s.credit))
998
+ limit 1;
999
+ if found then
1000
+ raise exception 'unbalanced_entry: entry % does not balance, or does not agree with its lines', coalesce(v_found, '(no number)');
1001
+ end if;
1002
+
1003
+ -- 4. The matching: what a line says is matched is what its matchings add up to.
1004
+ select l.id::text into v_found
1005
+ from entry_lines l
1006
+ where l.company_id = v_company
1007
+ and l.matched_amount <> coalesce((select sum(r.amount) from reconciliations r
1008
+ where r.debit_line_id = l.id or r.credit_line_id = l.id), 0)
1009
+ limit 1;
1010
+ if found then
1011
+ raise exception 'matching_mismatch: line % says it is matched for another amount than its matchings add up to', v_found;
1012
+ end if;
1013
+
1014
+ -- 5. The numbered books: no counter is behind a number already used, or the
1015
+ -- next entry would take a number that exists.
1016
+ select e.number into v_found
1017
+ from entries e
1018
+ join journals j on j.id = e.journal_id
1019
+ cross join lateral numbering_rules(v_company) n
1020
+ where e.company_id = v_company and e.number is not null
1021
+ and number_counter(n.number_format, e.number) is not null
1022
+ and number_counter(n.number_format, e.number) > coalesce((
1023
+ select s.last_number from journal_sequences s
1024
+ where s.journal_id = j.id
1025
+ and s.year = case when n.number_format ~ '\{(YYYY|YY)\}'
1026
+ then extract(year from e.entry_date)::smallint
1027
+ else 0::smallint end), 0)
1028
+ limit 1;
1029
+ if found then
1030
+ raise exception 'counter_behind: entry % carries a number its journal has not counted up to; the next entry would collide', v_found;
1031
+ end if;
1032
+
1033
+ -- The letters of the matching, the same way.
1034
+ select l.matching_number into v_found
1035
+ from entry_lines l
1036
+ where l.company_id = v_company and l.matching_number ~ '[0-9]+$'
1037
+ and substring(l.matching_number from '[0-9]+$')::bigint
1038
+ > coalesce((select s.last_number from matching_sequences s where s.company_id = v_company), 0)
1039
+ limit 1;
1040
+ if found then
1041
+ raise exception 'counter_behind: the matching letter % is ahead of the counter of the company; the next matching would reuse a letter', v_found;
1042
+ end if;
1043
+
1044
+ -- 6. The financial years do not overlap: every date belongs to one year or
1045
+ -- to none, which is what a lock and a closing are read against.
1046
+ select x.name into v_found
1047
+ from fiscal_years x
1048
+ join fiscal_years y on y.company_id = x.company_id and y.id <> x.id
1049
+ and daterange(x.start_date, x.end_date, '[]') && daterange(y.start_date, y.end_date, '[]')
1050
+ where x.company_id = v_company
1051
+ limit 1;
1052
+ if found then
1053
+ raise exception 'overlapping_years: the financial year % overlaps another one of the same company', v_found;
1054
+ end if;
1055
+
1056
+ -- The first member. The people of the other installation did not travel.
1057
+ if v_owner is not null then
1058
+ insert into company_members (company_id, user_id, role)
1059
+ values (v_company, v_owner, 'owner')
1060
+ on conflict (company_id, user_id) do nothing;
1061
+ end if;
1062
+
1063
+ -- Where this installation's own testimony starts: everything on the trail
1064
+ -- of this company before this row is what the archive said.
1065
+ perform audit_record(v_company, 'companies', v_company, v_manifest #>> '{company,name}',
1066
+ 'insert', 'company_imported', null,
1067
+ jsonb_build_object(
1068
+ 'origin_instance', v_manifest -> 'origin_instance',
1069
+ 'exported_at', v_manifest -> 'exported_at',
1070
+ 'exported_by', v_manifest -> 'exported_by',
1071
+ 'socle_version', v_manifest -> 'socle_version',
1072
+ 'rows', v_total));
1073
+
1074
+ return jsonb_build_object(
1075
+ 'company_id', v_company,
1076
+ 'name', v_manifest #>> '{company,name}',
1077
+ 'rows', v_total,
1078
+ 'owner', v_owner,
1079
+ 'tables', (select jsonb_object_agg(m ->> 'name', (m ->> 'rows')::bigint)
1080
+ from jsonb_array_elements(v_manifest -> 'tables') m),
1081
+ 'files_to_carry', jsonb_array_length(coalesce(v_manifest #> '{files,list}', '[]'::jsonb)));
1082
+ end;
1083
+ $$;
1084
+
1085
+ comment on function import_company(jsonb, uuid) is
1086
+ 'Takes in the archive `export_company()` wrote: one company, whole, with its identifiers, its numbers, its locks and its trail. The installer or an administrator of the installation only. Rows are inserted with the user triggers of the filled tables off for the length of the transaction, foreign keys on, and the result is checked — ownership of every row, no reference into another company, balance, matching, counters — before anything stays. A company already here is refused. `p_owner_user_id`, or the caller, becomes its first owner.';
1087
+
1088
+ -- ---------------------------------------------------------------------------
1089
+ -- Grants. Every function is reachable by a signed-in user and guarded inside,
1090
+ -- which is the doctrine of 20260914151207.
1091
+ -- ---------------------------------------------------------------------------
1092
+
1093
+ revoke execute on function version_at_least(text, text) from public, anon;
1094
+ revoke execute on function company_scoped_tables() from public, anon;
1095
+ revoke execute on function company_archive_tables() from public, anon;
1096
+ revoke execute on function company_archive_unclassified() from public, anon;
1097
+ revoke execute on function company_archive_predicate(text, text, text) from public, anon;
1098
+ revoke execute on function company_archive_row_count(uuid, text) from public, anon;
1099
+ revoke execute on function note_company_export(uuid) from public, anon;
1100
+ revoke execute on function assert_may_export_company(uuid) from public, anon;
1101
+ revoke execute on function export_company_table(uuid, text) from public, anon;
1102
+ revoke execute on function export_company_manifest(uuid) from public, anon;
1103
+ revoke execute on function export_company_tables(uuid) from public, anon;
1104
+ revoke execute on function export_company(uuid) from public, anon;
1105
+ revoke execute on function import_company(jsonb, uuid) from public, anon;
1106
+
1107
+ grant execute on function version_at_least(text, text) to authenticated, service_role;
1108
+ grant execute on function company_scoped_tables() to authenticated, service_role;
1109
+ grant execute on function company_archive_tables() to authenticated, service_role;
1110
+ grant execute on function company_archive_unclassified() to authenticated, service_role;
1111
+ grant execute on function company_archive_predicate(text, text, text) to authenticated, service_role;
1112
+ grant execute on function company_archive_row_count(uuid, text) to authenticated, service_role;
1113
+ grant execute on function note_company_export(uuid) to authenticated, service_role;
1114
+ grant execute on function assert_may_export_company(uuid) to authenticated, service_role;
1115
+ grant execute on function export_company_table(uuid, text) to authenticated, service_role;
1116
+ grant execute on function export_company_manifest(uuid) to authenticated, service_role;
1117
+ grant execute on function export_company_tables(uuid) to authenticated, service_role;
1118
+ grant execute on function export_company(uuid) to authenticated, service_role;
1119
+ grant execute on function import_company(jsonb, uuid) to authenticated, service_role;