rastrillo / abono Public

Clone
git clone https://amadan.net/rastrillo/abono

Plain git — no account needed to clone.

Download

Download this file

1package abono
2
3import (
4 "context"
5 "errors"
6 "fmt"
7 "net/url"
8 "slices"
9 "strings"
10
11 "gorm.io/gorm"
12)
13
14var (
15 // ErrUnknownCode is a promotion code Stripe has no active promotion for.
16 ErrUnknownCode = errors.New("abono: no such promotion code")
17 // ErrUnknownPlan is a plan not for sale, or not at the interval asked
18 // for, or sold at several intervals when none was asked for.
19 ErrUnknownPlan = errors.New("abono: no such plan for sale")
20 // ErrNoCustomer is a subject with nothing in Stripe to manage, or a
21 // Stripe customer that is not the subject's.
22 ErrNoCustomer = errors.New("abono: no Stripe customer")
23
24 errOff = errors.New("abono: Stripe is not configured")
25)
26
27// CheckoutRequest is what a reader is buying, and who they are if signed
28// in.
29type CheckoutRequest struct {
30 // Plan is the plan to sell, and Interval which of its sold prices
31 // ("month", "year"). With no Interval the plan must have exactly one
32 // sold price.
33 Plan, Interval string
34 // Subject is the signed-in reader, "" when signed out; Email is their
35 // address, given to Checkout when Stripe has no customer for them yet.
36 Subject, Email string
37 // PromotionCode is a code the reader typed. Without one, Checkout shows
38 // its own field for it.
39 PromotionCode string
40 // SuccessPath and CancelPath are where Stripe sends the reader back,
41 // relative to Config.Origin ("/" when empty). SuccessPath gets
42 // session_id={CHECKOUT_SESSION_ID} added, for Finish.
43 SuccessPath, CancelPath string
44}
45
46// Checkout starts a hosted Checkout for a subscription to req.Plan and
47// returns its address. It takes cards only (ruling R33): a card settles
48// while Checkout waits, so a subscription Stripe calls active has been
49// paid for. Bank debits (ACH, SEPA) can leave a subscription active while
50// the money is on its way, or after it failed, and v0.1 does not handle
51// that.
52func (s *Service) Checkout(ctx context.Context, req CheckoutRequest) (string, error) {
53 if !s.On() {
54 return "", errOff
55 }
56 g := s.cfg.DB.WithContext(ctx)
57 if err := s.learnIntervals(ctx, req.Plan); err != nil {
58 return "", err
59 }
60 price, err := soldPrice(g, req.Plan, req.Interval)
61 if err != nil {
62 return "", err
63 }
64 success, err := s.successURL(req.SuccessPath)
65 if err != nil {
66 return "", err
67 }
68 cancel, err := s.back(req.CancelPath)
69 if err != nil {
70 return "", err
71 }
72 f := url.Values{
73 "mode": {"subscription"},
74 "line_items[0][price]": {price},
75 "line_items[0][quantity]": {"1"},
76 "success_url": {success},
77 "cancel_url": {cancel},
78 "subscription_data[metadata][abono_plan]": {req.Plan},
79 "payment_method_types[0]": {"card"},
80 }
81 customer := ""
82 if subject := strings.TrimSpace(req.Subject); subject != "" {
83 f.Set("client_reference_id", subject)
84 f.Set("subscription_data[metadata][abono_subject]", subject)
85 if customer, err = customerOf(g, subject); err != nil {
86 return "", err
87 }
88 }
89 if customer != "" {
90 f.Set("customer", customer)
91 } else if email := strings.TrimSpace(req.Email); email != "" {
92 f.Set("customer_email", email)
93 }
94 if code := strings.TrimSpace(req.PromotionCode); code != "" {
95 promo, err := s.stripe.PromotionCode(ctx, code)
96 if err != nil {
97 return "", err
98 }
99 if promo == "" {
100 return "", fmt.Errorf("%w: %q", ErrUnknownCode, code)
101 }
102 f.Set("discounts[0][promotion_code]", promo)
103 } else {
104 f.Set("allow_promotion_codes", "true")
105 }
106 cs, err := s.stripe.CreateCheckout(ctx, f)
107 if err != nil {
108 return "", err
109 }
110 return cs.URL, nil
111}
112
113// learnIntervals reads from Stripe every sold price of plan never read
114// before (refreshed_at NULL: a price seeded from the environment, or added
115// by id), and records its interval, amount and currency, so the price can
116// be chosen by interval (rulings R17 and R19). A price read once is known,
117// even with no interval (a one-off). A price Stripe cannot read stops the
118// sale.
119func (s *Service) learnIntervals(ctx context.Context, plan string) error {
120 var ids []string
121 if err := s.cfg.DB.WithContext(ctx).Raw(`SELECT stripe_price_id FROM abono_prices
122 WHERE plan = ? AND sold = 1 AND refreshed_at IS NULL ORDER BY stripe_price_id`, plan).Scan(&ids).Error; err != nil {
123 return err
124 }
125 for _, id := range ids {
126 if err := s.refreshPrice(ctx, id); err != nil {
127 return err
128 }
129 }
130 return nil
131}
132
133// refreshPrice records what Stripe says of price id: its interval
134// ("" for a one-off price), amount and currency.
135func (s *Service) refreshPrice(ctx context.Context, id string) error {
136 p, err := s.stripe.GetPrice(ctx, id)
137 if err != nil {
138 return fmt.Errorf("abono: reading price %q: %w", id, err)
139 }
140 return s.cfg.DB.WithContext(ctx).Exec(`UPDATE abono_prices
141 SET interval = ?, interval_count = ?, amount = ?, currency = ?, refreshed_at = ? WHERE stripe_price_id = ?`,
142 p.Recurring.Interval, p.Recurring.IntervalCount, p.UnitAmount, p.Currency, s.now(), id).Error
143}
144
145// soldPrice is the price to sell for plan at interval: the sold price with
146// that interval, or with no interval the plan's only sold price. A price
147// v0.1 cannot sell (Price.Unsupported) is never one of them.
148func soldPrice(g *gorm.DB, plan, interval string) (string, error) {
149 var prices []priceRow
150 if err := g.Raw(`SELECT p.stripe_price_id, p.interval, p.interval_count FROM abono_prices p
151 JOIN abono_offers o ON o.plan = p.plan
152 WHERE p.plan = ? AND p.sold = 1 AND o.archived_at IS NULL
153 ORDER BY p.stripe_price_id`, plan).Scan(&prices).Error; err != nil {
154 return "", err
155 }
156 var match []string
157 for _, p := range prices {
158 if p.price().Unsupported() {
159 continue
160 }
161 if interval == "" || p.Interval == interval {
162 match = append(match, p.StripePriceID)
163 }
164 }
165 if len(match) != 1 {
166 if interval == "" {
167 return "", fmt.Errorf("%w: %q", ErrUnknownPlan, plan)
168 }
169 return "", fmt.Errorf("%w: %q by the %s", ErrUnknownPlan, plan, interval)
170 }
171 return match[0], nil
172}
173
174// back is Origin plus path, a path on this site ("/" when empty).
175func (s *Service) back(path string) (string, error) {
176 if path == "" {
177 path = "/"
178 }
179 if !strings.HasPrefix(path, "/") || strings.HasPrefix(path, "//") {
180 return "", fmt.Errorf("abono: %q is not a path on this site", path)
181 }
182 return strings.TrimRight(s.cfg.Origin, "/") + path, nil
183}
184
185// successURL is Origin plus path with session_id={CHECKOUT_SESSION_ID} in
186// its query, for Finish: any session_id already there is replaced, the rest
187// of the query is kept as written, and a fragment stays after the query, so
188// the parameter reaches the server. The braces are left as they are:
189// Stripe looks for the placeholder literally.
190func (s *Service) successURL(path string) (string, error) {
191 base, err := s.back(path)
192 if err != nil {
193 return "", err
194 }
195 u, err := url.Parse(base)
196 if err != nil {
197 return "", fmt.Errorf("abono: success path %q: %w", path, err)
198 }
199 var keep []string
200 for _, kv := range strings.Split(u.RawQuery, "&") {
201 k, _, _ := strings.Cut(kv, "=")
202 if key, err := url.QueryUnescape(k); kv == "" || (err == nil && key == "session_id") {
203 continue
204 }
205 keep = append(keep, kv)
206 }
207 u.RawQuery = strings.Join(append(keep, "session_id={CHECKOUT_SESSION_ID}"), "&")
208 return u.String(), nil
209}
210
211// customerOf is the Stripe customer subject pays as: their newest
212// subscription's, else one mapped to them, else "".
213func customerOf(g *gorm.DB, subject string) (string, error) {
214 if subject == "" {
215 return "", nil
216 }
217 var ids []string
218 if err := g.Raw(`SELECT stripe_customer_id FROM abono_subscriptions
219 WHERE subject = ? AND stripe_customer_id <> '' ORDER BY created_at DESC LIMIT 1`, subject).Scan(&ids).Error; err != nil {
220 return "", err
221 }
222 if len(ids) == 0 {
223 if err := g.Raw(`SELECT stripe_customer_id FROM abono_customers
224 WHERE subject = ? ORDER BY rowid DESC LIMIT 1`, subject).Scan(&ids).Error; err != nil {
225 return "", err
226 }
227 }
228 if len(ids) == 0 {
229 return "", nil
230 }
231 return ids[0], nil
232}
233
234// Finished is what Checkout's return found: the address paid with, the
235// subject the subscription is now tied to ("" when unclaimed), and whether
236// it was paid. Processing is a Checkout that completed while its payment
237// is still on its way: nothing is recorded yet, the webhook does that when
238// it lands, and the reader must not be asked to buy again.
239type Finished struct {
240 Email, Subject string
241 Paid bool
242 Processing bool
243}
244
245// Finish is Checkout's return. It syncs the session's subscription at once
246// rather than waiting for the webhook, so the next page already reflects
247// the payment. It signs nobody in. Paid is true only for a complete session
248// whose payment_status is paid or no_payment_required; a complete session
249// still unpaid is Processing; any other session records nothing.
250func (s *Service) Finish(ctx context.Context, sessionID string) (Finished, error) {
251 if !s.On() {
252 return Finished{}, errOff
253 }
254 cs, err := s.stripe.GetCheckout(ctx, sessionID)
255 if err != nil {
256 return Finished{}, err
257 }
258 f := Finished{Email: normalEmail(cs.CustomerDetails.Email)}
259 // Complete is not paid: a bank debit completes the session while the
260 // money is still on its way. Then nothing is recorded here, and the
261 // webhook syncs when the payment lands. A trial or a 100% code has
262 // nothing to pay, and is paid.
263 paid := cs.PaymentStatus == "paid" || cs.PaymentStatus == "no_payment_required"
264 if cs.Status == "complete" && !paid {
265 f.Processing = true
266 return f, nil
267 }
268 if cs.Status != "complete" || cs.Subscription == "" {
269 return f, nil
270 }
271 // The subscription's lock from the fetch to the commit (ruling R35):
272 // a webhook for it waits, so whichever read Stripe later writes later.
273 unlock, err := s.subs.lock(ctx, cs.Subscription)
274 if err != nil {
275 return Finished{}, err
276 }
277 defer unlock()
278 sub, err := s.stripe.GetSubscription(ctx, cs.Subscription)
279 if err != nil {
280 return Finished{}, err
281 }
282 hint := Hint{Subject: cs.ClientReferenceID, Email: cs.CustomerDetails.Email}
283 if hint.Subject == "" {
284 hint.Subject = sub.Metadata["abono_subject"]
285 }
286 var subject string
287 if err := s.tx(ctx, func(tx *gorm.DB) error {
288 var err error
289 subject, err = s.syncTx(ctx, tx, sub, hint, false)
290 return err
291 }); err != nil {
292 return Finished{}, err
293 }
294 if e := normalEmail(sub.Customer.Email); e != "" {
295 f.Email = e
296 }
297 f.Subject, f.Paid = subject, true
298 return f, nil
299}
300
301// Customers is every Stripe customer subject pays through, newest
302// subscription first: the customer of each of the subject's subscription
303// rows, whether or not it grants and whether or not an offer recognises
304// its price (ruling R37). A reader with two guest purchases, or one on a
305// price no plan has, is still charged by each, so a page offers billing
306// for every one of them, not for what Holdings shows.
307func (s *Service) Customers(ctx context.Context, subject string) ([]string, error) {
308 subject = strings.TrimSpace(subject)
309 if subject == "" {
310 return nil, nil
311 }
312 var ids []string
313 err := s.cfg.DB.WithContext(ctx).Raw(`SELECT stripe_customer_id FROM abono_subscriptions
314 WHERE subject = ? AND stripe_customer_id <> ''
315 GROUP BY stripe_customer_id ORDER BY MAX(created_at) DESC, stripe_customer_id DESC`, subject).Scan(&ids).Error
316 return ids, err
317}
318
319// Portal is a Customer Portal session for customer, one of subject's
320// Customers, where they change their card or plan or cancel, coming back
321// to returnPath on this site. A customer that is not one of subject's is
322// ErrNoCustomer, so a posted id cannot open someone else's billing.
323func (s *Service) Portal(ctx context.Context, subject, customer, returnPath string) (string, error) {
324 if !s.On() {
325 return "", errOff
326 }
327 back, err := s.back(returnPath)
328 if err != nil {
329 return "", err
330 }
331 customers, err := s.Customers(ctx, subject)
332 if err != nil {
333 return "", err
334 }
335 customer = strings.TrimSpace(customer)
336 if customer == "" || !slices.Contains(customers, customer) {
337 return "", ErrNoCustomer
338 }
339 return s.stripe.PortalURL(ctx, customer, back)
340}
341