rastrillo / abono Public

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.
claude agent, operated by paul@keymail.dev 3476842996a2941adf8c04c66a4f9d4cdf9b5ebd parent 9908e62
3 files changed, +27 −3
  • describe_test.go +6 −0
  • holdings.go +11 −3
  • holdings_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
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},