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.
Files changed (361) hide show
  1. package/README.md +873 -0
  2. package/dist/args.d.ts +57 -0
  3. package/dist/args.d.ts.map +1 -0
  4. package/dist/args.js +134 -0
  5. package/dist/args.js.map +1 -0
  6. package/dist/assets/expected-objects.json +9432 -0
  7. package/dist/assets/migrations/20260911120000_core_companies.sql +260 -0
  8. package/dist/assets/migrations/20260911120100_accounts_journals.sql +211 -0
  9. package/dist/assets/migrations/20260911120200_contacts.sql +90 -0
  10. package/dist/assets/migrations/20260911120300_taxes.sql +146 -0
  11. package/dist/assets/migrations/20260911120400_entries.sql +338 -0
  12. package/dist/assets/migrations/20260911120500_documents.sql +279 -0
  13. package/dist/assets/migrations/20260911120600_payments_reconciliation.sql +251 -0
  14. package/dist/assets/migrations/20260911120700_bank.sql +178 -0
  15. package/dist/assets/migrations/20260911120800_analytics_attachments.sql +128 -0
  16. package/dist/assets/migrations/20260911120900_post_document.sql +279 -0
  17. package/dist/assets/migrations/20260911121000_reporting.sql +235 -0
  18. package/dist/assets/migrations/20260911121100_country_templates.sql +226 -0
  19. package/dist/assets/migrations/20260911121200_fec.sql +71 -0
  20. package/dist/assets/migrations/20260911130000_instance.sql +77 -0
  21. package/dist/assets/migrations/20260911130100_instance_members.sql +224 -0
  22. package/dist/assets/migrations/20260911140000_instance_admins.sql +112 -0
  23. package/dist/assets/migrations/20260911160000_tax_posting_templates_unique.sql +17 -0
  24. package/dist/assets/migrations/20260911170000_document_amount_paid.sql +82 -0
  25. package/dist/assets/migrations/20260911173000_post_payment.sql +122 -0
  26. package/dist/assets/migrations/20260911173100_sequence_counters_under_rls.sql +84 -0
  27. package/dist/assets/migrations/20260911183000_country_journal_defaults.sql +143 -0
  28. package/dist/assets/migrations/20260911193853_line_account_defaults.sql +274 -0
  29. package/dist/assets/migrations/20260911195054_products.sql +221 -0
  30. package/dist/assets/migrations/20260911210131_anon_surface.sql +85 -0
  31. package/dist/assets/migrations/20260912074712_country_packs.sql +318 -0
  32. package/dist/assets/migrations/20260912080311_report_code_and_region.sql +198 -0
  33. package/dist/assets/migrations/20260912081014_pack_certification_maintained.sql +31 -0
  34. package/dist/assets/migrations/20260912081015_pack_certification_backfill.sql +16 -0
  35. package/dist/assets/migrations/20260912090407_tax_report_boxes.sql +329 -0
  36. package/dist/assets/migrations/20260912091917_tax_on_base_value.sql +25 -0
  37. package/dist/assets/migrations/20260912091918_tax_engine_columns.sql +776 -0
  38. package/dist/assets/migrations/20260912094412_opening_and_closing.sql +759 -0
  39. package/dist/assets/migrations/20260912095825_charts_of_accounts.sql +326 -0
  40. package/dist/assets/migrations/20260912100412_financial_statements.sql +632 -0
  41. package/dist/assets/migrations/20260912104719_one_formula_evaluator.sql +209 -0
  42. package/dist/assets/migrations/20260912105720_entry_kind_appropriation.sql +26 -0
  43. package/dist/assets/migrations/20260912105721_appropriation_entry_kind.sql +444 -0
  44. package/dist/assets/migrations/20260912111751_document_rules.sql +274 -0
  45. package/dist/assets/migrations/20260912112132_cash_basis_vat_and_fx.sql +1189 -0
  46. package/dist/assets/migrations/20260913074512_modules.sql +493 -0
  47. package/dist/assets/migrations/20260913075903_asset_disposal_roles.sql +47 -0
  48. package/dist/assets/migrations/20260913083216_capabilities.sql +637 -0
  49. package/dist/assets/migrations/20260913083901_company_invitations.sql +262 -0
  50. package/dist/assets/migrations/20260913084402_user_preferences.sql +376 -0
  51. package/dist/assets/migrations/20260913084847_company_profile.sql +261 -0
  52. package/dist/assets/migrations/20260913085436_numbering_reads_the_pack.sql +235 -0
  53. package/dist/assets/migrations/20260913085932_api_keys.sql +303 -0
  54. package/dist/assets/migrations/20260913090216_fiscal_year_bounds.sql +159 -0
  55. package/dist/assets/migrations/20260913092527_entries_import.sql +219 -0
  56. package/dist/assets/migrations/20260913101536_null_safe_guards.sql +188 -0
  57. package/dist/assets/migrations/20260913102115_installer_is_named.sql +462 -0
  58. package/dist/assets/migrations/20260913102758_no_currency_default.sql +138 -0
  59. package/dist/assets/migrations/20260913103355_cash_basis_needs_a_box.sql +453 -0
  60. package/dist/assets/migrations/20260913104014_aged_balance_named_group.sql +79 -0
  61. package/dist/assets/migrations/20260913104232_foreign_key_indexes.sql +144 -0
  62. package/dist/assets/migrations/20260913105120_declared_no_reader.sql +41 -0
  63. package/dist/assets/migrations/20260913111407_pack_languages.sql +223 -0
  64. package/dist/assets/migrations/20260913112233_schema_comments_for_a_reader.sql +27 -0
  65. package/dist/assets/migrations/20260913114535_fec_opening_balances.sql +307 -0
  66. package/dist/assets/migrations/20260914103412_audit_log.sql +509 -0
  67. package/dist/assets/migrations/20260914111907_pack_upgrade.sql +428 -0
  68. package/dist/assets/migrations/20260914120500_rounding_reads_the_currency.sql +206 -0
  69. package/dist/assets/migrations/20260914121200_amounts_round_at_the_currency.sql +2403 -0
  70. package/dist/assets/migrations/20260914134325_schema_version_0_2_0.sql +36 -0
  71. package/dist/assets/migrations/20260914143915_accounts_in_use.sql +428 -0
  72. package/dist/assets/migrations/20260914144731_account_code_frozen.sql +99 -0
  73. package/dist/assets/migrations/20260914151207_schema_grants_its_own_rights.sql +284 -0
  74. package/dist/assets/migrations/20260914152840_pack_upgrade_records_its_own_line.sql +237 -0
  75. package/dist/assets/migrations/20260914163943_declaration_periodicity.sql +349 -0
  76. package/dist/assets/migrations/20260915094000_foreign_services_received.sql +37 -0
  77. package/dist/assets/migrations/20260915094500_mentions_read_a_foreign_service.sql +72 -0
  78. package/dist/assets/migrations/20260915153000_document_shares.sql +568 -0
  79. package/dist/assets/migrations/20260915160000_ec_sales_list.sql +181 -0
  80. package/dist/assets/migrations/20260915161842_pack_sources.sql +56 -0
  81. package/dist/assets/migrations/20260915170500_schema_version_0_3_0.sql +45 -0
  82. package/dist/assets/migrations/20260915174500_a_vat_category_is_a_code.sql +177 -0
  83. package/dist/assets/migrations/20260915180000_document_rule_references.sql +85 -0
  84. package/dist/assets/migrations/20260915181000_territories.sql +342 -0
  85. package/dist/assets/migrations/20260915181500_ec_sales_list_reads_the_territories.sql +222 -0
  86. package/dist/assets/migrations/20260915182000_intracom_triangular.sql +53 -0
  87. package/dist/assets/migrations/20260915182500_a_triangular_supply_says_reverse_charge.sql +99 -0
  88. package/dist/assets/migrations/20260915191200_a_document_knows_its_language.sql +633 -0
  89. package/dist/assets/migrations/20260915195000_a_price_that_holds_its_tax.sql +649 -0
  90. package/dist/assets/migrations/20260915200000_a_posting_names_its_boxes.sql +1004 -0
  91. package/dist/assets/migrations/20260916094500_a_company_files_more_than_one_declaration.sql +795 -0
  92. package/dist/assets/migrations/20260916103000_a_ledger_line_names_its_posting.sql +902 -0
  93. package/dist/assets/migrations/20260916123000_a_vocabulary_for_a_tax_that_is_not_a_vat.sql +144 -0
  94. package/dist/assets/migrations/20260916124000_a_box_can_be_a_rate_of_a_box.sql +525 -0
  95. package/dist/assets/migrations/20260916125000_a_tax_follows_the_territory.sql +1318 -0
  96. package/dist/assets/migrations/20260916126000_a_tax_point_and_its_exception.sql +1211 -0
  97. package/dist/assets/migrations/20260917090000_a_counterparty_that_learns.sql +561 -0
  98. package/dist/assets/migrations/20260917120000_what_the_money_pays.sql +545 -0
  99. package/dist/assets/migrations/20260917150000_a_filing_is_a_row.sql +488 -0
  100. package/dist/assets/migrations/20260917170000_when_it_is_due.sql +194 -0
  101. package/dist/assets/migrations/20260917180000_a_box_is_a_number_and_a_kind.sql +213 -0
  102. package/dist/assets/migrations/20260917190000_what_a_declaration_owes.sql +486 -0
  103. package/dist/assets/migrations/20260917200000_what_moved_after_it_went.sql +227 -0
  104. package/dist/assets/migrations/20260918090000_a_form_names_its_file.sql +27 -0
  105. package/dist/assets/migrations/20260918110000_a_deposit_and_what_came_back.sql +286 -0
  106. package/dist/assets/migrations/20260918113741_a_fourth_preset_is_named.sql +9 -0
  107. package/dist/assets/migrations/20260918113807_a_client_reads_and_hands_over.sql +96 -0
  108. package/dist/assets/migrations/20260918114322_two_definer_functions_check_their_caller.sql +75 -0
  109. package/dist/assets/migrations/20260918130000_every_company_somebody_keeps.sql +177 -0
  110. package/dist/assets/migrations/20260918140000_a_session_nobody_prepared.sql +40 -0
  111. package/dist/assets/migrations/20260918141107_a_line_keeps_the_tax_it_was_posted_with.sql +256 -0
  112. package/dist/assets/migrations/20260918141342_a_company_has_an_electronic_address.sql +66 -0
  113. package/dist/assets/migrations/20260918141605_an_invoice_reads_whole_from_the_views.sql +313 -0
  114. package/dist/assets/migrations/20260918141627_a_policy_asks_once.sql +132 -0
  115. package/dist/assets/migrations/20260918143352_a_suggestion_reads_the_words_once.sql +204 -0
  116. package/dist/assets/migrations/20260918143417_a_reference_of_the_caller_and_a_rehearsal.sql +115 -0
  117. package/dist/assets/migrations/20260918150712_a_company_leaves_with_its_books.sql +1119 -0
  118. package/dist/assets/migrations/20260918150931_a_statement_is_imported_once.sql +654 -0
  119. package/dist/assets/migrations/20260918161204_a_posted_document_does_not_move.sql +437 -0
  120. package/dist/assets/migrations/20260918161538_a_posted_entry_does_not_move.sql +252 -0
  121. package/dist/assets/migrations/20260918171946_an_entry_is_posted_by_post_entry.sql +289 -0
  122. package/dist/assets/migrations/20260918174312_schema_version_0_4_0.sql +53 -0
  123. package/dist/assets/modules/assets/module.json +13 -0
  124. package/dist/assets/modules/assets/supabase/migrations/20260913081447_assets.sql +1190 -0
  125. package/dist/assets/modules/assets/supabase/migrations/20260913104234_foreign_key_indexes.sql +47 -0
  126. package/dist/assets/modules/assets/supabase/migrations/20260913104500_assets_capabilities.sql +145 -0
  127. package/dist/assets/modules/assets/supabase/migrations/20260914122100_assets_round_at_the_currency.sql +564 -0
  128. package/dist/assets/modules/assets/supabase/migrations/20260914145012_assets_accounts_in_use.sql +37 -0
  129. package/dist/assets/modules/assets/supabase/migrations/20260914151530_assets_grants_its_own_rights.sql +63 -0
  130. package/dist/assets/modules/assets/supabase/migrations/20260918113912_assets_client_reads.sql +20 -0
  131. package/dist/assets/modules/assets/supabase/migrations/20260918150801_assets_leave_with_the_company.sql +41 -0
  132. package/dist/assets/modules/budgets/module.json +12 -0
  133. package/dist/assets/modules/budgets/supabase/migrations/20260913083012_budgets.sql +210 -0
  134. package/dist/assets/modules/budgets/supabase/migrations/20260913104233_foreign_key_indexes.sql +37 -0
  135. package/dist/assets/modules/budgets/supabase/migrations/20260913104501_budgets_capabilities.sql +72 -0
  136. package/dist/assets/modules/budgets/supabase/migrations/20260914122400_budgets_round_at_the_currency.sql +68 -0
  137. package/dist/assets/modules/budgets/supabase/migrations/20260914145048_budgets_accounts_in_use.sql +30 -0
  138. package/dist/assets/modules/budgets/supabase/migrations/20260914151812_budgets_grants_its_own_rights.sql +31 -0
  139. package/dist/assets/modules/budgets/supabase/migrations/20260918113954_budgets_client_reads.sql +20 -0
  140. package/dist/assets/modules/budgets/supabase/migrations/20260918150823_budgets_leave_with_the_company.sql +37 -0
  141. package/dist/assets/modules/schema/module.1.json +69 -0
  142. package/dist/assets/seed/00_currencies.sql +15 -0
  143. package/dist/assets/seed/00_territories.sql +280 -0
  144. package/dist/assets/seed/05_framework_generic.sql +110 -0
  145. package/dist/assets/seed/10_pack_be.sql +1284 -0
  146. package/dist/assets/seed/11_pack_fr.sql +1112 -0
  147. package/dist/assets/seed/12_pack_lu.sql +2484 -0
  148. package/dist/assets/seed/13_pack_ee.sql +733 -0
  149. package/dist/assets/seed/14_pack_gb.sql +796 -0
  150. package/dist/assets/seed/15_pack_us.sql +751 -0
  151. package/dist/assets/seed/90_demo_company.sql +368 -0
  152. package/dist/assets/seed/modules/assets/10_pack_be.sql +58 -0
  153. package/dist/assets/seed/modules/assets/11_pack_fr.sql +57 -0
  154. package/dist/assets/seed/modules/assets/14_pack_gb.sql +58 -0
  155. package/dist/assets/seed/modules/assets/15_pack_us.sql +60 -0
  156. package/dist/auth.d.ts +59 -0
  157. package/dist/auth.d.ts.map +1 -0
  158. package/dist/auth.js +134 -0
  159. package/dist/auth.js.map +1 -0
  160. package/dist/bin.d.ts +6 -0
  161. package/dist/bin.d.ts.map +1 -0
  162. package/dist/bin.js +8 -0
  163. package/dist/bin.js.map +1 -0
  164. package/dist/books.d.ts +60 -0
  165. package/dist/books.d.ts.map +1 -0
  166. package/dist/books.js +168 -0
  167. package/dist/books.js.map +1 -0
  168. package/dist/bootstrap.d.ts +236 -0
  169. package/dist/bootstrap.d.ts.map +1 -0
  170. package/dist/bootstrap.js +459 -0
  171. package/dist/bootstrap.js.map +1 -0
  172. package/dist/bundle.d.ts +27 -0
  173. package/dist/bundle.d.ts.map +1 -0
  174. package/dist/bundle.js +47 -0
  175. package/dist/bundle.js.map +1 -0
  176. package/dist/checklist.d.ts +35 -0
  177. package/dist/checklist.d.ts.map +1 -0
  178. package/dist/checklist.js +64 -0
  179. package/dist/checklist.js.map +1 -0
  180. package/dist/cli.d.ts +14 -0
  181. package/dist/cli.d.ts.map +1 -0
  182. package/dist/cli.js +318 -0
  183. package/dist/cli.js.map +1 -0
  184. package/dist/commands/company.d.ts +59 -0
  185. package/dist/commands/company.d.ts.map +1 -0
  186. package/dist/commands/company.js +287 -0
  187. package/dist/commands/company.js.map +1 -0
  188. package/dist/commands/contact.d.ts +9 -0
  189. package/dist/commands/contact.d.ts.map +1 -0
  190. package/dist/commands/contact.js +82 -0
  191. package/dist/commands/contact.js.map +1 -0
  192. package/dist/commands/demo.d.ts +19 -0
  193. package/dist/commands/demo.d.ts.map +1 -0
  194. package/dist/commands/demo.js +72 -0
  195. package/dist/commands/demo.js.map +1 -0
  196. package/dist/commands/doctor.d.ts +12 -0
  197. package/dist/commands/doctor.d.ts.map +1 -0
  198. package/dist/commands/doctor.js +68 -0
  199. package/dist/commands/doctor.js.map +1 -0
  200. package/dist/commands/document.d.ts +17 -0
  201. package/dist/commands/document.d.ts.map +1 -0
  202. package/dist/commands/document.js +217 -0
  203. package/dist/commands/document.js.map +1 -0
  204. package/dist/commands/init.d.ts +31 -0
  205. package/dist/commands/init.d.ts.map +1 -0
  206. package/dist/commands/init.js +535 -0
  207. package/dist/commands/init.js.map +1 -0
  208. package/dist/commands/login.d.ts +24 -0
  209. package/dist/commands/login.d.ts.map +1 -0
  210. package/dist/commands/login.js +128 -0
  211. package/dist/commands/login.js.map +1 -0
  212. package/dist/commands/migrate.d.ts +30 -0
  213. package/dist/commands/migrate.d.ts.map +1 -0
  214. package/dist/commands/migrate.js +115 -0
  215. package/dist/commands/migrate.js.map +1 -0
  216. package/dist/commands/module.d.ts +32 -0
  217. package/dist/commands/module.d.ts.map +1 -0
  218. package/dist/commands/module.js +285 -0
  219. package/dist/commands/module.js.map +1 -0
  220. package/dist/commands/pack.d.ts +22 -0
  221. package/dist/commands/pack.d.ts.map +1 -0
  222. package/dist/commands/pack.js +481 -0
  223. package/dist/commands/pack.js.map +1 -0
  224. package/dist/commands/payment.d.ts +15 -0
  225. package/dist/commands/payment.d.ts.map +1 -0
  226. package/dist/commands/payment.js +97 -0
  227. package/dist/commands/payment.js.map +1 -0
  228. package/dist/commands/register.d.ts +20 -0
  229. package/dist/commands/register.d.ts.map +1 -0
  230. package/dist/commands/register.js +134 -0
  231. package/dist/commands/register.js.map +1 -0
  232. package/dist/commands/status.d.ts +8 -0
  233. package/dist/commands/status.d.ts.map +1 -0
  234. package/dist/commands/status.js +134 -0
  235. package/dist/commands/status.js.map +1 -0
  236. package/dist/commands/whoami.d.ts +21 -0
  237. package/dist/commands/whoami.d.ts.map +1 -0
  238. package/dist/commands/whoami.js +133 -0
  239. package/dist/commands/whoami.js.map +1 -0
  240. package/dist/company.d.ts +16 -0
  241. package/dist/company.d.ts.map +1 -0
  242. package/dist/company.js +26 -0
  243. package/dist/company.js.map +1 -0
  244. package/dist/config.d.ts +24 -0
  245. package/dist/config.d.ts.map +1 -0
  246. package/dist/config.js +50 -0
  247. package/dist/config.js.map +1 -0
  248. package/dist/connection.d.ts +79 -0
  249. package/dist/connection.d.ts.map +1 -0
  250. package/dist/connection.js +116 -0
  251. package/dist/connection.js.map +1 -0
  252. package/dist/context.d.ts +47 -0
  253. package/dist/context.d.ts.map +1 -0
  254. package/dist/context.js +93 -0
  255. package/dist/context.js.map +1 -0
  256. package/dist/doctor.d.ts +45 -0
  257. package/dist/doctor.d.ts.map +1 -0
  258. package/dist/doctor.js +448 -0
  259. package/dist/doctor.js.map +1 -0
  260. package/dist/grants.d.ts +88 -0
  261. package/dist/grants.d.ts.map +1 -0
  262. package/dist/grants.js +174 -0
  263. package/dist/grants.js.map +1 -0
  264. package/dist/identity.d.ts +54 -0
  265. package/dist/identity.d.ts.map +1 -0
  266. package/dist/identity.js +132 -0
  267. package/dist/identity.js.map +1 -0
  268. package/dist/index.d.ts +44 -0
  269. package/dist/index.d.ts.map +1 -0
  270. package/dist/index.js +43 -0
  271. package/dist/index.js.map +1 -0
  272. package/dist/inventory.d.ts +155 -0
  273. package/dist/inventory.d.ts.map +1 -0
  274. package/dist/inventory.js +303 -0
  275. package/dist/inventory.js.map +1 -0
  276. package/dist/migrations.d.ts +76 -0
  277. package/dist/migrations.d.ts.map +1 -0
  278. package/dist/migrations.js +226 -0
  279. package/dist/migrations.js.map +1 -0
  280. package/dist/module/read.d.ts +91 -0
  281. package/dist/module/read.d.ts.map +1 -0
  282. package/dist/module/read.js +172 -0
  283. package/dist/module/read.js.map +1 -0
  284. package/dist/output.d.ts +110 -0
  285. package/dist/output.d.ts.map +1 -0
  286. package/dist/output.js +173 -0
  287. package/dist/output.js.map +1 -0
  288. package/dist/pack/certification.d.ts +27 -0
  289. package/dist/pack/certification.d.ts.map +1 -0
  290. package/dist/pack/certification.js +38 -0
  291. package/dist/pack/certification.js.map +1 -0
  292. package/dist/pack/compile.d.ts +75 -0
  293. package/dist/pack/compile.d.ts.map +1 -0
  294. package/dist/pack/compile.js +801 -0
  295. package/dist/pack/compile.js.map +1 -0
  296. package/dist/pack/filing.d.ts +48 -0
  297. package/dist/pack/filing.d.ts.map +1 -0
  298. package/dist/pack/filing.js +56 -0
  299. package/dist/pack/filing.js.map +1 -0
  300. package/dist/pack/read.d.ts +766 -0
  301. package/dist/pack/read.d.ts.map +1 -0
  302. package/dist/pack/read.js +2336 -0
  303. package/dist/pack/read.js.map +1 -0
  304. package/dist/pack/schema.d.ts +26 -0
  305. package/dist/pack/schema.d.ts.map +1 -0
  306. package/dist/pack/schema.js +181 -0
  307. package/dist/pack/schema.js.map +1 -0
  308. package/dist/pack/territories.d.ts +98 -0
  309. package/dist/pack/territories.d.ts.map +1 -0
  310. package/dist/pack/territories.js +0 -0
  311. package/dist/pack/territories.js.map +1 -0
  312. package/dist/pack/upgrade.d.ts +86 -0
  313. package/dist/pack/upgrade.d.ts.map +1 -0
  314. package/dist/pack/upgrade.js +76 -0
  315. package/dist/pack/upgrade.js.map +1 -0
  316. package/dist/pack/vat-codes.d.ts +273 -0
  317. package/dist/pack/vat-codes.d.ts.map +1 -0
  318. package/dist/pack/vat-codes.js +513 -0
  319. package/dist/pack/vat-codes.js.map +1 -0
  320. package/dist/profiles.d.ts +67 -0
  321. package/dist/profiles.d.ts.map +1 -0
  322. package/dist/profiles.js +187 -0
  323. package/dist/profiles.js.map +1 -0
  324. package/dist/prompt.d.ts +40 -0
  325. package/dist/prompt.d.ts.map +1 -0
  326. package/dist/prompt.js +120 -0
  327. package/dist/prompt.js.map +1 -0
  328. package/dist/registry.d.ts +84 -0
  329. package/dist/registry.d.ts.map +1 -0
  330. package/dist/registry.js +104 -0
  331. package/dist/registry.js.map +1 -0
  332. package/dist/rest.d.ts +81 -0
  333. package/dist/rest.d.ts.map +1 -0
  334. package/dist/rest.js +245 -0
  335. package/dist/rest.js.map +1 -0
  336. package/dist/schema.d.ts +15 -0
  337. package/dist/schema.d.ts.map +1 -0
  338. package/dist/schema.js +15 -0
  339. package/dist/schema.js.map +1 -0
  340. package/dist/seeds.d.ts +27 -0
  341. package/dist/seeds.d.ts.map +1 -0
  342. package/dist/seeds.js +47 -0
  343. package/dist/seeds.js.map +1 -0
  344. package/dist/session.d.ts +39 -0
  345. package/dist/session.d.ts.map +1 -0
  346. package/dist/session.js +92 -0
  347. package/dist/session.js.map +1 -0
  348. package/dist/sql.d.ts +48 -0
  349. package/dist/sql.d.ts.map +1 -0
  350. package/dist/sql.js +112 -0
  351. package/dist/sql.js.map +1 -0
  352. package/dist/status.d.ts +94 -0
  353. package/dist/status.d.ts.map +1 -0
  354. package/dist/status.js +120 -0
  355. package/dist/status.js.map +1 -0
  356. package/dist/ui.d.ts +55 -0
  357. package/dist/ui.d.ts.map +1 -0
  358. package/dist/ui.js +114 -0
  359. package/dist/ui.js.map +1 -0
  360. package/package.json +29 -0
  361. 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;