rastrillo / abono Public

fakestripe: renew on the billing anchor, and keep invoicing a past_due subscription

Summary

The fake counts every period end from the subscription's billing anchor, clamped to the last day of a shorter month (31 January renews 28 February, then 31 March), as Stripe does. A past_due subscription is no longer frozen: each later renewal fails again and moves the period on, which is the case final-review C1 needs to be real in tests.
Agent notes
Final review M1 (ruling R43) and the fake half of C1 (ruling R32). TestFakeRenewalFailureGoesPastDue now expects a second failed renewal a period later instead of silence; TestFakeRenewsOnTheBillingAnchor is new. Retries within a period are still not modelled.
claude agent, operated by paul@keymail.dev a4b22f999d4b916e73ca8e71e54a54f6943a6b47 parent aac1e31
2 files changed, +85 −19
  • internal/fakestripe/fake.go +40 −14
  • internal/fakestripe/fake_test.go +45 −5

CI passed — run details

CI log
=== vet ===
go vet ./...
=== fmt ===
=== test ===
go test ./... -count=1
ok  	amadan.net/rastrillo/abono	0.861s
ok  	amadan.net/rastrillo/abono/abonotest	0.068s
ok  	amadan.net/rastrillo/abono/cmd/abono	0.950s
ok  	amadan.net/rastrillo/abono/internal/fakestripe	0.026s
ok  	amadan.net/rastrillo/abono/internal/stripeapi	0.013s
ok  	example	0.149s
=== race ===
CGO_ENABLED=1 go test ./... -race -count=1
ok  	amadan.net/rastrillo/abono	7.083s
ok  	amadan.net/rastrillo/abono/abonotest	1.513s
ok  	amadan.net/rastrillo/abono/cmd/abono	2.340s
ok  	amadan.net/rastrillo/abono/internal/fakestripe	1.080s
ok  	amadan.net/rastrillo/abono/internal/stripeapi	1.029s
ok  	example	1.954s
=== browser ===
ok  	example	1.003s
diff --git a/internal/fakestripe/fake.go b/internal/fakestripe/fake.go
index 28a7549..adb11c1 100644
--- a/internal/fakestripe/fake.go
+++ b/internal/fakestripe/fake.go
@@ -123,6 +123,13 @@ type subscription struct {
periodStart, periodEnd time.Time
cancel bool
metadata map[string]string
+ // anchor is the billing cycle anchor, and cycles how many periods
+ // past it the current period ends: the period end is always counted
+ // from the anchor (see periodAfter), so a subscription that began on
+ // the 31st renews on the last day of a shorter month and on the 31st
+ // again after it, as Stripe's does.
+ anchor time.Time
+ cycles int
}
type session struct {
@@ -207,6 +214,7 @@ func (f *Fake) PutSubscription(s Subscription) string {
f.subs[s.ID] = &subscription{
id: s.ID, customer: s.Customer, status: s.Status, price: s.Price, interval: s.Interval,
periodStart: s.PeriodStart, periodEnd: s.PeriodEnd, cancel: s.CancelAtPeriodEnd, metadata: s.Metadata,
+ anchor: s.PeriodEnd,
}
return s.ID
}
@@ -311,8 +319,11 @@ func (f *Fake) Advance(d time.Duration) {
// trialing one whose period has ended renews, paid, and moves to the next
// period; with FailNextRenewal it goes past_due instead (the period still
// moves on, as Stripe's does: the failed invoice is for the new one, and
-// current_period_start is where it began). A past_due subscription is not
-// renewed again: Stripe's retries and what follows them are not modelled.
+// current_period_start is where it began). A past_due subscription goes on
+// being invoiced, as Stripe's does when an account leaves subscriptions
+// past_due: each renewal fails again and the period moves on again. Retries
+// within a period are not modelled. Periods are counted from the billing
+// anchor, clamped to the end of a shorter month.
// A delivery that fails stays queued for the next Tick or page action;
// DeliveryErrors says why.
func (f *Fake) Tick() { _ = f.tick(context.Background()) }
@@ -332,19 +343,17 @@ func (f *Fake) tick(ctx context.Context) error {
events = append(events, f.event("customer.subscription.deleted", f.subObject(s, false)))
break
}
- if s.status == "past_due" {
- break
- }
+ s.cycles++
switch {
- case f.failNext[id]:
+ case f.failNext[id] || s.status == "past_due":
delete(f.failNext, id)
s.status = "past_due"
- s.periodStart, s.periodEnd = s.periodEnd, addInterval(s.periodEnd, s.interval)
+ s.periodStart, s.periodEnd = s.periodEnd, periodAfter(s.anchor, s.cycles, s.interval)
events = append(events, f.event("invoice.payment_failed", f.invoice(s, "open")),
f.event("customer.subscription.updated", f.subObject(s, false)))
default:
s.status = "active" // a trial that renews paid is over
- s.periodStart, s.periodEnd = s.periodEnd, addInterval(s.periodEnd, s.interval)
+ s.periodStart, s.periodEnd = s.periodEnd, periodAfter(s.anchor, s.cycles, s.interval)
events = append(events, f.event("invoice.paid", f.invoice(s, "paid")),
f.event("customer.subscription.updated", f.subObject(s, false)))
}
@@ -616,7 +625,8 @@ func (f *Fake) pay(ctx context.Context, id, email string) (string, error) {
}
sub := &subscription{
id: f.next("sub"), customer: c.id, status: "active", price: s.price, interval: interval,
- periodStart: now, periodEnd: addInterval(now, interval), metadata: s.metadata,
+ periodStart: now, periodEnd: periodAfter(now, 1, interval), metadata: s.metadata,
+ anchor: now, cycles: 1,
}
f.subs[sub.id] = sub
f.order = append(f.order, sub.id)
@@ -870,16 +880,32 @@ func priceObject(p *price) map[string]any {
"recurring": recurring, "type": typ, "active": true}
}
-func addInterval(t time.Time, interval string) time.Time {
+// periodAfter is the end of the n-th period of interval counted from
+// anchor. Months and years keep the anchor's day, clamped to the last day
+// of a shorter month (31 January, 28 February, 31 March), which is how
+// Stripe bills; time.AddDate would roll 31 January on to 3 March.
+func periodAfter(anchor time.Time, n int, interval string) time.Time {
switch interval {
case "year":
- return t.AddDate(1, 0, 0)
+ return addMonths(anchor, 12*n)
case "week":
- return t.AddDate(0, 0, 7)
+ return anchor.AddDate(0, 0, 7*n)
case "day":
- return t.AddDate(0, 0, 1)
+ return anchor.AddDate(0, 0, n)
+ }
+ return addMonths(anchor, n)
+}
+
+// addMonths is t plus n months, on t's day or the target month's last day
+// when that comes first.
+func addMonths(t time.Time, n int) time.Time {
+ y, m, d := t.Date()
+ first := time.Date(y, m+time.Month(n), 1, t.Hour(), t.Minute(), t.Second(), t.Nanosecond(), t.Location())
+ last := first.AddDate(0, 1, -1).Day()
+ if d > last {
+ d = last
}
- return t.AddDate(0, 1, 0)
+ return first.AddDate(0, 0, d-1)
}
func unix(t time.Time) int64 {
diff --git a/internal/fakestripe/fake_test.go b/internal/fakestripe/fake_test.go
index e55e90d..c065576 100644
--- a/internal/fakestripe/fake_test.go
+++ b/internal/fakestripe/fake_test.go
@@ -321,13 +321,53 @@ func TestFakeRenewalFailureGoesPastDue(t *testing.T) {
t.Errorf("failure events = %v, want %v", got, want)
}
- // Nothing more happens to a past_due subscription on its own.
+ // Stripe keeps invoicing a past_due subscription (an account may leave
+ // it past_due for good): the next renewal fails too, and the period
+ // moves on again. abono must not count its 30 days from this new start
+ // (ruling R32).
r.f.Advance(366 * 24 * time.Hour)
- if sub = r.sub(t, id); sub.Status != "past_due" {
- t.Errorf("later: %s, want still past_due", sub.Status)
+ now = now.Add(366 * 24 * time.Hour)
+ sub = r.sub(t, id)
+ if sub.Status != "past_due" || !sub.PeriodStart().Equal(t0.AddDate(3, 0, 0)) || !sub.PeriodEnd().Equal(t0.AddDate(4, 0, 0)) {
+ t.Errorf("a period later: %s from %v until %v, want past_due from %v until %v",
+ sub.Status, sub.PeriodStart(), sub.PeriodEnd(), t0.AddDate(3, 0, 0), t0.AddDate(4, 0, 0))
+ }
+ if got, want := types(t, r.hook.take(), now), []string{"invoice.payment_failed", "customer.subscription.updated"}; !reflect.DeepEqual(got, want) {
+ t.Errorf("events of the second failed renewal = %v, want %v", got, want)
+ }
+}
+
+// TestFakeRenewsOnTheBillingAnchor is Stripe's month-end rule: a monthly
+// subscription anchored on the 31st renews on the last day of a shorter
+// month, then on the 31st again, never drifting to the 3rd.
+func TestFakeRenewsOnTheBillingAnchor(t *testing.T) {
+ jan31 := time.Date(2027, 1, 31, 12, 0, 0, 0, time.UTC)
+ f := fakestripe.New(fakestripe.Options{Now: func() time.Time { return jan31 }})
+ f.AddPrice("price_M", "monthly", 600, "eur", "month")
+ id := f.PutSubscription(fakestripe.Subscription{Status: "active", Price: "price_M",
+ PeriodStart: time.Date(2026, 12, 31, 12, 0, 0, 0, time.UTC), PeriodEnd: jan31})
+
+ period := func() (time.Time, time.Time) {
+ t.Helper()
+ b, ok := f.SubscriptionJSON(id)
+ if !ok {
+ t.Fatal("the subscription is gone")
+ }
+ var s stripeapi.Subscription
+ if err := json.Unmarshal(b, &s); err != nil {
+ t.Fatal(err)
+ }
+ return s.PeriodStart(), s.PeriodEnd()
+ }
+ f.Tick()
+ feb28 := time.Date(2027, 2, 28, 12, 0, 0, 0, time.UTC)
+ if start, end := period(); !start.Equal(jan31) || !end.Equal(feb28) {
+ t.Errorf("renewed on 31 January: %v to %v, want %v to %v", start, end, jan31, feb28)
}
- if got := r.hook.take(); len(got) != 0 {
- t.Errorf("events for a past_due subscription: %d", len(got))
+ f.Advance(feb28.Sub(jan31))
+ mar31 := time.Date(2027, 3, 31, 12, 0, 0, 0, time.UTC)
+ if start, end := period(); !start.Equal(feb28) || !end.Equal(mar31) {
+ t.Errorf("renewed on 28 February: %v to %v, want %v to %v", start, end, feb28, mar31)
}
}