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 "sort"
6 "time"
7
8 "gorm.io/gorm"
9)
10
11// backstopWindow is how long past a paid period's end a past_due subscription
12// still grants: Stripe lets an account leave a subscription past_due for
13// good once its retries end, so abono stops holding it 30 days after the
14// period it was paid to, whatever the account's setting. The paid period's
15// end is the unpaid one's current_period_start: a failed renewal has
16// already moved current_period_end a whole period on (ruling R21). Stripe
17// goes on invoicing a past_due subscription, moving the start on again
18// with each failed renewal, so abono keeps the first unpaid start it saw,
19// unpaid_since, and counts from that (ruling R32). With no start the
20// count is from current_period_end instead (ruling R30).
21const backstopWindow = 30 * 24 * time.Hour
22
23// Source says where a holding came from.
24type Source string
25
26const (
27 // SourceSubscription is a Stripe subscription.
28 SourceSubscription Source = "subscription"
29 // SourceComp is a comp: given by an operator, not paid for.
30 SourceComp Source = "comp"
31)
32
33// Holding is one plan a subject has, or had: what Stripe or an operator
34// says of it, and whether it grants access right now. Grants is the
35// answer; the rest is for showing it.
36type Holding struct {
37 Plan string
38 Source Source
39 // Status is Stripe's subscription status, or a comp's: active or
40 // canceled.
41 Status string
42 // Until is the current period's end for a subscription, a comp's
43 // ends_at; nil when there is none.
44 Until *time.Time
45 // PeriodStart is a subscription's current period's start, nil when
46 // unknown.
47 PeriodStart *time.Time
48 // UnpaidSince is, for a past_due or unpaid subscription, the first
49 // unpaid day abono saw: the start of the first period that went
50 // unpaid. The 30 days count from it, however many renewals have failed
51 // since. Nil once paid again.
52 UnpaidSince *time.Time
53 CancelAtPeriodEnd bool
54 // Grants is whether this holding gives access at the moment Holdings
55 // was asked.
56 Grants bool
57 // Backstop is set on a past_due subscription that grants only because
58 // of the 30-day backstop: its paid period is over. The owner's screen
59 // flags these.
60 Backstop bool
61
62 SubscriptionID string // set for SourceSubscription
63 CompID int64 // set for SourceComp
64}
65
66// pastDueAnchor is where the 30 days of a past_due subscription count from:
67// the first unpaid day abono saw (ruling R32), else the start of the unpaid
68// period, or, when that is unknown, the period end (ruling R30). ok is
69// false when none is known.
70func pastDueAnchor(unpaidSince, periodStart, periodEnd *time.Time) (anchor time.Time, ok bool) {
71 switch {
72 case unpaidSince != nil:
73 return *unpaidSince, true
74 case periodStart != nil:
75 return *periodStart, true
76 case periodEnd != nil:
77 return *periodEnd, true
78 }
79 return time.Time{}, false
80}
81
82// pastDueStops is the moment a past_due subscription stops granting: 30 days
83// after its anchor, or the period end if that is sooner and the subscription
84// is set to cancel with its period (ruling R30). ok is false when there is
85// nothing to count from, and then it does not grant.
86func pastDueStops(unpaidSince, periodStart, periodEnd *time.Time, cancelAtPeriodEnd bool) (stops time.Time, ok bool) {
87 anchor, ok := pastDueAnchor(unpaidSince, periodStart, periodEnd)
88 if !ok {
89 return time.Time{}, false
90 }
91 stops = anchor.Add(backstopWindow)
92 if cancelAtPeriodEnd && periodEnd != nil && periodEnd.Before(stops) {
93 stops = *periodEnd
94 }
95 return stops, true
96}
97
98// grants is the one holding rule for a Stripe subscription, used by
99// Holdings and by Describe, so a screen can never say what access does not.
100//
101// active, trialing grant, unless cancelling: then only
102// while now < periodEnd
103// past_due grants while now < the earlier of
104// unpaidSince + 30 days and, if cancelling,
105// periodEnd. With no unpaidSince the 30
106// days count from periodStart, else from
107// periodEnd; with none it does not grant
108// canceled, unpaid, incomplete,
109// incomplete_expired, paused, anything
110// Stripe adds later does not
111//
112// backstop is true for a past_due subscription that grants only because of
113// the 30-day window, that is once now has reached the anchor (unpaidSince,
114// else periodStart, else periodEnd).
115func grants(status string, unpaidSince, periodStart, periodEnd *time.Time, cancelAtPeriodEnd bool, now time.Time) (grants, backstop bool) {
116 switch status {
117 case "active", "trialing":
118 // A subscription set to end with its period grants until the period
119 // is over. Stripe moves it to canceled then, but that webhook may be
120 // late or lost, and access must not outlive what was paid for. With
121 // no period end there is nothing to count to, so it grants.
122 if cancelAtPeriodEnd && periodEnd != nil && !now.Before(*periodEnd) {
123 return false, false
124 }
125 return true, false
126 case "past_due":
127 // Counted from the first unpaid period's start, never its end, when
128 // the start is known: Stripe moved the end a whole period on when the
129 // renewal failed, and moves the start on with each renewal that
130 // fails after it.
131 stops, ok := pastDueStops(unpaidSince, periodStart, periodEnd, cancelAtPeriodEnd)
132 if !ok || !now.Before(stops) {
133 return false, false
134 }
135 anchor, _ := pastDueAnchor(unpaidSince, periodStart, periodEnd)
136 return true, !now.Before(anchor)
137 }
138 return false, false
139}
140
141// compGrants is the rule for a comp: active, and before its end if it has
142// one. A comp whose end is now is over.
143func compGrants(status string, endsAt *time.Time, now time.Time) bool {
144 if status != compActive {
145 return false
146 }
147 return endsAt == nil || now.Before(*endsAt)
148}
149
150// evaluate is the holding rule applied to h at now.
151func (h Holding) evaluate(now time.Time) (ok, held bool) {
152 if h.Source == SourceComp {
153 return compGrants(h.Status, h.Until, now), false
154 }
155 return grants(h.Status, h.UnpaidSince, h.PeriodStart, h.Until, h.CancelAtPeriodEnd, now)
156}
157
158type subscriptionRow struct {
159 StripeSubscriptionID string
160 Subject string
161 Plan string
162 Status string
163 CurrentPeriodStart *time.Time
164 CurrentPeriodEnd *time.Time
165 CancelAtPeriodEnd bool
166 UnpaidSince *time.Time
167 CreatedAt time.Time
168}
169
170func (subscriptionRow) TableName() string { return "abono_subscriptions" }
171
172// Holdings is what this subject has, newest first: subscriptions and comps
173// together, the ones that no longer grant included (they read as ended).
174// Subscriptions whose price no offer recognises (plan "") and payments not
175// yet tied to a subject are left out: neither can grant.
176func (s *Service) Holdings(ctx context.Context, subject string) ([]Holding, error) {
177 return s.holdings(ctx, s.cfg.DB, subject)
178}
179
180// HoldingsIn is Holdings read through tx, for a Holder or OnChange callback,
181// which runs inside abono's write transaction: Holdings uses the service's
182// own handle, which does not join that transaction and so waits on it, on the
183// single writer connection, forever. HoldingsIn sees the transaction's own
184// writes and judges them by the service's clock.
185func (s *Service) HoldingsIn(ctx context.Context, tx *gorm.DB, subject string) ([]Holding, error) {
186 return s.holdings(ctx, tx, subject)
187}
188
189func (s *Service) holdings(ctx context.Context, db *gorm.DB, subject string) ([]Holding, error) {
190 if subject == "" {
191 return nil, nil
192 }
193 g := db.WithContext(ctx)
194 var subs []subscriptionRow
195 if err := g.Where("subject = ? AND plan <> ''", subject).Find(&subs).Error; err != nil {
196 return nil, err
197 }
198 var comps []compRow
199 if err := g.Where("subject = ?", subject).Find(&comps).Error; err != nil {
200 return nil, err
201 }
202
203 now := s.now()
204 type dated struct {
205 Holding
206 created time.Time
207 }
208 var all []dated
209 for _, r := range subs {
210 h := Holding{
211 Plan: r.Plan, Source: SourceSubscription, Status: r.Status,
212 Until: utc(r.CurrentPeriodEnd), PeriodStart: utc(r.CurrentPeriodStart), UnpaidSince: utc(r.UnpaidSince),
213 CancelAtPeriodEnd: r.CancelAtPeriodEnd,
214 SubscriptionID: r.StripeSubscriptionID,
215 }
216 h.Grants, h.Backstop = h.evaluate(now)
217 all = append(all, dated{h, r.CreatedAt})
218 }
219 for _, r := range comps {
220 h := Holding{Plan: r.Plan, Source: SourceComp, Status: r.Status, Until: utc(r.EndsAt), CompID: r.ID}
221 h.Grants, h.Backstop = h.evaluate(now)
222 all = append(all, dated{h, r.CreatedAt})
223 }
224 sort.SliceStable(all, func(i, j int) bool {
225 if !all[i].created.Equal(all[j].created) {
226 return all[i].created.After(all[j].created)
227 }
228 // Same instant: the later comp, then subscriptions, in a fixed order.
229 return all[i].CompID > all[j].CompID
230 })
231 out := make([]Holding, len(all))
232 for i, d := range all {
233 out[i] = d.Holding
234 }
235 return out, nil
236}
237
238// HasPlan reports whether subject holds plan right now, by a subscription
239// or a comp. (The design calls this Grants; it is renamed so it cannot be
240// confused with a comp.)
241func (s *Service) HasPlan(ctx context.Context, subject, plan string) (bool, error) {
242 return s.hasPlan(ctx, s.cfg.DB, subject, plan)
243}
244
245// HasPlanIn is HasPlan read through tx; see HoldingsIn. It is what OnChange
246// calls to learn the subject's state after the write that triggered it.
247func (s *Service) HasPlanIn(ctx context.Context, tx *gorm.DB, subject, plan string) (bool, error) {
248 return s.hasPlan(ctx, tx, subject, plan)
249}
250
251func (s *Service) hasPlan(ctx context.Context, db *gorm.DB, subject, plan string) (bool, error) {
252 if plan == "" {
253 return false, nil
254 }
255 hs, err := s.holdings(ctx, db, subject)
256 if err != nil {
257 return false, err
258 }
259 for _, h := range hs {
260 if h.Plan == plan && h.Grants {
261 return true, nil
262 }
263 }
264 return false, nil
265}
266
267func utc(t *time.Time) *time.Time {
268 if t == nil {
269 return nil
270 }
271 u := t.UTC()
272 return &u
273}
274