From 9a2c565abefca25e4b8ca4f76f47a55f4da30f8b Mon Sep 17 00:00:00 2001 From: Matthew Meszaros Date: Thu, 17 Sep 2026 14:39:50 +0200 Subject: [PATCH] feat: enable Stripe Tax for subscriptions, credit purchases, automatic top-ups, billing details, and failure alerts --- .../docs/development/configuration.mdx | 10 +- docs/content/docs/guides/billing.mdx | 4 + go.mod | 4 +- go.sum | 5 +- internal/app/opsnotify/events.go | 4 +- internal/app/stripe/disabled.go | 2 +- internal/app/stripe/service.go | 291 +++++++++++++++--- internal/app/stripe/service_test.go | 93 +++++- site/src/pages/pricing.astro | 5 + 9 files changed, 357 insertions(+), 61 deletions(-) diff --git a/docs/content/docs/development/configuration.mdx b/docs/content/docs/development/configuration.mdx index c7e6f4630..08dd4b263 100644 --- a/docs/content/docs/development/configuration.mdx +++ b/docs/content/docs/development/configuration.mdx @@ -475,7 +475,15 @@ Automatic inbox tagging is read by the backend, consumer, and `warmblyctl`. Both | `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. +Stripe Tax also needs account-side setup. In **Tax > Settings**, confirm the head office address, use the business-use SaaS product tax code (`txcd_10103001`) as the default, and choose whether prices include tax or have tax added at checkout. Apply the same tax code and an explicit tax behavior to every subscription and credit-pack product and price. A price set to inclusive or exclusive cannot be changed later, so replacing that choice requires a new Price and an update to the matching plan or `STRIPE_CREDIT_PACK_*_PRICE_ID` value. + +Add a Tax registration only after the business is registered with that tax authority. Automatic tax returns zero with a `not_collecting` reason in jurisdictions where no active registration applies. Enabling Stripe Tax is not a substitute for registering or filing. + +Stripe Checkout always requests the billing address needed for tax and lets business buyers provide a legal name and business tax ID. The active default billing portal configuration must allow updates to payment methods, billing email, billing address, name, and tax ID, and must show invoice history. This lets a customer correct its tax location or registration before a renewal. + +Point the webhook endpoint at `/webhook/stripe` using API version `2026-08-26.dahlia`, matching stripe-go v86. 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`, `invoice.finalization_failed`, `payment_intent.succeeded` and `charge.refunded`. If you replace the endpoint, update `STRIPE_WEBHOOK_SECRET` to the new endpoint's signing secret before disabling the old endpoint. The handler accepts the previous webhook shape during an endpoint upgrade, but new endpoints should use the matching version. + +Turning on Stripe Tax does not update existing subscriptions. Review and migrate them from **Tax > Migrations** after setting the prices' tax behavior. Stripe applies the tax change from the next billing cycle without prorating it. The Dashboard migration does not operate on sandbox subscriptions, so update those individually when testing. Delayed sends run through the local poller, so the backend must be running for scheduled work to fire. diff --git a/docs/content/docs/guides/billing.mdx b/docs/content/docs/guides/billing.mdx index 247c91331..02499cb65 100644 --- a/docs/content/docs/guides/billing.mdx +++ b/docs/content/docs/guides/billing.mdx @@ -28,6 +28,8 @@ When you open something your plan does not include, the dashboard opens a full-s Promo codes can be applied in the chooser before checkout. +Stripe calculates applicable sales tax, VAT, or GST from the billing address entered at checkout. Where supported, a buyer purchasing for a company can add the company's legal name and business tax ID. Stripe includes those details on invoices and applies reverse charge or another zero-tax treatment where the jurisdiction and tax ID allow it. + Only the workspace owner can change the plan. Other members see the same comparison with a note to ask the owner. Which roles can do what is covered in [Team and roles](/guides/team-roles/). ## Managing your subscription @@ -49,6 +51,8 @@ Need a single limit raised without moving plan? Request an increase from **Setti **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 also lets the owner update the billing address, legal billing name, and business tax ID. Update these details before the next renewal if the company moves or its tax registration changes. + 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 diff --git a/go.mod b/go.mod index 76d5252da..0a57a75f4 100644 --- a/go.mod +++ b/go.mod @@ -27,6 +27,7 @@ require ( github.com/golangci/golangci-lint v1.64.8 github.com/google/uuid v1.6.0 github.com/gorilla/websocket v1.5.0 + github.com/hamba/avro/v2 v2.24.0 github.com/invopop/jsonschema v0.13.0 github.com/jackc/pgx/v5 v5.9.0 github.com/meszmate/apple-go v0.0.0-20250828163208-7fea48c91b32 @@ -42,7 +43,7 @@ require ( github.com/redis/go-redis/v9 v9.11.0 github.com/rs/zerolog v1.34.0 github.com/spf13/cobra v1.9.1 - github.com/stripe/stripe-go/v76 v76.25.0 + github.com/stripe/stripe-go/v86 v86.4.2 github.com/xuri/excelize/v2 v2.11.0 go.uber.org/zap v1.27.0 golang.org/x/crypto v0.55.0 @@ -175,7 +176,6 @@ require ( github.com/gostaticanalysis/comment v1.5.0 // indirect github.com/gostaticanalysis/forcetypeassert v0.2.0 // indirect github.com/gostaticanalysis/nilerr v0.1.1 // indirect - github.com/hamba/avro/v2 v2.24.0 // indirect github.com/hashicorp/go-immutable-radix/v2 v2.1.0 // indirect github.com/hashicorp/go-version v1.7.0 // indirect github.com/hashicorp/golang-lru/v2 v2.0.7 // indirect diff --git a/go.sum b/go.sum index a030343b9..22254ef50 100644 --- a/go.sum +++ b/go.sum @@ -861,8 +861,8 @@ github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo= github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= -github.com/stripe/stripe-go/v76 v76.25.0 h1:kmDoOTvdQSTQssQzWZQQkgbAR2Q8eXdMWbN/ylNalWA= -github.com/stripe/stripe-go/v76 v76.25.0/go.mod h1:rw1MxjlAKKcZ+3FOXgTHgwiOa2ya6CPq6ykpJ0Q6Po4= +github.com/stripe/stripe-go/v86 v86.4.2 h1:ITFadkLOU2nlPvVJJdT6WwpScsc8q7X0dR4yrdEr7os= +github.com/stripe/stripe-go/v86 v86.4.2/go.mod h1:Co7QRXCKGNOPTugAdvjgRo+KcMtd9hxy+pZMN0yThsQ= github.com/subosito/gotenv v1.4.1 h1:jyEFiXpy21Wm81FBN71l9VoMMV8H8jG+qIK3GCpY6Qs= github.com/subosito/gotenv v1.4.1/go.mod h1:ayKnFf/c6rvx/2iiLrJUk1e6plDbT3edrFNGqEflhK0= github.com/tdakkota/asciicheck v0.4.1 h1:bm0tbcmi0jezRA2b5kg4ozmMuGAFotKI3RZfrhfovg8= @@ -1074,7 +1074,6 @@ golang.org/x/net v0.0.0-20201021035429-f5854403a974/go.mod h1:sp8m0HH+o8qH0wwXwY golang.org/x/net v0.0.0-20201110031124-69a78807bb2b/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU= golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg= golang.org/x/net v0.0.0-20210405180319-a5a99cb37ef4/go.mod h1:p54w0d4576C0XHj96bSt6lcn1PtDYWL6XObtHCRCNQM= -golang.org/x/net v0.0.0-20210520170846-37e1c6afe023/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y= golang.org/x/net v0.0.0-20211015210444-4f30a5c0130f/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y= golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c= golang.org/x/net v0.2.0/go.mod h1:KqCZLdyyvdV855qA2rE3GC2aiw5xGR5TEjj8smXukLY= diff --git a/internal/app/opsnotify/events.go b/internal/app/opsnotify/events.go index 846954b34..ed0c53ff5 100644 --- a/internal/app/opsnotify/events.go +++ b/internal/app/opsnotify/events.go @@ -61,8 +61,8 @@ var Catalog = []EventDef{ }, { Key: EventSubscriptionIssue, Group: "Sales", - Label: "Payment failed", - Description: "A subscription went past due and sending is at risk.", + Label: "Subscription billing issue", + Description: "A subscription invoice could not be finalized or paid, so sending may be at risk.", Severity: SeverityWarning, }, { diff --git a/internal/app/stripe/disabled.go b/internal/app/stripe/disabled.go index a780fb419..507fee669 100644 --- a/internal/app/stripe/disabled.go +++ b/internal/app/stripe/disabled.go @@ -4,7 +4,7 @@ import ( "context" "github.com/google/uuid" - stripe "github.com/stripe/stripe-go/v76" + stripe "github.com/stripe/stripe-go/v86" "github.com/warmbly/warmbly/internal/errx" ) diff --git a/internal/app/stripe/service.go b/internal/app/stripe/service.go index 50a43ca24..e02e2f046 100644 --- a/internal/app/stripe/service.go +++ b/internal/app/stripe/service.go @@ -13,17 +13,18 @@ import ( "github.com/google/uuid" "github.com/rs/zerolog/log" - "github.com/stripe/stripe-go/v76" - portalsession "github.com/stripe/stripe-go/v76/billingportal/session" - "github.com/stripe/stripe-go/v76/checkout/session" - "github.com/stripe/stripe-go/v76/coupon" - "github.com/stripe/stripe-go/v76/customer" - balancetxn "github.com/stripe/stripe-go/v76/customerbalancetransaction" - "github.com/stripe/stripe-go/v76/invoice" - "github.com/stripe/stripe-go/v76/paymentintent" - "github.com/stripe/stripe-go/v76/price" - "github.com/stripe/stripe-go/v76/subscription" - "github.com/stripe/stripe-go/v76/webhook" + "github.com/stripe/stripe-go/v86" + portalsession "github.com/stripe/stripe-go/v86/billingportal/session" + "github.com/stripe/stripe-go/v86/checkout/session" + "github.com/stripe/stripe-go/v86/coupon" + "github.com/stripe/stripe-go/v86/customer" + balancetxn "github.com/stripe/stripe-go/v86/customerbalancetransaction" + "github.com/stripe/stripe-go/v86/invoice" + "github.com/stripe/stripe-go/v86/paymentintent" + "github.com/stripe/stripe-go/v86/price" + "github.com/stripe/stripe-go/v86/subscription" + taxcalculation "github.com/stripe/stripe-go/v86/tax/calculation" + "github.com/stripe/stripe-go/v86/webhook" "github.com/warmbly/warmbly/internal/app/discount" "github.com/warmbly/warmbly/internal/app/worker" "github.com/warmbly/warmbly/internal/config" @@ -239,7 +240,10 @@ func (s *stripeService) CreateCheckoutSession(ctx context.Context, userID uuid.U } params := &stripe.CheckoutSessionParams{ - Mode: stripe.String(string(stripe.CheckoutSessionModeSubscription)), + Mode: stripe.String(string(stripe.CheckoutSessionModeSubscription)), + AutomaticTax: &stripe.CheckoutSessionAutomaticTaxParams{Enabled: stripe.Bool(true)}, + BillingAddressCollection: stripe.String(string(stripe.CheckoutSessionBillingAddressCollectionRequired)), + TaxIDCollection: &stripe.CheckoutSessionTaxIDCollectionParams{Enabled: stripe.Bool(true)}, LineItems: []*stripe.CheckoutSessionLineItemParams{ { Price: stripe.String(priceID), @@ -262,6 +266,10 @@ func (s *stripeService) CreateCheckoutSession(ctx context.Context, userID uuid.U if customerID != "" { params.Customer = stripe.String(customerID) + params.CustomerUpdate = &stripe.CheckoutSessionCustomerUpdateParams{ + Address: stripe.String("auto"), + Name: stripe.String("auto"), + } } // Auto-apply the invitee's referral discount when none was supplied, so a @@ -400,7 +408,10 @@ func (s *stripeService) CreateCreditCheckoutSession(ctx context.Context, userID, } params := &stripe.CheckoutSessionParams{ - Mode: stripe.String(string(stripe.CheckoutSessionModePayment)), + Mode: stripe.String(string(stripe.CheckoutSessionModePayment)), + AutomaticTax: &stripe.CheckoutSessionAutomaticTaxParams{Enabled: stripe.Bool(true)}, + BillingAddressCollection: stripe.String(string(stripe.CheckoutSessionBillingAddressCollectionRequired)), + TaxIDCollection: &stripe.CheckoutSessionTaxIDCollectionParams{Enabled: stripe.Bool(true)}, LineItems: []*stripe.CheckoutSessionLineItemParams{ {Price: stripe.String(priceID), Quantity: stripe.Int64(1)}, }, @@ -418,6 +429,10 @@ func (s *stripeService) CreateCreditCheckoutSession(ctx context.Context, userID, } if sub != nil && sub.StripeCustomerID != "" { params.Customer = stripe.String(sub.StripeCustomerID) + params.CustomerUpdate = &stripe.CheckoutSessionCustomerUpdateParams{ + Address: stripe.String("auto"), + Name: stripe.String("auto"), + } } else { params.CustomerCreation = stripe.String("always") } @@ -451,6 +466,9 @@ func (s *stripeService) AutoTopUpCredits(ctx context.Context, orgID uuid.UUID, p if perr != nil { return false, fmt.Errorf("resolve pack price: %w", perr) } + if p.TaxBehavior != stripe.PriceTaxBehaviorInclusive && p.TaxBehavior != stripe.PriceTaxBehaviorExclusive { + return false, fmt.Errorf("credit pack price %q must have an explicit tax behavior", priceID) + } // Off-session confirmation needs the customer's saved default payment // method (the card the subscription bills). Without one, auto top-up @@ -469,18 +487,42 @@ func (s *stripeService) AutoTopUpCredits(ctx context.Context, orgID uuid.UUID, p return false, fmt.Errorf("no saved payment method") } + lineItem := &stripe.TaxCalculationLineItemParams{ + Amount: stripe.Int64(p.UnitAmount), + Quantity: stripe.Int64(1), + Reference: stripe.String(packKey), + } + if p.Product != nil && p.Product.ID != "" { + lineItem.Product = stripe.String(p.Product.ID) + } + lineItem.TaxBehavior = stripe.String(string(p.TaxBehavior)) + taxCalc, taxErr := taxcalculation.New(&stripe.TaxCalculationParams{ + Currency: stripe.String(string(p.Currency)), + Customer: stripe.String(sub.StripeCustomerID), + LineItems: []*stripe.TaxCalculationLineItemParams{lineItem}, + }) + if taxErr != nil { + return false, fmt.Errorf("calculate credit top-up tax: %w", taxErr) + } + pi, ierr := paymentintent.New(&stripe.PaymentIntentParams{ - Amount: stripe.Int64(p.UnitAmount), + Amount: stripe.Int64(taxCalc.AmountTotal), Currency: stripe.String(string(p.Currency)), Customer: stripe.String(sub.StripeCustomerID), PaymentMethod: stripe.String(pmID), OffSession: stripe.Bool(true), Confirm: stripe.Bool(true), + Hooks: &stripe.PaymentIntentHooksParams{ + Inputs: &stripe.PaymentIntentHooksInputsParams{ + Tax: &stripe.PaymentIntentHooksInputsTaxParams{Calculation: stripe.String(taxCalc.ID)}, + }, + }, Metadata: map[string]string{ - "org_id": orgID.String(), - "purpose": "credit_auto_topup", - "pack_key": packKey, - "credits": strconv.Itoa(creditAmount), + "org_id": orgID.String(), + "purpose": "credit_auto_topup", + "pack_key": packKey, + "credits": strconv.Itoa(creditAmount), + "tax_calculation_id": taxCalc.ID, }, }) if ierr != nil { @@ -490,17 +532,8 @@ func (s *stripeService) AutoTopUpCredits(ctx context.Context, orgID uuid.UUID, p return false, fmt.Errorf("off-session charge not settled (status %s)", pi.Status) } - // Fulfill immediately, idempotent on the PaymentIntent id so a concurrent - // webhook or retry can never double-grant. - if _, gerr := s.credits.GrantPurchased(ctx, orgID, creditAmount, "credit_auto_topup", pi.ID); gerr != nil { - return false, fmt.Errorf("grant after charge: %w", gerr) - } - if s.audit != nil { - s.audit.LogAction(ctx, orgID, uuid.Nil, models.AuditActionCreate, models.AuditEntityCreditPurchase, nil, "", "", nil, map[string]string{ - "pack_key": packKey, - "credits": strconv.Itoa(creditAmount), - "auto": "true", - }) + if xerr := s.fulfillAutoTopUp(ctx, pi, false); xerr != nil { + return false, fmt.Errorf("grant after charge: %s", xerr.Message) } return true, nil } @@ -633,9 +666,10 @@ func (s *stripeService) ChangePlan(ctx context.Context, orgID uuid.UUID, newPlan }, }, ProrationBehavior: stripe.String(prorationBehavior), + AutomaticTax: &stripe.SubscriptionAutomaticTaxParams{Enabled: stripe.Bool(true)}, } if couponID != nil { - params.Coupon = stripe.String(*couponID) + params.Discounts = []*stripe.SubscriptionDiscountParams{{Coupon: stripe.String(*couponID)}} } updated, stripeErr := subscription.Update(*sub.StripeSubscriptionID, params) @@ -690,19 +724,22 @@ func (s *stripeService) PreviewPlanChange(ctx context.Context, orgID uuid.UUID, itemID := stripeSub.Items.Data[0].ID // Preview the upcoming invoice with the plan change - params := &stripe.InvoiceUpcomingParams{ + params := &stripe.InvoiceCreatePreviewParams{ + AutomaticTax: &stripe.InvoiceCreatePreviewAutomaticTaxParams{Enabled: stripe.Bool(true)}, Customer: stripe.String(sub.StripeCustomerID), Subscription: stripe.String(*sub.StripeSubscriptionID), - SubscriptionItems: []*stripe.SubscriptionItemsParams{ - { - ID: stripe.String(itemID), - Price: stripe.String(*newPlan.StripePriceID), + SubscriptionDetails: &stripe.InvoiceCreatePreviewSubscriptionDetailsParams{ + Items: []*stripe.InvoiceCreatePreviewSubscriptionDetailsItemParams{ + { + ID: stripe.String(itemID), + Price: stripe.String(*newPlan.StripePriceID), + }, }, + ProrationBehavior: stripe.String("create_prorations"), }, - SubscriptionProrationBehavior: stripe.String("create_prorations"), } - preview, stripeErr := invoice.Upcoming(params) + preview, stripeErr := invoice.CreatePreview(params) if stripeErr != nil { return nil, errx.New(errx.Internal, fmt.Sprintf("failed to preview invoice: %v", stripeErr)) } @@ -710,7 +747,7 @@ func (s *stripeService) PreviewPlanChange(ctx context.Context, orgID uuid.UUID, // Calculate proration amount from line items var prorationAmount int64 for _, line := range preview.Lines.Data { - if line.Proration { + if invoiceLineIsProration(line) { prorationAmount += line.Amount } } @@ -726,7 +763,9 @@ func (s *stripeService) PreviewPlanChange(ctx context.Context, orgID uuid.UUID, } func (s *stripeService) VerifyWebhook(payload []byte, signature string) (*stripe.Event, *errx.Error) { - event, err := webhook.ConstructEvent(payload, signature, s.cfg.WebhookSecret) + event, err := webhook.ConstructEventWithOptions(payload, signature, s.cfg.WebhookSecret, webhook.ConstructEventOptions{ + IgnoreAPIVersionMismatch: true, + }) if err != nil { return nil, errx.New(errx.BadRequest, "invalid webhook signature") } @@ -760,6 +799,10 @@ func (s *stripeService) ProcessWebhookEvent(ctx context.Context, event *stripe.E processErr = s.handleInvoicePaid(ctx, event) case "invoice.payment_failed": processErr = s.handleInvoicePaymentFailed(ctx, event) + case "invoice.finalization_failed": + processErr = s.handleInvoiceFinalizationFailed(event) + case "payment_intent.succeeded": + processErr = s.handlePaymentIntentSucceeded(ctx, event) case "charge.refunded": processErr = s.handleChargeRefunded(ctx, event) } @@ -785,6 +828,46 @@ func (s *stripeService) ProcessWebhookEvent(ctx context.Context, event *stripe.E return processErr } +func (s *stripeService) handlePaymentIntentSucceeded(ctx context.Context, event *stripe.Event) *errx.Error { + var pi stripe.PaymentIntent + if err := json.Unmarshal(event.Data.Raw, &pi); err != nil { + return errx.New(errx.Internal, "failed to parse payment intent") + } + return s.fulfillAutoTopUp(ctx, &pi, true) +} + +func (s *stripeService) fulfillAutoTopUp(ctx context.Context, pi *stripe.PaymentIntent, audit bool) *errx.Error { + if pi == nil || pi.Metadata["purpose"] != "credit_auto_topup" { + return nil + } + if pi.Status != stripe.PaymentIntentStatusSucceeded { + return nil + } + if s.credits == nil { + return errx.New(errx.Internal, "credits service is unavailable") + } + + orgID, err := uuid.Parse(pi.Metadata["org_id"]) + if err != nil { + return errx.New(errx.BadRequest, "invalid org_id in auto top-up metadata") + } + creditAmount, err := strconv.Atoi(pi.Metadata["credits"]) + if err != nil || creditAmount <= 0 { + return errx.New(errx.BadRequest, "invalid credits amount in auto top-up metadata") + } + if _, err := s.credits.GrantPurchased(ctx, orgID, creditAmount, "credit_auto_topup", pi.ID); err != nil { + return errx.New(errx.Internal, "failed to grant auto top-up credits") + } + if audit && s.audit != nil { + s.audit.LogAction(ctx, orgID, uuid.Nil, models.AuditActionCreate, models.AuditEntityCreditPurchase, nil, "", "", nil, map[string]string{ + "pack_key": pi.Metadata["pack_key"], + "credits": strconv.Itoa(creditAmount), + "auto": "true", + }) + } + return nil +} + func (s *stripeService) handleCheckoutCompleted(ctx context.Context, event *stripe.Event) *errx.Error { var checkoutSession stripe.CheckoutSession if err := json.Unmarshal(event.Data.Raw, &checkoutSession); err != nil { @@ -1079,11 +1162,25 @@ func (s *stripeService) handleSubscriptionUpdated(ctx context.Context, event *st sub.Status = mapStripeStatus(stripeSub.Status) sub.StripeSubscriptionID = &stripeSub.ID - // Update period - periodStart := time.Unix(stripeSub.CurrentPeriodStart, 0) - periodEnd := time.Unix(stripeSub.CurrentPeriodEnd, 0) - sub.CurrentPeriodStart = &periodStart - sub.CurrentPeriodEnd = &periodEnd + // Billing periods live on subscription items in current Stripe API versions. + if stripeSub.Items != nil && len(stripeSub.Items.Data) > 0 { + periodStart := time.Unix(stripeSub.Items.Data[0].CurrentPeriodStart, 0) + periodEnd := time.Unix(stripeSub.Items.Data[0].CurrentPeriodEnd, 0) + sub.CurrentPeriodStart = &periodStart + sub.CurrentPeriodEnd = &periodEnd + } else { + // Accept the pre-dahlia webhook shape while the endpoint is upgraded. + var legacy struct { + CurrentPeriodStart int64 `json:"current_period_start"` + CurrentPeriodEnd int64 `json:"current_period_end"` + } + if json.Unmarshal(event.Data.Raw, &legacy) == nil && legacy.CurrentPeriodEnd > 0 { + periodStart := time.Unix(legacy.CurrentPeriodStart, 0) + periodEnd := time.Unix(legacy.CurrentPeriodEnd, 0) + sub.CurrentPeriodStart = &periodStart + sub.CurrentPeriodEnd = &periodEnd + } + } sub.CancelAtPeriodEnd = stripeSub.CancelAtPeriodEnd if stripeSub.CanceledAt > 0 { @@ -1227,19 +1324,20 @@ func (s *stripeService) handleInvoicePaid(ctx context.Context, event *stripe.Eve if err := json.Unmarshal(event.Data.Raw, &inv); err != nil { return errx.New(errx.Internal, "failed to parse invoice") } + subscriptionID, subscriptionMetadata, priceID := invoiceReferences(&inv, event.Data.Raw) // 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, err = s.subRepo.GetByStripeSubscriptionID(ctx, inv.Subscription.ID) + if subscriptionID != "" { + sub, err = s.subRepo.GetByStripeSubscriptionID(ctx, subscriptionID) 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 sub == nil && subscriptionMetadata["org_id"] != "" { + orgID, parseErr := uuid.Parse(subscriptionMetadata["org_id"]) if parseErr != nil { return errx.New(errx.BadRequest, "invalid invoice organization") } @@ -1261,8 +1359,8 @@ func (s *stripeService) handleInvoicePaid(ctx context.Context, event *stripe.Eve // Resolve the plan: prefer the invoiced price, fall back to the local // subscription's plan. var plan *models.Plan - if inv.Lines != nil && len(inv.Lines.Data) > 0 && inv.Lines.Data[0].Price != nil { - plan, err = s.planRepo.GetByStripePriceID(ctx, inv.Lines.Data[0].Price.ID) + if priceID != "" { + plan, err = s.planRepo.GetByStripePriceID(ctx, priceID) if err != nil { return errx.New(errx.Internal, "failed to get invoice plan") } @@ -1278,7 +1376,7 @@ func (s *stripeService) handleInvoicePaid(ctx context.Context, event *stripe.Eve // grant on each subscription billing cycle. Only subscription create/cycle // invoices refresh the allowance; plan-change or one-off invoices don't, and // the top-up (purchased) pool is never touched. Idempotent on the event id. - if s.credits != nil && inv.Subscription != nil && + if s.credits != nil && subscriptionID != "" && (inv.BillingReason == stripe.InvoiceBillingReasonSubscriptionCreate || inv.BillingReason == stripe.InvoiceBillingReasonSubscriptionCycle) { if err := s.credits.ResetMonthlyAllowance(ctx, sub.OrganizationID, plan.MonthlyCredits, event.ID); err != nil { @@ -1351,6 +1449,39 @@ func (s *stripeService) handleInvoicePaymentFailed(ctx context.Context, event *s return nil } +func (s *stripeService) handleInvoiceFinalizationFailed(event *stripe.Event) *errx.Error { + if s.opsNotify == nil { + return nil + } + var inv struct { + ID string `json:"id"` + CustomerEmail string `json:"customer_email"` + Customer string `json:"customer"` + Number string `json:"number"` + AutomaticTax struct { + Status string `json:"status"` + DisabledReason string `json:"disabled_reason"` + } `json:"automatic_tax"` + LastFinalizationError struct { + Code string `json:"code"` + Message string `json:"message"` + } `json:"last_finalization_error"` + } + _ = json.Unmarshal(event.Data.Raw, &inv) + s.opsNotify.NotifyOperator( + "subscription.payment_failed", + "Invoice finalization failed", + "Stripe could not finalize an invoice. Check the customer's billing address and tax location before retrying it.", + map[string]string{ + "Customer": firstNonEmpty(inv.CustomerEmail, inv.Customer), + "Invoice": firstNonEmpty(inv.Number, inv.ID), + "Code": inv.LastFinalizationError.Code, + "Reason": firstNonEmpty(inv.LastFinalizationError.Message, inv.AutomaticTax.DisabledReason, inv.AutomaticTax.Status), + }, + ) + return nil +} + // zeroDecimalCurrencies have no minor unit, so their amounts are already whole // units and must not be divided. https://docs.stripe.com/currencies var zeroDecimalCurrencies = map[string]bool{ @@ -1381,6 +1512,64 @@ func firstNonEmpty(vals ...string) string { return "" } +func invoiceLineIsProration(line *stripe.InvoiceLineItem) bool { + if line == nil || line.Parent == nil { + return false + } + if d := line.Parent.InvoiceItemDetails; d != nil { + return d.Proration + } + if d := line.Parent.SubscriptionItemDetails; d != nil { + return d.Proration + } + return false +} + +func invoiceReferences(inv *stripe.Invoice, raw json.RawMessage) (subscriptionID string, metadata map[string]string, priceID string) { + if inv != nil { + if inv.Parent != nil && inv.Parent.SubscriptionDetails != nil { + details := inv.Parent.SubscriptionDetails + metadata = details.Metadata + if details.Subscription != nil { + subscriptionID = details.Subscription.ID + } + } + if inv.Lines != nil && len(inv.Lines.Data) > 0 { + line := inv.Lines.Data[0] + if line != nil && line.Pricing != nil && line.Pricing.PriceDetails != nil && line.Pricing.PriceDetails.Price != nil { + priceID = line.Pricing.PriceDetails.Price.ID + } + } + } + + // Stripe webhook endpoints keep their configured API version until an + // operator upgrades them. Read the legacy fields during that transition. + var legacy struct { + Subscription *stripe.Subscription `json:"subscription"` + SubscriptionDetails *struct { + Metadata map[string]string `json:"metadata"` + } `json:"subscription_details"` + Lines *struct { + Data []*struct { + Price *stripe.Price `json:"price"` + } `json:"data"` + } `json:"lines"` + } + if json.Unmarshal(raw, &legacy) != nil { + return subscriptionID, metadata, priceID + } + if subscriptionID == "" && legacy.Subscription != nil { + subscriptionID = legacy.Subscription.ID + } + if len(metadata) == 0 && legacy.SubscriptionDetails != nil { + metadata = legacy.SubscriptionDetails.Metadata + } + if priceID == "" && legacy.Lines != nil && len(legacy.Lines.Data) > 0 && legacy.Lines.Data[0].Price != nil { + priceID = legacy.Lines.Data[0].Price.ID + } + return subscriptionID, metadata, priceID +} + func mapStripeStatus(status stripe.SubscriptionStatus) models.SubscriptionStatus { switch status { case stripe.SubscriptionStatusTrialing: diff --git a/internal/app/stripe/service_test.go b/internal/app/stripe/service_test.go index 3037206c7..b0c381435 100644 --- a/internal/app/stripe/service_test.go +++ b/internal/app/stripe/service_test.go @@ -9,7 +9,8 @@ import ( "testing" "github.com/google/uuid" - stripeapi "github.com/stripe/stripe-go/v76" + stripeapi "github.com/stripe/stripe-go/v86" + "github.com/warmbly/warmbly/internal/config" "github.com/warmbly/warmbly/internal/errx" "github.com/warmbly/warmbly/internal/models" "github.com/warmbly/warmbly/internal/repository" @@ -58,6 +59,15 @@ func TestSubscriptionCheckoutCustomerParameters(t *testing.T) { if r.Form.Get("mode") != "subscription" || r.Form.Has("customer_creation") || r.Form.Get("customer") != customerID { t.Errorf("invalid checkout parameters: %v", r.Form) } + if r.Form.Get("automatic_tax[enabled]") != "true" || r.Form.Get("billing_address_collection") != "required" || r.Form.Get("tax_id_collection[enabled]") != "true" { + t.Errorf("tax collection is not enabled: %v", r.Form) + } + if customerID == "" && (r.Form.Has("customer_update[address]") || r.Form.Has("customer_update[name]")) { + t.Errorf("new-customer checkout included customer_update: %v", r.Form) + } + if customerID != "" && (r.Form.Get("customer_update[address]") != "auto" || r.Form.Get("customer_update[name]") != "auto") { + t.Errorf("existing-customer checkout does not save business details: %v", r.Form) + } w.Header().Set("Content-Type", "application/json") _, _ = w.Write([]byte(`{"id":"cs_test","url":"https://checkout.stripe.com/test"}`)) })) @@ -74,6 +84,41 @@ func TestSubscriptionCheckoutCustomerParameters(t *testing.T) { } } +func TestCreditCheckoutCollectsTaxAndBusinessDetails(t *testing.T) { + for _, customerID := range []string{"", "cus_existing"} { + t.Run("customer_"+customerID, func(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if err := r.ParseForm(); err != nil { + t.Fatal(err) + } + if r.Form.Get("mode") != "payment" || r.Form.Get("automatic_tax[enabled]") != "true" || r.Form.Get("billing_address_collection") != "required" || r.Form.Get("tax_id_collection[enabled]") != "true" { + t.Errorf("invalid tax-aware credit checkout: %v", r.Form) + } + if customerID == "" && r.Form.Get("customer_creation") != "always" { + t.Errorf("credit checkout will not save the new customer: %v", r.Form) + } + if customerID != "" && (r.Form.Get("customer") != customerID || r.Form.Get("customer_update[address]") != "auto" || r.Form.Get("customer_update[name]") != "auto") { + t.Errorf("credit checkout does not update the existing customer: %v", r.Form) + } + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{"id":"cs_credit","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{ + cfg: &config.StripeConfig{CreditPackPriceIDs: map[string]string{"pack_500": "price_pack"}}, + subRepo: &billingSubRepo{sub: &models.Subscription{StripeCustomerID: customerID}}, + } + if _, xerr := s.CreateCreditCheckoutSession(context.Background(), uuid.New(), uuid.New(), "pack_500", 500, "https://example.com/success", "https://example.com/cancel"); xerr != nil { + t.Fatal(xerr) + } + }) + } +} + func TestPortalRejectsMissingCustomer(t *testing.T) { for _, customerID := range []string{"", " \t"} { url, err := (&stripeService{}).CreatePortalSession(context.Background(), customerID, "https://example.com") @@ -135,6 +180,52 @@ func (r *billingSubRepo) RecordWebhookEvent(context.Context, *models.StripeWebho return nil } +func TestAutoTopUpCalculatesAndAssociatesTax(t *testing.T) { + orgID := uuid.New() + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + switch { + case r.Method == http.MethodGet && r.URL.Path == "/v1/prices/price_pack": + _, _ = w.Write([]byte(`{"id":"price_pack","currency":"usd","unit_amount":1000,"tax_behavior":"exclusive","product":"prod_credits"}`)) + case r.Method == http.MethodGet && r.URL.Path == "/v1/customers/cus_test": + _, _ = w.Write([]byte(`{"id":"cus_test","invoice_settings":{"default_payment_method":"pm_test"}}`)) + case r.Method == http.MethodPost && r.URL.Path == "/v1/tax/calculations": + if err := r.ParseForm(); err != nil { + t.Fatal(err) + } + if r.Form.Get("customer") != "cus_test" || r.Form.Get("line_items[0][amount]") != "1000" || r.Form.Get("line_items[0][product]") != "prod_credits" || r.Form.Get("line_items[0][tax_behavior]") != "exclusive" { + t.Errorf("invalid tax calculation: %v", r.Form) + } + _, _ = w.Write([]byte(`{"id":"taxcalc_test","currency":"usd","amount_total":1200}`)) + case r.Method == http.MethodPost && r.URL.Path == "/v1/payment_intents": + if err := r.ParseForm(); err != nil { + t.Fatal(err) + } + if r.Form.Get("amount") != "1200" || r.Form.Get("hooks[inputs][tax][calculation]") != "taxcalc_test" || r.Form.Get("metadata[tax_calculation_id]") != "taxcalc_test" { + t.Errorf("payment intent is not linked to its tax calculation: %v", r.Form) + } + _, _ = w.Write([]byte(`{"id":"pi_test","status":"succeeded","metadata":{"org_id":"` + orgID.String() + `","purpose":"credit_auto_topup","pack_key":"pack_500","credits":"500","tax_calculation_id":"taxcalc_test"}}`)) + default: + http.NotFound(w, r) + } + })) + 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) }) + + credits := &billingCredits{} + s := &stripeService{ + cfg: &config.StripeConfig{CreditPackPriceIDs: map[string]string{"pack_500": "price_pack"}}, + subRepo: &billingSubRepo{sub: &models.Subscription{StripeCustomerID: "cus_test"}}, + credits: credits, + } + granted, err := s.AutoTopUpCredits(context.Background(), orgID, "pack_500", 500) + if err != nil || !granted || credits.granted != 500 { + t.Fatalf("granted=%v credits=%d err=%v", granted, credits.granted, err) + } +} + func TestInvoiceBeforeCheckoutAndRetryAfterCreditFailure(t *testing.T) { orgID := uuid.New() repo := &billingSubRepo{sub: &models.Subscription{OrganizationID: orgID}} diff --git a/site/src/pages/pricing.astro b/site/src/pages/pricing.astro index b4dca6fd9..785257c6e 100644 --- a/site/src/pages/pricing.astro +++ b/site/src/pages/pricing.astro @@ -252,6 +252,10 @@ const headers = ['Starter', 'Grow', 'Business', 'Enterprise'];

+ Applicable taxes are calculated from your billing address and shown before you pay. Where supported, business buyers can add their legal name and tax ID at checkout. +

+ +

Rather run it yourself? Self-hosting is free, forever. Once Warmbly Cloud launches, link your instance and we run the warmup for your mailboxes in the shared pool, free for up to 10 mailboxes and $15 a month for unlimited. Everything else, sending, inbox, campaigns and data, stays on your server. @@ -424,6 +428,7 @@ const headers = ['Starter', 'Grow', 'Business', 'Enterprise']; ['Can a self-hosted instance use the warmup pool?', 'Yes, once Warmbly Cloud launches. Open Settings, Warmbly Cloud on your instance, approve the code here, and pick the mailboxes to enroll. Warmbly runs their warmup in the shared pool; sending, contacts and inbox stay on your server.'], ['Is there a per-seat charge?', 'No. Add as many teammates as you want. Roles and audit log unlock on Business.'], ['Annual billing?', 'Yes, with 20% off. Toggle is at the top of this page.'], + ['Can I buy as a business?', 'Yes. Add your legal business name and tax ID in Stripe Checkout. They appear on invoices, and Stripe applies reverse charge where the rules allow it. You can update both later from Settings, Billing, Payment.'], ['Is there a free plan?', 'Yes. A hosted workspace starts free with up to 10 mailboxes and warmup, no card and no time limit. Sending, the inbox and CRM unlock when you pick a plan. Self-hosting is free too.'], ['Refund policy?', 'Full refund inside 14 days of a paid purchase if the platform did not work for you.'], ['Startup pricing?', '50% off Grow for one year for pre-seed and seed companies on YC, Antler, EF, Techstars or similar.'],