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,654 @@
|
|
|
1
|
+
-- Ekwo OS — a statement is imported once.
|
|
2
|
+
--
|
|
3
|
+
-- `bank_statements` and `bank_transactions` have existed since the bank
|
|
4
|
+
-- migration, `suggest_matches()` and `auto_settle()` work on their lines, and
|
|
5
|
+
-- nothing wrote them but a test. This is the way in: `import_bank_statement()`
|
|
6
|
+
-- takes what a format brick read out of a file — statements and lines, as
|
|
7
|
+
-- jsonb — and writes the two tables.
|
|
8
|
+
--
|
|
9
|
+
-- **A statement never becomes an entry.** The function writes no ledger line,
|
|
10
|
+
-- no payment and no matching. A statement line becomes a payment when
|
|
11
|
+
-- `settle_from_statement()` says what it pays, and only then; an import that
|
|
12
|
+
-- booked would be a second way of booking money, with its own idea of the
|
|
13
|
+
-- counterpart account. It stops at `bank_transactions`, state `pending`.
|
|
14
|
+
--
|
|
15
|
+
-- **Idempotent, and the database is what refuses.** A statement is replayed
|
|
16
|
+
-- all the time: the same file twice, a whole month after its first fortnight,
|
|
17
|
+
-- two people importing the same extract. So every imported line carries an
|
|
18
|
+
-- `import_key`, unique per bank account, and the insert is `on conflict do
|
|
19
|
+
-- nothing` — it is the index that refuses the duplicate, not a lookup that
|
|
20
|
+
-- might race.
|
|
21
|
+
--
|
|
22
|
+
-- The key, and why it is that one:
|
|
23
|
+
--
|
|
24
|
+
-- * **When the bank gave the movement a reference** (`bankReference`, the
|
|
25
|
+
-- `AcctSvcrRef` of ISO 20022) the key is that reference, with the position
|
|
26
|
+
-- inside a split batch, the booking date and the amount. The reference is
|
|
27
|
+
-- the identity; date and amount are there because nothing promises a bank
|
|
28
|
+
-- never reuses a reference across years, and because they cost nothing: a
|
|
29
|
+
-- replayed movement has the same date and the same amount.
|
|
30
|
+
--
|
|
31
|
+
-- * **When it did not**, the movement has no identity of its own, and the
|
|
32
|
+
-- key is everything the statement says about it — dates, amount, currency,
|
|
33
|
+
-- counterparty, communications, end-to-end and mandate identifiers — plus
|
|
34
|
+
-- **which occurrence it is** among the lines of the same file that say
|
|
35
|
+
-- exactly the same. Two identical transfers on the same day are two lines,
|
|
36
|
+
-- numbered 1 and 2; they are both imported, and the same file replayed
|
|
37
|
+
-- numbers them 1 and 2 again and imports neither. A key without the
|
|
38
|
+
-- occurrence would silently drop the second rent of two tenants who pay
|
|
39
|
+
-- the same amount with the same words.
|
|
40
|
+
--
|
|
41
|
+
-- What this cannot tell apart: two *different* files, neither overlapping
|
|
42
|
+
-- the other, that each carry one line identical in every field, from a
|
|
43
|
+
-- bank that references nothing. The second is taken for the first. That
|
|
44
|
+
-- takes two statements on one day, no bank reference and no end-to-end
|
|
45
|
+
-- identifier at once; it is written in docs/decisions.md and not guessed
|
|
46
|
+
-- around here.
|
|
47
|
+
--
|
|
48
|
+
-- * The position of an entry inside its statement (`entryReference`,
|
|
49
|
+
-- `index`) is in neither key: a line is the sixteenth of a fortnight and
|
|
50
|
+
-- the sixteenth of a month only by accident.
|
|
51
|
+
--
|
|
52
|
+
-- **An overlapping statement imports what is new and lists everything.** A
|
|
53
|
+
-- line exists once, in `bank_transactions`; a statement *lists* lines, in
|
|
54
|
+
-- `bank_statement_lines`, whether it brought them or found them. So the month
|
|
55
|
+
-- that arrives after its first fortnight adds the second fortnight, lists
|
|
56
|
+
-- thirty-one days, and still proves its closing balance — which it could not
|
|
57
|
+
-- if a line belonged to one statement only. `balance_end_computed` reads the
|
|
58
|
+
-- list from now on.
|
|
59
|
+
|
|
60
|
+
-- ---------------------------------------------------------------------------
|
|
61
|
+
-- What an import has to remember about a statement
|
|
62
|
+
-- ---------------------------------------------------------------------------
|
|
63
|
+
|
|
64
|
+
alter table bank_statements
|
|
65
|
+
add column if not exists statement_ref text,
|
|
66
|
+
add column if not exists sequence_number numeric(18, 0),
|
|
67
|
+
add column if not exists period_start date,
|
|
68
|
+
add column if not exists source_format text,
|
|
69
|
+
add column if not exists source_file_name text,
|
|
70
|
+
add column if not exists source_checksum text;
|
|
71
|
+
|
|
72
|
+
comment on column bank_statements.statement_ref is
|
|
73
|
+
'The identifier the bank gave the statement (Stmt/Id of a camt.053). With the account and the closing date it is what makes a replayed statement the same statement. Null on a statement entered by hand.';
|
|
74
|
+
comment on column bank_statements.sequence_number is
|
|
75
|
+
'The bank''s own numbering of the statements of this account — the legal sequence number where the format has one, else the electronic one. A hole in it is a statement nobody imported; `bank_statement_continuity` shows it.';
|
|
76
|
+
comment on column bank_statements.period_start is
|
|
77
|
+
'The day the statement opens on: the date of its opening balance, else the start of its period, else its first line. `statement_date` is the day it closes on.';
|
|
78
|
+
comment on column bank_statements.source_format is
|
|
79
|
+
'What the statement was read from, as the reader named it — camt.053.001.08. Null on a statement entered by hand.';
|
|
80
|
+
comment on column bank_statements.source_file_name is
|
|
81
|
+
'The name of the file the statement was first imported from, when the caller gave one.';
|
|
82
|
+
comment on column bank_statements.source_checksum is
|
|
83
|
+
'The checksum of the file the statement was first imported from, as the caller computed it — the core never sees the bytes. Kept so that the file can be recognised later; not what makes an import idempotent, because the same statement arrives in files that differ by a timestamp.';
|
|
84
|
+
|
|
85
|
+
create unique index if not exists bank_statements_account_ref_idx
|
|
86
|
+
on bank_statements (bank_account_id, statement_ref, statement_date)
|
|
87
|
+
where statement_ref is not null;
|
|
88
|
+
|
|
89
|
+
alter table bank_transactions
|
|
90
|
+
add column if not exists import_key text;
|
|
91
|
+
|
|
92
|
+
comment on column bank_transactions.import_key is
|
|
93
|
+
'What makes an imported line the same line when its statement is replayed or overlapped: `ref:` and a digest when the bank gave the movement a reference, `fp:` and a digest of everything the statement says about it, with its occurrence among identical lines, when it did not. Unique per bank account, and written by import_bank_statement() only. Null on a line entered by hand.';
|
|
94
|
+
|
|
95
|
+
create unique index if not exists bank_transactions_import_key_idx
|
|
96
|
+
on bank_transactions (bank_account_id, import_key)
|
|
97
|
+
where import_key is not null;
|
|
98
|
+
|
|
99
|
+
-- ---------------------------------------------------------------------------
|
|
100
|
+
-- A statement lists lines; a line exists once
|
|
101
|
+
-- ---------------------------------------------------------------------------
|
|
102
|
+
|
|
103
|
+
create table if not exists bank_statement_lines (
|
|
104
|
+
company_id uuid not null references companies(id) on delete cascade,
|
|
105
|
+
statement_id uuid not null,
|
|
106
|
+
transaction_id uuid not null references bank_transactions(id) on delete cascade,
|
|
107
|
+
position integer not null,
|
|
108
|
+
created_at timestamptz not null default now(),
|
|
109
|
+
primary key (statement_id, transaction_id),
|
|
110
|
+
foreign key (statement_id, company_id)
|
|
111
|
+
references bank_statements(id, company_id) on delete cascade
|
|
112
|
+
);
|
|
113
|
+
|
|
114
|
+
comment on table bank_statement_lines is
|
|
115
|
+
'Which lines a statement lists. A line is stored once, in bank_transactions, under the first statement that brought it; a later statement that overlaps the first lists the same line here instead of duplicating it, and its closing balance is proved over the list.';
|
|
116
|
+
comment on column bank_statement_lines.position is
|
|
117
|
+
'Where the line stands in this statement, from 1 — which is not where it stood in another.';
|
|
118
|
+
|
|
119
|
+
create index if not exists bank_statement_lines_transaction_idx
|
|
120
|
+
on bank_statement_lines (transaction_id);
|
|
121
|
+
create index if not exists bank_statement_lines_statement_company_idx
|
|
122
|
+
on bank_statement_lines (statement_id, company_id);
|
|
123
|
+
create index if not exists bank_statement_lines_company_idx
|
|
124
|
+
on bank_statement_lines (company_id);
|
|
125
|
+
|
|
126
|
+
alter table bank_statement_lines enable row level security;
|
|
127
|
+
|
|
128
|
+
-- The form every policy has had since `a_policy_asks_once`: the set of
|
|
129
|
+
-- companies is worked out once per statement, not once per row.
|
|
130
|
+
create policy bank_statement_lines_select on bank_statement_lines
|
|
131
|
+
for select using (company_id = any ((select companies_with_capability('bank.read'))::uuid[]));
|
|
132
|
+
create policy bank_statement_lines_write on bank_statement_lines
|
|
133
|
+
for all using (company_id = any ((select companies_with_capability('bank.write'))::uuid[]))
|
|
134
|
+
with check (company_id = any ((select companies_with_capability('bank.write'))::uuid[]));
|
|
135
|
+
|
|
136
|
+
grant select, insert, update, delete on table bank_statement_lines to authenticated, service_role;
|
|
137
|
+
|
|
138
|
+
-- The computed closing balance is over what the statement lists *or* holds: a
|
|
139
|
+
-- line entered by hand under a statement has no row in the list, and counts
|
|
140
|
+
-- as it always has.
|
|
141
|
+
|
|
142
|
+
create or replace function bank_statement_movement(p_statement_id uuid)
|
|
143
|
+
returns numeric
|
|
144
|
+
language sql
|
|
145
|
+
stable
|
|
146
|
+
security invoker
|
|
147
|
+
as $$
|
|
148
|
+
select coalesce(sum(t.amount), 0)
|
|
149
|
+
from bank_transactions t
|
|
150
|
+
where t.statement_id = p_statement_id
|
|
151
|
+
or exists (
|
|
152
|
+
select 1 from bank_statement_lines l
|
|
153
|
+
where l.statement_id = p_statement_id and l.transaction_id = t.id
|
|
154
|
+
);
|
|
155
|
+
$$;
|
|
156
|
+
|
|
157
|
+
comment on function bank_statement_movement(uuid) is
|
|
158
|
+
'The sum of the lines a statement lists or holds, each counted once. What balance_end_computed adds to balance_start.';
|
|
159
|
+
|
|
160
|
+
revoke execute on function bank_statement_movement(uuid) from public, anon;
|
|
161
|
+
grant execute on function bank_statement_movement(uuid) to authenticated, service_role;
|
|
162
|
+
|
|
163
|
+
create or replace function bank_statements_refresh_balance()
|
|
164
|
+
returns trigger
|
|
165
|
+
language plpgsql
|
|
166
|
+
as $$
|
|
167
|
+
declare
|
|
168
|
+
v_transaction uuid;
|
|
169
|
+
v_new uuid;
|
|
170
|
+
v_old uuid;
|
|
171
|
+
begin
|
|
172
|
+
if tg_op <> 'DELETE' then
|
|
173
|
+
v_transaction := new.id;
|
|
174
|
+
v_new := new.statement_id;
|
|
175
|
+
end if;
|
|
176
|
+
if tg_op <> 'INSERT' then
|
|
177
|
+
v_transaction := old.id;
|
|
178
|
+
v_old := old.statement_id;
|
|
179
|
+
end if;
|
|
180
|
+
update bank_statements s
|
|
181
|
+
set balance_end_computed = s.balance_start + bank_statement_movement(s.id)
|
|
182
|
+
where s.id in (v_new, v_old)
|
|
183
|
+
or s.id in (select l.statement_id from bank_statement_lines l
|
|
184
|
+
where l.transaction_id = v_transaction);
|
|
185
|
+
return null;
|
|
186
|
+
end;
|
|
187
|
+
$$;
|
|
188
|
+
|
|
189
|
+
create or replace function bank_statements_recompute()
|
|
190
|
+
returns trigger
|
|
191
|
+
language plpgsql
|
|
192
|
+
as $$
|
|
193
|
+
begin
|
|
194
|
+
new.balance_end_computed := new.balance_start + bank_statement_movement(new.id);
|
|
195
|
+
return new;
|
|
196
|
+
end;
|
|
197
|
+
$$;
|
|
198
|
+
|
|
199
|
+
create or replace function bank_statement_lines_refresh_balance()
|
|
200
|
+
returns trigger
|
|
201
|
+
language plpgsql
|
|
202
|
+
as $$
|
|
203
|
+
declare
|
|
204
|
+
v_statement uuid;
|
|
205
|
+
begin
|
|
206
|
+
if tg_op = 'DELETE' then v_statement := old.statement_id; else v_statement := new.statement_id; end if;
|
|
207
|
+
update bank_statements s
|
|
208
|
+
set balance_end_computed = s.balance_start + bank_statement_movement(s.id)
|
|
209
|
+
where s.id = v_statement;
|
|
210
|
+
return null;
|
|
211
|
+
end;
|
|
212
|
+
$$;
|
|
213
|
+
|
|
214
|
+
revoke execute on function bank_statement_lines_refresh_balance() from public, anon, authenticated, service_role;
|
|
215
|
+
|
|
216
|
+
create trigger bank_statement_lines_refresh_balance
|
|
217
|
+
after insert or delete on bank_statement_lines
|
|
218
|
+
for each row execute function bank_statement_lines_refresh_balance();
|
|
219
|
+
|
|
220
|
+
-- ---------------------------------------------------------------------------
|
|
221
|
+
-- A missing statement shows
|
|
222
|
+
-- ---------------------------------------------------------------------------
|
|
223
|
+
--
|
|
224
|
+
-- The closing balance of one statement is the opening balance of the next. The
|
|
225
|
+
-- *previous* statement of an account is the latest one that closed on or
|
|
226
|
+
-- before the day this one opens **and opened before it** — the second
|
|
227
|
+
-- condition is what keeps a fortnight from being taken for the predecessor of
|
|
228
|
+
-- the month that contains it. Computed at each read: importing the missing
|
|
229
|
+
-- month afterwards closes the gap without anybody touching a row.
|
|
230
|
+
|
|
231
|
+
create or replace view bank_statement_continuity
|
|
232
|
+
with (security_invoker = true) as
|
|
233
|
+
select s.id as statement_id,
|
|
234
|
+
s.company_id,
|
|
235
|
+
s.bank_account_id,
|
|
236
|
+
s.statement_ref,
|
|
237
|
+
s.sequence_number,
|
|
238
|
+
coalesce(s.period_start, s.statement_date) as period_start,
|
|
239
|
+
s.statement_date,
|
|
240
|
+
s.balance_start,
|
|
241
|
+
p.id as previous_statement_id,
|
|
242
|
+
p.statement_ref as previous_statement_ref,
|
|
243
|
+
p.statement_date as previous_statement_date,
|
|
244
|
+
p.balance_end_declared as previous_balance_end,
|
|
245
|
+
s.balance_start - p.balance_end_declared as balance_gap,
|
|
246
|
+
case when s.sequence_number is not null and p.sequence_number is not null
|
|
247
|
+
then s.sequence_number - p.sequence_number - 1 end as missing_statements,
|
|
248
|
+
(p.id is not null and s.balance_start <> p.balance_end_declared) as is_broken
|
|
249
|
+
from bank_statements s
|
|
250
|
+
left join lateral (
|
|
251
|
+
select q.*
|
|
252
|
+
from bank_statements q
|
|
253
|
+
where q.bank_account_id = s.bank_account_id
|
|
254
|
+
and q.id <> s.id
|
|
255
|
+
and q.statement_date <= coalesce(s.period_start, s.statement_date)
|
|
256
|
+
and coalesce(q.period_start, q.statement_date) < coalesce(s.period_start, s.statement_date)
|
|
257
|
+
order by q.statement_date desc, coalesce(q.period_start, q.statement_date) desc, q.created_at desc
|
|
258
|
+
limit 1
|
|
259
|
+
) p on true;
|
|
260
|
+
|
|
261
|
+
comment on view bank_statement_continuity is
|
|
262
|
+
'Each statement beside the one before it on the same account: whether its opening balance is the closing balance of the previous one (`is_broken`, `balance_gap`), and how many statements the bank numbered in between (`missing_statements`). A break is shown and never refused: the statement that is there is still true, and the one that is missing can only be seen from here.';
|
|
263
|
+
|
|
264
|
+
grant select on bank_statement_continuity to authenticated, service_role;
|
|
265
|
+
|
|
266
|
+
-- ---------------------------------------------------------------------------
|
|
267
|
+
-- import_bank_statement
|
|
268
|
+
-- ---------------------------------------------------------------------------
|
|
269
|
+
--
|
|
270
|
+
-- What it reads, which is what `@ekwo-ai/camt053` returns and what any other
|
|
271
|
+
-- reader can return — the contract is this list, not that package:
|
|
272
|
+
--
|
|
273
|
+
-- p_file.version | .namespace what was read, kept in source_format
|
|
274
|
+
-- p_file.statements[] one or more
|
|
275
|
+
-- .id the bank's identifier of the statement
|
|
276
|
+
-- .legalSequenceNumber | .electronicSequenceNumber
|
|
277
|
+
-- .account.identifier { kind, value } `iban`, or anything else
|
|
278
|
+
-- .account.currency
|
|
279
|
+
-- .period { from, to }
|
|
280
|
+
-- .openingBalance | .closingBalance { amount, currency, date }
|
|
281
|
+
-- .lines[]
|
|
282
|
+
-- .index .detail .booked .bookingDate .valueDate .amount .currency
|
|
283
|
+
-- .bankReference .endToEndId .transactionId .mandateId
|
|
284
|
+
-- .counterparty { name, account { kind, value } }
|
|
285
|
+
-- .remittance { unstructured[], structured[] { reference } }
|
|
286
|
+
-- .additionalInformation
|
|
287
|
+
--
|
|
288
|
+
-- Amounts are decimal strings, signed from the account holder's side, and are
|
|
289
|
+
-- cast here and nowhere before: no figure of a statement is ever a float.
|
|
290
|
+
--
|
|
291
|
+
-- p_source is what the caller knows about the file and the core cannot:
|
|
292
|
+
-- { file_name, checksum, byte_size, mime_type, storage_path }. With a
|
|
293
|
+
-- `storage_path` the file is recorded in `attachments`, on the statement; the
|
|
294
|
+
-- bytes themselves are the caller's to store.
|
|
295
|
+
|
|
296
|
+
create or replace function import_bank_statement(
|
|
297
|
+
p_company_id uuid,
|
|
298
|
+
p_file jsonb,
|
|
299
|
+
p_source jsonb default null,
|
|
300
|
+
p_bank_account_id uuid default null
|
|
301
|
+
)
|
|
302
|
+
returns table (
|
|
303
|
+
statement_index integer,
|
|
304
|
+
statement_id uuid,
|
|
305
|
+
bank_account_id uuid,
|
|
306
|
+
statement_ref text,
|
|
307
|
+
already_imported boolean,
|
|
308
|
+
lines_read integer,
|
|
309
|
+
lines_imported integer,
|
|
310
|
+
lines_known integer,
|
|
311
|
+
lines_not_booked integer,
|
|
312
|
+
warnings jsonb
|
|
313
|
+
)
|
|
314
|
+
language plpgsql
|
|
315
|
+
security invoker
|
|
316
|
+
as $$
|
|
317
|
+
-- The columns this function returns share their names with columns it writes;
|
|
318
|
+
-- inside a statement a bare name is the column.
|
|
319
|
+
#variable_conflict use_column
|
|
320
|
+
declare
|
|
321
|
+
v_statements jsonb := p_file -> 'statements';
|
|
322
|
+
v_format text := coalesce(p_file ->> 'namespace', p_file ->> 'version');
|
|
323
|
+
v_stmt jsonb;
|
|
324
|
+
v_index integer := 0;
|
|
325
|
+
v_identifier jsonb;
|
|
326
|
+
v_value text;
|
|
327
|
+
v_account bank_accounts;
|
|
328
|
+
v_currency text;
|
|
329
|
+
v_opening numeric;
|
|
330
|
+
v_closing numeric;
|
|
331
|
+
v_movement numeric;
|
|
332
|
+
v_bad jsonb;
|
|
333
|
+
v_ref text;
|
|
334
|
+
v_start date;
|
|
335
|
+
v_end date;
|
|
336
|
+
v_existing bank_statements;
|
|
337
|
+
v_id uuid;
|
|
338
|
+
v_already boolean;
|
|
339
|
+
v_read integer;
|
|
340
|
+
v_imported integer;
|
|
341
|
+
v_listed integer;
|
|
342
|
+
v_skipped integer;
|
|
343
|
+
v_warnings jsonb;
|
|
344
|
+
v_chain record;
|
|
345
|
+
begin
|
|
346
|
+
if not exists (select 1 from companies c where c.id = p_company_id) then
|
|
347
|
+
raise exception 'unknown_company: %', p_company_id using errcode = 'no_data_found';
|
|
348
|
+
end if;
|
|
349
|
+
-- Row level security is what refuses; this is what says why. Without it a
|
|
350
|
+
-- viewer reads "new row violates row-level security policy", which names a
|
|
351
|
+
-- mechanism and not a reason.
|
|
352
|
+
-- Null-safe on purpose: `not NULL` is NULL and `if NULL` does not raise, which
|
|
353
|
+
-- is how a guard of this shape was once skipped for a caller with no session.
|
|
354
|
+
if not coalesce(is_installer(), false)
|
|
355
|
+
and not coalesce(has_capability(p_company_id, 'bank.write'), false) then
|
|
356
|
+
raise exception 'not_allowed: importing a statement into this company needs bank.write'
|
|
357
|
+
using errcode = '42501';
|
|
358
|
+
end if;
|
|
359
|
+
if v_statements is null or jsonb_typeof(v_statements) <> 'array'
|
|
360
|
+
or jsonb_array_length(v_statements) = 0 then
|
|
361
|
+
raise exception 'invalid_statement_file: no statement in what was given — expected { statements: [ … ] } as a format reader returns it';
|
|
362
|
+
end if;
|
|
363
|
+
if p_bank_account_id is not null and jsonb_array_length(v_statements) <> 1 then
|
|
364
|
+
raise exception 'invalid_statement_file: a bank account can be named for a file of one statement, and this one holds %',
|
|
365
|
+
jsonb_array_length(v_statements);
|
|
366
|
+
end if;
|
|
367
|
+
|
|
368
|
+
for v_stmt in select value from jsonb_array_elements(v_statements) loop
|
|
369
|
+
v_index := v_index + 1;
|
|
370
|
+
v_identifier := v_stmt #> '{account,identifier}';
|
|
371
|
+
v_value := upper(regexp_replace(coalesce(v_identifier ->> 'value', ''), '\s', '', 'g'));
|
|
372
|
+
v_ref := nullif(v_stmt ->> 'id', '');
|
|
373
|
+
|
|
374
|
+
if v_value = '' or v_ref is null then
|
|
375
|
+
raise exception 'invalid_statement_file: statement % has no identifier or names no account', v_index;
|
|
376
|
+
end if;
|
|
377
|
+
|
|
378
|
+
-- 1. The account. Known, or refused by name: an account created by an
|
|
379
|
+
-- import is an account nobody decided, mapped to no journal and no
|
|
380
|
+
-- ledger account.
|
|
381
|
+
if p_bank_account_id is not null then
|
|
382
|
+
select * into v_account from bank_accounts b
|
|
383
|
+
where b.id = p_bank_account_id and b.company_id = p_company_id;
|
|
384
|
+
if not found then
|
|
385
|
+
raise exception 'unknown_bank_account: % is not a bank account of this company', p_bank_account_id
|
|
386
|
+
using errcode = 'no_data_found';
|
|
387
|
+
end if;
|
|
388
|
+
if v_account.iban is not null and v_identifier ->> 'kind' = 'iban'
|
|
389
|
+
and upper(regexp_replace(v_account.iban, '\s', '', 'g')) <> v_value then
|
|
390
|
+
raise exception 'bank_account_mismatch: the statement is of account % and the bank account named holds %',
|
|
391
|
+
v_value, v_account.iban;
|
|
392
|
+
end if;
|
|
393
|
+
else
|
|
394
|
+
-- `bank_accounts.iban` is the only column the core has for what
|
|
395
|
+
-- identifies an account, and it is compared with whatever the statement
|
|
396
|
+
-- wrote — IBAN or not. The column is misnamed, which is a known gap with
|
|
397
|
+
-- a change of its own; a second, stricter lookup here would not fix it.
|
|
398
|
+
select * into v_account from bank_accounts b
|
|
399
|
+
where b.company_id = p_company_id
|
|
400
|
+
and b.iban is not null
|
|
401
|
+
and upper(regexp_replace(b.iban, '\s', '', 'g')) = v_value;
|
|
402
|
+
if not found then
|
|
403
|
+
raise exception 'unknown_bank_account: no bank account of this company is identified by % (%) — create it, or name the one this statement belongs to',
|
|
404
|
+
v_value, coalesce(v_identifier ->> 'kind', 'unknown kind')
|
|
405
|
+
using errcode = 'no_data_found';
|
|
406
|
+
end if;
|
|
407
|
+
end if;
|
|
408
|
+
|
|
409
|
+
-- 2. The currency. A statement in another currency than its account is
|
|
410
|
+
-- not converted; it is a statement of another account.
|
|
411
|
+
v_currency := coalesce(v_stmt #>> '{account,currency}', v_stmt #>> '{openingBalance,currency}',
|
|
412
|
+
v_stmt #>> '{closingBalance,currency}');
|
|
413
|
+
if v_currency is distinct from v_account.currency_code::text then
|
|
414
|
+
raise exception 'statement_currency_mismatch: statement % is in % and the bank account % in %',
|
|
415
|
+
v_ref, coalesce(v_currency, 'no currency'), v_account.name, v_account.currency_code;
|
|
416
|
+
end if;
|
|
417
|
+
|
|
418
|
+
-- 3. The lines that count: booked, readable, in the account's currency
|
|
419
|
+
-- and no finer than the column that will hold them — which is two
|
|
420
|
+
-- decimals whatever the currency, a limit of `bank_transactions.amount`
|
|
421
|
+
-- written in docs/international.md. Nothing is rounded here: a figure
|
|
422
|
+
-- the column cannot hold is refused, not adjusted.
|
|
423
|
+
select jsonb_agg(jsonb_build_object('line', l.value -> 'index', 'why',
|
|
424
|
+
case
|
|
425
|
+
when l.value ->> 'amount' is null then 'no readable amount'
|
|
426
|
+
when coalesce(l.value ->> 'bookingDate', l.value ->> 'valueDate') is null then 'no date'
|
|
427
|
+
when l.value ->> 'currency' is distinct from v_currency then
|
|
428
|
+
format('in %s, not converted', coalesce(l.value ->> 'currency', 'no currency'))
|
|
429
|
+
else 'more than two decimals'
|
|
430
|
+
end))
|
|
431
|
+
into v_bad
|
|
432
|
+
from jsonb_array_elements(coalesce(v_stmt -> 'lines', '[]'::jsonb)) l
|
|
433
|
+
where coalesce((l.value ->> 'booked')::boolean, false)
|
|
434
|
+
and (l.value ->> 'amount' is null
|
|
435
|
+
or coalesce(l.value ->> 'bookingDate', l.value ->> 'valueDate') is null
|
|
436
|
+
or l.value ->> 'currency' is distinct from v_currency
|
|
437
|
+
or scale(trim_scale((l.value ->> 'amount')::numeric)) > 2);
|
|
438
|
+
if v_bad is not null then
|
|
439
|
+
raise exception 'unreadable_statement_line: statement % holds booked lines that cannot be imported as they are: %',
|
|
440
|
+
v_ref, v_bad;
|
|
441
|
+
end if;
|
|
442
|
+
|
|
443
|
+
-- 4. The balance, recomputed here: what a client says it checked is not a
|
|
444
|
+
-- check. Opening plus what is booked is the closing, or nothing is
|
|
445
|
+
-- written — a statement that does not add up is a file that lost a
|
|
446
|
+
-- line on the way, and importing it would hide which.
|
|
447
|
+
if v_stmt #>> '{openingBalance,amount}' is null or v_stmt #>> '{closingBalance,amount}' is null then
|
|
448
|
+
raise exception 'statement_without_balances: statement % carries no opening or no closing balance, so nothing proves its lines are all there', v_ref;
|
|
449
|
+
end if;
|
|
450
|
+
v_opening := (v_stmt #>> '{openingBalance,amount}')::numeric;
|
|
451
|
+
v_closing := (v_stmt #>> '{closingBalance,amount}')::numeric;
|
|
452
|
+
select coalesce(sum((l.value ->> 'amount')::numeric), 0), count(*)
|
|
453
|
+
into v_movement, v_read
|
|
454
|
+
from jsonb_array_elements(coalesce(v_stmt -> 'lines', '[]'::jsonb)) l
|
|
455
|
+
where coalesce((l.value ->> 'booked')::boolean, false);
|
|
456
|
+
select count(*) into v_skipped
|
|
457
|
+
from jsonb_array_elements(coalesce(v_stmt -> 'lines', '[]'::jsonb)) l
|
|
458
|
+
where not coalesce((l.value ->> 'booked')::boolean, false);
|
|
459
|
+
if v_opening + v_movement <> v_closing then
|
|
460
|
+
raise exception 'unbalanced_statement: statement % opens at %, its booked lines add up to %, and it closes at % — a difference of %',
|
|
461
|
+
v_ref, v_opening, v_movement, v_closing, v_closing - (v_opening + v_movement);
|
|
462
|
+
end if;
|
|
463
|
+
if scale(trim_scale(v_opening)) > 2 or scale(trim_scale(v_closing)) > 2 then
|
|
464
|
+
raise exception 'unreadable_statement_line: the balances of statement % carry more than two decimals', v_ref;
|
|
465
|
+
end if;
|
|
466
|
+
|
|
467
|
+
-- 5. The statement: the same one, or a new one.
|
|
468
|
+
select min(coalesce(l.value ->> 'bookingDate', l.value ->> 'valueDate')::date),
|
|
469
|
+
max(coalesce(l.value ->> 'bookingDate', l.value ->> 'valueDate')::date)
|
|
470
|
+
into v_start, v_end
|
|
471
|
+
from jsonb_array_elements(coalesce(v_stmt -> 'lines', '[]'::jsonb)) l
|
|
472
|
+
where coalesce((l.value ->> 'booked')::boolean, false);
|
|
473
|
+
v_start := coalesce((v_stmt #>> '{openingBalance,date}')::date, (v_stmt #>> '{period,from}')::date, v_start);
|
|
474
|
+
v_end := coalesce((v_stmt #>> '{closingBalance,date}')::date, (v_stmt #>> '{period,to}')::date, v_end);
|
|
475
|
+
if v_end is null then
|
|
476
|
+
raise exception 'invalid_statement_file: statement % has no closing date, no period and no line to take one from', v_ref;
|
|
477
|
+
end if;
|
|
478
|
+
|
|
479
|
+
select * into v_existing from bank_statements s
|
|
480
|
+
where s.bank_account_id = v_account.id and s.statement_ref = v_ref and s.statement_date = v_end;
|
|
481
|
+
v_already := found;
|
|
482
|
+
if v_already then
|
|
483
|
+
if v_existing.balance_start <> v_opening or v_existing.balance_end_declared <> v_closing then
|
|
484
|
+
raise exception 'statement_conflict: statement % of % was imported with balances % → %, and this file says % → %',
|
|
485
|
+
v_ref, v_end, v_existing.balance_start, v_existing.balance_end_declared, v_opening, v_closing;
|
|
486
|
+
end if;
|
|
487
|
+
v_id := v_existing.id;
|
|
488
|
+
else
|
|
489
|
+
insert into bank_statements
|
|
490
|
+
(company_id, bank_account_id, name, statement_date, period_start, balance_start,
|
|
491
|
+
balance_end_declared, source, statement_ref, sequence_number, source_format,
|
|
492
|
+
source_file_name, source_checksum)
|
|
493
|
+
values
|
|
494
|
+
(p_company_id, v_account.id, v_ref, v_end, coalesce(v_start, v_end), v_opening,
|
|
495
|
+
v_closing, 'import', v_ref,
|
|
496
|
+
coalesce(v_stmt ->> 'legalSequenceNumber', v_stmt ->> 'electronicSequenceNumber')::numeric,
|
|
497
|
+
v_format, p_source ->> 'file_name', p_source ->> 'checksum')
|
|
498
|
+
returning id into v_id;
|
|
499
|
+
end if;
|
|
500
|
+
|
|
501
|
+
-- 6. The lines. Keyed, inserted where the key is new, listed either way.
|
|
502
|
+
with read as (
|
|
503
|
+
select l.value as line,
|
|
504
|
+
(l.value ->> 'index')::integer as position,
|
|
505
|
+
coalesce(l.value ->> 'bookingDate', l.value ->> 'valueDate')::date as booked_on,
|
|
506
|
+
(l.value ->> 'amount')::numeric as amount,
|
|
507
|
+
nullif(l.value ->> 'bankReference', '') as bank_reference,
|
|
508
|
+
(select string_agg(u.value, ' ' order by u.ordinality)
|
|
509
|
+
from jsonb_array_elements_text(coalesce(l.value #> '{remittance,unstructured}', '[]'::jsonb))
|
|
510
|
+
with ordinality u) as free_text,
|
|
511
|
+
(select string_agg(s.value ->> 'reference', ' ' order by s.ordinality)
|
|
512
|
+
from jsonb_array_elements(coalesce(l.value #> '{remittance,structured}', '[]'::jsonb))
|
|
513
|
+
with ordinality s) as structured
|
|
514
|
+
from jsonb_array_elements(coalesce(v_stmt -> 'lines', '[]'::jsonb)) l
|
|
515
|
+
where coalesce((l.value ->> 'booked')::boolean, false)
|
|
516
|
+
),
|
|
517
|
+
described as (
|
|
518
|
+
select r.*,
|
|
519
|
+
case when r.bank_reference is not null
|
|
520
|
+
then concat_ws(chr(31), 'ref', r.bank_reference, coalesce(r.line ->> 'detail', '0'),
|
|
521
|
+
r.booked_on::text, trim_scale(r.amount)::text)
|
|
522
|
+
else concat_ws(chr(31), 'fp', r.booked_on::text, coalesce(r.line ->> 'valueDate', ''),
|
|
523
|
+
trim_scale(r.amount)::text, r.line ->> 'currency',
|
|
524
|
+
coalesce(upper(regexp_replace(r.line #>> '{counterparty,account,value}', '\s', '', 'g')), ''),
|
|
525
|
+
coalesce(r.line #>> '{counterparty,name}', ''),
|
|
526
|
+
coalesce(r.structured, ''), coalesce(r.free_text, ''),
|
|
527
|
+
coalesce(r.line ->> 'endToEndId', ''), coalesce(r.line ->> 'transactionId', ''),
|
|
528
|
+
coalesce(r.line ->> 'mandateId', ''),
|
|
529
|
+
coalesce(r.line ->> 'additionalInformation', ''))
|
|
530
|
+
end as said
|
|
531
|
+
from read r
|
|
532
|
+
),
|
|
533
|
+
keyed as (
|
|
534
|
+
select d.*,
|
|
535
|
+
(case when d.bank_reference is not null then 'ref:' else 'fp:' end)
|
|
536
|
+
|| encode(sha256(convert_to(
|
|
537
|
+
d.said || chr(31)
|
|
538
|
+
|| (row_number() over (partition by d.said order by d.position))::text, 'UTF8')), 'hex')
|
|
539
|
+
as import_key
|
|
540
|
+
from described d
|
|
541
|
+
),
|
|
542
|
+
inserted as (
|
|
543
|
+
insert into bank_transactions
|
|
544
|
+
(company_id, statement_id, bank_account_id, sequence, transaction_date, value_date,
|
|
545
|
+
amount, currency_code, description, counterpart_name, counterpart_iban, reference,
|
|
546
|
+
structured_reference, state, raw, import_key)
|
|
547
|
+
select p_company_id, v_id, v_account.id, k.position, k.booked_on,
|
|
548
|
+
(k.line ->> 'valueDate')::date, k.amount, v_account.currency_code,
|
|
549
|
+
coalesce(k.free_text, k.line ->> 'additionalInformation'),
|
|
550
|
+
k.line #>> '{counterparty,name}',
|
|
551
|
+
-- What the statement wrote, IBAN or not: it is what recognising a
|
|
552
|
+
-- counterparty compares, and `contact_patterns` already calls it
|
|
553
|
+
-- an account and not an IBAN.
|
|
554
|
+
upper(regexp_replace(k.line #>> '{counterparty,account,value}', '\s', '', 'g')),
|
|
555
|
+
k.bank_reference,
|
|
556
|
+
k.line #>> '{remittance,structured,0,reference}',
|
|
557
|
+
'pending', k.line, k.import_key
|
|
558
|
+
from keyed k
|
|
559
|
+
on conflict (bank_account_id, import_key) where import_key is not null do nothing
|
|
560
|
+
returning id, import_key
|
|
561
|
+
),
|
|
562
|
+
-- Listed either way: the lines this statement just brought, which the
|
|
563
|
+
-- table as this statement sees it does not hold yet, and the ones it found.
|
|
564
|
+
listed as (
|
|
565
|
+
insert into bank_statement_lines (company_id, statement_id, transaction_id, position)
|
|
566
|
+
select p_company_id, v_id, t.id, k.position
|
|
567
|
+
from keyed k
|
|
568
|
+
join (select i.id, i.import_key from inserted i
|
|
569
|
+
union all
|
|
570
|
+
select b.id, b.import_key from bank_transactions b
|
|
571
|
+
where b.bank_account_id = v_account.id and b.import_key is not null) t
|
|
572
|
+
on t.import_key = k.import_key
|
|
573
|
+
on conflict (statement_id, transaction_id) do nothing
|
|
574
|
+
returning 1
|
|
575
|
+
)
|
|
576
|
+
select (select count(*) from inserted), (select count(*) from listed) into v_imported, v_listed;
|
|
577
|
+
|
|
578
|
+
-- 7. The file, when the caller stored it somewhere.
|
|
579
|
+
if p_source ->> 'storage_path' is not null and not exists (
|
|
580
|
+
select 1 from attachments a
|
|
581
|
+
where a.entity_type = 'bank_statement' and a.entity_id = v_id
|
|
582
|
+
and a.checksum is not distinct from p_source ->> 'checksum'
|
|
583
|
+
and a.storage_path = p_source ->> 'storage_path'
|
|
584
|
+
) then
|
|
585
|
+
insert into attachments
|
|
586
|
+
(company_id, entity_type, entity_id, file_name, mime_type, byte_size, storage_path,
|
|
587
|
+
checksum, uploaded_by)
|
|
588
|
+
values
|
|
589
|
+
(p_company_id, 'bank_statement', v_id,
|
|
590
|
+
coalesce(p_source ->> 'file_name', v_ref), p_source ->> 'mime_type',
|
|
591
|
+
(p_source ->> 'byte_size')::bigint, p_source ->> 'storage_path',
|
|
592
|
+
p_source ->> 'checksum', auth.uid());
|
|
593
|
+
end if;
|
|
594
|
+
|
|
595
|
+
-- 8. What is signalled and never refused: a missing statement.
|
|
596
|
+
v_warnings := '[]'::jsonb;
|
|
597
|
+
select * into v_chain from bank_statement_continuity c where c.statement_id = v_id;
|
|
598
|
+
if v_chain.is_broken then
|
|
599
|
+
v_warnings := v_warnings || jsonb_build_object(
|
|
600
|
+
'code', 'balance_chain_broken',
|
|
601
|
+
'message', format('statement %s opens at %s and the previous one, %s of %s, closed at %s: %s is unaccounted for between them',
|
|
602
|
+
v_ref, v_opening, coalesce(v_chain.previous_statement_ref, 'unnamed'),
|
|
603
|
+
v_chain.previous_statement_date, v_chain.previous_balance_end, v_chain.balance_gap),
|
|
604
|
+
'previous_statement_id', v_chain.previous_statement_id,
|
|
605
|
+
'balance_gap', v_chain.balance_gap);
|
|
606
|
+
end if;
|
|
607
|
+
if v_chain.missing_statements is distinct from 0 and v_chain.missing_statements is not null then
|
|
608
|
+
v_warnings := v_warnings || jsonb_build_object(
|
|
609
|
+
'code', 'statement_number_gap',
|
|
610
|
+
'message', format('statement %s is numbered %s and the previous one %s',
|
|
611
|
+
v_ref, v_chain.sequence_number, v_chain.sequence_number - v_chain.missing_statements - 1),
|
|
612
|
+
'missing_statements', v_chain.missing_statements);
|
|
613
|
+
end if;
|
|
614
|
+
|
|
615
|
+
statement_index := v_index;
|
|
616
|
+
statement_id := v_id;
|
|
617
|
+
bank_account_id := v_account.id;
|
|
618
|
+
statement_ref := v_ref;
|
|
619
|
+
already_imported := v_already;
|
|
620
|
+
lines_read := v_read;
|
|
621
|
+
lines_imported := v_imported;
|
|
622
|
+
lines_known := v_read - v_imported;
|
|
623
|
+
lines_not_booked := v_skipped;
|
|
624
|
+
warnings := v_warnings;
|
|
625
|
+
return next;
|
|
626
|
+
end loop;
|
|
627
|
+
end;
|
|
628
|
+
$$;
|
|
629
|
+
|
|
630
|
+
comment on function import_bank_statement(uuid, jsonb, jsonb, uuid) is
|
|
631
|
+
'Writes what a format reader read out of a bank file into bank_statements and bank_transactions, and nothing else: no entry, no payment, no matching. Idempotent on import_key — a replayed file imports nothing, an overlapping statement imports what is new and lists the rest. Refuses, by name and before writing anything: an account the company does not have (unknown_bank_account), a statement that does not add up (unbalanced_statement) or has no balances, a booked line it cannot hold as it is (unreadable_statement_line), a currency that is not the account''s, and the same statement with other balances (statement_conflict). Signals and does not refuse: an opening balance that is not the previous closing one, a hole in the bank''s numbering. One row per statement of the file; the whole file is imported or none of it.';
|
|
632
|
+
|
|
633
|
+
revoke execute on function import_bank_statement(uuid, jsonb, jsonb, uuid) from public, anon;
|
|
634
|
+
grant execute on function import_bank_statement(uuid, jsonb, jsonb, uuid) to authenticated, service_role;
|
|
635
|
+
|
|
636
|
+
-- ---------------------------------------------------------------------------
|
|
637
|
+
-- The column that said "no reader yet"
|
|
638
|
+
-- ---------------------------------------------------------------------------
|
|
639
|
+
|
|
640
|
+
comment on column country_defaults.bank_statement_formats is
|
|
641
|
+
'Statement formats a bank of this country sends — coda, camt.053, cfonb120 — from the pack. **Read by clients, not by the socle**: import_bank_statement() takes what a format reader returned and does not ask which format it came from, so this list is what a client offers, and a name in it is a promise only where a reader exists. camt.053 has one; the others are owed, by name, in tests/bank_statement_formats.test.ts.';
|
|
642
|
+
|
|
643
|
+
-- ---------------------------------------------------------------------------
|
|
644
|
+
-- The list leaves with the company
|
|
645
|
+
-- ---------------------------------------------------------------------------
|
|
646
|
+
--
|
|
647
|
+
-- A company that leaves an installation takes its statements and their lines
|
|
648
|
+
-- with it. Which lines a statement lists is part of the statement: without it
|
|
649
|
+
-- an overlapping statement would arrive with a closing balance nothing proves.
|
|
650
|
+
-- Loaded after both of the tables it points at.
|
|
651
|
+
|
|
652
|
+
insert into company_archive_registry (table_name, disposition, load_order, via_column, via_table, reason) values
|
|
653
|
+
('bank_statement_lines', 'exported', 59, null, null, 'Which lines a statement lists, so a statement that overlaps another still proves its closing balance where it arrives.')
|
|
654
|
+
on conflict (table_schema, table_name) do nothing;
|