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,568 @@
|
|
|
1
|
+
-- Ekwo OS — a document, opened by whoever holds its link.
|
|
2
|
+
--
|
|
3
|
+
-- A company sends an invoice and the customer wants to look at it: what it
|
|
4
|
+
-- says, what is still owed on it, which sentences the law put at its foot. The
|
|
5
|
+
-- three answers already exist in this schema — `document_header`,
|
|
6
|
+
-- `document_line_items`, `document_tax_summary`, `document_legal_mentions`,
|
|
7
|
+
-- `documents.amount_residual` — and every one of them is behind row level
|
|
8
|
+
-- security, which is right: the customer is not a member of the company and
|
|
9
|
+
-- never will be. So the usual answers are both bad. A login for a customer who
|
|
10
|
+
-- will read one invoice is an account nobody maintains; an attachment is a
|
|
11
|
+
-- copy that stops being true the day the invoice is paid.
|
|
12
|
+
--
|
|
13
|
+
-- A **share** is the third answer: a row that says *this document may be read
|
|
14
|
+
-- by whoever presents this secret*, and one function that takes the secret and
|
|
15
|
+
-- returns the document as it was sent, with what is still owed on it today.
|
|
16
|
+
--
|
|
17
|
+
-- Six decisions, each of them a thing that could have been done otherwise.
|
|
18
|
+
--
|
|
19
|
+
-- **The token is the whole secret, and only its hash is stored.** 32 bytes,
|
|
20
|
+
-- rendered base64url, 43 characters. `document_shares.token_hash` is a sha256
|
|
21
|
+
-- of it and the clear token is returned by `share_document()` and by nothing
|
|
22
|
+
-- else, ever — the same arrangement `company_invitations` and `api_keys` are
|
|
23
|
+
-- already built on, and for the same reason: a database backup, a replica or a
|
|
24
|
+
-- support engineer reading a table hands nobody a live link.
|
|
25
|
+
--
|
|
26
|
+
-- **Where the 32 bytes come from is not `gen_random_bytes`.** That function is
|
|
27
|
+
-- `pgcrypto`, and `pgcrypto` is not available under PGlite, which is what this
|
|
28
|
+
-- repository's whole test suite runs on — an invariant that cannot be tested
|
|
29
|
+
-- is an invariant nobody is keeping. `gen_random_uuid()` is core Postgres and
|
|
30
|
+
-- is the source the primary keys of every table here already come from; two of
|
|
31
|
+
-- them are 32 bytes, of which 244 bits are random (a v4 UUID fixes six bits for
|
|
32
|
+
-- its version and its variant). 244 bits is far past the point where guessing
|
|
33
|
+
-- is the attack anybody would choose, and `sha256()` is core too, since
|
|
34
|
+
-- PostgreSQL 11. No extension is added by this migration and none is needed.
|
|
35
|
+
--
|
|
36
|
+
-- **`anon` gets one function and not one table.** The invariant of
|
|
37
|
+
-- `20260911210131` and `20260914151207` — the anonymous role holds no
|
|
38
|
+
-- privilege on any table or view of this schema, and reaches only functions
|
|
39
|
+
-- that answer about the caller — is not bent here. `shared_document()` is
|
|
40
|
+
-- `security definer`: the reader has no rights of their own, the function has
|
|
41
|
+
-- them, and what comes out is one document rendered into one jsonb. There is
|
|
42
|
+
-- no view to select from, no filter to widen and no second row to reach.
|
|
43
|
+
--
|
|
44
|
+
-- **No IP address and no user agent.** Who opened a link and from where is a
|
|
45
|
+
-- log, and a log of the people a company invoices is personal data with a
|
|
46
|
+
-- retention policy, a lawful basis and a subject-access request behind it. The
|
|
47
|
+
-- core records `view_count` and `last_viewed_at`, which is what the sender
|
|
48
|
+
-- actually asks ("did they open it?"), and the application in front of it
|
|
49
|
+
-- records whatever it is prepared to answer for.
|
|
50
|
+
--
|
|
51
|
+
-- **A share is not edited.** There is no `update` policy and no
|
|
52
|
+
-- `change_share()`. An expiry that moves is a link whose lifetime is a moving
|
|
53
|
+
-- target, and a token that outlives the decision to withdraw it is the one
|
|
54
|
+
-- failure a sharing feature must not have. Revoke it and make another.
|
|
55
|
+
--
|
|
56
|
+
-- **Sales only.** A purchase invoice is a document somebody else wrote about
|
|
57
|
+
-- their own business: their prices, their bank details, their legal mentions.
|
|
58
|
+
-- Publishing it under a link this company controls would be publishing a third
|
|
59
|
+
-- party's data, and there is no request behind it — nobody shares a supplier's
|
|
60
|
+
-- invoice with the supplier. `share_document()` refuses it by name.
|
|
61
|
+
|
|
62
|
+
-- ---------------------------------------------------------------------------
|
|
63
|
+
-- 1. The instance learns its own address
|
|
64
|
+
--
|
|
65
|
+
-- A link has to be absolute, and nothing in this schema knew where the
|
|
66
|
+
-- installation answers. The column is nullable, carries no default, and no URL
|
|
67
|
+
-- of Ekwo's is written anywhere: a self-hosted installation is reachable at
|
|
68
|
+
-- the address its operator chose, and a core that guessed would hand out links
|
|
69
|
+
-- to somebody else's host. Null is a supported state — `share_document()`
|
|
70
|
+
-- returns the token with a null `url`, and the caller builds the link itself.
|
|
71
|
+
-- ---------------------------------------------------------------------------
|
|
72
|
+
|
|
73
|
+
alter table instance
|
|
74
|
+
add column if not exists public_base_url text;
|
|
75
|
+
|
|
76
|
+
comment on column instance.public_base_url is
|
|
77
|
+
'Where this installation answers on the public internet, as an origin with no trailing slash — the base a shared document link is built on. Null where the operator has not said, and then a share returns its token with no URL.';
|
|
78
|
+
|
|
79
|
+
alter table instance
|
|
80
|
+
add constraint instance_public_base_url_absolute
|
|
81
|
+
check (public_base_url is null or public_base_url ~ '^https?://[^[:space:]]+$');
|
|
82
|
+
|
|
83
|
+
-- ---------------------------------------------------------------------------
|
|
84
|
+
-- 2. What a share is about
|
|
85
|
+
--
|
|
86
|
+
-- One value today. It exists because the second one is already foreseeable —
|
|
87
|
+
-- a statement of account, a financial statement, an ageing balance sent to a
|
|
88
|
+
-- customer are the same act with a different subject — and because the shape
|
|
89
|
+
-- that survives it is decided now or paid for later. `document_id` is nullable
|
|
90
|
+
-- and tied to the kind by a check, so the kind that arrives next adds its own
|
|
91
|
+
-- column and its own branch of the same check, and every row written before it
|
|
92
|
+
-- keeps meaning exactly what it meant.
|
|
93
|
+
-- ---------------------------------------------------------------------------
|
|
94
|
+
|
|
95
|
+
create type share_subject_kind as enum ('document');
|
|
96
|
+
|
|
97
|
+
comment on type share_subject_kind is
|
|
98
|
+
'What a share gives access to. One value today; a statement or a report is the same act with another subject, and the column is here so that arriving costs a branch rather than a rewrite.';
|
|
99
|
+
|
|
100
|
+
create table document_shares (
|
|
101
|
+
id uuid primary key default gen_random_uuid(),
|
|
102
|
+
company_id uuid not null references companies(id) on delete cascade,
|
|
103
|
+
subject_kind share_subject_kind not null default 'document',
|
|
104
|
+
document_id uuid references documents(id) on delete cascade,
|
|
105
|
+
-- sha256 of the token, hex. Unique, because presenting a token is a lookup
|
|
106
|
+
-- on this column and two rows answering it would be two links nobody can
|
|
107
|
+
-- tell apart.
|
|
108
|
+
token_hash text not null unique,
|
|
109
|
+
expires_at timestamptz,
|
|
110
|
+
revoked_at timestamptz,
|
|
111
|
+
created_by uuid,
|
|
112
|
+
created_at timestamptz not null default now(),
|
|
113
|
+
view_count integer not null default 0,
|
|
114
|
+
last_viewed_at timestamptz,
|
|
115
|
+
constraint document_shares_subject_matches_kind
|
|
116
|
+
check ((subject_kind = 'document') = (document_id is not null)),
|
|
117
|
+
constraint document_shares_expiry check (expires_at is null or expires_at > created_at),
|
|
118
|
+
constraint document_shares_view_count_positive check (view_count >= 0),
|
|
119
|
+
constraint document_shares_token_hash_shape check (token_hash ~ '^[0-9a-f]{64}$'),
|
|
120
|
+
foreign key (document_id, company_id) references documents(id, company_id) on delete cascade
|
|
121
|
+
);
|
|
122
|
+
|
|
123
|
+
comment on table document_shares is
|
|
124
|
+
'Public links onto a document. The token is handed over once and kept only as a sha256; the link is withdrawn by revoking it, never by editing it.';
|
|
125
|
+
comment on column document_shares.token_hash is
|
|
126
|
+
'sha256 of the token, hex. The token itself is returned by share_document() and stored nowhere.';
|
|
127
|
+
comment on column document_shares.expires_at is
|
|
128
|
+
'When the link stops answering. Null means it answers until it is revoked, which is a deliberate choice and not an oversight: an invoice is looked at years later.';
|
|
129
|
+
comment on column document_shares.revoked_at is
|
|
130
|
+
'When the link was withdrawn. A withdrawn link answers exactly like one that never existed.';
|
|
131
|
+
comment on column document_shares.created_by is
|
|
132
|
+
'auth.users.id of whoever created it. No foreign key, for the same reason company_members has none.';
|
|
133
|
+
comment on column document_shares.view_count is
|
|
134
|
+
'How many times the document was read through this link. No address and no user agent: who opened it and from where is a log the application keeps, with the retention policy that goes with it.';
|
|
135
|
+
|
|
136
|
+
-- The second one leads with the two columns of the composite foreign key, in
|
|
137
|
+
-- its order, which is the rule `20260913104232` set and a test enforces: a key
|
|
138
|
+
-- without an index is a sequential scan on every delete of the parent row.
|
|
139
|
+
create index document_shares_company_idx on document_shares (company_id, created_at desc);
|
|
140
|
+
create index document_shares_document_idx on document_shares (document_id, company_id);
|
|
141
|
+
|
|
142
|
+
-- ---------------------------------------------------------------------------
|
|
143
|
+
-- 3. Sharing is its own capability
|
|
144
|
+
--
|
|
145
|
+
-- `documents.write` would have been close enough to work and wrong to read: a
|
|
146
|
+
-- bookkeeper who drafts invoices is not necessarily the person a company wants
|
|
147
|
+
-- publishing them on the open internet, and the two acts are refused and
|
|
148
|
+
-- granted separately the first time somebody asks. It sits on the two presets
|
|
149
|
+
-- that issue documents, and on neither the viewer nor anything below.
|
|
150
|
+
-- ---------------------------------------------------------------------------
|
|
151
|
+
|
|
152
|
+
insert into capabilities (code, area, description) values
|
|
153
|
+
('documents.share', 'documents',
|
|
154
|
+
'Publish a sales document behind a link anyone holding it can open, and withdraw such a link.')
|
|
155
|
+
on conflict (code) do nothing;
|
|
156
|
+
|
|
157
|
+
insert into role_capabilities (role, capability)
|
|
158
|
+
select r.role, 'documents.share'
|
|
159
|
+
from (values ('owner'::member_role), ('accountant'::member_role)) as r(role)
|
|
160
|
+
on conflict do nothing;
|
|
161
|
+
|
|
162
|
+
-- ---------------------------------------------------------------------------
|
|
163
|
+
-- 4. Which documents may be shared, and the refusal each one gets
|
|
164
|
+
--
|
|
165
|
+
-- Four rules, and a named refusal for each, because "not allowed" on an
|
|
166
|
+
-- invoice somebody is trying to send is the least useful sentence a database
|
|
167
|
+
-- can produce. Written once here and read by both `share_document()` — which
|
|
168
|
+
-- refuses — and `shared_document()` — which stops answering the day the
|
|
169
|
+
-- document leaves the set, so cancelling an invoice closes the links onto it
|
|
170
|
+
-- without anybody having to remember to revoke them.
|
|
171
|
+
-- ---------------------------------------------------------------------------
|
|
172
|
+
|
|
173
|
+
create or replace function document_share_refusal(p_document documents)
|
|
174
|
+
returns text
|
|
175
|
+
language sql
|
|
176
|
+
immutable
|
|
177
|
+
as $$
|
|
178
|
+
select case
|
|
179
|
+
when p_document.doc_type::text not like 'sale%' then
|
|
180
|
+
'share_not_a_sale: a ' || p_document.doc_type::text ||
|
|
181
|
+
' is a third party''s own document — their prices, their bank details, their mentions — and this installation does not publish it'
|
|
182
|
+
when p_document.state = 'cancelled' then
|
|
183
|
+
'share_cancelled_document: that document was cancelled, and a cancelled document is not sent to anybody'
|
|
184
|
+
when p_document.doc_type in ('sale_invoice', 'sale_credit_note')
|
|
185
|
+
and p_document.state = 'draft' then
|
|
186
|
+
'share_draft_document: that document is still a draft. Post it — a draft has no number, carries no entry, and is not what was sent'
|
|
187
|
+
when p_document.number is null then
|
|
188
|
+
'share_unnumbered_document: that document carries no number, so there is nothing to show a customer that they can quote back'
|
|
189
|
+
else null
|
|
190
|
+
end;
|
|
191
|
+
$$;
|
|
192
|
+
|
|
193
|
+
comment on function document_share_refusal(documents) is
|
|
194
|
+
'Why this document may not be shared, or null when it may. Sales only, never cancelled, never an unposted invoice, always numbered.';
|
|
195
|
+
|
|
196
|
+
-- ---------------------------------------------------------------------------
|
|
197
|
+
-- 5. Creating a link
|
|
198
|
+
--
|
|
199
|
+
-- `security definer`, for three reasons at once: the token has to be hashed
|
|
200
|
+
-- where nobody sees it, `audit_record()` is reachable by no client, and a
|
|
201
|
+
-- caller who may share a document is not necessarily a caller who may insert
|
|
202
|
+
-- into a table nothing else writes.
|
|
203
|
+
--
|
|
204
|
+
-- The token comes back in this answer and in no other. The URL is the
|
|
205
|
+
-- installation's base plus `/shared/<token>` — one path, written down in
|
|
206
|
+
-- `docs/sharing.md`, so that an operator putting a reverse proxy in front of
|
|
207
|
+
-- their installation knows which route has to reach the application.
|
|
208
|
+
-- ---------------------------------------------------------------------------
|
|
209
|
+
|
|
210
|
+
create or replace function share_document(
|
|
211
|
+
p_document_id uuid,
|
|
212
|
+
p_expires_at timestamptz default null
|
|
213
|
+
)
|
|
214
|
+
returns table (share_id uuid, token text, url text)
|
|
215
|
+
language plpgsql
|
|
216
|
+
security definer
|
|
217
|
+
set search_path = public, pg_temp
|
|
218
|
+
as $$
|
|
219
|
+
declare
|
|
220
|
+
v_doc documents%rowtype;
|
|
221
|
+
v_refusal text;
|
|
222
|
+
v_token text;
|
|
223
|
+
v_base text;
|
|
224
|
+
v_row document_shares%rowtype;
|
|
225
|
+
begin
|
|
226
|
+
select * into v_doc from documents where id = p_document_id;
|
|
227
|
+
if v_doc.id is null then
|
|
228
|
+
raise exception 'unknown_document: document % does not exist', p_document_id;
|
|
229
|
+
end if;
|
|
230
|
+
|
|
231
|
+
if not is_installer() and not has_capability(v_doc.company_id, 'documents.share') then
|
|
232
|
+
raise exception 'not_allowed: publishing a document of this company needs documents.share'
|
|
233
|
+
using errcode = '42501';
|
|
234
|
+
end if;
|
|
235
|
+
|
|
236
|
+
v_refusal := document_share_refusal(v_doc);
|
|
237
|
+
if v_refusal is not null then
|
|
238
|
+
raise exception '%', v_refusal;
|
|
239
|
+
end if;
|
|
240
|
+
|
|
241
|
+
if p_expires_at is not null and p_expires_at <= now() then
|
|
242
|
+
raise exception 'share_expires_in_the_past: % is not in the future, so this link would be born dead',
|
|
243
|
+
p_expires_at;
|
|
244
|
+
end if;
|
|
245
|
+
|
|
246
|
+
-- 32 bytes as base64url, 43 characters, no padding. `translate` maps the two
|
|
247
|
+
-- characters base64 and base64url disagree on and drops the `=`, whose
|
|
248
|
+
-- position is implied by the length.
|
|
249
|
+
v_token := translate(
|
|
250
|
+
encode(
|
|
251
|
+
decode(replace(gen_random_uuid()::text, '-', '') ||
|
|
252
|
+
replace(gen_random_uuid()::text, '-', ''), 'hex'),
|
|
253
|
+
'base64'),
|
|
254
|
+
'+/=', '-_');
|
|
255
|
+
|
|
256
|
+
insert into document_shares (company_id, subject_kind, document_id, token_hash,
|
|
257
|
+
expires_at, created_by)
|
|
258
|
+
values (v_doc.company_id, 'document', v_doc.id,
|
|
259
|
+
encode(sha256(convert_to(v_token, 'UTF8')), 'hex'),
|
|
260
|
+
p_expires_at, auth.uid())
|
|
261
|
+
returning * into v_row;
|
|
262
|
+
|
|
263
|
+
perform audit_record(
|
|
264
|
+
v_doc.company_id, 'document_shares', v_row.id,
|
|
265
|
+
coalesce(v_doc.number, v_doc.id::text), 'insert', 'document_shared',
|
|
266
|
+
null,
|
|
267
|
+
jsonb_build_object('document_id', v_doc.id, 'doc_type', v_doc.doc_type,
|
|
268
|
+
'expires_at', v_row.expires_at));
|
|
269
|
+
|
|
270
|
+
select i.public_base_url into v_base from instance i where i.id = 1;
|
|
271
|
+
|
|
272
|
+
return query select v_row.id,
|
|
273
|
+
v_token,
|
|
274
|
+
case when v_base is null then null
|
|
275
|
+
else rtrim(v_base, '/') || '/shared/' || v_token end;
|
|
276
|
+
end;
|
|
277
|
+
$$;
|
|
278
|
+
|
|
279
|
+
comment on function share_document(uuid, timestamptz) is
|
|
280
|
+
'Publishes a sales document behind a link and returns the token once — only its hash is stored. `url` is the instance''s public base plus /shared/<token>, or null where the instance has not recorded one. A share is never edited: revoke it and make another.';
|
|
281
|
+
|
|
282
|
+
create or replace function revoke_share(p_share_id uuid)
|
|
283
|
+
returns document_shares
|
|
284
|
+
language plpgsql
|
|
285
|
+
security definer
|
|
286
|
+
set search_path = public, pg_temp
|
|
287
|
+
as $$
|
|
288
|
+
declare
|
|
289
|
+
v_row document_shares%rowtype;
|
|
290
|
+
begin
|
|
291
|
+
select * into v_row from document_shares where id = p_share_id;
|
|
292
|
+
if v_row.id is null then
|
|
293
|
+
raise exception 'unknown_share: no share with that id';
|
|
294
|
+
end if;
|
|
295
|
+
|
|
296
|
+
if not is_installer() and not has_capability(v_row.company_id, 'documents.share') then
|
|
297
|
+
raise exception 'not_allowed: withdrawing a link of this company needs documents.share'
|
|
298
|
+
using errcode = '42501';
|
|
299
|
+
end if;
|
|
300
|
+
|
|
301
|
+
update document_shares
|
|
302
|
+
set revoked_at = coalesce(revoked_at, now())
|
|
303
|
+
where id = p_share_id
|
|
304
|
+
returning * into v_row;
|
|
305
|
+
|
|
306
|
+
perform audit_record(
|
|
307
|
+
v_row.company_id, 'document_shares', v_row.id,
|
|
308
|
+
(select coalesce(d.number, d.id::text) from documents d where d.id = v_row.document_id),
|
|
309
|
+
'update', 'document_share_revoked',
|
|
310
|
+
null, jsonb_build_object('revoked_at', v_row.revoked_at));
|
|
311
|
+
|
|
312
|
+
return v_row;
|
|
313
|
+
end;
|
|
314
|
+
$$;
|
|
315
|
+
|
|
316
|
+
comment on function revoke_share(uuid) is
|
|
317
|
+
'Withdraws a link, now and for good. A withdrawn link answers exactly like one that never existed; there is no un-withdraw, because a secret that has been out of the building is issued again rather than brought back.';
|
|
318
|
+
|
|
319
|
+
-- ---------------------------------------------------------------------------
|
|
320
|
+
-- 6. Reading one
|
|
321
|
+
--
|
|
322
|
+
-- The public door. `anon` may execute it and it takes one argument: the token
|
|
323
|
+
-- is the whole of what the caller presents and the whole of what the function
|
|
324
|
+
-- consults, so there is nothing to enumerate and nothing to widen. Unknown,
|
|
325
|
+
-- revoked, expired, and a document that has since left the shareable set all
|
|
326
|
+
-- produce the *same* answer — SQL null — because an error message that
|
|
327
|
+
-- distinguished them would tell somebody feeding tokens at it which of their
|
|
328
|
+
-- guesses had been a real link.
|
|
329
|
+
--
|
|
330
|
+
-- What comes out is the document as it was sent, plus the one thing that
|
|
331
|
+
-- legitimately moves after sending: what is still owed. Every figure is read
|
|
332
|
+
-- from the views the renderer of this schema already uses, so a shared link
|
|
333
|
+
-- and a printed invoice cannot come to disagree.
|
|
334
|
+
--
|
|
335
|
+
-- What deliberately does not come out: no identifier of anything — not the
|
|
336
|
+
-- document, not the company, not the contact, not a tax, not an account —
|
|
337
|
+
-- no analytic axis, no cost, no internal contact note, no list of anything
|
|
338
|
+
-- the company owns. A link is one document, and nothing it carries can be
|
|
339
|
+
-- turned into a question about a second one.
|
|
340
|
+
--
|
|
341
|
+
-- Every amount is rendered as a decimal *string*. A JSON number is a double
|
|
342
|
+
-- by the time it reaches a browser, and an invoice total that arrives as
|
|
343
|
+
-- 1210.0000000000002 is the oldest bug in billing software. It is also what
|
|
344
|
+
-- the rest of this project already says on the wire: the MCP server tells its
|
|
345
|
+
-- clients that amounts are decimal strings, and `::text` on a numeric keeps
|
|
346
|
+
-- the scale the column was rounded at.
|
|
347
|
+
-- ---------------------------------------------------------------------------
|
|
348
|
+
|
|
349
|
+
create or replace function shared_document(p_token text)
|
|
350
|
+
returns jsonb
|
|
351
|
+
language plpgsql
|
|
352
|
+
security definer
|
|
353
|
+
set search_path = public, pg_temp
|
|
354
|
+
as $$
|
|
355
|
+
declare
|
|
356
|
+
v_share document_shares%rowtype;
|
|
357
|
+
v_doc documents%rowtype;
|
|
358
|
+
v_header document_header%rowtype;
|
|
359
|
+
v_language text;
|
|
360
|
+
v_last_payment date;
|
|
361
|
+
v_payload jsonb;
|
|
362
|
+
begin
|
|
363
|
+
select * into v_share
|
|
364
|
+
from document_shares
|
|
365
|
+
where token_hash = encode(sha256(convert_to(coalesce(p_token, ''), 'UTF8')), 'hex');
|
|
366
|
+
|
|
367
|
+
if v_share.id is null
|
|
368
|
+
or v_share.revoked_at is not null
|
|
369
|
+
or (v_share.expires_at is not null and v_share.expires_at <= now())
|
|
370
|
+
or v_share.subject_kind <> 'document' then
|
|
371
|
+
return null;
|
|
372
|
+
end if;
|
|
373
|
+
|
|
374
|
+
select * into v_doc from documents where id = v_share.document_id;
|
|
375
|
+
if v_doc.id is null or document_share_refusal(v_doc) is not null then
|
|
376
|
+
return null;
|
|
377
|
+
end if;
|
|
378
|
+
|
|
379
|
+
-- The link was used. Counted before the payload is built, so a reader who
|
|
380
|
+
-- gives up halfway is still a reader.
|
|
381
|
+
update document_shares
|
|
382
|
+
set view_count = view_count + 1,
|
|
383
|
+
last_viewed_at = now()
|
|
384
|
+
where id = v_share.id;
|
|
385
|
+
|
|
386
|
+
select * into v_header from document_header where document_id = v_doc.id;
|
|
387
|
+
|
|
388
|
+
-- The language the document speaks: the one the customer reads, else the one
|
|
389
|
+
-- the company keeps its books in. `companies.language` is not null — it is
|
|
390
|
+
-- filled from the pack when the company is created — so the chain always
|
|
391
|
+
-- ends somewhere, and it ends without a literal. A language written into
|
|
392
|
+
-- this function would be one country's answer printed on every other
|
|
393
|
+
-- country's invoice.
|
|
394
|
+
--
|
|
395
|
+
-- `preferred_languages()` is the published way of choosing a language and it
|
|
396
|
+
-- cannot be used here: it starts at the preferences of `auth.uid()`, which
|
|
397
|
+
-- is null for the one reader this function exists for. The gap is written
|
|
398
|
+
-- down in `docs/international.md`.
|
|
399
|
+
select coalesce(ct.language, c.language)
|
|
400
|
+
into v_language
|
|
401
|
+
from documents d
|
|
402
|
+
join companies c on c.id = d.company_id
|
|
403
|
+
join contacts ct on ct.id = d.contact_id
|
|
404
|
+
where d.id = v_doc.id;
|
|
405
|
+
|
|
406
|
+
-- When the last money against it arrived. The other side of every matching
|
|
407
|
+
-- on the document's own third-party lines, where that side is a payment.
|
|
408
|
+
select max(p.payment_date) into v_last_payment
|
|
409
|
+
from entry_lines dl
|
|
410
|
+
join reconciliations r
|
|
411
|
+
on r.debit_line_id = dl.id or r.credit_line_id = dl.id
|
|
412
|
+
join entry_lines ol
|
|
413
|
+
on ol.id = case when r.debit_line_id = dl.id then r.credit_line_id else r.debit_line_id end
|
|
414
|
+
join payments p on p.entry_id = ol.entry_id
|
|
415
|
+
where dl.entry_id = v_doc.entry_id;
|
|
416
|
+
|
|
417
|
+
v_payload := jsonb_build_object(
|
|
418
|
+
'document', jsonb_build_object(
|
|
419
|
+
'type', v_header.doc_type,
|
|
420
|
+
'number', v_header.number,
|
|
421
|
+
'document_date', v_header.document_date,
|
|
422
|
+
'due_date', v_header.due_date,
|
|
423
|
+
'delivery_date', v_header.delivery_date,
|
|
424
|
+
'currency', v_header.currency_code,
|
|
425
|
+
'language', v_language,
|
|
426
|
+
'payment_terms', v_header.payment_terms,
|
|
427
|
+
'payment_reference', v_header.payment_reference,
|
|
428
|
+
'buyer_reference', v_header.buyer_reference,
|
|
429
|
+
'order_reference', v_header.order_reference,
|
|
430
|
+
-- BT-22, the note the seller wrote on the invoice itself. An internal
|
|
431
|
+
-- remark about a customer is `contacts.notes` and is not in this object.
|
|
432
|
+
'note', v_header.note
|
|
433
|
+
),
|
|
434
|
+
'seller', jsonb_build_object(
|
|
435
|
+
'name', v_header.seller_name,
|
|
436
|
+
'legal_name', v_header.seller_legal_name,
|
|
437
|
+
'legal_form', v_header.seller_legal_form,
|
|
438
|
+
'vat_number', v_header.seller_vat_number,
|
|
439
|
+
'registration_number', v_header.seller_registration_number,
|
|
440
|
+
'address_line1', v_header.seller_address_line1,
|
|
441
|
+
'address_line2', v_header.seller_address_line2,
|
|
442
|
+
'postal_code', v_header.seller_postal_code,
|
|
443
|
+
'city', v_header.seller_city,
|
|
444
|
+
'country', v_header.seller_country,
|
|
445
|
+
'email', v_header.seller_email,
|
|
446
|
+
'phone', v_header.seller_phone,
|
|
447
|
+
'website', v_header.seller_website,
|
|
448
|
+
'logo_url', v_header.seller_logo_url,
|
|
449
|
+
'iban', v_header.payee_iban,
|
|
450
|
+
'bic', v_header.payee_bic
|
|
451
|
+
),
|
|
452
|
+
'buyer', jsonb_build_object(
|
|
453
|
+
'name', v_header.buyer_name,
|
|
454
|
+
'vat_number', v_header.buyer_vat_number,
|
|
455
|
+
'registration_number', v_header.buyer_registration_number,
|
|
456
|
+
'address_line1', v_header.buyer_address_line1,
|
|
457
|
+
'address_line2', v_header.buyer_address_line2,
|
|
458
|
+
'postal_code', v_header.buyer_postal_code,
|
|
459
|
+
'city', v_header.buyer_city,
|
|
460
|
+
'country', v_header.buyer_country
|
|
461
|
+
),
|
|
462
|
+
'lines', coalesce((
|
|
463
|
+
select jsonb_agg(jsonb_build_object(
|
|
464
|
+
'sequence', li.sequence,
|
|
465
|
+
'type', li.line_type,
|
|
466
|
+
'name', li.item_name,
|
|
467
|
+
'description', li.item_description,
|
|
468
|
+
'quantity', li.quantity::text,
|
|
469
|
+
'unit_code', li.unit_code,
|
|
470
|
+
'unit_price', li.unit_price::text,
|
|
471
|
+
'discount_percent', li.discount_percent::text,
|
|
472
|
+
'amount_untaxed', li.amount_untaxed::text,
|
|
473
|
+
'tax_category', li.vat_category,
|
|
474
|
+
'tax_rate', li.vat_rate::text,
|
|
475
|
+
'tax_exemption_code', li.tax_exemption_code)
|
|
476
|
+
order by li.sequence)
|
|
477
|
+
from document_line_items li
|
|
478
|
+
where li.document_id = v_doc.id), '[]'::jsonb),
|
|
479
|
+
'tax_summary', coalesce((
|
|
480
|
+
select jsonb_agg(jsonb_build_object(
|
|
481
|
+
'name', ts.tax_name,
|
|
482
|
+
'category', ts.vat_category,
|
|
483
|
+
'rate', ts.tax_rate::text,
|
|
484
|
+
'base_amount', ts.base_amount::text,
|
|
485
|
+
-- What the other party actually pays, which is what an invoice
|
|
486
|
+
-- prints: zero where the tax self-assesses, and what the four
|
|
487
|
+
-- totals below add up from.
|
|
488
|
+
'tax_amount', ts.tax_charged::text)
|
|
489
|
+
order by ts.tax_rate, ts.tax_name)
|
|
490
|
+
from document_tax_summary ts
|
|
491
|
+
where ts.document_id = v_doc.id), '[]'::jsonb),
|
|
492
|
+
'totals', jsonb_build_object(
|
|
493
|
+
'amount_untaxed', v_header.amount_untaxed::text,
|
|
494
|
+
'amount_tax', v_header.amount_tax::text,
|
|
495
|
+
'amount_total', v_header.amount_total::text
|
|
496
|
+
),
|
|
497
|
+
-- The one thing that legitimately moves after the document was sent, so a
|
|
498
|
+
-- link a customer keeps stays worth opening.
|
|
499
|
+
'payment', jsonb_build_object(
|
|
500
|
+
'state', v_header.payment_state,
|
|
501
|
+
'amount_paid', v_header.amount_paid::text,
|
|
502
|
+
'amount_residual', v_header.amount_residual::text,
|
|
503
|
+
'last_payment_date', v_last_payment
|
|
504
|
+
),
|
|
505
|
+
'legal_mentions', coalesce((
|
|
506
|
+
select jsonb_agg(jsonb_build_object(
|
|
507
|
+
'code', lm.code,
|
|
508
|
+
'text', label_for(lm.text, lm.text_i18n, array[v_language]))
|
|
509
|
+
order by lm.sequence, lm.code)
|
|
510
|
+
from document_legal_mentions lm
|
|
511
|
+
where lm.document_id = v_doc.id), '[]'::jsonb)
|
|
512
|
+
);
|
|
513
|
+
|
|
514
|
+
return v_payload;
|
|
515
|
+
end;
|
|
516
|
+
$$;
|
|
517
|
+
|
|
518
|
+
comment on function shared_document(text) is
|
|
519
|
+
'One document, read by whoever holds its link: the header, the lines, the tax breakdown, the totals, the legal mentions in the document''s own language, and what is still owed today. Returns null — the same null, in the same shape — for a token that is unknown, withdrawn, expired, or onto a document that may no longer be shared.';
|
|
520
|
+
|
|
521
|
+
-- ---------------------------------------------------------------------------
|
|
522
|
+
-- 7. Row level security
|
|
523
|
+
--
|
|
524
|
+
-- Reading the links of a company is reading its documents, so it is
|
|
525
|
+
-- `documents.read` — which every preset holds, viewer included, because seeing
|
|
526
|
+
-- that an invoice was published is part of reading it. Nothing writes the
|
|
527
|
+
-- table through the API: the two functions above are the only way in, and each
|
|
528
|
+
-- checks what a policy would have. That is the shape `api_keys` and
|
|
529
|
+
-- `company_invitations` already have, and it is why `list_shares` is a select
|
|
530
|
+
-- and not a fourth function — a list that row level security already produces
|
|
531
|
+
-- correctly does not need a function to produce it a second way.
|
|
532
|
+
--
|
|
533
|
+
-- There is no policy for `anon`, on this table or on any other. What an
|
|
534
|
+
-- anonymous reader reaches is one `security definer` function, and the table
|
|
535
|
+
-- under it is as closed to them as every other table of this schema.
|
|
536
|
+
-- ---------------------------------------------------------------------------
|
|
537
|
+
|
|
538
|
+
alter table document_shares enable row level security;
|
|
539
|
+
|
|
540
|
+
create policy document_shares_select on document_shares
|
|
541
|
+
for select using (has_capability(company_id, 'documents.read'));
|
|
542
|
+
|
|
543
|
+
comment on policy document_shares_select on document_shares is
|
|
544
|
+
'Anyone who may read the documents of the company may see which of them are published, and since when. The token is not here: only its hash is.';
|
|
545
|
+
|
|
546
|
+
-- ---------------------------------------------------------------------------
|
|
547
|
+
-- 8. Grants
|
|
548
|
+
--
|
|
549
|
+
-- The rule of `supabase/migrations/README.md`: the schema closes behind itself
|
|
550
|
+
-- and then says, by name, who may reach what. `anon` gains exactly one
|
|
551
|
+
-- execute, on the function that is the public door, and nothing on the table.
|
|
552
|
+
-- ---------------------------------------------------------------------------
|
|
553
|
+
|
|
554
|
+
revoke execute on all functions in schema public from public;
|
|
555
|
+
|
|
556
|
+
grant select on table document_shares to authenticated, service_role;
|
|
557
|
+
|
|
558
|
+
revoke execute on function document_share_refusal(documents) from public, anon;
|
|
559
|
+
grant execute on function document_share_refusal(documents) to authenticated, service_role;
|
|
560
|
+
|
|
561
|
+
revoke execute on function share_document(uuid, timestamptz) from public, anon;
|
|
562
|
+
grant execute on function share_document(uuid, timestamptz) to authenticated, service_role;
|
|
563
|
+
|
|
564
|
+
revoke execute on function revoke_share(uuid) from public, anon;
|
|
565
|
+
grant execute on function revoke_share(uuid) to authenticated, service_role;
|
|
566
|
+
|
|
567
|
+
revoke execute on function shared_document(text) from public;
|
|
568
|
+
grant execute on function shared_document(text) to anon, authenticated, service_role;
|