Reconciling Duplicate Payments After an Incident

Reversal is not the end of a duplicate-charge incident; proving the reversal was complete and correct is. That proof comes from a three-way reconciliation between your ledger, the payment processor’s records and the audit table your remediation wrote. This runbook is the closing step of incident response for duplicate processing.

Prerequisites. A frozen incident table listing the charges to reverse, a reversal_audit table populated by the remediation job, and read access to the processor’s API or settlement export.


Step 1 — Load the processor’s records

Never reconcile against your own data alone. The incident happened because your view and the processor’s view diverged.

The join type decides whether you find the worst bucket An inner join between the ledger and the processor export returns only charges present in both, so charges that exist at the processor but never reached the ledger are invisible. A full outer join returns those rows too, which is the only way the unrecorded-charge bucket can be discovered at all. INNER JOIN only both the rest is invisible FULL OUTER JOIN all rows
# Stripe: export every charge in the incident window with its metadata.
stripe charges list \
  --created "gte=$(date -d '2026-08-01 09:12:00Z' +%s)" \
  --created "lt=$(date -d '2026-08-01 14:41:00Z' +%s)" \
  --limit 100 --expand 'data.refunds' \
  | jq -r '.data[] | [.id, .metadata.idempotency_key, .amount, .currency,
                      .status, .captured, (.refunds.data | length),
                      .created] | @csv' \
  > processor_window.csv
CREATE TABLE incident_2026_08_01_processor (
    processor_charge_id text PRIMARY KEY,
    idempotency_key     text,
    amount_minor        bigint      NOT NULL,
    currency            text        NOT NULL,
    status              text        NOT NULL,
    captured            boolean     NOT NULL,
    refund_count        int         NOT NULL,
    created_at          timestamptz NOT NULL
);
\copy incident_2026_08_01_processor FROM 'processor_window.csv' WITH (FORMAT csv)

Use the settlement export rather than the API where one exists. The API reflects current state; the settlement file reflects what actually moved money, and for a financial reconciliation that distinction matters.


Step 2 — Join into one view

The three-way reconciliation and its discrepancy buckets Three overlapping circles labelled ledger, processor and audit. The central overlap of all three is labelled reconciled and reversed, the healthy outcome. Ledger only is labelled phantom ledger row with no money movement. Processor only is labelled unrecorded charge, the most serious bucket. Ledger and processor without audit is labelled duplicate not yet reversed. Audit without processor is labelled reversal claimed but not confirmed. ledger processor reversal audit reconciled the healthy set phantom no money moved unrecorded charges included not yet reversed claimed, unconfirmed Closure criterion every bucket except "reconciled" is empty and the query that proves it is committed
CREATE VIEW incident_2026_08_01_recon AS
SELECT
    coalesce(l.processor_charge_id, p.processor_charge_id) AS charge_id,
    coalesce(l.idempotency_key, p.idempotency_key)         AS idempotency_key,
    l.customer_id,
    l.amount_minor        AS ledger_amount,
    p.amount_minor        AS processor_amount,
    p.status              AS processor_status,
    p.captured,
    p.refund_count,
    a.action              AS audit_action,
    a.processor_ref       AS audit_processor_ref,
    CASE
      WHEN p.processor_charge_id IS NULL                      THEN 'phantom_ledger'
      WHEN l.processor_charge_id IS NULL                      THEN 'unrecorded_charge'
      WHEN l.amount_minor <> p.amount_minor                   THEN 'amount_mismatch'
      WHEN d.reverse_id IS NOT NULL AND a.action IS NULL      THEN 'not_yet_reversed'
      WHEN a.action IN ('void','refund') AND p.refund_count = 0
           AND p.captured                                     THEN 'claimed_unconfirmed'
      ELSE 'reconciled'
    END AS bucket
  FROM ledger_charges l
  FULL OUTER JOIN incident_2026_08_01_processor p
    ON p.processor_charge_id = l.processor_charge_id
  LEFT JOIN incident_2026_08_01_duplicates d
    ON d.reverse_id = l.id
  LEFT JOIN reversal_audit a
    ON a.charge_id = l.id AND a.run_id = 'incident-2026-08-01'
 WHERE coalesce(l.created_at, p.created_at)
       BETWEEN TIMESTAMPTZ '2026-08-01 09:12:00Z' AND TIMESTAMPTZ '2026-08-01 14:41:00Z';

FULL OUTER JOIN is essential. An inner join silently drops the unrecorded_charge bucket — charges that exist at the processor but never made it into your ledger — which is precisely the population you most need to find.


Step 3 — Classify

SELECT bucket, count(*), sum(coalesce(processor_amount, ledger_amount))/100.0 AS value
  FROM incident_2026_08_01_recon
 GROUP BY bucket ORDER BY count(*) DESC;
Bucket Meaning Severity Action
reconciled Ledger, processor and audit all agree None
unrecorded_charge Money moved; your ledger has no record Critical Backfill the ledger, then decide reverse or keep
not_yet_reversed Identified as a duplicate, no reversal recorded High Re-run the remediation for these ids only
claimed_unconfirmed Audit says refunded; the processor shows none High Re-query the processor; if truly absent, re-issue with the same derived key
amount_mismatch Ledger and processor disagree on the amount High Manual review — usually a partial capture, occasionally a currency bug
phantom_ledger Ledger row with no corresponding charge Medium Usually a rolled-back transaction; verify no money moved, then void the row

unrecorded_charge deserves its severity. It is invisible to every query that starts from your own database, it means a customer was charged for something you have no record of, and it is the bucket most likely to become a regulatory conversation.


Step 4 — Resolve each bucket

RUN = "incident-2026-08-01"

def resolve_not_yet_reversed(conn) -> None:
    rows = conn.execute("""
        SELECT charge_id, idempotency_key FROM incident_2026_08_01_recon
         WHERE bucket = 'not_yet_reversed'
    """).fetchall()
    for row in rows:
        # Same derived key as the original run: re-running is a no-op at the
        # processor if the reversal actually did land and we simply missed it.
        key = f"{RUN}:reverse:{row.charge_id}"
        result = processor.refunds.create(charge=row.charge_id, reason="duplicate",
                                          idempotency_key=key)
        audit(row, action="refund", reason="reconciliation_catchup",
              processor_ref=result.id)

def resolve_unrecorded(conn) -> None:
    rows = conn.execute("""
        SELECT charge_id, processor_amount, idempotency_key
          FROM incident_2026_08_01_recon WHERE bucket = 'unrecorded_charge'
    """).fetchall()
    for row in rows:
        # Backfill FIRST so the charge is visible, then classify it like any other.
        conn.execute("""INSERT INTO ledger_charges
                          (processor_charge_id, amount_minor, idempotency_key,
                           source, created_at)
                        VALUES (%s, %s, %s, 'reconciliation_backfill', now())
                        ON CONFLICT (processor_charge_id) DO NOTHING""",
                     (row.charge_id, row.processor_amount, row.idempotency_key))
        audit(row, action="backfilled", reason="present_at_processor_only")

Every resolution writes an audit row. A reconciliation that fixes data without recording what it fixed has replaced one unexplained state with another.


Step 5 — Prove closure and freeze the evidence

-- The closure check. Commit this query to the repository; it must be re-runnable
-- months later when someone asks whether the incident was really resolved.
SELECT bucket, count(*)
  FROM incident_2026_08_01_recon
 WHERE bucket <> 'reconciled'
 GROUP BY bucket;
-- expect: zero rows
-- Value closure: what left the business, and what came back.
SELECT
  sum(processor_amount) FILTER (WHERE bucket = 'reconciled')            / 100.0 AS total_charged,
  sum(a.amount_minor)   FILTER (WHERE a.action IN ('void','refund'))    / 100.0 AS total_returned,
  count(DISTINCT r.customer_id) FILTER (WHERE a.action IS NOT NULL)              AS customers_made_whole
  FROM incident_2026_08_01_recon r
  LEFT JOIN reversal_audit a ON a.charge_id = r.charge_id AND a.run_id = 'incident-2026-08-01';
# Freeze the evidence: materialise the view, stamp it, and mark it for long retention.
psql <<'SQL'
CREATE TABLE incident_2026_08_01_recon_final AS
  SELECT *, now() AS frozen_at FROM incident_2026_08_01_recon;
COMMENT ON TABLE incident_2026_08_01_recon_final IS
  'Incident 2026-08-01 reconciliation. RETENTION: 7 years. Do not drop.';
SQL

The incident_ prefix and the retention comment are not decoration — they are what stop a routine cleanup of analysis tables from deleting the only proof that the incident was resolved.


Failure scenarios and debugging

Failure Scenario Remediation Steps Observability Hooks
Reconciliation uses an inner join, so charges present only at the processor are never surfaced Use FULL OUTER JOIN and assert that the unrecorded_charge bucket is explicitly evaluated, not implicitly absent. Row count of the reconciliation view versus count(*) of the processor export; a shortfall means rows were dropped
Processor API queried instead of the settlement file, so refunds issued after the export appear as unconfirmed Re-query immediately before classifying, or reconcile against the settlement file which is immutable once published. claimed_unconfirmed bucket that empties on re-run — a sign the snapshot was stale, not that reversals failed
Catch-up reversals issued with fresh random keys, double-refunding charges that were already refunded Reuse the deterministic run:reverse:charge_id key so the processor collapses the repeat into a no-op. reversal_audit unique constraint violations; processor refund count above 1 for any charge
Reconciliation table dropped by a routine cleanup of temporary analysis artefacts Name every artefact with an incident_ prefix, add a retention comment, and exclude the prefix from cleanup jobs. A scheduled check asserting the incident tables still exist, running for the full retention period
Amount mismatches auto-resolved as duplicates, refunding partial captures in full Route amount_mismatch to manual review only. Never automate a bucket whose cause is ambiguous. amount_mismatch resolutions recorded with an operator id; zero automated resolutions in that bucket
What "closed" actually means Five discrepancy buckets are shown with their counts driven to zero, alongside a value delta of zero between what was charged in error and what was returned. Closure is both conditions together, evidenced by a committed query that can be re-run months later rather than by a statement in a channel. unrecorded 0 not yet reversed 0 unconfirmed 0 amount mismatch 0 phantom 0 value charged in error − value returned = 0 and the query proving it is committed, not pasted into a channel

SRE / observability checklist

  1. reconciliation_bucket_count{bucket} — Gauge per bucket, exported while the incident is open. Closure is every non-reconciled bucket at zero.
  2. unrecorded_charge_count — The bucket that matters most. Any non-zero value is a customer charged for something invisible to your systems.
  3. reversal_audit_unmatched_total — Counter of audit rows with no corresponding processor refund. Non-zero means a claimed reversal did not land.
  4. reconciliation_value_delta — Gauge of total_charged - total_returned for the duplicate set. Should reach exactly zero.
  5. incident_table_present — Boolean gauge from a scheduled check, for the full retention period. Catches accidental cleanup years later.
  6. run_id on every audit and reconciliation row — So a second remediation pass is distinguishable from the first, and neither can be confused for the other.