Skip to content

Commit 7faa91c

Browse files
steebchenclaude
andauthored
fix(billing): send invoices for chat plans (#2944)
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent 9fcb2f7 commit 7faa91c

4 files changed

Lines changed: 157 additions & 36 deletions

File tree

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

Lines changed: 13 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -477,12 +477,19 @@ chatPlans.openapi(changeTier, async (c) => {
477477
})
478478
.where(eq(tables.organization.id, personalOrg.id));
479479

480-
await db.insert(tables.transaction).values({
481-
organizationId: personalOrg.id,
482-
type: isUpgrade ? "chat_plan_upgrade" : "chat_plan_downgrade",
483-
description: `Changed from ${personalOrg.chatPlan} to ${newTier} plan`,
484-
status: "completed",
485-
});
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) {
486+
await db.insert(tables.transaction).values({
487+
organizationId: personalOrg.id,
488+
type: "chat_plan_downgrade",
489+
description: `Changed from ${personalOrg.chatPlan} to ${newTier} plan`,
490+
status: "completed",
491+
});
492+
}
486493

487494
await logAuditEvent({
488495
organizationId: personalOrg.id,

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

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,6 @@ import { z } from "zod";
44

55
import { ensureStripeCustomer, finalizeDevPlanSetupSession } from "@/stripe.js";
66
import { findDefaultOrganization } from "@/utils/default-org.js";
7-
import { resolveDevPassBillingDetails } from "@/utils/devpass-billing.js";
87
import {
98
buildInvoiceDataForTransaction,
109
generateAndEmailInvoice,
@@ -13,6 +12,7 @@ import {
1312
isRefundTransaction,
1413
} from "@/utils/invoice.js";
1514
import { getOrCreatePersonalOrg } from "@/utils/personal-org.js";
15+
import { resolveDevPassBillingDetails } from "@/utils/plan-billing.js";
1616

1717
import { logAuditEvent } from "@llmgateway/audit";
1818
import {

apps/api/src/stripe.ts

Lines changed: 114 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -26,7 +26,6 @@ import {
2626
import { computeReferralBonus } from "./lib/referral-bonus.js";
2727
import { posthog } from "./posthog.js";
2828
import { getStripe, type StripeMode } from "./routes/payments.js";
29-
import { resolveDevPassBillingDetails } from "./utils/devpass-billing.js";
3029
import {
3130
notifyChatPlanCancelled,
3231
notifyChatPlanRenewed,
@@ -44,6 +43,10 @@ import {
4443
sendTransactionalEmail,
4544
} from "./utils/email.js";
4645
import { generateAndEmailInvoice } from "./utils/invoice.js";
46+
import {
47+
resolveChatPlanBillingDetails,
48+
resolveDevPassBillingDetails,
49+
} from "./utils/plan-billing.js";
4750

4851
import type { ServerTypes } from "./vars.js";
4952
import type Stripe from "stripe";
@@ -1301,16 +1304,14 @@ async function handleCheckoutSessionCompleted(
13011304
.returning();
13021305

13031306
try {
1307+
const billingDetails =
1308+
await resolveChatPlanBillingDetails(organization);
13041309
await generateAndEmailInvoice({
13051310
organizationId: organization.id,
13061311
invoiceNumber: transaction.id,
13071312
invoiceDate: new Date(),
13081313
organizationName: organization.name,
1309-
billingEmail: organization.billingEmail,
1310-
billingCompany: organization.billingCompany,
1311-
billingAddress: organization.billingAddress,
1312-
billingTaxId: organization.billingTaxId,
1313-
billingNotes: organization.billingNotes,
1314+
...billingDetails,
13141315
lineItems: [
13151316
{
13161317
description: `Chat Plan ${chatPlanTier.toUpperCase()} ($${creditsLimit} credits included)`,
@@ -3063,6 +3064,11 @@ export async function handleInvoicePaymentSucceeded(event: {
30633064
// true cycle renewal, not on mid-cycle tier-change proration invoices.
30643065
const isChatPlanRenewal =
30653066
isChatPlanSubscription && invoice.billing_reason === "subscription_cycle";
3067+
// Mid-cycle chat plan tier change: the change-tier endpoint charges the
3068+
// prorated upgrade with `always_invoice`, which Stripe bills as a
3069+
// `subscription_update` invoice.
3070+
const isChatPlanUpgradeInvoice =
3071+
isChatPlanSubscription && invoice.billing_reason === "subscription_update";
30663072

30673073
logger.info(
30683074
`Found organization: ${organization.name} (${organization.id}), current plan: ${organization.plan}, billingReason: ${invoice.billing_reason}, isDevPlanRenewal: ${isDevPlanRenewal}, isChatPlanRenewal: ${isChatPlanRenewal}`,
@@ -3188,17 +3194,20 @@ export async function handleInvoicePaymentSucceeded(event: {
31883194
organization.chatPlan as ChatPlanTier,
31893195
);
31903196

3191-
await db.insert(tables.transaction).values({
3192-
organizationId,
3193-
type: "chat_plan_renewal",
3194-
amount: (invoice.amount_paid / 100).toString(),
3195-
creditAmount: creditsLimit.toString(),
3196-
currency: invoice.currency.toUpperCase(),
3197-
status: "completed",
3198-
stripePaymentIntentId: (invoice as any).payment_intent,
3199-
stripeInvoiceId: invoice.id,
3200-
description: `Chat Plan ${organization.chatPlan?.toUpperCase()} renewed`,
3201-
});
3197+
const [renewalTransaction] = await db
3198+
.insert(tables.transaction)
3199+
.values({
3200+
organizationId,
3201+
type: "chat_plan_renewal",
3202+
amount: (invoice.amount_paid / 100).toString(),
3203+
creditAmount: creditsLimit.toString(),
3204+
currency: invoice.currency.toUpperCase(),
3205+
status: "completed",
3206+
stripePaymentIntentId: (invoice as any).payment_intent,
3207+
stripeInvoiceId: invoice.id,
3208+
description: `Chat Plan ${organization.chatPlan?.toUpperCase()} renewed`,
3209+
})
3210+
.returning();
32023211

32033212
await db
32043213
.update(tables.organization)
@@ -3213,6 +3222,29 @@ export async function handleInvoicePaymentSucceeded(event: {
32133222
`Chat plan ${organization.chatPlan} renewed for organization ${organizationId}, credits reset to 0/${creditsLimit}`,
32143223
);
32153224

3225+
try {
3226+
const billingDetails = await resolveChatPlanBillingDetails(organization);
3227+
await generateAndEmailInvoice({
3228+
organizationId: organization.id,
3229+
invoiceNumber: renewalTransaction.id,
3230+
invoiceDate: new Date(),
3231+
organizationName: organization.name,
3232+
...billingDetails,
3233+
lineItems: [
3234+
{
3235+
description: `Chat Plan ${organization.chatPlan?.toUpperCase()} renewal ($${creditsLimit} credits included)`,
3236+
amount: invoice.amount_paid / 100,
3237+
},
3238+
],
3239+
currency: invoice.currency.toUpperCase(),
3240+
});
3241+
} catch (e) {
3242+
logger.error(
3243+
"Invoice email failed (chat plan renewal invoice); suppressing failure",
3244+
e as Error,
3245+
);
3246+
}
3247+
32163248
posthog.capture({
32173249
distinctId: "organization",
32183250
event: "chat_plan_renewed",
@@ -3456,6 +3488,71 @@ export async function handleInvoicePaymentSucceeded(event: {
34563488
logger.info(
34573489
`Skipping non-renewal dev plan invoice for organization ${organizationId} (billingReason: ${invoice.billing_reason})`,
34583490
);
3491+
} else if (isChatPlanUpgradeInvoice) {
3492+
// Invoice from a mid-cycle chat plan upgrade. The change-tier endpoint
3493+
// already applied the new tier/limit synchronously; this webhook records
3494+
// the charge and emails the invoice. onConflictDoNothing on the unique
3495+
// stripeInvoiceId index keeps it idempotent against Stripe retries, so the
3496+
// row and email are produced at most once. Credits are left untouched — an
3497+
// upgrade must not reset the cycle's usage.
3498+
const creditsLimit = getChatPlanCreditsLimit(
3499+
organization.chatPlan as ChatPlanTier,
3500+
);
3501+
const [upgradeTransaction] = await db
3502+
.insert(tables.transaction)
3503+
.values({
3504+
organizationId,
3505+
type: "chat_plan_upgrade",
3506+
amount: (invoice.amount_paid / 100).toString(),
3507+
creditAmount: creditsLimit.toString(),
3508+
currency: invoice.currency.toUpperCase(),
3509+
status: "completed",
3510+
stripePaymentIntentId: (invoice as any).payment_intent,
3511+
stripeInvoiceId: invoice.id,
3512+
description: `Chat Plan ${organization.chatPlan?.toUpperCase()} upgrade`,
3513+
})
3514+
.onConflictDoNothing()
3515+
.returning();
3516+
3517+
if (upgradeTransaction) {
3518+
try {
3519+
const billingDetails =
3520+
await resolveChatPlanBillingDetails(organization);
3521+
await generateAndEmailInvoice({
3522+
organizationId: organization.id,
3523+
invoiceNumber: upgradeTransaction.id,
3524+
invoiceDate: new Date(),
3525+
organizationName: organization.name,
3526+
...billingDetails,
3527+
lineItems: [
3528+
{
3529+
description: `Chat Plan ${organization.chatPlan?.toUpperCase()} upgrade`,
3530+
amount: invoice.amount_paid / 100,
3531+
},
3532+
],
3533+
currency: invoice.currency.toUpperCase(),
3534+
});
3535+
} catch (e) {
3536+
logger.error(
3537+
"Invoice email failed (chat plan upgrade invoice); suppressing failure",
3538+
e as Error,
3539+
);
3540+
}
3541+
logger.info(
3542+
`Recorded chat plan upgrade invoice for organization ${organizationId}; credits used left unchanged`,
3543+
);
3544+
} else {
3545+
logger.info(
3546+
`Chat plan upgrade transaction already exists for invoice ${invoice.id}; skipping duplicate insert/email`,
3547+
);
3548+
}
3549+
} else if (isChatPlanSubscription) {
3550+
// Any other chat-plan invoice (e.g. `manual`). Do not fall through to the
3551+
// Pro-subscription handler, which would wrongly flip the org to the Pro
3552+
// plan and email a Pro invoice.
3553+
logger.info(
3554+
`Skipping non-renewal chat plan invoice for organization ${organizationId} (billingReason: ${invoice.billing_reason})`,
3555+
);
34593556
} else {
34603557
// Handle regular pro subscription
34613558
// Create transaction record for subscription start
Lines changed: 29 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@ import { findDefaultOrganization } from "./default-org.js";
44

55
type Organization = typeof tables.organization.$inferSelect;
66

7-
export interface DevPassBillingDetails {
7+
export interface PlanBillingDetails {
88
billingEmail: string;
99
billingCompany: string | null;
1010
billingAddress: string | null;
@@ -18,7 +18,7 @@ function pickBillingDetails(org: {
1818
billingAddress: string | null;
1919
billingTaxId: string | null;
2020
billingNotes: string | null;
21-
}): DevPassBillingDetails {
21+
}): PlanBillingDetails {
2222
return {
2323
billingEmail: org.billingEmail,
2424
billingCompany: org.billingCompany,
@@ -28,17 +28,12 @@ function pickBillingDetails(org: {
2828
};
2929
}
3030

31-
// Resolve the billing details to use on a DevPass invoice for `personalOrg`.
32-
// When the override flag is off (default), the owner's default-org billing
33-
// details are mirrored exactly (company, address, tax id, notes, and email);
34-
// when on, the DevPass org's own billing* fields are used.
35-
export async function resolveDevPassBillingDetails(
31+
// Mirror the owner's default ("main") organization billing details — company,
32+
// address, tax id, notes and email. Falls back to the given per-user (devpass
33+
// or chat) org when the owner has no default org.
34+
async function resolveDefaultOrgBillingDetails(
3635
personalOrg: Organization,
37-
): Promise<DevPassBillingDetails> {
38-
if (personalOrg.devPlanBillingOverride) {
39-
return pickBillingDetails(personalOrg);
40-
}
41-
36+
): Promise<PlanBillingDetails> {
4237
const owner = await db.query.userOrganization.findFirst({
4338
where: {
4439
organizationId: { eq: personalOrg.id },
@@ -53,3 +48,25 @@ export async function resolveDevPassBillingDetails(
5348

5449
return pickBillingDetails(defaultOrg ?? personalOrg);
5550
}
51+
52+
// Resolve the billing details to use on a DevPass invoice for `personalOrg`.
53+
// When the override flag is off (default), the owner's default-org billing
54+
// details are mirrored exactly (company, address, tax id, notes, and email);
55+
// when on, the DevPass org's own billing* fields are used.
56+
export async function resolveDevPassBillingDetails(
57+
personalOrg: Organization,
58+
): Promise<PlanBillingDetails> {
59+
if (personalOrg.devPlanBillingOverride) {
60+
return pickBillingDetails(personalOrg);
61+
}
62+
63+
return await resolveDefaultOrgBillingDetails(personalOrg);
64+
}
65+
66+
// Chat plan invoices always bill against the owner's default ("main") org
67+
// billing settings — chat orgs have no per-plan billing override.
68+
export async function resolveChatPlanBillingDetails(
69+
personalOrg: Organization,
70+
): Promise<PlanBillingDetails> {
71+
return await resolveDefaultOrgBillingDetails(personalOrg);
72+
}

0 commit comments

Comments
 (0)