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,303 @@
|
|
|
1
|
+
-- Ekwo OS — a key for a machine, and what it is deliberately not.
|
|
2
|
+
--
|
|
3
|
+
-- Everything in this schema answers to `auth.uid()`, which comes from a JWT
|
|
4
|
+
-- the customer's own GoTrue issued. That is right for a person and impossible
|
|
5
|
+
-- for a script: a nightly import, a till, a bank feed has no browser to sign
|
|
6
|
+
-- in with, and the two usual answers are both wrong. Handing it the
|
|
7
|
+
-- `service_role` key gives it every company of the installation and every
|
|
8
|
+
-- table in it. Creating a fake user for it puts a password in a crontab and
|
|
9
|
+
-- makes the audit trail say a person did it.
|
|
10
|
+
--
|
|
11
|
+
-- So a key is a **third kind of caller**, and it is narrower than both by
|
|
12
|
+
-- construction:
|
|
13
|
+
--
|
|
14
|
+
-- * it belongs to one company and can never reach another;
|
|
15
|
+
-- * it carries an explicit list of capabilities, which the person who
|
|
16
|
+
-- issued it must hold themselves — nobody mints a key stronger than they
|
|
17
|
+
-- are;
|
|
18
|
+
-- * it expires when it is told to, and it is withdrawn by one update;
|
|
19
|
+
-- * it is stored as a sha256 and shown once, like an invitation token.
|
|
20
|
+
--
|
|
21
|
+
-- **How a key authenticates, and why it is this way.** A holder calls
|
|
22
|
+
-- `use_api_key(secret)` at the start of a transaction. That function checks
|
|
23
|
+
-- the hash, refuses a withdrawn or expired key, records the use, and puts the
|
|
24
|
+
-- key's fingerprint in `ekwo.api_key` — transaction-locally, so it is gone
|
|
25
|
+
-- when the transaction ends. `has_capability()` then answers for the key as
|
|
26
|
+
-- well as for the user, and every policy in the schema follows without being
|
|
27
|
+
-- rewritten.
|
|
28
|
+
--
|
|
29
|
+
-- The alternative considered was exchanging a key for a GoTrue session. It
|
|
30
|
+
-- needs GoTrue to know about our keys, which means either a fake user per key
|
|
31
|
+
-- — the thing this is here to avoid — or a service that mints tokens, which
|
|
32
|
+
-- is a second secret to hold. The setting is simpler and its blast radius is
|
|
33
|
+
-- one transaction.
|
|
34
|
+
--
|
|
35
|
+
-- **What it cannot do, stated rather than discovered.** A key is not a
|
|
36
|
+
-- session. `auth.uid()` stays null, so the policies that ask for a signed-in
|
|
37
|
+
-- user rather than for a capability — the reference tables, the company row
|
|
38
|
+
-- itself — stay closed to it. And because PostgREST runs each request in its
|
|
39
|
+
-- own transaction, `use_api_key()` cannot be a separate request over that
|
|
40
|
+
-- route: a key is for a client that holds a connection and opens a
|
|
41
|
+
-- transaction, which is the self-hosted route the MCP server already has.
|
|
42
|
+
--
|
|
43
|
+
-- **The setting cannot be forged into a privilege.** What goes into
|
|
44
|
+
-- `ekwo.api_key` is the hash, not the id, and the hash is only readable by
|
|
45
|
+
-- somebody who already holds `members.manage` on that company — who can
|
|
46
|
+
-- simply issue a key. There is nothing to escalate to.
|
|
47
|
+
|
|
48
|
+
create table api_keys (
|
|
49
|
+
id uuid primary key default gen_random_uuid(),
|
|
50
|
+
company_id uuid not null references companies(id) on delete cascade,
|
|
51
|
+
name text not null,
|
|
52
|
+
-- The visible half, so a list of keys can be read by a human and a leaked
|
|
53
|
+
-- secret can be matched to the row it belongs to.
|
|
54
|
+
prefix text not null,
|
|
55
|
+
key_hash text not null unique,
|
|
56
|
+
capabilities text[] not null,
|
|
57
|
+
created_by uuid,
|
|
58
|
+
created_at timestamptz not null default now(),
|
|
59
|
+
expires_at timestamptz,
|
|
60
|
+
last_used_at timestamptz,
|
|
61
|
+
revoked_at timestamptz,
|
|
62
|
+
constraint api_keys_has_capability check (cardinality(capabilities) > 0),
|
|
63
|
+
constraint api_keys_expiry check (expires_at is null or expires_at > created_at)
|
|
64
|
+
);
|
|
65
|
+
|
|
66
|
+
comment on table api_keys is
|
|
67
|
+
'Machine access to one company. Hashed at rest, scoped to an explicit list of capabilities, and never wider than the person who issued it.';
|
|
68
|
+
comment on column api_keys.prefix is
|
|
69
|
+
'The readable head of the secret. It identifies a key without being one.';
|
|
70
|
+
comment on column api_keys.capabilities is
|
|
71
|
+
'Exactly what this key may do. Not a role: a machine has a job, not a job title.';
|
|
72
|
+
comment on column api_keys.created_by is
|
|
73
|
+
'auth.users.id of whoever issued it. No foreign key, for the same reason company_members has none.';
|
|
74
|
+
|
|
75
|
+
create index api_keys_company_idx on api_keys (company_id, created_at desc);
|
|
76
|
+
create index api_keys_prefix_idx on api_keys (prefix);
|
|
77
|
+
|
|
78
|
+
-- ---------------------------------------------------------------------------
|
|
79
|
+
-- Issuing one
|
|
80
|
+
-- ---------------------------------------------------------------------------
|
|
81
|
+
|
|
82
|
+
create or replace function create_api_key(
|
|
83
|
+
p_company_id uuid,
|
|
84
|
+
p_name text,
|
|
85
|
+
p_capabilities jsonb,
|
|
86
|
+
p_expires_at timestamptz default null
|
|
87
|
+
)
|
|
88
|
+
returns table (api_key_id uuid, secret text, prefix text, expires_at timestamptz)
|
|
89
|
+
language plpgsql
|
|
90
|
+
security definer
|
|
91
|
+
set search_path = public, pg_temp
|
|
92
|
+
as $$
|
|
93
|
+
declare
|
|
94
|
+
v_caps text[] := coalesce(
|
|
95
|
+
(select array_agg(value) from jsonb_array_elements_text(coalesce(p_capabilities, '[]'::jsonb)) as value),
|
|
96
|
+
'{}');
|
|
97
|
+
v_prefix text := left(replace(gen_random_uuid()::text, '-', ''), 12);
|
|
98
|
+
v_secret text;
|
|
99
|
+
v_cap text;
|
|
100
|
+
v_row api_keys%rowtype;
|
|
101
|
+
begin
|
|
102
|
+
if auth.uid() is not null and not has_capability(p_company_id, 'members.manage') then
|
|
103
|
+
raise exception 'not_allowed: issuing a key for this company needs members.manage'
|
|
104
|
+
using errcode = '42501';
|
|
105
|
+
end if;
|
|
106
|
+
|
|
107
|
+
if cardinality(v_caps) = 0 then
|
|
108
|
+
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';
|
|
109
|
+
end if;
|
|
110
|
+
|
|
111
|
+
foreach v_cap in array v_caps loop
|
|
112
|
+
if v_cap not in (select code from capabilities) then
|
|
113
|
+
raise exception 'unknown_capability: % is not a capability of this installation', v_cap;
|
|
114
|
+
end if;
|
|
115
|
+
-- Nobody mints a key stronger than they are. An owner issuing a key is
|
|
116
|
+
-- bounded by their own capabilities, which is also what makes revoking a
|
|
117
|
+
-- person's capability revoke the keys they left behind.
|
|
118
|
+
if auth.uid() is not null and not has_capability(p_company_id, v_cap) then
|
|
119
|
+
raise exception 'not_allowed: you do not hold % yourself, so you cannot put it on a key', v_cap
|
|
120
|
+
using errcode = '42501';
|
|
121
|
+
end if;
|
|
122
|
+
end loop;
|
|
123
|
+
|
|
124
|
+
v_secret := 'ekwo_' || v_prefix || '_' ||
|
|
125
|
+
replace(gen_random_uuid()::text, '-', '') ||
|
|
126
|
+
replace(gen_random_uuid()::text, '-', '');
|
|
127
|
+
|
|
128
|
+
insert into api_keys (company_id, name, prefix, key_hash, capabilities, created_by, expires_at)
|
|
129
|
+
values (p_company_id, p_name, v_prefix,
|
|
130
|
+
encode(sha256(convert_to(v_secret, 'UTF8')), 'hex'),
|
|
131
|
+
v_caps, auth.uid(), p_expires_at)
|
|
132
|
+
returning * into v_row;
|
|
133
|
+
|
|
134
|
+
return query select v_row.id, v_secret, v_row.prefix, v_row.expires_at;
|
|
135
|
+
end;
|
|
136
|
+
$$;
|
|
137
|
+
|
|
138
|
+
comment on function create_api_key(uuid, text, jsonb, timestamptz) is
|
|
139
|
+
'Issues a machine key on one company and returns the secret once. Only the hash is stored, and no capability can be put on a key that the person issuing it does not hold.';
|
|
140
|
+
|
|
141
|
+
-- ---------------------------------------------------------------------------
|
|
142
|
+
-- Recording that a key was used
|
|
143
|
+
--
|
|
144
|
+
-- Its own function because it is its own act: `use_api_key()` calls it, and a
|
|
145
|
+
-- caller that wants to record a use it made through another path can too.
|
|
146
|
+
-- Definer and unconditional — a key that is in use records it whether or not
|
|
147
|
+
-- the call it is about to make succeeds.
|
|
148
|
+
-- ---------------------------------------------------------------------------
|
|
149
|
+
|
|
150
|
+
create or replace function touch_api_key(p_api_key_id uuid)
|
|
151
|
+
returns void
|
|
152
|
+
language sql
|
|
153
|
+
security definer
|
|
154
|
+
set search_path = public, pg_temp
|
|
155
|
+
as $$
|
|
156
|
+
update api_keys set last_used_at = now() where id = p_api_key_id;
|
|
157
|
+
$$;
|
|
158
|
+
|
|
159
|
+
comment on function touch_api_key(uuid) is
|
|
160
|
+
'Records that a key was used just now. A key that has never been used, and one that has not been used for a year, are both things an operator should be able to see.';
|
|
161
|
+
|
|
162
|
+
-- ---------------------------------------------------------------------------
|
|
163
|
+
-- Presenting one
|
|
164
|
+
-- ---------------------------------------------------------------------------
|
|
165
|
+
|
|
166
|
+
create or replace function use_api_key(p_secret text)
|
|
167
|
+
returns api_keys
|
|
168
|
+
language plpgsql
|
|
169
|
+
security definer
|
|
170
|
+
set search_path = public, pg_temp
|
|
171
|
+
as $$
|
|
172
|
+
declare
|
|
173
|
+
v_row api_keys%rowtype;
|
|
174
|
+
begin
|
|
175
|
+
select * into v_row
|
|
176
|
+
from api_keys
|
|
177
|
+
where key_hash = encode(sha256(convert_to(coalesce(p_secret, ''), 'UTF8')), 'hex');
|
|
178
|
+
|
|
179
|
+
if v_row.id is null then
|
|
180
|
+
raise exception 'unknown_api_key: that is not a key of this installation'
|
|
181
|
+
using errcode = '42501';
|
|
182
|
+
end if;
|
|
183
|
+
if v_row.revoked_at is not null then
|
|
184
|
+
raise exception 'api_key_revoked: that key was withdrawn on %', v_row.revoked_at
|
|
185
|
+
using errcode = '42501';
|
|
186
|
+
end if;
|
|
187
|
+
if v_row.expires_at is not null and v_row.expires_at <= now() then
|
|
188
|
+
raise exception 'api_key_expired: that key expired on %', v_row.expires_at
|
|
189
|
+
using errcode = '42501';
|
|
190
|
+
end if;
|
|
191
|
+
|
|
192
|
+
-- Transaction-local: the key is presented for the work at hand and not for
|
|
193
|
+
-- the life of a connection somebody else may inherit from a pool.
|
|
194
|
+
perform set_config('ekwo.api_key', v_row.key_hash, true);
|
|
195
|
+
perform touch_api_key(v_row.id);
|
|
196
|
+
|
|
197
|
+
return v_row;
|
|
198
|
+
end;
|
|
199
|
+
$$;
|
|
200
|
+
|
|
201
|
+
comment on function use_api_key(text) is
|
|
202
|
+
'Presents a machine key for the current transaction: has_capability() answers for it until the transaction ends. Refuses a key that is unknown, withdrawn or expired.';
|
|
203
|
+
|
|
204
|
+
create or replace function current_api_key()
|
|
205
|
+
returns api_keys
|
|
206
|
+
language sql
|
|
207
|
+
stable
|
|
208
|
+
security definer
|
|
209
|
+
set search_path = public, pg_temp
|
|
210
|
+
as $$
|
|
211
|
+
select k.* from api_keys k
|
|
212
|
+
where k.key_hash = nullif(current_setting('ekwo.api_key', true), '')
|
|
213
|
+
and k.revoked_at is null
|
|
214
|
+
and (k.expires_at is null or k.expires_at > now());
|
|
215
|
+
$$;
|
|
216
|
+
|
|
217
|
+
comment on function current_api_key() is
|
|
218
|
+
'The key presented in this transaction, or nothing. What a client reads back to know what it may do.';
|
|
219
|
+
|
|
220
|
+
create or replace function revoke_api_key(p_api_key_id uuid)
|
|
221
|
+
returns api_keys
|
|
222
|
+
language plpgsql
|
|
223
|
+
security definer
|
|
224
|
+
set search_path = public, pg_temp
|
|
225
|
+
as $$
|
|
226
|
+
declare
|
|
227
|
+
v_row api_keys%rowtype;
|
|
228
|
+
begin
|
|
229
|
+
select * into v_row from api_keys where id = p_api_key_id;
|
|
230
|
+
if v_row.id is null then
|
|
231
|
+
raise exception 'unknown_api_key: no key with that id';
|
|
232
|
+
end if;
|
|
233
|
+
if auth.uid() is not null and not has_capability(v_row.company_id, 'members.manage') then
|
|
234
|
+
raise exception 'not_allowed: withdrawing a key needs members.manage'
|
|
235
|
+
using errcode = '42501';
|
|
236
|
+
end if;
|
|
237
|
+
|
|
238
|
+
update api_keys set revoked_at = coalesce(revoked_at, now())
|
|
239
|
+
where id = p_api_key_id
|
|
240
|
+
returning * into v_row;
|
|
241
|
+
|
|
242
|
+
return v_row;
|
|
243
|
+
end;
|
|
244
|
+
$$;
|
|
245
|
+
|
|
246
|
+
comment on function revoke_api_key(uuid) is
|
|
247
|
+
'Withdraws a key. There is no un-withdraw: a secret that has been out of the building is issued again, not brought back.';
|
|
248
|
+
|
|
249
|
+
-- ---------------------------------------------------------------------------
|
|
250
|
+
-- has_capability, which now answers for a key as well as for a user
|
|
251
|
+
--
|
|
252
|
+
-- The member answer first, because that is the common case and it is the one
|
|
253
|
+
-- a policy on a table of a company is written for. A key is consulted only
|
|
254
|
+
-- when there is no member answer, so presenting a key can never take away
|
|
255
|
+
-- what the signed-in person already had.
|
|
256
|
+
-- ---------------------------------------------------------------------------
|
|
257
|
+
|
|
258
|
+
create or replace function has_capability(p_company_id uuid, p_capability text)
|
|
259
|
+
returns boolean
|
|
260
|
+
language sql
|
|
261
|
+
stable
|
|
262
|
+
security definer
|
|
263
|
+
set search_path = public, pg_temp
|
|
264
|
+
as $$
|
|
265
|
+
select coalesce(
|
|
266
|
+
(select case
|
|
267
|
+
when p_capability = any (m.capabilities_revoked) then false
|
|
268
|
+
when p_capability = any (m.capabilities_granted) then true
|
|
269
|
+
else exists (
|
|
270
|
+
select 1 from role_capabilities rc
|
|
271
|
+
where rc.role = m.role and rc.capability = p_capability
|
|
272
|
+
)
|
|
273
|
+
end
|
|
274
|
+
from company_members m
|
|
275
|
+
where m.company_id = p_company_id
|
|
276
|
+
and m.user_id = auth.uid()),
|
|
277
|
+
(select p_capability = any (k.capabilities)
|
|
278
|
+
from api_keys k
|
|
279
|
+
where k.key_hash = nullif(current_setting('ekwo.api_key', true), '')
|
|
280
|
+
and k.company_id = p_company_id
|
|
281
|
+
and k.revoked_at is null
|
|
282
|
+
and (k.expires_at is null or k.expires_at > now())),
|
|
283
|
+
false);
|
|
284
|
+
$$;
|
|
285
|
+
|
|
286
|
+
comment on function has_capability(uuid, text) is
|
|
287
|
+
'Whether the current caller may do one named thing in one company — a signed-in member by their preset and their adjustments, or a machine key by its own list. Revoked beats granted, and a non-member holding no key holds nothing.';
|
|
288
|
+
|
|
289
|
+
-- ---------------------------------------------------------------------------
|
|
290
|
+
-- Row level security
|
|
291
|
+
--
|
|
292
|
+
-- Reading the keys of a company is administering its members, and nothing
|
|
293
|
+
-- writes the table through the API: the four functions above are the way in.
|
|
294
|
+
-- ---------------------------------------------------------------------------
|
|
295
|
+
|
|
296
|
+
alter table api_keys enable row level security;
|
|
297
|
+
|
|
298
|
+
create policy api_keys_select on api_keys
|
|
299
|
+
for select using (
|
|
300
|
+
has_capability(company_id, 'members.manage') or is_instance_admin()
|
|
301
|
+
);
|
|
302
|
+
|
|
303
|
+
revoke execute on all functions in schema public from public;
|
|
@@ -0,0 +1,159 @@
|
|
|
1
|
+
-- Ekwo OS — the first financial year is a parameter too.
|
|
2
|
+
--
|
|
3
|
+
-- `ekwo init` opened the first year on 1 January and closed it on 31
|
|
4
|
+
-- December, in two string literals. That is right for Belgium and for France
|
|
5
|
+
-- and wrong for the United Kingdom, for India, for Australia and for every
|
|
6
|
+
-- company whose year does not follow the calendar — and the country model has
|
|
7
|
+
-- carried the answer since P0-7: `country_defaults.fiscal_year_default`, one
|
|
8
|
+
-- of `calendar`, `april`, `july` or `october`, with no default of its own.
|
|
9
|
+
--
|
|
10
|
+
-- This is its reader. It is in the schema rather than in the CLI because
|
|
11
|
+
-- three callers ask the same question — `ekwo init`, the MCP server creating
|
|
12
|
+
-- a company, and whoever adds the year after that — and three answers to it
|
|
13
|
+
-- is how a company ends up with two overlapping first years.
|
|
14
|
+
--
|
|
15
|
+
-- **A day, not a duration.** The bounds are the opening day and the day
|
|
16
|
+
-- before the same day next year, so a year opened on 1 April closes on 31
|
|
17
|
+
-- March, and a leap day changes nothing. The 52/53-week year some retailers
|
|
18
|
+
-- keep is not this function's business: `fiscal_years` takes any two dates,
|
|
19
|
+
-- and a company that keeps one names them.
|
|
20
|
+
--
|
|
21
|
+
-- **No fallback.** A pack that says nothing gets `no_fiscal_year_default`,
|
|
22
|
+
-- naming the field it has to fill and the flag that answers for it. Opening
|
|
23
|
+
-- somebody's first year on a date nobody chose is the kind of wrong that is
|
|
24
|
+
-- found a year later, in a closing.
|
|
25
|
+
|
|
26
|
+
create or replace function fiscal_year_bounds(
|
|
27
|
+
p_country char(2),
|
|
28
|
+
p_year integer,
|
|
29
|
+
p_start date default null,
|
|
30
|
+
out start_date date,
|
|
31
|
+
out end_date date
|
|
32
|
+
)
|
|
33
|
+
language plpgsql
|
|
34
|
+
stable
|
|
35
|
+
as $$
|
|
36
|
+
declare
|
|
37
|
+
v_opening text;
|
|
38
|
+
v_month integer;
|
|
39
|
+
begin
|
|
40
|
+
if p_start is not null then
|
|
41
|
+
start_date := p_start;
|
|
42
|
+
else
|
|
43
|
+
select fiscal_year_default into v_opening
|
|
44
|
+
from country_defaults where country = p_country;
|
|
45
|
+
|
|
46
|
+
if v_opening is null then
|
|
47
|
+
raise exception 'no_fiscal_year_default: the % pack declares no defaults.fiscal_year_default, so there is no month to open the first year on. Name the first day instead', p_country;
|
|
48
|
+
end if;
|
|
49
|
+
|
|
50
|
+
v_month := case v_opening
|
|
51
|
+
when 'calendar' then 1
|
|
52
|
+
when 'april' then 4
|
|
53
|
+
when 'july' then 7
|
|
54
|
+
when 'october' then 10
|
|
55
|
+
end;
|
|
56
|
+
if v_month is null then
|
|
57
|
+
raise exception 'unknown_fiscal_year_default: % is not one of calendar, april, july, october', v_opening;
|
|
58
|
+
end if;
|
|
59
|
+
|
|
60
|
+
start_date := make_date(p_year, v_month, 1);
|
|
61
|
+
end if;
|
|
62
|
+
|
|
63
|
+
end_date := (start_date + interval '1 year' - interval '1 day')::date;
|
|
64
|
+
end;
|
|
65
|
+
$$;
|
|
66
|
+
|
|
67
|
+
comment on function fiscal_year_bounds(char, integer, date) is
|
|
68
|
+
'The first and last day of a financial year opening in a given calendar year, on the month the country pack declares — or on a day the caller names. Raises rather than assuming January.';
|
|
69
|
+
|
|
70
|
+
-- ---------------------------------------------------------------------------
|
|
71
|
+
-- create_company — a company, its owner, its chart and its first year
|
|
72
|
+
--
|
|
73
|
+
-- The four statements a client had to get right in the right order, with the
|
|
74
|
+
-- two that carry a rule delegated rather than restated:
|
|
75
|
+
-- `install_country_template()` copies the chart, the journals and the taxes,
|
|
76
|
+
-- and `fiscal_year_bounds()` decides the two dates. What is left here is the
|
|
77
|
+
-- sequence.
|
|
78
|
+
--
|
|
79
|
+
-- `ekwo init` keeps its own sequence, and deliberately: the installer is
|
|
80
|
+
-- check-then-act — it reports "already there" for every step and can be run
|
|
81
|
+
-- twice on a half-finished project — where this function creates or raises.
|
|
82
|
+
-- What they share is every rule; what differs is what they do when the thing
|
|
83
|
+
-- already exists.
|
|
84
|
+
--
|
|
85
|
+
-- Creating a company is an instance-level act, which is the policy on
|
|
86
|
+
-- `companies` and the doctrine behind it: an administrator creates companies
|
|
87
|
+
-- and invites members, and is not thereby on anybody's books. So the creator
|
|
88
|
+
-- is made the first member here, explicitly, because being able to create a
|
|
89
|
+
-- company is not being able to read one.
|
|
90
|
+
-- ---------------------------------------------------------------------------
|
|
91
|
+
|
|
92
|
+
create or replace function create_company(
|
|
93
|
+
p_name text,
|
|
94
|
+
p_country char(2),
|
|
95
|
+
p_currency_code char(3) default null,
|
|
96
|
+
p_language char(2) default null,
|
|
97
|
+
p_chart_code text default null,
|
|
98
|
+
p_fiscal_year integer default null,
|
|
99
|
+
p_fiscal_year_start date default null,
|
|
100
|
+
p_owner_user_id uuid default null
|
|
101
|
+
)
|
|
102
|
+
returns companies
|
|
103
|
+
language plpgsql
|
|
104
|
+
as $$
|
|
105
|
+
declare
|
|
106
|
+
v_defaults country_defaults%rowtype;
|
|
107
|
+
v_currency char(3);
|
|
108
|
+
v_language char(2);
|
|
109
|
+
v_owner uuid := coalesce(p_owner_user_id, auth.uid());
|
|
110
|
+
v_year integer := coalesce(p_fiscal_year, extract(year from coalesce(p_fiscal_year_start, current_date))::integer);
|
|
111
|
+
v_bounds record;
|
|
112
|
+
v_company companies%rowtype;
|
|
113
|
+
begin
|
|
114
|
+
if auth.uid() is not null and not is_instance_admin() then
|
|
115
|
+
raise exception 'not_instance_admin: creating a company is an instance-level act'
|
|
116
|
+
using errcode = '42501';
|
|
117
|
+
end if;
|
|
118
|
+
|
|
119
|
+
select * into v_defaults from country_defaults where country = p_country;
|
|
120
|
+
|
|
121
|
+
-- The currency and the language have to be settled before the insert: both
|
|
122
|
+
-- columns are not null with a default, so there is no later moment at which
|
|
123
|
+
-- they are empty and the pack could fill them. The pack answers, or the
|
|
124
|
+
-- caller does, and there is no third answer written here.
|
|
125
|
+
v_currency := upper(coalesce(p_currency_code, v_defaults.currency_code));
|
|
126
|
+
if v_currency is null then
|
|
127
|
+
raise exception 'no_currency: the % pack names no currency; name one', p_country;
|
|
128
|
+
end if;
|
|
129
|
+
v_language := lower(coalesce(p_language, v_defaults.language_default));
|
|
130
|
+
if v_language is null then
|
|
131
|
+
raise exception 'no_language: the % pack names no language for its labels; name one', p_country;
|
|
132
|
+
end if;
|
|
133
|
+
|
|
134
|
+
select * into v_bounds from fiscal_year_bounds(p_country, v_year, p_fiscal_year_start);
|
|
135
|
+
|
|
136
|
+
insert into companies (name, country, fiscal_country, currency_code, language)
|
|
137
|
+
values (p_name, p_country, p_country, v_currency, v_language)
|
|
138
|
+
returning * into v_company;
|
|
139
|
+
|
|
140
|
+
if v_owner is not null then
|
|
141
|
+
insert into company_members (company_id, user_id, role)
|
|
142
|
+
values (v_company.id, v_owner, 'owner')
|
|
143
|
+
on conflict (company_id, user_id) do nothing;
|
|
144
|
+
end if;
|
|
145
|
+
|
|
146
|
+
perform install_country_template(v_company.id, p_country, v_language, p_chart_code);
|
|
147
|
+
|
|
148
|
+
insert into fiscal_years (company_id, name, start_date, end_date)
|
|
149
|
+
values (v_company.id, 'FY' || v_year::text, v_bounds.start_date, v_bounds.end_date);
|
|
150
|
+
|
|
151
|
+
select * into v_company from companies where id = v_company.id;
|
|
152
|
+
return v_company;
|
|
153
|
+
end;
|
|
154
|
+
$$;
|
|
155
|
+
|
|
156
|
+
comment on function create_company(text, char, char, char, text, integer, date, uuid) is
|
|
157
|
+
'Creates a company, makes the caller its first member, copies the country pack into it and opens its first financial year on the month that pack declares. An instance-level act, like the policy on companies.';
|
|
158
|
+
|
|
159
|
+
revoke execute on all functions in schema public from public;
|
|
@@ -0,0 +1,219 @@
|
|
|
1
|
+
-- Ekwo OS — taking over books that already have numbers.
|
|
2
|
+
--
|
|
3
|
+
-- ST13 gave `country_defaults.numbering_gapless` a reader: `post_entry()`
|
|
4
|
+
-- refuses a number chosen by hand where the law forbids a hole in the
|
|
5
|
+
-- sequence, because a number that skips the counter is exactly how a hole
|
|
6
|
+
-- appears. That entry also wrote down what it would cost — "the day an import
|
|
7
|
+
-- of somebody's old entries under their old numbers is written, this is the
|
|
8
|
+
-- rule it will have to argue with" — and the argument arrived immediately: a
|
|
9
|
+
-- company moving from another system arrives with three years of entries that
|
|
10
|
+
-- already carry numbers, and those numbers are the ones its VAT returns, its
|
|
11
|
+
-- filings and its auditor already know.
|
|
12
|
+
--
|
|
13
|
+
-- So the rule stands and gains an exception that has a name.
|
|
14
|
+
--
|
|
15
|
+
-- **`entries.import` is a capability, and it is in no preset.** Not a flag on
|
|
16
|
+
-- the call, not a session setting, not an argument somebody can pass by
|
|
17
|
+
-- accident: the one thing that lets an explicit number through is a
|
|
18
|
+
-- capability an owner has to grant, on purpose, to the person doing the
|
|
19
|
+
-- import — and take back afterwards. It is in `capabilities` so it can be
|
|
20
|
+
-- granted and revoked like every other, and out of `role_capabilities` so
|
|
21
|
+
-- that no role carries it by default. Importing is not something an
|
|
22
|
+
-- accountant does on a Tuesday.
|
|
23
|
+
--
|
|
24
|
+
-- **What it does not relax.** A duplicate is still refused, by the unique
|
|
25
|
+
-- index `entries_company_number_idx` that has been there since the first
|
|
26
|
+
-- release — `(company_id, number)`, which is stricter than per journal and
|
|
27
|
+
-- was already the right key. And an explicit number on a normal entry, from
|
|
28
|
+
-- somebody without the capability, is refused exactly as it was yesterday.
|
|
29
|
+
--
|
|
30
|
+
-- **It applies to everybody, including the installer.** A gapless sequence is
|
|
31
|
+
-- a rule about the books and not a permission, so this guard does not carry
|
|
32
|
+
-- the `auth.uid() is null` exemption the permission checks do: a superuser
|
|
33
|
+
-- connection is refused like anybody else. An importer running over such a
|
|
34
|
+
-- connection sets the request claim for the user it is acting for — which is
|
|
35
|
+
-- what `ekwo demo` already does to satisfy `claim_instance_admin()` — and
|
|
36
|
+
-- that user holds `entries.import`.
|
|
37
|
+
--
|
|
38
|
+
-- **The counter catches up.** An imported number that is written in the
|
|
39
|
+
-- country's own pattern advances `journal_sequences` to it, so the first
|
|
40
|
+
-- entry booked after the import continues the series instead of restarting
|
|
41
|
+
-- at one and colliding. A number that does not parse against the pattern —
|
|
42
|
+
-- a series from another system, in another shape — leaves the counter alone,
|
|
43
|
+
-- because there is nothing in it the counter could learn.
|
|
44
|
+
|
|
45
|
+
insert into capabilities (code, area, description) values
|
|
46
|
+
('entries.import', 'entries',
|
|
47
|
+
'Post an entry under a number chosen by hand, where the country forbids a hole in the sequence. For taking over books that already have numbers. In no preset: it is granted for an import and taken back after it.');
|
|
48
|
+
|
|
49
|
+
-- ---------------------------------------------------------------------------
|
|
50
|
+
-- Reading a counter back out of a number
|
|
51
|
+
--
|
|
52
|
+
-- The pattern is the pack's, so the way to read a number is the pattern read
|
|
53
|
+
-- backwards: the literal text of the format is literal, `{CODE}`, `{YYYY}`,
|
|
54
|
+
-- `{YY}` and `{MM}` are what they are, and the `{N…}` token is the number we
|
|
55
|
+
-- are after. Anything that does not match returns null, which is an answer.
|
|
56
|
+
-- ---------------------------------------------------------------------------
|
|
57
|
+
|
|
58
|
+
create or replace function number_counter(p_format text, p_number text)
|
|
59
|
+
returns integer
|
|
60
|
+
language plpgsql
|
|
61
|
+
immutable
|
|
62
|
+
as $$
|
|
63
|
+
declare
|
|
64
|
+
v_pattern text;
|
|
65
|
+
v_found text;
|
|
66
|
+
begin
|
|
67
|
+
if p_format is null or p_number is null then
|
|
68
|
+
return null;
|
|
69
|
+
end if;
|
|
70
|
+
|
|
71
|
+
-- Everything that is not a token becomes literal; the tokens become what
|
|
72
|
+
-- they can match. `regexp_replace` with the `g` flag walks the format once.
|
|
73
|
+
v_pattern := regexp_replace(p_format, '([.^$*+?()\[\]{}|\\])', '\\\1', 'g');
|
|
74
|
+
-- The escaping above also escaped the braces of the tokens, so the tokens
|
|
75
|
+
-- are matched in their escaped shape.
|
|
76
|
+
v_pattern := replace(v_pattern, '\{CODE\}', '.+');
|
|
77
|
+
v_pattern := replace(v_pattern, '\{YYYY\}', '[0-9]{4}');
|
|
78
|
+
v_pattern := replace(v_pattern, '\{YY\}', '[0-9]{2}');
|
|
79
|
+
v_pattern := replace(v_pattern, '\{MM\}', '[0-9]{2}');
|
|
80
|
+
v_pattern := regexp_replace(v_pattern, '\\\{N+\\\}', '([0-9]+)');
|
|
81
|
+
|
|
82
|
+
if v_pattern !~ '\(\[0-9\]\+\)' then
|
|
83
|
+
return null;
|
|
84
|
+
end if;
|
|
85
|
+
|
|
86
|
+
v_found := (regexp_match(p_number, '^' || v_pattern || '$'))[1];
|
|
87
|
+
if v_found is null then
|
|
88
|
+
return null;
|
|
89
|
+
end if;
|
|
90
|
+
return v_found::integer;
|
|
91
|
+
exception
|
|
92
|
+
when others then
|
|
93
|
+
return null;
|
|
94
|
+
end;
|
|
95
|
+
$$;
|
|
96
|
+
|
|
97
|
+
comment on function number_counter(text, text) is
|
|
98
|
+
'The counter inside a number, read back through the pattern it was written with, or null when the number does not follow that pattern. What lets an import advance the sequence it interrupted.';
|
|
99
|
+
|
|
100
|
+
-- ---------------------------------------------------------------------------
|
|
101
|
+
-- Catching the counter up
|
|
102
|
+
--
|
|
103
|
+
-- Definer for the reason the counters are definer: `journal_sequences` has a
|
|
104
|
+
-- select policy and no other, and nobody writes it by hand. The caller has
|
|
105
|
+
-- already been checked by `post_entry()`.
|
|
106
|
+
-- ---------------------------------------------------------------------------
|
|
107
|
+
|
|
108
|
+
create or replace function catch_up_journal_sequence(p_journal_id uuid, p_date date, p_number text)
|
|
109
|
+
returns void
|
|
110
|
+
language plpgsql
|
|
111
|
+
security definer
|
|
112
|
+
set search_path = public, pg_temp
|
|
113
|
+
as $$
|
|
114
|
+
declare
|
|
115
|
+
v_company uuid;
|
|
116
|
+
v_format text;
|
|
117
|
+
v_period smallint;
|
|
118
|
+
v_counter integer;
|
|
119
|
+
begin
|
|
120
|
+
select j.company_id into v_company from journals j where j.id = p_journal_id;
|
|
121
|
+
if v_company is null then
|
|
122
|
+
return;
|
|
123
|
+
end if;
|
|
124
|
+
|
|
125
|
+
select number_format into v_format from numbering_rules(v_company);
|
|
126
|
+
v_counter := number_counter(v_format, p_number);
|
|
127
|
+
if v_counter is null then
|
|
128
|
+
return;
|
|
129
|
+
end if;
|
|
130
|
+
|
|
131
|
+
v_period := case when v_format ~ '\{(YYYY|YY)\}'
|
|
132
|
+
then extract(year from p_date)::smallint
|
|
133
|
+
else 0::smallint end;
|
|
134
|
+
|
|
135
|
+
insert into journal_sequences (journal_id, year, last_number)
|
|
136
|
+
values (p_journal_id, v_period, v_counter)
|
|
137
|
+
on conflict (journal_id, year)
|
|
138
|
+
do update set last_number = greatest(journal_sequences.last_number, excluded.last_number);
|
|
139
|
+
end;
|
|
140
|
+
$$;
|
|
141
|
+
|
|
142
|
+
comment on function catch_up_journal_sequence(uuid, date, text) is
|
|
143
|
+
'Advances a journal counter to an imported number, so the next automatic one continues the series rather than colliding with it. Does nothing for a number that does not follow the country''s pattern.';
|
|
144
|
+
|
|
145
|
+
-- ---------------------------------------------------------------------------
|
|
146
|
+
-- post_entry, with the exception that has a name
|
|
147
|
+
--
|
|
148
|
+
-- Unchanged but for the guard and the catching-up: everything else is the
|
|
149
|
+
-- function as published.
|
|
150
|
+
-- ---------------------------------------------------------------------------
|
|
151
|
+
|
|
152
|
+
create or replace function post_entry(p_entry_id uuid)
|
|
153
|
+
returns entries
|
|
154
|
+
language plpgsql
|
|
155
|
+
as $$
|
|
156
|
+
declare
|
|
157
|
+
v_entry entries%rowtype;
|
|
158
|
+
v_lines integer;
|
|
159
|
+
v_gapless boolean;
|
|
160
|
+
begin
|
|
161
|
+
select * into v_entry from entries where id = p_entry_id for update;
|
|
162
|
+
if not found then
|
|
163
|
+
raise exception 'unknown_entry: entry % does not exist', p_entry_id;
|
|
164
|
+
end if;
|
|
165
|
+
if v_entry.state = 'posted' then
|
|
166
|
+
return v_entry;
|
|
167
|
+
end if;
|
|
168
|
+
if v_entry.state = 'cancelled' then
|
|
169
|
+
raise exception 'entry_cancelled: entry % cannot be posted', p_entry_id;
|
|
170
|
+
end if;
|
|
171
|
+
|
|
172
|
+
select count(*) into v_lines from entry_lines where entry_id = p_entry_id;
|
|
173
|
+
if v_lines = 0 then
|
|
174
|
+
raise exception 'entry_empty: entry % has no lines', p_entry_id;
|
|
175
|
+
end if;
|
|
176
|
+
|
|
177
|
+
-- Re-read the totals maintained by the line trigger.
|
|
178
|
+
select * into v_entry from entries where id = p_entry_id;
|
|
179
|
+
if v_entry.total_debit <> v_entry.total_credit then
|
|
180
|
+
raise exception 'entry_unbalanced: entry % has debit % and credit %',
|
|
181
|
+
p_entry_id, v_entry.total_debit, v_entry.total_credit;
|
|
182
|
+
end if;
|
|
183
|
+
|
|
184
|
+
-- Where the law forbids a hole, the number comes from the counter and from
|
|
185
|
+
-- nowhere else — unless the caller holds `entries.import`, which is how a
|
|
186
|
+
-- set of books that already has numbers is taken over. A duplicate is
|
|
187
|
+
-- refused either way, by `entries_company_number_idx`.
|
|
188
|
+
if v_entry.number is not null then
|
|
189
|
+
select numbering_gapless into v_gapless from numbering_rules(v_entry.company_id);
|
|
190
|
+
if v_gapless and not (auth.uid() is not null
|
|
191
|
+
and has_capability(v_entry.company_id, 'entries.import')) then
|
|
192
|
+
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',
|
|
193
|
+
p_entry_id;
|
|
194
|
+
end if;
|
|
195
|
+
end if;
|
|
196
|
+
|
|
197
|
+
perform assert_period_open(v_entry.company_id, v_entry.entry_date, true);
|
|
198
|
+
|
|
199
|
+
update entries
|
|
200
|
+
set number = coalesce(number, next_entry_number(journal_id, entry_date)),
|
|
201
|
+
fiscal_year_id = coalesce(fiscal_year_id, fiscal_year_at(company_id, entry_date)),
|
|
202
|
+
state = 'posted',
|
|
203
|
+
posted_at = now()
|
|
204
|
+
where id = p_entry_id
|
|
205
|
+
returning * into v_entry;
|
|
206
|
+
|
|
207
|
+
-- An imported number advances the counter it interrupted, so the next
|
|
208
|
+
-- automatic one continues the series. A number drawn from the counter is
|
|
209
|
+
-- already at it and this is a no-op.
|
|
210
|
+
perform catch_up_journal_sequence(v_entry.journal_id, v_entry.entry_date, v_entry.number);
|
|
211
|
+
|
|
212
|
+
return v_entry;
|
|
213
|
+
end;
|
|
214
|
+
$$;
|
|
215
|
+
|
|
216
|
+
comment on function post_entry(uuid) is
|
|
217
|
+
'Validates, numbers and posts an entry. Raises rather than warning: a swallowed error is a missing entry. Where the country forbids a hole in the sequence it refuses a number chosen by hand, unless the caller holds entries.import — and then the counter catches up to it.';
|
|
218
|
+
|
|
219
|
+
revoke execute on all functions in schema public from public;
|