diff --git a/cmd/account-api/main.go b/cmd/account-api/main.go index 777b664..55f9c9c 100644 --- a/cmd/account-api/main.go +++ b/cmd/account-api/main.go @@ -104,6 +104,13 @@ func (d *dispatcher) dispatch(ctx context.Context, action string, requestPayload } return d.svc.GetPaymentMethods(ctx, req) + case "GetServiceStatus": + var req billing.GetServiceStatusRequest + if err := json.Unmarshal(requestPayload, &req); err != nil { + return nil, billing.InvalidInput("malformed request payload: " + err.Error()) + } + return d.svc.GetServiceStatus(ctx, req) + case "DetachPaymentMethod": var req billing.DetachPaymentMethodRequest if err := json.Unmarshal(requestPayload, &req); err != nil { @@ -350,6 +357,7 @@ func buildRouter(d *dispatcher) *chi.Mux { r.Post("/v1/billing.StartAddPaymentMethod", makeHTTPHandler(d, "StartAddPaymentMethod")) r.Post("/v1/billing.FinishAddPaymentMethod", makeHTTPHandler(d, "FinishAddPaymentMethod")) r.Post("/v1/billing.GetPaymentMethods", makeHTTPHandler(d, "GetPaymentMethods")) + r.Post("/v1/billing.GetServiceStatus", makeHTTPHandler(d, "GetServiceStatus")) r.Post("/v1/billing.DetachPaymentMethod", makeHTTPHandler(d, "DetachPaymentMethod")) r.Post("/v1/billing.SetDefaultPaymentMethod", makeHTTPHandler(d, "SetDefaultPaymentMethod")) // Usage RPCs invoked by the platform control plane (manifest metric diff --git a/internal/account/billing/service.go b/internal/account/billing/service.go index a6b111a..54f79d2 100644 --- a/internal/account/billing/service.go +++ b/internal/account/billing/service.go @@ -6,6 +6,7 @@ import ( "github.com/google/uuid" + "github.com/mirrorstack-ai/billing-engine/internal/account/eligibility" billingstripe "github.com/mirrorstack-ai/billing-engine/internal/shared/stripe" ) @@ -298,6 +299,78 @@ func (s *Service) GetPaymentMethods(ctx context.Context, req GetPaymentMethodsRe return &GetPaymentMethodsResponse{PaymentMethods: methods}, nil } +// GetServiceStatus is the read behind the platform's service-block gate: it +// returns whether the account's services should be BLOCKED on payment standing +// (no usable non-fraud card, a failed first charge, or a failed-charge streak +// >= 2), per internal/account/eligibility. Read-only; never touches Stripe. +// +// A user with no billing account yet has no card on file, so the gate blocks on +// the card requirement (ReasonNoUsableCard) rather than 404-ing — the caller +// reads one Blocked flag and never has to special-case "not set up". A real +// account gathers the three signals in one store read and hands them to the +// pure Evaluate. +func (s *Service) GetServiceStatus(ctx context.Context, req GetServiceStatusRequest) (*GetServiceStatusResponse, error) { + if req.UserID == uuid.Nil { + return nil, InvalidInput("user_id required") + } + + accountID, found, err := s.store.AccountByUser(ctx, req.UserID) + if err != nil { + return nil, Internal("account lookup failed", err) + } + if !found { + // No account ⇒ no card ⇒ blocked on the (unconditional) card gate. + return serviceStatusResponse(eligibility.Verdict{ + Blocked: true, + Reason: eligibility.ReasonNoUsableCard, + Reasons: []eligibility.Reason{eligibility.ReasonNoUsableCard}, + }), nil + } + + sig, err := s.store.ServiceBlockSignals(ctx, accountID) + if err != nil { + return nil, Internal("service-block signals read failed", err) + } + verdict := eligibility.Evaluate(eligibility.Signals{ + UsableNonFraudCardCount: sig.UsableCardCount, + FirstCharge: firstChargeState(sig.FirstChargeStatus), + FailedChargeStreak: sig.FailedChargeStreak, + }) + return serviceStatusResponse(verdict), nil +} + +// firstChargeState maps the earliest real invoice's Stripe status (from +// ServiceBlockSignals) to the eligibility enum. The query already excludes +// draft/void, so only paid/open/uncollectible/"" reach here; "" (no charge yet) +// and any unrecognized status fall through to FirstChargeNone (graced). +func firstChargeState(status string) eligibility.FirstChargeState { + switch status { + case "paid": + return eligibility.FirstChargeSucceeded + case "open": + return eligibility.FirstChargePending + case "uncollectible": + return eligibility.FirstChargeFailed + default: + return eligibility.FirstChargeNone + } +} + +// serviceStatusResponse serializes an eligibility.Verdict into the wire +// response, stringifying the Reason codes. An eligible verdict carries an empty +// Reasons slice, which the omitempty tag drops from the JSON. +func serviceStatusResponse(v eligibility.Verdict) *GetServiceStatusResponse { + reasons := make([]string, len(v.Reasons)) + for i, r := range v.Reasons { + reasons[i] = string(r) + } + return &GetServiceStatusResponse{ + Blocked: v.Blocked, + Reason: string(v.Reason), + Reasons: reasons, + } +} + // DetachPaymentMethod detaches a saved card from the user's Stripe // Customer. Ownership is enforced via PaymentMethodTarget — the PM must // belong to the caller's account. The mirror row is soft-deleted diff --git a/internal/account/billing/service_test.go b/internal/account/billing/service_test.go index cf38952..16529c8 100644 --- a/internal/account/billing/service_test.go +++ b/internal/account/billing/service_test.go @@ -20,6 +20,7 @@ type fakeStore struct { hasUsablePM map[uuid.UUID]bool hasUnpaidInvoice map[uuid.UUID]bool paymentMethodsBy map[uuid.UUID][]billing.PaymentMethod + serviceSignals map[uuid.UUID]billing.ServiceSignals addCardRequests map[uuid.UUID]*fakeAddCardRequest // PM-target lookups for detach / set-default, keyed by payment method id. @@ -32,6 +33,7 @@ type fakeStore struct { errHasUsablePaymentMx error errHasUnpaidInvoice error errListPaymentMethods error + errServiceSignals error errPaymentMethodTarget error errInsertAddCardRequest error errSetSetupIntent error @@ -62,6 +64,7 @@ func newFakeStore() *fakeStore { hasUsablePM: map[uuid.UUID]bool{}, hasUnpaidInvoice: map[uuid.UUID]bool{}, paymentMethodsBy: map[uuid.UUID][]billing.PaymentMethod{}, + serviceSignals: map[uuid.UUID]billing.ServiceSignals{}, pmTargets: map[uuid.UUID]pmTarget{}, addCardRequests: map[uuid.UUID]*fakeAddCardRequest{}, } @@ -125,6 +128,13 @@ func (s *fakeStore) ListPaymentMethods(_ context.Context, accountID uuid.UUID) ( return s.paymentMethodsBy[accountID], nil } +func (s *fakeStore) ServiceBlockSignals(_ context.Context, accountID uuid.UUID) (billing.ServiceSignals, error) { + if s.errServiceSignals != nil { + return billing.ServiceSignals{}, s.errServiceSignals + } + return s.serviceSignals[accountID], nil +} + func (s *fakeStore) PaymentMethodTarget(_ context.Context, _ uuid.UUID, paymentMethodID uuid.UUID) (string, string, bool, bool, error) { if s.errPaymentMethodTarget != nil { return "", "", false, false, s.errPaymentMethodTarget @@ -621,6 +631,103 @@ func TestGetPaymentMethods_HasMethods_ReturnsAll(t *testing.T) { require.True(t, resp.PaymentMethods[0].IsDefault) } +// --- GetServiceStatus (service-block gate) -------------------------------- + +func TestGetServiceStatus_MissingUserID_InvalidInput(t *testing.T) { + svc := billing.NewService(newFakeStore(), &fakeStripe{}, "") + + _, err := svc.GetServiceStatus(context.Background(), billing.GetServiceStatusRequest{}) + + var be *billing.Error + require.ErrorAs(t, err, &be) + require.Equal(t, billing.CodeInvalidInput, be.Code) +} + +func TestGetServiceStatus_NoAccount_BlockedNoCard(t *testing.T) { + // A user with no billing account has no card on file → blocked on the card + // gate, not a 404. + svc := billing.NewService(newFakeStore(), &fakeStripe{}, "") + + resp, err := svc.GetServiceStatus(context.Background(), billing.GetServiceStatusRequest{UserID: uuid.New()}) + + require.NoError(t, err) + require.True(t, resp.Blocked) + require.Equal(t, "NO_USABLE_CARD", resp.Reason) + require.Equal(t, []string{"NO_USABLE_CARD"}, resp.Reasons) +} + +func TestGetServiceStatus_Eligible(t *testing.T) { + store := newFakeStore() + userID, accountID := uuid.New(), uuid.New() + store.accountsByUser[userID] = fakeAccount{id: accountID, stripeCustomerID: "cus_x"} + store.serviceSignals[accountID] = billing.ServiceSignals{ + UsableCardCount: 1, FailedChargeStreak: 1, FirstChargeStatus: "paid", + } + svc := billing.NewService(store, &fakeStripe{}, "") + + resp, err := svc.GetServiceStatus(context.Background(), billing.GetServiceStatusRequest{UserID: userID}) + + require.NoError(t, err) + require.False(t, resp.Blocked) + require.Equal(t, "ELIGIBLE", resp.Reason) + require.Empty(t, resp.Reasons) +} + +func TestGetServiceStatus_NewAccountWithCardIsGraced(t *testing.T) { + // No charge yet ("" first-charge status) + a card → eligible (grace). + store := newFakeStore() + userID, accountID := uuid.New(), uuid.New() + store.accountsByUser[userID] = fakeAccount{id: accountID} + store.serviceSignals[accountID] = billing.ServiceSignals{UsableCardCount: 1, FirstChargeStatus: ""} + svc := billing.NewService(store, &fakeStripe{}, "") + + resp, err := svc.GetServiceStatus(context.Background(), billing.GetServiceStatusRequest{UserID: userID}) + + require.NoError(t, err) + require.False(t, resp.Blocked) +} + +func TestGetServiceStatus_MapsSignalsToBlockingReasons(t *testing.T) { + cases := []struct { + name string + signals billing.ServiceSignals + wantReason string + }{ + {"no card", billing.ServiceSignals{UsableCardCount: 0, FirstChargeStatus: "paid"}, "NO_USABLE_CARD"}, + {"first charge uncollectible", billing.ServiceSignals{UsableCardCount: 1, FirstChargeStatus: "uncollectible"}, "FIRST_CHARGE_FAILED"}, + {"streak of two", billing.ServiceSignals{UsableCardCount: 1, FirstChargeStatus: "paid", FailedChargeStreak: 2}, "TOO_MANY_FAILURES"}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + store := newFakeStore() + userID, accountID := uuid.New(), uuid.New() + store.accountsByUser[userID] = fakeAccount{id: accountID} + store.serviceSignals[accountID] = tc.signals + svc := billing.NewService(store, &fakeStripe{}, "") + + resp, err := svc.GetServiceStatus(context.Background(), billing.GetServiceStatusRequest{UserID: userID}) + + require.NoError(t, err) + require.True(t, resp.Blocked) + require.Equal(t, tc.wantReason, resp.Reason) + }) + } +} + +func TestGetServiceStatus_SignalsError_Internal(t *testing.T) { + store := newFakeStore() + userID, accountID := uuid.New(), uuid.New() + store.accountsByUser[userID] = fakeAccount{id: accountID} + store.errServiceSignals = errors.New("conn dropped") + svc := billing.NewService(store, &fakeStripe{}, "") + + _, err := svc.GetServiceStatus(context.Background(), billing.GetServiceStatusRequest{UserID: userID}) + + var be *billing.Error + require.ErrorAs(t, err, &be) + require.Equal(t, billing.CodeInternal, be.Code) +} + func TestDetachPaymentMethod_OwnedPM_DetachesFromStripe(t *testing.T) { store := newFakeStore() pmID := uuid.New() diff --git a/internal/account/billing/store.go b/internal/account/billing/store.go index 9533564..726fcd8 100644 --- a/internal/account/billing/store.go +++ b/internal/account/billing/store.go @@ -52,6 +52,13 @@ type Store interface { // none exist. ListPaymentMethods(ctx context.Context, accountID uuid.UUID) ([]PaymentMethod, error) + // ServiceBlockSignals reads, in one round-trip, the three inputs the + // service-block eligibility gate reasons over for an account: the usable + // non-fraud card count, the consecutive failed-charge streak, and the + // earliest real charge's status ("" when the account has none yet). See + // db.ServiceBlockSignals. + ServiceBlockSignals(ctx context.Context, accountID uuid.UUID) (ServiceSignals, error) + // PaymentMethodTarget resolves an active payment method owned by the // user, returning its Stripe PM id, the account's Stripe customer // id, and whether the row is currently the default. found=false when @@ -205,6 +212,18 @@ func (s *pgxStore) HasUnpaidInvoice(ctx context.Context, accountID uuid.UUID) (b return s.q.AccountHasUnpaidInvoice(ctx, accountID.String()) } +func (s *pgxStore) ServiceBlockSignals(ctx context.Context, accountID uuid.UUID) (ServiceSignals, error) { + row, err := s.q.ServiceBlockSignals(ctx, accountID.String()) + if err != nil { + return ServiceSignals{}, err + } + return ServiceSignals{ + UsableCardCount: int(row.UsableCardCount), + FailedChargeStreak: int(row.FailedChargeStreak), + FirstChargeStatus: row.FirstChargeStatus, + }, nil +} + func (s *pgxStore) ListPaymentMethods(ctx context.Context, accountID uuid.UUID) ([]PaymentMethod, error) { rows, err := s.q.ListPaymentMethods(ctx, accountID.String()) if err != nil { diff --git a/internal/account/billing/types.go b/internal/account/billing/types.go index 78c755d..ed9c4d1 100644 --- a/internal/account/billing/types.go +++ b/internal/account/billing/types.go @@ -179,6 +179,32 @@ type GetPaymentMethodsResponse struct { PaymentMethods []PaymentMethod `json:"payment_methods"` } +// ServiceSignals are the raw service-block gate inputs read for one account +// (db.ServiceBlockSignals). The service maps these into eligibility.Signals: +// FirstChargeStatus is the earliest real invoice's Stripe status ("" when the +// account has no charge yet), mapped to the FirstChargeState enum. +type ServiceSignals struct { + UsableCardCount int + FailedChargeStreak int + FirstChargeStatus string +} + +// GetServiceStatusRequest is the payload of GetServiceStatus — the account is +// addressed by owner, the same as Ensure / GetPaymentMethods. +type GetServiceStatusRequest struct { + UserID uuid.UUID `json:"user_id"` +} + +// GetServiceStatusResponse is the service-block verdict for the account. Blocked +// is the single field a caller must read to gate service; Reason is the primary +// machine-readable cause (eligibility.Reason, "ELIGIBLE" when not blocked); +// Reasons lists every failing gate (omitted when eligible). +type GetServiceStatusResponse struct { + Blocked bool `json:"blocked"` + Reason string `json:"reason"` + Reasons []string `json:"reasons,omitempty"` +} + // PaymentMethod is the projection of a payment_methods_mirror row // returned to UI consumers. Card-only in v1. type PaymentMethod struct { diff --git a/internal/account/db/billing.sql.go b/internal/account/db/billing.sql.go index f9f4d8c..aabd0e4 100644 --- a/internal/account/db/billing.sql.go +++ b/internal/account/db/billing.sql.go @@ -197,6 +197,89 @@ func (q *Queries) SelectAccountByUser(ctx context.Context, ownerUserID pgtype.UU return i, err } +const serviceBlockSignals = `-- name: ServiceBlockSignals :one +SELECT + (SELECT COUNT(*) + FROM ms_billing.payment_methods_mirror pmm + WHERE pmm.account_id = a.id + AND pmm.deleted_at IS NULL + AND NOT pmm.fraud_blocked + AND (pmm.exp_year, pmm.exp_month) >= ( + EXTRACT(YEAR FROM current_date)::INT, + EXTRACT(MONTH FROM current_date)::INT + ))::int AS usable_card_count, + (SELECT COUNT(*) + FROM ms_billing.invoices f + WHERE f.account_id = a.id + AND (f.ever_failed OR f.status = 'uncollectible') + AND f.created_at > COALESCE( + (SELECT MAX(p.created_at) + FROM ms_billing.invoices p + WHERE p.account_id = a.id AND p.status = 'paid'), + '-infinity'::timestamptz + ))::int AS failed_charge_streak, + COALESCE(( + SELECT inv.status + FROM ms_billing.invoices inv + WHERE inv.account_id = a.id + AND inv.status NOT IN ('draft', 'void') + ORDER BY inv.created_at ASC, inv.id ASC + LIMIT 1 + ), '')::text AS first_charge_status +FROM ms_billing.accounts a +WHERE a.id = $1 +` + +type ServiceBlockSignalsRow struct { + UsableCardCount int32 `json:"usable_card_count"` + FailedChargeStreak int32 `json:"failed_charge_streak"` + FirstChargeStatus string `json:"first_charge_status"` +} + +// ServiceBlockSignals reads, in ONE round-trip, the three inputs the +// service-block eligibility gate (internal/account/eligibility) reasons over, +// all scoped to one already-resolved account id: +// +// usable_card_count — active (deleted_at IS NULL), non-fraud +// (NOT fraud_blocked, migration 038), NOT-expired cards. +// Reuses HasUsablePaymentMethod's expiry predicate, +// COUNT instead of EXISTS. The gate blocks at 0. +// failed_charge_streak — the account's consecutive failed-charge count, +// DERIVED at read time from the timestamped invoice +// mirror: the number of distinct FAILED invoices +// (ever_failed, migration 039, OR currently +// 'uncollectible') created AFTER the account's most- +// recent PAID invoice. The gate blocks at >= 2. +// Deriving it (vs a stored counter mutated by two +// out-of-transaction webhook UPDATEs in Stripe DELIVERY +// order) makes it immune to at-least-once + out-of-order +// delivery: reorder the paid/failed events however you +// like, the count over the immutable (status, ever_failed, +// created_at) facts is the same. "Reset on the next +// successful charge" falls out for free — a newer paid +// invoice moves the cutoff forward, excluding older +// failures. A failed-then-paid invoice is excluded (its +// created_at is not AFTER its own paid instant). +// first_charge_status — the status of the account's EARLIEST real charge: +// the oldest invoice that is not 'draft' (never +// finalized) or 'void' (cancelled, never a real charge +// attempt), by (created_at, id) ASC. '' when the account +// has no such invoice yet (brand new — the gate graces +// it as long as a card is present). The Go layer maps +// '' / paid / open / uncollectible → the FirstChargeState +// enum (none / succeeded / pending / failed). +// +// Scalar subqueries (not JOINs) so each signal is independent and a NULL card +// count is impossible (COUNT is 0, not NULL); first_charge_status COALESCEs the +// no-invoice case to ”. One row per account id (or none → caller maps to the +// not-found verdict). +func (q *Queries) ServiceBlockSignals(ctx context.Context, id string) (ServiceBlockSignalsRow, error) { + row := q.db.QueryRow(ctx, serviceBlockSignals, id) + var i ServiceBlockSignalsRow + err := row.Scan(&i.UsableCardCount, &i.FailedChargeStreak, &i.FirstChargeStatus) + return i, err +} + const setStripeCustomer = `-- name: SetStripeCustomer :execrows UPDATE ms_billing.accounts SET stripe_customer_id = $2 WHERE id = $1 ` diff --git a/internal/account/db/models.go b/internal/account/db/models.go index 883f5f1..04cf7c8 100644 --- a/internal/account/db/models.go +++ b/internal/account/db/models.go @@ -469,6 +469,8 @@ type MsBillingInvoice struct { InvoicePdf pgtype.Text `json:"invoice_pdf"` // Server-computed at invoice-create time: true iff the charged amount (netted arrears + advance base, micros) exceeded the account auto_collect_threshold_micros (or the default when NULL) that applied WHEN THE CHARGE FIRED. Post-hoc disclosure only. IsLargeAutoCollect bool `json:"is_large_auto_collect"` + // Sticky, set-only: true once this invoice failed a payment (payment_failed / marked_uncollectible), never cleared. Lets the service-block gate DERIVE the failed-charge streak at read time — counting (ever_failed OR uncollectible) invoices created after the last paid one — so it survives a later flip to paid and is immune to webhook delivery order. + EverFailed bool `json:"ever_failed"` } type MsBillingMetricDefinition struct { @@ -511,6 +513,12 @@ type MsBillingPaymentMethodsMirror struct { AttachedAt time.Time `json:"attached_at"` DeletedAt pgtype.Timestamptz `json:"deleted_at"` Fingerprint pgtype.Text `json:"fingerprint"` + // True once Stripe flags this card as fraud/dispute risk (set by the radar.early_fraud_warning / charge.dispute webhook — follow-up PR). The service-block gate EXCLUDES fraud_blocked cards from the usable-card count. Defaults false so every existing card is treated non-fraud. + FraudBlocked bool `json:"fraud_blocked"` + // Audit: the signal that set fraud_blocked (e.g. early_fraud_warning, dispute). NULL until flagged. + FraudReason pgtype.Text `json:"fraud_reason"` + // Audit: when fraud_blocked was set. NULL until flagged. + FraudFlaggedAt pgtype.Timestamptz `json:"fraud_flagged_at"` } type MsBillingUsageAggregate struct { diff --git a/internal/account/db/queries/billing.sql b/internal/account/db/queries/billing.sql index fe75c41..4332586 100644 --- a/internal/account/db/queries/billing.sql +++ b/internal/account/db/queries/billing.sql @@ -80,3 +80,72 @@ SELECT EXISTS ( WHERE account_id = $1 AND status IN ('open', 'uncollectible') ) AS has_unpaid; + +-- ServiceBlockSignals reads, in ONE round-trip, the three inputs the +-- service-block eligibility gate (internal/account/eligibility) reasons over, +-- all scoped to one already-resolved account id: +-- +-- usable_card_count — active (deleted_at IS NULL), non-fraud +-- (NOT fraud_blocked, migration 038), NOT-expired cards. +-- Reuses HasUsablePaymentMethod's expiry predicate, +-- COUNT instead of EXISTS. The gate blocks at 0. +-- failed_charge_streak — the account's consecutive failed-charge count, +-- DERIVED at read time from the timestamped invoice +-- mirror: the number of distinct FAILED invoices +-- (ever_failed, migration 039, OR currently +-- 'uncollectible') created AFTER the account's most- +-- recent PAID invoice. The gate blocks at >= 2. +-- Deriving it (vs a stored counter mutated by two +-- out-of-transaction webhook UPDATEs in Stripe DELIVERY +-- order) makes it immune to at-least-once + out-of-order +-- delivery: reorder the paid/failed events however you +-- like, the count over the immutable (status, ever_failed, +-- created_at) facts is the same. "Reset on the next +-- successful charge" falls out for free — a newer paid +-- invoice moves the cutoff forward, excluding older +-- failures. A failed-then-paid invoice is excluded (its +-- created_at is not AFTER its own paid instant). +-- first_charge_status — the status of the account's EARLIEST real charge: +-- the oldest invoice that is not 'draft' (never +-- finalized) or 'void' (cancelled, never a real charge +-- attempt), by (created_at, id) ASC. '' when the account +-- has no such invoice yet (brand new — the gate graces +-- it as long as a card is present). The Go layer maps +-- '' / paid / open / uncollectible → the FirstChargeState +-- enum (none / succeeded / pending / failed). +-- +-- Scalar subqueries (not JOINs) so each signal is independent and a NULL card +-- count is impossible (COUNT is 0, not NULL); first_charge_status COALESCEs the +-- no-invoice case to ''. One row per account id (or none → caller maps to the +-- not-found verdict). +-- name: ServiceBlockSignals :one +SELECT + (SELECT COUNT(*) + FROM ms_billing.payment_methods_mirror pmm + WHERE pmm.account_id = a.id + AND pmm.deleted_at IS NULL + AND NOT pmm.fraud_blocked + AND (pmm.exp_year, pmm.exp_month) >= ( + EXTRACT(YEAR FROM current_date)::INT, + EXTRACT(MONTH FROM current_date)::INT + ))::int AS usable_card_count, + (SELECT COUNT(*) + FROM ms_billing.invoices f + WHERE f.account_id = a.id + AND (f.ever_failed OR f.status = 'uncollectible') + AND f.created_at > COALESCE( + (SELECT MAX(p.created_at) + FROM ms_billing.invoices p + WHERE p.account_id = a.id AND p.status = 'paid'), + '-infinity'::timestamptz + ))::int AS failed_charge_streak, + COALESCE(( + SELECT inv.status + FROM ms_billing.invoices inv + WHERE inv.account_id = a.id + AND inv.status NOT IN ('draft', 'void') + ORDER BY inv.created_at ASC, inv.id ASC + LIMIT 1 + ), '')::text AS first_charge_status +FROM ms_billing.accounts a +WHERE a.id = $1; diff --git a/internal/account/db/queries/webhook.sql b/internal/account/db/queries/webhook.sql index cc8ec9b..80e6b07 100644 --- a/internal/account/db/queries/webhook.sql +++ b/internal/account/db/queries/webhook.sql @@ -152,16 +152,24 @@ WHERE stripe_pm_id = $1 AND status = 'pending'; -- Out-of-order + at-least-once safety: Stripe delivers webhooks at-least-once -- and NOT in guaranteed order, so a late invoice.finalized (open) can arrive -- after invoice.paid (paid). The WHERE guard enforces a MONOTONIC status --- transition via a rank ladder — draft(0) < open(1) < paid/void/uncollectible(2, --- terminal) — so the row's status can only move forward. The new status's rank +-- transition via a rank ladder — draft(0) < open(1) < void/uncollectible(2) < +-- paid(3) — so the row's status can only move forward. The new status's rank -- must be strictly greater than the current rank (a forward transition), with -- one exception: an identical re-apply of the SAME status is allowed through so --- a replayed paid can refresh amount_paid/amount_due idempotently. A terminal --- status (rank 2) is never overwritten by a different status because no rank --- exceeds 2 and the equal-rank branch requires status equality, so paid→void --- (both rank 2) is a no-op — terminal-once-set holds. The CASE ladder is inline --- (not a stored ENUM) so a new Stripe status maps to the bottom rung (-1) and --- can never silently regress a known terminal state. +-- a replayed paid can refresh amount_paid/amount_due idempotently. +-- +-- paid outranks void/uncollectible (rank 3 vs 2) DELIBERATELY: paying an +-- invoice that Stripe already marked uncollectible (the customer settles it on +-- the hosted page) transitions it uncollectible→paid, which MUST land so the +-- delinquency signal clears and the collection relax + service-block cure run +-- off the invoice.paid handler. Ranking paid at 2 (its old value) rejected that +-- transition (2 > 2 false) and trapped a paying customer in delinquent/blocked +-- state forever. void/uncollectible stay at 2 and so still cannot overwrite a +-- paid(3) row (2 > 3 false; the equal-rank branch requires status equality), and +-- Stripe never emits void/uncollectible AFTER a real payment — so terminal-once- +-- paid still holds while the genuine recovery is no longer blocked. The CASE +-- ladder is inline (not a stored ENUM) so a new Stripe status maps to the bottom +-- rung (-1) and can never silently regress a known terminal state. -- -- :execrows → >0 means the row existed AND the guard let the update through; 0 -- means either no mirror row (drift: the charge spine's UpsertInvoice hasn't @@ -181,12 +189,12 @@ WHERE i.stripe_invoice_id = @stripe_invoice_id -- forward transition: incoming rank strictly above the stored rank … (CASE @status::text WHEN 'draft' THEN 0 WHEN 'open' THEN 1 - WHEN 'paid' THEN 2 WHEN 'void' THEN 2 WHEN 'uncollectible' THEN 2 + WHEN 'void' THEN 2 WHEN 'uncollectible' THEN 2 WHEN 'paid' THEN 3 ELSE -1 END) > (CASE i.status WHEN 'draft' THEN 0 WHEN 'open' THEN 1 - WHEN 'paid' THEN 2 WHEN 'void' THEN 2 WHEN 'uncollectible' THEN 2 + WHEN 'void' THEN 2 WHEN 'uncollectible' THEN 2 WHEN 'paid' THEN 3 ELSE -1 END) -- … OR an identical re-apply (idempotent amount refresh on replay). OR i.status = @status @@ -223,3 +231,19 @@ WHERE a.id = ( WHERE i.account_id = a.id AND i.status IN ('open', 'uncollectible') ); + +-- MarkInvoiceFailed sets the sticky ever_failed flag (migration 039) on an +-- invoice that failed a payment (invoice.payment_failed / marked_uncollectible). +-- ever_failed is what lets ServiceBlockSignals count an invoice that is still +-- 'open' after a failed charge (a currently-'uncollectible' invoice the +-- derivation already catches by status). :execrows, but the caller ignores the +-- count: the flag is a set-only latch (a second failure event for the same +-- invoice re-sets it to the same value — 0 rows, a harmless no-op), and the +-- failed-charge STREAK is derived at read time from these facts + created_at, +-- not maintained as a counter, so there is nothing to double-count. Keyed on the +-- invoice, so it is a safe no-op when the mirror row has not landed yet. +-- name: MarkInvoiceFailed :execrows +UPDATE ms_billing.invoices +SET ever_failed = true +WHERE stripe_invoice_id = $1 + AND NOT ever_failed; diff --git a/internal/account/db/webhook.sql.go b/internal/account/db/webhook.sql.go index de769ab..f49710f 100644 --- a/internal/account/db/webhook.sql.go +++ b/internal/account/db/webhook.sql.go @@ -37,12 +37,12 @@ WHERE i.stripe_invoice_id = $7 -- forward transition: incoming rank strictly above the stored rank … (CASE $1::text WHEN 'draft' THEN 0 WHEN 'open' THEN 1 - WHEN 'paid' THEN 2 WHEN 'void' THEN 2 WHEN 'uncollectible' THEN 2 + WHEN 'void' THEN 2 WHEN 'uncollectible' THEN 2 WHEN 'paid' THEN 3 ELSE -1 END) > (CASE i.status WHEN 'draft' THEN 0 WHEN 'open' THEN 1 - WHEN 'paid' THEN 2 WHEN 'void' THEN 2 WHEN 'uncollectible' THEN 2 + WHEN 'void' THEN 2 WHEN 'uncollectible' THEN 2 WHEN 'paid' THEN 3 ELSE -1 END) -- … OR an identical re-apply (idempotent amount refresh on replay). OR i.status = $1 @@ -76,16 +76,24 @@ type ApplyInvoiceStatusParams struct { // Out-of-order + at-least-once safety: Stripe delivers webhooks at-least-once // and NOT in guaranteed order, so a late invoice.finalized (open) can arrive // after invoice.paid (paid). The WHERE guard enforces a MONOTONIC status -// transition via a rank ladder — draft(0) < open(1) < paid/void/uncollectible(2, -// terminal) — so the row's status can only move forward. The new status's rank +// transition via a rank ladder — draft(0) < open(1) < void/uncollectible(2) < +// paid(3) — so the row's status can only move forward. The new status's rank // must be strictly greater than the current rank (a forward transition), with // one exception: an identical re-apply of the SAME status is allowed through so -// a replayed paid can refresh amount_paid/amount_due idempotently. A terminal -// status (rank 2) is never overwritten by a different status because no rank -// exceeds 2 and the equal-rank branch requires status equality, so paid→void -// (both rank 2) is a no-op — terminal-once-set holds. The CASE ladder is inline -// (not a stored ENUM) so a new Stripe status maps to the bottom rung (-1) and -// can never silently regress a known terminal state. +// a replayed paid can refresh amount_paid/amount_due idempotently. +// +// paid outranks void/uncollectible (rank 3 vs 2) DELIBERATELY: paying an +// invoice that Stripe already marked uncollectible (the customer settles it on +// the hosted page) transitions it uncollectible→paid, which MUST land so the +// delinquency signal clears and the collection relax + service-block cure run +// off the invoice.paid handler. Ranking paid at 2 (its old value) rejected that +// transition (2 > 2 false) and trapped a paying customer in delinquent/blocked +// state forever. void/uncollectible stay at 2 and so still cannot overwrite a +// paid(3) row (2 > 3 false; the equal-rank branch requires status equality), and +// Stripe never emits void/uncollectible AFTER a real payment — so terminal-once- +// paid still holds while the genuine recovery is no longer blocked. The CASE +// ladder is inline (not a stored ENUM) so a new Stripe status maps to the bottom +// rung (-1) and can never silently regress a known terminal state. // // :execrows → >0 means the row existed AND the guard let the update through; 0 // means either no mirror row (drift: the charge spine's UpsertInvoice hasn't @@ -223,6 +231,31 @@ func (q *Queries) MarkEventProcessed(ctx context.Context, arg MarkEventProcessed return result.RowsAffected(), nil } +const markInvoiceFailed = `-- name: MarkInvoiceFailed :execrows +UPDATE ms_billing.invoices +SET ever_failed = true +WHERE stripe_invoice_id = $1 + AND NOT ever_failed +` + +// MarkInvoiceFailed sets the sticky ever_failed flag (migration 039) on an +// invoice that failed a payment (invoice.payment_failed / marked_uncollectible). +// ever_failed is what lets ServiceBlockSignals count an invoice that is still +// 'open' after a failed charge (a currently-'uncollectible' invoice the +// derivation already catches by status). :execrows, but the caller ignores the +// count: the flag is a set-only latch (a second failure event for the same +// invoice re-sets it to the same value — 0 rows, a harmless no-op), and the +// failed-charge STREAK is derived at read time from these facts + created_at, +// not maintained as a counter, so there is nothing to double-count. Keyed on the +// invoice, so it is a safe no-op when the mirror row has not landed yet. +func (q *Queries) MarkInvoiceFailed(ctx context.Context, stripeInvoiceID string) (int64, error) { + result, err := q.db.Exec(ctx, markInvoiceFailed, stripeInvoiceID) + if err != nil { + return 0, err + } + return result.RowsAffected(), nil +} + const mirrorRowByStripePM = `-- name: MirrorRowByStripePM :one SELECT id, account_id, fingerprint FROM ms_billing.payment_methods_mirror diff --git a/internal/account/eligibility/eligibility.go b/internal/account/eligibility/eligibility.go new file mode 100644 index 0000000..77ffbfc --- /dev/null +++ b/internal/account/eligibility/eligibility.go @@ -0,0 +1,129 @@ +// Package eligibility implements the SERVICE-BLOCK gate: the pure decision of +// whether an account's services may run, given its payment standing. It is a +// sibling of the collection package (internal/account/collection) and follows +// the same posture — DELIBERATELY PURE: the verdict is a total function of +// explicit Signals, with NO DB / Stripe / clock access. The caller (the +// account-api billing service) gathers the signals (via db.ServiceBlockSignals) +// and calls Evaluate; persistence + transport live in the caller. This keeps +// the policy unit-testable in isolation and puts the gate rules in one obvious, +// finance-legible place. +// +// The gate (product spec): an account is ELIGIBLE for service iff ALL of — +// +// (1) it has at least one NON-FRAUD card on file, +// (2) its FIRST charge did not fail, and +// (3) its consecutive failed-charge streak is < 2. +// +// If any gate fails, the account is BLOCKED. The three gates map one-for-one to +// the three Signals fields; see Evaluate for the exact boolean logic and the +// grace edges (a brand-new account with no charge yet is graced, not blocked). +package eligibility + +// MaxFailedChargeStreak is the consecutive failed-charge count at which services +// are blocked. The spec is "< 2 failed" (2 excluded), so a streak of 0 or 1 +// passes and 2+ blocks. The streak is RECOVERABLE — it is derived (by +// ServiceBlockSignals) as the failures since the account's last successful +// charge, so paying moves the cutoff forward and self-heals the block. +// Hardcoded (mirrors collection's inline finance thresholds); promote to a +// per-account column only if a knob is needed. +const MaxFailedChargeStreak = 2 + +// FirstChargeState is the outcome of the account's EARLIEST real charge — the +// oldest invoice that is not draft (never finalized) or void (cancelled). It is +// the enum form of ServiceBlockSignals.first_charge_status, resolved by the +// caller from the invoice mirror. +type FirstChargeState string + +const ( + // FirstChargeNone: the account has no finalized charge yet (brand new). GRACE + // — eligible as long as a card is present, so arrears/usage billing isn't a + // bootstrap deadlock (the account must run to generate a charge at all). + FirstChargeNone FirstChargeState = "none" + // FirstChargeSucceeded: the earliest real invoice is 'paid'. Passes gate 2. + FirstChargeSucceeded FirstChargeState = "succeeded" + // FirstChargePending: the earliest real invoice is still 'open' (Stripe is + // smart-retrying). GRACE during the retry window — not yet a failure. + FirstChargePending FirstChargeState = "pending" + // FirstChargeFailed: the earliest real invoice went 'uncollectible' (Stripe + // gave up). This is the only first-charge state that BLOCKS (fails gate 2). + FirstChargeFailed FirstChargeState = "failed" +) + +// Signals are the live inputs the gate reasons over, gathered by the caller in +// one DB read (db.ServiceBlockSignals). None are derived here. +type Signals struct { + // UsableNonFraudCardCount is the number of active (not soft-deleted), + // not-fraud-flagged, not-expired cards on the account. Gate 1 requires >= 1. + UsableNonFraudCardCount int + // FirstCharge is the outcome of the account's earliest real charge. Gate 2 + // blocks only on FirstChargeFailed. + FirstCharge FirstChargeState + // FailedChargeStreak is the account's consecutive failed-charge counter + // (resets to 0 on a successful charge). Gate 3 blocks at >= MaxFailedChargeStreak. + FailedChargeStreak int +} + +// Reason is a stable, machine-readable code for a Verdict — the primary block +// cause (or ReasonEligible), for the wire contract, the UI, and test assertions. +type Reason string + +const ( + // ReasonEligible: not blocked — all gates pass. + ReasonEligible Reason = "ELIGIBLE" + // ReasonNoUsableCard: gate 1 failed — zero non-fraud usable cards on file. + ReasonNoUsableCard Reason = "NO_USABLE_CARD" + // ReasonFirstChargeFailed: gate 2 failed — the account's first charge went + // uncollectible. + ReasonFirstChargeFailed Reason = "FIRST_CHARGE_FAILED" + // ReasonTooManyFailures: gate 3 failed — the consecutive failed-charge streak + // reached MaxFailedChargeStreak. + ReasonTooManyFailures Reason = "TOO_MANY_FAILURES" +) + +// Verdict is the gate's decision. Blocked is the single field a caller must read +// to gate service; Reason is the primary cause (ReasonEligible when !Blocked); +// Reasons lists EVERY failing gate (empty when eligible) so a UI can tell a +// customer everything they must fix, not just the first thing. +type Verdict struct { + Blocked bool + Reason Reason + Reasons []Reason +} + +// Evaluate applies the three gates in a fixed priority order and returns the +// Verdict. The gates, and the exact boolean each checks: +// +// gate 1 (card): UsableNonFraudCardCount >= 1 +// gate 2 (first charge): FirstCharge != FirstChargeFailed +// gate 3 (failures): FailedChargeStreak < MaxFailedChargeStreak +// Blocked = NOT (gate1 AND gate2 AND gate3) +// +// Priority (card → first charge → failures) fixes which cause becomes the +// primary Reason when more than one gate fails; Reasons collects them all in the +// same order. The order is purely presentational — Blocked is the AND of all +// three regardless. +// +// Grace edges (why gate 2 is "!= failed", not "== succeeded"): a brand-new +// account (FirstChargeNone) and one whose first invoice is still retrying +// (FirstChargePending) both PASS gate 2 — blocking them would deadlock an +// account that must run before it can ever be charged. Only an outright failed +// first charge (uncollectible) blocks. Gate 1 is unconditional, so even a graced +// new account still needs a card. +func Evaluate(s Signals) Verdict { + var reasons []Reason + + if s.UsableNonFraudCardCount < 1 { + reasons = append(reasons, ReasonNoUsableCard) + } + if s.FirstCharge == FirstChargeFailed { + reasons = append(reasons, ReasonFirstChargeFailed) + } + if s.FailedChargeStreak >= MaxFailedChargeStreak { + reasons = append(reasons, ReasonTooManyFailures) + } + + if len(reasons) == 0 { + return Verdict{Blocked: false, Reason: ReasonEligible} + } + return Verdict{Blocked: true, Reason: reasons[0], Reasons: reasons} +} diff --git a/internal/account/eligibility/eligibility_test.go b/internal/account/eligibility/eligibility_test.go new file mode 100644 index 0000000..923af23 --- /dev/null +++ b/internal/account/eligibility/eligibility_test.go @@ -0,0 +1,166 @@ +package eligibility + +import ( + "reflect" + "testing" +) + +// card returns Signals with a good first charge and clean streak, varying only +// the usable-card count — so a case exercises exactly the gate it names. +func card(n int) Signals { + return Signals{UsableNonFraudCardCount: n, FirstCharge: FirstChargeSucceeded, FailedChargeStreak: 0} +} + +func TestEvaluate(t *testing.T) { + cases := []struct { + name string + in Signals + wantBlocked bool + wantReason Reason + wantReasons []Reason + }{ + // --- fully eligible --- + { + name: "one card, first charge paid, no failures", + in: Signals{UsableNonFraudCardCount: 1, FirstCharge: FirstChargeSucceeded, FailedChargeStreak: 0}, + wantReason: ReasonEligible, + }, + { + name: "many cards, streak of one still under the cap", + in: Signals{UsableNonFraudCardCount: 3, FirstCharge: FirstChargeSucceeded, FailedChargeStreak: 1}, + wantReason: ReasonEligible, + }, + + // --- gate 1: card --- + { + name: "zero cards blocks even with a clean charge history", + in: Signals{UsableNonFraudCardCount: 0, FirstCharge: FirstChargeSucceeded, FailedChargeStreak: 0}, + wantBlocked: true, + wantReason: ReasonNoUsableCard, + wantReasons: []Reason{ReasonNoUsableCard}, + }, + { + name: "negative card count is treated as no card (defensive)", + in: Signals{UsableNonFraudCardCount: -1, FirstCharge: FirstChargeSucceeded, FailedChargeStreak: 0}, + wantBlocked: true, + wantReason: ReasonNoUsableCard, + wantReasons: []Reason{ReasonNoUsableCard}, + }, + + // --- gate 2: first charge (grace edges) --- + { + name: "brand-new account, no charge yet, with a card is graced", + in: Signals{UsableNonFraudCardCount: 1, FirstCharge: FirstChargeNone, FailedChargeStreak: 0}, + wantReason: ReasonEligible, + }, + { + name: "first charge still pending (retrying) with a card is graced", + in: Signals{UsableNonFraudCardCount: 1, FirstCharge: FirstChargePending, FailedChargeStreak: 0}, + wantReason: ReasonEligible, + }, + { + name: "new account with NO card is blocked on the card, not graced away", + in: Signals{UsableNonFraudCardCount: 0, FirstCharge: FirstChargeNone, FailedChargeStreak: 0}, + wantBlocked: true, + wantReason: ReasonNoUsableCard, + wantReasons: []Reason{ReasonNoUsableCard}, + }, + { + name: "first charge failed blocks", + in: Signals{UsableNonFraudCardCount: 1, FirstCharge: FirstChargeFailed, FailedChargeStreak: 1}, + wantBlocked: true, + wantReason: ReasonFirstChargeFailed, + wantReasons: []Reason{ReasonFirstChargeFailed}, + }, + + // --- gate 3: failure streak boundary (< 2, so 2 excluded) --- + { + name: "streak of one is allowed (boundary, still under 2)", + in: Signals{UsableNonFraudCardCount: 1, FirstCharge: FirstChargeSucceeded, FailedChargeStreak: 1}, + wantReason: ReasonEligible, + }, + { + name: "streak of exactly two blocks (2 excluded by < 2)", + in: Signals{UsableNonFraudCardCount: 1, FirstCharge: FirstChargeSucceeded, FailedChargeStreak: 2}, + wantBlocked: true, + wantReason: ReasonTooManyFailures, + wantReasons: []Reason{ReasonTooManyFailures}, + }, + { + name: "streak above two blocks", + in: Signals{UsableNonFraudCardCount: 1, FirstCharge: FirstChargeSucceeded, FailedChargeStreak: 5}, + wantBlocked: true, + wantReason: ReasonTooManyFailures, + wantReasons: []Reason{ReasonTooManyFailures}, + }, + + // --- multiple gates fail: priority + all-reasons --- + { + name: "no card AND first charge failed: primary is card, both reported", + in: Signals{UsableNonFraudCardCount: 0, FirstCharge: FirstChargeFailed, FailedChargeStreak: 1}, + wantBlocked: true, + wantReason: ReasonNoUsableCard, + wantReasons: []Reason{ReasonNoUsableCard, ReasonFirstChargeFailed}, + }, + { + name: "all three gates fail: card is primary, all three reported in order", + in: Signals{UsableNonFraudCardCount: 0, FirstCharge: FirstChargeFailed, FailedChargeStreak: 3}, + wantBlocked: true, + wantReason: ReasonNoUsableCard, + wantReasons: []Reason{ReasonNoUsableCard, ReasonFirstChargeFailed, ReasonTooManyFailures}, + }, + { + name: "first charge failed AND too many failures (card ok): first-charge is primary", + in: Signals{UsableNonFraudCardCount: 2, FirstCharge: FirstChargeFailed, FailedChargeStreak: 2}, + wantBlocked: true, + wantReason: ReasonFirstChargeFailed, + wantReasons: []Reason{ReasonFirstChargeFailed, ReasonTooManyFailures}, + }, + } + + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + got := Evaluate(tc.in) + if got.Blocked != tc.wantBlocked { + t.Errorf("Blocked = %v, want %v", got.Blocked, tc.wantBlocked) + } + if got.Reason != tc.wantReason { + t.Errorf("Reason = %q, want %q", got.Reason, tc.wantReason) + } + if !reflect.DeepEqual(got.Reasons, tc.wantReasons) { + t.Errorf("Reasons = %v, want %v", got.Reasons, tc.wantReasons) + } + }) + } +} + +// TestEvaluate_EligibleHasNoReasons pins the contract that an eligible verdict +// carries an empty Reasons slice (a UI iterates it to show "what to fix"). +func TestEvaluate_EligibleHasNoReasons(t *testing.T) { + got := Evaluate(card(1)) + if got.Blocked { + t.Fatalf("expected eligible, got blocked: %+v", got) + } + if len(got.Reasons) != 0 { + t.Errorf("eligible verdict must have no Reasons, got %v", got.Reasons) + } +} + +// TestEvaluate_PrimaryReasonIsFirstOfReasons pins that, whenever blocked, the +// primary Reason is exactly Reasons[0] — the invariant a caller relies on when +// it shows one headline cause but keeps the full list. +func TestEvaluate_PrimaryReasonIsFirstOfReasons(t *testing.T) { + for _, n := range []int{0, 1} { + for _, fc := range []FirstChargeState{FirstChargeNone, FirstChargeSucceeded, FirstChargePending, FirstChargeFailed} { + for _, streak := range []int{0, 1, 2, 4} { + v := Evaluate(Signals{UsableNonFraudCardCount: n, FirstCharge: fc, FailedChargeStreak: streak}) + if v.Blocked && v.Reason != v.Reasons[0] { + t.Errorf("blocked verdict %+v: Reason %q != Reasons[0] %q", v, v.Reason, v.Reasons[0]) + } + if !v.Blocked && v.Reason != ReasonEligible { + t.Errorf("unblocked verdict must read ELIGIBLE, got %q", v.Reason) + } + } + } + } +} diff --git a/internal/account/webhook/handlers.go b/internal/account/webhook/handlers.go index 6dd3e57..030f955 100644 --- a/internal/account/webhook/handlers.go +++ b/internal/account/webhook/handlers.go @@ -229,6 +229,21 @@ func (r *Router) handleInvoiceLifecycle(ctx context.Context, event stripego.Even return Result{HTTPStatus: 400, Status: StatusInvalidBody} } + // SERVICE-BLOCK failure latch — set BEFORE the found-guard and the status + // reconcile, on BOTH failure signals (payment_failed leaves the invoice + // 'open'; marked_uncollectible is a terminal that may arrive first under + // out-of-order delivery). ever_failed is invoice-keyed and set-only, so this + // is order-independent and a harmless no-op when the mirror row hasn't landed + // — the read-time streak derivation (ServiceBlockSignals) does the rest, so + // there is no counter to advance here and no reset on invoice.paid. A failure + // here is surfaced (500) so Stripe retries; the latch makes the retry a no-op. + if event.Type == stripego.EventTypeInvoicePaymentFailed || event.Type == stripego.EventTypeInvoiceMarkedUncollectible { + if err := r.store.MarkInvoiceFailed(ctx, inv.ID); err != nil { + r.log.ErrorContext(ctx, "invoice failure latch (ever_failed) failed", "event_id", event.ID, "type", event.Type, "stripe_invoice_id", inv.ID, "error", err) + return Result{HTTPStatus: 500, Status: StatusInternal} + } + } + found, err := r.store.ApplyInvoiceStatus(ctx, ApplyInvoiceStatusParams{ StripeInvoiceID: inv.ID, // Stripe invoice status mirrored verbatim (draft/open/paid/ @@ -258,7 +273,9 @@ func (r *Router) handleInvoiceLifecycle(ctx context.Context, event stripego.Even // RELAX driver — invoice.paid only. A failure here is surfaced (500) so Stripe // retries: the relax UPDATE is idempotent (a no-op once the account is already - // arrears), so a retry after the mirror already advanced is safe. + // arrears), so a retry after the mirror already advanced is safe. The + // service-block auto-cure needs no write here — paying an invoice moves the + // ServiceBlockSignals streak cutoff (most-recent paid) forward automatically. if event.Type == stripego.EventTypeInvoicePaid { relaxed, err := r.store.RelaxCollectionOnPaidInvoice(ctx, inv.ID) if err != nil { diff --git a/internal/account/webhook/router.go b/internal/account/webhook/router.go index 845e78e..71d9494 100644 --- a/internal/account/webhook/router.go +++ b/internal/account/webhook/router.go @@ -128,6 +128,16 @@ type Store interface { // no-op (the account was not prepaid, is still delinquent, or has no mirror // row), never an error. It NEVER charges — relax and charge are decoupled. RelaxCollectionOnPaidInvoice(ctx context.Context, stripeInvoiceID string) (relaxed bool, err error) + + // MarkInvoiceFailed latches the sticky ever_failed flag (migration 039) on an + // invoice that failed a payment (invoice.payment_failed / marked_uncollectible), + // so ServiceBlockSignals' read-time streak derivation counts an invoice still + // 'open' after a failed charge. Set-only + invoice-keyed, so it is idempotent + // under Stripe's at-least-once + out-of-order delivery and a safe no-op when + // the mirror row has not landed. The failed-charge STREAK is DERIVED at read + // 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 } // ApplyInvoiceStatusParams carries the columns ApplyInvoiceStatus reconciles diff --git a/internal/account/webhook/router_test.go b/internal/account/webhook/router_test.go index 45d08f5..b38bd10 100644 --- a/internal/account/webhook/router_test.go +++ b/internal/account/webhook/router_test.go @@ -460,6 +460,83 @@ func TestProcess_InvoicePaymentFailed_StaysOpen(t *testing.T) { require.Equal(t, int64(0), s.AppliedInvoices[0].AmountPaidCents) } +// --- service-block failure latch (migration 039; streak derived at read time) --- + +func TestProcess_FailureEvents_LatchEverFailed(t *testing.T) { + // Both failure signals latch ever_failed via MarkInvoiceFailed — and BEFORE + // the found-guard, so an out-of-order marked_uncollectible (or a not-yet- + // mirrored invoice) still latches. No account write happens; the streak is + // derived at read time. + for _, ev := range []struct{ name, typ, status string }{ + {"payment_failed", "invoice.payment_failed", "open"}, + {"marked_uncollectible", "invoice.marked_uncollectible", "uncollectible"}, + } { + t.Run(ev.name, func(t *testing.T) { + v := &webhooktest.FakeVerifier{Event: invoiceEvent("evt_lat_"+ev.name, ev.typ, "in_1", ev.status, 0, 1200)} + s := webhooktest.NewFakeStore() + r := newRouter(v, s) + + res := r.Process(context.Background(), []byte(`{}`), "sig") + + require.Equal(t, 200, res.HTTPStatus) + require.Equal(t, webhook.StatusOK, res.Status) + require.Equal(t, []string{"in_1"}, s.FailedInvoices, "failure events must latch ever_failed") + }) + } +} + +func TestProcess_FailureLatch_RunsEvenOnDriftMirror(t *testing.T) { + // The latch is set BEFORE the found-guard, so a payment_failed for a not-yet- + // mirrored invoice still calls MarkInvoiceFailed (a store-side no-op) — this + // is what makes the read-time streak order-independent. The event still ACKs + // drift (the status reconcile found no row). + v := &webhooktest.FakeVerifier{Event: invoiceEvent("evt_lat_drift", "invoice.payment_failed", "in_orphan", "open", 0, 1200)} + s := webhooktest.NewFakeStore() + s.InvoiceFound = false + r := newRouter(v, s) + + res := r.Process(context.Background(), []byte(`{}`), "sig") + + require.Equal(t, webhook.StatusDriftWarning, res.Status) + require.Equal(t, []string{"in_orphan"}, s.FailedInvoices, "latch runs ahead of the found-guard") +} + +func TestProcess_NonFailureInvoiceEvent_DoesNotLatch(t *testing.T) { + // Only the two failure signals latch ever_failed. created/finalized/paid/void + // land the mirror but never mark the invoice failed. + for _, ev := range []struct{ name, typ, status string }{ + {"created", "invoice.created", "draft"}, + {"finalized", "invoice.finalized", "open"}, + {"paid", "invoice.paid", "paid"}, + {"voided", "invoice.voided", "void"}, + } { + t.Run(ev.name, func(t *testing.T) { + v := &webhooktest.FakeVerifier{Event: invoiceEvent("evt_nl_"+ev.name, ev.typ, "in_x", ev.status, 0, 1200)} + s := webhooktest.NewFakeStore() + r := newRouter(v, s) + + res := r.Process(context.Background(), []byte(`{}`), "sig") + + require.Equal(t, webhook.StatusOK, res.Status) + require.Empty(t, s.FailedInvoices, "only failure events latch ever_failed") + }) + } +} + +func TestProcess_FailureLatchError_Internal(t *testing.T) { + // A latch store error surfaces as 500 so Stripe retries; the set-only latch + // makes the retry a harmless no-op. + v := &webhooktest.FakeVerifier{Event: invoiceEvent("evt_lat_err", "invoice.payment_failed", "in_1", "open", 0, 1200)} + s := webhooktest.NewFakeStore() + s.ErrMarkFailed = errors.New("db down") + r := newRouter(v, s) + + 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 fc76cee..2fca8e0 100644 --- a/internal/account/webhook/store.go +++ b/internal/account/webhook/store.go @@ -295,6 +295,22 @@ func (s *pgxStore) RelaxCollectionOnPaidInvoice(ctx context.Context, stripeInvoi return rows > 0, nil } +// MarkInvoiceFailed latches the sticky ever_failed flag on an invoice that +// failed a payment (invoice.payment_failed / marked_uncollectible). It is a +// single set-only UPDATE — NO account counter, NO transaction: the failed-charge +// streak is DERIVED at read time by ServiceBlockSignals from ever_failed + +// status + created_at, so there is nothing to increment atomically and nothing +// a reordered/duplicate delivery can double-count. The rows-affected count is +// irrelevant (a repeat failure event for the same invoice is a harmless 0-row +// no-op), so the method returns only an error. A no-op when the mirror row has +// not landed yet (keyed on the invoice) — the currently-'uncollectible' case is +// counted by status regardless, and an 'open'-after-failure invoice is remarked +// by the next payment_failed once the row exists. +func (s *pgxStore) MarkInvoiceFailed(ctx context.Context, stripeInvoiceID string) error { + _, err := s.q.MarkInvoiceFailed(ctx, stripeInvoiceID) + return err +} + // 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 2f3813c..94fddb8 100644 --- a/internal/account/webhook/store_integration_test.go +++ b/internal/account/webhook/store_integration_test.go @@ -715,3 +715,128 @@ func readMode(t *testing.T, pool *pgxpool.Pool, accountID uuid.UUID) string { accountID.String()).Scan(&mode)) return mode } + +// --- service-block failure latch + read-time streak derivation (migration 039) --- + +// seedInvoiceAt inserts an invoice with an explicit created_at + ever_failed so +// the read-time streak derivation (ordered on created_at) can be exercised +// deterministically without relying on wall-clock insert order. +func seedInvoiceAt(t *testing.T, pool *pgxpool.Pool, accountID uuid.UUID, stripeInvoiceID, status string, everFailed bool, createdAt string) { + t.Helper() + _, err := pool.Exec(context.Background(), + `INSERT INTO ms_billing.invoices + (account_id, stripe_invoice_id, status, amount_due, currency, ever_failed, created_at) + VALUES ($1, $2, $3, 1200, 'usd', $4, $5::timestamptz)`, + accountID, stripeInvoiceID, status, everFailed, createdAt, + ) + require.NoError(t, err) +} + +func TestPgxStore_MarkInvoiceFailed_LatchesEverFailed(t *testing.T) { + // The set-only latch flips ever_failed and is an idempotent no-op on repeat / + // on an un-mirrored invoice. + pool := testutil.NewTestDB(t) + store := webhook.NewStore(pool) + accountID := seedAccount(t, pool, "cus_latch") + ctx := context.Background() + seedInvoice(t, pool, accountID, "in_A", "open", 0, 1200) + + require.NoError(t, store.MarkInvoiceFailed(ctx, "in_A")) + var everFailed bool + require.NoError(t, pool.QueryRow(ctx, + `SELECT ever_failed FROM ms_billing.invoices WHERE stripe_invoice_id = 'in_A'`).Scan(&everFailed)) + require.True(t, everFailed) + + require.NoError(t, store.MarkInvoiceFailed(ctx, "in_A"), "repeat is a no-op") + require.NoError(t, store.MarkInvoiceFailed(ctx, "in_orphan"), "un-mirrored invoice is a no-op") +} + +func TestPgxStore_ServiceBlockSignals_StreakDerivation_IsDeliveryOrderImmune(t *testing.T) { + // The failed-charge streak is DERIVED from (ever_failed OR uncollectible) + + // created_at relative to the most-recent paid invoice — so it depends only on + // the immutable facts, never on webhook delivery order. Timeline: A failed + // (t1), B PAID (t2), C failed (t3). Only C is after the last paid, so the + // streak is 1 regardless of the order these rows/latches landed. + pool := testutil.NewTestDB(t) + bstore := billing.NewStore(pool) + accountID := seedAccount(t, pool, "cus_deriv") + ctx := context.Background() + + seedInvoiceAt(t, pool, accountID, "in_A", "open", true, "2026-01-01T00:00:00Z") + seedInvoiceAt(t, pool, accountID, "in_B", "paid", false, "2026-02-01T00:00:00Z") + seedInvoiceAt(t, pool, accountID, "in_C", "open", true, "2026-03-01T00:00:00Z") + + sig, err := bstore.ServiceBlockSignals(ctx, accountID) + require.NoError(t, err) + require.Equal(t, 1, sig.FailedChargeStreak, "only failures after the last paid invoice count") + + // Two distinct failures after the last paid reach the block threshold. + seedInvoiceAt(t, pool, accountID, "in_D", "uncollectible", false, "2026-04-01T00:00:00Z") + sig, err = bstore.ServiceBlockSignals(ctx, accountID) + require.NoError(t, err) + require.Equal(t, 2, sig.FailedChargeStreak) + + // A newer paid invoice moves the cutoff forward and clears the streak (the + // auto-cure) — no counter to reset. + seedInvoiceAt(t, pool, accountID, "in_E", "paid", false, "2026-05-01T00:00:00Z") + sig, err = bstore.ServiceBlockSignals(ctx, accountID) + require.NoError(t, err) + require.Equal(t, 0, sig.FailedChargeStreak, "a later paid invoice cures the streak") +} + +func TestPgxStore_ServiceBlockSignals_ReflectsCardAndFirstCharge(t *testing.T) { + // The one-shot read reflects the usable non-fraud card count (fraud-blocked + // excluded) and the earliest real charge's status. + pool := testutil.NewTestDB(t) + bstore := billing.NewStore(pool) + accountID := seedAccount(t, pool, "cus_sig") + ctx := context.Background() + + sig, err := bstore.ServiceBlockSignals(ctx, accountID) + require.NoError(t, err) + require.Equal(t, 0, sig.UsableCardCount) + require.Equal(t, 0, sig.FailedChargeStreak) + require.Equal(t, "", sig.FirstChargeStatus) + + _, err = pool.Exec(ctx, + `INSERT INTO ms_billing.payment_methods_mirror + (account_id, stripe_payment_method_id, brand, last4, exp_month, exp_year) + VALUES ($1, 'pm_good', 'visa', '4242', 12, 2999)`, accountID) + require.NoError(t, err) + _, err = pool.Exec(ctx, + `INSERT INTO ms_billing.payment_methods_mirror + (account_id, stripe_payment_method_id, brand, last4, exp_month, exp_year, fraud_blocked) + VALUES ($1, 'pm_fraud', 'visa', '0002', 12, 2999, true)`, accountID) + require.NoError(t, err) + seedInvoice(t, pool, accountID, "in_first", "uncollectible", 0, 1200) + + sig, err = bstore.ServiceBlockSignals(ctx, accountID) + require.NoError(t, err) + require.Equal(t, 1, sig.UsableCardCount, "fraud-blocked card is excluded from the usable count") + require.Equal(t, "uncollectible", sig.FirstChargeStatus) +} + +func TestPgxStore_ApplyInvoiceStatus_UncollectibleToPaid_Cures(t *testing.T) { + // M1: paying an uncollectible invoice (customer settles on the hosted page) + // must land — paid outranks uncollectible — so the delinquency/block clears. + pool := testutil.NewTestDB(t) + store := webhook.NewStore(pool) + accountID := seedAccount(t, pool, "cus_cure") + seedInvoice(t, pool, accountID, "in_unc", "uncollectible", 0, 1200) + ctx := context.Background() + + found, err := store.ApplyInvoiceStatus(ctx, webhook.ApplyInvoiceStatusParams{ + StripeInvoiceID: "in_unc", Status: "paid", AmountPaidCents: 1200, AmountDueCents: 1200, + }) + require.NoError(t, err) + require.True(t, found, "uncollectible→paid must be accepted") + require.Equal(t, "paid", readInvoiceStatus(t, pool, "in_unc")) + + // And a stray late uncollectible after paid must NOT regress it. + found, err = store.ApplyInvoiceStatus(ctx, webhook.ApplyInvoiceStatusParams{ + StripeInvoiceID: "in_unc", Status: "uncollectible", AmountPaidCents: 0, AmountDueCents: 1200, + }) + require.NoError(t, err) + require.False(t, found, "a late uncollectible must not overwrite paid") + require.Equal(t, "paid", readInvoiceStatus(t, pool, "in_unc")) +} diff --git a/internal/account/webhook/webhooktest/fakes.go b/internal/account/webhook/webhooktest/fakes.go index 7d469f7..fcb2ce0 100644 --- a/internal/account/webhook/webhooktest/fakes.go +++ b/internal/account/webhook/webhooktest/fakes.go @@ -49,6 +49,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 // Found-flag knobs TouchedFound bool // returned by TouchAccountByStripeCustomer @@ -68,6 +69,7 @@ type FakeStore struct { ErrResolve error // from ResolvePendingAddCardRequest ErrApplyInvoice error // from ApplyInvoiceStatus ErrRelax error // from RelaxCollectionOnPaidInvoice + ErrMarkFailed error // from MarkInvoiceFailed ErrActivate error // from StampAccountActivated } @@ -170,6 +172,14 @@ func (s *FakeStore) RelaxCollectionOnPaidInvoice(_ context.Context, stripeInvoic return s.Relaxed, nil } +func (s *FakeStore) MarkInvoiceFailed(_ context.Context, stripeInvoiceID string) error { + if s.ErrMarkFailed != nil { + return s.ErrMarkFailed + } + s.FailedInvoices = append(s.FailedInvoices, stripeInvoiceID) + return 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/migrations/billing/038_payment_method_fraud.down.sql b/migrations/billing/038_payment_method_fraud.down.sql new file mode 100644 index 0000000..748916d --- /dev/null +++ b/migrations/billing/038_payment_method_fraud.down.sql @@ -0,0 +1,5 @@ +-- 038 down: drop the fraud-flag columns from the payment-method mirror. +ALTER TABLE ms_billing.payment_methods_mirror + DROP COLUMN IF EXISTS fraud_blocked, + DROP COLUMN IF EXISTS fraud_reason, + DROP COLUMN IF EXISTS fraud_flagged_at; diff --git a/migrations/billing/038_payment_method_fraud.up.sql b/migrations/billing/038_payment_method_fraud.up.sql new file mode 100644 index 0000000..0ba7ee2 --- /dev/null +++ b/migrations/billing/038_payment_method_fraud.up.sql @@ -0,0 +1,42 @@ +-- Migration 038 — fraud flag on the payment-method mirror (service-block gate). +-- +-- The service-block eligibility gate (internal/account/eligibility) requires +-- "at least one NON-FRAUD card on file". payment_methods_mirror (002/005) held +-- no risk state at all — only brand/last4/exp + is_default + deleted_at — so a +-- card that Stripe later flags (an early-fraud warning or a dispute) could not +-- be excluded from the usable-card count. These columns add that risk state. +-- +-- fraud_blocked is the authoritative "exclude this card" flag the gate reads; +-- fraud_reason / fraud_flagged_at are the audit trail (which signal, when). +-- All default to the non-fraud state, so EVERY existing row is treated as a +-- good card the moment this lands — the gate is fully functional on the card +-- count immediately. The webhook that SETS fraud_blocked=true +-- (radar.early_fraud_warning.created + charge.dispute.created) is a follow-up +-- PR: shipping the column now with no writer just means every card stays +-- non-fraud until that path lands, which is the safe interim (never a false +-- block). +-- +-- fraud_blocked is a ONE-WAY latch in practice (a flagged card is not +-- auto-cleared) — a disputed/fraudulent card is detached + re-added, not +-- un-flagged in place. No index: cards-per-account is tiny and the count query +-- already rides the pmm_account_active_idx (002) partial index on account_id. +-- +-- Born clean at slot 038 (next free after 037). sqlc picks up the columns from +-- migrations/billing/ automatically. + +ALTER TABLE ms_billing.payment_methods_mirror + ADD COLUMN fraud_blocked BOOLEAN NOT NULL DEFAULT false, + ADD COLUMN fraud_reason TEXT NULL, + ADD COLUMN fraud_flagged_at TIMESTAMPTZ NULL; + +COMMENT ON COLUMN ms_billing.payment_methods_mirror.fraud_blocked IS + 'True once Stripe flags this card as fraud/dispute risk (set by the ' + 'radar.early_fraud_warning / charge.dispute webhook — follow-up PR). The ' + 'service-block gate EXCLUDES fraud_blocked cards from the usable-card count. ' + 'Defaults false so every existing card is treated non-fraud.'; + +COMMENT ON COLUMN ms_billing.payment_methods_mirror.fraud_reason IS + 'Audit: the signal that set fraud_blocked (e.g. early_fraud_warning, dispute). NULL until flagged.'; + +COMMENT ON COLUMN ms_billing.payment_methods_mirror.fraud_flagged_at IS + 'Audit: when fraud_blocked was set. NULL until flagged.'; diff --git a/migrations/billing/039_invoice_ever_failed.down.sql b/migrations/billing/039_invoice_ever_failed.down.sql new file mode 100644 index 0000000..b6d9aff --- /dev/null +++ b/migrations/billing/039_invoice_ever_failed.down.sql @@ -0,0 +1,3 @@ +-- 039 down: drop the sticky per-invoice failure marker. +ALTER TABLE ms_billing.invoices + DROP COLUMN IF EXISTS ever_failed; diff --git a/migrations/billing/039_invoice_ever_failed.up.sql b/migrations/billing/039_invoice_ever_failed.up.sql new file mode 100644 index 0000000..0c21dbd --- /dev/null +++ b/migrations/billing/039_invoice_ever_failed.up.sql @@ -0,0 +1,33 @@ +-- Migration 039 — sticky per-invoice "ever failed" marker (service-block gate). +-- +-- The service-block gate needs to count an account's failed-charge STREAK. That +-- streak is DERIVED at read time (ServiceBlockSignals) as the number of distinct +-- FAILED invoices created after the account's most-recent PAID invoice — a +-- delivery-order-immune reading of the invoice mirror, not a mutable counter. +-- +-- ever_failed is the fact that read needs: an invoice can sit at status 'open' +-- AFTER a failed payment (Stripe keeps retrying), so status alone can't tell an +-- open-because-just-finalized invoice from an open-because-it-failed one. The +-- webhook latches ever_failed=true on a failure event (payment_failed / +-- marked_uncollectible); the derivation then counts (ever_failed OR currently +-- 'uncollectible') invoices. It is set-only and never cleared — a historical +-- "this invoice failed at least once" fact that survives a later flip to paid +-- (that invoice is excluded from the streak by the created_at cutoff, not by +-- clearing the flag). Distinct from the delinquency signal (AccountHasUnpaidInvoice, +-- CURRENT status only). +-- +-- Set-only + invoice-keyed makes the webhook write idempotent under Stripe's +-- at-least-once + out-of-order delivery — there is no counter to double-count. +-- Every historic row defaults false; only a NEW failure event sets it. Born +-- clean at slot 039 (next free after 038). sqlc picks up the column +-- automatically. updated_at is trigger-maintained (001). + +ALTER TABLE ms_billing.invoices + ADD COLUMN ever_failed BOOLEAN NOT NULL DEFAULT false; + +COMMENT ON COLUMN ms_billing.invoices.ever_failed IS + 'Sticky, set-only: true once this invoice failed a payment ' + '(payment_failed / marked_uncollectible), never cleared. Lets the ' + 'service-block gate DERIVE the failed-charge streak at read time — counting ' + '(ever_failed OR uncollectible) invoices created after the last paid one — ' + 'so it survives a later flip to paid and is immune to webhook delivery order.'; diff --git a/scripts/init-db.sql b/scripts/init-db.sql index f4ebd08..f4c5f2b 100644 --- a/scripts/init-db.sql +++ b/scripts/init-db.sql @@ -54,3 +54,9 @@ \i migrations/billing/035_billing_run_frozen_charge.up.sql \i migrations/billing/036_charge_attempt_markers.up.sql \i migrations/billing/037_apps_name.up.sql + +-- 038–039: service-block eligibility gate — card fraud flag + sticky per-invoice +-- failure marker (the failed-charge streak is DERIVED at read time from these +-- facts in ServiceBlockSignals, so it needs no stored column). +\i migrations/billing/038_payment_method_fraud.up.sql +\i migrations/billing/039_invoice_ever_failed.up.sql