mirror of
https://github.com/warmbly/warmbly.git
synced 2026-10-05 00:02:12 +00:00
feat: fix dashboard Stripe checkout, portal eligibility, webhook recovery and audit rendering
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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. */
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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.
|
||||
|
||||
Reference in New Issue
Block a user