Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 8 additions & 1 deletion cmd/account-webhook/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 1 addition & 1 deletion cmd/account-webhook/main_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 ----------------------------------------------------------
Expand Down
4 changes: 4 additions & 0 deletions internal/account/billing/service_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
4 changes: 4 additions & 0 deletions internal/account/cycle/charge_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
52 changes: 52 additions & 0 deletions internal/account/db/queries/webhook.sql
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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)
);
80 changes: 80 additions & 0 deletions internal/account/db/webhook.sql.go

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

99 changes: 99 additions & 0 deletions internal/account/webhook/handlers.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
}
Loading
Loading