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..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 @@ -247,3 +258,44 @@ 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, 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, + 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(@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 f49710f..2ae186a 100644 --- a/internal/account/db/webhook.sql.go +++ b/internal/account/db/webhook.sql.go @@ -141,6 +141,69 @@ 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($4::text, '') IS NOT 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, 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, + 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 @@ -450,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/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..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 @@ -138,6 +146,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 +196,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 +216,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, @@ -214,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) @@ -226,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 @@ -266,6 +313,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..1cedc4f 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,153 @@ 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) +} + +// --- 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 2fca8e0..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 @@ -311,6 +317,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..b1042ec 100644 --- a/internal/account/webhook/store_integration_test.go +++ b/internal/account/webhook/store_integration_test.go @@ -840,3 +840,132 @@ 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") +} + +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 fcb2ce0..c4e4077 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,10 +59,12 @@ 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 ErrMark error // from MarkEventProcessed + ErrUnmark error // from UnmarkEventProcessed ErrTouch error // from TouchAccountByStripeCustomer ErrSetDefault error // from SetDefaultPaymentMethod ErrInsert error // from InsertPaymentMethod @@ -70,9 +74,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 +96,7 @@ func NewFakeStore() *FakeStore { InsertFound: true, SoftDelFound: true, InvoiceFound: true, + FraudFound: true, ActivatedNew: true, } } @@ -101,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 @@ -180,6 +202,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