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.
2 files changed,
+85
−19
internal/fakestripe/fake.go+40 −14internal/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
Intent
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) |
| } |
| } |