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
8 changes: 8 additions & 0 deletions cmd/account-api/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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
Expand Down
73 changes: 73 additions & 0 deletions internal/account/billing/service.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"
)

Expand Down Expand Up @@ -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
Expand Down
107 changes: 107 additions & 0 deletions internal/account/billing/service_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -32,6 +33,7 @@ type fakeStore struct {
errHasUsablePaymentMx error
errHasUnpaidInvoice error
errListPaymentMethods error
errServiceSignals error
errPaymentMethodTarget error
errInsertAddCardRequest error
errSetSetupIntent error
Expand Down Expand Up @@ -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{},
}
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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()
Expand Down
19 changes: 19 additions & 0 deletions internal/account/billing/store.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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 {
Expand Down
26 changes: 26 additions & 0 deletions internal/account/billing/types.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
Loading
Loading