Skip to content

fix(dev-plans): reprice drafted renewal on tier change - #2909

Open
steebchen wants to merge 2 commits into
mainfrom
devpass-renewal-tier-mismatch
Open

fix(dev-plans): reprice drafted renewal on tier change#2909
steebchen wants to merge 2 commits into
mainfrom
devpass-renewal-tier-mismatch

Conversation

@steebchen

@steebchen steebchen commented Jul 3, 2026

Copy link
Copy Markdown
Member

Problem

A DevPass subscriber downgraded pro → lite at 06:37 PM and was renewed at 07:01 PM the same day — charged the PRO price ($79) but granted LITE credits ($87) with the transaction labeled "Dev Plan LITE renewed". Net effect: overcharged ~$50 for a lite cycle.

Root cause

Stripe drafts the subscription_cycle renewal invoice up to ~an hour before finalizing and charging it. A tier change swaps the subscription item's price immediately (proration_behavior: "none"), but an already-drafted renewal invoice keeps billing the price it was drafted with — the swap lands too late. The renewal webhook then desyncs three ways: amount comes from Stripe's invoice.amount_paid (old tier), while credits, tier, and description follow devPlanPendingTier (new tier).

Any tier change (or cancel-downgrade) inside the pre-renewal finalization window hits this, in either direction.

Fix

After every tier-change price swap (change-tier, both directions, and cancel-downgrade), look for a draft subscription_cycle invoice on the subscription and re-price it by adding an adjustment invoice item for the difference between the new tier's price and the draft's net plan amount (subscription lines + earlier tagged adjustment lines).

  • Computing against the net amount makes it idempotent and correct across consecutive changes in the same window (downgrade → upgrade, downgrade → cancel) and a no-op when the draft was created after the swap already at the new price.
  • Adjustment lines are tagged with devPlanRenewalReprice metadata so only our own adjustments are netted.
  • Best-effort: a failure only reverts to today's (rare) near-boundary mismatch and never fails the tier change itself — failing after the swap but before recording the pending tier would create the reverse mismatch.
  • For upgrades it runs only after the prorated charge succeeded, so a rolled-back upgrade never touches the draft.

Tests

Unit (dev-plans.spec.ts, mocked Stripe, 18 passing): downgrade re-prices a drafted renewal (−$50), skips when the draft already bills the new price, downgrade still succeeds when re-pricing fails, cancel-downgrade nets the earlier adjustment back out (+$50).

Real Stripe Test Clock e2e (dev-plans-reprice.e2e.ts, opt-in via STRIPE_TESTCLOCK_E2E, skipped in CI): reproduces the incident against a live sandbox and runs the actual repriceDraftRenewalInvoice helper. All 6 pass:

  • Initial PRO subscription invoice pays at $79.
  • Root cause confirmed: Stripe drafts the renewal at $79 before finalizing; the mid-window price swap leaves that draft billing $79.
  • The helper re-prices the draft to $29 with a −$50 adjustment line; idempotent on re-run.
  • cancel-downgrade re-prices back to $79 (adjustments net to $0).
  • End-to-end: after advancing the clock past finalization, the invoice charges $29, not $79.

🤖 Generated with Claude Code

Summary by CodeRabbit

  • Bug Fixes
    • Downgrade scheduling now keeps already-drafted upcoming renewal charges aligned with the selected tier.
    • If the drafted renewal invoice already reflects the new tier, no additional adjustment is applied.
    • If re-pricing a drafted renewal fails, the tier change/downgrade still proceeds and remains scheduled.
    • Canceling a scheduled downgrade now re-prices the drafted renewal back to the current tier.
  • Tests
    • Added an end-to-end verification covering downgrade re-pricing, idempotency, and cancel-downgrade behavior against Stripe.

Copilot AI review requested due to automatic review settings July 3, 2026 22:38
@coderabbitai

coderabbitai Bot commented Jul 3, 2026

Copy link
Copy Markdown
Contributor

Review Change Stack

Warning

Review limit reached

@steebchen, you've reached your PR review limit, so we couldn't start this review.

Next review available in: 3 minutes

Enable usage-based reviews in Billing to review now. Otherwise, wait until the next included review is available.
You're only billed for reviews past your plan's rate limits ($0.25/file).

How can I continue?

After more reviews become available, a review can be triggered using the @coderabbitai review command as a PR comment. Alternatively, push new commits to this PR.

To avoid repeated limits, reduce automatic review volume by pausing incremental auto-reviews earlier, using label-based review opt-in, excluding WIP or generated PR titles, or requesting reviews manually when the PR is ready. If your team needs uninterrupted high-volume reviews, an organization admin can enable usage-based reviews.

How do review limits work?

CodeRabbit enforces per-developer PR review limits for each organization. Most developers receive the normal plan review availability.

For paid Pro and Pro+ PR reviews, CodeRabbit uses adaptive limits for sustained high-volume activity. When a developer's recent PR review activity reaches the 95th percentile or higher among CodeRabbit users, additional reviews become available more gradually as earlier reviews age out of the rolling window.

Please refer docs for additional details.

Review details
⚙️ Run configuration

Configuration used: Repository UI

Review profile: CHILL

Plan: Pro Plus

Run ID: e1c6702d-0379-4195-9bef-86e8e66ac65d

📥 Commits

Reviewing files that changed from the base of the PR and between 7fd2c8a and d57ac4a.

📒 Files selected for processing (4)
  • apps/api/src/lib/pending-renewal.ts
  • apps/api/src/routes/dev-plans-reprice.e2e.ts
  • apps/api/src/routes/dev-plans.spec.ts
  • apps/api/src/routes/dev-plans.ts

Walkthrough

Adds a Stripe renewal-invoice re-pricing helper for dev-plan tier changes, wires it into change and cancel downgrade flows, and expands unit and real-Stripe test coverage for adjustment, skip, failure, reversal, and final billing behavior.

Changes

Draft renewal invoice re-pricing

Layer / File(s) Summary
Re-pricing helper implementation
apps/api/src/routes/dev-plans.ts
Adds repriceDraftRenewalInvoice, which finds the drafted renewal invoice, computes the billed amount including prior tagged adjustments, compares it with the target tier price, and creates a tagged invoice-item adjustment when needed.
Tier-change and cancel-downgrade wiring
apps/api/src/routes/dev-plans.ts
Calls the helper after /change-tier price updates and after /cancel-downgrade price reversion so drafted renewal invoices are re-priced to the active tier.
Test mock setup for renewal re-pricing scenarios
apps/api/src/routes/dev-plans.spec.ts
Extends Stripe mocks with prices.retrieve and invoice-item listing, and defaults invoices.list to an empty result before each test.
Downgrade scheduling re-pricing tests
apps/api/src/routes/dev-plans.spec.ts
Adds unit tests for drafting-window downgrade scheduling, including creating the adjustment, skipping redundant adjustments, and continuing when draft re-pricing fails.
Cancel-downgrade re-pricing reversal test
apps/api/src/routes/dev-plans.spec.ts
Adds a unit test that canceling a scheduled downgrade reapplies the inverse adjustment to restore the draft renewal invoice to the current tier price.
Real-Stripe e2e coverage
apps/api/src/routes/dev-plans-reprice.e2e.ts
Adds a gated Stripe test-clock suite that exercises downgrade re-pricing, idempotency, cancel-downgrade reversal, and final invoice amount verification.

Estimated code review effort: 3 (Moderate) | ~25 minutes

Sequence Diagram(s)

sequenceDiagram
    participant Client
    participant DevPlansRoute
    participant Stripe
    participant repriceDraftRenewalInvoice

    Client->>DevPlansRoute: POST /change-tier
    DevPlansRoute->>Stripe: update subscription item price
    DevPlansRoute->>repriceDraftRenewalInvoice: repriceDraftRenewalInvoice(...)
    repriceDraftRenewalInvoice->>Stripe: invoices.list(drafted renewal)
    repriceDraftRenewalInvoice->>Stripe: prices.retrieve(target tier)
    alt draft total differs
        repriceDraftRenewalInvoice->>Stripe: invoiceItems.create(adjustment)
    else draft total matches
        repriceDraftRenewalInvoice-->>DevPlansRoute: no adjustment
    end
    DevPlansRoute-->>Client: response

    Client->>DevPlansRoute: POST /cancel-downgrade
    DevPlansRoute->>Stripe: restore current tier price
    DevPlansRoute->>repriceDraftRenewalInvoice: repriceDraftRenewalInvoice(...)
    repriceDraftRenewalInvoice->>Stripe: invoiceItems.create(inverse adjustment)
    DevPlansRoute-->>Client: response
Loading

Possibly related PRs

  • theopenco/llmgateway#2902: Modifies the same dev-plan downgrade scheduling path that this PR extends with draft renewal invoice re-pricing.
  • theopenco/llmgateway#2840: Changes the same /change-tier flow in apps/api/src/routes/dev-plans.ts, which this PR hooks into after tier updates.
  • theopenco/llmgateway#2479: Touches the same dev-plan tier-change and downgrade billing logic in apps/api/src/routes/dev-plans.ts.
🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Docstring Coverage ✅ Passed No functions found in the changed files to evaluate docstring coverage. Skipping docstring coverage check.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title clearly summarizes the main change: re-pricing drafted renewal invoices when a dev plan tier changes.
✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch devpass-renewal-tier-mismatch

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 58dac2012f

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread apps/api/src/routes/dev-plans.ts Outdated
Comment on lines +246 to +250
await stripe.invoiceItems.create({
customer: customerId,
invoice: draft.id,
amount: adjustmentCents,
currency: draft.currency,

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Serialize draft renewal reprice adjustments

When two tier-change requests for the same subscription run during the pre-renewal draft window, both can list the draft before either new adjustment line is visible. Since this invoiceItems.create call has no per-draft/new-tier idempotency key or lock, duplicate requests can each add the full adjustment (for example, two pro→lite requests add two -$50 lines), leaving the renewal invoice over-adjusted. Serialize per subscription/invoice or make the adjustment creation idempotent.

Useful? React with 👍 / 👎.

Comment thread apps/api/src/routes/dev-plans.ts Outdated
return;
}

const billedCents = draft.lines.data.reduce((sum, line) => {

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Fetch all draft invoice lines before summing

Stripe only embeds the first handful of invoice lines on an Invoice object, but this sum uses only draft.lines.data. If a user schedules/cancels/supersedes enough changes in the draft window, prior devPlanRenewalReprice lines can fall outside that embedded page, so billedCents is computed from incomplete data and the next adjustment can move the renewal away from the target tier price. Retrieve the full paginated invoice lines before computing the net billed amount.

Useful? React with 👍 / 👎.

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Addresses a Stripe edge case where a dev plan tier change close to renewal can leave an already-drafted subscription_cycle invoice billing the old tier price while the renewal logic applies the new tier (credits/description), causing mismatched charges and entitlements.

Changes:

  • Added repriceDraftRenewalInvoice(...) to detect a draft renewal invoice and apply an adjustment invoice item to reconcile the draft’s net plan amount to the new tier price.
  • Invoked draft re-pricing after tier-change price swaps in change-tier and when reverting in cancel-downgrade.
  • Added unit tests covering re-pricing behavior, idempotency/no-op scenarios, best-effort failure behavior, and cancel-downgrade netting.

Reviewed changes

Copilot reviewed 2 out of 2 changed files in this pull request and generated 3 comments.

File Description
apps/api/src/routes/dev-plans.ts Adds best-effort logic to re-price already-drafted Stripe renewal invoices after dev plan tier swaps (and on cancel-downgrade).
apps/api/src/routes/dev-plans.spec.ts Adds unit tests validating draft renewal invoice re-pricing and resilience/idempotency cases.

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

// downgrade inside that window swaps the subscription price too late for
// the draft, so without an adjustment the renewal bills the old (higher)
// tier while the webhook grants the lower tier's credits.
process.env.STRIPE_DEV_PLAN_LITE_PRICE_ID = "price_lite";
it("skips the draft adjustment when the draft already bills the new price", async () => {
// The draft can be created concurrently with (or after) the price swap, in
// which case it already bills the new tier and must not be adjusted again.
process.env.STRIPE_DEV_PLAN_LITE_PRICE_ID = "price_lite";
it("schedules the downgrade even if re-pricing the draft fails", async () => {
// Re-pricing is best-effort: a Stripe failure there must not leave the
// price swapped without the pending tier recorded (the reverse mismatch).
process.env.STRIPE_DEV_PLAN_LITE_PRICE_ID = "price_lite";

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

🧹 Nitpick comments (1)
apps/api/src/routes/dev-plans.spec.ts (1)

986-1281: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low value

Consider extracting shared Stripe mock boilerplate.

The four new tests each repeat near-identical subscriptions.retrieve/subscriptions.update mock payloads (differing only in a few fields like tier/price). A small factory (e.g. mockDevPlanSubscription({ devPlan, priceId })) would reduce duplication for this new block, consistent with the DRY guideline. Since this repeats an existing pattern already used throughout the file, this is optional and not blocking.

As per coding guidelines, "Apply DRY principles for code reuse."

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@apps/api/src/routes/dev-plans.spec.ts` around lines 986 - 1281, The new
dev-plan Stripe tests duplicate the same `stripeMock.subscriptions.retrieve` and
`stripeMock.subscriptions.update` payloads across multiple cases. Extract a
small reusable factory/helper in `dev-plans.spec.ts` (for example around these
test blocks) that builds the shared subscription mock from inputs like tier and
price ID, and use it in the affected tests to remove repetition while keeping
the test-specific invoice and pricing assertions.

Source: Coding guidelines

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@apps/api/src/routes/dev-plans.ts`:
- Around line 228-256: The dev plan renewal adjustment in dev-plans.ts is
marking only the invoice item, but the dedupe logic checks draft invoice line
metadata on draft.lines.data. Update the Stripe invoice item creation path so
the marker survives onto the generated draft invoice line, and ensure the
repricing/cancel flow in the dev-plan renewal handling uses a reliable persisted
marker for previously applied adjustments.

---

Nitpick comments:
In `@apps/api/src/routes/dev-plans.spec.ts`:
- Around line 986-1281: The new dev-plan Stripe tests duplicate the same
`stripeMock.subscriptions.retrieve` and `stripeMock.subscriptions.update`
payloads across multiple cases. Extract a small reusable factory/helper in
`dev-plans.spec.ts` (for example around these test blocks) that builds the
shared subscription mock from inputs like tier and price ID, and use it in the
affected tests to remove repetition while keeping the test-specific invoice and
pricing assertions.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Repository UI

Review profile: CHILL

Plan: Pro

Run ID: 036e09de-d664-4677-afc6-8e3c4dab670f

📥 Commits

Reviewing files that changed from the base of the PR and between 3301bfe and 58dac20.

📒 Files selected for processing (2)
  • apps/api/src/routes/dev-plans.spec.ts
  • apps/api/src/routes/dev-plans.ts

Comment thread apps/api/src/routes/dev-plans.ts Outdated

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🧹 Nitpick comments (2)
apps/api/src/routes/dev-plans-reprice.e2e.ts (2)

153-166: 🩺 Stability & Availability | 🔵 Trivial | 💤 Low value

Tight timeout margin in draft-invoice test.

waitForClock can take up to 120s (60 × 2000ms) inside advanceTo, but the enclosing test also has a 120000ms Vitest timeout (line 166) that must additionally cover the subsequent getDraftRenewal list call and assertions. In a slow sandbox this leaves little to no margin and could cause an intermittent timeout failure unrelated to the actual behavior under test.

Consider giving this test (and others calling advanceTo) a larger timeout, e.g. 150000ms, to decouple clock-polling time from assertion overhead.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@apps/api/src/routes/dev-plans-reprice.e2e.ts` around lines 153 - 166, The
draft-invoice e2e test has too little timeout headroom because advanceTo() can
consume most of the current Vitest timeout before getDraftRenewal() and
assertions run. Increase the timeout on this test, and any similar tests that
call advanceTo(), so the total budget comfortably exceeds waitForClock’s polling
window; use the test name and advanceTo() as the key spots to update.

97-99: 🎯 Functional Correctness | 🔵 Trivial | 💤 Low value

unit_amount ?? 0 fallback can mask a misconfigured price.

If either PRO_PRICE_ID or LITE_PRICE_ID resolves to a price with unit_amount: null (e.g., a metered/tiered price), the fallback to 0 lets the subsequent expect(proUnit).toBeGreaterThan(liteUnit) pass or fail for the wrong reason instead of failing clearly on a misconfigured fixture.

🔧 Suggested fix to fail fast on unexpected null unit_amount
-		proUnit = (await stripe.prices.retrieve(PRO_PRICE_ID)).unit_amount ?? 0;
-		liteUnit = (await stripe.prices.retrieve(LITE_PRICE_ID)).unit_amount ?? 0;
+		const proPrice = await stripe.prices.retrieve(PRO_PRICE_ID);
+		const litePrice = await stripe.prices.retrieve(LITE_PRICE_ID);
+		expect(proPrice.unit_amount, "PRO price must have a unit_amount").not.toBeNull();
+		expect(litePrice.unit_amount, "LITE price must have a unit_amount").not.toBeNull();
+		proUnit = proPrice.unit_amount!;
+		liteUnit = litePrice.unit_amount!;
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@apps/api/src/routes/dev-plans-reprice.e2e.ts` around lines 97 - 99, The
`dev-plans-reprice.e2e.ts` assertion is masking bad Stripe fixtures by falling
back to 0 when `stripe.prices.retrieve(...).unit_amount` is null. Update the
test around `PRO_PRICE_ID` and `LITE_PRICE_ID` to fail fast if either retrieved
price has a null `unit_amount` instead of defaulting, so
`expect(proUnit).toBeGreaterThan(liteUnit)` only runs with valid amounts and
clearly surfaces misconfigured prices.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Nitpick comments:
In `@apps/api/src/routes/dev-plans-reprice.e2e.ts`:
- Around line 153-166: The draft-invoice e2e test has too little timeout
headroom because advanceTo() can consume most of the current Vitest timeout
before getDraftRenewal() and assertions run. Increase the timeout on this test,
and any similar tests that call advanceTo(), so the total budget comfortably
exceeds waitForClock’s polling window; use the test name and advanceTo() as the
key spots to update.
- Around line 97-99: The `dev-plans-reprice.e2e.ts` assertion is masking bad
Stripe fixtures by falling back to 0 when
`stripe.prices.retrieve(...).unit_amount` is null. Update the test around
`PRO_PRICE_ID` and `LITE_PRICE_ID` to fail fast if either retrieved price has a
null `unit_amount` instead of defaulting, so
`expect(proUnit).toBeGreaterThan(liteUnit)` only runs with valid amounts and
clearly surfaces misconfigured prices.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Repository UI

Review profile: CHILL

Plan: Pro

Run ID: 4e737072-3b6f-4864-aeb5-42f3d4d97a43

📥 Commits

Reviewing files that changed from the base of the PR and between 58dac20 and 7fd2c8a.

📒 Files selected for processing (2)
  • apps/api/src/routes/dev-plans-reprice.e2e.ts
  • apps/api/src/routes/dev-plans.ts
🚧 Files skipped from review as they are similar to previous changes (1)
  • apps/api/src/routes/dev-plans.ts

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 7fd2c8a3f9

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread apps/api/src/routes/dev-plans.ts Outdated
// If Stripe already drafted the upcoming renewal invoice (tier change
// within the pre-renewal finalization window), it still bills the old
// price — re-price it to match the tier taking effect at renewal.
await repriceDraftRenewalInvoice({

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Persist pending tier before repricing draft

When scheduling a downgrade in the pre-renewal draft window, this best-effort Stripe work runs after the subscription price was changed but before devPlanPendingTier is persisted below. If Stripe finalizes and the invoice.payment_succeeded webhook runs during these awaits, the webhook computes the renewal tier from organization.devPlanPendingTier ?? organization.devPlan (apps/api/src/stripe.ts:3246), so it can grant/record the old tier even though the draft may already have been adjusted to the new lower price, and then this request leaves a pending downgrade for the next cycle. Write the pending tier before repricing, or make the webhook derive the renewal tier from Stripe, so the renewal state matches the charge.

Useful? React with 👍 / 👎.


// The downgrade may have already re-priced a drafted renewal invoice to
// the lower tier; re-price it back so the renewal bills the current tier.
await repriceDraftRenewalInvoice({

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Clear cancelled downgrade before repricing draft

When cancelling a downgrade, this reprice runs after the Stripe price is reverted but before devPlanPendingTier is cleared below. If the renewal invoice finalizes during these Stripe calls, the webhook still sees the stale pending tier and uses it as the effective renewal tier at apps/api/src/stripe.ts:3246, so a customer can be charged the restored higher/current price while the org is renewed onto the cancelled lower tier. Clear the pending tier before the best-effort reprice, or otherwise make the webhook ignore the stale pending tier once cancellation starts.

Useful? React with 👍 / 👎.

steebchen and others added 2 commits July 28, 2026 12:09
Stripe drafts the subscription_cycle renewal invoice up to ~an hour
before finalizing and charging it. A scheduled tier change (a downgrade,
or an upgrade deferred to renewal) swaps the subscription item's price
with proration suppressed and no cycle re-anchor, expecting the renewal
to bill the new tier. But a change landing inside that draft window
leaves the already-drafted invoice billing the OLD price, so a pro->lite
downgrade scheduled minutes before renewal charged the PRO price ($79)
while the renewal webhook granted LITE credits ($87) and labeled the
transaction "Dev Plan LITE renewed".

Add repriceDraftRenewalInvoice() next to voidPendingCycleRenewalInvoices
(both handle the same pre-finalization draft window; the apply-now
upgrade path voids the stale draft because it re-anchors, while a
scheduled change keeps the cycle so its draft must be re-priced instead).
It adds an adjustment invoice item for the difference between the new
tier's price and the draft's net plan amount (subscription lines plus
earlier tagged adjustments), which is idempotent across consecutive
changes and a no-op when the draft already bills the new price. Wired
into the scheduled-change branch (both directions) and cancel-downgrade.
Best-effort: a failure only reverts to the pre-existing near-boundary
mismatch and never fails the tier change.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Add an opt-in Stripe Test Clock e2e that reproduces the incident against
a live sandbox: subscribe PRO, advance to the renewal boundary so Stripe
drafts the subscription_cycle invoice, then downgrade to LITE mid-window
and run the real repriceDraftRenewalInvoice helper.

Confirms end-to-end: Stripe drafts the renewal at the PRO price before
finalizing; the item price swap does NOT re-price the existing draft
(root cause); the helper adjusts the draft to the LITE price idempotently;
cancel-downgrade nets it back to PRO; and the invoice finalizes and
charges the re-priced LITE amount once the clock passes finalization.

Gated behind STRIPE_TESTCLOCK_E2E so it is skipped in normal CI.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@steebchen
steebchen force-pushed the devpass-renewal-tier-mismatch branch from 7fd2c8a to d57ac4a Compare July 28, 2026 11:10
@chatgpt-codex-connector

Copy link
Copy Markdown

You have reached your Codex usage limits for code reviews. You can see your limits in the Codex usage dashboard.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants