package abono import ( "context" "sort" "time" "gorm.io/gorm" ) // backstopWindow is how long past a paid period's end a past_due subscription // still grants: Stripe lets an account leave a subscription past_due for // good once its retries end, so abono stops holding it 30 days after the // period it was paid to, whatever the account's setting. The paid period's // end is the unpaid one's current_period_start: a failed renewal has // already moved current_period_end a whole period on (ruling R21). Stripe // goes on invoicing a past_due subscription, moving the start on again // with each failed renewal, so abono keeps the first unpaid start it saw, // unpaid_since, and counts from that (ruling R32). With no start the // count is from current_period_end instead (ruling R30). const backstopWindow = 30 * 24 * time.Hour // Source says where a holding came from. type Source string const ( // SourceSubscription is a Stripe subscription. SourceSubscription Source = "subscription" // SourceComp is a comp: given by an operator, not paid for. SourceComp Source = "comp" ) // Holding is one plan a subject has, or had: what Stripe or an operator // says of it, and whether it grants access right now. Grants is the // answer; the rest is for showing it. type Holding struct { Plan string Source Source // Status is Stripe's subscription status, or a comp's: active or // canceled. Status string // Until is the current period's end for a subscription, a comp's // ends_at; nil when there is none. Until *time.Time // PeriodStart is a subscription's current period's start, nil when // unknown. PeriodStart *time.Time // UnpaidSince is, for a past_due or unpaid subscription, the first // unpaid day abono saw: the start of the first period that went // unpaid. The 30 days count from it, however many renewals have failed // since. Nil once paid again. UnpaidSince *time.Time CancelAtPeriodEnd bool // Grants is whether this holding gives access at the moment Holdings // was asked. Grants bool // Backstop is set on a past_due subscription that grants only because // of the 30-day backstop: its paid period is over. The owner's screen // flags these. Backstop bool SubscriptionID string // set for SourceSubscription CompID int64 // set for SourceComp } // pastDueAnchor is where the 30 days of a past_due subscription count from: // the first unpaid day abono saw (ruling R32), else the start of the unpaid // period, or, when that is unknown, the period end (ruling R30). ok is // false when none is known. func pastDueAnchor(unpaidSince, periodStart, periodEnd *time.Time) (anchor time.Time, ok bool) { switch { case unpaidSince != nil: return *unpaidSince, true case periodStart != nil: return *periodStart, true case periodEnd != nil: return *periodEnd, true } return time.Time{}, false } // pastDueStops is the moment a past_due subscription stops granting: 30 days // after its anchor, or the period end if that is sooner and the subscription // is set to cancel with its period (ruling R30). ok is false when there is // nothing to count from, and then it does not grant. func pastDueStops(unpaidSince, periodStart, periodEnd *time.Time, cancelAtPeriodEnd bool) (stops time.Time, ok bool) { anchor, ok := pastDueAnchor(unpaidSince, periodStart, periodEnd) if !ok { return time.Time{}, false } stops = anchor.Add(backstopWindow) if cancelAtPeriodEnd && periodEnd != nil && periodEnd.Before(stops) { stops = *periodEnd } return stops, true } // grants is the one holding rule for a Stripe subscription, used by // Holdings and by Describe, so a screen can never say what access does not. // // active, trialing grant, unless cancelling: then only // while now < periodEnd // past_due grants while now < the earlier of // unpaidSince + 30 days and, if cancelling, // periodEnd. With no unpaidSince the 30 // days count from periodStart, else from // periodEnd; with none it does not grant // canceled, unpaid, incomplete, // incomplete_expired, paused, anything // Stripe adds later does not // // backstop is true for a past_due subscription that grants only because of // the 30-day window, that is once now has reached the anchor (unpaidSince, // else periodStart, else periodEnd). func grants(status string, unpaidSince, periodStart, periodEnd *time.Time, cancelAtPeriodEnd bool, now time.Time) (grants, backstop bool) { switch status { case "active", "trialing": // A subscription set to end with its period grants until the period // is over. Stripe moves it to canceled then, but that webhook may be // late or lost, and access must not outlive what was paid for. With // no period end there is nothing to count to, so it grants. if cancelAtPeriodEnd && periodEnd != nil && !now.Before(*periodEnd) { return false, false } return true, false case "past_due": // Counted from the first unpaid period's start, never its end, when // the start is known: Stripe moved the end a whole period on when the // renewal failed, and moves the start on with each renewal that // fails after it. stops, ok := pastDueStops(unpaidSince, periodStart, periodEnd, cancelAtPeriodEnd) if !ok || !now.Before(stops) { return false, false } anchor, _ := pastDueAnchor(unpaidSince, periodStart, periodEnd) return true, !now.Before(anchor) } return false, false } // compGrants is the rule for a comp: active, and before its end if it has // one. A comp whose end is now is over. func compGrants(status string, endsAt *time.Time, now time.Time) bool { if status != compActive { return false } return endsAt == nil || now.Before(*endsAt) } // evaluate is the holding rule applied to h at now. func (h Holding) evaluate(now time.Time) (ok, held bool) { if h.Source == SourceComp { return compGrants(h.Status, h.Until, now), false } return grants(h.Status, h.UnpaidSince, h.PeriodStart, h.Until, h.CancelAtPeriodEnd, now) } type subscriptionRow struct { StripeSubscriptionID string Subject string Plan string Status string CurrentPeriodStart *time.Time CurrentPeriodEnd *time.Time CancelAtPeriodEnd bool UnpaidSince *time.Time CreatedAt time.Time } func (subscriptionRow) TableName() string { return "abono_subscriptions" } // Holdings is what this subject has, newest first: subscriptions and comps // together, the ones that no longer grant included (they read as ended). // Subscriptions whose price no offer recognises (plan "") and payments not // yet tied to a subject are left out: neither can grant. func (s *Service) Holdings(ctx context.Context, subject string) ([]Holding, error) { return s.holdings(ctx, s.cfg.DB, subject) } // HoldingsIn is Holdings read through tx, for a Holder or OnChange callback, // which runs inside abono's write transaction: Holdings uses the service's // own handle, which does not join that transaction and so waits on it, on the // single writer connection, forever. HoldingsIn sees the transaction's own // writes and judges them by the service's clock. func (s *Service) HoldingsIn(ctx context.Context, tx *gorm.DB, subject string) ([]Holding, error) { return s.holdings(ctx, tx, subject) } func (s *Service) holdings(ctx context.Context, db *gorm.DB, subject string) ([]Holding, error) { if subject == "" { return nil, nil } g := db.WithContext(ctx) var subs []subscriptionRow if err := g.Where("subject = ? AND plan <> ''", subject).Find(&subs).Error; err != nil { return nil, err } var comps []compRow if err := g.Where("subject = ?", subject).Find(&comps).Error; err != nil { return nil, err } now := s.now() type dated struct { Holding created time.Time } var all []dated for _, r := range subs { h := Holding{ Plan: r.Plan, Source: SourceSubscription, Status: r.Status, Until: utc(r.CurrentPeriodEnd), PeriodStart: utc(r.CurrentPeriodStart), UnpaidSince: utc(r.UnpaidSince), CancelAtPeriodEnd: r.CancelAtPeriodEnd, SubscriptionID: r.StripeSubscriptionID, } h.Grants, h.Backstop = h.evaluate(now) all = append(all, dated{h, r.CreatedAt}) } for _, r := range comps { h := Holding{Plan: r.Plan, Source: SourceComp, Status: r.Status, Until: utc(r.EndsAt), CompID: r.ID} h.Grants, h.Backstop = h.evaluate(now) all = append(all, dated{h, r.CreatedAt}) } sort.SliceStable(all, func(i, j int) bool { if !all[i].created.Equal(all[j].created) { return all[i].created.After(all[j].created) } // Same instant: the later comp, then subscriptions, in a fixed order. return all[i].CompID > all[j].CompID }) out := make([]Holding, len(all)) for i, d := range all { out[i] = d.Holding } return out, nil } // HasPlan reports whether subject holds plan right now, by a subscription // or a comp. (The design calls this Grants; it is renamed so it cannot be // confused with a comp.) func (s *Service) HasPlan(ctx context.Context, subject, plan string) (bool, error) { return s.hasPlan(ctx, s.cfg.DB, subject, plan) } // HasPlanIn is HasPlan read through tx; see HoldingsIn. It is what OnChange // calls to learn the subject's state after the write that triggered it. func (s *Service) HasPlanIn(ctx context.Context, tx *gorm.DB, subject, plan string) (bool, error) { return s.hasPlan(ctx, tx, subject, plan) } func (s *Service) hasPlan(ctx context.Context, db *gorm.DB, subject, plan string) (bool, error) { if plan == "" { return false, nil } hs, err := s.holdings(ctx, db, subject) if err != nil { return false, err } for _, h := range hs { if h.Plan == plan && h.Grants { return true, nil } } return false, nil } func utc(t *time.Time) *time.Time { if t == nil { return nil } u := t.UTC() return &u }