abono: past_due counts 30 days from the first unpaid day, not the latest period
Summary
abono_subscriptions gains unpaid_since. Sync sets it to the unpaid period's start the first time a subscription is seen past_due or unpaid, keeps it while it stays unpaid, and clears it when the subscription is active or trialing again. Holdings, HasPlan, Describe and Unrecognised count the 30 days from it, so a second failed renewal, which moves Stripe's current_period_start on, no longer restarts the grace. Holding gains UnpaidSince.
Agent notes
Final review C1, ruling R32. migrations/0001_init.sql is edited before v0.1.0 (allowed pre-release): frozenAbonoChecksum re-recorded from a634c00c... to 871e1b39...; development databases need a reset. A subscription first seen already past_due takes its current period start (the ruling's accepted cost: up to one extra period's grace if abono missed the first failure). Regression: TestConsecutiveFailedRenewalsKeepTheFirstUnpaidDay drives the double through two failed monthly renewals; two TestHoldingTable rows cover unpaid_since before a later start.
7 files changed,
+168
−40
describe.go+4 −3holdings.go+37 −24holdings_test.go+9 −3migrations/0001_init.sql+5 −1schema_test.go+1 −1sync.go+37 −8sync_test.go+75 −0
CI failed — run details
CI log
=== vet === go vet ./... === fmt === gofmt needed: holdings.go make: *** [Makefile:16: fmt-check] Error 1 amadan: step fmt failed: exit status 2 amadan: job failed at step fmt — its output is above, not at the tail of this log
Intent
diff --git a/describe.go b/describe.go| index 45ff3a4..43edbc3 100644 |
| --- a/describe.go |
| +++ b/describe.go |
| @@ -33,8 +33,9 @@ type Status struct { |
| // renews active or trialing, with a period end, not cancelling: Date is the period end |
| // cancelling cancel_at_period_end and granting: Date is the period end |
| // failing past_due and granting: Date is the day access stops, 30 days after the |
| -// unpaid period started (after the period end when the start is |
| -// unknown), or the period end if that is sooner and it is cancelling |
| +// first unpaid period started (UnpaidSince, else PeriodStart; after the |
| +// period end when neither is known), or the period end if that is |
| +// sooner and it is cancelling |
| // ended does not grant |
| // comp a comp with no end |
| // comp_until a comp with an end: Date is ends_at |
| @@ -56,7 +57,7 @@ func Describe(h Holding, now time.Time) Status { |
| switch { |
| case h.Status == "past_due": |
| // Granting means there is a stop date to give (ruling R30). |
| - stops, ok := pastDueStops(h.PeriodStart, h.Until, h.CancelAtPeriodEnd) |
| + stops, ok := pastDueStops(h.UnpaidSince, h.PeriodStart, h.Until, h.CancelAtPeriodEnd) |
| if !ok { |
| return Status{Key: StatusEnded, Tone: "neutral"} |
| } |
diff --git a/holdings.go b/holdings.go| index 72c9873..3e0aa44 100644 |
| --- a/holdings.go |
| +++ b/holdings.go |
| @@ -13,8 +13,11 @@ import ( |
| // good once its retries end, so abono stops holding it 30 days after the |
| // period it was paid to, whatever the account's setting. The paid period's |
| // end is the unpaid one's current_period_start: a failed renewal has |
| -// already moved current_period_end a whole period on (ruling R21). With no |
| -// start the count is from current_period_end instead (ruling R30). |
| +// already moved current_period_end a whole period on (ruling R21). Stripe |
| +// goes on invoicing a past_due subscription, moving the start on again |
| +// with each failed renewal, so abono keeps the first unpaid start it saw, |
| +// unpaid_since, and counts from that (ruling R32). With no start the |
| +// count is from current_period_end instead (ruling R30). |
| const backstopWindow = 30 * 24 * time.Hour |
| // Source says where a holding came from. |
| @@ -40,9 +43,13 @@ type Holding struct { |
| // ends_at; nil when there is none. |
| Until *time.Time |
| // PeriodStart is a subscription's current period's start, nil when |
| - // unknown. For a past_due one it is where the unpaid period began, |
| - // which the 30 days count from. |
| - PeriodStart *time.Time |
| + // unknown. |
| + PeriodStart *time.Time |
| + // UnpaidSince is, for a past_due or unpaid subscription, the first |
| + // unpaid day abono saw: the start of the first period that went |
| + // unpaid. The 30 days count from it, however many renewals have failed |
| + // since. Nil once paid again. |
| + UnpaidSince *time.Time |
| CancelAtPeriodEnd bool |
| // Grants is whether this holding gives access at the moment Holdings |
| // was asked. |
| @@ -57,10 +64,13 @@ type Holding struct { |
| } |
| // pastDueAnchor is where the 30 days of a past_due subscription count from: |
| -// the start of the unpaid period, or, when that is unknown, the period end |
| -// (ruling R30). ok is false when neither is known. |
| -func pastDueAnchor(periodStart, periodEnd *time.Time) (anchor time.Time, ok bool) { |
| +// the first unpaid day abono saw (ruling R32), else the start of the unpaid |
| +// period, or, when that is unknown, the period end (ruling R30). ok is |
| +// false when none is known. |
| +func pastDueAnchor(unpaidSince, periodStart, periodEnd *time.Time) (anchor time.Time, ok bool) { |
| switch { |
| + case unpaidSince != nil: |
| + return *unpaidSince, true |
| case periodStart != nil: |
| return *periodStart, true |
| case periodEnd != nil: |
| @@ -73,8 +83,8 @@ func pastDueAnchor(periodStart, periodEnd *time.Time) (anchor time.Time, ok bool |
| // after its anchor, or the period end if that is sooner and the subscription |
| // is set to cancel with its period (ruling R30). ok is false when there is |
| // nothing to count from, and then it does not grant. |
| -func pastDueStops(periodStart, periodEnd *time.Time, cancelAtPeriodEnd bool) (stops time.Time, ok bool) { |
| - anchor, ok := pastDueAnchor(periodStart, periodEnd) |
| +func pastDueStops(unpaidSince, periodStart, periodEnd *time.Time, cancelAtPeriodEnd bool) (stops time.Time, ok bool) { |
| + anchor, ok := pastDueAnchor(unpaidSince, periodStart, periodEnd) |
| if !ok { |
| return time.Time{}, false |
| } |
| @@ -91,18 +101,18 @@ func pastDueStops(periodStart, periodEnd *time.Time, cancelAtPeriodEnd bool) (st |
| // active, trialing grant, unless cancelling: then only |
| // while now < periodEnd |
| // past_due grants while now < the earlier of |
| -// periodStart + 30 days and, if cancelling, |
| -// periodEnd. With no periodStart the 30 |
| -// days count from periodEnd; with neither |
| -// it does not grant |
| +// unpaidSince + 30 days and, if cancelling, |
| +// periodEnd. With no unpaidSince the 30 |
| +// days count from periodStart, else from |
| +// periodEnd; with none it does not grant |
| // canceled, unpaid, incomplete, |
| // incomplete_expired, paused, anything |
| // Stripe adds later does not |
| // |
| // backstop is true for a past_due subscription that grants only because of |
| -// the 30-day window, that is once now has reached the anchor (periodStart, |
| -// or periodEnd when there is none). |
| -func grants(status string, periodStart, periodEnd *time.Time, cancelAtPeriodEnd bool, now time.Time) (grants, backstop bool) { |
| +// the 30-day window, that is once now has reached the anchor (unpaidSince, |
| +// else periodStart, else periodEnd). |
| +func grants(status string, unpaidSince, periodStart, 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 |
| @@ -114,14 +124,15 @@ func grants(status string, periodStart, periodEnd *time.Time, cancelAtPeriodEnd |
| } |
| return true, false |
| case "past_due": |
| - // Counted from the unpaid period's start, never its end, when the |
| - // start is known: Stripe moved the end a whole period on when the |
| - // renewal failed. |
| - stops, ok := pastDueStops(periodStart, periodEnd, cancelAtPeriodEnd) |
| + // Counted from the first unpaid period's start, never its end, when |
| + // the start is known: Stripe moved the end a whole period on when the |
| + // renewal failed, and moves the start on with each renewal that |
| + // fails after it. |
| + stops, ok := pastDueStops(unpaidSince, periodStart, periodEnd, cancelAtPeriodEnd) |
| if !ok || !now.Before(stops) { |
| return false, false |
| } |
| - anchor, _ := pastDueAnchor(periodStart, periodEnd) |
| + anchor, _ := pastDueAnchor(unpaidSince, periodStart, periodEnd) |
| return true, !now.Before(anchor) |
| } |
| return false, false |
| @@ -141,7 +152,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.PeriodStart, h.Until, h.CancelAtPeriodEnd, now) |
| + return grants(h.Status, h.UnpaidSince, h.PeriodStart, h.Until, h.CancelAtPeriodEnd, now) |
| } |
| type subscriptionRow struct { |
| @@ -152,6 +163,7 @@ type subscriptionRow struct { |
| CurrentPeriodStart *time.Time |
| CurrentPeriodEnd *time.Time |
| CancelAtPeriodEnd bool |
| + UnpaidSince *time.Time |
| CreatedAt time.Time |
| } |
| @@ -197,7 +209,8 @@ func (s *Service) holdings(ctx context.Context, db *gorm.DB, subject string) ([] |
| for _, r := range subs { |
| h := Holding{ |
| Plan: r.Plan, Source: SourceSubscription, Status: r.Status, |
| - Until: utc(r.CurrentPeriodEnd), PeriodStart: utc(r.CurrentPeriodStart), CancelAtPeriodEnd: r.CancelAtPeriodEnd, |
| + Until: utc(r.CurrentPeriodEnd), PeriodStart: utc(r.CurrentPeriodStart), UnpaidSince: utc(r.UnpaidSince), |
| + CancelAtPeriodEnd: r.CancelAtPeriodEnd, |
| SubscriptionID: r.StripeSubscriptionID, |
| } |
| h.Grants, h.Backstop = h.evaluate(now) |
diff --git a/holdings_test.go b/holdings_test.go| index 87a7c51..bf0480a 100644 |
| --- a/holdings_test.go |
| +++ b/holdings_test.go |
| @@ -26,6 +26,7 @@ func TestHoldingTable(t *testing.T) { |
| status string |
| end *time.Time // current_period_end, or a comp's ends_at |
| start *time.Time // current_period_start |
| + unpaid *time.Time // unpaid_since |
| cancel bool |
| now time.Time |
| grants bool |
| @@ -73,6 +74,11 @@ func TestHoldingTable(t *testing.T) { |
| {name: "past_due, same period, not cancelling, past the period end", status: "past_due", start: ptr(end.Add(-10 * day)), end: ptr(end), now: at(day), grants: true, backstop: true}, |
| {name: "past_due, cancelling, start+30d sooner than the period end", status: "past_due", start: ptr(end), end: ptr(end.AddDate(1, 0, 0)), cancel: true, now: at(30 * day), grants: false}, |
| {name: "past_due, cancelling, no period start, at the period end", status: "past_due", end: ptr(end), cancel: true, now: at(0), grants: false}, |
| + // R32: Stripe goes on invoicing a past_due subscription, and each |
| + // failed renewal moves the period start on. The 30 days count from |
| + // unpaid_since, the first unpaid start abono saw. |
| + {name: "past_due, unpaid since an earlier period, one second before unpaid_since+30d", status: "past_due", unpaid: ptr(end), start: ptr(end.Add(29 * day)), end: ptr(end.Add(59 * day)), now: at(30*day - time.Second), grants: true, backstop: true}, |
| + {name: "past_due, unpaid since an earlier period, at unpaid_since+30d though the start is a day old", status: "past_due", unpaid: ptr(end), start: ptr(end.Add(29 * day)), end: ptr(end.Add(59 * day)), now: at(30 * day), grants: false}, |
| {name: "canceled", status: "canceled", end: ptr(end), now: at(-day), grants: false}, |
| {name: "unpaid", status: "unpaid", end: ptr(end), now: at(-day), grants: false}, |
| {name: "incomplete", status: "incomplete", end: ptr(end), now: at(-day), grants: false}, |
| @@ -98,9 +104,9 @@ func TestHoldingTable(t *testing.T) { |
| db.exec(`INSERT INTO abono_comps (subject, plan, status, ends_at, created_at, updated_at) VALUES (?, 'annual', ?, ?, ?, ?)`, |
| sub, c.status, c.end, t0, t0) |
| } else { |
| - db.exec(`INSERT INTO abono_subscriptions (stripe_subscription_id, stripe_customer_id, subject, plan, stripe_price_id, status, current_period_start, current_period_end, cancel_at_period_end, created_at, updated_at) |
| - VALUES ('sub_1', 'cus_1', ?, 'annual', 'price_A', ?, ?, ?, ?, ?, ?)`, |
| - sub, c.status, c.start, c.end, c.cancel, t0, t0) |
| + db.exec(`INSERT INTO abono_subscriptions (stripe_subscription_id, stripe_customer_id, subject, plan, stripe_price_id, status, current_period_start, current_period_end, cancel_at_period_end, unpaid_since, created_at, updated_at) |
| + VALUES ('sub_1', 'cus_1', ?, 'annual', 'price_A', ?, ?, ?, ?, ?, ?, ?)`, |
| + sub, c.status, c.start, c.end, c.cancel, c.unpaid, t0, t0) |
| } |
| hs, err := s.Holdings(ctx, sub) |
diff --git a/migrations/0001_init.sql b/migrations/0001_init.sql| index 00bee34..06a5566 100644 |
| --- a/migrations/0001_init.sql |
| +++ b/migrations/0001_init.sql |
| @@ -36,7 +36,10 @@ CREATE INDEX abono_customers_subject ON abono_customers(subject); |
| -- Stripe's subscriptions, as last reported. plan = '' means the price |
| -- was not recognised. subject = '' with `email` set means unclaimed: |
| --- paid for, not yet tied to a signed-in person. |
| +-- paid for, not yet tied to a signed-in person. unpaid_since is the |
| +-- first unpaid day abono saw (the unpaid period's start), kept while the |
| +-- subscription stays past_due or unpaid and cleared when it is paid: the |
| +-- 30 days of past_due count from it, never from a later period's start. |
| CREATE TABLE abono_subscriptions ( |
| stripe_subscription_id TEXT NOT NULL PRIMARY KEY, |
| stripe_customer_id TEXT NOT NULL, |
| @@ -47,6 +50,7 @@ CREATE TABLE abono_subscriptions ( |
| current_period_start DATETIME, |
| current_period_end DATETIME, |
| cancel_at_period_end INTEGER NOT NULL DEFAULT 0, |
| + unpaid_since DATETIME, |
| email TEXT NOT NULL DEFAULT '', |
| created_at DATETIME NOT NULL, |
| updated_at DATETIME NOT NULL |
diff --git a/schema_test.go b/schema_test.go| index d7c49cc..832a529 100644 |
| --- a/schema_test.go |
| +++ b/schema_test.go |
| @@ -140,7 +140,7 @@ func TestSchemaTimestampsDecode(t *testing.T) { |
| // migration file beside it, with a new ID and a new entry here. See |
| // rastrillo's own migrate/frozen_checksums_test.go, which this test is |
| // deliberately shaped after. |
| -const frozenAbonoChecksum = "a634c00c6cdd9170b094c55a9a77658142a48c33652821238b2aa25ee9f53185" |
| +const frozenAbonoChecksum = "871e1b3937108565a3bc50789fa78461758cda939f5bce89a51de8ea5fde52bb" |
| func TestSchemaFrozenChecksum(t *testing.T) { |
| for _, m := range abono.Schema.All() { |
diff --git a/sync.go b/sync.go| index bd28a21..5777780 100644 |
| --- a/sync.go |
| +++ b/sync.go |
| @@ -33,6 +33,7 @@ type subRecord struct { |
| CurrentPeriodStart *time.Time |
| CurrentPeriodEnd *time.Time |
| CancelAtPeriodEnd bool |
| + UnpaidSince *time.Time |
| Email string |
| CreatedAt time.Time |
| UpdatedAt time.Time |
| @@ -102,9 +103,14 @@ func (s *Service) syncTx(ctx context.Context, tx *gorm.DB, sub stripeapi.Subscri |
| if end := sub.PeriodEnd(); !end.IsZero() { |
| row.CurrentPeriodEnd = &end |
| } |
| + var oldUnpaid *time.Time |
| + if len(have) > 0 { |
| + oldUnpaid = utc(have[0].UnpaidSince) |
| + } |
| + row.UnpaidSince = unpaidSince(row.Status, oldUnpaid, row.CurrentPeriodStart, row.CurrentPeriodEnd) |
| if len(have) == 0 { |
| - ok, _ := grants(row.Status, row.CurrentPeriodStart, row.CurrentPeriodEnd, row.CancelAtPeriodEnd, now) |
| + ok, _ := grants(row.Status, row.UnpaidSince, row.CurrentPeriodStart, row.CurrentPeriodEnd, row.CancelAtPeriodEnd, now) |
| if !ok && !adopt { |
| // An ended subscription first seen now (a reconcile reading the |
| // account's history): recording it could only take away access |
| @@ -117,10 +123,10 @@ func (s *Service) syncTx(ctx context.Context, tx *gorm.DB, sub stripeapi.Subscri |
| } |
| if err := tx.Exec(`INSERT INTO abono_subscriptions |
| (stripe_subscription_id, stripe_customer_id, subject, plan, stripe_price_id, status, |
| - current_period_start, current_period_end, cancel_at_period_end, email, created_at, updated_at) |
| - VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, |
| + current_period_start, current_period_end, cancel_at_period_end, unpaid_since, email, created_at, updated_at) |
| + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, |
| row.StripeSubscriptionID, row.StripeCustomerID, row.Subject, row.Plan, row.StripePriceID, row.Status, |
| - row.CurrentPeriodStart, row.CurrentPeriodEnd, row.CancelAtPeriodEnd, row.Email, row.CreatedAt, row.UpdatedAt).Error; err != nil { |
| + row.CurrentPeriodStart, row.CurrentPeriodEnd, row.CancelAtPeriodEnd, row.UnpaidSince, row.Email, row.CreatedAt, row.UpdatedAt).Error; err != nil { |
| return "", err |
| } |
| if row.Plan != "" { |
| @@ -160,16 +166,16 @@ func (s *Service) syncTx(ctx context.Context, tx *gorm.DB, sub stripeapi.Subscri |
| moved := row.Subject != old.Subject || row.Plan != old.Plan || row.Status != old.Status || |
| row.CancelAtPeriodEnd != old.CancelAtPeriodEnd || !sameTime(row.CurrentPeriodEnd, old.CurrentPeriodEnd) || |
| - !sameTime(row.CurrentPeriodStart, old.CurrentPeriodStart) |
| + !sameTime(row.CurrentPeriodStart, old.CurrentPeriodStart) || !sameTime(row.UnpaidSince, old.UnpaidSince) |
| if !moved && row.StripePriceID == old.StripePriceID && row.StripeCustomerID == old.StripeCustomerID && row.Email == old.Email { |
| return row.Subject, nil |
| } |
| if err := tx.Exec(`UPDATE abono_subscriptions SET |
| stripe_customer_id = ?, subject = ?, plan = ?, stripe_price_id = ?, status = ?, |
| - current_period_start = ?, current_period_end = ?, cancel_at_period_end = ?, email = ?, updated_at = ? |
| + current_period_start = ?, current_period_end = ?, cancel_at_period_end = ?, unpaid_since = ?, email = ?, updated_at = ? |
| WHERE stripe_subscription_id = ?`, |
| row.StripeCustomerID, row.Subject, row.Plan, row.StripePriceID, row.Status, |
| - row.CurrentPeriodStart, row.CurrentPeriodEnd, row.CancelAtPeriodEnd, row.Email, now, row.StripeSubscriptionID).Error; err != nil { |
| + row.CurrentPeriodStart, row.CurrentPeriodEnd, row.CancelAtPeriodEnd, row.UnpaidSince, row.Email, now, row.StripeSubscriptionID).Error; err != nil { |
| return "", err |
| } |
| if !moved { |
| @@ -188,6 +194,29 @@ func (s *Service) syncTx(ctx context.Context, tx *gorm.DB, sub stripeapi.Subscri |
| return row.Subject, nil |
| } |
| +// unpaidSince is a subscription's first unpaid day after a sync that |
| +// reads status (ruling R32). Paid (active or trialing), there is none. |
| +// Unpaid (past_due or unpaid), it is the day already recorded, or, the |
| +// first time the subscription is seen unpaid, where the 30 days count from |
| +// now: the unpaid period's start, else its end. A later failed renewal |
| +// moves Stripe's period start on, never this. Any other status keeps what |
| +// was recorded; it grants nothing either way. |
| +func unpaidSince(status string, recorded, periodStart, periodEnd *time.Time) *time.Time { |
| + switch status { |
| + case "active", "trialing": |
| + return nil |
| + case "past_due", "unpaid": |
| + if recorded != nil { |
| + return recorded |
| + } |
| + if anchor, ok := pastDueAnchor(nil, periodStart, periodEnd); ok { |
| + return &anchor |
| + } |
| + return nil |
| + } |
| + return recorded |
| +} |
| + |
| // holder is whose a subscription is: the hint's subject, then whoever its |
| // Stripe customer is already mapped to, then, if ask, the app's Holder for |
| // the customer's address and then the hint's. A subject from the hint or |
| @@ -288,7 +317,7 @@ func (s *Service) Unrecognised(ctx context.Context) ([]Unrecognised, error) { |
| now := s.now() |
| var out []Unrecognised |
| for _, r := range rows { |
| - if ok, _ := grants(r.Status, utc(r.CurrentPeriodStart), utc(r.CurrentPeriodEnd), r.CancelAtPeriodEnd, now); !ok { |
| + if ok, _ := grants(r.Status, utc(r.UnpaidSince), utc(r.CurrentPeriodStart), utc(r.CurrentPeriodEnd), r.CancelAtPeriodEnd, now); !ok { |
| continue |
| } |
| out = append(out, Unrecognised{ |
diff --git a/sync_test.go b/sync_test.go| index 52abe5b..329134e 100644 |
| --- a/sync_test.go |
| +++ b/sync_test.go |
| @@ -603,3 +603,78 @@ func TestSyncKeepsThePeriodStartForThePastDueWindow(t *testing.T) { |
| t.Errorf("OnChange after the period moved: %v, want once", changes) |
| } |
| } |
| + |
| +// TestConsecutiveFailedRenewalsKeepTheFirstUnpaidDay is final review C1 |
| +// (ruling R32): Stripe goes on invoicing a past_due subscription, and each |
| +// failed renewal moves current_period_start on. The 30 days count from the |
| +// first unpaid day abono saw, so a second failed renewal does not restart |
| +// them; a payment that brings the subscription back clears the day. |
| +func TestConsecutiveFailedRenewalsKeepTheFirstUnpaidDay(t *testing.T) { |
| + now := t0 |
| + d := newStripeDouble(t) |
| + rec := &recorder{} |
| + s, db := d.service(t, &now, rec.config()) |
| + seedPlans(t, s) |
| + ctx := context.Background() |
| + sync := func(when string) { |
| + t.Helper() |
| + now = d.Now() |
| + if err := s.Sync(ctx, d.sub(t, "sub_1"), abono.Hint{Subject: "ana"}); err != nil { |
| + t.Fatalf("%s: Sync: %v", when, err) |
| + } |
| + } |
| + |
| + // A monthly member, paid until 22 November. |
| + paidUntil := t0.AddDate(0, 1, 0) |
| + d.put(t, subSpec{ID: "sub_1", Status: "active", Price: "price_M", Interval: "month", Customer: "cus_1", |
| + Email: "ana@example.com", Start: t0, End: paidUntil}) |
| + sync("paid") |
| + |
| + // The November renewal fails: 30 days from 22 November. |
| + d.FailNextRenewal("sub_1") |
| + d.Advance(paidUntil.Sub(d.Now())) |
| + sync("the first failed renewal") |
| + if !hasPlan(t, s, "ana", "monthly") { |
| + t.Fatal("past_due on its first unpaid day does not grant") |
| + } |
| + |
| + // The December renewal fails too. Stripe's period now starts on 22 |
| + // December, which is also the day the first 30 days ran out. |
| + d.Advance(paidUntil.AddDate(0, 1, 0).Sub(d.Now())) |
| + sync("the second failed renewal") |
| + if sub := d.sub(t, "sub_1"); sub.Status != "past_due" || !sub.PeriodStart().Equal(paidUntil.AddDate(0, 1, 0)) { |
| + t.Fatalf("the double did not invoice again: %s from %v", sub.Status, sub.PeriodStart()) |
| + } |
| + if hasPlan(t, s, "ana", "monthly") { |
| + t.Error("a second failed renewal restarted the 30 days: past_due still grants a month after the first unpaid day") |
| + } |
| + hs, err := s.Holdings(ctx, "ana") |
| + if err != nil || len(hs) != 1 { |
| + t.Fatalf("Holdings = %+v, %v", hs, err) |
| + } |
| + if got := abono.Describe(hs[0], paidUntil.Add(29*day)).Date; got == nil || !got.Equal(paidUntil.Add(30*day)) { |
| + t.Errorf("the failing date = %v, want 30 days from the first unpaid day, %v", got, paidUntil.Add(30*day)) |
| + } |
| + if got, want := db.dump(`SELECT unpaid_since FROM abono_subscriptions`), paidUntil.Format("2006-01-02 15:04:05")+" +0000 UTC\n"; got != want { |
| + t.Errorf("unpaid_since = %q, want %q", got, want) |
| + } |
| + |
| + // Paid at last: active again, and the unpaid day is forgotten, so a |
| + // later failure starts its own 30 days. |
| + back := paidUntil.AddDate(0, 1, 0) |
| + d.put(t, subSpec{ID: "sub_1", Status: "active", Price: "price_M", Interval: "month", Customer: "cus_1", |
| + Email: "ana@example.com", Start: back, End: back.AddDate(0, 1, 0)}) |
| + sync("paid again") |
| + if !hasPlan(t, s, "ana", "monthly") { |
| + t.Error("paid again does not grant") |
| + } |
| + if got := db.dump(`SELECT unpaid_since IS NULL FROM abono_subscriptions`); got != "1\n" { |
| + t.Errorf("unpaid_since after paying = %q, want cleared", got) |
| + } |
| + d.FailNextRenewal("sub_1") |
| + d.Advance(back.AddDate(0, 1, 0).Sub(d.Now())) |
| + sync("a later failed renewal") |
| + if !hasPlan(t, s, "ana", "monthly") { |
| + t.Error("a new failure after paying does not get its own 30 days") |
| + } |
| +} |