Skip to content
Open
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
34 changes: 34 additions & 0 deletions cmd/account-api/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -223,6 +223,34 @@ func (d *dispatcher) dispatch(ctx context.Context, action string, requestPayload
}
return d.cycleSvc.SyncAppModules(ctx, req)

case "SetOrgDesignation":
var req cycle.SetOrgDesignationRequest
if err := json.Unmarshal(requestPayload, &req); err != nil {
return nil, billing.InvalidInput("malformed request payload: " + err.Error())
}
return d.cycleSvc.SetOrgDesignation(ctx, req)

case "GetOrgDesignation":
var req cycle.GetOrgDesignationRequest
if err := json.Unmarshal(requestPayload, &req); err != nil {
return nil, billing.InvalidInput("malformed request payload: " + err.Error())
}
return d.cycleSvc.GetOrgDesignation(ctx, req)

case "RevokeSponsorship":
var req cycle.RevokeSponsorshipRequest
if err := json.Unmarshal(requestPayload, &req); err != nil {
return nil, billing.InvalidInput("malformed request payload: " + err.Error())
}
return d.cycleSvc.RevokeSponsorship(ctx, req)

case "RepointOrgUsage":
var req cycle.RepointOrgUsageRequest
if err := json.Unmarshal(requestPayload, &req); err != nil {
return nil, billing.InvalidInput("malformed request payload: " + err.Error())
}
return d.cycleSvc.RepointOrgUsage(ctx, req)

case "SetBudget":
var req budget.SetBudgetRequest
if err := json.Unmarshal(requestPayload, &req); err != nil {
Expand Down Expand Up @@ -403,6 +431,12 @@ func buildRouter(d *dispatcher) *chi.Mux {
// route group, same as the other billing writes.
r.Post("/v1/billing.RegisterApp", makeHTTPHandler(d, "RegisterApp"))
r.Post("/v1/billing.SyncAppModules", makeHTTPHandler(d, "SyncAppModules"))

// Org funding designation + attach sweep (org-billing W0, migration 041).
r.Post("/v1/billing.SetOrgDesignation", makeHTTPHandler(d, "SetOrgDesignation"))
r.Post("/v1/billing.GetOrgDesignation", makeHTTPHandler(d, "GetOrgDesignation"))
r.Post("/v1/billing.RevokeSponsorship", makeHTTPHandler(d, "RevokeSponsorship"))
r.Post("/v1/billing.RepointOrgUsage", makeHTTPHandler(d, "RepointOrgUsage"))
// Platform-infra ingest (Plane 1). RecordInfraUsage is the INVERSE of
// the SDK meter seam: it is called by platform-trusted producers
// (dispatch compute, cdn-worker egress — deferred PRs), accepts ONLY
Expand Down
113 changes: 90 additions & 23 deletions internal/account/billing/service.go
Original file line number Diff line number Diff line change
Expand Up @@ -43,8 +43,8 @@ func NewService(store Store, stripe billingstripe.Client, returnURL string) *Ser
// UUID, Ensure simply returns Missing: ["billing_account"]. api-platform
// is responsible for resolving the user before invoking Ensure.
func (s *Service) Ensure(ctx context.Context, req EnsureRequest) (*EnsureResponse, error) {
if req.UserID == uuid.Nil {
return nil, InvalidInput("user_id required")
if err := validateOwner(req.UserID, req.OrgID); err != nil {
return nil, err
}

// Default + validate the Require list. Capability is a typed string,
Expand All @@ -63,8 +63,17 @@ func (s *Service) Ensure(ctx context.Context, req EnsureRequest) (*EnsureRespons

resp := &EnsureResponse{Missing: []string{}}

// Missing billing account short-circuits: per-capability checks need account_id.
accountID, found, err := s.store.AccountByUser(ctx, req.UserID)
// Missing billing account short-circuits: per-capability checks need
// account_id. The org leg resolves through the FUNDING gate (designation +
// activation) — an org that never designated has no billing capability.
var accountID uuid.UUID
var found bool
var err error
if req.OrgID != uuid.Nil {
accountID, found, err = s.store.ResolveOrgFundedAccount(ctx, req.OrgID)
} else {
accountID, found, err = s.store.AccountByUser(ctx, req.UserID)
}
if err != nil {
return nil, Internal("account lookup failed", err)
}
Expand All @@ -76,7 +85,18 @@ func (s *Service) Ensure(ctx context.Context, req EnsureRequest) (*EnsureRespons
// Per-capability checks. Order is fixed (PM before subscription) so
// the Missing slice is deterministic regardless of Require ordering.
if slices.Contains(require, RequirePaymentMethod) {
hasPM, err := s.store.HasUsablePaymentMethod(ctx, accountID)
// The payment_method capability checks the FUNDING account (org-billing
// D1): a sponsor-funded org account owns no PM rows — the sponsor's
// does. User principals fund themselves, so they skip the hop (the SQL
// is provably identity for a non-org account).
fundingID := accountID
if req.OrgID != uuid.Nil {
fundingID, err = s.store.ChargeFundingAccount(ctx, accountID)
if err != nil {
return nil, Internal("funding account lookup failed", err)
}
}
hasPM, err := s.store.HasUsablePaymentMethod(ctx, fundingID)
if err != nil {
return nil, Internal("payment-method lookup failed", err)
}
Expand Down Expand Up @@ -117,11 +137,11 @@ func (s *Service) Ensure(ctx context.Context, req EnsureRequest) (*EnsureRespons
// via Stripe metadata reconciliation (metadata.billing_account_id is
// stable); not addressed in the v1 handler.
func (s *Service) PrepareAddPaymentMethod(ctx context.Context, req PrepareAddPaymentMethodRequest) (*PrepareAddPaymentMethodResponse, error) {
if req.UserID == uuid.Nil {
return nil, InvalidInput("user_id required")
if err := validateOwner(req.UserID, req.OrgID); err != nil {
return nil, err
}

accountID, stripeCustomerID, err := s.store.EnsureAccount(ctx, req.UserID)
accountID, stripeCustomerID, err := s.ensureOwnerAccount(ctx, req.UserID, req.OrgID)
if err != nil {
return nil, Internal("ensure account failed", err)
}
Expand Down Expand Up @@ -180,11 +200,11 @@ func (s *Service) PrepareAddPaymentMethod(ctx context.Context, req PrepareAddPay
// pending until the 24h TTL purge picks it up) but a retry would
// create a fresh request, which is fine.
func (s *Service) StartAddPaymentMethod(ctx context.Context, req StartAddPaymentMethodRequest) (*StartAddPaymentMethodResponse, error) {
if req.UserID == uuid.Nil {
return nil, InvalidInput("user_id required")
if err := validateOwner(req.UserID, req.OrgID); err != nil {
return nil, err
}

accountID, stripeCustomerID, err := s.store.EnsureAccount(ctx, req.UserID)
accountID, stripeCustomerID, err := s.ensureOwnerAccount(ctx, req.UserID, req.OrgID)
if err != nil {
return nil, Internal("ensure account failed", err)
}
Expand Down Expand Up @@ -246,14 +266,14 @@ func (s *Service) StartAddPaymentMethod(ctx context.Context, req StartAddPayment
// that doesn't belong to the caller (or doesn't exist) returns 404
// rather than leaking existence to a different user.
func (s *Service) FinishAddPaymentMethod(ctx context.Context, req FinishAddPaymentMethodRequest) (*FinishAddPaymentMethodResponse, error) {
if req.UserID == uuid.Nil {
return nil, InvalidInput("user_id required")
if err := validateOwner(req.UserID, req.OrgID); err != nil {
return nil, err
}
if req.RequestID == uuid.Nil {
return nil, InvalidInput("request_id required")
}

accountID, found, err := s.store.AccountByUser(ctx, req.UserID)
accountID, found, err := s.ownerAccount(ctx, req.UserID, req.OrgID)
if err != nil {
return nil, Internal("account lookup failed", err)
}
Expand All @@ -279,11 +299,11 @@ func (s *Service) FinishAddPaymentMethod(ctx context.Context, req FinishAddPayme
// Returns an empty slice (not nil, not an error) when the user has
// no accounts row or no methods attached.
func (s *Service) GetPaymentMethods(ctx context.Context, req GetPaymentMethodsRequest) (*GetPaymentMethodsResponse, error) {
if req.UserID == uuid.Nil {
return nil, InvalidInput("user_id required")
if err := validateOwner(req.UserID, req.OrgID); err != nil {
return nil, err
}

accountID, found, err := s.store.AccountByUser(ctx, req.UserID)
accountID, found, err := s.ownerAccount(ctx, req.UserID, req.OrgID)
if err != nil {
return nil, Internal("account lookup failed", err)
}
Expand All @@ -310,10 +330,13 @@ func (s *Service) GetPaymentMethods(ctx context.Context, req GetPaymentMethodsRe
// authoritative against direct API callers too. To remove the current
// default, set another card as default first.
func (s *Service) DetachPaymentMethod(ctx context.Context, req DetachPaymentMethodRequest) (*DetachPaymentMethodResponse, error) {
if req.UserID == uuid.Nil || req.PaymentMethodID == uuid.Nil {
return nil, InvalidInput("user_id and payment_method_id required")
if err := validateOwner(req.UserID, req.OrgID); err != nil {
return nil, err
}
stripePMID, _, isDefault, found, err := s.store.PaymentMethodTarget(ctx, req.UserID, req.PaymentMethodID)
if req.PaymentMethodID == uuid.Nil {
return nil, InvalidInput("payment_method_id required")
}
stripePMID, _, isDefault, found, err := s.paymentMethodTarget(ctx, req.UserID, req.OrgID, req.PaymentMethodID)
if err != nil {
return nil, Internal("payment method lookup failed", err)
}
Expand All @@ -333,10 +356,13 @@ func (s *Service) DetachPaymentMethod(ctx context.Context, req DetachPaymentMeth
// invoice-settings default at the given card. Ownership-checked as above;
// is_default is synced asynchronously by the customer.updated webhook.
func (s *Service) SetDefaultPaymentMethod(ctx context.Context, req SetDefaultPaymentMethodRequest) (*SetDefaultPaymentMethodResponse, error) {
if req.UserID == uuid.Nil || req.PaymentMethodID == uuid.Nil {
return nil, InvalidInput("user_id and payment_method_id required")
if err := validateOwner(req.UserID, req.OrgID); err != nil {
return nil, err
}
if req.PaymentMethodID == uuid.Nil {
return nil, InvalidInput("payment_method_id required")
}
stripePMID, stripeCustomerID, _, found, err := s.store.PaymentMethodTarget(ctx, req.UserID, req.PaymentMethodID)
stripePMID, stripeCustomerID, _, found, err := s.paymentMethodTarget(ctx, req.UserID, req.OrgID, req.PaymentMethodID)
if err != nil {
return nil, Internal("payment method lookup failed", err)
}
Expand All @@ -348,3 +374,44 @@ func (s *Service) SetDefaultPaymentMethod(ctx context.Context, req SetDefaultPay
}
return &SetDefaultPaymentMethodResponse{}, nil
}

// validateOwner enforces the exactly-one owner-principal contract shared by
// Ensure and every payment-method RPC: a user XOR an org.
func validateOwner(userID, orgID uuid.UUID) error {
if userID == uuid.Nil && orgID == uuid.Nil {
return InvalidInput("user_id or org_id required")
}
if userID != uuid.Nil && orgID != uuid.Nil {
return InvalidInput("user_id and org_id are mutually exclusive")
}
return nil
}

// ownerAccount resolves the EXISTING account for a (user XOR org) principal —
// the read-path twin of ensureOwnerAccount. The org leg resolves by row
// EXISTENCE (AccountByOrg), not by funding: cards are manageable while a
// funding='org' designation awaits its first bind.
func (s *Service) ownerAccount(ctx context.Context, userID, orgID uuid.UUID) (uuid.UUID, bool, error) {
if orgID != uuid.Nil {
return s.store.AccountByOrg(ctx, orgID)
}
return s.store.AccountByUser(ctx, userID)
}

// ensureOwnerAccount get-or-creates the account for a (user XOR org)
// principal — the write-path resolution the add-card flows use.
func (s *Service) ensureOwnerAccount(ctx context.Context, userID, orgID uuid.UUID) (uuid.UUID, string, error) {
if orgID != uuid.Nil {
return s.store.EnsureOrgAccount(ctx, orgID)
}
return s.store.EnsureAccount(ctx, userID)
}

// paymentMethodTarget dispatches the detach / set-default ownership gate to
// the user or org twin.
func (s *Service) paymentMethodTarget(ctx context.Context, userID, orgID, paymentMethodID uuid.UUID) (stripePMID, stripeCustomerID string, isDefault, found bool, err error) {
if orgID != uuid.Nil {
return s.store.PaymentMethodTargetForOrg(ctx, orgID, paymentMethodID)
}
return s.store.PaymentMethodTarget(ctx, userID, paymentMethodID)
}
Loading
Loading