package server import ( "bytes" "errors" "fmt" "github.com/stripe/stripe-go/v74" portalsession "github.com/stripe/stripe-go/v74/billingportal/session" "github.com/stripe/stripe-go/v74/checkout/session" "github.com/stripe/stripe-go/v74/customer" "github.com/stripe/stripe-go/v74/price" "github.com/stripe/stripe-go/v74/subscription" "github.com/stripe/stripe-go/v74/webhook" "heckel.io/ntfy/v2/log" "heckel.io/ntfy/v2/user" "heckel.io/ntfy/v2/util" "io" "net/http" "net/netip" "time" ) // Payments in ntfy are done via Stripe. // // Pretty much all payments related things are in this file. The following processes // handle payments: // // - Checkout: // Creating a Stripe customer and subscription via the Checkout flow. This flow is only used if the // ntfy user is not already a Stripe customer. This requires redirecting to the Stripe checkout page. // It is implemented in handleAccountBillingSubscriptionCreate and the success callback // handleAccountBillingSubscriptionCreateSuccess. // - Update subscription: // Switching between Stripe subscriptions (upgrade/downgrade) is handled via // handleAccountBillingSubscriptionUpdate. This also handles proration. // - Cancel subscription (at period end): // Users can cancel the Stripe subscription via the web app at the end of the billing period. This // simply updates the subscription and Stripe will cancel it. Users cannot immediately cancel the // subscription. // - Webhooks: // Whenever a subscription changes (updated, deleted), Stripe sends us a request via a webhook. // This is used to keep the local user database fields up to date. Stripe is the source of truth. // What Stripe says is mirrored and not questioned. var ( errNotAPaidTier = errors.New("tier does not have billing price identifier") errMultipleBillingSubscriptions = errors.New("cannot have multiple billing subscriptions") errNoBillingSubscription = errors.New("user does not have an active billing subscription") ) var ( retryUserDelays = []time.Duration{3 * time.Second, 5 * time.Second, 7 * time.Second} ) // handleBillingTiersGet returns all available paid tiers, and the free tier. This is to populate the upgrade dialog // in the UI. Note that this endpoint does NOT have a user context (no u!). func (s *Server) handleBillingTiersGet(w http.ResponseWriter, _ *http.Request, _ *visitor) error { tiers, err := s.userManager.Tiers() if err != nil { return err } freeTier := configBasedVisitorLimits(s.config) response := []*apiAccountBillingTier{ { // This is a bit of a hack: This is the "Free" tier. It has no tier code, name or price. Limits: &apiAccountLimits{ Basis: string(visitorLimitBasisIP), Messages: freeTier.MessageLimit, MessagesExpiryDuration: int64(freeTier.MessageExpiryDuration.Seconds()), Emails: freeTier.EmailLimit, Calls: freeTier.CallLimit, Reservations: freeTier.ReservationsLimit, AttachmentTotalSize: freeTier.AttachmentTotalSizeLimit, AttachmentFileSize: freeTier.AttachmentFileSizeLimit, AttachmentExpiryDuration: int64(freeTier.AttachmentExpiryDuration.Seconds()), }, }, } prices, err := s.priceCache.Value() if err != nil { return err } for _, tier := range tiers { priceMonth, priceYear := prices[tier.StripeMonthlyPriceID], prices[tier.StripeYearlyPriceID] if priceMonth == 0 || priceYear == 0 { // Only allow tiers that have both prices! continue } response = append(response, &apiAccountBillingTier{ Code: tier.Code, Name: tier.Name, Prices: &apiAccountBillingPrices{ Month: priceMonth, Year: priceYear, }, Limits: &apiAccountLimits{ Basis: string(visitorLimitBasisTier), Messages: tier.MessageLimit, MessagesExpiryDuration: int64(tier.MessageExpiryDuration.Seconds()), Emails: tier.EmailLimit, Calls: tier.CallLimit, Reservations: tier.ReservationLimit, AttachmentTotalSize: tier.AttachmentTotalSizeLimit, AttachmentFileSize: tier.AttachmentFileSizeLimit, AttachmentExpiryDuration: int64(tier.AttachmentExpiryDuration.Seconds()), }, }) } return s.writeJSON(w, response) } // handleAccountBillingSubscriptionCreate creates a Stripe checkout flow to create a user subscription. The tier // will be updated by a subsequent webhook from Stripe, once the subscription becomes active. func (s *Server) handleAccountBillingSubscriptionCreate(w http.ResponseWriter, r *http.Request, v *visitor) error { u := v.User() if u.Billing.StripeSubscriptionID != "" { return errHTTPBadRequestBillingSubscriptionExists } req, err := readJSONWithLimit[apiAccountBillingSubscriptionChangeRequest](r.Body, jsonBodyBytesLimit, false) if err != nil { return err } tier, err := s.userManager.Tier(req.Tier) if err != nil { return err } var priceID string if req.Interval == string(stripe.PriceRecurringIntervalMonth) && tier.StripeMonthlyPriceID != "" { priceID = tier.StripeMonthlyPriceID } else if req.Interval == string(stripe.PriceRecurringIntervalYear) && tier.StripeYearlyPriceID != "" { priceID = tier.StripeYearlyPriceID } else { return errNotAPaidTier } logvr(v, r). With(tier). Fields(log.Context{ "stripe_price_id": priceID, "stripe_subscription_interval": req.Interval, }). Tag(tagStripe). Info("Creating Stripe checkout flow") var stripeCustomerID *string if u.Billing.StripeCustomerID != "" { stripeCustomerID = &u.Billing.StripeCustomerID stripeCustomer, err := s.stripe.GetCustomer(u.Billing.StripeCustomerID) if err != nil { return err } else if stripeCustomer.Subscriptions != nil && len(stripeCustomer.Subscriptions.Data) > 0 { return errMultipleBillingSubscriptions } } successURL := s.config.BaseURL + apiAccountBillingSubscriptionCheckoutSuccessTemplate params := &stripe.CheckoutSessionParams{ Customer: stripeCustomerID, // A user may have previously deleted their subscription ClientReferenceID: &u.ID, SuccessURL: &successURL, Mode: stripe.String(string(stripe.CheckoutSessionModeSubscription)), AllowPromotionCodes: stripe.Bool(true), LineItems: []*stripe.CheckoutSessionLineItemParams{ { Price: stripe.String(priceID), Quantity: stripe.Int64(1), }, }, AutomaticTax: &stripe.CheckoutSessionAutomaticTaxParams{ Enabled: stripe.Bool(true), }, } sess, err := s.stripe.NewCheckoutSession(params) if err != nil { return err } response := &apiAccountBillingSubscriptionCreateResponse{ RedirectURL: sess.URL, } return s.writeJSON(w, response) } // handleAccountBillingSubscriptionCreateSuccess is called after the Stripe checkout session has succeeded. We use // the session ID in the URL to retrieve the Stripe subscription and update the local database. This is the first // and only time we can map the local username with the Stripe customer ID. func (s *Server) handleAccountBillingSubscriptionCreateSuccess(w http.ResponseWriter, r *http.Request, v *visitor) error { // We don't have v.User() in this endpoint, only a userManager! matches := apiAccountBillingSubscriptionCheckoutSuccessRegex.FindStringSubmatch(r.URL.Path) if len(matches) != 2 { return errHTTPInternalErrorInvalidPath } sessionID := matches[1] sess, err := s.stripe.GetSession(sessionID) // FIXME How do we rate limit this? if err != nil { return err } else if sess.Customer == nil || sess.Subscription == nil || sess.ClientReferenceID == "" { return errHTTPBadRequestBillingRequestInvalid.Wrap("customer or subscription not found") } sub, err := s.stripe.GetSubscription(sess.Subscription.ID) if err != nil { return err } else if sub.Items == nil || len(sub.Items.Data) != 1 || sub.Items.Data[0].Price == nil || sub.Items.Data[0].Price.Recurring == nil { return errHTTPBadRequestBillingRequestInvalid.Wrap("more than one line item in existing subscription") } priceID, interval := sub.Items.Data[0].Price.ID, sub.Items.Data[0].Price.Recurring.Interval tier, err := s.userManager.TierByStripePrice(priceID) if err != nil { return err } u, err := s.userManager.UserByID(sess.ClientReferenceID) if err != nil { return err } v.SetUser(u) logvr(v, r). With(tier). Tag(tagStripe). Fields(log.Context{ "stripe_customer_id": sess.Customer.ID, "stripe_price_id": priceID, "stripe_subscription_id": sub.ID, "stripe_subscription_status": string(sub.Status), "stripe_subscription_interval": string(interval), "stripe_subscription_paid_until": sub.CurrentPeriodEnd, }). Info("Stripe checkout flow succeeded, updating user tier and subscription") customerParams := &stripe.CustomerParams{ Params: stripe.Params{ Metadata: map[string]string{ "user_id": u.ID, "user_name": u.Name, }, }, } if _, err := s.stripe.UpdateCustomer(sess.Customer.ID, customerParams); err != nil { return err } if err := s.updateSubscriptionAndTier(r, v, u, tier, sess.Customer.ID, sub.ID, string(sub.Status), string(interval), sub.CurrentPeriodEnd, sub.CancelAt); err != nil { return err } http.Redirect(w, r, s.config.BaseURL+accountPath, http.StatusSeeOther) return nil } // handleAccountBillingSubscriptionUpdate updates an existing Stripe subscription to a new price, and updates // a user's tier accordingly. This endpoint only works if there is an existing subscription. func (s *Server) handleAccountBillingSubscriptionUpdate(w http.ResponseWriter, r *http.Request, v *visitor) error { u := v.User() if u.Billing.StripeSubscriptionID == "" { return errNoBillingSubscription } req, err := readJSONWithLimit[apiAccountBillingSubscriptionChangeRequest](r.Body, jsonBodyBytesLimit, false) if err != nil { return err } tier, err := s.userManager.Tier(req.Tier) if err != nil { return err } var priceID string if req.Interval == string(stripe.PriceRecurringIntervalMonth) && tier.StripeMonthlyPriceID != "" { priceID = tier.StripeMonthlyPriceID } else if req.Interval == string(stripe.PriceRecurringIntervalYear) && tier.StripeYearlyPriceID != "" { priceID = tier.StripeYearlyPriceID } else { return errNotAPaidTier } logvr(v, r). Tag(tagStripe). Fields(log.Context{ "new_tier_id": tier.ID, "new_tier_code": tier.Code, "new_tier_stripe_price_id": priceID, "new_tier_stripe_subscription_interval": req.Interval, // Other stripe_* fields filled by visitor context }). Info("Changing Stripe subscription and billing tier to %s/%s (price %s, %s)", tier.ID, tier.Name, priceID, req.Interval) sub, err := s.stripe.GetSubscription(u.Billing.StripeSubscriptionID) if err != nil { return err } else if sub.Items == nil || len(sub.Items.Data) != 1 { return errHTTPBadRequestBillingRequestInvalid.Wrap("no items, or more than one item") } params := &stripe.SubscriptionParams{ CancelAtPeriodEnd: stripe.Bool(false), ProrationBehavior: stripe.String(string(stripe.SubscriptionSchedulePhaseProrationBehaviorAlwaysInvoice)), Items: []*stripe.SubscriptionItemsParams{ { ID: stripe.String(sub.Items.Data[0].ID), Price: stripe.String(priceID), }, }, } _, err = s.stripe.UpdateSubscription(sub.ID, params) if err != nil { return err } return s.writeJSON(w, newSuccessResponse()) } // handleAccountBillingSubscriptionDelete facilitates downgrading a paid user to a tier-less user, // and cancelling the Stripe subscription entirely. Note that this does not actually change the tier. // That is done by a webhook at the period end (in X days). func (s *Server) handleAccountBillingSubscriptionDelete(w http.ResponseWriter, r *http.Request, v *visitor) error { logvr(v, r).Tag(tagStripe).Info("Deleting Stripe subscription") u := v.User() if u.Billing.StripeSubscriptionID != "" { params := &stripe.SubscriptionParams{ CancelAtPeriodEnd: stripe.Bool(true), } _, err := s.stripe.UpdateSubscription(u.Billing.StripeSubscriptionID, params) if err != nil { return err } } return s.writeJSON(w, newSuccessResponse()) } // handleAccountBillingPortalSessionCreate creates a session to the customer billing portal, and returns the // redirect URL. The billing portal allows customers to change their payment methods, and cancel the subscription. func (s *Server) handleAccountBillingPortalSessionCreate(w http.ResponseWriter, r *http.Request, v *visitor) error { logvr(v, r).Tag(tagStripe).Info("Creating Stripe billing portal session") u := v.User() if u.Billing.StripeCustomerID == "" { return errHTTPBadRequestNotAPaidUser } params := &stripe.BillingPortalSessionParams{ Customer: stripe.String(u.Billing.StripeCustomerID), ReturnURL: stripe.String(s.config.BaseURL), } ps, err := s.stripe.NewPortalSession(params) if err != nil { return err } response := &apiAccountBillingPortalRedirectResponse{ RedirectURL: ps.URL, } return s.writeJSON(w, response) } // handleAccountBillingWebhook handles incoming Stripe webhooks. It mainly keeps the local user database in sync // with the Stripe view of the world. This endpoint is authorized via the Stripe webhook secret. Note that the // visitor (v) in this endpoint is the Stripe API, so we don't have u available. func (s *Server) handleAccountBillingWebhook(_ http.ResponseWriter, r *http.Request, v *visitor) error { stripeSignature := r.Header.Get("Stripe-Signature") if stripeSignature == "" { return errHTTPBadRequestBillingRequestInvalid } body, err := util.Peek(r.Body, jsonBodyBytesLimit) if err != nil { return err } else if body.LimitReached { return errHTTPEntityTooLargeJSONBody } event, err := s.stripe.ConstructWebhookEvent(body.PeekedBytes, stripeSignature, s.config.StripeWebhookKey) if err != nil { return err } else if event.Data == nil || event.Data.Raw == nil { return errHTTPBadRequestBillingRequestInvalid } switch event.Type { case "customer.subscription.updated": return s.handleAccountBillingWebhookSubscriptionUpdated(r, v, event) case "customer.subscription.deleted": return s.handleAccountBillingWebhookSubscriptionDeleted(r, v, event) default: logvr(v, r). Tag(tagStripe). Field("stripe_webhook_type", event.Type). Warn("Unhandled Stripe webhook event %s received", event.Type) return nil } } func (s *Server) handleAccountBillingWebhookSubscriptionUpdated(r *http.Request, v *visitor, event stripe.Event) error { ev, err := util.UnmarshalJSON[apiStripeSubscriptionUpdatedEvent](io.NopCloser(bytes.NewReader(event.Data.Raw))) if err != nil { return err } else if ev.ID == "" || ev.Customer == "" || ev.Status == "" || ev.CurrentPeriodEnd == 0 || ev.Items == nil || len(ev.Items.Data) != 1 || ev.Items.Data[0].Price == nil || ev.Items.Data[0].Price.ID == "" || ev.Items.Data[0].Price.Recurring == nil { logvr(v, r).Tag(tagStripe).Field("stripe_request", fmt.Sprintf("%#v", ev)).Warn("Unexpected request from Stripe") return errHTTPBadRequestBillingRequestInvalid } subscriptionID, priceID, interval := ev.ID, ev.Items.Data[0].Price.ID, ev.Items.Data[0].Price.Recurring.Interval logvr(v, r). Tag(tagStripe). Fields(log.Context{ "stripe_webhook_type": event.Type, "stripe_customer_id": ev.Customer, "stripe_price_id": priceID, "stripe_subscription_id": ev.ID, "stripe_subscription_status": ev.Status, "stripe_subscription_interval": interval, "stripe_subscription_paid_until": ev.CurrentPeriodEnd, "stripe_subscription_cancel_at": ev.CancelAt, }). Info("Updating subscription to status %s, with price %s", ev.Status, priceID) userFn := func() (*user.User, error) { return s.userManager.UserByStripeCustomer(ev.Customer) } // We retry the user retrieval function, because during the Stripe checkout, there a race between the browser // checkout success redirect (see handleAccountBillingSubscriptionCreateSuccess), and this webhook. The checkout // success call is the one that updates the user with the Stripe customer ID. u, err := util.Retry[user.User](userFn, retryUserDelays...) if err != nil { return err } v.SetUser(u) tier, err := s.userManager.TierByStripePrice(priceID) if err != nil { return err } if err := s.updateSubscriptionAndTier(r, v, u, tier, ev.Customer, subscriptionID, ev.Status, string(interval), ev.CurrentPeriodEnd, ev.CancelAt); err != nil { return err } s.publishSyncEventAsync(s.visitor(netip.IPv4Unspecified(), u)) return nil } func (s *Server) handleAccountBillingWebhookSubscriptionDeleted(r *http.Request, v *visitor, event stripe.Event) error { ev, err := util.UnmarshalJSON[apiStripeSubscriptionDeletedEvent](io.NopCloser(bytes.NewReader(event.Data.Raw))) if err != nil { return err } else if ev.Customer == "" { return errHTTPBadRequestBillingRequestInvalid } u, err := s.userManager.UserByStripeCustomer(ev.Customer) if err != nil { return err } v.SetUser(u) logvr(v, r). Tag(tagStripe). Field("stripe_webhook_type", event.Type). Info("Subscription deleted, downgrading to unpaid tier") if err := s.updateSubscriptionAndTier(r, v, u, nil, ev.Customer, "", "", "", 0, 0); err != nil { return err } s.publishSyncEventAsync(s.visitor(netip.IPv4Unspecified(), u)) return nil } func (s *Server) updateSubscriptionAndTier(r *http.Request, v *visitor, u *user.User, tier *user.Tier, customerID, subscriptionID, status, interval string, paidUntil, cancelAt int64) error { reservationsLimit := visitorDefaultReservationsLimit if tier != nil { reservationsLimit = tier.ReservationLimit } if err := s.maybeRemoveMessagesAndExcessReservations(r, v, u, reservationsLimit); err != nil { return err } if tier == nil && u.Tier != nil { logvr(v, r).Tag(tagStripe).Info("Resetting tier for user %s", u.Name) if err := s.userManager.ResetTier(u.Name); err != nil { return err } } else if tier != nil && u.TierID() != tier.ID { logvr(v, r). Tag(tagStripe). Fields(log.Context{ "new_tier_id": tier.ID, "new_tier_code": tier.Code, }). Info("Changing tier to tier %s (%s) for user %s", tier.ID, tier.Name, u.Name) if err := s.userManager.ChangeTier(u.Name, tier.Code); err != nil { return err } } // Update billing fields billing := &user.Billing{ StripeCustomerID: customerID, StripeSubscriptionID: subscriptionID, StripeSubscriptionStatus: stripe.SubscriptionStatus(status), StripeSubscriptionInterval: stripe.PriceRecurringInterval(interval), StripeSubscriptionPaidUntil: time.Unix(paidUntil, 0), StripeSubscriptionCancelAt: time.Unix(cancelAt, 0), } if err := s.userManager.ChangeBilling(u.Name, billing); err != nil { return err } return nil } // fetchStripePrices contacts the Stripe API to retrieve all prices. This is used by the server to cache the prices // in memory, and ultimately for the web app to display the price table. func (s *Server) fetchStripePrices() (map[string]int64, error) { log.Debug("Caching prices from Stripe API") priceMap := make(map[string]int64) prices, err := s.stripe.ListPrices(&stripe.PriceListParams{Active: stripe.Bool(true)}) if err != nil { log.Warn("Fetching Stripe prices failed: %s", err.Error()) return nil, err } for _, p := range prices { priceMap[p.ID] = p.UnitAmount log.Trace("- Caching price %s = %v", p.ID, priceMap[p.ID]) } return priceMap, nil } // stripeAPI is a small interface to facilitate mocking of the Stripe API type stripeAPI interface { NewCheckoutSession(params *stripe.CheckoutSessionParams) (*stripe.CheckoutSession, error) NewPortalSession(params *stripe.BillingPortalSessionParams) (*stripe.BillingPortalSession, error) ListPrices(params *stripe.PriceListParams) ([]*stripe.Price, error) GetCustomer(id string) (*stripe.Customer, error) GetSession(id string) (*stripe.CheckoutSession, error) GetSubscription(id string) (*stripe.Subscription, error) UpdateCustomer(id string, params *stripe.CustomerParams) (*stripe.Customer, error) UpdateSubscription(id string, params *stripe.SubscriptionParams) (*stripe.Subscription, error) CancelSubscription(id string) (*stripe.Subscription, error) ConstructWebhookEvent(payload []byte, header string, secret string) (stripe.Event, error) } // realStripeAPI is a thin shim around the Stripe functions to facilitate mocking type realStripeAPI struct{} var _ stripeAPI = (*realStripeAPI)(nil) func newStripeAPI() stripeAPI { return &realStripeAPI{} } func (s *realStripeAPI) NewCheckoutSession(params *stripe.CheckoutSessionParams) (*stripe.CheckoutSession, error) { return session.New(params) } func (s *realStripeAPI) NewPortalSession(params *stripe.BillingPortalSessionParams) (*stripe.BillingPortalSession, error) { return portalsession.New(params) } func (s *realStripeAPI) ListPrices(params *stripe.PriceListParams) ([]*stripe.Price, error) { prices := make([]*stripe.Price, 0) iter := price.List(params) for iter.Next() { prices = append(prices, iter.Price()) } if iter.Err() != nil { return nil, iter.Err() } return prices, nil } func (s *realStripeAPI) GetCustomer(id string) (*stripe.Customer, error) { return customer.Get(id, nil) } func (s *realStripeAPI) GetSession(id string) (*stripe.CheckoutSession, error) { return session.Get(id, nil) } func (s *realStripeAPI) GetSubscription(id string) (*stripe.Subscription, error) { return subscription.Get(id, nil) } func (s *realStripeAPI) UpdateCustomer(id string, params *stripe.CustomerParams) (*stripe.Customer, error) { return customer.Update(id, params) } func (s *realStripeAPI) UpdateSubscription(id string, params *stripe.SubscriptionParams) (*stripe.Subscription, error) { return subscription.Update(id, params) } func (s *realStripeAPI) CancelSubscription(id string) (*stripe.Subscription, error) { return subscription.Cancel(id, nil) } func (s *realStripeAPI) ConstructWebhookEvent(payload []byte, header string, secret string) (stripe.Event, error) { return webhook.ConstructEvent(payload, header, secret) }