rastrillo / abono Public

stripeapi: period start reads the items first

Summary

PeriodStart takes the earliest item current_period_start when any item has one, and the top-level current_period_start only when none does (ruling R21), so a later top-level start can no longer lengthen the past_due window. TestSubscriptionWire gains conflicting cases: top-level later and earlier than the item, items that disagree, an item without a start, and top-level only.
claude agent, operated by paul@keymail.dev 23f39802b944c6908dbc829473822519ce1b92a3 parent b836424
2 files changed, +35 −2
  • internal/stripeapi/client.go +10 −2
  • internal/stripeapi/client_test.go +25 −0

CI passed — run details

CI log
=== vet ===
go vet ./...
=== fmt ===
=== test ===
go test ./... -count=1
ok  	amadan.net/rastrillo/abono	0.589s
ok  	amadan.net/rastrillo/abono/abonotest	0.052s
ok  	amadan.net/rastrillo/abono/internal/fakestripe	0.019s
ok  	amadan.net/rastrillo/abono/internal/stripeapi	0.007s
=== race ===
CGO_ENABLED=1 go test ./... -race -count=1
ok  	amadan.net/rastrillo/abono	4.889s
ok  	amadan.net/rastrillo/abono/abonotest	1.433s
ok  	amadan.net/rastrillo/abono/internal/fakestripe	1.125s
ok  	amadan.net/rastrillo/abono/internal/stripeapi	1.020s
=== browser ===
browser: no browser test file yet, nothing to run
diff --git a/internal/stripeapi/client.go b/internal/stripeapi/client.go
index cbf91b1..f92f5d9 100644
--- a/internal/stripeapi/client.go
+++ b/internal/stripeapi/client.go
@@ -81,13 +81,21 @@ func (s Subscription) PeriodEnd() time.Time {
// PeriodStart is when the current period began, or the zero time. After
// a failed renewal Stripe has already moved the period on, so this is
// where the unpaid period starts: the end of the one last paid for.
+//
+// The items are read first (ruling R21): the earliest item start, since
+// the past_due window counts from it and a later one would lengthen it.
+// The top-level start, an older API version's, is read only when no item
+// has one.
func (s Subscription) PeriodStart() time.Time {
- start := s.CurrentPeriodStart
+ var start int64
for _, it := range s.Items.Data {
- if it.CurrentPeriodStart > start {
+ if it.CurrentPeriodStart != 0 && (start == 0 || it.CurrentPeriodStart < start) {
start = it.CurrentPeriodStart
}
}
+ if start == 0 {
+ start = s.CurrentPeriodStart
+ }
if start == 0 {
return time.Time{}
}
diff --git a/internal/stripeapi/client_test.go b/internal/stripeapi/client_test.go
index f5be0ae..56b027c 100644
--- a/internal/stripeapi/client_test.go
+++ b/internal/stripeapi/client_test.go
@@ -34,6 +34,31 @@ func TestSubscriptionWire(t *testing.T) {
t.Errorf("%s: %+v", name, s)
}
}
+ // Items first (ruling R21): a top-level start, later or not, is read
+ // only when no item has one, and of several items the earliest wins.
+ for name, c := range map[string]struct {
+ raw string
+ want int64
+ }{
+ "top-level later than the item": {`{"id":"sub_1","current_period_start":1750000000,"items":{"data":[{"current_period_start":1700000000}]}}`, 1_700_000_000},
+ "top-level earlier than the item": {`{"id":"sub_1","current_period_start":1650000000,"items":{"data":[{"current_period_start":1700000000}]}}`, 1_700_000_000},
+ "items disagree": {`{"id":"sub_1","items":{"data":[{"current_period_start":1710000000},{"current_period_start":1700000000}]}}`, 1_700_000_000},
+ "an item without one": {`{"id":"sub_1","current_period_start":1750000000,"items":{"data":[{},{"current_period_start":1700000000}]}}`, 1_700_000_000},
+ "only top-level": {`{"id":"sub_1","current_period_start":1750000000,"items":{"data":[{}]}}`, 1_750_000_000},
+ } {
+ var w subscriptionWire
+ if err := json.Unmarshal([]byte(c.raw), &w); err != nil {
+ t.Fatal(err)
+ }
+ s, err := w.decode()
+ if err != nil {
+ t.Fatal(err)
+ }
+ if got := s.PeriodStart(); !got.Equal(time.Unix(c.want, 0)) {
+ t.Errorf("%s: PeriodStart = %v, want %v", name, got, time.Unix(c.want, 0).UTC())
+ }
+ }
+
var none Subscription
if !none.PeriodStart().IsZero() || !none.PeriodEnd().IsZero() {
t.Error("a subscription with no period has a start or an end")