|
| 1 | +-- ============================================================================= |
| 2 | +-- One-time dedupe for synced_payments — OUT-3896 |
| 3 | +-- ============================================================================= |
| 4 | +-- Context: payment.succeeded had no idempotency check, so repeated webhooks |
| 5 | +-- created duplicate EXPENSE rows (each pointing to a separate Xero |
| 6 | +-- BankTransaction). Before the new partial unique index on |
| 7 | +-- (portal_id, tenant_id, copilot_payment_id) WHERE copilot_payment_id IS NOT NULL |
| 8 | +-- can be created, each group must be collapsed to a single canonical row: |
| 9 | +-- the EARLIEST-created mapping (tie-break on id for determinism). |
| 10 | +-- |
| 11 | +-- Only rows with copilot_payment_id IS NOT NULL (EXPENSE rows) are affected. |
| 12 | +-- PAYMENT rows store copilot_payment_id = NULL and are untouched. |
| 13 | +-- |
| 14 | +-- Run the DRY-RUN queries first to preview impact, then run the DELETE. |
| 15 | +-- Must run BEFORE the migration that creates the new index, ideally with |
| 16 | +-- payment.succeeded processing paused so no new duplicates appear in the gap. |
| 17 | +-- |
| 18 | +-- NOTE: This removes duplicate DB rows only. The duplicate BankTransactions |
| 19 | +-- already in Xero are a separate manual accounting reconciliation. |
| 20 | +-- ============================================================================= |
| 21 | + |
| 22 | + |
| 23 | +-- ----------------------------------------------------------------------------- |
| 24 | +-- DRY RUN #1 — duplicate groups (the affected keys) |
| 25 | +-- ----------------------------------------------------------------------------- |
| 26 | +SELECT portal_id, tenant_id, copilot_payment_id, COUNT(*) AS row_count |
| 27 | +FROM "synced_payments" |
| 28 | +WHERE copilot_payment_id IS NOT NULL |
| 29 | +GROUP BY portal_id, tenant_id, copilot_payment_id |
| 30 | +HAVING COUNT(*) > 1 |
| 31 | +ORDER BY row_count DESC; |
| 32 | + |
| 33 | + |
| 34 | +-- ----------------------------------------------------------------------------- |
| 35 | +-- DRY RUN #2 — survivor vs doomed, side by side (rn = 1 is the survivor) |
| 36 | +-- ----------------------------------------------------------------------------- |
| 37 | +SELECT |
| 38 | + id, |
| 39 | + portal_id, |
| 40 | + tenant_id, |
| 41 | + copilot_payment_id, |
| 42 | + xero_payment_id, |
| 43 | + type, |
| 44 | + created_at, |
| 45 | + ROW_NUMBER() OVER ( |
| 46 | + PARTITION BY portal_id, tenant_id, copilot_payment_id |
| 47 | + ORDER BY created_at ASC, id ASC |
| 48 | + ) AS rn, |
| 49 | + CASE |
| 50 | + WHEN ROW_NUMBER() OVER ( |
| 51 | + PARTITION BY portal_id, tenant_id, copilot_payment_id |
| 52 | + ORDER BY created_at ASC, id ASC |
| 53 | + ) = 1 THEN 'KEEP' |
| 54 | + ELSE 'DELETE' |
| 55 | + END AS action |
| 56 | +FROM "synced_payments" |
| 57 | +WHERE copilot_payment_id IS NOT NULL |
| 58 | +ORDER BY portal_id, tenant_id, copilot_payment_id, rn; |
| 59 | + |
| 60 | + |
| 61 | +-- ----------------------------------------------------------------------------- |
| 62 | +-- THE DELETE — keep the earliest-created row per |
| 63 | +-- (portal, tenant, copilot_payment_id), delete the rest. |
| 64 | +-- Tie-break on id so the result is deterministic. |
| 65 | +-- ----------------------------------------------------------------------------- |
| 66 | +DELETE FROM "synced_payments" a |
| 67 | +USING "synced_payments" b |
| 68 | +WHERE a."copilot_payment_id" IS NOT NULL |
| 69 | + AND b."copilot_payment_id" IS NOT NULL |
| 70 | + AND a."portal_id" = b."portal_id" |
| 71 | + AND a."tenant_id" = b."tenant_id" |
| 72 | + AND a."copilot_payment_id" = b."copilot_payment_id" |
| 73 | + AND ( |
| 74 | + a."created_at" > b."created_at" |
| 75 | + OR (a."created_at" = b."created_at" AND a."id" > b."id") |
| 76 | + ); |
0 commit comments