Give each Stripe call of a member's plan change a deadline of its own
#295. A member's plan change (a switch to another tier, Keep plan, Cancel plan) and the sweep that fires scheduled changes made their Stripe calls without a deadline, and the calls after the first ran on the request's context or none: a request that ended mid-change could leave a change made at Stripe and unrecorded in the console. Each move's first Stripe call, its read of the subscription, carries the request's context, and requestEnded checks the request again before the first write, so a request that ends before then changes nothing. Every later call carries a deadline of its own that the end of the request does not cut (stripeCallContext): 60 seconds for an upgrade's charge, 40 for the others, and 20 for the calls of the switch of a subscription paused for a grant, which resolves them before the member is answered. The record and the reconcile after the last Stripe call run within another 60 seconds (recordMove). Every Stripe write carries the idempotency key stripe-go adds, so its own retries cannot repeat a write. A write that gets no answer before its deadline is reported as failed and records nothing; Stripe's event for a write that did land brings the console in line. A downgrade's update of its subscription schedule is the exception, since no event records it: it carries a key the console chose, and when it got no answer (gotNoAnswer) it is sent once more with the same parameters and key (sendScheduleUpdate). An update Stripe answered, a 500 included, is not sent again, because Stripe replays its saved answer to the same key. Each move lists the subscription's active scheduled changes before its first Stripe call, and its record supersedes only those, so a later request's cancellation or downgrade recorded meanwhile is not erased. The sweep gives each cancel or price change 20 seconds inside its one-minute run, and when less than 30 seconds are left (20 per write plus a margin of 10) it stops and leaves the remaining rows to its next attempt. Ride-alongs, listed in the proposal: - The switch's price preview ends with the request. - currentPrices skips only a price with no mapping and returns every other read error; a failed mapping read was taken as "this price is not current", which decides Keep plan's refusal and the reconcile's rule 1. The Stripe mock records the idempotency key a call's params carry (Call.IdempotencyKey); docs/stripe.md and docs/testing.md say what the code does. No user-visible string changes. Break list: 53 records, 53 CAUGHT. Named without a record, with the reason: the downgrade's read of the current item before its first Stripe call, each move's call of recordMove, requestEnded and activeMoves (none is one replacement; the recordMove, recordContext, requestEnded and item 4.11 breaks stand for them), the reconcile's use of currentPrices' error (an ended context fails the reconcile at its first statement), and the resend test's guard row. Full suite on reset databases: 5608 passed, 16 skipped, none failed.
This commit is contained in:
+1
-1
@@ -204,7 +204,7 @@ Sync runs through a transactional outbox: the operator action enqueues `create_s
|
||||
|
||||
Every Stripe object the console reads carries a `livemode` field naming the environment it exists in, except `SubscriptionItem`, which has no such field: a subscription item's environment can only be taken from its parent subscription.
|
||||
|
||||
The console keeps the Stripe library's client defaults (stripe-go v81): a call times out after 80 seconds and is retried twice, a timeout included, so one call can run about four minutes unless the context it is given ends sooner. A member's request gives its handler 32 seconds (`requestTimeout`, `internal/server/server.go`), so a call made on the request's context ends with the request, and a call made without it runs on after the member has been answered.
|
||||
The console keeps the Stripe library's client defaults (stripe-go v81): a call times out after 80 seconds and is retried twice, a timeout included, so one call can run about four minutes unless the context it is given ends sooner. A member's request gives its handler 32 seconds (`requestTimeout`, `internal/server/server.go`). A plan change's first Stripe call, a read of the subscription, carries the request's context, and the change checks that context again before its first write, so a request that ends before then changes nothing. Every later call carries a deadline of its own that the end of the request does not cut, 60 seconds for an upgrade's charge and 40 for the others (20 for the paused switch's draft list, voids and clear, which its restore resolves before the member is answered), and the change then records what it did and reconciles within 60 seconds more. A member whose request runs out of time gets the timeout answer while the change goes on; the change, its record included, ends at most about four minutes after its first write. A write that got no answer, or that Stripe answered with a server error, reaches the console's records through Stripe's event, except a downgrade's update, which the console records only from Stripe's answer to its own call. The scheduled-change sweep gives each cancel or price change 20 seconds inside its one-minute attempt, and leaves the remaining due changes to its next attempt when less than 30 seconds is left.
|
||||
|
||||
## Plan changes
|
||||
|
||||
|
||||
+4
-2
@@ -198,8 +198,10 @@ deterministically and offline, with **no live Stripe account**.
|
||||
A call records the deadline its request's context carried
|
||||
(`Call.Deadline`) and whether that context was already done
|
||||
(`Call.ContextDone`); a call whose context was already done answers the
|
||||
context's error, as the SDK's HTTP client does. The mock never waits, so a
|
||||
test reads a deadline as recorded.
|
||||
context's error, as the SDK's HTTP client does. A call also records the
|
||||
idempotency key its params carried explicitly (`Call.IdempotencyKey`); a key
|
||||
stripe-go generates itself is not seen, because the mock stands in for the
|
||||
SDK's backend. The mock never waits, so a test reads a deadline as recorded.
|
||||
`stripetest.KeyMode` answers the mode of the key the SDK holds when it is
|
||||
called (`"test"` under `Install`); a test passes its result,
|
||||
`stripetest.KeyMode()`, where the console passes the mode boot derived.
|
||||
|
||||
@@ -31,11 +31,22 @@ func SweepCollectionDriftPaged(ctx context.Context, db *sql.DB, logger *slog.Log
|
||||
// The paused switch's deadlines, for the tests in fulfillment_test.
|
||||
const (
|
||||
PausedSwitchCallTimeout = pausedSwitchCallTimeout
|
||||
PausedSwitchChargeTimeout = pausedSwitchChargeTimeout
|
||||
PausedSwitchRestoreTimeout = pausedSwitchRestoreTimeout
|
||||
PausedSwitchBackstopDelay = pausedSwitchBackstopDelay
|
||||
)
|
||||
|
||||
// The sweep's write deadline, the margin it keeps for a row's other work and
|
||||
// its out-of-time error, for TestSweepWritesCarryTheirDeadline and
|
||||
// TestSweepLeavesARowItHasNoTimeToFire in fulfillment_test.
|
||||
const (
|
||||
SweepCallTimeout = sweepCallTimeout
|
||||
SweepRowMargin = sweepRowMargin
|
||||
)
|
||||
|
||||
// ErrSweepOutOfTime is the error the sweep returns when its attempt leaves no
|
||||
// room for another due row.
|
||||
var ErrSweepOutOfTime = errSweepOutOfTime
|
||||
|
||||
// SetRequestBackstop replaces the paused switch's backstop writer for the
|
||||
// test and restores it in t.Cleanup. A test that sets it does not call
|
||||
// t.Parallel: the variable is the package's.
|
||||
|
||||
@@ -31,10 +31,10 @@ import (
|
||||
// deadline of its own that outlives the request (stripeCallContext).
|
||||
//
|
||||
// - pausedSwitchCallTimeout bounds the draft list, the two voids and the
|
||||
// clear; pausedSwitchChargeTimeout bounds call 2, longer because a
|
||||
// deadline that cuts a slow but successful charge answers the generic
|
||||
// failure after the member was charged; pausedSwitchRestoreTimeout bounds
|
||||
// the restore, whose calls share it.
|
||||
// clear; PlanChangeChargeTimeout (plan_change.go) bounds call 2, longer
|
||||
// because a deadline that cuts a slow but successful charge answers the
|
||||
// generic failure after the member was charged;
|
||||
// pausedSwitchRestoreTimeout bounds the restore, whose calls share it.
|
||||
// - pausedSwitchBackstopDelay is longer than the request's bounded length
|
||||
// from the backstop's insert: the void 20 seconds, the clear 20, call 2
|
||||
// 60, the decline's void 20 and the restore 60, three minutes in all, so
|
||||
@@ -47,7 +47,6 @@ import (
|
||||
// poll and one outbox attempt (outboxAttemptTimeout, two minutes).
|
||||
const (
|
||||
pausedSwitchCallTimeout = 20 * time.Second
|
||||
pausedSwitchChargeTimeout = 60 * time.Second
|
||||
pausedSwitchRestoreTimeout = 60 * time.Second
|
||||
pausedSwitchBackstopDelay = 4 * time.Minute
|
||||
renewalFinalizeMargin = 10 * time.Minute
|
||||
@@ -67,24 +66,6 @@ func consolePaused(sub *stripe.Subscription) bool {
|
||||
return sub.PauseCollection != nil && sub.Metadata[collectionMarkerKey] == collectionMarkerValue
|
||||
}
|
||||
|
||||
// stripeCallContext is the context of one Stripe call the paused switch makes:
|
||||
// the request's values with a deadline of d, and not the request's
|
||||
// cancellation. A member who closes the page after the clear must not cut the
|
||||
// calls that put the pause back, and a call cut mid-flight is resolved by the
|
||||
// restore's fresh fetch.
|
||||
func stripeCallContext(ctx context.Context, d time.Duration) (context.Context, context.CancelFunc) {
|
||||
return context.WithTimeout(context.WithoutCancel(ctx), d)
|
||||
}
|
||||
|
||||
// callTimeoutParams returns void params that carry a stripeCallContext of d,
|
||||
// and its cancel.
|
||||
func callTimeoutParams(ctx context.Context, d time.Duration) (*stripe.InvoiceVoidInvoiceParams, context.CancelFunc) {
|
||||
cctx, cancel := stripeCallContext(ctx, d)
|
||||
params := &stripe.InvoiceVoidInvoiceParams{}
|
||||
params.Context = cctx
|
||||
return params, cancel
|
||||
}
|
||||
|
||||
// renewalFinalizesSoon reports whether one of the subscription's draft
|
||||
// invoices is a renewal that Stripe finalizes by now + renewalFinalizeMargin.
|
||||
// A renewal made while the subscription is paused is a draft for about an hour
|
||||
@@ -181,8 +162,9 @@ func restorePause(ctx context.Context, db *sql.DB, logger *slog.Logger, keyMode,
|
||||
// 6. call 1, the clear;
|
||||
// 7. call 2, upgradeNow's update, unchanged;
|
||||
// 8. its answer as upgradeNow reads it, an applied branch under
|
||||
// context.WithoutCancel.
|
||||
func pausedUpgrade(ctx context.Context, db *sql.DB, logger *slog.Logger, keyMode string, billingQ *billing.Queries, ls ladderSubscription, current *stripe.Subscription, targetStripePriceID string, prorationDate time.Time, reason string) error {
|
||||
// context.WithoutCancel, whose release and record carry the plan change's
|
||||
// deadlines (appliedUpgrade).
|
||||
func pausedUpgrade(ctx context.Context, db *sql.DB, logger *slog.Logger, keyMode string, billingQ *billing.Queries, ls ladderSubscription, before []billing.SubscriptionScheduledChange, current *stripe.Subscription, targetStripePriceID string, prorationDate time.Time, reason string) error {
|
||||
if prorationDate.Before(time.Unix(current.CurrentPeriodStart, 0)) {
|
||||
return ErrStalePreview
|
||||
}
|
||||
@@ -192,10 +174,7 @@ func pausedUpgrade(ctx context.Context, db *sql.DB, logger *slog.Logger, keyMode
|
||||
if err := requestBackstop(ctx, db, ls.coreSubID, time.Now().Add(pausedSwitchBackstopDelay)); err != nil {
|
||||
return fmt.Errorf("switch: write the collection backstop: %w", err)
|
||||
}
|
||||
voidParams, cancelVoid := callTimeoutParams(ctx, pausedSwitchCallTimeout)
|
||||
err := voidAbandonedUpgrade(current, voidParams)
|
||||
cancelVoid()
|
||||
if err != nil {
|
||||
if err := voidAbandonedUpgrade(ctx, current, pausedSwitchCallTimeout); err != nil {
|
||||
return fmt.Errorf("switch: void an abandoned upgrade's invoice: %w", err)
|
||||
}
|
||||
|
||||
@@ -209,17 +188,15 @@ func pausedUpgrade(ctx context.Context, db *sql.DB, logger *slog.Logger, keyMode
|
||||
if err := pausedSwitchClear(ctx, ls.stripeSubID); err != nil {
|
||||
return err
|
||||
}
|
||||
updated, err := pausedSwitchUpdate(ctx, ls, current, targetStripePriceID, prorationDate)
|
||||
updated, err := sendUpgrade(ctx, ls, current, targetStripePriceID, prorationDate)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
voidParams, cancelVoid = callTimeoutParams(ctx, pausedSwitchCallTimeout)
|
||||
defer cancelVoid()
|
||||
if err := upgradeAnswer(logger, ls, updated, voidParams); err != nil {
|
||||
if err := upgradeAnswer(ctx, logger, ls, updated, pausedSwitchCallTimeout); err != nil {
|
||||
return err
|
||||
}
|
||||
applied = true
|
||||
return appliedUpgrade(context.WithoutCancel(ctx), db, logger, keyMode, billingQ, ls, current, updated, reason)
|
||||
return appliedUpgrade(context.WithoutCancel(ctx), db, logger, keyMode, billingQ, ls, before, current, updated, reason)
|
||||
}
|
||||
|
||||
// pausedSwitchClear is call 1: the console's clear, under its own deadline.
|
||||
@@ -233,17 +210,3 @@ func pausedSwitchClear(ctx context.Context, stripeSubID string) error {
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// pausedSwitchUpdate is call 2: upgradeNow's update with nothing changed in
|
||||
// its form, under pausedSwitchChargeTimeout.
|
||||
func pausedSwitchUpdate(ctx context.Context, ls ladderSubscription, current *stripe.Subscription, targetStripePriceID string, prorationDate time.Time) (*stripe.Subscription, error) {
|
||||
cctx, cancel := stripeCallContext(ctx, pausedSwitchChargeTimeout)
|
||||
defer cancel()
|
||||
params := upgradeParams(ls, current, targetStripePriceID, prorationDate)
|
||||
params.Context = cctx
|
||||
updated, err := subscription.Update(ls.stripeSubID, params)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("switch: stripe upgrade subscription item: %w", err)
|
||||
}
|
||||
return updated, nil
|
||||
}
|
||||
|
||||
@@ -725,7 +725,7 @@ func checkDeadlines(t *testing.T, row string, sent []sentCall) {
|
||||
update, last := find(kind("update"))
|
||||
checkDeadline(t, row, "the draft list", list, fulfillment.PausedSwitchCallTimeout)
|
||||
checkDeadline(t, row, "the clear", clear, fulfillment.PausedSwitchCallTimeout)
|
||||
checkDeadline(t, row, "the update", update, fulfillment.PausedSwitchChargeTimeout)
|
||||
checkDeadline(t, row, "the update", update, fulfillment.PlanChangeChargeTimeout)
|
||||
if row == "declined" {
|
||||
void, i := find(kind("void"))
|
||||
checkDeadline(t, row, "the void", void, fulfillment.PausedSwitchCallTimeout)
|
||||
|
||||
@@ -110,6 +110,122 @@ var ErrRenewalFinalizing = errors.New("switch: a paused renewal is about to fina
|
||||
// a credit balance or a price that changed since.
|
||||
const PreviewValidFor = time.Hour
|
||||
|
||||
// A plan change's deadlines (design D1). The change's first Stripe call, a
|
||||
// read of the subscription, carries the request's context, and the change
|
||||
// checks it again before its first write (requestEnded), so a request that
|
||||
// ends before then stops the change before anything is written. Every later
|
||||
// call carries a deadline of its own and not the request's cancellation
|
||||
// (stripeCallContext): PlanChangeChargeTimeout for an upgrade's charge,
|
||||
// PlanChangeCallTimeout for every other call but the paused switch's own
|
||||
// (pausedSwitchCallTimeout, paused_switch.go). After its last Stripe write
|
||||
// the change records what it did and reconciles under
|
||||
// PlanChangeRecordTimeout (recordMove). Each is longer than the request's 32
|
||||
// seconds (requestTimeout in internal/server;
|
||||
// TestPlanChangeDeadlinesOutlastTheRequest), so a call cut by its deadline
|
||||
// ends after the member has had the timeout answer. The charge's is longer
|
||||
// still, because a deadline that cuts a slow but successful charge leaves
|
||||
// the member charged with no answer.
|
||||
const (
|
||||
PlanChangeCallTimeout = 40 * time.Second
|
||||
PlanChangeChargeTimeout = 60 * time.Second
|
||||
PlanChangeRecordTimeout = 60 * time.Second
|
||||
)
|
||||
|
||||
// stripeCallContext is the context of one Stripe call a plan change makes
|
||||
// after its first: the request's values with a deadline of d, and not the
|
||||
// request's cancellation. A member who closes the page, or a request that
|
||||
// runs out of time, must not cut a write or the record that follows it.
|
||||
func stripeCallContext(ctx context.Context, d time.Duration) (context.Context, context.CancelFunc) {
|
||||
return context.WithTimeout(context.WithoutCancel(ctx), d)
|
||||
}
|
||||
|
||||
// setCallContext sets on p a stripeCallContext of d and returns its cancel.
|
||||
// A write sent with params carries the idempotency key stripe-go adds and
|
||||
// reuses across its own retries.
|
||||
func setCallContext(ctx context.Context, p *stripe.Params, d time.Duration) context.CancelFunc {
|
||||
cctx, cancel := stripeCallContext(ctx, d)
|
||||
p.Context = cctx
|
||||
return cancel
|
||||
}
|
||||
|
||||
// recordContext is the context of a plan change's record (recordMove): the
|
||||
// request's values with PlanChangeRecordTimeout, and not its cancellation.
|
||||
func recordContext(ctx context.Context) (context.Context, context.CancelFunc) {
|
||||
return stripeCallContext(ctx, PlanChangeRecordTimeout)
|
||||
}
|
||||
|
||||
// requestEnded returns an error wrapping ctx's when the request has ended.
|
||||
// Each move calls it immediately before its first Stripe write, so a request
|
||||
// that ends after the first read answered stops the change.
|
||||
func requestEnded(ctx context.Context, move string) error {
|
||||
if err := ctx.Err(); err != nil {
|
||||
return fmt.Errorf("%s: the request ended before the change: %w", move, err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// activeMoves lists the subscription's active scheduled changes. Each move
|
||||
// lists them before its first Stripe call, on the request's context, and
|
||||
// passes them to recordMove, which supersedes those rows and no other.
|
||||
func activeMoves(ctx context.Context, billingQ *billing.Queries, ls ladderSubscription, move string) ([]billing.SubscriptionScheduledChange, error) {
|
||||
rows, err := billingQ.ListActiveScheduledChangesBySubscription(ctx, ls.coreSubID)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("%s: list scheduled changes: %w", move, err)
|
||||
}
|
||||
return rows, nil
|
||||
}
|
||||
|
||||
// supersedeBefore supersedes the scheduled changes in before one by one. A
|
||||
// row another path already closed is skipped.
|
||||
func supersedeBefore(ctx context.Context, billingQ *billing.Queries, before []billing.SubscriptionScheduledChange) error {
|
||||
for _, row := range before {
|
||||
if _, err := billingQ.SupersedeScheduledChange(ctx, row.ScheduledChangeID); err != nil && !errors.Is(err, sql.ErrNoRows) {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// recordMove is a plan change's record after its last Stripe write: it
|
||||
// supersedes the scheduled changes in before, the rows that were active when
|
||||
// the change began, and no row another request wrote since; records change
|
||||
// when it is set; and reconciles, all under recordContext, so neither a
|
||||
// member who closes the page nor a request that runs out of time leaves the
|
||||
// write at Stripe unrecorded. move prefixes its errors.
|
||||
func recordMove(ctx context.Context, db *sql.DB, logger *slog.Logger, keyMode string, billingQ *billing.Queries, move string, ls ladderSubscription, before []billing.SubscriptionScheduledChange, change *billing.CreateScheduledChangeParams, reason string) error {
|
||||
rctx, cancel := recordContext(ctx)
|
||||
defer cancel()
|
||||
if err := supersedeBefore(rctx, billingQ, before); err != nil {
|
||||
return fmt.Errorf("%s: supersede prior scheduled changes: %w", move, err)
|
||||
}
|
||||
if change != nil {
|
||||
if _, err := billingQ.CreateScheduledChange(rctx, *change); err != nil {
|
||||
return fmt.Errorf("%s: record the scheduled change: %w", move, err)
|
||||
}
|
||||
}
|
||||
return ReconcileSubscription(rctx, db, logger, keyMode, ls.stripeSubID, reason)
|
||||
}
|
||||
|
||||
// releaseSchedule releases the subscription schedule id under a
|
||||
// stripeCallContext of PlanChangeCallTimeout.
|
||||
func releaseSchedule(ctx context.Context, id string) error {
|
||||
params := &stripe.SubscriptionScheduleReleaseParams{}
|
||||
cancel := setCallContext(ctx, ¶ms.Params, PlanChangeCallTimeout)
|
||||
defer cancel()
|
||||
_, err := subscriptionschedule.Release(id, params)
|
||||
return err
|
||||
}
|
||||
|
||||
// setCancelAtPeriodEnd sets the subscription's cancel_at_period_end to on
|
||||
// under a stripeCallContext of PlanChangeCallTimeout.
|
||||
func setCancelAtPeriodEnd(ctx context.Context, stripeSubID string, on bool) error {
|
||||
params := &stripe.SubscriptionParams{CancelAtPeriodEnd: stripe.Bool(on)}
|
||||
cancel := setCallContext(ctx, ¶ms.Params, PlanChangeCallTimeout)
|
||||
defer cancel()
|
||||
_, err := subscription.Update(stripeSubID, params)
|
||||
return err
|
||||
}
|
||||
|
||||
// UpgradeNeedsApprovalError is returned when an upgrade's payment needs the
|
||||
// cardholder's approval (3-D Secure). The subscription carries a pending
|
||||
// update that applies once the member pays the invoice on Stripe's hosted
|
||||
@@ -197,7 +313,10 @@ func logUnreachableMapping(logger *slog.Logger, msg, rowID, stripeID string, liv
|
||||
// before the target price, the mappings or the commitment are read, so
|
||||
// neither a Stripe call nor a deferred scheduled change is made. keyMode is
|
||||
// the mode of the key the process runs under; it is checked before the
|
||||
// Stripe call and handed to the reconcile that follows it.
|
||||
// Stripe call and handed to the reconcile that follows it. The first Stripe
|
||||
// call carries ctx, and every later call and the record carry the deadlines
|
||||
// of the plan change (PlanChangeCallTimeout), so the change finishes and is
|
||||
// recorded when ctx ends after its first write.
|
||||
func SwitchPlan(ctx context.Context, db *sql.DB, logger *slog.Logger, keyMode string, orgID, ladderID, targetPriceID string, prorationDate time.Time, reason string) error {
|
||||
if logger == nil {
|
||||
logger = slog.Default()
|
||||
@@ -311,24 +430,31 @@ func upgradeNow(ctx context.Context, db *sql.DB, logger *slog.Logger, keyMode st
|
||||
return ErrStalePreview
|
||||
}
|
||||
|
||||
current, err := subscription.Get(ls.stripeSubID, &stripe.SubscriptionParams{Expand: []*string{stripe.String("latest_invoice")}})
|
||||
before, err := activeMoves(ctx, billingQ, ls, "switch")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
current, err := subscription.Get(ls.stripeSubID, &stripe.SubscriptionParams{Params: stripe.Params{Context: ctx}, Expand: []*string{stripe.String("latest_invoice")}})
|
||||
if err != nil {
|
||||
return fmt.Errorf("switch: read subscription before upgrade: %w", err)
|
||||
}
|
||||
if consolePaused(current) {
|
||||
return pausedUpgrade(ctx, db, logger, keyMode, billingQ, ls, current, targetStripePriceID, prorationDate, reason)
|
||||
return pausedUpgrade(ctx, db, logger, keyMode, billingQ, ls, before, current, targetStripePriceID, prorationDate, reason)
|
||||
}
|
||||
if err := voidAbandonedUpgrade(current, nil); err != nil {
|
||||
return fmt.Errorf("switch: void an abandoned upgrade's invoice: %w", err)
|
||||
}
|
||||
updated, err := subscription.Update(ls.stripeSubID, upgradeParams(ls, current, targetStripePriceID, prorationDate))
|
||||
if err != nil {
|
||||
return fmt.Errorf("switch: stripe upgrade subscription item: %w", err)
|
||||
}
|
||||
if err := upgradeAnswer(logger, ls, updated, nil); err != nil {
|
||||
if err := requestEnded(ctx, "switch"); err != nil {
|
||||
return err
|
||||
}
|
||||
return appliedUpgrade(ctx, db, logger, keyMode, billingQ, ls, current, updated, reason)
|
||||
if err := voidAbandonedUpgrade(ctx, current, PlanChangeCallTimeout); err != nil {
|
||||
return fmt.Errorf("switch: void an abandoned upgrade's invoice: %w", err)
|
||||
}
|
||||
updated, err := sendUpgrade(ctx, ls, current, targetStripePriceID, prorationDate)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err := upgradeAnswer(ctx, logger, ls, updated, PlanChangeCallTimeout); err != nil {
|
||||
return err
|
||||
}
|
||||
return appliedUpgrade(ctx, db, logger, keyMode, billingQ, ls, before, current, updated, reason)
|
||||
}
|
||||
|
||||
// voidAbandonedUpgrade voids the invoice an earlier attempt the member
|
||||
@@ -336,16 +462,33 @@ func upgradeNow(ctx context.Context, db *sql.DB, logger *slog.Logger, keyMode st
|
||||
// invoice that stays payable; voiding it means paying it later cannot apply a
|
||||
// move the member has since replaced (Stripe accepts the new pending update
|
||||
// either way; sandbox Q4). current is fetched with latest_invoice expanded.
|
||||
// params carries the call's context, nil for none. The error is Stripe's, for
|
||||
// d is the void's deadline, a stripeCallContext. The error is Stripe's, for
|
||||
// the caller to name.
|
||||
func voidAbandonedUpgrade(current *stripe.Subscription, params *stripe.InvoiceVoidInvoiceParams) error {
|
||||
func voidAbandonedUpgrade(ctx context.Context, current *stripe.Subscription, d time.Duration) error {
|
||||
if current.PendingUpdate != nil && current.LatestInvoice != nil && current.LatestInvoice.Status == stripe.InvoiceStatusOpen {
|
||||
params := &stripe.InvoiceVoidInvoiceParams{}
|
||||
cancel := setCallContext(ctx, ¶ms.Params, d)
|
||||
defer cancel()
|
||||
_, err := invoice.VoidInvoice(current.LatestInvoice.ID, params)
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// sendUpgrade is the upgrade's one Stripe update, whichever form sends it
|
||||
// (upgradeNow, and pausedUpgrade's call 2), under a stripeCallContext of
|
||||
// PlanChangeChargeTimeout.
|
||||
func sendUpgrade(ctx context.Context, ls ladderSubscription, current *stripe.Subscription, targetStripePriceID string, prorationDate time.Time) (*stripe.Subscription, error) {
|
||||
params := upgradeParams(ls, current, targetStripePriceID, prorationDate)
|
||||
cancel := setCallContext(ctx, ¶ms.Params, PlanChangeChargeTimeout)
|
||||
defer cancel()
|
||||
updated, err := subscription.Update(ls.stripeSubID, params)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("switch: stripe upgrade subscription item: %w", err)
|
||||
}
|
||||
return updated, nil
|
||||
}
|
||||
|
||||
// upgradeParams is the upgrade's one Stripe update, whichever form sends it.
|
||||
func upgradeParams(ls ladderSubscription, current *stripe.Subscription, targetStripePriceID string, prorationDate time.Time) *stripe.SubscriptionParams {
|
||||
params := &stripe.SubscriptionParams{
|
||||
@@ -373,8 +516,8 @@ func upgradeParams(ls ladderSubscription, current *stripe.Subscription, targetSt
|
||||
// upgradeAnswer reads the update's answer: nil when it applied (no pending
|
||||
// update), UpgradeNeedsApprovalError when the payment needs the cardholder's
|
||||
// approval, otherwise the declined invoice is voided and ErrUpgradeDeclined
|
||||
// returned. voidParams carries the void's context, nil for none.
|
||||
func upgradeAnswer(logger *slog.Logger, ls ladderSubscription, updated *stripe.Subscription, voidParams *stripe.InvoiceVoidInvoiceParams) error {
|
||||
// returned. d is the void's deadline, a stripeCallContext.
|
||||
func upgradeAnswer(ctx context.Context, logger *slog.Logger, ls ladderSubscription, updated *stripe.Subscription, d time.Duration) error {
|
||||
if updated.PendingUpdate == nil {
|
||||
return nil
|
||||
}
|
||||
@@ -390,7 +533,10 @@ func upgradeAnswer(logger *slog.Logger, ls ladderSubscription, updated *stripe.S
|
||||
// retry of it can never apply the move (sandbox Q3: voiding clears
|
||||
// the pending update at once).
|
||||
if inv != nil {
|
||||
if _, verr := invoice.VoidInvoice(inv.ID, voidParams); verr != nil {
|
||||
params := &stripe.InvoiceVoidInvoiceParams{}
|
||||
cancel := setCallContext(ctx, ¶ms.Params, d)
|
||||
defer cancel()
|
||||
if _, verr := invoice.VoidInvoice(inv.ID, params); verr != nil {
|
||||
logger.Warn("switch: void a declined upgrade's invoice",
|
||||
slog.String("stripe_subscription_id", ls.stripeSubID), slog.Any("error", verr))
|
||||
}
|
||||
@@ -403,22 +549,20 @@ func upgradeAnswer(logger *slog.Logger, ls ladderSubscription, updated *stripe.S
|
||||
// so its schedule goes; a failure here is left to reconcile's rule 4, which
|
||||
// releases it on the next event or sweep. Then reconcile updates the core
|
||||
// item in place and supersedes the ladder attachment to the new tier via the
|
||||
// transition primitive.
|
||||
func appliedUpgrade(ctx context.Context, db *sql.DB, logger *slog.Logger, keyMode string, billingQ *billing.Queries, ls ladderSubscription, current, updated *stripe.Subscription, reason string) error {
|
||||
// transition primitive. The release and the record carry the plan change's
|
||||
// deadlines (releaseSchedule, recordMove), so they finish when ctx ends.
|
||||
func appliedUpgrade(ctx context.Context, db *sql.DB, logger *slog.Logger, keyMode string, billingQ *billing.Queries, ls ladderSubscription, before []billing.SubscriptionScheduledChange, current, updated *stripe.Subscription, reason string) error {
|
||||
if updated.Schedule != nil || current.Schedule != nil {
|
||||
id := current.Schedule
|
||||
if updated.Schedule != nil {
|
||||
id = updated.Schedule
|
||||
}
|
||||
if _, err := subscriptionschedule.Release(id.ID, nil); err != nil {
|
||||
if err := releaseSchedule(ctx, id.ID); err != nil {
|
||||
logger.Warn("switch: release a scheduled downgrade after an upgrade",
|
||||
slog.String("stripe_subscription_id", ls.stripeSubID), slog.Any("error", err))
|
||||
}
|
||||
}
|
||||
if err := billingQ.SupersedeActiveScheduledChanges(ctx, ls.coreSubID); err != nil {
|
||||
return fmt.Errorf("switch: supersede prior scheduled changes: %w", err)
|
||||
}
|
||||
return ReconcileSubscription(ctx, db, logger, keyMode, ls.stripeSubID, reason)
|
||||
return recordMove(ctx, db, logger, keyMode, billingQ, "switch", ls, before, nil, reason)
|
||||
}
|
||||
|
||||
// downgradeAtRenewal attaches a two-phase subscription schedule
|
||||
@@ -428,24 +572,42 @@ func appliedUpgrade(ctx context.Context, db *sql.DB, logger *slog.Logger, keyMod
|
||||
// cancellation armed at period end is cleared first, so the downgrade
|
||||
// replaces it; an existing schedule (an earlier downgrade) is rewritten.
|
||||
func downgradeAtRenewal(ctx context.Context, db *sql.DB, logger *slog.Logger, keyMode string, billingQ *billing.Queries, ls ladderSubscription, targetPriceID, targetStripePriceID, reason string) error {
|
||||
current, err := subscription.Get(ls.stripeSubID, nil)
|
||||
before, err := activeMoves(ctx, billingQ, ls, "switch")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
// The current item is read before any Stripe call: its console price goes
|
||||
// into the schedule's metadata, and a read after the schedule's creation
|
||||
// could fail after a write.
|
||||
item, err := billingQ.GetSubscriptionItemByID(ctx, ls.coreItemID)
|
||||
if err != nil {
|
||||
return fmt.Errorf("switch: read the current item: %w", err)
|
||||
}
|
||||
current, err := subscription.Get(ls.stripeSubID, &stripe.SubscriptionParams{Params: stripe.Params{Context: ctx}})
|
||||
if err != nil {
|
||||
return fmt.Errorf("switch: read subscription before downgrade: %w", err)
|
||||
}
|
||||
if current.Items == nil || len(current.Items.Data) == 0 || current.Items.Data[0].Price == nil {
|
||||
return fmt.Errorf("switch: subscription %s has no priced item", ls.stripeSubID)
|
||||
}
|
||||
if err := requestEnded(ctx, "switch"); err != nil {
|
||||
return err
|
||||
}
|
||||
if current.CancelAtPeriodEnd && current.Schedule == nil {
|
||||
if _, err := subscription.Update(ls.stripeSubID, &stripe.SubscriptionParams{CancelAtPeriodEnd: stripe.Bool(false)}); err != nil {
|
||||
if err := setCancelAtPeriodEnd(ctx, ls.stripeSubID, false); err != nil {
|
||||
return fmt.Errorf("switch: clear a scheduled cancellation before downgrade: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
var sch *stripe.SubscriptionSchedule
|
||||
open := &stripe.SubscriptionScheduleParams{}
|
||||
cancelOpen := setCallContext(ctx, &open.Params, PlanChangeCallTimeout)
|
||||
defer cancelOpen()
|
||||
if current.Schedule != nil {
|
||||
sch, err = subscriptionschedule.Get(current.Schedule.ID, nil)
|
||||
sch, err = subscriptionschedule.Get(current.Schedule.ID, open)
|
||||
} else {
|
||||
sch, err = subscriptionschedule.New(&stripe.SubscriptionScheduleParams{FromSubscription: stripe.String(ls.stripeSubID)})
|
||||
open.FromSubscription = stripe.String(ls.stripeSubID)
|
||||
sch, err = subscriptionschedule.New(open)
|
||||
}
|
||||
if err != nil {
|
||||
return fmt.Errorf("switch: open a subscription schedule: %w", err)
|
||||
@@ -457,10 +619,6 @@ func downgradeAtRenewal(ctx context.Context, db *sql.DB, logger *slog.Logger, ke
|
||||
start = sch.Phases[0].StartDate
|
||||
}
|
||||
|
||||
item, err := billingQ.GetSubscriptionItemByID(ctx, ls.coreItemID)
|
||||
if err != nil {
|
||||
return fmt.Errorf("switch: read the current item: %w", err)
|
||||
}
|
||||
cur := current.Items.Data[0]
|
||||
quantity := cur.Quantity
|
||||
if quantity <= 0 {
|
||||
@@ -485,25 +643,54 @@ func downgradeAtRenewal(ctx context.Context, db *sql.DB, logger *slog.Logger, ke
|
||||
},
|
||||
}
|
||||
update.AddMetadata(scheduledFromPriceKey, item.PriceID)
|
||||
if _, err := subscriptionschedule.Update(sch.ID, update); err != nil {
|
||||
if err := sendScheduleUpdate(ctx, logger, ls.stripeSubID, sch.ID, update); err != nil {
|
||||
return fmt.Errorf("switch: schedule the downgrade: %w", err)
|
||||
}
|
||||
|
||||
if err := billingQ.SupersedeActiveScheduledChanges(ctx, ls.coreSubID); err != nil {
|
||||
return fmt.Errorf("switch: supersede prior scheduled changes: %w", err)
|
||||
}
|
||||
if _, err := billingQ.CreateScheduledChange(ctx, billing.CreateScheduledChangeParams{
|
||||
logger.Info("switch: downgrade scheduled for renewal",
|
||||
slog.String("subscription_id", ls.coreSubID), slog.Time("effective_at", time.Unix(current.CurrentPeriodEnd, 0)))
|
||||
return recordMove(ctx, db, logger, keyMode, billingQ, "switch", ls, before, &billing.CreateScheduledChangeParams{
|
||||
SubscriptionID: ls.coreSubID,
|
||||
ChangeType: "plan_switch",
|
||||
TargetPriceID: uuid.NullUUID{UUID: uuid.MustParse(targetPriceID), Valid: true},
|
||||
EffectiveAt: time.Unix(current.CurrentPeriodEnd, 0),
|
||||
EffectiveTrigger: "period_end",
|
||||
}); err != nil {
|
||||
return fmt.Errorf("switch: record the scheduled downgrade: %w", err)
|
||||
}, reason)
|
||||
}
|
||||
|
||||
// gotNoAnswer reports whether a Stripe write that returned err got no
|
||||
// answer from Stripe (a deadline, a cancellation, a network fault) or an
|
||||
// answer that is not Stripe's error object, such as a gateway's error page,
|
||||
// so that it may or may not have taken effect. Stripe saves the answer it
|
||||
// gave under the request's idempotency key, a status of 500 or above
|
||||
// included, and returns it again to a send with that key, so only a write
|
||||
// that got no answer is worth sending again.
|
||||
func gotNoAnswer(err error) bool {
|
||||
var se *stripe.Error
|
||||
return !errors.As(err, &se)
|
||||
}
|
||||
|
||||
// sendScheduleUpdate sends the downgrade's update of the schedule scheduleID
|
||||
// under an idempotency key the change chooses and a stripeCallContext of
|
||||
// PlanChangeCallTimeout. When the update got no answer (gotNoAnswer), it
|
||||
// sends the same update once more with the same key under a fresh
|
||||
// stripeCallContext: Stripe answers a send that landed with its saved
|
||||
// answer, performs one that never arrived, and answers 409 to one it is
|
||||
// still executing. An update Stripe answered is not sent again.
|
||||
func sendScheduleUpdate(ctx context.Context, logger *slog.Logger, stripeSubID, scheduleID string, update *stripe.SubscriptionScheduleParams) error {
|
||||
update.IdempotencyKey = stripe.String(uuid.NewString())
|
||||
cancel := setCallContext(ctx, &update.Params, PlanChangeCallTimeout)
|
||||
_, err := subscriptionschedule.Update(scheduleID, update)
|
||||
cancel()
|
||||
if err == nil || !gotNoAnswer(err) {
|
||||
return err
|
||||
}
|
||||
logger.Info("switch: downgrade scheduled for renewal",
|
||||
slog.String("subscription_id", ls.coreSubID), slog.Time("effective_at", time.Unix(current.CurrentPeriodEnd, 0)))
|
||||
return ReconcileSubscription(ctx, db, logger, keyMode, ls.stripeSubID, reason)
|
||||
logger.Warn("switch: the schedule's update got no answer; sending it again with the same idempotency key",
|
||||
slog.String("stripe_subscription_id", stripeSubID), slog.Any("error", err))
|
||||
cancel = setCallContext(ctx, &update.Params, PlanChangeCallTimeout)
|
||||
defer cancel()
|
||||
_, err = subscriptionschedule.Update(scheduleID, update)
|
||||
return err
|
||||
}
|
||||
|
||||
// KeepCurrentPlan drops whatever move is scheduled on the member's live (active or
|
||||
@@ -517,7 +704,9 @@ func downgradeAtRenewal(ctx context.Context, db *sql.DB, logger *slog.Logger, ke
|
||||
// subscription with ErrSubscriptionPaused, and a suspended subscription whose
|
||||
// scheduled downgrade Stripe has already applied with ErrScheduledMoveApplied,
|
||||
// before it releases a schedule or supersedes a row; the active path is
|
||||
// unchanged.
|
||||
// unchanged. The first Stripe call carries ctx, and every later call and the
|
||||
// record carry the deadlines of the plan change (PlanChangeCallTimeout), so
|
||||
// the change finishes and is recorded when ctx ends after its first write.
|
||||
func KeepCurrentPlan(ctx context.Context, db *sql.DB, logger *slog.Logger, keyMode string, orgID, ladderID, reason string) error {
|
||||
if logger == nil {
|
||||
logger = slog.Default()
|
||||
@@ -538,30 +727,33 @@ func KeepCurrentPlan(ctx context.Context, db *sql.DB, logger *slog.Logger, keyMo
|
||||
return nil
|
||||
}
|
||||
|
||||
current, err := subscription.Get(ls.stripeSubID, &stripe.SubscriptionParams{Expand: []*string{stripe.String("schedule")}})
|
||||
current, err := subscription.Get(ls.stripeSubID, &stripe.SubscriptionParams{Params: stripe.Params{Context: ctx}, Expand: []*string{stripe.String("schedule")}})
|
||||
if err != nil {
|
||||
return fmt.Errorf("keep: read subscription: %w", err)
|
||||
}
|
||||
if ls.positionStatus == "suspended" {
|
||||
prices := currentPrices(ctx, internalstripe.New(db), current)
|
||||
prices, err := currentPrices(ctx, internalstripe.New(db), current)
|
||||
if err != nil {
|
||||
return fmt.Errorf("keep: read current prices: %w", err)
|
||||
}
|
||||
if scheduleIsSpent(current, prices) || downgradeLanded(active, prices) {
|
||||
return ErrScheduledMoveApplied
|
||||
}
|
||||
}
|
||||
if err := requestEnded(ctx, "keep"); err != nil {
|
||||
return err
|
||||
}
|
||||
if current.Schedule != nil && current.Schedule.Metadata[scheduledFromPriceKey] != "" {
|
||||
if _, err := subscriptionschedule.Release(current.Schedule.ID, nil); err != nil {
|
||||
if err := releaseSchedule(ctx, current.Schedule.ID); err != nil {
|
||||
return fmt.Errorf("keep: release the scheduled downgrade: %w", err)
|
||||
}
|
||||
}
|
||||
if current.CancelAtPeriodEnd {
|
||||
if _, err := subscription.Update(ls.stripeSubID, &stripe.SubscriptionParams{CancelAtPeriodEnd: stripe.Bool(false)}); err != nil {
|
||||
if err := setCancelAtPeriodEnd(ctx, ls.stripeSubID, false); err != nil {
|
||||
return fmt.Errorf("keep: clear the scheduled cancellation: %w", err)
|
||||
}
|
||||
}
|
||||
if err := billingQ.SupersedeActiveScheduledChanges(ctx, ls.coreSubID); err != nil {
|
||||
return fmt.Errorf("keep: supersede scheduled changes: %w", err)
|
||||
}
|
||||
return ReconcileSubscription(ctx, db, logger, keyMode, ls.stripeSubID, reason)
|
||||
return recordMove(ctx, db, logger, keyMode, billingQ, "keep", ls, active, nil, reason)
|
||||
}
|
||||
|
||||
// ErrResumeUnavailable is Resume plan's refusal for every reason the console
|
||||
@@ -652,7 +844,7 @@ type SwitchPreview struct {
|
||||
// moment of the request; it is best-effort, so any failure returns an
|
||||
// unavailable preview (no error) with ProrationDate still set, and the
|
||||
// switch proceeds without a figure. A downgrade's answer comes from the
|
||||
// console's records without a Stripe call.
|
||||
// console's records without a Stripe call. The invoice preview carries ctx.
|
||||
func PreviewSwitch(ctx context.Context, db *sql.DB, logger *slog.Logger, orgID, ladderID, targetPriceID string) SwitchPreview {
|
||||
if logger == nil {
|
||||
logger = slog.Default()
|
||||
@@ -706,7 +898,7 @@ func PreviewSwitch(ctx context.Context, db *sql.DB, logger *slog.Logger, orgID,
|
||||
if err != nil || !priceMapping.StripePriceID.Valid {
|
||||
return unavailable
|
||||
}
|
||||
inv, err := invoice.CreatePreview(&stripe.InvoiceCreatePreviewParams{
|
||||
previewParams := &stripe.InvoiceCreatePreviewParams{
|
||||
Subscription: stripe.String(ls.stripeSubID),
|
||||
SubscriptionDetails: &stripe.InvoiceCreatePreviewSubscriptionDetailsParams{
|
||||
Items: []*stripe.InvoiceCreatePreviewSubscriptionDetailsItemParams{{
|
||||
@@ -716,7 +908,9 @@ func PreviewSwitch(ctx context.Context, db *sql.DB, logger *slog.Logger, orgID,
|
||||
ProrationBehavior: stripe.String("always_invoice"),
|
||||
ProrationDate: stripe.Int64(moment.Unix()),
|
||||
},
|
||||
})
|
||||
}
|
||||
previewParams.Context = ctx
|
||||
inv, err := invoice.CreatePreview(previewParams)
|
||||
if err != nil || inv == nil {
|
||||
logger.Debug("preview: stripe create invoice preview", slog.Any("error", err))
|
||||
return unavailable
|
||||
@@ -741,7 +935,10 @@ func PreviewSwitch(ctx context.Context, db *sql.DB, logger *slog.Logger, orgID,
|
||||
// makes a Stripe call. For a subscription that pays without holding its
|
||||
// ladder it first voids the open invoice of a switch the member left awaiting
|
||||
// approval, so paying that invoice later cannot apply the switch with the
|
||||
// cancellation set (D11).
|
||||
// cancellation set (D11). The first Stripe call carries ctx, and every later
|
||||
// call and the record carry the deadlines of the plan change
|
||||
// (PlanChangeCallTimeout), so the change finishes and is recorded when ctx
|
||||
// ends after its first write.
|
||||
func CancelSubscription(ctx context.Context, db *sql.DB, logger *slog.Logger, keyMode string, orgID, ladderID string, timing CancelTiming, reason string) error {
|
||||
if logger == nil {
|
||||
logger = slog.Default()
|
||||
@@ -765,6 +962,10 @@ func CancelSubscription(ctx context.Context, db *sql.DB, logger *slog.Logger, ke
|
||||
return derr // fee policy -> refuse
|
||||
}
|
||||
|
||||
before, err := activeMoves(ctx, billingQ, ls, "cancel")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
// A subscription that pays without holding its ladder may carry a switch
|
||||
// the member left awaiting approval: a pending update and an open invoice.
|
||||
// Stripe keeps the pending update through cancel_at_period_end, and
|
||||
@@ -775,11 +976,14 @@ func CancelSubscription(ctx context.Context, db *sql.DB, logger *slog.Logger, ke
|
||||
// ladder keeps today's form (D11).
|
||||
var current *stripe.Subscription
|
||||
if !ls.holdsLadder {
|
||||
current, err = subscription.Get(ls.stripeSubID, &stripe.SubscriptionParams{Expand: []*string{stripe.String("latest_invoice")}})
|
||||
current, err = subscription.Get(ls.stripeSubID, &stripe.SubscriptionParams{Params: stripe.Params{Context: ctx}, Expand: []*string{stripe.String("latest_invoice")}})
|
||||
if err != nil {
|
||||
return fmt.Errorf("cancel: read subscription: %w", err)
|
||||
}
|
||||
if err := voidAbandonedUpgrade(current, nil); err != nil {
|
||||
if err := requestEnded(ctx, "cancel"); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := voidAbandonedUpgrade(ctx, current, PlanChangeCallTimeout); err != nil {
|
||||
return fmt.Errorf("cancel: void an abandoned switch's invoice: %w", err)
|
||||
}
|
||||
}
|
||||
@@ -792,26 +996,25 @@ func CancelSubscription(ctx context.Context, db *sql.DB, logger *slog.Logger, ke
|
||||
// attached (plan-switch-timing sandbox Q2): the cancellation replaces
|
||||
// the downgrade, so the schedule goes first.
|
||||
if current == nil {
|
||||
current, err = subscription.Get(ls.stripeSubID, nil)
|
||||
if err != nil {
|
||||
return fmt.Errorf("cancel: read subscription: %w", err)
|
||||
if current, err = readBeforeCancel(ctx, ls.stripeSubID); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
if current.Schedule != nil {
|
||||
if _, err := subscriptionschedule.Release(current.Schedule.ID, nil); err != nil {
|
||||
if err := releaseSchedule(ctx, current.Schedule.ID); err != nil {
|
||||
return fmt.Errorf("cancel: release a scheduled downgrade: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
switch timing {
|
||||
case CancelImmediate:
|
||||
if _, err := subscription.Cancel(ls.stripeSubID, nil); err != nil {
|
||||
params := &stripe.SubscriptionCancelParams{}
|
||||
cancelCall := setCallContext(ctx, ¶ms.Params, PlanChangeCallTimeout)
|
||||
defer cancelCall()
|
||||
if _, err := subscription.Cancel(ls.stripeSubID, params); err != nil {
|
||||
return fmt.Errorf("cancel: stripe cancel subscription: %w", err)
|
||||
}
|
||||
if err := billingQ.SupersedeActiveScheduledChanges(ctx, ls.coreSubID); err != nil {
|
||||
return fmt.Errorf("cancel: supersede prior scheduled changes: %w", err)
|
||||
}
|
||||
return ReconcileSubscription(ctx, db, logger, keyMode, ls.stripeSubID, reason)
|
||||
return recordMove(ctx, db, logger, keyMode, billingQ, "cancel", ls, before, nil, reason)
|
||||
|
||||
default: // CancelAtPeriodEnd
|
||||
if !ls.sub.CurrentPeriodEnd.Valid {
|
||||
@@ -821,20 +1024,31 @@ func CancelSubscription(ctx context.Context, db *sql.DB, logger *slog.Logger, ke
|
||||
// cancellation row whose flag Stripe does not carry
|
||||
// (plan-switch-timing D4 rule 3), so a row recorded before the flag
|
||||
// could be superseded by a reconcile that ran in between.
|
||||
if _, err := subscription.Update(ls.stripeSubID, &stripe.SubscriptionParams{
|
||||
CancelAtPeriodEnd: stripe.Bool(true),
|
||||
}); err != nil {
|
||||
if err := setCancelAtPeriodEnd(ctx, ls.stripeSubID, true); err != nil {
|
||||
return fmt.Errorf("cancel: stripe set cancel_at_period_end: %w", err)
|
||||
}
|
||||
if err := scheduleCancellation(ctx, billingQ, ls.coreSubID, ls.sub.CurrentPeriodEnd.Time, "period_end"); err != nil {
|
||||
return err
|
||||
}
|
||||
// Reconcile syncs the local cancel_at_period_end flag; status stays active
|
||||
// until the period closes, when the firing path ends the provision.
|
||||
return ReconcileSubscription(ctx, db, logger, keyMode, ls.stripeSubID, reason)
|
||||
return recordMove(ctx, db, logger, keyMode, billingQ, "cancel", ls, before, &billing.CreateScheduledChangeParams{
|
||||
SubscriptionID: ls.coreSubID,
|
||||
ChangeType: "cancellation",
|
||||
EffectiveAt: ls.sub.CurrentPeriodEnd.Time,
|
||||
EffectiveTrigger: "period_end",
|
||||
}, reason)
|
||||
}
|
||||
}
|
||||
|
||||
// readBeforeCancel is the first Stripe call of the cancellation of a
|
||||
// subscription that holds its ladder, a read on the request's context, and
|
||||
// the check after it that the request has not ended (requestEnded).
|
||||
func readBeforeCancel(ctx context.Context, stripeSubID string) (*stripe.Subscription, error) {
|
||||
current, err := subscription.Get(stripeSubID, &stripe.SubscriptionParams{Params: stripe.Params{Context: ctx}})
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("cancel: read subscription: %w", err)
|
||||
}
|
||||
return current, requestEnded(ctx, "cancel")
|
||||
}
|
||||
|
||||
// scheduleCancellation supersedes any pending scheduled change on the subscription
|
||||
// and records a new cancellation intent.
|
||||
func scheduleCancellation(ctx context.Context, billingQ *billing.Queries, coreSubID string, at time.Time, trigger string) error {
|
||||
|
||||
@@ -0,0 +1,715 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Commercial
|
||||
// SPDX-FileCopyrightText: 2025-2026 Christian Galo
|
||||
|
||||
package fulfillment_test
|
||||
|
||||
// plan_change_deadline_test.go pins openspec change
|
||||
// plan-change-calls-carry-their-own-deadlines: a plan change's first Stripe
|
||||
// call carries the request's context and the change checks it again before
|
||||
// its first write (D1), every later call and the record carry deadlines of
|
||||
// their own, the downgrade's update that got no answer is sent again with its
|
||||
// key (D2), and a late record supersedes only the rows that were active when
|
||||
// its change began. Each test runs the change under a request context whose
|
||||
// deadline is an hour away, a context the test cancels from the mock, or
|
||||
// context.Background() when only the answers matter, and reads what the mock
|
||||
// received.
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"errors"
|
||||
"net/url"
|
||||
"slices"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"git.coopcloud.tech/wiki-cafe/member-console/internal/billing"
|
||||
"git.coopcloud.tech/wiki-cafe/member-console/internal/fulfillment"
|
||||
"git.coopcloud.tech/wiki-cafe/member-console/internal/stripetest"
|
||||
stripe "github.com/stripe/stripe-go/v81"
|
||||
)
|
||||
|
||||
// planMove is a plan change a row runs: its fixture, the change, the error it
|
||||
// must return and a count of rows the row reads after it.
|
||||
type planMove struct {
|
||||
database *sql.DB
|
||||
fx fixture
|
||||
mock *stripetest.MockBackend
|
||||
run func(ctx context.Context) error
|
||||
wantErr error // nil unless the change must return one
|
||||
wantName string // wantErr's name in a failure text
|
||||
countName string
|
||||
count func() int
|
||||
}
|
||||
|
||||
// planCallName names a call a plan change makes, so a row lists the calls it
|
||||
// expects in words. A GET of a subscription that expands its prices is the
|
||||
// reconcile's fetch and any other GET of it is the change's read; a POST to a
|
||||
// subscription is the charge when it carries a price, the clear or the set by
|
||||
// its cancel_at_period_end. A call the names do not cover is its stripetest.Line.
|
||||
func planCallName(c stripetest.Call) string {
|
||||
switch {
|
||||
case strings.HasPrefix(c.Path, "/v1/subscriptions/"):
|
||||
switch {
|
||||
case c.Method == "GET":
|
||||
for _, vs := range c.Form {
|
||||
if slices.Contains(vs, "items.data.price") {
|
||||
return "the fetch"
|
||||
}
|
||||
}
|
||||
return "the read"
|
||||
case c.Method == "DELETE":
|
||||
return "the cancel"
|
||||
case c.Form.Get("items[0][price]") != "":
|
||||
return "the charge"
|
||||
case c.Form.Get("cancel_at_period_end") == "false":
|
||||
return "the clear"
|
||||
case c.Form.Get("cancel_at_period_end") == "true":
|
||||
return "the set"
|
||||
}
|
||||
case c.Method == "POST" && c.Path == "/v1/subscription_schedules":
|
||||
return "the creation"
|
||||
case c.Method == "GET" && strings.HasPrefix(c.Path, "/v1/subscription_schedules/"):
|
||||
return "the schedule's read"
|
||||
case c.Method == "POST" && strings.HasPrefix(c.Path, "/v1/subscription_schedules/") && strings.HasSuffix(c.Path, "/release"):
|
||||
return "the release"
|
||||
case c.Method == "POST" && strings.HasPrefix(c.Path, "/v1/subscription_schedules/"):
|
||||
return "the update"
|
||||
case c.Method == "POST" && strings.HasPrefix(c.Path, "/v1/invoices/") && strings.HasSuffix(c.Path, "/void"):
|
||||
return "the void"
|
||||
case c.Path == "/v1/invoices/create_preview":
|
||||
return "the preview"
|
||||
}
|
||||
return stripetest.Line(c)
|
||||
}
|
||||
|
||||
// planCallNames names each call of sent with planCallName.
|
||||
func planCallNames(sent []sentCall) []string {
|
||||
out := make([]string, 0, len(sent))
|
||||
for _, c := range sent {
|
||||
out = append(out, planCallName(c.Call))
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// collectCalls makes mock.OnCall collect each call with the moment the mock
|
||||
// received it, after the OnCall already set, and returns the collection.
|
||||
func collectCalls(mock *stripetest.MockBackend) *[]sentCall {
|
||||
var sent []sentCall
|
||||
prev := mock.OnCall
|
||||
mock.OnCall = func(c stripetest.Call) {
|
||||
if prev != nil {
|
||||
prev(c)
|
||||
}
|
||||
sent = append(sent, sentCall{c, time.Now()})
|
||||
}
|
||||
return &sent
|
||||
}
|
||||
|
||||
// noAnswer is the error stripe-go returns for a request that got no answer
|
||||
// before its deadline: a *url.Error wrapping context.DeadlineExceeded.
|
||||
func noAnswer(c stripetest.Call) error {
|
||||
return &url.Error{Op: c.Method, URL: "https://api.stripe.com" + c.Path, Err: context.DeadlineExceeded}
|
||||
}
|
||||
|
||||
// scheduleFrom is the schedule Stripe holds once the update c has landed: the
|
||||
// console's metadata and the two phases' prices as the form wrote them.
|
||||
func scheduleFrom(c stripetest.Call) *stripe.SubscriptionSchedule {
|
||||
phase := func(i string) *stripe.SubscriptionSchedulePhase {
|
||||
return &stripe.SubscriptionSchedulePhase{Items: []*stripe.SubscriptionSchedulePhaseItem{
|
||||
{Price: &stripe.Price{ID: c.Form.Get("phases[" + i + "][items][0][price]")}},
|
||||
}}
|
||||
}
|
||||
return &stripe.SubscriptionSchedule{
|
||||
ID: mockSchedule,
|
||||
Metadata: map[string]string{"scheduled_from_price_id": c.Form.Get("metadata[scheduled_from_price_id]")},
|
||||
Phases: []*stripe.SubscriptionSchedulePhase{phase("0"), phase("1")},
|
||||
}
|
||||
}
|
||||
|
||||
// pcMust fails the test when a setup step returned an error.
|
||||
func pcMust(t *testing.T, err error, what string) {
|
||||
t.Helper()
|
||||
if err != nil {
|
||||
t.Fatalf("%s: %v", what, err)
|
||||
}
|
||||
}
|
||||
|
||||
// pcSwitch is the switch of the fixture's member to the core price priceID,
|
||||
// prorated at the moment it runs when it is an upgrade.
|
||||
func pcSwitch(database *sql.DB, fx fixture, priceID string, upgrade bool) func(context.Context) error {
|
||||
return func(ctx context.Context) error {
|
||||
at := time.Time{}
|
||||
if upgrade {
|
||||
at = time.Now()
|
||||
}
|
||||
return fulfillment.SwitchPlan(ctx, database, discardLogger(), stripetest.KeyMode(), fx.orgID, fx.ladderID, priceID, at, "member:switch")
|
||||
}
|
||||
}
|
||||
|
||||
// pcKeep is Keep plan for the fixture's member.
|
||||
func pcKeep(database *sql.DB, fx fixture) func(context.Context) error {
|
||||
return func(ctx context.Context) error {
|
||||
return fulfillment.KeepCurrentPlan(ctx, database, discardLogger(), stripetest.KeyMode(), fx.orgID, fx.ladderID, "member:keep")
|
||||
}
|
||||
}
|
||||
|
||||
// pcCancel is Cancel plan for the fixture's member at timing.
|
||||
func pcCancel(database *sql.DB, fx fixture, timing fulfillment.CancelTiming) func(context.Context) error {
|
||||
return func(ctx context.Context) error {
|
||||
return fulfillment.CancelSubscription(ctx, database, discardLogger(), stripetest.KeyMode(), fx.orgID, fx.ladderID, timing, "member:cancel")
|
||||
}
|
||||
}
|
||||
|
||||
// pcRows counts the fixture's period-end scheduled-change rows of a type and
|
||||
// status when it is called.
|
||||
func pcRows(t *testing.T, database *sql.DB, fx fixture, changeType, status string) func() int {
|
||||
return func() int {
|
||||
return rowCount(t, database, coreSubID(t, database, fx.stripeSubID), changeType, "period_end", status)
|
||||
}
|
||||
}
|
||||
|
||||
// moveDowngrade is a downgrade of a member on the second tier to the first.
|
||||
func moveDowngrade(t *testing.T) planMove {
|
||||
t.Helper()
|
||||
database, fx, mock, _, _, _ := onTier2(t)
|
||||
return planMove{database: database, fx: fx, mock: mock, run: pcSwitch(database, fx, fx.priceID, false),
|
||||
countName: "scheduled downgrades", count: pcRows(t, database, fx, "plan_switch", "scheduled")}
|
||||
}
|
||||
|
||||
// moveDowngradeOverCancellation is moveDowngrade over a cancellation armed at
|
||||
// the period end.
|
||||
func moveDowngradeOverCancellation(t *testing.T) planMove {
|
||||
t.Helper()
|
||||
m := moveDowngrade(t)
|
||||
pcMust(t, pcCancel(m.database, m.fx, fulfillment.CancelAtPeriodEnd)(context.Background()), "arm a cancellation")
|
||||
return m
|
||||
}
|
||||
|
||||
// moveDowngradeOverDowngrade is moveDowngrade over the same downgrade already
|
||||
// scheduled.
|
||||
func moveDowngradeOverDowngrade(t *testing.T) planMove {
|
||||
t.Helper()
|
||||
m := moveDowngrade(t)
|
||||
pcMust(t, m.run(context.Background()), "schedule the first downgrade")
|
||||
return m
|
||||
}
|
||||
|
||||
// moveUpgrade is an upgrade of a member on the first tier to the second, which
|
||||
// the mock holds once it is paid.
|
||||
func moveUpgrade(t *testing.T) planMove {
|
||||
t.Helper()
|
||||
database, fx, mock, prod2, corePrice2, stripePrice2 := onTier1(t)
|
||||
mock.Sub = onTier(fx, stripePrice2)
|
||||
return planMove{database: database, fx: fx, mock: mock, run: pcSwitch(database, fx, corePrice2, true),
|
||||
countName: "attachments of the new tier", count: func() int { return activeAttachCount(t, database, fx.ladderID, prod2) }}
|
||||
}
|
||||
|
||||
// moveUpgradeOverDowngrade is an upgrade to a third tier over a scheduled
|
||||
// downgrade, whose schedule Stripe keeps attached.
|
||||
func moveUpgradeOverDowngrade(t *testing.T) planMove {
|
||||
t.Helper()
|
||||
database, fx, mock, _, _, _ := onTier2(t)
|
||||
pcMust(t, pcSwitch(database, fx, fx.priceID, false)(context.Background()), "schedule the downgrade")
|
||||
prod3, corePrice3, stripePrice3 := addTier(t, database, fx, "top", 6000)
|
||||
upgraded := onTier(fx, stripePrice3)
|
||||
upgraded.Schedule = mock.Sub.Schedule
|
||||
mock.Sub = upgraded
|
||||
return planMove{database: database, fx: fx, mock: mock, run: pcSwitch(database, fx, corePrice3, true),
|
||||
countName: "attachments of the new tier", count: func() int { return activeAttachCount(t, database, fx.ladderID, prod3) }}
|
||||
}
|
||||
|
||||
// moveAbandonedUpgrade is moveUpgrade over an earlier attempt's open invoice.
|
||||
func moveAbandonedUpgrade(t *testing.T) planMove {
|
||||
t.Helper()
|
||||
m := moveUpgrade(t)
|
||||
updated := m.mock.Sub
|
||||
m.mock.Sub = pendingSub(m.fx, "in_old", stripe.PaymentIntentStatusRequiresAction)
|
||||
m.mock.UpdateSub = updated
|
||||
return m
|
||||
}
|
||||
|
||||
// moveDeclinedUpgrade is an upgrade the card declines.
|
||||
func moveDeclinedUpgrade(t *testing.T) planMove {
|
||||
t.Helper()
|
||||
m := moveUpgrade(t)
|
||||
m.mock.Sub = m.fx.activeSub(m.fx.planItem())
|
||||
m.mock.UpdateSub = pendingSub(m.fx, "in_declined", stripe.PaymentIntentStatusRequiresPaymentMethod)
|
||||
m.wantErr, m.wantName = fulfillment.ErrUpgradeDeclined, "ErrUpgradeDeclined"
|
||||
return m
|
||||
}
|
||||
|
||||
// moveKeepDowngrade is Keep plan over a scheduled downgrade.
|
||||
func moveKeepDowngrade(t *testing.T) planMove {
|
||||
t.Helper()
|
||||
m := moveDowngrade(t)
|
||||
pcMust(t, m.run(context.Background()), "schedule the downgrade")
|
||||
m.run = pcKeep(m.database, m.fx)
|
||||
m.countName, m.count = "superseded downgrades", pcRows(t, m.database, m.fx, "plan_switch", "superseded")
|
||||
return m
|
||||
}
|
||||
|
||||
// moveKeepCancellation is Keep plan over a cancellation armed at the period
|
||||
// end.
|
||||
func moveKeepCancellation(t *testing.T) planMove {
|
||||
t.Helper()
|
||||
database, fx, mock, _, _, _ := onTier1(t)
|
||||
pcMust(t, pcCancel(database, fx, fulfillment.CancelAtPeriodEnd)(context.Background()), "arm a cancellation")
|
||||
return planMove{database: database, fx: fx, mock: mock, run: pcKeep(database, fx),
|
||||
countName: "scheduled cancellations", count: pcRows(t, database, fx, "cancellation", "scheduled")}
|
||||
}
|
||||
|
||||
// moveKeepSuspended is Keep plan on a suspended subscription whose scheduled
|
||||
// downgrade Stripe has landed (TestKeepRefusesADowngradeThatLanded).
|
||||
func moveKeepSuspended(t *testing.T) planMove {
|
||||
t.Helper()
|
||||
m := moveDowngrade(t)
|
||||
pcMust(t, m.run(context.Background()), "schedule the downgrade")
|
||||
landed := m.fx.activeSub(m.fx.planItem())
|
||||
landed.Schedule = m.mock.Sub.Schedule
|
||||
m.mock.Sub = landed
|
||||
holdAs(t, m.database, m.fx, m.mock, stripe.SubscriptionStatusPastDue)
|
||||
m.run = pcKeep(m.database, m.fx)
|
||||
return m
|
||||
}
|
||||
|
||||
// moveCancelPeriodEnd is Cancel plan at the period end of a member on the
|
||||
// first tier.
|
||||
func moveCancelPeriodEnd(t *testing.T) planMove {
|
||||
t.Helper()
|
||||
database, fx, mock, _, _, _ := onTier1(t)
|
||||
return planMove{database: database, fx: fx, mock: mock, run: pcCancel(database, fx, fulfillment.CancelAtPeriodEnd),
|
||||
countName: "scheduled cancellations", count: pcRows(t, database, fx, "cancellation", "scheduled")}
|
||||
}
|
||||
|
||||
// moveCancelOverDowngrade is Cancel plan at the period end over a scheduled
|
||||
// downgrade.
|
||||
func moveCancelOverDowngrade(t *testing.T) planMove {
|
||||
t.Helper()
|
||||
m := moveDowngrade(t)
|
||||
pcMust(t, m.run(context.Background()), "schedule the downgrade")
|
||||
m.run = pcCancel(m.database, m.fx, fulfillment.CancelAtPeriodEnd)
|
||||
m.countName, m.count = "scheduled cancellations", pcRows(t, m.database, m.fx, "cancellation", "scheduled")
|
||||
return m
|
||||
}
|
||||
|
||||
// moveCancelNow is an immediate Cancel plan.
|
||||
func moveCancelNow(t *testing.T) planMove {
|
||||
t.Helper()
|
||||
m := moveCancelPeriodEnd(t)
|
||||
m.run = pcCancel(m.database, m.fx, fulfillment.CancelImmediate)
|
||||
return m
|
||||
}
|
||||
|
||||
// moveCancelUnderGrant is Cancel plan at the period end of a subscription that
|
||||
// pays under a grant and carries an abandoned approval's open invoice.
|
||||
func moveCancelUnderGrant(t *testing.T) planMove {
|
||||
t.Helper()
|
||||
database, fx, mock := payingUnderGrant(t)
|
||||
abandoned := pendingSub(fx, "in_abandoned", stripe.PaymentIntentStatusRequiresAction)
|
||||
mock.Sub.PendingUpdate, mock.Sub.LatestInvoice = abandoned.PendingUpdate, abandoned.LatestInvoice
|
||||
return planMove{database: database, fx: fx, mock: mock, run: pcCancel(database, fx, fulfillment.CancelAtPeriodEnd),
|
||||
countName: "scheduled cancellations", count: pcRows(t, database, fx, "cancellation", "scheduled")}
|
||||
}
|
||||
|
||||
// movePreview is the preview of an upgrade, which must be available.
|
||||
func movePreview(t *testing.T) planMove {
|
||||
t.Helper()
|
||||
database, fx, mock, _, corePrice2, _ := onTier1(t)
|
||||
mock.Invoice = &stripe.Invoice{AmountDue: 900, Currency: stripe.CurrencyUSD}
|
||||
return planMove{database: database, fx: fx, mock: mock, run: func(ctx context.Context) error {
|
||||
if p := fulfillment.PreviewSwitch(ctx, database, discardLogger(), fx.orgID, fx.ladderID, corePrice2); !p.Available {
|
||||
t.Fatalf("the preview of an upgrade: the preview is unavailable")
|
||||
}
|
||||
return nil
|
||||
}}
|
||||
}
|
||||
|
||||
// deadlineWant is a call a row expects and the kind of deadline it carries:
|
||||
// "request", "call", "charge" or "record".
|
||||
type deadlineWant struct{ name, kind string }
|
||||
|
||||
// checkPlanChangeDeadlines checks that sent holds the calls want names, in
|
||||
// order, and that each carries the deadline of its kind; ctx is the request's.
|
||||
func checkPlanChangeDeadlines(t *testing.T, row string, ctx context.Context, sent []sentCall, want []deadlineWant) {
|
||||
t.Helper()
|
||||
var names []string
|
||||
for _, w := range want {
|
||||
names = append(names, w.name)
|
||||
}
|
||||
if got := planCallNames(sent); !slices.Equal(got, names) {
|
||||
t.Fatalf("%s: the change sent %v, want %v", row, got, names)
|
||||
}
|
||||
requestDeadline, _ := ctx.Deadline()
|
||||
for i, w := range want {
|
||||
switch w.kind {
|
||||
case "request":
|
||||
if !sent[i].Deadline.Equal(requestDeadline) {
|
||||
t.Errorf("%s: %s carried deadline %v, want the request's %v", row, w.name, sent[i].Deadline, requestDeadline)
|
||||
}
|
||||
case "call":
|
||||
checkDeadline(t, row, w.name, sent[i], fulfillment.PlanChangeCallTimeout)
|
||||
case "charge":
|
||||
checkDeadline(t, row, w.name, sent[i], fulfillment.PlanChangeChargeTimeout)
|
||||
case "record":
|
||||
checkDeadline(t, row, w.name, sent[i], fulfillment.PlanChangeRecordTimeout)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Every Stripe call of a plan change carries the deadline of its kind: the
|
||||
// first read the request's, the charge sixty seconds, the other writes forty
|
||||
// and the record's fetch sixty.
|
||||
func TestPlanChangeCallsCarryTheirDeadlines(t *testing.T) {
|
||||
rows := []struct {
|
||||
name string
|
||||
build func(*testing.T) planMove
|
||||
want []deadlineWant
|
||||
}{
|
||||
{"a downgrade", moveDowngrade, []deadlineWant{{"the read", "request"}, {"the creation", "call"}, {"the update", "call"}, {"the fetch", "record"}}},
|
||||
{"a downgrade over an armed cancellation", moveDowngradeOverCancellation, []deadlineWant{{"the read", "request"}, {"the clear", "call"}, {"the creation", "call"}, {"the update", "call"}, {"the fetch", "record"}}},
|
||||
{"a downgrade over an earlier downgrade", moveDowngradeOverDowngrade, []deadlineWant{{"the read", "request"}, {"the schedule's read", "call"}, {"the update", "call"}, {"the fetch", "record"}}},
|
||||
{"an upgrade", moveUpgrade, []deadlineWant{{"the read", "request"}, {"the charge", "charge"}, {"the fetch", "record"}}},
|
||||
{"an upgrade over a scheduled downgrade", moveUpgradeOverDowngrade, []deadlineWant{{"the read", "request"}, {"the charge", "charge"}, {"the release", "call"}, {"the fetch", "record"}}},
|
||||
{"an upgrade over an abandoned attempt", moveAbandonedUpgrade, []deadlineWant{{"the read", "request"}, {"the void", "call"}, {"the charge", "charge"}, {"the fetch", "record"}}},
|
||||
{"a declined upgrade", moveDeclinedUpgrade, []deadlineWant{{"the read", "request"}, {"the charge", "charge"}, {"the void", "call"}}},
|
||||
{"keep a downgrade", moveKeepDowngrade, []deadlineWant{{"the read", "request"}, {"the release", "call"}, {"the fetch", "record"}}},
|
||||
{"keep a cancellation", moveKeepCancellation, []deadlineWant{{"the read", "request"}, {"the clear", "call"}, {"the fetch", "record"}}},
|
||||
{"cancel at the period end over a downgrade", moveCancelOverDowngrade, []deadlineWant{{"the read", "request"}, {"the release", "call"}, {"the set", "call"}, {"the fetch", "record"}}},
|
||||
{"cancel now", moveCancelNow, []deadlineWant{{"the read", "request"}, {"the cancel", "call"}, {"the fetch", "record"}}},
|
||||
{"a cancellation under a grant", moveCancelUnderGrant, []deadlineWant{{"the read", "request"}, {"the void", "call"}, {"the set", "call"}, {"the fetch", "record"}}},
|
||||
{"the preview of an upgrade", movePreview, []deadlineWant{{"the preview", "request"}}},
|
||||
}
|
||||
for _, row := range rows {
|
||||
t.Run(row.name, func(t *testing.T) {
|
||||
m := row.build(t)
|
||||
sent := collectCalls(m.mock)
|
||||
ctx, cancel := context.WithTimeout(context.Background(), time.Hour)
|
||||
defer cancel()
|
||||
wantName := m.wantName
|
||||
if wantName == "" {
|
||||
wantName = "nil"
|
||||
}
|
||||
if err := m.run(ctx); !errors.Is(err, m.wantErr) {
|
||||
t.Fatalf("%s: the change returned %v, want %s", row.name, err, wantName)
|
||||
}
|
||||
checkPlanChangeDeadlines(t, row.name, ctx, *sent, row.want)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// A change whose first write has been sent finishes and records what it did
|
||||
// when the request ends there: the later calls do not carry the ended
|
||||
// context, and the change returns nil with its record in the database.
|
||||
func TestAPlanChangeOutlivesItsRequest(t *testing.T) {
|
||||
rows := []struct {
|
||||
name string
|
||||
build func(*testing.T) planMove
|
||||
endsAt string
|
||||
want int
|
||||
}{
|
||||
{"a downgrade", moveDowngrade, "the creation", 1},
|
||||
{"a cancellation at the period end", moveCancelPeriodEnd, "the set", 1},
|
||||
{"keep a downgrade", moveKeepDowngrade, "the release", 1},
|
||||
{"an upgrade", moveUpgrade, "the charge", 1},
|
||||
{"a cancellation under a grant", moveCancelUnderGrant, "the void", 1},
|
||||
}
|
||||
for _, row := range rows {
|
||||
t.Run(row.name, func(t *testing.T) {
|
||||
m := row.build(t)
|
||||
sent := collectCalls(m.mock)
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
defer cancel()
|
||||
inner := m.mock.Handle
|
||||
m.mock.Handle = func(c stripetest.Call) (any, error, bool) {
|
||||
if planCallName(c) == row.endsAt {
|
||||
cancel()
|
||||
}
|
||||
return inner(c)
|
||||
}
|
||||
if err := m.run(ctx); err != nil {
|
||||
t.Errorf("%s: after the request ended at %s, the change returned %v, want nil", row.name, row.endsAt, err)
|
||||
}
|
||||
if n := m.count(); n != row.want {
|
||||
t.Errorf("%s: after the request ended at %s, %s = %d, want %d", row.name, row.endsAt, m.countName, n, row.want)
|
||||
}
|
||||
at := slices.IndexFunc(*sent, func(c sentCall) bool { return planCallName(c.Call) == row.endsAt })
|
||||
if at < 0 {
|
||||
t.Fatalf("%s: the change never sent %s: %v", row.name, row.endsAt, planCallNames(*sent))
|
||||
}
|
||||
for _, c := range (*sent)[at+1:] {
|
||||
if c.ContextDone {
|
||||
t.Errorf("%s: %s was sent with the ended request's context", row.name, planCallName(c.Call))
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// The moves a request that ends before the change's first write stops.
|
||||
var firstReadMoves = []struct {
|
||||
name string
|
||||
build func(*testing.T) planMove
|
||||
}{
|
||||
{"a downgrade", moveDowngrade},
|
||||
{"an upgrade", moveUpgrade},
|
||||
{"keep a downgrade", moveKeepDowngrade},
|
||||
{"cancel at the period end", moveCancelPeriodEnd},
|
||||
}
|
||||
|
||||
// A request that ends while the change's first read is in flight stops the
|
||||
// change with the read's error, before anything is sent or written.
|
||||
func TestARequestThatEndsDuringTheFirstReadStopsTheChange(t *testing.T) {
|
||||
for _, row := range firstReadMoves {
|
||||
t.Run(row.name, func(t *testing.T) {
|
||||
m := row.build(t)
|
||||
sent := collectCalls(m.mock)
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
defer cancel()
|
||||
collected := m.mock.OnCall
|
||||
m.mock.OnCall = func(c stripetest.Call) {
|
||||
cancel()
|
||||
collected(c)
|
||||
}
|
||||
err := m.run(ctx)
|
||||
if !errors.Is(err, context.Canceled) {
|
||||
t.Errorf("%s: the request ended during the first read and the change returned %v, want context.Canceled", row.name, err)
|
||||
}
|
||||
if len(*sent) > 1 {
|
||||
t.Errorf("%s: the request ended during the first read and the change sent %v after it, want nothing", row.name, planCallNames((*sent)[1:]))
|
||||
}
|
||||
if n := m.count(); n != 0 {
|
||||
t.Errorf("%s: the request ended during the first read and %s = %d, want %d", row.name, m.countName, n, 0)
|
||||
}
|
||||
if err != nil && strings.Contains(err.Error(), "the request ended before the change") {
|
||||
t.Errorf("%s: the request ended during the first read and the change returned %v, want the read's error", row.name, err)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// A request that ends after the first read answered and before the first
|
||||
// write stops the change: nothing is sent or written after the read.
|
||||
func TestARequestThatEndsAfterTheFirstReadStopsTheChange(t *testing.T) {
|
||||
rows := []struct {
|
||||
name string
|
||||
build func(*testing.T) planMove
|
||||
want int
|
||||
inError string
|
||||
}{
|
||||
{"a downgrade", moveDowngrade, 0, ""},
|
||||
{"an upgrade", moveUpgrade, 0, ""},
|
||||
{"keep a downgrade", moveKeepDowngrade, 0, ""},
|
||||
{"cancel at the period end", moveCancelPeriodEnd, 0, ""},
|
||||
{"keep on a suspended subscription", moveKeepSuspended, 1, "keep: read current prices"},
|
||||
}
|
||||
for _, row := range rows {
|
||||
t.Run(row.name, func(t *testing.T) {
|
||||
m := row.build(t)
|
||||
sent := collectCalls(m.mock)
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
defer cancel()
|
||||
inner := m.mock.Handle
|
||||
m.mock.Handle = func(c stripetest.Call) (any, error, bool) {
|
||||
p, err, handled := inner(c)
|
||||
cancel()
|
||||
return p, err, handled
|
||||
}
|
||||
err := m.run(ctx)
|
||||
if !errors.Is(err, context.Canceled) {
|
||||
t.Errorf("%s: the request ended after the first read and the change returned %v, want context.Canceled", row.name, err)
|
||||
}
|
||||
if row.inError != "" && (err == nil || !strings.Contains(err.Error(), row.inError)) {
|
||||
t.Errorf("%s: the request ended after the first read and the change returned %v, want the error of the prices' read", row.name, err)
|
||||
}
|
||||
if len(*sent) > 1 {
|
||||
t.Errorf("%s: the request ended after the first read and the change sent %v after it, want nothing", row.name, planCallNames((*sent)[1:]))
|
||||
}
|
||||
if n := m.count(); n != row.want {
|
||||
t.Errorf("%s: the request ended after the first read and %s = %d, want %d", row.name, m.countName, n, row.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// resendAnswer is how a row's Stripe answers each send of the call the row
|
||||
// names: n counts the sends so far, inner is the behaviour of stripeLike, which
|
||||
// applies a write to the mock's state before the answer is chosen.
|
||||
type resendAnswer func(mock *stripetest.MockBackend, inner func(stripetest.Call) (any, error, bool), n int, c stripetest.Call) (any, error, bool)
|
||||
|
||||
// answerLanded is a first send that landed without an answer: the write is
|
||||
// applied and the schedule recorded, and the resend gets Stripe's saved
|
||||
// answer, which the mock gives by falling through.
|
||||
func answerLanded(mock *stripetest.MockBackend, inner func(stripetest.Call) (any, error, bool), n int, c stripetest.Call) (any, error, bool) {
|
||||
if n > 1 {
|
||||
return inner(c)
|
||||
}
|
||||
// stripeLike only applies the write to the mock and always falls through; the answer is chosen below.
|
||||
_, _, _ = inner(c)
|
||||
mock.Schedule = scheduleFrom(c)
|
||||
return nil, noAnswer(c), true
|
||||
}
|
||||
|
||||
// answerNeverArrived is a first send that got no answer and was not applied;
|
||||
// the resend is applied.
|
||||
func answerNeverArrived(_ *stripetest.MockBackend, inner func(stripetest.Call) (any, error, bool), n int, c stripetest.Call) (any, error, bool) {
|
||||
if n > 1 {
|
||||
return inner(c)
|
||||
}
|
||||
return nil, noAnswer(c), true
|
||||
}
|
||||
|
||||
// answerNone is every send getting no answer.
|
||||
func answerNone(_ *stripetest.MockBackend, _ func(stripetest.Call) (any, error, bool), _ int, c stripetest.Call) (any, error, bool) {
|
||||
return nil, noAnswer(c), true
|
||||
}
|
||||
|
||||
// answerServerError is every send applied and answered with a server error, as
|
||||
// Stripe replays a saved 500.
|
||||
func answerServerError(_ *stripetest.MockBackend, inner func(stripetest.Call) (any, error, bool), _ int, c stripetest.Call) (any, error, bool) {
|
||||
// stripeLike only applies the write to the mock and always falls through; the answer is chosen below.
|
||||
_, _, _ = inner(c)
|
||||
return nil, &stripe.Error{Type: stripe.ErrorTypeAPI, HTTPStatusCode: 500, Msg: "server error"}, true
|
||||
}
|
||||
|
||||
// answerRefused is every send refused with a 400.
|
||||
func answerRefused(_ *stripetest.MockBackend, _ func(stripetest.Call) (any, error, bool), _ int, _ stripetest.Call) (any, error, bool) {
|
||||
return nil, &stripe.Error{Type: stripe.ErrorTypeInvalidRequest, HTTPStatusCode: 400, Msg: "refused"}, true
|
||||
}
|
||||
|
||||
// isNil is the wanted error of a change that succeeds.
|
||||
func isNil(err error) bool { return err == nil }
|
||||
|
||||
// isDeadline is the wanted error of a write that got no answer.
|
||||
func isDeadline(err error) bool { return errors.Is(err, context.DeadlineExceeded) }
|
||||
|
||||
// isStripeStatus is the wanted error of a write Stripe answered with status.
|
||||
func isStripeStatus(status int) func(error) bool {
|
||||
return func(err error) bool {
|
||||
var se *stripe.Error
|
||||
return errors.As(err, &se) && se.HTTPStatusCode == status
|
||||
}
|
||||
}
|
||||
|
||||
// checkSends checks the calls a change sent after the first one named after:
|
||||
// their names, and, for a call sent twice, that both carry one key and the
|
||||
// resend a deadline of its own.
|
||||
func checkSends(t *testing.T, row string, sent []sentCall, after string, wantAfter []string, resent bool) {
|
||||
t.Helper()
|
||||
first := slices.IndexFunc(sent, func(c sentCall) bool { return planCallName(c.Call) == after })
|
||||
if first < 0 {
|
||||
t.Fatalf("%s: the change never sent %s: %v", row, after, planCallNames(sent))
|
||||
}
|
||||
if got := planCallNames(sent[first+1:]); !slices.Equal(got, wantAfter) {
|
||||
t.Errorf("%s: the change sent %v after %s, want %v", row, got, after, wantAfter)
|
||||
}
|
||||
if !resent || len(sent) <= first+1 {
|
||||
return
|
||||
}
|
||||
key := sent[first].IdempotencyKey
|
||||
if key == "" {
|
||||
t.Errorf("%s: the first send carried no idempotency key", row)
|
||||
}
|
||||
if again := sent[first+1]; again.IdempotencyKey != key {
|
||||
t.Errorf("%s: the resend's idempotency key is %q, want the first send's %q", row, again.IdempotencyKey, key)
|
||||
}
|
||||
checkDeadline(t, row, "the resend", sent[first+1], fulfillment.PlanChangeCallTimeout)
|
||||
}
|
||||
|
||||
// A downgrade's update that got no answer is sent again with its key when
|
||||
// that is the right thing to do, and not otherwise (D2).
|
||||
func TestADowngradeUpdateWithoutAnAnswerIsResent(t *testing.T) {
|
||||
rows := []struct {
|
||||
name string
|
||||
build func(*testing.T) planMove
|
||||
answer resendAnswer
|
||||
after string // the call the answer is for: "the update" or "the set"
|
||||
wantErr func(error) bool
|
||||
wantName string
|
||||
want int
|
||||
wantAfter []string // the calls sent after the first one the answer is for
|
||||
resent bool // the call is sent twice, with one key
|
||||
}{
|
||||
{"an update that landed", moveDowngrade, answerLanded, "the update", isNil, "nil", 1, []string{"the update", "the fetch"}, true},
|
||||
{"an update answered 500", moveDowngrade, answerServerError, "the update", isStripeStatus(500), "Stripe's server error", 0, nil, false},
|
||||
{"an update that did not land", moveDowngrade, answerNeverArrived, "the update", isNil, "nil", 1, []string{"the update", "the fetch"}, true},
|
||||
{"a resend that gets no answer", moveDowngrade, answerNone, "the update", isDeadline, "the resend's error", 0, []string{"the update"}, true},
|
||||
{"a refused update", moveDowngrade, answerRefused, "the update", isStripeStatus(400), "Stripe's refusal", 0, nil, false},
|
||||
{"a flag without an answer", moveCancelPeriodEnd, answerNone, "the set", isDeadline, "the flag's error", 0, nil, false},
|
||||
}
|
||||
for _, row := range rows {
|
||||
t.Run(row.name, func(t *testing.T) {
|
||||
m := row.build(t)
|
||||
sent := collectCalls(m.mock)
|
||||
inner := m.mock.Handle
|
||||
n := 0
|
||||
m.mock.Handle = func(c stripetest.Call) (any, error, bool) {
|
||||
if planCallName(c) != row.after {
|
||||
return inner(c)
|
||||
}
|
||||
n++
|
||||
return row.answer(m.mock, inner, n, c)
|
||||
}
|
||||
if err := m.run(context.Background()); !row.wantErr(err) {
|
||||
t.Errorf("%s: the change returned %v, want %s", row.name, err, row.wantName)
|
||||
}
|
||||
if got := m.count(); got != row.want {
|
||||
t.Errorf("%s: the change left %d %s, want %d", row.name, got, m.countName, row.want)
|
||||
}
|
||||
checkSends(t, row.name, *sent, row.after, row.wantAfter, row.resent)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// A change that records late supersedes the rows that were active when it
|
||||
// began and no row another request wrote since: a record is not another
|
||||
// request's later choice.
|
||||
func TestALateRecordKeepsAChangeAnotherRequestRecorded(t *testing.T) {
|
||||
deferred := func(t *testing.T, m planMove) {
|
||||
t.Helper()
|
||||
_, err := billing.New(m.database).CreateScheduledChange(context.Background(), billing.CreateScheduledChangeParams{
|
||||
SubscriptionID: coreSubID(t, m.database, m.fx.stripeSubID),
|
||||
ChangeType: "cancellation",
|
||||
EffectiveAt: time.Now().Add(24 * time.Hour),
|
||||
EffectiveTrigger: "term_boundary",
|
||||
})
|
||||
pcMust(t, err, "record a deferred cancellation")
|
||||
}
|
||||
rows := []struct {
|
||||
name string
|
||||
before bool // the row exists before the change begins
|
||||
wantRow int // the row's scheduled count after the change
|
||||
wantChange int
|
||||
}{
|
||||
// The row a request writes during the change stays; a row the change
|
||||
// found when it began is superseded by its record. The row is a
|
||||
// term_boundary one because the reconcile's rules leave such a row
|
||||
// alone, so only the record's own list can supersede it.
|
||||
{"a row another request wrote during the change", false, 1, 1},
|
||||
{"a row the change found when it began", true, 0, 1},
|
||||
}
|
||||
for _, row := range rows {
|
||||
t.Run(row.name, func(t *testing.T) {
|
||||
m := moveDowngrade(t)
|
||||
if row.before {
|
||||
deferred(t, m)
|
||||
}
|
||||
inner := m.mock.Handle
|
||||
m.mock.Handle = func(c stripetest.Call) (any, error, bool) {
|
||||
if planCallName(c) == "the creation" && !row.before {
|
||||
deferred(t, m)
|
||||
}
|
||||
return inner(c)
|
||||
}
|
||||
pcMust(t, m.run(context.Background()), "the downgrade")
|
||||
core := coreSubID(t, m.database, m.fx.stripeSubID)
|
||||
if n := rowCount(t, m.database, core, "cancellation", "term_boundary", "scheduled"); n != row.wantRow {
|
||||
t.Errorf("%s: the change left %d deferred cancellations scheduled, want %d", row.name, n, row.wantRow)
|
||||
}
|
||||
if n := m.count(); n != row.wantChange {
|
||||
t.Errorf("%s: the change left %d downgrades scheduled, want %d", row.name, n, row.wantChange)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -1163,7 +1163,10 @@ func convergeScheduledChanges(
|
||||
if err != nil {
|
||||
return spentSchedule{}, fmt.Errorf("reconcile: list scheduled changes: %w", err)
|
||||
}
|
||||
current := currentPrices(ctx, stripeQ, sub)
|
||||
current, err := currentPrices(ctx, stripeQ, sub)
|
||||
if err != nil {
|
||||
return spentSchedule{}, fmt.Errorf("reconcile: read the current prices: %w", err)
|
||||
}
|
||||
closed, err := applyLandedDowngrades(ctx, billingQ, rows, current)
|
||||
if err != nil {
|
||||
return spentSchedule{}, err
|
||||
@@ -1183,22 +1186,28 @@ type spentSchedule struct {
|
||||
}
|
||||
|
||||
// currentPrices returns the console price ids the subscription carries now.
|
||||
// A price whose mapping cannot be read is left out.
|
||||
func currentPrices(ctx context.Context, stripeQ *internalstripe.Queries, sub *stripe.Subscription) map[string]bool {
|
||||
// A price the console does not map is left out; any other failed read, an
|
||||
// ended context's included, returns its error, so a request that ends during
|
||||
// the reads cannot skew scheduleIsSpent and downgradeLanded by dropping a price.
|
||||
func currentPrices(ctx context.Context, stripeQ *internalstripe.Queries, sub *stripe.Subscription) (map[string]bool, error) {
|
||||
current := map[string]bool{}
|
||||
if sub.Items == nil {
|
||||
return current
|
||||
return current, nil
|
||||
}
|
||||
for _, item := range sub.Items.Data {
|
||||
if item.Price == nil {
|
||||
continue
|
||||
}
|
||||
m, err := stripeQ.GetPriceMappingByStripeID(ctx, sql.NullString{String: item.Price.ID, Valid: true})
|
||||
if err == nil {
|
||||
current[m.PriceID] = true
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
continue // a price the console does not map is not one of its prices
|
||||
}
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
current[m.PriceID] = true
|
||||
}
|
||||
return current
|
||||
return current, nil
|
||||
}
|
||||
|
||||
// applyLandedDowngrades is rule 1: a period-end downgrade whose target price
|
||||
|
||||
@@ -31,11 +31,48 @@ import (
|
||||
// within minutes, so a day means the event or the release never arrived.
|
||||
const stalePeriodEndAfter = 24 * time.Hour
|
||||
|
||||
// sweepCallTimeout is the deadline of the Stripe write the sweep makes for a
|
||||
// claimed row, the cancel or the item update, inside the attempt's own
|
||||
// deadline. sweepRowMargin is the time the row's other work needs: the reads
|
||||
// before the write (the mapping reads, the claim, firePlanSwitch's reads) and
|
||||
// the reconcile's fetch after it. The sweep fires a row only while at least
|
||||
// both remain of its attempt (roomToFire), so the attempt's deadline does not
|
||||
// cut a write it started, and the attempt never outlives its deadline.
|
||||
const (
|
||||
sweepCallTimeout = 20 * time.Second
|
||||
sweepRowMargin = 10 * time.Second
|
||||
)
|
||||
|
||||
// errSweepOutOfTime is the sweep's error when its attempt has too little
|
||||
// time left to fire another due row; the rows it left stay scheduled for the
|
||||
// attempt Temporal retries.
|
||||
var errSweepOutOfTime = errors.New("sweep: too little of the attempt is left to fire another scheduled change")
|
||||
|
||||
// roomToFire reports whether ctx leaves at least sweepCallTimeout and
|
||||
// sweepRowMargin before its deadline. A context without a deadline always
|
||||
// does.
|
||||
func roomToFire(ctx context.Context) bool {
|
||||
deadline, ok := ctx.Deadline()
|
||||
return !ok || time.Until(deadline) >= sweepCallTimeout+sweepRowMargin
|
||||
}
|
||||
|
||||
// cancelAtStripe cancels the subscription at Stripe now, under a deadline of
|
||||
// sweepCallTimeout inside the attempt.
|
||||
func cancelAtStripe(ctx context.Context, stripeSubID string) error {
|
||||
cctx, cancel := context.WithTimeout(ctx, sweepCallTimeout)
|
||||
defer cancel()
|
||||
_, err := subscription.Cancel(stripeSubID, &stripe.SubscriptionCancelParams{Params: stripe.Params{Context: cctx}})
|
||||
return err
|
||||
}
|
||||
|
||||
// SweepDueScheduledChanges fires every scheduled change whose effective_at has
|
||||
// passed and returns the number applied. Individual failures are logged and
|
||||
// skipped so one bad row does not stall the sweep. keyMode is the mode of the
|
||||
// key the process runs under; each due row is checked against it before the
|
||||
// row is claimed, and each reconcile the row runs receives it.
|
||||
// row is claimed, and each reconcile the row runs receives it. Before each row
|
||||
// it checks that the attempt leaves room for the row's Stripe write
|
||||
// (roomToFire); when it does not, it stops and returns errSweepOutOfTime with
|
||||
// the number of rows it left, which stay scheduled for the next attempt.
|
||||
func SweepDueScheduledChanges(ctx context.Context, db *sql.DB, logger *slog.Logger, keyMode string) (int, error) {
|
||||
if logger == nil {
|
||||
logger = slog.Default()
|
||||
@@ -46,7 +83,10 @@ func SweepDueScheduledChanges(ctx context.Context, db *sql.DB, logger *slog.Logg
|
||||
return 0, fmt.Errorf("sweep: list due scheduled changes: %w", err)
|
||||
}
|
||||
fired := 0
|
||||
for _, ch := range due {
|
||||
for i, ch := range due {
|
||||
if !roomToFire(ctx) {
|
||||
return fired, fmt.Errorf("%w: %d due scheduled changes left for the next attempt", errSweepOutOfTime, len(due)-i)
|
||||
}
|
||||
if ferr := fireScheduledChange(ctx, db, logger, keyMode, ch); ferr != nil {
|
||||
logger.Error("sweep: fire scheduled change failed",
|
||||
slog.String("scheduled_change_id", ch.ScheduledChangeID), slog.Any("error", ferr))
|
||||
@@ -158,7 +198,8 @@ func reconcilePeriodEndDowngrade(
|
||||
|
||||
// fireCancellation cancels the claimed change's subscription at Stripe unless
|
||||
// it is already gone, then converges. A refused cancel is logged and the
|
||||
// reconcile still runs.
|
||||
// reconcile still runs. The cancel carries sweepCallTimeout, and one cut by it
|
||||
// is handled as a refused one.
|
||||
func fireCancellation(
|
||||
ctx context.Context,
|
||||
db *sql.DB,
|
||||
@@ -171,7 +212,7 @@ func fireCancellation(
|
||||
if sub, gerr := billingQ.GetSubscriptionByID(ctx, ch.SubscriptionID); gerr == nil &&
|
||||
(sub.Status == "canceled" || sub.EndedAt.Valid) {
|
||||
// already terminal — just reconcile to converge state
|
||||
} else if _, cerr := subscription.Cancel(stripeSubID, nil); cerr != nil {
|
||||
} else if cerr := cancelAtStripe(ctx, stripeSubID); cerr != nil {
|
||||
logger.Warn("fire: stripe cancel (continuing to reconcile)",
|
||||
slog.String("stripe_subscription_id", stripeSubID), slog.Any("error", cerr))
|
||||
}
|
||||
@@ -179,7 +220,8 @@ func fireCancellation(
|
||||
}
|
||||
|
||||
// firePlanSwitch moves the subscription's first current item to the claimed
|
||||
// change's target price at Stripe, prorating, then converges.
|
||||
// change's target price at Stripe, prorating, then converges. The update
|
||||
// carries sweepCallTimeout, and one cut by it is handled as a refused one.
|
||||
func firePlanSwitch(
|
||||
ctx context.Context,
|
||||
db *sql.DB,
|
||||
@@ -205,13 +247,17 @@ func firePlanSwitch(
|
||||
if merr != nil || !itemMapping.StripeSubscriptionItemID.Valid {
|
||||
return fmt.Errorf("fire: resolve stripe item for %s: %w", ch.ScheduledChangeID, merr)
|
||||
}
|
||||
if _, uerr := subscription.Update(stripeSubID, &stripe.SubscriptionParams{
|
||||
params := &stripe.SubscriptionParams{
|
||||
Items: []*stripe.SubscriptionItemsParams{{
|
||||
ID: stripe.String(itemMapping.StripeSubscriptionItemID.String),
|
||||
Price: stripe.String(priceMapping.StripePriceID.String),
|
||||
}},
|
||||
ProrationBehavior: stripe.String("create_prorations"),
|
||||
}); uerr != nil {
|
||||
}
|
||||
cctx, cancel := context.WithTimeout(ctx, sweepCallTimeout)
|
||||
defer cancel()
|
||||
params.Context = cctx
|
||||
if _, uerr := subscription.Update(stripeSubID, params); uerr != nil {
|
||||
return fmt.Errorf("fire: stripe modify item for %s: %w", ch.ScheduledChangeID, uerr)
|
||||
}
|
||||
return ReconcileSubscription(ctx, db, logger, keyMode, stripeSubID, "fire:plan_switch")
|
||||
|
||||
@@ -0,0 +1,90 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Commercial
|
||||
// SPDX-FileCopyrightText: 2025-2026 Christian Galo
|
||||
|
||||
package fulfillment_test
|
||||
|
||||
// sweep_deadline_db_test.go pins design D3 of openspec change
|
||||
// plan-change-calls-carry-their-own-deadlines: the sweep's cancel and item
|
||||
// update carry a deadline of their own inside the attempt, and the sweep
|
||||
// leaves a row it has no time to fire for the attempt Temporal retries. The
|
||||
// tests run on a scratch database, because the sweep fires every due row of
|
||||
// its database.
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"git.coopcloud.tech/wiki-cafe/member-console/internal/fulfillment"
|
||||
"git.coopcloud.tech/wiki-cafe/member-console/internal/stripetest"
|
||||
)
|
||||
|
||||
// The sweep's cancel and item update each carry sweepCallTimeout.
|
||||
func TestSweepWritesCarryTheirDeadline(t *testing.T) {
|
||||
rows := []struct {
|
||||
name string
|
||||
setup func(e *traceEnv)
|
||||
call string // the write's name in a failure text
|
||||
is func(stripetest.Call) bool
|
||||
}{
|
||||
{"a due cancellation", func(e *traceEnv) { cancelAtPeriodEnd(e) }, "the cancel",
|
||||
func(c stripetest.Call) bool {
|
||||
return c.Method == "DELETE" && strings.HasPrefix(c.Path, "/v1/subscriptions/")
|
||||
}},
|
||||
{"a term-boundary switch", func(e *traceEnv) { deferredSwitch(e) }, "the update",
|
||||
func(c stripetest.Call) bool {
|
||||
return c.Method == "POST" && strings.HasPrefix(c.Path, "/v1/subscriptions/") && c.Form.Get("items[0][id]") != ""
|
||||
}},
|
||||
}
|
||||
for _, row := range rows {
|
||||
t.Run(row.name, func(t *testing.T) {
|
||||
e := newTraceEnv(t, true)
|
||||
e.stripeLike()
|
||||
e.answer()
|
||||
row.setup(e)
|
||||
sent := collectCalls(e.mock)
|
||||
ctx, cancel := context.WithTimeout(context.Background(), time.Hour)
|
||||
defer cancel()
|
||||
n, err := fulfillment.SweepDueScheduledChanges(ctx, e.db, e.logger, stripetest.KeyMode())
|
||||
if n != 1 || err != nil {
|
||||
t.Errorf("%s: the sweep returned %d, %v, want 1 and no error", row.name, n, err)
|
||||
}
|
||||
for _, c := range *sent {
|
||||
if row.is(c.Call) {
|
||||
checkDeadline(t, row.name, row.call, c, fulfillment.SweepCallTimeout)
|
||||
return
|
||||
}
|
||||
}
|
||||
var lines []string
|
||||
for _, c := range *sent {
|
||||
lines = append(lines, stripetest.Line(c.Call))
|
||||
}
|
||||
t.Fatalf("%s: the sweep sent %v, want %s among them", row.name, lines, row.call)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// A sweep whose attempt leaves the write's time but not the row's margin
|
||||
// fires nothing and returns ErrSweepOutOfTime, with the row still scheduled.
|
||||
func TestSweepLeavesARowItHasNoTimeToFire(t *testing.T) {
|
||||
e := newTraceEnv(t, true)
|
||||
e.stripeLike()
|
||||
e.answer()
|
||||
cancelAtPeriodEnd(e)
|
||||
sent := collectCalls(e.mock)
|
||||
left := fulfillment.SweepCallTimeout + fulfillment.SweepRowMargin/2
|
||||
ctx, cancel := context.WithTimeout(context.Background(), left)
|
||||
defer cancel()
|
||||
n, err := fulfillment.SweepDueScheduledChanges(ctx, e.db, e.logger, stripetest.KeyMode())
|
||||
if n != 0 || !errors.Is(err, fulfillment.ErrSweepOutOfTime) {
|
||||
t.Errorf("a sweep with %v left returned %d, %v, want 0 and ErrSweepOutOfTime", left, n, err)
|
||||
}
|
||||
if len(*sent) != 0 {
|
||||
t.Errorf("a sweep with %v left sent %v, want nothing", left, planCallNames(*sent))
|
||||
}
|
||||
if got := rowCount(t, e.w.DB, e.coreSubscription(), "cancellation", "period_end", "scheduled"); got != 1 {
|
||||
t.Errorf("a sweep with %v left left %d cancellations scheduled, want 1", left, got)
|
||||
}
|
||||
}
|
||||
@@ -16,6 +16,7 @@ import (
|
||||
"testing/synctest"
|
||||
"time"
|
||||
|
||||
"git.coopcloud.tech/wiki-cafe/member-console/internal/fulfillment"
|
||||
"git.coopcloud.tech/wiki-cafe/member-console/internal/middleware"
|
||||
"git.coopcloud.tech/wiki-cafe/member-console/internal/testkit"
|
||||
)
|
||||
@@ -160,3 +161,21 @@ func TestAReaderPastTheWriteTimeoutIsCut(t *testing.T) {
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// A plan change's call, charge and record deadlines are each longer than the
|
||||
// request's, so a call cut by its own deadline ends after the member has had
|
||||
// the timeout answer (design D1).
|
||||
func TestPlanChangeDeadlinesOutlastTheRequest(t *testing.T) {
|
||||
for _, d := range []struct {
|
||||
name string
|
||||
d time.Duration
|
||||
}{
|
||||
{"PlanChangeCallTimeout", fulfillment.PlanChangeCallTimeout},
|
||||
{"PlanChangeChargeTimeout", fulfillment.PlanChangeChargeTimeout},
|
||||
{"PlanChangeRecordTimeout", fulfillment.PlanChangeRecordTimeout},
|
||||
} {
|
||||
if d.d <= requestTimeout {
|
||||
t.Errorf("%s is %v, want longer than the request's %v", d.name, d.d, requestTimeout)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -117,6 +117,9 @@ type Call struct {
|
||||
// in the future only as recorded here.
|
||||
Deadline time.Time
|
||||
ContextDone bool
|
||||
// IdempotencyKey is the key the request's params carried explicitly;
|
||||
// empty when stripe-go would generate one.
|
||||
IdempotencyKey string
|
||||
}
|
||||
|
||||
// Line is c as one line of text: "stripe", the method, the path and, when
|
||||
@@ -221,6 +224,9 @@ func newCall(method, path string, params stripe.ParamsContainer) Call {
|
||||
c.Deadline, _ = ctx.Deadline()
|
||||
c.ContextDone = ctx.Err() != nil
|
||||
}
|
||||
if k := params.GetParams().IdempotencyKey; k != nil {
|
||||
c.IdempotencyKey = *k
|
||||
}
|
||||
fv := &form.Values{}
|
||||
form.AppendTo(fv, params)
|
||||
if q, err := url.ParseQuery(fv.Encode()); err == nil {
|
||||
|
||||
@@ -0,0 +1,209 @@
|
||||
## Context
|
||||
|
||||
A member's plan change runs in the request that asked for it: `PostSwitch`, `PostKeep` and `PostCancel` (`internal/server/member_plan_moves.go:276`, `:320`, `:407`) call `SwitchPlan`, `KeepCurrentPlan` and `CancelSubscription` with `r.Context()`. The request's context carries the deadline `middleware.Timeout(requestTimeout)` sets (`internal/server/server.go:346`, `:806`): `http.TimeoutHandler` runs the handler with a context that ends after 32 seconds, answers 503 "Request timed out" at that moment (`internal/middleware/timeout.go`), and lets the handler's goroutine run on, its writes refused. The context also ends when the member closes the page. The switch's preview (`PreviewSwitch`, from the preview request) runs the same way.
|
||||
|
||||
The sweep (`SweepDueScheduledChanges`, `internal/fulfillment/sweep.go:39-58`) runs in `SweepScheduledChangesActivity` (`internal/workflows/billing/activities.go:41-42`) under `common.DefaultActivityOptions` (`internal/workflows/common/options.go:16`, `:21`): one minute per attempt, five attempts, every hour (`internal/workflows/billing/schedule.go:22`). The Temporal SDK gives the activity's context the attempt's deadline (`go.temporal.io/sdk` v1.38.0, `internal/activity.go:442-452`). The sweep claims a due cancellation or switch (`MarkScheduledChangeApplied`, `scheduled -> applied`) before its Stripe call (`sweep.go:92-97`), so a claimed row is never listed again; a period-end downgrade is reconciled without a claim (`:87-89`).
|
||||
|
||||
stripe-go v81.4.0 attaches a context to a request only when `Params.Context` is set, and sends an `Idempotency-Key` on a write (POST, PUT, PATCH, DELETE) only when the params are not nil (`stripe.go:563-577`, `:1557-1559`). Without a context a call runs under the client's 80-second timeout and two retries, about four minutes; with one, `shouldRetry` stops once the context is done (`:946-963`), and a retry already waiting finishes its pause first, 0.5 then 1 second (`:1035-1060`). A cancelled or expired context gives no guarantee whether Stripe completed the operation; the caller retries with the same idempotency key or queries the state (`params.go:186-190`). `docs/stripe.md` ("How it works") states the defaults and that a call made without the request's context runs on after the member has been answered.
|
||||
|
||||
The paused switch (`internal/fulfillment/paused_switch.go`) already gives each of its calls a deadline detached from the request: `stripeCallContext` is `context.WithTimeout(context.WithoutCancel(ctx), d)` (`:75-77`), 20 seconds for the draft list, the voids and the clear, 60 for the charge, 60 for the restore (`:48-54`). Its restore makes every cut resolve before the member is answered: a call cut mid-flight is decided again by the restore's fresh fetch. Its applied branch is the exception: `appliedUpgrade(context.WithoutCancel(ctx), ...)` (`:222`) sends the release with `nil` params and reconciles under a context with no deadline, so both run on stripe-go's defaults.
|
||||
|
||||
The scheduled-change ledger (`core.subscription_scheduled_changes`) is the console's record of a scheduled move. The reconcile keeps it true to Stripe in one direction only (`convergeScheduledChanges`, `internal/fulfillment/reconcile.go:1143-1175`): it applies a period-end downgrade Stripe has landed, supersedes a period-end row Stripe no longer carries, and leaves `term_boundary` rows alone. It never writes a row. The plan card reads a downgrade from the ledger (`DowngradeAt`, `internal/server/plan_offer.go:367-384`), and Keep plan returns at once when the ledger holds no active row and the subscription row carries no cancellation (`plan_change.go:537-539`). A cancellation at the period end, by contrast, is read from `cancel_at_period_end` on the subscription row, which the reconcile mirrors from Stripe.
|
||||
|
||||
### The call table
|
||||
|
||||
Every Stripe call in `internal/fulfillment/` outside the tests. "R" is a read, "W" a write; "Today" says whether the call passes a context.
|
||||
|
||||
| # | Function | Call | R/W | Today | After this change | What runs after it |
|
||||
|---|---|---|---|---|---|---|
|
||||
| 1 | `fetchSubscription` (`reconcile.go:239`) | `subscription.Get` | R | the reconcile's | unchanged | the reconcile's transaction |
|
||||
| 2 | `ReconcileLatestSubscriptionForCustomer` (`:483`) | `subscription.List` | R | the reconcile's | unchanged | the reconcile |
|
||||
| 3 | `releaseSpentSchedule` (`:1300`) | `subscriptionschedule.Release` | W | the reconcile's | unchanged | `supersedeReleasedDowngrades` |
|
||||
| 4 | `applyCollectionAction` (`collection.go:114`) | `subscription.Update` | W | the reconcile's | unchanged | the outbox's second decision |
|
||||
| 5 | `guardRenewalFinalization` (`paused_switch.go:127`) | `invoice.List` | R | own 20 s, detached | unchanged | the backstop insert |
|
||||
| 6 | `pausedSwitchClear` (`:231`) | `subscription.Update` | W | own 20 s, detached | unchanged | the charge |
|
||||
| 7 | `pausedSwitchUpdate` (`:244`) | `subscription.Update` | W | own 60 s, detached | becomes `sendUpgrade`, shared with row 11 | `upgradeAnswer` |
|
||||
| 8 | `voidAbandonedUpgrade` (`plan_change.go:343`) | `invoice.VoidInvoice` | W | the paused switch's 20 s; `nil` from the others | own deadline, given by the caller: 20 s paused, 40 s otherwise | the charge, or the cancellation |
|
||||
| 9 | `upgradeAnswer` (`:393`) | `invoice.VoidInvoice` | W | the paused switch's 20 s; `nil` from `upgradeNow` | own deadline, given by the caller | the decline's answer |
|
||||
| 10 | `upgradeNow` (`:314`) | `subscription.Get` | R | none | the request's | the form's choice, the void, the charge |
|
||||
| 11 | `upgradeNow` (`:324`) | `subscription.Update` | W | none (params, so a key) | own 60 s, detached (`sendUpgrade`) | `upgradeAnswer`, `appliedUpgrade` |
|
||||
| 12 | `appliedUpgrade` (`:413`) | `subscriptionschedule.Release` | W | `nil` | own 40 s, detached (`releaseSchedule`) | supersede, reconcile |
|
||||
| 13 | `downgradeAtRenewal` (`:431`) | `subscription.Get` | R | `nil` | the request's | the clear, the schedule |
|
||||
| 14 | `downgradeAtRenewal` (`:439`) | `subscription.Update` (flag off) | W | none | own 40 s, detached (`setCancelAtPeriodEnd`) | the schedule |
|
||||
| 15 | `downgradeAtRenewal` (`:446`) | `subscriptionschedule.Get` | R | `nil` | own 40 s, detached | the item read (database), the update |
|
||||
| 16 | `downgradeAtRenewal` (`:448`) | `subscriptionschedule.New` | W | none | own 40 s, detached | the item read (database), the update |
|
||||
| 17 | `downgradeAtRenewal` (`:488`) | `subscriptionschedule.Update` | W | none | own 40 s, detached, with an idempotency key of the change's own (`sendScheduleUpdate`) | supersede, the ledger row, reconcile; or row 18 |
|
||||
| 18 | `sendScheduleUpdate` (new) | `subscriptionschedule.Update` | W | | the same update sent once more with the same key, own 40 s, detached, when row 17 gets no answer | the record, or the error |
|
||||
| 19 | `KeepCurrentPlan` (`:541`) | `subscription.Get` | R | none | the request's | `currentPrices` (database), the release, the clear |
|
||||
| 20 | `KeepCurrentPlan` (`:552`) | `subscriptionschedule.Release` | W | `nil` | own 40 s, detached (`releaseSchedule`) | the clear, supersede, reconcile |
|
||||
| 21 | `KeepCurrentPlan` (`:557`) | `subscription.Update` (flag off) | W | none | own 40 s, detached (`setCancelAtPeriodEnd`) | supersede, reconcile |
|
||||
| 22 | `PreviewSwitch` (`:709`) | `invoice.CreatePreview` | R | none | the request's | the confirmation's figures |
|
||||
| 23 | `CancelSubscription` (`:778`) | `subscription.Get` | R | none | the request's | the void |
|
||||
| 24 | `CancelSubscription` (`:795`) | `subscription.Get` | R | `nil` | the request's (it runs only when row 23 did not) | the release |
|
||||
| 25 | `CancelSubscription` (`:801`) | `subscriptionschedule.Release` | W | `nil` | own 40 s, detached (`releaseSchedule`) | the cancel or the flag |
|
||||
| 26 | `CancelSubscription` (`:808`) | `subscription.Cancel` | W | `nil` | own 40 s, detached | supersede, reconcile |
|
||||
| 27 | `CancelSubscription` (`:824`) | `subscription.Update` (flag on) | W | none | own 40 s, detached (`setCancelAtPeriodEnd`) | supersede, the ledger row, reconcile |
|
||||
| 28 | `fireCancellation` (`sweep.go:174`) | `subscription.Cancel` | W | `nil` | 20 s inside the attempt (`cancelAtStripe`) | reconcile |
|
||||
| 29 | `firePlanSwitch` (`sweep.go:208`) | `subscription.Update` | W | none | 20 s inside the attempt | reconcile |
|
||||
|
||||
The database work after a change's last write (supersede, the ledger row, the reconcile with its own calls 1 and 3) runs under `PlanChangeRecordTimeout` after this change, detached from the request.
|
||||
|
||||
## Goals / Non-Goals
|
||||
|
||||
**Goals:**
|
||||
- Every Stripe call of a plan change and of the sweep ends at a deadline its caller chose.
|
||||
- A plan change whose first write has been sent finishes and records what it did whatever happens to the request; a request that ends before the change's first write is sent changes nothing.
|
||||
- A stated rule for every write that may have reached Stripe when its deadline passed.
|
||||
|
||||
**Non-Goals:**
|
||||
- The timeout answer's wording (#296).
|
||||
- A reconcile that writes ledger rows, a sweep that returns a claimed row to `scheduled`, the `term_boundary` rows an upgrade leaves and the answer to a change whose closing reconcile fails; the sweep's is #177, the others open questions below.
|
||||
- The reconcile's calls (#285) and the paused switch's own calls and restore (#278).
|
||||
|
||||
## Decisions
|
||||
|
||||
### D1. The budget
|
||||
|
||||
Pick: a plan change's first Stripe call, always a read of the subscription (rows 10, 13, 19, 23 or 24), carries the request's context. Every later call carries a deadline of its own and not the request's cancellation (`stripeCallContext`, moved from `paused_switch.go` to `plan_change.go`): `PlanChangeChargeTimeout`, 60 seconds, for an upgrade's charge, and `PlanChangeCallTimeout`, 40 seconds, for every other call except the paused switch's own 20-second calls (below). Immediately before its first write, each move checks the request's context (`requestEnded`) and returns an error wrapping its error when the request has ended; the paused form's check is its backstop insert, made on the request's context before its first Stripe write. After the change's last Stripe write, its database writes and its reconcile run in `recordMove` under `recordContext`, the request's values with `PlanChangeRecordTimeout`, 60 seconds, and not its cancellation. The three are exported so that `internal/server` can test them against `requestTimeout`.
|
||||
|
||||
The reasons, against carrying the request's context on every call:
|
||||
|
||||
- **Agreement.** Carrying the request's context ends the change wherever the request ends, between any two of its calls. A write sent before that point has landed or may have, and the database writes after it fail on the same context, so the console's record and Stripe disagree on exactly the paths the issue reports (a slow Stripe, a member who closes the page). Detaching from the first write on removes those paths: the only cut left is a call's own deadline, which D2 states a rule for. Carrying the request's context on the first read, and checking it again before the first write, keeps the one cut that is safe: before the first write nothing has changed, so a request that ends there stops the change with Stripe and the console still agreeing.
|
||||
- **The member's answer.** Every deadline after the first call, outside the paused switch's own calls, is longer than the request's 32 seconds, measured from a moment no earlier than the request's start. A call cut by its own deadline therefore ends after the member has had the timeout answer, never inside the request, where it would be answered "We couldn't complete that change. Try again in a moment." about a change that may have been made (D4). A write that Stripe answers with a status of 500 or above inside the request is answered that way as it is today, although it may have landed; Stripe's event repairs the record, and the member's answer is #296's question (D4). The paused switch keeps its 20-second calls, its read of the draft invoices, its voids and its clear, because its restore decides the subscription's state again before the member is answered, so its answer is true when it comes; its restore's calls share one deadline of 60 seconds.
|
||||
- **The charge stays at 60 seconds**, the paused switch's figure for the same call: a deadline that cuts a slow but successful charge leaves the member charged without an answer. `pausedSwitchChargeTimeout` is replaced by `PlanChangeChargeTimeout`, and `pausedSwitchUpdate` becomes `sendUpgrade`, which both forms of the upgrade send, so one call has one deadline.
|
||||
- **The record gets 60 seconds**, the paused switch's restore's figure for the same work: a reconcile's fetch, its transaction and possibly a release.
|
||||
|
||||
The change deliberately does not fit the request when Stripe is slow. Summing every deadline after the first write, a downgrade runs at most 220 seconds (the flag 40, the schedule 40, the update 40, its resend 40, the record 60), an upgrade 200, a cancellation of a subscription that pays under a grant 180, Keep plan 140, each plus up to a second of stripe-go's retry pause per call. Today one call alone can run about four minutes, and a downgrade four of them. The paused switch's applied branch, now bounded, runs at most 200 seconds from its backstop's insert (the void 20, the clear 20, the charge 60, the release 40, the record 60), inside the backstop's four minutes.
|
||||
|
||||
Database statements before the first write stay on the request's context: the resolve, the refusals, the list of the subscription's active scheduled changes that each move takes before its first Stripe call, Keep plan's `currentPrices`. `currentPrices` returns the error of a failed mapping read, a cancelled context's included, and leaves out only a price with no mapping, so that a request that ends there stops Keep plan and does not skew `scheduleIsSpent` and `downgradeLanded`; the reconcile, its other caller, returns that error too. `downgradeAtRenewal`'s read of the current item moves before its first Stripe call, because today it runs between the schedule's creation and its update (`plan_change.go:460`), where the request's end would fail it after a write. A commitment's deferred move (`SwitchPlan`'s `term_boundary` branch, `CancelSubscription`'s) is recorded on the request's context as today: it makes no Stripe write, except a cancellation of a subscription that pays under a grant, which may void an abandoned approval's invoice first; that void is right whether or not the deferred cancellation is recorded, so a request that ends between them leaves Stripe and the console agreeing.
|
||||
|
||||
`recordMove(ctx, db, logger, keyMode, billingQ, move, ls, before, change, reason)` supersedes the scheduled changes in `before`, the subscription's active rows that the move listed before its first Stripe call, writes `change` when it is set, and reconciles, under `recordContext`. It supersedes those rows one by one (`SupersedeScheduledChange`, which skips a row another path already closed) and not every active row (`SupersedeActiveScheduledChanges`), because a change that outlives its request records up to the sums below later, and a row another request wrote in the meantime, a cancellation deferred to a commitment's end say, is that member's later choice and stays. `appliedUpgrade`, `downgradeAtRenewal`, `KeepCurrentPlan` and both timings of `CancelSubscription` end with it; `scheduleCancellation` stays for the deferred branch. The error messages keep each move's prefix ("switch", "keep", "cancel"); three texts change: Keep plan's supersede reads "keep: supersede prior scheduled changes" where it read "keep: supersede scheduled changes", the downgrade's record reads "switch: record the scheduled change" where it read "switch: record the scheduled downgrade", and the cancellation's reads "cancel: record the scheduled change" where it read "cancel: schedule cancellation". No test reads these texts.
|
||||
|
||||
Rejected: one deadline for the whole change, detached at its start. A request that ends before the first write would then go on to write after the member was answered, and each call would have whatever the earlier calls left, so the charge could get less than its 60 seconds.
|
||||
|
||||
Rejected: per-call deadlines of 20 seconds, as the paused switch has. A write cut at 20 seconds inside the request answers the generic failure about a write that may have landed, and nothing resolves it before the answer.
|
||||
|
||||
Rejected: running the change's writes as a durable job (an outbox entry or a workflow), which a stop of the process does not cut. A durable form can replace the goroutine later, and this change forecloses nothing of it. It is not chosen here because the member's answer needs the charge's outcome synchronously, for the approval link and the decline; the resend of D2 and #260's stop path (Risks) close what the goroutine leaves open.
|
||||
|
||||
### D2. A write that may have reached Stripe
|
||||
|
||||
The rule:
|
||||
|
||||
1. **Every write carries params**, with its own context, so stripe-go sends an idempotency key and reuses it across its own retries: a retry of a write that reached Stripe gets Stripe's first answer, not a refusal of a second attempt. The releases (rows 12, 20, 25), the cancels (26, 28) and the voids (8, 9) sent `nil` and carried no key.
|
||||
2. **A refusal** (Stripe answered, 4xx) is returned or logged as today, and the change records nothing for it.
|
||||
3. **A write that gets no answer** (its deadline passed or the network failed: `gotNoAnswer`, an error that carries no answer from Stripe), and **a write Stripe answers with a status of 500 or above**, whose outcome Stripe does not state, are returned or logged as a refusal is, and the change records nothing for them. A 500 or above arrives inside the request, and the member's answer to it is the failure answer as for a refusal; D4. Stripe sends an event for a write that landed, and the reconcile that event starts records it: the flag, the items, the cancellation, the release's effect on the ledger (rules 2 and 3), the voided invoice.
|
||||
4. **The exception is the downgrade's schedule update (row 17)**, whose effect no reconcile records, because the reconcile never writes a ledger row. It carries an idempotency key the change chose (`stripe.String(uuid.NewString())`) before its first send. When it got no answer (`gotNoAnswer`), the change sends it once more (row 18), with the same parameters and the same key under a fresh 40 seconds, and records the downgrade when that send is answered: if the first update landed, Stripe returns its saved answer; if it never arrived, the resend performs it, which is what the member asked for; if Stripe is still executing it, Stripe answers 409, stripe-go retries twice within about two seconds, and the change returns the 409 and records nothing; the update can then land, which is open question 1. A first send that Stripe answered with its error object and a status of 500 or above is not sent again: Stripe replays that answer to the same key. When the second send gets no answer too, or is refused, the change returns its error and records nothing; what that can leave is open question 1.
|
||||
5. **The member** is answered as today for a refusal, and for a write Stripe answers with a status of 500 or above inside the request. A write cut by its own deadline ends after the request (D1), so the member has had the timeout answer, and the change goes on to its record when the resend is answered.
|
||||
|
||||
| Write (row) | Change | Success | Refusal | No answer, not sent | No answer, sent (may have landed) |
|
||||
|---|---|---|---|---|---|
|
||||
| Void of an abandoned approval's invoice (8) | upgrade; cancellation under a grant | the pending update is dropped; the change goes on | error; nothing changed | as a refusal | error. If it landed, the invoice is void and the pending update gone, which the invoice's events record; a later attempt finds no open invoice to void |
|
||||
| The charge (11) | upgrade | applied, awaiting approval, or declined, as today | error; nothing changed | as a refusal | error. If it applied, Stripe's event moves the tier; the reconcile releases a spent schedule (rule 4) and supersedes its downgrade (rule 2), and a cancellation the upgrade disarmed (rule 3); a `term_boundary` row stays (open question 2). If it awaits approval, or declined and the void of row 9 got no answer, the open invoice stays payable until its pending update expires, at most 23 hours, and a payment of it applies the upgrade after the member read the answer; the next attempt voids it |
|
||||
| Void of a declined upgrade's invoice (9) | upgrade | the member reads the decline | logged; the decline is answered | as a refusal | logged; the decline is answered. If it did not land, the invoice stays open and payable until the pending update expires: a payment of it, Stripe's retry or any member of the organization on the Billing page, applies the upgrade, which Stripe's event records, after the member read that the card declined. The next attempt's `voidAbandonedUpgrade` voids it |
|
||||
| Release after an applied upgrade (12) | upgrade | the record runs | logged; the record runs, and its reconcile releases the spent schedule (rule 4) | as a refusal | as a refusal; the record's reconcile releases the schedule if it is still attached |
|
||||
| Flag off before a downgrade (14) | downgrade | the schedule follows | error; nothing changed | as a refusal | error. If it landed, the cancellation is taken back and no downgrade is scheduled; Stripe's event supersedes the cancellation row (rule 3). Stripe and the console agree; the member's page shows neither |
|
||||
| Schedule creation (16) | downgrade | the update follows | error; a cancellation that row 14 cleared stays cleared, which Stripe's event supersedes (rule 3); nothing else changed | as a refusal | error. If it landed, Stripe holds a schedule without the console's metadata, which mirrors the current phase and releases itself at the period end; no price changes. A later downgrade adopts it, Cancel plan and an upgrade release it, rule 4 leaves it |
|
||||
| Schedule update (17) | downgrade | the record runs | error; nothing recorded; no resend | sent again with the same key (row 18); recorded when it is answered, its error otherwise | sent again with the same key; recorded when it is answered, its error otherwise. A status of 500 or above: error; nothing recorded; no resend (Stripe replays it) |
|
||||
| Keep plan's release (20) | Keep plan | the record runs | error | as a refusal | error. If it landed, Stripe's event supersedes the downgrade row (rule 2) |
|
||||
| Keep plan's flag off (21) | Keep plan | the record runs | error | as a refusal | error. If it landed, Stripe's event supersedes the cancellation row (rule 3) |
|
||||
| Cancel plan's release (25) | Cancel plan | the cancel or the flag follows | error | as a refusal | error. If it landed, the downgrade is taken back and no cancellation is set; rule 2 supersedes the row |
|
||||
| Cancel now (26) | Cancel plan, immediate | the record runs | error | as a refusal | error. If it landed, Stripe's `customer.subscription.deleted` ends the provision |
|
||||
| Flag on (27) | Cancel plan, period end | the record writes the cancellation row | error; no row (`TestRefusedCancellationRecordsNothing`) | as a refusal | error; no row. If it landed, the reconcile mirrors the flag onto the subscription row, which the card and Keep plan read, so the card shows the cancellation and Keep plan takes it back; the ledger lacks the row, and Stripe ends the subscription at the period end on its own; the missing row is #177's, which decides who owns a period-end move Stripe holds |
|
||||
| The sweep's cancel (28) | sweep | the reconcile records the end | logged; the reconcile runs; the row stays `applied` | as a refusal | as a refusal. If it landed, the reconcile or Stripe's event ends the provision; if not, the row is spent (#177) |
|
||||
| The sweep's item update (29) | sweep | the reconcile moves the tier | error; the row stays `applied` | as a refusal | as a refusal. If it landed, Stripe's event moves the tier; if not, the row is spent (#177) |
|
||||
|
||||
Rejected: sending every write that may have landed again. Each other write's effect reaches the console through Stripe's event within seconds, and a resend would add a call and a deadline to every change for a state the reconcile already records.
|
||||
|
||||
Rejected: an idempotency key of the console's own, reused by a later attempt. A member's next attempt is a new request with possibly other parameters, which Stripe refuses under a reused key. The downgrade's update alone carries a key of the change's own, and only its resend within the same change, with the same parameters, reuses it.
|
||||
|
||||
Rejected: reading the schedule back after an update that may have landed, in place of the resend. A read cannot tell an update that did not land from one that has not landed yet, and a client-side deadline does not stop Stripe's server, so an update still executing when the read arrives is reported as not landed and then lands, unrecorded. The resend settles the update that landed and the one that never arrived. It does not settle an update Stripe is still executing when the resend arrives (409) or one Stripe answered with a server error (replayed); both are open question 1's, which removes the resend.
|
||||
|
||||
### D3. The sweep
|
||||
|
||||
Pick: the sweep's cancel and item update each carry `context.WithTimeout(ctx, sweepCallTimeout)`, 20 seconds inside the attempt's context, not detached from it. Before each due row, the sweep checks that at least `sweepCallTimeout + sweepRowMargin` remains before the attempt's deadline (`roomToFire`), `sweepRowMargin` being 10 seconds for the reads before the write (the mapping reads, the claim, `firePlanSwitch`'s reads) and the reconcile's fetch after it; when less remains, it stops and returns `errSweepOutOfTime` with the number of rows it left, so the activity fails and Temporal retries the attempt with a fresh minute, the left rows still `scheduled`. A context without a deadline always has room.
|
||||
|
||||
So the attempt's own deadline does not cut a write the sweep started, and the attempt never outlives its minute: Temporal never starts the next attempt beside a call still in flight. The row's reconcile after a write that ran to its 20 seconds may find little time left; when it does, Stripe's event reconciles. A write that ends at its own deadline is handled as a refused one is today (D2, rows 28 and 29): the claimed row stays `applied`, and the next sweep does not see it. That is the claim's existing rule for a refusal; returning such a row to `scheduled` is #177.
|
||||
|
||||
Rejected: detaching the sweep's write from the attempt, as the plan change detaches from the request. The attempt would outlive its deadline and Temporal would start the next one beside it, which #285 removed for the reconcile's calls.
|
||||
|
||||
### D4. The member's answer and #296
|
||||
|
||||
No string changes. A refusal is answered as today. A write cut by its own deadline, and the resend that follows it, end after the request's 32 seconds (D1), so the member reads the timeout answer: 503 "Request timed out", which the page shows as "Server error. Try again later." That answer says nothing of the unknown outcome, which is #296. This change interacts with it in two ways. A write cut by its deadline now reaches the member as that one answer, so #296's rewording covers it. And the change now finishes after that answer, at most the sums of D1 later, so #296's "reloading the page shows the outcome" becomes true once the change has ended.
|
||||
|
||||
A second unknown outcome exists today and stays: a write that Stripe answers with a status of 500 or above inside the request, or a network fault that exhausts stripe-go's own retries quickly. The move returns the error, and the member reads "We couldn't complete that change. Try again in a moment." about a write that may have landed; Stripe's event repairs the record. This change does not alter it, and it is #296's scope: a write Stripe answers with a status of 500 or above inside the request is an unknown outcome, the same as a timeout. The landing tasks post that on #296.
|
||||
|
||||
One answer stays false and is not a timeout: when a change's writes have landed and its record's reconcile fails at once (a database error, or Stripe refusing the fetch), the member reads "We couldn't complete that change. Try again in a moment." about a change that was made and recorded. It does so today, and this change leaves it. It is not #296's: the change was made and recorded, so the answer should be the change's own. It is open question 3.
|
||||
|
||||
### D5. Ride-alongs and the documents
|
||||
|
||||
- **The preview carries the request's context** (row 22). It is a read on the switch's own request path, best effort: a preview the request's end cuts is the unavailable preview, as any failed preview is. Listed openly under `CONTRIBUTING.md`, "How changes are made", item 1.
|
||||
- **`docs/stripe.md`**, "How it works" (`:207`), states the deadlines in place of "a call made without it runs on after the member has been answered". No other document names these calls: `docs/database-locks.md` describes no lock this change moves (Locks and transactions), and `docs/testing.md` gains the sentence on `Call.IdempotencyKey`, the mock's one addition (tasks 4.9, 5.2).
|
||||
|
||||
## Failure table
|
||||
|
||||
| # | State | Today | After | Test |
|
||||
|---|---|---|---|---|
|
||||
| F1 | The request ends (the member closes the page, or 32 seconds pass) after a change's first write: a downgrade after its schedule's creation, a cancellation after its flag, Keep plan after its release, an upgrade after its charge | The later calls run on with no deadline; the database writes after them fail on the ended context; the downgrade's schedule is at Stripe with no ledger row, the cancellation's flag with no row, the upgrade's new tier waits for Stripe's event | The later calls and the record run to their own deadlines; the ledger row is written and the reconcile runs; the member has had the timeout answer | `TestAPlanChangeOutlivesItsRequest` |
|
||||
| F2 | The request ends while the change's first read is in flight | The read runs on, up to about four minutes; the change goes on to write; its database writes then fail | The read ends with the request; the change returns the context's error; nothing is sent or written | `TestARequestThatEndsDuringTheFirstReadStopsTheChange` |
|
||||
| F2a | The request ends after the first read answered and before the first write | The change goes on to write; its database writes then fail | The change returns an error wrapping the context's; nothing is sent or written | `TestARequestThatEndsAfterTheFirstReadStopsTheChange` |
|
||||
| F3 | Stripe answers slowly | Each call can run about four minutes | The first read ends with the request; the charge at 60 seconds; every other call at 40 (20 for the paused switch's own); the record at 60 | `TestPlanChangeCallsCarryTheirDeadlines` |
|
||||
| F4 | The downgrade's schedule update gets no answer, and it landed | The change fails; no ledger row; the renewal applies a downgrade the console never showed | The resend with the same key gets Stripe's saved answer; the change records the downgrade, reconciles and logs a warning | `TestADowngradeUpdateWithoutAnAnswerIsResent/an_update_that_landed` |
|
||||
| F5 | The update gets no answer, and it did not land | The change fails; no row | The resend performs the update; the change records the downgrade | `.../an_update_that_did_not_land` |
|
||||
| F6 | The update and its resend both get no answer | The change fails; no row | The resend's error; no row (open question 1) | `.../a_resend_that_gets_no_answer` |
|
||||
| F7 | Stripe refuses the update | The change fails; no row | Unchanged; no resend | `.../a_refused_update` |
|
||||
| F7a | Stripe answers the update 500 | The change fails; no row | Unchanged: error; no row; no resend, because Stripe replays a saved 500 | `.../an_update_answered_500` |
|
||||
| F8 | Another write gets no answer: the cancellation's flag | The change fails; no row | Unchanged; nothing sent again | `.../a_flag_without_an_answer` |
|
||||
| F8a | A change outlives its request, and another request records a scheduled change before the first change's record runs | The first change's record fails on the ended context and supersedes nothing; two requests that overlap inside 32 seconds can still supersede each other's row | The record supersedes only the rows that were active when the change began; the later row stays | `TestALateRecordKeepsAChangeAnotherRequestRecorded` |
|
||||
| F9 | A sweep's cancel or item update meets a slow Stripe | The call runs about four minutes, past the attempt's minute; Temporal starts the next attempt beside it | The call ends at 20 seconds, inside the attempt | `TestSweepWritesCarryTheirDeadline` |
|
||||
| F10 | Less than 30 seconds of the sweep's attempt remain when a due row comes up | The row is claimed and its write can be cut by the attempt's end | The sweep stops; the row stays `scheduled`; the activity fails and Temporal retries it | `TestSweepLeavesARowItHasNoTimeToFire` |
|
||||
| F11 | A sweep write ends at its own deadline | (the call runs on) | Handled as a refusal: the row stays `applied` (#177) | `TestSweepTrace` cases `cancellation_refused` and `term_boundary_switch_refused`, unchanged |
|
||||
| F12 | The request ends during the preview's invoice preview | The preview runs on | The preview ends; the unavailable preview | `TestPlanChangeCallsCarryTheirDeadlines/the_preview_of_an_upgrade` |
|
||||
| F13 | Every call runs to its deadline | A change's writes run past every bound | 32 seconds is shorter than every later deadline outside the paused switch's own calls | `TestPlanChangeDeadlinesOutlastTheRequest` |
|
||||
|
||||
## Locks and transactions
|
||||
|
||||
No lock or transaction changes. Every Stripe call of a change runs outside a transaction, as today. `recordMove` runs the same statements in the same order as the code it replaces (supersede, the ledger row, then `ReconcileSubscription`, which takes the reconcile's locks in `docs/database-locks.md`'s order), on a context the request's end does not cancel. A change that outlives its request therefore holds no lock longer than before: its locks are the reconcile's, taken inside the record's 60 seconds. The sweep's stop before the attempt's deadline happens before a claim, outside every transaction.
|
||||
|
||||
## Tests
|
||||
|
||||
Each test claim is pinned by a break (`docs/testing.md`, "Prove a test with deliberate breaks"); the tasks list each break with the text its failing test prints. The plan-change tests run each change under a request context whose deadline is an hour away and read, through the Stripe mock's `Call.Deadline`, the deadline each call carried, against the moment the mock received it, as `TestSwitchAPausedSubscription_EveryCallCarriesADeadline` does. A row first checks that the change sent the calls it names, in order, so a fixture that makes no such call cannot pass the row. A request that ends is a context the test cancels from the mock's `Handle` or `OnCall` when a named call arrives. A resend is read through `stripetest.Call.IdempotencyKey`, which records the key a call's params carry explicitly. A write without an answer is a `Handle` that answers `*url.Error` wrapping `context.DeadlineExceeded`, the shape stripe-go returns, after it has or has not applied the write to the mock's state. The sweep's tests run on a scratch database, as `TestSweepTrace` does, because the sweep fires every due row of its database.
|
||||
|
||||
## What is unverified
|
||||
|
||||
- **That Stripe sends an event after a schedule's release, an attached schedule, or a cleared or set flag.** D2's repairs for rows 14, 20, 21, 25 and 27 rely on `customer.subscription.updated` for a change to the subscription's `schedule` or `cancel_at_period_end`; `docs/stripe.md` (`:251`) says a release made in the Dashboard reaches the console. No test of this change observes Stripe's events.
|
||||
- **That a schedule's update can land after its answer was lost.** Stripe documents that it answers a send with the same key from its saved answer, a 500 included, and answers 409 while the first is executing (Idempotent requests; Advanced error handling); the mock does not replay, so the tests check the key the resend carries, not Stripe's answer to it.
|
||||
- **That `http.TimeoutHandler` lets the handler's goroutine run on.** Read from net/http and `internal/middleware/timeout.go`'s comment; no test runs a change past the request.
|
||||
|
||||
## Risks / Trade-offs
|
||||
|
||||
- **A change may run after the member was answered**, for up to the sums in D1. A member who reloads at once can see the plan before the change; #296's wording is where the member learns that.
|
||||
- **A process that stops while a change runs past its request cuts it**, as a crash would. At HEAD a stop signal ends the process at once, because `start` installs no signal handler (#260). Once #260's stop path exists, it does not wait for this work, because the timeout handler has already answered, and closing the pool fails the record between its Stripe write and its database write. #260's stop path must therefore wait for plan changes in flight (a counter in `fulfillment` that each detached change holds until its record returns), bounded by the sums of D1, about 220 seconds, or the orchestrator's grace period must cover them; otherwise each deploy can cut a record after its Stripe write, or the stop path must accept cutting a change between a Stripe write and the console's record. The landing tasks post this on #260. The paused switch's backstop covers its own case; for the others, D2's last column applies, and a downgrade's lost record is open question 1.
|
||||
- **A declined upgrade whose void got no answer stays payable** until its pending update expires, at most 23 hours (D2, rows 9 and 11): a payment of that invoice applies the upgrade after the member read "declined". The record stays true through Stripe's event; the member's answer goes stale. The next attempt voids it.
|
||||
- **A second move made while a change is still running** can now interleave with the first change's late record. Before this change, the record of a request that ran out of time failed on its ended context and superseded nothing, and two requests that overlapped inside 32 seconds could still supersede each other's row, because nothing serializes the moves of one subscription (no lock, no index). With the record landing up to the sums of D1 later, the window would grow: an upgrade's charge takes 50 seconds, the member reloads and cancels under a commitment, which writes a `term_boundary` cancellation row at 40 seconds, and the upgrade's record at 55 seconds would supersede that row, which no rule heals. The record therefore supersedes only the rows that were active when its change began (D1, `recordMove`), which closes the loss for both cases. What stays: the first change's row can be active beside the later request's. When the two are of different kinds, the later reconcile supersedes the one Stripe no longer carries (rules 2 and 3); two identical rows are both true. When both are downgrades to different tiers, Stripe holds one target and the ledger two, and the plan card names whichever row's id sorts first until the renewal's reconcile supersedes the other (rule 4); Keep plan takes both back. Open question 1's reconcile, which reads the attached schedule, closes it. #177's one-pending-change index would make the late record's insert fail after its Stripe write; that issue has to decide that a late record yields to the later request's row. #219 and #275 are open bugs on the same rows.
|
||||
- **A change that outlives its request keeps its goroutine and its database connections** until it ends, at most the sums in D1. The handler goroutine already ran on past the timeout answer today; its database work then failed at once, where it now runs.
|
||||
- **A write that gets no answer is answered as a failure** when Stripe answers it with a status of 500 or above inside the request, as today (D4). #296 owns the member's answer.
|
||||
|
||||
## Open questions
|
||||
|
||||
Each is a new rule, so each needs its own issue (`CONTRIBUTING.md`, "How changes are made", item 1). The statements below follow the forge's bug form.
|
||||
|
||||
### 1. A downgrade Stripe holds is not shown when the console never recorded it
|
||||
|
||||
- **What happens:** The console records a scheduled downgrade only in the switch that schedules it; the reconcile, which keeps the record true to Stripe, takes a recorded downgrade back when Stripe no longer holds it but never records one Stripe holds. When the switch's last Stripe call lands but neither its answer nor the answer to its resend reaches the console, Stripe answers it with a server error (which Stripe repeats to the resend), Stripe is still executing it when the resend arrives, or the console's process stops between that call and the record, or the console fails to write the record, Stripe holds the downgrade and the console shows none: the plan's card names no downgrade, Keep plan finds nothing to take back, and the renewal moves the member down.
|
||||
- **What should happen:** The reconcile records a period-end downgrade from the subscription schedule the console attached, whose metadata names the price it was made from and whose last phase names the target, as it already takes one back when the schedule is gone. It also supersedes a period-end downgrade whose target the schedule's last phase does not carry, so two downgrades made close together leave one. Once it does, the resend of the downgrade's update becomes redundant and is deleted, so that the console does not keep two mechanisms for one fact.
|
||||
- **Where:** `internal/fulfillment/reconcile.go` (`convergeScheduledChanges`); `internal/fulfillment/plan_change.go` (`downgradeAtRenewal`). Related: #177, #219, #275.
|
||||
- **Why it matters:** A member can be moved to a lower plan that the console never showed as scheduled and that Keep plan cannot take back.
|
||||
|
||||
### 2. A commitment's deferred move survives an upgrade the console learns of from Stripe
|
||||
|
||||
- **What happens:** When a member upgrades, the console takes back every scheduled move on the subscription, including a downgrade or a cancellation deferred to the end of a commitment. It does so only in the request that applied the upgrade. An upgrade that applies later, when the member approves the payment on Stripe's invoice page, or whose answer the console never received, reaches the console only through the reconcile, which leaves moves deferred to a commitment's end alone. At the commitment's end the sweep applies the old move over the upgrade.
|
||||
- **What should happen:** An upgrade that applies takes back the deferred moves however the console learns of it.
|
||||
- **Where:** `internal/fulfillment/reconcile.go` (`convergeScheduledChanges`); `internal/fulfillment/plan_change.go` (`appliedUpgrade`). Related: #177, #219, #275.
|
||||
- **Why it matters:** A member who upgraded can be moved down or cancelled at the end of a commitment against their latest choice.
|
||||
|
||||
### 3. A plan change made and recorded is answered as failed when the reconcile after it fails
|
||||
|
||||
- **What happens:** When a member's plan change has been made at Stripe and the console has recorded it, but the reconcile of the subscription that follows fails at once (a database error, or Stripe refusing to read the subscription), the member reads "We couldn't complete that change. Try again in a moment.", although the change was made.
|
||||
- **What should happen:** Once the change is made and recorded, a failure of the reconcile after it is logged and the member reads the change's own answer. Stripe's event for the change brings the subscription's record up to date.
|
||||
- **Where:** `internal/fulfillment/plan_change.go` (the record at the end of each move); `internal/server/member_plan_moves.go`.
|
||||
- **Why it matters:** A member who follows the advice repeats a change that has already taken effect.
|
||||
@@ -0,0 +1,54 @@
|
||||
## Why
|
||||
|
||||
A member's plan change (a switch, Keep plan, Cancel plan) and the sweep that fires scheduled plan changes call Stripe without a context (`internal/fulfillment/plan_change.go`, `internal/fulfillment/sweep.go`). stripe-go then runs each call under its own defaults, an 80-second timeout and two retries, about four minutes, whatever deadline the caller has. A downgrade makes up to four such calls in a row.
|
||||
|
||||
The console ends a member's request after 32 seconds (`requestTimeout`, `internal/server/server.go`) and cancels its context; the context is also cancelled when the member closes the page. The Stripe calls keep going, but the database writes that follow them use the cancelled context and fail. A downgrade can attach a subscription schedule at Stripe and stop before it records the move, and nothing records it later: the reconcile supersedes a scheduled-change row Stripe no longer carries but never writes one, so the plan card shows no downgrade, Keep plan finds nothing to take back, and the renewal moves the member down after the member was told the change failed.
|
||||
|
||||
The sweep's attempt has one minute. Its cancel or item update runs past that minute, and Temporal starts the next attempt beside it, the defect the reconcile's calls no longer have since `reconcile-calls-carry-context` (#285).
|
||||
|
||||
## What Changes
|
||||
|
||||
- **A plan change's first Stripe call, a read of the subscription, carries the request's context, and the change checks that context again before its first write**, so a request that ends before then stops the change before anything is written (design D1).
|
||||
- **Every later Stripe call of a plan change carries a deadline of its own and not the request's cancellation**, as the paused switch's calls already do: 60 seconds for an upgrade's charge (`PlanChangeChargeTimeout`, which replaces the paused switch's equal constant), 40 seconds for every other call (`PlanChangeCallTimeout`), except the paused switch's own 20-second calls. Each deadline of 40 or 60 seconds is longer than the request's 32 seconds, so a call cut by its deadline ends after the member has had the timeout answer. A write that Stripe answers with a server error inside the request is still answered as a failure, as today (D1, D4).
|
||||
- **After its last Stripe write, a plan change's database writes and its reconcile run under one deadline of 60 seconds that the request's end does not cut** (`recordMove`, `PlanChangeRecordTimeout`). A member who closes the page, or a request that runs out of time, no longer leaves a change made at Stripe and unrecorded. The record supersedes only the scheduled changes that were active when the change began, so a late record leaves a change another request recorded in the meantime. The paused switch's applied branch, which ran its release and its reconcile with no deadline at all, gets the same bounds (D1).
|
||||
- **Every Stripe write carries params**, so stripe-go sends an idempotency key and reuses it across its own retries; the releases, the cancel and the voids sent `nil` and carried none (D2).
|
||||
- **The downgrade's schedule update is sent again when it gets no answer.** A write that gets no answer before its deadline, or that Stripe answers with a status of 500 or above, is returned as the change's error and records nothing, because Stripe's event for a write that landed reconciles it. The one exception is the downgrade's schedule update, whose effect no reconcile records: the change sends it with an idempotency key of its own and, when it gets no answer, once more with the same key under a deadline of its own, and records the downgrade when the second send is answered (D2).
|
||||
- **The sweep's cancel and item update carry a deadline of 20 seconds inside the sweep's attempt** (`sweepCallTimeout`), and the sweep stops claiming rows once less than that plus 10 seconds for the reads around the call remains before the attempt's deadline, returning an error so Temporal retries the attempt with the rest still scheduled (D3).
|
||||
- **`docs/stripe.md`**, "How it works", states the deadlines.
|
||||
|
||||
Ride-alongs, quick fixes listed openly (`CONTRIBUTING.md`, "How changes are made", item 1):
|
||||
|
||||
- The switch preview's invoice preview (`PreviewSwitch`), a read on the request path the issue does not name, carries the request's context.
|
||||
- `currentPrices` skips only a price with no mapping and returns every other error; a failed mapping read was taken as "this price is not current", which decides Keep plan's refusal and the reconcile's rule 1.
|
||||
|
||||
No user-visible string changes. The member's answer to a request that runs out of time stays the timeout answer, whose wording is #296's.
|
||||
|
||||
## Out of scope
|
||||
|
||||
- **The wording of the timeout answer** (#296). This change makes a write cut by its own deadline reach the member as that answer, and makes the change finish after it (D4).
|
||||
- **A ledger row written by the reconcile for a downgrade the console's schedule carries**, **a commitment's deferred move surviving an upgrade the console learns of from Stripe**, and **the member's answer when the reconcile after a recorded change fails.** Each needs a new rule; design.md states each as an issue under Open questions. A claimed sweep row whose Stripe call never acted is #177, which this change comments on.
|
||||
- **The reconcile's own calls** (#285) and **the paused switch's own calls and restore** (#278), which already carry deadlines.
|
||||
|
||||
## Capabilities
|
||||
|
||||
### New Capabilities
|
||||
|
||||
None.
|
||||
|
||||
### Modified Capabilities
|
||||
|
||||
- `stripe-subscription-management`: added "A plan change's Stripe calls carry deadlines of their own" (the first read on the request's context, later calls and the record on deadlines that outlast the request, idempotency keys, the downgrade's resend) and "The scheduled-change sweep fires a row only while its attempt leaves time for the call" (the sweep's 20-second calls and the stop before the attempt's deadline). Modified: "Member-initiated paid-to-paid plan switch" and "Member keeps the current plan", whose record clauses supersede the scheduled changes that were active when the change began.
|
||||
|
||||
## Impact
|
||||
|
||||
- **Code:** `internal/fulfillment/plan_change.go` (the deadlines, `stripeCallContext`, `setCallContext`, `recordContext`, `recordMove`, `requestEnded`, `activeMoves`, `supersedeBefore`, `readBeforeCancel`, `releaseSchedule`, `setCancelAtPeriodEnd`, `sendUpgrade`, `gotNoAnswer`, `sendScheduleUpdate`; every move's calls), `internal/fulfillment/reconcile.go` (`currentPrices` returns the error of a failed read), `internal/stripetest/mock.go` (`Call` records an explicit idempotency key), `internal/fulfillment/paused_switch.go` (`stripeCallContext` and `pausedSwitchUpdate` move to `plan_change.go`, `callTimeoutParams` and `pausedSwitchChargeTimeout` go), `internal/fulfillment/sweep.go` (`sweepCallTimeout`, `sweepRowMargin`, `roomToFire`, `errSweepOutOfTime`, `cancelAtStripe`, the item update's context).
|
||||
- **Schema, routes, strings:** none. **Stripe calls:** the same calls with the same forms; the downgrade sends its schedule update once more after an update that got no answer. The releases, the cancels and the voids now carry an idempotency key.
|
||||
- **Behaviour a deployment sees:** a plan change whose request runs out of time finishes and is recorded, at most about four minutes after its first write when every call runs to its deadline; the member reads the timeout answer meanwhile. A request that ends before the change's first write changes nothing. The sweep's write ends within its attempt, and a sweep short of time leaves the rest for its next attempt. A process that stops during such a change cuts it, as it does at present (design, Risks).
|
||||
- **Tests:** six new tests in `internal/fulfillment` for the plan changes, two for the sweep, one in `internal/server`; one reference in `paused_switch_test.go` follows the renamed charge deadline. No golden changes.
|
||||
- **Docs:** `docs/stripe.md`, `docs/testing.md`.
|
||||
|
||||
## Related issues
|
||||
|
||||
- **#295** is this change; it closes with it.
|
||||
- **#296** rewords the timeout answer. A write cut by its own deadline reaches the member as that answer, and the change finishes after it. A write that Stripe answers with a server error inside the request reaches the member as "We couldn't complete that change", as today; that is an unknown outcome too, and it is #296's scope. The two changes land in either order.
|
||||
- **#260** adds the stop path. A change that outlives its request is invisible to a stop that waits only for tracked requests; design Risks states the constraint, and the landing tasks post it there.
|
||||
+205
@@ -0,0 +1,205 @@
|
||||
## ADDED Requirements
|
||||
|
||||
### Requirement: A plan change's Stripe calls carry deadlines of their own
|
||||
|
||||
A member's plan change (a switch, Keep plan, Cancel plan) SHALL send its first Stripe call, a read of the subscription, with the request's context, and SHALL check that context again before its first Stripe write, so that a request that ends before then stops the change before any Stripe write and before any write to the console's records. Every later Stripe call of the change SHALL carry a deadline of its own and SHALL NOT end when the request ends or the member closes the page: sixty seconds for an upgrade's charge, whichever form sends it, and forty seconds for every other call, except the calls of the switch of a subscription paused for a grant made after its first read and before its charge or after its decline: its read of the draft invoices, its voids and its clear carry twenty seconds, because its restore decides the subscription's state again before the member is answered, and its restore's calls share one deadline of sixty seconds. After the change's last Stripe write, its writes to the scheduled-change ledger and the reconcile that follows SHALL run under one deadline of sixty seconds that the request's end does not cut, and SHALL supersede only the scheduled changes that were active when the change began. A move deferred to a commitment's end records its scheduled change on the request's context and supersedes every active scheduled change, as before, also after the void that a cancellation of a subscription that pays under a grant sends first. Each deadline of forty or sixty seconds SHALL be longer than the request's thirty-two seconds. The switch's proration preview SHALL send its invoice preview with the request's context.
|
||||
|
||||
Every Stripe write of a plan change and of the scheduled-change sweep SHALL carry request parameters, so that the Stripe library sends an idempotency key and reuses it across its own retries.
|
||||
|
||||
A Stripe write that Stripe refuses, that Stripe answers with a status of 500 or above, or that gets no answer before its deadline SHALL be returned or logged as the change returns or logs a refusal, and the change SHALL record nothing for it. A write gets no answer when its error carries no answer from Stripe. The downgrade's update of its subscription schedule is the exception: it SHALL carry an idempotency key the change chose, and when it gets no answer the change SHALL send it once more with the same parameters and the same key under a deadline of its own, and SHALL record the downgrade and reconcile when that second send is answered without error; otherwise it SHALL return the error of the second send and record nothing. An update Stripe answered with its error object, whether it refused it or answered with a status of 500 or above, SHALL NOT be sent again, because Stripe answers the same key with the same answer.
|
||||
|
||||
#### Scenario: A downgrade's calls carry their deadlines
|
||||
|
||||
- **WHEN** a member's downgrade runs under a request with a deadline, reads the subscription, creates a subscription schedule, writes its phases and reconciles
|
||||
- **THEN** the read SHALL carry the request's deadline
|
||||
- **AND** the schedule's creation and its update SHALL each carry a deadline forty seconds after it was sent
|
||||
- **AND** the reconcile's read of the subscription SHALL carry a deadline sixty seconds after the change's last Stripe write
|
||||
|
||||
#### Scenario: An upgrade's charge carries sixty seconds
|
||||
|
||||
- **WHEN** a member's upgrade sends its charge
|
||||
- **THEN** the charge SHALL carry a deadline sixty seconds after it was sent, and the release of a scheduled downgrade's schedule after it forty seconds
|
||||
|
||||
#### Scenario: A change outlives its request
|
||||
|
||||
- **WHEN** the member's request ends after a downgrade's schedule was created
|
||||
- **THEN** the change SHALL write the schedule's phases, record the scheduled downgrade and reconcile, and SHALL return no error
|
||||
|
||||
#### Scenario: A request that ends during the first read stops the change
|
||||
|
||||
- **WHEN** the member's request ends while a change's first read of the subscription is in flight
|
||||
- **THEN** the change SHALL return the request context's error
|
||||
- **AND** it SHALL send no further Stripe call and record nothing
|
||||
|
||||
#### Scenario: A request that ends after the first read stops the change
|
||||
|
||||
- **WHEN** the member's request ends after a change's first read was answered and before its first write
|
||||
- **THEN** the change SHALL return an error wrapping the request context's error
|
||||
- **AND** it SHALL send no further Stripe call and record nothing
|
||||
|
||||
#### Scenario: A late record leaves a change another request recorded
|
||||
|
||||
- **WHEN** a change outlives its request, and another request records a scheduled change on the same subscription before the first change's record runs
|
||||
- **THEN** the first change's record SHALL leave that scheduled change `scheduled`
|
||||
- **AND** it SHALL supersede the scheduled changes that were active when the first change began
|
||||
|
||||
#### Scenario: A downgrade update that landed without an answer is recorded
|
||||
|
||||
- **WHEN** the downgrade's schedule update gets no answer before its deadline, and the second send of it, with the same idempotency key, is answered without error
|
||||
- **THEN** the change SHALL record the scheduled downgrade, reconcile, and return no error
|
||||
|
||||
#### Scenario: A downgrade update that gets no answer twice records nothing
|
||||
|
||||
- **WHEN** the downgrade's schedule update and its second send both get no answer
|
||||
- **THEN** the change SHALL return the error of the second send and record no scheduled downgrade
|
||||
|
||||
#### Scenario: Another write without an answer is not sent again
|
||||
|
||||
- **WHEN** a cancellation's update of `cancel_at_period_end` gets no answer before its deadline
|
||||
- **THEN** the change SHALL return that error, record no scheduled cancellation, and send no further Stripe call
|
||||
|
||||
### Requirement: The scheduled-change sweep fires a row only while its attempt leaves time for the call
|
||||
|
||||
The scheduled-change sweep's cancel of a subscription and its update of an item's price SHALL each carry a deadline twenty seconds after it is sent, within the deadline of the sweep's attempt. Before each due row, the sweep SHALL check that at least thirty seconds remain before its attempt's deadline, twenty for the call and ten for the reads before it and the reconcile after it; when less remains, it SHALL claim no further row, SHALL leave the remaining rows `scheduled`, and SHALL return an error that names how many rows it left, so that the attempt is retried. A sweep whose context has no deadline SHALL fire every due row. A cancel or update that gets no answer before its deadline SHALL be handled as the sweep handles a refused one.
|
||||
|
||||
#### Scenario: The sweep's cancel carries its deadline
|
||||
|
||||
- **WHEN** the sweep fires a due cancellation under an attempt with an hour left
|
||||
- **THEN** its cancel of the subscription SHALL carry a deadline twenty seconds after it was sent
|
||||
|
||||
#### Scenario: The sweep leaves a row it has no time to fire
|
||||
|
||||
- **WHEN** a cancellation is due and the sweep's attempt has less than thirty seconds left
|
||||
- **THEN** the sweep SHALL make no Stripe call, SHALL leave the row `scheduled`, and SHALL return the error that leaves the rows for the next attempt
|
||||
|
||||
## MODIFIED Requirements
|
||||
|
||||
### Requirement: Member-initiated paid-to-paid plan switch
|
||||
|
||||
The system SHALL provide a member-initiated endpoint that moves an active paid subscription from its current tier to another paid tier on the same ladder by changing the **existing subscription item's price** to the target tier's default `price_id`. It SHALL NOT create a second subscription, nor cancel-and-recreate. The move's direction is decided by the tiers' ranks on the ladder, and the direction decides its timing:
|
||||
|
||||
- **Upgrade** (target of higher rank): the system SHALL update the item with the preview's `proration_date` (see "Live proration preview before a switch"), `proration_behavior=always_invoice` and `payment_behavior=pending_if_incomplete`, so Stripe invoices the prorated difference at once and changes the subscription only when that invoice is paid. Toward a price of another recurring interval than the price the subscription pays, Stripe does not prorate within the current period: the invoice charges the new price's full period less a credit for the unused part of the current one, and the subscription's billing cycle anchor moves to the moment of the switch, so the billing date moves to that day. The system SHALL NOT send `billing_cycle_anchor` on the update, so Stripe's rule applies. When the update applies, the system SHALL release a schedule a scheduled downgrade attached, supersede the scheduled changes that were active when the change began, and reconcile. When the invoice's payment needs the cardholder's approval, the system SHALL answer with the invoice's hosted page and change nothing else. When the payment fails otherwise, the system SHALL void that invoice, so the pending update can never apply, and SHALL answer that the card was declined, changing nothing else. Before it sends an upgrade, the system SHALL void the invoice of any pending update an earlier attempt left.
|
||||
- **Downgrade** (target of lower rank) on an evergreen subscription or under the `allow` policy: the system SHALL leave the current price in force until `current_period_end` and SHALL attach a Stripe subscription schedule whose next phase carries the target price with `proration_behavior=none` and whose end behavior is `release`, reusing the schedule already attached by an earlier downgrade, and SHALL record the current tier's console price id in the schedule's metadata as `scheduled_from_price_id`. A cancellation armed at period end SHALL be cleared first. The system SHALL supersede the scheduled changes that were active when the change began and record a `subscription_scheduled_changes` row with `change_type='plan_switch'`, `effective_trigger='period_end'`, `effective_at=current_period_end` and the target `price_id`. No proration and no credit SHALL arise.
|
||||
|
||||
A request whose target is the current tier SHALL be refused as a move to the current tier; taking a scheduled move back is the keep endpoint's job (see "Member keeps the current plan").
|
||||
|
||||
When the item's price changes, `fulfillment.ReconcileSubscription`'s product-keyed diff (see "Subscription reconcile computes a genuine diff, grouped by product") SHALL converge it: the prior tier's product becomes surplus and SHALL be ended via `entitlements.end_conferral`, and the target tier's product becomes missing and SHALL be conferred via `entitlements.confer`, sourced by the subscription — so that exactly one tier's position is held on that ladder. The recorded position transitions and `subscription_changes` SHALL attribute the member as initiator for a change the member's request applied, and Stripe's event for a change a schedule applied. The console SHALL NOT store the schedule's id; it SHALL read the subscription's `schedule` from Stripe when a move needs it.
|
||||
|
||||
The target tier's product SHALL clear the member gate that checkout applies (published, active, public) before any subscription is read. A price can outlive its product's publication state, so the gate is checked on the product, independently of the listing queries that hide unpublished tiers from the catalog.
|
||||
|
||||
The switch SHALL also refuse, before any Stripe call, when a Stripe id it would send cannot be reached under the current API key: the target price mapping, the subscription mapping or the item mapping records an environment that disagrees with the key's mode, or carries `sync_status = 'stale'` (stripe-integration-infrastructure, Requirement: Mappings record the environment that made their ids). The refusal SHALL carry the unavailable-plan message the unpublished-tier refusal carries and SHALL change nothing. A target price the key cannot reach is not purchasable, so the offer decision refuses it before the switch reads the subscription (below); for an unreachable subscription or item mapping the switch SHALL log `plan_change: price mapping unreachable under the current key; skipping` with the row id, the Stripe id, `recorded_mode` and `key_mode`, naming `stale` in place of the recorded mode for a stale row. An unverified mapping is not a disagreement and SHALL switch as it does today.
|
||||
|
||||
On a ladder with a held rung (`member-product-discovery`, "Plan-move controls reflect built billing mechanics"; `grant-resumption`), the switch SHALL also refuse, before any Stripe call, the held rung and every rung ranked below both the held rung and the current rung, because the catalog reaches the held rung only by a cancellation at the period end (through the held rung's own "Downgrade" when it ranks below the current rung, and through the current card's "Cancel plan" when it ranks above) and offers no move below both. It SHALL read the held rung through the same position loader, and decide through the same offer decision, that the catalog's routing uses. The refusal SHALL carry the unavailable-plan message and SHALL change nothing, so a page rendered before the grant was marked cannot schedule a switch the catalog no longer offers.
|
||||
|
||||
The switch SHALL accept only a target that the offer decision (`member-offer`, "One decision says which move a member is offered on a ladder") offers as an enabled switch on the named ladder, asked with the organization's position on that ladder and the posted price. So it SHALL refuse, before any Stripe call and changing nothing: a price that is not the target product's default price, an inactive price, a one-time price, a target that is not a tier of the named ladder, the tier a scheduled downgrade already moves to, and every target the catalog reaches through Checkout, through the cancellation, or not at all. A target that fails the member gate, and a price id that is malformed or unknown, SHALL be refused with the unavailable-plan message before the order below applies. For every other target the refusal's answer SHALL be chosen in this order, first match wins: a target that is not a tier of the named ladder SHALL be refused with the unavailable-plan message, whatever holds the ladder; then a position that is suspended SHALL be refused with "Pay the unpaid invoice before changing this plan.", or with "A paused plan cannot be changed." when its subscription is `paused` ("A suspended subscription is kept or cancelled, never switched"); then a ladder no subscription pays for SHALL be refused with the answer for a ladder without an active paid subscription; then a target the decision answers as the current tier SHALL be refused as a move to the current tier; every other refusal SHALL carry the unavailable-plan message ("That plan is no longer available. Refresh the page and choose a current plan."). When any read the decision needs fails (the ladder's tiers, the organization's default pool, the positions in it with their provisions, subscriptions, scheduled changes and a scheduled downgrade's target product, the held grant, the cancel prediction, the target product's prices, or its price mapping for a reason other than its absence), the switch SHALL refuse, before any Stripe call, with the move-failed message ("We couldn't complete that change. Try again in a moment.") and SHALL change nothing.
|
||||
|
||||
#### Scenario: Switch modifies the existing subscription in place
|
||||
|
||||
- **WHEN** a member on a paid tier of a ladder moves to another paid tier on the same ladder, up or down
|
||||
- **THEN** the price SHALL change on the existing subscription's item, at once for an upgrade and at `current_period_end` for a downgrade
|
||||
- **AND** the system SHALL NOT create a new subscription or cancel the existing one
|
||||
|
||||
#### Scenario: An upgrade is charged now and applies once paid
|
||||
|
||||
- **WHEN** a member on Plus moves up to Pro, priced at Plus's interval, mid-period and the card on file pays the invoice
|
||||
- **THEN** Stripe SHALL issue an invoice for the prorated difference at once and change the item's price to Pro's
|
||||
- **AND** reconcile's diff SHALL end Plus's conferral and confer Pro's, so exactly one tier's position is held on that ladder
|
||||
|
||||
#### Scenario: An upgrade to a price of another interval starts a new period
|
||||
|
||||
- **WHEN** a member on Plus at $30.00 a month moves up to Max at $300.00 a year a third of a day into the period, and the card on file pays the invoice
|
||||
- **THEN** Stripe SHALL charge $300.00 less a credit for the unused part of the month, the subscription's period SHALL start at the switch and end a year later, and the update SHALL carry no `billing_cycle_anchor`
|
||||
- **AND** after the reconcile, Max's card on the Products page SHALL show the renewal date a year after the switch
|
||||
|
||||
#### Scenario: A declined upgrade changes nothing
|
||||
|
||||
- **WHEN** the card on file declines an upgrade's invoice
|
||||
- **THEN** the system SHALL void that invoice and answer that the card was declined
|
||||
- **AND** the subscription's price, the member's tier, a pending cancellation and a scheduled downgrade SHALL be as they were before the request
|
||||
|
||||
#### Scenario: An upgrade awaiting the bank's approval links to Stripe
|
||||
|
||||
- **WHEN** an upgrade's invoice needs the cardholder's approval
|
||||
- **THEN** the system SHALL answer with the amount and the invoice's hosted page, and SHALL change nothing else
|
||||
- **AND** when the member approves the payment there, the tier SHALL change through the resulting subscription event and reconcile
|
||||
|
||||
#### Scenario: A downgrade takes effect at renewal
|
||||
|
||||
- **WHEN** a member on Pro moves down to Plus on an evergreen subscription mid-period
|
||||
- **THEN** the member SHALL keep Pro until `current_period_end`, no proration or credit SHALL arise, and a `plan_switch` row with `effective_trigger='period_end'` SHALL be scheduled
|
||||
- **AND** the renewal invoice SHALL bill Plus's price, and reconcile SHALL move the tier when Stripe enters the schedule's next phase
|
||||
|
||||
#### Scenario: A second downgrade replaces the first
|
||||
|
||||
- **WHEN** a member with a downgrade to Plus scheduled chooses Basic before renewal
|
||||
- **THEN** the schedule's next phase SHALL carry Basic's price, the Plus row SHALL be superseded and a Basic row scheduled
|
||||
|
||||
#### Scenario: An upgrade drops a scheduled downgrade once it lands
|
||||
|
||||
- **WHEN** a member with a downgrade to Plus scheduled moves up to Premium and the invoice is paid
|
||||
- **THEN** the schedule SHALL be released and the scheduled row superseded, and the renewal SHALL bill Premium
|
||||
|
||||
#### Scenario: Switch is rejected when there is no active paid subscription on the ladder
|
||||
|
||||
- **WHEN** a member requests a paid-to-paid switch but holds no active paid subscription on the target ladder
|
||||
- **THEN** the system SHALL reject the request rather than create a subscription (the free/default→paid path is owned by `member-plan-upgrade` / Checkout)
|
||||
|
||||
#### Scenario: A tier of another ladder is unavailable whatever holds the named ladder
|
||||
|
||||
- **WHEN** a member whose organization holds nothing on the named ladder posts a switch to a published tier of another ladder
|
||||
- **THEN** the request SHALL be refused with the unavailable-plan message, not with the answer for a ladder without an active paid subscription, and nothing SHALL change
|
||||
|
||||
#### Scenario: Switch onto an unpublished tier is refused
|
||||
|
||||
- **WHEN** a member posts a switch whose target price belongs to a product that is draft, retired, inactive or not public, even one with an active, Stripe-mapped price
|
||||
- **THEN** the request SHALL be refused with the unavailable-plan message before any subscription is read, and nothing SHALL change
|
||||
|
||||
#### Scenario: Switch onto a price the key cannot reach is refused
|
||||
|
||||
- **WHEN** a member posts a switch whose target price mapping, subscription mapping or item mapping is recorded in the environment the key is not in, or carries `sync_status = 'stale'`
|
||||
- **THEN** the request SHALL be refused with the unavailable-plan message before any Stripe call, and nothing SHALL change
|
||||
- **AND** for an unreachable subscription or item mapping the system SHALL log `plan_change: price mapping unreachable under the current key; skipping` with the row id, the Stripe id, `recorded_mode` and `key_mode`
|
||||
|
||||
#### Scenario: A switch to a held rung is refused
|
||||
|
||||
- **WHEN** a member on Pro, whose subscription bought over a grant for Plus that is marked to resume and holds Plus as the held rung, posts a switch to Plus, or to Basic ranked below Plus
|
||||
- **THEN** the request SHALL be refused with the unavailable-plan message before any Stripe call, and nothing SHALL be scheduled or changed
|
||||
|
||||
#### Scenario: A switch to a price other than the default is refused
|
||||
|
||||
- **WHEN** a member on Pro by subscription posts a switch to an active, mapped price of Plus that is not Plus's default price
|
||||
- **THEN** the request SHALL be refused with the unavailable-plan message before any Stripe call, and nothing SHALL change
|
||||
|
||||
#### Scenario: A switch to a one-time price is refused
|
||||
|
||||
- **WHEN** a member on Pro by subscription posts a switch to Plus, whose default price is one-time
|
||||
- **THEN** the request SHALL be refused with the unavailable-plan message before any Stripe call, and nothing SHALL change
|
||||
|
||||
#### Scenario: A switch to the tier a scheduled downgrade moves to is refused
|
||||
|
||||
- **WHEN** a member on Pro with a downgrade to Standard scheduled posts a switch to Standard's default price
|
||||
- **THEN** the request SHALL be refused with the unavailable-plan message before any Stripe call, and the scheduled downgrade SHALL stay as it was
|
||||
|
||||
#### Scenario: A switch refuses when a read fails
|
||||
|
||||
- **WHEN** a read the offer decision needs fails while a member's switch is checked, such as the organization's default pool or the positions in it, the held grant or the target product's prices
|
||||
- **THEN** the request SHALL be refused with the move-failed message, no Stripe call SHALL be made, and nothing SHALL change
|
||||
|
||||
### Requirement: Member keeps the current plan
|
||||
|
||||
The system SHALL provide a member-initiated endpoint that, given a ladder, drops whatever move is scheduled on the member's active subscription on that ladder: it SHALL release an attached subscription schedule, clear Stripe's `cancel_at_period_end`, supersede the scheduled changes that were active when the change began, and reconcile. It SHALL charge nothing and SHALL change nothing when no move is scheduled. It SHALL name only the ladder, never a price, so a member on a price an operator has since retired can still keep the plan.
|
||||
|
||||
#### Scenario: Keeping drops a scheduled downgrade
|
||||
|
||||
- **WHEN** a member with a downgrade to Plus scheduled keeps Pro before renewal
|
||||
- **THEN** the system SHALL release the schedule and supersede the scheduled row, nothing SHALL be charged, and the renewal SHALL bill Pro
|
||||
|
||||
#### Scenario: Keeping drops a scheduled cancellation
|
||||
|
||||
- **WHEN** a member with a period-end cancellation scheduled keeps the plan
|
||||
- **THEN** Stripe's `cancel_at_period_end` SHALL be false, the cancellation row SHALL be superseded, and the subscription SHALL renew
|
||||
|
||||
#### Scenario: Keeping with nothing scheduled changes nothing
|
||||
|
||||
- **WHEN** a member keeps a plan that has no scheduled move
|
||||
- **THEN** the system SHALL make no Stripe change
|
||||
@@ -0,0 +1,502 @@
|
||||
Tests follow `docs/testing.md`: database tests open the database through `dbtest`, build their rows through the test kit or the package's fixtures, filter to the rows their own fixture made, and pass twice in a row without a reset. A Stripe test drives `stripetest.MockBackend` with the API state Stripe would return, never a hand-built event payload, and runs without `t.Parallel()`. The sweep's tests run on a scratch database (`newTraceEnv(t, true)`), because the sweep fires every due row of its database. A request that ends is a context the test cancels from the mock's `Handle` or `OnCall` when a named call arrives; a write without an answer is a `Handle` that answers `&url.Error{Op: c.Method, URL: "https://api.stripe.com" + c.Path, Err: context.DeadlineExceeded}`, the shape stripe-go returns.
|
||||
|
||||
Sections 1 to 5 are the parts of one commit; section 6 is not a commit. The commit passes `make test` with the test stack up, which runs `make lint` first and then every package one at a time (`-p 1`). No golden, route table or existing test result changes: `stripetest.Line` prints no context, a call whose params carry only a context encodes the form that `nil` encoded, and every existing fixture's calls answer at once. Any such change is a stop. The edits to existing test files are item 4.8's rename and `export_test.go` (items 1.4 and 3.4); the test kit's one edit is item 4.9. A script's usage is learned from its header or source, never by running it.
|
||||
|
||||
**Breaks.** The list of item 4.12 is written to a file outside the repository and run with `go run ./scripts/mutation-check -list <file> -side new` on the commit, from the repository root, with `test/.env` loaded as `make test` loads it (without it the database tests skip and every record reports SKIPPED), after its green control run; the tests are new, so there is no old side. A break of Go code is a test record (`name`, `file`, `old`, `new`, `pkg`, `run_new`, `want_new`). A break caught by two tests is two records, one per test, with the same `file`, `old` and `new`. Every record's `want_new` is a text the failing test prints only when it fails, never a test or subtest name alone. Every record must be CAUGHT. An `old` text below is the code as the section's items write it; when the committed code words it differently, the record is corrected to the committed text before the run, never dropped, and its `new` text keeps the package compiling. A break that no test of the commit sees is named with the reason, and the commit message says so. The commit message lists every break with its result.
|
||||
|
||||
**Copy.** No user-visible string changes; there is no Copy table. The new and changed texts are log lines and error wrappings.
|
||||
|
||||
## 1. A plan change's deadlines
|
||||
|
||||
- [x] 1.1 `internal/fulfillment/plan_change.go`, after `PreviewValidFor` (design D1): the deadlines and the helpers that apply them. `stripeCallContext` moves here from `paused_switch.go` with this comment; `callTimeoutParams` is deleted.
|
||||
|
||||
```go
|
||||
// A plan change's deadlines (design D1). The change's first Stripe call, a
|
||||
// read of the subscription, carries the request's context, and the change
|
||||
// checks it again before its first write (requestEnded), so a request that
|
||||
// ends before then stops the change before anything is written. Every later
|
||||
// call carries a deadline of its own and not the request's cancellation
|
||||
// (stripeCallContext): PlanChangeChargeTimeout for an upgrade's charge,
|
||||
// PlanChangeCallTimeout for every other call but the paused switch's own
|
||||
// (pausedSwitchCallTimeout, paused_switch.go). After its last Stripe write
|
||||
// the change records what it did and reconciles under
|
||||
// PlanChangeRecordTimeout (recordMove). Each is longer than the request's 32
|
||||
// seconds (requestTimeout in internal/server;
|
||||
// TestPlanChangeDeadlinesOutlastTheRequest), so a call cut by its deadline
|
||||
// ends after the member has had the timeout answer. The charge's is longer
|
||||
// still, because a deadline that cuts a slow but successful charge leaves
|
||||
// the member charged with no answer.
|
||||
const (
|
||||
PlanChangeCallTimeout = 40 * time.Second
|
||||
PlanChangeChargeTimeout = 60 * time.Second
|
||||
PlanChangeRecordTimeout = 60 * time.Second
|
||||
)
|
||||
|
||||
// stripeCallContext is the context of one Stripe call a plan change makes
|
||||
// after its first: the request's values with a deadline of d, and not the
|
||||
// request's cancellation. A member who closes the page, or a request that
|
||||
// runs out of time, must not cut a write or the record that follows it.
|
||||
func stripeCallContext(ctx context.Context, d time.Duration) (context.Context, context.CancelFunc) {
|
||||
return context.WithTimeout(context.WithoutCancel(ctx), d)
|
||||
}
|
||||
|
||||
// setCallContext sets on p a stripeCallContext of d and returns its cancel.
|
||||
// A write sent with params carries the idempotency key stripe-go adds and
|
||||
// reuses across its own retries.
|
||||
func setCallContext(ctx context.Context, p *stripe.Params, d time.Duration) context.CancelFunc {
|
||||
cctx, cancel := stripeCallContext(ctx, d)
|
||||
p.Context = cctx
|
||||
return cancel
|
||||
}
|
||||
|
||||
// recordContext is the context of a plan change's record (recordMove): the
|
||||
// request's values with PlanChangeRecordTimeout, and not its cancellation.
|
||||
func recordContext(ctx context.Context) (context.Context, context.CancelFunc) {
|
||||
return stripeCallContext(ctx, PlanChangeRecordTimeout)
|
||||
}
|
||||
|
||||
// requestEnded returns an error wrapping ctx's when the request has ended.
|
||||
// Each move calls it immediately before its first Stripe write, so a request
|
||||
// that ends after the first read answered stops the change.
|
||||
func requestEnded(ctx context.Context, move string) error {
|
||||
if err := ctx.Err(); err != nil {
|
||||
return fmt.Errorf("%s: the request ended before the change: %w", move, err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// activeMoves lists the subscription's active scheduled changes. Each move
|
||||
// lists them before its first Stripe call, on the request's context, and
|
||||
// passes them to recordMove, which supersedes those rows and no other.
|
||||
func activeMoves(ctx context.Context, billingQ *billing.Queries, ls ladderSubscription, move string) ([]billing.SubscriptionScheduledChange, error) {
|
||||
rows, err := billingQ.ListActiveScheduledChangesBySubscription(ctx, ls.coreSubID)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("%s: list scheduled changes: %w", move, err)
|
||||
}
|
||||
return rows, nil
|
||||
}
|
||||
|
||||
// supersedeBefore supersedes the scheduled changes in before one by one. A
|
||||
// row another path already closed is skipped.
|
||||
func supersedeBefore(ctx context.Context, billingQ *billing.Queries, before []billing.SubscriptionScheduledChange) error {
|
||||
for _, row := range before {
|
||||
if _, err := billingQ.SupersedeScheduledChange(ctx, row.ScheduledChangeID); err != nil && !errors.Is(err, sql.ErrNoRows) {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// recordMove is a plan change's record after its last Stripe write: it
|
||||
// supersedes the scheduled changes in before, the rows that were active when
|
||||
// the change began, and no row another request wrote since; records change
|
||||
// when it is set; and reconciles, all under recordContext, so neither a
|
||||
// member who closes the page nor a request that runs out of time leaves the
|
||||
// write at Stripe unrecorded. move prefixes its errors.
|
||||
func recordMove(ctx context.Context, db *sql.DB, logger *slog.Logger, keyMode string, billingQ *billing.Queries, move string, ls ladderSubscription, before []billing.SubscriptionScheduledChange, change *billing.CreateScheduledChangeParams, reason string) error {
|
||||
rctx, cancel := recordContext(ctx)
|
||||
defer cancel()
|
||||
if err := supersedeBefore(rctx, billingQ, before); err != nil {
|
||||
return fmt.Errorf("%s: supersede prior scheduled changes: %w", move, err)
|
||||
}
|
||||
if change != nil {
|
||||
if _, err := billingQ.CreateScheduledChange(rctx, *change); err != nil {
|
||||
return fmt.Errorf("%s: record the scheduled change: %w", move, err)
|
||||
}
|
||||
}
|
||||
return ReconcileSubscription(rctx, db, logger, keyMode, ls.stripeSubID, reason)
|
||||
}
|
||||
|
||||
// releaseSchedule releases the subscription schedule id under a
|
||||
// stripeCallContext of PlanChangeCallTimeout.
|
||||
func releaseSchedule(ctx context.Context, id string) error {
|
||||
params := &stripe.SubscriptionScheduleReleaseParams{}
|
||||
cancel := setCallContext(ctx, ¶ms.Params, PlanChangeCallTimeout)
|
||||
defer cancel()
|
||||
_, err := subscriptionschedule.Release(id, params)
|
||||
return err
|
||||
}
|
||||
|
||||
// setCancelAtPeriodEnd sets the subscription's cancel_at_period_end to on
|
||||
// under a stripeCallContext of PlanChangeCallTimeout.
|
||||
func setCancelAtPeriodEnd(ctx context.Context, stripeSubID string, on bool) error {
|
||||
params := &stripe.SubscriptionParams{CancelAtPeriodEnd: stripe.Bool(on)}
|
||||
cancel := setCallContext(ctx, ¶ms.Params, PlanChangeCallTimeout)
|
||||
defer cancel()
|
||||
_, err := subscription.Update(stripeSubID, params)
|
||||
return err
|
||||
}
|
||||
```
|
||||
|
||||
- [x] 1.2 `voidAbandonedUpgrade` and `upgradeAnswer` take the void's deadline in place of its params, so each void's deadline starts when the void is sent. Their doc comments replace "params carries the call's context, nil for none" and "voidParams carries the void's context, nil for none" with "d is the void's deadline, a stripeCallContext".
|
||||
|
||||
```go
|
||||
func voidAbandonedUpgrade(ctx context.Context, current *stripe.Subscription, d time.Duration) error {
|
||||
if current.PendingUpdate != nil && current.LatestInvoice != nil && current.LatestInvoice.Status == stripe.InvoiceStatusOpen {
|
||||
params := &stripe.InvoiceVoidInvoiceParams{}
|
||||
cancel := setCallContext(ctx, ¶ms.Params, d)
|
||||
defer cancel()
|
||||
_, err := invoice.VoidInvoice(current.LatestInvoice.ID, params)
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
```
|
||||
|
||||
`upgradeAnswer(ctx context.Context, logger *slog.Logger, ls ladderSubscription, updated *stripe.Subscription, d time.Duration) error` keeps its body but for the void:
|
||||
|
||||
```go
|
||||
if inv != nil {
|
||||
params := &stripe.InvoiceVoidInvoiceParams{}
|
||||
cancel := setCallContext(ctx, ¶ms.Params, d)
|
||||
defer cancel()
|
||||
if _, verr := invoice.VoidInvoice(inv.ID, params); verr != nil {
|
||||
```
|
||||
|
||||
- [x] 1.3 `pausedSwitchUpdate` (`paused_switch.go:237-249`) moves to `plan_change.go` as `sendUpgrade`, beside `upgradeParams`, and both forms of the upgrade send it:
|
||||
|
||||
```go
|
||||
// sendUpgrade is the upgrade's one Stripe update, whichever form sends it
|
||||
// (upgradeNow, and pausedUpgrade's call 2), under a stripeCallContext of
|
||||
// PlanChangeChargeTimeout.
|
||||
func sendUpgrade(ctx context.Context, ls ladderSubscription, current *stripe.Subscription, targetStripePriceID string, prorationDate time.Time) (*stripe.Subscription, error) {
|
||||
params := upgradeParams(ls, current, targetStripePriceID, prorationDate)
|
||||
cancel := setCallContext(ctx, ¶ms.Params, PlanChangeChargeTimeout)
|
||||
defer cancel()
|
||||
updated, err := subscription.Update(ls.stripeSubID, params)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("switch: stripe upgrade subscription item: %w", err)
|
||||
}
|
||||
return updated, nil
|
||||
}
|
||||
```
|
||||
|
||||
- [x] 1.4 `internal/fulfillment/paused_switch.go`: `pausedSwitchChargeTimeout` is deleted. The deadlines' comment (`:29-47`) names `PlanChangeChargeTimeout` (`plan_change.go`) for call 2, with the same 60 seconds in its sum. `pausedUpgrade` sends `if err := voidAbandonedUpgrade(ctx, current, pausedSwitchCallTimeout); err != nil {` in place of `:195-200`, `updated, err := sendUpgrade(ctx, ls, current, targetStripePriceID, prorationDate)` in place of `pausedSwitchUpdate`, and `if err := upgradeAnswer(ctx, logger, ls, updated, pausedSwitchCallTimeout); err != nil {` in place of `:216-218`. `pausedUpgrade` takes the `before` list of item 1.12 from `upgradeNow` and passes it on. Its applied branch keeps `appliedUpgrade(context.WithoutCancel(ctx), ...)`, now with `before`; item 8 of its doc comment adds that the branch's release and record carry the plan change's deadlines. `export_test.go` drops `PausedSwitchChargeTimeout`; item 4.8 renames its one use.
|
||||
|
||||
- [x] 1.5 `upgradeNow` (`plan_change.go:308-332`):
|
||||
|
||||
```go
|
||||
before, err := activeMoves(ctx, billingQ, ls, "switch")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
current, err := subscription.Get(ls.stripeSubID, &stripe.SubscriptionParams{Params: stripe.Params{Context: ctx}, Expand: []*string{stripe.String("latest_invoice")}})
|
||||
if err != nil {
|
||||
return fmt.Errorf("switch: read subscription before upgrade: %w", err)
|
||||
}
|
||||
if consolePaused(current) {
|
||||
return pausedUpgrade(ctx, db, logger, keyMode, billingQ, ls, before, current, targetStripePriceID, prorationDate, reason)
|
||||
}
|
||||
if err := requestEnded(ctx, "switch"); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := voidAbandonedUpgrade(ctx, current, PlanChangeCallTimeout); err != nil {
|
||||
return fmt.Errorf("switch: void an abandoned upgrade's invoice: %w", err)
|
||||
}
|
||||
updated, err := sendUpgrade(ctx, ls, current, targetStripePriceID, prorationDate)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err := upgradeAnswer(ctx, logger, ls, updated, PlanChangeCallTimeout); err != nil {
|
||||
return err
|
||||
}
|
||||
return appliedUpgrade(ctx, db, logger, keyMode, billingQ, ls, before, current, updated, reason)
|
||||
```
|
||||
|
||||
- [x] 1.6 `appliedUpgrade` (`:407-422`) sends `if err := releaseSchedule(ctx, id.ID); err != nil {` in place of the `nil` release, keeping its warning, and ends with `return recordMove(ctx, db, logger, keyMode, billingQ, "switch", ls, before, nil, reason)`; it takes `before` from its caller. Its doc comment says that the release and the record carry the plan change's deadlines.
|
||||
|
||||
- [x] 1.7 `downgradeAtRenewal` (`:430-507`): `before, err := activeMoves(ctx, billingQ, ls, "switch")` is the first statement. The current item is read before any Stripe call, with a comment saying why (its console price goes into the schedule's metadata, and a read after the schedule's creation could fail after a write); then:
|
||||
|
||||
```go
|
||||
current, err := subscription.Get(ls.stripeSubID, &stripe.SubscriptionParams{Params: stripe.Params{Context: ctx}})
|
||||
```
|
||||
|
||||
Right after the first read and the item's read, `if err := requestEnded(ctx, "switch"); err != nil { return err }` runs before the clear and the schedule. The cancellation is cleared with `if err := setCancelAtPeriodEnd(ctx, ls.stripeSubID, false); err != nil {`. The schedule is opened with one set of params for the read or the creation:
|
||||
|
||||
```go
|
||||
var sch *stripe.SubscriptionSchedule
|
||||
open := &stripe.SubscriptionScheduleParams{}
|
||||
cancelOpen := setCallContext(ctx, &open.Params, PlanChangeCallTimeout)
|
||||
defer cancelOpen()
|
||||
if current.Schedule != nil {
|
||||
sch, err = subscriptionschedule.Get(current.Schedule.ID, open)
|
||||
} else {
|
||||
open.FromSubscription = stripe.String(ls.stripeSubID)
|
||||
sch, err = subscriptionschedule.New(open)
|
||||
}
|
||||
```
|
||||
|
||||
The update is sent by `sendScheduleUpdate` (section 2), which sets its idempotency key and its deadlines: `if err := sendScheduleUpdate(ctx, logger, ls.stripeSubID, sch.ID, update); err != nil { return fmt.Errorf("switch: schedule the downgrade: %w", err) }`. The function ends:
|
||||
|
||||
```go
|
||||
logger.Info("switch: downgrade scheduled for renewal",
|
||||
slog.String("subscription_id", ls.coreSubID), slog.Time("effective_at", time.Unix(current.CurrentPeriodEnd, 0)))
|
||||
return recordMove(ctx, db, logger, keyMode, billingQ, "switch", ls, before, &billing.CreateScheduledChangeParams{
|
||||
SubscriptionID: ls.coreSubID,
|
||||
ChangeType: "plan_switch",
|
||||
TargetPriceID: uuid.NullUUID{UUID: uuid.MustParse(targetPriceID), Valid: true},
|
||||
EffectiveAt: time.Unix(current.CurrentPeriodEnd, 0),
|
||||
EffectiveTrigger: "period_end",
|
||||
}, reason)
|
||||
```
|
||||
|
||||
- [x] 1.8 `KeepCurrentPlan` (`:521-565`):
|
||||
|
||||
```go
|
||||
current, err := subscription.Get(ls.stripeSubID, &stripe.SubscriptionParams{Params: stripe.Params{Context: ctx}, Expand: []*string{stripe.String("schedule")}})
|
||||
```
|
||||
|
||||
then `prices, err := currentPrices(ctx, internalstripe.New(db), current)` with `if err != nil { return fmt.Errorf("keep: read current prices: %w", err) }` in the suspended branch, `if err := requestEnded(ctx, "keep"); err != nil { return err }` after that branch and before the release, `if err := releaseSchedule(ctx, current.Schedule.ID); err != nil {` and `if err := setCancelAtPeriodEnd(ctx, ls.stripeSubID, false); err != nil {` with their existing errors, and `return recordMove(ctx, db, logger, keyMode, billingQ, "keep", ls, active, nil, reason)`, `active` being the list the function already takes. `currentPrices` keeps the request's context: it runs before the first write.
|
||||
|
||||
- [x] 1.9 `CancelSubscription` (`:745-836`): `before, err := activeMoves(ctx, billingQ, ls, "cancel")` runs before its first read. The read of a subscription that pays without holding its ladder is
|
||||
|
||||
```go
|
||||
current, err = subscription.Get(ls.stripeSubID, &stripe.SubscriptionParams{Params: stripe.Params{Context: ctx}, Expand: []*string{stripe.String("latest_invoice")}})
|
||||
```
|
||||
|
||||
followed by `if err := requestEnded(ctx, "cancel"); err != nil { return err }` and `if err := voidAbandonedUpgrade(ctx, current, PlanChangeCallTimeout); err != nil {`; the other read moves into a helper that the `if current == nil` block calls as `if current, err = readBeforeCancel(ctx, ls.stripeSubID); err != nil { return err }`, so each path checks the request once, before its first write (gocognit scores `CancelSubscription` 28):
|
||||
|
||||
```go
|
||||
// readBeforeCancel is the first Stripe call of the cancellation of a
|
||||
// subscription that holds its ladder, a read on the request's context, and
|
||||
// the check after it that the request has not ended (requestEnded).
|
||||
func readBeforeCancel(ctx context.Context, stripeSubID string) (*stripe.Subscription, error) {
|
||||
current, err := subscription.Get(stripeSubID, &stripe.SubscriptionParams{Params: stripe.Params{Context: ctx}})
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("cancel: read subscription: %w", err)
|
||||
}
|
||||
return current, requestEnded(ctx, "cancel")
|
||||
}
|
||||
```
|
||||
|
||||
The release is then `if err := releaseSchedule(ctx, current.Schedule.ID); err != nil {`, as before. The immediate timing:
|
||||
|
||||
```go
|
||||
case CancelImmediate:
|
||||
params := &stripe.SubscriptionCancelParams{}
|
||||
cancelCall := setCallContext(ctx, ¶ms.Params, PlanChangeCallTimeout)
|
||||
defer cancelCall()
|
||||
if _, err := subscription.Cancel(ls.stripeSubID, params); err != nil {
|
||||
return fmt.Errorf("cancel: stripe cancel subscription: %w", err)
|
||||
}
|
||||
return recordMove(ctx, db, logger, keyMode, billingQ, "cancel", ls, before, nil, reason)
|
||||
```
|
||||
|
||||
The period-end timing keeps its two comments, sets the flag with `if err := setCancelAtPeriodEnd(ctx, ls.stripeSubID, true); err != nil {` and its existing error, and ends with `return recordMove(ctx, db, logger, keyMode, billingQ, "cancel", ls, before, &billing.CreateScheduledChangeParams{SubscriptionID: ls.coreSubID, ChangeType: "cancellation", EffectiveAt: ls.sub.CurrentPeriodEnd.Time, EffectiveTrigger: "period_end"}, reason)`, laid out one field per line as gofmt leaves it. The commitment's deferred branch keeps `scheduleCancellation` on the request's context (design D1).
|
||||
|
||||
- [x] 1.10 `PreviewSwitch` (`:709-719`, ride-along, design D5): the params are built into `previewParams`, then `previewParams.Context = ctx`, then `inv, err := invoice.CreatePreview(previewParams)`.
|
||||
|
||||
- [x] 1.11 The doc comments of `SwitchPlan`, `KeepCurrentPlan` and `CancelSubscription` each gain one sentence: the first Stripe call carries ctx, and every later call and the record carry the deadlines of the plan change (`PlanChangeCallTimeout`), so the change finishes and is recorded when ctx ends after its first write. `PreviewSwitch`'s says that the preview carries ctx.
|
||||
|
||||
- [x] 1.12 `internal/fulfillment/reconcile.go`: `currentPrices` returns `(map[string]bool, error)`; a price with no mapping (`errors.Is(err, sql.ErrNoRows)`) is left out, and every other failed read returns its error, whatever the context's state. Its doc comment says so. `convergeScheduledChanges` returns `fmt.Errorf("reconcile: read the current prices: %w", err)` for it. No existing test result changes: a reconcile on an ended context already fails at its first statement.
|
||||
|
||||
## 2. The downgrade's resend
|
||||
|
||||
- [x] 2.1 `plan_change.go` (design D2):
|
||||
|
||||
```go
|
||||
// gotNoAnswer reports whether a Stripe write that returned err got no
|
||||
// answer from Stripe (a deadline, a cancellation, a network fault) or an answer that is not Stripe's error object, such as a gateway's error page, so
|
||||
// that it may or may not have taken effect. Stripe saves the answer it
|
||||
// gave under the request's idempotency key, a status of 500 or above
|
||||
// included, and returns it again to a send with that key, so only a write
|
||||
// that got no answer is worth sending again.
|
||||
func gotNoAnswer(err error) bool {
|
||||
var se *stripe.Error
|
||||
return !errors.As(err, &se)
|
||||
}
|
||||
|
||||
// sendScheduleUpdate sends the downgrade's update of the schedule scheduleID
|
||||
// under an idempotency key the change chooses and a stripeCallContext of
|
||||
// PlanChangeCallTimeout. When the update got no answer (gotNoAnswer), it
|
||||
// sends the same update once more with the same key under a fresh
|
||||
// stripeCallContext: Stripe answers a send that landed with its saved
|
||||
// answer, performs one that never arrived, and answers 409 to one it is
|
||||
// still executing. An update Stripe answered is not sent again.
|
||||
func sendScheduleUpdate(ctx context.Context, logger *slog.Logger, stripeSubID, scheduleID string, update *stripe.SubscriptionScheduleParams) error {
|
||||
update.IdempotencyKey = stripe.String(uuid.NewString())
|
||||
cancel := setCallContext(ctx, &update.Params, PlanChangeCallTimeout)
|
||||
_, err := subscriptionschedule.Update(scheduleID, update)
|
||||
cancel()
|
||||
if err == nil || !gotNoAnswer(err) {
|
||||
return err
|
||||
}
|
||||
logger.Warn("switch: the schedule's update got no answer; sending it again with the same idempotency key",
|
||||
slog.String("stripe_subscription_id", stripeSubID), slog.Any("error", err))
|
||||
cancel = setCallContext(ctx, &update.Params, PlanChangeCallTimeout)
|
||||
defer cancel()
|
||||
_, err = subscriptionschedule.Update(scheduleID, update)
|
||||
return err
|
||||
}
|
||||
```
|
||||
|
||||
- [x] 2.2 `downgradeAtRenewal` sends the update through `sendScheduleUpdate` (item 1.7); no other code of the move changes.
|
||||
|
||||
## 3. The sweep's deadline
|
||||
|
||||
- [x] 3.1 `internal/fulfillment/sweep.go` (design D3):
|
||||
|
||||
```go
|
||||
// sweepCallTimeout is the deadline of the Stripe write the sweep makes for a
|
||||
// claimed row, the cancel or the item update, inside the attempt's own
|
||||
// deadline. sweepRowMargin is the time the row's other work needs: the reads
|
||||
// before the write (the mapping reads, the claim, firePlanSwitch's reads) and
|
||||
// the reconcile's fetch after it. The sweep fires a row only while at least
|
||||
// both remain of its attempt (roomToFire), so the attempt's deadline does not
|
||||
// cut a write it started, and the attempt never outlives its deadline.
|
||||
const (
|
||||
sweepCallTimeout = 20 * time.Second
|
||||
sweepRowMargin = 10 * time.Second
|
||||
)
|
||||
|
||||
// errSweepOutOfTime is the sweep's error when its attempt has too little
|
||||
// time left to fire another due row; the rows it left stay scheduled for the
|
||||
// attempt Temporal retries.
|
||||
var errSweepOutOfTime = errors.New("sweep: too little of the attempt is left to fire another scheduled change")
|
||||
|
||||
// roomToFire reports whether ctx leaves at least sweepCallTimeout and
|
||||
// sweepRowMargin before its deadline. A context without a deadline always
|
||||
// does.
|
||||
func roomToFire(ctx context.Context) bool {
|
||||
deadline, ok := ctx.Deadline()
|
||||
return !ok || time.Until(deadline) >= sweepCallTimeout+sweepRowMargin
|
||||
}
|
||||
|
||||
// cancelAtStripe cancels the subscription at Stripe now, under a deadline of
|
||||
// sweepCallTimeout inside the attempt.
|
||||
func cancelAtStripe(ctx context.Context, stripeSubID string) error {
|
||||
cctx, cancel := context.WithTimeout(ctx, sweepCallTimeout)
|
||||
defer cancel()
|
||||
_, err := subscription.Cancel(stripeSubID, &stripe.SubscriptionCancelParams{Params: stripe.Params{Context: cctx}})
|
||||
return err
|
||||
}
|
||||
```
|
||||
|
||||
- [x] 3.2 `SweepDueScheduledChanges`'s loop checks before each row, and its doc comment says so:
|
||||
|
||||
```go
|
||||
for i, ch := range due {
|
||||
if !roomToFire(ctx) {
|
||||
return fired, fmt.Errorf("%w: %d due scheduled changes left for the next attempt", errSweepOutOfTime, len(due)-i)
|
||||
}
|
||||
```
|
||||
|
||||
- [x] 3.3 `fireCancellation` sends `} else if cerr := cancelAtStripe(ctx, stripeSubID); cerr != nil {`. `firePlanSwitch` builds its update into `params`, then `cctx, cancel := context.WithTimeout(ctx, sweepCallTimeout)`, `defer cancel()`, `params.Context = cctx`, and sends `subscription.Update(stripeSubID, params)`. Their doc comments say that the write carries `sweepCallTimeout` and that one cut by it is handled as a refused one.
|
||||
|
||||
- [x] 3.4 `export_test.go`: `const SweepCallTimeout = sweepCallTimeout`, `const SweepRowMargin = sweepRowMargin` and `var ErrSweepOutOfTime = errSweepOutOfTime`, with a comment naming the tests in `fulfillment_test` that read them.
|
||||
|
||||
## 4. The tests and their breaks
|
||||
|
||||
Items 4.1 to 4.4, 4.10 and 4.11 go in a new `internal/fulfillment/plan_change_deadline_test.go` and items 4.5 and 4.6 in a new `internal/fulfillment/sweep_deadline_db_test.go`, package `fulfillment_test`. They reuse `sentCall` and `checkDeadline` (`paused_switch_test.go:680-695`), `onTier1`, `onTier2`, `onTier`, `addTier`, `pendingSub`, `rowCount`, `mockSchedule` (`plan_switch_timing_test.go`), `payingUnderGrant` (`plan_change_paid_under_grant_test.go`), `activeAttachCount`, `coreSubID`, `scalarInt` (`reconcile_integration_test.go`), and `newTraceEnv`, `cancelAtPeriodEnd`, `deferredSwitch` and `(*traceEnv).answer` (the trace tests). `planCallName(c stripetest.Call) string` names a call: a `GET /v1/subscriptions/{id}` whose form expands `items.data.price` is "the fetch" and any other "the read"; a `POST /v1/subscriptions/{id}` is "the charge" when it carries `items[0][price]`, "the clear" when `cancel_at_period_end=false`, "the set" when `true`; `DELETE /v1/subscriptions/{id}` is "the cancel"; `POST /v1/subscription_schedules` "the creation"; `GET /v1/subscription_schedules/{id}` "the schedule's read"; `POST .../release` "the release"; another `POST /v1/subscription_schedules/{id}` "the update"; `POST /v1/invoices/{id}/void` "the void"; `POST /v1/invoices/create_preview` "the preview"; anything else `stripetest.Line(c)`.
|
||||
|
||||
- [x] 4.1 `TestPlanChangeCallsCarryTheirDeadlines`: each row builds its fixture, runs any earlier move under `context.Background()`, installs `mock.OnCall` to collect a `sentCall` for each call, and runs its change under `context.WithTimeout(context.Background(), time.Hour)`. `checkPlanChangeDeadlines` first checks the names of the calls sent, in order (failure text `%s: the change sent %v, want %v`, with `t.Fatalf`), then each call's deadline by its kind: "request", the context's own deadline (failure text `%s: %s carried deadline %v, want the request's %v`); "call", "charge" and "record", `checkDeadline` with `PlanChangeCallTimeout`, `PlanChangeChargeTimeout` and `PlanChangeRecordTimeout` (its texts `%s: %s carried no deadline` and `%s: %s's deadline is %v after it was sent, want %v`). Rows, with the calls and kinds they want:
|
||||
- `a downgrade`: `onTier2`, `SwitchPlan` to `fx.priceID`; the read (request), the creation (call), the update (call), the fetch (record).
|
||||
- `a downgrade over an armed cancellation`: `onTier2`, `CancelSubscription` at the period end first; the read, the clear (call), the creation, the update, the fetch.
|
||||
- `a downgrade over an earlier downgrade`: `onTier2`, the same downgrade first; the read, the schedule's read (call), the update, the fetch.
|
||||
- `an upgrade`: `onTier1`, `mock.Sub = onTier(fx, stripePrice2)`, `SwitchPlan` to `corePrice2` at `time.Now()`; the read, the charge (charge), the fetch.
|
||||
- `an upgrade over a scheduled downgrade`: the setup of `TestUpgradeDropsAScheduledDowngrade` (`plan_switch_timing_test.go:367-393`) up to its upgrade; the read, the charge, the release (call), the fetch.
|
||||
- `an upgrade over an abandoned attempt`: the setup of `TestAbandonedAttemptIsVoidedFirst`; the read, the void (call), the charge, the fetch.
|
||||
- `a declined upgrade`: `onTier1`, `mock.UpdateSub = pendingSub(fx, "in_declined", stripe.PaymentIntentStatusRequiresPaymentMethod)`, and the change must return `ErrUpgradeDeclined` (failure text `a declined upgrade: the change returned %v, want ErrUpgradeDeclined`, `t.Fatalf`); the read, the charge, the void (call).
|
||||
- `keep a downgrade`: `onTier2`, the downgrade first, `KeepCurrentPlan`; the read, the release, the fetch.
|
||||
- `keep a cancellation`: `onTier1`, a cancellation at the period end first, `KeepCurrentPlan`; the read, the clear, the fetch.
|
||||
- `cancel at the period end over a downgrade`: `onTier2`, the downgrade first, `CancelSubscription` at the period end; the read, the release, the set (call), the fetch.
|
||||
- `cancel now`: `onTier1`, `CancelSubscription` with `CancelImmediate`; the read, the cancel (call), the fetch.
|
||||
- `a cancellation under a grant`: `payingUnderGrant`, then `mock.Sub.PendingUpdate` and an open `mock.Sub.LatestInvoice` `in_abandoned` as `pendingSub` builds them, `CancelSubscription` at the period end; the read, the void, the set, the fetch.
|
||||
- `the preview of an upgrade`: `onTier1`, `mock.Invoice = &stripe.Invoice{AmountDue: 900, Currency: stripe.CurrencyUSD}`, `PreviewSwitch` to `corePrice2`, which must be available (failure text `the preview of an upgrade: the preview is unavailable`, `t.Fatalf`); the preview (request).
|
||||
|
||||
- [x] 4.2 `TestAPlanChangeOutlivesItsRequest`: each row runs its change under `context.WithCancel(context.Background())` and wraps the mock's `Handle` so that the call it names cancels the context and then falls through to `stripeLike`'s answer. The change returns nil (failure text `%s: after the request ended at %s, the change returned %v, want nil`); the row's count holds (failure text `%s: after the request ended at %s, %s = %d, want %d`); and no call after the named one carries a done context (failure text `%s: %s was sent with the ended request's context`). Rows:
|
||||
- `a downgrade`, ended at the creation: `onTier2`, `SwitchPlan` to `fx.priceID`; "scheduled downgrades" (`rowCount` `plan_switch`, `period_end`, `scheduled`) = 1.
|
||||
- `a cancellation at the period end`, ended at the set: `onTier1`; "scheduled cancellations" = 1.
|
||||
- `keep a downgrade`, ended at the release: `onTier2`, the downgrade under `context.Background()` first; "superseded downgrades" = 1.
|
||||
- `an upgrade`, ended at the charge: `onTier1`, `mock.Sub = onTier(fx, stripePrice2)`; "attachments of the new tier" (`activeAttachCount` of `prod2`) = 1.
|
||||
- `a cancellation under a grant`, ended at the void: `payingUnderGrant`, then `mock.Sub.PendingUpdate` and an open `mock.Sub.LatestInvoice` `in_abandoned` as item 4.1's row builds them; "scheduled cancellations" = 1.
|
||||
|
||||
- [x] 4.3 `TestARequestThatEndsDuringTheFirstReadStopsTheChange`: each row runs its change under `context.WithCancel(context.Background())` with `mock.OnCall` cancelling it when the first call of the change arrives, so the mock answers that read with the context's error. The change returns an error wrapping `context.Canceled` (failure text `%s: the request ended during the first read and the change returned %v, want context.Canceled`); it sends nothing after the read (failure text `%s: the request ended during the first read and the change sent %v after it, want nothing`); the row's count is unchanged (failure text `%s: the request ended during the first read and %s = %d, want %d`). A fourth check is that the error is the read's and not `requestEnded`'s: `strings.Contains(err.Error(), "the request ended before the change")` is false (failure text `%s: the request ended during the first read and the change returned %v, want the read's error`). The four checks use `t.Errorf`, so that a break of a first read reports the check it names. Rows: `a downgrade` (scheduled downgrades 0), `an upgrade` (attachments of the new tier 0), `keep a downgrade` (superseded downgrades 0), `cancel at the period end` (scheduled cancellations 0), with the fixtures of item 4.2.
|
||||
|
||||
- [x] 4.4 `TestADowngradeUpdateWithoutAnAnswerIsResent`: `noAnswer(c)` (the preamble's `*url.Error`) and `scheduleFrom(c)` are new helpers in `plan_change_deadline_test.go`. Its checks use `t.Errorf`; in `a refused update` every update answers the 400. Each row wraps `Handle` so the named write answers as the row says, collects `sentCall`s, and checks the change's error (failure text `%s: the change returned %v, want %s`), a count (failure text `%s: the change left %d %s, want %d`), and the names of the calls sent after the first update (failure text `%s: the change sent %v after %s, want %v`). Where the update is sent twice, the two carry one non-empty `Call.IdempotencyKey` (failure texts `%s: the first send carried no idempotency key` and `%s: the resend's idempotency key is %q, want the first send's %q`), and `checkDeadline` with `PlanChangeCallTimeout` names the second "the resend" (the first is read by item 4.1). Rows:
|
||||
- `an update that landed`: `onTier2`; the first update falls through to `stripeLike`, then sets `mock.Schedule` to the schedule its form wrote (`scheduleFrom(c)`: the `scheduled_from_price_id` metadata and the two phases' `items[0][price]`), and answers `noAnswer(c)`; the second falls through, answering what Stripe would from its saved answer. Want nil, 1 scheduled downgrade, then the update again and the fetch.
|
||||
- `an update answered 500`: `onTier2`; every update applies through `stripeLike` and answers `&stripe.Error{Type: stripe.ErrorTypeAPI, HTTPStatusCode: 500, Msg: "server error"}`, as Stripe replays a saved 500. Want an error in which `errors.As` finds a `*stripe.Error` with status 500 ("Stripe's server error"), 0 scheduled downgrades, nothing after the update.
|
||||
- `an update that did not land`: `onTier2`; the first update answers `noAnswer(c)` without applying anything; the second falls through to `stripeLike` and applies it. Want nil, 1 scheduled downgrade, then the update again and the fetch.
|
||||
- `a resend that gets no answer`: `onTier2`; both updates answer `noAnswer(c)`. Want an error wrapping `context.DeadlineExceeded` ("the resend's error"), 0 scheduled downgrades, the update again alone.
|
||||
- `a refused update`: `onTier2`; the update answers `&stripe.Error{Type: stripe.ErrorTypeInvalidRequest, HTTPStatusCode: 400, Msg: "refused"}`. Want an error that `errors.As` finds a `*stripe.Error` in ("Stripe's refusal"), 0 scheduled downgrades, nothing after the update.
|
||||
- `a flag without an answer`: `onTier1`, `CancelSubscription` at the period end; the set answers `noAnswer(c)`. Want the set's error ("the flag's error"), 0 scheduled cancellations, nothing after the set. It is a guard row: no code of this change sends a flag again, so it has no break, and the commit message says so.
|
||||
|
||||
- [x] 4.5 `TestSweepWritesCarryTheirDeadline` (`sweep_deadline_db_test.go`): rows `a due cancellation` (`e.answer()`, `cancelAtPeriodEnd(e)`, the cancel) and `a term-boundary switch` (`e.answer()`, `deferredSwitch(e)`, the update), each on `newTraceEnv(t, true)` with `e.stripeLike()`. The sweep runs under `context.WithTimeout(context.Background(), time.Hour)` with `e.mock.OnCall` wrapped to collect `sentCall`s. Failure texts: `%s: the sweep returned %d, %v, want 1 and no error`; `%s: the sweep sent %v, want %s among them` (`t.Fatalf`); then `checkDeadline` with `SweepCallTimeout`.
|
||||
|
||||
- [x] 4.6 `TestSweepLeavesARowItHasNoTimeToFire`: `newTraceEnv(t, true)`, `e.stripeLike()`, `e.answer()`, `cancelAtPeriodEnd(e)`, then the sweep under a context whose deadline is `SweepCallTimeout + SweepRowMargin/2` away, which leaves the call's time and not the row's margin. Failure texts: `a sweep with %v left returned %d, %v, want 0 and ErrSweepOutOfTime` (`errors.Is`); `a sweep with %v left sent %v, want nothing`; `a sweep with %v left left %d cancellations scheduled, want 1` (`rowCount` on `e.w.DB`).
|
||||
|
||||
- [x] 4.7 `TestPlanChangeDeadlinesOutlastTheRequest` (`internal/server/server_timeouts_test.go`): for each of `fulfillment.PlanChangeCallTimeout`, `PlanChangeChargeTimeout` and `PlanChangeRecordTimeout`, failure text `%s is %v, want longer than the request's %v` when it is not longer than `requestTimeout`.
|
||||
|
||||
- [x] 4.8 `paused_switch_test.go:728` reads `fulfillment.PlanChangeChargeTimeout` in place of `fulfillment.PausedSwitchChargeTimeout`. Every other existing test keeps its result: `TestRefusedCancellationRecordsNothing` still refuses with a plain error, which the set returns; the paused switch's deadline tests read 20 seconds for its void and clear and 60 for its update, as before; the trace goldens print the same calls and forms.
|
||||
|
||||
- [x] 4.9 `internal/stripetest/mock.go`: `Call` gains `IdempotencyKey string`, "the key the request's params carried explicitly; empty when stripe-go would generate one", set in `newCall` from `params.GetParams().IdempotencyKey` when it is not nil. `Line` does not print it, so no golden changes. `docs/testing.md` gains the sentence of item 5.2.
|
||||
|
||||
- [x] 4.10 `TestARequestThatEndsAfterTheFirstReadStopsTheChange`: each row runs its change under `context.WithCancel(context.Background())` and wraps `Handle` so that the change's first call falls through to `stripeLike`'s answer and then cancels the context: the read answers, and the request has ended. The change returns an error wrapping `context.Canceled` (failure text `%s: the request ended after the first read and the change returned %v, want context.Canceled`); it sends nothing after the read (failure text `%s: the request ended after the first read and the change sent %v after it, want nothing`); the row's count is unchanged (failure text `%s: the request ended after the first read and %s = %d, want %d`). The three checks use `t.Errorf`. Rows: the four of item 4.3, and `keep on a suspended subscription`: the setup of `TestKeepRefusesADowngradeThatLanded` (`suspended_subscription_test.go:212-224`) up to its `KeepCurrentPlan`, whose error must also contain "keep: read current prices" (failure text `keep on a suspended subscription: the request ended after the first read and the change returned %v, want the error of the prices' read`), because without that check `currentPrices` drops the price it cannot read, and the refusal `ErrScheduledMoveApplied` answers instead of the prices' error. Its count is "scheduled downgrades" = 1 (`rowCount` `plan_switch`, `period_end`, `scheduled`).
|
||||
|
||||
- [x] 4.11 `TestALateRecordKeepsAChangeAnotherRequestRecorded`: each row runs a downgrade (`onTier2`, `SwitchPlan` to `fx.priceID`) and wraps `Handle` so that the schedule's creation first runs `fn`, then falls through. Failure text `%s: the change left %d %s scheduled, want %d`. Rows:
|
||||
- `a row another request wrote during the change`: `fn` inserts a `cancellation` row with trigger `term_boundary` through `billing.New(database).CreateScheduledChange`. Want that row `scheduled` (count 1) and the downgrade `scheduled` (count 1).
|
||||
- `a row the change found when it began`: before the change, a `cancellation` row with trigger `term_boundary` is inserted through `billing.New(database).CreateScheduledChange`, as the first row inserts its own; `fn` does nothing. Want that row `scheduled` count 0 and the downgrade 1. The row is a `term_boundary` one because rules 1 to 4 of the reconcile leave such a row alone, so only the record's own `before` can supersede it; a period-end cancellation row would be superseded by rule 3 whether or not `before` listed it, once the downgrade clears the flag.
|
||||
|
||||
- [x] 4.12 Breaks. Package `./internal/fulfillment` unless named; a record's `run_new` is the test and, for a row, its subtest.
|
||||
- `stripeCallContext` keeping the request's cancellation (`plan_change.go`, `old` `return context.WithTimeout(context.WithoutCancel(ctx), d)`, `new` `return context.WithTimeout(ctx, d)`): caught by `TestAPlanChangeOutlivesItsRequest/a_downgrade`, `want_new` `a downgrade: after the request ended at the creation, the change returned`, and by `TestAPlanChangeOutlivesItsRequest/a_cancellation_under_a_grant`, `want_new` `a cancellation under a grant: after the request ended at the void, the change returned`;
|
||||
- `recordContext` keeping it (`old` `return stripeCallContext(ctx, PlanChangeRecordTimeout)`, `new` `return context.WithTimeout(ctx, PlanChangeRecordTimeout)`): caught by `TestAPlanChangeOutlivesItsRequest/keep_a_downgrade`, `want_new` `keep a downgrade: after the request ended at the release, the change returned`;
|
||||
- `recordMove` on the request's context (`old` `rctx, cancel := recordContext(ctx)`, `new` `rctx, cancel := ctx, func() {}`): caught by `TestPlanChangeCallsCarryTheirDeadlines/a_downgrade`, `want_new` `a downgrade: the fetch's deadline is`, and by `TestAPlanChangeOutlivesItsRequest/a_cancellation_at_the_period_end`, `want_new` `a cancellation at the period end: after the request ended at the set, the change returned`;
|
||||
- each first read sent without the request's context, its `new` the same line with `Context: context.WithoutCancel(ctx)` in place of `Context: ctx`: the upgrade's (`old` the `current, err := ...latest_invoice` line of item 1.5), caught by `TestPlanChangeCallsCarryTheirDeadlines/an_upgrade`, `want_new` `an upgrade: the read carried deadline`, and by `TestARequestThatEndsDuringTheFirstReadStopsTheChange/an_upgrade`, `want_new` `an upgrade: the request ended during the first read and the change returned`; the downgrade's (item 1.7), caught by `.../a_downgrade` of both tests, `want_new` `a downgrade: the read carried deadline` and `a downgrade: the request ended during the first read and the change returned`; Keep plan's (item 1.8), caught by `.../keep_a_downgrade` of both, `want_new` `keep a downgrade: the read carried deadline` and `keep a downgrade: the request ended during the first read and the change returned`; the cancellation's under a grant (the `latest_invoice` line of item 1.9), caught by `TestPlanChangeCallsCarryTheirDeadlines/a_cancellation_under_a_grant`, `want_new` `a cancellation under a grant: the read carried deadline`; the cancellation's other read (`readBeforeCancel`, item 1.9; `old` `current, err := subscription.Get(stripeSubID, &stripe.SubscriptionParams{Params: stripe.Params{Context: ctx}})`, `new` the same line with `Context: context.WithoutCancel(ctx)`), caught by `TestPlanChangeCallsCarryTheirDeadlines/cancel_now`, `want_new` `cancel now: the read carried deadline`, and by `TestARequestThatEndsDuringTheFirstReadStopsTheChange/cancel_at_the_period_end`, `want_new` `cancel at the period end: the request ended during the first read and the change returned`;
|
||||
- the holding path's read run on every path (`old` `if current == nil {`, `new` `if true {`): caught by `TestAPlanChangeOutlivesItsRequest/a_cancellation_under_a_grant`, `want_new` `a cancellation under a grant: after the request ended at the void, the change returned`;
|
||||
- the abandoned approval's void sent without params (`old` `_, err := invoice.VoidInvoice(current.LatestInvoice.ID, params)`, `new` the same with `nil`): caught by `TestPlanChangeCallsCarryTheirDeadlines/an_upgrade_over_an_abandoned_attempt`, `want_new` `an upgrade over an abandoned attempt: the void carried no deadline`, by `.../a_cancellation_under_a_grant`, `want_new` `a cancellation under a grant: the void carried no deadline`, and by `TestSwitchAPausedSubscription_AnAbandonedUpgradeIsVoidedFirst`, `want_new` `an abandoned upgrade: the void carried no deadline`;
|
||||
- the decline's void sent without params (`old` `if _, verr := invoice.VoidInvoice(inv.ID, params); verr != nil {`, `new` the same with `nil`): caught by `TestPlanChangeCallsCarryTheirDeadlines/a_declined_upgrade`, `want_new` `a declined upgrade: the void carried no deadline`, and by `TestSwitchAPausedSubscription_EveryCallCarriesADeadline/declined`, `want_new` `declined: the void carried no deadline`;
|
||||
- the charge under the ordinary call's deadline (`old` `cancel := setCallContext(ctx, ¶ms.Params, PlanChangeChargeTimeout)`, `new` the same with `PlanChangeCallTimeout`): caught by `TestPlanChangeCallsCarryTheirDeadlines/an_upgrade`, `want_new` `an upgrade: the charge's deadline is`, and by `TestSwitchAPausedSubscription_EveryCallCarriesADeadline/declined`, `want_new` `declined: the update's deadline is`;
|
||||
- `releaseSchedule` sending `nil` (`old` `_, err := subscriptionschedule.Release(id, params)`, `new` the same with `nil`): caught by `TestPlanChangeCallsCarryTheirDeadlines/an_upgrade_over_a_scheduled_downgrade`, `want_new` `an upgrade over a scheduled downgrade: the release carried no deadline`, by `.../keep_a_downgrade`, `want_new` `keep a downgrade: the release carried no deadline`, and by `.../cancel_at_the_period_end_over_a_downgrade`, `want_new` `cancel at the period end over a downgrade: the release carried no deadline`;
|
||||
- `setCancelAtPeriodEnd` sending params without a context (`old` `_, err := subscription.Update(stripeSubID, params)`, `new` `_, err := subscription.Update(stripeSubID, &stripe.SubscriptionParams{CancelAtPeriodEnd: stripe.Bool(on)})`): caught by `TestPlanChangeCallsCarryTheirDeadlines/a_downgrade_over_an_armed_cancellation`, `want_new` `a downgrade over an armed cancellation: the clear carried no deadline`, by `.../keep_a_cancellation`, `want_new` `keep a cancellation: the clear carried no deadline`, and by `.../cancel_at_the_period_end_over_a_downgrade`, `want_new` `cancel at the period end over a downgrade: the set carried no deadline`;
|
||||
- the schedule opened without a context (`old` `cancelOpen := setCallContext(ctx, &open.Params, PlanChangeCallTimeout)`, `new` `cancelOpen := func() {}`): caught by `TestPlanChangeCallsCarryTheirDeadlines/a_downgrade`, `want_new` `a downgrade: the creation carried no deadline`, and by `.../a_downgrade_over_an_earlier_downgrade`, `want_new` `a downgrade over an earlier downgrade: the schedule's read carried no deadline`;
|
||||
- the immediate cancel sent without params (`old` `if _, err := subscription.Cancel(ls.stripeSubID, params); err != nil {`, `new` the same with `nil`): caught by `TestPlanChangeCallsCarryTheirDeadlines/cancel_now`, `want_new` `cancel now: the cancel carried no deadline`;
|
||||
- the preview without the request's context (`old` `previewParams.Context = ctx`, `new` `previewParams.Context = context.WithoutCancel(ctx)`): caught by `TestPlanChangeCallsCarryTheirDeadlines/the_preview_of_an_upgrade`, `want_new` `the preview of an upgrade: the preview carried deadline`;
|
||||
- the update without a context (`old` `cancel := setCallContext(ctx, &update.Params, PlanChangeCallTimeout)`, `new` `cancel := func() {}`): caught by `TestPlanChangeCallsCarryTheirDeadlines/a_downgrade`, `want_new` `a downgrade: the update carried no deadline`;
|
||||
- no resend (`old` `if err == nil || !gotNoAnswer(err) {`, `new` `if true {`): caught by `TestADowngradeUpdateWithoutAnAnswerIsResent/an_update_that_landed`, `want_new` `an update that landed: the change returned`, and by `.../an_update_that_did_not_land`, `want_new` `an update that did not land: the change returned`;
|
||||
- the resend's error ignored (`old` `_, err = subscriptionschedule.Update(scheduleID, update)`, `new` `_, _ = subscriptionschedule.Update(scheduleID, update)` followed by `err = nil`): caught by `.../a_resend_that_gets_no_answer`, `want_new` `a resend that gets no answer: the change returned`;
|
||||
- a refusal sent again (`old` `if err == nil || !gotNoAnswer(err) {`, `new` `if err == nil {`): caught by `.../a_refused_update`, `want_new` `a refused update: the change sent`;
|
||||
- a 500 sent again (`old` `return !errors.As(err, &se)`, `new` `return !errors.As(err, &se) || se.HTTPStatusCode >= 500`): caught by `.../an_update_answered_500`, `want_new` `an update answered 500: the change sent`;
|
||||
- the first send without a key (`old` `update.IdempotencyKey = stripe.String(uuid.NewString())`, `new` `_ = uuid.NewString()`): caught by `.../an_update_that_landed`, `want_new` `an update that landed: the first send carried no idempotency key`;
|
||||
- a fresh key on the resend (`old` `cancel = setCallContext(ctx, &update.Params, PlanChangeCallTimeout)`, `new` the same preceded by `update.IdempotencyKey = stripe.String(uuid.NewString())`): caught by `.../an_update_that_landed`, `want_new` `an update that landed: the resend's idempotency key is`;
|
||||
- the resend under the charge's deadline (`old` `cancel = setCallContext(ctx, &update.Params, PlanChangeCallTimeout)`, `new` the same with `PlanChangeChargeTimeout`): caught by `.../an_update_that_landed`, `want_new` `an update that landed: the resend's deadline is`;
|
||||
- `requestEnded` finding no ended request (`old` `return fmt.Errorf("%s: the request ended before the change: %w", move, err)`, `new` `_ = err`): caught by `TestARequestThatEndsAfterTheFirstReadStopsTheChange/a_downgrade`, `want_new` `a downgrade: the request ended after the first read and the change returned`, by `.../an_upgrade`, `want_new` `an upgrade: the request ended after the first read and the change returned`, by `.../keep_a_downgrade`, `want_new` `keep a downgrade: the request ended after the first read and the change returned`, and by `.../cancel_at_the_period_end`, `want_new` `cancel at the period end: the request ended after the first read and the change returned`;
|
||||
- `currentPrices` dropping a price whose mapping could not be read (`reconcile.go`, `old` `return nil, err`, `new` `continue`): caught by `.../keep_on_a_suspended_subscription`, `want_new` `keep on a suspended subscription: the request ended after the first read and the change returned`;
|
||||
- the record superseding every active row (`old` `if err := supersedeBefore(rctx, billingQ, before); err != nil {`, `new` `if err := billingQ.SupersedeActiveScheduledChanges(rctx, ls.coreSubID); err != nil {`): caught by `TestALateRecordKeepsAChangeAnotherRequestRecorded/a_row_another_request_wrote_during_the_change`, `want_new` `a row another request wrote during the change: the change left 0`;
|
||||
- the record superseding no row (`old` `rows, err := billingQ.ListActiveScheduledChangesBySubscription(ctx, ls.coreSubID)`, `new` `rows, err := []billing.SubscriptionScheduledChange(nil), error(nil)`): caught by `.../a_row_the_change_found_when_it_began`, `want_new` `a row the change found when it began: the change left 1`;
|
||||
- the sweep leaving no margin (`old` `time.Until(deadline) >= sweepCallTimeout+sweepRowMargin`, `new` `time.Until(deadline) >= sweepCallTimeout`): caught by `TestSweepLeavesARowItHasNoTimeToFire`, `want_new` `want 0 and ErrSweepOutOfTime`;
|
||||
- the sweep's cancel without a deadline (`sweep.go`, `old` the `subscription.Cancel` line of item 3.1, `new` the same with `Context: context.WithoutCancel(cctx)`): caught by `TestSweepWritesCarryTheirDeadline/a_due_cancellation`, `want_new` `a due cancellation: the cancel carried no deadline`;
|
||||
- the sweep's update without a deadline (`old` `params.Context = cctx`, `new` `params.Context = context.WithoutCancel(cctx)`): caught by `TestSweepWritesCarryTheirDeadline/a_term-boundary_switch`, `want_new` `a term-boundary switch: the update carried no deadline`;
|
||||
- the sweep firing with no time left (`old` `if !roomToFire(ctx) {`, `new` `if false && !roomToFire(ctx) {`): caught by `TestSweepLeavesARowItHasNoTimeToFire`, `want_new` `want 0 and ErrSweepOutOfTime`;
|
||||
- a call deadline shorter than the request (`plan_change.go`, `old` `PlanChangeCallTimeout = 40 * time.Second`, `new` the same with `20`, `pkg` `./internal/server`): caught by `TestPlanChangeDeadlinesOutlastTheRequest`, `want_new` `PlanChangeCallTimeout is 20s`.
|
||||
|
||||
Named without a break, with the reason: the read of the current item moved before the downgrade's first Stripe call (item 1.7), because moving it back is two edits; `TestAPlanChangeOutlivesItsRequest/a_downgrade` fails on a read left after the creation. Each move's call of `recordMove`, because bypassing it is not one replacement; each move's row of items 4.1 and 4.2 fails on a record made on the request's context, and the `recordMove` and `recordContext` breaks stand for them. Each move's call of `requestEnded`, because removing one is not one replacement; each row of item 4.10 fails on a missing call, and the `requestEnded` break stands for them. Each move's call of `activeMoves` and the `before` it passes, for the same reason; the two breaks of item 4.11 stand for them. The reconcile's use of `currentPrices`' error, because an ended context fails the reconcile at its first statement before it. The guard row of item 4.4.
|
||||
|
||||
## 5. The documents
|
||||
|
||||
- [x] 5.1 `docs/stripe.md`, "How it works" (`:207`, design D5): the paragraph keeps its first sentence and replaces the second with: "A member's request gives its handler 32 seconds (`requestTimeout`, `internal/server/server.go`). A plan change's first Stripe call, a read of the subscription, carries the request's context, and the change checks that context again before its first write, so a request that ends before then changes nothing. Every later call carries a deadline of its own that the end of the request does not cut, 60 seconds for an upgrade's charge and 40 for the others (20 for the paused switch's draft list, voids and clear, which its restore resolves before the member is answered), and the change then records what it did and reconciles within 60 seconds more. A member whose request runs out of time gets the timeout answer while the change goes on; the change, its record included, ends at most about four minutes after its first write. A write that got no answer, or that Stripe answered with a server error, reaches the console's records through Stripe's event, except a downgrade's update, which the console records only from Stripe's answer to its own call. The scheduled-change sweep gives each cancel or price change 20 seconds inside its one-minute attempt, and leaves the remaining due changes to its next attempt when less than 30 seconds is left."
|
||||
- [x] 5.2 `docs/testing.md`, the paragraph on the Stripe mock (`:196-203`), gains after the sentence on `Call.ContextDone`: "A call also records the idempotency key its params carried explicitly (`Call.IdempotencyKey`); a key stripe-go generates itself is not seen, because the mock stands in for the SDK's backend." No other document changes: `docs/database-locks.md` describes no lock this change moves.
|
||||
|
||||
## 6. Landing
|
||||
|
||||
- [x] 6.1 With the test stack up, `make test` passes (it runs `make lint` first), and `openspec validate plan-change-calls-carry-their-own-deadlines --strict` passes.
|
||||
- [x] 6.2 The break list of item 4.12 reads CAUGHT in the commit message, with the breaks named without a record and why.
|
||||
- [ ] 6.3 After the push, post on #295 under `CONTRIBUTING.md`'s rules for forge text (item 1: it reads for someone who has never opened the repository, explains the console's terms once, and names no decision number, change name, date as narrative, deployment or count), and close it. Draft:
|
||||
|
||||
> A member's plan change (a switch to another tier, Keep plan, or Cancel plan) and the sweep that fires scheduled plan changes now give every Stripe call a deadline. A plan change's first call, its read of the subscription, ends with the member's request, so a request that ends before Stripe answers changes nothing. Every later call has a deadline of its own that outlasts the request's 32 seconds, 60 seconds for an upgrade's charge and 40 for the others (20 for the calls of the switch of a subscription paused for a grant, which resolves them before the member is answered), and after its last Stripe call the change records what it did (the console's record of a scheduled downgrade or cancellation) and reconciles the subscription within another 60 seconds. A member who closes the page, or whose request runs out of time, no longer leaves a change made at Stripe and unrecorded: the member reads the timeout answer, and the change finishes and is recorded. Every Stripe write now carries the idempotency key the Stripe library adds, so the library's own retries cannot repeat a write. A write that gets no answer before its deadline is reported as failed and records nothing, because Stripe's event for a write that did land brings the console in line. The one exception is a downgrade's update of its subscription schedule, which no event would record, so the console sends it with an idempotency key of its own and, when it gets no answer, sends it once more with the same key, and records the downgrade when that second send is answered. A change also checks that the request is still open before its first write, and its record supersedes only the scheduled changes that existed when it began, so a later request's cancellation or downgrade is not erased. The sweep gives each cancel or price change 20 seconds inside its one-minute run, and when less than 30 seconds is left it stops and leaves the remaining changes for its next attempt. The switch's price preview also ends with the request.
|
||||
>
|
||||
> Commit: …
|
||||
|
||||
- [ ] 6.4 The three open questions of design.md are put to the maintainer as proposed issues, each in the bug form as written there; none is opened without the maintainer's go.
|
||||
- [ ] 6.5 After the push, post on #296 a comment under the same rules for forge text (no go needed). Draft:
|
||||
|
||||
> A second unknown outcome belongs to this question. When a member's plan change makes a write at Stripe and Stripe answers it with a server error (a status of 500 or above) before the member's request has ended, the console answers "We couldn't complete that change. Try again in a moment." although the write may have gone through; Stripe's event then brings the console's records up to date, and the member's page shows the change after a reload for every change but a downgrade, which the console records only from Stripe's answer, so after a reload the page can show no downgrade that Stripe will still carry out at the renewal. That is an unknown outcome, the same as a timeout, and it does not reach the timeout answer. Whatever wording settles the unknown outcome of a timeout should cover it. The fix for #295 leaves this behaviour as it is.
|
||||
|
||||
- [ ] 6.6 After the push, post on #260 a comment under the same rules (no go needed). Draft:
|
||||
|
||||
> A member's plan change can keep running after the member's request has been answered: once its first Stripe write is sent, its later Stripe calls and its database writes run to deadlines of their own, up to about 220 seconds in all. The request's timeout handler has already answered at 32 seconds, so a stop path that waits only for the server's tracked requests does not wait for these changes, and closing the database pool then fails the write that records the change, after its write at Stripe. The stop path therefore has to wait for plan changes in flight (for instance a counter that each change holds until its record returns, bounded by about 220 seconds), or the orchestrator's grace period has to cover them; otherwise a stop can cut a change between a Stripe write and the console's record, and each deploy that catches a slow change can leave a change made at Stripe and unrecorded. Running the change's writes as a durable job, which a stop does not cut, was considered and not chosen for #295, because the member's answer needs the charge's outcome at once.
|
||||
|
||||
- [ ] 6.7 After the push, post on #177 a comment under the same rules (no go needed). Draft:
|
||||
|
||||
> The fix for #295 gives the sweep's cancel and price update 20 seconds each inside the sweep's one-minute run, where the Stripe library's default allowed 80 seconds and two retries. A call that gets no answer and did not go through is therefore more frequent; the row it claimed stays applied and no later sweep fires it, as above. The same fix lets a member's plan change record its scheduled change after the request ends, so that change's row can stand beside a row a later request recorded meanwhile; a one-pending-change index has to let the later row stand rather than refuse the late one. It can also leave a cancellation at the period end that Stripe carries with no row in the console's record: when the call that sets it went through but its answer was lost, the subscription shows the cancellation, but the sweep has no row to fire if Stripe's event for the subscription's end is lost. Who owns that row belongs to this question.
|
||||
|
||||
- [ ] 6.8 The archive waits for the maintainer's go; the delta carries the spec change, and no Purpose needs an edit a delta cannot make.
|
||||
Reference in New Issue
Block a user