abono: a cancelling membership stops at its period end
Summary
The shared holding rule now stops an active or trialing subscription with cancel_at_period_end at its period end, so a late or lost cancellation webhook cannot extend access. Describe reads it as ended from then. past_due rules are unchanged.
Agent notes
No period end means nothing to count to, so it still grants. Boundary tests: one second before, at, one second after, a day after, for active and trialing.
3 files changed,
+27
−3
describe_test.go+6 −0holdings.go+11 −3holdings_test.go+10 −0
CI passed — run details
CI log
=== vet === go vet ./... === fmt === === test === go test ./... -count=1 ok amadan.net/rastrillo/abono 0.147s ok amadan.net/rastrillo/abono/internal/stripeapi 0.006s === race === CGO_ENABLED=1 go test ./... -race -count=1 ok amadan.net/rastrillo/abono 2.615s ok amadan.net/rastrillo/abono/internal/stripeapi 1.018s === browser === browser: no browser test file yet, nothing to run
Intent
diff --git a/describe_test.go b/describe_test.go| index 23df428..14dd830 100644 |
| --- a/describe_test.go |
| +++ b/describe_test.go |
| @@ -41,6 +41,12 @@ func TestDescribe(t *testing.T) { |
| {"failing and cancelling reads as failing", |
| abono.Holding{Source: abono.SourceSubscription, Status: "past_due", Until: &end, CancelAtPeriodEnd: true}, |
| "abono.status.failing", "warning", ptr(end.Add(30 * day))}, |
| + {"ended, cancelling and its period is over", |
| + abono.Holding{Source: abono.SourceSubscription, Status: "active", Until: &past, CancelAtPeriodEnd: true}, |
| + "abono.status.ended", "neutral", nil}, |
| + {"ended, cancelling trial at its period end", |
| + abono.Holding{Source: abono.SourceSubscription, Status: "trialing", Until: &now, CancelAtPeriodEnd: true}, |
| + "abono.status.ended", "neutral", nil}, |
| {"ended, canceled", |
| abono.Holding{Source: abono.SourceSubscription, Status: "canceled", Until: &past}, |
| "abono.status.ended", "neutral", nil}, |
diff --git a/holdings.go b/holdings.go| index e516e56..35ddf22 100644 |
| --- a/holdings.go |
| +++ b/holdings.go |
| @@ -50,7 +50,8 @@ type Holding struct { |
| // 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 grants |
| +// active, trialing grant, unless cancelling: then only |
| +// while now < periodEnd |
| // past_due grants while now < periodEnd + 30 days |
| // canceled, unpaid, incomplete, |
| // incomplete_expired, paused, anything |
| @@ -58,9 +59,16 @@ type Holding struct { |
| // |
| // backstop is true for a past_due subscription held only by the 30-day |
| // window, that is once now has reached periodEnd. |
| -func grants(status string, periodEnd *time.Time, now time.Time) (grants, backstop bool) { |
| +func grants(status string, 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": |
| if periodEnd == nil { |
| @@ -92,7 +100,7 @@ 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.Until, now) |
| + return grants(h.Status, h.Until, h.CancelAtPeriodEnd, now) |
| } |
| type subscriptionRow struct { |
diff --git a/holdings_test.go b/holdings_test.go| index eb328c4..4ff6f5a 100644 |
| --- a/holdings_test.go |
| +++ b/holdings_test.go |
| @@ -34,6 +34,16 @@ func TestHoldingTable(t *testing.T) { |
| {name: "active, no period end", status: "active", now: at(0), grants: true}, |
| {name: "active, cancelling, before the end", status: "active", end: ptr(end), cancel: true, now: at(-day), grants: true}, |
| {name: "trialing", status: "trialing", end: ptr(end), now: at(-day), grants: true}, |
| + {name: "active, cancelling, one second before the period end", status: "active", end: ptr(end), cancel: true, now: at(-time.Second), grants: true}, |
| + {name: "active, cancelling, at the period end", status: "active", end: ptr(end), cancel: true, now: at(0), grants: false}, |
| + {name: "active, cancelling, one second after the period end", status: "active", end: ptr(end), cancel: true, now: at(time.Second), grants: false}, |
| + {name: "active, cancelling, a day after the period end", status: "active", end: ptr(end), cancel: true, now: at(day), grants: false}, |
| + {name: "trialing, cancelling, one second before the period end", status: "trialing", end: ptr(end), cancel: true, now: at(-time.Second), grants: true}, |
| + {name: "trialing, cancelling, at the period end", status: "trialing", end: ptr(end), cancel: true, now: at(0), grants: false}, |
| + {name: "trialing, cancelling, one second after the period end", status: "trialing", end: ptr(end), cancel: true, now: at(time.Second), grants: false}, |
| + {name: "trialing, cancelling, a day after the period end", status: "trialing", end: ptr(end), cancel: true, now: at(day), grants: false}, |
| + {name: "active, cancelling, no period end", status: "active", cancel: true, now: at(400 * day), grants: true}, |
| + {name: "past_due, cancelling, keeps the past_due rule", status: "past_due", end: ptr(end), cancel: true, now: at(10 * day), grants: true, backstop: true}, |
| {name: "past_due before the period end", status: "past_due", end: ptr(end), now: at(-time.Second), grants: true}, |
| {name: "past_due at the period end", status: "past_due", end: ptr(end), now: at(0), grants: true, backstop: true}, |
| {name: "past_due, 29 days past", status: "past_due", end: ptr(end), now: at(29 * day), grants: true, backstop: true}, |