Skip to content

feat: fraud webhook — flag disputed/warned cards (service-block gate)#52

Merged
I-am-nothing merged 2 commits into
mainfrom
feat/fraud-webhook
Jul 6, 2026
Merged

feat: fraud webhook — flag disputed/warned cards (service-block gate)#52
I-am-nothing merged 2 commits into
mainfrom
feat/fraud-webhook

Conversation

@I-am-nothing

Copy link
Copy Markdown
Contributor

What

Completes the service-block gate's fraud dimension. Migration 038 shipped payment_methods_mirror.fraud_blocked with no writer, so today all cards read non-fraud. This wires the writer: on charge.dispute.created and radar.early_fraud_warning.created, latch fraud_blocked so the gate stops counting the card as usable.

Resolution (the crux)

Neither event carries a payment-method id or card fingerprint — verified against stripe-go v85: Dispute and RadarEarlyFraudWarning expose only a charge id. So the handler retrieves the charge (Client.RetrieveCharge{PaymentMethodID, Fingerprint, StripeCustomerID}) and flags the mirror.

  • Card-scoped, account-bounded (FlagPaymentMethodFraud): flags every active mirror row matching the card — by fingerprint (the canonical "same physical card" identity, robust to duplicate-collapse re-keying; no LIMIT so re-added siblings are covered) OR exact pm id (catches a NULL-fingerprint legacy/wallet row). Both account-bounded + card-unique, so the account's other cards and a shared card on a different account are untouched. Set-only + idempotent.
  • Handlers share one resolve→flag core: missing charge id → 400; retrieve/store error → 500 (Stripe redelivers); non-card charge or no mirror match → drift 200.
  • The webhook binary now also loads STRIPE_SECRET_KEY (a restricted rk_* with charges:read suffices) for the retrieve — still inside billing-engine's trust boundary.

Decisions (your calls)

  • Card-scoped — only the disputed physical card is flagged, not the whole account.
  • Webhook loads the restricted key to resolve the charge (vs building a charge→PM persistence pipeline).

Reviewed (Fable 5 find → Opus 4.8 verify)

Two real defects found + fixed in this PR:

  • F1 (HIGH): the "500 → Stripe redelivers" recovery every handler documents was a dead pathProcess marks the event processed before dispatch, so a 5xx was deduped on redelivery and never re-ran (the fraud flag / invoice latches lost forever on a transient blip). Fixed: compensate on 5xx (UnmarkEventProcessed drops the idempotency row so redelivery re-runs). Fixes a latent router-wide issue, not just fraud.
  • F2 (MEDIUM): the pm-id match only fired when the charge had no fingerprint, so a NULL-fingerprint mirror row whose pm id matched was under-blocked. Fixed: additive pm-id arm.

SQL safety, trust boundary, dispatch/decoding, and card-scoping came back clean.

Testing

  • Unit: both events, drift, non-card, missing-charge, retrieve/store errors, 5xx-unmarks-then-redelivery-re-runs, 2xx-stays-deduped.
  • Integration (real PG): flag flips + gate excludes the card + all-rows-same-fingerprint + account-scoping + NULL-fingerprint→pm-id + fingerprint→pm fallback + drift.

Note

radar.early_fraud_warning fires in Stripe live mode only, so that path is unit-covered now and verified end-to-end post-deploy; charge.dispute is test-mode triggerable.

No schema change (writer-only over the existing 038 columns).

🤖 Generated with Claude Code

I-am-nothing and others added 2 commits July 7, 2026 06:12
Completes the service-block gate's fraud dimension (migration 038 shipped the
fraud_blocked column with no writer): on charge.dispute.created and
radar.early_fraud_warning.created, latch fraud_blocked on the card so the gate
stops counting it as usable.

Resolution: both events carry ONLY a charge id (verified against stripe-go
v85 — neither Dispute nor RadarEarlyFraudWarning exposes a pm id or fingerprint),
so the handler retrieves the charge (new Client.RetrieveCharge) to get the
customer + card fingerprint + pm id, then flags the mirror.

- CARD-SCOPED, account-bounded (FlagPaymentMethodFraud): flags every ACTIVE
  mirror row for the card's FINGERPRINT on the charge's account (fingerprint is
  the canonical "same physical card" identity, robust to duplicate-collapse
  re-keying; falls back to pm id when the charge has no fingerprint). Never
  touches the account's OTHER cards; a shared card on a different account is
  untouched. Set-only + idempotent.
- Handlers share one resolve→flag core: missing charge id → 400; Stripe
  retrieve error / store error → 500 (Stripe redelivers; the flag is
  idempotent); non-card charge or no mirror match → drift_warning 200.
- The webhook binary now also loads STRIPE_SECRET_KEY (a restricted rk_* with
  charges:read suffices) for the retrieve — still inside billing-engine's trust
  boundary.

Tests: unit (both events, drift, non-card, missing-charge, retrieve/store
errors) + integration on real PG (flag flips + gate excludes the card +
all-rows-same-fingerprint + account-scoping + fingerprint→pm fallback + drift).

Note: radar.early_fraud_warning fires in Stripe LIVE mode only, so that path is
unit-covered now and verified end-to-end post-deploy; charge.dispute is
test-mode triggerable.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Adversarial review (Fable5 find / Opus4.8 verify) of the fraud webhook found
two real defects:

F1 (HIGH) — the "500 → Stripe redelivers → flag re-applied" recovery every
handler documents was a DEAD path. Process marks the event processed BEFORE
dispatch, so a handler that returns 5xx (a Stripe RetrieveCharge blip, a DB
error) is deduped as a duplicate on redelivery and NEVER re-runs — the fraud
flag (and the invoice latches) would be permanently lost on any transient
failure, and dispute/EFW fire only once. Fix: compensate on 5xx —
UnmarkEventProcessed drops the idempotency row so the redelivery re-enters the
handler. The mark-before-dispatch ordering still serializes concurrent
duplicates; all handler writes are replay-idempotent so re-running is safe.
Benefits every handler, not just the fraud pair.

F2 (MEDIUM) — FlagPaymentMethodFraud's pm-id arm only fired when the CHARGE had
no fingerprint, so a mirror row with a NULL fingerprint (legacy pre-005 / wallet
card) whose pm id exactly matched the disputed charge was never flagged when the
charge DID carry a fingerprint — an under-block. Fix: make the pm-id match an
additive OR (fingerprint OR exact pm id), both account-bounded + card-unique, so
no other card is touched.

Tests: 5xx-unmarks-then-redelivery-re-runs + 2xx-stays-deduped (unit);
NULL-fingerprint-row-matched-by-pm-id (integration).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@I-am-nothing I-am-nothing merged commit 300c4a0 into main Jul 6, 2026
1 check passed
@I-am-nothing I-am-nothing deleted the feat/fraud-webhook branch July 6, 2026 22:40
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant