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,188 @@
|
|
|
1
|
+
-- Ekwo OS — a guard that answers NULL is a guard that never fires.
|
|
2
|
+
--
|
|
3
|
+
-- `company_role()` returns NULL for somebody who is not a member of the
|
|
4
|
+
-- company, which is the honest answer to "which preset are they on". Two
|
|
5
|
+
-- helpers were built straight on top of it and inherited the NULL:
|
|
6
|
+
--
|
|
7
|
+
-- is_company_owner(c) = company_role(c) = 'owner'
|
|
8
|
+
-- can_write_company(c) = company_role(c) in ('owner', 'accountant')
|
|
9
|
+
--
|
|
10
|
+
-- Inside a policy that is harmless — `USING (NULL)` admits nothing. Inside a
|
|
11
|
+
-- procedural guard it is the opposite of harmless:
|
|
12
|
+
--
|
|
13
|
+
-- if not is_company_owner(p_company_id) then raise …
|
|
14
|
+
--
|
|
15
|
+
-- `not NULL` is NULL, `if NULL then` does not branch, and the stranger walks
|
|
16
|
+
-- past the exception into the body of a SECURITY DEFINER function. That is
|
|
17
|
+
-- exactly what `enable_module()` and `disable_module()` did, on a table —
|
|
18
|
+
-- `company_modules` — that has no write policy at all, so the function was
|
|
19
|
+
-- the only door and the door was open.
|
|
20
|
+
--
|
|
21
|
+
-- Two changes, because either alone would leave the trap in place for the
|
|
22
|
+
-- next function:
|
|
23
|
+
--
|
|
24
|
+
-- 1. the two helpers answer `false` where they answered NULL, so the shape
|
|
25
|
+
-- `if not <helper>(…)` is safe wherever somebody writes it next;
|
|
26
|
+
-- 2. the two module functions stop asking about a *role* and ask about the
|
|
27
|
+
-- capability the act needs — `company.write`, which the owner preset
|
|
28
|
+
-- holds and the accountant preset does not, so who is refused today is
|
|
29
|
+
-- exactly who was refused before the bug.
|
|
30
|
+
--
|
|
31
|
+
-- `has_capability()` has always coalesced to false, which is why every guard
|
|
32
|
+
-- written on it was sound. A test in `hardening.test.ts` now refuses any
|
|
33
|
+
-- function body that tests a helper which can answer NULL.
|
|
34
|
+
--
|
|
35
|
+
-- ---------------------------------------------------------------------------
|
|
36
|
+
-- The installer, named rather than inferred
|
|
37
|
+
--
|
|
38
|
+
-- A guard that asks for a capability has to let the installation itself
|
|
39
|
+
-- through: `ekwo migrate`, the seeds and `ekwo module enable` hold a database
|
|
40
|
+
-- connection and no session, so `auth.uid()` is null and `has_capability()`
|
|
41
|
+
-- says no to everything. Until now the schema wrote that exemption as
|
|
42
|
+
-- `auth.uid() is not null and not has_capability(…)`, which reads as "a
|
|
43
|
+
-- signed-in caller is checked" and means "an unsigned caller is not".
|
|
44
|
+
--
|
|
45
|
+
-- `is_installer()` states it instead, and states it narrowly: the runner sets
|
|
46
|
+
-- `ekwo.installing` on the connection it opened, nothing else sets it, and it
|
|
47
|
+
-- is refused outright when a machine key is presenting itself or when there is
|
|
48
|
+
-- a session. A caller that reaches the database through PostgREST cannot set
|
|
49
|
+
-- a GUC, and a caller holding a key has `ekwo.api_key` set by `use_api_key()`
|
|
50
|
+
-- for the length of its transaction. The next migration moves the whole
|
|
51
|
+
-- `auth.uid() is not null` family onto it.
|
|
52
|
+
-- ---------------------------------------------------------------------------
|
|
53
|
+
|
|
54
|
+
create or replace function is_installer()
|
|
55
|
+
returns boolean
|
|
56
|
+
language sql
|
|
57
|
+
stable
|
|
58
|
+
as $$
|
|
59
|
+
select auth.uid() is null
|
|
60
|
+
and nullif(current_setting('ekwo.api_key', true), '') is null
|
|
61
|
+
and current_setting('ekwo.installing', true) = 'on';
|
|
62
|
+
$$;
|
|
63
|
+
|
|
64
|
+
comment on function is_installer() is
|
|
65
|
+
'Whether the caller is the installation itself — the migration runner, the seeds, the CLI — rather than a person or a machine key. Set by the runner on its own connection; a session or a key can never be it.';
|
|
66
|
+
|
|
67
|
+
-- ---------------------------------------------------------------------------
|
|
68
|
+
-- The two helpers that could answer NULL
|
|
69
|
+
-- ---------------------------------------------------------------------------
|
|
70
|
+
|
|
71
|
+
create or replace function is_company_owner(p_company_id uuid)
|
|
72
|
+
returns boolean
|
|
73
|
+
language sql
|
|
74
|
+
stable
|
|
75
|
+
as $$
|
|
76
|
+
select coalesce(company_role(p_company_id) = 'owner', false);
|
|
77
|
+
$$;
|
|
78
|
+
|
|
79
|
+
comment on function is_company_owner(uuid) is
|
|
80
|
+
'Whether the current user is on the owner preset of a company. False, never NULL, for somebody who is not a member — a guard written as `if not is_company_owner(…)` has to fire for a stranger.';
|
|
81
|
+
|
|
82
|
+
create or replace function can_write_company(p_company_id uuid)
|
|
83
|
+
returns boolean
|
|
84
|
+
language sql
|
|
85
|
+
stable
|
|
86
|
+
as $$
|
|
87
|
+
select coalesce(has_capability(p_company_id, 'entries.write'), false);
|
|
88
|
+
$$;
|
|
89
|
+
|
|
90
|
+
comment on function can_write_company(uuid) is
|
|
91
|
+
'Whether the current caller may write the books of a company. One capability, not a role, and false rather than NULL for a stranger.';
|
|
92
|
+
|
|
93
|
+
-- ---------------------------------------------------------------------------
|
|
94
|
+
-- Enabling and disabling a module
|
|
95
|
+
--
|
|
96
|
+
-- Republished whole: the body is the one from 20260913074512 with the guard
|
|
97
|
+
-- rewritten. `company_modules` still has no write policy, so these two
|
|
98
|
+
-- functions are still the only way in.
|
|
99
|
+
-- ---------------------------------------------------------------------------
|
|
100
|
+
|
|
101
|
+
create or replace function enable_module(
|
|
102
|
+
p_company_id uuid,
|
|
103
|
+
p_code text,
|
|
104
|
+
p_settings jsonb default null
|
|
105
|
+
)
|
|
106
|
+
returns company_modules
|
|
107
|
+
language plpgsql
|
|
108
|
+
security definer
|
|
109
|
+
set search_path = public, pg_temp
|
|
110
|
+
as $$
|
|
111
|
+
declare
|
|
112
|
+
v_module modules%rowtype;
|
|
113
|
+
v_row company_modules%rowtype;
|
|
114
|
+
begin
|
|
115
|
+
if not is_installer() and not has_capability(p_company_id, 'company.write') then
|
|
116
|
+
raise exception 'not_allowed: enabling a module on this company needs company.write'
|
|
117
|
+
using errcode = '42501';
|
|
118
|
+
end if;
|
|
119
|
+
|
|
120
|
+
select * into v_module from modules where code = p_code;
|
|
121
|
+
if not found then
|
|
122
|
+
raise exception 'unknown_module: % is not installed on this instance; apply its migrations first', p_code;
|
|
123
|
+
end if;
|
|
124
|
+
|
|
125
|
+
if v_module.status = 'draft' and not module_is_enabled(p_company_id, p_code) then
|
|
126
|
+
raise exception 'module_not_available: % is still a draft on this installation', p_code;
|
|
127
|
+
end if;
|
|
128
|
+
if v_module.status = 'deprecated' and not module_is_enabled(p_company_id, p_code) then
|
|
129
|
+
raise exception 'module_deprecated: % is deprecated and takes no new company', p_code;
|
|
130
|
+
end if;
|
|
131
|
+
|
|
132
|
+
insert into company_modules (company_id, module_code, enabled_by, settings)
|
|
133
|
+
values (p_company_id, p_code, auth.uid(), coalesce(p_settings, '{}'::jsonb))
|
|
134
|
+
on conflict (company_id, module_code) do update
|
|
135
|
+
set settings = coalesce(p_settings, company_modules.settings)
|
|
136
|
+
returning * into v_row;
|
|
137
|
+
|
|
138
|
+
return v_row;
|
|
139
|
+
end;
|
|
140
|
+
$$;
|
|
141
|
+
|
|
142
|
+
comment on function enable_module(uuid, text, jsonb) is
|
|
143
|
+
'Enables a module on a company, and updates its settings when it is already enabled. Needs company.write, checked here because the table has no write policy.';
|
|
144
|
+
|
|
145
|
+
create or replace function disable_module(p_company_id uuid, p_code text)
|
|
146
|
+
returns void
|
|
147
|
+
language plpgsql
|
|
148
|
+
security definer
|
|
149
|
+
set search_path = public, pg_temp
|
|
150
|
+
as $$
|
|
151
|
+
declare
|
|
152
|
+
v_module modules%rowtype;
|
|
153
|
+
v_check regprocedure;
|
|
154
|
+
v_reason text;
|
|
155
|
+
begin
|
|
156
|
+
if not is_installer() and not has_capability(p_company_id, 'company.write') then
|
|
157
|
+
raise exception 'not_allowed: disabling a module on this company needs company.write'
|
|
158
|
+
using errcode = '42501';
|
|
159
|
+
end if;
|
|
160
|
+
|
|
161
|
+
select * into v_module from modules where code = p_code;
|
|
162
|
+
if not found then
|
|
163
|
+
raise exception 'unknown_module: % is not installed on this instance', p_code;
|
|
164
|
+
end if;
|
|
165
|
+
|
|
166
|
+
if not module_is_enabled(p_company_id, p_code) then
|
|
167
|
+
return;
|
|
168
|
+
end if;
|
|
169
|
+
|
|
170
|
+
v_check := to_regprocedure(format('%I.can_disable(uuid)', v_module.schema_name));
|
|
171
|
+
if v_check is not null then
|
|
172
|
+
execute format('select %I.can_disable($1)', v_module.schema_name)
|
|
173
|
+
into v_reason using p_company_id;
|
|
174
|
+
if v_reason is not null then
|
|
175
|
+
raise exception 'module_holds_data: % cannot be disabled on this company — %', p_code, v_reason
|
|
176
|
+
using errcode = '55006';
|
|
177
|
+
end if;
|
|
178
|
+
end if;
|
|
179
|
+
|
|
180
|
+
delete from company_modules
|
|
181
|
+
where company_id = p_company_id and module_code = p_code;
|
|
182
|
+
end;
|
|
183
|
+
$$;
|
|
184
|
+
|
|
185
|
+
comment on function disable_module(uuid, text) is
|
|
186
|
+
'Disables a module on a company, unless the module says it still holds data — `<schema>.can_disable(company)` returning a sentence refuses, returning null allows. Nothing the module wrote is deleted. Needs company.write.';
|
|
187
|
+
|
|
188
|
+
revoke execute on all functions in schema public from public;
|
|
@@ -0,0 +1,462 @@
|
|
|
1
|
+
-- Ekwo OS — "there is no session" was never a synonym for "this is the installer".
|
|
2
|
+
--
|
|
3
|
+
-- Eleven functions and triggers were written in this shape:
|
|
4
|
+
--
|
|
5
|
+
-- if auth.uid() is not null and not has_capability(…) then raise …
|
|
6
|
+
--
|
|
7
|
+
-- It reads as "a signed-in caller is checked". What it *means* is "a caller
|
|
8
|
+
-- with no session is not checked at all", and that was fine for exactly as
|
|
9
|
+
-- long as the only caller with no session was `ekwo migrate`.
|
|
10
|
+
--
|
|
11
|
+
-- Then `20260913085932` added machine keys. A key is deliberately not a
|
|
12
|
+
-- session: `use_api_key()` puts its fingerprint in `ekwo.api_key` and
|
|
13
|
+
-- `auth.uid()` stays null, which is the whole design — a key is answered for
|
|
14
|
+
-- by `has_capability()` and by nothing else. Except here. Every one of these
|
|
15
|
+
-- guards read the null and stood aside, so a key issued with
|
|
16
|
+
-- `["entries.read"]` could post an entry, book a document, close a financial
|
|
17
|
+
-- year, draw a number, match, invite a member, revoke somebody else's
|
|
18
|
+
-- invitation, create a company, take over a numbered book — and issue itself
|
|
19
|
+
-- a second key carrying every capability of the installation. The narrowest
|
|
20
|
+
-- caller in the schema was the widest.
|
|
21
|
+
--
|
|
22
|
+
-- The exemption is real and has to stay: the migration runner, the seeds and
|
|
23
|
+
-- the CLI hold a database connection and no session, and a guard that asks
|
|
24
|
+
-- for a capability would refuse the installation itself. So it is *named*
|
|
25
|
+
-- instead of inferred. `is_installer()` (20260913101536) is true only when
|
|
26
|
+
-- the runner set `ekwo.installing` on its own connection, and it is false
|
|
27
|
+
-- outright when there is a session or when a key is presenting itself. A
|
|
28
|
+
-- caller that reaches the database through PostgREST cannot set a GUC, and a
|
|
29
|
+
-- caller holding a key has `ekwo.api_key` set for the length of its
|
|
30
|
+
-- transaction — so neither can ever be the installer, whatever they do.
|
|
31
|
+
--
|
|
32
|
+
-- `has_capability()` is now the only authority over both people and machines,
|
|
33
|
+
-- which is what ST13 said it was.
|
|
34
|
+
--
|
|
35
|
+
-- `post_entry()` is the one member of the family that was *not* a hole, and
|
|
36
|
+
-- it changes for the other reason. Its test reads
|
|
37
|
+
-- `auth.uid() is not null and has_capability(…, 'entries.import')` as a
|
|
38
|
+
-- requirement rather than as an exemption, so a key was already refused — but
|
|
39
|
+
-- so was a key that legitimately held `entries.import`. It now asks for the
|
|
40
|
+
-- capability and nothing else, which admits the key and keeps the installer
|
|
41
|
+
-- bound by a rule that is a law and not a permission.
|
|
42
|
+
--
|
|
43
|
+
-- Every function below is republished whole, with its body unchanged but for
|
|
44
|
+
-- that one line. They come from `20260913083216` (the three triggers and the
|
|
45
|
+
-- two counters), `20260913085436` (the counter that reads the pack),
|
|
46
|
+
-- `20260913085932`, `20260913083901`, `20260913090216` and `20260913092527`.
|
|
47
|
+
|
|
48
|
+
create or replace function assert_may_post_entry()
|
|
49
|
+
returns trigger
|
|
50
|
+
language plpgsql
|
|
51
|
+
as $$
|
|
52
|
+
begin
|
|
53
|
+
if not is_installer() and not has_capability(new.company_id, 'entries.post') then
|
|
54
|
+
raise exception 'not_allowed: posting an entry in this company needs entries.post'
|
|
55
|
+
using errcode = '42501';
|
|
56
|
+
end if;
|
|
57
|
+
return new;
|
|
58
|
+
end;
|
|
59
|
+
$$;
|
|
60
|
+
|
|
61
|
+
create or replace function assert_may_post_document()
|
|
62
|
+
returns trigger
|
|
63
|
+
language plpgsql
|
|
64
|
+
as $$
|
|
65
|
+
begin
|
|
66
|
+
if not is_installer() and not has_capability(new.company_id, 'documents.post') then
|
|
67
|
+
raise exception 'not_allowed: booking a document in this company needs documents.post'
|
|
68
|
+
using errcode = '42501';
|
|
69
|
+
end if;
|
|
70
|
+
return new;
|
|
71
|
+
end;
|
|
72
|
+
$$;
|
|
73
|
+
|
|
74
|
+
create or replace function assert_may_close_year()
|
|
75
|
+
returns trigger
|
|
76
|
+
language plpgsql
|
|
77
|
+
as $$
|
|
78
|
+
begin
|
|
79
|
+
if not is_installer() and not has_capability(new.company_id, 'year_end.close') then
|
|
80
|
+
raise exception 'not_allowed: closing or re-opening a financial year needs year_end.close'
|
|
81
|
+
using errcode = '42501';
|
|
82
|
+
end if;
|
|
83
|
+
return new;
|
|
84
|
+
end;
|
|
85
|
+
$$;
|
|
86
|
+
|
|
87
|
+
create or replace function next_matching_number(p_company_id uuid)
|
|
88
|
+
returns text
|
|
89
|
+
language plpgsql
|
|
90
|
+
security definer
|
|
91
|
+
set search_path = public, pg_temp
|
|
92
|
+
as $$
|
|
93
|
+
declare
|
|
94
|
+
v_number integer;
|
|
95
|
+
begin
|
|
96
|
+
if not is_installer() and not has_capability(p_company_id, 'reconcile.write') then
|
|
97
|
+
raise exception 'not_allowed: matching in this company needs reconcile.write'
|
|
98
|
+
using errcode = '42501';
|
|
99
|
+
end if;
|
|
100
|
+
|
|
101
|
+
insert into matching_sequences (company_id, last_number)
|
|
102
|
+
values (p_company_id, 1)
|
|
103
|
+
on conflict (company_id)
|
|
104
|
+
do update set last_number = matching_sequences.last_number + 1
|
|
105
|
+
returning last_number into v_number;
|
|
106
|
+
|
|
107
|
+
return 'A' || lpad(v_number::text, 4, '0');
|
|
108
|
+
end;
|
|
109
|
+
$$;
|
|
110
|
+
|
|
111
|
+
create or replace function next_entry_number(p_journal_id uuid, p_date date)
|
|
112
|
+
returns text
|
|
113
|
+
language plpgsql
|
|
114
|
+
security definer
|
|
115
|
+
set search_path = public, pg_temp
|
|
116
|
+
as $$
|
|
117
|
+
declare
|
|
118
|
+
v_code text;
|
|
119
|
+
v_company uuid;
|
|
120
|
+
v_format text;
|
|
121
|
+
v_period smallint;
|
|
122
|
+
v_number integer;
|
|
123
|
+
begin
|
|
124
|
+
select j.code, j.company_id into v_code, v_company from journals j where j.id = p_journal_id;
|
|
125
|
+
if v_code is null then
|
|
126
|
+
raise exception 'unknown_journal: journal % does not exist', p_journal_id;
|
|
127
|
+
end if;
|
|
128
|
+
|
|
129
|
+
if not is_installer() and not has_capability(v_company, 'entries.post') then
|
|
130
|
+
raise exception 'not_allowed: drawing a number in this company needs entries.post'
|
|
131
|
+
using errcode = '42501';
|
|
132
|
+
end if;
|
|
133
|
+
|
|
134
|
+
select number_format into v_format from numbering_rules(v_company);
|
|
135
|
+
if v_format is null or v_format = '' then
|
|
136
|
+
raise exception 'no_number_format: the country pack of this company declares no documents.number_format, and there is no default to fall back on';
|
|
137
|
+
end if;
|
|
138
|
+
|
|
139
|
+
-- A pattern that carries the year restarts with it; one that does not is a
|
|
140
|
+
-- single series, kept under the period that is not a year.
|
|
141
|
+
v_period := case when v_format ~ '\{(YYYY|YY)\}'
|
|
142
|
+
then extract(year from p_date)::smallint
|
|
143
|
+
else 0::smallint end;
|
|
144
|
+
|
|
145
|
+
insert into journal_sequences (journal_id, year, last_number)
|
|
146
|
+
values (p_journal_id, v_period, 1)
|
|
147
|
+
on conflict (journal_id, year)
|
|
148
|
+
do update set last_number = journal_sequences.last_number + 1
|
|
149
|
+
returning last_number into v_number;
|
|
150
|
+
|
|
151
|
+
return format_number(v_format, v_code, p_date, v_number);
|
|
152
|
+
end;
|
|
153
|
+
$$;
|
|
154
|
+
|
|
155
|
+
create or replace function create_api_key(
|
|
156
|
+
p_company_id uuid,
|
|
157
|
+
p_name text,
|
|
158
|
+
p_capabilities jsonb,
|
|
159
|
+
p_expires_at timestamptz default null
|
|
160
|
+
)
|
|
161
|
+
returns table (api_key_id uuid, secret text, prefix text, expires_at timestamptz)
|
|
162
|
+
language plpgsql
|
|
163
|
+
security definer
|
|
164
|
+
set search_path = public, pg_temp
|
|
165
|
+
as $$
|
|
166
|
+
declare
|
|
167
|
+
v_caps text[] := coalesce(
|
|
168
|
+
(select array_agg(value) from jsonb_array_elements_text(coalesce(p_capabilities, '[]'::jsonb)) as value),
|
|
169
|
+
'{}');
|
|
170
|
+
v_prefix text := left(replace(gen_random_uuid()::text, '-', ''), 12);
|
|
171
|
+
v_secret text;
|
|
172
|
+
v_cap text;
|
|
173
|
+
v_row api_keys%rowtype;
|
|
174
|
+
begin
|
|
175
|
+
if not is_installer() and not has_capability(p_company_id, 'members.manage') then
|
|
176
|
+
raise exception 'not_allowed: issuing a key for this company needs members.manage'
|
|
177
|
+
using errcode = '42501';
|
|
178
|
+
end if;
|
|
179
|
+
|
|
180
|
+
if cardinality(v_caps) = 0 then
|
|
181
|
+
raise exception 'api_key_without_capability: name what this key may do; a key that may do nothing is a secret to look after for no reason';
|
|
182
|
+
end if;
|
|
183
|
+
|
|
184
|
+
foreach v_cap in array v_caps loop
|
|
185
|
+
if v_cap not in (select code from capabilities) then
|
|
186
|
+
raise exception 'unknown_capability: % is not a capability of this installation', v_cap;
|
|
187
|
+
end if;
|
|
188
|
+
-- Nobody mints a key stronger than they are. An owner issuing a key is
|
|
189
|
+
-- bounded by their own capabilities, which is also what makes revoking a
|
|
190
|
+
-- person's capability revoke the keys they left behind.
|
|
191
|
+
if not is_installer() and not has_capability(p_company_id, v_cap) then
|
|
192
|
+
raise exception 'not_allowed: you do not hold % yourself, so you cannot put it on a key', v_cap
|
|
193
|
+
using errcode = '42501';
|
|
194
|
+
end if;
|
|
195
|
+
end loop;
|
|
196
|
+
|
|
197
|
+
v_secret := 'ekwo_' || v_prefix || '_' ||
|
|
198
|
+
replace(gen_random_uuid()::text, '-', '') ||
|
|
199
|
+
replace(gen_random_uuid()::text, '-', '');
|
|
200
|
+
|
|
201
|
+
insert into api_keys (company_id, name, prefix, key_hash, capabilities, created_by, expires_at)
|
|
202
|
+
values (p_company_id, p_name, v_prefix,
|
|
203
|
+
encode(sha256(convert_to(v_secret, 'UTF8')), 'hex'),
|
|
204
|
+
v_caps, auth.uid(), p_expires_at)
|
|
205
|
+
returning * into v_row;
|
|
206
|
+
|
|
207
|
+
return query select v_row.id, v_secret, v_row.prefix, v_row.expires_at;
|
|
208
|
+
end;
|
|
209
|
+
$$;
|
|
210
|
+
|
|
211
|
+
create or replace function revoke_api_key(p_api_key_id uuid)
|
|
212
|
+
returns api_keys
|
|
213
|
+
language plpgsql
|
|
214
|
+
security definer
|
|
215
|
+
set search_path = public, pg_temp
|
|
216
|
+
as $$
|
|
217
|
+
declare
|
|
218
|
+
v_row api_keys%rowtype;
|
|
219
|
+
begin
|
|
220
|
+
select * into v_row from api_keys where id = p_api_key_id;
|
|
221
|
+
if v_row.id is null then
|
|
222
|
+
raise exception 'unknown_api_key: no key with that id';
|
|
223
|
+
end if;
|
|
224
|
+
if not is_installer() and not has_capability(v_row.company_id, 'members.manage') then
|
|
225
|
+
raise exception 'not_allowed: withdrawing a key needs members.manage'
|
|
226
|
+
using errcode = '42501';
|
|
227
|
+
end if;
|
|
228
|
+
|
|
229
|
+
update api_keys set revoked_at = coalesce(revoked_at, now())
|
|
230
|
+
where id = p_api_key_id
|
|
231
|
+
returning * into v_row;
|
|
232
|
+
|
|
233
|
+
return v_row;
|
|
234
|
+
end;
|
|
235
|
+
$$;
|
|
236
|
+
|
|
237
|
+
create or replace function invite_member(
|
|
238
|
+
p_company_id uuid,
|
|
239
|
+
p_email text,
|
|
240
|
+
p_role member_role default 'viewer',
|
|
241
|
+
-- A list crosses as JSON, the way `opening_balance` takes its lines: over
|
|
242
|
+
-- PostgREST an argument is a JSON body, and a Postgres driver handed a
|
|
243
|
+
-- JavaScript array builds an array literal instead. One spelling that both
|
|
244
|
+
-- routes read the same way.
|
|
245
|
+
p_capabilities jsonb default '[]'::jsonb,
|
|
246
|
+
p_valid_for interval default interval '14 days'
|
|
247
|
+
)
|
|
248
|
+
returns table (invitation_id uuid, token text, expires_at timestamptz)
|
|
249
|
+
language plpgsql
|
|
250
|
+
security definer
|
|
251
|
+
set search_path = public, pg_temp
|
|
252
|
+
as $$
|
|
253
|
+
declare
|
|
254
|
+
v_email text := lower(trim(p_email));
|
|
255
|
+
v_token text;
|
|
256
|
+
v_unknown text;
|
|
257
|
+
v_caps text[] := coalesce(
|
|
258
|
+
(select array_agg(value) from jsonb_array_elements_text(coalesce(p_capabilities, '[]'::jsonb)) as value),
|
|
259
|
+
'{}');
|
|
260
|
+
v_row company_invitations%rowtype;
|
|
261
|
+
begin
|
|
262
|
+
if not is_installer() and not has_capability(p_company_id, 'members.manage')
|
|
263
|
+
and not is_instance_admin() then
|
|
264
|
+
raise exception 'not_allowed: inviting somebody into this company needs members.manage'
|
|
265
|
+
using errcode = '42501';
|
|
266
|
+
end if;
|
|
267
|
+
|
|
268
|
+
if v_email is null or v_email not like '%_@_%' then
|
|
269
|
+
raise exception 'bad_email: % is not an address an invitation can be sent to', p_email;
|
|
270
|
+
end if;
|
|
271
|
+
|
|
272
|
+
select c into v_unknown
|
|
273
|
+
from unnest(v_caps) as c
|
|
274
|
+
where c not in (select code from capabilities)
|
|
275
|
+
limit 1;
|
|
276
|
+
if v_unknown is not null then
|
|
277
|
+
raise exception 'unknown_capability: % is not a capability of this installation', v_unknown;
|
|
278
|
+
end if;
|
|
279
|
+
|
|
280
|
+
-- Re-inviting the same address replaces the pending invitation rather than
|
|
281
|
+
-- leaving two tokens alive for one seat.
|
|
282
|
+
update company_invitations
|
|
283
|
+
set revoked_at = now()
|
|
284
|
+
where company_id = p_company_id
|
|
285
|
+
and email = v_email
|
|
286
|
+
and accepted_at is null
|
|
287
|
+
and revoked_at is null;
|
|
288
|
+
|
|
289
|
+
v_token := replace(gen_random_uuid()::text, '-', '') ||
|
|
290
|
+
replace(gen_random_uuid()::text, '-', '');
|
|
291
|
+
|
|
292
|
+
insert into company_invitations (company_id, email, role, capabilities_granted,
|
|
293
|
+
token_hash, invited_by, expires_at)
|
|
294
|
+
values (p_company_id, v_email, p_role, v_caps,
|
|
295
|
+
encode(sha256(convert_to(v_token, 'UTF8')), 'hex'), auth.uid(), now() + p_valid_for)
|
|
296
|
+
returning * into v_row;
|
|
297
|
+
|
|
298
|
+
return query select v_row.id, v_token, v_row.expires_at;
|
|
299
|
+
end;
|
|
300
|
+
$$;
|
|
301
|
+
|
|
302
|
+
create or replace function revoke_invitation(p_invitation_id uuid)
|
|
303
|
+
returns company_invitations
|
|
304
|
+
language plpgsql
|
|
305
|
+
security definer
|
|
306
|
+
set search_path = public, pg_temp
|
|
307
|
+
as $$
|
|
308
|
+
declare
|
|
309
|
+
v_row company_invitations%rowtype;
|
|
310
|
+
begin
|
|
311
|
+
select * into v_row from company_invitations where id = p_invitation_id;
|
|
312
|
+
if v_row.id is null then
|
|
313
|
+
raise exception 'unknown_invitation: no invitation with that id';
|
|
314
|
+
end if;
|
|
315
|
+
|
|
316
|
+
if not is_installer() and not has_capability(v_row.company_id, 'members.manage')
|
|
317
|
+
and not is_instance_admin() then
|
|
318
|
+
raise exception 'not_allowed: withdrawing an invitation needs members.manage'
|
|
319
|
+
using errcode = '42501';
|
|
320
|
+
end if;
|
|
321
|
+
|
|
322
|
+
if v_row.accepted_at is not null then
|
|
323
|
+
raise exception 'invitation_already_accepted: it became a membership on %; remove the member instead',
|
|
324
|
+
v_row.accepted_at;
|
|
325
|
+
end if;
|
|
326
|
+
|
|
327
|
+
update company_invitations set revoked_at = coalesce(revoked_at, now())
|
|
328
|
+
where id = p_invitation_id
|
|
329
|
+
returning * into v_row;
|
|
330
|
+
|
|
331
|
+
return v_row;
|
|
332
|
+
end;
|
|
333
|
+
$$;
|
|
334
|
+
|
|
335
|
+
create or replace function create_company(
|
|
336
|
+
p_name text,
|
|
337
|
+
p_country char(2),
|
|
338
|
+
p_currency_code char(3) default null,
|
|
339
|
+
p_language char(2) default null,
|
|
340
|
+
p_chart_code text default null,
|
|
341
|
+
p_fiscal_year integer default null,
|
|
342
|
+
p_fiscal_year_start date default null,
|
|
343
|
+
p_owner_user_id uuid default null
|
|
344
|
+
)
|
|
345
|
+
returns companies
|
|
346
|
+
language plpgsql
|
|
347
|
+
as $$
|
|
348
|
+
declare
|
|
349
|
+
v_defaults country_defaults%rowtype;
|
|
350
|
+
v_currency char(3);
|
|
351
|
+
v_language char(2);
|
|
352
|
+
v_owner uuid := coalesce(p_owner_user_id, auth.uid());
|
|
353
|
+
v_year integer := coalesce(p_fiscal_year, extract(year from coalesce(p_fiscal_year_start, current_date))::integer);
|
|
354
|
+
v_bounds record;
|
|
355
|
+
v_company companies%rowtype;
|
|
356
|
+
begin
|
|
357
|
+
if not is_installer() and not is_instance_admin() then
|
|
358
|
+
raise exception 'not_instance_admin: creating a company is an instance-level act'
|
|
359
|
+
using errcode = '42501';
|
|
360
|
+
end if;
|
|
361
|
+
|
|
362
|
+
select * into v_defaults from country_defaults where country = p_country;
|
|
363
|
+
|
|
364
|
+
-- The currency and the language have to be settled before the insert: both
|
|
365
|
+
-- columns are not null with a default, so there is no later moment at which
|
|
366
|
+
-- they are empty and the pack could fill them. The pack answers, or the
|
|
367
|
+
-- caller does, and there is no third answer written here.
|
|
368
|
+
v_currency := upper(coalesce(p_currency_code, v_defaults.currency_code));
|
|
369
|
+
if v_currency is null then
|
|
370
|
+
raise exception 'no_currency: the % pack names no currency; name one', p_country;
|
|
371
|
+
end if;
|
|
372
|
+
v_language := lower(coalesce(p_language, v_defaults.language_default));
|
|
373
|
+
if v_language is null then
|
|
374
|
+
raise exception 'no_language: the % pack names no language for its labels; name one', p_country;
|
|
375
|
+
end if;
|
|
376
|
+
|
|
377
|
+
select * into v_bounds from fiscal_year_bounds(p_country, v_year, p_fiscal_year_start);
|
|
378
|
+
|
|
379
|
+
insert into companies (name, country, fiscal_country, currency_code, language)
|
|
380
|
+
values (p_name, p_country, p_country, v_currency, v_language)
|
|
381
|
+
returning * into v_company;
|
|
382
|
+
|
|
383
|
+
if v_owner is not null then
|
|
384
|
+
insert into company_members (company_id, user_id, role)
|
|
385
|
+
values (v_company.id, v_owner, 'owner')
|
|
386
|
+
on conflict (company_id, user_id) do nothing;
|
|
387
|
+
end if;
|
|
388
|
+
|
|
389
|
+
perform install_country_template(v_company.id, p_country, v_language, p_chart_code);
|
|
390
|
+
|
|
391
|
+
insert into fiscal_years (company_id, name, start_date, end_date)
|
|
392
|
+
values (v_company.id, 'FY' || v_year::text, v_bounds.start_date, v_bounds.end_date);
|
|
393
|
+
|
|
394
|
+
select * into v_company from companies where id = v_company.id;
|
|
395
|
+
return v_company;
|
|
396
|
+
end;
|
|
397
|
+
$$;
|
|
398
|
+
|
|
399
|
+
create or replace function post_entry(p_entry_id uuid)
|
|
400
|
+
returns entries
|
|
401
|
+
language plpgsql
|
|
402
|
+
as $$
|
|
403
|
+
declare
|
|
404
|
+
v_entry entries%rowtype;
|
|
405
|
+
v_lines integer;
|
|
406
|
+
v_gapless boolean;
|
|
407
|
+
begin
|
|
408
|
+
select * into v_entry from entries where id = p_entry_id for update;
|
|
409
|
+
if not found then
|
|
410
|
+
raise exception 'unknown_entry: entry % does not exist', p_entry_id;
|
|
411
|
+
end if;
|
|
412
|
+
if v_entry.state = 'posted' then
|
|
413
|
+
return v_entry;
|
|
414
|
+
end if;
|
|
415
|
+
if v_entry.state = 'cancelled' then
|
|
416
|
+
raise exception 'entry_cancelled: entry % cannot be posted', p_entry_id;
|
|
417
|
+
end if;
|
|
418
|
+
|
|
419
|
+
select count(*) into v_lines from entry_lines where entry_id = p_entry_id;
|
|
420
|
+
if v_lines = 0 then
|
|
421
|
+
raise exception 'entry_empty: entry % has no lines', p_entry_id;
|
|
422
|
+
end if;
|
|
423
|
+
|
|
424
|
+
-- Re-read the totals maintained by the line trigger.
|
|
425
|
+
select * into v_entry from entries where id = p_entry_id;
|
|
426
|
+
if v_entry.total_debit <> v_entry.total_credit then
|
|
427
|
+
raise exception 'entry_unbalanced: entry % has debit % and credit %',
|
|
428
|
+
p_entry_id, v_entry.total_debit, v_entry.total_credit;
|
|
429
|
+
end if;
|
|
430
|
+
|
|
431
|
+
-- Where the law forbids a hole, the number comes from the counter and from
|
|
432
|
+
-- nowhere else — unless the caller holds `entries.import`, which is how a
|
|
433
|
+
-- set of books that already has numbers is taken over. A duplicate is
|
|
434
|
+
-- refused either way, by `entries_company_number_idx`.
|
|
435
|
+
if v_entry.number is not null then
|
|
436
|
+
select numbering_gapless into v_gapless from numbering_rules(v_entry.company_id);
|
|
437
|
+
if v_gapless and not has_capability(v_entry.company_id, 'entries.import') then
|
|
438
|
+
raise exception 'numbering_gapless: this country forbids a hole in the sequence, so entry % may not be posted under a number chosen by hand. Leave entries.number empty and the journal counter draws it, or hold entries.import to bring in books that already have numbers',
|
|
439
|
+
p_entry_id;
|
|
440
|
+
end if;
|
|
441
|
+
end if;
|
|
442
|
+
|
|
443
|
+
perform assert_period_open(v_entry.company_id, v_entry.entry_date, true);
|
|
444
|
+
|
|
445
|
+
update entries
|
|
446
|
+
set number = coalesce(number, next_entry_number(journal_id, entry_date)),
|
|
447
|
+
fiscal_year_id = coalesce(fiscal_year_id, fiscal_year_at(company_id, entry_date)),
|
|
448
|
+
state = 'posted',
|
|
449
|
+
posted_at = now()
|
|
450
|
+
where id = p_entry_id
|
|
451
|
+
returning * into v_entry;
|
|
452
|
+
|
|
453
|
+
-- An imported number advances the counter it interrupted, so the next
|
|
454
|
+
-- automatic one continues the series. A number drawn from the counter is
|
|
455
|
+
-- already at it and this is a no-op.
|
|
456
|
+
perform catch_up_journal_sequence(v_entry.journal_id, v_entry.entry_date, v_entry.number);
|
|
457
|
+
|
|
458
|
+
return v_entry;
|
|
459
|
+
end;
|
|
460
|
+
$$;
|
|
461
|
+
|
|
462
|
+
revoke execute on all functions in schema public from public;
|