From 828fc668917034aa337a4bafec4f90f17f1e9cdd Mon Sep 17 00:00:00 2001 From: Sheng Kun Chang Date: Tue, 7 Jul 2026 06:12:46 +0800 Subject: [PATCH 1/2] =?UTF-8?q?feat:=20fraud=20webhook=20=E2=80=94=20flag?= =?UTF-8?q?=20disputed/warned=20cards=20(service-block=20gate)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- cmd/account-webhook/main.go | 9 +- cmd/account-webhook/main_test.go | 2 +- internal/account/billing/service_test.go | 4 + internal/account/cycle/charge_test.go | 4 + internal/account/db/queries/webhook.sql | 35 +++++ internal/account/db/webhook.sql.go | 56 ++++++++ internal/account/webhook/handlers.go | 99 ++++++++++++++ internal/account/webhook/router.go | 30 ++++- internal/account/webhook/router_test.go | 123 +++++++++++++++++- internal/account/webhook/store.go | 16 +++ .../account/webhook/store_integration_test.go | 112 ++++++++++++++++ internal/account/webhook/webhooktest/fakes.go | 42 ++++++ internal/shared/stripe/client.go | 24 ++++ internal/shared/stripe/types.go | 22 ++++ 14 files changed, 573 insertions(+), 5 deletions(-) diff --git a/cmd/account-webhook/main.go b/cmd/account-webhook/main.go index 654d981..3b1cb30 100644 --- a/cmd/account-webhook/main.go +++ b/cmd/account-webhook/main.go @@ -69,11 +69,18 @@ func main() { // + router. func buildRouter() *webhook.Router { webhookSecret := config.MustEnv("STRIPE_WEBHOOK_SECRET") + // The fraud handlers (charge.dispute.created / radar.early_fraud_warning.created) + // carry only a charge id, so the webhook must retrieve the charge to resolve + // the disputed card — this binary now also loads the Stripe API key (a + // restricted rk_* with charges:read is sufficient). Still inside the + // billing-engine trust boundary (CLAUDE.md). + stripeKey := config.MustEnv("STRIPE_SECRET_KEY") pool := config.MustPgxPool() verifier := billingstripe.NewVerifier(webhookSecret) store := webhook.NewStore(pool) - return webhook.NewRouter(verifier, store, slog.Default()) + charges := billingstripe.NewClient(stripeKey) + return webhook.NewRouter(verifier, store, charges, slog.Default()) } // proxyHandler is the Lambda entrypoint. Uses APIGatewayProxyRequest diff --git a/cmd/account-webhook/main_test.go b/cmd/account-webhook/main_test.go index b0f9bf5..25516a1 100644 --- a/cmd/account-webhook/main_test.go +++ b/cmd/account-webhook/main_test.go @@ -20,7 +20,7 @@ import ( func makeRouter(t *testing.T, verifier *webhooktest.FakeVerifier, store *webhooktest.FakeStore) *webhook.Router { t.Helper() - return webhook.NewRouter(verifier, store, webhooktest.SilentLogger()) + return webhook.NewRouter(verifier, store, &webhooktest.FakeChargeRetriever{}, webhooktest.SilentLogger()) } // --- httpHandler ---------------------------------------------------------- diff --git a/internal/account/billing/service_test.go b/internal/account/billing/service_test.go index 16529c8..dee5149 100644 --- a/internal/account/billing/service_test.go +++ b/internal/account/billing/service_test.go @@ -236,6 +236,10 @@ func (f *fakeStripe) CreateCheckoutSession(_ context.Context, _, _ string) (*str }, nil } +func (f *fakeStripe) RetrieveCharge(_ context.Context, _ string) (billingstripe.ChargeCardRef, error) { + return billingstripe.ChargeCardRef{}, nil // unused by the billing service +} + func (f *fakeStripe) DetachPaymentMethod(_ context.Context, stripePaymentMethodID string) error { if f.errDetach != nil { return f.errDetach diff --git a/internal/account/cycle/charge_test.go b/internal/account/cycle/charge_test.go index 427cdd7..e1d312f 100644 --- a/internal/account/cycle/charge_test.go +++ b/internal/account/cycle/charge_test.go @@ -83,6 +83,10 @@ func newFakeStripe() *fakeStripe { } } +func (f *fakeStripe) RetrieveCharge(_ context.Context, _ string) (billingstripe.ChargeCardRef, error) { + return billingstripe.ChargeCardRef{}, nil // unused by the charge cycle +} + func (f *fakeStripe) CreateDraftInvoice(_ context.Context, custID, ref, idemKey string) (billingstripe.Invoice, error) { f.invoiceCalls = append(f.invoiceCalls, invoiceCall{custID, ref, idemKey}) if f.errDraft != nil { diff --git a/internal/account/db/queries/webhook.sql b/internal/account/db/queries/webhook.sql index 80e6b07..f258711 100644 --- a/internal/account/db/queries/webhook.sql +++ b/internal/account/db/queries/webhook.sql @@ -247,3 +247,38 @@ UPDATE ms_billing.invoices SET ever_failed = true WHERE stripe_invoice_id = $1 AND NOT ever_failed; + +-- FlagPaymentMethodFraud latches fraud_blocked (migration 038) on the disputed / +-- early-fraud-warned physical card so the service-block gate (ServiceBlockSignals) +-- stops counting it as usable. The charge.dispute.created / radar.early_fraud_ +-- warning.created events carry only a charge id, so the handler first retrieves +-- the charge from Stripe to get the customer id + card fingerprint + pm id, then +-- calls this. +-- +-- CARD-SCOPED, account-bounded: it flags every ACTIVE mirror row for the card's +-- FINGERPRINT on the account the charge belongs to — the fingerprint is the +-- canonical "same physical card" identity (robust to the duplicate-collapse +-- re-keying in ResolvePendingAddCardRequest, which mints a fresh pm_* per +-- re-add), and no LIMIT so a card re-added on the same account before the +-- collapse ran is fully covered. It NEVER blocks the account's OTHER cards, and +-- the stripe_customer_id bound means a shared physical card on a different +-- account is untouched. Falls back to the pm id only when the charge has no +-- fingerprint (non-card / legacy). Set-only + NOT fraud_blocked makes it +-- idempotent — a replay, or the second of the dispute/EFW pair for the same +-- card, flags 0 rows. deleted_at IS NULL keeps it to gate-visible cards. +-- :execrows → 0 = drift/no-op (never mirrored, already detached, or already +-- flagged): the Go layer logs drift_warning and ACKs 200. +-- name: FlagPaymentMethodFraud :execrows +UPDATE ms_billing.payment_methods_mirror pmm +SET fraud_blocked = true, + fraud_reason = @fraud_reason, + fraud_flagged_at = now() +FROM ms_billing.accounts a +WHERE pmm.account_id = a.id + AND a.stripe_customer_id = @stripe_customer_id + AND pmm.deleted_at IS NULL + AND NOT pmm.fraud_blocked + AND ( + (NULLIF(@fingerprint::text, '') IS NOT NULL AND pmm.fingerprint = @fingerprint) + OR (NULLIF(@fingerprint::text, '') IS NULL AND pmm.stripe_payment_method_id = @stripe_payment_method_id) + ); diff --git a/internal/account/db/webhook.sql.go b/internal/account/db/webhook.sql.go index f49710f..3a7f34b 100644 --- a/internal/account/db/webhook.sql.go +++ b/internal/account/db/webhook.sql.go @@ -141,6 +141,62 @@ func (q *Queries) DuplicateFingerprintPM(ctx context.Context, arg DuplicateFinge return id, err } +const flagPaymentMethodFraud = `-- name: FlagPaymentMethodFraud :execrows +UPDATE ms_billing.payment_methods_mirror pmm +SET fraud_blocked = true, + fraud_reason = $1, + fraud_flagged_at = now() +FROM ms_billing.accounts a +WHERE pmm.account_id = a.id + AND a.stripe_customer_id = $2 + AND pmm.deleted_at IS NULL + AND NOT pmm.fraud_blocked + AND ( + (NULLIF($3::text, '') IS NOT NULL AND pmm.fingerprint = $3) + OR (NULLIF($3::text, '') IS NULL AND pmm.stripe_payment_method_id = $4) + ) +` + +type FlagPaymentMethodFraudParams struct { + FraudReason pgtype.Text `json:"fraud_reason"` + StripeCustomerID pgtype.Text `json:"stripe_customer_id"` + Fingerprint string `json:"fingerprint"` + StripePaymentMethodID string `json:"stripe_payment_method_id"` +} + +// FlagPaymentMethodFraud latches fraud_blocked (migration 038) on the disputed / +// early-fraud-warned physical card so the service-block gate (ServiceBlockSignals) +// stops counting it as usable. The charge.dispute.created / radar.early_fraud_ +// warning.created events carry only a charge id, so the handler first retrieves +// the charge from Stripe to get the customer id + card fingerprint + pm id, then +// calls this. +// +// CARD-SCOPED, account-bounded: it flags every ACTIVE mirror row for the card's +// FINGERPRINT on the account the charge belongs to — the fingerprint is the +// canonical "same physical card" identity (robust to the duplicate-collapse +// re-keying in ResolvePendingAddCardRequest, which mints a fresh pm_* per +// re-add), and no LIMIT so a card re-added on the same account before the +// collapse ran is fully covered. It NEVER blocks the account's OTHER cards, and +// the stripe_customer_id bound means a shared physical card on a different +// account is untouched. Falls back to the pm id only when the charge has no +// fingerprint (non-card / legacy). Set-only + NOT fraud_blocked makes it +// idempotent — a replay, or the second of the dispute/EFW pair for the same +// card, flags 0 rows. deleted_at IS NULL keeps it to gate-visible cards. +// :execrows → 0 = drift/no-op (never mirrored, already detached, or already +// flagged): the Go layer logs drift_warning and ACKs 200. +func (q *Queries) FlagPaymentMethodFraud(ctx context.Context, arg FlagPaymentMethodFraudParams) (int64, error) { + result, err := q.db.Exec(ctx, flagPaymentMethodFraud, + arg.FraudReason, + arg.StripeCustomerID, + arg.Fingerprint, + arg.StripePaymentMethodID, + ) + if err != nil { + return 0, err + } + return result.RowsAffected(), nil +} + const insertPaymentMethod = `-- name: InsertPaymentMethod :execrows WITH acct AS ( SELECT id FROM ms_billing.accounts WHERE stripe_customer_id = $1 diff --git a/internal/account/webhook/handlers.go b/internal/account/webhook/handlers.go index 030f955..ae027b5 100644 --- a/internal/account/webhook/handlers.go +++ b/internal/account/webhook/handlers.go @@ -289,6 +289,83 @@ func (r *Router) handleInvoiceLifecycle(ctx context.Context, event stripego.Even return Result{HTTPStatus: 200, Status: StatusOK} } +// fraud_reason tokens recorded on the mirror row, stable + compact (they feed +// the fraud_reason audit column, migration 038). +const ( + fraudReasonDispute = "dispute" + fraudReasonEFW = "early_fraud_warning" +) + +// handleChargeDisputeCreated flags the disputed card as fraud (charge.dispute.created). +func (r *Router) handleChargeDisputeCreated(ctx context.Context, event stripego.Event) Result { + d, err := decodeDispute(event) + if err != nil { + r.log.WarnContext(ctx, "charge.dispute.created decode failed", "event_id", event.ID, "error", err) + return Result{HTTPStatus: 400, Status: StatusInvalidBody} + } + chargeID := "" + if d.Charge != nil { + chargeID = d.Charge.ID + } + return r.flagFraudForCharge(ctx, event, chargeID, fraudReasonDispute) +} + +// handleEarlyFraudWarningCreated flags the warned card as fraud +// (radar.early_fraud_warning.created). Radar EFWs are issuer-driven and fire in +// LIVE mode only (never test mode), so this path is exercised end-to-end only +// in production; its logic is identical to the dispute path modulo the reason. +func (r *Router) handleEarlyFraudWarningCreated(ctx context.Context, event stripego.Event) Result { + efw, err := decodeEarlyFraudWarning(event) + if err != nil { + r.log.WarnContext(ctx, "radar.early_fraud_warning.created decode failed", "event_id", event.ID, "error", err) + return Result{HTTPStatus: 400, Status: StatusInvalidBody} + } + chargeID := "" + if efw.Charge != nil { + chargeID = efw.Charge.ID + } + return r.flagFraudForCharge(ctx, event, chargeID, fraudReasonEFW) +} + +// flagFraudForCharge is the shared resolve→flag core for the dispute + EFW +// handlers: both events carry only a charge id, so it retrieves the charge to +// get the card's customer + fingerprint + pm id, then latches fraud_blocked on +// the matching mirror rows (card-scoped, account-bounded — see +// FlagPaymentMethodFraud). Failure modes: +// - missing charge id (malformed event): 400. +// - Stripe retrieve error: 500 so Stripe redelivers (the flag is idempotent). +// - charge has no card ref (non-card charge): drift_warning 200, no store call. +// - no active mirror row matched (never mirrored / detached / already flagged): +// drift_warning 200, no error. +func (r *Router) flagFraudForCharge(ctx context.Context, event stripego.Event, chargeID, reason string) Result { + if chargeID == "" { + r.log.WarnContext(ctx, "fraud event missing charge id", "event_id", event.ID, "type", event.Type) + return Result{HTTPStatus: 400, Status: StatusInvalidBody} + } + + ref, err := r.charges.RetrieveCharge(ctx, chargeID) + if err != nil { + r.log.ErrorContext(ctx, "fraud charge retrieve failed", "event_id", event.ID, "charge_id", chargeID, "error", err) + return Result{HTTPStatus: 500, Status: StatusInternal} + } + if ref.PaymentMethodID == "" && ref.Fingerprint == "" { + r.log.WarnContext(ctx, "fraud drift: charge carries no card ref", "event_id", event.ID, "charge_id", chargeID) + return Result{HTTPStatus: 200, Status: StatusDriftWarning} + } + + found, err := r.store.FlagPaymentMethodFraud(ctx, ref.StripeCustomerID, ref.Fingerprint, ref.PaymentMethodID, reason) + if err != nil { + r.log.ErrorContext(ctx, "fraud flag payment method failed", "event_id", event.ID, "charge_id", chargeID, "error", err) + return Result{HTTPStatus: 500, Status: StatusInternal} + } + if !found { + r.log.WarnContext(ctx, "fraud drift: no active mirror row for charge card (never mirrored / detached / already flagged)", "event_id", event.ID, "charge_id", chargeID) + return Result{HTTPStatus: 200, Status: StatusDriftWarning} + } + r.log.InfoContext(ctx, "card fraud-blocked", "event_id", event.ID, "type", event.Type, "reason", reason) + return Result{HTTPStatus: 200, Status: StatusOK} +} + // --- decode helpers ------------------------------------------------------- // // stripe-go's Event.Data.Raw is the JSON payload of the event's data @@ -345,3 +422,25 @@ func decodeInvoice(event stripego.Event) (*stripego.Invoice, error) { } return &inv, nil } + +func decodeDispute(event stripego.Event) (*stripego.Dispute, error) { + if event.Data == nil { + return nil, errNilEventData + } + var d stripego.Dispute + if err := json.Unmarshal(event.Data.Raw, &d); err != nil { + return nil, err + } + return &d, nil +} + +func decodeEarlyFraudWarning(event stripego.Event) (*stripego.RadarEarlyFraudWarning, error) { + if event.Data == nil { + return nil, errNilEventData + } + var efw stripego.RadarEarlyFraudWarning + if err := json.Unmarshal(event.Data.Raw, &efw); err != nil { + return nil, err + } + return &efw, nil +} diff --git a/internal/account/webhook/router.go b/internal/account/webhook/router.go index 71d9494..0a5f32c 100644 --- a/internal/account/webhook/router.go +++ b/internal/account/webhook/router.go @@ -138,6 +138,15 @@ type Store interface { // time (not a maintained counter), so there is no account write and nothing to // reset on invoice.paid. MarkInvoiceFailed(ctx context.Context, stripeInvoiceID string) error + + // FlagPaymentMethodFraud latches fraud_blocked (migration 038) on a disputed / + // early-fraud-warned card so the service-block gate stops counting it as + // usable. Card-scoped + account-bounded: every ACTIVE mirror row for the + // card's fingerprint on the charge's account (fallback: the pm id when the + // charge carries no fingerprint). Set-only + idempotent. Returns (found, + // error): found=false (0 rows) is a drift no-op — the card was never + // mirrored, is already detached, or is already flagged. + FlagPaymentMethodFraud(ctx context.Context, stripeCustomerID, fingerprint, stripePaymentMethodID, reason string) (found bool, err error) } // ApplyInvoiceStatusParams carries the columns ApplyInvoiceStatus reconciles @@ -179,9 +188,19 @@ type InsertPaymentMethodParams struct { // Router is the entry point exposed to cmd/account-webhook. It owns // signature verification + idempotency + per-event dispatch. The // Lambda binary calls Process; everything else is internal. +// ChargeRetriever is the narrow Stripe surface the fraud handlers need: resolve +// a charge id (all a dispute / early-fraud-warning event carries) to the card's +// pm id + fingerprint + owning customer. Kept as a webhook-local interface (not +// the full billingstripe.Client) so the Router depends only on what it uses; +// *billingstripe.realClient satisfies it structurally, and tests pass a fake. +type ChargeRetriever interface { + RetrieveCharge(ctx context.Context, chargeID string) (billingstripe.ChargeCardRef, error) +} + type Router struct { verifier billingstripe.Verifier store Store + charges ChargeRetriever log *slog.Logger } @@ -189,17 +208,20 @@ type Router struct { // values panic at construction. The strict checks catch wiring bugs // at startup rather than silently falling back to defaults that would // mask the misconfiguration in production. -func NewRouter(verifier billingstripe.Verifier, store Store, log *slog.Logger) *Router { +func NewRouter(verifier billingstripe.Verifier, store Store, charges ChargeRetriever, log *slog.Logger) *Router { if verifier == nil { panic("webhook.NewRouter: verifier must not be nil") } if store == nil { panic("webhook.NewRouter: store must not be nil") } + if charges == nil { + panic("webhook.NewRouter: charges must not be nil") + } if log == nil { panic("webhook.NewRouter: log must not be nil") } - return &Router{verifier: verifier, store: store, log: log} + return &Router{verifier: verifier, store: store, charges: charges, log: log} } // Process verifies the signature, performs the idempotency check, @@ -266,6 +288,10 @@ func (r *Router) dispatch(ctx context.Context, event stripego.Event) Result { // already ranks void/uncollectible as terminal (rank 2), so no handler // change is needed — they only need to be dispatched here. return r.handleInvoiceLifecycle(ctx, event) + case stripego.EventTypeChargeDisputeCreated: + return r.handleChargeDisputeCreated(ctx, event) + case stripego.EventTypeRadarEarlyFraudWarningCreated: + return r.handleEarlyFraudWarningCreated(ctx, event) default: r.log.InfoContext(ctx, "webhook unhandled event", "event_id", event.ID, "type", event.Type) return Result{HTTPStatus: 200, Status: StatusUnhandled} diff --git a/internal/account/webhook/router_test.go b/internal/account/webhook/router_test.go index b38bd10..5575249 100644 --- a/internal/account/webhook/router_test.go +++ b/internal/account/webhook/router_test.go @@ -11,6 +11,7 @@ import ( "github.com/mirrorstack-ai/billing-engine/internal/account/webhook" "github.com/mirrorstack-ai/billing-engine/internal/account/webhook/webhooktest" + billingstripe "github.com/mirrorstack-ai/billing-engine/internal/shared/stripe" ) // --- event builders ------------------------------------------------------- @@ -67,8 +68,20 @@ func invoiceEvent(id, eventType, invoiceID, status string, amountPaid, amountDue } } +// fraudEvent builds a charge.dispute.created / radar.early_fraud_warning.created +// event carrying a bare charge id (Stripe delivers `charge` unexpanded, so it +// arrives as a "ch_…" string that decodes into Charge{ID}). +func fraudEvent(id, eventType, chargeID string) stripego.Event { + raw, _ := json.Marshal(map[string]any{"id": "obj_" + id, "charge": chargeID}) + return stripego.Event{ID: id, Type: stripego.EventType(eventType), Data: &stripego.EventData{Raw: raw}} +} + func newRouter(v *webhooktest.FakeVerifier, s *webhooktest.FakeStore) *webhook.Router { - return webhook.NewRouter(v, s, webhooktest.SilentLogger()) + return newRouterWithCharges(v, s, &webhooktest.FakeChargeRetriever{}) +} + +func newRouterWithCharges(v *webhooktest.FakeVerifier, s *webhooktest.FakeStore, c webhook.ChargeRetriever) *webhook.Router { + return webhook.NewRouter(v, s, c, webhooktest.SilentLogger()) } // --- tests ---------------------------------------------------------------- @@ -537,6 +550,114 @@ func TestProcess_FailureLatchError_Internal(t *testing.T) { require.Equal(t, webhook.StatusInternal, res.Status) } +// --- fraud webhook (charge.dispute.created / radar.early_fraud_warning.created) --- + +func fraudRetriever(chargeID string, ref billingstripe.ChargeCardRef) *webhooktest.FakeChargeRetriever { + return &webhooktest.FakeChargeRetriever{Refs: map[string]billingstripe.ChargeCardRef{chargeID: ref}} +} + +func TestProcess_ChargeDisputeCreated_FlagsCard(t *testing.T) { + // dispute → retrieve the charge → flag the card with reason "dispute". + v := &webhooktest.FakeVerifier{Event: fraudEvent("evt_disp", "charge.dispute.created", "ch_1")} + s := webhooktest.NewFakeStore() + c := fraudRetriever("ch_1", billingstripe.ChargeCardRef{PaymentMethodID: "pm_1", Fingerprint: "fp_1", StripeCustomerID: "cus_1"}) + r := newRouterWithCharges(v, s, c) + + res := r.Process(context.Background(), []byte(`{}`), "sig") + + require.Equal(t, 200, res.HTTPStatus) + require.Equal(t, webhook.StatusOK, res.Status) + require.Len(t, s.FraudFlags, 1) + require.Equal(t, webhooktest.FraudFlag{StripeCustomerID: "cus_1", Fingerprint: "fp_1", StripePaymentMethodID: "pm_1", Reason: "dispute"}, s.FraudFlags[0]) +} + +func TestProcess_EarlyFraudWarningCreated_FlagsCard(t *testing.T) { + // EFW → same resolve+flag with reason "early_fraud_warning". + v := &webhooktest.FakeVerifier{Event: fraudEvent("evt_efw", "radar.early_fraud_warning.created", "ch_2")} + s := webhooktest.NewFakeStore() + c := fraudRetriever("ch_2", billingstripe.ChargeCardRef{PaymentMethodID: "pm_2", Fingerprint: "fp_2", StripeCustomerID: "cus_2"}) + r := newRouterWithCharges(v, s, c) + + res := r.Process(context.Background(), []byte(`{}`), "sig") + + require.Equal(t, webhook.StatusOK, res.Status) + require.Len(t, s.FraudFlags, 1) + require.Equal(t, "early_fraud_warning", s.FraudFlags[0].Reason) +} + +func TestProcess_Fraud_NoMirrorMatch_DriftWarning(t *testing.T) { + // The card isn't in our mirror (or is already flagged): store reports 0 rows + // → drift_warning 200, no error. The store WAS consulted. + v := &webhooktest.FakeVerifier{Event: fraudEvent("evt_disp_drift", "charge.dispute.created", "ch_3")} + s := webhooktest.NewFakeStore() + s.FraudFound = false + c := fraudRetriever("ch_3", billingstripe.ChargeCardRef{PaymentMethodID: "pm_3", Fingerprint: "fp_3", StripeCustomerID: "cus_3"}) + r := newRouterWithCharges(v, s, c) + + res := r.Process(context.Background(), []byte(`{}`), "sig") + + require.Equal(t, 200, res.HTTPStatus) + require.Equal(t, webhook.StatusDriftWarning, res.Status) + require.Len(t, s.FraudFlags, 1) +} + +func TestProcess_Fraud_NonCardCharge_DriftNoStoreCall(t *testing.T) { + // A charge with no card ref (empty pm + fingerprint) → drift_warning, and the + // store is never consulted. + v := &webhooktest.FakeVerifier{Event: fraudEvent("evt_disp_noncard", "charge.dispute.created", "ch_4")} + s := webhooktest.NewFakeStore() + c := fraudRetriever("ch_4", billingstripe.ChargeCardRef{StripeCustomerID: "cus_4"}) // no pm, no fingerprint + r := newRouterWithCharges(v, s, c) + + res := r.Process(context.Background(), []byte(`{}`), "sig") + + require.Equal(t, webhook.StatusDriftWarning, res.Status) + require.Empty(t, s.FraudFlags, "non-card charge must not reach the store") +} + +func TestProcess_Fraud_MissingChargeID_BadRequest(t *testing.T) { + // A dispute event with no charge → 400, retriever never called. + v := &webhooktest.FakeVerifier{Event: fraudEvent("evt_disp_nocharge", "charge.dispute.created", "")} + s := webhooktest.NewFakeStore() + c := &webhooktest.FakeChargeRetriever{} // any charge id resolves to zero ref + r := newRouterWithCharges(v, s, c) + + res := r.Process(context.Background(), []byte(`{}`), "sig") + + require.Equal(t, 400, res.HTTPStatus) + require.Equal(t, webhook.StatusInvalidBody, res.Status) + require.Empty(t, s.FraudFlags) +} + +func TestProcess_Fraud_RetrieveError_Internal(t *testing.T) { + // A Stripe retrieve failure → 500 so Stripe redelivers; the store is never + // reached (the flag is idempotent, so the redelivery is safe). + v := &webhooktest.FakeVerifier{Event: fraudEvent("evt_disp_rerr", "charge.dispute.created", "ch_5")} + s := webhooktest.NewFakeStore() + c := &webhooktest.FakeChargeRetriever{Err: errors.New("stripe down")} + r := newRouterWithCharges(v, s, c) + + res := r.Process(context.Background(), []byte(`{}`), "sig") + + require.Equal(t, 500, res.HTTPStatus) + require.Equal(t, webhook.StatusInternal, res.Status) + require.Empty(t, s.FraudFlags) +} + +func TestProcess_Fraud_FlagStoreError_Internal(t *testing.T) { + // A store error while flagging → 500 so Stripe redelivers. + v := &webhooktest.FakeVerifier{Event: fraudEvent("evt_disp_serr", "charge.dispute.created", "ch_6")} + s := webhooktest.NewFakeStore() + s.ErrFlagFraud = errors.New("db down") + c := fraudRetriever("ch_6", billingstripe.ChargeCardRef{PaymentMethodID: "pm_6", Fingerprint: "fp_6", StripeCustomerID: "cus_6"}) + r := newRouterWithCharges(v, s, c) + + res := r.Process(context.Background(), []byte(`{}`), "sig") + + require.Equal(t, 500, res.HTTPStatus) + require.Equal(t, webhook.StatusInternal, res.Status) +} + func TestProcess_InvoiceVoided_AppliesVoid(t *testing.T) { // An admin/Stripe void (invoice.voided, status 'void') MUST reach the // reconciler so the mirror leaves 'open' and the delinquency signal clears. diff --git a/internal/account/webhook/store.go b/internal/account/webhook/store.go index 2fca8e0..2e278ef 100644 --- a/internal/account/webhook/store.go +++ b/internal/account/webhook/store.go @@ -311,6 +311,22 @@ func (s *pgxStore) MarkInvoiceFailed(ctx context.Context, stripeInvoiceID string return err } +// FlagPaymentMethodFraud latches fraud_blocked on the disputed/warned card +// (card-scoped, account-bounded; see the query doc). Returns (found, error): +// found=false (0 rows) is a drift no-op the handler ACKs 200. +func (s *pgxStore) FlagPaymentMethodFraud(ctx context.Context, stripeCustomerID, fingerprint, stripePaymentMethodID, reason string) (bool, error) { + rows, err := s.q.FlagPaymentMethodFraud(ctx, db.FlagPaymentMethodFraudParams{ + FraudReason: text(reason), + StripeCustomerID: text(stripeCustomerID), + Fingerprint: fingerprint, + StripePaymentMethodID: stripePaymentMethodID, + }) + if err != nil { + return false, err + } + return rows > 0, nil +} + // text wraps a non-null Go string in the pgtype.Text the generated // queries expect for nullable TEXT columns. func text(s string) pgtype.Text { diff --git a/internal/account/webhook/store_integration_test.go b/internal/account/webhook/store_integration_test.go index 94fddb8..09b05ad 100644 --- a/internal/account/webhook/store_integration_test.go +++ b/internal/account/webhook/store_integration_test.go @@ -840,3 +840,115 @@ func TestPgxStore_ApplyInvoiceStatus_UncollectibleToPaid_Cures(t *testing.T) { require.False(t, found, "a late uncollectible must not overwrite paid") require.Equal(t, "paid", readInvoiceStatus(t, pool, "in_unc")) } + +// --- fraud flag (migration 038) + gate exclusion ------------------------- + +func seedCardFP(t *testing.T, pool *pgxpool.Pool, accountID uuid.UUID, pmID, fingerprint string) { + t.Helper() + var fp any + if fingerprint != "" { + fp = fingerprint + } + _, err := pool.Exec(context.Background(), + `INSERT INTO ms_billing.payment_methods_mirror + (account_id, stripe_payment_method_id, brand, last4, exp_month, exp_year, fingerprint) + VALUES ($1, $2, 'visa', '4242', 12, 2999, $3)`, + accountID, pmID, fp, + ) + require.NoError(t, err) +} + +func readFraud(t *testing.T, pool *pgxpool.Pool, pmID string) (blocked bool, reason string, flaggedSet bool) { + t.Helper() + require.NoError(t, pool.QueryRow(context.Background(), + `SELECT fraud_blocked, COALESCE(fraud_reason, ''), fraud_flagged_at IS NOT NULL + FROM ms_billing.payment_methods_mirror WHERE stripe_payment_method_id = $1`, + pmID).Scan(&blocked, &reason, &flaggedSet)) + return +} + +func TestPgxStore_FlagPaymentMethodFraud_ByFingerprint_FlipsAndExcludesFromGate(t *testing.T) { + pool := testutil.NewTestDB(t) + store := webhook.NewStore(pool) + bstore := billing.NewStore(pool) + accountID := seedAccount(t, pool, "cus_fraud") + seedCardFP(t, pool, accountID, "pm_fraud", "fp_x") + ctx := context.Background() + + // Before: the card counts as usable. + sig, err := bstore.ServiceBlockSignals(ctx, accountID) + require.NoError(t, err) + require.Equal(t, 1, sig.UsableCardCount) + + found, err := store.FlagPaymentMethodFraud(ctx, "cus_fraud", "fp_x", "", "dispute") + require.NoError(t, err) + require.True(t, found) + + blocked, reason, flaggedSet := readFraud(t, pool, "pm_fraud") + require.True(t, blocked) + require.Equal(t, "dispute", reason) + require.True(t, flaggedSet, "fraud_flagged_at must be stamped") + + // After: the gate no longer counts the flagged card. + sig, err = bstore.ServiceBlockSignals(ctx, accountID) + require.NoError(t, err) + require.Equal(t, 0, sig.UsableCardCount, "fraud-blocked card is excluded from the gate") + + // Idempotent replay: already flagged → 0 rows. + found, err = store.FlagPaymentMethodFraud(ctx, "cus_fraud", "fp_x", "", "dispute") + require.NoError(t, err) + require.False(t, found) +} + +func TestPgxStore_FlagPaymentMethodFraud_AllRowsSameFingerprint_AccountScoped(t *testing.T) { + pool := testutil.NewTestDB(t) + store := webhook.NewStore(pool) + ctx := context.Background() + + // Same physical card (fp_shared) added twice on account A (pre-collapse), and + // once on a DIFFERENT account B. + accountA := seedAccount(t, pool, "cus_A") + seedCardFP(t, pool, accountA, "pm_A1", "fp_shared") + seedCardFP(t, pool, accountA, "pm_A2", "fp_shared") + accountB := seedAccount(t, pool, "cus_B") + seedCardFP(t, pool, accountB, "pm_B1", "fp_shared") + + found, err := store.FlagPaymentMethodFraud(ctx, "cus_A", "fp_shared", "", "dispute") + require.NoError(t, err) + require.True(t, found) + + // Both of A's copies flip; B's card with the same fingerprint is untouched. + for _, pm := range []string{"pm_A1", "pm_A2"} { + blocked, _, _ := readFraud(t, pool, pm) + require.True(t, blocked, pm+" (account A) must be flagged") + } + blockedB, _, _ := readFraud(t, pool, "pm_B1") + require.False(t, blockedB, "the same fingerprint on a DIFFERENT account must not be flagged") +} + +func TestPgxStore_FlagPaymentMethodFraud_FingerprintFallbackToPMID(t *testing.T) { + // A legacy card with a NULL fingerprint: resolution falls back to the pm id. + pool := testutil.NewTestDB(t) + store := webhook.NewStore(pool) + accountID := seedAccount(t, pool, "cus_legacy") + seedCardFP(t, pool, accountID, "pm_legacy", "") // NULL fingerprint + ctx := context.Background() + + found, err := store.FlagPaymentMethodFraud(ctx, "cus_legacy", "", "pm_legacy", "early_fraud_warning") + require.NoError(t, err) + require.True(t, found) + blocked, reason, _ := readFraud(t, pool, "pm_legacy") + require.True(t, blocked) + require.Equal(t, "early_fraud_warning", reason) +} + +func TestPgxStore_FlagPaymentMethodFraud_UnknownCard_Drift(t *testing.T) { + pool := testutil.NewTestDB(t) + store := webhook.NewStore(pool) + _ = seedAccount(t, pool, "cus_none") + ctx := context.Background() + + found, err := store.FlagPaymentMethodFraud(ctx, "cus_none", "fp_absent", "", "dispute") + require.NoError(t, err) + require.False(t, found, "no matching mirror row → drift no-op") +} diff --git a/internal/account/webhook/webhooktest/fakes.go b/internal/account/webhook/webhooktest/fakes.go index fcb2ce0..7b17ee0 100644 --- a/internal/account/webhook/webhooktest/fakes.go +++ b/internal/account/webhook/webhooktest/fakes.go @@ -15,6 +15,7 @@ import ( stripego "github.com/stripe/stripe-go/v85" "github.com/mirrorstack-ai/billing-engine/internal/account/webhook" + billingstripe "github.com/mirrorstack-ai/billing-engine/internal/shared/stripe" ) // FakeVerifier implements billingstripe.Verifier. Configure Event for @@ -50,6 +51,7 @@ type FakeStore struct { AppliedInvoices []webhook.ApplyInvoiceStatusParams // captured calls to ApplyInvoiceStatus RelaxedInvoices []string // stripe_invoice_id values from RelaxCollectionOnPaidInvoice FailedInvoices []string // stripe_invoice_id values from MarkInvoiceFailed + FraudFlags []FraudFlag // captured calls to FlagPaymentMethodFraud // Found-flag knobs TouchedFound bool // returned by TouchAccountByStripeCustomer @@ -57,6 +59,7 @@ type FakeStore struct { SoftDelFound bool // returned by SoftDeletePaymentMethod InvoiceFound bool // returned by ApplyInvoiceStatus Relaxed bool // returned by RelaxCollectionOnPaidInvoice + FraudFound bool // returned by FlagPaymentMethodFraud ActivatedNew bool // returned by StampAccountActivated (firstBind) // Error injection @@ -70,9 +73,18 @@ type FakeStore struct { ErrApplyInvoice error // from ApplyInvoiceStatus ErrRelax error // from RelaxCollectionOnPaidInvoice ErrMarkFailed error // from MarkInvoiceFailed + ErrFlagFraud error // from FlagPaymentMethodFraud ErrActivate error // from StampAccountActivated } +// FraudFlag records one FlagPaymentMethodFraud call. +type FraudFlag struct { + StripeCustomerID string + Fingerprint string + StripePaymentMethodID string + Reason string +} + // NewFakeStore returns a FakeStore configured for happy-path tests: // every "found" boolean true, the Processed map initialized, no // errors injected. @@ -83,6 +95,7 @@ func NewFakeStore() *FakeStore { InsertFound: true, SoftDelFound: true, InvoiceFound: true, + FraudFound: true, ActivatedNew: true, } } @@ -180,6 +193,35 @@ func (s *FakeStore) MarkInvoiceFailed(_ context.Context, stripeInvoiceID string) return nil } +func (s *FakeStore) FlagPaymentMethodFraud(_ context.Context, stripeCustomerID, fingerprint, stripePaymentMethodID, reason string) (bool, error) { + if s.ErrFlagFraud != nil { + return false, s.ErrFlagFraud + } + s.FraudFlags = append(s.FraudFlags, FraudFlag{ + StripeCustomerID: stripeCustomerID, + Fingerprint: fingerprint, + StripePaymentMethodID: stripePaymentMethodID, + Reason: reason, + }) + return s.FraudFound, nil +} + +// FakeChargeRetriever implements webhook.ChargeRetriever. Refs maps a charge id +// to the card ref it resolves to; Err forces a retrieve failure. A charge id +// absent from Refs resolves to the zero ChargeCardRef (empty pm+fingerprint — +// the non-card / drift path). +type FakeChargeRetriever struct { + Refs map[string]billingstripe.ChargeCardRef + Err error +} + +func (f *FakeChargeRetriever) RetrieveCharge(_ context.Context, chargeID string) (billingstripe.ChargeCardRef, error) { + if f.Err != nil { + return billingstripe.ChargeCardRef{}, f.Err + } + return f.Refs[chargeID], nil +} + // SilentLogger returns a slog.Logger that discards all output. Useful // for tests that don't make logging assertions. func SilentLogger() *slog.Logger { diff --git a/internal/shared/stripe/client.go b/internal/shared/stripe/client.go index 6e05b43..e1e1c05 100644 --- a/internal/shared/stripe/client.go +++ b/internal/shared/stripe/client.go @@ -100,6 +100,30 @@ func (c *realClient) DetachPaymentMethod(ctx context.Context, stripePaymentMetho return err } +// RetrieveCharge fetches a charge and projects the card-identifying fields the +// fraud webhook resolves against. payment_method (a plain string id) and +// payment_method_details.card.fingerprint ride the default charge retrieve, so +// no expand params are needed; customer arrives unexpanded (only .ID set), +// which is all we use. Missing card details (a non-card charge) leave the +// respective fields empty rather than erroring — the caller treats an empty +// pm+fingerprint as drift. +func (c *realClient) RetrieveCharge(ctx context.Context, chargeID string) (ChargeCardRef, error) { + params := &stripego.ChargeParams{} + params.Context = ctx + ch, err := c.sc.Charges.Get(chargeID, params) + if err != nil { + return ChargeCardRef{}, err + } + ref := ChargeCardRef{PaymentMethodID: ch.PaymentMethod} + if ch.Customer != nil { + ref.StripeCustomerID = ch.Customer.ID + } + if ch.PaymentMethodDetails != nil && ch.PaymentMethodDetails.Card != nil { + ref.Fingerprint = ch.PaymentMethodDetails.Card.Fingerprint + } + return ref, nil +} + // SetDefaultPaymentMethod sets the Customer's invoice-settings default // payment method. The resulting customer.updated webhook syncs the // mirror's is_default flags for the account. diff --git a/internal/shared/stripe/types.go b/internal/shared/stripe/types.go index 9d5d920..a241435 100644 --- a/internal/shared/stripe/types.go +++ b/internal/shared/stripe/types.go @@ -87,6 +87,15 @@ type Client interface { // (id/status/amounts) for the mirror. FinalizeInvoice(ctx context.Context, invoiceID, idemKey string) (Invoice, error) + // RetrieveCharge fetches a charge by id and projects the card-identifying + // fields the fraud webhook needs. The charge.dispute.created / + // radar.early_fraud_warning.created events carry only a charge id (no pm id, + // no fingerprint), so resolving the disputed card to a mirror row requires + // this one retrieve. A retrieved charge returns both the payment_method id + // and payment_method_details.card.fingerprint by default. Rare + off the hot + // path, so a synchronous call in the webhook handler is fine. + RetrieveCharge(ctx context.Context, chargeID string) (ChargeCardRef, error) + // FindInvoiceByRef looks a Customer's invoice up by its ms_charge_ref // metadata anchor (stamped by CreateDraftInvoice) — the crash-recovery read // (review 2026-07-06, H5): Stripe prunes idempotency keys after ~24h, so a @@ -118,6 +127,19 @@ type Invoice struct { Currency string } +// ChargeCardRef is the trust-boundary-edge projection of a Stripe charge the +// fraud webhook needs to resolve a disputed/warned card to a mirror row: +// the payment_method id, the card fingerprint (the canonical "same physical +// card" identity, preferred for matching), and the owning Stripe customer id +// (to scope the flag to one account). Kept stripe-go-free like Invoice. Any +// field may be empty — a non-card charge has no card block, and Stripe tags +// fingerprint omitempty (wallet-tokenized cards may also omit it). +type ChargeCardRef struct { + PaymentMethodID string + Fingerprint string + StripeCustomerID string +} + // Verifier verifies Stripe webhook signatures. Kept separate from // Client because the API surface is independent: webhooks use a // distinct STRIPE_WEBHOOK_SECRET, and signature verification doesn't From d89dc77db4675b549387c6b4daa3cba4488a8913 Mon Sep 17 00:00:00 2001 From: Sheng Kun Chang Date: Tue, 7 Jul 2026 06:34:44 +0800 Subject: [PATCH 2/2] fix: retry-recover fraud events on 5xx + additive pm-id match (review) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- internal/account/db/queries/webhook.sql | 45 +++++++++++----- internal/account/db/webhook.sql.go | 52 ++++++++++++++----- internal/account/webhook/router.go | 31 +++++++++-- internal/account/webhook/router_test.go | 39 ++++++++++++++ internal/account/webhook/store.go | 6 +++ .../account/webhook/store_integration_test.go | 17 ++++++ internal/account/webhook/webhooktest/fakes.go | 9 ++++ 7 files changed, 168 insertions(+), 31 deletions(-) diff --git a/internal/account/db/queries/webhook.sql b/internal/account/db/queries/webhook.sql index f258711..8a6ddd9 100644 --- a/internal/account/db/queries/webhook.sql +++ b/internal/account/db/queries/webhook.sql @@ -8,6 +8,17 @@ INSERT INTO ms_billing.webhook_events_processed (event_id, event_type) VALUES ($1, $2) ON CONFLICT (event_id) DO NOTHING; +-- UnmarkEventProcessed deletes the idempotency row for an event so Stripe's +-- redelivery of the same event_id re-enters the handler instead of being +-- short-circuited as a duplicate. The router calls this ONLY when dispatch +-- returned a 5xx (the side effect did not complete): mark-processed commits +-- BEFORE dispatch to serialize concurrent duplicate deliveries, so without this +-- compensation a transient handler failure would be permanently deduped and the +-- documented "500 → Stripe retries" recovery would never fire. Every handler's +-- writes are replay-idempotent, so re-running on redelivery is safe. +-- name: UnmarkEventProcessed :exec +DELETE FROM ms_billing.webhook_events_processed WHERE event_id = $1; + -- TouchAccountByStripeCustomer bumps updated_at for the account matching -- a Stripe customer. :execrows → >0 means found. -- name: TouchAccountByStripeCustomer :execrows @@ -255,19 +266,25 @@ WHERE stripe_invoice_id = $1 -- the charge from Stripe to get the customer id + card fingerprint + pm id, then -- calls this. -- --- CARD-SCOPED, account-bounded: it flags every ACTIVE mirror row for the card's --- FINGERPRINT on the account the charge belongs to — the fingerprint is the --- canonical "same physical card" identity (robust to the duplicate-collapse --- re-keying in ResolvePendingAddCardRequest, which mints a fresh pm_* per --- re-add), and no LIMIT so a card re-added on the same account before the --- collapse ran is fully covered. It NEVER blocks the account's OTHER cards, and --- the stripe_customer_id bound means a shared physical card on a different --- account is untouched. Falls back to the pm id only when the charge has no --- fingerprint (non-card / legacy). Set-only + NOT fraud_blocked makes it --- idempotent — a replay, or the second of the dispute/EFW pair for the same --- card, flags 0 rows. deleted_at IS NULL keeps it to gate-visible cards. --- :execrows → 0 = drift/no-op (never mirrored, already detached, or already --- flagged): the Go layer logs drift_warning and ACKs 200. +-- CARD-SCOPED, account-bounded. It flags, within the charge's account, every +-- ACTIVE mirror row that is the disputed physical card, matched by EITHER key +-- (additive OR): +-- - FINGERPRINT — the canonical "same physical card" identity, robust to the +-- duplicate-collapse re-keying in ResolvePendingAddCardRequest (which mints +-- a fresh pm_* per re-add); no LIMIT, so all same-card siblings are covered. +-- - exact PM ID — catches a mirror row whose fingerprint is NULL (legacy +-- pre-005 rows, or wallet-tokenized cards) but whose stripe_payment_method_id +-- is exactly the charge's pm — which the fingerprint arm would miss. +-- Both arms are guarded against an empty key (a non-card charge with neither is +-- short-circuited to drift in the handler), so an empty param never widens the +-- match. Each arm still matches only the ONE physical card (pm id is unique; +-- fingerprint is that card's identity), so no OTHER card is touched, and the +-- stripe_customer_id bound means a shared card on a DIFFERENT account is +-- untouched. Set-only + NOT fraud_blocked makes it idempotent — a replay, or the +-- second of the dispute/EFW pair for the same card, flags 0 rows. deleted_at IS +-- NULL keeps it to gate-visible cards. :execrows → 0 = drift/no-op (never +-- mirrored, already detached, or already flagged): the Go layer logs +-- drift_warning and ACKs 200. -- name: FlagPaymentMethodFraud :execrows UPDATE ms_billing.payment_methods_mirror pmm SET fraud_blocked = true, @@ -280,5 +297,5 @@ WHERE pmm.account_id = a.id AND NOT pmm.fraud_blocked AND ( (NULLIF(@fingerprint::text, '') IS NOT NULL AND pmm.fingerprint = @fingerprint) - OR (NULLIF(@fingerprint::text, '') IS NULL AND pmm.stripe_payment_method_id = @stripe_payment_method_id) + OR (NULLIF(@stripe_payment_method_id::text, '') IS NOT NULL AND pmm.stripe_payment_method_id = @stripe_payment_method_id) ); diff --git a/internal/account/db/webhook.sql.go b/internal/account/db/webhook.sql.go index 3a7f34b..2ae186a 100644 --- a/internal/account/db/webhook.sql.go +++ b/internal/account/db/webhook.sql.go @@ -153,7 +153,7 @@ WHERE pmm.account_id = a.id AND NOT pmm.fraud_blocked AND ( (NULLIF($3::text, '') IS NOT NULL AND pmm.fingerprint = $3) - OR (NULLIF($3::text, '') IS NULL AND pmm.stripe_payment_method_id = $4) + OR (NULLIF($4::text, '') IS NOT NULL AND pmm.stripe_payment_method_id = $4) ) ` @@ -171,19 +171,26 @@ type FlagPaymentMethodFraudParams struct { // the charge from Stripe to get the customer id + card fingerprint + pm id, then // calls this. // -// CARD-SCOPED, account-bounded: it flags every ACTIVE mirror row for the card's -// FINGERPRINT on the account the charge belongs to — the fingerprint is the -// canonical "same physical card" identity (robust to the duplicate-collapse -// re-keying in ResolvePendingAddCardRequest, which mints a fresh pm_* per -// re-add), and no LIMIT so a card re-added on the same account before the -// collapse ran is fully covered. It NEVER blocks the account's OTHER cards, and -// the stripe_customer_id bound means a shared physical card on a different -// account is untouched. Falls back to the pm id only when the charge has no -// fingerprint (non-card / legacy). Set-only + NOT fraud_blocked makes it -// idempotent — a replay, or the second of the dispute/EFW pair for the same -// card, flags 0 rows. deleted_at IS NULL keeps it to gate-visible cards. -// :execrows → 0 = drift/no-op (never mirrored, already detached, or already -// flagged): the Go layer logs drift_warning and ACKs 200. +// CARD-SCOPED, account-bounded. It flags, within the charge's account, every +// ACTIVE mirror row that is the disputed physical card, matched by EITHER key +// (additive OR): +// - FINGERPRINT — the canonical "same physical card" identity, robust to the +// duplicate-collapse re-keying in ResolvePendingAddCardRequest (which mints +// a fresh pm_* per re-add); no LIMIT, so all same-card siblings are covered. +// - exact PM ID — catches a mirror row whose fingerprint is NULL (legacy +// pre-005 rows, or wallet-tokenized cards) but whose stripe_payment_method_id +// is exactly the charge's pm — which the fingerprint arm would miss. +// +// Both arms are guarded against an empty key (a non-card charge with neither is +// short-circuited to drift in the handler), so an empty param never widens the +// match. Each arm still matches only the ONE physical card (pm id is unique; +// fingerprint is that card's identity), so no OTHER card is touched, and the +// stripe_customer_id bound means a shared card on a DIFFERENT account is +// untouched. Set-only + NOT fraud_blocked makes it idempotent — a replay, or the +// second of the dispute/EFW pair for the same card, flags 0 rows. deleted_at IS +// NULL keeps it to gate-visible cards. :execrows → 0 = drift/no-op (never +// mirrored, already detached, or already flagged): the Go layer logs +// drift_warning and ACKs 200. func (q *Queries) FlagPaymentMethodFraud(ctx context.Context, arg FlagPaymentMethodFraudParams) (int64, error) { result, err := q.db.Exec(ctx, flagPaymentMethodFraud, arg.FraudReason, @@ -506,3 +513,20 @@ func (q *Queries) TouchAccountByStripeCustomer(ctx context.Context, stripeCustom } return result.RowsAffected(), nil } + +const unmarkEventProcessed = `-- name: UnmarkEventProcessed :exec +DELETE FROM ms_billing.webhook_events_processed WHERE event_id = $1 +` + +// UnmarkEventProcessed deletes the idempotency row for an event so Stripe's +// redelivery of the same event_id re-enters the handler instead of being +// short-circuited as a duplicate. The router calls this ONLY when dispatch +// returned a 5xx (the side effect did not complete): mark-processed commits +// BEFORE dispatch to serialize concurrent duplicate deliveries, so without this +// compensation a transient handler failure would be permanently deduped and the +// documented "500 → Stripe retries" recovery would never fire. Every handler's +// writes are replay-idempotent, so re-running on redelivery is safe. +func (q *Queries) UnmarkEventProcessed(ctx context.Context, eventID string) error { + _, err := q.db.Exec(ctx, unmarkEventProcessed, eventID) + return err +} diff --git a/internal/account/webhook/router.go b/internal/account/webhook/router.go index 0a5f32c..640e1e9 100644 --- a/internal/account/webhook/router.go +++ b/internal/account/webhook/router.go @@ -56,6 +56,14 @@ type Store interface { // MUST NOT execute the side effect. MarkEventProcessed(ctx context.Context, eventID, eventType string) (firstTime bool, err error) + // UnmarkEventProcessed deletes the idempotency row for an event. The router + // calls it ONLY when dispatch returned a 5xx, so Stripe's redelivery of the + // same event re-runs the handler instead of being deduped as a duplicate — + // the mark-before-dispatch ordering (which serializes concurrent duplicate + // deliveries) would otherwise make the "500 → Stripe retries" recovery a + // permanent no-op. Handler writes are replay-idempotent, so re-running is safe. + UnmarkEventProcessed(ctx context.Context, eventID string) error + // TouchAccountByStripeCustomer updates accounts.updated_at for the // account matching stripeCustomerID. Used by customer.updated. // Returns (found bool, error): missing account is logged as a drift @@ -236,8 +244,10 @@ func (r *Router) Process(ctx context.Context, payload []byte, signature string) return Result{HTTPStatus: 400, Status: StatusBadSignature} } - // Idempotency: insert the event_id FIRST. Duplicate → short-circuit - // before any side effect. + // Idempotency: insert the event_id FIRST so concurrent duplicate deliveries + // of the same event serialize (only one wins the INSERT). A 5xx dispatch + // outcome is compensated below so the mark doesn't permanently dedupe an + // event whose side effect never completed. firstTime, err := r.store.MarkEventProcessed(ctx, event.ID, string(event.Type)) if err != nil { r.log.ErrorContext(ctx, "idempotency record insert failed", "event_id", event.ID, "error", err) @@ -248,7 +258,22 @@ func (r *Router) Process(ctx context.Context, payload []byte, signature string) return Result{HTTPStatus: 200, Status: StatusDuplicate} } - return r.dispatch(ctx, event) + res := r.dispatch(ctx, event) + if res.HTTPStatus >= 500 { + // The side effect did not complete. Drop the idempotency row so Stripe's + // redelivery of this same event re-enters dispatch instead of short- + // circuiting as a duplicate — otherwise the "500 → Stripe retries" + // recovery every handler relies on (the fraud flag, the invoice latches) + // is a permanent no-op. All handler writes are replay-idempotent, so + // at-least-once re-execution is safe. Best-effort: a failed compensation + // leaves the event deduped (logged loudly); the residual crash-between- + // dispatch-and-delete window is far narrower than the Stripe-error window + // this closes. + if derr := r.store.UnmarkEventProcessed(ctx, event.ID); derr != nil { + r.log.ErrorContext(ctx, "idempotency compensation delete failed; redelivery will no-op", "event_id", event.ID, "error", derr) + } + } + return res } // dispatch routes to the per-event handler. Unknown events ACK with diff --git a/internal/account/webhook/router_test.go b/internal/account/webhook/router_test.go index 5575249..1cedc4f 100644 --- a/internal/account/webhook/router_test.go +++ b/internal/account/webhook/router_test.go @@ -658,6 +658,45 @@ func TestProcess_Fraud_FlagStoreError_Internal(t *testing.T) { require.Equal(t, webhook.StatusInternal, res.Status) } +// --- 5xx idempotency compensation (redelivery recovery) ------------------- + +func TestProcess_5xxDispatch_UnmarksSoRedeliveryReRuns(t *testing.T) { + // A 5xx dispatch outcome must DROP the idempotency row, so Stripe's + // redelivery of the same event re-enters the handler instead of being deduped + // forever (the "500 → Stripe retries" recovery the handlers document). + v := &webhooktest.FakeVerifier{Event: fraudEvent("evt_retry", "charge.dispute.created", "ch_1")} + s := webhooktest.NewFakeStore() + c := &webhooktest.FakeChargeRetriever{Err: errors.New("stripe 503")} + r := newRouterWithCharges(v, s, c) + + res := r.Process(context.Background(), []byte(`{}`), "sig") + require.Equal(t, 500, res.HTTPStatus) + require.False(t, s.Processed["evt_retry"], "a 5xx must unmark the event for redelivery") + + // Redelivery with a now-healthy retriever actually applies the flag. + c.Err = nil + c.Refs = map[string]billingstripe.ChargeCardRef{"ch_1": {PaymentMethodID: "pm_1", Fingerprint: "fp_1", StripeCustomerID: "cus_1"}} + res = r.Process(context.Background(), []byte(`{}`), "sig") + require.Equal(t, webhook.StatusOK, res.Status) + require.Len(t, s.FraudFlags, 1, "redelivery re-ran the handler and flagged the card") +} + +func TestProcess_2xxDispatch_StaysDedupedOnRedelivery(t *testing.T) { + // A successful (2xx) event stays recorded, so a redelivery is deduped — the + // compensation must fire ONLY on 5xx. + v := &webhooktest.FakeVerifier{Event: fraudEvent("evt_ok", "charge.dispute.created", "ch_1")} + s := webhooktest.NewFakeStore() + c := fraudRetriever("ch_1", billingstripe.ChargeCardRef{PaymentMethodID: "pm_1", Fingerprint: "fp_1", StripeCustomerID: "cus_1"}) + r := newRouterWithCharges(v, s, c) + + require.Equal(t, webhook.StatusOK, r.Process(context.Background(), []byte(`{}`), "sig").Status) + require.True(t, s.Processed["evt_ok"], "a 2xx stays recorded") + + res := r.Process(context.Background(), []byte(`{}`), "sig") + require.Equal(t, webhook.StatusDuplicate, res.Status) + require.Len(t, s.FraudFlags, 1, "a deduped redelivery must not re-flag") +} + func TestProcess_InvoiceVoided_AppliesVoid(t *testing.T) { // An admin/Stripe void (invoice.voided, status 'void') MUST reach the // reconciler so the mirror leaves 'open' and the delinquency signal clears. diff --git a/internal/account/webhook/store.go b/internal/account/webhook/store.go index 2e278ef..1810331 100644 --- a/internal/account/webhook/store.go +++ b/internal/account/webhook/store.go @@ -45,6 +45,12 @@ func (s *pgxStore) MarkEventProcessed(ctx context.Context, eventID, eventType st return rows == 1, nil } +// UnmarkEventProcessed deletes the idempotency row so a 5xx-failed event is +// re-run on Stripe's redelivery (see the query/interface docs). +func (s *pgxStore) UnmarkEventProcessed(ctx context.Context, eventID string) error { + return s.q.UnmarkEventProcessed(ctx, eventID) +} + // TouchAccountByStripeCustomer updates accounts.updated_at for the // row matching stripeCustomerID. Returns (found, error). The trigger // installed by migration 001 maintains updated_at, but the explicit diff --git a/internal/account/webhook/store_integration_test.go b/internal/account/webhook/store_integration_test.go index 09b05ad..b1042ec 100644 --- a/internal/account/webhook/store_integration_test.go +++ b/internal/account/webhook/store_integration_test.go @@ -952,3 +952,20 @@ func TestPgxStore_FlagPaymentMethodFraud_UnknownCard_Drift(t *testing.T) { require.NoError(t, err) require.False(t, found, "no matching mirror row → drift no-op") } + +func TestPgxStore_FlagPaymentMethodFraud_NullFingerprintRow_MatchedByPMID(t *testing.T) { + // A legacy/wallet mirror row with a NULL fingerprint, but the disputed charge + // DID carry a fingerprint — the row must still be flagged via its exact pm id + // (the additive pm-id arm), not silently missed (F2). + pool := testutil.NewTestDB(t) + store := webhook.NewStore(pool) + accountID := seedAccount(t, pool, "cus_wallet") + seedCardFP(t, pool, accountID, "pm_wallet", "") // NULL fingerprint + ctx := context.Background() + + found, err := store.FlagPaymentMethodFraud(ctx, "cus_wallet", "fp_charge", "pm_wallet", "dispute") + require.NoError(t, err) + require.True(t, found, "a NULL-fingerprint row must be flagged by exact pm id even when the charge carries a fingerprint") + blocked, _, _ := readFraud(t, pool, "pm_wallet") + require.True(t, blocked) +} diff --git a/internal/account/webhook/webhooktest/fakes.go b/internal/account/webhook/webhooktest/fakes.go index 7b17ee0..c4e4077 100644 --- a/internal/account/webhook/webhooktest/fakes.go +++ b/internal/account/webhook/webhooktest/fakes.go @@ -64,6 +64,7 @@ type FakeStore struct { // Error injection ErrMark error // from MarkEventProcessed + ErrUnmark error // from UnmarkEventProcessed ErrTouch error // from TouchAccountByStripeCustomer ErrSetDefault error // from SetDefaultPaymentMethod ErrInsert error // from InsertPaymentMethod @@ -114,6 +115,14 @@ func (s *FakeStore) MarkEventProcessed(_ context.Context, eventID, _ string) (bo return true, nil } +func (s *FakeStore) UnmarkEventProcessed(_ context.Context, eventID string) error { + if s.ErrUnmark != nil { + return s.ErrUnmark + } + delete(s.Processed, eventID) + return nil +} + func (s *FakeStore) TouchAccountByStripeCustomer(_ context.Context, _ string) (bool, error) { if s.ErrTouch != nil { return false, s.ErrTouch