@fayz-ai/db 0.11.0 → 0.13.0

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 (119) hide show
  1. package/canonical-schema.json +49 -0
  2. package/migrations/010_migration_ledger.sql +11 -1
  3. package/migrations/037_sync_tick_one_at_a_time.sql +256 -0
  4. package/migrations/038_onboarding_responses.sql +103 -0
  5. package/migrations/039_unit_tree.sql +270 -0
  6. package/migrations/040_resource_grants.sql +474 -0
  7. package/migrations/041_scoped_columns.sql +192 -0
  8. package/migrations/042_unit_scope_policies.sql +145 -0
  9. package/migrations/043_view_invoker.sql +90 -0
  10. package/migrations/044_unit_member_facts.sql +47 -0
  11. package/migrations/045_unit_entry.sql +236 -0
  12. package/migrations/046_membership_visible_to_members.sql +85 -0
  13. package/migrations/047_tasks.sql +266 -0
  14. package/migrations/048_every_login_is_a_person.sql +190 -0
  15. package/migrations/049_bookable_people.sql +126 -0
  16. package/migrations/050_authz_core.sql +230 -0
  17. package/migrations/051_authz_helpers.sql +330 -0
  18. package/migrations/052_authz_fanout.sql +253 -0
  19. package/migrations/053_authz_catalog_templates.sql +402 -0
  20. package/migrations/060_tenancy_seam.sql +132 -0
  21. package/migrations/061_create_tenant.sql +125 -0
  22. package/migrations/062_invitations.sql +329 -0
  23. package/migrations/063_legacy_tenancy_retired.sql +568 -0
  24. package/migrations/064_agent_guard_authz.sql +118 -0
  25. package/migrations/064b_supersede_unit_scope.sql +205 -0
  26. package/migrations/065_scaffold_sweep.sql +139 -0
  27. package/migrations/066_shadow_report.sql +122 -0
  28. package/migrations/070_permission_catalog.sql +205 -0
  29. package/migrations/071_v1_permission_map.sql +266 -0
  30. package/migrations/072_role_rpcs.sql +126 -0
  31. package/migrations/073_legacy_rbac_retire.sql +471 -0
  32. package/migrations/075_company_legal_entities.sql +169 -0
  33. package/migrations/076_tenant_settings.sql +251 -0
  34. package/migrations/080_people_roles.sql +269 -0
  35. package/migrations/081_people_identity.sql +271 -0
  36. package/migrations/082_people_membership_link.sql +142 -0
  37. package/migrations/083_people_data_migration.sql +75 -0
  38. package/migrations/084_people_registrations_tokens.sql +282 -0
  39. package/migrations/085_catalog_products.sql +333 -0
  40. package/migrations/086_catalog_pricing.sql +194 -0
  41. package/migrations/087_catalog_packages.sql +134 -0
  42. package/migrations/090_orders_venda.sql +166 -0
  43. package/migrations/091_orders_scaffold.sql +92 -0
  44. package/migrations/095_config_field_rules.sql +261 -0
  45. package/migrations/096_audit_append_only.sql +256 -0
  46. package/migrations/097_sequences_authorized.sql +111 -0
  47. package/migrations/098_holidays.sql +80 -0
  48. package/migrations/099_access_grants.sql +293 -0
  49. package/migrations/100_migration_schema.sql +217 -0
  50. package/migrations/101_migration_allowlist.sql +202 -0
  51. package/migrations/102_migration_rpcs.sql +933 -0
  52. package/migrations/103_migration_reconcile_ponr.sql +132 -0
  53. package/migrations/105_policies_to_authenticated.sql +71 -0
  54. package/migrations/106_function_search_path.sql +55 -0
  55. package/migrations/107_core_table_prefix.sql +361 -0
  56. package/migrations/108_authz_write_guards.sql +149 -0
  57. package/migrations/109_catalog_migration_promoted_columns.sql +126 -0
  58. package/migrations/110_people_kind_vocabulary.sql +120 -0
  59. package/migrations/111_migration_excluded_is_a_decision.sql +259 -0
  60. package/migrations/111_replacing_a_view_keeps_its_dependents.sql +132 -0
  61. package/migrations/112_money_precision_standard.sql +132 -0
  62. package/migrations/113_addresses_units_companies.sql +114 -0
  63. package/migrations/114_migrations_ledger_immutable.sql +65 -0
  64. package/migrations/115_registration_honours_field_rules.sql +172 -0
  65. package/migrations/116_field_rules_check_needs_a_tenant.sql +72 -0
  66. package/migrations/117_authz_reconciliation_schedule.sql +82 -0
  67. package/migrations/118_migration_catalog_writers.sql +450 -0
  68. package/migrations/119_migration_config_writers.sql +193 -0
  69. package/migrations/120_migration_facet_writer.sql +139 -0
  70. package/migrations/121_migration_financial_writers.sql +294 -0
  71. package/migrations/122_migration_inventory_writers.sql +245 -0
  72. package/migrations/123_migration_order_item_writer.sql +144 -0
  73. package/migrations/124_migration_ledger_writers.sql +170 -0
  74. package/migrations/125_migration_authz_people_writers.sql +205 -0
  75. package/migrations/126_owner_scoping_is_configuration.sql +247 -0
  76. package/migrations/127_scaffold_audit_hook.sql +98 -0
  77. package/migrations/128_shadow_tables_enforced.sql +93 -0
  78. package/migrations/129_order_kind_terminal_states.sql +57 -0
  79. package/migrations/130_every_login_is_a_person.sql +196 -0
  80. package/migrations/131_migration_invoice_split.sql +127 -0
  81. package/migrations/132_audit_prune_scheduled.sql +28 -0
  82. package/migrations/133_unit_is_a_session_property.sql +155 -0
  83. package/migrations/134_field_rules_on_read.sql +341 -0
  84. package/migrations/135_distribution_polarity.sql +158 -0
  85. package/migrations/136_unit_id_means_locality.sql +358 -0
  86. package/migrations/137_vendor_named_spine_objects.sql +126 -0
  87. package/migrations/138_grants_inherit_down_the_tree.sql +178 -0
  88. package/migrations/139_distributable_is_a_scaffold_option.sql +109 -0
  89. package/migrations/140_masking_is_a_scaffold_option.sql +85 -0
  90. package/migrations/141_renamed_objects_keep_a_compat_name.sql +192 -0
  91. package/migrations/142_a_person_has_addresses.sql +212 -0
  92. package/migrations/143_a_professional_has_a_rate.sql +75 -0
  93. package/migrations/144_a_product_can_be_sold_in_sizes.sql +57 -0
  94. package/migrations/145_the_app_says_which_tenant_it_is_in.sql +123 -0
  95. package/migrations/146_products_stock_has_one_writer.sql +67 -0
  96. package/migrations/147_every_archetype_has_a_kind_registry.sql +388 -0
  97. package/migrations/148_the_legacy_archetype_stops_being_writable.sql +154 -0
  98. package/migrations/149_the_event_log_gets_a_consumer.sql +331 -0
  99. package/migrations/150_the_app_is_configured_in_the_database.sql +285 -0
  100. package/migrations/151_stock_balance_is_an_archetype.sql +144 -0
  101. package/migrations/152_a_file_is_an_archetype.sql +222 -0
  102. package/migrations/153_a_plugin_calls_a_service_through_one_door.sql +199 -0
  103. package/migrations/154_the_plan_is_a_ceiling.sql +258 -0
  104. package/migrations/155_a_tenant_can_leave.sql +271 -0
  105. package/migrations/156_the_service_door_has_a_server_side.sql +335 -0
  106. package/migrations/157_nobody_creates_objects_in_public.sql +74 -0
  107. package/migrations/158_the_registry_owns_the_status_vocabulary.sql +153 -0
  108. package/migrations/159_the_plan_catalogue_gets_its_first_rows.sql +132 -0
  109. package/migrations/160_the_vertical_decides_what_starts_on.sql +246 -0
  110. package/migrations/161_a_tenant_leaves_with_its_data.sql +363 -0
  111. package/migrations/162_every_name_has_one_creator_for_real.sql +126 -0
  112. package/migrations/163_a_species_nobody_declared_goes_to_quarantine.sql +184 -0
  113. package/migrations/164_the_sequence_table_joins_the_register.sql +26 -0
  114. package/migrations/165_a_view_is_not_a_way_in.sql +90 -0
  115. package/migrations/166_the_platform_billing_leftovers_go.sql +72 -0
  116. package/migrations/167_a_view_gets_back_what_its_table_grants.sql +112 -0
  117. package/migrations/168_the_grant_census_stops_crying_wolf.sql +33 -0
  118. package/migrations/169_the_census_asks_postgres_instead_of_guessing.sql +45 -0
  119. package/package.json +6 -5
@@ -0,0 +1,245 @@
1
+ -- 122_migration_inventory_writers.sql
2
+ --
3
+ -- Inventory gets its writers, and positions get told the truth. #143 / #148.
4
+ --
5
+ -- Every inventory row in the allowlist had `writer = NULL`. Three of them are
6
+ -- plain reference data (measurement units, their conversions, stock locations)
7
+ -- and one is the ledger itself.
8
+ --
9
+ -- POSITIONS ARE NOT MIGRATED, AND THAT IS THE POINT OF THE SLICE. In V2 a
10
+ -- position is DERIVED: `plg_inventory_stock_movements` is the ledger, and the
11
+ -- after-insert trigger of `007` maintains `plg_inventory_stock_positions` from
12
+ -- it. Writing V1 positions in directly would fight the derivation and produce a
13
+ -- balance that agrees with nothing — the first movement after cutover would
14
+ -- recompute it away. So `stock_positions` becomes `verify_only`, exactly like
15
+ -- `client_credits`: the extractor offers it, nothing is written, and
16
+ -- reconciliation compares the V1 balance against the one V2 derived. That is a
17
+ -- stronger check than copying the number would have been, because it proves the
18
+ -- movements add up.
19
+ --
20
+ -- The movement writer declares itself internal. `005`'s single-writer gate
21
+ -- refuses any write to the ledger or to positions unless
22
+ -- `fayz.inventory_internal` is on, and it is right to: "however privileged" is
23
+ -- the phrase in its own message. The migration is one of the internal writers —
24
+ -- it is definer-only, fenced, and audited — so it sets the flag for its
25
+ -- statement and lets the derivation run, rather than being exempted from it.
26
+ --
27
+ -- THREE MAPPINGS NAMED COLUMNS THAT DO NOT EXIST, because `005` renamed them and
28
+ -- 091 was written against the shape before that: `stock_location_id` is
29
+ -- `source_location_id` on movements (and stays `stock_location_id` on positions),
30
+ -- `destination_stock_location_id` is `destination_location_id`, and V1's
31
+ -- `movement_type` is `kind` with a closed vocabulary (in/out/transfer/adjust/
32
+ -- reverse) that is now validated instead of trusted.
33
+ --
34
+ -- TWO TABLES ARE DEFERRED because their destination was never built:
35
+ -- `plg_inventory_stock_unit_labels` and `plg_inventory_supplier_products` do not
36
+ -- exist anywhere in the repo (#143 found this). Creating them here would mean
37
+ -- inventing columns PRD 06 R5 names but does not specify.
38
+ --
39
+ -- Replay-safe: `_allow` upserts under the documented unlock; writers are
40
+ -- CREATE OR REPLACE.
41
+
42
+ ALTER TABLE migration.allowlist DROP CONSTRAINT IF EXISTS allowlist_writer_check;
43
+ ALTER TABLE migration.allowlist ADD CONSTRAINT allowlist_writer_check
44
+ CHECK (writer IS NULL OR writer IN ('person', 'unit', 'category', 'product', 'order', 'invoice',
45
+ 'price_table', 'price_table_item', 'package', 'package_item',
46
+ 'field_rule', 'holiday', 'facet',
47
+ 'fin_account', 'fin_payment_method_type', 'fin_payment_method',
48
+ 'fin_card_brand', 'fin_chart_account', 'fin_cost_center',
49
+ 'inv_measurement_unit', 'inv_unit_conversion', 'inv_location', 'inv_movement'));
50
+
51
+ DO $unlock$
52
+ BEGIN
53
+ PERFORM set_config('migration.allowlist_unlock', 'on', true);
54
+
55
+ UPDATE migration.allowlist SET writer = 'inv_measurement_unit' WHERE source_table = 'measurement_units';
56
+ UPDATE migration.allowlist SET writer = 'inv_unit_conversion' WHERE source_table = 'unit_conversions';
57
+ UPDATE migration.allowlist SET writer = 'inv_location' WHERE source_table = 'stock_locations';
58
+
59
+ UPDATE migration.allowlist SET writer = 'inv_movement',
60
+ columns = '{"uuid":"id","product_id":"product_id@products","stock_location_id":"source_location_id@stock_locations","destination_stock_location_id":"destination_location_id@stock_locations","movement_type":"kind","quantity":"quantity","unit_cost":"unit_cost","total_cost":"total_cost","batch_number":"batch_number","expiration_date":"expiration_date","document_number":"document_number","reason":"reason","notes":"notes","movement_date":"movement_date","supplier_id":"jsonb:v1_supplier_ref"}'::jsonb,
61
+ notes = 'PRD 06 R1: the append-only ledger. 005 renamed stock_location_id -> source_location_id and movement_type -> kind; 091 was written against the shape before that (#112).'
62
+ WHERE source_table = 'stock_movements';
63
+
64
+ -- positions are DERIVED from the ledger in V2; copying V1''s numbers in would
65
+ -- fight the derivation, so reconciliation compares them instead
66
+ UPDATE migration.allowlist SET writer = NULL, writer_args = '{"verify_only":true}'::jsonb,
67
+ notes = 'PRD 06 R1: NOT migrated. A position is derived from plg_inventory_stock_movements by 007''s trigger; writing V1 balances in directly would fight that and the first movement after cutover would recompute them away. Reconciliation compares the V1 balance against the derived one, which proves the movements add up (#112).'
68
+ WHERE source_table = 'stock_positions';
69
+
70
+ DELETE FROM migration.allowlist WHERE source_table IN ('stock_unit_labels', 'supplier_product_mappings',
71
+ 'discount_reasons', 'discount_users',
72
+ 'cancellation_reasons', 'cancellation_users');
73
+ END $unlock$;
74
+
75
+ INSERT INTO migration.excluded_tables (source_table, reason) VALUES
76
+ ('stock_unit_labels', 'Deferred: public.plg_inventory_stock_unit_labels does not exist anywhere in the repo. PRD 06 R5 names the concept without specifying it, so building the table here would mean inventing its columns (#143).'),
77
+ ('discount_reasons', 'Deferred: 091 pointed it at `config.orders`, a schema that never shipped. PRD 04 R7 says a discount reason is configuration of the owning plugin (orders/financial) — and no such table was built, so there is nowhere to put it. A tenant re-declares its reasons in V2 (#112).'),
78
+ ('discount_users', 'Deferred with discount_reasons: who may grant a discount is a permission in V2 (app.role_permissions), not a row (#112).'),
79
+ ('cancellation_reasons', 'Deferred: same as discount_reasons — configuration with no destination contract (#112).'),
80
+ ('cancellation_users', 'Deferred with cancellation_reasons: who may cancel is a permission in V2, not a row (#112).'),
81
+ ('supplier_product_mappings', 'Deferred: public.plg_inventory_supplier_products does not exist. Same as stock_unit_labels — named by PRD 06 R5, never specified. The supplier itself IS migrated, as a Person with the supplier role (#143).')
82
+ ON CONFLICT (source_table) DO UPDATE SET reason = EXCLUDED.reason;
83
+
84
+ CREATE OR REPLACE FUNCTION migration.upsert_inv_measurement_unit(p_tenant uuid, p_id uuid, p_row jsonb, p_args jsonb DEFAULT '{}'::jsonb)
85
+ RETURNS jsonb LANGUAGE plpgsql SECURITY DEFINER SET search_path = '' AS $$
86
+ DECLARE v_existing uuid; v_created boolean := false; v_standalone boolean := migration._standalone();
87
+ BEGIN
88
+ IF v_standalone THEN PERFORM migration._assert_fence(p_tenant); PERFORM set_config('migration.writer', 'on', true); END IF;
89
+ IF p_id IS NULL OR nullif(btrim(coalesce(p_row ->> 'name', '')), '') IS NULL THEN
90
+ RETURN jsonb_build_object('status', 'quarantined', 'kind', 'state', 'reason', 'a measurement unit needs an id and a name');
91
+ END IF;
92
+ SELECT tenant_id INTO v_existing FROM public.plg_inventory_measurement_units WHERE id = p_id;
93
+ IF v_existing IS NOT NULL AND v_existing <> p_tenant THEN
94
+ RETURN jsonb_build_object('status', 'quarantined', 'kind', 'tenant', 'reason', format('measurement unit %s already exists in another tenant', p_id));
95
+ END IF;
96
+ IF v_existing IS NULL THEN
97
+ INSERT INTO public.plg_inventory_measurement_units (id, tenant_id, name, abbreviation, is_active)
98
+ VALUES (p_id, p_tenant, p_row ->> 'name', p_row ->> 'abbreviation', coalesce((p_row ->> 'is_active')::boolean, true));
99
+ v_created := true;
100
+ ELSE
101
+ UPDATE public.plg_inventory_measurement_units SET
102
+ name = p_row ->> 'name',
103
+ abbreviation = CASE WHEN p_row ? 'abbreviation' THEN p_row ->> 'abbreviation' ELSE abbreviation END,
104
+ is_active = CASE WHEN p_row ? 'is_active' THEN coalesce((p_row ->> 'is_active')::boolean, true) ELSE is_active END
105
+ WHERE id = p_id;
106
+ END IF;
107
+ IF v_standalone THEN PERFORM migration._audit(p_tenant, 'public.plg_inventory_measurement_units', p_id, jsonb_build_object('reason', CASE WHEN v_created THEN 'created' ELSE 'updated' END)); END IF;
108
+ RETURN jsonb_build_object('status', 'migrated', 'target_id', p_id, 'reason', CASE WHEN v_created THEN 'created' ELSE 'updated' END);
109
+ END $$;
110
+
111
+ CREATE OR REPLACE FUNCTION migration.upsert_inv_unit_conversion(p_tenant uuid, p_id uuid, p_row jsonb, p_args jsonb DEFAULT '{}'::jsonb)
112
+ RETURNS jsonb LANGUAGE plpgsql SECURITY DEFINER SET search_path = '' AS $$
113
+ DECLARE v_existing uuid; v_created boolean := false; v_standalone boolean := migration._standalone();
114
+ BEGIN
115
+ IF v_standalone THEN PERFORM migration._assert_fence(p_tenant); PERFORM set_config('migration.writer', 'on', true); END IF;
116
+ IF p_id IS NULL OR (p_row ->> 'from_unit_id') IS NULL OR (p_row ->> 'to_unit_id') IS NULL THEN
117
+ RETURN jsonb_build_object('status', 'quarantined', 'kind', 'fk', 'reason', 'a conversion needs both of its units');
118
+ END IF;
119
+ IF coalesce((p_row ->> 'factor')::numeric, 0) <= 0 THEN
120
+ RETURN jsonb_build_object('status', 'quarantined', 'kind', 'value', 'reason', 'a conversion factor must be > 0 — a zero factor would make every converted quantity vanish');
121
+ END IF;
122
+ SELECT tenant_id INTO v_existing FROM public.plg_inventory_unit_conversions WHERE id = p_id;
123
+ IF v_existing IS NOT NULL AND v_existing <> p_tenant THEN
124
+ RETURN jsonb_build_object('status', 'quarantined', 'kind', 'tenant', 'reason', format('conversion %s already exists in another tenant', p_id));
125
+ END IF;
126
+ IF v_existing IS NULL THEN
127
+ INSERT INTO public.plg_inventory_unit_conversions (id, tenant_id, from_unit_id, to_unit_id, factor, product_id)
128
+ VALUES (p_id, p_tenant, (p_row ->> 'from_unit_id')::uuid, (p_row ->> 'to_unit_id')::uuid,
129
+ (p_row ->> 'factor')::numeric, (p_row ->> 'product_id')::uuid);
130
+ v_created := true;
131
+ ELSE
132
+ UPDATE public.plg_inventory_unit_conversions SET
133
+ from_unit_id = (p_row ->> 'from_unit_id')::uuid, to_unit_id = (p_row ->> 'to_unit_id')::uuid,
134
+ factor = (p_row ->> 'factor')::numeric
135
+ WHERE id = p_id;
136
+ END IF;
137
+ IF v_standalone THEN PERFORM migration._audit(p_tenant, 'public.plg_inventory_unit_conversions', p_id, jsonb_build_object('reason', CASE WHEN v_created THEN 'created' ELSE 'updated' END)); END IF;
138
+ RETURN jsonb_build_object('status', 'migrated', 'target_id', p_id, 'reason', CASE WHEN v_created THEN 'created' ELSE 'updated' END);
139
+ END $$;
140
+
141
+ CREATE OR REPLACE FUNCTION migration.upsert_inv_location(p_tenant uuid, p_id uuid, p_row jsonb, p_args jsonb DEFAULT '{}'::jsonb)
142
+ RETURNS jsonb LANGUAGE plpgsql SECURITY DEFINER SET search_path = '' AS $$
143
+ DECLARE v_existing uuid; v_created boolean := false; v_standalone boolean := migration._standalone();
144
+ BEGIN
145
+ IF v_standalone THEN PERFORM migration._assert_fence(p_tenant); PERFORM set_config('migration.writer', 'on', true); END IF;
146
+ IF p_id IS NULL OR nullif(btrim(coalesce(p_row ->> 'name', '')), '') IS NULL THEN
147
+ RETURN jsonb_build_object('status', 'quarantined', 'kind', 'state', 'reason', 'a stock location needs an id and a name');
148
+ END IF;
149
+ -- PRD 06 R1: a location belongs to a unit. V1 rows without one cannot be placed.
150
+ IF (p_row ->> 'unit_id') IS NULL THEN
151
+ RETURN jsonb_build_object('status', 'quarantined', 'kind', 'fk', 'reason', 'a stock location needs its unit (PRD 06 R1: unit_id is mandatory)');
152
+ END IF;
153
+ SELECT tenant_id INTO v_existing FROM public.plg_inventory_stock_locations WHERE id = p_id;
154
+ IF v_existing IS NOT NULL AND v_existing <> p_tenant THEN
155
+ RETURN jsonb_build_object('status', 'quarantined', 'kind', 'tenant', 'reason', format('stock location %s already exists in another tenant', p_id));
156
+ END IF;
157
+ IF v_existing IS NULL THEN
158
+ INSERT INTO public.plg_inventory_stock_locations (id, tenant_id, name, description, unit_id, is_active)
159
+ VALUES (p_id, p_tenant, p_row ->> 'name', p_row ->> 'description', (p_row ->> 'unit_id')::uuid,
160
+ coalesce((p_row ->> 'is_active')::boolean, true));
161
+ v_created := true;
162
+ ELSE
163
+ UPDATE public.plg_inventory_stock_locations SET
164
+ name = p_row ->> 'name',
165
+ description = CASE WHEN p_row ? 'description' THEN p_row ->> 'description' ELSE description END,
166
+ unit_id = (p_row ->> 'unit_id')::uuid,
167
+ is_active = CASE WHEN p_row ? 'is_active' THEN coalesce((p_row ->> 'is_active')::boolean, true) ELSE is_active END
168
+ WHERE id = p_id;
169
+ END IF;
170
+ IF v_standalone THEN PERFORM migration._audit(p_tenant, 'public.plg_inventory_stock_locations', p_id, jsonb_build_object('reason', CASE WHEN v_created THEN 'created' ELSE 'updated' END)); END IF;
171
+ RETURN jsonb_build_object('status', 'migrated', 'target_id', p_id, 'reason', CASE WHEN v_created THEN 'created' ELSE 'updated' END);
172
+ END $$;
173
+
174
+ CREATE OR REPLACE FUNCTION migration.upsert_inv_movement(p_tenant uuid, p_id uuid, p_row jsonb, p_args jsonb DEFAULT '{}'::jsonb)
175
+ RETURNS jsonb LANGUAGE plpgsql SECURITY DEFINER SET search_path = '' AS $$
176
+ DECLARE
177
+ v_kind_raw text := nullif(btrim(lower(coalesce(p_row ->> 'kind', ''))), '');
178
+ v_kind text := CASE v_kind_raw
179
+ WHEN 'in' THEN 'in' WHEN 'out' THEN 'out'
180
+ WHEN 'transfer' THEN 'transfer' WHEN 'adjust' THEN 'adjust' WHEN 'reverse' THEN 'reverse'
181
+ -- the SDK's legacy labels, and V1's pt-BR
182
+ WHEN 'entry' THEN 'in' WHEN 'entrada' THEN 'in'
183
+ WHEN 'exit' THEN 'out' WHEN 'saida' THEN 'out' WHEN 'saída' THEN 'out'
184
+ WHEN 'loss' THEN 'out' WHEN 'perda' THEN 'out'
185
+ WHEN 'adjustment' THEN 'adjust' WHEN 'ajuste' THEN 'adjust'
186
+ WHEN 'transferencia' THEN 'transfer' WHEN 'transferência' THEN 'transfer'
187
+ END;
188
+ v_meta jsonb := coalesce(p_row -> 'metadata', '{}'::jsonb);
189
+ v_exists boolean;
190
+ v_standalone boolean := migration._standalone();
191
+ BEGIN
192
+ IF v_standalone THEN PERFORM migration._assert_fence(p_tenant); PERFORM set_config('migration.writer', 'on', true); END IF;
193
+ IF p_id IS NULL OR (p_row ->> 'product_id') IS NULL THEN
194
+ RETURN jsonb_build_object('status', 'quarantined', 'kind', 'fk', 'reason', 'a stock movement needs an id and its product');
195
+ END IF;
196
+ IF v_kind IS NULL THEN
197
+ RETURN jsonb_build_object('status', 'quarantined', 'kind', 'value',
198
+ 'reason', format('unknown movement type %L (expected in|out|transfer|adjust|reverse, or a V1 label of one)', coalesce(v_kind_raw, '<null>')));
199
+ END IF;
200
+ IF coalesce((p_row ->> 'quantity')::numeric, 0) <= 0 THEN
201
+ RETURN jsonb_build_object('status', 'quarantined', 'kind', 'value', 'reason', 'a stock movement needs a quantity > 0 — direction is the kind, not the sign');
202
+ END IF;
203
+ IF v_kind = 'transfer' AND (p_row ->> 'destination_location_id') IS NULL THEN
204
+ RETURN jsonb_build_object('status', 'quarantined', 'kind', 'state', 'reason', 'a transfer needs its destination location');
205
+ END IF;
206
+
207
+ SELECT EXISTS (SELECT 1 FROM public.plg_inventory_stock_movements WHERE id = p_id) INTO v_exists;
208
+ IF v_exists THEN
209
+ -- the ledger is append-only: a second offer of the same movement is a replay,
210
+ -- not an update. Nothing to do, and saying "migrated" again is honest —
211
+ -- the destination already holds exactly this row.
212
+ RETURN jsonb_build_object('status', 'migrated', 'target_id', p_id, 'reason', 'already in the ledger (append-only)');
213
+ END IF;
214
+
215
+ -- 005's single-writer gate refuses any write "however privileged" unless this
216
+ -- is set. The migration IS one of the internal writers — definer-only, fenced
217
+ -- and audited — so it declares itself rather than being exempted, and the
218
+ -- after-insert trigger derives the position from what it writes.
219
+ PERFORM set_config('fayz.inventory_internal', 'on', true);
220
+ INSERT INTO public.plg_inventory_stock_movements
221
+ (id, tenant_id, product_id, kind, quantity, unit_cost, total_cost,
222
+ source_location_id, destination_location_id, batch_number, expiration_date,
223
+ document_number, reason, notes, movement_date, metadata)
224
+ VALUES (p_id, p_tenant, (p_row ->> 'product_id')::uuid, v_kind,
225
+ (p_row ->> 'quantity')::numeric,
226
+ (p_row ->> 'unit_cost')::numeric, (p_row ->> 'total_cost')::numeric,
227
+ (p_row ->> 'source_location_id')::uuid, (p_row ->> 'destination_location_id')::uuid,
228
+ p_row ->> 'batch_number', (p_row ->> 'expiration_date')::date,
229
+ p_row ->> 'document_number', p_row ->> 'reason', p_row ->> 'notes',
230
+ coalesce((p_row ->> 'movement_date')::timestamptz, now()), v_meta);
231
+ PERFORM set_config('fayz.inventory_internal', 'off', true);
232
+
233
+ IF v_standalone THEN PERFORM migration._audit(p_tenant, 'public.plg_inventory_stock_movements', p_id, jsonb_build_object('reason', 'created')); END IF;
234
+ RETURN jsonb_build_object('status', 'migrated', 'target_id', p_id, 'reason', 'created');
235
+ END $$;
236
+
237
+ DO $$
238
+ DECLARE r record;
239
+ BEGIN
240
+ FOR r IN SELECT p.oid::regprocedure AS sig FROM pg_proc p JOIN pg_namespace n ON n.oid = p.pronamespace
241
+ WHERE n.nspname = 'migration' AND p.proname LIKE 'upsert\_inv\_%' LOOP
242
+ EXECUTE format('REVOKE ALL ON FUNCTION %s FROM public, anon, authenticated', r.sig);
243
+ EXECUTE format('GRANT EXECUTE ON FUNCTION %s TO service_role', r.sig);
244
+ END LOOP;
245
+ END $$;
@@ -0,0 +1,144 @@
1
+ -- 123_migration_order_item_writer.sql
2
+ --
3
+ -- The Venda's lines, and four histories that were never mapped. #140 / #148.
4
+ --
5
+ -- `invoice_items` is the last big row-per-row destination in the catalog/orders
6
+ -- half. It becomes `public.order_items`, which `091` made a first-class business
7
+ -- table (tenant_id, unit_id, composite FK to the order, execution status as a
8
+ -- column).
9
+ --
10
+ -- ONE THING THE WRITER REFUSES ON PURPOSE. `091` gave the line an
11
+ -- `execution_status` COLUMN, and 091 maps V1's `is_executed` into
12
+ -- `jsonb:execution.is_executed`. Both cannot be the truth. The column wins — it
13
+ -- is what `financial_cancel_paid_item` reads — so the writer sets it from the
14
+ -- V1 flag and leaves the rest of the execution detail in metadata, which is what
15
+ -- ADR 0009 says a promoted column means. This is the same defect as #198, caught
16
+ -- before it shipped rather than after.
17
+ --
18
+ -- THREE FACETS AND FOUR HISTORIES:
19
+ --
20
+ -- invoice_item_adjustments / _participants / invoice_items_discounted map NO
21
+ -- column (`'{}'` in 091), so they become facets of the line. The facet writer
22
+ -- reports `skipped` with "the facet maps no column into the destination" —
23
+ -- recorded, not silently dropped, and reconcile-safe.
24
+ --
25
+ -- invoice_cancellation_logs, invoice_item_cancellation_logs,
26
+ -- payment_deletion_logs and payment_unlink_logs are DEFERRED. They map no
27
+ -- column either, and unlike a facet there is no row for them to enrich: each
28
+ -- would have to become an `audit_logs` row, and `public.audit_logs` is the
29
+ -- actor-derived log of V2 actions — every row carries the user who did it and
30
+ -- the entity it happened to. A V1 history row has neither in the mapping, so
31
+ -- importing it produces audit entries with no actor and no content. They are
32
+ -- re-admitted the day someone maps their columns.
33
+ --
34
+ -- bill_attachments points at `storage`: files, not rows. Recorded as a
35
+ -- decision rather than left pending forever.
36
+ --
37
+ -- Replay-safe: `_allow` upserts under the documented unlock; writer is
38
+ -- CREATE OR REPLACE.
39
+
40
+ ALTER TABLE migration.allowlist DROP CONSTRAINT IF EXISTS allowlist_writer_check;
41
+ ALTER TABLE migration.allowlist ADD CONSTRAINT allowlist_writer_check
42
+ CHECK (writer IS NULL OR writer IN ('person', 'unit', 'category', 'product', 'order', 'invoice',
43
+ 'price_table', 'price_table_item', 'package', 'package_item',
44
+ 'field_rule', 'holiday', 'facet',
45
+ 'fin_account', 'fin_payment_method_type', 'fin_payment_method',
46
+ 'fin_card_brand', 'fin_chart_account', 'fin_cost_center',
47
+ 'inv_measurement_unit', 'inv_unit_conversion', 'inv_location', 'inv_movement',
48
+ 'order_item'));
49
+
50
+ DO $unlock$
51
+ BEGIN
52
+ PERFORM set_config('migration.allowlist_unlock', 'on', true);
53
+
54
+ UPDATE migration.allowlist SET writer = 'order_item' WHERE source_table = 'invoice_items';
55
+
56
+ UPDATE migration.allowlist SET writer = 'facet', writer_args = jsonb_build_object('target', 'public.order_items')
57
+ WHERE source_table IN ('invoice_item_adjustments', 'invoice_item_participants', 'invoice_items_discounted');
58
+
59
+ UPDATE migration.allowlist SET writer = 'facet', writer_args = jsonb_build_object('target', 'public.products')
60
+ WHERE source_table = 'product_intermediate_info';
61
+
62
+ DELETE FROM migration.allowlist
63
+ WHERE source_table IN ('invoice_cancellation_logs', 'invoice_item_cancellation_logs',
64
+ 'payment_deletion_logs', 'payment_unlink_logs', 'bill_attachments');
65
+ END $unlock$;
66
+
67
+ INSERT INTO migration.excluded_tables (source_table, reason) VALUES
68
+ ('invoice_cancellation_logs', 'Deferred: 091 maps no column, and public.audit_logs is the actor-derived log of V2 actions — every row carries who did it and what it happened to. A V1 history row has neither in the mapping, so importing it produces audit entries with no actor and no content. Re-admit when the columns are mapped (#113).'),
69
+ ('invoice_item_cancellation_logs', 'Deferred with invoice_cancellation_logs (#113).'),
70
+ ('payment_deletion_logs', 'Deferred with invoice_cancellation_logs (#113).'),
71
+ ('payment_unlink_logs', 'Deferred with invoice_cancellation_logs (#113).'),
72
+ ('bill_attachments', 'Not a row destination: bill attachments are FILES. 091 pointed them at `storage`, which is object storage and not something a row writer reaches. Recorded as a decision rather than left pending forever (#113).')
73
+ ON CONFLICT (source_table) DO UPDATE SET reason = EXCLUDED.reason;
74
+
75
+ CREATE OR REPLACE FUNCTION migration.upsert_order_item(p_tenant uuid, p_id uuid, p_row jsonb, p_args jsonb DEFAULT '{}'::jsonb)
76
+ RETURNS jsonb LANGUAGE plpgsql SECURITY DEFINER SET search_path = '' AS $$
77
+ DECLARE
78
+ v_meta jsonb := coalesce(p_row -> 'metadata', '{}'::jsonb);
79
+ v_order uuid;
80
+ v_order_tenant uuid;
81
+ v_exec text;
82
+ v_existing uuid;
83
+ v_created boolean := false;
84
+ v_standalone boolean := migration._standalone();
85
+ BEGIN
86
+ IF v_standalone THEN PERFORM migration._assert_fence(p_tenant); PERFORM set_config('migration.writer', 'on', true); END IF;
87
+ BEGIN
88
+ v_order := (p_row ->> 'order_id')::uuid;
89
+ EXCEPTION WHEN OTHERS THEN v_order := NULL; END;
90
+ IF p_id IS NULL OR v_order IS NULL THEN
91
+ RETURN jsonb_build_object('status', 'quarantined', 'kind', 'fk', 'reason', 'an order line needs its order — a line without a Venda is not a line');
92
+ END IF;
93
+ SELECT tenant_id INTO v_order_tenant FROM public.orders WHERE id = v_order;
94
+ IF v_order_tenant IS NULL THEN
95
+ RETURN jsonb_build_object('status', 'quarantined', 'kind', 'fk', 'reason', format('order %s is not in the destination yet', v_order));
96
+ END IF;
97
+ IF v_order_tenant <> p_tenant THEN
98
+ RETURN jsonb_build_object('status', 'quarantined', 'kind', 'tenant', 'reason', format('order %s belongs to another tenant', v_order));
99
+ END IF;
100
+
101
+ -- 081 promoted execution state to a COLUMN and 091 still maps V1's is_executed
102
+ -- into JSONB. Both cannot be the truth; the column is what
103
+ -- financial_cancel_paid_item reads, so it is set from the flag and the rest of
104
+ -- the execution detail stays in metadata (ADR 0009).
105
+ v_exec := CASE
106
+ WHEN (v_meta #>> '{execution,is_executed}') IN ('true', 't', '1') THEN 'executed'
107
+ WHEN (v_meta #>> '{cancellation,at}') IS NOT NULL THEN 'cancelled'
108
+ ELSE 'pending'
109
+ END;
110
+
111
+ SELECT tenant_id INTO v_existing FROM public.order_items WHERE id = p_id;
112
+ IF v_existing IS NOT NULL AND v_existing <> p_tenant THEN
113
+ RETURN jsonb_build_object('status', 'quarantined', 'kind', 'tenant', 'reason', format('order line %s already exists in another tenant', p_id));
114
+ END IF;
115
+ IF v_existing IS NULL THEN
116
+ INSERT INTO public.order_items (id, tenant_id, order_id, product_id, name, description,
117
+ quantity, unit_price, discount, total, sort_order, execution_status, metadata)
118
+ VALUES (p_id, p_tenant, v_order, (p_row ->> 'product_id')::uuid,
119
+ coalesce(nullif(btrim(coalesce(p_row ->> 'name', '')), ''), 'Item'),
120
+ p_row ->> 'description',
121
+ coalesce((p_row ->> 'quantity')::numeric, 1),
122
+ round(coalesce((p_row ->> 'unit_price')::numeric, 0), 2),
123
+ round(coalesce((p_row ->> 'discount')::numeric, 0), 2),
124
+ round(coalesce((p_row ->> 'total')::numeric, 0), 2),
125
+ coalesce((p_row ->> 'sort_order')::integer, 0), v_exec, v_meta);
126
+ v_created := true;
127
+ ELSE
128
+ UPDATE public.order_items SET
129
+ order_id = v_order,
130
+ product_id = CASE WHEN p_row ? 'product_id' THEN (p_row ->> 'product_id')::uuid ELSE product_id END,
131
+ name = CASE WHEN p_row ? 'name' THEN coalesce(nullif(btrim(p_row ->> 'name'), ''), name) ELSE name END,
132
+ quantity = CASE WHEN p_row ? 'quantity' THEN coalesce((p_row ->> 'quantity')::numeric, 1) ELSE quantity END,
133
+ unit_price = CASE WHEN p_row ? 'unit_price' THEN round(coalesce((p_row ->> 'unit_price')::numeric, 0), 2) ELSE unit_price END,
134
+ discount = CASE WHEN p_row ? 'discount' THEN round(coalesce((p_row ->> 'discount')::numeric, 0), 2) ELSE discount END,
135
+ total = CASE WHEN p_row ? 'total' THEN round(coalesce((p_row ->> 'total')::numeric, 0), 2) ELSE total END,
136
+ execution_status = v_exec,
137
+ metadata = migration._merge_meta(metadata, v_meta)
138
+ WHERE id = p_id;
139
+ END IF;
140
+ IF v_standalone THEN PERFORM migration._audit(p_tenant, 'public.order_items', p_id, jsonb_build_object('reason', CASE WHEN v_created THEN 'created' ELSE 'updated' END)); END IF;
141
+ RETURN jsonb_build_object('status', 'migrated', 'target_id', p_id, 'reason', CASE WHEN v_created THEN 'created' ELSE 'updated' END);
142
+ END $$;
143
+ REVOKE ALL ON FUNCTION migration.upsert_order_item(uuid, uuid, jsonb, jsonb) FROM public, anon, authenticated;
144
+ GRANT EXECUTE ON FUNCTION migration.upsert_order_item(uuid, uuid, jsonb, jsonb) TO service_role;
@@ -0,0 +1,170 @@
1
+ -- 124_migration_ledger_writers.sql
2
+ --
3
+ -- V1's money events reach the two-leg ledger. #142 / #148.
4
+ --
5
+ -- `financial_movements` is the largest financial table in a V1 pool and had no
6
+ -- writer, so every one of a tenant's money events landed `pending`.
7
+ --
8
+ -- LEGS. V2's ledger is two-legged and `plg_financial_movements_legs_check`
9
+ -- refuses a money event without both. A V1 movement has neither — it predates
10
+ -- the model — so the writer resolves them the same way the in-place backfill
11
+ -- does, through `plg_financial_legacy_legs`: the money holder from the bank
12
+ -- account or the cash session, the counter-leg from the invoice's direction, and
13
+ -- `legacy` / `legacy_holder` when neither is knowable. Reusing that function
14
+ -- rather than reimplementing it means a migrated pool and an upgraded pool end
15
+ -- up with the same postings, which is the property that makes the two paths
16
+ -- comparable at all.
17
+ --
18
+ -- Bills and commissions are obligation MIRRORS, not money events: the helper
19
+ -- says so and they are inserted without legs, which is what `legs_check`'s own
20
+ -- first clause allows.
21
+ --
22
+ -- TWO TABLES ARE DEFERRED, both because writing them would mean guessing at
23
+ -- money:
24
+ --
25
+ -- payment_allocations. V1 links a payment movement to a BILL MOVEMENT; V2
26
+ -- links a payment movement to an INSTALLMENT (`plg_financial_invoice_
27
+ -- installments`), and those are different objects — the installment is created
28
+ -- by `financial_create_invoice`, not by the movement import. 101's mapping
29
+ -- papers over that by pointing `bill_movement_id` at
30
+ -- `installment_id@financial_movements`, which would write a movement's id into
31
+ -- a column that means an installment. An allocation is what says WHICH debt a
32
+ -- payment settled; getting it wrong silently mis-states every balance built on
33
+ -- it, and there is no way to verify the mapping without the real V1 rows. It
34
+ -- is re-admitted with #141's invoice split, which is what creates the
35
+ -- installments an allocation can point at.
36
+ --
37
+ -- cc_receiving_fee_configs. 091 maps NO column (`'{}'`), and a fee config is
38
+ -- brand x installment range x rate per receiving account — four things, none
39
+ -- of them mapped. A wrong fee rate silently mis-states every card receivable.
40
+ --
41
+ -- Replay-safe: `_allow` upserts under the documented unlock; writers are
42
+ -- CREATE OR REPLACE; the movement writer is idempotent by id and the ledger is
43
+ -- append-only, so a second offer is a replay.
44
+
45
+ ALTER TABLE migration.allowlist DROP CONSTRAINT IF EXISTS allowlist_writer_check;
46
+ ALTER TABLE migration.allowlist ADD CONSTRAINT allowlist_writer_check
47
+ CHECK (writer IS NULL OR writer IN ('person', 'unit', 'category', 'product', 'order', 'invoice',
48
+ 'price_table', 'price_table_item', 'package', 'package_item',
49
+ 'field_rule', 'holiday', 'facet',
50
+ 'fin_account', 'fin_payment_method_type', 'fin_payment_method',
51
+ 'fin_card_brand', 'fin_chart_account', 'fin_cost_center',
52
+ 'inv_measurement_unit', 'inv_unit_conversion', 'inv_location', 'inv_movement',
53
+ 'order_item', 'fin_movement'));
54
+
55
+ DO $unlock$
56
+ BEGIN
57
+ PERFORM set_config('migration.allowlist_unlock', 'on', true);
58
+ -- `direction` was NOT MAPPED by 091, and it is the column that says whether
59
+ -- money came in or went out. Without it every movement would have to guess.
60
+ UPDATE migration.allowlist SET writer = 'fin_movement',
61
+ columns = columns || '{"direction":"direction"}'::jsonb
62
+ WHERE source_table = 'financial_movements';
63
+ DELETE FROM migration.allowlist WHERE source_table IN ('payment_allocations', 'cc_receiving_fee_configs',
64
+ 'cash_register_sessions', 'cash_session_reviewers');
65
+ END $unlock$;
66
+
67
+ INSERT INTO migration.excluded_tables (source_table, reason) VALUES
68
+ ('payment_allocations', 'Deferred: V1 links a payment movement to a BILL MOVEMENT; V2 links it to an INSTALLMENT, and those are different objects — the installment is created by financial_create_invoice, not by the movement import. 091 papered over that by pointing bill_movement_id at installment_id@financial_movements, which writes a movement id into a column meaning an installment. An allocation says WHICH debt a payment settled; getting it wrong silently mis-states every balance built on it. Re-admit with #141''s invoice split, which creates the installments an allocation can point at (#114).'),
69
+ ('cash_register_sessions', 'Deferred: a V2 cash session is ATTRIBUTED — user_id is NOT NULL and only one may be open per user and unit, because the session is the accountability record for a drawer. 091 carries V1''s opener only as an opaque `v1_opened_by` reference with no mapping to a V2 auth user, so every migrated session would have to be attributed to somebody who did not open it. Re-admit when V1 users are mapped to V2 logins (#114).'),
70
+ ('cash_session_reviewers', 'Deferred with cash_register_sessions: a reviewer of a session that is not migrated has nothing to review (#114).'),
71
+ ('cc_receiving_fee_configs', 'Deferred: 091 maps no column at all, and a fee config is brand x installment range x rate per receiving account — four things, none of them mapped. A wrong fee rate silently mis-states every card receivable (#114).')
72
+ ON CONFLICT (source_table) DO UPDATE SET reason = EXCLUDED.reason;
73
+
74
+ CREATE OR REPLACE FUNCTION migration.upsert_fin_movement(p_tenant uuid, p_id uuid, p_row jsonb, p_args jsonb DEFAULT '{}'::jsonb)
75
+ RETURNS jsonb LANGUAGE plpgsql SECURITY DEFINER SET search_path = '' AS $$
76
+ DECLARE
77
+ v_meta jsonb := coalesce(p_row -> 'metadata', '{}'::jsonb);
78
+ v_kind_raw text := nullif(btrim(lower(coalesce(p_row ->> 'movement_kind', ''))), '');
79
+ v_kind text := CASE v_kind_raw
80
+ WHEN 'bill' THEN 'bill' WHEN 'payment' THEN 'payment'
81
+ WHEN 'transfer' THEN 'transfer' WHEN 'commission' THEN 'commission'
82
+ -- V1's vocabulary
83
+ WHEN 'conta' THEN 'bill' WHEN 'titulo' THEN 'bill' WHEN 'título' THEN 'bill'
84
+ WHEN 'pagamento' THEN 'payment' WHEN 'recebimento' THEN 'payment'
85
+ WHEN 'transferencia' THEN 'transfer' WHEN 'transferência' THEN 'transfer'
86
+ WHEN 'comissao' THEN 'commission' WHEN 'comissão' THEN 'commission'
87
+ END;
88
+ v_dir text := nullif(btrim(lower(coalesce(p_row ->> 'direction', ''))), '');
89
+ v_amount numeric := round(coalesce((p_row ->> 'amount')::numeric, 0), 2);
90
+ v_legs record;
91
+ v_debit uuid := (p_row ->> 'debit_account_id')::uuid;
92
+ v_credit uuid := (p_row ->> 'credit_account_id')::uuid;
93
+ v_exists boolean;
94
+ v_standalone boolean := migration._standalone();
95
+ BEGIN
96
+ IF v_standalone THEN PERFORM migration._assert_fence(p_tenant); PERFORM set_config('migration.writer', 'on', true); END IF;
97
+ IF p_id IS NULL THEN
98
+ RETURN jsonb_build_object('status', 'quarantined', 'kind', 'state', 'reason', 'a movement needs an id');
99
+ END IF;
100
+ IF v_kind IS NULL THEN
101
+ RETURN jsonb_build_object('status', 'quarantined', 'kind', 'value',
102
+ 'reason', format('unknown movement kind %L (expected bill|payment|transfer|commission, or a V1 label of one)', coalesce(v_kind_raw, '<null>')));
103
+ END IF;
104
+ IF v_dir IS NOT NULL AND v_dir NOT IN ('debit', 'credit') THEN
105
+ RETURN jsonb_build_object('status', 'quarantined', 'kind', 'value', 'reason', format('unknown direction %L (expected debit|credit)', v_dir));
106
+ END IF;
107
+ -- A V1 pool that does not carry the column still knows the direction, through
108
+ -- the invoice the movement settles: a receivable is money in, a payable money
109
+ -- out. That is a derivation, not a guess. When there is no invoice either,
110
+ -- refuse — "which way did this money go" is not something to default.
111
+ IF v_dir IS NULL AND (p_row ->> 'financial_invoice_id') IS NOT NULL THEN
112
+ SELECT CASE t.direction WHEN 'receivable' THEN 'credit' WHEN 'payable' THEN 'debit' END INTO v_dir
113
+ FROM public.plg_financial_invoices t WHERE t.id = (p_row ->> 'financial_invoice_id')::uuid;
114
+ END IF;
115
+ IF v_dir IS NULL THEN
116
+ RETURN jsonb_build_object('status', 'quarantined', 'kind', 'state',
117
+ 'reason', 'the movement carries no direction and settles no invoice — which way the money went cannot be derived, and must not be defaulted');
118
+ END IF;
119
+ IF v_amount < 0 THEN
120
+ RETURN jsonb_build_object('status', 'quarantined', 'kind', 'value', 'reason', 'a money event cannot carry a negative amount — direction is the direction, not the sign');
121
+ END IF;
122
+
123
+ SELECT EXISTS (SELECT 1 FROM public.plg_financial_movements WHERE id = p_id) INTO v_exists;
124
+ IF v_exists THEN
125
+ -- append-only: a second offer of the same money event is a replay
126
+ RETURN jsonb_build_object('status', 'migrated', 'target_id', p_id, 'reason', 'already in the ledger (append-only)');
127
+ END IF;
128
+
129
+ -- Legs, resolved exactly as the in-place backfill resolves them, so a migrated
130
+ -- pool and an upgraded pool end up with the same postings.
131
+ IF v_debit IS NULL OR v_credit IS NULL THEN
132
+ SELECT * INTO v_legs FROM public.plg_financial_legacy_legs(
133
+ p_tenant, v_kind, v_dir, (p_row ->> 'bank_account_id')::uuid, (p_row ->> 'cash_session_id')::uuid,
134
+ (p_row ->> 'financial_invoice_id')::uuid, v_meta);
135
+ IF v_legs.o_mirror THEN
136
+ -- an obligation mirror, not a money event: legs_check allows it legless
137
+ v_meta := v_meta || jsonb_build_object('legacy_mirror', true);
138
+ v_debit := NULL; v_credit := NULL;
139
+ ELSE
140
+ v_debit := coalesce(v_debit, v_legs.o_debit);
141
+ v_credit := coalesce(v_credit, v_legs.o_credit);
142
+ END IF;
143
+ END IF;
144
+
145
+ INSERT INTO public.plg_financial_movements
146
+ (id, tenant_id, financial_invoice_id, direction, movement_kind, amount, paid_amount, status,
147
+ due_date, payment_date, installment_number, notes, metadata, debit_account_id, credit_account_id)
148
+ VALUES (p_id, p_tenant, (p_row ->> 'financial_invoice_id')::uuid, v_dir, v_kind, v_amount,
149
+ round(coalesce((p_row ->> 'paid_amount')::numeric, 0), 2),
150
+ coalesce(p_row ->> 'status', 'pending'),
151
+ -- due_date is NOT NULL on the ledger. A V1 payment has no due date —
152
+ -- it is not an obligation — so it takes the date the money moved, which
153
+ -- is the only date that means anything for it. A bill keeps its own.
154
+ coalesce((p_row ->> 'due_date')::date, (p_row ->> 'paid_at')::date, current_date),
155
+ (p_row ->> 'paid_at')::date,
156
+ (p_row ->> 'installment_number')::integer, p_row ->> 'notes', v_meta, v_debit, v_credit);
157
+
158
+ IF v_standalone THEN PERFORM migration._audit(p_tenant, 'public.plg_financial_movements', p_id, jsonb_build_object('reason', 'created')); END IF;
159
+ RETURN jsonb_build_object('status', 'migrated', 'target_id', p_id, 'reason', 'created');
160
+ END $$;
161
+
162
+ DO $$
163
+ DECLARE r record;
164
+ BEGIN
165
+ FOR r IN SELECT p.oid::regprocedure AS sig FROM pg_proc p JOIN pg_namespace n ON n.oid = p.pronamespace
166
+ WHERE n.nspname = 'migration' AND p.proname = 'upsert_fin_movement' LOOP
167
+ EXECUTE format('REVOKE ALL ON FUNCTION %s FROM public, anon, authenticated', r.sig);
168
+ EXECUTE format('GRANT EXECUTE ON FUNCTION %s TO service_role', r.sig);
169
+ END LOOP;
170
+ END $$;