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,313 @@
1
+ -- Ekwo OS — what an invoice says is in the three views it is read through.
2
+ --
3
+ -- `packages/formats/README.md` tells a brick to read `document_header`,
4
+ -- `document_line_items` and `document_tax_summary` and nothing else. The first
5
+ -- brick that wrote a sendable invoice from them had to go behind them four
6
+ -- times: for the tax point, for the delivery address, for the buyer's
7
+ -- electronic address, and for the reason a tax charges nothing. Each was in
8
+ -- the schema and none was in a view. A contract that needs a join to the
9
+ -- tables under it is not the contract.
10
+ --
11
+ -- **The header gains what `documents`, `contacts` and now `companies` already
12
+ -- know.** BT-7, the four columns of BG-15 this schema has, and the two
13
+ -- electronic addresses. Nothing is invented: the delivery address has no
14
+ -- second line and no region because `documents` has neither, and a delivery
15
+ -- to a *named party* (BT-70) or a *location identifier* (BT-71) has no column
16
+ -- anywhere. They are absent rather than approximated.
17
+ --
18
+ -- **The breakdown says why a group charges nothing, in a code and in a
19
+ -- sentence.** BT-121 is `taxes.exemption_code`, which the lines view already
20
+ -- published and the breakdown did not — although it is the breakdown the
21
+ -- standard puts it on.
22
+ --
23
+ -- BT-120, the reason in words, is the decision of this file. The tax has a
24
+ -- `legal_reference`, and it is the obvious candidate and the wrong one: it is
25
+ -- the pack's *argument* — which article, why this code and not another, what
26
+ -- the pack chose not to do — written for whoever reviews the pack. Some are a
27
+ -- citation of six words and some are a paragraph that discusses this
28
+ -- repository. None was written to be read by a customer. The sentence a
29
+ -- country wants on an invoice that charges no tax exists already, per country,
30
+ -- translated, dated: `legal_mention_templates`. "Reverse charge: the customer
31
+ -- is to account for the VAT" is what BT-120 is for, and it is printed at the
32
+ -- foot of the same invoice by `document_legal_mentions`.
33
+ --
34
+ -- So `exemption_reason` is the mention of the tax's treatment, in the language
35
+ -- of the document, valid on its date — and null where the pack has no
36
+ -- sentence for that treatment, which includes every tax that charges
37
+ -- something. `legal_reference` is published beside it under its own name for
38
+ -- a reader who wants the citation, and is not called a reason.
39
+ --
40
+ -- **One place says which treatments a condition covers.** The view that
41
+ -- prints the mentions decided it in a `case`; a second copy here would drift
42
+ -- the first time a treatment is added. `legal_mention_treatments()` holds it
43
+ -- and both views read it. `document_legal_mentions` is otherwise
44
+ -- `20260915191200` to the letter: same columns, same order.
45
+ --
46
+ -- What is still missing, and is a gap of the vocabulary rather than of a view:
47
+ -- `applies_when` has no condition for a supply that is outside the scope of
48
+ -- the tax, so a category-O group has no sentence and, where the pack gives it
49
+ -- no code either, breaks BR-O-10.
50
+ --
51
+ -- Columns are appended, which is all `create or replace view` allows and what
52
+ -- keeps the grants, the comment and `security_invoker` of the object replaced.
53
+
54
+ -- ---------------------------------------------------------------------------
55
+ -- 1. The treatments behind a condition
56
+ -- ---------------------------------------------------------------------------
57
+
58
+ create or replace function legal_mention_treatments(p_applies_when text)
59
+ returns text[]
60
+ language sql
61
+ immutable
62
+ set search_path = public, pg_temp
63
+ as $$
64
+ select case p_applies_when
65
+ when 'reverse_charge' then array['domestic_reverse_charge',
66
+ 'foreign_services_received',
67
+ 'intracom_triangular']
68
+ when 'intra_eu_goods' then array['intracom_goods', 'intracom_acquisition_goods']
69
+ when 'intra_eu_services' then array['intracom_services', 'intracom_acquisition_services']
70
+ when 'export' then array['export']
71
+ when 'exempt' then array['exempt']
72
+ end;
73
+ $$;
74
+
75
+ comment on function legal_mention_treatments(text) is
76
+ 'The tax treatments a condition of legal_mention_templates.applies_when covers, or null for a condition that is not about a treatment (always, late_payment, cash_basis, small_business). The one place that says so: document_legal_mentions reads it to print the sentence, document_tax_summary to give a VAT group its reason.';
77
+
78
+ revoke execute on function legal_mention_treatments(text) from public, anon;
79
+ grant execute on function legal_mention_treatments(text) to authenticated, service_role;
80
+
81
+ create or replace view document_legal_mentions
82
+ with (security_invoker = true) as
83
+ with document_taxes as (
84
+ select d.id as document_id,
85
+ d.company_id,
86
+ c.fiscal_country as country,
87
+ d.document_date,
88
+ d.doc_type,
89
+ d.language,
90
+ coalesce(
91
+ array_agg(distinct t.treatment::text) filter (where t.treatment is not null),
92
+ array[]::text[]
93
+ ) as treatments,
94
+ coalesce(bool_or(t.cash_basis), false) as on_cash_basis
95
+ from documents d
96
+ join companies c on c.id = d.company_id
97
+ left join document_lines l on l.document_id = d.id
98
+ left join taxes t on t.id = l.tax_id
99
+ group by d.id, d.company_id, c.fiscal_country, d.document_date, d.doc_type, d.language
100
+ )
101
+ select dt.document_id,
102
+ dt.company_id,
103
+ m.country,
104
+ m.code,
105
+ m.applies_when,
106
+ dt.language,
107
+ label_for(m.text, m.text_i18n,
108
+ preferred_languages(dt.language, dt.company_id)) as text,
109
+ m.text_i18n,
110
+ m.sequence,
111
+ m.legal_reference
112
+ from document_taxes dt
113
+ join legal_mention_templates m
114
+ on m.country = dt.country
115
+ and m.valid_from <= dt.document_date
116
+ and (m.valid_to is null or m.valid_to >= dt.document_date)
117
+ where case m.applies_when
118
+ when 'always' then true
119
+ when 'cash_basis' then dt.on_cash_basis
120
+ when 'late_payment' then dt.doc_type in ('sale_invoice', 'sale_credit_note')
121
+ else coalesce(dt.treatments && legal_mention_treatments(m.applies_when), false)
122
+ end;
123
+
124
+ grant select on table document_legal_mentions to authenticated, service_role;
125
+
126
+ -- ---------------------------------------------------------------------------
127
+ -- 2. The breakdown, with its reason
128
+ --
129
+ -- `20260918141107` and three columns. `d.id` and `t.id` are primary keys and
130
+ -- both are grouped on, so the columns of a document and of a tax may be read
131
+ -- without being aggregated. A reader that does not select the reason does not
132
+ -- pay for it: the planner drops an unread scalar subquery from a view, and
133
+ -- `documents_refresh_totals()` reads two sums.
134
+ -- ---------------------------------------------------------------------------
135
+
136
+ create or replace view document_tax_summary
137
+ with (security_invoker = true) as
138
+ select l.document_id,
139
+ l.company_id,
140
+ d.doc_type,
141
+ l.tax_id,
142
+ t.code as tax_code,
143
+ t.name as tax_name,
144
+ l.vat_category,
145
+ l.vat_rate::numeric(12, 4) as tax_rate,
146
+ sum(l.amount_untaxed) as base_amount,
147
+ -- Gross tax: what the VAT return reports.
148
+ round_amount(
149
+ case when bool_and(l.unit_price_includes_tax)
150
+ then sum(l.amount_incl_tax)
151
+ - sum(l.amount_incl_tax) / (1 + coalesce(l.vat_rate, 0) / 100)
152
+ else sum(l.amount_untaxed) * coalesce(l.vat_rate, 0) / 100
153
+ end,
154
+ rounding_of(l.company_id, d.currency_code)) as tax_amount,
155
+ -- Charged tax: what the other party actually pays. Zero when the tax
156
+ -- postings net out, which is exactly what self-assessment means.
157
+ round_amount(
158
+ case when bool_and(l.unit_price_includes_tax)
159
+ then sum(l.amount_incl_tax)
160
+ - sum(l.amount_incl_tax) / (1 + coalesce(l.vat_rate, 0) / 100)
161
+ else sum(l.amount_untaxed) * coalesce(l.vat_rate, 0) / 100
162
+ end
163
+ * coalesce((
164
+ select sum(tp.factor_percent)
165
+ from tax_postings tp
166
+ where tp.tax_id = t.id
167
+ and tp.posting_type in ('tax', 'tax_on_base')
168
+ and tp.document_kind = case
169
+ when d.doc_type in ('sale_credit_note', 'purchase_credit_note')
170
+ then 'credit_note'::tax_document_kind
171
+ else 'invoice'::tax_document_kind
172
+ end
173
+ ), 100) / 100,
174
+ rounding_of(l.company_id, d.currency_code)) as tax_charged,
175
+ t.exemption_code, -- BT-121
176
+ -- BT-120: the sentence the country puts on an invoice for the
177
+ -- treatment of this tax, in the language of the document. Several
178
+ -- sentences for one treatment are several sentences, in their order.
179
+ (select string_agg(
180
+ label_for(m.text, m.text_i18n, preferred_languages(d.language, d.company_id)),
181
+ ' ' order by m.sequence, m.code)
182
+ from legal_mention_templates m
183
+ join companies c on c.id = d.company_id
184
+ where m.country = c.fiscal_country
185
+ and m.valid_from <= d.document_date
186
+ and (m.valid_to is null or m.valid_to >= d.document_date)
187
+ and t.treatment::text = any (legal_mention_treatments(m.applies_when))
188
+ ) as exemption_reason,
189
+ t.legal_reference
190
+ from document_lines l
191
+ join documents d on d.id = l.document_id
192
+ left join taxes t on t.id = l.tax_id
193
+ where l.line_type = 'product'
194
+ group by l.document_id, l.company_id, d.id, l.tax_id, t.id, l.vat_category, l.vat_rate;
195
+
196
+ comment on view document_tax_summary is
197
+ 'VAT breakdown of a document, one row per tax, rounded once on the group (EN 16931 BR-CO-14) — on the group''s base, or on the gross it was quoted at where the price includes the tax. The category and the rate are the ones the lines carry: the tax of today for a draft, the tax as it stood for a document that was posted. Where the tax charges nothing it says why: exemption_code is BT-121 as the pack codes it, exemption_reason is BT-120 — the sentence the country puts on an invoice for that treatment, in the language of the document, null where the pack has none. legal_reference is the article the tax rests on, written for whoever reviews the pack and not for a customer.';
198
+
199
+ grant select on table document_tax_summary to authenticated, service_role;
200
+
201
+ -- ---------------------------------------------------------------------------
202
+ -- 3. The header, with what it left in the tables
203
+ -- ---------------------------------------------------------------------------
204
+
205
+ create or replace view document_header
206
+ with (security_invoker = true) as
207
+ select d.id as document_id,
208
+ d.company_id,
209
+ d.doc_type,
210
+ d.state,
211
+ d.payment_state,
212
+ d.number,
213
+ d.supplier_reference,
214
+ d.document_date,
215
+ d.accounting_date,
216
+ d.due_date,
217
+ d.delivery_date,
218
+ d.currency_code,
219
+ d.amount_untaxed,
220
+ d.amount_tax,
221
+ d.amount_total,
222
+ d.amount_paid,
223
+ d.amount_residual,
224
+ d.payment_terms,
225
+ d.payment_means_code,
226
+ d.payment_reference,
227
+ d.buyer_reference,
228
+ d.order_reference,
229
+ d.contract_reference,
230
+ d.project_reference,
231
+ d.note,
232
+ coalesce(d.payee_iban, seller_bank.iban) as payee_iban,
233
+ seller_bank.bic as payee_bic,
234
+
235
+ -- The seller: this company.
236
+ coalesce(c.trade_name, c.name) as seller_name,
237
+ c.legal_name as seller_legal_name,
238
+ c.legal_form as seller_legal_form,
239
+ c.vat_number as seller_vat_number,
240
+ c.registration_number as seller_registration_number,
241
+ c.address_line1 as seller_address_line1,
242
+ c.address_line2 as seller_address_line2,
243
+ c.postal_code as seller_postal_code,
244
+ c.city as seller_city,
245
+ c.country as seller_country,
246
+ c.region as seller_region,
247
+ c.email as seller_email,
248
+ c.phone as seller_phone,
249
+ c.website as seller_website,
250
+ c.logo_url as seller_logo_url,
251
+ c.share_capital as seller_share_capital,
252
+ c.share_capital_currency as seller_share_capital_currency,
253
+ c.activity_code as seller_activity_code,
254
+ c.activity_scheme as seller_activity_scheme,
255
+ c.document_template,
256
+
257
+ -- The buyer: the contact the document is written against.
258
+ ct.id as buyer_id,
259
+ ct.name as buyer_name,
260
+ ct.vat_number as buyer_vat_number,
261
+ ct.registration_number as buyer_registration_number,
262
+ ct.address_line1 as buyer_address_line1,
263
+ ct.address_line2 as buyer_address_line2,
264
+ ct.postal_code as buyer_postal_code,
265
+ ct.city as buyer_city,
266
+ ct.country as buyer_country,
267
+ ct.region as buyer_region,
268
+ ct.email as buyer_email,
269
+
270
+ -- What the country of the document requires. Its country is the
271
+ -- company's fiscal_country — the country whose VAT rules apply — and
272
+ -- not its address, which is the same distinction
273
+ -- document_legal_mentions makes.
274
+ c.fiscal_country as country,
275
+ cd.number_format,
276
+ cd.numbering_gapless,
277
+ cd.legal_payment_days,
278
+ cd.late_payment_reference,
279
+ cd.tax_point_rule,
280
+ cd.einvoice_profile,
281
+ cd.einvoice_mandatory_from,
282
+ cd.party_scheme,
283
+ cd.vat_scheme,
284
+
285
+ -- What the document is written in. Not the reader's preference and
286
+ -- not the customer's of today: the document's own, as of the day it
287
+ -- was issued.
288
+ d.language,
289
+
290
+ -- The day the tax fell due, where somebody stated one (BT-7), and
291
+ -- where the goods went (BG-15), as far as `documents` records it.
292
+ d.tax_point_date,
293
+ d.delivery_address_line1,
294
+ d.delivery_postal_code,
295
+ d.delivery_city,
296
+ d.delivery_country,
297
+
298
+ -- Where the network delivers: BT-34 for the seller, BT-49 for the
299
+ -- buyer. A scheme and a value each, both or neither.
300
+ c.peppol_scheme as seller_peppol_scheme,
301
+ c.peppol_identifier as seller_peppol_identifier,
302
+ ct.peppol_scheme as buyer_peppol_scheme,
303
+ ct.peppol_identifier as buyer_peppol_identifier
304
+ from documents d
305
+ join companies c on c.id = d.company_id
306
+ join contacts ct on ct.id = d.contact_id
307
+ left join bank_accounts seller_bank on seller_bank.id = c.default_bank_account_id
308
+ left join country_defaults cd on cd.country = c.fiscal_country;
309
+
310
+ comment on view document_header is
311
+ 'One document with everything printed above its lines: the seller, the buyer, the amounts, where it is paid, what its country requires, the language it is written in, the tax point and the delivery where they were stated, and the electronic address of both parties. The lines are document_line_items and the sentences are document_legal_mentions.';
312
+
313
+ grant select on table document_header to authenticated, service_role;
@@ -0,0 +1,132 @@
1
+ -- Ekwo OS — a policy asks once per statement, not once per row.
2
+ --
3
+ -- Found by `tests/load/`, the first test of this repository that reads books
4
+ -- of more than fifteen documents, and reads them as a signed-in member rather
5
+ -- than as the owner of the database.
6
+ --
7
+ -- Every table of a company carried the policy
8
+ --
9
+ -- using (has_capability(company_id, 'entries.read'))
10
+ --
11
+ -- and `has_capability()` is `stable`. It was read here — and written down on
12
+ -- the roadmap — as "evaluated once per query". That is not what `stable`
13
+ -- means. It promises Postgres that the function gives the same answer for the
14
+ -- same arguments within one statement; it does not make Postgres remember
15
+ -- the answer. With a column as its argument the function is called for every
16
+ -- row the scan visits, and being SECURITY DEFINER it cannot be inlined: each
17
+ -- call is a function entry and one or two index probes of its own.
18
+ --
19
+ -- Measured on PGlite, five companies of 10 000 documents, one of them read:
20
+ --
21
+ -- as the owner as a member calls of has_capability()
22
+ -- general_ledger 15 ms 133 ms about 11 700
23
+ -- trial_balance 72 ms 3 266 ms about 359 000
24
+ -- aged_balance 25 ms 233 ms about 20 100
25
+ -- vat_return 44 ms 460 ms about 40 000
26
+ --
27
+ -- The plans were the same on both sides — the same indexes, no sequential
28
+ -- scan — so nothing about the shape of a plan would ever have shown it. The
29
+ -- whole difference is the policy, and it is nine to forty-five times the cost
30
+ -- of the query it guards.
31
+ --
32
+ -- ---------------------------------------------------------------------------
33
+ -- What changes
34
+ --
35
+ -- `companies_with_capability(capability)` answers the same question the other
36
+ -- way round: not "may the caller do this in that company" but "in which
37
+ -- companies may the caller do this", as an array. A policy then reads
38
+ --
39
+ -- using (company_id = any ((select companies_with_capability('entries.read'))::uuid[]))
40
+ --
41
+ -- and the sub-select, which depends on no column, is an InitPlan: Postgres
42
+ -- runs it once per statement and compares every row against the result. As a
43
+ -- bonus the comparison is an ordinary `=` on an indexed column, which the
44
+ -- planner may use to reach the rows, where a function call could only ever
45
+ -- filter them.
46
+ --
47
+ -- **One definition of who may do what.** The new function does not restate
48
+ -- the rules of `has_capability()` — revoked beats granted, a key is consulted
49
+ -- only where there is no member answer. It lists the only companies where the
50
+ -- answer can be yes — those the caller is a member of, and the one a presented
51
+ -- key belongs to — and asks `has_capability()` about each. A change to the
52
+ -- rules changes both, and `tests/capabilities.test.ts` compares the two over
53
+ -- every preset, every capability and a machine key.
54
+ --
55
+ -- **Which policies.** Those whose expression is exactly
56
+ -- `has_capability(company_id, '<capability>')`, in `public`: fifty-two of
57
+ -- them, a select and a write policy on twenty-six tables. They are rewritten
58
+ -- by reading the catalogue rather than by fifty-two statements, so that the
59
+ -- capability each policy names is carried over and cannot be mistyped here.
60
+ -- The block refuses to finish if it found none, or if one of that shape is
61
+ -- left.
62
+ --
63
+ -- `with check` is left as it was. It is evaluated once per row *written*,
64
+ -- which is what a check is for, and a statement that writes ten thousand rows
65
+ -- is an import, not a report.
66
+ --
67
+ -- Left alone, and said so: the policies that combine the test with another
68
+ -- (`attachments`, `api_keys`, `company_members`), the ones that reach the
69
+ -- company through a parent row (`journal_sequences`, `tax_filing_boxes`,
70
+ -- `tax_filing_deposits`), and the modules, whose policies also ask
71
+ -- `module_enabled()`. None of them guards a table a report walks.
72
+ -- `docs/decisions.md` records them.
73
+
74
+ create or replace function companies_with_capability(p_capability text)
75
+ returns uuid[]
76
+ language sql
77
+ stable
78
+ security definer
79
+ set search_path = public, pg_temp
80
+ as $$
81
+ select coalesce(array_agg(x.company_id), '{}'::uuid[])
82
+ from (
83
+ select m.company_id
84
+ from company_members m
85
+ where m.user_id = auth.uid()
86
+ union
87
+ select k.company_id
88
+ from api_keys k
89
+ where k.key_hash = nullif(current_setting('ekwo.api_key', true), '')
90
+ ) x
91
+ where has_capability(x.company_id, p_capability);
92
+ $$;
93
+
94
+ comment on function companies_with_capability(text) is
95
+ 'The companies in which the current caller may do one named thing — the question has_capability() answers, asked once for all of them. It is what a row level security policy compares company_id against, inside a sub-select, so that the answer is worked out once per statement instead of once per row.';
96
+
97
+ revoke execute on function companies_with_capability(text) from public, anon;
98
+ grant execute on function companies_with_capability(text) to authenticated, service_role;
99
+
100
+ do $$
101
+ declare
102
+ r record;
103
+ v_shape constant text := '^has_capability\(company_id, (''[a-z_.]+'')::text\)$';
104
+ v_changed integer := 0;
105
+ v_left integer;
106
+ begin
107
+ for r in
108
+ select p.schemaname, p.tablename, p.policyname,
109
+ (regexp_match(p.qual, v_shape))[1] as capability
110
+ from pg_policies p
111
+ where p.schemaname = 'public'
112
+ and p.qual ~ v_shape
113
+ order by p.tablename, p.policyname
114
+ loop
115
+ execute format(
116
+ 'alter policy %I on %I.%I using (company_id = any ((select companies_with_capability(%s))::uuid[]))',
117
+ r.policyname, r.schemaname, r.tablename, r.capability);
118
+ v_changed := v_changed + 1;
119
+ end loop;
120
+
121
+ if v_changed = 0 then
122
+ raise exception 'no policy of the shape has_capability(company_id, …) was found: either this ran twice, or the policies changed shape and this migration has to be read again';
123
+ end if;
124
+
125
+ select count(*) into v_left
126
+ from pg_policies p
127
+ where p.schemaname = 'public' and p.qual ~ v_shape;
128
+ if v_left > 0 then
129
+ raise exception '% policies still call has_capability() once per row', v_left;
130
+ end if;
131
+ end;
132
+ $$;
@@ -0,0 +1,204 @@
1
+ -- Ekwo OS — a suggestion looks at the contacts it could be, not at all of them.
2
+ --
3
+ -- Found by `tests/load/`: `suggest_contacts()` over one month of bank
4
+ -- statement — 167 lines, a company of 500 contacts — took 3.5 seconds on
5
+ -- PGlite, twenty milliseconds a line, when the five reports beside it answer
6
+ -- in tens of milliseconds over the whole ledger. With this migration the same
7
+ -- month takes 0.24 seconds, and gives the same answer line for line: the old
8
+ -- body and the new were run side by side over the 167 lines and their 974
9
+ -- suggestions compared — contact, score, method, sentence and alternatives.
10
+ --
11
+ -- Nothing is wrong with its plan. `contacts` is reached through its company
12
+ -- index and no large table is scanned. The cost is what it does to each row:
13
+ -- for every active contact of the company it calls `significant_words()` on
14
+ -- the name — a SQL function with an aggregate in it, which Postgres cannot
15
+ -- inline and so enters once per contact — and then intersects two arrays up
16
+ -- to three times to score the pair. Five hundred contacts is five hundred of
17
+ -- those per statement line, and every one of them but a handful ends in
18
+ -- `score is null`: the names have no word in common. It was written down as a
19
+ -- risk when the function shipped — "O(contacts) per line, fine interactively,
20
+ -- heavy for a year of statements" — and this is the measure of it.
21
+ --
22
+ -- ---------------------------------------------------------------------------
23
+ -- What changes
24
+ --
25
+ -- `contacts.name_words`: the words of the name, stored, worked out by the same
26
+ -- `significant_words()` so the two can never split a name differently. It is a
27
+ -- generated column, so nothing has to remember to maintain it.
28
+ --
29
+ -- It holds **every** word, not only the significant ones. How long a word has
30
+ -- to be to count is a setting of the company (`matching_settings`), and a
31
+ -- stored column cannot depend on a setting; a word of any length is the
32
+ -- superset every setting selects from.
33
+ --
34
+ -- `suggest_contacts()` is replaced whole, with one line added to `by_name`:
35
+ -- `c.name_words && v_words`. That is a filter before the scoring and never a
36
+ -- change to it, because every score the function gives requires a shared
37
+ -- word already:
38
+ --
39
+ -- - 0.900 and the graded 0.650–0.900 are defined on the intersection of the
40
+ -- contact's significant words with the statement's, which is inside the
41
+ -- intersection of *all* the contact's words with the statement's;
42
+ -- - 0.950 is the two names being equal once trimmed and lowercased. The
43
+ -- statement's words are the significant words of that same text, and the
44
+ -- function only reaches this point when there is at least one — so the
45
+ -- contact's name contains it.
46
+ --
47
+ -- So no contact that scored before is set aside now, and the scores, the
48
+ -- sentences and the count of alternatives are computed by the lines that
49
+ -- computed them yesterday. `tests/contact_matching.test.ts` is unchanged and
50
+ -- passes; `tests/load/` reports the time.
51
+ --
52
+ -- ---------------------------------------------------------------------------
53
+ -- What is deliberately not here: an index on `name_words`.
54
+ --
55
+ -- A GIN index on the column is the obvious companion, and it was tried. With
56
+ -- 2 500 contacts in the instance, 500 of them in the company, the planner does
57
+ -- pick it — and the month of statement goes from 238 ms to 217 ms. The time
58
+ -- was never in finding the rows of a company, which `contacts_company_name_idx`
59
+ -- already does; it was in the function calls made on each of them. A tenth of
60
+ -- the time is not worth an index that every write of a contact has to
61
+ -- maintain, so it waits for an instance whose contacts are counted in tens of
62
+ -- thousands, and for a plan that asks for it.
63
+
64
+ alter table contacts
65
+ add column name_words text[] generated always as (significant_words(name, 1)) stored;
66
+
67
+ comment on column contacts.name_words is
68
+ 'Every word of the name, lowercased and deduplicated by significant_words(). Generated. Read by suggest_contacts() to set aside, with one array operator, the contacts whose name shares no word with a statement line — before it scores the ones that do.';
69
+
70
+ create or replace function suggest_contacts(p_transaction_id uuid)
71
+ returns table (
72
+ contact_id uuid,
73
+ score numeric,
74
+ method text,
75
+ because text,
76
+ alternatives integer
77
+ )
78
+ language plpgsql
79
+ stable
80
+ security invoker
81
+ as $$
82
+ declare
83
+ v_tx bank_transactions;
84
+ v_policy matching_policy;
85
+ v_words text[];
86
+ begin
87
+ -- Row level security answers this select, so a member of another company
88
+ -- gets nothing and is told nothing.
89
+ select * into v_tx from bank_transactions t where t.id = p_transaction_id;
90
+ if not found then
91
+ raise exception 'not_found: bank transaction %', p_transaction_id
92
+ using errcode = 'no_data_found';
93
+ end if;
94
+
95
+ v_policy := matching_policy_of(v_tx.company_id);
96
+ v_words := significant_words(
97
+ coalesce(v_tx.counterpart_name, v_tx.description),
98
+ v_policy.minimum_word_length);
99
+
100
+ return query
101
+ with
102
+ -- 1. The account the money moved from or to. An account identifier is not a
103
+ -- resemblance: either the statement carried the one recorded against the
104
+ -- contact, or it did not. Hence a score of 1, and the only evidence that
105
+ -- needs no threshold.
106
+ by_account as (
107
+ select c.id, 1::numeric as score, 'account'::text as method,
108
+ format('the statement carries the account recorded against %s', c.name) as because
109
+ from contacts c
110
+ where c.company_id = v_tx.company_id
111
+ and v_tx.counterpart_iban is not null
112
+ and c.iban is not null
113
+ and upper(replace(c.iban, ' ', '')) = upper(replace(v_tx.counterpart_iban, ' ', ''))
114
+ union
115
+ select p.contact_id, 1::numeric, 'account',
116
+ format('a motif of %s carries this account', c.name)
117
+ from contact_patterns p
118
+ join contacts c on c.id = p.contact_id
119
+ where p.company_id = v_tx.company_id
120
+ and p.active
121
+ and p.kind = 'counterparty_account'
122
+ and v_tx.counterpart_iban is not null
123
+ and upper(replace(p.value, ' ', '')) = upper(replace(v_tx.counterpart_iban, ' ', ''))
124
+ ),
125
+ -- 2. A motif that was learned or declared. Its score is its confidence: what
126
+ -- it has been worth so far, and nothing else.
127
+ by_pattern as (
128
+ select p.contact_id as id, p.confidence as score,
129
+ ('pattern:' || p.kind::text)::text as method,
130
+ format('%s, a motif of %s confirmed %s of %s times',
131
+ coalesce(p.value, format('%s to %s', p.amount_min, p.amount_max)),
132
+ c.name, p.success_count, p.usage_count) as because
133
+ from contact_patterns p
134
+ join contacts c on c.id = p.contact_id
135
+ where p.company_id = v_tx.company_id
136
+ and p.active
137
+ and case p.kind
138
+ when 'name_variation' then
139
+ v_tx.counterpart_name is not null
140
+ and lower(trim(v_tx.counterpart_name)) = lower(trim(p.value))
141
+ when 'description_keyword' then
142
+ v_tx.description is not null
143
+ and position(lower(p.value) in lower(v_tx.description)) > 0
144
+ and not exists (
145
+ select 1 from unnest(coalesce(p.exclude_words, '{}'::text[])) as x(word)
146
+ where position(lower(x.word) in lower(v_tx.description)) > 0
147
+ )
148
+ when 'amount_range' then
149
+ p.currency_code = v_tx.currency_code
150
+ and abs(v_tx.amount) between p.amount_min and p.amount_max
151
+ else false
152
+ end
153
+ ),
154
+ -- 3. The name, compared word by word. Three shapes, from the one that says
155
+ -- the most to the one that says the least, and the last of them is why
156
+ -- `alternatives` exists.
157
+ by_name as (
158
+ select c.id, s.score, 'name'::text as method, s.because
159
+ from contacts c
160
+ cross join lateral (
161
+ select significant_words(c.name, v_policy.minimum_word_length) as words
162
+ ) w
163
+ cross join lateral (
164
+ select
165
+ case
166
+ when lower(trim(c.name)) = lower(trim(coalesce(v_tx.counterpart_name, ''))) then 0.950
167
+ when cardinality(w.words) > 0 and w.words <@ v_words then 0.900
168
+ when cardinality(w.words) > 0 and v_words <@ w.words then 0.900
169
+ when cardinality(array(select unnest(w.words) intersect select unnest(v_words))) > 0 then
170
+ 0.650 + 0.250 * (
171
+ cardinality(array(select unnest(w.words) intersect select unnest(v_words)))::numeric
172
+ / least(cardinality(w.words), cardinality(v_words))
173
+ )
174
+ else null
175
+ end as score,
176
+ format('the name on the statement shares %s with %s',
177
+ array_to_string(array(select unnest(w.words) intersect select unnest(v_words)), ', '),
178
+ c.name) as because
179
+ ) s
180
+ where c.company_id = v_tx.company_id
181
+ and c.active
182
+ and cardinality(v_words) > 0
183
+ -- The one line this migration adds. A contact whose name shares no word
184
+ -- with the statement scores nothing below, so it is set aside here, by
185
+ -- an array operator on a stored column, before anything is worked out
186
+ -- about it.
187
+ and c.name_words && v_words
188
+ and s.score is not null
189
+ and s.score >= v_policy.name_threshold
190
+ ),
191
+ all_candidates as (
192
+ select * from by_account
193
+ union all select * from by_pattern
194
+ union all select * from by_name
195
+ )
196
+ select a.id, a.score, a.method, a.because,
197
+ count(*) over (partition by a.method)::integer
198
+ from all_candidates a
199
+ order by a.score desc, a.method;
200
+ end;
201
+ $$;
202
+
203
+ comment on function suggest_contacts(uuid) is
204
+ 'Who this statement line could be, with the score, the evidence in a sentence, and how many contacts that same evidence reached. It writes nothing and decides nothing: a caller that applies a match on its own is expected to require alternatives = 1. Only the contacts whose name shares a word with the line are scored, through contacts.name_words.';