| 1 | package abono |
| 2 | |
| 3 | import ( |
| 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). |
| 21 | const backstopWindow = 30 * 24 * time.Hour |
| 22 | |
| 23 | // Source says where a holding came from. |
| 24 | type Source string |
| 25 | |
| 26 | const ( |
| 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. |
| 36 | type 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. |
| 70 | func 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. |
| 86 | func 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). |
| 115 | func 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. |
| 143 | func 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. |
| 151 | func (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 | |
| 158 | type 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 | |
| 170 | func (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. |
| 176 | func (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. |
| 185 | func (s *Service) HoldingsIn(ctx context.Context, tx *gorm.DB, subject string) ([]Holding, error) { |
| 186 | return s.holdings(ctx, tx, subject) |
| 187 | } |
| 188 | |
| 189 | func (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.) |
| 241 | func (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. |
| 247 | func (s *Service) HasPlanIn(ctx context.Context, tx *gorm.DB, subject, plan string) (bool, error) { |
| 248 | return s.hasPlan(ctx, tx, subject, plan) |
| 249 | } |
| 250 | |
| 251 | func (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 | |
| 267 | func utc(t *time.Time) *time.Time { |
| 268 | if t == nil { |
| 269 | return nil |
| 270 | } |
| 271 | u := t.UTC() |
| 272 | return &u |
| 273 | } |
| 274 | |