@@ -26,7 +26,6 @@ import {
2626import { computeReferralBonus } from "./lib/referral-bonus.js" ;
2727import { posthog } from "./posthog.js" ;
2828import { getStripe , type StripeMode } from "./routes/payments.js" ;
29- import { resolveDevPassBillingDetails } from "./utils/devpass-billing.js" ;
3029import {
3130 notifyChatPlanCancelled ,
3231 notifyChatPlanRenewed ,
@@ -44,6 +43,10 @@ import {
4443 sendTransactionalEmail ,
4544} from "./utils/email.js" ;
4645import { generateAndEmailInvoice } from "./utils/invoice.js" ;
46+ import {
47+ resolveChatPlanBillingDetails ,
48+ resolveDevPassBillingDetails ,
49+ } from "./utils/plan-billing.js" ;
4750
4851import type { ServerTypes } from "./vars.js" ;
4952import 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
0 commit comments