ekwo-os 0.4.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +873 -0
- package/dist/args.d.ts +57 -0
- package/dist/args.d.ts.map +1 -0
- package/dist/args.js +134 -0
- package/dist/args.js.map +1 -0
- package/dist/assets/expected-objects.json +9432 -0
- package/dist/assets/migrations/20260911120000_core_companies.sql +260 -0
- package/dist/assets/migrations/20260911120100_accounts_journals.sql +211 -0
- package/dist/assets/migrations/20260911120200_contacts.sql +90 -0
- package/dist/assets/migrations/20260911120300_taxes.sql +146 -0
- package/dist/assets/migrations/20260911120400_entries.sql +338 -0
- package/dist/assets/migrations/20260911120500_documents.sql +279 -0
- package/dist/assets/migrations/20260911120600_payments_reconciliation.sql +251 -0
- package/dist/assets/migrations/20260911120700_bank.sql +178 -0
- package/dist/assets/migrations/20260911120800_analytics_attachments.sql +128 -0
- package/dist/assets/migrations/20260911120900_post_document.sql +279 -0
- package/dist/assets/migrations/20260911121000_reporting.sql +235 -0
- package/dist/assets/migrations/20260911121100_country_templates.sql +226 -0
- package/dist/assets/migrations/20260911121200_fec.sql +71 -0
- package/dist/assets/migrations/20260911130000_instance.sql +77 -0
- package/dist/assets/migrations/20260911130100_instance_members.sql +224 -0
- package/dist/assets/migrations/20260911140000_instance_admins.sql +112 -0
- package/dist/assets/migrations/20260911160000_tax_posting_templates_unique.sql +17 -0
- package/dist/assets/migrations/20260911170000_document_amount_paid.sql +82 -0
- package/dist/assets/migrations/20260911173000_post_payment.sql +122 -0
- package/dist/assets/migrations/20260911173100_sequence_counters_under_rls.sql +84 -0
- package/dist/assets/migrations/20260911183000_country_journal_defaults.sql +143 -0
- package/dist/assets/migrations/20260911193853_line_account_defaults.sql +274 -0
- package/dist/assets/migrations/20260911195054_products.sql +221 -0
- package/dist/assets/migrations/20260911210131_anon_surface.sql +85 -0
- package/dist/assets/migrations/20260912074712_country_packs.sql +318 -0
- package/dist/assets/migrations/20260912080311_report_code_and_region.sql +198 -0
- package/dist/assets/migrations/20260912081014_pack_certification_maintained.sql +31 -0
- package/dist/assets/migrations/20260912081015_pack_certification_backfill.sql +16 -0
- package/dist/assets/migrations/20260912090407_tax_report_boxes.sql +329 -0
- package/dist/assets/migrations/20260912091917_tax_on_base_value.sql +25 -0
- package/dist/assets/migrations/20260912091918_tax_engine_columns.sql +776 -0
- package/dist/assets/migrations/20260912094412_opening_and_closing.sql +759 -0
- package/dist/assets/migrations/20260912095825_charts_of_accounts.sql +326 -0
- package/dist/assets/migrations/20260912100412_financial_statements.sql +632 -0
- package/dist/assets/migrations/20260912104719_one_formula_evaluator.sql +209 -0
- package/dist/assets/migrations/20260912105720_entry_kind_appropriation.sql +26 -0
- package/dist/assets/migrations/20260912105721_appropriation_entry_kind.sql +444 -0
- package/dist/assets/migrations/20260912111751_document_rules.sql +274 -0
- package/dist/assets/migrations/20260912112132_cash_basis_vat_and_fx.sql +1189 -0
- package/dist/assets/migrations/20260913074512_modules.sql +493 -0
- package/dist/assets/migrations/20260913075903_asset_disposal_roles.sql +47 -0
- package/dist/assets/migrations/20260913083216_capabilities.sql +637 -0
- package/dist/assets/migrations/20260913083901_company_invitations.sql +262 -0
- package/dist/assets/migrations/20260913084402_user_preferences.sql +376 -0
- package/dist/assets/migrations/20260913084847_company_profile.sql +261 -0
- package/dist/assets/migrations/20260913085436_numbering_reads_the_pack.sql +235 -0
- package/dist/assets/migrations/20260913085932_api_keys.sql +303 -0
- package/dist/assets/migrations/20260913090216_fiscal_year_bounds.sql +159 -0
- package/dist/assets/migrations/20260913092527_entries_import.sql +219 -0
- package/dist/assets/migrations/20260913101536_null_safe_guards.sql +188 -0
- package/dist/assets/migrations/20260913102115_installer_is_named.sql +462 -0
- package/dist/assets/migrations/20260913102758_no_currency_default.sql +138 -0
- package/dist/assets/migrations/20260913103355_cash_basis_needs_a_box.sql +453 -0
- package/dist/assets/migrations/20260913104014_aged_balance_named_group.sql +79 -0
- package/dist/assets/migrations/20260913104232_foreign_key_indexes.sql +144 -0
- package/dist/assets/migrations/20260913105120_declared_no_reader.sql +41 -0
- package/dist/assets/migrations/20260913111407_pack_languages.sql +223 -0
- package/dist/assets/migrations/20260913112233_schema_comments_for_a_reader.sql +27 -0
- package/dist/assets/migrations/20260913114535_fec_opening_balances.sql +307 -0
- package/dist/assets/migrations/20260914103412_audit_log.sql +509 -0
- package/dist/assets/migrations/20260914111907_pack_upgrade.sql +428 -0
- package/dist/assets/migrations/20260914120500_rounding_reads_the_currency.sql +206 -0
- package/dist/assets/migrations/20260914121200_amounts_round_at_the_currency.sql +2403 -0
- package/dist/assets/migrations/20260914134325_schema_version_0_2_0.sql +36 -0
- package/dist/assets/migrations/20260914143915_accounts_in_use.sql +428 -0
- package/dist/assets/migrations/20260914144731_account_code_frozen.sql +99 -0
- package/dist/assets/migrations/20260914151207_schema_grants_its_own_rights.sql +284 -0
- package/dist/assets/migrations/20260914152840_pack_upgrade_records_its_own_line.sql +237 -0
- package/dist/assets/migrations/20260914163943_declaration_periodicity.sql +349 -0
- package/dist/assets/migrations/20260915094000_foreign_services_received.sql +37 -0
- package/dist/assets/migrations/20260915094500_mentions_read_a_foreign_service.sql +72 -0
- package/dist/assets/migrations/20260915153000_document_shares.sql +568 -0
- package/dist/assets/migrations/20260915160000_ec_sales_list.sql +181 -0
- package/dist/assets/migrations/20260915161842_pack_sources.sql +56 -0
- package/dist/assets/migrations/20260915170500_schema_version_0_3_0.sql +45 -0
- package/dist/assets/migrations/20260915174500_a_vat_category_is_a_code.sql +177 -0
- package/dist/assets/migrations/20260915180000_document_rule_references.sql +85 -0
- package/dist/assets/migrations/20260915181000_territories.sql +342 -0
- package/dist/assets/migrations/20260915181500_ec_sales_list_reads_the_territories.sql +222 -0
- package/dist/assets/migrations/20260915182000_intracom_triangular.sql +53 -0
- package/dist/assets/migrations/20260915182500_a_triangular_supply_says_reverse_charge.sql +99 -0
- package/dist/assets/migrations/20260915191200_a_document_knows_its_language.sql +633 -0
- package/dist/assets/migrations/20260915195000_a_price_that_holds_its_tax.sql +649 -0
- package/dist/assets/migrations/20260915200000_a_posting_names_its_boxes.sql +1004 -0
- package/dist/assets/migrations/20260916094500_a_company_files_more_than_one_declaration.sql +795 -0
- package/dist/assets/migrations/20260916103000_a_ledger_line_names_its_posting.sql +902 -0
- package/dist/assets/migrations/20260916123000_a_vocabulary_for_a_tax_that_is_not_a_vat.sql +144 -0
- package/dist/assets/migrations/20260916124000_a_box_can_be_a_rate_of_a_box.sql +525 -0
- package/dist/assets/migrations/20260916125000_a_tax_follows_the_territory.sql +1318 -0
- package/dist/assets/migrations/20260916126000_a_tax_point_and_its_exception.sql +1211 -0
- package/dist/assets/migrations/20260917090000_a_counterparty_that_learns.sql +561 -0
- package/dist/assets/migrations/20260917120000_what_the_money_pays.sql +545 -0
- package/dist/assets/migrations/20260917150000_a_filing_is_a_row.sql +488 -0
- package/dist/assets/migrations/20260917170000_when_it_is_due.sql +194 -0
- package/dist/assets/migrations/20260917180000_a_box_is_a_number_and_a_kind.sql +213 -0
- package/dist/assets/migrations/20260917190000_what_a_declaration_owes.sql +486 -0
- package/dist/assets/migrations/20260917200000_what_moved_after_it_went.sql +227 -0
- package/dist/assets/migrations/20260918090000_a_form_names_its_file.sql +27 -0
- package/dist/assets/migrations/20260918110000_a_deposit_and_what_came_back.sql +286 -0
- package/dist/assets/migrations/20260918113741_a_fourth_preset_is_named.sql +9 -0
- package/dist/assets/migrations/20260918113807_a_client_reads_and_hands_over.sql +96 -0
- package/dist/assets/migrations/20260918114322_two_definer_functions_check_their_caller.sql +75 -0
- package/dist/assets/migrations/20260918130000_every_company_somebody_keeps.sql +177 -0
- package/dist/assets/migrations/20260918140000_a_session_nobody_prepared.sql +40 -0
- package/dist/assets/migrations/20260918141107_a_line_keeps_the_tax_it_was_posted_with.sql +256 -0
- package/dist/assets/migrations/20260918141342_a_company_has_an_electronic_address.sql +66 -0
- package/dist/assets/migrations/20260918141605_an_invoice_reads_whole_from_the_views.sql +313 -0
- package/dist/assets/migrations/20260918141627_a_policy_asks_once.sql +132 -0
- package/dist/assets/migrations/20260918143352_a_suggestion_reads_the_words_once.sql +204 -0
- package/dist/assets/migrations/20260918143417_a_reference_of_the_caller_and_a_rehearsal.sql +115 -0
- package/dist/assets/migrations/20260918150712_a_company_leaves_with_its_books.sql +1119 -0
- package/dist/assets/migrations/20260918150931_a_statement_is_imported_once.sql +654 -0
- package/dist/assets/migrations/20260918161204_a_posted_document_does_not_move.sql +437 -0
- package/dist/assets/migrations/20260918161538_a_posted_entry_does_not_move.sql +252 -0
- package/dist/assets/migrations/20260918171946_an_entry_is_posted_by_post_entry.sql +289 -0
- package/dist/assets/migrations/20260918174312_schema_version_0_4_0.sql +53 -0
- package/dist/assets/modules/assets/module.json +13 -0
- package/dist/assets/modules/assets/supabase/migrations/20260913081447_assets.sql +1190 -0
- package/dist/assets/modules/assets/supabase/migrations/20260913104234_foreign_key_indexes.sql +47 -0
- package/dist/assets/modules/assets/supabase/migrations/20260913104500_assets_capabilities.sql +145 -0
- package/dist/assets/modules/assets/supabase/migrations/20260914122100_assets_round_at_the_currency.sql +564 -0
- package/dist/assets/modules/assets/supabase/migrations/20260914145012_assets_accounts_in_use.sql +37 -0
- package/dist/assets/modules/assets/supabase/migrations/20260914151530_assets_grants_its_own_rights.sql +63 -0
- package/dist/assets/modules/assets/supabase/migrations/20260918113912_assets_client_reads.sql +20 -0
- package/dist/assets/modules/assets/supabase/migrations/20260918150801_assets_leave_with_the_company.sql +41 -0
- package/dist/assets/modules/budgets/module.json +12 -0
- package/dist/assets/modules/budgets/supabase/migrations/20260913083012_budgets.sql +210 -0
- package/dist/assets/modules/budgets/supabase/migrations/20260913104233_foreign_key_indexes.sql +37 -0
- package/dist/assets/modules/budgets/supabase/migrations/20260913104501_budgets_capabilities.sql +72 -0
- package/dist/assets/modules/budgets/supabase/migrations/20260914122400_budgets_round_at_the_currency.sql +68 -0
- package/dist/assets/modules/budgets/supabase/migrations/20260914145048_budgets_accounts_in_use.sql +30 -0
- package/dist/assets/modules/budgets/supabase/migrations/20260914151812_budgets_grants_its_own_rights.sql +31 -0
- package/dist/assets/modules/budgets/supabase/migrations/20260918113954_budgets_client_reads.sql +20 -0
- package/dist/assets/modules/budgets/supabase/migrations/20260918150823_budgets_leave_with_the_company.sql +37 -0
- package/dist/assets/modules/schema/module.1.json +69 -0
- package/dist/assets/seed/00_currencies.sql +15 -0
- package/dist/assets/seed/00_territories.sql +280 -0
- package/dist/assets/seed/05_framework_generic.sql +110 -0
- package/dist/assets/seed/10_pack_be.sql +1284 -0
- package/dist/assets/seed/11_pack_fr.sql +1112 -0
- package/dist/assets/seed/12_pack_lu.sql +2484 -0
- package/dist/assets/seed/13_pack_ee.sql +733 -0
- package/dist/assets/seed/14_pack_gb.sql +796 -0
- package/dist/assets/seed/15_pack_us.sql +751 -0
- package/dist/assets/seed/90_demo_company.sql +368 -0
- package/dist/assets/seed/modules/assets/10_pack_be.sql +58 -0
- package/dist/assets/seed/modules/assets/11_pack_fr.sql +57 -0
- package/dist/assets/seed/modules/assets/14_pack_gb.sql +58 -0
- package/dist/assets/seed/modules/assets/15_pack_us.sql +60 -0
- package/dist/auth.d.ts +59 -0
- package/dist/auth.d.ts.map +1 -0
- package/dist/auth.js +134 -0
- package/dist/auth.js.map +1 -0
- package/dist/bin.d.ts +6 -0
- package/dist/bin.d.ts.map +1 -0
- package/dist/bin.js +8 -0
- package/dist/bin.js.map +1 -0
- package/dist/books.d.ts +60 -0
- package/dist/books.d.ts.map +1 -0
- package/dist/books.js +168 -0
- package/dist/books.js.map +1 -0
- package/dist/bootstrap.d.ts +236 -0
- package/dist/bootstrap.d.ts.map +1 -0
- package/dist/bootstrap.js +459 -0
- package/dist/bootstrap.js.map +1 -0
- package/dist/bundle.d.ts +27 -0
- package/dist/bundle.d.ts.map +1 -0
- package/dist/bundle.js +47 -0
- package/dist/bundle.js.map +1 -0
- package/dist/checklist.d.ts +35 -0
- package/dist/checklist.d.ts.map +1 -0
- package/dist/checklist.js +64 -0
- package/dist/checklist.js.map +1 -0
- package/dist/cli.d.ts +14 -0
- package/dist/cli.d.ts.map +1 -0
- package/dist/cli.js +318 -0
- package/dist/cli.js.map +1 -0
- package/dist/commands/company.d.ts +59 -0
- package/dist/commands/company.d.ts.map +1 -0
- package/dist/commands/company.js +287 -0
- package/dist/commands/company.js.map +1 -0
- package/dist/commands/contact.d.ts +9 -0
- package/dist/commands/contact.d.ts.map +1 -0
- package/dist/commands/contact.js +82 -0
- package/dist/commands/contact.js.map +1 -0
- package/dist/commands/demo.d.ts +19 -0
- package/dist/commands/demo.d.ts.map +1 -0
- package/dist/commands/demo.js +72 -0
- package/dist/commands/demo.js.map +1 -0
- package/dist/commands/doctor.d.ts +12 -0
- package/dist/commands/doctor.d.ts.map +1 -0
- package/dist/commands/doctor.js +68 -0
- package/dist/commands/doctor.js.map +1 -0
- package/dist/commands/document.d.ts +17 -0
- package/dist/commands/document.d.ts.map +1 -0
- package/dist/commands/document.js +217 -0
- package/dist/commands/document.js.map +1 -0
- package/dist/commands/init.d.ts +31 -0
- package/dist/commands/init.d.ts.map +1 -0
- package/dist/commands/init.js +535 -0
- package/dist/commands/init.js.map +1 -0
- package/dist/commands/login.d.ts +24 -0
- package/dist/commands/login.d.ts.map +1 -0
- package/dist/commands/login.js +128 -0
- package/dist/commands/login.js.map +1 -0
- package/dist/commands/migrate.d.ts +30 -0
- package/dist/commands/migrate.d.ts.map +1 -0
- package/dist/commands/migrate.js +115 -0
- package/dist/commands/migrate.js.map +1 -0
- package/dist/commands/module.d.ts +32 -0
- package/dist/commands/module.d.ts.map +1 -0
- package/dist/commands/module.js +285 -0
- package/dist/commands/module.js.map +1 -0
- package/dist/commands/pack.d.ts +22 -0
- package/dist/commands/pack.d.ts.map +1 -0
- package/dist/commands/pack.js +481 -0
- package/dist/commands/pack.js.map +1 -0
- package/dist/commands/payment.d.ts +15 -0
- package/dist/commands/payment.d.ts.map +1 -0
- package/dist/commands/payment.js +97 -0
- package/dist/commands/payment.js.map +1 -0
- package/dist/commands/register.d.ts +20 -0
- package/dist/commands/register.d.ts.map +1 -0
- package/dist/commands/register.js +134 -0
- package/dist/commands/register.js.map +1 -0
- package/dist/commands/status.d.ts +8 -0
- package/dist/commands/status.d.ts.map +1 -0
- package/dist/commands/status.js +134 -0
- package/dist/commands/status.js.map +1 -0
- package/dist/commands/whoami.d.ts +21 -0
- package/dist/commands/whoami.d.ts.map +1 -0
- package/dist/commands/whoami.js +133 -0
- package/dist/commands/whoami.js.map +1 -0
- package/dist/company.d.ts +16 -0
- package/dist/company.d.ts.map +1 -0
- package/dist/company.js +26 -0
- package/dist/company.js.map +1 -0
- package/dist/config.d.ts +24 -0
- package/dist/config.d.ts.map +1 -0
- package/dist/config.js +50 -0
- package/dist/config.js.map +1 -0
- package/dist/connection.d.ts +79 -0
- package/dist/connection.d.ts.map +1 -0
- package/dist/connection.js +116 -0
- package/dist/connection.js.map +1 -0
- package/dist/context.d.ts +47 -0
- package/dist/context.d.ts.map +1 -0
- package/dist/context.js +93 -0
- package/dist/context.js.map +1 -0
- package/dist/doctor.d.ts +45 -0
- package/dist/doctor.d.ts.map +1 -0
- package/dist/doctor.js +448 -0
- package/dist/doctor.js.map +1 -0
- package/dist/grants.d.ts +88 -0
- package/dist/grants.d.ts.map +1 -0
- package/dist/grants.js +174 -0
- package/dist/grants.js.map +1 -0
- package/dist/identity.d.ts +54 -0
- package/dist/identity.d.ts.map +1 -0
- package/dist/identity.js +132 -0
- package/dist/identity.js.map +1 -0
- package/dist/index.d.ts +44 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +43 -0
- package/dist/index.js.map +1 -0
- package/dist/inventory.d.ts +155 -0
- package/dist/inventory.d.ts.map +1 -0
- package/dist/inventory.js +303 -0
- package/dist/inventory.js.map +1 -0
- package/dist/migrations.d.ts +76 -0
- package/dist/migrations.d.ts.map +1 -0
- package/dist/migrations.js +226 -0
- package/dist/migrations.js.map +1 -0
- package/dist/module/read.d.ts +91 -0
- package/dist/module/read.d.ts.map +1 -0
- package/dist/module/read.js +172 -0
- package/dist/module/read.js.map +1 -0
- package/dist/output.d.ts +110 -0
- package/dist/output.d.ts.map +1 -0
- package/dist/output.js +173 -0
- package/dist/output.js.map +1 -0
- package/dist/pack/certification.d.ts +27 -0
- package/dist/pack/certification.d.ts.map +1 -0
- package/dist/pack/certification.js +38 -0
- package/dist/pack/certification.js.map +1 -0
- package/dist/pack/compile.d.ts +75 -0
- package/dist/pack/compile.d.ts.map +1 -0
- package/dist/pack/compile.js +801 -0
- package/dist/pack/compile.js.map +1 -0
- package/dist/pack/filing.d.ts +48 -0
- package/dist/pack/filing.d.ts.map +1 -0
- package/dist/pack/filing.js +56 -0
- package/dist/pack/filing.js.map +1 -0
- package/dist/pack/read.d.ts +766 -0
- package/dist/pack/read.d.ts.map +1 -0
- package/dist/pack/read.js +2336 -0
- package/dist/pack/read.js.map +1 -0
- package/dist/pack/schema.d.ts +26 -0
- package/dist/pack/schema.d.ts.map +1 -0
- package/dist/pack/schema.js +181 -0
- package/dist/pack/schema.js.map +1 -0
- package/dist/pack/territories.d.ts +98 -0
- package/dist/pack/territories.d.ts.map +1 -0
- package/dist/pack/territories.js +0 -0
- package/dist/pack/territories.js.map +1 -0
- package/dist/pack/upgrade.d.ts +86 -0
- package/dist/pack/upgrade.d.ts.map +1 -0
- package/dist/pack/upgrade.js +76 -0
- package/dist/pack/upgrade.js.map +1 -0
- package/dist/pack/vat-codes.d.ts +273 -0
- package/dist/pack/vat-codes.d.ts.map +1 -0
- package/dist/pack/vat-codes.js +513 -0
- package/dist/pack/vat-codes.js.map +1 -0
- package/dist/profiles.d.ts +67 -0
- package/dist/profiles.d.ts.map +1 -0
- package/dist/profiles.js +187 -0
- package/dist/profiles.js.map +1 -0
- package/dist/prompt.d.ts +40 -0
- package/dist/prompt.d.ts.map +1 -0
- package/dist/prompt.js +120 -0
- package/dist/prompt.js.map +1 -0
- package/dist/registry.d.ts +84 -0
- package/dist/registry.d.ts.map +1 -0
- package/dist/registry.js +104 -0
- package/dist/registry.js.map +1 -0
- package/dist/rest.d.ts +81 -0
- package/dist/rest.d.ts.map +1 -0
- package/dist/rest.js +245 -0
- package/dist/rest.js.map +1 -0
- package/dist/schema.d.ts +15 -0
- package/dist/schema.d.ts.map +1 -0
- package/dist/schema.js +15 -0
- package/dist/schema.js.map +1 -0
- package/dist/seeds.d.ts +27 -0
- package/dist/seeds.d.ts.map +1 -0
- package/dist/seeds.js +47 -0
- package/dist/seeds.js.map +1 -0
- package/dist/session.d.ts +39 -0
- package/dist/session.d.ts.map +1 -0
- package/dist/session.js +92 -0
- package/dist/session.js.map +1 -0
- package/dist/sql.d.ts +48 -0
- package/dist/sql.d.ts.map +1 -0
- package/dist/sql.js +112 -0
- package/dist/sql.js.map +1 -0
- package/dist/status.d.ts +94 -0
- package/dist/status.d.ts.map +1 -0
- package/dist/status.js +120 -0
- package/dist/status.js.map +1 -0
- package/dist/ui.d.ts +55 -0
- package/dist/ui.d.ts.map +1 -0
- package/dist/ui.js +114 -0
- package/dist/ui.js.map +1 -0
- package/package.json +29 -0
- package/schema/output.1.json +613 -0
|
@@ -0,0 +1,561 @@
|
|
|
1
|
+
-- Ekwo OS — who the money came from, and a memory of how we knew.
|
|
2
|
+
--
|
|
3
|
+
-- A statement line carries a name the bank wrote, an IBAN, a free-text
|
|
4
|
+
-- description and an amount. None of them is a contact. Somebody looks at the
|
|
5
|
+
-- line, decides it is Acme, and moves on — and the next month the same line
|
|
6
|
+
-- arrives and somebody decides the same thing again. That repetition is the
|
|
7
|
+
-- work this table removes.
|
|
8
|
+
--
|
|
9
|
+
-- What is stored is a **motif**: a fact about how this counterparty appears on
|
|
10
|
+
-- a statement of this company. Four kinds, and no fifth without a migration:
|
|
11
|
+
--
|
|
12
|
+
-- counterparty_account the account the money moves from or to
|
|
13
|
+
-- name_variation a form of the name, as the bank writes it
|
|
14
|
+
-- description_keyword a word that appears in the line, with exclusions
|
|
15
|
+
-- amount_range a band of amounts, for a recurring charge
|
|
16
|
+
--
|
|
17
|
+
-- The first one is **not** called `iban`, deliberately. An IBAN is ISO 13616
|
|
18
|
+
-- and about half the world does not use one: the United States identifies an
|
|
19
|
+
-- account by an ABA routing number and an account number, Canada by a transit
|
|
20
|
+
-- and an institution, Australia by a BSB. The rest of this schema has not
|
|
21
|
+
-- learnt that yet — `bank_accounts.iban`, `contacts.iban`,
|
|
22
|
+
-- `bank_transactions.counterpart_iban` and `documents.payee_iban` all name the
|
|
23
|
+
-- European answer, and the last of them cites BT-84 of EN 16931, which is
|
|
24
|
+
-- called *Payment account identifier* and is not an IBAN. That is written up
|
|
25
|
+
-- in `docs/international.md` and is somebody's next migration. This table does
|
|
26
|
+
-- not add to it: a motif holds the identifier the statement carried, whatever
|
|
27
|
+
-- scheme it was written in, and the day the columns above learn to say their
|
|
28
|
+
-- scheme, nothing here has to be renamed.
|
|
29
|
+
--
|
|
30
|
+
-- The vocabulary is closed; only the values are learned. Nothing here is
|
|
31
|
+
-- executed, nothing is a regular expression, and nothing chooses a contact on
|
|
32
|
+
-- its own: `suggest_contacts()` answers with candidates and the reason for
|
|
33
|
+
-- each, and a person — or an agent acting as one — decides.
|
|
34
|
+
--
|
|
35
|
+
-- The design is not new: it has been running in production in a commercial
|
|
36
|
+
-- accounting system since 2025, which is why it carries two incidents of that
|
|
37
|
+
-- production as tests rather than as comments. Both are in
|
|
38
|
+
-- `tests/contact_matching.test.ts`.
|
|
39
|
+
|
|
40
|
+
create type contact_pattern_kind as enum (
|
|
41
|
+
'counterparty_account', 'name_variation', 'description_keyword', 'amount_range'
|
|
42
|
+
);
|
|
43
|
+
|
|
44
|
+
comment on type contact_pattern_kind is
|
|
45
|
+
'What a motif remembers. Four values, closed: the account the money moved from or to, a spelling of a name, a word of the description, or a band of amounts. A fifth needs a migration, which is the point — a pack or a user cannot invent a rule here.';
|
|
46
|
+
|
|
47
|
+
create type contact_pattern_source as enum ('declared', 'learned');
|
|
48
|
+
|
|
49
|
+
comment on type contact_pattern_source is
|
|
50
|
+
'Where the motif came from: `declared` if somebody wrote it down, `learned` if a confirmation taught it. The two are kept apart because a declared motif is an instruction and a learned one is a statistic.';
|
|
51
|
+
|
|
52
|
+
-- ---------------------------------------------------------------------------
|
|
53
|
+
-- contact_patterns
|
|
54
|
+
-- ---------------------------------------------------------------------------
|
|
55
|
+
|
|
56
|
+
create table contact_patterns (
|
|
57
|
+
id uuid primary key default gen_random_uuid(),
|
|
58
|
+
company_id uuid not null references companies(id) on delete cascade,
|
|
59
|
+
contact_id uuid not null references contacts(id) on delete cascade,
|
|
60
|
+
kind contact_pattern_kind not null,
|
|
61
|
+
-- The value, for the three motifs that are a piece of text. Null for
|
|
62
|
+
-- `amount_range`, which is the three numeric columns below.
|
|
63
|
+
value text,
|
|
64
|
+
-- Words that take the motif back. `description_keyword` only: "insurance"
|
|
65
|
+
-- identifies the broker except when the line also says "car", which is the
|
|
66
|
+
-- other broker. A list of words, never an expression.
|
|
67
|
+
exclude_words text[],
|
|
68
|
+
amount_min numeric(16, 2),
|
|
69
|
+
amount_max numeric(16, 2),
|
|
70
|
+
amount_typical numeric(16, 2),
|
|
71
|
+
currency_code char(3) references currencies(code),
|
|
72
|
+
-- What the motif has been worth so far. `confidence` is derived from the two
|
|
73
|
+
-- counters by `contact_pattern_confidence()`; it is stored so a query can
|
|
74
|
+
-- order by it without recomputing, and a trigger keeps it honest.
|
|
75
|
+
usage_count integer not null default 0 check (usage_count >= 0),
|
|
76
|
+
success_count integer not null default 0 check (success_count >= 0),
|
|
77
|
+
confidence numeric(4, 3) not null default 0.5
|
|
78
|
+
check (confidence >= 0 and confidence <= 1),
|
|
79
|
+
last_matched_at timestamptz,
|
|
80
|
+
source contact_pattern_source not null,
|
|
81
|
+
active boolean not null default true,
|
|
82
|
+
created_at timestamptz not null default now(),
|
|
83
|
+
updated_at timestamptz not null default now(),
|
|
84
|
+
foreign key (contact_id, company_id) references contacts(id, company_id),
|
|
85
|
+
constraint contact_patterns_success_within_usage
|
|
86
|
+
check (success_count <= usage_count),
|
|
87
|
+
-- Each kind fills its own columns and no others. A motif that is half an
|
|
88
|
+
-- amount range and half a keyword is not a motif, it is a bug that would be
|
|
89
|
+
-- read by whichever branch ran first.
|
|
90
|
+
constraint contact_patterns_shape check (
|
|
91
|
+
case kind
|
|
92
|
+
when 'amount_range' then
|
|
93
|
+
value is null and exclude_words is null
|
|
94
|
+
and amount_min is not null and amount_max is not null
|
|
95
|
+
and currency_code is not null and amount_min <= amount_max
|
|
96
|
+
when 'description_keyword' then
|
|
97
|
+
value is not null
|
|
98
|
+
and amount_min is null and amount_max is null
|
|
99
|
+
and amount_typical is null and currency_code is null
|
|
100
|
+
else
|
|
101
|
+
value is not null and exclude_words is null
|
|
102
|
+
and amount_min is null and amount_max is null
|
|
103
|
+
and amount_typical is null and currency_code is null
|
|
104
|
+
end
|
|
105
|
+
)
|
|
106
|
+
);
|
|
107
|
+
|
|
108
|
+
comment on table contact_patterns is
|
|
109
|
+
'How a counterparty shows up on this company''s statements: one row per learned or declared motif, of four closed kinds. Read by suggest_contacts(), written by confirm_contact(). The core still chooses no contact for anybody — it answers with candidates and says why.';
|
|
110
|
+
|
|
111
|
+
comment on column contact_patterns.value is
|
|
112
|
+
'The account identifier as the statement wrote it, the spelling of the name, or the keyword. Null exactly when the kind is amount_range. An account identifier is not assumed to be an IBAN: half the world does not have one, and the scheme it is written in is a question the rest of this schema cannot answer yet.';
|
|
113
|
+
comment on column contact_patterns.exclude_words is
|
|
114
|
+
'Words that disqualify a line the keyword would otherwise claim. Only a description_keyword carries them.';
|
|
115
|
+
comment on column contact_patterns.amount_typical is
|
|
116
|
+
'The amount most often seen inside the band. Never a condition — it orders two candidates that are otherwise equal.';
|
|
117
|
+
comment on column contact_patterns.usage_count is
|
|
118
|
+
'How many times this motif has been in front of a decision.';
|
|
119
|
+
comment on column contact_patterns.success_count is
|
|
120
|
+
'How many of those decisions confirmed the contact it names.';
|
|
121
|
+
comment on column contact_patterns.confidence is
|
|
122
|
+
'success + 1 over usage + 2 — the two counters and nothing else. A motif nobody has used yet is worth 0.5 and says so, instead of claiming certainty from one lucky match.';
|
|
123
|
+
comment on column contact_patterns.source is
|
|
124
|
+
'declared by a person, or learned from a confirmation.';
|
|
125
|
+
comment on column contact_patterns.active is
|
|
126
|
+
'A motif turned off without being forgotten. An inactive motif is never read; its counters stay, so turning it back on does not restart the learning.';
|
|
127
|
+
|
|
128
|
+
create unique index contact_patterns_unique_value_idx
|
|
129
|
+
on contact_patterns (company_id, contact_id, kind, value)
|
|
130
|
+
where value is not null;
|
|
131
|
+
create unique index contact_patterns_unique_range_idx
|
|
132
|
+
on contact_patterns (company_id, contact_id, kind, amount_min, amount_max, currency_code)
|
|
133
|
+
where value is null;
|
|
134
|
+
create index contact_patterns_company_kind_idx
|
|
135
|
+
on contact_patterns (company_id, kind) where active;
|
|
136
|
+
create index contact_patterns_contact_idx on contact_patterns (contact_id, company_id);
|
|
137
|
+
create index contact_patterns_currency_idx on contact_patterns (currency_code);
|
|
138
|
+
|
|
139
|
+
create trigger contact_patterns_set_updated_at
|
|
140
|
+
before update on contact_patterns
|
|
141
|
+
for each row execute function set_updated_at();
|
|
142
|
+
|
|
143
|
+
alter table contact_patterns enable row level security;
|
|
144
|
+
|
|
145
|
+
create policy contact_patterns_select on contact_patterns
|
|
146
|
+
for select using (has_capability(company_id, 'contacts.read'));
|
|
147
|
+
create policy contact_patterns_write on contact_patterns
|
|
148
|
+
for all using (has_capability(company_id, 'contacts.write'))
|
|
149
|
+
with check (has_capability(company_id, 'contacts.write'));
|
|
150
|
+
|
|
151
|
+
comment on policy contact_patterns_select on contact_patterns is
|
|
152
|
+
'Whoever may read a contact may read what is known about how it appears on a statement.';
|
|
153
|
+
comment on policy contact_patterns_write on contact_patterns is
|
|
154
|
+
'contacts.write: a motif is knowledge about a contact, and changing it changes who future money is attributed to.';
|
|
155
|
+
|
|
156
|
+
grant select, insert, update, delete on table contact_patterns
|
|
157
|
+
to authenticated, service_role;
|
|
158
|
+
|
|
159
|
+
create trigger contact_patterns_audit
|
|
160
|
+
after insert or update or delete on contact_patterns
|
|
161
|
+
for each row execute function audit_changes('{"company":"company_id","key":["kind","value"]}');
|
|
162
|
+
|
|
163
|
+
-- ---------------------------------------------------------------------------
|
|
164
|
+
-- The numbers the matching runs on
|
|
165
|
+
--
|
|
166
|
+
-- The system this comes from holds them as literals inside a function: 0.60 to
|
|
167
|
+
-- accept a name, one cent of tolerance on an amount, two on a sum, ninety days
|
|
168
|
+
-- of window. They are good numbers — they come from two years of use — and a
|
|
169
|
+
-- literal is still the wrong place for them, because the moment a company
|
|
170
|
+
-- disagrees the only way to say so is a fork.
|
|
171
|
+
--
|
|
172
|
+
-- They are also not all the same kind of number. A tolerance in *cents* is a
|
|
173
|
+
-- statement about the euro; a quarter of the world's currencies has no cents
|
|
174
|
+
-- and three currencies have three decimals. So a tolerance here is counted in
|
|
175
|
+
-- **units of the currency's own smallest denomination**, resolved through
|
|
176
|
+
-- `rounding_of()`, and never in hundredths of anything.
|
|
177
|
+
-- ---------------------------------------------------------------------------
|
|
178
|
+
|
|
179
|
+
create table matching_settings (
|
|
180
|
+
company_id uuid primary key references companies(id) on delete cascade,
|
|
181
|
+
name_threshold numeric(4, 3) check (name_threshold >= 0 and name_threshold <= 1),
|
|
182
|
+
amount_tolerance_units integer check (amount_tolerance_units >= 0),
|
|
183
|
+
sum_tolerance_units integer check (sum_tolerance_units >= 0),
|
|
184
|
+
date_window_days integer check (date_window_days >= 0),
|
|
185
|
+
minimum_word_length integer check (minimum_word_length >= 1),
|
|
186
|
+
created_at timestamptz not null default now(),
|
|
187
|
+
updated_at timestamptz not null default now()
|
|
188
|
+
);
|
|
189
|
+
|
|
190
|
+
comment on table matching_settings is
|
|
191
|
+
'What this company considers close enough. Every column is nullable and null means the shipped answer, which lives in matching_policy_of() and nowhere else — so a reader always gets a number and a company that never had an opinion has no row.';
|
|
192
|
+
comment on column matching_settings.name_threshold is
|
|
193
|
+
'How much of a name has to agree before a contact is proposed at all, between 0 and 1.';
|
|
194
|
+
comment on column matching_settings.amount_tolerance_units is
|
|
195
|
+
'How many smallest units of the currency two amounts may differ by and still be the same payment. Units, not cents: the currency says how much a unit is worth.';
|
|
196
|
+
comment on column matching_settings.sum_tolerance_units is
|
|
197
|
+
'The same, for a transaction that pays several documents at once, where each of them was rounded on its own.';
|
|
198
|
+
comment on column matching_settings.date_window_days is
|
|
199
|
+
'How far from a document a payment may sit and still be proposed for it.';
|
|
200
|
+
comment on column matching_settings.minimum_word_length is
|
|
201
|
+
'How long a word has to be to count as evidence of a name.';
|
|
202
|
+
|
|
203
|
+
create trigger matching_settings_set_updated_at
|
|
204
|
+
before update on matching_settings
|
|
205
|
+
for each row execute function set_updated_at();
|
|
206
|
+
|
|
207
|
+
alter table matching_settings enable row level security;
|
|
208
|
+
|
|
209
|
+
create policy matching_settings_select on matching_settings
|
|
210
|
+
for select using (is_company_member(company_id));
|
|
211
|
+
create policy matching_settings_write on matching_settings
|
|
212
|
+
for all using (has_capability(company_id, 'company.write'))
|
|
213
|
+
with check (has_capability(company_id, 'company.write'));
|
|
214
|
+
|
|
215
|
+
comment on policy matching_settings_select on matching_settings is
|
|
216
|
+
'A member reads how their company matches, the way they read the company itself.';
|
|
217
|
+
comment on policy matching_settings_write on matching_settings is
|
|
218
|
+
'company.write: these numbers decide what gets attributed automatically, which is a decision about the company and not about one contact.';
|
|
219
|
+
|
|
220
|
+
grant select, insert, update, delete on table matching_settings
|
|
221
|
+
to authenticated, service_role;
|
|
222
|
+
|
|
223
|
+
create trigger matching_settings_audit
|
|
224
|
+
after insert or update or delete on matching_settings
|
|
225
|
+
for each row execute function audit_changes('{"company":"company_id"}');
|
|
226
|
+
|
|
227
|
+
create type matching_policy as (
|
|
228
|
+
name_threshold numeric,
|
|
229
|
+
amount_tolerance_units integer,
|
|
230
|
+
sum_tolerance_units integer,
|
|
231
|
+
date_window_days integer,
|
|
232
|
+
minimum_word_length integer
|
|
233
|
+
);
|
|
234
|
+
|
|
235
|
+
comment on type matching_policy is
|
|
236
|
+
'The five numbers the matching runs on, resolved for one company.';
|
|
237
|
+
|
|
238
|
+
create or replace function matching_policy_of(p_company_id uuid)
|
|
239
|
+
returns matching_policy
|
|
240
|
+
language sql
|
|
241
|
+
stable
|
|
242
|
+
security invoker
|
|
243
|
+
set search_path = public
|
|
244
|
+
as $$
|
|
245
|
+
-- The shipped answers, in the one place they exist. Each comes from rules
|
|
246
|
+
-- that have run in production; none is a fact about a country, a currency or
|
|
247
|
+
-- a language, which is why they can be shipped at all.
|
|
248
|
+
--
|
|
249
|
+
-- 0.600 a name is evidence at three fifths of agreement
|
|
250
|
+
-- 1 one smallest unit of the currency between two amounts
|
|
251
|
+
-- 2 two of them across a sum of documents rounded separately
|
|
252
|
+
-- 90 a payment more than a quarter away from its document is a
|
|
253
|
+
-- question, not a match
|
|
254
|
+
-- 4 a word of three letters or fewer is a legal form or a
|
|
255
|
+
-- preposition, never a party
|
|
256
|
+
select row(
|
|
257
|
+
coalesce(s.name_threshold, 0.600),
|
|
258
|
+
coalesce(s.amount_tolerance_units, 1),
|
|
259
|
+
coalesce(s.sum_tolerance_units, 2),
|
|
260
|
+
coalesce(s.date_window_days, 90),
|
|
261
|
+
coalesce(s.minimum_word_length, 4)
|
|
262
|
+
)::matching_policy
|
|
263
|
+
from (select p_company_id as company_id) c
|
|
264
|
+
left join matching_settings s on s.company_id = c.company_id;
|
|
265
|
+
$$;
|
|
266
|
+
|
|
267
|
+
comment on function matching_policy_of(uuid) is
|
|
268
|
+
'The five numbers, for this company: its own where it has an opinion, the shipped ones otherwise. The only function that carries a default.';
|
|
269
|
+
|
|
270
|
+
revoke execute on function matching_policy_of(uuid) from public, anon;
|
|
271
|
+
grant execute on function matching_policy_of(uuid) to authenticated, service_role;
|
|
272
|
+
|
|
273
|
+
-- ---------------------------------------------------------------------------
|
|
274
|
+
-- The words of a name
|
|
275
|
+
--
|
|
276
|
+
-- Two names agree when they share a word long enough to mean something. What
|
|
277
|
+
-- is deliberately **not** here is a list of legal forms to ignore — `sarl`,
|
|
278
|
+
-- `bvba`, `gmbh`, `llc`. Such a list is country data, it would be a literal in
|
|
279
|
+
-- the core, and this repository refuses those on principle.
|
|
280
|
+
--
|
|
281
|
+
-- It is not needed either, because a better rule falls out of the data: a word
|
|
282
|
+
-- that reaches several contacts of the company identifies none of them.
|
|
283
|
+
-- Thirty suppliers called "… SARL" make `sarl` worthless without anybody
|
|
284
|
+
-- having to say so, and the same rule catches the word this list would have
|
|
285
|
+
-- forgotten — a town, a trade, the name of a group. `suggest_contacts()`
|
|
286
|
+
-- therefore counts how many contacts each piece of evidence reaches and says
|
|
287
|
+
-- so, and a caller applying a match on its own is expected to refuse anything
|
|
288
|
+
-- that reached more than one.
|
|
289
|
+
--
|
|
290
|
+
-- This was learned the hard way on 16 April 2026: matching on the first five
|
|
291
|
+
-- characters of a name made `SARL` equal to `SASU` and `SPRL`, and unrelated
|
|
292
|
+
-- suppliers were matched to each other. `tests/contact_matching.test.ts` keeps
|
|
293
|
+
-- that incident as a test.
|
|
294
|
+
-- ---------------------------------------------------------------------------
|
|
295
|
+
|
|
296
|
+
create or replace function significant_words(p_text text, p_minimum_length integer)
|
|
297
|
+
returns text[]
|
|
298
|
+
language sql
|
|
299
|
+
immutable
|
|
300
|
+
as $$
|
|
301
|
+
select coalesce(array_agg(distinct w), '{}'::text[])
|
|
302
|
+
from regexp_split_to_table(
|
|
303
|
+
lower(translate(coalesce(p_text, ''), '.,&()/-_''"+*:;#', ' ')),
|
|
304
|
+
'\s+'
|
|
305
|
+
) as w
|
|
306
|
+
where length(w) >= p_minimum_length;
|
|
307
|
+
$$;
|
|
308
|
+
|
|
309
|
+
comment on function significant_words(text, integer) is
|
|
310
|
+
'The words of a name that are long enough to be evidence, lowercased and deduplicated. No stop list: a word shared by several contacts is disqualified by the count of what it reaches, which is a fact about this company rather than an opinion about a language.';
|
|
311
|
+
|
|
312
|
+
revoke execute on function significant_words(text, integer) from public, anon;
|
|
313
|
+
grant execute on function significant_words(text, integer) to authenticated, service_role;
|
|
314
|
+
|
|
315
|
+
-- ---------------------------------------------------------------------------
|
|
316
|
+
-- suggest_contacts
|
|
317
|
+
--
|
|
318
|
+
-- Read-only, and it decides nothing. It answers with every contact the line
|
|
319
|
+
-- could be, the score of each, the evidence, and — the column that matters —
|
|
320
|
+
-- how many contacts that same evidence reached. One is a recognition; two is
|
|
321
|
+
-- a coincidence with a name on it.
|
|
322
|
+
-- ---------------------------------------------------------------------------
|
|
323
|
+
|
|
324
|
+
create or replace function suggest_contacts(p_transaction_id uuid)
|
|
325
|
+
returns table (
|
|
326
|
+
contact_id uuid,
|
|
327
|
+
score numeric,
|
|
328
|
+
method text,
|
|
329
|
+
because text,
|
|
330
|
+
alternatives integer
|
|
331
|
+
)
|
|
332
|
+
language plpgsql
|
|
333
|
+
stable
|
|
334
|
+
security invoker
|
|
335
|
+
as $$
|
|
336
|
+
declare
|
|
337
|
+
v_tx bank_transactions;
|
|
338
|
+
v_policy matching_policy;
|
|
339
|
+
v_words text[];
|
|
340
|
+
begin
|
|
341
|
+
-- Row level security answers this select, so a member of another company
|
|
342
|
+
-- gets nothing and is told nothing.
|
|
343
|
+
select * into v_tx from bank_transactions t where t.id = p_transaction_id;
|
|
344
|
+
if not found then
|
|
345
|
+
raise exception 'not_found: bank transaction %', p_transaction_id
|
|
346
|
+
using errcode = 'no_data_found';
|
|
347
|
+
end if;
|
|
348
|
+
|
|
349
|
+
v_policy := matching_policy_of(v_tx.company_id);
|
|
350
|
+
v_words := significant_words(
|
|
351
|
+
coalesce(v_tx.counterpart_name, v_tx.description),
|
|
352
|
+
v_policy.minimum_word_length);
|
|
353
|
+
|
|
354
|
+
return query
|
|
355
|
+
with
|
|
356
|
+
-- 1. The account the money moved from or to. An account identifier is not a
|
|
357
|
+
-- resemblance: either the statement carried the one recorded against the
|
|
358
|
+
-- contact, or it did not. Hence a score of 1, and the only evidence that
|
|
359
|
+
-- needs no threshold.
|
|
360
|
+
by_account as (
|
|
361
|
+
select c.id, 1::numeric as score, 'account'::text as method,
|
|
362
|
+
format('the statement carries the account recorded against %s', c.name) as because
|
|
363
|
+
from contacts c
|
|
364
|
+
where c.company_id = v_tx.company_id
|
|
365
|
+
and v_tx.counterpart_iban is not null
|
|
366
|
+
and c.iban is not null
|
|
367
|
+
and upper(replace(c.iban, ' ', '')) = upper(replace(v_tx.counterpart_iban, ' ', ''))
|
|
368
|
+
union
|
|
369
|
+
select p.contact_id, 1::numeric, 'account',
|
|
370
|
+
format('a motif of %s carries this account', c.name)
|
|
371
|
+
from contact_patterns p
|
|
372
|
+
join contacts c on c.id = p.contact_id
|
|
373
|
+
where p.company_id = v_tx.company_id
|
|
374
|
+
and p.active
|
|
375
|
+
and p.kind = 'counterparty_account'
|
|
376
|
+
and v_tx.counterpart_iban is not null
|
|
377
|
+
and upper(replace(p.value, ' ', '')) = upper(replace(v_tx.counterpart_iban, ' ', ''))
|
|
378
|
+
),
|
|
379
|
+
-- 2. A motif that was learned or declared. Its score is its confidence: what
|
|
380
|
+
-- it has been worth so far, and nothing else.
|
|
381
|
+
by_pattern as (
|
|
382
|
+
select p.contact_id as id, p.confidence as score,
|
|
383
|
+
('pattern:' || p.kind::text)::text as method,
|
|
384
|
+
format('%s, a motif of %s confirmed %s of %s times',
|
|
385
|
+
coalesce(p.value, format('%s to %s', p.amount_min, p.amount_max)),
|
|
386
|
+
c.name, p.success_count, p.usage_count) as because
|
|
387
|
+
from contact_patterns p
|
|
388
|
+
join contacts c on c.id = p.contact_id
|
|
389
|
+
where p.company_id = v_tx.company_id
|
|
390
|
+
and p.active
|
|
391
|
+
and case p.kind
|
|
392
|
+
when 'name_variation' then
|
|
393
|
+
v_tx.counterpart_name is not null
|
|
394
|
+
and lower(trim(v_tx.counterpart_name)) = lower(trim(p.value))
|
|
395
|
+
when 'description_keyword' then
|
|
396
|
+
v_tx.description is not null
|
|
397
|
+
and position(lower(p.value) in lower(v_tx.description)) > 0
|
|
398
|
+
and not exists (
|
|
399
|
+
select 1 from unnest(coalesce(p.exclude_words, '{}'::text[])) as x(word)
|
|
400
|
+
where position(lower(x.word) in lower(v_tx.description)) > 0
|
|
401
|
+
)
|
|
402
|
+
when 'amount_range' then
|
|
403
|
+
p.currency_code = v_tx.currency_code
|
|
404
|
+
and abs(v_tx.amount) between p.amount_min and p.amount_max
|
|
405
|
+
else false
|
|
406
|
+
end
|
|
407
|
+
),
|
|
408
|
+
-- 3. The name, compared word by word. Three shapes, from the one that says
|
|
409
|
+
-- the most to the one that says the least, and the last of them is why
|
|
410
|
+
-- `alternatives` exists.
|
|
411
|
+
by_name as (
|
|
412
|
+
select c.id, s.score, 'name'::text as method, s.because
|
|
413
|
+
from contacts c
|
|
414
|
+
cross join lateral (
|
|
415
|
+
select significant_words(c.name, v_policy.minimum_word_length) as words
|
|
416
|
+
) w
|
|
417
|
+
cross join lateral (
|
|
418
|
+
select
|
|
419
|
+
case
|
|
420
|
+
when lower(trim(c.name)) = lower(trim(coalesce(v_tx.counterpart_name, ''))) then 0.950
|
|
421
|
+
when cardinality(w.words) > 0 and w.words <@ v_words then 0.900
|
|
422
|
+
when cardinality(w.words) > 0 and v_words <@ w.words then 0.900
|
|
423
|
+
when cardinality(array(select unnest(w.words) intersect select unnest(v_words))) > 0 then
|
|
424
|
+
0.650 + 0.250 * (
|
|
425
|
+
cardinality(array(select unnest(w.words) intersect select unnest(v_words)))::numeric
|
|
426
|
+
/ least(cardinality(w.words), cardinality(v_words))
|
|
427
|
+
)
|
|
428
|
+
else null
|
|
429
|
+
end as score,
|
|
430
|
+
format('the name on the statement shares %s with %s',
|
|
431
|
+
array_to_string(array(select unnest(w.words) intersect select unnest(v_words)), ', '),
|
|
432
|
+
c.name) as because
|
|
433
|
+
) s
|
|
434
|
+
where c.company_id = v_tx.company_id
|
|
435
|
+
and c.active
|
|
436
|
+
and cardinality(v_words) > 0
|
|
437
|
+
and s.score is not null
|
|
438
|
+
and s.score >= v_policy.name_threshold
|
|
439
|
+
),
|
|
440
|
+
all_candidates as (
|
|
441
|
+
select * from by_account
|
|
442
|
+
union all select * from by_pattern
|
|
443
|
+
union all select * from by_name
|
|
444
|
+
)
|
|
445
|
+
select a.id, a.score, a.method, a.because,
|
|
446
|
+
count(*) over (partition by a.method)::integer
|
|
447
|
+
from all_candidates a
|
|
448
|
+
order by a.score desc, a.method;
|
|
449
|
+
end;
|
|
450
|
+
$$;
|
|
451
|
+
|
|
452
|
+
comment on function suggest_contacts(uuid) is
|
|
453
|
+
'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.';
|
|
454
|
+
|
|
455
|
+
revoke execute on function suggest_contacts(uuid) from public, anon;
|
|
456
|
+
grant execute on function suggest_contacts(uuid) to authenticated, service_role;
|
|
457
|
+
|
|
458
|
+
-- ---------------------------------------------------------------------------
|
|
459
|
+
-- confirm_contact
|
|
460
|
+
--
|
|
461
|
+
-- The only place a motif is learned, because the only thing worth learning
|
|
462
|
+
-- from is a decision somebody stands behind.
|
|
463
|
+
--
|
|
464
|
+
-- It also does the unglamorous half: a motif that pointed at somebody else is
|
|
465
|
+
-- charged a use and not a success, so being wrong costs confidence. Without
|
|
466
|
+
-- that, every motif converges on certainty and the oldest mistake wins.
|
|
467
|
+
-- ---------------------------------------------------------------------------
|
|
468
|
+
|
|
469
|
+
create or replace function confirm_contact(p_transaction_id uuid, p_contact_id uuid)
|
|
470
|
+
returns bank_transactions
|
|
471
|
+
language plpgsql
|
|
472
|
+
volatile
|
|
473
|
+
security invoker
|
|
474
|
+
as $$
|
|
475
|
+
declare
|
|
476
|
+
v_tx bank_transactions;
|
|
477
|
+
v_contact contacts;
|
|
478
|
+
v_policy matching_policy;
|
|
479
|
+
begin
|
|
480
|
+
select * into v_tx from bank_transactions t where t.id = p_transaction_id;
|
|
481
|
+
if not found then
|
|
482
|
+
raise exception 'not_found: bank transaction %', p_transaction_id
|
|
483
|
+
using errcode = 'no_data_found';
|
|
484
|
+
end if;
|
|
485
|
+
|
|
486
|
+
select * into v_contact
|
|
487
|
+
from contacts c
|
|
488
|
+
where c.id = p_contact_id and c.company_id = v_tx.company_id;
|
|
489
|
+
if not found then
|
|
490
|
+
raise exception 'contact_not_of_company: % is not a contact of the company this line belongs to',
|
|
491
|
+
p_contact_id;
|
|
492
|
+
end if;
|
|
493
|
+
|
|
494
|
+
v_policy := matching_policy_of(v_tx.company_id);
|
|
495
|
+
|
|
496
|
+
-- Every motif that named somebody else has now been in front of a decision
|
|
497
|
+
-- and lost it. Charged first, so that a motif that also named the right
|
|
498
|
+
-- contact is not punished for it below.
|
|
499
|
+
update contact_patterns p
|
|
500
|
+
set usage_count = p.usage_count + 1,
|
|
501
|
+
confidence = (p.success_count + 1)::numeric / (p.usage_count + 3)
|
|
502
|
+
where p.id in (
|
|
503
|
+
select cp.id
|
|
504
|
+
from contact_patterns cp
|
|
505
|
+
join suggest_contacts(p_transaction_id) s
|
|
506
|
+
on s.contact_id = cp.contact_id
|
|
507
|
+
and s.method = 'pattern:' || cp.kind::text
|
|
508
|
+
where cp.company_id = v_tx.company_id
|
|
509
|
+
and cp.contact_id <> p_contact_id
|
|
510
|
+
);
|
|
511
|
+
|
|
512
|
+
-- The account, when the statement carried one. This is the motif worth the
|
|
513
|
+
-- most, and the one a bank writes identically every time.
|
|
514
|
+
if v_tx.counterpart_iban is not null then
|
|
515
|
+
insert into contact_patterns (company_id, contact_id, kind, value, source,
|
|
516
|
+
usage_count, success_count, confidence, last_matched_at)
|
|
517
|
+
values (v_tx.company_id, p_contact_id, 'counterparty_account',
|
|
518
|
+
upper(replace(v_tx.counterpart_iban, ' ', '')), 'learned',
|
|
519
|
+
1, 1, 2::numeric / 3, now())
|
|
520
|
+
on conflict (company_id, contact_id, kind, value) where value is not null
|
|
521
|
+
do update set usage_count = contact_patterns.usage_count + 1,
|
|
522
|
+
success_count = contact_patterns.success_count + 1,
|
|
523
|
+
confidence = (contact_patterns.success_count + 2)::numeric
|
|
524
|
+
/ (contact_patterns.usage_count + 3),
|
|
525
|
+
last_matched_at = now(),
|
|
526
|
+
active = true;
|
|
527
|
+
end if;
|
|
528
|
+
|
|
529
|
+
-- The name as this bank writes it, which is rarely the name in the ledger.
|
|
530
|
+
-- Stored only when it carries a word worth comparing: a line whose name is
|
|
531
|
+
-- "PAIEMENT" teaches nothing.
|
|
532
|
+
if v_tx.counterpart_name is not null
|
|
533
|
+
and cardinality(significant_words(v_tx.counterpart_name, v_policy.minimum_word_length)) > 0
|
|
534
|
+
then
|
|
535
|
+
insert into contact_patterns (company_id, contact_id, kind, value, source,
|
|
536
|
+
usage_count, success_count, confidence, last_matched_at)
|
|
537
|
+
values (v_tx.company_id, p_contact_id, 'name_variation',
|
|
538
|
+
trim(v_tx.counterpart_name), 'learned', 1, 1, 2::numeric / 3, now())
|
|
539
|
+
on conflict (company_id, contact_id, kind, value) where value is not null
|
|
540
|
+
do update set usage_count = contact_patterns.usage_count + 1,
|
|
541
|
+
success_count = contact_patterns.success_count + 1,
|
|
542
|
+
confidence = (contact_patterns.success_count + 2)::numeric
|
|
543
|
+
/ (contact_patterns.usage_count + 3),
|
|
544
|
+
last_matched_at = now(),
|
|
545
|
+
active = true;
|
|
546
|
+
end if;
|
|
547
|
+
|
|
548
|
+
update bank_transactions t
|
|
549
|
+
set contact_id = p_contact_id
|
|
550
|
+
where t.id = p_transaction_id
|
|
551
|
+
returning * into v_tx;
|
|
552
|
+
|
|
553
|
+
return v_tx;
|
|
554
|
+
end;
|
|
555
|
+
$$;
|
|
556
|
+
|
|
557
|
+
comment on function confirm_contact(uuid, uuid) is
|
|
558
|
+
'Attributes a statement line to a contact and learns from it: the account and the name as this bank writes them become motifs, and every motif that had named somebody else is charged a use without a success. Knowing who the money came from is not knowing what it pays — this function never touches a document and never reconciles anything.';
|
|
559
|
+
|
|
560
|
+
revoke execute on function confirm_contact(uuid, uuid) from public, anon;
|
|
561
|
+
grant execute on function confirm_contact(uuid, uuid) to authenticated, service_role;
|