package abono import ( "context" "errors" "fmt" "net/url" "slices" "strings" "gorm.io/gorm" ) var ( // ErrUnknownCode is a promotion code Stripe has no active promotion for. ErrUnknownCode = errors.New("abono: no such promotion code") // ErrUnknownPlan is a plan not for sale, or not at the interval asked // for, or sold at several intervals when none was asked for. ErrUnknownPlan = errors.New("abono: no such plan for sale") // ErrNoCustomer is a subject with nothing in Stripe to manage, or a // Stripe customer that is not the subject's. ErrNoCustomer = errors.New("abono: no Stripe customer") errOff = errors.New("abono: Stripe is not configured") ) // CheckoutRequest is what a reader is buying, and who they are if signed // in. type CheckoutRequest struct { // Plan is the plan to sell, and Interval which of its sold prices // ("month", "year"). With no Interval the plan must have exactly one // sold price. Plan, Interval string // Subject is the signed-in reader, "" when signed out; Email is their // address, given to Checkout when Stripe has no customer for them yet. Subject, Email string // PromotionCode is a code the reader typed. Without one, Checkout shows // its own field for it. PromotionCode string // SuccessPath and CancelPath are where Stripe sends the reader back, // relative to Config.Origin ("/" when empty). SuccessPath gets // session_id={CHECKOUT_SESSION_ID} added, for Finish. SuccessPath, CancelPath string } // Checkout starts a hosted Checkout for a subscription to req.Plan and // returns its address. It takes cards only (ruling R33): a card settles // while Checkout waits, so a subscription Stripe calls active has been // paid for. Bank debits (ACH, SEPA) can leave a subscription active while // the money is on its way, or after it failed, and v0.1 does not handle // that. func (s *Service) Checkout(ctx context.Context, req CheckoutRequest) (string, error) { if !s.On() { return "", errOff } g := s.cfg.DB.WithContext(ctx) if err := s.learnIntervals(ctx, req.Plan); err != nil { return "", err } price, err := soldPrice(g, req.Plan, req.Interval) if err != nil { return "", err } success, err := s.successURL(req.SuccessPath) if err != nil { return "", err } cancel, err := s.back(req.CancelPath) if err != nil { return "", err } f := url.Values{ "mode": {"subscription"}, "line_items[0][price]": {price}, "line_items[0][quantity]": {"1"}, "success_url": {success}, "cancel_url": {cancel}, "subscription_data[metadata][abono_plan]": {req.Plan}, "payment_method_types[0]": {"card"}, } customer := "" if subject := strings.TrimSpace(req.Subject); subject != "" { f.Set("client_reference_id", subject) f.Set("subscription_data[metadata][abono_subject]", subject) if customer, err = customerOf(g, subject); err != nil { return "", err } } if customer != "" { f.Set("customer", customer) } else if email := strings.TrimSpace(req.Email); email != "" { f.Set("customer_email", email) } if code := strings.TrimSpace(req.PromotionCode); code != "" { promo, err := s.stripe.PromotionCode(ctx, code) if err != nil { return "", err } if promo == "" { return "", fmt.Errorf("%w: %q", ErrUnknownCode, code) } f.Set("discounts[0][promotion_code]", promo) } else { f.Set("allow_promotion_codes", "true") } cs, err := s.stripe.CreateCheckout(ctx, f) if err != nil { return "", err } return cs.URL, nil } // learnIntervals reads from Stripe every sold price of plan never read // before (refreshed_at NULL: a price seeded from the environment, or added // by id), and records its interval, amount and currency, so the price can // be chosen by interval (rulings R17 and R19). A price read once is known, // even with no interval (a one-off). A price Stripe cannot read stops the // sale. func (s *Service) learnIntervals(ctx context.Context, plan string) error { var ids []string if err := s.cfg.DB.WithContext(ctx).Raw(`SELECT stripe_price_id FROM abono_prices WHERE plan = ? AND sold = 1 AND refreshed_at IS NULL ORDER BY stripe_price_id`, plan).Scan(&ids).Error; err != nil { return err } for _, id := range ids { if err := s.refreshPrice(ctx, id); err != nil { return err } } return nil } // refreshPrice records what Stripe says of price id: its interval // ("" for a one-off price), amount and currency. func (s *Service) refreshPrice(ctx context.Context, id string) error { p, err := s.stripe.GetPrice(ctx, id) if err != nil { return fmt.Errorf("abono: reading price %q: %w", id, err) } return s.cfg.DB.WithContext(ctx).Exec(`UPDATE abono_prices SET interval = ?, interval_count = ?, amount = ?, currency = ?, refreshed_at = ? WHERE stripe_price_id = ?`, p.Recurring.Interval, p.Recurring.IntervalCount, p.UnitAmount, p.Currency, s.now(), id).Error } // soldPrice is the price to sell for plan at interval: the sold price with // that interval, or with no interval the plan's only sold price. A price // v0.1 cannot sell (Price.Unsupported) is never one of them. func soldPrice(g *gorm.DB, plan, interval string) (string, error) { var prices []priceRow if err := g.Raw(`SELECT p.stripe_price_id, p.interval, p.interval_count FROM abono_prices p JOIN abono_offers o ON o.plan = p.plan WHERE p.plan = ? AND p.sold = 1 AND o.archived_at IS NULL ORDER BY p.stripe_price_id`, plan).Scan(&prices).Error; err != nil { return "", err } var match []string for _, p := range prices { if p.price().Unsupported() { continue } if interval == "" || p.Interval == interval { match = append(match, p.StripePriceID) } } if len(match) != 1 { if interval == "" { return "", fmt.Errorf("%w: %q", ErrUnknownPlan, plan) } return "", fmt.Errorf("%w: %q by the %s", ErrUnknownPlan, plan, interval) } return match[0], nil } // back is Origin plus path, a path on this site ("/" when empty). func (s *Service) back(path string) (string, error) { if path == "" { path = "/" } if !strings.HasPrefix(path, "/") || strings.HasPrefix(path, "//") { return "", fmt.Errorf("abono: %q is not a path on this site", path) } return strings.TrimRight(s.cfg.Origin, "/") + path, nil } // successURL is Origin plus path with session_id={CHECKOUT_SESSION_ID} in // its query, for Finish: any session_id already there is replaced, the rest // of the query is kept as written, and a fragment stays after the query, so // the parameter reaches the server. The braces are left as they are: // Stripe looks for the placeholder literally. func (s *Service) successURL(path string) (string, error) { base, err := s.back(path) if err != nil { return "", err } u, err := url.Parse(base) if err != nil { return "", fmt.Errorf("abono: success path %q: %w", path, err) } var keep []string for _, kv := range strings.Split(u.RawQuery, "&") { k, _, _ := strings.Cut(kv, "=") if key, err := url.QueryUnescape(k); kv == "" || (err == nil && key == "session_id") { continue } keep = append(keep, kv) } u.RawQuery = strings.Join(append(keep, "session_id={CHECKOUT_SESSION_ID}"), "&") return u.String(), nil } // customerOf is the Stripe customer subject pays as: their newest // subscription's, else one mapped to them, else "". func customerOf(g *gorm.DB, subject string) (string, error) { if subject == "" { return "", nil } var ids []string if err := g.Raw(`SELECT stripe_customer_id FROM abono_subscriptions WHERE subject = ? AND stripe_customer_id <> '' ORDER BY created_at DESC LIMIT 1`, subject).Scan(&ids).Error; err != nil { return "", err } if len(ids) == 0 { if err := g.Raw(`SELECT stripe_customer_id FROM abono_customers WHERE subject = ? ORDER BY rowid DESC LIMIT 1`, subject).Scan(&ids).Error; err != nil { return "", err } } if len(ids) == 0 { return "", nil } return ids[0], nil } // Finished is what Checkout's return found: the address paid with, the // subject the subscription is now tied to ("" when unclaimed), and whether // it was paid. Processing is a Checkout that completed while its payment // is still on its way: nothing is recorded yet, the webhook does that when // it lands, and the reader must not be asked to buy again. type Finished struct { Email, Subject string Paid bool Processing bool } // Finish is Checkout's return. It syncs the session's subscription at once // rather than waiting for the webhook, so the next page already reflects // the payment. It signs nobody in. Paid is true only for a complete session // whose payment_status is paid or no_payment_required; a complete session // still unpaid is Processing; any other session records nothing. func (s *Service) Finish(ctx context.Context, sessionID string) (Finished, error) { if !s.On() { return Finished{}, errOff } cs, err := s.stripe.GetCheckout(ctx, sessionID) if err != nil { return Finished{}, err } f := Finished{Email: normalEmail(cs.CustomerDetails.Email)} // Complete is not paid: a bank debit completes the session while the // money is still on its way. Then nothing is recorded here, and the // webhook syncs when the payment lands. A trial or a 100% code has // nothing to pay, and is paid. paid := cs.PaymentStatus == "paid" || cs.PaymentStatus == "no_payment_required" if cs.Status == "complete" && !paid { f.Processing = true return f, nil } if cs.Status != "complete" || cs.Subscription == "" { return f, nil } // The subscription's lock from the fetch to the commit (ruling R35): // a webhook for it waits, so whichever read Stripe later writes later. unlock, err := s.subs.lock(ctx, cs.Subscription) if err != nil { return Finished{}, err } defer unlock() sub, err := s.stripe.GetSubscription(ctx, cs.Subscription) if err != nil { return Finished{}, err } hint := Hint{Subject: cs.ClientReferenceID, Email: cs.CustomerDetails.Email} if hint.Subject == "" { hint.Subject = sub.Metadata["abono_subject"] } var subject string if err := s.tx(ctx, func(tx *gorm.DB) error { var err error subject, err = s.syncTx(ctx, tx, sub, hint, false) return err }); err != nil { return Finished{}, err } if e := normalEmail(sub.Customer.Email); e != "" { f.Email = e } f.Subject, f.Paid = subject, true return f, nil } // Customers is every Stripe customer subject pays through, newest // subscription first: the customer of each of the subject's subscription // rows, whether or not it grants and whether or not an offer recognises // its price (ruling R37). A reader with two guest purchases, or one on a // price no plan has, is still charged by each, so a page offers billing // for every one of them, not for what Holdings shows. func (s *Service) Customers(ctx context.Context, subject string) ([]string, error) { subject = strings.TrimSpace(subject) if subject == "" { return nil, nil } var ids []string err := s.cfg.DB.WithContext(ctx).Raw(`SELECT stripe_customer_id FROM abono_subscriptions WHERE subject = ? AND stripe_customer_id <> '' GROUP BY stripe_customer_id ORDER BY MAX(created_at) DESC, stripe_customer_id DESC`, subject).Scan(&ids).Error return ids, err } // Portal is a Customer Portal session for customer, one of subject's // Customers, where they change their card or plan or cancel, coming back // to returnPath on this site. A customer that is not one of subject's is // ErrNoCustomer, so a posted id cannot open someone else's billing. func (s *Service) Portal(ctx context.Context, subject, customer, returnPath string) (string, error) { if !s.On() { return "", errOff } back, err := s.back(returnPath) if err != nil { return "", err } customers, err := s.Customers(ctx, subject) if err != nil { return "", err } customer = strings.TrimSpace(customer) if customer == "" || !slices.Contains(customers, customer) { return "", ErrNoCustomer } return s.stripe.PortalURL(ctx, customer, back) }