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,2403 @@
|
|
|
1
|
+
-- Ekwo OS — every amount the schema writes is rounded at the decimals of its
|
|
2
|
+
-- currency, by the method of its country.
|
|
3
|
+
--
|
|
4
|
+
-- The previous migration added the vocabulary. This one makes the socle speak
|
|
5
|
+
-- it: fifty-one `round(x, 2)` in eighteen functions, a view and a generated
|
|
6
|
+
-- column, replaced by `round_amount(x, rounding)` — and the rounding resolved
|
|
7
|
+
-- once per call from `rounding_of()`, which is the only reader of
|
|
8
|
+
-- `currencies.decimal_places` and `country_defaults.rounding_method`.
|
|
9
|
+
--
|
|
10
|
+
-- Four things are worth saying about what changed beyond the substitution.
|
|
11
|
+
--
|
|
12
|
+
-- **A function rounds in as many currencies as it handles.** `post_document`
|
|
13
|
+
-- writes the base and the tax of a document in the document's own currency
|
|
14
|
+
-- and the ledger lines in the company's; `post_payment` and `reconcile` do
|
|
15
|
+
-- the same. Each resolves both and uses the right one on each line, so a yen
|
|
16
|
+
-- invoice settled from a euro account rounds each side at the decimals that
|
|
17
|
+
-- side has, instead of assuming both have two.
|
|
18
|
+
--
|
|
19
|
+
-- **A local that carries a scale is a second rounding rule.** `v_amount
|
|
20
|
+
-- numeric(16, 2)` rounds on every assignment, silently, at two decimals,
|
|
21
|
+
-- whatever the currency — so the declarations of every rewritten function are
|
|
22
|
+
-- plain `numeric` now and the only thing that rounds is `round_amount`.
|
|
23
|
+
--
|
|
24
|
+
-- **A tolerance is a fraction of a unit, not of a cent.** `0.005` meant "half
|
|
25
|
+
-- a cent" and `0.001` "a tenth of one"; in a currency with no decimals they
|
|
26
|
+
-- are a two-hundredth and a thousandth of the smallest coin there is, which
|
|
27
|
+
-- is to say nothing at all. They are written against `currency_unit()` now.
|
|
28
|
+
--
|
|
29
|
+
-- **`document_lines.amount_untaxed` stops being a generated column.** Its
|
|
30
|
+
-- expression was `round(quantity * unit_price * (1 - discount / 100), 2)`, and
|
|
31
|
+
-- a generated column may not look anything up — so it could not ask what
|
|
32
|
+
-- currency the document is in. It becomes a column written by a `before`
|
|
33
|
+
-- trigger that always overwrites it, which keeps the guarantee that mattered:
|
|
34
|
+
-- the total is derived and can never be keyed in.
|
|
35
|
+
--
|
|
36
|
+
-- What this migration does *not* change is the scale the amounts are stored
|
|
37
|
+
-- at. Every monetary column is `numeric(16, 2)`, so a currency with three
|
|
38
|
+
-- decimals is rounded correctly by the engine and then held at two by the
|
|
39
|
+
-- column. Widening them is a migration of its own — it rewrites the text of
|
|
40
|
+
-- every amount this schema returns — and `docs/decisions.md` says so.
|
|
41
|
+
|
|
42
|
+
-- ---------------------------------------------------------------------------
|
|
43
|
+
-- document_lines.amount_untaxed
|
|
44
|
+
--
|
|
45
|
+
-- `drop expression` keeps the column and its values and takes away only the
|
|
46
|
+
-- rule that computed them; the trigger below puts a better rule back. A
|
|
47
|
+
-- document that is already posted is never rewritten, so nothing recomputes
|
|
48
|
+
-- history.
|
|
49
|
+
-- ---------------------------------------------------------------------------
|
|
50
|
+
|
|
51
|
+
alter table document_lines alter column amount_untaxed drop expression;
|
|
52
|
+
|
|
53
|
+
comment on column document_lines.amount_untaxed is
|
|
54
|
+
'quantity x unit_price less the discount, rounded once at the decimals of the document''s currency. Written by a trigger on every insert and update, so it is derived and never keyed in.';
|
|
55
|
+
|
|
56
|
+
create or replace function document_lines_amount_untaxed()
|
|
57
|
+
returns trigger
|
|
58
|
+
language plpgsql
|
|
59
|
+
as $$
|
|
60
|
+
declare
|
|
61
|
+
v_company uuid;
|
|
62
|
+
v_currency char(3);
|
|
63
|
+
v_round money_rounding;
|
|
64
|
+
begin
|
|
65
|
+
if new.line_type <> 'product' then
|
|
66
|
+
new.amount_untaxed := 0;
|
|
67
|
+
return new;
|
|
68
|
+
end if;
|
|
69
|
+
|
|
70
|
+
-- Read in two steps: `select f(x) into a_composite` would assign the whole
|
|
71
|
+
-- row to the first field of the target, which is a trap plpgsql lays for
|
|
72
|
+
-- every function that returns a composite.
|
|
73
|
+
select d.company_id, d.currency_code into v_company, v_currency
|
|
74
|
+
from documents d where d.id = new.document_id;
|
|
75
|
+
v_round := rounding_of(v_company, v_currency);
|
|
76
|
+
|
|
77
|
+
new.amount_untaxed := round_amount(
|
|
78
|
+
new.quantity * new.unit_price * (1 - new.discount_percent / 100), v_round);
|
|
79
|
+
return new;
|
|
80
|
+
end;
|
|
81
|
+
$$;
|
|
82
|
+
|
|
83
|
+
comment on function document_lines_amount_untaxed() is
|
|
84
|
+
'Derives a line''s amount from its quantity, price and discount, rounded once at the decimals of the document''s currency. What the generated column used to do, minus the assumption that every currency has cents.';
|
|
85
|
+
|
|
86
|
+
create trigger document_lines_amount_untaxed
|
|
87
|
+
before insert or update on document_lines
|
|
88
|
+
for each row execute function document_lines_amount_untaxed();
|
|
89
|
+
|
|
90
|
+
-- ---------------------------------------------------------------------------
|
|
91
|
+
-- document_tax_summary
|
|
92
|
+
--
|
|
93
|
+
-- VAT is still rounded once per tax group on the rounded basis (BR-CO-14);
|
|
94
|
+
-- what changes is the number of decimals it is rounded to.
|
|
95
|
+
-- ---------------------------------------------------------------------------
|
|
96
|
+
|
|
97
|
+
create or replace view document_tax_summary
|
|
98
|
+
with (security_invoker = true) as
|
|
99
|
+
select l.document_id,
|
|
100
|
+
l.company_id,
|
|
101
|
+
d.doc_type,
|
|
102
|
+
l.tax_id,
|
|
103
|
+
t.code as tax_code,
|
|
104
|
+
t.name as tax_name,
|
|
105
|
+
t.vat_category,
|
|
106
|
+
t.amount as tax_rate,
|
|
107
|
+
sum(l.amount_untaxed) as base_amount,
|
|
108
|
+
-- Gross tax: what the VAT return reports.
|
|
109
|
+
round_amount(sum(l.amount_untaxed) * coalesce(t.amount, 0) / 100,
|
|
110
|
+
rounding_of(l.company_id, d.currency_code)) as tax_amount,
|
|
111
|
+
-- Charged tax: what the other party actually pays. Zero when the tax
|
|
112
|
+
-- postings net out, which is exactly what self-assessment means.
|
|
113
|
+
round_amount(
|
|
114
|
+
sum(l.amount_untaxed) * coalesce(t.amount, 0) / 100
|
|
115
|
+
* coalesce((
|
|
116
|
+
select sum(tp.factor_percent)
|
|
117
|
+
from tax_postings tp
|
|
118
|
+
where tp.tax_id = t.id
|
|
119
|
+
and tp.posting_type in ('tax', 'tax_on_base')
|
|
120
|
+
and tp.document_kind = case
|
|
121
|
+
when d.doc_type in ('sale_credit_note', 'purchase_credit_note')
|
|
122
|
+
then 'credit_note'::tax_document_kind
|
|
123
|
+
else 'invoice'::tax_document_kind
|
|
124
|
+
end
|
|
125
|
+
), 100) / 100,
|
|
126
|
+
rounding_of(l.company_id, d.currency_code)) as tax_charged
|
|
127
|
+
from document_lines l
|
|
128
|
+
join documents d on d.id = l.document_id
|
|
129
|
+
left join taxes t on t.id = l.tax_id
|
|
130
|
+
where l.line_type = 'product'
|
|
131
|
+
group by l.document_id, l.company_id, d.doc_type, d.currency_code, l.tax_id,
|
|
132
|
+
t.id, t.code, t.name, t.vat_category, t.amount;
|
|
133
|
+
|
|
134
|
+
create or replace function documents_refresh_totals(p_document_id uuid)
|
|
135
|
+
returns void
|
|
136
|
+
language plpgsql
|
|
137
|
+
as $$
|
|
138
|
+
declare
|
|
139
|
+
v_untaxed numeric;
|
|
140
|
+
v_tax numeric;
|
|
141
|
+
v_company uuid;
|
|
142
|
+
v_currency char(3);
|
|
143
|
+
v_round money_rounding;
|
|
144
|
+
begin
|
|
145
|
+
select d.company_id, d.currency_code into v_company, v_currency
|
|
146
|
+
from documents d where d.id = p_document_id;
|
|
147
|
+
if not found then
|
|
148
|
+
return;
|
|
149
|
+
end if;
|
|
150
|
+
v_round := rounding_of(v_company, v_currency);
|
|
151
|
+
|
|
152
|
+
select coalesce(sum(base_amount), 0), coalesce(sum(tax_charged), 0)
|
|
153
|
+
into v_untaxed, v_tax
|
|
154
|
+
from document_tax_summary
|
|
155
|
+
where document_id = p_document_id;
|
|
156
|
+
|
|
157
|
+
-- The three totals are written in the document's currency, which is the one
|
|
158
|
+
-- `amount_total` is compared against when the entry is built.
|
|
159
|
+
update documents
|
|
160
|
+
set amount_untaxed = round_amount(v_untaxed, v_round),
|
|
161
|
+
amount_tax = round_amount(v_tax, v_round),
|
|
162
|
+
amount_total = round_amount(v_untaxed + v_tax, v_round)
|
|
163
|
+
where id = p_document_id;
|
|
164
|
+
end;
|
|
165
|
+
$$;
|
|
166
|
+
|
|
167
|
+
create or replace function documents_refresh_amount_paid(p_document_id uuid)
|
|
168
|
+
returns void
|
|
169
|
+
language plpgsql
|
|
170
|
+
as $$
|
|
171
|
+
begin
|
|
172
|
+
-- The statement names amount_paid so that documents_refresh_payment_state,
|
|
173
|
+
-- which watches that column, fires in turn.
|
|
174
|
+
update documents d
|
|
175
|
+
set amount_paid = coalesce((
|
|
176
|
+
select sum(case
|
|
177
|
+
when l.amount_currency is null then l.matched_amount
|
|
178
|
+
else round_amount(l.matched_amount * l.amount_currency
|
|
179
|
+
/ nullif(abs(l.debit - l.credit), 0),
|
|
180
|
+
rounding_of(d.company_id, d.currency_code))
|
|
181
|
+
end)
|
|
182
|
+
from entry_lines l
|
|
183
|
+
join accounts a on a.id = l.account_id
|
|
184
|
+
where l.entry_id = d.entry_id
|
|
185
|
+
and a.reconcilable
|
|
186
|
+
and a.account_type in ('asset_receivable', 'liability_payable')
|
|
187
|
+
), 0)
|
|
188
|
+
where d.id = p_document_id
|
|
189
|
+
and d.entry_id is not null;
|
|
190
|
+
end;
|
|
191
|
+
$$;
|
|
192
|
+
|
|
193
|
+
create or replace function post_document(p_document_id uuid)
|
|
194
|
+
returns entries
|
|
195
|
+
language plpgsql
|
|
196
|
+
as $$
|
|
197
|
+
declare
|
|
198
|
+
v_doc documents%rowtype;
|
|
199
|
+
v_entry entries%rowtype;
|
|
200
|
+
v_journal uuid;
|
|
201
|
+
v_date date;
|
|
202
|
+
v_is_sale boolean;
|
|
203
|
+
v_is_credit boolean;
|
|
204
|
+
v_kind tax_document_kind;
|
|
205
|
+
v_base_credit boolean;
|
|
206
|
+
v_seq integer := 0;
|
|
207
|
+
v_contact uuid;
|
|
208
|
+
v_maturity date;
|
|
209
|
+
v_terms smallint;
|
|
210
|
+
v_counterpart uuid;
|
|
211
|
+
-- Plain `numeric`, not `numeric(16, 2)`: a local that carries a scale is a
|
|
212
|
+
-- second rounding rule hiding in a declaration, and it is not the currency's.
|
|
213
|
+
-- The only thing that rounds here is `round_amount`.
|
|
214
|
+
v_diff numeric;
|
|
215
|
+
v_diff_cur numeric;
|
|
216
|
+
v_amount numeric;
|
|
217
|
+
v_book numeric;
|
|
218
|
+
v_box_amount numeric;
|
|
219
|
+
v_share numeric;
|
|
220
|
+
v_share_book numeric;
|
|
221
|
+
v_share_box numeric;
|
|
222
|
+
v_left numeric;
|
|
223
|
+
v_left_box numeric;
|
|
224
|
+
v_side_left numeric;
|
|
225
|
+
v_side_left_neg numeric;
|
|
226
|
+
v_side_credit boolean;
|
|
227
|
+
v_label text;
|
|
228
|
+
v_rate numeric(18, 8);
|
|
229
|
+
v_home char(3);
|
|
230
|
+
v_foreign boolean;
|
|
231
|
+
v_total_cur numeric;
|
|
232
|
+
-- Two currencies, one method: the document is stated in its own and the
|
|
233
|
+
-- ledger keeps the company's, and a yen invoice paid in euros rounds each
|
|
234
|
+
-- side at the decimals that side has.
|
|
235
|
+
v_round money_rounding;
|
|
236
|
+
v_book_round money_rounding;
|
|
237
|
+
v_cash boolean;
|
|
238
|
+
v_transition uuid;
|
|
239
|
+
v_postings integer;
|
|
240
|
+
r record;
|
|
241
|
+
p record;
|
|
242
|
+
g record;
|
|
243
|
+
begin
|
|
244
|
+
select * into v_doc from documents where id = p_document_id for update;
|
|
245
|
+
if not found then
|
|
246
|
+
raise exception 'unknown_document: document % does not exist', p_document_id;
|
|
247
|
+
end if;
|
|
248
|
+
if v_doc.state = 'posted' then
|
|
249
|
+
raise exception 'document_already_posted: document % is already posted', p_document_id;
|
|
250
|
+
end if;
|
|
251
|
+
if v_doc.state = 'cancelled' then
|
|
252
|
+
raise exception 'document_cancelled: document % cannot be posted', p_document_id;
|
|
253
|
+
end if;
|
|
254
|
+
if v_doc.entry_id is not null then
|
|
255
|
+
raise exception 'document_already_booked: document % already points at entry %',
|
|
256
|
+
p_document_id, v_doc.entry_id;
|
|
257
|
+
end if;
|
|
258
|
+
|
|
259
|
+
if v_doc.doc_type in ('sale_quote', 'purchase_order') then
|
|
260
|
+
raise exception 'document_not_accountable: a % is not booked', v_doc.doc_type;
|
|
261
|
+
end if;
|
|
262
|
+
|
|
263
|
+
v_is_sale := v_doc.doc_type in ('sale_invoice', 'sale_credit_note');
|
|
264
|
+
v_is_credit := v_doc.doc_type in ('sale_credit_note', 'purchase_credit_note');
|
|
265
|
+
v_kind := case when v_is_credit then 'credit_note' else 'invoice' end::tax_document_kind;
|
|
266
|
+
-- Sale invoice and purchase credit note credit the base; the other two debit it.
|
|
267
|
+
v_base_credit := (v_is_sale <> v_is_credit);
|
|
268
|
+
|
|
269
|
+
v_date := coalesce(v_doc.accounting_date, v_doc.document_date);
|
|
270
|
+
|
|
271
|
+
if not exists (
|
|
272
|
+
select 1 from document_lines
|
|
273
|
+
where document_id = p_document_id and line_type = 'product' and amount_untaxed <> 0
|
|
274
|
+
) then
|
|
275
|
+
raise exception 'document_empty: document % has no billable line', p_document_id;
|
|
276
|
+
end if;
|
|
277
|
+
|
|
278
|
+
-- A fixed-amount tax has no basis to spread over lines; refuse rather than
|
|
279
|
+
-- guess.
|
|
280
|
+
if exists (
|
|
281
|
+
select 1 from document_lines l join taxes t on t.id = l.tax_id
|
|
282
|
+
where l.document_id = p_document_id and t.amount_type <> 'percent'
|
|
283
|
+
) then
|
|
284
|
+
raise exception 'unsupported_tax_amount_type: only percentage taxes can be posted';
|
|
285
|
+
end if;
|
|
286
|
+
|
|
287
|
+
-- Every tax used must be in force on the accounting date.
|
|
288
|
+
for r in
|
|
289
|
+
select distinct t.id, t.code, t.valid_from, t.valid_to
|
|
290
|
+
from document_lines l join taxes t on t.id = l.tax_id
|
|
291
|
+
where l.document_id = p_document_id
|
|
292
|
+
loop
|
|
293
|
+
if v_date < r.valid_from or (r.valid_to is not null and v_date > r.valid_to) then
|
|
294
|
+
raise exception 'tax_not_in_force: tax % is not applicable on %', r.code, v_date;
|
|
295
|
+
end if;
|
|
296
|
+
end loop;
|
|
297
|
+
|
|
298
|
+
-- Totals are derived; make sure they reflect the lines as they stand now.
|
|
299
|
+
perform documents_refresh_totals(p_document_id);
|
|
300
|
+
select * into v_doc from documents where id = p_document_id;
|
|
301
|
+
|
|
302
|
+
select c.currency_code into v_home from companies c where c.id = v_doc.company_id;
|
|
303
|
+
v_rate := v_doc.exchange_rate;
|
|
304
|
+
v_foreign := v_doc.currency_code <> v_home;
|
|
305
|
+
v_round := rounding_of(v_doc.company_id, v_doc.currency_code);
|
|
306
|
+
v_book_round := rounding_of(v_doc.company_id);
|
|
307
|
+
|
|
308
|
+
v_journal := coalesce(
|
|
309
|
+
v_doc.journal_id,
|
|
310
|
+
case when v_is_sale
|
|
311
|
+
then (select sales_journal_id from companies where id = v_doc.company_id)
|
|
312
|
+
else (select purchase_journal_id from companies where id = v_doc.company_id)
|
|
313
|
+
end
|
|
314
|
+
);
|
|
315
|
+
if v_journal is null then
|
|
316
|
+
raise exception 'no_journal: set journal_id on the document or a default journal on the company';
|
|
317
|
+
end if;
|
|
318
|
+
|
|
319
|
+
perform assert_period_open(v_doc.company_id, v_date, true);
|
|
320
|
+
|
|
321
|
+
v_label := coalesce(v_doc.number, v_doc.supplier_reference, 'document');
|
|
322
|
+
|
|
323
|
+
insert into entries (company_id, journal_id, fiscal_year_id, entry_date, reference,
|
|
324
|
+
description, state, document_id, currency_code)
|
|
325
|
+
values (v_doc.company_id, v_journal, fiscal_year_at(v_doc.company_id, v_date), v_date,
|
|
326
|
+
coalesce(v_doc.number, v_doc.supplier_reference),
|
|
327
|
+
v_label || case when v_doc.supplier_reference is not null and v_doc.number is not null
|
|
328
|
+
then ' / ' || v_doc.supplier_reference else '' end,
|
|
329
|
+
'draft', p_document_id, v_doc.currency_code)
|
|
330
|
+
returning * into v_entry;
|
|
331
|
+
|
|
332
|
+
-- ------------------------------------------------------------------ bases
|
|
333
|
+
for r in
|
|
334
|
+
select l.account_id,
|
|
335
|
+
l.tax_id,
|
|
336
|
+
sum(l.amount_untaxed) as base_amount,
|
|
337
|
+
min(l.sequence) as seq,
|
|
338
|
+
string_agg(distinct l.name, ', ') as label
|
|
339
|
+
from document_lines l
|
|
340
|
+
where l.document_id = p_document_id
|
|
341
|
+
and l.line_type = 'product'
|
|
342
|
+
group by l.account_id, l.tax_id
|
|
343
|
+
having sum(l.amount_untaxed) <> 0
|
|
344
|
+
order by 4
|
|
345
|
+
loop
|
|
346
|
+
select tp.declaration_box, tp.factor_percent, tp.box_factor_percent,
|
|
347
|
+
coalesce(t.cash_basis, false) as cash_basis
|
|
348
|
+
into p
|
|
349
|
+
from tax_postings tp
|
|
350
|
+
join taxes t on t.id = tp.tax_id
|
|
351
|
+
where tp.tax_id = r.tax_id
|
|
352
|
+
and tp.document_kind = v_kind
|
|
353
|
+
and tp.posting_type = 'base'
|
|
354
|
+
limit 1;
|
|
355
|
+
|
|
356
|
+
v_amount := round_amount(r.base_amount * coalesce(p.factor_percent, 100) / 100, v_round);
|
|
357
|
+
v_book := round_amount(v_amount / v_rate, v_book_round);
|
|
358
|
+
v_seq := v_seq + 10;
|
|
359
|
+
|
|
360
|
+
insert into entry_lines (entry_id, company_id, account_id, sequence, name,
|
|
361
|
+
debit, credit, tax_id, tax_line,
|
|
362
|
+
declaration_box, box_amount, currency_code, amount_currency)
|
|
363
|
+
values (v_entry.id, v_doc.company_id, r.account_id, v_seq, left(r.label, 200),
|
|
364
|
+
case when v_base_credit then 0 else v_book end,
|
|
365
|
+
case when v_base_credit then v_book else 0 end,
|
|
366
|
+
r.tax_id, false,
|
|
367
|
+
case when coalesce(p.cash_basis, false) then null else p.declaration_box end,
|
|
368
|
+
case when p.declaration_box is null then null
|
|
369
|
+
else round_amount(r.base_amount * coalesce(p.box_factor_percent, 100) / 100 / v_rate,
|
|
370
|
+
v_book_round) end,
|
|
371
|
+
v_doc.currency_code,
|
|
372
|
+
case when v_foreign then v_amount end);
|
|
373
|
+
end loop;
|
|
374
|
+
|
|
375
|
+
-- ------------------------------------------------------------------ taxes
|
|
376
|
+
for r in
|
|
377
|
+
select s.tax_id, s.tax_code, s.tax_name, s.tax_amount
|
|
378
|
+
from document_tax_summary s
|
|
379
|
+
where s.document_id = p_document_id
|
|
380
|
+
and s.tax_id is not null
|
|
381
|
+
and s.tax_amount <> 0
|
|
382
|
+
order by s.tax_code
|
|
383
|
+
loop
|
|
384
|
+
select t.cash_basis, t.cash_basis_transition_account_id
|
|
385
|
+
into v_cash, v_transition
|
|
386
|
+
from taxes t where t.id = r.tax_id;
|
|
387
|
+
|
|
388
|
+
if v_cash then
|
|
389
|
+
-- A tax that waits needs somewhere to wait. Refuse by name rather than
|
|
390
|
+
-- book it on the account it is due on, which would make it due.
|
|
391
|
+
if v_transition is null then
|
|
392
|
+
raise exception 'no_cash_basis_account: tax % falls due on collection and names no transition account',
|
|
393
|
+
r.tax_code;
|
|
394
|
+
end if;
|
|
395
|
+
-- One posting per side, or the transition lines of a document cannot be
|
|
396
|
+
-- told apart when the matching sends each of them on. A tax whose
|
|
397
|
+
-- postings net out has nothing waiting to collect anyway.
|
|
398
|
+
select count(*) into v_postings
|
|
399
|
+
from tax_postings tp
|
|
400
|
+
where tp.tax_id = r.tax_id and tp.document_kind = v_kind
|
|
401
|
+
and tp.posting_type = 'tax';
|
|
402
|
+
if v_postings > 1 then
|
|
403
|
+
raise exception 'cash_basis_split_tax: tax % falls due on collection and has % tax postings; it takes one',
|
|
404
|
+
r.tax_code, v_postings;
|
|
405
|
+
end if;
|
|
406
|
+
if exists (select 1 from tax_postings tp
|
|
407
|
+
where tp.tax_id = r.tax_id and tp.document_kind = v_kind
|
|
408
|
+
and tp.posting_type = 'tax_on_base') then
|
|
409
|
+
raise exception 'cash_basis_tax_on_base: tax % falls due on collection and carries a non-deductible share; a cost is not deferred',
|
|
410
|
+
r.tax_code;
|
|
411
|
+
end if;
|
|
412
|
+
-- And it needs a box to fall due *into*. `settle_cash_basis_tax()` only
|
|
413
|
+
-- ever looks at lines that carry a `box_amount`, and a posting with no
|
|
414
|
+
-- `declaration_box` produces none — so the amount would sit on the
|
|
415
|
+
-- transition account for ever, settled by nothing and reported by
|
|
416
|
+
-- nothing, with no error anywhere. Refuse it here, where the pack can
|
|
417
|
+
-- still be corrected, rather than discover it in a balance years later.
|
|
418
|
+
if not exists (select 1 from tax_postings tp
|
|
419
|
+
where tp.tax_id = r.tax_id and tp.document_kind = v_kind
|
|
420
|
+
and tp.posting_type = 'tax'
|
|
421
|
+
and tp.declaration_box is not null) then
|
|
422
|
+
raise exception 'no_cash_basis_box: tax % falls due on collection and its posting names no declaration box; the amount would wait on the transition account and never settle',
|
|
423
|
+
r.tax_code;
|
|
424
|
+
end if;
|
|
425
|
+
end if;
|
|
426
|
+
|
|
427
|
+
-- The postings of one side share out the tax of the group; the last of
|
|
428
|
+
-- each side takes what is left. Until `tax_on_base` there was never more
|
|
429
|
+
-- than one posting per side, so this changes no existing tax by a cent —
|
|
430
|
+
-- and it is what keeps a 50/50 split honest: 0.63 becomes 0.32 and 0.31,
|
|
431
|
+
-- where rounding each half on its own would book 0.64 against a document
|
|
432
|
+
-- that totals 0.63.
|
|
433
|
+
v_side_left := null;
|
|
434
|
+
v_side_left_neg := null;
|
|
435
|
+
|
|
436
|
+
for p in
|
|
437
|
+
select tp.posting_type, tp.factor_percent, tp.account_id,
|
|
438
|
+
tp.declaration_box, tp.box_factor_percent,
|
|
439
|
+
case when tp.factor_percent >= 0 then 1 else -1 end as side,
|
|
440
|
+
sum(abs(tp.factor_percent))
|
|
441
|
+
over (partition by case when tp.factor_percent >= 0 then 1 else -1 end)
|
|
442
|
+
as side_factor,
|
|
443
|
+
row_number() over (
|
|
444
|
+
partition by case when tp.factor_percent >= 0 then 1 else -1 end
|
|
445
|
+
order by tp.sequence, tp.id)
|
|
446
|
+
= count(*) over (
|
|
447
|
+
partition by case when tp.factor_percent >= 0 then 1 else -1 end)
|
|
448
|
+
as is_last_of_side
|
|
449
|
+
from tax_postings tp
|
|
450
|
+
where tp.tax_id = r.tax_id
|
|
451
|
+
and tp.document_kind = v_kind
|
|
452
|
+
and tp.posting_type in ('tax', 'tax_on_base')
|
|
453
|
+
order by tp.sequence, tp.id
|
|
454
|
+
loop
|
|
455
|
+
-- The tax of the group was rounded once, in the view. Every posting is
|
|
456
|
+
-- a share of that one figure, never of a re-derived one.
|
|
457
|
+
if p.side >= 0 then
|
|
458
|
+
if v_side_left is null then
|
|
459
|
+
v_side_left := round_amount(r.tax_amount * p.side_factor / 100, v_round);
|
|
460
|
+
end if;
|
|
461
|
+
if p.is_last_of_side then
|
|
462
|
+
v_amount := v_side_left;
|
|
463
|
+
else
|
|
464
|
+
v_amount := round_amount(r.tax_amount * abs(p.factor_percent) / 100, v_round);
|
|
465
|
+
v_side_left := v_side_left - v_amount;
|
|
466
|
+
end if;
|
|
467
|
+
else
|
|
468
|
+
if v_side_left_neg is null then
|
|
469
|
+
v_side_left_neg := round_amount(r.tax_amount * p.side_factor / 100, v_round);
|
|
470
|
+
end if;
|
|
471
|
+
if p.is_last_of_side then
|
|
472
|
+
v_amount := v_side_left_neg;
|
|
473
|
+
else
|
|
474
|
+
v_amount := round_amount(r.tax_amount * abs(p.factor_percent) / 100, v_round);
|
|
475
|
+
v_side_left_neg := v_side_left_neg - v_amount;
|
|
476
|
+
end if;
|
|
477
|
+
end if;
|
|
478
|
+
|
|
479
|
+
if v_amount = 0 then
|
|
480
|
+
continue;
|
|
481
|
+
end if;
|
|
482
|
+
-- A positive factor keeps the side of the base, a negative one flips it.
|
|
483
|
+
v_side_credit := case when p.factor_percent >= 0 then v_base_credit else not v_base_credit end;
|
|
484
|
+
-- The box keeps its own rounding: `box_factor_percent` was always
|
|
485
|
+
-- independent from `factor_percent`, because a declaration figure is
|
|
486
|
+
-- not a ledger figure and only the ledger has to balance.
|
|
487
|
+
v_box_amount := case when p.declaration_box is null then null
|
|
488
|
+
else round_amount(r.tax_amount * p.box_factor_percent / 100 / v_rate,
|
|
489
|
+
v_book_round) end;
|
|
490
|
+
v_book := round_amount(v_amount / v_rate, v_book_round);
|
|
491
|
+
|
|
492
|
+
if p.posting_type = 'tax' then
|
|
493
|
+
v_seq := v_seq + 10;
|
|
494
|
+
|
|
495
|
+
insert into entry_lines (entry_id, company_id, account_id, sequence, name,
|
|
496
|
+
debit, credit, tax_id, tax_line,
|
|
497
|
+
declaration_box, box_amount, currency_code, amount_currency)
|
|
498
|
+
values (v_entry.id, v_doc.company_id,
|
|
499
|
+
case when v_cash then v_transition else p.account_id end,
|
|
500
|
+
v_seq, r.tax_name,
|
|
501
|
+
case when v_side_credit then 0 else v_book end,
|
|
502
|
+
case when v_side_credit then v_book else 0 end,
|
|
503
|
+
r.tax_id, true,
|
|
504
|
+
case when v_cash then null else p.declaration_box end,
|
|
505
|
+
v_box_amount, v_doc.currency_code,
|
|
506
|
+
case when v_foreign then v_amount end);
|
|
507
|
+
continue;
|
|
508
|
+
end if;
|
|
509
|
+
|
|
510
|
+
-- `tax_on_base`: the tax is a cost, so it lands on the accounts of the
|
|
511
|
+
-- lines it taxes, split in proportion to their base. The last share
|
|
512
|
+
-- takes whatever is left, so the shares add up to the amount that was
|
|
513
|
+
-- rounded once on the group and the entry still balances to the cent.
|
|
514
|
+
v_left := v_amount;
|
|
515
|
+
v_left_box := v_box_amount;
|
|
516
|
+
|
|
517
|
+
for g in
|
|
518
|
+
select account_id,
|
|
519
|
+
base_amount,
|
|
520
|
+
seq,
|
|
521
|
+
sum(base_amount) over () as total_base,
|
|
522
|
+
row_number() over (order by seq) = count(*) over () as is_last
|
|
523
|
+
from (
|
|
524
|
+
select l.account_id,
|
|
525
|
+
sum(l.amount_untaxed) as base_amount,
|
|
526
|
+
min(l.sequence) as seq
|
|
527
|
+
from document_lines l
|
|
528
|
+
where l.document_id = p_document_id
|
|
529
|
+
and l.line_type = 'product'
|
|
530
|
+
and l.tax_id = r.tax_id
|
|
531
|
+
group by l.account_id
|
|
532
|
+
having sum(l.amount_untaxed) <> 0
|
|
533
|
+
) as groups
|
|
534
|
+
order by seq
|
|
535
|
+
loop
|
|
536
|
+
if g.is_last then
|
|
537
|
+
v_share := v_left;
|
|
538
|
+
v_share_box := v_left_box;
|
|
539
|
+
else
|
|
540
|
+
v_share := round_amount(v_amount * g.base_amount / g.total_base, v_round);
|
|
541
|
+
v_share_box := case when v_box_amount is null then null
|
|
542
|
+
else round_amount(v_box_amount * g.base_amount / g.total_base,
|
|
543
|
+
v_book_round) end;
|
|
544
|
+
v_left := v_left - v_share;
|
|
545
|
+
v_left_box := v_left_box - v_share_box;
|
|
546
|
+
end if;
|
|
547
|
+
|
|
548
|
+
if v_share = 0 then
|
|
549
|
+
continue;
|
|
550
|
+
end if;
|
|
551
|
+
|
|
552
|
+
v_seq := v_seq + 10;
|
|
553
|
+
v_share_book := round_amount(v_share / v_rate, v_book_round);
|
|
554
|
+
|
|
555
|
+
-- `tax_line` stays false: the amount is on a base account and belongs
|
|
556
|
+
-- to the base side of the declaration, which is why the Belgian grids
|
|
557
|
+
-- 82 and 83 report it together with the base.
|
|
558
|
+
insert into entry_lines (entry_id, company_id, account_id, sequence, name,
|
|
559
|
+
debit, credit, tax_id, tax_line,
|
|
560
|
+
declaration_box, box_amount, currency_code, amount_currency)
|
|
561
|
+
values (v_entry.id, v_doc.company_id, g.account_id, v_seq, r.tax_name,
|
|
562
|
+
case when v_side_credit then 0 else v_share_book end,
|
|
563
|
+
case when v_side_credit then v_share_book else 0 end,
|
|
564
|
+
r.tax_id, false,
|
|
565
|
+
p.declaration_box, v_share_box, v_doc.currency_code,
|
|
566
|
+
case when v_foreign then v_share end);
|
|
567
|
+
end loop;
|
|
568
|
+
end loop;
|
|
569
|
+
end loop;
|
|
570
|
+
|
|
571
|
+
-- ------------------------------------------------------------ counterpart
|
|
572
|
+
select total_debit - total_credit into v_diff from entries where id = v_entry.id;
|
|
573
|
+
select coalesce(sum(case when l.debit > 0 then l.amount_currency else -l.amount_currency end), 0)
|
|
574
|
+
into v_diff_cur
|
|
575
|
+
from entry_lines l where l.entry_id = v_entry.id;
|
|
576
|
+
|
|
577
|
+
if v_diff = 0 then
|
|
578
|
+
raise exception 'document_counterpart_zero: document % produced a nil counterpart', p_document_id;
|
|
579
|
+
end if;
|
|
580
|
+
|
|
581
|
+
v_contact := commercial_entity(v_doc.contact_id);
|
|
582
|
+
v_counterpart := resolve_counterpart_account(v_doc.company_id, v_contact, v_is_sale);
|
|
583
|
+
|
|
584
|
+
select payment_terms_days into v_terms from contacts where id = v_contact;
|
|
585
|
+
v_maturity := coalesce(v_doc.due_date, v_doc.document_date + coalesce(v_terms, 30));
|
|
586
|
+
|
|
587
|
+
v_amount := abs(v_diff);
|
|
588
|
+
-- The counterpart balances the entry in both currencies. The total it is
|
|
589
|
+
-- checked against is the document's own, which is the currency
|
|
590
|
+
-- `amount_total` is stated in.
|
|
591
|
+
v_total_cur := case when v_foreign then abs(v_diff_cur) else v_amount end;
|
|
592
|
+
v_seq := v_seq + 10;
|
|
593
|
+
|
|
594
|
+
insert into entry_lines (entry_id, company_id, account_id, sequence, name,
|
|
595
|
+
debit, credit, contact_id, date_maturity, currency_code,
|
|
596
|
+
amount_currency)
|
|
597
|
+
values (v_entry.id, v_doc.company_id, v_counterpart, v_seq, v_label,
|
|
598
|
+
case when v_diff > 0 then 0 else v_amount end,
|
|
599
|
+
case when v_diff > 0 then v_amount else 0 end,
|
|
600
|
+
v_contact, v_maturity, v_doc.currency_code,
|
|
601
|
+
case when v_foreign then v_total_cur end);
|
|
602
|
+
|
|
603
|
+
-- The ledger is right by construction. If the header disagrees, the header
|
|
604
|
+
-- is what is wrong, and we say so instead of quietly patching a line.
|
|
605
|
+
-- Half a unit of the document's own currency, which is what `0.005` used to
|
|
606
|
+
-- mean when every currency was assumed to have cents.
|
|
607
|
+
if abs(v_total_cur - abs(v_doc.amount_total)) > currency_unit(v_round) / 2 then
|
|
608
|
+
raise exception 'document_total_mismatch: document % totals % but its lines book %',
|
|
609
|
+
p_document_id, v_doc.amount_total, v_total_cur;
|
|
610
|
+
end if;
|
|
611
|
+
|
|
612
|
+
-- --------------------------------------------------------------- posting
|
|
613
|
+
v_entry := post_entry(v_entry.id);
|
|
614
|
+
|
|
615
|
+
update documents
|
|
616
|
+
set state = 'posted',
|
|
617
|
+
number = coalesce(number, v_entry.number),
|
|
618
|
+
entry_id = v_entry.id,
|
|
619
|
+
accounting_date = v_date
|
|
620
|
+
where id = p_document_id;
|
|
621
|
+
|
|
622
|
+
return v_entry;
|
|
623
|
+
end;
|
|
624
|
+
$$;
|
|
625
|
+
|
|
626
|
+
create or replace function post_payment(p_payment_id uuid)
|
|
627
|
+
returns entries
|
|
628
|
+
language plpgsql
|
|
629
|
+
as $$
|
|
630
|
+
declare
|
|
631
|
+
v_pay payments%rowtype;
|
|
632
|
+
v_entry entries%rowtype;
|
|
633
|
+
v_money uuid;
|
|
634
|
+
v_third uuid;
|
|
635
|
+
v_contact uuid;
|
|
636
|
+
v_inbound boolean;
|
|
637
|
+
v_label text;
|
|
638
|
+
v_home char(3);
|
|
639
|
+
v_foreign boolean;
|
|
640
|
+
v_book numeric;
|
|
641
|
+
v_book_round money_rounding;
|
|
642
|
+
begin
|
|
643
|
+
select * into v_pay from payments where id = p_payment_id for update;
|
|
644
|
+
if not found then
|
|
645
|
+
raise exception 'unknown_payment: payment % does not exist', p_payment_id;
|
|
646
|
+
end if;
|
|
647
|
+
if v_pay.entry_id is not null then
|
|
648
|
+
raise exception 'payment_already_booked: payment % already points at entry %',
|
|
649
|
+
p_payment_id, v_pay.entry_id;
|
|
650
|
+
end if;
|
|
651
|
+
if v_pay.state = 'cancelled' then
|
|
652
|
+
raise exception 'payment_cancelled: payment % cannot be booked', p_payment_id;
|
|
653
|
+
end if;
|
|
654
|
+
|
|
655
|
+
v_inbound := v_pay.direction = 'inbound';
|
|
656
|
+
|
|
657
|
+
-- The bank side: the account behind the payment's bank account, or the
|
|
658
|
+
-- default account of the journal it goes through.
|
|
659
|
+
select b.account_id into v_money
|
|
660
|
+
from bank_accounts b
|
|
661
|
+
where b.id = v_pay.bank_account_id;
|
|
662
|
+
|
|
663
|
+
if v_money is null then
|
|
664
|
+
select coalesce(j.default_account_id, b.account_id) into v_money
|
|
665
|
+
from journals j
|
|
666
|
+
left join bank_accounts b on b.id = j.bank_account_id
|
|
667
|
+
where j.id = v_pay.journal_id;
|
|
668
|
+
end if;
|
|
669
|
+
|
|
670
|
+
-- A bank account wired to the journal from its own side counts too: that is
|
|
671
|
+
-- the direction an operator fills in first.
|
|
672
|
+
if v_money is null then
|
|
673
|
+
select b.account_id into v_money
|
|
674
|
+
from bank_accounts b
|
|
675
|
+
where b.journal_id = v_pay.journal_id
|
|
676
|
+
and b.active
|
|
677
|
+
and b.account_id is not null
|
|
678
|
+
order by b.created_at
|
|
679
|
+
limit 1;
|
|
680
|
+
end if;
|
|
681
|
+
|
|
682
|
+
if v_money is null then
|
|
683
|
+
raise exception 'no_bank_account: set bank_account_id on the payment, or default_account_id on journal %',
|
|
684
|
+
v_pay.journal_id;
|
|
685
|
+
end if;
|
|
686
|
+
|
|
687
|
+
-- The third-party side, by role.
|
|
688
|
+
v_contact := commercial_entity(v_pay.contact_id);
|
|
689
|
+
v_third := resolve_counterpart_account(v_pay.company_id, v_contact, v_inbound);
|
|
690
|
+
|
|
691
|
+
v_label := coalesce(v_pay.reference, v_pay.memo, 'payment');
|
|
692
|
+
|
|
693
|
+
select c.currency_code into v_home from companies c where c.id = v_pay.company_id;
|
|
694
|
+
v_foreign := v_pay.currency_code <> v_home;
|
|
695
|
+
-- The payment is stated in its own currency and booked in the company's, so
|
|
696
|
+
-- what the ledger takes is rounded at the ledger's decimals.
|
|
697
|
+
v_book_round := rounding_of(v_pay.company_id);
|
|
698
|
+
v_book := round_amount(v_pay.amount / v_pay.exchange_rate, v_book_round);
|
|
699
|
+
|
|
700
|
+
insert into entries (company_id, journal_id, fiscal_year_id, entry_date, reference,
|
|
701
|
+
description, state, currency_code)
|
|
702
|
+
values (v_pay.company_id, v_pay.journal_id,
|
|
703
|
+
fiscal_year_at(v_pay.company_id, v_pay.payment_date), v_pay.payment_date,
|
|
704
|
+
v_pay.reference, v_label, 'draft', v_pay.currency_code)
|
|
705
|
+
returning * into v_entry;
|
|
706
|
+
|
|
707
|
+
insert into entry_lines (entry_id, company_id, account_id, sequence, name,
|
|
708
|
+
debit, credit, currency_code, amount_currency)
|
|
709
|
+
values (v_entry.id, v_pay.company_id, v_money, 10, v_label,
|
|
710
|
+
case when v_inbound then v_book else 0 end,
|
|
711
|
+
case when v_inbound then 0 else v_book end,
|
|
712
|
+
v_pay.currency_code,
|
|
713
|
+
case when v_foreign then v_pay.amount end);
|
|
714
|
+
|
|
715
|
+
insert into entry_lines (entry_id, company_id, account_id, sequence, name,
|
|
716
|
+
debit, credit, contact_id, currency_code, amount_currency)
|
|
717
|
+
values (v_entry.id, v_pay.company_id, v_third, 20, v_label,
|
|
718
|
+
case when v_inbound then 0 else v_book end,
|
|
719
|
+
case when v_inbound then v_book else 0 end,
|
|
720
|
+
v_contact, v_pay.currency_code,
|
|
721
|
+
case when v_foreign then v_pay.amount end);
|
|
722
|
+
|
|
723
|
+
v_entry := post_entry(v_entry.id);
|
|
724
|
+
|
|
725
|
+
update payments
|
|
726
|
+
set entry_id = v_entry.id,
|
|
727
|
+
state = 'posted'
|
|
728
|
+
where id = p_payment_id;
|
|
729
|
+
|
|
730
|
+
return v_entry;
|
|
731
|
+
end;
|
|
732
|
+
$$;
|
|
733
|
+
|
|
734
|
+
create or replace function reconcile(
|
|
735
|
+
p_line_a uuid,
|
|
736
|
+
p_line_b uuid,
|
|
737
|
+
p_amount numeric default null
|
|
738
|
+
)
|
|
739
|
+
returns reconciliations
|
|
740
|
+
language plpgsql
|
|
741
|
+
as $$
|
|
742
|
+
declare
|
|
743
|
+
v_a entry_lines%rowtype;
|
|
744
|
+
v_b entry_lines%rowtype;
|
|
745
|
+
v_debit entry_lines%rowtype;
|
|
746
|
+
v_credit entry_lines%rowtype;
|
|
747
|
+
v_amount numeric;
|
|
748
|
+
v_letter text;
|
|
749
|
+
v_result reconciliations%rowtype;
|
|
750
|
+
v_reconcilable boolean;
|
|
751
|
+
v_home char(3);
|
|
752
|
+
v_country char(2);
|
|
753
|
+
v_journal uuid;
|
|
754
|
+
v_fx boolean := false;
|
|
755
|
+
v_open_d numeric;
|
|
756
|
+
v_open_c numeric;
|
|
757
|
+
v_cur_d numeric;
|
|
758
|
+
v_cur_c numeric;
|
|
759
|
+
v_cur numeric;
|
|
760
|
+
v_comp_d numeric;
|
|
761
|
+
v_comp_c numeric;
|
|
762
|
+
v_gap numeric := 0;
|
|
763
|
+
-- The ledger's rounding, and the one of the currency the two lines are
|
|
764
|
+
-- stated in when that is not the ledger's.
|
|
765
|
+
v_round money_rounding;
|
|
766
|
+
v_cur_round money_rounding;
|
|
767
|
+
v_gain uuid;
|
|
768
|
+
v_loss uuid;
|
|
769
|
+
v_result_account uuid;
|
|
770
|
+
v_fx_entry entries%rowtype;
|
|
771
|
+
v_fx_line uuid;
|
|
772
|
+
v_date date;
|
|
773
|
+
v_transfer uuid;
|
|
774
|
+
v_doc uuid;
|
|
775
|
+
begin
|
|
776
|
+
select * into v_a from entry_lines where id = p_line_a for update;
|
|
777
|
+
if not found then raise exception 'unknown_entry_line: %', p_line_a; end if;
|
|
778
|
+
select * into v_b from entry_lines where id = p_line_b for update;
|
|
779
|
+
if not found then raise exception 'unknown_entry_line: %', p_line_b; end if;
|
|
780
|
+
|
|
781
|
+
if v_a.account_id <> v_b.account_id then
|
|
782
|
+
raise exception 'reconcile_account_mismatch: lines are on different accounts';
|
|
783
|
+
end if;
|
|
784
|
+
if v_a.company_id <> v_b.company_id then
|
|
785
|
+
raise exception 'reconcile_company_mismatch: lines belong to different companies';
|
|
786
|
+
end if;
|
|
787
|
+
|
|
788
|
+
select a.reconcilable into v_reconcilable from accounts a where a.id = v_a.account_id;
|
|
789
|
+
if not v_reconcilable then
|
|
790
|
+
raise exception 'account_not_reconcilable: account of line % is not reconcilable', p_line_a;
|
|
791
|
+
end if;
|
|
792
|
+
|
|
793
|
+
if (v_a.debit > 0) = (v_b.debit > 0) then
|
|
794
|
+
raise exception 'reconcile_same_side: a debit must be matched against a credit';
|
|
795
|
+
end if;
|
|
796
|
+
|
|
797
|
+
if v_a.debit > 0 then
|
|
798
|
+
v_debit := v_a; v_credit := v_b;
|
|
799
|
+
else
|
|
800
|
+
v_debit := v_b; v_credit := v_a;
|
|
801
|
+
end if;
|
|
802
|
+
|
|
803
|
+
select c.currency_code, c.country, c.miscellaneous_journal_id
|
|
804
|
+
into v_home, v_country, v_journal
|
|
805
|
+
from companies c where c.id = v_debit.company_id;
|
|
806
|
+
|
|
807
|
+
v_round := rounding_of(v_debit.company_id);
|
|
808
|
+
v_open_d := abs(v_debit.debit - v_debit.credit) - v_debit.matched_amount;
|
|
809
|
+
v_open_c := abs(v_credit.debit - v_credit.credit) - v_credit.matched_amount;
|
|
810
|
+
|
|
811
|
+
v_fx := v_debit.currency_code is not null
|
|
812
|
+
and v_debit.currency_code = v_credit.currency_code
|
|
813
|
+
and v_debit.currency_code <> v_home
|
|
814
|
+
and coalesce(v_debit.amount_currency, 0) <> 0
|
|
815
|
+
and coalesce(v_credit.amount_currency, 0) <> 0;
|
|
816
|
+
|
|
817
|
+
if v_fx then
|
|
818
|
+
v_cur_round := rounding_of(v_debit.company_id, v_debit.currency_code);
|
|
819
|
+
-- What is still open on each line, in its own currency, in proportion to
|
|
820
|
+
-- what is still open in the ledger.
|
|
821
|
+
v_cur_d := round_amount(v_debit.amount_currency * v_open_d
|
|
822
|
+
/ nullif(abs(v_debit.debit - v_debit.credit), 0), v_cur_round);
|
|
823
|
+
v_cur_c := round_amount(v_credit.amount_currency * v_open_c
|
|
824
|
+
/ nullif(abs(v_credit.debit - v_credit.credit), 0), v_cur_round);
|
|
825
|
+
v_cur := coalesce(p_amount, least(v_cur_d, v_cur_c));
|
|
826
|
+
|
|
827
|
+
if v_cur is null or v_cur <= 0 then
|
|
828
|
+
raise exception 'reconcile_nothing_left: no open amount to match';
|
|
829
|
+
end if;
|
|
830
|
+
-- A tenth of a unit of that currency, which is what `0.001` was when
|
|
831
|
+
-- every currency was assumed to have cents.
|
|
832
|
+
if v_cur > v_cur_d + currency_unit(v_cur_round) / 10
|
|
833
|
+
or v_cur > v_cur_c + currency_unit(v_cur_round) / 10 then
|
|
834
|
+
raise exception 'reconcile_over_currency: % exceeds what is open in %',
|
|
835
|
+
v_cur, v_debit.currency_code;
|
|
836
|
+
end if;
|
|
837
|
+
|
|
838
|
+
v_comp_d := round_amount(v_cur * abs(v_debit.debit - v_debit.credit)
|
|
839
|
+
/ v_debit.amount_currency, v_round);
|
|
840
|
+
v_comp_c := round_amount(v_cur * abs(v_credit.debit - v_credit.credit)
|
|
841
|
+
/ v_credit.amount_currency, v_round);
|
|
842
|
+
v_amount := least(v_comp_d, v_comp_c);
|
|
843
|
+
v_gap := v_comp_d - v_comp_c;
|
|
844
|
+
else
|
|
845
|
+
v_amount := coalesce(p_amount, least(v_open_d, v_open_c));
|
|
846
|
+
end if;
|
|
847
|
+
|
|
848
|
+
if v_amount is null or v_amount <= 0 then
|
|
849
|
+
raise exception 'reconcile_nothing_left: no open amount to match';
|
|
850
|
+
end if;
|
|
851
|
+
if v_amount > v_open_d + currency_unit(v_round) / 10 then
|
|
852
|
+
raise exception 'reconcile_over_debit: % exceeds the open amount of the debit line', v_amount;
|
|
853
|
+
end if;
|
|
854
|
+
if v_amount > v_open_c + currency_unit(v_round) / 10 then
|
|
855
|
+
raise exception 'reconcile_over_credit: % exceeds the open amount of the credit line', v_amount;
|
|
856
|
+
end if;
|
|
857
|
+
|
|
858
|
+
-- Reuse a letter already carried by either side, otherwise draw a new one.
|
|
859
|
+
v_letter := coalesce(v_debit.matching_number, v_credit.matching_number,
|
|
860
|
+
next_matching_number(v_debit.company_id));
|
|
861
|
+
|
|
862
|
+
-- Two chains meeting: merge them under a single letter.
|
|
863
|
+
if v_debit.matching_number is not null
|
|
864
|
+
and v_credit.matching_number is not null
|
|
865
|
+
and v_debit.matching_number <> v_credit.matching_number then
|
|
866
|
+
update reconciliations
|
|
867
|
+
set matching_number = v_letter
|
|
868
|
+
where company_id = v_debit.company_id
|
|
869
|
+
and matching_number = v_credit.matching_number;
|
|
870
|
+
end if;
|
|
871
|
+
|
|
872
|
+
-- The date the settlement is complete on: the later of the two entries.
|
|
873
|
+
select greatest(ed.entry_date, ec.entry_date) into v_date
|
|
874
|
+
from entries ed, entries ec
|
|
875
|
+
where ed.id = v_debit.entry_id and ec.id = v_credit.entry_id;
|
|
876
|
+
|
|
877
|
+
-- ------------------------------------------------------- exchange difference
|
|
878
|
+
if v_gap <> 0 then
|
|
879
|
+
select account_id_by_code(v_debit.company_id, cd.fx_gain_code),
|
|
880
|
+
account_id_by_code(v_debit.company_id, cd.fx_loss_code)
|
|
881
|
+
into v_gain, v_loss
|
|
882
|
+
from country_defaults cd where cd.country = v_country;
|
|
883
|
+
|
|
884
|
+
-- A debit side short of the credit side means more of the company's money
|
|
885
|
+
-- came in than the receivable was booked at: a gain.
|
|
886
|
+
v_result_account := case when v_gap < 0 then v_gain else v_loss end;
|
|
887
|
+
if v_result_account is null then
|
|
888
|
+
raise exception 'no_fx_accounts: matching % with % realises % and the country model names no exchange gain and loss account',
|
|
889
|
+
p_line_a, p_line_b, v_gap;
|
|
890
|
+
end if;
|
|
891
|
+
if v_journal is null then
|
|
892
|
+
raise exception 'no_miscellaneous_journal: company % has no journal for an exchange difference',
|
|
893
|
+
v_debit.company_id;
|
|
894
|
+
end if;
|
|
895
|
+
|
|
896
|
+
insert into entries (company_id, journal_id, fiscal_year_id, entry_date,
|
|
897
|
+
description, state, currency_code)
|
|
898
|
+
values (v_debit.company_id, v_journal, fiscal_year_at(v_debit.company_id, v_date), v_date,
|
|
899
|
+
'Exchange difference on ' || v_letter, 'draft', v_home)
|
|
900
|
+
returning * into v_fx_entry;
|
|
901
|
+
|
|
902
|
+
insert into entry_lines (entry_id, company_id, account_id, sequence, name,
|
|
903
|
+
debit, credit, contact_id, currency_code)
|
|
904
|
+
values (v_fx_entry.id, v_debit.company_id, v_debit.account_id, 10,
|
|
905
|
+
'Exchange difference',
|
|
906
|
+
case when v_gap < 0 then abs(v_gap) else 0 end,
|
|
907
|
+
case when v_gap < 0 then 0 else abs(v_gap) end,
|
|
908
|
+
coalesce(v_debit.contact_id, v_credit.contact_id), v_home)
|
|
909
|
+
returning id into v_fx_line;
|
|
910
|
+
|
|
911
|
+
insert into entry_lines (entry_id, company_id, account_id, sequence, name,
|
|
912
|
+
debit, credit, currency_code)
|
|
913
|
+
values (v_fx_entry.id, v_debit.company_id, v_result_account, 20,
|
|
914
|
+
'Exchange difference',
|
|
915
|
+
case when v_gap < 0 then 0 else abs(v_gap) end,
|
|
916
|
+
case when v_gap < 0 then abs(v_gap) else 0 end,
|
|
917
|
+
v_home);
|
|
918
|
+
|
|
919
|
+
v_fx_entry := post_entry(v_fx_entry.id);
|
|
920
|
+
|
|
921
|
+
-- And it is matched with the side it completes, under the same letter.
|
|
922
|
+
if v_gap < 0 then
|
|
923
|
+
insert into reconciliations (company_id, debit_line_id, credit_line_id, amount, matching_number)
|
|
924
|
+
values (v_debit.company_id, v_fx_line, v_credit.id, abs(v_gap), v_letter);
|
|
925
|
+
else
|
|
926
|
+
insert into reconciliations (company_id, debit_line_id, credit_line_id, amount, matching_number)
|
|
927
|
+
values (v_debit.company_id, v_debit.id, v_fx_line, abs(v_gap), v_letter);
|
|
928
|
+
end if;
|
|
929
|
+
end if;
|
|
930
|
+
|
|
931
|
+
insert into reconciliations (company_id, debit_line_id, credit_line_id, amount,
|
|
932
|
+
matching_number, fx_entry_id)
|
|
933
|
+
values (v_debit.company_id, v_debit.id, v_credit.id, v_amount, v_letter, v_fx_entry.id)
|
|
934
|
+
returning * into v_result;
|
|
935
|
+
|
|
936
|
+
-- ------------------------------------------------------- a tax falling due
|
|
937
|
+
for v_doc in
|
|
938
|
+
select distinct d.id
|
|
939
|
+
from entry_lines l join documents d on d.entry_id = l.entry_id
|
|
940
|
+
where l.id in (v_debit.id, v_credit.id)
|
|
941
|
+
loop
|
|
942
|
+
v_transfer := coalesce(settle_cash_basis_tax(v_doc, v_date), v_transfer);
|
|
943
|
+
end loop;
|
|
944
|
+
|
|
945
|
+
if v_transfer is not null then
|
|
946
|
+
update reconciliations set tax_transfer_entry_id = v_transfer where id = v_result.id
|
|
947
|
+
returning * into v_result;
|
|
948
|
+
end if;
|
|
949
|
+
|
|
950
|
+
return v_result;
|
|
951
|
+
end;
|
|
952
|
+
$$;
|
|
953
|
+
|
|
954
|
+
create or replace function settle_cash_basis_tax(
|
|
955
|
+
p_document_id uuid,
|
|
956
|
+
p_date date
|
|
957
|
+
)
|
|
958
|
+
returns uuid
|
|
959
|
+
language plpgsql
|
|
960
|
+
as $$
|
|
961
|
+
declare
|
|
962
|
+
v_doc documents%rowtype;
|
|
963
|
+
v_company companies%rowtype;
|
|
964
|
+
v_entry entries%rowtype;
|
|
965
|
+
v_kind tax_document_kind;
|
|
966
|
+
v_total numeric;
|
|
967
|
+
v_paid numeric;
|
|
968
|
+
v_ratio numeric;
|
|
969
|
+
v_seq integer := 0;
|
|
970
|
+
v_final uuid;
|
|
971
|
+
v_box text;
|
|
972
|
+
v_target uuid;
|
|
973
|
+
v_due numeric;
|
|
974
|
+
v_done numeric;
|
|
975
|
+
v_delta numeric;
|
|
976
|
+
v_box_due numeric;
|
|
977
|
+
v_box_done numeric;
|
|
978
|
+
v_box_delta numeric;
|
|
979
|
+
-- The transfer is written in the ledger's currency on both sides, which is
|
|
980
|
+
-- the company's own: the entry it produces says so.
|
|
981
|
+
v_round money_rounding;
|
|
982
|
+
v_credit boolean;
|
|
983
|
+
w record;
|
|
984
|
+
begin
|
|
985
|
+
select * into v_doc from documents where id = p_document_id;
|
|
986
|
+
if not found or v_doc.entry_id is null then
|
|
987
|
+
return null;
|
|
988
|
+
end if;
|
|
989
|
+
|
|
990
|
+
-- Nothing is waiting on this document: the answer for every tax that falls
|
|
991
|
+
-- due when it is invoiced, which is most of them.
|
|
992
|
+
if not exists (
|
|
993
|
+
select 1
|
|
994
|
+
from entry_lines q join taxes t on t.id = q.tax_id
|
|
995
|
+
where q.entry_id = v_doc.entry_id
|
|
996
|
+
and t.cash_basis
|
|
997
|
+
and q.declaration_box is null
|
|
998
|
+
and q.box_amount is not null
|
|
999
|
+
) then
|
|
1000
|
+
return null;
|
|
1001
|
+
end if;
|
|
1002
|
+
|
|
1003
|
+
select * into v_company from companies where id = v_doc.company_id;
|
|
1004
|
+
v_round := rounding_of(v_doc.company_id);
|
|
1005
|
+
if v_company.miscellaneous_journal_id is null then
|
|
1006
|
+
raise exception 'no_miscellaneous_journal: company % has no journal for the transfer of a cash-basis tax',
|
|
1007
|
+
v_doc.company_id;
|
|
1008
|
+
end if;
|
|
1009
|
+
|
|
1010
|
+
v_kind := case when v_doc.doc_type in ('sale_credit_note', 'purchase_credit_note')
|
|
1011
|
+
then 'credit_note' else 'invoice' end::tax_document_kind;
|
|
1012
|
+
|
|
1013
|
+
-- What share of this document has been settled, read on its own third-party
|
|
1014
|
+
-- lines and in the ledger's currency on both sides of the division.
|
|
1015
|
+
select coalesce(sum(abs(tl.debit - tl.credit)), 0), coalesce(sum(tl.matched_amount), 0)
|
|
1016
|
+
into v_total, v_paid
|
|
1017
|
+
from entry_lines tl join accounts a on a.id = tl.account_id
|
|
1018
|
+
where tl.entry_id = v_doc.entry_id
|
|
1019
|
+
and a.reconcilable
|
|
1020
|
+
and a.account_type in ('asset_receivable', 'liability_payable');
|
|
1021
|
+
|
|
1022
|
+
if v_total = 0 then
|
|
1023
|
+
return null;
|
|
1024
|
+
end if;
|
|
1025
|
+
-- An overpayment settles the document, and no more: a tax is due on what
|
|
1026
|
+
-- was invoiced.
|
|
1027
|
+
v_ratio := least(1, greatest(0, v_paid / v_total));
|
|
1028
|
+
|
|
1029
|
+
for w in
|
|
1030
|
+
select l2.id, l2.account_id, l2.tax_id, l2.tax_line, l2.box_amount,
|
|
1031
|
+
l2.debit, l2.credit, l2.name, l2.sequence
|
|
1032
|
+
from entry_lines l2 join taxes t on t.id = l2.tax_id
|
|
1033
|
+
where l2.entry_id = v_doc.entry_id
|
|
1034
|
+
and t.cash_basis
|
|
1035
|
+
and l2.declaration_box is null
|
|
1036
|
+
and l2.box_amount is not null
|
|
1037
|
+
order by l2.sequence
|
|
1038
|
+
loop
|
|
1039
|
+
select tp.account_id, tp.declaration_box
|
|
1040
|
+
into v_final, v_box
|
|
1041
|
+
from tax_postings tp
|
|
1042
|
+
where tp.tax_id = w.tax_id
|
|
1043
|
+
and tp.document_kind = v_kind
|
|
1044
|
+
and tp.posting_type = (case when w.tax_line then 'tax' else 'base' end)::tax_posting_type
|
|
1045
|
+
limit 1;
|
|
1046
|
+
|
|
1047
|
+
-- The account the transfer of this line lands on, which is also how an
|
|
1048
|
+
-- earlier transfer of the same line is recognised.
|
|
1049
|
+
v_target := case when w.tax_line then v_final else w.account_id end;
|
|
1050
|
+
if v_target is null then
|
|
1051
|
+
raise exception 'no_cash_basis_target: the tax of line % names no account to fall due on', w.id;
|
|
1052
|
+
end if;
|
|
1053
|
+
-- A line only waits because a box was worked out for it, and that box came
|
|
1054
|
+
-- from this very posting. If it has none, the amount would wait for ever.
|
|
1055
|
+
if v_box is null then
|
|
1056
|
+
raise exception 'no_cash_basis_box: the tax of line % holds an amount for a box the posting does not name', w.id;
|
|
1057
|
+
end if;
|
|
1058
|
+
|
|
1059
|
+
if w.tax_line then
|
|
1060
|
+
v_due := round_amount((w.debit + w.credit) * v_ratio, v_round);
|
|
1061
|
+
select coalesce(sum(case when w.credit > 0 then x.credit - x.debit
|
|
1062
|
+
else x.debit - x.credit end), 0)
|
|
1063
|
+
into v_done
|
|
1064
|
+
from entry_lines x join entries e on e.id = x.entry_id
|
|
1065
|
+
where e.document_id = p_document_id
|
|
1066
|
+
and e.id <> v_doc.entry_id
|
|
1067
|
+
and x.tax_id = w.tax_id
|
|
1068
|
+
and x.tax_line
|
|
1069
|
+
and x.account_id = v_target;
|
|
1070
|
+
else
|
|
1071
|
+
v_due := 0;
|
|
1072
|
+
v_done := 0;
|
|
1073
|
+
end if;
|
|
1074
|
+
v_delta := v_due - v_done;
|
|
1075
|
+
|
|
1076
|
+
v_box_due := round_amount(w.box_amount * v_ratio, v_round);
|
|
1077
|
+
select coalesce(sum(x.box_amount), 0)
|
|
1078
|
+
into v_box_done
|
|
1079
|
+
from entry_lines x join entries e on e.id = x.entry_id
|
|
1080
|
+
where e.document_id = p_document_id
|
|
1081
|
+
and e.id <> v_doc.entry_id
|
|
1082
|
+
and x.tax_id = w.tax_id
|
|
1083
|
+
and x.tax_line = w.tax_line
|
|
1084
|
+
and x.account_id = v_target;
|
|
1085
|
+
v_box_delta := v_box_due - v_box_done;
|
|
1086
|
+
|
|
1087
|
+
if v_delta = 0 and v_box_delta = 0 then
|
|
1088
|
+
continue;
|
|
1089
|
+
end if;
|
|
1090
|
+
|
|
1091
|
+
if v_entry.id is null then
|
|
1092
|
+
insert into entries (company_id, journal_id, fiscal_year_id, entry_date, reference,
|
|
1093
|
+
description, state, document_id, currency_code)
|
|
1094
|
+
values (v_doc.company_id, v_company.miscellaneous_journal_id,
|
|
1095
|
+
fiscal_year_at(v_doc.company_id, p_date), p_date,
|
|
1096
|
+
v_doc.number,
|
|
1097
|
+
coalesce(v_doc.number, 'document') || ' — tax due on settlement',
|
|
1098
|
+
'draft', p_document_id, v_company.currency_code)
|
|
1099
|
+
returning * into v_entry;
|
|
1100
|
+
end if;
|
|
1101
|
+
|
|
1102
|
+
-- The side the document put the tax on, kept when the share grows and
|
|
1103
|
+
-- flipped when it shrinks.
|
|
1104
|
+
v_credit := (w.credit > 0) = (v_delta > 0);
|
|
1105
|
+
|
|
1106
|
+
v_seq := v_seq + 10;
|
|
1107
|
+
insert into entry_lines (entry_id, company_id, account_id, sequence, name,
|
|
1108
|
+
debit, credit, tax_id, tax_line,
|
|
1109
|
+
declaration_box, box_amount, currency_code)
|
|
1110
|
+
values (v_entry.id, v_doc.company_id, v_target, v_seq, w.name,
|
|
1111
|
+
case when v_credit then 0 else abs(v_delta) end,
|
|
1112
|
+
case when v_credit then abs(v_delta) else 0 end,
|
|
1113
|
+
w.tax_id, w.tax_line,
|
|
1114
|
+
v_box, v_box_delta,
|
|
1115
|
+
v_company.currency_code);
|
|
1116
|
+
|
|
1117
|
+
if v_delta <> 0 then
|
|
1118
|
+
v_seq := v_seq + 10;
|
|
1119
|
+
insert into entry_lines (entry_id, company_id, account_id, sequence, name,
|
|
1120
|
+
debit, credit, tax_id, tax_line, currency_code)
|
|
1121
|
+
values (v_entry.id, v_doc.company_id, w.account_id, v_seq, w.name,
|
|
1122
|
+
case when v_credit then abs(v_delta) else 0 end,
|
|
1123
|
+
case when v_credit then 0 else abs(v_delta) end,
|
|
1124
|
+
w.tax_id, true, v_company.currency_code);
|
|
1125
|
+
end if;
|
|
1126
|
+
end loop;
|
|
1127
|
+
|
|
1128
|
+
if v_entry.id is null then
|
|
1129
|
+
return null;
|
|
1130
|
+
end if;
|
|
1131
|
+
|
|
1132
|
+
v_entry := post_entry(v_entry.id);
|
|
1133
|
+
return v_entry.id;
|
|
1134
|
+
end;
|
|
1135
|
+
$$;
|
|
1136
|
+
|
|
1137
|
+
create or replace function opening_balance(
|
|
1138
|
+
p_company_id uuid,
|
|
1139
|
+
p_fiscal_year_id uuid,
|
|
1140
|
+
p_lines jsonb,
|
|
1141
|
+
p_allow_result_accounts boolean default false
|
|
1142
|
+
)
|
|
1143
|
+
returns uuid
|
|
1144
|
+
language plpgsql
|
|
1145
|
+
as $$
|
|
1146
|
+
declare
|
|
1147
|
+
v_year fiscal_years%rowtype;
|
|
1148
|
+
v_journal uuid;
|
|
1149
|
+
v_entry entries%rowtype;
|
|
1150
|
+
v_line jsonb;
|
|
1151
|
+
v_account uuid;
|
|
1152
|
+
v_code text;
|
|
1153
|
+
v_debit numeric;
|
|
1154
|
+
v_credit numeric;
|
|
1155
|
+
v_carries boolean;
|
|
1156
|
+
v_sequence integer := 0;
|
|
1157
|
+
v_debits numeric := 0;
|
|
1158
|
+
v_credits numeric := 0;
|
|
1159
|
+
-- An opening balance is the ledger's own, so it is written at the decimals
|
|
1160
|
+
-- of the company's currency.
|
|
1161
|
+
v_round money_rounding := rounding_of(p_company_id);
|
|
1162
|
+
begin
|
|
1163
|
+
select * into v_year from fiscal_years where id = p_fiscal_year_id;
|
|
1164
|
+
if not found then
|
|
1165
|
+
raise exception 'unknown_fiscal_year: fiscal year % does not exist', p_fiscal_year_id;
|
|
1166
|
+
end if;
|
|
1167
|
+
if v_year.company_id <> p_company_id then
|
|
1168
|
+
raise exception 'fiscal_year_other_company: fiscal year % does not belong to company %',
|
|
1169
|
+
p_fiscal_year_id, p_company_id;
|
|
1170
|
+
end if;
|
|
1171
|
+
if v_year.is_closed then
|
|
1172
|
+
raise exception 'fiscal_year_closed: % is closed', v_year.name
|
|
1173
|
+
using errcode = '55006';
|
|
1174
|
+
end if;
|
|
1175
|
+
if has_opening_entry(p_fiscal_year_id) then
|
|
1176
|
+
raise exception 'opening_entry_exists: % already has an opening entry', v_year.name;
|
|
1177
|
+
end if;
|
|
1178
|
+
|
|
1179
|
+
if jsonb_typeof(p_lines) <> 'array' or jsonb_array_length(p_lines) = 0 then
|
|
1180
|
+
raise exception 'opening_empty: opening_balance takes a non-empty array of lines';
|
|
1181
|
+
end if;
|
|
1182
|
+
|
|
1183
|
+
v_journal := opening_journal_id(p_company_id);
|
|
1184
|
+
if v_journal is null then
|
|
1185
|
+
raise exception 'no_opening_journal: the pack of this company names no journal of type opening. Set defaults.journal_roles.opening in the pack, which fills country_defaults.opening_journal_code.'
|
|
1186
|
+
using errcode = '55006';
|
|
1187
|
+
end if;
|
|
1188
|
+
|
|
1189
|
+
perform set_config('ekwo.year_end_entry', 'on', true);
|
|
1190
|
+
insert into entries (company_id, journal_id, fiscal_year_id, entry_date,
|
|
1191
|
+
description, state, kind)
|
|
1192
|
+
values (p_company_id, v_journal, p_fiscal_year_id, v_year.start_date,
|
|
1193
|
+
'Opening balance', 'draft', 'opening')
|
|
1194
|
+
returning * into v_entry;
|
|
1195
|
+
perform set_config('ekwo.year_end_entry', 'off', true);
|
|
1196
|
+
|
|
1197
|
+
for v_line in select * from jsonb_array_elements(p_lines)
|
|
1198
|
+
loop
|
|
1199
|
+
v_sequence := v_sequence + 10;
|
|
1200
|
+
v_code := v_line ->> 'account_code';
|
|
1201
|
+
if v_code is null then
|
|
1202
|
+
raise exception 'opening_line_without_account: line % names no account_code', v_sequence / 10;
|
|
1203
|
+
end if;
|
|
1204
|
+
|
|
1205
|
+
select a.id, a.carries_forward into v_account, v_carries
|
|
1206
|
+
from accounts a
|
|
1207
|
+
where a.company_id = p_company_id and a.code = v_code;
|
|
1208
|
+
if v_account is null then
|
|
1209
|
+
raise exception 'unknown_account: % is not an account of this company', v_code;
|
|
1210
|
+
end if;
|
|
1211
|
+
if not v_carries and not p_allow_result_accounts then
|
|
1212
|
+
raise exception 'opening_result_account: % is an income or expense account; an opening balance is made of the balance sheet. Pass p_allow_result_accounts when taking over books mid-year.',
|
|
1213
|
+
v_code;
|
|
1214
|
+
end if;
|
|
1215
|
+
|
|
1216
|
+
v_debit := round_amount(coalesce((v_line ->> 'debit')::numeric, 0), v_round);
|
|
1217
|
+
v_credit := round_amount(coalesce((v_line ->> 'credit')::numeric, 0), v_round);
|
|
1218
|
+
if v_debit < 0 or v_credit < 0 then
|
|
1219
|
+
raise exception 'opening_negative_amount: % has a negative amount; a side is chosen, never a sign', v_code;
|
|
1220
|
+
end if;
|
|
1221
|
+
if v_debit <> 0 and v_credit <> 0 then
|
|
1222
|
+
raise exception 'opening_two_sides: % carries both a debit and a credit', v_code;
|
|
1223
|
+
end if;
|
|
1224
|
+
if v_debit = 0 and v_credit = 0 then
|
|
1225
|
+
raise exception 'opening_no_amount: % carries neither a debit nor a credit', v_code;
|
|
1226
|
+
end if;
|
|
1227
|
+
|
|
1228
|
+
insert into entry_lines (entry_id, company_id, account_id, sequence, name,
|
|
1229
|
+
debit, credit, contact_id)
|
|
1230
|
+
values (v_entry.id, p_company_id, v_account, v_sequence,
|
|
1231
|
+
coalesce(v_line ->> 'label', 'Opening balance'),
|
|
1232
|
+
v_debit, v_credit, (v_line ->> 'contact_id')::uuid);
|
|
1233
|
+
|
|
1234
|
+
v_debits := v_debits + v_debit;
|
|
1235
|
+
v_credits := v_credits + v_credit;
|
|
1236
|
+
end loop;
|
|
1237
|
+
|
|
1238
|
+
if v_debits <> v_credits then
|
|
1239
|
+
raise exception 'opening_unbalanced: the opening balance has debit % and credit %',
|
|
1240
|
+
v_debits, v_credits;
|
|
1241
|
+
end if;
|
|
1242
|
+
|
|
1243
|
+
v_entry := post_entry(v_entry.id);
|
|
1244
|
+
return v_entry.id;
|
|
1245
|
+
end;
|
|
1246
|
+
$$;
|
|
1247
|
+
|
|
1248
|
+
create or replace function post_module_entry(
|
|
1249
|
+
p_company_id uuid,
|
|
1250
|
+
p_module_code text,
|
|
1251
|
+
p_ref text,
|
|
1252
|
+
p_date date,
|
|
1253
|
+
p_description text,
|
|
1254
|
+
p_lines jsonb,
|
|
1255
|
+
p_journal_id uuid default null
|
|
1256
|
+
)
|
|
1257
|
+
returns uuid
|
|
1258
|
+
language plpgsql
|
|
1259
|
+
as $$
|
|
1260
|
+
declare
|
|
1261
|
+
v_company companies%rowtype;
|
|
1262
|
+
v_journal uuid;
|
|
1263
|
+
v_entry entries%rowtype;
|
|
1264
|
+
v_line jsonb;
|
|
1265
|
+
v_account uuid;
|
|
1266
|
+
v_code text;
|
|
1267
|
+
v_debit numeric;
|
|
1268
|
+
v_credit numeric;
|
|
1269
|
+
v_sequence integer := 0;
|
|
1270
|
+
v_debits numeric := 0;
|
|
1271
|
+
v_credits numeric := 0;
|
|
1272
|
+
-- A module writes into the ledger, so its lines are written at the decimals
|
|
1273
|
+
-- of the company's own currency, like every other entry.
|
|
1274
|
+
v_round money_rounding;
|
|
1275
|
+
begin
|
|
1276
|
+
select * into v_company from companies where id = p_company_id;
|
|
1277
|
+
if not found then
|
|
1278
|
+
raise exception 'unknown_company: %', p_company_id;
|
|
1279
|
+
end if;
|
|
1280
|
+
v_round := rounding_of(p_company_id);
|
|
1281
|
+
|
|
1282
|
+
if not module_is_enabled(p_company_id, p_module_code) then
|
|
1283
|
+
raise exception 'module_not_enabled: % is not enabled on this company', p_module_code
|
|
1284
|
+
using errcode = '55006';
|
|
1285
|
+
end if;
|
|
1286
|
+
|
|
1287
|
+
if p_ref is null or p_ref = '' then
|
|
1288
|
+
raise exception 'module_entry_without_ref: an entry written by a module says what it is for, so a second one cannot repeat it';
|
|
1289
|
+
end if;
|
|
1290
|
+
|
|
1291
|
+
if jsonb_typeof(p_lines) <> 'array' or jsonb_array_length(p_lines) = 0 then
|
|
1292
|
+
raise exception 'module_entry_empty: post_module_entry takes a non-empty array of lines';
|
|
1293
|
+
end if;
|
|
1294
|
+
|
|
1295
|
+
-- The miscellaneous journal unless the caller names one. A module booking is
|
|
1296
|
+
-- not a movement of money and not a document, so it has no business on a
|
|
1297
|
+
-- bank or a sales journal — the same reasoning that put the exchange
|
|
1298
|
+
-- difference and the cash-basis transfer there.
|
|
1299
|
+
v_journal := coalesce(p_journal_id, v_company.miscellaneous_journal_id);
|
|
1300
|
+
if v_journal is null then
|
|
1301
|
+
raise exception 'no_miscellaneous_journal: this company has no journal for an entry that is neither a document nor a payment';
|
|
1302
|
+
end if;
|
|
1303
|
+
|
|
1304
|
+
insert into entries (company_id, journal_id, fiscal_year_id, entry_date,
|
|
1305
|
+
description, state, module_code, module_ref)
|
|
1306
|
+
values (p_company_id, v_journal, fiscal_year_at(p_company_id, p_date), p_date,
|
|
1307
|
+
p_description, 'draft', p_module_code, p_ref)
|
|
1308
|
+
returning * into v_entry;
|
|
1309
|
+
|
|
1310
|
+
for v_line in select * from jsonb_array_elements(p_lines)
|
|
1311
|
+
loop
|
|
1312
|
+
v_sequence := v_sequence + 10;
|
|
1313
|
+
v_code := v_line ->> 'account_code';
|
|
1314
|
+
v_account := (v_line ->> 'account_id')::uuid;
|
|
1315
|
+
if v_account is null then
|
|
1316
|
+
if v_code is null then
|
|
1317
|
+
raise exception 'module_line_without_account: line % names neither account_id nor account_code', v_sequence / 10;
|
|
1318
|
+
end if;
|
|
1319
|
+
v_account := account_id_by_code(p_company_id, v_code);
|
|
1320
|
+
if v_account is null then
|
|
1321
|
+
raise exception 'unknown_account: % is not an account of this company', v_code;
|
|
1322
|
+
end if;
|
|
1323
|
+
end if;
|
|
1324
|
+
|
|
1325
|
+
v_debit := round_amount(coalesce((v_line ->> 'debit')::numeric, 0), v_round);
|
|
1326
|
+
v_credit := round_amount(coalesce((v_line ->> 'credit')::numeric, 0), v_round);
|
|
1327
|
+
if v_debit < 0 or v_credit < 0 then
|
|
1328
|
+
raise exception 'module_line_negative: a line chooses a side, never a sign';
|
|
1329
|
+
end if;
|
|
1330
|
+
if v_debit <> 0 and v_credit <> 0 then
|
|
1331
|
+
raise exception 'module_line_two_sides: a line carries a debit or a credit, not both';
|
|
1332
|
+
end if;
|
|
1333
|
+
if v_debit = 0 and v_credit = 0 then
|
|
1334
|
+
raise exception 'module_line_no_amount: a line with neither a debit nor a credit says nothing';
|
|
1335
|
+
end if;
|
|
1336
|
+
|
|
1337
|
+
insert into entry_lines (entry_id, company_id, account_id, sequence, name,
|
|
1338
|
+
debit, credit, contact_id, date_maturity)
|
|
1339
|
+
values (v_entry.id, p_company_id, v_account, v_sequence,
|
|
1340
|
+
coalesce(v_line ->> 'label', p_description),
|
|
1341
|
+
v_debit, v_credit,
|
|
1342
|
+
(v_line ->> 'contact_id')::uuid,
|
|
1343
|
+
(v_line ->> 'date_maturity')::date);
|
|
1344
|
+
|
|
1345
|
+
v_debits := v_debits + v_debit;
|
|
1346
|
+
v_credits := v_credits + v_credit;
|
|
1347
|
+
end loop;
|
|
1348
|
+
|
|
1349
|
+
if v_debits <> v_credits then
|
|
1350
|
+
raise exception 'entry_unbalanced: the lines of % have debit % and credit %', p_ref, v_debits, v_credits;
|
|
1351
|
+
end if;
|
|
1352
|
+
|
|
1353
|
+
v_entry := post_entry(v_entry.id);
|
|
1354
|
+
return v_entry.id;
|
|
1355
|
+
end;
|
|
1356
|
+
$$;
|
|
1357
|
+
|
|
1358
|
+
create or replace function close_fiscal_year(p_fiscal_year_id uuid)
|
|
1359
|
+
returns jsonb
|
|
1360
|
+
language plpgsql
|
|
1361
|
+
as $$
|
|
1362
|
+
declare
|
|
1363
|
+
v_year fiscal_years%rowtype;
|
|
1364
|
+
v_company companies%rowtype;
|
|
1365
|
+
v_defaults country_defaults%rowtype;
|
|
1366
|
+
v_journal uuid;
|
|
1367
|
+
v_result numeric;
|
|
1368
|
+
v_profit boolean;
|
|
1369
|
+
v_kind text;
|
|
1370
|
+
v_cyr uuid; -- current-year result account, the side that applies
|
|
1371
|
+
v_retained uuid; -- retained earnings, the side that applies
|
|
1372
|
+
v_carries boolean;
|
|
1373
|
+
v_appropriate uuid;
|
|
1374
|
+
v_closing uuid;
|
|
1375
|
+
v_entry entries%rowtype;
|
|
1376
|
+
v_lines integer;
|
|
1377
|
+
v_debits numeric;
|
|
1378
|
+
v_credits numeric;
|
|
1379
|
+
-- A close is written in the ledger's own currency, and the figure it
|
|
1380
|
+
-- reports is written with the decimals that currency has.
|
|
1381
|
+
v_round money_rounding;
|
|
1382
|
+
begin
|
|
1383
|
+
select * into v_year from fiscal_years where id = p_fiscal_year_id for update;
|
|
1384
|
+
if not found then
|
|
1385
|
+
raise exception 'unknown_fiscal_year: fiscal year % does not exist', p_fiscal_year_id;
|
|
1386
|
+
end if;
|
|
1387
|
+
if v_year.is_closed then
|
|
1388
|
+
raise exception 'fiscal_year_already_closed: % was closed on %', v_year.name, v_year.closed_at;
|
|
1389
|
+
end if;
|
|
1390
|
+
|
|
1391
|
+
select * into v_company from companies where id = v_year.company_id;
|
|
1392
|
+
v_round := rounding_of(v_year.company_id);
|
|
1393
|
+
|
|
1394
|
+
-- 1. What has to be true before a year can be closed.
|
|
1395
|
+
|
|
1396
|
+
if exists (
|
|
1397
|
+
select 1 from entries e
|
|
1398
|
+
where e.company_id = v_year.company_id
|
|
1399
|
+
and e.entry_date between v_year.start_date and v_year.end_date
|
|
1400
|
+
and e.state = 'draft'
|
|
1401
|
+
) then
|
|
1402
|
+
raise exception 'fiscal_year_has_drafts: % still holds draft entries; post or cancel them first', v_year.name;
|
|
1403
|
+
end if;
|
|
1404
|
+
|
|
1405
|
+
if exists (
|
|
1406
|
+
select 1 from fiscal_years f
|
|
1407
|
+
where f.company_id = v_year.company_id
|
|
1408
|
+
and f.start_date < v_year.start_date
|
|
1409
|
+
and not f.is_closed
|
|
1410
|
+
and exists (select 1 from entries e where e.fiscal_year_id = f.id and e.state = 'posted')
|
|
1411
|
+
) then
|
|
1412
|
+
raise exception 'earlier_fiscal_year_open: a year before % holds posted entries and is not closed', v_year.name;
|
|
1413
|
+
end if;
|
|
1414
|
+
|
|
1415
|
+
if exists (
|
|
1416
|
+
select 1 from fiscal_years f
|
|
1417
|
+
where f.company_id = v_year.company_id
|
|
1418
|
+
and f.start_date > v_year.start_date
|
|
1419
|
+
and f.is_closed
|
|
1420
|
+
) then
|
|
1421
|
+
raise exception 'later_fiscal_year_closed: a year after % is already closed; re-open it first', v_year.name;
|
|
1422
|
+
end if;
|
|
1423
|
+
|
|
1424
|
+
-- 2. The accounts the result travels through, from the country model.
|
|
1425
|
+
|
|
1426
|
+
select * into v_defaults from country_defaults where country = v_company.country;
|
|
1427
|
+
if not found then
|
|
1428
|
+
raise exception 'unknown_country_template: no country model for %; close_fiscal_year reads its accounts from the pack',
|
|
1429
|
+
v_company.country;
|
|
1430
|
+
end if;
|
|
1431
|
+
|
|
1432
|
+
if v_defaults.closing_style is null then
|
|
1433
|
+
raise exception 'no_closing_defaults: the pack of this company says nothing about how a year is closed. Set defaults.closing_style, and the account roles it needs, in the pack.'
|
|
1434
|
+
using errcode = '55006';
|
|
1435
|
+
end if;
|
|
1436
|
+
|
|
1437
|
+
v_journal := opening_journal_id(v_year.company_id);
|
|
1438
|
+
if v_journal is null then
|
|
1439
|
+
raise exception 'no_opening_journal: the pack of this company names no journal of type opening. Set defaults.journal_roles.opening in the pack, which fills country_defaults.opening_journal_code.'
|
|
1440
|
+
using errcode = '55006';
|
|
1441
|
+
end if;
|
|
1442
|
+
|
|
1443
|
+
-- The result of the year: income less expense, over the accounts that do
|
|
1444
|
+
-- not carry forward. A credit balance is a profit.
|
|
1445
|
+
select round_amount(coalesce(-sum(l.balance), 0), v_round) into v_result
|
|
1446
|
+
from entry_lines l
|
|
1447
|
+
join entries e on e.id = l.entry_id and e.state = 'posted'
|
|
1448
|
+
join accounts a on a.id = l.account_id
|
|
1449
|
+
where l.company_id = v_year.company_id
|
|
1450
|
+
and e.entry_date between v_year.start_date and v_year.end_date
|
|
1451
|
+
and not a.carries_forward;
|
|
1452
|
+
|
|
1453
|
+
v_profit := v_result >= 0;
|
|
1454
|
+
v_kind := case when v_result > 0 then 'profit'
|
|
1455
|
+
when v_result < 0 then 'loss'
|
|
1456
|
+
else 'nil' end;
|
|
1457
|
+
|
|
1458
|
+
v_retained := coalesce(
|
|
1459
|
+
case when v_profit then v_company.retained_earnings_account_id end,
|
|
1460
|
+
account_id_by_code(v_year.company_id,
|
|
1461
|
+
case when v_profit then v_defaults.retained_earnings_code
|
|
1462
|
+
else coalesce(v_defaults.retained_earnings_loss_code, v_defaults.retained_earnings_code) end));
|
|
1463
|
+
|
|
1464
|
+
v_cyr := account_id_by_code(v_year.company_id,
|
|
1465
|
+
case when v_profit then v_defaults.current_year_result_profit_code
|
|
1466
|
+
else v_defaults.current_year_result_loss_code end);
|
|
1467
|
+
|
|
1468
|
+
if v_defaults.closing_style = 'retained_earnings' then
|
|
1469
|
+
if v_retained is null then
|
|
1470
|
+
raise exception 'no_retained_earnings_account: the country model of % names none, and the company has none',
|
|
1471
|
+
v_company.country;
|
|
1472
|
+
end if;
|
|
1473
|
+
v_cyr := v_retained;
|
|
1474
|
+
else
|
|
1475
|
+
if v_cyr is null then
|
|
1476
|
+
raise exception 'no_current_year_result_account: the country model of % names no account for a %',
|
|
1477
|
+
v_company.country, v_kind;
|
|
1478
|
+
end if;
|
|
1479
|
+
end if;
|
|
1480
|
+
|
|
1481
|
+
-- A style is a promise about where the result sits at the end. Assert it
|
|
1482
|
+
-- rather than trust it: a pack that names an income account where the
|
|
1483
|
+
-- balance sheet is expected would carry nothing forward, silently.
|
|
1484
|
+
select a.carries_forward into v_carries from accounts a where a.id = v_cyr;
|
|
1485
|
+
if v_defaults.closing_style = 'appropriation_accounts' and v_carries then
|
|
1486
|
+
raise exception 'closing_style_mismatch: % expects an appropriation account inside the income statement, and % carries forward',
|
|
1487
|
+
v_defaults.closing_style, v_cyr;
|
|
1488
|
+
end if;
|
|
1489
|
+
if v_defaults.closing_style <> 'appropriation_accounts' and not v_carries then
|
|
1490
|
+
raise exception 'closing_style_mismatch: % expects an account on the balance sheet, and % does not carry forward',
|
|
1491
|
+
v_defaults.closing_style, v_cyr;
|
|
1492
|
+
end if;
|
|
1493
|
+
|
|
1494
|
+
-- 3. The appropriation entry: the result leaves the income statement
|
|
1495
|
+
-- through an account that is itself part of it, and lands on retained
|
|
1496
|
+
-- earnings. Only the third style has one, and only when there is a
|
|
1497
|
+
-- result to move.
|
|
1498
|
+
|
|
1499
|
+
if v_defaults.closing_style = 'appropriation_accounts' and v_result <> 0 then
|
|
1500
|
+
if v_retained is null then
|
|
1501
|
+
raise exception 'no_retained_earnings_account: the country model of % names none, and the company has none',
|
|
1502
|
+
v_company.country;
|
|
1503
|
+
end if;
|
|
1504
|
+
|
|
1505
|
+
perform set_config('ekwo.year_end_entry', 'on', true);
|
|
1506
|
+
insert into entries (company_id, journal_id, fiscal_year_id, entry_date,
|
|
1507
|
+
description, state, kind)
|
|
1508
|
+
values (v_year.company_id, v_journal, v_year.id, v_year.end_date,
|
|
1509
|
+
'Result of the year', 'draft', 'appropriation')
|
|
1510
|
+
returning * into v_entry;
|
|
1511
|
+
perform set_config('ekwo.year_end_entry', 'off', true);
|
|
1512
|
+
v_appropriate := v_entry.id;
|
|
1513
|
+
|
|
1514
|
+
insert into entry_lines (entry_id, company_id, account_id, sequence, name, debit, credit)
|
|
1515
|
+
values (v_appropriate, v_year.company_id, v_cyr, 10, 'Result of the year',
|
|
1516
|
+
case when v_profit then abs(v_result) else 0 end,
|
|
1517
|
+
case when v_profit then 0 else abs(v_result) end),
|
|
1518
|
+
(v_appropriate, v_year.company_id, v_retained, 20, 'Result of the year',
|
|
1519
|
+
case when v_profit then 0 else abs(v_result) end,
|
|
1520
|
+
case when v_profit then abs(v_result) else 0 end);
|
|
1521
|
+
|
|
1522
|
+
perform post_entry(v_appropriate);
|
|
1523
|
+
end if;
|
|
1524
|
+
|
|
1525
|
+
-- 4. The closing entry: every income and expense account back to zero.
|
|
1526
|
+
-- In the first two styles the difference is the result and goes to the
|
|
1527
|
+
-- account chosen above; in the third the accounts already net to zero,
|
|
1528
|
+
-- because the appropriation entry put the result among them.
|
|
1529
|
+
|
|
1530
|
+
perform set_config('ekwo.year_end_entry', 'on', true);
|
|
1531
|
+
insert into entries (company_id, journal_id, fiscal_year_id, entry_date,
|
|
1532
|
+
description, state, kind)
|
|
1533
|
+
values (v_year.company_id, v_journal, v_year.id, v_year.end_date,
|
|
1534
|
+
'Closing entry', 'draft', 'closing')
|
|
1535
|
+
returning * into v_entry;
|
|
1536
|
+
perform set_config('ekwo.year_end_entry', 'off', true);
|
|
1537
|
+
v_closing := v_entry.id;
|
|
1538
|
+
|
|
1539
|
+
insert into entry_lines (entry_id, company_id, account_id, sequence, name, debit, credit)
|
|
1540
|
+
select v_closing, v_year.company_id, s.account_id,
|
|
1541
|
+
row_number() over (order by s.code) * 10,
|
|
1542
|
+
'Closing entry',
|
|
1543
|
+
case when s.balance < 0 then -s.balance else 0 end,
|
|
1544
|
+
case when s.balance > 0 then s.balance else 0 end
|
|
1545
|
+
from (
|
|
1546
|
+
select a.id as account_id, a.code,
|
|
1547
|
+
round_amount(sum(l.balance), v_round) as balance
|
|
1548
|
+
from entry_lines l
|
|
1549
|
+
join entries e on e.id = l.entry_id and e.state = 'posted'
|
|
1550
|
+
join accounts a on a.id = l.account_id
|
|
1551
|
+
where l.company_id = v_year.company_id
|
|
1552
|
+
and e.entry_date between v_year.start_date and v_year.end_date
|
|
1553
|
+
and not a.carries_forward
|
|
1554
|
+
group by a.id, a.code
|
|
1555
|
+
having round_amount(sum(l.balance), v_round) <> 0
|
|
1556
|
+
) s;
|
|
1557
|
+
|
|
1558
|
+
select count(*) into v_lines from entry_lines where entry_id = v_closing;
|
|
1559
|
+
|
|
1560
|
+
if v_lines = 0 then
|
|
1561
|
+
delete from entries where id = v_closing;
|
|
1562
|
+
v_closing := null;
|
|
1563
|
+
else
|
|
1564
|
+
if v_defaults.closing_style <> 'appropriation_accounts' and v_result <> 0 then
|
|
1565
|
+
insert into entry_lines (entry_id, company_id, account_id, sequence, name, debit, credit)
|
|
1566
|
+
values (v_closing, v_year.company_id, v_cyr, 1000000, 'Result of the year',
|
|
1567
|
+
case when v_profit then 0 else abs(v_result) end,
|
|
1568
|
+
case when v_profit then abs(v_result) else 0 end);
|
|
1569
|
+
end if;
|
|
1570
|
+
|
|
1571
|
+
select total_debit, total_credit into v_debits, v_credits from entries where id = v_closing;
|
|
1572
|
+
if v_debits <> v_credits then
|
|
1573
|
+
raise exception 'closing_unbalanced: the closing entry has debit % and credit %. The income statement of % does not net to its result.',
|
|
1574
|
+
v_debits, v_credits, v_year.name;
|
|
1575
|
+
end if;
|
|
1576
|
+
|
|
1577
|
+
perform post_entry(v_closing);
|
|
1578
|
+
end if;
|
|
1579
|
+
|
|
1580
|
+
-- 5. The year is closed, and only from here.
|
|
1581
|
+
|
|
1582
|
+
perform set_config('ekwo.closing_fiscal_year', 'on', true);
|
|
1583
|
+
update fiscal_years
|
|
1584
|
+
set is_closed = true,
|
|
1585
|
+
closed_at = now()
|
|
1586
|
+
where id = p_fiscal_year_id;
|
|
1587
|
+
perform set_config('ekwo.closing_fiscal_year', 'off', true);
|
|
1588
|
+
|
|
1589
|
+
return jsonb_build_object(
|
|
1590
|
+
'fiscal_year_id', p_fiscal_year_id,
|
|
1591
|
+
'closing_style', v_defaults.closing_style,
|
|
1592
|
+
-- The figure is written the way the currency writes it: a yen result
|
|
1593
|
+
-- carries no decimals and a dinar one carries three.
|
|
1594
|
+
'result', to_char(v_result, amount_text_format(v_round)),
|
|
1595
|
+
'result_kind', v_kind,
|
|
1596
|
+
'appropriation_entry_id', v_appropriate,
|
|
1597
|
+
'closing_entry_id', v_closing
|
|
1598
|
+
);
|
|
1599
|
+
end;
|
|
1600
|
+
$$;
|
|
1601
|
+
|
|
1602
|
+
create or replace function aged_balance(
|
|
1603
|
+
p_company_id uuid,
|
|
1604
|
+
p_at date default current_date,
|
|
1605
|
+
p_group text default 'receivable'
|
|
1606
|
+
)
|
|
1607
|
+
returns table (
|
|
1608
|
+
contact_id uuid,
|
|
1609
|
+
contact_name text,
|
|
1610
|
+
account_id uuid,
|
|
1611
|
+
account_code text,
|
|
1612
|
+
not_due numeric,
|
|
1613
|
+
days_1_30 numeric,
|
|
1614
|
+
days_31_60 numeric,
|
|
1615
|
+
days_61_90 numeric,
|
|
1616
|
+
days_over_90 numeric,
|
|
1617
|
+
total numeric
|
|
1618
|
+
)
|
|
1619
|
+
language plpgsql
|
|
1620
|
+
stable
|
|
1621
|
+
as $$
|
|
1622
|
+
declare
|
|
1623
|
+
-- What is still open is read in the ledger's currency, so the ageing is
|
|
1624
|
+
-- written at the decimals the company keeps its books in.
|
|
1625
|
+
v_round money_rounding;
|
|
1626
|
+
begin
|
|
1627
|
+
if p_group is null or p_group not in ('receivable', 'payable') then
|
|
1628
|
+
raise exception 'invalid_group: % is not an ageing group; it is receivable or payable', coalesce(p_group, 'null');
|
|
1629
|
+
end if;
|
|
1630
|
+
|
|
1631
|
+
v_round := rounding_of(p_company_id);
|
|
1632
|
+
|
|
1633
|
+
return query
|
|
1634
|
+
with open_lines as (
|
|
1635
|
+
select l.contact_id,
|
|
1636
|
+
l.account_id,
|
|
1637
|
+
case when p_group = 'payable' then -l.balance else l.balance end
|
|
1638
|
+
* (case when abs(l.balance) = 0 then 0
|
|
1639
|
+
else (abs(l.balance) - l.matched_amount) / abs(l.balance) end) as residual,
|
|
1640
|
+
coalesce(l.date_maturity, e.entry_date) as due_date
|
|
1641
|
+
from entry_lines l
|
|
1642
|
+
join entries e on e.id = l.entry_id
|
|
1643
|
+
join accounts a on a.id = l.account_id
|
|
1644
|
+
where l.company_id = p_company_id
|
|
1645
|
+
and e.state = 'posted'
|
|
1646
|
+
and e.entry_date <= p_at
|
|
1647
|
+
and a.reconcilable
|
|
1648
|
+
and a.account_type = case when p_group = 'payable'
|
|
1649
|
+
then 'liability_payable'::account_type
|
|
1650
|
+
else 'asset_receivable'::account_type end
|
|
1651
|
+
-- Half a unit of the currency: below that, nothing is open.
|
|
1652
|
+
and abs(l.balance) - l.matched_amount > currency_unit(v_round) / 2
|
|
1653
|
+
)
|
|
1654
|
+
select o.contact_id,
|
|
1655
|
+
c.name,
|
|
1656
|
+
o.account_id,
|
|
1657
|
+
a.code,
|
|
1658
|
+
round_amount(sum(o.residual) filter (where o.due_date >= p_at), v_round),
|
|
1659
|
+
round_amount(sum(o.residual) filter (where p_at - o.due_date between 1 and 30), v_round),
|
|
1660
|
+
round_amount(sum(o.residual) filter (where p_at - o.due_date between 31 and 60), v_round),
|
|
1661
|
+
round_amount(sum(o.residual) filter (where p_at - o.due_date between 61 and 90), v_round),
|
|
1662
|
+
round_amount(sum(o.residual) filter (where p_at - o.due_date > 90), v_round),
|
|
1663
|
+
round_amount(sum(o.residual), v_round)
|
|
1664
|
+
from open_lines o
|
|
1665
|
+
left join contacts c on c.id = o.contact_id
|
|
1666
|
+
join accounts a on a.id = o.account_id
|
|
1667
|
+
group by o.contact_id, c.name, o.account_id, a.code
|
|
1668
|
+
order by c.name nulls last;
|
|
1669
|
+
end;
|
|
1670
|
+
$$;
|
|
1671
|
+
|
|
1672
|
+
create or replace function statement_account_matches(
|
|
1673
|
+
p_company_id uuid,
|
|
1674
|
+
p_statement_code text,
|
|
1675
|
+
p_from date,
|
|
1676
|
+
p_to date
|
|
1677
|
+
)
|
|
1678
|
+
returns table (
|
|
1679
|
+
account_id uuid,
|
|
1680
|
+
account_code text,
|
|
1681
|
+
account_name text,
|
|
1682
|
+
account_type account_type,
|
|
1683
|
+
balance numeric,
|
|
1684
|
+
line_code text
|
|
1685
|
+
)
|
|
1686
|
+
language sql
|
|
1687
|
+
stable
|
|
1688
|
+
as $$
|
|
1689
|
+
with statement as (
|
|
1690
|
+
select s.code, s.kind from statement_templates s where s.code = p_statement_code
|
|
1691
|
+
),
|
|
1692
|
+
balances as (
|
|
1693
|
+
select a.id, a.code, a.name, a.account_type,
|
|
1694
|
+
round_amount(coalesce(sum(l.balance), 0), rounding_of(p_company_id)) as balance
|
|
1695
|
+
from accounts a
|
|
1696
|
+
join entry_lines l on l.account_id = a.id
|
|
1697
|
+
join entries e on e.id = l.entry_id
|
|
1698
|
+
cross join statement st
|
|
1699
|
+
where a.company_id = p_company_id
|
|
1700
|
+
and e.state = 'posted'
|
|
1701
|
+
and case when st.kind = 'balance_sheet'
|
|
1702
|
+
then e.entry_date <= p_to
|
|
1703
|
+
else e.entry_date between p_from and p_to
|
|
1704
|
+
end
|
|
1705
|
+
-- An income statement is what the period earned, and the entry that
|
|
1706
|
+
-- closes a year is not that: it books the mirror image of every income
|
|
1707
|
+
-- and expense account so they start the next year at nil. Left in, a
|
|
1708
|
+
-- closed year reads as a result of zero. An allocation section leaves
|
|
1709
|
+
-- it out for the same reason and keeps the appropriation entry, which
|
|
1710
|
+
-- is the movement it exists to show. A balance sheet keeps both, and
|
|
1711
|
+
-- must: together they are what puts the result on the line it shows.
|
|
1712
|
+
and (st.kind not in ('income_statement', 'allocation') or e.kind <> 'closing')
|
|
1713
|
+
group by a.id, a.code, a.name, a.account_type
|
|
1714
|
+
),
|
|
1715
|
+
ranked as (
|
|
1716
|
+
select b.id, b.code, b.name, b.account_type, b.balance, r.line_code,
|
|
1717
|
+
row_number() over (
|
|
1718
|
+
partition by b.id
|
|
1719
|
+
order by case r.rule_kind
|
|
1720
|
+
when 'account_code' then 1
|
|
1721
|
+
when 'code_range' then 2
|
|
1722
|
+
when 'code_prefix' then 2
|
|
1723
|
+
else 3
|
|
1724
|
+
end,
|
|
1725
|
+
length(coalesce(r.code_from, '')) desc,
|
|
1726
|
+
r.sequence, r.line_code
|
|
1727
|
+
) as rank
|
|
1728
|
+
from balances b
|
|
1729
|
+
join statement_line_rules r on r.statement_code = p_statement_code
|
|
1730
|
+
where case r.rule_kind
|
|
1731
|
+
when 'account_code' then b.code = r.code_from
|
|
1732
|
+
when 'code_prefix' then left(b.code, length(r.code_from)) = r.code_from
|
|
1733
|
+
when 'code_range' then left(b.code, length(r.code_from)) >= r.code_from
|
|
1734
|
+
and left(b.code, length(r.code_to)) <= r.code_to
|
|
1735
|
+
else b.account_type = r.account_type
|
|
1736
|
+
end
|
|
1737
|
+
and case r.balance_side
|
|
1738
|
+
when 'debit' then b.balance > 0
|
|
1739
|
+
when 'credit' then b.balance < 0
|
|
1740
|
+
else true
|
|
1741
|
+
end
|
|
1742
|
+
)
|
|
1743
|
+
select b.id, b.code, b.name, b.account_type, b.balance, r.line_code
|
|
1744
|
+
from balances b
|
|
1745
|
+
left join ranked r on r.id = b.id and r.rank = 1
|
|
1746
|
+
order by b.code;
|
|
1747
|
+
$$;
|
|
1748
|
+
|
|
1749
|
+
-- ---------------------------------------------------------------------------
|
|
1750
|
+
-- evaluate_totals
|
|
1751
|
+
--
|
|
1752
|
+
-- The one evaluator the declaration forms and the statements share. It is
|
|
1753
|
+
-- immutable and looks nothing up, so the rounding reaches it as an argument
|
|
1754
|
+
-- rather than as a lookup — which is also what keeps it usable from both.
|
|
1755
|
+
-- The three-argument form is dropped rather than left beside the new one: two
|
|
1756
|
+
-- overloads where one silently rounds at two decimals is the bug this
|
|
1757
|
+
-- migration exists to remove.
|
|
1758
|
+
-- ---------------------------------------------------------------------------
|
|
1759
|
+
|
|
1760
|
+
drop function if exists evaluate_totals(jsonb, jsonb, boolean);
|
|
1761
|
+
|
|
1762
|
+
create or replace function evaluate_totals(
|
|
1763
|
+
p_values jsonb,
|
|
1764
|
+
p_formulas jsonb,
|
|
1765
|
+
p_rounding money_rounding,
|
|
1766
|
+
p_keep_zero boolean default false
|
|
1767
|
+
)
|
|
1768
|
+
returns jsonb
|
|
1769
|
+
language plpgsql
|
|
1770
|
+
immutable
|
|
1771
|
+
as $$
|
|
1772
|
+
declare
|
|
1773
|
+
v_values jsonb := coalesce(p_values, '{}'::jsonb);
|
|
1774
|
+
v_out jsonb := '{}'::jsonb;
|
|
1775
|
+
v_done jsonb := '{}'::jsonb;
|
|
1776
|
+
v_all jsonb := coalesce(p_formulas, '[]'::jsonb);
|
|
1777
|
+
v_left integer;
|
|
1778
|
+
v_settled integer;
|
|
1779
|
+
v_ready boolean;
|
|
1780
|
+
v_amount numeric;
|
|
1781
|
+
v_part numeric;
|
|
1782
|
+
v_refs text[];
|
|
1783
|
+
v_ref text;
|
|
1784
|
+
v_key text;
|
|
1785
|
+
v_stuck text;
|
|
1786
|
+
f jsonb;
|
|
1787
|
+
begin
|
|
1788
|
+
select count(*) into v_left from jsonb_array_elements(v_all);
|
|
1789
|
+
|
|
1790
|
+
while v_left > 0 loop
|
|
1791
|
+
v_settled := 0;
|
|
1792
|
+
|
|
1793
|
+
for f in
|
|
1794
|
+
select t.x from jsonb_array_elements(v_all) with ordinality as t(x, ord)
|
|
1795
|
+
order by coalesce((t.x ->> 'sequence')::integer, 0), t.ord
|
|
1796
|
+
loop
|
|
1797
|
+
v_key := f ->> 'key';
|
|
1798
|
+
if v_done ? v_key then
|
|
1799
|
+
continue;
|
|
1800
|
+
end if;
|
|
1801
|
+
|
|
1802
|
+
select coalesce(array_agg(e.value), '{}'::text[]) into v_refs
|
|
1803
|
+
from (
|
|
1804
|
+
select jsonb_array_elements_text(coalesce(f -> 'plus', '[]'::jsonb)) as value
|
|
1805
|
+
union all
|
|
1806
|
+
select jsonb_array_elements_text(coalesce(f -> 'minus', '[]'::jsonb))
|
|
1807
|
+
) as e;
|
|
1808
|
+
|
|
1809
|
+
-- Ready when every reference that names another formula has been worked
|
|
1810
|
+
-- out already.
|
|
1811
|
+
v_ready := true;
|
|
1812
|
+
foreach v_ref in array v_refs loop
|
|
1813
|
+
if exists (
|
|
1814
|
+
select 1 from jsonb_array_elements(v_all) as g(x)
|
|
1815
|
+
where (g.x ->> 'key') <> v_key
|
|
1816
|
+
and not (v_done ? (g.x ->> 'key'))
|
|
1817
|
+
and case when strpos(v_ref, ':') > 0
|
|
1818
|
+
then (g.x ->> 'key') = replace(v_ref, ':', '|')
|
|
1819
|
+
else split_part((g.x ->> 'key'), '|', 1) = v_ref
|
|
1820
|
+
end
|
|
1821
|
+
) then
|
|
1822
|
+
v_ready := false;
|
|
1823
|
+
end if;
|
|
1824
|
+
end loop;
|
|
1825
|
+
if not v_ready then
|
|
1826
|
+
continue;
|
|
1827
|
+
end if;
|
|
1828
|
+
|
|
1829
|
+
v_amount := 0;
|
|
1830
|
+
for v_ref in
|
|
1831
|
+
select jsonb_array_elements_text(coalesce(f -> 'plus', '[]'::jsonb))
|
|
1832
|
+
loop
|
|
1833
|
+
select coalesce(sum(e.value::numeric), 0) into v_part
|
|
1834
|
+
from jsonb_each_text(v_values) as e(key, value)
|
|
1835
|
+
where case when strpos(v_ref, ':') > 0
|
|
1836
|
+
then e.key = replace(v_ref, ':', '|')
|
|
1837
|
+
else split_part(e.key, '|', 1) = v_ref
|
|
1838
|
+
end;
|
|
1839
|
+
v_amount := v_amount + v_part;
|
|
1840
|
+
end loop;
|
|
1841
|
+
for v_ref in
|
|
1842
|
+
select jsonb_array_elements_text(coalesce(f -> 'minus', '[]'::jsonb))
|
|
1843
|
+
loop
|
|
1844
|
+
select coalesce(sum(e.value::numeric), 0) into v_part
|
|
1845
|
+
from jsonb_each_text(v_values) as e(key, value)
|
|
1846
|
+
where case when strpos(v_ref, ':') > 0
|
|
1847
|
+
then e.key = replace(v_ref, ':', '|')
|
|
1848
|
+
else split_part(e.key, '|', 1) = v_ref
|
|
1849
|
+
end;
|
|
1850
|
+
v_amount := v_amount - v_part;
|
|
1851
|
+
end loop;
|
|
1852
|
+
|
|
1853
|
+
-- The floor belongs to the pair it splits — the Belgian 71 and 72, the
|
|
1854
|
+
-- French 25 and 28 — so it applies before the sign the caller reads the
|
|
1855
|
+
-- line with.
|
|
1856
|
+
if coalesce((f ->> 'floor_zero')::boolean, false) then
|
|
1857
|
+
v_amount := greatest(v_amount, 0);
|
|
1858
|
+
end if;
|
|
1859
|
+
v_amount := round_amount(v_amount * coalesce((f ->> 'factor')::numeric, 1), p_rounding);
|
|
1860
|
+
|
|
1861
|
+
v_values := v_values || jsonb_build_object(v_key, v_amount);
|
|
1862
|
+
v_done := v_done || jsonb_build_object(v_key, true);
|
|
1863
|
+
if p_keep_zero or v_amount <> 0 then
|
|
1864
|
+
v_out := v_out || jsonb_build_object(v_key, v_amount);
|
|
1865
|
+
end if;
|
|
1866
|
+
v_settled := v_settled + 1;
|
|
1867
|
+
v_left := v_left - 1;
|
|
1868
|
+
end loop;
|
|
1869
|
+
|
|
1870
|
+
if v_settled = 0 then
|
|
1871
|
+
select string_agg(t.x ->> 'key', ', ' order by t.x ->> 'key') into v_stuck
|
|
1872
|
+
from jsonb_array_elements(v_all) as t(x)
|
|
1873
|
+
where not (v_done ? (t.x ->> 'key'));
|
|
1874
|
+
raise exception 'formula_cycle: these totals depend on each other and on nothing else: %', v_stuck;
|
|
1875
|
+
end if;
|
|
1876
|
+
end loop;
|
|
1877
|
+
|
|
1878
|
+
return v_out;
|
|
1879
|
+
end;
|
|
1880
|
+
$$;
|
|
1881
|
+
|
|
1882
|
+
create or replace function financial_statement(
|
|
1883
|
+
p_company_id uuid,
|
|
1884
|
+
p_statement_code text,
|
|
1885
|
+
p_from date,
|
|
1886
|
+
p_to date
|
|
1887
|
+
)
|
|
1888
|
+
returns table (
|
|
1889
|
+
line_code text,
|
|
1890
|
+
parent_code text,
|
|
1891
|
+
name text,
|
|
1892
|
+
sequence integer,
|
|
1893
|
+
is_total boolean,
|
|
1894
|
+
amount numeric,
|
|
1895
|
+
xbrl_element text
|
|
1896
|
+
)
|
|
1897
|
+
language plpgsql
|
|
1898
|
+
stable
|
|
1899
|
+
as $$
|
|
1900
|
+
declare
|
|
1901
|
+
-- line code -> amount: the lines summed from the ledger, then the totals
|
|
1902
|
+
-- `evaluate_totals()` derives from them.
|
|
1903
|
+
v_values jsonb := '{}'::jsonb;
|
|
1904
|
+
v_formulas jsonb := '[]'::jsonb;
|
|
1905
|
+
v_rows jsonb := '[]'::jsonb;
|
|
1906
|
+
-- A statement is the ledger read back, so it is written with the decimals
|
|
1907
|
+
-- of the currency the ledger is kept in.
|
|
1908
|
+
v_round money_rounding;
|
|
1909
|
+
r record;
|
|
1910
|
+
begin
|
|
1911
|
+
if not exists (select 1 from companies c where c.id = p_company_id) then
|
|
1912
|
+
raise exception 'unknown_company: %', p_company_id;
|
|
1913
|
+
end if;
|
|
1914
|
+
v_round := rounding_of(p_company_id);
|
|
1915
|
+
if not exists (select 1 from statement_templates s where s.code = p_statement_code) then
|
|
1916
|
+
raise exception 'unknown_statement: % is not a statement of this installation', p_statement_code;
|
|
1917
|
+
end if;
|
|
1918
|
+
|
|
1919
|
+
-- 1. The lines summed from the ledger. All of them first, whatever their
|
|
1920
|
+
-- place in the scheme: a scheme prints a subtotal above the lines it adds
|
|
1921
|
+
-- up — every balance sheet does — and only a total that names another
|
|
1922
|
+
-- total depends on an order, which the evaluator keeps.
|
|
1923
|
+
select coalesce(jsonb_object_agg(l.code,
|
|
1924
|
+
round_amount(coalesce(s.balance, 0) * l.sign, v_round)), '{}'::jsonb)
|
|
1925
|
+
into v_values
|
|
1926
|
+
from statement_line_templates l
|
|
1927
|
+
left join (
|
|
1928
|
+
select m.line_code, round_amount(sum(m.balance), v_round) as balance
|
|
1929
|
+
from statement_account_matches(p_company_id, p_statement_code, p_from, p_to) m
|
|
1930
|
+
where m.line_code is not null
|
|
1931
|
+
group by m.line_code
|
|
1932
|
+
) s on s.line_code = l.code
|
|
1933
|
+
where l.statement_code = p_statement_code
|
|
1934
|
+
and not l.is_total;
|
|
1935
|
+
|
|
1936
|
+
-- 2. The totals, worked out by the one evaluator the declaration forms use.
|
|
1937
|
+
-- A statement prints its whole frame, so every total comes back, and the
|
|
1938
|
+
-- sign it reads the line with is the factor.
|
|
1939
|
+
select coalesce(jsonb_agg(jsonb_build_object(
|
|
1940
|
+
'key', l.code, 'plus', to_jsonb(l.plus_lines), 'minus', to_jsonb(l.minus_lines),
|
|
1941
|
+
'factor', l.sign, 'sequence', l.sequence
|
|
1942
|
+
) order by l.sequence, l.code), '[]'::jsonb)
|
|
1943
|
+
into v_formulas
|
|
1944
|
+
from statement_line_templates l
|
|
1945
|
+
where l.statement_code = p_statement_code
|
|
1946
|
+
and l.is_total;
|
|
1947
|
+
|
|
1948
|
+
v_values := v_values || evaluate_totals(v_values, v_formulas, v_round, true);
|
|
1949
|
+
|
|
1950
|
+
-- 3. The frame, in the order it is printed. Every line is returned, nil
|
|
1951
|
+
-- included: a statement is read top to bottom and tied out, where a
|
|
1952
|
+
-- declaration prints what it has — which is why `vat_return()` drops a
|
|
1953
|
+
-- nil box and this does not.
|
|
1954
|
+
for r in
|
|
1955
|
+
select l.code, l.parent_code, l.name, l.sequence, l.is_total, l.xbrl_element
|
|
1956
|
+
from statement_line_templates l
|
|
1957
|
+
where l.statement_code = p_statement_code
|
|
1958
|
+
order by l.sequence, l.code
|
|
1959
|
+
loop
|
|
1960
|
+
v_rows := v_rows || jsonb_build_array(jsonb_build_object(
|
|
1961
|
+
'line_code', r.code, 'parent_code', r.parent_code, 'name', r.name,
|
|
1962
|
+
'sequence', r.sequence, 'is_total', r.is_total,
|
|
1963
|
+
'amount', coalesce((v_values ->> r.code)::numeric, 0),
|
|
1964
|
+
'xbrl_element', r.xbrl_element));
|
|
1965
|
+
end loop;
|
|
1966
|
+
|
|
1967
|
+
return query
|
|
1968
|
+
select (x ->> 'line_code')::text,
|
|
1969
|
+
(x ->> 'parent_code')::text,
|
|
1970
|
+
(x ->> 'name')::text,
|
|
1971
|
+
(x ->> 'sequence')::integer,
|
|
1972
|
+
(x ->> 'is_total')::boolean,
|
|
1973
|
+
(x ->> 'amount')::numeric,
|
|
1974
|
+
(x ->> 'xbrl_element')::text
|
|
1975
|
+
from jsonb_array_elements(v_rows) as x;
|
|
1976
|
+
end;
|
|
1977
|
+
$$;
|
|
1978
|
+
|
|
1979
|
+
create or replace function vat_return(
|
|
1980
|
+
p_company_id uuid,
|
|
1981
|
+
p_from date,
|
|
1982
|
+
p_to date,
|
|
1983
|
+
p_report_code text default null
|
|
1984
|
+
)
|
|
1985
|
+
returns table (
|
|
1986
|
+
box text,
|
|
1987
|
+
kind text,
|
|
1988
|
+
amount numeric,
|
|
1989
|
+
computed boolean,
|
|
1990
|
+
name text,
|
|
1991
|
+
sequence integer,
|
|
1992
|
+
hidden boolean,
|
|
1993
|
+
report_code text
|
|
1994
|
+
)
|
|
1995
|
+
language plpgsql
|
|
1996
|
+
stable
|
|
1997
|
+
as $$
|
|
1998
|
+
declare
|
|
1999
|
+
-- 'box|kind' -> amount, for every box summed from the ledger, then the
|
|
2000
|
+
-- totals `evaluate_totals()` derives from them. The key carries the kind
|
|
2001
|
+
-- because the French CA3 puts a base and a tax on line 08 and a formula has
|
|
2002
|
+
-- to be able to name one of them.
|
|
2003
|
+
v_values jsonb := '{}'::jsonb;
|
|
2004
|
+
v_formulas jsonb := '[]'::jsonb;
|
|
2005
|
+
v_totals jsonb := '{}'::jsonb;
|
|
2006
|
+
v_rows jsonb := '[]'::jsonb;
|
|
2007
|
+
v_country char(2);
|
|
2008
|
+
v_report text;
|
|
2009
|
+
v_in char(2);
|
|
2010
|
+
v_count integer;
|
|
2011
|
+
v_codes text;
|
|
2012
|
+
-- A declaration figure is not a ledger figure, but it is written in the
|
|
2013
|
+
-- same currency and with the same decimals.
|
|
2014
|
+
v_round money_rounding;
|
|
2015
|
+
r record;
|
|
2016
|
+
begin
|
|
2017
|
+
select c.fiscal_country into v_country from companies c where c.id = p_company_id;
|
|
2018
|
+
if not found then
|
|
2019
|
+
raise exception 'unknown_company: %', p_company_id;
|
|
2020
|
+
end if;
|
|
2021
|
+
v_round := rounding_of(p_company_id);
|
|
2022
|
+
|
|
2023
|
+
-- Which form. The caller names one, or the country files exactly one on
|
|
2024
|
+
-- that date. Two and no name is a question only the caller can answer — a
|
|
2025
|
+
-- Canadian company files the federal return and the Québec one at once — so
|
|
2026
|
+
-- this asks instead of guessing.
|
|
2027
|
+
if p_report_code is not null then
|
|
2028
|
+
select t.country, t.code into v_in, v_report
|
|
2029
|
+
from tax_report_templates t
|
|
2030
|
+
where t.code = p_report_code
|
|
2031
|
+
and t.valid_from <= p_to
|
|
2032
|
+
and (t.valid_to is null or t.valid_to >= p_to)
|
|
2033
|
+
order by t.country
|
|
2034
|
+
limit 1;
|
|
2035
|
+
if v_report is null then
|
|
2036
|
+
raise exception 'unknown_tax_report: % is not a declaration form in force on %',
|
|
2037
|
+
p_report_code, p_to;
|
|
2038
|
+
end if;
|
|
2039
|
+
else
|
|
2040
|
+
select count(*), min(t.code), string_agg(t.code, ', ' order by t.code)
|
|
2041
|
+
into v_count, v_report, v_codes
|
|
2042
|
+
from tax_report_templates t
|
|
2043
|
+
where t.country = v_country
|
|
2044
|
+
and t.is_periodic_return
|
|
2045
|
+
and t.valid_from <= p_to
|
|
2046
|
+
and (t.valid_to is null or t.valid_to >= p_to);
|
|
2047
|
+
if v_count > 1 then
|
|
2048
|
+
raise exception 'ambiguous_tax_report: % files several declarations on % (%); name one',
|
|
2049
|
+
v_country, p_to, v_codes;
|
|
2050
|
+
end if;
|
|
2051
|
+
v_in := v_country;
|
|
2052
|
+
if v_count = 0 then
|
|
2053
|
+
v_report := null; -- no pack for this country: the ledger boxes, and no total.
|
|
2054
|
+
end if;
|
|
2055
|
+
end if;
|
|
2056
|
+
|
|
2057
|
+
-- 1. What the tax postings wrote on the ledger. Unchanged: this half never
|
|
2058
|
+
-- knew a country.
|
|
2059
|
+
for r in
|
|
2060
|
+
with ledger as (
|
|
2061
|
+
select l.declaration_box as lbox,
|
|
2062
|
+
case when l.tax_line then 'tax' else 'base' end as lkind,
|
|
2063
|
+
round_amount(sum(l.box_amount), v_round) as lamount
|
|
2064
|
+
from entry_lines l
|
|
2065
|
+
join entries e on e.id = l.entry_id
|
|
2066
|
+
where l.company_id = p_company_id
|
|
2067
|
+
and e.state = 'posted'
|
|
2068
|
+
and e.entry_date between p_from and p_to
|
|
2069
|
+
and l.declaration_box is not null
|
|
2070
|
+
-- A box number belongs to one form. A line whose posting names
|
|
2071
|
+
-- another form is not on this declaration; one that names none is
|
|
2072
|
+
-- the single-return case every European company is in.
|
|
2073
|
+
and (v_report is null or coalesce((
|
|
2074
|
+
select min(tp.report_code)
|
|
2075
|
+
from tax_postings tp
|
|
2076
|
+
where tp.tax_id = l.tax_id
|
|
2077
|
+
and tp.declaration_box = l.declaration_box
|
|
2078
|
+
and tp.posting_type =
|
|
2079
|
+
(case when l.tax_line then 'tax' else 'base' end)::tax_posting_type
|
|
2080
|
+
), v_report) = v_report)
|
|
2081
|
+
group by 1, 2
|
|
2082
|
+
having round_amount(sum(l.box_amount), v_round) <> 0
|
|
2083
|
+
)
|
|
2084
|
+
select g.lbox, g.lkind, g.lamount, b.name as lname,
|
|
2085
|
+
b.sequence as lsequence, coalesce(b.hidden, false) as lhidden
|
|
2086
|
+
from ledger g
|
|
2087
|
+
left join tax_report_box_templates b
|
|
2088
|
+
on b.country = v_in and b.report_code = v_report
|
|
2089
|
+
and b.box = g.lbox and b.kind = g.lkind
|
|
2090
|
+
order by coalesce(b.sequence, 2147483647), g.lbox, g.lkind
|
|
2091
|
+
loop
|
|
2092
|
+
v_values := v_values || jsonb_build_object(r.lbox || '|' || r.lkind, r.lamount);
|
|
2093
|
+
v_rows := v_rows || jsonb_build_array(jsonb_build_object(
|
|
2094
|
+
'box', r.lbox, 'kind', r.lkind, 'amount', r.lamount, 'computed', false,
|
|
2095
|
+
'name', r.lname, 'sequence', r.lsequence, 'hidden', r.lhidden,
|
|
2096
|
+
'report_code', v_report));
|
|
2097
|
+
end loop;
|
|
2098
|
+
|
|
2099
|
+
-- 2. The totals of the form, through the evaluator the statements use. A
|
|
2100
|
+
-- return prints what it has, so a nil total is left out of the answer —
|
|
2101
|
+
-- and kept in the working set, so a later total that names it reads a
|
|
2102
|
+
-- zero rather than a gap.
|
|
2103
|
+
if v_report is not null then
|
|
2104
|
+
select coalesce(jsonb_agg(jsonb_build_object(
|
|
2105
|
+
'key', b.box || '|total', 'plus', to_jsonb(b.plus_boxes),
|
|
2106
|
+
'minus', to_jsonb(b.minus_boxes), 'floor_zero', b.floor_zero,
|
|
2107
|
+
'sequence', b.sequence
|
|
2108
|
+
) order by b.sequence, b.box), '[]'::jsonb)
|
|
2109
|
+
into v_formulas
|
|
2110
|
+
from tax_report_box_templates b
|
|
2111
|
+
where b.country = v_in
|
|
2112
|
+
and b.report_code = v_report
|
|
2113
|
+
and b.kind = 'total'
|
|
2114
|
+
and (b.valid_from is null or b.valid_from <= p_to)
|
|
2115
|
+
and (b.valid_to is null or b.valid_to >= p_to);
|
|
2116
|
+
|
|
2117
|
+
v_totals := evaluate_totals(v_values, v_formulas, v_round, false);
|
|
2118
|
+
|
|
2119
|
+
for r in
|
|
2120
|
+
select b.box as tbox, b.name as tname, b.sequence as tsequence, b.hidden as thidden
|
|
2121
|
+
from tax_report_box_templates b
|
|
2122
|
+
where b.country = v_in
|
|
2123
|
+
and b.report_code = v_report
|
|
2124
|
+
and b.kind = 'total'
|
|
2125
|
+
and (b.valid_from is null or b.valid_from <= p_to)
|
|
2126
|
+
and (b.valid_to is null or b.valid_to >= p_to)
|
|
2127
|
+
and v_totals ? (b.box || '|total')
|
|
2128
|
+
order by b.sequence, b.box
|
|
2129
|
+
loop
|
|
2130
|
+
v_rows := v_rows || jsonb_build_array(jsonb_build_object(
|
|
2131
|
+
'box', r.tbox, 'kind', 'total',
|
|
2132
|
+
'amount', (v_totals ->> (r.tbox || '|total'))::numeric, 'computed', true,
|
|
2133
|
+
'name', r.tname, 'sequence', r.tsequence, 'hidden', r.thidden,
|
|
2134
|
+
'report_code', v_report));
|
|
2135
|
+
end loop;
|
|
2136
|
+
end if;
|
|
2137
|
+
|
|
2138
|
+
return query
|
|
2139
|
+
select (x ->> 'box')::text,
|
|
2140
|
+
(x ->> 'kind')::text,
|
|
2141
|
+
(x ->> 'amount')::numeric,
|
|
2142
|
+
(x ->> 'computed')::boolean,
|
|
2143
|
+
(x ->> 'name')::text,
|
|
2144
|
+
(x ->> 'sequence')::integer,
|
|
2145
|
+
(x ->> 'hidden')::boolean,
|
|
2146
|
+
(x ->> 'report_code')::text
|
|
2147
|
+
from jsonb_array_elements(v_rows) as x;
|
|
2148
|
+
end;
|
|
2149
|
+
$$;
|
|
2150
|
+
|
|
2151
|
+
create or replace function fec_lines(
|
|
2152
|
+
p_company_id uuid,
|
|
2153
|
+
p_from date,
|
|
2154
|
+
p_to date
|
|
2155
|
+
)
|
|
2156
|
+
returns table (
|
|
2157
|
+
journal_code text,
|
|
2158
|
+
journal_lib text,
|
|
2159
|
+
ecriture_num text,
|
|
2160
|
+
ecriture_date date,
|
|
2161
|
+
compte_num text,
|
|
2162
|
+
compte_lib text,
|
|
2163
|
+
comp_aux_num text,
|
|
2164
|
+
comp_aux_lib text,
|
|
2165
|
+
piece_ref text,
|
|
2166
|
+
piece_date date,
|
|
2167
|
+
ecriture_lib text,
|
|
2168
|
+
debit numeric,
|
|
2169
|
+
credit numeric,
|
|
2170
|
+
ecriture_let text,
|
|
2171
|
+
date_let date,
|
|
2172
|
+
valid_date date,
|
|
2173
|
+
montant_devise numeric,
|
|
2174
|
+
idevise text
|
|
2175
|
+
)
|
|
2176
|
+
language plpgsql
|
|
2177
|
+
stable
|
|
2178
|
+
as $$
|
|
2179
|
+
declare
|
|
2180
|
+
v_year fiscal_years%rowtype;
|
|
2181
|
+
v_defaults country_defaults%rowtype;
|
|
2182
|
+
v_journal journals%rowtype;
|
|
2183
|
+
v_label text;
|
|
2184
|
+
v_number text;
|
|
2185
|
+
v_result numeric := 0;
|
|
2186
|
+
-- The file is the ledger written out, so the opening lines carry the same
|
|
2187
|
+
-- decimals as the movements below them.
|
|
2188
|
+
v_round money_rounding;
|
|
2189
|
+
v_carried integer := 0;
|
|
2190
|
+
v_role text;
|
|
2191
|
+
v_account uuid;
|
|
2192
|
+
v_result_code text;
|
|
2193
|
+
v_result_name text;
|
|
2194
|
+
v_carries boolean;
|
|
2195
|
+
begin
|
|
2196
|
+
v_round := rounding_of(p_company_id);
|
|
2197
|
+
|
|
2198
|
+
-- 1. Is this the file of a whole financial year? Only then are there
|
|
2199
|
+
-- opening balances to carry.
|
|
2200
|
+
|
|
2201
|
+
select f.* into v_year
|
|
2202
|
+
from fiscal_years f
|
|
2203
|
+
where f.company_id = p_company_id
|
|
2204
|
+
and f.start_date = p_from
|
|
2205
|
+
and f.end_date = p_to;
|
|
2206
|
+
|
|
2207
|
+
if found then
|
|
2208
|
+
-- What stands on the day before the year opens. `trial_balance` is where
|
|
2209
|
+
-- this repository computes a cumulative balance, and computing it a
|
|
2210
|
+
-- second time here is how the two would start to disagree.
|
|
2211
|
+
select coalesce(round_amount(sum(t.opening_balance) filter (where not a.carries_forward),
|
|
2212
|
+
v_round), 0),
|
|
2213
|
+
count(*) filter (where a.carries_forward
|
|
2214
|
+
and round_amount(t.opening_balance, v_round) <> 0)
|
|
2215
|
+
into v_result, v_carried
|
|
2216
|
+
from trial_balance(p_company_id, p_from, p_to) t
|
|
2217
|
+
join accounts a on a.id = t.account_id;
|
|
2218
|
+
|
|
2219
|
+
-- A first set of books has nothing to carry, and asks the pack for
|
|
2220
|
+
-- nothing. Everything below is needed only when there is a balance.
|
|
2221
|
+
if v_carried > 0 or v_result <> 0 then
|
|
2222
|
+
select d.* into v_defaults
|
|
2223
|
+
from companies c
|
|
2224
|
+
join country_defaults d on d.country = c.country
|
|
2225
|
+
where c.id = p_company_id;
|
|
2226
|
+
|
|
2227
|
+
select j.* into v_journal from journals j where j.id = opening_journal_id(p_company_id);
|
|
2228
|
+
if not found then
|
|
2229
|
+
raise exception 'no_opening_journal: the pack of this company names no journal of type opening, and the opening balances of a financial year are booked on one. Set defaults.journal_roles.opening in the pack, which fills country_defaults.opening_journal_code.'
|
|
2230
|
+
using errcode = '55006';
|
|
2231
|
+
end if;
|
|
2232
|
+
|
|
2233
|
+
v_label := coalesce(v_defaults.opening_entry_label, 'Opening balance');
|
|
2234
|
+
|
|
2235
|
+
-- One entry, so the file has one balanced *à-nouveaux* rather than as
|
|
2236
|
+
-- many single-sided entries as there are accounts. The number is built
|
|
2237
|
+
-- from the journal the pack names and the day the year opens, which
|
|
2238
|
+
-- makes it stable across two exports of the same year and distinct
|
|
2239
|
+
-- between years — including two short years inside one calendar year.
|
|
2240
|
+
v_number := v_journal.code || '-' || to_char(v_year.start_date, 'YYYYMMDD');
|
|
2241
|
+
|
|
2242
|
+
-- 2. The result of the years that are not closed yet, on the account
|
|
2243
|
+
-- the close would have left it on. `result_accounts` keeps it on a
|
|
2244
|
+
-- balance-sheet account of its own until a meeting allocates it;
|
|
2245
|
+
-- the other two styles have already reached retained earnings by the
|
|
2246
|
+
-- time the year is over — an appropriation account is inside the
|
|
2247
|
+
-- income statement and the closing entry empties it, so it is never
|
|
2248
|
+
-- what a balance sheet carries forward.
|
|
2249
|
+
if v_result <> 0 then
|
|
2250
|
+
v_role := case v_defaults.closing_style
|
|
2251
|
+
when 'result_accounts' then
|
|
2252
|
+
case when v_result < 0 then v_defaults.current_year_result_profit_code
|
|
2253
|
+
else v_defaults.current_year_result_loss_code end
|
|
2254
|
+
when 'appropriation_accounts' then
|
|
2255
|
+
case when v_result < 0 then v_defaults.retained_earnings_code
|
|
2256
|
+
else coalesce(v_defaults.retained_earnings_loss_code,
|
|
2257
|
+
v_defaults.retained_earnings_code) end
|
|
2258
|
+
when 'retained_earnings' then
|
|
2259
|
+
case when v_result < 0 then v_defaults.retained_earnings_code
|
|
2260
|
+
else coalesce(v_defaults.retained_earnings_loss_code,
|
|
2261
|
+
v_defaults.retained_earnings_code) end
|
|
2262
|
+
end;
|
|
2263
|
+
|
|
2264
|
+
-- A company may name its own retained earnings account, and
|
|
2265
|
+
-- `close_fiscal_year` prefers it for a profit. The same preference
|
|
2266
|
+
-- here, or the two would carry the result to two different places.
|
|
2267
|
+
if v_result < 0 and v_defaults.closing_style is distinct from 'result_accounts' then
|
|
2268
|
+
select c.retained_earnings_account_id into v_account
|
|
2269
|
+
from companies c where c.id = p_company_id;
|
|
2270
|
+
end if;
|
|
2271
|
+
v_account := coalesce(v_account, account_id_by_code(p_company_id, v_role));
|
|
2272
|
+
|
|
2273
|
+
if v_account is null then
|
|
2274
|
+
raise exception 'no_result_account: the books hold a result of % that no year has closed, and the country model of this company names no balance-sheet account to carry it to. Set defaults.closing_style and the account roles it needs in the pack.',
|
|
2275
|
+
to_char(-v_result, amount_text_format(v_round))
|
|
2276
|
+
using errcode = '55006';
|
|
2277
|
+
end if;
|
|
2278
|
+
|
|
2279
|
+
select a.code, a.name, a.carries_forward
|
|
2280
|
+
into v_result_code, v_result_name, v_carries
|
|
2281
|
+
from accounts a where a.id = v_account;
|
|
2282
|
+
|
|
2283
|
+
if not v_carries then
|
|
2284
|
+
raise exception 'no_result_account: % is an income or expense account, and an opening balance is made of the balance sheet. The country model names it for a result that no year has closed; name an account that carries forward.',
|
|
2285
|
+
v_result_code
|
|
2286
|
+
using errcode = '55006';
|
|
2287
|
+
end if;
|
|
2288
|
+
end if;
|
|
2289
|
+
|
|
2290
|
+
-- 3. The lines themselves. The account rows and the result row share
|
|
2291
|
+
-- one shape, so the side is chosen once: a positive balance is a
|
|
2292
|
+
-- debit, and a sign never reaches the file.
|
|
2293
|
+
return query
|
|
2294
|
+
with opening as (
|
|
2295
|
+
select t.account_code as code,
|
|
2296
|
+
t.account_name as name,
|
|
2297
|
+
round_amount(t.opening_balance, v_round) as balance
|
|
2298
|
+
from trial_balance(p_company_id, p_from, p_to) t
|
|
2299
|
+
join accounts a on a.id = t.account_id
|
|
2300
|
+
where a.carries_forward
|
|
2301
|
+
and round_amount(t.opening_balance, v_round) <> 0
|
|
2302
|
+
union all
|
|
2303
|
+
select v_result_code, v_result_name, v_result
|
|
2304
|
+
where v_result <> 0
|
|
2305
|
+
)
|
|
2306
|
+
select v_journal.code,
|
|
2307
|
+
v_journal.name,
|
|
2308
|
+
v_number,
|
|
2309
|
+
p_from,
|
|
2310
|
+
o.code,
|
|
2311
|
+
o.name,
|
|
2312
|
+
null::text,
|
|
2313
|
+
null::text,
|
|
2314
|
+
v_number,
|
|
2315
|
+
p_from,
|
|
2316
|
+
v_label,
|
|
2317
|
+
-- Down to the scale the ledger keeps, so an opening line and a
|
|
2318
|
+
-- movement line come back in the same shape.
|
|
2319
|
+
round_amount(case when o.balance > 0 then o.balance else 0 end, v_round),
|
|
2320
|
+
round_amount(case when o.balance < 0 then -o.balance else 0 end, v_round),
|
|
2321
|
+
null::text,
|
|
2322
|
+
null::date,
|
|
2323
|
+
p_from,
|
|
2324
|
+
null::numeric,
|
|
2325
|
+
null::text
|
|
2326
|
+
from opening o
|
|
2327
|
+
order by o.code;
|
|
2328
|
+
end if;
|
|
2329
|
+
end if;
|
|
2330
|
+
|
|
2331
|
+
-- 4. The movements of the period, as before, less the entries the close
|
|
2332
|
+
-- wrote inside it.
|
|
2333
|
+
|
|
2334
|
+
return query
|
|
2335
|
+
select j.code,
|
|
2336
|
+
j.name,
|
|
2337
|
+
e.number,
|
|
2338
|
+
e.entry_date,
|
|
2339
|
+
a.code,
|
|
2340
|
+
a.name,
|
|
2341
|
+
c.auxiliary_code,
|
|
2342
|
+
case when c.auxiliary_code is not null then c.name end,
|
|
2343
|
+
coalesce(d.number, d.supplier_reference, e.reference, e.number),
|
|
2344
|
+
coalesce(d.document_date, e.entry_date),
|
|
2345
|
+
coalesce(nullif(l.name, ''), e.description, a.name),
|
|
2346
|
+
l.debit,
|
|
2347
|
+
l.credit,
|
|
2348
|
+
l.matching_number,
|
|
2349
|
+
(select max(r.matched_at) from reconciliations r
|
|
2350
|
+
where r.debit_line_id = l.id or r.credit_line_id = l.id),
|
|
2351
|
+
coalesce(e.posted_at::date, e.entry_date),
|
|
2352
|
+
case when l.currency_code is not null and l.currency_code <> co.currency_code
|
|
2353
|
+
then l.amount_currency end,
|
|
2354
|
+
-- `char(3)` where the column of this function is `text`: RETURN
|
|
2355
|
+
-- QUERY matches types exactly, where the SQL body this replaces
|
|
2356
|
+
-- coerced them on the way out.
|
|
2357
|
+
case when l.currency_code is not null and l.currency_code <> co.currency_code
|
|
2358
|
+
then l.currency_code::text end
|
|
2359
|
+
from entry_lines l
|
|
2360
|
+
join entries e on e.id = l.entry_id
|
|
2361
|
+
join journals j on j.id = e.journal_id
|
|
2362
|
+
join accounts a on a.id = l.account_id
|
|
2363
|
+
join companies co on co.id = l.company_id
|
|
2364
|
+
left join contacts c on c.id = l.contact_id
|
|
2365
|
+
left join documents d on d.id = e.document_id
|
|
2366
|
+
where l.company_id = p_company_id
|
|
2367
|
+
and e.state = 'posted'
|
|
2368
|
+
and e.entry_date between p_from and p_to
|
|
2369
|
+
and e.kind not in ('closing', 'appropriation')
|
|
2370
|
+
order by e.entry_date, e.number, l.sequence, l.id;
|
|
2371
|
+
end;
|
|
2372
|
+
$$;
|
|
2373
|
+
|
|
2374
|
+
comment on function aged_balance(uuid, date, text) is
|
|
2375
|
+
'Ageing of what is still open, read from the ledger and from the matching, written at the decimals of the company''s currency. Two groups, receivable and payable; anything else is refused by name.';
|
|
2376
|
+
|
|
2377
|
+
comment on function evaluate_totals(jsonb, jsonb, money_rounding, boolean) is
|
|
2378
|
+
'Works out the totals of a declaration form or a statement from the figures below them, rounding each at the decimals of the currency it is stated in.';
|
|
2379
|
+
|
|
2380
|
+
revoke execute on function evaluate_totals(jsonb, jsonb, money_rounding, boolean) from public, anon;
|
|
2381
|
+
grant execute on function evaluate_totals(jsonb, jsonb, money_rounding, boolean) to authenticated, service_role;
|
|
2382
|
+
|
|
2383
|
+
-- A trigger function runs as the statement that fired it and is called by
|
|
2384
|
+
-- nobody directly, so it is open to nobody.
|
|
2385
|
+
revoke execute on function document_lines_amount_untaxed() from public, anon;
|
|
2386
|
+
|
|
2387
|
+
-- ---------------------------------------------------------------------------
|
|
2388
|
+
-- Two columns stop being a promise
|
|
2389
|
+
--
|
|
2390
|
+
-- `20260913105120` marked five columns "declared, no reader yet", because a
|
|
2391
|
+
-- column with a plausible name and no reader is read as behaviour. Two of the
|
|
2392
|
+
-- five now have one, and `docs/schema.md` is generated from these comments.
|
|
2393
|
+
-- The other three — `cash_rounding_unit`, `bank_statement_formats`,
|
|
2394
|
+
-- `payment_formats` — are still promises and their comments are untouched.
|
|
2395
|
+
-- ---------------------------------------------------------------------------
|
|
2396
|
+
|
|
2397
|
+
comment on column currencies.decimal_places is
|
|
2398
|
+
'Decimals this currency is written with: 2 for the euro, 0 for the yen, 3 for the dinar. Read by rounding_of(), which is what every amount in the schema is rounded at. The columns that hold an amount are still numeric(16, 2), so a currency with more than two decimals is rounded right and stored short until they are widened.';
|
|
2399
|
+
|
|
2400
|
+
comment on column country_defaults.rounding_method is
|
|
2401
|
+
'How a country rounds an amount, from the pack. Read by rounding_of() and applied by round_amount(), which is the only function of the schema that names a method. A pack that says nothing about rounding gets this column''s own default, not a country''s.';
|
|
2402
|
+
|
|
2403
|
+
revoke execute on all functions in schema public from public;
|