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,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.';
|