feat: fix dashboard Stripe checkout, portal eligibility, webhook recovery and audit rendering

This commit is contained in:
Matthew Meszaros
2026-09-16 15:23:17 +02:00
parent 94c7e414e2
commit 6872ebbb23
12 changed files with 422 additions and 67 deletions
+2
View File
@@ -416,6 +416,8 @@ Alongside them, `api_url` is this API's own public base (for a copyable example
- All of `/pool-link/*` and `/cloud-link/*` (the self-hosted warmup pool link: approving an instance's code, listing and unlinking instances, reading the pool plan's price and opening its checkout, and on a self-hosted instance the connect flow and mailbox enrollment). `POST /pool-link/codes` and `POST /pool-link/poll` are public and per-IP rate limited: they are the device-code handshake an instance uses before it has a token, and `/pool-link/instance/*` accepts only an instance token. `/pool-link/instance/oauth/*`, `/pool-link/instance/mailboxes/:id/token`, `/pool-link/instance/workspace-mailboxes` and `/pool-link/instance/mailboxes/adopt` are the cloud-managed mailbox surface (Google and Microsoft sign-in on Warmbly's OAuth apps, brokered access tokens); their instance-side counterparts are `/cloud-link/oauth/*` and `/cloud-link/workspace-mailboxes/*`
- All of `/admin/*`
`POST /subscription/portal` requires an existing Stripe billing customer. A workspace without one receives `400` with code `bad_request` and a message to complete checkout first; free and operator-granted plans alone do not create a billing customer.
### Referrals and discounts
The referral program and billing discount history are part of `/subscription/*`, so they are JWT only and never accept an API key. There is no API permission scope for them; browser callers are gated by the org permission below. The discount validation and checkout endpoints are listed under [Subscription and billing](/api/reference/account-org/) in the reference.
@@ -471,6 +471,8 @@ Set these on the backend and the consumer.
| `BILLING_PROVIDER` | `none` (every feature unlocked, no trial expiry; the dashboard reports the workspace as self-hosted rather than on a free tier and hides billing) or `stripe` | `none` |
| `STRIPE_SECRET_KEY`, `STRIPE_WEBHOOK_SECRET`, `STRIPE_PUBLISHABLE_KEY` | Required together when `BILLING_PROVIDER=stripe`. The backend exits at boot if any is missing | unset |
Stripe also needs an active default billing portal configuration, with payment method updates, billing email updates and invoice history enabled. Point its webhook endpoint at `/webhook/stripe` using API version `2023-10-16`, matching stripe-go v76. A newer webhook version is rejected by the SDK even with a valid signature. Subscribe to `checkout.session.completed`, `checkout.session.async_payment_succeeded`, `checkout.session.expired`, `customer.subscription.created`, `customer.subscription.updated`, `customer.subscription.deleted`, `invoice.paid`, `invoice.payment_failed` and `charge.refunded`. If you replace the endpoint, update `STRIPE_WEBHOOK_SECRET` to the new endpoint’s signing secret before disabling the old endpoint.
Delayed sends run through the local poller, so the backend must be running for scheduled work to fire.
## Observability
+7 -3
View File
@@ -23,8 +23,8 @@ Unlimited mailboxes means exactly that, under a fair-use allowance of one mailbo
When you open something your plan does not include, the dashboard opens a full-screen plan chooser instead of sending you to settings. It names the feature, highlights the plan that unlocks it, and lets you pick monthly or annual billing.
- **No subscription yet.** Choosing a plan opens Stripe Checkout. When you come back, you land on the page you left with the feature unlocked.
- **Already on a paid plan.** Choosing a higher plan switches you in place. The change is prorated and takes effect immediately.
- **Enterprise.** Opens the billing portal so you can reach us about custom volume.
- **Already have a Stripe subscription.** Choosing a higher plan switches you in place. The change is prorated and takes effect immediately.
- **Enterprise.** Opens an inquiry form so you can reach us about custom volume.
Promo codes can be applied in the chooser before checkout.
@@ -43,18 +43,22 @@ Only the workspace owner can change the plan. Other members see the same compari
Need a single limit raised without moving plan? Request an increase from **Settings > Limits**, linked from the usage section, or straight from the connect dialog when it is mailboxes you need. An operator reviews it and the approved limit takes effect on the workspace, including the daily send allowance the sender enforces.
**Plans** compares the lineup as full plan cards, with the best value plan highlighted. On a paid plan each card also prices the switch before you commit, showing what is due today and when the next bill lands. Promo codes and the codes this workspace has redeemed live here too.
**Plans** compares the lineup as full plan cards, with the best value plan highlighted. For an existing Stripe subscription, monthly plan cards preview the estimated proration and next billing date. Stripe calculates annual proration when you switch. Promo codes and the codes this workspace has redeemed live here too.
**AI and credits** covers the credit balance, top-ups and usage. See [AI credits](/guides/ai-credits/).
**Payment** links out to the Stripe billing portal. Cards and the billing email are held by Stripe and never touch Warmbly, so they are read and changed there. Invoices and PDF receipts live in the portal too.
The portal requires a billing account created through checkout. If your workspace has never completed checkout, including a free workspace or one with only an operator-granted plan, the payment tab explains that checkout is required first and links to the plan chooser. Portal and cancellation controls only appear when the corresponding billing account or Stripe subscription exists.
## Plans granted by an operator
A workspace can be put on a plan without paying for it. Warmbly calls this a **managed plan**, and it exists for internal workspaces, design partners and support gestures.
A managed plan entitles exactly what the plan entitles. Nothing about the product behaves differently, and nothing is charged: no Stripe subscription is created, no invoice is raised, and the billing page still shows what the workspace would pay. The grant carries the reason it was made and who made it, and it can be open-ended or given an expiry date, in which case it lapses on its own.
Upgrading a workspace with only a managed plan opens Checkout to create a Stripe subscription. Proration previews and cancellation apply only to an existing Stripe subscription. If the selected billing interval has no configured price, the dashboard explains that the plan is unavailable instead of redirecting to the portal.
Operators grant one from the admin panel on the workspace, under **Plan**. The organization list marks granted workspaces with a `managed` badge and can filter to them, so "who is paid because we said so" is answerable without checking Stripe. A grant that has lapsed keeps its reason on file and is shown as `grant lapsed` rather than disappearing.
A grant is held beside the plan a workspace pays for, never on top of it, so a paying workspace that is granted a different plan goes back to the one it pays for when the grant ends. Revoking returns the workspace to whatever it actually pays for, which for most is the free plan.
+99 -29
View File
@@ -262,8 +262,6 @@ func (s *stripeService) CreateCheckoutSession(ctx context.Context, userID uuid.U
if customerID != "" {
params.Customer = stripe.String(customerID)
} else {
params.CustomerCreation = stripe.String("always")
}
// Auto-apply the invitee's referral discount when none was supplied, so a
@@ -508,7 +506,13 @@ func (s *stripeService) AutoTopUpCredits(ctx context.Context, orgID uuid.UUID, p
}
func (s *stripeService) CreatePortalSession(ctx context.Context, customerID, returnURL string) (string, *errx.Error) {
// Free and managed subscriptions may not have a Stripe customer yet.
if strings.TrimSpace(customerID) == "" {
return "", errx.New(errx.BadRequest, "This workspace has no billing account yet. Complete checkout before opening the billing portal.")
}
params := &stripe.BillingPortalSessionParams{
Params: stripe.Params{Context: ctx},
Customer: stripe.String(customerID),
ReturnURL: stripe.String(returnURL),
}
@@ -550,7 +554,7 @@ func (s *stripeService) ChangePlan(ctx context.Context, orgID uuid.UUID, newPlan
if err != nil {
return nil, errx.New(errx.Internal, "failed to get subscription")
}
if sub == nil || sub.StripeSubscriptionID == nil {
if sub == nil || sub.StripeSubscriptionID == nil || strings.TrimSpace(*sub.StripeSubscriptionID) == "" {
return nil, errx.New(errx.BadRequest, "no active subscription")
}
@@ -561,10 +565,10 @@ func (s *stripeService) ChangePlan(ctx context.Context, orgID uuid.UUID, newPlan
// Pick the monthly or yearly Stripe price for the requested interval.
priceID := newPlan.StripePriceID
if interval == string(models.DurationYear) && newPlan.StripePriceIDYearly != nil {
if interval == string(models.DurationYear) {
priceID = newPlan.StripePriceIDYearly
}
if priceID == nil {
if priceID == nil || strings.TrimSpace(*priceID) == "" {
return nil, errx.New(errx.BadRequest, "plan has no Stripe price")
}
@@ -588,7 +592,7 @@ func (s *stripeService) ChangePlan(ctx context.Context, orgID uuid.UUID, newPlan
return nil, xerr
}
if len(stripeSub.Items.Data) == 0 {
if stripeSub.Items == nil || len(stripeSub.Items.Data) == 0 {
return nil, errx.New(errx.Internal, "subscription has no items")
}
@@ -659,7 +663,7 @@ func (s *stripeService) PreviewPlanChange(ctx context.Context, orgID uuid.UUID,
if err != nil {
return nil, errx.New(errx.Internal, "failed to get subscription")
}
if sub == nil || sub.StripeSubscriptionID == nil {
if sub == nil || sub.StripeSubscriptionID == nil || strings.TrimSpace(*sub.StripeSubscriptionID) == "" {
return nil, errx.New(errx.BadRequest, "no active subscription")
}
@@ -669,7 +673,7 @@ func (s *stripeService) PreviewPlanChange(ctx context.Context, orgID uuid.UUID,
if err != nil || newPlan == nil {
return nil, errx.New(errx.NotFound, "plan not found")
}
if newPlan.StripePriceID == nil {
if newPlan.StripePriceID == nil || strings.TrimSpace(*newPlan.StripePriceID) == "" {
return nil, errx.New(errx.BadRequest, "plan has no Stripe price")
}
@@ -679,7 +683,7 @@ func (s *stripeService) PreviewPlanChange(ctx context.Context, orgID uuid.UUID,
return nil, xerr
}
if len(stripeSub.Items.Data) == 0 {
if stripeSub.Items == nil || len(stripeSub.Items.Data) == 0 {
return nil, errx.New(errx.Internal, "subscription has no items")
}
@@ -742,7 +746,7 @@ func (s *stripeService) ProcessWebhookEvent(ctx context.Context, event *stripe.E
// Process based on event type
var processErr *errx.Error
switch event.Type {
case "checkout.session.completed":
case "checkout.session.completed", "checkout.session.async_payment_succeeded":
processErr = s.handleCheckoutCompleted(ctx, event)
case "checkout.session.expired":
processErr = s.handleCheckoutExpired(ctx, event)
@@ -817,11 +821,13 @@ func (s *stripeService) handleCheckoutCompleted(ctx context.Context, event *stri
// Get or create subscription record
var sub *models.Subscription
if hasOrgID {
sub, _ = s.subRepo.GetByOrganizationID(ctx, orgID)
sub, err = s.subRepo.GetByOrganizationID(ctx, orgID)
} else {
// Only legacy checkouts without an organization may resolve by user.
sub, err = s.subRepo.GetByUserID(ctx, userID)
}
// Fallback to user-based lookup for backward compatibility with in-flight checkouts
if sub == nil {
sub, _ = s.subRepo.GetByUserID(ctx, userID)
if err != nil {
return errx.New(errx.Internal, "failed to get subscription")
}
if sub == nil {
@@ -938,6 +944,20 @@ func (s *stripeService) fulfillCreditTopup(ctx context.Context, event *stripe.Ev
return errx.New(errx.BadRequest, "invalid credits amount in credit checkout metadata")
}
// A first credit purchase also creates the customer's billing account.
if sess.Customer != nil && sess.Customer.ID != "" {
sub, lookupErr := s.subRepo.GetByOrganizationID(ctx, orgID)
if lookupErr != nil || sub == nil {
return errx.New(errx.Internal, "failed to get subscription for credit purchase")
}
if sub.StripeCustomerID == "" {
sub.StripeCustomerID = sess.Customer.ID
if updateErr := s.subRepo.Update(ctx, sub); updateErr != nil {
return errx.New(errx.Internal, "failed to save billing customer")
}
}
}
if _, gerr := s.credits.GrantPurchased(ctx, orgID, credits, "credit_topup", event.ID); gerr != nil {
return errx.New(errx.Internal, "failed to grant purchased credits")
}
@@ -993,7 +1013,7 @@ func (s *stripeService) countSubscriptionStarted(ctx context.Context, sub *model
if planName != "" {
props["plan"] = planName
}
if len(stripeSub.Items.Data) > 0 {
if stripeSub.Items != nil && len(stripeSub.Items.Data) > 0 {
if price := stripeSub.Items.Data[0].Price; price != nil {
props["currency"] = string(price.Currency)
props["amount"] = float64(price.UnitAmount) / 100
@@ -1023,13 +1043,28 @@ func (s *stripeService) handleSubscriptionUpdated(ctx context.Context, event *st
}
sub, err := s.subRepo.GetByStripeSubscriptionID(ctx, stripeSub.ID)
if err != nil || sub == nil {
// Try to find by customer
sub, err = s.subRepo.GetByStripeCustomerID(ctx, stripeSub.Customer.ID)
if err != nil || sub == nil {
// No local subscription found, might be created via webhook before checkout complete
return nil
if err != nil {
return errx.New(errx.Internal, "failed to get subscription")
}
// Checkout's organization metadata survives subscription events arriving first.
if sub == nil && stripeSub.Metadata["org_id"] != "" {
orgID, parseErr := uuid.Parse(stripeSub.Metadata["org_id"])
if parseErr != nil {
return errx.New(errx.BadRequest, "invalid org_id in subscription metadata")
}
sub, err = s.subRepo.GetByOrganizationID(ctx, orgID)
if err != nil || sub == nil {
return errx.New(errx.Internal, "failed to resolve subscription organization")
}
}
if sub == nil && stripeSub.Customer != nil {
sub, err = s.subRepo.GetByStripeCustomerID(ctx, stripeSub.Customer.ID)
if err != nil {
return errx.New(errx.Internal, "failed to get billing customer")
}
}
if sub == nil {
return nil
}
// Store old state for migration checks
@@ -1038,6 +1073,9 @@ func (s *stripeService) handleSubscriptionUpdated(ctx context.Context, event *st
oldPlan, _ := s.planRepo.GetByID(ctx, oldPlanID)
// Update status
if stripeSub.Customer != nil {
sub.StripeCustomerID = stripeSub.Customer.ID
}
sub.Status = mapStripeStatus(stripeSub.Status)
sub.StripeSubscriptionID = &stripeSub.ID
@@ -1064,7 +1102,7 @@ func (s *stripeService) handleSubscriptionUpdated(ctx context.Context, event *st
// Update plan if price changed
var newPlan *models.Plan
if len(stripeSub.Items.Data) > 0 {
if stripeSub.Items != nil && len(stripeSub.Items.Data) > 0 {
priceID := stripeSub.Items.Data[0].Price.ID
sub.StripePriceID = &priceID
@@ -1078,6 +1116,10 @@ func (s *stripeService) handleSubscriptionUpdated(ctx context.Context, event *st
return errx.New(errx.Internal, "failed to update subscription")
}
if s.audit != nil {
s.audit.LogAction(ctx, sub.OrganizationID, sub.UserID, models.AuditActionUpdate, models.AuditEntitySubscription, &sub.ID, "", "", nil, nil)
}
// The workspace has started paying. Reported after the write, so a failed
// update never counts as a start, and keyed off the same transition the
// premium-worker migration below uses, so a redelivered webhook does not
@@ -1136,7 +1178,10 @@ func (s *stripeService) handleSubscriptionDeleted(ctx context.Context, event *st
}
sub, err := s.subRepo.GetByStripeSubscriptionID(ctx, stripeSub.ID)
if err != nil || sub == nil {
if err != nil {
return errx.New(errx.Internal, "failed to get canceled subscription")
}
if sub == nil {
return nil
}
@@ -1151,6 +1196,10 @@ func (s *stripeService) handleSubscriptionDeleted(ctx context.Context, event *st
return errx.New(errx.Internal, "failed to update subscription")
}
if s.audit != nil {
s.audit.LogAction(ctx, sub.OrganizationID, sub.UserID, models.AuditActionUpdate, models.AuditEntitySubscription, &sub.ID, "", "", nil, nil)
}
// Cancelling releases the reserved worker back to the fleet. Nothing else
// moves: a cancelled org's mailboxes keep the workers they are on, which
// is both cheaper and better for them than a forced re-authentication.
@@ -1182,11 +1231,28 @@ func (s *stripeService) handleInvoicePaid(ctx context.Context, event *stripe.Eve
// Resolve the org from the subscription, falling back to customer. Shared by
// the credit-grant and referral-reward paths below.
var sub *models.Subscription
var err error
if inv.Subscription != nil {
sub, _ = s.subRepo.GetByStripeSubscriptionID(ctx, inv.Subscription.ID)
sub, err = s.subRepo.GetByStripeSubscriptionID(ctx, inv.Subscription.ID)
if err != nil {
return errx.New(errx.Internal, "failed to get invoice subscription")
}
}
if sub == nil && inv.SubscriptionDetails != nil && inv.SubscriptionDetails.Metadata["org_id"] != "" {
orgID, parseErr := uuid.Parse(inv.SubscriptionDetails.Metadata["org_id"])
if parseErr != nil {
return errx.New(errx.BadRequest, "invalid invoice organization")
}
sub, err = s.subRepo.GetByOrganizationID(ctx, orgID)
if err != nil || sub == nil {
return errx.New(errx.Internal, "failed to resolve invoice organization")
}
}
if sub == nil && inv.Customer != nil {
sub, _ = s.subRepo.GetByStripeCustomerID(ctx, inv.Customer.ID)
sub, err = s.subRepo.GetByStripeCustomerID(ctx, inv.Customer.ID)
if err != nil {
return errx.New(errx.Internal, "failed to get invoice customer")
}
}
if sub == nil {
return nil
@@ -1196,13 +1262,16 @@ func (s *stripeService) handleInvoicePaid(ctx context.Context, event *stripe.Eve
// subscription's plan.
var plan *models.Plan
if inv.Lines != nil && len(inv.Lines.Data) > 0 && inv.Lines.Data[0].Price != nil {
plan, _ = s.planRepo.GetByStripePriceID(ctx, inv.Lines.Data[0].Price.ID)
plan, err = s.planRepo.GetByStripePriceID(ctx, inv.Lines.Data[0].Price.ID)
if err != nil {
return errx.New(errx.Internal, "failed to get invoice plan")
}
}
if plan == nil {
plan, _ = s.planRepo.GetByID(ctx, sub.PlanID)
plan, err = s.planRepo.GetByID(ctx, sub.PlanID)
}
if plan == nil {
return nil
if err != nil || plan == nil {
return errx.New(errx.Internal, "failed to resolve invoice plan")
}
// Monthly credit allowance: RESET (set-to-N) the monthly pool to the plan's
@@ -1214,6 +1283,7 @@ func (s *stripeService) handleInvoicePaid(ctx context.Context, event *stripe.Eve
inv.BillingReason == stripe.InvoiceBillingReasonSubscriptionCycle) {
if err := s.credits.ResetMonthlyAllowance(ctx, sub.OrganizationID, plan.MonthlyCredits, event.ID); err != nil {
errs.CaptureException(fmt.Errorf("monthly credit reset failed for org %s: %w", sub.OrganizationID, err))
return errx.New(errx.Internal, "failed to reset monthly credits")
} else if s.audit != nil {
s.audit.LogAction(ctx, sub.OrganizationID, sub.UserID, models.AuditActionUpdate, models.AuditEntityCreditGrant, nil, "", "", nil, map[string]string{
"reason": "monthly_reset",
+173
View File
@@ -0,0 +1,173 @@
package stripe
import (
"context"
"encoding/json"
"errors"
"net/http"
"net/http/httptest"
"testing"
"github.com/google/uuid"
stripeapi "github.com/stripe/stripe-go/v76"
"github.com/warmbly/warmbly/internal/errx"
"github.com/warmbly/warmbly/internal/models"
"github.com/warmbly/warmbly/internal/repository"
)
type billingSubRepo struct {
repository.SubscriptionRepository
sub *models.Subscription
lookupErr error
updated bool
recorded bool
}
func (r *billingSubRepo) GetByOrganizationID(context.Context, uuid.UUID) (*models.Subscription, error) {
return r.sub, r.lookupErr
}
func (r *billingSubRepo) GetByStripeSubscriptionID(context.Context, string) (*models.Subscription, error) {
return nil, r.lookupErr
}
func (r *billingSubRepo) Update(_ context.Context, sub *models.Subscription) error {
r.sub, r.updated = sub, true
return nil
}
type billingPlanRepo struct {
repository.PlanRepository
plan *models.Plan
}
func (r *billingPlanRepo) GetByID(context.Context, uuid.UUID) (*models.Plan, error) {
return r.plan, nil
}
func (r *billingPlanRepo) GetByStripePriceID(context.Context, string) (*models.Plan, error) {
return r.plan, nil
}
func TestSubscriptionCheckoutCustomerParameters(t *testing.T) {
for _, customerID := range []string{"", "cus_existing"} {
t.Run("customer_"+customerID, func(t *testing.T) {
called := false
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
called = true
if err := r.ParseForm(); err != nil {
t.Fatal(err)
}
if r.Form.Get("mode") != "subscription" || r.Form.Has("customer_creation") || r.Form.Get("customer") != customerID {
t.Errorf("invalid checkout parameters: %v", r.Form)
}
w.Header().Set("Content-Type", "application/json")
_, _ = w.Write([]byte(`{"id":"cs_test","url":"https://checkout.stripe.com/test"}`))
}))
defer server.Close()
old := stripeapi.GetBackend(stripeapi.APIBackend)
stripeapi.SetBackend(stripeapi.APIBackend, stripeapi.GetBackendWithConfig(stripeapi.APIBackend, &stripeapi.BackendConfig{URL: stripeapi.String(server.URL), HTTPClient: server.Client()}))
t.Cleanup(func() { stripeapi.SetBackend(stripeapi.APIBackend, old) })
s := &stripeService{subRepo: &billingSubRepo{sub: &models.Subscription{StripeCustomerID: customerID}}}
_, err := s.CreateCheckoutSession(context.Background(), uuid.New(), uuid.New(), "price_test", "https://example.com/success", "https://example.com/cancel", "")
if err != nil || !called {
t.Fatalf("checkout called=%v error=%v", called, err)
}
})
}
}
func TestPortalRejectsMissingCustomer(t *testing.T) {
for _, customerID := range []string{"", " \t"} {
url, err := (&stripeService{}).CreatePortalSession(context.Background(), customerID, "https://example.com")
if err == nil || err.Code != errx.BadRequest || url != "" {
t.Fatalf("url=%q error=%v", url, err)
}
}
}
func TestSubscriptionEventBeforeCheckout(t *testing.T) {
orgID, planID := uuid.New(), uuid.New()
repo := &billingSubRepo{sub: &models.Subscription{OrganizationID: orgID}}
s := &stripeService{subRepo: repo, planRepo: &billingPlanRepo{plan: &models.Plan{ID: planID}}}
raw, err := json.Marshal(map[string]any{"id": "sub_new", "customer": "cus_new", "status": "active", "metadata": map[string]string{"org_id": orgID.String()}, "items": map[string]any{"data": []any{map[string]any{"price": map[string]string{"id": "price_new"}}}}})
if err != nil {
t.Fatal(err)
}
if xerr := s.handleSubscriptionUpdated(context.Background(), &stripeapi.Event{Data: &stripeapi.EventData{Raw: raw}}); xerr != nil {
t.Fatal(xerr)
}
if !repo.updated || repo.sub.PlanID != planID || repo.sub.StripeCustomerID != "cus_new" || repo.sub.StripeSubscriptionID == nil || *repo.sub.StripeSubscriptionID != "sub_new" || repo.sub.Status != models.SubscriptionStatusActive {
t.Fatalf("subscription event did not activate the workspace: %+v", repo.sub)
}
}
func TestSubscriptionLookupFailureRemainsRetryable(t *testing.T) {
s := &stripeService{subRepo: &billingSubRepo{lookupErr: errors.New("database unavailable")}}
err := s.handleSubscriptionUpdated(context.Background(), &stripeapi.Event{Data: &stripeapi.EventData{Raw: json.RawMessage(`{"id":"sub_new"}`)}})
if err == nil || err.Code != errx.Internal {
t.Fatalf("expected retryable failure, got %v", err)
}
}
func TestAnnualChangeDoesNotFallBackToMonthly(t *testing.T) {
s := &stripeService{subRepo: &billingSubRepo{sub: &models.Subscription{StripeSubscriptionID: stripeapi.String("sub_existing")}}, planRepo: &billingPlanRepo{plan: &models.Plan{StripePriceID: stripeapi.String("price_monthly")}}}
_, err := s.ChangePlan(context.Background(), uuid.New(), uuid.New(), "", "", "year")
if err == nil || err.Code != errx.BadRequest {
t.Fatalf("expected unavailable annual price, got %v", err)
}
}
type billingCredits struct {
resetErr error
granted int
reset int
}
func (c *billingCredits) ResetMonthlyAllowance(_ context.Context, _ uuid.UUID, amount int, _ string) error {
c.reset = amount
return c.resetErr
}
func (c *billingCredits) GrantPurchased(_ context.Context, _ uuid.UUID, amount int, _, _ string) (int, error) {
c.granted += amount
return c.granted, nil
}
func (r *billingSubRepo) WebhookEventExists(context.Context, string) (bool, error) { return false, nil }
func (r *billingSubRepo) RecordWebhookEvent(context.Context, *models.StripeWebhookEvent) error {
r.recorded = true
return nil
}
func TestInvoiceBeforeCheckoutAndRetryAfterCreditFailure(t *testing.T) {
orgID := uuid.New()
repo := &billingSubRepo{sub: &models.Subscription{OrganizationID: orgID}}
credits := &billingCredits{resetErr: errors.New("temporary ledger failure")}
s := &stripeService{subRepo: repo, planRepo: &billingPlanRepo{plan: &models.Plan{MonthlyCredits: 500}}, credits: credits}
raw := json.RawMessage(`{"subscription":"sub_new","billing_reason":"subscription_create","subscription_details":{"metadata":{"org_id":"` + orgID.String() + `"}},"lines":{"data":[{"price":{"id":"price_new"}}]}}`)
event := &stripeapi.Event{ID: "evt_invoice", Type: "invoice.paid", Data: &stripeapi.EventData{Raw: raw}}
if err := s.ProcessWebhookEvent(context.Background(), event); err == nil || err.Code != errx.Internal {
t.Fatalf("expected retryable error, got %v", err)
}
if repo.recorded {
t.Fatal("failed credit grant marked processed")
}
credits.resetErr = nil
if err := s.ProcessWebhookEvent(context.Background(), event); err != nil {
t.Fatal(err)
}
if !repo.recorded || credits.reset != 500 {
t.Fatal("retried invoice did not grant monthly allowance")
}
}
func TestAsyncCreditPurchaseStoresCustomer(t *testing.T) {
orgID := uuid.New()
repo := &billingSubRepo{sub: &models.Subscription{OrganizationID: orgID}}
credits := &billingCredits{}
s := &stripeService{subRepo: repo, credits: credits}
raw := json.RawMessage(`{"id":"cs_credit","customer":"cus_credit","payment_status":"paid","metadata":{"purpose":"credit_topup","credits":"500","org_id":"` + orgID.String() + `"}}`)
event := &stripeapi.Event{ID: "evt_credit", Type: "checkout.session.async_payment_succeeded", Data: &stripeapi.EventData{Raw: raw}}
if err := s.ProcessWebhookEvent(context.Background(), event); err != nil {
t.Fatal(err)
}
if !repo.updated || repo.sub.StripeCustomerID != "cus_credit" || credits.granted != 500 || !repo.recorded {
t.Fatal("purchase did not persist the billing customer and credits")
}
}
+2 -5
View File
@@ -148,11 +148,8 @@ export default function AuditPage() {
)
: all;
const stats = React.useMemo(() => {
const out: Record<string, number> = { total: all.length };
for (const l of all) out[l.action] = (out[l.action] ?? 0) + 1;
return out;
}, [all]);
const stats: Record<string, number> = { total: all.length };
for (const log of all) stats[log.action] = (stats[log.action] ?? 0) + 1;
const activeFilterCount =
(action ? 1 : 0) + (entityType ? 1 : 0) + (date ? 1 : 0) + (search ? 1 : 0);
@@ -182,7 +182,7 @@ export default function OverviewTab({ onChangePlan }: { onChangePlan: () => void
<SparklesIcon className="w-3 h-3" />
{onFreeTier ? "Choose a plan" : "Change plan"}
</button>
<button
{flow.hasBillingCustomer && <button
type="button"
onClick={flow.openPortal}
disabled={flow.portalPending}
@@ -194,8 +194,8 @@ export default function OverviewTab({ onChangePlan }: { onChangePlan: () => void
<CreditCardIcon className="w-3 h-3" />
)}
Payment method
</button>
<button
</button>}
{flow.hasBillingCustomer && <button
type="button"
onClick={flow.openPortal}
disabled={flow.portalPending}
@@ -203,7 +203,7 @@ export default function OverviewTab({ onChangePlan }: { onChangePlan: () => void
>
<FileTextIcon className="w-3 h-3" />
Invoices
</button>
</button>}
<Link
to="/app/settings/limits"
className="h-7 px-2.5 rounded-md border border-slate-200 hover:border-slate-300 text-[12px] text-slate-700 hover:text-slate-900 inline-flex items-center gap-1.5 transition-colors"
@@ -211,7 +211,7 @@ export default function OverviewTab({ onChangePlan }: { onChangePlan: () => void
<SlidersHorizontalIcon className="w-3 h-3" />
Request a limit increase
</Link>
{!onFreeTier && (
{flow.hasStripeSubscription && (
<div className="ml-auto">
{cancelAtEnd ? (
<button
+19 -10
View File
@@ -205,14 +205,14 @@ export default function BillingSettingsPage() {
<SectionShell
title="Billing"
description={`Plan, payment and invoices for ${currentOrg?.name ?? "this workspace"}.`}
actions={
actions={flow.hasBillingCustomer ? (
<TopbarAction
icon={<ExternalLinkIcon className="w-3 h-3" />}
onClick={openPortal}
>
{flow.portalPending ? "Opening…" : "Manage billing"}
</TopbarAction>
}
) : undefined}
>
<div>
<div className="sticky top-0 z-20 bg-white/95 backdrop-blur px-2 md:px-6 flex items-center gap-1 border-b border-slate-200/70 overflow-x-auto">
@@ -281,8 +281,9 @@ export default function BillingSettingsPage() {
ctaVerb={currentPlan.id === "free" ? "Get" : "Switch to"}
footer={
<ProrationNote
interval={billingInterval}
planId={flow.resolveServerPlan(id)?.id}
enabled={currentPlan.id !== "free" && currentPlan.id !== id}
enabled={flow.hasStripeSubscription && currentPlan.id !== id}
/>
}
onChoose={() => upgrade(id)}
@@ -395,7 +396,13 @@ export default function BillingSettingsPage() {
</>
)}
{tab === "payment" && (
{tab === "payment" && !flow.hasBillingCustomer && (
<Section eyebrow="Payment" description="Complete checkout to set up billing. Operator-granted plans do not create a Stripe billing account.">
<button type="button" onClick={() => navigate(pathForTab("plans"))} className="h-7 px-3 rounded-md bg-slate-900 text-white text-[12px]">Choose a plan</button>
</Section>
)}
{tab === "payment" && flow.hasBillingCustomer && (
<>
<Section
eyebrow="Payment"
@@ -523,12 +530,14 @@ function fmtDate(value?: string | null): string {
// What a switch costs today, shown on each plan card for a paying workspace.
// An empty id disables the query, so free workspaces and the current plan never
// hit /subscription/preview-change.
function ProrationNote({ planId, enabled }: { planId?: string; enabled: boolean }) {
const preview = usePreviewPlanChange(enabled && planId ? planId : "");
function ProrationNote({ planId, enabled, interval }: { planId?: string; enabled: boolean; interval: BillingInterval }) {
const preview = usePreviewPlanChange(enabled && interval === "monthly" && planId ? planId : "");
if (!enabled) return null;
return (
<div className="rounded-md border border-slate-200/80 bg-slate-50 px-2.5 py-1.5 text-[11px] leading-snug">
{preview.isPending ? (
{interval === "annual" ? (
<span className="text-slate-500">Stripe calculates annual proration when you switch.</span>
) : preview.isPending ? (
<span className="text-slate-400">Pricing this switch…</span>
) : preview.data ? (
<>
@@ -539,9 +548,9 @@ function ProrationNote({ planId, enabled }: { planId?: string; enabled: boolean
preview.data.proration_amount,
preview.data.currency,
);
if (due > 0) return `Due today $${fmtMoney(due)}`;
if (due < 0) return `Credit $${fmtMoney(Math.abs(due))}`;
return "No charge today";
if (due > 0) return `Estimated proration $${fmtMoney(due)}`;
if (due < 0) return `Estimated credit $${fmtMoney(Math.abs(due))}`;
return "No estimated proration";
})()}
</div>
<div className="text-slate-400">
+2 -1
View File
@@ -18,6 +18,7 @@
// subscription row is ignored: it would otherwise report a free trial that
// nothing enforces.
import type Subscription from "@/lib/api/models/app/subscription/Subscription";
import useSubscription from "@/lib/api/hooks/app/subscription/useSubscription";
import useAuthConfig from "@/lib/api/hooks/auth/useAuthConfig";
import { useAppStore } from "@/stores";
@@ -32,7 +33,7 @@ export type Plan = PlanID;
export interface FeatureAccess {
loading: boolean;
status?: "active" | "canceled" | "past_due" | "trialing" | "incomplete";
status?: Subscription["status"];
plan: PlanID;
/** False on a deployment running without a billing provider: every gate
* below is open and billing/referral surfaces do not apply. */
+82
View File
@@ -0,0 +1,82 @@
import { act, renderHook } from "@testing-library/react";
import { beforeEach, describe, expect, it, vi } from "vitest";
import useUpgradeFlow from "./useUpgradeFlow";
const state = vi.hoisted(() => ({
subscription: { data: { stripe_customer_id: "", stripe_subscription_id: null as string | null, status: "incomplete", managed: true }, isPending: false, isError: false },
plans: { data: [{ id: "grow-id", name: "Grow", stripe_price_id: "price_month", stripe_price_id_yearly: "price_year" }], isPending: false },
checkout: vi.fn(),
change: vi.fn(),
portal: vi.fn(),
error: vi.fn(),
}));
vi.mock("@/lib/api/hooks/app/subscription/useSubscription", () => ({ default: () => state.subscription }));
vi.mock("@/lib/api/hooks/app/subscription/usePlans", () => ({ default: () => state.plans }));
vi.mock("@/lib/api/hooks/app/subscription/useCreateCheckoutSession", () => ({ default: () => ({ mutateAsync: state.checkout }) }));
vi.mock("@/lib/api/hooks/app/subscription/useChangePlan", () => ({ default: () => ({ mutateAsync: state.change }) }));
vi.mock("@/lib/api/hooks/app/subscription/useCreatePortalSession", () => ({ default: () => ({ mutateAsync: state.portal, isPending: false }) }));
vi.mock("react-hot-toast", () => ({ default: { promise: (p: Promise<unknown>) => p, error: state.error } }));
beforeEach(() => {
vi.clearAllMocks();
state.subscription.data = { stripe_customer_id: "", stripe_subscription_id: null, status: "incomplete", managed: true };
state.subscription.isPending = false;
state.subscription.isError = false;
state.plans.data[0].stripe_price_id_yearly = "price_year";
state.checkout.mockRejectedValue(new Error("stop before redirect"));
state.change.mockResolvedValue({});
});
describe("billing flow selection", () => {
it("sends a managed workspace without a Stripe subscription to checkout", async () => {
const { result } = renderHook(() => useUpgradeFlow());
await act(async () => { await result.current.upgrade("grow", { interval: "annual" }); });
expect(state.checkout).toHaveBeenCalledWith(expect.objectContaining({ price_id: "price_year" }));
expect(state.change).not.toHaveBeenCalled();
expect(state.portal).not.toHaveBeenCalled();
});
it("changes an existing Stripe subscription in place", async () => {
state.subscription.data = { stripe_customer_id: "cus_existing", stripe_subscription_id: "sub_existing", status: "active", managed: false };
const { result } = renderHook(() => useUpgradeFlow());
await act(async () => { await result.current.upgrade("grow", { interval: "annual" }); });
expect(state.change).toHaveBeenCalledWith(expect.objectContaining({ plan_id: "grow-id", interval: "year" }));
expect(state.checkout).not.toHaveBeenCalled();
});
it("uses checkout again after a subscription has ended", async () => {
state.subscription.data.stripe_subscription_id = "sub_canceled";
state.subscription.data.status = "canceled";
const { result } = renderHook(() => useUpgradeFlow());
await act(async () => { await result.current.upgrade("grow", { interval: "annual" }); });
expect(state.checkout).toHaveBeenCalled();
expect(state.change).not.toHaveBeenCalled();
});
it("does not send missing prices to the portal", async () => {
state.plans.data[0].stripe_price_id_yearly = "";
const { result } = renderHook(() => useUpgradeFlow());
await act(async () => { await result.current.upgrade("grow", { interval: "annual" }); });
expect(state.checkout).not.toHaveBeenCalled();
expect(state.portal).not.toHaveBeenCalled();
expect(state.error).toHaveBeenCalled();
});
it("never requests a portal for a workspace without a customer", async () => {
const { result } = renderHook(() => useUpgradeFlow());
await act(async () => { expect(await result.current.openPortal()).toBe(false); });
expect(state.portal).not.toHaveBeenCalled();
expect(result.current.hasBillingCustomer).toBe(false);
});
it("does not start checkout when the existing subscription cannot be loaded", async () => {
state.subscription.isError = true;
const { result } = renderHook(() => useUpgradeFlow());
await act(async () => { await result.current.upgrade("grow", { interval: "annual" }); });
expect(state.checkout).not.toHaveBeenCalled();
expect(state.change).not.toHaveBeenCalled();
expect(state.error).toHaveBeenCalled();
});
it("waits for subscription state before choosing a billing operation", async () => {
state.subscription.isPending = true;
const { result } = renderHook(() => useUpgradeFlow());
await act(async () => { await result.current.upgrade("grow", { interval: "annual" }); });
expect(state.checkout).not.toHaveBeenCalled();
expect(state.change).not.toHaveBeenCalled();
});
});
+26 -13
View File
@@ -4,11 +4,12 @@
//
// no active subscription → Stripe Checkout (redirect), returns to `returnTo`
// already on a paid plan → in-place plan change with the chosen interval
// enterprise / unresolved → the Stripe billing portal
// enterprise → contact sales
// missing price → explain that the plan is unavailable
import React from "react";
import toast from "react-hot-toast";
import useFeatureAccess from "@/hooks/useFeatureAccess";
import useSubscription from "@/lib/api/hooks/app/subscription/useSubscription";
import useCreateCheckoutSession from "@/lib/api/hooks/app/subscription/useCreateCheckoutSession";
import useChangePlan from "@/lib/api/hooks/app/subscription/useChangePlan";
import useCreatePortalSession from "@/lib/api/hooks/app/subscription/useCreatePortalSession";
@@ -38,7 +39,10 @@ function returnUrl(path: string, result: "success" | "cancel"): string {
}
export default function useUpgradeFlow() {
const access = useFeatureAccess();
const subscription = useSubscription();
const hasBillingCustomer = !!subscription.data?.stripe_customer_id?.trim();
const hasStripeSubscription = !!subscription.data?.stripe_subscription_id?.trim() &&
!["canceled", "incomplete_expired"].includes(subscription.data?.status ?? "");
const plansQuery = usePlans();
const checkout = useCreateCheckoutSession();
const changePlan = useChangePlan();
@@ -71,6 +75,10 @@ export default function useUpgradeFlow() {
);
const openPortal = React.useCallback(async (): Promise<boolean> => {
if (!hasBillingCustomer) {
toast.error(subscription.isPending ? "Still loading billing. Try again in a moment." : "Complete checkout to set up billing before opening the portal.");
return false;
}
try {
// Stripe returns the browser to wherever the portal was opened from.
const { url } = await toast.promise(portal.mutateAsync({ return_url: window.location.href }), {
@@ -83,18 +91,20 @@ export default function useUpgradeFlow() {
} catch {
return false;
}
}, [portal]);
}, [portal, hasBillingCustomer, subscription.isPending]);
const upgrade = React.useCallback(
async (catalogId: PlanID, opts: UpgradeOptions): Promise<UpgradeOutcome> => {
if (pending) return "failed";
// Plans are still loading: resolveServerPlan would return undefined
// and the caller would be sent to the billing portal instead of
// Stripe Checkout. Refuse rather than take the wrong branch.
if (plansQuery.isPending) {
// Wait for billing state before choosing checkout or a plan change.
if (plansQuery.isPending || subscription.isPending) {
toast.error("Still loading plans. Try again in a moment.");
return "failed";
}
if (subscription.isError || plansQuery.isError) {
toast.error("Could not load billing. Reload the page before trying again.");
return "failed";
}
setPending(catalogId);
let outcome: UpgradeOutcome = "failed";
try {
@@ -103,12 +113,13 @@ export default function useUpgradeFlow() {
return outcome;
}
const target = resolveServerPlan(catalogId);
const onPaid = getPlan(access.plan).id !== "free";
const onPaid = hasStripeSubscription;
const annual = opts.interval === "annual";
const priceId = annual ? target?.stripe_price_id_yearly : target?.stripe_price_id;
if (!target || (!onPaid && !priceId)) {
outcome = (await openPortal()) ? "portal" : "failed";
if (!target || !priceId) {
toast.error("This plan is not available for the selected billing interval. Please contact support.");
outcome = "failed";
return outcome;
}
@@ -150,17 +161,19 @@ export default function useUpgradeFlow() {
return "failed";
} finally {
// A redirect keeps its spinner until the page unloads.
if (outcome !== "redirect" && outcome !== "portal") {
if (outcome !== "redirect") {
setPending((p) => (p === catalogId ? null : p));
}
}
},
[pending, plansQuery.isPending, access.plan, resolveServerPlan, openPortal, changePlan, checkout],
[pending, plansQuery.isPending, plansQuery.isError, subscription.isPending, subscription.isError, hasStripeSubscription, resolveServerPlan, changePlan, checkout],
);
return {
upgrade,
openPortal,
hasBillingCustomer,
hasStripeSubscription,
resolveServerPlan,
/** Catalog plan whose upgrade is in flight, if any. */
pending,
@@ -1,10 +1,12 @@
export default interface Subscription {
id: string
stripe_customer_id: string
stripe_subscription_id?: string | null
plan_id: string
// Nested plan summary as the API returns it (the old flat plan_name field
// never existed on the wire).
plan?: { name: string } | null
status: 'active' | 'canceled' | 'past_due' | 'trialing' | 'incomplete'
status: 'active' | 'canceled' | 'past_due' | 'trialing' | 'incomplete' | 'incomplete_expired' | 'unpaid' | 'paused'
// True when an operator granted this plan rather than Stripe. Such a
// subscription keeps whatever `status` it had, so this is the only signal
// that the workspace is entitled. Expiry is resolved server-side.