From 2aafa46c2aa64306b22a223de7262cdcd5e43372 Mon Sep 17 00:00:00 2001 From: Robert Borbely Date: Wed, 27 May 2026 11:40:37 +0200 Subject: [PATCH 1/3] fix(taxcode): dedupe tax_codes by app mapping --- .../dedupe_tax_codes_by_app_mapping_test.go | 338 ++++++++++++++++++ ...0_dedupe_tax_codes_by_app_mapping.down.sql | 9 + ...000_dedupe_tax_codes_by_app_mapping.up.sql | 74 ++++ tools/migrate/migrations/atlas.sum | 3 +- 4 files changed, 423 insertions(+), 1 deletion(-) create mode 100644 tools/migrate/dedupe_tax_codes_by_app_mapping_test.go create mode 100644 tools/migrate/migrations/20260527120000_dedupe_tax_codes_by_app_mapping.down.sql create mode 100644 tools/migrate/migrations/20260527120000_dedupe_tax_codes_by_app_mapping.up.sql diff --git a/tools/migrate/dedupe_tax_codes_by_app_mapping_test.go b/tools/migrate/dedupe_tax_codes_by_app_mapping_test.go new file mode 100644 index 0000000000..e578ae40af --- /dev/null +++ b/tools/migrate/dedupe_tax_codes_by_app_mapping_test.go @@ -0,0 +1,338 @@ +package migrate_test + +import ( + "database/sql" + "testing" + "time" + + "github.com/oklog/ulid/v2" + "github.com/stretchr/testify/require" +) + +func TestDedupeTaxCodesByAppMappingMigration(t *testing.T) { + namespace := "dedupe_tax_codes_test" + + // Times used to control winner/loser ordering. + // T1 < T2 so the older row wins when both are non-system. + t1 := time.Date(2024, 1, 1, 0, 0, 0, 0, time.UTC) + t2 := time.Date(2024, 6, 1, 0, 0, 0, 0, time.UTC) + + // Group 1: same Stripe mapping txcd_10103001. + // Loser (group1Loser): key "stripe_txcd_10103001", non-system, created T1. + // Winner (group1Winner): key "saas_business", system, created T2. + // System wins despite being newer — exercises is_system DESC sort key. + group1Loser := ulid.Make().String() + group1Winner := ulid.Make().String() + + // Group 2: same Stripe mapping txcd_99999999, both non-system. + // Loser (group2Loser): key "stripe_txcd_99999999", created T2 (newer). + // Winner (group2Winner): key "legacy_99", created T1 (older). + // No system row → oldest wins. + group2Loser := ulid.Make().String() + group2Winner := ulid.Make().String() + + // Singleton: a tax code with no duplicates — must remain live. + singleton := ulid.Make().String() + + // Child-table rows pointing at group1Loser (FK rows that must be repointed). + wfcRowID := ulid.Make().String() // billing_workflow_configs + planID := ulid.Make().String() // plans (parent for plan_rate_cards) + phaseID := ulid.Make().String() // plan_phases (parent for plan_rate_cards) + prcRowID := ulid.Make().String() // plan_rate_cards + + customerID := ulid.Make().String() // customers (parent for subscriptions and charge_flat_fees) + subID := ulid.Make().String() // subscriptions + subPhaseID := ulid.Make().String() // subscription_phases + subItemID := ulid.Make().String() // subscription_items + + flatFeeID := ulid.Make().String() // charge_flat_fees + + // organization_default_tax_codes: both FK columns pointing at group1Loser. + orgDTCID := ulid.Make().String() + + runner{ + stops: stops{ + { + // Stop 1: after migration 20260520130000 has been applied. + // We insert all fixture rows here, before the dedup migration runs. + version: 20260520130000, + direction: directionUp, + action: func(t *testing.T, db *sql.DB) { + // Insert tax_codes rows. + // Group 1 loser: non-system, created at T1. + _, err := db.Exec(` + INSERT INTO tax_codes ( + id, namespace, created_at, updated_at, + name, key, app_mappings + ) VALUES ( + $1, $2, $3, $3, + 'Stripe txcd_10103001 (auto)', 'stripe_txcd_10103001', + '[{"app_type":"stripe","tax_code":"txcd_10103001"}]'::jsonb + )`, group1Loser, namespace, t1) + require.NoError(t, err) + + // Group 1 winner: system-managed, created at T2 (newer, but is_system wins). + _, err = db.Exec(` + INSERT INTO tax_codes ( + id, namespace, created_at, updated_at, + name, key, app_mappings, annotations + ) VALUES ( + $1, $2, $3, $3, + 'SaaS Business', 'saas_business', + '[{"app_type":"stripe","tax_code":"txcd_10103001"}]'::jsonb, + '{"managed_by":"system"}'::jsonb + )`, group1Winner, namespace, t2) + require.NoError(t, err) + + // Group 2 loser: non-system, created at T2 (newer → loses). + _, err = db.Exec(` + INSERT INTO tax_codes ( + id, namespace, created_at, updated_at, + name, key, app_mappings + ) VALUES ( + $1, $2, $3, $3, + 'Stripe txcd_99999999 (auto)', 'stripe_txcd_99999999', + '[{"app_type":"stripe","tax_code":"txcd_99999999"}]'::jsonb + )`, group2Loser, namespace, t2) + require.NoError(t, err) + + // Group 2 winner: non-system, created at T1 (older → wins). + _, err = db.Exec(` + INSERT INTO tax_codes ( + id, namespace, created_at, updated_at, + name, key, app_mappings + ) VALUES ( + $1, $2, $3, $3, + 'Legacy 99', 'legacy_99', + '[{"app_type":"stripe","tax_code":"txcd_99999999"}]'::jsonb + )`, group2Winner, namespace, t1) + require.NoError(t, err) + + // Singleton: no duplicate, must remain untouched. + _, err = db.Exec(` + INSERT INTO tax_codes ( + id, namespace, created_at, updated_at, + name, key, app_mappings + ) VALUES ( + $1, $2, $3, $3, + 'Singleton Code', 'singleton_code', + '[{"app_type":"stripe","tax_code":"txcd_unique_000"}]'::jsonb + )`, singleton, namespace, t1) + require.NoError(t, err) + + // billing_workflow_configs row pointing at group1Loser. + _, err = db.Exec(` + INSERT INTO billing_workflow_configs ( + id, namespace, created_at, updated_at, + collection_alignment, line_collection_period, + invoice_auto_advance, invoice_draft_period, + invoice_due_after, invoice_collection_method, + invoice_progressive_billing, tax_code_id + ) VALUES ( + $1, $2, NOW(), NOW(), + 'subscription', 'P1M', + true, 'P1D', + 'P30D', 'charge_automatically', + false, $3 + )`, wfcRowID, namespace, group1Loser) + require.NoError(t, err) + + // plan → plan_phases → plan_rate_cards chain. + _, err = db.Exec(` + INSERT INTO plans ( + id, namespace, created_at, updated_at, + name, key, version, currency, + billing_cadence, pro_rating_config + ) VALUES ( + $1, $2, NOW(), NOW(), + 'Test Plan', 'test-plan', 1, 'USD', + 'P1M', '{"enabled":true,"mode":"prorate_prices"}'::jsonb + )`, planID, namespace) + require.NoError(t, err) + + _, err = db.Exec(` + INSERT INTO plan_phases ( + id, namespace, created_at, updated_at, + name, key, plan_id, index + ) VALUES ( + $1, $2, NOW(), NOW(), + 'Default Phase', 'default', $3, 0 + )`, phaseID, namespace, planID) + require.NoError(t, err) + + _, err = db.Exec(` + INSERT INTO plan_rate_cards ( + id, namespace, created_at, updated_at, + name, key, type, phase_id, tax_code_id + ) VALUES ( + $1, $2, NOW(), NOW(), + 'Test Rate Card', 'test-rc', 'FLAT_FEE', $3, $4 + )`, prcRowID, namespace, phaseID, group1Loser) + require.NoError(t, err) + + // customers → subscriptions → subscription_phases → subscription_items chain. + _, err = db.Exec(` + INSERT INTO customers ( + id, namespace, created_at, updated_at, + key, name + ) VALUES ( + $1, $2, NOW(), NOW(), + 'dedupe-customer', 'Dedupe Customer' + )`, customerID, namespace) + require.NoError(t, err) + + _, err = db.Exec(` + INSERT INTO subscriptions ( + id, namespace, created_at, updated_at, + active_from, currency, customer_id, plan_id, + billing_cadence, pro_rating_config, billing_anchor + ) VALUES ( + $1, $2, NOW(), NOW(), + '2024-01-01 00:00:00', 'USD', $3, NULL, + 'P1M', '{"enabled":true,"mode":"prorate_prices"}'::jsonb, + '2024-01-01 00:00:00' + )`, subID, namespace, customerID) + require.NoError(t, err) + + _, err = db.Exec(` + INSERT INTO subscription_phases ( + id, namespace, created_at, updated_at, + key, name, subscription_id, active_from + ) VALUES ( + $1, $2, NOW(), NOW(), + 'default', 'Default Phase', $3, '2024-01-01 00:00:00' + )`, subPhaseID, namespace, subID) + require.NoError(t, err) + + _, err = db.Exec(` + INSERT INTO subscription_items ( + id, namespace, created_at, updated_at, + active_from, key, name, phase_id, tax_code_id + ) VALUES ( + $1, $2, NOW(), NOW(), + '2024-01-01 00:00:00', 'item-1', 'Item 1', $3, $4 + )`, subItemID, namespace, subPhaseID, group1Loser) + require.NoError(t, err) + + // charge_flat_fees row pointing at group1Loser. + _, err = db.Exec(` + INSERT INTO charge_flat_fees ( + id, namespace, + payment_term, invoice_at, settlement_mode, + pro_rating, amount_before_proration, amount_after_proration, + service_period_from, service_period_to, + billing_period_from, billing_period_to, + full_service_period_from, full_service_period_to, + status, status_detailed, unique_reference_id, currency, managed_by, + created_at, updated_at, name, customer_id, + tax_code_id + ) VALUES ( + $1, $2, + 'in_advance', NOW(), 'invoice_only', + 'no_prorating', 100, 100, + '2024-01-01 00:00:00', '2024-02-01 00:00:00', + '2024-01-01 00:00:00', '2024-02-01 00:00:00', + '2024-01-01 00:00:00', '2024-02-01 00:00:00', + 'final', 'final', 'dedup-flat-fee-ref', 'USD', 'subscription', + NOW(), NOW(), 'Flat Fee Charge', $3, + $4 + )`, flatFeeID, namespace, customerID, group1Loser) + require.NoError(t, err) + + // organization_default_tax_codes: both columns pointing at group1Loser. + _, err = db.Exec(` + INSERT INTO organization_default_tax_codes ( + id, namespace, created_at, updated_at, + invoicing_tax_code_id, credit_grant_tax_code_id + ) VALUES ( + $1, $2, NOW(), NOW(), $3, $3 + )`, orgDTCID, namespace, group1Loser) + require.NoError(t, err) + }, + }, + { + // Stop 2: after the dedup migration 20260527120000 has run. + // Assert all losers are soft-deleted, winners are live, + // and every FK row now points at the winner. + version: 20260527120000, + direction: directionUp, + action: func(t *testing.T, db *sql.DB) { + // Group 1 loser must be soft-deleted. + var deletedAt sql.NullTime + err := db.QueryRow(` + SELECT deleted_at FROM tax_codes WHERE id = $1 + `, group1Loser).Scan(&deletedAt) + require.NoError(t, err) + require.True(t, deletedAt.Valid, "group1Loser should be soft-deleted after dedup") + + // Group 1 winner must remain live. + err = db.QueryRow(` + SELECT deleted_at FROM tax_codes WHERE id = $1 + `, group1Winner).Scan(&deletedAt) + require.NoError(t, err) + require.False(t, deletedAt.Valid, "group1Winner should remain live after dedup") + + // Group 2 loser must be soft-deleted. + err = db.QueryRow(` + SELECT deleted_at FROM tax_codes WHERE id = $1 + `, group2Loser).Scan(&deletedAt) + require.NoError(t, err) + require.True(t, deletedAt.Valid, "group2Loser should be soft-deleted after dedup") + + // Group 2 winner must remain live. + err = db.QueryRow(` + SELECT deleted_at FROM tax_codes WHERE id = $1 + `, group2Winner).Scan(&deletedAt) + require.NoError(t, err) + require.False(t, deletedAt.Valid, "group2Winner should remain live after dedup") + + // Singleton must remain untouched (still live). + err = db.QueryRow(` + SELECT deleted_at FROM tax_codes WHERE id = $1 + `, singleton).Scan(&deletedAt) + require.NoError(t, err) + require.False(t, deletedAt.Valid, "singleton should not be touched by dedup") + + // billing_workflow_configs FK must now point at group1Winner. + var taxCodeID string + err = db.QueryRow(` + SELECT tax_code_id FROM billing_workflow_configs WHERE id = $1 + `, wfcRowID).Scan(&taxCodeID) + require.NoError(t, err) + require.Equal(t, group1Winner, taxCodeID, "billing_workflow_configs.tax_code_id should be repointed to group1Winner") + + // plan_rate_cards FK must now point at group1Winner. + err = db.QueryRow(` + SELECT tax_code_id FROM plan_rate_cards WHERE id = $1 + `, prcRowID).Scan(&taxCodeID) + require.NoError(t, err) + require.Equal(t, group1Winner, taxCodeID, "plan_rate_cards.tax_code_id should be repointed to group1Winner") + + // subscription_items FK must now point at group1Winner. + err = db.QueryRow(` + SELECT tax_code_id FROM subscription_items WHERE id = $1 + `, subItemID).Scan(&taxCodeID) + require.NoError(t, err) + require.Equal(t, group1Winner, taxCodeID, "subscription_items.tax_code_id should be repointed to group1Winner") + + // charge_flat_fees FK must now point at group1Winner. + err = db.QueryRow(` + SELECT tax_code_id FROM charge_flat_fees WHERE id = $1 + `, flatFeeID).Scan(&taxCodeID) + require.NoError(t, err) + require.Equal(t, group1Winner, taxCodeID, "charge_flat_fees.tax_code_id should be repointed to group1Winner") + + // organization_default_tax_codes: both columns must point at group1Winner. + var invTaxCodeID, cgTaxCodeID string + err = db.QueryRow(` + SELECT invoicing_tax_code_id, credit_grant_tax_code_id + FROM organization_default_tax_codes WHERE id = $1 + `, orgDTCID).Scan(&invTaxCodeID, &cgTaxCodeID) + require.NoError(t, err) + require.Equal(t, group1Winner, invTaxCodeID, "organization_default_tax_codes.invoicing_tax_code_id should be repointed to group1Winner") + require.Equal(t, group1Winner, cgTaxCodeID, "organization_default_tax_codes.credit_grant_tax_code_id should be repointed to group1Winner") + }, + }, + }, + }.Test(t) +} diff --git a/tools/migrate/migrations/20260527120000_dedupe_tax_codes_by_app_mapping.down.sql b/tools/migrate/migrations/20260527120000_dedupe_tax_codes_by_app_mapping.down.sql new file mode 100644 index 0000000000..3c0897eaa3 --- /dev/null +++ b/tools/migrate/migrations/20260527120000_dedupe_tax_codes_by_app_mapping.down.sql @@ -0,0 +1,9 @@ +-- Intentionally no-op. +-- +-- This migration soft-deletes duplicate tax_codes rows and repoints all +-- tax_code_id FK columns from losers to winners. Rolling back would require +-- restoring the soft-deleted losers and re-pointing every FK back — but the +-- original loser IDs are no longer stored anywhere, and re-introducing them +-- would leave the FK columns pointing at rows that may have been deleted for +-- other reasons. The dedup is a one-way data cleanup; re-running the migration +-- on a clean database is a no-op (the temp table will be empty). diff --git a/tools/migrate/migrations/20260527120000_dedupe_tax_codes_by_app_mapping.up.sql b/tools/migrate/migrations/20260527120000_dedupe_tax_codes_by_app_mapping.up.sql new file mode 100644 index 0000000000..3e70919660 --- /dev/null +++ b/tools/migrate/migrations/20260527120000_dedupe_tax_codes_by_app_mapping.up.sql @@ -0,0 +1,74 @@ +-- Deduplicate tax_codes rows that share the same Stripe app_mapping content. +-- +-- Two write paths can produce duplicate (namespace, app_type, app_tax_code) groups: +-- the OSS seeder (system-managed keys like "saas_business") and the dual-write path +-- GetOrCreateByAppMapping (auto-keyed as "stripe_txcd_XXXXXXXX"). The read-side +-- tie-break already returns the right row, but duplicate rows remain on disk and +-- downstream tax_code_id FK columns may still point at losers. This migration +-- soft-deletes every loser and repoints all FKs to the winner in one transaction. + +-- Step 1: materialise loser → winner pairs into a temp table. +-- Winner selection mirrors the read-side tie-break: system-managed rows first, +-- then oldest created_at, then smallest id. +DROP TABLE IF EXISTS _tax_code_dedup_map; +CREATE TEMP TABLE _tax_code_dedup_map AS +WITH expanded AS ( + SELECT + t.id, + t.namespace, + t.created_at, + (m->>'app_type') AS app_type, + (m->>'tax_code') AS app_tax_code, + COALESCE(t.annotations->>'managed_by', '') = 'system' AS is_system + FROM tax_codes t, + LATERAL jsonb_array_elements(COALESCE(t.app_mappings, '[]'::jsonb)) AS m + WHERE t.deleted_at IS NULL + AND t.app_mappings IS NOT NULL +), +ranked AS ( + SELECT *, + ROW_NUMBER() OVER ( + PARTITION BY namespace, app_type, app_tax_code + ORDER BY is_system DESC, created_at ASC, id ASC + ) AS rn, + COUNT(*) OVER (PARTITION BY namespace, app_type, app_tax_code) AS group_size + FROM expanded +), +winners AS ( + SELECT namespace, app_type, app_tax_code, id AS winner_id + FROM ranked WHERE rn = 1 AND group_size > 1 +) +SELECT DISTINCT r.id AS loser_id, w.winner_id +FROM ranked r +JOIN winners w USING (namespace, app_type, app_tax_code) +WHERE r.rn > 1; + +CREATE INDEX ON _tax_code_dedup_map (loser_id); + +-- Step 2: repoint every tax_code_id FK column to the winner. +UPDATE billing_workflow_configs SET tax_code_id = m.winner_id FROM _tax_code_dedup_map m WHERE billing_workflow_configs.tax_code_id = m.loser_id; +UPDATE billing_customer_overrides SET tax_code_id = m.winner_id FROM _tax_code_dedup_map m WHERE billing_customer_overrides.tax_code_id = m.loser_id; +UPDATE billing_invoice_lines SET tax_code_id = m.winner_id FROM _tax_code_dedup_map m WHERE billing_invoice_lines.tax_code_id = m.loser_id; +UPDATE billing_invoice_split_line_groups SET tax_code_id = m.winner_id FROM _tax_code_dedup_map m WHERE billing_invoice_split_line_groups.tax_code_id = m.loser_id; +UPDATE billing_standard_invoice_detailed_lines SET tax_code_id = m.winner_id FROM _tax_code_dedup_map m WHERE billing_standard_invoice_detailed_lines.tax_code_id = m.loser_id; +UPDATE charge_usage_based_run_detailed_line SET tax_code_id = m.winner_id FROM _tax_code_dedup_map m WHERE charge_usage_based_run_detailed_line.tax_code_id = m.loser_id; +UPDATE charge_flat_fee_run_detailed_lines SET tax_code_id = m.winner_id FROM _tax_code_dedup_map m WHERE charge_flat_fee_run_detailed_lines.tax_code_id = m.loser_id; +UPDATE subscription_items SET tax_code_id = m.winner_id FROM _tax_code_dedup_map m WHERE subscription_items.tax_code_id = m.loser_id; +UPDATE plan_rate_cards SET tax_code_id = m.winner_id FROM _tax_code_dedup_map m WHERE plan_rate_cards.tax_code_id = m.loser_id; +UPDATE addon_rate_cards SET tax_code_id = m.winner_id FROM _tax_code_dedup_map m WHERE addon_rate_cards.tax_code_id = m.loser_id; +UPDATE charge_flat_fees SET tax_code_id = m.winner_id FROM _tax_code_dedup_map m WHERE charge_flat_fees.tax_code_id = m.loser_id; +UPDATE charge_usage_based SET tax_code_id = m.winner_id FROM _tax_code_dedup_map m WHERE charge_usage_based.tax_code_id = m.loser_id; +UPDATE charge_credit_purchases SET tax_code_id = m.winner_id FROM _tax_code_dedup_map m WHERE charge_credit_purchases.tax_code_id = m.loser_id; +UPDATE organization_default_tax_codes SET invoicing_tax_code_id = m.winner_id FROM _tax_code_dedup_map m WHERE organization_default_tax_codes.invoicing_tax_code_id = m.loser_id; +UPDATE organization_default_tax_codes SET credit_grant_tax_code_id = m.winner_id FROM _tax_code_dedup_map m WHERE organization_default_tax_codes.credit_grant_tax_code_id = m.loser_id; + +-- Step 3: soft-delete losers. +UPDATE tax_codes + SET deleted_at = NOW(), updated_at = NOW() + WHERE id IN (SELECT loser_id FROM _tax_code_dedup_map) + AND deleted_at IS NULL; + +-- Step 4: drop the temp table explicitly. Without ON COMMIT DROP (which atlas +-- migrate validate cannot model under its autocommit dry-run), session-scoped +-- temp tables would otherwise live until the migration connection closes. +DROP TABLE _tax_code_dedup_map; diff --git a/tools/migrate/migrations/atlas.sum b/tools/migrate/migrations/atlas.sum index 1ce7866b80..88ce729397 100644 --- a/tools/migrate/migrations/atlas.sum +++ b/tools/migrate/migrations/atlas.sum @@ -1,4 +1,4 @@ -h1:gSQes9wG0XKT/ue8LFsMTxUNkdkK1Mvn9pk39x933b8= +h1:GlYJ60qcTBAl8oF9zIgn4Ll+UElajycPn0zO9FhFbwo= 20240826120919_init.up.sql h1:tc1V91/smlmaeJGQ8h+MzTEeFjjnrrFDbDAjOYJK91o= 20240903155435_entitlement-expired-index.up.sql h1:Hp8u5uckmLXc1cRvWU0AtVnnK8ShlpzZNp8pbiJLhac= 20240917172257_billing-entities.up.sql h1:Q1dAMo0Vjiit76OybClNfYPGC5nmvov2/M2W1ioi4Kw= @@ -205,3 +205,4 @@ h1:gSQes9wG0XKT/ue8LFsMTxUNkdkK1Mvn9pk39x933b8= 20260519132345_reset-sync-state.up.sql h1:R8ekG92vwXQbtqmh+R3ZTTRMCM0VqPoLzJG3VIKlG4k= 20260520130000_repair_rebased_migrations.up.sql h1:XJhbK1CCplq5BEJi+VvHDVAcLUBJqHNF1LFGhaSa2Oo= 20260520130500_add_ledger_tax_behavior.up.sql h1:M8Lq8lE/O+RwPLJ/7qe5tVddruuAxOcBAeJ1usFEveg= +20260527120000_dedupe_tax_codes_by_app_mapping.up.sql h1:ajwBwf4YFYo/ZRSaTcIPoek4r0wpjDIiE/rm19EKbZE= From eaec437aa9246d82cc2b56ff55e2bb5cba1d88b0 Mon Sep 17 00:00:00 2001 From: Robert Borbely Date: Wed, 27 May 2026 11:56:20 +0200 Subject: [PATCH 2/3] fix(taxcode): fix review comment --- ...000_dedupe_tax_codes_by_app_mapping.up.sql | 22 +++++++++++++++++++ 1 file changed, 22 insertions(+) diff --git a/tools/migrate/migrations/20260527120000_dedupe_tax_codes_by_app_mapping.up.sql b/tools/migrate/migrations/20260527120000_dedupe_tax_codes_by_app_mapping.up.sql index 3e70919660..9970a7d73c 100644 --- a/tools/migrate/migrations/20260527120000_dedupe_tax_codes_by_app_mapping.up.sql +++ b/tools/migrate/migrations/20260527120000_dedupe_tax_codes_by_app_mapping.up.sql @@ -43,6 +43,28 @@ FROM ranked r JOIN winners w USING (namespace, app_type, app_tax_code) WHERE r.rn > 1; +-- Sanity check: a single loser_id must map to exactly one winner_id. A +-- multi-mapping tax_codes row can be ranked as a loser in two partitions +-- whose winners differ; the subsequent UPDATE ... FROM _tax_code_dedup_map +-- would then non-deterministically pick one winner per child row. Abort +-- fast instead so a human can inspect the data. +DO $$ +DECLARE + conflicting_count int; +BEGIN + SELECT COUNT(*) INTO conflicting_count + FROM ( + SELECT loser_id + FROM _tax_code_dedup_map + GROUP BY loser_id + HAVING COUNT(DISTINCT winner_id) > 1 + ) c; + + IF conflicting_count > 0 THEN + RAISE EXCEPTION 'tax_code dedup: % loser row(s) map to multiple distinct winner rows; aborting migration', conflicting_count; + END IF; +END $$; + CREATE INDEX ON _tax_code_dedup_map (loser_id); -- Step 2: repoint every tax_code_id FK column to the winner. From dcfaf7df1caa5de4cf2ea7e93d1eb86cfa7a2e12 Mon Sep 17 00:00:00 2001 From: Robert Borbely Date: Thu, 28 May 2026 14:19:43 +0200 Subject: [PATCH 3/3] fix(taxcode): also dedupe references inside tax_config JSONB --- .../dedupe_tax_codes_by_app_mapping_test.go | 204 +++++++++++++++++- ...000_dedupe_tax_codes_by_app_mapping.up.sql | 116 ++++++++-- tools/migrate/migrations/atlas.sum | 4 +- 3 files changed, 304 insertions(+), 20 deletions(-) diff --git a/tools/migrate/dedupe_tax_codes_by_app_mapping_test.go b/tools/migrate/dedupe_tax_codes_by_app_mapping_test.go index e578ae40af..d19986f04b 100644 --- a/tools/migrate/dedupe_tax_codes_by_app_mapping_test.go +++ b/tools/migrate/dedupe_tax_codes_by_app_mapping_test.go @@ -2,6 +2,7 @@ package migrate_test import ( "database/sql" + "fmt" "testing" "time" @@ -50,6 +51,19 @@ func TestDedupeTaxCodesByAppMappingMigration(t *testing.T) { // organization_default_tax_codes: both FK columns pointing at group1Loser. orgDTCID := ulid.Make().String() + // IDs for the billing_invoice_lines chain. + // Three apps (tax, invoicing, payment), a billing profile, a dedicated + // workflow config (separate from wfcRowID which has a tax_code_id FK), and + // a billing invoice are the minimum parent rows required. + taxAppID := ulid.Make().String() + invoicingAppID := ulid.Make().String() + paymentAppID := ulid.Make().String() + profileWfcID := ulid.Make().String() // workflow config for the billing profile + billingProfileID := ulid.Make().String() + invoiceWfcID := ulid.Make().String() // workflow config snapshot for the invoice + billingInvoiceID := ulid.Make().String() + billingLineID := ulid.Make().String() + runner{ stops: stops{ { @@ -121,20 +135,27 @@ func TestDedupeTaxCodesByAppMappingMigration(t *testing.T) { require.NoError(t, err) // billing_workflow_configs row pointing at group1Loser. + // invoice_default_tax_settings carries the loser id as a JSONB scalar — + // this exercises the Step 2 JSONB repoint for billing_workflow_configs. + // Pass the full JSON string as $4 to avoid parameter type ambiguity that + // arises when $3 is inferred as char(26) but also used in JSONB context. + wfcTaxSettings := fmt.Sprintf(`{"tax_code_id":%q}`, group1Loser) _, err = db.Exec(` INSERT INTO billing_workflow_configs ( id, namespace, created_at, updated_at, collection_alignment, line_collection_period, invoice_auto_advance, invoice_draft_period, invoice_due_after, invoice_collection_method, - invoice_progressive_billing, tax_code_id + invoice_progressive_billing, tax_code_id, + invoice_default_tax_settings ) VALUES ( $1, $2, NOW(), NOW(), 'subscription', 'P1M', true, 'P1D', 'P30D', 'charge_automatically', - false, $3 - )`, wfcRowID, namespace, group1Loser) + false, $3, + $4::jsonb + )`, wfcRowID, namespace, group1Loser, wfcTaxSettings) require.NoError(t, err) // plan → plan_phases → plan_rate_cards chain. @@ -204,14 +225,20 @@ func TestDedupeTaxCodesByAppMappingMigration(t *testing.T) { )`, subPhaseID, namespace, subID) require.NoError(t, err) + // subscription_items: tax_config carries the loser id as a JSONB scalar — + // exercises the Step 2 JSONB repoint for subscription_items. + // Pass the full JSON string as $5 to avoid parameter type ambiguity. + subItemTaxConfig := fmt.Sprintf(`{"tax_code_id":%q}`, group1Loser) _, err = db.Exec(` INSERT INTO subscription_items ( id, namespace, created_at, updated_at, - active_from, key, name, phase_id, tax_code_id + active_from, key, name, phase_id, tax_code_id, + tax_config ) VALUES ( $1, $2, NOW(), NOW(), - '2024-01-01 00:00:00', 'item-1', 'Item 1', $3, $4 - )`, subItemID, namespace, subPhaseID, group1Loser) + '2024-01-01 00:00:00', 'item-1', 'Item 1', $3, $4, + $5::jsonb + )`, subItemID, namespace, subPhaseID, group1Loser, subItemTaxConfig) require.NoError(t, err) // charge_flat_fees row pointing at group1Loser. @@ -248,6 +275,123 @@ func TestDedupeTaxCodesByAppMappingMigration(t *testing.T) { $1, $2, NOW(), NOW(), $3, $3 )`, orgDTCID, namespace, group1Loser) require.NoError(t, err) + + // billing_invoice_lines: exercises Step 2 JSONB scalar repoint and the + // Step 3 full entity snapshot rewrite. The tax_config carries both + // tax_code_id (scalar) and tax_code (a minimal TaxCode snapshot using the + // loser's id/key/name). After the migration both must reference the winner. + // + // Parent chain: apps → billing_workflow_configs → billing_profiles, + // apps → billing_invoices → billing_invoice_lines. + for _, row := range []struct { + id, name, appType, status string + }{ + {taxAppID, "Stripe Tax", "stripe", "active"}, + {invoicingAppID, "Stripe Invoicing", "stripe", "active"}, + {paymentAppID, "Stripe Payment", "stripe", "active"}, + } { + _, err = db.Exec(` + INSERT INTO apps (id, namespace, created_at, updated_at, name, type, status) + VALUES ($1, $2, NOW(), NOW(), $3, $4, $5) + `, row.id, namespace, row.name, row.appType, row.status) + require.NoError(t, err) + } + + _, err = db.Exec(` + INSERT INTO billing_workflow_configs ( + id, namespace, created_at, updated_at, + collection_alignment, line_collection_period, + invoice_auto_advance, invoice_draft_period, + invoice_due_after, invoice_collection_method, + invoice_progressive_billing + ) VALUES ( + $1, $2, NOW(), NOW(), + 'subscription', 'P1M', + true, 'P1D', + 'P30D', 'charge_automatically', + false + )`, profileWfcID, namespace) + require.NoError(t, err) + + _, err = db.Exec(` + INSERT INTO billing_profiles ( + id, namespace, created_at, updated_at, + name, supplier_name, "default", + tax_app_id, invoicing_app_id, payment_app_id, + workflow_config_id + ) VALUES ( + $1, $2, NOW(), NOW(), + 'Test Profile', 'Test Supplier', true, + $3, $4, $5, $6 + )`, billingProfileID, namespace, taxAppID, invoicingAppID, paymentAppID, profileWfcID) + require.NoError(t, err) + + _, err = db.Exec(` + INSERT INTO billing_workflow_configs ( + id, namespace, created_at, updated_at, + collection_alignment, line_collection_period, + invoice_auto_advance, invoice_draft_period, + invoice_due_after, invoice_collection_method, + invoice_progressive_billing + ) VALUES ( + $1, $2, NOW(), NOW(), + 'subscription', 'P1M', + true, 'P1D', + 'P30D', 'charge_automatically', + false + )`, invoiceWfcID, namespace) + require.NoError(t, err) + + _, err = db.Exec(` + INSERT INTO billing_invoices ( + id, namespace, created_at, updated_at, + supplier_name, customer_name, number, type, status, currency, + amount, taxes_total, taxes_inclusive_total, taxes_exclusive_total, + charges_total, discounts_total, credits_total, total, + tax_app_id, invoicing_app_id, payment_app_id, + source_billing_profile_id, workflow_config_id, customer_id + ) VALUES ( + $1, $2, NOW(), NOW(), + 'Test Supplier', 'Test Customer', 'INV-001', 'gathering', 'gathering', 'USD', + 0, 0, 0, 0, 0, 0, 0, 0, + $3, $4, $5, + $6, $7, $8 + )`, billingInvoiceID, namespace, + taxAppID, invoicingAppID, paymentAppID, + billingProfileID, invoiceWfcID, customerID) + require.NoError(t, err) + + // The loser snapshot embedded in tax_config.tax_code uses realistic + // timestamps (t1/t2) and the loser's app_mappings. After the migration + // the snapshot must be replaced with the winner's fields. + loserSnapshot := fmt.Sprintf( + `{"namespace":%q,"id":%q,"createdAt":%q,"updatedAt":%q,"key":"stripe_txcd_10103001","name":"Stripe txcd_10103001 (auto)","app_mappings":[{"app_type":"stripe","tax_code":"txcd_10103001"}]}`, + namespace, group1Loser, + t1.Format(time.RFC3339), + t1.Format(time.RFC3339), + ) + // Pass the full tax_config JSON as $4 to avoid parameter type ambiguity. + // The JSONB contains both the scalar tax_code_id and the full entity snapshot. + lineTaxConfig := fmt.Sprintf(`{"tax_code_id":%q,"tax_code":%s}`, group1Loser, loserSnapshot) + _, err = db.Exec(` + INSERT INTO billing_invoice_lines ( + id, namespace, created_at, updated_at, + name, currency, type, status, + amount, taxes_total, taxes_inclusive_total, taxes_exclusive_total, + charges_total, discounts_total, credits_total, total, + period_start, period_end, managed_by, invoice_at, + tax_code_id, tax_config, + invoice_id + ) VALUES ( + $1, $2, NOW(), NOW(), + 'Test Line', 'USD', 'flat_fee', 'valid', + 100, 0, 0, 0, 100, 0, 0, 100, + '2024-01-01 00:00:00', '2024-02-01 00:00:00', + 'subscription', '2024-02-01 00:00:00', + $3, $4::jsonb, + $5 + )`, billingLineID, namespace, group1Loser, lineTaxConfig, billingInvoiceID) + require.NoError(t, err) }, }, { @@ -301,6 +445,15 @@ func TestDedupeTaxCodesByAppMappingMigration(t *testing.T) { require.NoError(t, err) require.Equal(t, group1Winner, taxCodeID, "billing_workflow_configs.tax_code_id should be repointed to group1Winner") + // billing_workflow_configs JSONB scalar must also be repointed. + var wfcJSONBTaxCodeID string + err = db.QueryRow(` + SELECT invoice_default_tax_settings ->> 'tax_code_id' + FROM billing_workflow_configs WHERE id = $1 + `, wfcRowID).Scan(&wfcJSONBTaxCodeID) + require.NoError(t, err) + require.Equal(t, group1Winner, wfcJSONBTaxCodeID, "billing_workflow_configs.invoice_default_tax_settings.tax_code_id should be repointed to group1Winner") + // plan_rate_cards FK must now point at group1Winner. err = db.QueryRow(` SELECT tax_code_id FROM plan_rate_cards WHERE id = $1 @@ -315,6 +468,15 @@ func TestDedupeTaxCodesByAppMappingMigration(t *testing.T) { require.NoError(t, err) require.Equal(t, group1Winner, taxCodeID, "subscription_items.tax_code_id should be repointed to group1Winner") + // subscription_items JSONB scalar must also be repointed. + var subItemJSONBTaxCodeID string + err = db.QueryRow(` + SELECT tax_config ->> 'tax_code_id' + FROM subscription_items WHERE id = $1 + `, subItemID).Scan(&subItemJSONBTaxCodeID) + require.NoError(t, err) + require.Equal(t, group1Winner, subItemJSONBTaxCodeID, "subscription_items.tax_config.tax_code_id should be repointed to group1Winner") + // charge_flat_fees FK must now point at group1Winner. err = db.QueryRow(` SELECT tax_code_id FROM charge_flat_fees WHERE id = $1 @@ -331,6 +493,36 @@ func TestDedupeTaxCodesByAppMappingMigration(t *testing.T) { require.NoError(t, err) require.Equal(t, group1Winner, invTaxCodeID, "organization_default_tax_codes.invoicing_tax_code_id should be repointed to group1Winner") require.Equal(t, group1Winner, cgTaxCodeID, "organization_default_tax_codes.credit_grant_tax_code_id should be repointed to group1Winner") + + // billing_invoice_lines: scalar tax_code_id FK must be repointed. + err = db.QueryRow(` + SELECT tax_code_id FROM billing_invoice_lines WHERE id = $1 + `, billingLineID).Scan(&taxCodeID) + require.NoError(t, err) + require.Equal(t, group1Winner, taxCodeID, "billing_invoice_lines.tax_code_id should be repointed to group1Winner") + + // billing_invoice_lines: JSONB scalar tax_config.tax_code_id must be repointed. + var lineTaxConfigID string + err = db.QueryRow(` + SELECT tax_config ->> 'tax_code_id' + FROM billing_invoice_lines WHERE id = $1 + `, billingLineID).Scan(&lineTaxConfigID) + require.NoError(t, err) + require.Equal(t, group1Winner, lineTaxConfigID, "billing_invoice_lines.tax_config.tax_code_id should be repointed to group1Winner") + + // billing_invoice_lines: full entity snapshot (tax_config.tax_code) must + // reflect the winner's id, key, and name (Step 3 snapshot rewrite). + var snapshotID, snapshotKey, snapshotName string + err = db.QueryRow(` + SELECT tax_config -> 'tax_code' ->> 'id', + tax_config -> 'tax_code' ->> 'key', + tax_config -> 'tax_code' ->> 'name' + FROM billing_invoice_lines WHERE id = $1 + `, billingLineID).Scan(&snapshotID, &snapshotKey, &snapshotName) + require.NoError(t, err) + require.Equal(t, group1Winner, snapshotID, "billing_invoice_lines snapshot id should be rewritten to group1Winner") + require.Equal(t, "saas_business", snapshotKey, "billing_invoice_lines snapshot key should match group1Winner's key") + require.Equal(t, "SaaS Business", snapshotName, "billing_invoice_lines snapshot name should match group1Winner's name") }, }, }, diff --git a/tools/migrate/migrations/20260527120000_dedupe_tax_codes_by_app_mapping.up.sql b/tools/migrate/migrations/20260527120000_dedupe_tax_codes_by_app_mapping.up.sql index 9970a7d73c..677e594ed9 100644 --- a/tools/migrate/migrations/20260527120000_dedupe_tax_codes_by_app_mapping.up.sql +++ b/tools/migrate/migrations/20260527120000_dedupe_tax_codes_by_app_mapping.up.sql @@ -7,6 +7,8 @@ -- downstream tax_code_id FK columns may still point at losers. This migration -- soft-deletes every loser and repoints all FKs to the winner in one transaction. +BEGIN; + -- Step 1: materialise loser → winner pairs into a temp table. -- Winner selection mirrors the read-side tie-break: system-managed rows first, -- then oldest created_at, then smallest id. @@ -68,29 +70,119 @@ END $$; CREATE INDEX ON _tax_code_dedup_map (loser_id); -- Step 2: repoint every tax_code_id FK column to the winner. -UPDATE billing_workflow_configs SET tax_code_id = m.winner_id FROM _tax_code_dedup_map m WHERE billing_workflow_configs.tax_code_id = m.loser_id; -UPDATE billing_customer_overrides SET tax_code_id = m.winner_id FROM _tax_code_dedup_map m WHERE billing_customer_overrides.tax_code_id = m.loser_id; -UPDATE billing_invoice_lines SET tax_code_id = m.winner_id FROM _tax_code_dedup_map m WHERE billing_invoice_lines.tax_code_id = m.loser_id; -UPDATE billing_invoice_split_line_groups SET tax_code_id = m.winner_id FROM _tax_code_dedup_map m WHERE billing_invoice_split_line_groups.tax_code_id = m.loser_id; -UPDATE billing_standard_invoice_detailed_lines SET tax_code_id = m.winner_id FROM _tax_code_dedup_map m WHERE billing_standard_invoice_detailed_lines.tax_code_id = m.loser_id; -UPDATE charge_usage_based_run_detailed_line SET tax_code_id = m.winner_id FROM _tax_code_dedup_map m WHERE charge_usage_based_run_detailed_line.tax_code_id = m.loser_id; -UPDATE charge_flat_fee_run_detailed_lines SET tax_code_id = m.winner_id FROM _tax_code_dedup_map m WHERE charge_flat_fee_run_detailed_lines.tax_code_id = m.loser_id; -UPDATE subscription_items SET tax_code_id = m.winner_id FROM _tax_code_dedup_map m WHERE subscription_items.tax_code_id = m.loser_id; -UPDATE plan_rate_cards SET tax_code_id = m.winner_id FROM _tax_code_dedup_map m WHERE plan_rate_cards.tax_code_id = m.loser_id; -UPDATE addon_rate_cards SET tax_code_id = m.winner_id FROM _tax_code_dedup_map m WHERE addon_rate_cards.tax_code_id = m.loser_id; +-- Tables that carry a paired JSONB column (productcatalog.TaxConfig) also have +-- the embedded tax_code_id scalar repointed in the same UPDATE so that +-- both representations stay consistent without a second table pass. +-- Tables without a JSONB column (charge_flat_fees, charge_usage_based, +-- charge_credit_purchases, organization_default_tax_codes) only update the FK. +UPDATE billing_workflow_configs + SET tax_code_id = m.winner_id, + invoice_default_tax_settings = jsonb_set(invoice_default_tax_settings, '{tax_code_id}', to_jsonb(m.winner_id::text)) + FROM _tax_code_dedup_map m + WHERE billing_workflow_configs.tax_code_id = m.loser_id; + +UPDATE billing_customer_overrides + SET tax_code_id = m.winner_id, + invoice_default_tax_config = jsonb_set(invoice_default_tax_config, '{tax_code_id}', to_jsonb(m.winner_id::text)) + FROM _tax_code_dedup_map m + WHERE billing_customer_overrides.tax_code_id = m.loser_id; + +UPDATE billing_invoice_lines + SET tax_code_id = m.winner_id, + tax_config = jsonb_set(tax_config, '{tax_code_id}', to_jsonb(m.winner_id::text)) + FROM _tax_code_dedup_map m + WHERE billing_invoice_lines.tax_code_id = m.loser_id; + +UPDATE billing_invoice_split_line_groups + SET tax_code_id = m.winner_id, + tax_config = jsonb_set(tax_config, '{tax_code_id}', to_jsonb(m.winner_id::text)) + FROM _tax_code_dedup_map m + WHERE billing_invoice_split_line_groups.tax_code_id = m.loser_id; + +UPDATE billing_standard_invoice_detailed_lines + SET tax_code_id = m.winner_id, + tax_config = jsonb_set(tax_config, '{tax_code_id}', to_jsonb(m.winner_id::text)) + FROM _tax_code_dedup_map m + WHERE billing_standard_invoice_detailed_lines.tax_code_id = m.loser_id; + +UPDATE charge_usage_based_run_detailed_line + SET tax_code_id = m.winner_id, + tax_config = jsonb_set(tax_config, '{tax_code_id}', to_jsonb(m.winner_id::text)) + FROM _tax_code_dedup_map m + WHERE charge_usage_based_run_detailed_line.tax_code_id = m.loser_id; + +UPDATE charge_flat_fee_run_detailed_lines + SET tax_code_id = m.winner_id, + tax_config = jsonb_set(tax_config, '{tax_code_id}', to_jsonb(m.winner_id::text)) + FROM _tax_code_dedup_map m + WHERE charge_flat_fee_run_detailed_lines.tax_code_id = m.loser_id; + +UPDATE subscription_items + SET tax_code_id = m.winner_id, + tax_config = jsonb_set(tax_config, '{tax_code_id}', to_jsonb(m.winner_id::text)) + FROM _tax_code_dedup_map m + WHERE subscription_items.tax_code_id = m.loser_id; + +UPDATE plan_rate_cards + SET tax_code_id = m.winner_id, + tax_config = jsonb_set(tax_config, '{tax_code_id}', to_jsonb(m.winner_id::text)) + FROM _tax_code_dedup_map m + WHERE plan_rate_cards.tax_code_id = m.loser_id; + +UPDATE addon_rate_cards + SET tax_code_id = m.winner_id, + tax_config = jsonb_set(tax_config, '{tax_code_id}', to_jsonb(m.winner_id::text)) + FROM _tax_code_dedup_map m + WHERE addon_rate_cards.tax_code_id = m.loser_id; + UPDATE charge_flat_fees SET tax_code_id = m.winner_id FROM _tax_code_dedup_map m WHERE charge_flat_fees.tax_code_id = m.loser_id; UPDATE charge_usage_based SET tax_code_id = m.winner_id FROM _tax_code_dedup_map m WHERE charge_usage_based.tax_code_id = m.loser_id; UPDATE charge_credit_purchases SET tax_code_id = m.winner_id FROM _tax_code_dedup_map m WHERE charge_credit_purchases.tax_code_id = m.loser_id; UPDATE organization_default_tax_codes SET invoicing_tax_code_id = m.winner_id FROM _tax_code_dedup_map m WHERE organization_default_tax_codes.invoicing_tax_code_id = m.loser_id; UPDATE organization_default_tax_codes SET credit_grant_tax_code_id = m.winner_id FROM _tax_code_dedup_map m WHERE organization_default_tax_codes.credit_grant_tax_code_id = m.loser_id; --- Step 3: soft-delete losers. +-- Step 3: rewrite the full taxcode.TaxCode entity snapshot embedded inside +-- billing_invoice_lines.tax_config.tax_code. SnapshotTaxConfigIntoLines +-- (openmeter/billing/service/invoicecalc/taxconfig.go) stamps the resolved +-- entity into this sub-object at invoice-advance time; without rewriting +-- it, raw-SQL or edge-less reads would still see the loser's id, key, name. +-- +-- Match on the loser id still embedded inside the snapshot so only lines +-- whose snapshot is actually stale get rewritten. The guard +-- `tax_config -> 'tax_code' IS NOT NULL` skips gathering invoices that +-- never had a snapshot stamped. +UPDATE billing_invoice_lines bil + SET tax_config = jsonb_set( + bil.tax_config, + '{tax_code}', + jsonb_build_object( + 'namespace', w.namespace, + 'id', w.id, + 'createdAt', w.created_at, + 'updatedAt', w.updated_at, + 'deletedAt', w.deleted_at, + 'key', w.key, + 'name', w.name, + 'description', w.description, + 'app_mappings', COALESCE(w.app_mappings::jsonb, '[]'::jsonb), + 'metadata', COALESCE(w.metadata, '{}'::jsonb), + 'annotations', COALESCE(w.annotations, '{}'::jsonb) + ) + ) + FROM _tax_code_dedup_map m + JOIN tax_codes w ON w.id = m.winner_id + WHERE bil.tax_config -> 'tax_code' IS NOT NULL + AND bil.tax_config -> 'tax_code' ->> 'id' = m.loser_id; + +-- Step 4: soft-delete losers. UPDATE tax_codes SET deleted_at = NOW(), updated_at = NOW() WHERE id IN (SELECT loser_id FROM _tax_code_dedup_map) AND deleted_at IS NULL; --- Step 4: drop the temp table explicitly. Without ON COMMIT DROP (which atlas +-- Step 5: drop the temp table explicitly. Without ON COMMIT DROP (which atlas -- migrate validate cannot model under its autocommit dry-run), session-scoped -- temp tables would otherwise live until the migration connection closes. DROP TABLE _tax_code_dedup_map; + +COMMIT; diff --git a/tools/migrate/migrations/atlas.sum b/tools/migrate/migrations/atlas.sum index 88ce729397..0278aae581 100644 --- a/tools/migrate/migrations/atlas.sum +++ b/tools/migrate/migrations/atlas.sum @@ -1,4 +1,4 @@ -h1:GlYJ60qcTBAl8oF9zIgn4Ll+UElajycPn0zO9FhFbwo= +h1:MUO8x7HjdEQ+JK6ZMubbAOj7wEPcy/LzifR3e+QPBg8= 20240826120919_init.up.sql h1:tc1V91/smlmaeJGQ8h+MzTEeFjjnrrFDbDAjOYJK91o= 20240903155435_entitlement-expired-index.up.sql h1:Hp8u5uckmLXc1cRvWU0AtVnnK8ShlpzZNp8pbiJLhac= 20240917172257_billing-entities.up.sql h1:Q1dAMo0Vjiit76OybClNfYPGC5nmvov2/M2W1ioi4Kw= @@ -205,4 +205,4 @@ h1:GlYJ60qcTBAl8oF9zIgn4Ll+UElajycPn0zO9FhFbwo= 20260519132345_reset-sync-state.up.sql h1:R8ekG92vwXQbtqmh+R3ZTTRMCM0VqPoLzJG3VIKlG4k= 20260520130000_repair_rebased_migrations.up.sql h1:XJhbK1CCplq5BEJi+VvHDVAcLUBJqHNF1LFGhaSa2Oo= 20260520130500_add_ledger_tax_behavior.up.sql h1:M8Lq8lE/O+RwPLJ/7qe5tVddruuAxOcBAeJ1usFEveg= -20260527120000_dedupe_tax_codes_by_app_mapping.up.sql h1:ajwBwf4YFYo/ZRSaTcIPoek4r0wpjDIiE/rm19EKbZE= +20260527120000_dedupe_tax_codes_by_app_mapping.up.sql h1:/tOMj6sA5vrD6+xJLZXeJaxnY4fJoxhfDGzRCPqWeEA=