From 334eec7bd9fb067ebf251e97e73e73de552dae6d Mon Sep 17 00:00:00 2001 From: Sheng Kun Chang Date: Mon, 6 Jul 2026 16:26:54 +0800 Subject: [PATCH 1/2] feat: org billing accounts + funding designation (migration 041, W0 substrate) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Org-billing W0 (docs-temp/org-billing/design.md D1) — retires the D6 user-only rule: - One org = one ms_billing.accounts row (owner_kind='org'), created by EnsureOrgAccount (advisory-locked get-or-create, org namespace 'lbto') lazily at the org's first funding designation. - The designation picks only the FUNDING INSTRUMENT: sponsor mode finalizes invoices against the sponsor's Stripe customer/default PM (ChargeFundingAccount hop in resolveChargeableCustomer, resolved at charge time); attribution never moves. Sponsor = the acting user's own account, validated server-side; only the current sponsor may self-revoke. - Resolution (AccountByOwner / Ensure / RegisterApp) is designation- + activation-gated; an undesignated org registers UNBILLED roster rows (apps.account_id NULL + owner_org_id stamp) and lazy NULL-account events. - RepointOrgUsage attach sweep: backfills roster account_id, folds NULL-account events into the account's first open window (recorded_at clamp + repointed_from audit — the rollup windows by recorded_at, so older events would otherwise never bill), synthesizes fresh overage timers anchored at designation (prospective billing). - PM RPC family (Ensure/Prepare/Start/Finish/Get/Detach/SetDefault) widened to user-XOR-org owners; Ensure's payment_method capability checks the funding account. - Migration slot 041: 038-040 are claimed by the open service-block PR #50. Co-Authored-By: Claude Fable 5 --- cmd/account-api/main.go | 34 ++ internal/account/billing/service.go | 111 +++-- internal/account/billing/service_test.go | 186 ++++++++- internal/account/billing/store.go | 124 ++++++ internal/account/billing/types.go | 33 +- internal/account/cycle/apps.go | 40 +- internal/account/cycle/apps_test.go | 47 ++- internal/account/cycle/charge_test.go | 53 +++ .../cycle/migration027_integration_test.go | 16 +- .../cycle/migration028_integration_test.go | 2 +- .../cycle/migration029_integration_test.go | 16 +- .../cycle/migration030_integration_test.go | 6 +- .../cycle/migration031_integration_test.go | 4 +- .../cycle/migration033_integration_test.go | 8 +- internal/account/cycle/org.go | 380 ++++++++++++++++++ internal/account/cycle/org_test.go | 337 ++++++++++++++++ internal/account/cycle/proration.go | 9 + internal/account/cycle/service.go | 15 +- internal/account/cycle/service_test.go | 161 +++++++- internal/account/cycle/store.go | 305 +++++++++++++- .../store_prorationlock_integration_test.go | 6 +- internal/account/db/apps.sql.go | 21 +- internal/account/db/models.go | 17 +- internal/account/db/org.sql.go | 357 ++++++++++++++++ internal/account/db/queries/apps.sql | 13 +- internal/account/db/queries/org.sql | 153 +++++++ internal/account/usage/store.go | 21 +- .../041_org_billing_designations.down.sql | 27 ++ .../041_org_billing_designations.up.sql | 89 ++++ 29 files changed, 2479 insertions(+), 112 deletions(-) create mode 100644 internal/account/cycle/org.go create mode 100644 internal/account/cycle/org_test.go create mode 100644 internal/account/db/org.sql.go create mode 100644 internal/account/db/queries/org.sql create mode 100644 migrations/billing/041_org_billing_designations.down.sql create mode 100644 migrations/billing/041_org_billing_designations.up.sql diff --git a/cmd/account-api/main.go b/cmd/account-api/main.go index 777b664..749875e 100644 --- a/cmd/account-api/main.go +++ b/cmd/account-api/main.go @@ -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 { @@ -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 diff --git a/internal/account/billing/service.go b/internal/account/billing/service.go index a6b111a..89c6926 100644 --- a/internal/account/billing/service.go +++ b/internal/account/billing/service.go @@ -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, @@ -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.AccountByOrgFunded(ctx, req.OrgID) + } else { + accountID, found, err = s.store.AccountByUser(ctx, req.UserID) + } if err != nil { return nil, Internal("account lookup failed", err) } @@ -73,10 +82,19 @@ func (s *Service) Ensure(ctx context.Context, req EnsureRequest) (*EnsureRespons return resp, nil } + // The payment_method capability checks the FUNDING account (org-billing + // D1): a sponsor-funded org account owns no PM rows — the sponsor's does. + // A self-funded account (every user account, org-funded orgs) maps to + // itself, so the hop is uniform. + fundingID, err := s.store.ChargeFundingAccount(ctx, accountID) + if err != nil { + return nil, Internal("funding account lookup failed", err) + } + // 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) + hasPM, err := s.store.HasUsablePaymentMethod(ctx, fundingID) if err != nil { return nil, Internal("payment-method lookup failed", err) } @@ -117,11 +135,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) } @@ -180,11 +198,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) } @@ -246,14 +264,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) } @@ -279,11 +297,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) } @@ -310,10 +328,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 + } + if req.PaymentMethodID == uuid.Nil { + return nil, InvalidInput("payment_method_id required") } - stripePMID, _, isDefault, found, err := s.store.PaymentMethodTarget(ctx, req.UserID, req.PaymentMethodID) + stripePMID, _, isDefault, found, err := s.paymentMethodTarget(ctx, req.UserID, req.OrgID, req.PaymentMethodID) if err != nil { return nil, Internal("payment method lookup failed", err) } @@ -333,10 +354,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 } - stripePMID, stripeCustomerID, _, found, err := s.store.PaymentMethodTarget(ctx, req.UserID, req.PaymentMethodID) + if req.PaymentMethodID == uuid.Nil { + return nil, InvalidInput("payment_method_id required") + } + stripePMID, stripeCustomerID, _, found, err := s.paymentMethodTarget(ctx, req.UserID, req.OrgID, req.PaymentMethodID) if err != nil { return nil, Internal("payment method lookup failed", err) } @@ -348,3 +372,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) +} diff --git a/internal/account/billing/service_test.go b/internal/account/billing/service_test.go index cf38952..9791c84 100644 --- a/internal/account/billing/service_test.go +++ b/internal/account/billing/service_test.go @@ -25,6 +25,16 @@ type fakeStore struct { // PM-target lookups for detach / set-default, keyed by payment method id. pmTargets map[uuid.UUID]pmTarget + // org-billing state (migration 041). accountsByOrg models the org account + // rows (existence — AccountByOrg / EnsureOrgAccount); fundedOrgs the + // designation+activation gate AccountByOrgFunded reads; fundingOf the + // sponsor funding hop (absent → identity); orgPMTargets the org twin of + // pmTargets (PaymentMethodTargetForOrg). + accountsByOrg map[uuid.UUID]fakeAccount + fundedOrgs map[uuid.UUID]bool + fundingOf map[uuid.UUID]uuid.UUID + orgPMTargets map[uuid.UUID]pmTarget + // Injected failures (set per-test as needed). errEnsureAccount error errSetStripeCustomer error @@ -64,6 +74,10 @@ func newFakeStore() *fakeStore { paymentMethodsBy: map[uuid.UUID][]billing.PaymentMethod{}, pmTargets: map[uuid.UUID]pmTarget{}, addCardRequests: map[uuid.UUID]*fakeAddCardRequest{}, + accountsByOrg: map[uuid.UUID]fakeAccount{}, + fundedOrgs: map[uuid.UUID]bool{}, + fundingOf: map[uuid.UUID]uuid.UUID{}, + orgPMTargets: map[uuid.UUID]pmTarget{}, } } @@ -122,7 +136,10 @@ func (s *fakeStore) ListPaymentMethods(_ context.Context, accountID uuid.UUID) ( if s.errListPaymentMethods != nil { return nil, s.errListPaymentMethods } - return s.paymentMethodsBy[accountID], nil + if pms, ok := s.paymentMethodsBy[accountID]; ok { + return pms, nil + } + return []billing.PaymentMethod{}, nil // Store contract: empty slice, never nil } func (s *fakeStore) PaymentMethodTarget(_ context.Context, _ uuid.UUID, paymentMethodID uuid.UUID) (string, string, bool, bool, error) { @@ -169,6 +186,52 @@ func (s *fakeStore) GetAddCardRequest(_ context.Context, requestID, accountID uu return &billing.AddCardRequestStatus{Status: r.status, PaymentMethod: r.paymentMethod}, nil } +func (s *fakeStore) EnsureOrgAccount(_ context.Context, orgID uuid.UUID) (uuid.UUID, string, error) { + if a, ok := s.accountsByOrg[orgID]; ok { + return a.id, a.stripeCustomerID, nil + } + a := fakeAccount{id: uuid.New()} + s.accountsByOrg[orgID] = a + return a.id, "", nil +} + +func (s *fakeStore) AccountByOrg(_ context.Context, orgID uuid.UUID) (uuid.UUID, bool, error) { + a, ok := s.accountsByOrg[orgID] + if !ok { + return uuid.Nil, false, nil + } + return a.id, true, nil +} + +func (s *fakeStore) AccountByOrgFunded(_ context.Context, orgID uuid.UUID) (uuid.UUID, bool, error) { + // Row EXISTENCE is not enough — the funded gate (designation + activation) + // is modeled as an explicit flag. + a, ok := s.accountsByOrg[orgID] + if !ok || !s.fundedOrgs[orgID] { + return uuid.Nil, false, nil + } + return a.id, true, nil +} + +func (s *fakeStore) ChargeFundingAccount(_ context.Context, accountID uuid.UUID) (uuid.UUID, error) { + // Identity unless a sponsor funding mapping is configured (org D1 hop). + if funding, ok := s.fundingOf[accountID]; ok { + return funding, nil + } + return accountID, nil +} + +func (s *fakeStore) PaymentMethodTargetForOrg(_ context.Context, _, paymentMethodID uuid.UUID) (string, string, bool, bool, error) { + if s.errPaymentMethodTarget != nil { + return "", "", false, false, s.errPaymentMethodTarget + } + t, ok := s.orgPMTargets[paymentMethodID] + if !ok { + return "", "", false, false, nil + } + return t.stripePMID, t.stripeCustomerID, t.isDefault, true, nil +} + // --- in-memory Stripe Client fake ---------------------------------------- type fakeStripe struct { @@ -491,6 +554,74 @@ func TestEnsure_UnknownRequire_InvalidInput(t *testing.T) { require.Equal(t, billing.CodeInvalidInput, be.Code) } +func TestEnsure_UnfundedOrg_MissingBillingAccount(t *testing.T) { + // The org leg resolves through the FUNDING gate (designation + activation), + // not row existence: an org whose account row exists but never funded still + // has no billing capability. + store := newFakeStore() + org := uuid.New() + store.accountsByOrg[org] = fakeAccount{id: uuid.New()} // row exists, NOT funded + svc := billing.NewService(store, &fakeStripe{}, "") + + resp, err := svc.Ensure(context.Background(), billing.EnsureRequest{OrgID: org}) + + require.NoError(t, err) + require.Equal(t, []string{billing.MissingBillingAccount}, resp.Missing) +} + +func TestEnsure_SponsorFundedOrg_PMSatisfiedThroughFundingHop(t *testing.T) { + // A sponsor-funded org account owns NO PM rows itself — the payment_method + // capability checks the FUNDING account (org-billing D1 hop). + store := newFakeStore() + org := uuid.New() + orgAcct, sponsorAcct := uuid.New(), uuid.New() + store.accountsByOrg[org] = fakeAccount{id: orgAcct} + store.fundedOrgs[org] = true + store.fundingOf[orgAcct] = sponsorAcct + store.hasUsablePM[sponsorAcct] = true // hasUsablePM[orgAcct] stays false + svc := billing.NewService(store, &fakeStripe{}, "") + + resp, err := svc.Ensure(context.Background(), billing.EnsureRequest{OrgID: org}) + + require.NoError(t, err) + require.True(t, resp.Ready()) + require.Empty(t, resp.Missing) +} + +func TestEnsure_UserAndOrgBothSet_InvalidInput(t *testing.T) { + svc := billing.NewService(newFakeStore(), &fakeStripe{}, "") + + _, err := svc.Ensure(context.Background(), billing.EnsureRequest{UserID: uuid.New(), OrgID: uuid.New()}) + + var be *billing.Error + require.ErrorAs(t, err, &be) + require.Equal(t, billing.CodeInvalidInput, be.Code) +} + +func TestStartAddPaymentMethod_OwnerValidation(t *testing.T) { + // The exactly-one owner-principal contract (user XOR org) gates every + // payment-method RPC, not just Ensure. + store := newFakeStore() + stripeFake := &fakeStripe{} + svc := billing.NewService(store, stripeFake, "") + for _, tc := range []struct { + name string + req billing.StartAddPaymentMethodRequest + }{ + {"neither owner", billing.StartAddPaymentMethodRequest{}}, + {"both owners", billing.StartAddPaymentMethodRequest{UserID: uuid.New(), OrgID: uuid.New()}}, + } { + t.Run(tc.name, func(t *testing.T) { + _, err := svc.StartAddPaymentMethod(context.Background(), tc.req) + var be *billing.Error + require.ErrorAs(t, err, &be) + require.Equal(t, billing.CodeInvalidInput, be.Code) + require.Empty(t, stripeFake.createdCustomers, "rejected up-front, before any Stripe call") + require.Empty(t, store.addCardRequests) + }) + } +} + func TestPrepareAddPaymentMethod_FirstTime_CreatesCustomerAndSetupIntent(t *testing.T) { store := newFakeStore() stripeFake := &fakeStripe{} @@ -603,6 +734,22 @@ func TestGetPaymentMethods_NoAccount_EmptySlice(t *testing.T) { require.Empty(t, resp.PaymentMethods) } +func TestGetPaymentMethods_OrgAccountNoCards_EmptySlice(t *testing.T) { + // The org read resolves by row EXISTENCE (cards are manageable while a + // funding='org' designation awaits its first bind); a card-less org + // account lists empty, exactly like the user leg. + store := newFakeStore() + org := uuid.New() + store.accountsByOrg[org] = fakeAccount{id: uuid.New(), stripeCustomerID: "cus_org"} + svc := billing.NewService(store, &fakeStripe{}, "") + + resp, err := svc.GetPaymentMethods(context.Background(), billing.GetPaymentMethodsRequest{OrgID: org}) + + require.NoError(t, err) + require.NotNil(t, resp.PaymentMethods) + require.Empty(t, resp.PaymentMethods) +} + func TestGetPaymentMethods_HasMethods_ReturnsAll(t *testing.T) { store := newFakeStore() userID := uuid.New() @@ -702,3 +849,40 @@ func TestSetDefaultPaymentMethod_NotOwned_ReturnsNotFound(t *testing.T) { require.ErrorAs(t, err, &be) require.Equal(t, billing.CodeNotFound, be.Code) } + +func TestDetachPaymentMethod_OrgOwnedPM_ResolvesThroughOrgTarget(t *testing.T) { + // An org-scoped detach dispatches to PaymentMethodTargetForOrg: the PM must + // belong to the ORG account — a user-owned PM is invisible through the org + // gate (404, not a cross-owner detach). + store := newFakeStore() + orgPM, userPM := uuid.New(), uuid.New() + store.orgPMTargets[orgPM] = pmTarget{stripePMID: "pm_org", stripeCustomerID: "cus_org"} + store.pmTargets[userPM] = pmTarget{stripePMID: "pm_user", stripeCustomerID: "cus_u"} + stripeFake := &fakeStripe{} + svc := billing.NewService(store, stripeFake, "") + + _, err := svc.DetachPaymentMethod(context.Background(), + billing.DetachPaymentMethodRequest{OrgID: uuid.New(), PaymentMethodID: orgPM}) + require.NoError(t, err) + require.Equal(t, []string{"pm_org"}, stripeFake.detached) + + _, err = svc.DetachPaymentMethod(context.Background(), + billing.DetachPaymentMethodRequest{OrgID: uuid.New(), PaymentMethodID: userPM}) + var be *billing.Error + require.ErrorAs(t, err, &be) + require.Equal(t, billing.CodeNotFound, be.Code) +} + +func TestSetDefaultPaymentMethod_OrgOwnedPM_SetsOrgCustomerDefault(t *testing.T) { + store := newFakeStore() + pmID := uuid.New() + store.orgPMTargets[pmID] = pmTarget{stripePMID: "pm_org", stripeCustomerID: "cus_org"} + stripeFake := &fakeStripe{} + svc := billing.NewService(store, stripeFake, "") + + _, err := svc.SetDefaultPaymentMethod(context.Background(), + billing.SetDefaultPaymentMethodRequest{OrgID: uuid.New(), PaymentMethodID: pmID}) + + require.NoError(t, err) + require.Equal(t, []string{"cus_org=pm_org"}, stripeFake.defaultsSet) +} diff --git a/internal/account/billing/store.go b/internal/account/billing/store.go index 9533564..9e12d9e 100644 --- a/internal/account/billing/store.go +++ b/internal/account/billing/store.go @@ -77,6 +77,33 @@ type Store interface { // PaymentMethod is populated via JOIN. Returns (nil, nil) when no // row matches both id + account_id (treated as 404 by the service). GetAddCardRequest(ctx context.Context, requestID, accountID uuid.UUID) (*AddCardRequestStatus, error) + + // EnsureOrgAccount is EnsureAccount's org twin (migration 041): the same + // advisory-locked get-or-create on the org namespace ('lbto'). Created + // regardless of designation state so a funding='org' card can bind before + // the designation completes. + EnsureOrgAccount(ctx context.Context, orgID uuid.UUID) (accountID uuid.UUID, stripeCustomerID string, err error) + + // AccountByOrg resolves the org's account row by EXISTENCE (not + // funded-gated) — cards are manageable while a funding='org' designation + // awaits its first bind. + AccountByOrg(ctx context.Context, orgID uuid.UUID) (uuid.UUID, bool, error) + + // AccountByOrgFunded resolves the org's account through the designation + + // activation gate — Ensure's org resolution ("the pointer never flips to + // an unfunded account", design D1). + AccountByOrgFunded(ctx context.Context, orgID uuid.UUID) (uuid.UUID, bool, error) + + // ChargeFundingAccount maps an account to the account whose Stripe + // customer / default PM pays its invoices — itself, unless it is an org + // account with a sponsor designation. Ensure's payment_method capability + // checks the FUNDING account: a sponsor-funded org account owns no PM + // rows itself. + ChargeFundingAccount(ctx context.Context, accountID uuid.UUID) (uuid.UUID, error) + + // PaymentMethodTargetForOrg is PaymentMethodTarget's org twin: the PM must + // belong to the ORG account (detach / set-default ownership gate). + PaymentMethodTargetForOrg(ctx context.Context, orgID, paymentMethodID uuid.UUID) (stripePMID, stripeCustomerID string, isDefault, found bool, err error) } // AddCardRequestStatus is the projection of an add_card_requests row @@ -114,6 +141,13 @@ type pgxStore struct { // constraint; this lock IS the uniqueness guard. const AdvisoryLockNamespaceBillingAccountUser int32 = 0x6c627461 // "lbta" — billing_account, easy to grep +// AdvisoryLockNamespaceBillingAccountOrg is the org twin of the user +// namespace above: every writer that get-or-creates an ORG-owned accounts row +// (migration 041 — cycle's EnsureOrgAccount, this package's org add-card leg) +// serializes on this (namespace, hashtext(org_id)) pair for the same reason — +// the advisory lock IS the uniqueness guard. +const AdvisoryLockNamespaceBillingAccountOrg int32 = 0x6c62746f // "lbto" + func (s *pgxStore) EnsureAccount(ctx context.Context, userID uuid.UUID) (uuid.UUID, string, error) { var id string var stripeCustomerID string @@ -310,6 +344,96 @@ func (s *pgxStore) GetAddCardRequest(ctx context.Context, requestID, accountID u return out, nil } +// EnsureOrgAccount mirrors EnsureAccount on the org leg — same +// transaction + advisory-lock shape, org namespace, org queries. +func (s *pgxStore) EnsureOrgAccount(ctx context.Context, orgID uuid.UUID) (uuid.UUID, string, error) { + var id string + var stripeCustomerID string + err := pgx.BeginFunc(ctx, s.pool, func(tx pgx.Tx) error { + qtx := s.q.WithTx(tx) + if err := qtx.AcquireBillingAccountUserLock(ctx, db.AcquireBillingAccountUserLockParams{ + Column1: AdvisoryLockNamespaceBillingAccountOrg, + Column2: orgID.String(), + }); err != nil { + return err + } + existing, err := qtx.SelectAccountByOrg(ctx, nullableUUID(orgID)) + if err == nil { + id, stripeCustomerID = existing.ID, existing.StripeCustomerID + return nil + } + if !errors.Is(err, pgx.ErrNoRows) { + return err + } + inserted, err := qtx.InsertOrgAccount(ctx, nullableUUID(orgID)) + if err != nil { + return err + } + id, stripeCustomerID = inserted.ID, inserted.StripeCustomerID + return nil + }) + if err != nil { + return uuid.Nil, "", err + } + parsed, err := uuid.Parse(id) + if err != nil { + return uuid.Nil, "", err + } + return parsed, stripeCustomerID, nil +} + +func (s *pgxStore) AccountByOrg(ctx context.Context, orgID uuid.UUID) (uuid.UUID, bool, error) { + row, err := s.q.SelectAccountByOrg(ctx, nullableUUID(orgID)) + if errors.Is(err, pgx.ErrNoRows) { + return uuid.Nil, false, nil + } + if err != nil { + return uuid.Nil, false, err + } + parsed, err := uuid.Parse(row.ID) + if err != nil { + return uuid.Nil, false, err + } + return parsed, true, nil +} + +func (s *pgxStore) AccountByOrgFunded(ctx context.Context, orgID uuid.UUID) (uuid.UUID, bool, error) { + id, err := s.q.ResolveOrgFundedAccount(ctx, orgID.String()) + if errors.Is(err, pgx.ErrNoRows) { + return uuid.Nil, false, nil // no designation / not activated — unbilled + } + if err != nil { + return uuid.Nil, false, err + } + parsed, err := uuid.Parse(id) + if err != nil { + return uuid.Nil, false, err + } + return parsed, true, nil +} + +func (s *pgxStore) ChargeFundingAccount(ctx context.Context, accountID uuid.UUID) (uuid.UUID, error) { + id, err := s.q.ChargeFundingAccount(ctx, accountID.String()) + if err != nil { + return uuid.Nil, err // incl. ErrNoRows: the account was just resolved — a missing row is a code bug + } + return uuid.Parse(id) +} + +func (s *pgxStore) PaymentMethodTargetForOrg(ctx context.Context, orgID, paymentMethodID uuid.UUID) (string, string, bool, bool, error) { + row, err := s.q.PaymentMethodTargetForOrg(ctx, db.PaymentMethodTargetForOrgParams{ + OwnerOrgID: nullableUUID(orgID), + ID: paymentMethodID.String(), + }) + if errors.Is(err, pgx.ErrNoRows) { + return "", "", false, false, nil + } + if err != nil { + return "", "", false, false, err + } + return row.StripePaymentMethodID, row.StripeCustomerID, row.IsDefault, true, nil +} + // nullableUUID converts a google/uuid.UUID into the pgtype.UUID the // generated queries expect for the nullable owner_user_id column. A // non-Nil UUID is always marked Valid; callers never pass Nil here diff --git a/internal/account/billing/types.go b/internal/account/billing/types.go index 78c755d..3e980ab 100644 --- a/internal/account/billing/types.go +++ b/internal/account/billing/types.go @@ -32,7 +32,11 @@ const ( // Each requested capability is checked independently; the union of // unmet ones appears in EnsureResponse.Missing. type EnsureRequest struct { - UserID uuid.UUID `json:"user_id"` + // Exactly one of UserID / OrgID — the payer principal. An org resolves + // through its funding designation (migration 041): not designated / not + // activated → Missing: ["billing_account"]. + UserID uuid.UUID `json:"user_id,omitempty"` + OrgID uuid.UUID `json:"org_id,omitempty"` Require []Capability `json:"require,omitempty"` } @@ -66,7 +70,11 @@ const ( // PrepareAddPaymentMethodRequest is the payload of PrepareAddPaymentMethod. type PrepareAddPaymentMethodRequest struct { - UserID uuid.UUID `json:"user_id"` + // Exactly one of UserID / OrgID. The org leg get-or-creates the ORG + // account row (regardless of designation state) so the card can bind + // before a funding='org' designation completes. + UserID uuid.UUID `json:"user_id,omitempty"` + OrgID uuid.UUID `json:"org_id,omitempty"` // Email is the account email, set on the Stripe Customer so a // setup-mode Checkout Session can be confirmed (Stripe requires one) // and for receipts/dunning. api-platform supplies it from the @@ -92,7 +100,9 @@ type PrepareAddPaymentMethodResponse struct { // DetachPaymentMethodRequest is the payload of DetachPaymentMethod. type DetachPaymentMethodRequest struct { - UserID uuid.UUID `json:"user_id"` + // Exactly one of UserID / OrgID — the PM must belong to that owner's account. + UserID uuid.UUID `json:"user_id,omitempty"` + OrgID uuid.UUID `json:"org_id,omitempty"` PaymentMethodID uuid.UUID `json:"payment_method_id"` } @@ -102,7 +112,9 @@ type DetachPaymentMethodResponse struct{} // SetDefaultPaymentMethodRequest is the payload of SetDefaultPaymentMethod. type SetDefaultPaymentMethodRequest struct { - UserID uuid.UUID `json:"user_id"` + // Exactly one of UserID / OrgID — the PM must belong to that owner's account. + UserID uuid.UUID `json:"user_id,omitempty"` + OrgID uuid.UUID `json:"org_id,omitempty"` PaymentMethodID uuid.UUID `json:"payment_method_id"` } @@ -119,7 +131,9 @@ type SetDefaultPaymentMethodResponse struct{} // be confirmed against the setup-mode Checkout Session. Empty tolerated // but blocks confirm. type StartAddPaymentMethodRequest struct { - UserID uuid.UUID `json:"user_id"` + // Exactly one of UserID / OrgID (org leg: see PrepareAddPaymentMethodRequest). + UserID uuid.UUID `json:"user_id,omitempty"` + OrgID uuid.UUID `json:"org_id,omitempty"` Email string `json:"email,omitempty"` } @@ -139,7 +153,9 @@ type StartAddPaymentMethodResponse struct { // the polling half of the add-card RPC. The frontend retries until // status is no longer "pending". type FinishAddPaymentMethodRequest struct { - UserID uuid.UUID `json:"user_id"` + // Exactly one of UserID / OrgID — must match the Start call's owner. + UserID uuid.UUID `json:"user_id,omitempty"` + OrgID uuid.UUID `json:"org_id,omitempty"` RequestID uuid.UUID `json:"request_id"` } @@ -171,7 +187,10 @@ type FinishAddPaymentMethodResponse struct { // GetPaymentMethodsRequest is the payload of GetPaymentMethods. type GetPaymentMethodsRequest struct { - UserID uuid.UUID `json:"user_id"` + // Exactly one of UserID / OrgID. A sponsor-funded org account owns no PM + // rows (the sponsor's account does) — an empty list is its normal state. + UserID uuid.UUID `json:"user_id,omitempty"` + OrgID uuid.UUID `json:"org_id,omitempty"` } // GetPaymentMethodsResponse is the body of the success envelope. diff --git a/internal/account/cycle/apps.go b/internal/account/cycle/apps.go index d7d240e..792673f 100644 --- a/internal/account/cycle/apps.go +++ b/internal/account/cycle/apps.go @@ -30,9 +30,13 @@ import ( const maxModuleCount = 100_000 // RegisterAppRequest is the payload of the RegisterApp RPC. Owner fields -// mirror the other owner-keyed RPCs (exactly one set); v1 resolves USER -// owners only — org billing is out of scope (D6), matching billing.Ensure -// (the one account-creation path, user-keyed). +// mirror the other owner-keyed RPCs (exactly one set). A USER owner resolves +// through the advisory-locked get-or-create as always. An ORG owner resolves +// through the org's funding designation (migration 041): funded → the org +// account; not designated / not activated → an UNBILLED roster row (NULL +// account_id, owner_org_id stamped) that the RepointOrgUsage sweep attaches +// once funding lands. Org owners are never rejected anymore (the old D6 rule +// is retired by the org-billing wave). type RegisterAppRequest struct { OwnerUserID uuid.UUID `json:"owner_user_id,omitempty"` OwnerOrgID uuid.UUID `json:"owner_org_id,omitempty"` @@ -125,12 +129,6 @@ func (s *Service) RegisterApp(ctx context.Context, req RegisterAppRequest) (*Reg if req.OwnerUserID != uuid.Nil && req.OwnerOrgID != uuid.Nil { return nil, billing.InvalidInput("owner_user_id and owner_org_id are mutually exclusive") } - if req.OwnerOrgID != uuid.Nil { - // v1 has no org-owned billing accounts (D6); the ONE account-creation - // path (billing.Ensure / EnsureAccountForUser) is user-keyed. Loud - // rather than silently dropping the mirror row. - return nil, billing.InvalidInput("org-owned billing accounts are not supported yet (v1 resolves user owners only)") - } if req.AppID == uuid.Nil { return nil, billing.InvalidInput("app_id required") } @@ -146,12 +144,28 @@ func (s *Service) RegisterApp(ctx context.Context, req RegisterAppRequest) (*Reg createdAt = s.nowFn().UTC() } - accountID, err := s.store.EnsureAccountForUser(ctx, req.OwnerUserID) - if err != nil { - return nil, billing.Internal("ensure billing account failed", err) + var accountID uuid.UUID + if req.OwnerUserID != uuid.Nil { + id, err := s.store.EnsureAccountForUser(ctx, req.OwnerUserID) + if err != nil { + return nil, billing.Internal("ensure billing account failed", err) + } + accountID = id + } else { + // Org leg: resolve the org's funded account through its designation. + // Not designated / not activated → accountID stays Nil and the roster + // row registers UNBILLED (owner_org_id stamped for the later attach + // sweep). Never an error — app creation must not block on billing. + id, funded, err := s.store.ResolveOrgFundedAccount(ctx, req.OwnerOrgID) + if err != nil { + return nil, billing.Internal("org account resolution failed", err) + } + if funded { + accountID = id + } } - if err := s.store.InsertAppMirror(ctx, req.AppID, accountID, req.ModuleCount, createdAt, req.Name); err != nil { + if err := s.store.InsertAppMirror(ctx, req.AppID, accountID, req.OwnerOrgID, req.ModuleCount, createdAt, req.Name); err != nil { return nil, billing.Internal("insert app mirror failed", err) } diff --git a/internal/account/cycle/apps_test.go b/internal/account/cycle/apps_test.go index ec8c4ad..e73a4a7 100644 --- a/internal/account/cycle/apps_test.go +++ b/internal/account/cycle/apps_test.go @@ -325,6 +325,52 @@ func TestRegisterApp_CreatesMissingAccount(t *testing.T) { require.Empty(t, sc.itemCalls) } +func TestRegisterApp_FundedOrgResolvesOrgAccount(t *testing.T) { + // An ORG owner resolves through the funding designation (migration 041): + // a designated + activated org registers straight onto the org account — + // still no charge (creation grace applies identically). + store := newFakeStore() + org, acct := uuid.New(), uuid.New() + store.accountsByOrg[org] = acct + store.orgDesignations[org] = cycle.OrgDesignation{OrgID: org, Funding: cycle.OrgFundingOrg} + store.activation[acct] = time.Date(2026, 5, 4, 9, 0, 0, 0, time.UTC) + sc := newFakeStripe() + appID := uuid.New() + + resp, err := appsSvc(store, sc).RegisterApp(context.Background(), cycle.RegisterAppRequest{ + OwnerOrgID: org, AppID: appID, ModuleCount: 2, + }) + require.NoError(t, err) + require.Equal(t, acct, resp.AccountID) + require.Equal(t, acct, store.apps[appID].AccountID) + require.Equal(t, org, store.appOwnerOrg[appID]) + require.Equal(t, 2, liveTimerCount(store, appID)) + require.Empty(t, sc.itemCalls) +} + +func TestRegisterApp_UnfundedOrgRegistersUnbilled(t *testing.T) { + // An org that never designated (or has not activated yet) registers an + // UNBILLED roster row: Nil account, owner_org_id stamped for the later + // attach sweep — and NO error, app creation must not block on billing. + store := newFakeStore() + org := uuid.New() // no designation, no account + sc := newFakeStripe() + appID := uuid.New() + + resp, err := appsSvc(store, sc).RegisterApp(context.Background(), cycle.RegisterAppRequest{ + OwnerOrgID: org, AppID: appID, ModuleCount: 3, + }) + require.NoError(t, err) + require.Equal(t, uuid.Nil, resp.AccountID) + require.Equal(t, uuid.Nil, store.apps[appID].AccountID) + require.Equal(t, org, store.appOwnerOrg[appID]) + // Timers still synthesize (each rides its own grace, Nil-account until the + // attach sweep backfills the roster row); nothing charges. + require.Equal(t, 3, liveTimerCount(store, appID)) + require.Empty(t, sc.itemCalls) + require.Empty(t, sc.invoiceCalls) +} + func TestRegisterApp_Validation(t *testing.T) { svc := appsSvc(newFakeStore(), newFakeStripe()) for _, tc := range []struct { @@ -333,7 +379,6 @@ func TestRegisterApp_Validation(t *testing.T) { }{ {"no owner", cycle.RegisterAppRequest{AppID: uuid.New()}}, {"both owners", cycle.RegisterAppRequest{OwnerUserID: uuid.New(), OwnerOrgID: uuid.New(), AppID: uuid.New()}}, - {"org owner (v1 user-only)", cycle.RegisterAppRequest{OwnerOrgID: uuid.New(), AppID: uuid.New()}}, {"nil app", cycle.RegisterAppRequest{OwnerUserID: uuid.New()}}, {"negative module count", cycle.RegisterAppRequest{OwnerUserID: uuid.New(), AppID: uuid.New(), ModuleCount: -1}}, // FINDING-4 pin: a count past the cap must be rejected loudly, never diff --git a/internal/account/cycle/charge_test.go b/internal/account/cycle/charge_test.go index 427cdd7..4f17b72 100644 --- a/internal/account/cycle/charge_test.go +++ b/internal/account/cycle/charge_test.go @@ -208,6 +208,59 @@ func TestRunBillingCycle_ChargesArrears(t *testing.T) { } } +// --- org-billing D1: the funding hop (resolveChargeableCustomer) -------------- + +func TestRunBillingCycle_SponsorFundingHopChargesSponsorCustomer(t *testing.T) { + // An org account whose designation names a sponsor gates on — and charges — + // the SPONSOR's default PM + Stripe customer, while everything else (the + // run row, the invoice mirror) stays keyed to the ORG account. The org + // account itself has NO usable PM and NO customer, so a leg resolving the + // org account directly could not have produced this charge. + store := newFakeStore() + org, orgAcct, sponsorAcct := uuid.New(), uuid.New(), uuid.New() + store.accountsByOrg[org] = orgAcct + store.orgDesignations[org] = cycle.OrgDesignation{ + OrgID: org, Funding: cycle.OrgFundingSponsor, SponsorAccountID: sponsorAcct, + } + store.hasPMByAccount[orgAcct] = false + store.hasPMByAccount[sponsorAcct] = true + store.stripeCustomerByAccount[sponsorAcct] = "cus_sponsor" + store.chargedTotal = 1_000_000 + sc := newFakeStripe() + + resp, err := chargeSvc(store, sc).RunBillingCycle(context.Background(), orgAcct, periodStart, periodEnd, 0) + require.NoError(t, err) + require.Equal(t, cycle.RunStatusInvoiced, resp.Status) + require.Len(t, sc.itemCalls, 1) + require.Equal(t, "cus_sponsor", sc.itemCalls[0].custID, "the charge lands on the sponsor's Stripe customer") + require.Equal(t, "cus_sponsor", sc.invoiceCalls[0].custID) + + // Attribution never moves: the mirror + run row stay on the ORG account. + require.Equal(t, orgAcct, store.invoices[resp.StripeInvoiceID].AccountID) + _, ok := store.insertedRuns[runKey(orgAcct, periodStart, periodEnd)] + require.True(t, ok) +} + +func TestRunBillingCycle_SponsorRevokedDegradesToNoPMSkip(t *testing.T) { + // The same org account with its designation revoked funds ITSELF (identity + // hop) — and it has no PM, so the run degrades to the ordinary transient + // skipped_no_pm, never an error and never a charge on the ex-sponsor. + store := newFakeStore() + org, orgAcct, sponsorAcct := uuid.New(), uuid.New(), uuid.New() + store.accountsByOrg[org] = orgAcct // no designation row (revoked) + store.hasPMByAccount[orgAcct] = false + store.hasPMByAccount[sponsorAcct] = true + store.stripeCustomerByAccount[sponsorAcct] = "cus_sponsor" + store.chargedTotal = 1_000_000 + sc := newFakeStripe() + + resp, err := chargeSvc(store, sc).RunBillingCycle(context.Background(), orgAcct, periodStart, periodEnd, 0) + require.NoError(t, err) + require.Equal(t, cycle.RunStatusSkippedNoPM, resp.Status) + require.Empty(t, sc.itemCalls) + require.Empty(t, sc.invoiceCalls) +} + // --- FINDING 3: a reclaimed boundary run reuses its FROZEN charge amount, never // a freshly-recomputed live total, so the stable Stripe idem key never conflicts - diff --git a/internal/account/cycle/migration027_integration_test.go b/internal/account/cycle/migration027_integration_test.go index acb1b1b..10200ba 100644 --- a/internal/account/cycle/migration027_integration_test.go +++ b/internal/account/cycle/migration027_integration_test.go @@ -31,8 +31,8 @@ func TestAppsMirror_Integration_GuardSemantics(t *testing.T) { // Register + a conflicting retry: the FIRST registration's created_at / // module_count survive (ON CONFLICT DO NOTHING). - require.NoError(t, store.InsertAppMirror(ctx, appID, acct, 2, created, "")) - require.NoError(t, store.InsertAppMirror(ctx, appID, acct, 9, created.AddDate(0, 0, 5), "")) + require.NoError(t, store.InsertAppMirror(ctx, appID, acct, uuid.Nil, 2, created, "")) + require.NoError(t, store.InsertAppMirror(ctx, appID, acct, uuid.Nil, 9, created.AddDate(0, 0, 5), "")) app, found, err := store.AppMirror(ctx, appID) require.NoError(t, err) require.True(t, found) @@ -85,15 +85,15 @@ func TestAppsMirror_Integration_LiveRosterScan(t *testing.T) { newPeriodStart := mustTime(t, "2026-07-01T00:00:00Z") live1, live2, dead, late := uuid.New(), uuid.New(), uuid.New(), uuid.New() - require.NoError(t, store.InsertAppMirror(ctx, live1, acct, 0, created, "")) - require.NoError(t, store.InsertAppMirror(ctx, live2, acct, 6, created, "")) - require.NoError(t, store.InsertAppMirror(ctx, dead, acct, 9, created, "")) + require.NoError(t, store.InsertAppMirror(ctx, live1, acct, uuid.Nil, 0, created, "")) + require.NoError(t, store.InsertAppMirror(ctx, live2, acct, uuid.Nil, 6, created, "")) + require.NoError(t, store.InsertAppMirror(ctx, dead, acct, uuid.Nil, 9, created, "")) require.NoError(t, store.MarkAppDeleted(ctx, dead)) - require.NoError(t, store.InsertAppMirror(ctx, uuid.New(), other, 3, created, "")) // another account's app + require.NoError(t, store.InsertAppMirror(ctx, uuid.New(), other, uuid.Nil, 3, created, "")) // another account's app // Created INSIDE the new period (on the cutoff instant is also out — the // comparison is strict): its new-period base belongs to the RegisterApp // proration leg, never this boundary's advance sum. - require.NoError(t, store.InsertAppMirror(ctx, late, acct, 4, mustTime(t, "2026-07-01T10:00:00Z"), "")) + require.NoError(t, store.InsertAppMirror(ctx, late, acct, uuid.Nil, 4, mustTime(t, "2026-07-01T10:00:00Z"), "")) apps, err := store.LiveAppsCreatedBefore(ctx, acct, newPeriodStart, usage.GraceDays) require.NoError(t, err) @@ -109,7 +109,7 @@ func TestAppsMirror_Integration_LiveRosterScan(t *testing.T) { // 2026-07-06): excluded too — it hasn't survived grace, and its creation // charge covers the straddled period. inGrace := uuid.New() - require.NoError(t, store.InsertAppMirror(ctx, inGrace, acct, 1, mustTime(t, "2026-06-29T00:00:00Z"), "")) + require.NoError(t, store.InsertAppMirror(ctx, inGrace, acct, uuid.Nil, 1, mustTime(t, "2026-06-29T00:00:00Z"), "")) apps, err = store.LiveAppsCreatedBefore(ctx, acct, newPeriodStart, usage.GraceDays) require.NoError(t, err) require.Len(t, apps, 2, "an app whose creation grace straddles the boundary joins only at the NEXT boundary") diff --git a/internal/account/cycle/migration028_integration_test.go b/internal/account/cycle/migration028_integration_test.go index 2668c52..8048907 100644 --- a/internal/account/cycle/migration028_integration_test.go +++ b/internal/account/cycle/migration028_integration_test.go @@ -28,7 +28,7 @@ func TestAppBaseSnapshots_Integration_ConflictSemanticsAndRead(t *testing.T) { acct := seedAccount(t, pool) appID := uuid.New() - require.NoError(t, store.InsertAppMirror(ctx, appID, acct, 5, mustTime(t, "2026-06-10T08:00:00Z"), "")) + require.NoError(t, store.InsertAppMirror(ctx, appID, acct, uuid.Nil, 5, mustTime(t, "2026-06-10T08:00:00Z"), "")) periodStart := mustTime(t, "2026-06-01T00:00:00Z") periodEnd := mustTime(t, "2026-07-01T00:00:00Z") diff --git a/internal/account/cycle/migration029_integration_test.go b/internal/account/cycle/migration029_integration_test.go index 87e9e34..729aaec 100644 --- a/internal/account/cycle/migration029_integration_test.go +++ b/internal/account/cycle/migration029_integration_test.go @@ -37,11 +37,11 @@ func TestAppsPendingProration_Integration_Filter(t *testing.T) { deletedIn := uuid.New() // deleted WITHIN its grace → excluded (never charged) deletedAfter := uuid.New() // deleted AFTER its grace elapsed → returned (D11: survived, still owes) charged := uuid.New() // guard armed → excluded - require.NoError(t, store.InsertAppMirror(ctx, pending, acct, 0, now.Add(-10*24*time.Hour), "")) - require.NoError(t, store.InsertAppMirror(ctx, young, acct, 0, now.Add(2*time.Hour), "")) - require.NoError(t, store.InsertAppMirror(ctx, deletedIn, acct, 0, now.Add(-time.Hour), "")) - require.NoError(t, store.InsertAppMirror(ctx, deletedAfter, acct, 0, now.Add(-9*24*time.Hour), "")) - require.NoError(t, store.InsertAppMirror(ctx, charged, acct, 0, now.Add(-8*24*time.Hour), "")) + require.NoError(t, store.InsertAppMirror(ctx, pending, acct, uuid.Nil, 0, now.Add(-10*24*time.Hour), "")) + require.NoError(t, store.InsertAppMirror(ctx, young, acct, uuid.Nil, 0, now.Add(2*time.Hour), "")) + require.NoError(t, store.InsertAppMirror(ctx, deletedIn, acct, uuid.Nil, 0, now.Add(-time.Hour), "")) + require.NoError(t, store.InsertAppMirror(ctx, deletedAfter, acct, uuid.Nil, 0, now.Add(-9*24*time.Hour), "")) + require.NoError(t, store.InsertAppMirror(ctx, charged, acct, uuid.Nil, 0, now.Add(-8*24*time.Hour), "")) require.NoError(t, store.MarkAppDeleted(ctx, deletedIn)) require.NoError(t, store.MarkAppDeleted(ctx, deletedAfter)) require.NoError(t, store.SetAppProrationInvoice(ctx, charged, "in_already")) @@ -81,7 +81,7 @@ func TestChargeProrationLocked_Integration_Semantics(t *testing.T) { // Live, unarmed app → the callback fires, and the invoice + snapshot + guard // commit atomically. live := uuid.New() - require.NoError(t, store.InsertAppMirror(ctx, live, acct, 0, mustTime(t, "2026-07-01T08:00:00Z"), "")) + require.NoError(t, store.InsertAppMirror(ctx, live, acct, uuid.Nil, 0, mustTime(t, "2026-07-01T08:00:00Z"), "")) called := false outcome, invID, err := store.ChargeProrationLocked(ctx, live, func(l cycle.AppMirror) (*cycle.ProrationCharge, error) { called = true @@ -113,7 +113,7 @@ func TestChargeProrationLocked_Integration_Semantics(t *testing.T) { // relative to the real clock because MarkAppDeleted stamps now() — a fixed // past date would make this a post-grace delete, which D11 charges. deleted := uuid.New() - require.NoError(t, store.InsertAppMirror(ctx, deleted, acct, 0, time.Now().UTC().Add(-time.Hour), "")) + require.NoError(t, store.InsertAppMirror(ctx, deleted, acct, uuid.Nil, 0, time.Now().UTC().Add(-time.Hour), "")) require.NoError(t, store.MarkAppDeleted(ctx, deleted)) called = false outcome, _, err = store.ChargeProrationLocked(ctx, deleted, func(cycle.AppMirror) (*cycle.ProrationCharge, error) { @@ -126,7 +126,7 @@ func TestChargeProrationLocked_Integration_Semantics(t *testing.T) { // Callback declines (0 cents) → NoCharge, guard stays unarmed, nothing persisted. zero := uuid.New() - require.NoError(t, store.InsertAppMirror(ctx, zero, acct, 0, mustTime(t, "2026-07-01T08:00:00Z"), "")) + require.NoError(t, store.InsertAppMirror(ctx, zero, acct, uuid.Nil, 0, mustTime(t, "2026-07-01T08:00:00Z"), "")) outcome, _, err = store.ChargeProrationLocked(ctx, zero, func(cycle.AppMirror) (*cycle.ProrationCharge, error) { return nil, nil }) diff --git a/internal/account/cycle/migration030_integration_test.go b/internal/account/cycle/migration030_integration_test.go index 3c8039c..74041c6 100644 --- a/internal/account/cycle/migration030_integration_test.go +++ b/internal/account/cycle/migration030_integration_test.go @@ -26,7 +26,7 @@ func TestCreatedModuleCount_Integration_FrozenAcrossSetAppModuleCount(t *testing acct := seedAccount(t, pool) appID := uuid.New() - require.NoError(t, store.InsertAppMirror(ctx, appID, acct, 2, mustTime(t, "2026-07-01T08:00:00Z"), "")) + require.NoError(t, store.InsertAppMirror(ctx, appID, acct, uuid.Nil, 2, mustTime(t, "2026-07-01T08:00:00Z"), "")) app, found, err := store.AppMirror(ctx, appID) require.NoError(t, err) @@ -64,8 +64,8 @@ func TestCreatedModuleCount_Integration_RetryKeepsFirstRegistrationsFrozenCount( acct := seedAccount(t, pool) appID := uuid.New() created := mustTime(t, "2026-07-01T08:00:00Z") - require.NoError(t, store.InsertAppMirror(ctx, appID, acct, 3, created, "")) - require.NoError(t, store.InsertAppMirror(ctx, appID, acct, 12, created, "")) // retry, different count + require.NoError(t, store.InsertAppMirror(ctx, appID, acct, uuid.Nil, 3, created, "")) + require.NoError(t, store.InsertAppMirror(ctx, appID, acct, uuid.Nil, 12, created, "")) // retry, different count app, _, err := store.AppMirror(ctx, appID) require.NoError(t, err) diff --git a/internal/account/cycle/migration031_integration_test.go b/internal/account/cycle/migration031_integration_test.go index 9f40eac..879aac3 100644 --- a/internal/account/cycle/migration031_integration_test.go +++ b/internal/account/cycle/migration031_integration_test.go @@ -28,7 +28,7 @@ func TestProrationSkipped_Integration_OneShotAndExcludedFromPending(t *testing.T cutoff := mustTime(t, "2026-04-05T00:00:00Z") skipped := uuid.New() - require.NoError(t, store.InsertAppMirror(ctx, skipped, acct, 0, mustTime(t, "2026-01-01T08:00:00Z"), "")) + require.NoError(t, store.InsertAppMirror(ctx, skipped, acct, uuid.Nil, 0, mustTime(t, "2026-01-01T08:00:00Z"), "")) // Past grace, unarmed, not yet skipped → pending. ids, err := store.AppsPendingProration(ctx, cutoff) @@ -66,7 +66,7 @@ func TestProrationSkipped_Integration_RefusesToSkipAnAlreadyChargedApp(t *testin acct := seedAccount(t, pool) appID := uuid.New() - require.NoError(t, store.InsertAppMirror(ctx, appID, acct, 0, mustTime(t, "2026-01-01T08:00:00Z"), "")) + require.NoError(t, store.InsertAppMirror(ctx, appID, acct, uuid.Nil, 0, mustTime(t, "2026-01-01T08:00:00Z"), "")) require.NoError(t, store.SetAppProrationInvoice(ctx, appID, "in_already_charged")) require.NoError(t, store.SetAppProrationSkipped(ctx, appID)) // no-op, not an error diff --git a/internal/account/cycle/migration033_integration_test.go b/internal/account/cycle/migration033_integration_test.go index d956e32..e0c23ed 100644 --- a/internal/account/cycle/migration033_integration_test.go +++ b/internal/account/cycle/migration033_integration_test.go @@ -31,7 +31,7 @@ func TestModuleOverageTimers_Integration_SynthesisFIFOAndSweep(t *testing.T) { require.NoError(t, err) app := uuid.New() - require.NoError(t, store.InsertAppMirror(ctx, app, acct, 0, mustTime(t, "2026-06-01T00:00:00Z"), "")) + require.NoError(t, store.InsertAppMirror(ctx, app, acct, uuid.Nil, 0, mustTime(t, "2026-06-01T00:00:00Z"), "")) // 5 "included" installs anchored early + 1 "over" install anchored June 10. early := mustTime(t, "2026-05-04T00:00:00Z") @@ -109,7 +109,7 @@ func TestModuleOverageTimers_Integration_ConcurrentReconcileNeverDoubleInserts(t acct := seedAccount(t, pool) app := uuid.New() created := mustTime(t, "2026-06-19T12:00:00Z") - require.NoError(t, store.InsertAppMirror(ctx, app, acct, 7, created, "")) + require.NoError(t, store.InsertAppMirror(ctx, app, acct, uuid.Nil, 7, created, "")) const workers = 8 errs := make(chan error, workers) @@ -164,8 +164,8 @@ func TestModuleOverageTimers_Integration_OverQueries(t *testing.T) { appA, appB := uuid.New(), uuid.New() created := mustTime(t, "2026-06-19T12:00:00Z") - require.NoError(t, store.InsertAppMirror(ctx, appA, acct, 7, created, "")) - require.NoError(t, store.InsertAppMirror(ctx, appB, acct, 0, created, "")) + require.NoError(t, store.InsertAppMirror(ctx, appA, acct, uuid.Nil, 7, created, "")) + require.NoError(t, store.InsertAppMirror(ctx, appB, acct, uuid.Nil, 0, created, "")) // appA: 7 co-created install timers at created_at → FIFO ranks 0-6, so 2 are // "over" (rank ≥ IncludedModules=5). diff --git a/internal/account/cycle/org.go b/internal/account/cycle/org.go new file mode 100644 index 0000000..223f442 --- /dev/null +++ b/internal/account/cycle/org.go @@ -0,0 +1,380 @@ +package cycle + +// Org funding designation + the RepointOrgUsage attach sweep (org-billing W0 +// substrate, workspace docs-temp/org-billing/design.md D1). +// +// One org = ONE ms_billing.accounts row; the designation picks only the +// FUNDING INSTRUMENT for that account's invoices (sponsor's card vs the org's +// own). Attribution — periods, aggregates, roster, overage pool, budgets, +// collection state, invoice mirror — always lives on the org account itself, +// so funding switches never move frozen state; they re-route only future +// charges (resolveChargeableCustomer's ChargeFundingAccount hop). +// +// AUTHZ CONTRACT: billing-engine trusts api-platform (internal RPC). The +// caller has already verified org membership + CanManage (owner|admin) and +// step-up reauth; this service enforces only the invariants it owns — the +// sponsor is the ACTING user's own account (never another member's wallet) +// and only the current sponsor may self-revoke. + +import ( + "context" + + "github.com/google/uuid" + + "github.com/mirrorstack-ai/billing-engine/internal/account/billing" + "github.com/mirrorstack-ai/billing-engine/internal/billingperiod" +) + +// OrgFunding is the designation's funding instrument choice. +type OrgFunding string + +const ( + // OrgFundingSponsor — invoices charge the sponsor's Stripe customer / + // default PM; the org account activates immediately (anchor = designation + // day, ADR 0006 amendment). + OrgFundingSponsor OrgFunding = "sponsor" + // OrgFundingOrg — invoices charge the org's own Stripe customer / card; + // resolution stays gated on activated_at (stamped at card bind), so the + // org remains unbilled until the bind completes. + OrgFundingOrg OrgFunding = "org" +) + +// OrgDesignation is the ms_billing.org_billing_designations row (migration +// 038). SponsorAccountID / SponsorUserID are Nil unless Funding is sponsor. +type OrgDesignation struct { + OrgID uuid.UUID + Funding OrgFunding + SponsorAccountID uuid.UUID + SponsorUserID uuid.UUID + DisclosedBacklogMicros int64 + UpdatedBy uuid.UUID +} + +// SetOrgDesignationRequest is the payload of the SetOrgDesignation RPC. +// ActingUserID is the org owner/admin performing the designation — for +// sponsor funding it IS the sponsor (billing-engine validates the sponsor +// account belongs to it; api-platform re-verified membership + CanManage + +// reauth before proxying). +type SetOrgDesignationRequest struct { + OrgID uuid.UUID `json:"org_id"` + ActingUserID uuid.UUID `json:"acting_user_id"` + Funding string `json:"funding"` +} + +// SetOrgDesignationResponse reports the designation outcome. Activated is +// false only on a funding='org' designation whose card has not bound yet — +// the org stays unbilled until it does. DisclosedBacklogMicros echoes the +// recorded pre-designation backlog estimate (decision 1 disclosure); +// AttachedApps / RepointedEvents report the attach sweep (0 when not funded +// yet). +type SetOrgDesignationResponse struct { + AccountID uuid.UUID `json:"account_id"` + Funding string `json:"funding"` + Activated bool `json:"activated"` + DisclosedBacklogMicros int64 `json:"disclosed_backlog_micros"` + AttachedApps int64 `json:"attached_apps"` + RepointedEvents int64 `json:"repointed_events"` +} + +// GetOrgDesignationRequest reads the org's designation + funding state. +type GetOrgDesignationRequest struct { + OrgID uuid.UUID `json:"org_id"` +} + +// GetOrgDesignationResponse: Found=false → the org never designated. +// PendingBacklogMicros is the live unbilled-backlog estimate — the number the +// designation UI must show before the user confirms (decision 1). AccountID +// is Nil until the org account row exists. SponsorUserID lets api-platform +// enforce the sponsor-leave/demote guard and gate the self-revoke. +type GetOrgDesignationResponse struct { + Found bool `json:"found"` + Funding string `json:"funding,omitempty"` + SponsorUserID uuid.UUID `json:"sponsor_user_id,omitempty"` + AccountID uuid.UUID `json:"account_id,omitempty"` + Activated bool `json:"activated"` + PendingBacklogMicros int64 `json:"pending_backlog_micros"` +} + +// RevokeSponsorshipRequest is the sponsor SELF-revoke: authorized by BEING +// the current sponsor (not by CanManage — a demoted sponsor must still be +// able to withdraw their own card). +type RevokeSponsorshipRequest struct { + OrgID uuid.UUID `json:"org_id"` + ActingUserID uuid.UUID `json:"acting_user_id"` +} + +// RevokeSponsorshipResponse: Revoked=false → no designation row existed +// (idempotent revoke). +type RevokeSponsorshipResponse struct { + Revoked bool `json:"revoked"` +} + +// RepointOrgUsageRequest triggers the attach/backfill sweep for an org whose +// funded account just resolved (fired by api-platform after a funding='org' +// card bind completes; SetOrgDesignation runs the same sweep inline for the +// sponsor path). Idempotent — attached rows and swept events never match +// again. +type RepointOrgUsageRequest struct { + OrgID uuid.UUID `json:"org_id"` +} + +// RepointOrgUsageResponse: Funded=false → the org has no funded designation +// yet (nothing swept; not an error — the caller may fire optimistically). +type RepointOrgUsageResponse struct { + AccountID uuid.UUID `json:"account_id,omitempty"` + Funded bool `json:"funded"` + AttachedApps int64 `json:"attached_apps"` + RepointedEvents int64 `json:"repointed_events"` +} + +// SetOrgDesignation writes the org's funding choice (design D1): +// +// - sponsor: validates the sponsor is the ACTING user's own account with a +// usable default PM, records the designation with the computed backlog +// disclosure, activates the org account if this is its first funding +// (anchor = designation day), and runs the attach sweep inline. +// +// - org: records the designation; the account row is ensured but NOT +// activated here — the card-bind webhook stamps activation, after which +// api-platform fires RepointOrgUsage. When the account is ALREADY +// activated (a funding switch back to the org's own card), the attach +// sweep runs inline since resolution is already live. +func (s *Service) SetOrgDesignation(ctx context.Context, req SetOrgDesignationRequest) (*SetOrgDesignationResponse, error) { + if req.OrgID == uuid.Nil { + return nil, billing.InvalidInput("org_id required") + } + if req.ActingUserID == uuid.Nil { + return nil, billing.InvalidInput("acting_user_id required") + } + funding := OrgFunding(req.Funding) + if funding != OrgFundingSponsor && funding != OrgFundingOrg { + return nil, billing.InvalidInput("funding must be 'sponsor' or 'org'") + } + + accountID, err := s.store.EnsureOrgAccount(ctx, req.OrgID) + if err != nil { + return nil, billing.Internal("ensure org account failed", err) + } + + d := OrgDesignation{ + OrgID: req.OrgID, + Funding: funding, + UpdatedBy: req.ActingUserID, + } + + if funding == OrgFundingSponsor { + // The sponsor is the acting user's OWN account — designating another + // member's wallet is structurally impossible (the request carries no + // sponsor field). It must already exist with a usable default PM: a + // card-less sponsor would activate the org into a state where every + // charge run skips no_pm. + sponsorAccountID, found, err := s.store.AccountIDByUser(ctx, req.ActingUserID) + if err != nil { + return nil, billing.Internal("sponsor account lookup failed", err) + } + if !found { + return nil, billing.InvalidInput("sponsor has no billing account — add a payment method first") + } + hasPM, err := s.store.HasUsableDefaultPM(ctx, sponsorAccountID) + if err != nil { + return nil, billing.Internal("sponsor payment-method check failed", err) + } + if !hasPM { + return nil, billing.InvalidInput("sponsor has no usable default payment method — add a card first") + } + d.SponsorAccountID = sponsorAccountID + d.SponsorUserID = req.ActingUserID + } + + // Record the backlog estimate the UI disclosed pre-confirm (decision 1). + // Computed server-side at write time so the recorded figure is the sweep's + // contemporaneous view, not a stale client echo. + backlog, err := s.store.OrgUnbilledBacklogMicros(ctx, req.OrgID) + if err != nil { + return nil, billing.Internal("unbilled backlog estimate failed", err) + } + d.DisclosedBacklogMicros = backlog + + if funding == OrgFundingSponsor { + // Sponsor funding activates immediately — a usable instrument exists. + // Activation is written BEFORE the designation row: a crash between the + // two leaves an activated-but-undesignated account (invisible, unbilled, + // fixed by retrying) rather than a designation that LOOKS configured but + // never bills (the resolution gate requires activation). Idempotent — + // the anchor is immutable once set (ADR 0006). + if err := s.store.ActivateAccountIfUnset(ctx, accountID, s.nowFn().UTC()); err != nil { + return nil, billing.Internal("activate org account failed", err) + } + } + + if err := s.store.UpsertOrgDesignation(ctx, d); err != nil { + return nil, billing.Internal("write org designation failed", err) + } + + resp := &SetOrgDesignationResponse{ + AccountID: accountID, + Funding: string(funding), + DisclosedBacklogMicros: backlog, + } + + // Attach sweep — only once resolution is live (activated). For a fresh + // funding='org' designation this is deferred to the card-bind completion + // (api-platform fires RepointOrgUsage then). + _, activated, err := s.store.AccountActivation(ctx, accountID) + if err != nil { + return nil, billing.Internal("account activation lookup failed", err) + } + resp.Activated = activated + if activated { + attached, repointed, err := s.attachOrgBilling(ctx, req.OrgID, accountID) + if err != nil { + return nil, err + } + resp.AttachedApps, resp.RepointedEvents = attached, repointed + } + return resp, nil +} + +// GetOrgDesignation reads the org's designation + funding state, including +// the live pending-backlog estimate the designation UI must disclose. +func (s *Service) GetOrgDesignation(ctx context.Context, req GetOrgDesignationRequest) (*GetOrgDesignationResponse, error) { + if req.OrgID == uuid.Nil { + return nil, billing.InvalidInput("org_id required") + } + resp := &GetOrgDesignationResponse{} + + backlog, err := s.store.OrgUnbilledBacklogMicros(ctx, req.OrgID) + if err != nil { + return nil, billing.Internal("unbilled backlog estimate failed", err) + } + resp.PendingBacklogMicros = backlog + + accountID, accountFound, err := s.store.OrgAccountID(ctx, req.OrgID) + if err != nil { + return nil, billing.Internal("org account lookup failed", err) + } + if accountFound { + resp.AccountID = accountID + _, resp.Activated, err = s.store.AccountActivation(ctx, accountID) + if err != nil { + return nil, billing.Internal("account activation lookup failed", err) + } + } + + d, found, err := s.store.OrgDesignation(ctx, req.OrgID) + if err != nil { + return nil, billing.Internal("org designation lookup failed", err) + } + if !found { + return resp, nil + } + resp.Found = true + resp.Funding = string(d.Funding) + resp.SponsorUserID = d.SponsorUserID + return resp, nil +} + +// RevokeSponsorship is the sponsor self-revoke: only the CURRENT sponsor may +// withdraw their card; the org drops to unbilled (new events record +// NULL-account) until it re-designates. Frozen attribution never rewrites — +// roster rows keep their account_id; already-attached state simply stops +// being fundable, which the charge legs surface as transient no_pm skips. +func (s *Service) RevokeSponsorship(ctx context.Context, req RevokeSponsorshipRequest) (*RevokeSponsorshipResponse, error) { + if req.OrgID == uuid.Nil { + return nil, billing.InvalidInput("org_id required") + } + if req.ActingUserID == uuid.Nil { + return nil, billing.InvalidInput("acting_user_id required") + } + d, found, err := s.store.OrgDesignation(ctx, req.OrgID) + if err != nil { + return nil, billing.Internal("org designation lookup failed", err) + } + if !found { + return &RevokeSponsorshipResponse{Revoked: false}, nil // idempotent + } + if d.Funding != OrgFundingSponsor { + return nil, billing.InvalidInput("designation is not sponsor-funded") + } + if d.SponsorUserID != req.ActingUserID { + return nil, billing.InvalidInput("only the current sponsor may revoke sponsorship") + } + revoked, err := s.store.DeleteOrgDesignation(ctx, req.OrgID) + if err != nil { + return nil, billing.Internal("delete org designation failed", err) + } + return &RevokeSponsorshipResponse{Revoked: revoked}, nil +} + +// RepointOrgUsage runs the attach/backfill sweep for an org whose funded +// account resolves — fired by api-platform after a funding='org' card bind +// completes (the sponsor path sweeps inline in SetOrgDesignation). Unfunded +// orgs report Funded=false without error so optimistic fire-and-forget +// callers need no state machine. +func (s *Service) RepointOrgUsage(ctx context.Context, req RepointOrgUsageRequest) (*RepointOrgUsageResponse, error) { + if req.OrgID == uuid.Nil { + return nil, billing.InvalidInput("org_id required") + } + accountID, funded, err := s.store.ResolveOrgFundedAccount(ctx, req.OrgID) + if err != nil { + return nil, billing.Internal("org account resolution failed", err) + } + if !funded { + return &RepointOrgUsageResponse{Funded: false}, nil + } + attached, repointed, err := s.attachOrgBilling(ctx, req.OrgID, accountID) + if err != nil { + return nil, err + } + return &RepointOrgUsageResponse{ + AccountID: accountID, + Funded: true, + AttachedApps: attached, + RepointedEvents: repointed, + }, nil +} + +// attachOrgBilling is the shared attach/backfill sweep body: backfill +// account_id onto the org's unbilled roster rows, fold its NULL-account +// events into the account's CURRENT OPEN window (clamped so they bill in the +// first period that closes after designation — decision 1), then synthesize +// each live app's overage timers fresh, anchored NOW (prospective billing: +// grace runs from designation, never retroactively). Every step is +// idempotent, so a crashed sweep re-runs safely via RepointOrgUsage. +func (s *Service) attachOrgBilling(ctx context.Context, orgID, accountID uuid.UUID) (attached, repointed int64, err error) { + attached, err = s.store.AttachOrgAppsToAccount(ctx, orgID, accountID) + if err != nil { + return 0, 0, billing.Internal("attach org apps failed", err) + } + + activatedAt, activated, err := s.store.AccountActivation(ctx, accountID) + if err != nil { + return 0, 0, billing.Internal("account activation lookup failed", err) + } + if !activated { + // Callers gate on funded resolution (which requires activation); an + // unactivated account here is a code bug, not a skip. + return 0, 0, billing.Internal("attach sweep reached an unactivated account", nil) + } + now := s.nowFn().UTC() + windowStart, _ := billingperiod.AnchoredPeriodWindow(now, billingperiod.AnchorDay(activatedAt)) + + repointed, err = s.store.RepointOrgNullAccountEvents(ctx, orgID, accountID, windowStart) + if err != nil { + return 0, 0, billing.Internal("repoint org usage events failed", err) + } + + // Fresh timers for every live app — the reconcile derives count + account + // from the roster row inside its own advisory-locked transaction, so a + // concurrent RegisterApp/SyncAppModules retry can never double-insert. + apps, err := s.store.OrgLiveAppIDs(ctx, orgID) + if err != nil { + return 0, 0, billing.Internal("org app enumeration failed", err) + } + for _, appID := range apps { + if err := s.store.ReconcileModuleTimersToTarget(ctx, appID, now, moduleGraceExpiry(now), now); err != nil { + return 0, 0, billing.Internal("reconcile module overage timers failed", err) + } + } + return attached, repointed, nil +} diff --git a/internal/account/cycle/org_test.go b/internal/account/cycle/org_test.go new file mode 100644 index 0000000..e563c03 --- /dev/null +++ b/internal/account/cycle/org_test.go @@ -0,0 +1,337 @@ +package cycle_test + +// SetOrgDesignation / GetOrgDesignation / RevokeSponsorship / RepointOrgUsage +// (org-billing W0 substrate, design D1). Reuses the in-memory fakeStore +// (service_test.go) — no new harness. The org RPCs never touch Stripe, so the +// services here wire a nil client. + +import ( + "context" + "testing" + "time" + + "github.com/google/uuid" + "github.com/stretchr/testify/require" + + "github.com/mirrorstack-ai/billing-engine/internal/account/billing" + "github.com/mirrorstack-ai/billing-engine/internal/account/cycle" +) + +// Fixed clock: 2026-07-06 12:00 UTC. A sponsor designation activates the org +// account at this instant (anchor day 6), so the account's current anchored +// window is [2026-07-06, 2026-08-06) and the repoint sweep clamps to Jul 6. +var orgNow = time.Date(2026, 7, 6, 12, 0, 0, 0, time.UTC) + +func orgSvc(store *fakeStore) *cycle.Service { + return cycle.NewService(store, nil).WithNow(func() time.Time { return orgNow }) +} + +// sponsorUser seeds an acting user with an existing billing account + usable +// default PM — the valid-sponsor baseline tests weaken. Returns (user, account). +func sponsorUser(store *fakeStore) (uuid.UUID, uuid.UUID) { + user, acct := uuid.New(), uuid.New() + store.accountsByUser[user] = acct + store.hasPMByAccount[acct] = true + return user, acct +} + +// seedOrgApp seeds an UNBILLED org roster row (Nil account, owner_org_id +// stamped) — the pre-designation shape RegisterApp writes for an unfunded org. +func seedOrgApp(store *fakeStore, orgID uuid.UUID, moduleCount int) uuid.UUID { + id := uuid.New() + store.apps[id] = cycle.AppMirror{ + AppID: id, AccountID: uuid.Nil, ModuleCount: moduleCount, + CreatedAt: orgNow.AddDate(0, 0, -30), + } + store.appOwnerOrg[id] = orgID + return id +} + +// --- SetOrgDesignation: sponsor funding ------------------------------------- + +func TestSetOrgDesignation_SponsorActivatesAndRunsAttachSweep(t *testing.T) { + store := newFakeStore() + user, sponsorAcct := sponsorUser(store) + org := uuid.New() + app1 := seedOrgApp(store, org, 3) + app2 := seedOrgApp(store, org, 0) + store.orgBacklog[org] = 12_345_000 + store.orgNullEvents[org] = 7 + + resp, err := orgSvc(store).SetOrgDesignation(context.Background(), cycle.SetOrgDesignationRequest{ + OrgID: org, ActingUserID: user, Funding: "sponsor", + }) + require.NoError(t, err) + acct := store.accountsByOrg[org] + require.NotEqual(t, uuid.Nil, acct, "the org account row is ensured") + require.Equal(t, acct, resp.AccountID) + require.Equal(t, "sponsor", resp.Funding) + require.True(t, resp.Activated) + require.EqualValues(t, 12_345_000, resp.DisclosedBacklogMicros) + + // Designation row: the sponsor pair is the ACTING user + their account, + // with the disclosure recorded at write time. + d := store.orgDesignations[org] + require.Equal(t, cycle.OrgFundingSponsor, d.Funding) + require.Equal(t, sponsorAcct, d.SponsorAccountID) + require.Equal(t, user, d.SponsorUserID) + require.EqualValues(t, 12_345_000, d.DisclosedBacklogMicros) + require.Equal(t, user, d.UpdatedBy) + + // Sponsor funding activates immediately: anchor = designation instant. + require.Equal(t, orgNow, store.activation[acct]) + + // Attach sweep ran inline: both roster rows attached... + require.EqualValues(t, 2, resp.AttachedApps) + require.Equal(t, acct, store.apps[app1].AccountID) + require.Equal(t, acct, store.apps[app2].AccountID) + + // ...the NULL-account events folded into the account's current open window + // (anchor day 6 → clamped to Jul 6 00:00, decision 1)... + require.EqualValues(t, 7, resp.RepointedEvents) + require.Equal(t, []repointCall{{org, acct, time.Date(2026, 7, 6, 0, 0, 0, 0, time.UTC)}}, store.repointCalls) + + // ...and each live app's timers synthesized fresh, anchored NOW + // (prospective grace — never retroactive). + require.Equal(t, 3, liveTimerCount(store, app1)) + require.Zero(t, liveTimerCount(store, app2)) + for _, tm := range store.timers { + require.Equal(t, orgNow, tm.installedAt) + require.Equal(t, orgNow.AddDate(0, 0, 3), tm.graceExpiresAt) + } +} + +func TestSetOrgDesignation_SponsorRequiresAccountAndUsablePM(t *testing.T) { + // A sponsor without an existing account, or with an account but no usable + // default PM, is rejected — a card-less sponsor would activate the org + // into a state where every charge run skips no_pm. Nothing is written. + t.Run("no account", func(t *testing.T) { + store := newFakeStore() + _, err := orgSvc(store).SetOrgDesignation(context.Background(), cycle.SetOrgDesignationRequest{ + OrgID: uuid.New(), ActingUserID: uuid.New(), Funding: "sponsor", + }) + requireCode(t, err, billing.CodeInvalidInput) + require.Empty(t, store.orgDesignations) + }) + t.Run("no usable PM", func(t *testing.T) { + store := newFakeStore() + user, acct := sponsorUser(store) + store.hasPMByAccount[acct] = false + _, err := orgSvc(store).SetOrgDesignation(context.Background(), cycle.SetOrgDesignationRequest{ + OrgID: uuid.New(), ActingUserID: user, Funding: "sponsor", + }) + requireCode(t, err, billing.CodeInvalidInput) + require.Empty(t, store.orgDesignations) + require.Empty(t, store.activation) + }) +} + +func TestSetOrgDesignation_Validation(t *testing.T) { + svc := orgSvc(newFakeStore()) + for _, tc := range []struct { + name string + req cycle.SetOrgDesignationRequest + }{ + {"nil org", cycle.SetOrgDesignationRequest{ActingUserID: uuid.New(), Funding: "sponsor"}}, + {"nil acting user", cycle.SetOrgDesignationRequest{OrgID: uuid.New(), Funding: "sponsor"}}, + {"bad funding", cycle.SetOrgDesignationRequest{OrgID: uuid.New(), ActingUserID: uuid.New(), Funding: "wallet"}}, + } { + t.Run(tc.name, func(t *testing.T) { + _, err := svc.SetOrgDesignation(context.Background(), tc.req) + requireCode(t, err, billing.CodeInvalidInput) + }) + } +} + +// --- SetOrgDesignation: org funding ------------------------------------------ + +func TestSetOrgDesignation_OrgFundingDefersSweepToCardBind(t *testing.T) { + // funding='org': the designation records but the account is NOT activated + // here (the card-bind webhook stamps it), so no attach sweep runs — "the + // pointer never flips to an unfunded account". Once the account IS + // activated, a re-designation sweeps inline. + store := newFakeStore() + org := uuid.New() + app := seedOrgApp(store, org, 2) + svc := orgSvc(store) + req := cycle.SetOrgDesignationRequest{OrgID: org, ActingUserID: uuid.New(), Funding: "org"} + + resp, err := svc.SetOrgDesignation(context.Background(), req) + require.NoError(t, err) + require.False(t, resp.Activated) + require.Zero(t, resp.AttachedApps) + require.Zero(t, resp.RepointedEvents) + acct := store.accountsByOrg[org] + require.Equal(t, acct, resp.AccountID) + _, activated := store.activation[acct] + require.False(t, activated, "activation is the card-bind webhook's job") + require.Equal(t, uuid.Nil, store.apps[app].AccountID, "no sweep before funding") + require.Empty(t, store.repointCalls) + require.Zero(t, liveTimerCount(store, app)) + d := store.orgDesignations[org] + require.Equal(t, cycle.OrgFundingOrg, d.Funding) + require.Equal(t, uuid.Nil, d.SponsorAccountID) + require.Equal(t, uuid.Nil, d.SponsorUserID) + + // Card bound (activation stamped); a funding switch back to 'org' finds + // resolution already live and sweeps inline. + store.activation[acct] = orgNow.AddDate(0, 0, -1) + resp, err = svc.SetOrgDesignation(context.Background(), req) + require.NoError(t, err) + require.True(t, resp.Activated) + require.EqualValues(t, 1, resp.AttachedApps) + require.Equal(t, acct, store.apps[app].AccountID) + require.Equal(t, 2, liveTimerCount(store, app)) +} + +// --- GetOrgDesignation -------------------------------------------------------- + +func TestGetOrgDesignation_NotFoundStillEchoesBacklog(t *testing.T) { + // A never-designated org reports Found=false but STILL carries the live + // backlog estimate — the number the designation UI must disclose. + store := newFakeStore() + org := uuid.New() + store.orgBacklog[org] = 555 + + resp, err := orgSvc(store).GetOrgDesignation(context.Background(), cycle.GetOrgDesignationRequest{OrgID: org}) + require.NoError(t, err) + require.False(t, resp.Found) + require.EqualValues(t, 555, resp.PendingBacklogMicros) + require.Equal(t, uuid.Nil, resp.AccountID) + require.False(t, resp.Activated) + + _, err = orgSvc(store).GetOrgDesignation(context.Background(), cycle.GetOrgDesignationRequest{}) + requireCode(t, err, billing.CodeInvalidInput) +} + +func TestGetOrgDesignation_FoundReportsFundingState(t *testing.T) { + store := newFakeStore() + user, _ := sponsorUser(store) + org := uuid.New() + svc := orgSvc(store) + _, err := svc.SetOrgDesignation(context.Background(), cycle.SetOrgDesignationRequest{ + OrgID: org, ActingUserID: user, Funding: "sponsor", + }) + require.NoError(t, err) + store.orgBacklog[org] = 999 // live estimate at read time + + resp, err := svc.GetOrgDesignation(context.Background(), cycle.GetOrgDesignationRequest{OrgID: org}) + require.NoError(t, err) + require.True(t, resp.Found) + require.Equal(t, "sponsor", resp.Funding) + require.Equal(t, user, resp.SponsorUserID) + require.Equal(t, store.accountsByOrg[org], resp.AccountID) + require.True(t, resp.Activated) + require.EqualValues(t, 999, resp.PendingBacklogMicros) +} + +// --- RevokeSponsorship --------------------------------------------------------- + +func TestRevokeSponsorship_IdempotentWhenAbsent(t *testing.T) { + resp, err := orgSvc(newFakeStore()).RevokeSponsorship(context.Background(), cycle.RevokeSponsorshipRequest{ + OrgID: uuid.New(), ActingUserID: uuid.New(), + }) + require.NoError(t, err) + require.False(t, resp.Revoked) +} + +func TestRevokeSponsorship_OnlyTheCurrentSponsorOfASponsorDesignation(t *testing.T) { + store := newFakeStore() + user, _ := sponsorUser(store) + org := uuid.New() + svc := orgSvc(store) + _, err := svc.SetOrgDesignation(context.Background(), cycle.SetOrgDesignationRequest{ + OrgID: org, ActingUserID: user, Funding: "sponsor", + }) + require.NoError(t, err) + + // A non-sponsor caller (org admin, whoever) cannot revoke — the self-revoke + // is authorized by BEING the sponsor, and the designation survives. + _, err = svc.RevokeSponsorship(context.Background(), cycle.RevokeSponsorshipRequest{ + OrgID: org, ActingUserID: uuid.New(), + }) + requireCode(t, err, billing.CodeInvalidInput) + require.Contains(t, store.orgDesignations, org) + + // The sponsor revokes: the designation row is gone (the org drops to + // unbilled until re-designation). + resp, err := svc.RevokeSponsorship(context.Background(), cycle.RevokeSponsorshipRequest{ + OrgID: org, ActingUserID: user, + }) + require.NoError(t, err) + require.True(t, resp.Revoked) + require.NotContains(t, store.orgDesignations, org) +} + +func TestRevokeSponsorship_RejectsOrgFundedDesignation(t *testing.T) { + // funding='org' has no sponsorship to revoke — the org PM family is + // managed through the card flows, not this endpoint. + store := newFakeStore() + org, user := uuid.New(), uuid.New() + svc := orgSvc(store) + _, err := svc.SetOrgDesignation(context.Background(), cycle.SetOrgDesignationRequest{ + OrgID: org, ActingUserID: user, Funding: "org", + }) + require.NoError(t, err) + + _, err = svc.RevokeSponsorship(context.Background(), cycle.RevokeSponsorshipRequest{ + OrgID: org, ActingUserID: user, + }) + requireCode(t, err, billing.CodeInvalidInput) + require.Contains(t, store.orgDesignations, org) +} + +// --- RepointOrgUsage ----------------------------------------------------------- + +func TestRepointOrgUsage_UnfundedReportsNotFundedWithoutError(t *testing.T) { + // No funded designation → Funded=false, nothing swept, and NOT an error — + // callers may fire optimistically. + store := newFakeStore() + org := uuid.New() + app := seedOrgApp(store, org, 2) + + resp, err := orgSvc(store).RepointOrgUsage(context.Background(), cycle.RepointOrgUsageRequest{OrgID: org}) + require.NoError(t, err) + require.False(t, resp.Funded) + require.Equal(t, uuid.Nil, store.apps[app].AccountID) + require.Empty(t, store.repointCalls) + + _, err = orgSvc(store).RepointOrgUsage(context.Background(), cycle.RepointOrgUsageRequest{}) + requireCode(t, err, billing.CodeInvalidInput) +} + +func TestRepointOrgUsage_FundedRunsAttachSweep(t *testing.T) { + // The card-bind completion path: a funding='org' designation whose account + // has since activated sweeps — roster attached, events repointed into the + // account's current open window, timers synthesized per live app. + store := newFakeStore() + org := uuid.New() + app := seedOrgApp(store, org, 2) + store.orgNullEvents[org] = 4 + svc := orgSvc(store) + _, err := svc.SetOrgDesignation(context.Background(), cycle.SetOrgDesignationRequest{ + OrgID: org, ActingUserID: uuid.New(), Funding: "org", + }) + require.NoError(t, err) + acct := store.accountsByOrg[org] + store.activation[acct] = orgNow.AddDate(0, 0, -2) // card bound Jul 4 → anchor day 4 + + resp, err := svc.RepointOrgUsage(context.Background(), cycle.RepointOrgUsageRequest{OrgID: org}) + require.NoError(t, err) + require.True(t, resp.Funded) + require.Equal(t, acct, resp.AccountID) + require.EqualValues(t, 1, resp.AttachedApps) + require.EqualValues(t, 4, resp.RepointedEvents) + require.Equal(t, acct, store.apps[app].AccountID) + require.Equal(t, []repointCall{{org, acct, time.Date(2026, 7, 4, 0, 0, 0, 0, time.UTC)}}, store.repointCalls) + require.Equal(t, 2, liveTimerCount(store, app)) + + // Re-fire (crashed-sweep recovery): idempotent — nothing new attaches or + // repoints, the timer set stays reconciled. + resp, err = svc.RepointOrgUsage(context.Background(), cycle.RepointOrgUsageRequest{OrgID: org}) + require.NoError(t, err) + require.True(t, resp.Funded) + require.Zero(t, resp.AttachedApps) + require.Zero(t, resp.RepointedEvents) + require.Equal(t, 2, liveTimerCount(store, app)) +} diff --git a/internal/account/cycle/proration.go b/internal/account/cycle/proration.go index f361e2f..eaf7588 100644 --- a/internal/account/cycle/proration.go +++ b/internal/account/cycle/proration.go @@ -216,6 +216,15 @@ func (s *Service) ChargeCreationProration(ctx context.Context, appID uuid.UUID) return &ProrationResult{AppID: appID, Status: ProrationStatusDeleted}, nil } + // UNBILLED org roster row (NULL account, migration 041): no payer exists + // yet. The sweep's work list (AppsPendingProration) already excludes these; + // this guards the direct path. Same transient posture as the unactivated + // skip — the RepointOrgUsage attach sweep backfills account_id and a later + // sweep evaluates the app normally. + if app.AccountID == uuid.Nil { + return &ProrationResult{AppID: appID, Status: ProrationStatusUnactivated}, nil + } + // Activation gate (D1d), same posture as the boundary spine: an // unactivated account (never bound a card) is never charged. activatedAt, activated, err := s.store.AccountActivation(ctx, app.AccountID) diff --git a/internal/account/cycle/service.go b/internal/account/cycle/service.go index 8feef99..a310278 100644 --- a/internal/account/cycle/service.go +++ b/internal/account/cycle/service.go @@ -72,15 +72,26 @@ func (s *Service) offSessionChargePermitted(ctx context.Context, accountID uuid. // skip status, transient/retried); a usable PM implies a Stripe Customer (a // card can't attach without one) -> an empty custID here is an anomaly, // surfaced as an error rather than silently auto-creating a Customer. +// +// The FUNDING HOP (org-billing D1): an org account whose designation names a +// sponsor gates on — and charges — the SPONSOR's default PM + Stripe +// customer; every other account funds itself. Resolved here, at charge time, +// so a designation switch re-routes only future charges; everything else +// (run rows, invoice mirror, ms_charge_ref) stays keyed to accountID, and a +// sponsor revoke degrades to the ordinary transient no_pm skip. func (s *Service) resolveChargeableCustomer(ctx context.Context, accountID uuid.UUID) (custID string, ok bool, err error) { - hasPM, err := s.store.HasUsableDefaultPM(ctx, accountID) + fundingID, err := s.store.ChargeFundingAccount(ctx, accountID) + if err != nil { + return "", false, billing.Internal("funding account lookup failed", err) + } + hasPM, err := s.store.HasUsableDefaultPM(ctx, fundingID) if err != nil { return "", false, billing.Internal("usable PM check failed", err) } if !hasPM { return "", false, nil } - custID, err = s.store.AccountStripeCustomer(ctx, accountID) + custID, err = s.store.AccountStripeCustomer(ctx, fundingID) if err != nil { return "", false, billing.Internal("stripe customer lookup failed", err) } diff --git a/internal/account/cycle/service_test.go b/internal/account/cycle/service_test.go index 5080f09..db63a78 100644 --- a/internal/account/cycle/service_test.go +++ b/internal/account/cycle/service_test.go @@ -73,6 +73,26 @@ type fakeStore struct { activation map[uuid.UUID]time.Time baseSnapshots map[snapKey]fakeBaseSnapshot + // per-account overrides for HasUsableDefaultPM / AccountStripeCustomer + // (absent → the flat hasPM / stripeCustomer defaults above). The org + // funding-hop tests need the sponsor and org accounts to differ. + hasPMByAccount map[uuid.UUID]bool + stripeCustomerByAccount map[uuid.UUID]string + + // org-billing state (migration 041). accountsByOrg models the + // EnsureOrgAccount get-or-create; orgDesignations the designation rows; + // appOwnerOrg the roster rows' owner_org_id stamp (cycle.AppMirror does + // not carry it); orgBacklog seeds OrgUnbilledBacklogMicros; orgNullEvents + // seeds each org's pending NULL-account event count, consumed by the + // repoint sweep (swept rows never match again); repointCalls records the + // sweep's events half so tests can assert the window clamp. + accountsByOrg map[uuid.UUID]uuid.UUID + orgDesignations map[uuid.UUID]cycle.OrgDesignation + appOwnerOrg map[uuid.UUID]uuid.UUID + orgBacklog map[uuid.UUID]int64 + orgNullEvents map[uuid.UUID]int64 + repointCalls []repointCall + // per-module install-timer state (migration 033). timers models // ms_billing.app_module_overage_timers keyed by surrogate id; each row's // removed/graceResolved/graceCharged* fields mirror the columns the FIFO + @@ -178,6 +198,14 @@ type markedRun struct { totalCents int64 } +// repointCall records one RepointOrgNullAccountEvents call — orgID/accountID +// plus the windowStart the sweep clamped the swept events into. +type repointCall struct { + orgID uuid.UUID + accountID uuid.UUID + windowStart time.Time +} + func newFakeStore() *fakeStore { return &fakeStore{ prices: map[string]int64{}, @@ -197,6 +225,14 @@ func newFakeStore() *fakeStore { activation: map[uuid.UUID]time.Time{}, baseSnapshots: map[snapKey]fakeBaseSnapshot{}, timers: map[uuid.UUID]*fakeTimer{}, + + hasPMByAccount: map[uuid.UUID]bool{}, + stripeCustomerByAccount: map[uuid.UUID]string{}, + accountsByOrg: map[uuid.UUID]uuid.UUID{}, + orgDesignations: map[uuid.UUID]cycle.OrgDesignation{}, + appOwnerOrg: map[uuid.UUID]uuid.UUID{}, + orgBacklog: map[uuid.UUID]int64{}, + orgNullEvents: map[uuid.UUID]int64{}, // Default collection state: arrears mode with a high credit limit + no // spend ceiling, so the existing charge tests (which don't set risk // fields) flow through the gate to the charge path unchanged. Risk tests @@ -313,17 +349,23 @@ func (f *fakeStore) PeriodChargedTotal(_ context.Context, _ uuid.UUID, _, _ time return f.chargedTotal, nil } -func (f *fakeStore) HasUsableDefaultPM(_ context.Context, _ uuid.UUID) (bool, error) { +func (f *fakeStore) HasUsableDefaultPM(_ context.Context, accountID uuid.UUID) (bool, error) { if f.errPM != nil { return false, f.errPM } + if v, ok := f.hasPMByAccount[accountID]; ok { + return v, nil + } return f.hasPM, nil } -func (f *fakeStore) AccountStripeCustomer(_ context.Context, _ uuid.UUID) (string, error) { +func (f *fakeStore) AccountStripeCustomer(_ context.Context, accountID uuid.UUID) (string, error) { if f.errCustomer != nil { return "", f.errCustomer } + if c, ok := f.stripeCustomerByAccount[accountID]; ok { + return c, nil + } return f.stripeCustomer, nil } @@ -479,7 +521,7 @@ func (f *fakeStore) AccountActivation(_ context.Context, accountID uuid.UUID) (t return at, ok, nil } -func (f *fakeStore) InsertAppMirror(_ context.Context, appID, accountID uuid.UUID, moduleCount int, createdAt time.Time, name string) error { +func (f *fakeStore) InsertAppMirror(_ context.Context, appID, accountID, ownerOrgID uuid.UUID, moduleCount int, createdAt time.Time, name string) error { if f.errAppInsert != nil { return f.errAppInsert } @@ -492,9 +534,122 @@ func (f *fakeStore) InsertAppMirror(_ context.Context, appID, accountID uuid.UUI CreatedAt: createdAt, Name: name, // frozen on first registration (migration 037) } + if ownerOrgID != uuid.Nil { + f.appOwnerOrg[appID] = ownerOrgID // owner_org_id stamp (migration 041); Nil = user-owned (NULL) + } + return nil +} + +// --- org account + designation fake (migration 041) ------------------------- + +func (f *fakeStore) EnsureOrgAccount(_ context.Context, orgID uuid.UUID) (uuid.UUID, error) { + if id, ok := f.accountsByOrg[orgID]; ok { + return id, nil // get-or-create: the same org always resolves the same account + } + id := uuid.New() + f.accountsByOrg[orgID] = id + return id, nil +} + +func (f *fakeStore) AccountIDByUser(_ context.Context, userID uuid.UUID) (uuid.UUID, bool, error) { + id, ok := f.accountsByUser[userID] + return id, ok, nil +} + +func (f *fakeStore) OrgAccountID(_ context.Context, orgID uuid.UUID) (uuid.UUID, bool, error) { + id, ok := f.accountsByOrg[orgID] + return id, ok, nil +} + +func (f *fakeStore) OrgDesignation(_ context.Context, orgID uuid.UUID) (cycle.OrgDesignation, bool, error) { + d, ok := f.orgDesignations[orgID] + return d, ok, nil +} + +func (f *fakeStore) UpsertOrgDesignation(_ context.Context, d cycle.OrgDesignation) error { + f.orgDesignations[d.OrgID] = d // re-designation overwrites in place + return nil +} + +func (f *fakeStore) DeleteOrgDesignation(_ context.Context, orgID uuid.UUID) (bool, error) { + _, existed := f.orgDesignations[orgID] + delete(f.orgDesignations, orgID) + return existed, nil +} + +func (f *fakeStore) ResolveOrgFundedAccount(_ context.Context, orgID uuid.UUID) (uuid.UUID, bool, error) { + // Mirrors the SQL's single funded gate: a designation row exists AND the + // org's account row is activated. + if _, designated := f.orgDesignations[orgID]; !designated { + return uuid.Nil, false, nil + } + acct, ok := f.accountsByOrg[orgID] + if !ok { + return uuid.Nil, false, nil + } + if _, activated := f.activation[acct]; !activated { + return uuid.Nil, false, nil + } + return acct, true, nil +} + +func (f *fakeStore) ActivateAccountIfUnset(_ context.Context, accountID uuid.UUID, at time.Time) error { + if _, ok := f.activation[accountID]; !ok { + f.activation[accountID] = at // the anchor is immutable once set (ADR 0006) + } return nil } +func (f *fakeStore) OrgUnbilledBacklogMicros(_ context.Context, orgID uuid.UUID) (int64, error) { + return f.orgBacklog[orgID], nil +} + +func (f *fakeStore) AttachOrgAppsToAccount(_ context.Context, orgID, accountID uuid.UUID) (int64, error) { + // WHERE owner_org_id = $1 AND account_id IS NULL — attached rows never + // match again (idempotent sweep). + var n int64 + for id, app := range f.apps { + if f.appOwnerOrg[id] == orgID && app.AccountID == uuid.Nil { + app.AccountID = accountID + f.apps[id] = app + n++ + } + } + return n, nil +} + +func (f *fakeStore) RepointOrgNullAccountEvents(_ context.Context, orgID, accountID uuid.UUID, windowStart time.Time) (int64, error) { + f.repointCalls = append(f.repointCalls, repointCall{orgID, accountID, windowStart}) + n := f.orgNullEvents[orgID] + delete(f.orgNullEvents, orgID) // swept events never match again (account_id IS NULL) + return n, nil +} + +func (f *fakeStore) OrgLiveAppIDs(_ context.Context, orgID uuid.UUID) ([]uuid.UUID, error) { + var out []uuid.UUID + for id, app := range f.apps { + if f.appOwnerOrg[id] == orgID && !app.Deleted { + out = append(out, id) + } + } + sort.Slice(out, func(i, j int) bool { return out[i].String() < out[j].String() }) + return out, nil +} + +func (f *fakeStore) ChargeFundingAccount(_ context.Context, accountID uuid.UUID) (uuid.UUID, error) { + // Identity, unless the account is an org account whose designation names a + // sponsor — mirrors the SQL's LEFT JOIN + COALESCE (D1 funding hop). + for orgID, acct := range f.accountsByOrg { + if acct != accountID { + continue + } + if d, ok := f.orgDesignations[orgID]; ok && d.Funding == cycle.OrgFundingSponsor { + return d.SponsorAccountID, nil + } + } + return accountID, nil +} + func (f *fakeStore) SetAppName(_ context.Context, appID uuid.UUID, name string) error { if app, ok := f.apps[appID]; ok && !app.Deleted { // no-op once deleted (WHERE deleted_at IS NULL) app.Name = name diff --git a/internal/account/cycle/store.go b/internal/account/cycle/store.go index 80ca04f..cbf0e00 100644 --- a/internal/account/cycle/store.go +++ b/internal/account/cycle/store.go @@ -201,8 +201,79 @@ type Store interface { // InsertAppMirror registers a ms_billing.apps roster row idempotently // (ON CONFLICT (app_id) DO NOTHING — a retry never rewrites the original // created_at / module_count / name, which anchor the proration + freeze the - // display name). name "" writes NULL. - InsertAppMirror(ctx context.Context, appID, accountID uuid.UUID, moduleCount int, createdAt time.Time, name string) error + // display name). name "" writes NULL. accountID uuid.Nil registers an + // UNBILLED org roster row (NULL account, migration 041); ownerOrgID is the + // org principal for org-owned apps (uuid.Nil = user-owned). + InsertAppMirror(ctx context.Context, appID, accountID, ownerOrgID uuid.UUID, moduleCount int, createdAt time.Time, name string) error + + // EnsureOrgAccount resolves the org's billing account, creating the row if + // none exists yet — the org twin of EnsureAccountForUser (advisory-locked + // get-or-create, namespace 'lbto'). No Stripe Customer is created here. + EnsureOrgAccount(ctx context.Context, orgID uuid.UUID) (uuid.UUID, error) + + // AccountIDByUser resolves a user's EXISTING billing account (Nil, false + // when none). The sponsor-designation lookup: a sponsor must already have + // an account with a usable default PM — designation never creates one. + AccountIDByUser(ctx context.Context, userID uuid.UUID) (uuid.UUID, bool, error) + + // OrgAccountID resolves the org's EXISTING account row (Nil, false when + // none), regardless of designation or activation — the read behind + // GetOrgDesignation's account echo. + OrgAccountID(ctx context.Context, orgID uuid.UUID) (uuid.UUID, bool, error) + + // OrgDesignation reads the org's funding designation row. found=false → + // the org never designated (unbilled). + OrgDesignation(ctx context.Context, orgID uuid.UUID) (OrgDesignation, bool, error) + + // UpsertOrgDesignation writes the org's funding choice; a re-designation + // overwrites in place (funding switches change only which instrument + // future invoice finalization charges — attribution never moves, D1). + UpsertOrgDesignation(ctx context.Context, d OrgDesignation) error + + // DeleteOrgDesignation is the sponsor self-revoke: the org drops back to + // unbilled until re-designation. deleted=false when no row existed + // (idempotent revoke). + DeleteOrgDesignation(ctx context.Context, orgID uuid.UUID) (bool, error) + + // ResolveOrgFundedAccount is THE org account resolution (ingest, reads, + // RegisterApp): the org's own account, gated on a designation row existing + // AND the account being activated — "the pointer never flips to an + // unfunded account" (D1). found=false → the org is unbilled. + ResolveOrgFundedAccount(ctx context.Context, orgID uuid.UUID) (uuid.UUID, bool, error) + + // ActivateAccountIfUnset stamps the ADR-0006 activation anchor when the + // org account activates by SPONSOR designation (anchor = designation day; + // the card-bind webhook stamps the funding='org' case). Idempotent — the + // anchor is immutable once set. + ActivateAccountIfUnset(ctx context.Context, accountID uuid.UUID, at time.Time) error + + // OrgUnbilledBacklogMicros estimates the org's pre-designation unbilled + // backlog (NULL-account events scoped through the roster's owner_org_id), + // priced like the live bill display — the DISCLOSURE figure shown before + // the designating user confirms. + OrgUnbilledBacklogMicros(ctx context.Context, orgID uuid.UUID) (int64, error) + + // AttachOrgAppsToAccount backfills account_id onto the org's unbilled + // roster rows (the roster half of the RepointOrgUsage sweep); returns the + // attached-row count. + AttachOrgAppsToAccount(ctx context.Context, orgID, accountID uuid.UUID) (int64, error) + + // RepointOrgNullAccountEvents folds the org's NULL-account events into its + // funded account, clamping recorded_at up to windowStart (the account's + // current open window) so every backfilled event bills in the first period + // that closes after designation — original instants audit to + // repointed_from (migration 041). Returns the swept-event count. + RepointOrgNullAccountEvents(ctx context.Context, orgID, accountID uuid.UUID, windowStart time.Time) (int64, error) + + // OrgLiveAppIDs lists the org's live roster rows — the attach sweep + // reconciles each one's timers after account_id backfills. + OrgLiveAppIDs(ctx context.Context, orgID uuid.UUID) ([]uuid.UUID, error) + + // ChargeFundingAccount maps an account to the account whose Stripe + // customer / default PM pays its invoices: itself, unless it is an org + // account whose designation names a sponsor (D1 funding hop). Resolved at + // charge time by resolveChargeableCustomer. + ChargeFundingAccount(ctx context.Context, accountID uuid.UUID) (uuid.UUID, error) // SetAppName updates the frozen display name (SyncAppModules rename); // no-op once the app is deleted (WHERE deleted_at IS NULL), freezing the @@ -1096,11 +1167,14 @@ func (s *pgxStore) AccountActivation(ctx context.Context, accountID uuid.UUID) ( return at.Time, true, nil } -func (s *pgxStore) InsertAppMirror(ctx context.Context, appID, accountID uuid.UUID, moduleCount int, createdAt time.Time, name string) error { +func (s *pgxStore) InsertAppMirror(ctx context.Context, appID, accountID, ownerOrgID uuid.UUID, moduleCount int, createdAt time.Time, name string) error { // RowsAffected 0 = a retry hit ON CONFLICT DO NOTHING — success either way. + // accountID uuid.Nil → NULL: an UNBILLED org roster row awaiting funding + // designation (migration 041); ownerOrgID uuid.Nil → NULL for user-owned apps. _, err := s.q.InsertAppMirror(ctx, db.InsertAppMirrorParams{ AppID: appID.String(), - AccountID: accountID.String(), + AccountID: pgUUIDOrNull(accountID), + OwnerOrgID: pgUUIDOrNull(ownerOrgID), ModuleCount: int32(moduleCount), //nolint:gosec // RegisterApp validates 0 ≤ count ≤ maxModuleCount (100000), far below int32 max CreatedAt: createdAt, Name: pgtype.Text{String: name, Valid: name != ""}, // NULL when the caller omits a name (frontend falls back) @@ -1130,13 +1204,11 @@ func (s *pgxStore) AppMirror(ctx context.Context, appID uuid.UUID) (AppMirror, b if err != nil { return AppMirror{}, false, err } - acct, err := uuid.Parse(row.AccountID) - if err != nil { - return AppMirror{}, false, err - } return AppMirror{ - AppID: app, - AccountID: acct, + AppID: app, + // Nil = an UNBILLED org roster row (NULL account, migration 041) — the + // charge legs and the timer reconcile all skip it until attach. + AccountID: uuidFromPg(row.AccountID), ModuleCount: int(row.ModuleCount), CreatedModuleCount: int(row.CreatedModuleCount), CreatedAt: row.CreatedAt, @@ -1214,13 +1286,12 @@ func (s *pgxStore) lockAndReadChargeableApp(ctx context.Context, appID uuid.UUID if err != nil { return AppMirror{}, 0, "", false, err } - acct, err := uuid.Parse(row.AccountID) - if err != nil { - return AppMirror{}, 0, "", false, err - } locked = AppMirror{ - AppID: app, - AccountID: acct, + AppID: app, + // Nil (NULL account — unbilled org roster row) never reaches the charge: + // AppsPendingProration excludes such rows and ChargeCreationProration + // guards the direct path, so this decode just carries the state through. + AccountID: uuidFromPg(row.AccountID), ModuleCount: int(row.ModuleCount), CreatedModuleCount: int(row.CreatedModuleCount), CreatedAt: row.CreatedAt, @@ -1519,8 +1590,17 @@ func (s *pgxStore) ReconcileModuleTimersToTarget(ctx context.Context, appID uuid } switch { case target > live: + // An UNBILLED org roster row (NULL account, migration 041) synthesizes + // no timers — app_module_overage_timers.account_id is NOT NULL and there + // is no account to tier on. The RepointOrgUsage attach sweep reconciles + // the app again once account_id is backfilled, anchoring fresh timers at + // the designation instant (prospective billing, org-billing D1). Shrinks + // and removals below still run (they key on app_id only). + if !row.AccountID.Valid { + return tx.Commit(ctx) + } if err := qtx.InsertModuleOverageTimers(ctx, db.InsertModuleOverageTimersParams{ - AccountID: row.AccountID, + AccountID: uuid.UUID(row.AccountID.Bytes).String(), AppID: appID.String(), InstalledAt: installedAt, GraceExpiresAt: graceExpiresAt, @@ -1674,6 +1754,197 @@ func (s *pgxStore) CoCreatedOverModuleTimers(ctx context.Context, accountID, app return parseUUIDs(ids) } +// EnsureOrgAccount mirrors EnsureAccountForUser on the org leg: the SAME +// advisory-locked get-or-create shape, serialized on the exported org +// namespace ('lbto') because the accounts table has no owner UNIQUE +// constraint — the lock IS the uniqueness guard. +func (s *pgxStore) EnsureOrgAccount(ctx context.Context, orgID uuid.UUID) (uuid.UUID, error) { + var id string + err := pgx.BeginFunc(ctx, s.pool, func(tx pgx.Tx) error { + qtx := s.q.WithTx(tx) + if err := qtx.AcquireBillingAccountUserLock(ctx, db.AcquireBillingAccountUserLockParams{ + Column1: billing.AdvisoryLockNamespaceBillingAccountOrg, + Column2: orgID.String(), + }); err != nil { + return err + } + existing, err := qtx.SelectAccountByOrg(ctx, pgtype.UUID{Bytes: orgID, Valid: true}) + if err == nil { + id = existing.ID + return nil + } + if !errors.Is(err, pgx.ErrNoRows) { + return err + } + inserted, err := qtx.InsertOrgAccount(ctx, pgtype.UUID{Bytes: orgID, Valid: true}) + if err != nil { + return err + } + id = inserted.ID + return nil + }) + if err != nil { + return uuid.Nil, err + } + return uuid.Parse(id) +} + +func (s *pgxStore) AccountIDByUser(ctx context.Context, userID uuid.UUID) (uuid.UUID, bool, error) { + id, err := s.q.AccountIDByUser(ctx, pgtype.UUID{Bytes: userID, Valid: true}) + if errors.Is(err, pgx.ErrNoRows) { + return uuid.Nil, false, nil + } + if err != nil { + return uuid.Nil, false, err + } + parsed, err := uuid.Parse(id) + if err != nil { + return uuid.Nil, false, err + } + return parsed, true, nil +} + +func (s *pgxStore) OrgAccountID(ctx context.Context, orgID uuid.UUID) (uuid.UUID, bool, error) { + row, err := s.q.SelectAccountByOrg(ctx, pgtype.UUID{Bytes: orgID, Valid: true}) + if errors.Is(err, pgx.ErrNoRows) { + return uuid.Nil, false, nil + } + if err != nil { + return uuid.Nil, false, err + } + parsed, err := uuid.Parse(row.ID) + if err != nil { + return uuid.Nil, false, err + } + return parsed, true, nil +} + +func (s *pgxStore) OrgDesignation(ctx context.Context, orgID uuid.UUID) (OrgDesignation, bool, error) { + row, err := s.q.GetOrgDesignation(ctx, orgID.String()) + if errors.Is(err, pgx.ErrNoRows) { + return OrgDesignation{}, false, nil + } + if err != nil { + return OrgDesignation{}, false, err + } + org, err := uuid.Parse(row.OrgID) + if err != nil { + return OrgDesignation{}, false, err + } + updatedBy, err := uuid.Parse(row.UpdatedBy) + if err != nil { + return OrgDesignation{}, false, err + } + return OrgDesignation{ + OrgID: org, + Funding: OrgFunding(row.Funding), + SponsorAccountID: uuidFromPg(row.SponsorAccountID), + SponsorUserID: uuidFromPg(row.SponsorUserID), + DisclosedBacklogMicros: row.DisclosedBacklogMicros, + UpdatedBy: updatedBy, + }, true, nil +} + +func (s *pgxStore) UpsertOrgDesignation(ctx context.Context, d OrgDesignation) error { + return s.q.UpsertOrgDesignation(ctx, db.UpsertOrgDesignationParams{ + OrgID: d.OrgID.String(), + Funding: string(d.Funding), + SponsorAccountID: pgUUIDOrNull(d.SponsorAccountID), + SponsorUserID: pgUUIDOrNull(d.SponsorUserID), + DisclosedBacklogMicros: d.DisclosedBacklogMicros, + UpdatedBy: d.UpdatedBy.String(), + }) +} + +func (s *pgxStore) DeleteOrgDesignation(ctx context.Context, orgID uuid.UUID) (bool, error) { + rows, err := s.q.DeleteOrgDesignation(ctx, orgID.String()) + if err != nil { + return false, err + } + return rows > 0, nil +} + +func (s *pgxStore) ResolveOrgFundedAccount(ctx context.Context, orgID uuid.UUID) (uuid.UUID, bool, error) { + id, err := s.q.ResolveOrgFundedAccount(ctx, orgID.String()) + if errors.Is(err, pgx.ErrNoRows) { + return uuid.Nil, false, nil // no designation, or not yet activated — unbilled + } + if err != nil { + return uuid.Nil, false, err + } + parsed, err := uuid.Parse(id) + if err != nil { + return uuid.Nil, false, err + } + return parsed, true, nil +} + +func (s *pgxStore) ActivateAccountIfUnset(ctx context.Context, accountID uuid.UUID, at time.Time) error { + // 0 rows = already activated — the anchor is immutable, a no-op by design. + _, err := s.q.ActivateAccountIfUnset(ctx, db.ActivateAccountIfUnsetParams{ + ID: accountID.String(), + ActivatedAt: pgtype.Timestamptz{Time: at, Valid: true}, + }) + return err +} + +func (s *pgxStore) OrgUnbilledBacklogMicros(ctx context.Context, orgID uuid.UUID) (int64, error) { + n, err := s.q.OrgUnbilledBacklogMicros(ctx, pgtype.UUID{Bytes: orgID, Valid: true}) + if err != nil { + return 0, err + } + // Same single decode-and-round point as every live money read. + return usage.MicrosFromNumeric(n) +} + +func (s *pgxStore) AttachOrgAppsToAccount(ctx context.Context, orgID, accountID uuid.UUID) (int64, error) { + return s.q.AttachOrgAppsToAccount(ctx, db.AttachOrgAppsToAccountParams{ + OwnerOrgID: pgtype.UUID{Bytes: orgID, Valid: true}, + AccountID: pgtype.UUID{Bytes: accountID, Valid: true}, + }) +} + +func (s *pgxStore) RepointOrgNullAccountEvents(ctx context.Context, orgID, accountID uuid.UUID, windowStart time.Time) (int64, error) { + return s.q.RepointOrgNullAccountEvents(ctx, db.RepointOrgNullAccountEventsParams{ + AccountID: accountID.String(), + WindowStart: windowStart, + OrgID: orgID.String(), + }) +} + +func (s *pgxStore) OrgLiveAppIDs(ctx context.Context, orgID uuid.UUID) ([]uuid.UUID, error) { + rows, err := s.q.OrgLiveAppIDs(ctx, pgtype.UUID{Bytes: orgID, Valid: true}) + if err != nil { + return nil, err + } + return parseUUIDs(rows) +} + +func (s *pgxStore) ChargeFundingAccount(ctx context.Context, accountID uuid.UUID) (uuid.UUID, error) { + id, err := s.q.ChargeFundingAccount(ctx, accountID.String()) + if err != nil { + return uuid.Nil, err // incl. ErrNoRows: a missing accounts row is a code bug, not a skip + } + return uuid.Parse(id) +} + +// pgUUIDOrNull maps uuid.Nil to a SQL NULL and a real UUID to a valid +// pgtype.UUID — the encode twin of uuidFromPg. +func pgUUIDOrNull(id uuid.UUID) pgtype.UUID { + if id == uuid.Nil { + return pgtype.UUID{} // Valid: false → NULL + } + return pgtype.UUID{Bytes: id, Valid: true} +} + +// uuidFromPg decodes a nullable uuid column: NULL → uuid.Nil. +func uuidFromPg(u pgtype.UUID) uuid.UUID { + if !u.Valid { + return uuid.Nil + } + return uuid.UUID(u.Bytes) +} + // parseUUIDs parses a slice of UUID-as-string account ids (the form the sqlc // NOT NULL uuid → string override yields) into uuid.UUID. func parseUUIDs(ids []string) ([]uuid.UUID, error) { diff --git a/internal/account/cycle/store_prorationlock_integration_test.go b/internal/account/cycle/store_prorationlock_integration_test.go index 108c7fd..0665f29 100644 --- a/internal/account/cycle/store_prorationlock_integration_test.go +++ b/internal/account/cycle/store_prorationlock_integration_test.go @@ -43,7 +43,7 @@ func TestChargeProrationLocked_Integration_LockNotHeldAcrossStripeCall(t *testin acct := seedAccount(t, pool) appID := uuid.New() - require.NoError(t, store.InsertAppMirror(ctx, appID, acct, 0, mustTime(t, "2026-07-01T08:00:00Z"), "")) + require.NoError(t, store.InsertAppMirror(ctx, appID, acct, uuid.Nil, 0, mustTime(t, "2026-07-01T08:00:00Z"), "")) insideCallback := make(chan struct{}) release := make(chan struct{}) @@ -96,7 +96,7 @@ func TestChargeProrationLocked_Integration_ConcurrentDeleteDoesNotBlockOnLock(t acct := seedAccount(t, pool) appID := uuid.New() - require.NoError(t, store.InsertAppMirror(ctx, appID, acct, 0, mustTime(t, "2026-07-01T08:00:00Z"), "")) + require.NoError(t, store.InsertAppMirror(ctx, appID, acct, uuid.Nil, 0, mustTime(t, "2026-07-01T08:00:00Z"), "")) insideCallback := make(chan struct{}) release := make(chan struct{}) @@ -155,7 +155,7 @@ func TestChargeProrationLocked_Integration_PersistsLargeAutoCollectFlag(t *testi acct := seedAccount(t, pool) appID := uuid.New() - require.NoError(t, store.InsertAppMirror(ctx, appID, acct, 0, mustTime(t, "2026-07-01T08:00:00Z"), "")) + require.NoError(t, store.InsertAppMirror(ctx, appID, acct, uuid.Nil, 0, mustTime(t, "2026-07-01T08:00:00Z"), "")) pc := mkProrationCharge(acct, appID, "in_large_flag", mustTime(t, "2026-07-04T00:00:00Z")) pc.Invoice.IsLargeAutoCollect = true diff --git a/internal/account/db/apps.sql.go b/internal/account/db/apps.sql.go index d8a3d96..220bb6c 100644 --- a/internal/account/db/apps.sql.go +++ b/internal/account/db/apps.sql.go @@ -16,6 +16,7 @@ const appsPendingProration = `-- name: AppsPendingProration :many SELECT app_id FROM ms_billing.apps WHERE created_at <= $1::timestamptz + AND account_id IS NOT NULL AND proration_invoice_id IS NULL AND (deleted_at IS NULL OR deleted_at >= created_at + make_interval(hours => $2::int)) @@ -40,6 +41,8 @@ type AppsPendingProrationParams struct { // HOURS (D5 — session-TZ/DST safety). proration_skipped_at IS NULL excludes // apps permanently skipped as a would-be retroactive catch-up (migration 031, // D1d). Ordered by created_at so the oldest pending app charges first. +// account_id IS NOT NULL excludes UNBILLED org roster rows (migration 041) — +// an org app enters this sweep only once RepointOrgUsage attaches it. func (q *Queries) AppsPendingProration(ctx context.Context, arg AppsPendingProrationParams) ([]string, error) { rows, err := q.db.Query(ctx, appsPendingProration, arg.CreatedBefore, arg.GraceHours) if err != nil { @@ -97,17 +100,18 @@ func (q *Queries) InsertAdvanceBaseSnapshot(ctx context.Context, arg InsertAdvan const insertAppMirror = `-- name: InsertAppMirror :execrows -INSERT INTO ms_billing.apps (app_id, account_id, module_count, created_module_count, created_at, name) -VALUES ($1, $2, $3, $3, $4, $5) +INSERT INTO ms_billing.apps (app_id, account_id, module_count, created_module_count, created_at, name, owner_org_id) +VALUES ($1, $2, $3, $3, $4, $5, $6) ON CONFLICT (app_id) DO NOTHING ` type InsertAppMirrorParams struct { AppID string `json:"app_id"` - AccountID string `json:"account_id"` + AccountID pgtype.UUID `json:"account_id"` ModuleCount int32 `json:"module_count"` CreatedAt time.Time `json:"created_at"` Name pgtype.Text `json:"name"` + OwnerOrgID pgtype.UUID `json:"owner_org_id"` } // Queries backing the ms_billing.apps mirror (migration 027) — the base-fee @@ -126,7 +130,11 @@ type InsertAppMirrorParams struct { // though both are success. // name ($5) is frozen from the FIRST registration like created_at / // module_count (ON CONFLICT DO NOTHING keeps the first value across retries); -// SyncAppModules updates it while the app is live (SetAppName). +// SyncAppModules updates it while the app is live (SetAppName). account_id +// ($2) is NULL for an org-owned app whose org has not designated funding yet +// (an UNBILLED roster row, migration 041); owner_org_id ($6) is stamped on +// every org-owned registration — funded or not — so the RepointOrgUsage sweep +// can scope the org's NULL-account events through the roster. func (q *Queries) InsertAppMirror(ctx context.Context, arg InsertAppMirrorParams) (int64, error) { result, err := q.db.Exec(ctx, insertAppMirror, arg.AppID, @@ -134,6 +142,7 @@ func (q *Queries) InsertAppMirror(ctx context.Context, arg InsertAppMirrorParams arg.ModuleCount, arg.CreatedAt, arg.Name, + arg.OwnerOrgID, ) if err != nil { return 0, err @@ -338,7 +347,7 @@ WHERE app_id = $1 type SelectAppMirrorRow struct { AppID string `json:"app_id"` - AccountID string `json:"account_id"` + AccountID pgtype.UUID `json:"account_id"` ModuleCount int32 `json:"module_count"` CreatedModuleCount int32 `json:"created_module_count"` CreatedAt time.Time `json:"created_at"` @@ -380,7 +389,7 @@ FOR UPDATE type SelectAppMirrorForUpdateRow struct { AppID string `json:"app_id"` - AccountID string `json:"account_id"` + AccountID pgtype.UUID `json:"account_id"` ModuleCount int32 `json:"module_count"` CreatedModuleCount int32 `json:"created_module_count"` CreatedAt time.Time `json:"created_at"` diff --git a/internal/account/db/models.go b/internal/account/db/models.go index 883f5f1..5c3871d 100644 --- a/internal/account/db/models.go +++ b/internal/account/db/models.go @@ -348,7 +348,7 @@ type MsBillingAddCardRequest struct { type MsBillingApp struct { AppID string `json:"app_id"` - AccountID string `json:"account_id"` + AccountID pgtype.UUID `json:"account_id"` ModuleCount int32 `json:"module_count"` CreatedAt time.Time `json:"created_at"` ProrationInvoiceID pgtype.Text `json:"proration_invoice_id"` @@ -361,7 +361,8 @@ type MsBillingApp struct { // First instant a creation-proration charge attempt for this app reached its Stripe section; NULL = never attempted. Recovery marker (036) — a retry with this set and an unarmed guard reconciles against Stripe (ms_charge_ref app-proration:) before minting new Stripe objects. ProrationAttemptedAt pgtype.Timestamptz `json:"proration_attempted_at"` // App display name, frozen from RegisterApp's payload and updated by SyncAppModules while the app is live (gated on deleted_at IS NULL); NEVER cleared on delete — this is what lets a deleted app's historical bill still show its name. NULL for pre-037 rows / callers that omit it. - Name pgtype.Text `json:"name"` + Name pgtype.Text `json:"name"` + OwnerOrgID pgtype.UUID `json:"owner_org_id"` } type MsBillingAppBaseSnapshot struct { @@ -499,6 +500,17 @@ type MsBillingModuleVisibility struct { UpdatedAt time.Time `json:"updated_at"` } +type MsBillingOrgBillingDesignation struct { + OrgID string `json:"org_id"` + Funding string `json:"funding"` + SponsorAccountID pgtype.UUID `json:"sponsor_account_id"` + SponsorUserID pgtype.UUID `json:"sponsor_user_id"` + DisclosedBacklogMicros int64 `json:"disclosed_backlog_micros"` + UpdatedBy string `json:"updated_by"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` +} + type MsBillingPaymentMethodsMirror struct { ID string `json:"id"` AccountID string `json:"account_id"` @@ -544,6 +556,7 @@ type MsBillingUsageEvent struct { IngestedAt time.Time `json:"ingested_at"` Model pgtype.Text `json:"model"` ModuleVersion pgtype.Text `json:"module_version"` + RepointedFrom pgtype.Timestamptz `json:"repointed_from"` } type MsBillingWebhookEventsProcessed struct { diff --git a/internal/account/db/org.sql.go b/internal/account/db/org.sql.go new file mode 100644 index 0000000..d81daa2 --- /dev/null +++ b/internal/account/db/org.sql.go @@ -0,0 +1,357 @@ +// Code generated by sqlc. DO NOT EDIT. +// versions: +// sqlc v1.30.0 +// source: org.sql + +package db + +import ( + "context" + "time" + + "github.com/jackc/pgx/v5/pgtype" +) + +const activateAccountIfUnset = `-- name: ActivateAccountIfUnset :execrows +UPDATE ms_billing.accounts +SET activated_at = $2 +WHERE id = $1 AND activated_at IS NULL +` + +type ActivateAccountIfUnsetParams struct { + ID string `json:"id"` + ActivatedAt pgtype.Timestamptz `json:"activated_at"` +} + +// ActivateAccountIfUnset stamps the ADR-0006 activation anchor when the org +// account activates by SPONSOR designation (its anchor = designation day; the +// card-bind webhook stamps the funding='org' case). Idempotent — the anchor +// is immutable once set. +func (q *Queries) ActivateAccountIfUnset(ctx context.Context, arg ActivateAccountIfUnsetParams) (int64, error) { + result, err := q.db.Exec(ctx, activateAccountIfUnset, arg.ID, arg.ActivatedAt) + if err != nil { + return 0, err + } + return result.RowsAffected(), nil +} + +const attachOrgAppsToAccount = `-- name: AttachOrgAppsToAccount :execrows +UPDATE ms_billing.apps +SET account_id = $2 +WHERE owner_org_id = $1 AND account_id IS NULL +` + +type AttachOrgAppsToAccountParams struct { + OwnerOrgID pgtype.UUID `json:"owner_org_id"` + AccountID pgtype.UUID `json:"account_id"` +} + +// AttachOrgAppsToAccount backfills account_id onto the org's unbilled roster +// rows — the roster half of the RepointOrgUsage sweep. Attached rows enter +// the base-fee machinery prospectively: created_at is untouched (the D1d +// no-retroactive-catch-up rule permanently skips any creation period that +// closed before activation), and timers are synthesized fresh by the caller. +func (q *Queries) AttachOrgAppsToAccount(ctx context.Context, arg AttachOrgAppsToAccountParams) (int64, error) { + result, err := q.db.Exec(ctx, attachOrgAppsToAccount, arg.OwnerOrgID, arg.AccountID) + if err != nil { + return 0, err + } + return result.RowsAffected(), nil +} + +const chargeFundingAccount = `-- name: ChargeFundingAccount :one +SELECT COALESCE(d.sponsor_account_id, a.id)::uuid AS funding_account_id +FROM ms_billing.accounts a +LEFT JOIN ms_billing.org_billing_designations d + ON a.owner_kind = 'org' + AND d.org_id = a.owner_org_id + AND d.funding = 'sponsor' +WHERE a.id = $1 +` + +// ChargeFundingAccount maps an account to the account whose Stripe customer / +// default PM pays its invoices: itself, unless it is an org account whose +// designation says a sponsor lends the card. The charge legs resolve their +// customer + PM gate through this exactly once, at charge time — a designation +// switch between runs re-routes only future charges (design D1). +func (q *Queries) ChargeFundingAccount(ctx context.Context, id string) (string, error) { + row := q.db.QueryRow(ctx, chargeFundingAccount, id) + var funding_account_id string + err := row.Scan(&funding_account_id) + return funding_account_id, err +} + +const deleteOrgDesignation = `-- name: DeleteOrgDesignation :execrows +DELETE FROM ms_billing.org_billing_designations WHERE org_id = $1 +` + +// DeleteOrgDesignation is the sponsor self-revoke: the org drops back to +// unbilled (resolution finds no designation) until re-designation. Roster +// rows KEEP their account_id — frozen attribution never rewrites; only new +// events record NULL until the org designates again. +func (q *Queries) DeleteOrgDesignation(ctx context.Context, orgID string) (int64, error) { + result, err := q.db.Exec(ctx, deleteOrgDesignation, orgID) + if err != nil { + return 0, err + } + return result.RowsAffected(), nil +} + +const getOrgDesignation = `-- name: GetOrgDesignation :one +SELECT org_id, funding, sponsor_account_id, sponsor_user_id, + disclosed_backlog_micros, updated_by, updated_at +FROM ms_billing.org_billing_designations +WHERE org_id = $1 +` + +type GetOrgDesignationRow struct { + OrgID string `json:"org_id"` + Funding string `json:"funding"` + SponsorAccountID pgtype.UUID `json:"sponsor_account_id"` + SponsorUserID pgtype.UUID `json:"sponsor_user_id"` + DisclosedBacklogMicros int64 `json:"disclosed_backlog_micros"` + UpdatedBy string `json:"updated_by"` + UpdatedAt time.Time `json:"updated_at"` +} + +// GetOrgDesignation reads the org's funding designation row verbatim. +func (q *Queries) GetOrgDesignation(ctx context.Context, orgID string) (GetOrgDesignationRow, error) { + row := q.db.QueryRow(ctx, getOrgDesignation, orgID) + var i GetOrgDesignationRow + err := row.Scan( + &i.OrgID, + &i.Funding, + &i.SponsorAccountID, + &i.SponsorUserID, + &i.DisclosedBacklogMicros, + &i.UpdatedBy, + &i.UpdatedAt, + ) + return i, err +} + +const insertOrgAccount = `-- name: InsertOrgAccount :one +INSERT INTO ms_billing.accounts (owner_kind, owner_org_id) +VALUES ('org', $1) +RETURNING id, COALESCE(stripe_customer_id, '')::text AS stripe_customer_id +` + +type InsertOrgAccountRow struct { + ID string `json:"id"` + StripeCustomerID string `json:"stripe_customer_id"` +} + +// InsertOrgAccount creates a fresh org-owned account (the org leg of the +// advisory-locked get-or-create — the lock, namespace 'lbto', is the +// uniqueness guard exactly like the user leg's 'lbta'). +func (q *Queries) InsertOrgAccount(ctx context.Context, ownerOrgID pgtype.UUID) (InsertOrgAccountRow, error) { + row := q.db.QueryRow(ctx, insertOrgAccount, ownerOrgID) + var i InsertOrgAccountRow + err := row.Scan(&i.ID, &i.StripeCustomerID) + return i, err +} + +const orgLiveAppIDs = `-- name: OrgLiveAppIDs :many +SELECT app_id +FROM ms_billing.apps +WHERE owner_org_id = $1 AND deleted_at IS NULL +` + +// OrgLiveAppIDs lists the org's live roster rows — the timer-synthesis loop +// of the RepointOrgUsage sweep reconciles each one after attach. +func (q *Queries) OrgLiveAppIDs(ctx context.Context, ownerOrgID pgtype.UUID) ([]string, error) { + rows, err := q.db.Query(ctx, orgLiveAppIDs, ownerOrgID) + if err != nil { + return nil, err + } + defer rows.Close() + items := []string{} + for rows.Next() { + var app_id string + if err := rows.Scan(&app_id); err != nil { + return nil, err + } + items = append(items, app_id) + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +} + +const orgUnbilledBacklogMicros = `-- name: OrgUnbilledBacklogMicros :one +SELECT COALESCE(SUM( + CASE + WHEN e.metric LIKE 'infra.%' OR e.metric LIKE 'platform.%' + THEN e.value * COALESCE(md.unit_price_micros, 0) * 12 / 10 + ELSE e.value * COALESCE(md.unit_price_micros, 0) + END), 0)::numeric AS backlog_micros +FROM ms_billing.usage_events e +LEFT JOIN ms_billing.metric_definitions md + ON md.module_id = e.module_id AND md.metric = e.metric +WHERE e.account_id IS NULL + AND e.app_id IN (SELECT app_id FROM ms_billing.apps WHERE owner_org_id = $1) +` + +// OrgUnbilledBacklogMicros estimates the org's pre-designation unbilled +// backlog: every NULL-account event attributable to the org (through its +// roster rows' owner_org_id), priced exactly like the live bill display +// (AppBillLines' live branch: declared price ×1 for custom metrics, ×12/10 +// for reserved infra.*/platform.*). It is the DISCLOSURE estimate shown +// before the sponsor confirms — the authoritative charge happens later, +// through the normal rollup, once the sweep re-points the events. +func (q *Queries) OrgUnbilledBacklogMicros(ctx context.Context, ownerOrgID pgtype.UUID) (pgtype.Numeric, error) { + row := q.db.QueryRow(ctx, orgUnbilledBacklogMicros, ownerOrgID) + var backlog_micros pgtype.Numeric + err := row.Scan(&backlog_micros) + return backlog_micros, err +} + +const paymentMethodTargetForOrg = `-- name: PaymentMethodTargetForOrg :one +SELECT + pmm.stripe_payment_method_id, + COALESCE(a.stripe_customer_id, '')::text AS stripe_customer_id, + pmm.is_default +FROM ms_billing.payment_methods_mirror pmm +JOIN ms_billing.accounts a ON a.id = pmm.account_id +WHERE a.owner_kind = 'org' AND a.owner_org_id = $1 + AND pmm.id = $2 AND pmm.deleted_at IS NULL +` + +type PaymentMethodTargetForOrgParams struct { + OwnerOrgID pgtype.UUID `json:"owner_org_id"` + ID string `json:"id"` +} + +type PaymentMethodTargetForOrgRow struct { + StripePaymentMethodID string `json:"stripe_payment_method_id"` + StripeCustomerID string `json:"stripe_customer_id"` + IsDefault bool `json:"is_default"` +} + +// PaymentMethodTargetForOrg is PaymentMethodTarget's org twin: resolves an +// active payment method owned by the ORG account for detach / set-default. +func (q *Queries) PaymentMethodTargetForOrg(ctx context.Context, arg PaymentMethodTargetForOrgParams) (PaymentMethodTargetForOrgRow, error) { + row := q.db.QueryRow(ctx, paymentMethodTargetForOrg, arg.OwnerOrgID, arg.ID) + var i PaymentMethodTargetForOrgRow + err := row.Scan(&i.StripePaymentMethodID, &i.StripeCustomerID, &i.IsDefault) + return i, err +} + +const repointOrgNullAccountEvents = `-- name: RepointOrgNullAccountEvents :execrows +UPDATE ms_billing.usage_events +SET account_id = $1::uuid, + repointed_from = CASE WHEN recorded_at < $2::timestamptz + THEN recorded_at ELSE repointed_from END, + recorded_at = GREATEST(recorded_at, $2::timestamptz) +WHERE account_id IS NULL + AND app_id IN (SELECT app_id FROM ms_billing.apps WHERE owner_org_id = $3::uuid) +` + +type RepointOrgNullAccountEventsParams struct { + AccountID string `json:"account_id"` + WindowStart time.Time `json:"window_start"` + OrgID string `json:"org_id"` +} + +// RepointOrgNullAccountEvents folds the org's pre-designation NULL-account +// events into its funded account — the events half of the sweep. The rollup +// windows events by recorded_at, so an event older than the account's current +// open window (@window_start) would never fall inside ANY future window: the +// sweep CLAMPS its recorded_at to the window start — "backfilled events bill +// in the first period that closes after designation" (decision 1) — and +// preserves the original instant in repointed_from (migration 041 audit +// column). Scoped through the roster's owner_org_id so lazy USER events are +// never swept. Idempotent: account_id IS NULL never matches a swept row again. +func (q *Queries) RepointOrgNullAccountEvents(ctx context.Context, arg RepointOrgNullAccountEventsParams) (int64, error) { + result, err := q.db.Exec(ctx, repointOrgNullAccountEvents, arg.AccountID, arg.WindowStart, arg.OrgID) + if err != nil { + return 0, err + } + return result.RowsAffected(), nil +} + +const resolveOrgFundedAccount = `-- name: ResolveOrgFundedAccount :one +SELECT a.id +FROM ms_billing.org_billing_designations d +JOIN ms_billing.accounts a + ON a.owner_kind = 'org' AND a.owner_org_id = d.org_id +WHERE d.org_id = $1 + AND a.activated_at IS NOT NULL +` + +// ResolveOrgFundedAccount is THE org account resolution (ingest, reads, +// Ensure): the org's own account, gated on a designation row existing AND the +// account being activated. Sponsor designation activates immediately (a +// usable instrument exists); funding='org' activates at card bind — so the +// single activated_at gate implements "the pointer never flips to an +// unfunded account" for both modes. No row → the org is unbilled (lazy +// NULL-account events), which callers treat exactly like a missing user +// account. +func (q *Queries) ResolveOrgFundedAccount(ctx context.Context, orgID string) (string, error) { + row := q.db.QueryRow(ctx, resolveOrgFundedAccount, orgID) + var id string + err := row.Scan(&id) + return id, err +} + +const selectAccountByOrg = `-- name: SelectAccountByOrg :one + +SELECT id, COALESCE(stripe_customer_id, '')::text AS stripe_customer_id +FROM ms_billing.accounts +WHERE owner_kind = 'org' AND owner_org_id = $1 +` + +type SelectAccountByOrgRow struct { + ID string `json:"id"` + StripeCustomerID string `json:"stripe_customer_id"` +} + +// Queries backing the org-billing substrate (migration 041, design D1): +// the org account get-or-create, the funding designation, and the +// RepointOrgUsage attach/backfill sweep. All operate on ms_billing. +// SelectAccountByOrg returns the existing org-owned account row, with the +// same non-null stripe_customer_id projection as SelectAccountByUser. +func (q *Queries) SelectAccountByOrg(ctx context.Context, ownerOrgID pgtype.UUID) (SelectAccountByOrgRow, error) { + row := q.db.QueryRow(ctx, selectAccountByOrg, ownerOrgID) + var i SelectAccountByOrgRow + err := row.Scan(&i.ID, &i.StripeCustomerID) + return i, err +} + +const upsertOrgDesignation = `-- name: UpsertOrgDesignation :exec +INSERT INTO ms_billing.org_billing_designations + (org_id, funding, sponsor_account_id, sponsor_user_id, + disclosed_backlog_micros, updated_by) +VALUES ($1, $2, $3, $4, $5, $6) +ON CONFLICT (org_id) DO UPDATE SET + funding = EXCLUDED.funding, + sponsor_account_id = EXCLUDED.sponsor_account_id, + sponsor_user_id = EXCLUDED.sponsor_user_id, + disclosed_backlog_micros = EXCLUDED.disclosed_backlog_micros, + updated_by = EXCLUDED.updated_by +` + +type UpsertOrgDesignationParams struct { + OrgID string `json:"org_id"` + Funding string `json:"funding"` + SponsorAccountID pgtype.UUID `json:"sponsor_account_id"` + SponsorUserID pgtype.UUID `json:"sponsor_user_id"` + DisclosedBacklogMicros int64 `json:"disclosed_backlog_micros"` + UpdatedBy string `json:"updated_by"` +} + +// UpsertOrgDesignation writes the org's funding choice. A re-designation +// overwrites in place (funding switches change only which instrument future +// invoice finalization charges — attribution never moves, design D1). +func (q *Queries) UpsertOrgDesignation(ctx context.Context, arg UpsertOrgDesignationParams) error { + _, err := q.db.Exec(ctx, upsertOrgDesignation, + arg.OrgID, + arg.Funding, + arg.SponsorAccountID, + arg.SponsorUserID, + arg.DisclosedBacklogMicros, + arg.UpdatedBy, + ) + return err +} diff --git a/internal/account/db/queries/apps.sql b/internal/account/db/queries/apps.sql index 6d249a1..fc32bcd 100644 --- a/internal/account/db/queries/apps.sql +++ b/internal/account/db/queries/apps.sql @@ -16,9 +16,13 @@ -- name: InsertAppMirror :execrows -- name ($5) is frozen from the FIRST registration like created_at / -- module_count (ON CONFLICT DO NOTHING keeps the first value across retries); --- SyncAppModules updates it while the app is live (SetAppName). -INSERT INTO ms_billing.apps (app_id, account_id, module_count, created_module_count, created_at, name) -VALUES ($1, $2, $3, $3, $4, $5) +-- SyncAppModules updates it while the app is live (SetAppName). account_id +-- ($2) is NULL for an org-owned app whose org has not designated funding yet +-- (an UNBILLED roster row, migration 041); owner_org_id ($6) is stamped on +-- every org-owned registration — funded or not — so the RepointOrgUsage sweep +-- can scope the org's NULL-account events through the roster. +INSERT INTO ms_billing.apps (app_id, account_id, module_count, created_module_count, created_at, name, owner_org_id) +VALUES ($1, $2, $3, $3, $4, $5, $6) ON CONFLICT (app_id) DO NOTHING; -- SelectAppMirror reads one roster row (deleted or not — the caller decides @@ -57,10 +61,13 @@ FOR UPDATE; -- HOURS (D5 — session-TZ/DST safety). proration_skipped_at IS NULL excludes -- apps permanently skipped as a would-be retroactive catch-up (migration 031, -- D1d). Ordered by created_at so the oldest pending app charges first. +-- account_id IS NOT NULL excludes UNBILLED org roster rows (migration 041) — +-- an org app enters this sweep only once RepointOrgUsage attaches it. -- name: AppsPendingProration :many SELECT app_id FROM ms_billing.apps WHERE created_at <= @created_before::timestamptz + AND account_id IS NOT NULL AND proration_invoice_id IS NULL AND (deleted_at IS NULL OR deleted_at >= created_at + make_interval(hours => @grace_hours::int)) diff --git a/internal/account/db/queries/org.sql b/internal/account/db/queries/org.sql new file mode 100644 index 0000000..27c6092 --- /dev/null +++ b/internal/account/db/queries/org.sql @@ -0,0 +1,153 @@ +-- Queries backing the org-billing substrate (migration 041, design D1): +-- the org account get-or-create, the funding designation, and the +-- RepointOrgUsage attach/backfill sweep. All operate on ms_billing. + +-- SelectAccountByOrg returns the existing org-owned account row, with the +-- same non-null stripe_customer_id projection as SelectAccountByUser. +-- name: SelectAccountByOrg :one +SELECT id, COALESCE(stripe_customer_id, '')::text AS stripe_customer_id +FROM ms_billing.accounts +WHERE owner_kind = 'org' AND owner_org_id = $1; + +-- InsertOrgAccount creates a fresh org-owned account (the org leg of the +-- advisory-locked get-or-create — the lock, namespace 'lbto', is the +-- uniqueness guard exactly like the user leg's 'lbta'). +-- name: InsertOrgAccount :one +INSERT INTO ms_billing.accounts (owner_kind, owner_org_id) +VALUES ('org', $1) +RETURNING id, COALESCE(stripe_customer_id, '')::text AS stripe_customer_id; + +-- GetOrgDesignation reads the org's funding designation row verbatim. +-- name: GetOrgDesignation :one +SELECT org_id, funding, sponsor_account_id, sponsor_user_id, + disclosed_backlog_micros, updated_by, updated_at +FROM ms_billing.org_billing_designations +WHERE org_id = $1; + +-- UpsertOrgDesignation writes the org's funding choice. A re-designation +-- overwrites in place (funding switches change only which instrument future +-- invoice finalization charges — attribution never moves, design D1). +-- name: UpsertOrgDesignation :exec +INSERT INTO ms_billing.org_billing_designations + (org_id, funding, sponsor_account_id, sponsor_user_id, + disclosed_backlog_micros, updated_by) +VALUES ($1, $2, $3, $4, $5, $6) +ON CONFLICT (org_id) DO UPDATE SET + funding = EXCLUDED.funding, + sponsor_account_id = EXCLUDED.sponsor_account_id, + sponsor_user_id = EXCLUDED.sponsor_user_id, + disclosed_backlog_micros = EXCLUDED.disclosed_backlog_micros, + updated_by = EXCLUDED.updated_by; + +-- DeleteOrgDesignation is the sponsor self-revoke: the org drops back to +-- unbilled (resolution finds no designation) until re-designation. Roster +-- rows KEEP their account_id — frozen attribution never rewrites; only new +-- events record NULL until the org designates again. +-- name: DeleteOrgDesignation :execrows +DELETE FROM ms_billing.org_billing_designations WHERE org_id = $1; + +-- ResolveOrgFundedAccount is THE org account resolution (ingest, reads, +-- Ensure): the org's own account, gated on a designation row existing AND the +-- account being activated. Sponsor designation activates immediately (a +-- usable instrument exists); funding='org' activates at card bind — so the +-- single activated_at gate implements "the pointer never flips to an +-- unfunded account" for both modes. No row → the org is unbilled (lazy +-- NULL-account events), which callers treat exactly like a missing user +-- account. +-- name: ResolveOrgFundedAccount :one +SELECT a.id +FROM ms_billing.org_billing_designations d +JOIN ms_billing.accounts a + ON a.owner_kind = 'org' AND a.owner_org_id = d.org_id +WHERE d.org_id = $1 + AND a.activated_at IS NOT NULL; + +-- ChargeFundingAccount maps an account to the account whose Stripe customer / +-- default PM pays its invoices: itself, unless it is an org account whose +-- designation says a sponsor lends the card. The charge legs resolve their +-- customer + PM gate through this exactly once, at charge time — a designation +-- switch between runs re-routes only future charges (design D1). +-- name: ChargeFundingAccount :one +SELECT COALESCE(d.sponsor_account_id, a.id)::uuid AS funding_account_id +FROM ms_billing.accounts a +LEFT JOIN ms_billing.org_billing_designations d + ON a.owner_kind = 'org' + AND d.org_id = a.owner_org_id + AND d.funding = 'sponsor' +WHERE a.id = $1; + +-- ActivateAccountIfUnset stamps the ADR-0006 activation anchor when the org +-- account activates by SPONSOR designation (its anchor = designation day; the +-- card-bind webhook stamps the funding='org' case). Idempotent — the anchor +-- is immutable once set. +-- name: ActivateAccountIfUnset :execrows +UPDATE ms_billing.accounts +SET activated_at = $2 +WHERE id = $1 AND activated_at IS NULL; + +-- OrgUnbilledBacklogMicros estimates the org's pre-designation unbilled +-- backlog: every NULL-account event attributable to the org (through its +-- roster rows' owner_org_id), priced exactly like the live bill display +-- (AppBillLines' live branch: declared price ×1 for custom metrics, ×12/10 +-- for reserved infra.*/platform.*). It is the DISCLOSURE estimate shown +-- before the sponsor confirms — the authoritative charge happens later, +-- through the normal rollup, once the sweep re-points the events. +-- name: OrgUnbilledBacklogMicros :one +SELECT COALESCE(SUM( + CASE + WHEN e.metric LIKE 'infra.%' OR e.metric LIKE 'platform.%' + THEN e.value * COALESCE(md.unit_price_micros, 0) * 12 / 10 + ELSE e.value * COALESCE(md.unit_price_micros, 0) + END), 0)::numeric AS backlog_micros +FROM ms_billing.usage_events e +LEFT JOIN ms_billing.metric_definitions md + ON md.module_id = e.module_id AND md.metric = e.metric +WHERE e.account_id IS NULL + AND e.app_id IN (SELECT app_id FROM ms_billing.apps WHERE owner_org_id = $1); + +-- AttachOrgAppsToAccount backfills account_id onto the org's unbilled roster +-- rows — the roster half of the RepointOrgUsage sweep. Attached rows enter +-- the base-fee machinery prospectively: created_at is untouched (the D1d +-- no-retroactive-catch-up rule permanently skips any creation period that +-- closed before activation), and timers are synthesized fresh by the caller. +-- name: AttachOrgAppsToAccount :execrows +UPDATE ms_billing.apps +SET account_id = $2 +WHERE owner_org_id = $1 AND account_id IS NULL; + +-- RepointOrgNullAccountEvents folds the org's pre-designation NULL-account +-- events into its funded account — the events half of the sweep. The rollup +-- windows events by recorded_at, so an event older than the account's current +-- open window (@window_start) would never fall inside ANY future window: the +-- sweep CLAMPS its recorded_at to the window start — "backfilled events bill +-- in the first period that closes after designation" (decision 1) — and +-- preserves the original instant in repointed_from (migration 041 audit +-- column). Scoped through the roster's owner_org_id so lazy USER events are +-- never swept. Idempotent: account_id IS NULL never matches a swept row again. +-- name: RepointOrgNullAccountEvents :execrows +UPDATE ms_billing.usage_events +SET account_id = @account_id::uuid, + repointed_from = CASE WHEN recorded_at < @window_start::timestamptz + THEN recorded_at ELSE repointed_from END, + recorded_at = GREATEST(recorded_at, @window_start::timestamptz) +WHERE account_id IS NULL + AND app_id IN (SELECT app_id FROM ms_billing.apps WHERE owner_org_id = @org_id::uuid); + +-- OrgLiveAppIDs lists the org's live roster rows — the timer-synthesis loop +-- of the RepointOrgUsage sweep reconciles each one after attach. +-- name: OrgLiveAppIDs :many +SELECT app_id +FROM ms_billing.apps +WHERE owner_org_id = $1 AND deleted_at IS NULL; + +-- PaymentMethodTargetForOrg is PaymentMethodTarget's org twin: resolves an +-- active payment method owned by the ORG account for detach / set-default. +-- name: PaymentMethodTargetForOrg :one +SELECT + pmm.stripe_payment_method_id, + COALESCE(a.stripe_customer_id, '')::text AS stripe_customer_id, + pmm.is_default +FROM ms_billing.payment_methods_mirror pmm +JOIN ms_billing.accounts a ON a.id = pmm.account_id +WHERE a.owner_kind = 'org' AND a.owner_org_id = $1 + AND pmm.id = $2 AND pmm.deleted_at IS NULL; diff --git a/internal/account/usage/store.go b/internal/account/usage/store.go index b0b213d..b8ced81 100644 --- a/internal/account/usage/store.go +++ b/internal/account/usage/store.go @@ -538,12 +538,23 @@ func (s *pgxStore) InsertUsageEvent(ctx context.Context, ev UsageEvent) (bool, e } func (s *pgxStore) AccountByOwner(ctx context.Context, owner Owner) (uuid.UUID, bool, error) { - // v1 ships the user-owned path (SelectAccountByUser). Org-owned - // accounts (owner_org_id) land with the org billing milestone; until - // then an org owner resolves to "no account yet" (lazy), which the - // service handles gracefully (records the event NULL-account). + // Org owners resolve through the funding designation (migration 041): + // designated AND activated → the org's own account; otherwise "no account + // yet" (lazy), which the service handles gracefully (records the event + // NULL-account — the RepointOrgUsage sweep folds it in at designation). if owner.OrgID != uuid.Nil { - return uuid.Nil, false, nil + id, err := s.q.ResolveOrgFundedAccount(ctx, owner.OrgID.String()) + if errors.Is(err, pgx.ErrNoRows) { + return uuid.Nil, false, nil + } + if err != nil { + return uuid.Nil, false, err + } + parsed, err := uuid.Parse(id) + if err != nil { + return uuid.Nil, false, err + } + return parsed, true, nil } row, err := s.q.SelectAccountByUser(ctx, pgtype.UUID{Bytes: owner.UserID, Valid: true}) if errors.Is(err, pgx.ErrNoRows) { diff --git a/migrations/billing/041_org_billing_designations.down.sql b/migrations/billing/041_org_billing_designations.down.sql new file mode 100644 index 0000000..466e9ab --- /dev/null +++ b/migrations/billing/041_org_billing_designations.down.sql @@ -0,0 +1,27 @@ +-- Down for 041: drop the designation table and the org-owned roster support. +-- Unbilled (NULL-account) roster rows cannot survive the NOT NULL restore — +-- they exist only for org-owned apps awaiting designation, so dropping them +-- loses no billed state (their usage_events keep account_id NULL regardless). + +ALTER TABLE ms_billing.usage_events + DROP COLUMN IF EXISTS repointed_from; + +DELETE FROM ms_billing.apps WHERE account_id IS NULL; + +DROP INDEX IF EXISTS ms_billing.apps_owner_org_idx; + +ALTER TABLE ms_billing.apps + DROP CONSTRAINT IF EXISTS apps_unbilled_only_org_check; + +ALTER TABLE ms_billing.apps + DROP COLUMN IF EXISTS owner_org_id; + +ALTER TABLE ms_billing.apps + ALTER COLUMN account_id SET NOT NULL; + +DROP INDEX IF EXISTS ms_billing.org_billing_designations_sponsor_account_idx; + +DROP TRIGGER IF EXISTS org_billing_designations_set_updated_at + ON ms_billing.org_billing_designations; + +DROP TABLE IF EXISTS ms_billing.org_billing_designations; diff --git a/migrations/billing/041_org_billing_designations.up.sql b/migrations/billing/041_org_billing_designations.up.sql new file mode 100644 index 0000000..23a3dc5 --- /dev/null +++ b/migrations/billing/041_org_billing_designations.up.sql @@ -0,0 +1,89 @@ +-- Migration 041 — org billing designations + org-owned roster support +-- (org-billing W0 substrate, workspace docs-temp/org-billing/design.md D1). +-- +-- One org = ONE ms_billing.accounts row (owner_kind = 'org', schema-ready +-- since 001), created lazily at the org's first funding designation. The +-- designation picks only the FUNDING INSTRUMENT for the org account's +-- invoices — attribution (periods, aggregates, roster, overage pool, budgets, +-- collection state, invoice mirror) always lives on the org account itself: +-- +-- funding = 'sponsor' — invoices charge the SPONSOR's Stripe customer / +-- default PM. The sponsor is always the acting org owner/admin's OWN +-- personal account (sponsor_account_id / sponsor_user_id), never another +-- member's wallet. Designating activates the org account immediately +-- (a usable instrument exists), so its ADR-0006 anchor = designation day. +-- +-- funding = 'org' — the org account charges its own Stripe customer / +-- card. The designation row may exist BEFORE the card binds; account +-- resolution stays gated on accounts.activated_at (stamped by the +-- payment_method.attached webhook), so the org remains unbilled until the +-- bind completes — the pointer never flips to an unfunded account. +-- +-- disclosed_backlog_micros records the pre-designation unbilled-backlog +-- estimate shown to (and confirmed by) the designating user before the +-- RepointOrgUsage sweep folds those events into the account's first open +-- period (org-billing decision 1, 2026-07-06). + +CREATE TABLE IF NOT EXISTS ms_billing.org_billing_designations ( + org_id UUID PRIMARY KEY, -- soft FK ms_organizations.orgs.id + funding TEXT NOT NULL CHECK (funding IN ('sponsor', 'org')), + -- ON DELETE CASCADE: a deleted sponsor account takes its sponsorship with + -- it — the org drops to unbilled (same terminal state as a self-revoke; + -- the funding-shape CHECK forbids a dangling sponsor pair). + sponsor_account_id UUID NULL REFERENCES ms_billing.accounts(id) ON DELETE CASCADE, + sponsor_user_id UUID NULL, -- soft FK ms_account.users.id + disclosed_backlog_micros BIGINT NOT NULL DEFAULT 0 CHECK (disclosed_backlog_micros >= 0), + updated_by UUID NOT NULL, -- acting org owner/admin (soft FK users) + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT now(), + + -- sponsor funding carries the sponsor pair; org funding carries neither. + CONSTRAINT org_designations_funding_check CHECK ( + (funding = 'sponsor' AND sponsor_account_id IS NOT NULL AND sponsor_user_id IS NOT NULL) + OR + (funding = 'org' AND sponsor_account_id IS NULL AND sponsor_user_id IS NULL) + ) +); + +-- FK covering index (the CASCADE's delete-side scan; also "which orgs does +-- this user sponsor" — the sponsor-lifecycle guard's read). +CREATE INDEX IF NOT EXISTS org_billing_designations_sponsor_account_idx + ON ms_billing.org_billing_designations (sponsor_account_id) + WHERE sponsor_account_id IS NOT NULL; + +CREATE TRIGGER org_billing_designations_set_updated_at +BEFORE UPDATE ON ms_billing.org_billing_designations +FOR EACH ROW +EXECUTE FUNCTION ms_billing.set_updated_at(); + +-- Org-owned roster rows. An org app may register BEFORE its org designates +-- funding: the row then carries owner_org_id with a NULL account_id — an +-- UNBILLED roster row (no base fee, no overage timers, excluded from every +-- charge sweep). The RepointOrgUsage sweep attaches it (account_id +-- backfilled, timers synthesized fresh) once the org's funded account +-- resolves. owner_org_id is stamped on EVERY org-owned registration — funded +-- or not — because the repoint sweep scopes NULL-account usage_events to the +-- org through it. +ALTER TABLE ms_billing.apps + ALTER COLUMN account_id DROP NOT NULL; + +ALTER TABLE ms_billing.apps + ADD COLUMN IF NOT EXISTS owner_org_id UUID NULL; + +-- A NULL account is legal ONLY for an org-owned row awaiting designation. +ALTER TABLE ms_billing.apps + ADD CONSTRAINT apps_unbilled_only_org_check + CHECK (account_id IS NOT NULL OR owner_org_id IS NOT NULL); + +CREATE INDEX IF NOT EXISTS apps_owner_org_idx + ON ms_billing.apps (owner_org_id) WHERE owner_org_id IS NOT NULL; + +-- Backfill audit trail for the RepointOrgUsage sweep. The rollup windows +-- events by recorded_at, so a pre-designation event older than the account's +-- current open window would never fall inside ANY future window — the sweep +-- therefore CLAMPS such an event's recorded_at to the open window's start +-- ("backfilled events bill in the first period that closes after +-- designation", org-billing decision 1) and preserves the original instant +-- here. NULL = never repointed/clamped (every ordinary event). +ALTER TABLE ms_billing.usage_events + ADD COLUMN IF NOT EXISTS repointed_from TIMESTAMPTZ NULL; From 7bbdf8dd67dcf63903dede6325220465fd309364 Mon Sep 17 00:00:00 2001 From: Sheng Kun Chang Date: Mon, 6 Jul 2026 16:44:43 +0800 Subject: [PATCH 2/2] refactor: recovery legs ride the funding hop + simplify org resolution decode MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Post-review fixes on the W0 substrate (4-lens /simplify pass): - recoveryCustomer: all four crash-recovery sites (boundary hasFrozen, boundary moneyMayHaveMoved, creation proration, module overage) now resolve the SAME ChargeFundingAccount hop the fresh-charge path uses — a sponsor-funded org's crashed attempt was previously looked up under the org's own (usually absent) Stripe customer: a loud permanent error at best, a missed reconcile at worst. Regression test pins the sponsor customer on the recovery lookup. The funding-switch-during-crash race is backstopped by the deterministic ~24h idem keys (same envelope as the documented Search-lag note); recorded-instrument hardening travels with the W2 transfer wave. - Ensure's funding hop hoists inside the RequirePaymentMethod branch and skips the provably-identity query for user principals. - billing.AccountByOrgFunded renamed ResolveOrgFundedAccount — THE org resolution now greps as one name across all three stores. - uuidRowFound decode helper (per package, matching the local-helper idiom) collapses six copies of the ErrNoRows/parse ceremony. Co-Authored-By: Claude Fable 5 --- internal/account/billing/service.go | 22 +++++----- internal/account/billing/service_test.go | 4 +- internal/account/billing/store.go | 55 +++++++++++------------ internal/account/cycle/charge.go | 6 +-- internal/account/cycle/charge_test.go | 6 ++- internal/account/cycle/org.go | 2 +- internal/account/cycle/overage.go | 2 +- internal/account/cycle/proration.go | 4 +- internal/account/cycle/proration_test.go | 47 ++++++++++++++++++++ internal/account/cycle/service.go | 24 ++++++++++ internal/account/cycle/store.go | 56 ++++++++++-------------- internal/account/usage/store.go | 29 +++++++----- 12 files changed, 165 insertions(+), 92 deletions(-) diff --git a/internal/account/billing/service.go b/internal/account/billing/service.go index 89c6926..507ff6f 100644 --- a/internal/account/billing/service.go +++ b/internal/account/billing/service.go @@ -70,7 +70,7 @@ func (s *Service) Ensure(ctx context.Context, req EnsureRequest) (*EnsureRespons var found bool var err error if req.OrgID != uuid.Nil { - accountID, found, err = s.store.AccountByOrgFunded(ctx, req.OrgID) + accountID, found, err = s.store.ResolveOrgFundedAccount(ctx, req.OrgID) } else { accountID, found, err = s.store.AccountByUser(ctx, req.UserID) } @@ -82,18 +82,20 @@ func (s *Service) Ensure(ctx context.Context, req EnsureRequest) (*EnsureRespons return resp, nil } - // The payment_method capability checks the FUNDING account (org-billing - // D1): a sponsor-funded org account owns no PM rows — the sponsor's does. - // A self-funded account (every user account, org-funded orgs) maps to - // itself, so the hop is uniform. - fundingID, err := s.store.ChargeFundingAccount(ctx, accountID) - if err != nil { - return nil, Internal("funding account lookup failed", err) - } - // Per-capability checks. Order is fixed (PM before subscription) so // the Missing slice is deterministic regardless of Require ordering. if slices.Contains(require, RequirePaymentMethod) { + // 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) diff --git a/internal/account/billing/service_test.go b/internal/account/billing/service_test.go index 9791c84..cc84ef5 100644 --- a/internal/account/billing/service_test.go +++ b/internal/account/billing/service_test.go @@ -27,7 +27,7 @@ type fakeStore struct { // org-billing state (migration 041). accountsByOrg models the org account // rows (existence — AccountByOrg / EnsureOrgAccount); fundedOrgs the - // designation+activation gate AccountByOrgFunded reads; fundingOf the + // designation+activation gate ResolveOrgFundedAccount reads; fundingOf the // sponsor funding hop (absent → identity); orgPMTargets the org twin of // pmTargets (PaymentMethodTargetForOrg). accountsByOrg map[uuid.UUID]fakeAccount @@ -203,7 +203,7 @@ func (s *fakeStore) AccountByOrg(_ context.Context, orgID uuid.UUID) (uuid.UUID, return a.id, true, nil } -func (s *fakeStore) AccountByOrgFunded(_ context.Context, orgID uuid.UUID) (uuid.UUID, bool, error) { +func (s *fakeStore) ResolveOrgFundedAccount(_ context.Context, orgID uuid.UUID) (uuid.UUID, bool, error) { // Row EXISTENCE is not enough — the funded gate (designation + activation) // is modeled as an explicit flag. a, ok := s.accountsByOrg[orgID] diff --git a/internal/account/billing/store.go b/internal/account/billing/store.go index 9e12d9e..0c7960b 100644 --- a/internal/account/billing/store.go +++ b/internal/account/billing/store.go @@ -89,10 +89,12 @@ type Store interface { // awaits its first bind. AccountByOrg(ctx context.Context, orgID uuid.UUID) (uuid.UUID, bool, error) - // AccountByOrgFunded resolves the org's account through the designation + - // activation gate — Ensure's org resolution ("the pointer never flips to - // an unfunded account", design D1). - AccountByOrgFunded(ctx context.Context, orgID uuid.UUID) (uuid.UUID, bool, error) + // ResolveOrgFundedAccount resolves the org's account through the + // designation + activation gate — Ensure's org resolution ("the pointer + // never flips to an unfunded account", design D1). Named identically in + // every store that wraps the one generated query, so THE org account + // resolution greps as one thing. + ResolveOrgFundedAccount(ctx context.Context, orgID uuid.UUID) (uuid.UUID, bool, error) // ChargeFundingAccount maps an account to the account whose Stripe // customer / default PM pays its invoices — itself, unless it is an org @@ -384,32 +386,13 @@ func (s *pgxStore) EnsureOrgAccount(ctx context.Context, orgID uuid.UUID) (uuid. func (s *pgxStore) AccountByOrg(ctx context.Context, orgID uuid.UUID) (uuid.UUID, bool, error) { row, err := s.q.SelectAccountByOrg(ctx, nullableUUID(orgID)) - if errors.Is(err, pgx.ErrNoRows) { - return uuid.Nil, false, nil - } - if err != nil { - return uuid.Nil, false, err - } - parsed, err := uuid.Parse(row.ID) - if err != nil { - return uuid.Nil, false, err - } - return parsed, true, nil + return uuidRowFound(row.ID, err) } -func (s *pgxStore) AccountByOrgFunded(ctx context.Context, orgID uuid.UUID) (uuid.UUID, bool, error) { +func (s *pgxStore) ResolveOrgFundedAccount(ctx context.Context, orgID uuid.UUID) (uuid.UUID, bool, error) { + // ErrNoRows = no designation / not activated — unbilled, a normal outcome. id, err := s.q.ResolveOrgFundedAccount(ctx, orgID.String()) - if errors.Is(err, pgx.ErrNoRows) { - return uuid.Nil, false, nil // no designation / not activated — unbilled - } - if err != nil { - return uuid.Nil, false, err - } - parsed, err := uuid.Parse(id) - if err != nil { - return uuid.Nil, false, err - } - return parsed, true, nil + return uuidRowFound(id, err) } func (s *pgxStore) ChargeFundingAccount(ctx context.Context, accountID uuid.UUID) (uuid.UUID, error) { @@ -434,6 +417,24 @@ func (s *pgxStore) PaymentMethodTargetForOrg(ctx context.Context, orgID, payment return row.StripePaymentMethodID, row.StripeCustomerID, row.IsDefault, true, nil } +// uuidRowFound decodes the (uuid-as-string, error) shape every single-row +// account-resolution query yields: ErrNoRows → (Nil, false, nil) — a normal +// lazy/missing outcome, not an error — else the parsed id. One home for the +// ceremony so the resolution wrappers stay one line each. +func uuidRowFound(id string, err error) (uuid.UUID, bool, error) { + if errors.Is(err, pgx.ErrNoRows) { + return uuid.Nil, false, nil + } + if err != nil { + return uuid.Nil, false, err + } + parsed, err := uuid.Parse(id) + if err != nil { + return uuid.Nil, false, err + } + return parsed, true, nil +} + // nullableUUID converts a google/uuid.UUID into the pgtype.UUID the // generated queries expect for the nullable owner_user_id column. A // non-Nil UUID is always marked Valid; callers never pass Nil here diff --git a/internal/account/cycle/charge.go b/internal/account/cycle/charge.go index 3052894..a181ab4 100644 --- a/internal/account/cycle/charge.go +++ b/internal/account/cycle/charge.go @@ -124,7 +124,7 @@ func (s *Service) RunBillingCycle(ctx context.Context, accountID uuid.UUID, peri // path are non-terminal; the frozen amount survives for a later reclaim. var recovered *billingstripe.Invoice if hasFrozen { - custID, err := s.store.AccountStripeCustomer(ctx, accountID) + custID, err := s.recoveryCustomer(ctx, accountID) if err != nil { return nil, billing.Internal("stripe customer lookup failed", err) } @@ -342,12 +342,12 @@ func (s *Service) RunBillingCycle(ctx context.Context, accountID uuid.UUID, peri // as a skip over moved money. Only the Customer id is required there. var custID string if moneyMayHaveMoved { - custID, err = s.store.AccountStripeCustomer(ctx, accountID) + custID, err = s.recoveryCustomer(ctx, accountID) if err != nil { return nil, billing.Internal("stripe customer lookup failed", err) } if custID == "" { - return nil, billing.Internal("billing run has a recovered Stripe invoice but the account has no Stripe customer id", nil) + return nil, billing.Internal("billing run has a recovered Stripe invoice but the funding account has no Stripe customer id", nil) } } else { var ok bool diff --git a/internal/account/cycle/charge_test.go b/internal/account/cycle/charge_test.go index 4f17b72..0cd5fd7 100644 --- a/internal/account/cycle/charge_test.go +++ b/internal/account/cycle/charge_test.go @@ -46,6 +46,9 @@ type fakeStripe struct { // setFindByRef. findByRefByRef map[string]billingstripe.Invoice findByRefCalls []string + // findByRefCustIDs records the customer each lookup searched under — the + // recovery legs must resolve the SAME funding hop as the fresh-charge path. + findByRefCustIDs []string // onCreateInvoice, when set, runs INSIDE FinalizeInvoice right before it // returns success — modeling a concurrent account mutation (e.g. a // threshold edit) that lands while the real Stripe HTTP call is in @@ -109,8 +112,9 @@ func (f *fakeStripe) setFindByRef(ref string, inv billingstripe.Invoice) { f.findByRefByRef[ref] = inv } -func (f *fakeStripe) FindInvoiceByRef(_ context.Context, _, ref string) (billingstripe.Invoice, bool, error) { +func (f *fakeStripe) FindInvoiceByRef(_ context.Context, custID, ref string) (billingstripe.Invoice, bool, error) { f.findByRefCalls = append(f.findByRefCalls, ref) + f.findByRefCustIDs = append(f.findByRefCustIDs, custID) if f.errFindByRef != nil { return billingstripe.Invoice{}, false, f.errFindByRef } diff --git a/internal/account/cycle/org.go b/internal/account/cycle/org.go index 223f442..64cfc34 100644 --- a/internal/account/cycle/org.go +++ b/internal/account/cycle/org.go @@ -40,7 +40,7 @@ const ( ) // OrgDesignation is the ms_billing.org_billing_designations row (migration -// 038). SponsorAccountID / SponsorUserID are Nil unless Funding is sponsor. +// 041). SponsorAccountID / SponsorUserID are Nil unless Funding is sponsor. type OrgDesignation struct { OrgID uuid.UUID Funding OrgFunding diff --git a/internal/account/cycle/overage.go b/internal/account/cycle/overage.go index 340132a..2752722 100644 --- a/internal/account/cycle/overage.go +++ b/internal/account/cycle/overage.go @@ -444,7 +444,7 @@ func (s *Service) SweepModuleOverage(ctx context.Context, at time.Time) (*SweepM // blocked by a PM removed after the crash (an idem/finalize failure lands in // the sweep's retried-error path instead). func (s *Service) recoverModuleOverageCharge(ctx context.Context, cand ModuleOverageCandidate, at time.Time, res *ModuleOverageResult) (bool, error) { - custID, err := s.store.AccountStripeCustomer(ctx, cand.AccountID) + custID, err := s.recoveryCustomer(ctx, cand.AccountID) if err != nil { return false, billing.Internal("stripe customer lookup failed (module overage recovery)", err) } diff --git a/internal/account/cycle/proration.go b/internal/account/cycle/proration.go index eaf7588..dc6a867 100644 --- a/internal/account/cycle/proration.go +++ b/internal/account/cycle/proration.go @@ -288,12 +288,12 @@ func (s *Service) ChargeCreationProration(ctx context.Context, appID uuid.UUID) var recoveredInv *billingstripe.Invoice moneyMayHaveMoved := false if app.ProrationAttempted { - custID, err = s.store.AccountStripeCustomer(ctx, app.AccountID) + custID, err = s.recoveryCustomer(ctx, app.AccountID) if err != nil { return nil, billing.Internal("stripe customer lookup failed", err) } if custID == "" { - return nil, billing.Internal("app has an attempted proration charge but the account has no Stripe customer id", nil) + return nil, billing.Internal("app has an attempted proration charge but the funding account has no Stripe customer id", nil) } if found, ok, err := s.stripe.FindInvoiceByRef(ctx, custID, appProrationChargeRef(appID)); err != nil { return nil, billing.StripeError("proration recovery lookup failed", err) diff --git a/internal/account/cycle/proration_test.go b/internal/account/cycle/proration_test.go index e76e817..51124a6 100644 --- a/internal/account/cycle/proration_test.go +++ b/internal/account/cycle/proration_test.go @@ -698,3 +698,50 @@ func TestChargeCreationProration_ActivatedBeforePeriodClosesStillCharges(t *test require.Len(t, sc.itemCalls, 1) require.False(t, store.apps[appID].ProrationSkipped) } + +// Recovery resolves the SAME funding hop as the fresh-charge path: a +// sponsor-funded org app's crashed proration attempt is looked up under the +// SPONSOR's Stripe customer — the org account has none, so a recovery +// resolving the attribution account directly would fail loudly (or, worse, +// miss the moved money and re-charge fresh once the idem key ages out). +func TestChargeCreationProration_RecoveryResolvesSponsorCustomer(t *testing.T) { + store := newFakeStore() + sc := newFakeStripe() + svc := appsSvc(store, sc) + + org, orgAcct, sponsorAcct := uuid.New(), uuid.New(), uuid.New() + store.accountsByOrg[org] = orgAcct + store.orgDesignations[org] = cycle.OrgDesignation{ + OrgID: org, Funding: cycle.OrgFundingSponsor, SponsorAccountID: sponsorAcct, + } + // Org account: activated (sponsor designation), NO PM, NO customer of its + // own. Sponsor: usable PM + customer — the only chargeable instrument. + created := appsNow.AddDate(0, 0, -7) // past grace, same anchored period + store.activation[orgAcct] = created + store.hasPMByAccount[orgAcct] = false + store.hasPMByAccount[sponsorAcct] = true + store.stripeCustomerByAccount[sponsorAcct] = "cus_sponsor" + + appID := uuid.New() + _, err := svc.RegisterApp(context.Background(), cycle.RegisterAppRequest{ + OwnerOrgID: org, AppID: appID, CreatedAt: created, + }) + require.NoError(t, err) + require.Equal(t, orgAcct, store.apps[appID].AccountID, "funded org app registers on the org account") + + // A prior attempt crashed after arming the durable marker but before any + // Stripe object survived (FindInvoiceByRef finds nothing) — recovery must + // SEARCH the sponsor's customer, then charge fresh through the same hop. + app := store.apps[appID] + app.ProrationAttempted = true + store.apps[appID] = app + + resp, err := svc.ChargeCreationProration(context.Background(), appID) + require.NoError(t, err) + require.Equal(t, cycle.ProrationStatusCharged, resp.Status) + require.Equal(t, []string{"cus_sponsor"}, sc.findByRefCustIDs, + "the recovery lookup must search the FUNDING customer, not the org account's") + require.Len(t, sc.invoiceCalls, 1) + require.Equal(t, "cus_sponsor", sc.invoiceCalls[0].custID, + "the fresh charge after a not-found recovery rides the same funding hop") +} diff --git a/internal/account/cycle/service.go b/internal/account/cycle/service.go index a310278..0f397f1 100644 --- a/internal/account/cycle/service.go +++ b/internal/account/cycle/service.go @@ -101,6 +101,30 @@ func (s *Service) resolveChargeableCustomer(ctx context.Context, accountID uuid. return custID, true, nil } +// recoveryCustomer resolves the Stripe customer a CRASHED charge attempt's +// invoice is searched under (FindInvoiceByRef) — through the SAME funding hop +// the fresh-charge path applied when it created the invoice, so recovery and +// charge always look at the same customer. For a sponsor-funded org account +// the attribution account usually has NO Stripe customer at all; searching it +// would turn every recovery into a loud error, or a false "nothing" that +// re-charges fresh. Deliberately NOT PM-gated, matching each recovery leg's +// reconcile-before-gates posture. +// +// Known narrow gap: a funding SWITCH between the crashed attempt and this +// recovery resolves the NEW instrument, so the old instrument's invoice is +// invisible here — the deterministic idempotency keys (~24h) then return the +// ORIGINAL invoice on the re-charge attempt, the same backstop the Search-lag +// note in RunBillingCycle already relies on. The recorded-instrument +// hardening (stamping the funding account onto the attempt markers) travels +// with the org-billing W2 transfer wave. +func (s *Service) recoveryCustomer(ctx context.Context, accountID uuid.UUID) (string, error) { + fundingID, err := s.store.ChargeFundingAccount(ctx, accountID) + if err != nil { + return "", err + } + return s.store.AccountStripeCustomer(ctx, fundingID) +} + // WithNow overrides the Service clock — deterministic-test hook only (mirrors // the usage.Service nowFn seam). Returns the Service for chaining. func (s *Service) WithNow(now func() time.Time) *Service { diff --git a/internal/account/cycle/store.go b/internal/account/cycle/store.go index cbf0e00..600fa59 100644 --- a/internal/account/cycle/store.go +++ b/internal/account/cycle/store.go @@ -1600,7 +1600,7 @@ func (s *pgxStore) ReconcileModuleTimersToTarget(ctx context.Context, appID uuid return tx.Commit(ctx) } if err := qtx.InsertModuleOverageTimers(ctx, db.InsertModuleOverageTimersParams{ - AccountID: uuid.UUID(row.AccountID.Bytes).String(), + AccountID: uuidFromPg(row.AccountID).String(), AppID: appID.String(), InstalledAt: installedAt, GraceExpiresAt: graceExpiresAt, @@ -1791,32 +1791,12 @@ func (s *pgxStore) EnsureOrgAccount(ctx context.Context, orgID uuid.UUID) (uuid. func (s *pgxStore) AccountIDByUser(ctx context.Context, userID uuid.UUID) (uuid.UUID, bool, error) { id, err := s.q.AccountIDByUser(ctx, pgtype.UUID{Bytes: userID, Valid: true}) - if errors.Is(err, pgx.ErrNoRows) { - return uuid.Nil, false, nil - } - if err != nil { - return uuid.Nil, false, err - } - parsed, err := uuid.Parse(id) - if err != nil { - return uuid.Nil, false, err - } - return parsed, true, nil + return uuidRowFound(id, err) } func (s *pgxStore) OrgAccountID(ctx context.Context, orgID uuid.UUID) (uuid.UUID, bool, error) { row, err := s.q.SelectAccountByOrg(ctx, pgtype.UUID{Bytes: orgID, Valid: true}) - if errors.Is(err, pgx.ErrNoRows) { - return uuid.Nil, false, nil - } - if err != nil { - return uuid.Nil, false, err - } - parsed, err := uuid.Parse(row.ID) - if err != nil { - return uuid.Nil, false, err - } - return parsed, true, nil + return uuidRowFound(row.ID, err) } func (s *pgxStore) OrgDesignation(ctx context.Context, orgID uuid.UUID) (OrgDesignation, bool, error) { @@ -1865,18 +1845,9 @@ func (s *pgxStore) DeleteOrgDesignation(ctx context.Context, orgID uuid.UUID) (b } func (s *pgxStore) ResolveOrgFundedAccount(ctx context.Context, orgID uuid.UUID) (uuid.UUID, bool, error) { + // ErrNoRows = no designation, or not yet activated — unbilled, normal. id, err := s.q.ResolveOrgFundedAccount(ctx, orgID.String()) - if errors.Is(err, pgx.ErrNoRows) { - return uuid.Nil, false, nil // no designation, or not yet activated — unbilled - } - if err != nil { - return uuid.Nil, false, err - } - parsed, err := uuid.Parse(id) - if err != nil { - return uuid.Nil, false, err - } - return parsed, true, nil + return uuidRowFound(id, err) } func (s *pgxStore) ActivateAccountIfUnset(ctx context.Context, accountID uuid.UUID, at time.Time) error { @@ -1928,6 +1899,23 @@ func (s *pgxStore) ChargeFundingAccount(ctx context.Context, accountID uuid.UUID return uuid.Parse(id) } +// uuidRowFound decodes the (uuid-as-string, error) shape every single-row +// account-resolution query yields: ErrNoRows → (Nil, false, nil) — a normal +// lazy/missing outcome, not an error — else the parsed id. +func uuidRowFound(id string, err error) (uuid.UUID, bool, error) { + if errors.Is(err, pgx.ErrNoRows) { + return uuid.Nil, false, nil + } + if err != nil { + return uuid.Nil, false, err + } + parsed, err := uuid.Parse(id) + if err != nil { + return uuid.Nil, false, err + } + return parsed, true, nil +} + // pgUUIDOrNull maps uuid.Nil to a SQL NULL and a real UUID to a valid // pgtype.UUID — the encode twin of uuidFromPg. func pgUUIDOrNull(id uuid.UUID) pgtype.UUID { diff --git a/internal/account/usage/store.go b/internal/account/usage/store.go index b8ced81..c2ad030 100644 --- a/internal/account/usage/store.go +++ b/internal/account/usage/store.go @@ -544,17 +544,7 @@ func (s *pgxStore) AccountByOwner(ctx context.Context, owner Owner) (uuid.UUID, // NULL-account — the RepointOrgUsage sweep folds it in at designation). if owner.OrgID != uuid.Nil { id, err := s.q.ResolveOrgFundedAccount(ctx, owner.OrgID.String()) - if errors.Is(err, pgx.ErrNoRows) { - return uuid.Nil, false, nil - } - if err != nil { - return uuid.Nil, false, err - } - parsed, err := uuid.Parse(id) - if err != nil { - return uuid.Nil, false, err - } - return parsed, true, nil + return uuidRowFound(id, err) } row, err := s.q.SelectAccountByUser(ctx, pgtype.UUID{Bytes: owner.UserID, Valid: true}) if errors.Is(err, pgx.ErrNoRows) { @@ -1071,6 +1061,23 @@ func (s *pgxStore) UpsertModuleVisibility(ctx context.Context, moduleID uuid.UUI }) } +// uuidRowFound decodes the (uuid-as-string, error) shape a single-row +// account-resolution query yields: ErrNoRows → (Nil, false, nil) — the normal +// lazy/missing outcome — else the parsed id. +func uuidRowFound(id string, err error) (uuid.UUID, bool, error) { + if errors.Is(err, pgx.ErrNoRows) { + return uuid.Nil, false, nil + } + if err != nil { + return uuid.Nil, false, err + } + parsed, err := uuid.Parse(id) + if err != nil { + return uuid.Nil, false, err + } + return parsed, true, nil +} + // nullableAccountID maps a Nil account UUID to a SQL NULL (the lazy // account case) and a real UUID to a valid pgtype.UUID. func nullableAccountID(id uuid.UUID) pgtype.UUID {