Skip to content

Commit 3dc7cab

Browse files
steebchenclaude
andcommitted
feat(dev-plans): charge full price on tier upgrade
Replace prorated tier-change billing with a full-charge, fresh-cycle model for DevPass and Chat plan upgrades. On an upgrade we now update the Stripe subscription with `billing_cycle_anchor: "now"` + `proration_behavior: "none"` + `payment_behavior: "error_if_incomplete"`, so Stripe immediately charges the full new-tier price, starts a fresh billing period, and applies no proration. Credits reset to the new tier's full allowance with usage zeroed. This removes the custom prorated-invoice + manual-rollback dance and the prorated credit-delta math. Downgrades are unchanged (DevPass deferred to renewal; Chat immediate). - dev-plans: full-charge upgrade, full-reset credits, keep per-cycle claim as a double-charge guard; preview returns full price + full new-tier credits. - stripe webhook: tier-change invoice (subscription_update) now full- resets to the new tier (read from subscription metadata), idempotent via unique stripeInvoiceId; add a chat-subscription skip branch so a chat subscription_update invoice never flips the org to Pro. - chat-plans: mirror the upgrade model; record the invoice id. - remove now-unused getProratedCreditDelta; update tests + UI copy. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent 31b25ce commit 3dc7cab

9 files changed

Lines changed: 511 additions & 1020 deletions

File tree

apps/api/src/routes/chat-plans.ts

Lines changed: 63 additions & 46 deletions
Original file line numberDiff line numberDiff line change
@@ -431,58 +431,75 @@ chatPlans.openapi(changeTier, async (c) => {
431431
const subscription = await getStripe().subscriptions.retrieve(
432432
personalOrg.chatPlanStripeSubscriptionId,
433433
);
434+
const subscriptionItemId = subscription.items.data[0].id;
435+
const newCreditsLimit = getChatPlanCreditsLimit(newTier);
434436

435-
// Same payment-behavior strategy as dev-plans: charge prorated upgrades
436-
// synchronously and reject the upgrade if the card declines, so we don't
437-
// leave the customer on the higher tier without collecting.
438-
const updated = await getStripe().subscriptions.update(
439-
personalOrg.chatPlanStripeSubscriptionId,
440-
{
441-
items: [
442-
{
443-
id: subscription.items.data[0].id,
444-
price: newPriceId,
437+
if (isUpgrade) {
438+
// Charge the full new-tier price today and start a fresh billing cycle
439+
// (`billing_cycle_anchor: "now"`) with no proration
440+
// (`proration_behavior: "none"`). `error_if_incomplete` makes the update
441+
// atomic, so a declined card leaves the subscription on the old tier.
442+
const updated = await getStripe().subscriptions.update(
443+
personalOrg.chatPlanStripeSubscriptionId,
444+
{
445+
items: [{ id: subscriptionItemId, price: newPriceId }],
446+
proration_behavior: "none",
447+
billing_cycle_anchor: "now",
448+
payment_behavior: "error_if_incomplete",
449+
metadata: {
450+
...subscription.metadata,
451+
chatPlan: newTier,
452+
chatPlanCycle: "monthly",
445453
},
446-
],
447-
proration_behavior: isUpgrade ? "always_invoice" : "create_prorations",
448-
payment_behavior: isUpgrade
449-
? "error_if_incomplete"
450-
: "allow_incomplete",
451-
metadata: {
452-
...subscription.metadata,
454+
},
455+
);
456+
457+
if (updated.status !== "active" && updated.status !== "trialing") {
458+
throw new HTTPException(402, {
459+
message:
460+
"Upgrade payment could not be collected. Update your payment method and try again.",
461+
});
462+
}
463+
464+
// Fresh billing cycle: reset credits to the new tier's full allowance,
465+
// zero out usage, and advance the cycle start. The resulting
466+
// `subscription_update` invoice is recorded and emailed by the webhook
467+
// (handleInvoicePaymentSucceeded), keyed on its unique stripeInvoiceId, so
468+
// we don't insert an upgrade transaction here.
469+
await db
470+
.update(tables.organization)
471+
.set({
453472
chatPlan: newTier,
454-
chatPlanCycle: "monthly",
473+
chatPlanCreditsLimit: newCreditsLimit.toString(),
474+
chatPlanCreditsUsed: "0",
475+
chatPlanBillingCycleStart: new Date(),
476+
})
477+
.where(eq(tables.organization.id, personalOrg.id));
478+
} else {
479+
// Downgrade: unchanged behavior. Take effect immediately with Stripe
480+
// proration crediting the unused higher-tier time on the next invoice.
481+
await getStripe().subscriptions.update(
482+
personalOrg.chatPlanStripeSubscriptionId,
483+
{
484+
items: [{ id: subscriptionItemId, price: newPriceId }],
485+
proration_behavior: "create_prorations",
486+
payment_behavior: "allow_incomplete",
487+
metadata: {
488+
...subscription.metadata,
489+
chatPlan: newTier,
490+
chatPlanCycle: "monthly",
491+
},
455492
},
456-
},
457-
);
493+
);
458494

459-
if (
460-
isUpgrade &&
461-
updated.status !== "active" &&
462-
updated.status !== "trialing"
463-
) {
464-
throw new HTTPException(402, {
465-
message:
466-
"Upgrade payment could not be collected. Update your payment method and try again.",
467-
});
468-
}
469-
470-
const newCreditsLimit = getChatPlanCreditsLimit(newTier);
495+
await db
496+
.update(tables.organization)
497+
.set({
498+
chatPlan: newTier,
499+
chatPlanCreditsLimit: newCreditsLimit.toString(),
500+
})
501+
.where(eq(tables.organization.id, personalOrg.id));
471502

472-
await db
473-
.update(tables.organization)
474-
.set({
475-
chatPlan: newTier,
476-
chatPlanCreditsLimit: newCreditsLimit.toString(),
477-
})
478-
.where(eq(tables.organization.id, personalOrg.id));
479-
480-
// Upgrades charge a prorated amount (`always_invoice`); the resulting
481-
// `subscription_update` invoice is recorded and emailed by the webhook
482-
// (handleInvoicePaymentSucceeded), keyed on its unique stripeInvoiceId, so
483-
// we must not insert a duplicate upgrade transaction here. Downgrades issue
484-
// no charge/invoice, so record the tier change now.
485-
if (!isUpgrade) {
486503
await db.insert(tables.transaction).values({
487504
organizationId: personalOrg.id,
488505
type: "chat_plan_downgrade",

0 commit comments

Comments
 (0)