feat: fraud webhook — flag disputed/warned cards (service-block gate)#52
Merged
Conversation
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>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
What
Completes the service-block gate's fraud dimension. Migration 038 shipped
payment_methods_mirror.fraud_blockedwith no writer, so today all cards read non-fraud. This wires the writer: oncharge.dispute.createdandradar.early_fraud_warning.created, latchfraud_blockedso 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:
DisputeandRadarEarlyFraudWarningexpose only a charge id. So the handler retrieves the charge (Client.RetrieveCharge→{PaymentMethodID, Fingerprint, StripeCustomerID}) and flags the mirror.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.STRIPE_SECRET_KEY(a restrictedrk_*withcharges:readsuffices) for the retrieve — still inside billing-engine's trust boundary.Decisions (your calls)
Reviewed (Fable 5 find → Opus 4.8 verify)
Two real defects found + fixed in this PR:
Processmarks 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 (UnmarkEventProcesseddrops the idempotency row so redelivery re-runs). Fixes a latent router-wide issue, not just fraud.SQL safety, trust boundary, dispatch/decoding, and card-scoping came back clean.
Testing
Note
radar.early_fraud_warningfires in Stripe live mode only, so that path is unit-covered now and verified end-to-end post-deploy;charge.disputeis test-mode triggerable.No schema change (writer-only over the existing 038 columns).
🤖 Generated with Claude Code