From b8d3d09d9eb8d9c65f946515829cda7ba9f2d87a Mon Sep 17 00:00:00 2001 From: javayhu Date: Mon, 8 Sep 2025 00:36:01 +0800 Subject: [PATCH] refactor: add support for webhook event of invoice.payment_succeeded & optimize the whole process --- src/payment/provider/stripe.ts | 627 ++++++++++++++------------------- 1 file changed, 266 insertions(+), 361 deletions(-) diff --git a/src/payment/provider/stripe.ts b/src/payment/provider/stripe.ts index 4de04c5..316fc8e 100644 --- a/src/payment/provider/stripe.ts +++ b/src/payment/provider/stripe.ts @@ -8,12 +8,8 @@ import { import { getCreditPackageById } from '@/credits/server'; import { CREDIT_TRANSACTION_TYPE } from '@/credits/types'; import { getDb } from '@/db'; -import { creditTransaction, payment, user } from '@/db/schema'; -import { - findPlanByPlanId, - findPlanByPriceId, - findPriceInPlan, -} from '@/lib/price-plan'; +import { payment, user } from '@/db/schema'; +import { findPlanByPlanId, findPriceInPlan } from '@/lib/price-plan'; import { sendNotification } from '@/notification/notification'; import { desc, eq } from 'drizzle-orm'; import { Stripe } from 'stripe'; @@ -523,88 +519,162 @@ export class StripeProvider implements PaymentProvider { } /** - * Handle successful invoice payment + * Handle successful invoice payment - NEW ARCHITECTURE + * Only create payment records here after payment is confirmed * @param invoice Stripe invoice */ private async onInvoicePaymentSucceeded( invoice: Stripe.Invoice ): Promise { console.log('>> Handle invoice payment succeeded'); - const subscriptionId = invoice.subscription as string | null; try { + const subscriptionId = invoice.subscription as string | null; + if (subscriptionId) { // This is a subscription payment - await this.handleSubscriptionPayment(invoice, subscriptionId); + await this.createSubscriptionPayment(invoice, subscriptionId); } else { - // This is a one-time payment with invoice - await this.handleOneTimeInvoicePayment(invoice); + // This is a one-time payment + await this.createOneTimePayment(invoice); } + + console.log('<< Successfully processed invoice payment'); } catch (error) { console.error('<< Handle invoice payment succeeded error:', error); + + // Check if it's a duplicate invoice error (database constraint violation) + if ( + error instanceof Error && + error.message.includes('unique constraint') + ) { + console.log('<< Invoice already processed:', invoice.id); + return; // Don't throw, this is expected for duplicate processing + } + + // For other errors, let Stripe retry throw error; } } /** - * Handle subscription payment success + * Create subscription payment record and process benefits - NEW ARCHITECTURE + * + * The order of events may be: + * checkout.session.completed + * customer.subscription.created + * customer.subscription.updated + * invoice.payment_succeeded + * * @param invoice Stripe invoice * @param subscriptionId Subscription ID */ - private async handleSubscriptionPayment( + private async createSubscriptionPayment( invoice: Stripe.Invoice, subscriptionId: string ): Promise { console.log( - '>> Handle subscription payment for subscription:', + '>> Create subscription payment record for subscription:', subscriptionId ); try { - const db = await getDb(); + // Get subscription details from Stripe + const subscription = + await this.stripe.subscriptions.retrieve(subscriptionId); + const customerId = subscription.customer as string; - // Find the payment record for this subscription - const payments = await db - .select({ - id: payment.id, - userId: payment.userId, - priceId: payment.priceId, - }) - .from(payment) - .where(eq(payment.subscriptionId, subscriptionId)) - .limit(1); - - if (payments.length === 0) { - console.warn( - '<< No payment record found for subscription:', - subscriptionId - ); + // Get priceId from subscription items + const priceId = subscription.items.data[0]?.price.id; + if (!priceId) { + console.warn('<< No priceId found for subscription'); return; } - const { userId, priceId } = payments[0]; + // Get userId from subscription metadata or fallback to customerId lookup + let userId: string | undefined = subscription.metadata.userId; + + // If no userId in metadata (common in renewals), find by customerId + if (!userId) { + console.log('No userId in metadata, finding by customerId'); + userId = await this.findUserIdByCustomerId(customerId); + + if (!userId) { + console.error('<< No userId found, this should not happen'); + return; + } + } + + const periodStart = this.getPeriodStart(subscription); + const periodEnd = this.getPeriodEnd(subscription); + const trialStart = subscription.trial_start + ? new Date(subscription.trial_start * 1000) + : null; + const trialEnd = subscription.trial_end + ? new Date(subscription.trial_end * 1000) + : null; + const currentDate = new Date(); + + // Create payment record with subscription status + const db = await getDb(); + const paymentResult = await db + .insert(payment) + .values({ + id: randomUUID(), + priceId, + type: PaymentTypes.SUBSCRIPTION, + userId, + customerId, + subscriptionId, + invoiceId: invoice.id, + interval: this.mapStripeIntervalToPlanInterval(subscription), + status: this.mapSubscriptionStatusToPaymentStatus( + subscription.status + ), // Use actual subscription status + periodStart, + periodEnd, + cancelAtPeriodEnd: subscription.cancel_at_period_end, + trialStart, + trialEnd, + createdAt: currentDate, + updatedAt: currentDate, + }) + .returning({ id: payment.id }); + + if (paymentResult.length === 0) { + console.warn('<< Failed to create subscription payment record'); + return; + } // Add subscription credits if enabled - if (userId && priceId && websiteConfig.credits?.enableCredits) { + if (websiteConfig.credits?.enableCredits) { await addSubscriptionCredits(userId, priceId); console.log('Added subscription credits for invoice:', invoice.id); } console.log('<< Successfully processed subscription payment'); } catch (error) { - console.error('<< Handle subscription payment error:', error); + console.error('<< Create subscription payment error:', error); + + // Don't throw error if it's already processed + if ( + error instanceof Error && + error.message.includes('unique constraint') + ) { + console.log('<< Subscription payment already processed:', invoice.id); + return; + } + throw error; } } /** - * Handle one-time payment with invoice + * Create one-time payment record and process benefits - NEW ARCHITECTURE * @param invoice Stripe invoice */ - private async handleOneTimeInvoicePayment( - invoice: Stripe.Invoice - ): Promise { - console.log('>> Handle one-time invoice payment:', invoice.id); + private async createOneTimePayment(invoice: Stripe.Invoice): Promise { + console.log('>> Create one-time payment record for invoice:', invoice.id); try { const customerId = invoice.customer as string; @@ -620,67 +690,61 @@ export class StripeProvider implements PaymentProvider { await this.stripe.paymentIntents.retrieve(paymentIntentId); const metadata = paymentIntent.metadata; - // Get userId from payment intent metadata - const userId = metadata?.userId; + // Get userId from payment intent metadata or fallback to customerId lookup + let userId: string | undefined = metadata?.userId; if (!userId) { - console.warn('<< No userId found in payment intent metadata'); - return; + console.log('No userId in metadata, finding by customerId'); + userId = await this.findUserIdByCustomerId(customerId); + + if (!userId) { + console.error('<< No userId found, this should not happen'); + return; + } } // Check if this is a credit purchase const isCreditPurchase = metadata?.type === 'credit_purchase'; if (isCreditPurchase) { - // Process credit purchase using metadata - await this.processCreditPurchaseFromMetadata(metadata, userId, invoice); + // Process credit purchase + await this.createCreditPurchasePayment(invoice, metadata, userId); } else { - // This is a lifetime plan purchase - const priceId = metadata?.priceId; - if (!priceId) { - console.warn('<< No priceId found in payment intent metadata'); - return; - } - - // Add lifetime credits if enabled - if (websiteConfig.credits?.enableCredits) { - await addLifetimeMonthlyCredits(userId, priceId); - console.log('Added lifetime credits for invoice:', invoice.id); - } - - // Send notification - const amount = invoice.amount_paid ? invoice.amount_paid / 100 : 0; - await sendNotification(invoice.id, customerId, userId, amount); + // Process lifetime plan purchase + await this.createLifetimePlanPayment( + invoice, + metadata, + userId, + customerId + ); } - console.log('<< Successfully processed one-time invoice payment'); + console.log('<< Successfully created one-time payment record'); } catch (error) { - console.error('<< Handle one-time invoice payment error:', error); + console.error('<< Create one-time payment error:', error); throw error; } } /** - * Process credit purchase payment using payment intent metadata + * Create payment record for credit purchase - NEW ARCHITECTURE + * @param invoice Stripe invoice * @param metadata Payment intent metadata * @param userId User ID - * @param invoice Stripe invoice */ - private async processCreditPurchaseFromMetadata( + private async createCreditPurchasePayment( + invoice: Stripe.Invoice, metadata: { [key: string]: string }, - userId: string, - invoice: Stripe.Invoice + userId: string ): Promise { - console.log('>> Process credit purchase payment from metadata'); + console.log('>> Create credit purchase payment record'); try { const packageId = metadata.packageId; const credits = metadata.credits; - const paymentIntentId = invoice.payment_intent as string; + const customerId = invoice.customer as string; if (!packageId || !credits) { - console.warn( - '<< Missing packageId or credits in payment intent metadata' - ); + console.warn('<< Missing packageId or credits in metadata'); return; } @@ -691,19 +755,27 @@ export class StripeProvider implements PaymentProvider { return; } - // Check if credits have already been added to prevent duplicates + // Create payment record const db = await getDb(); - const existingCreditTransaction = await db - .select({ id: creditTransaction.id }) - .from(creditTransaction) - .where(eq(creditTransaction.paymentId, paymentIntentId)) - .limit(1); + const currentDate = new Date(); + const paymentResult = await db + .insert(payment) + .values({ + id: randomUUID(), + priceId: metadata.priceId || '', + type: PaymentTypes.ONE_TIME, + userId, + customerId, + invoiceId: invoice.id, + status: 'completed', + periodStart: currentDate, + createdAt: currentDate, + updatedAt: currentDate, + }) + .returning({ id: payment.id }); - if (existingCreditTransaction.length > 0) { - console.log( - '<< Credits already added for payment intent:', - paymentIntentId - ); + if (paymentResult.length === 0) { + console.warn('<< Failed to create credit purchase payment record'); return; } @@ -714,98 +786,109 @@ export class StripeProvider implements PaymentProvider { amount: Number.parseInt(credits), type: CREDIT_TRANSACTION_TYPE.PURCHASE_PACKAGE, description: `+${credits} credits for package ${packageId} ($${amount.toLocaleString()})`, - paymentId: paymentIntentId, // Use payment intent ID as payment ID + paymentId: invoice.id, // Use invoice ID as payment ID expireDays: creditPackage.expireDays, }); - console.log('Added', credits, 'credits to user for confirmed payment'); + console.log('<< Successfully added credits to user for credit purchase'); } catch (error) { - console.error('<< Process credit purchase from metadata error:', error); + console.error('<< Create credit purchase payment error:', error); + + // Don't throw error if it's already processed + if ( + error instanceof Error && + error.message.includes('unique constraint') + ) { + console.log('<< Credit purchase already processed:', invoice.id); + return; + } + throw error; } } /** - * Create payment record + * Create payment record for lifetime plan purchase - NEW ARCHITECTURE + * @param invoice Stripe invoice + * @param metadata Payment intent metadata + * @param userId User ID + * @param customerId Customer ID + */ + private async createLifetimePlanPayment( + invoice: Stripe.Invoice, + metadata: { [key: string]: string }, + userId: string, + customerId: string + ): Promise { + console.log('>> Create lifetime plan payment record'); + + try { + const priceId = metadata?.priceId; + if (!priceId) { + console.warn('<< No priceId found in payment intent metadata'); + return; + } + + // Create payment record + const db = await getDb(); + const currentDate = new Date(); + const paymentResult = await db + .insert(payment) + .values({ + id: randomUUID(), + priceId, + type: PaymentTypes.ONE_TIME, + userId, + customerId, + invoiceId: invoice.id, + status: 'completed', + periodStart: currentDate, + createdAt: currentDate, + updatedAt: currentDate, + }) + .returning({ id: payment.id }); + + if (paymentResult.length === 0) { + console.warn('<< Failed to create lifetime plan payment record'); + return; + } + + // Add lifetime credits if enabled + if (websiteConfig.credits?.enableCredits) { + await addLifetimeMonthlyCredits(userId, priceId); + console.log('Added lifetime credits for invoice:', invoice.id); + } + + // Send notification + const amount = invoice.amount_paid ? invoice.amount_paid / 100 : 0; + await sendNotification(invoice.id, customerId, userId, amount); + + console.log('<< Successfully created lifetime plan payment record'); + } catch (error) { + console.error('<< Create lifetime plan payment error:', error); + + // Don't throw error if it's already processed + if ( + error instanceof Error && + error.message.includes('unique constraint') + ) { + console.log('<< Lifetime plan payment already processed:', invoice.id); + return; + } + + throw error; + } + } + + /** + * Handle subscription creation - NEW ARCHITECTURE + * Only log the event, payment records created in invoice.payment_succeeded * @param stripeSubscription Stripe subscription */ private async onCreateSubscription( stripeSubscription: Stripe.Subscription ): Promise { - console.log('>> Create payment record for Stripe subscription'); - const customerId = stripeSubscription.customer as string; - - // Check if subscription record already exists to prevent duplicates - const db = await getDb(); - const existingPayment = await db - .select({ id: payment.id }) - .from(payment) - .where(eq(payment.subscriptionId, stripeSubscription.id)) - .limit(1); - - if (existingPayment.length > 0) { - console.log( - '<< Subscription already has payment record:', - stripeSubscription.id - ); - return; - } - - // get priceId from subscription items (this is always available) - const priceId = stripeSubscription.items.data[0]?.price.id; - if (!priceId) { - console.warn('No priceId found for subscription'); - return; - } - - // get userId from metadata, we add it in the createCheckout session - const userId = stripeSubscription.metadata.userId; - if (!userId) { - console.warn('No userId found for subscription'); - return; - } - - const periodStart = this.getPeriodStart(stripeSubscription); - const periodEnd = this.getPeriodEnd(stripeSubscription); - - // create fields - const createFields: any = { - id: randomUUID(), - priceId: priceId, - type: PaymentTypes.SUBSCRIPTION, - userId: userId, - customerId: customerId, - subscriptionId: stripeSubscription.id, - interval: this.mapStripeIntervalToPlanInterval(stripeSubscription), - status: this.mapSubscriptionStatusToPaymentStatus( - stripeSubscription.status - ), - periodStart: periodStart, - periodEnd: periodEnd, - cancelAtPeriodEnd: stripeSubscription.cancel_at_period_end, - trialStart: stripeSubscription.trial_start - ? new Date(stripeSubscription.trial_start * 1000) - : null, - trialEnd: stripeSubscription.trial_end - ? new Date(stripeSubscription.trial_end * 1000) - : null, - createdAt: new Date(), - updatedAt: new Date(), - }; - - const result = await db - .insert(payment) - .values(createFields) - .returning({ id: payment.id }); - - if (result.length > 0) { - console.log('<< Created new payment record for Stripe subscription'); - } else { - console.warn('<< No payment record created for Stripe subscription'); - } - - // Note: Credits will be added when invoice.payment_succeeded event is received - // This ensures credits are only added for successful payments + console.log('Handle subscription creation:', stripeSubscription.id); } /** @@ -815,48 +898,24 @@ export class StripeProvider implements PaymentProvider { private async onUpdateSubscription( stripeSubscription: Stripe.Subscription ): Promise { - console.log('>> Update payment record for Stripe subscription'); + console.log('>> Handle subscription update'); // get priceId from subscription items (this is always available) const priceId = stripeSubscription.items.data[0]?.price.id; if (!priceId) { - console.warn('No priceId found for subscription'); + console.warn('<< No priceId found for subscription'); return; } - // Single query to check existence and get current payment data - const db = await getDb(); - const existingPayments = await db - .select({ - id: payment.id, - userId: payment.userId, - periodStart: payment.periodStart, - periodEnd: payment.periodEnd, - }) - .from(payment) - .where(eq(payment.subscriptionId, stripeSubscription.id)) - .limit(1); - - if (existingPayments.length === 0) { - console.warn( - '<< No payment record found for subscription update:', - stripeSubscription.id - ); - return; - } - - const currentPayment = existingPayments[0]; - // get new period start and end const newPeriodStart = this.getPeriodStart(stripeSubscription); const newPeriodEnd = this.getPeriodEnd(stripeSubscription); - - // Check if this is a renewal (period has changed and subscription is active) - const isRenewal = - stripeSubscription.status === 'active' && - currentPayment.periodStart && - newPeriodStart && - currentPayment.periodStart.getTime() !== newPeriodStart.getTime(); + const trialStart = stripeSubscription.trial_start + ? new Date(stripeSubscription.trial_start * 1000) + : undefined; + const trialEnd = stripeSubscription.trial_end + ? new Date(stripeSubscription.trial_end * 1000) + : undefined; // update fields const updateFields: any = { @@ -868,15 +927,12 @@ export class StripeProvider implements PaymentProvider { periodStart: newPeriodStart, periodEnd: newPeriodEnd, cancelAtPeriodEnd: stripeSubscription.cancel_at_period_end, - trialStart: stripeSubscription.trial_start - ? new Date(stripeSubscription.trial_start * 1000) - : undefined, - trialEnd: stripeSubscription.trial_end - ? new Date(stripeSubscription.trial_end * 1000) - : undefined, + trialStart: trialStart, + trialEnd: trialEnd, updatedAt: new Date(), }; + const db = await getDb(); const result = await db .update(payment) .set(updateFields) @@ -884,12 +940,9 @@ export class StripeProvider implements PaymentProvider { .returning({ id: payment.id }); if (result.length > 0) { - console.log('<< Updated payment record for Stripe subscription'); - - // Note: Credits for subscription renewals will be added when invoice.payment_succeeded event is received - // This ensures credits are only added for successful payments, not just subscription updates + console.log('<< Updated payment record for subscription'); } else { - console.warn('<< No payment record found for Stripe subscription'); + console.warn('<< No payment record found for subscription update'); } } @@ -900,25 +953,9 @@ export class StripeProvider implements PaymentProvider { private async onDeleteSubscription( stripeSubscription: Stripe.Subscription ): Promise { - console.log('>> Mark payment record for Stripe subscription as canceled'); + console.log('>> Handle subscription deletion'); const db = await getDb(); - - // Check if subscription record exists first - const existingPayment = await db - .select({ id: payment.id }) - .from(payment) - .where(eq(payment.subscriptionId, stripeSubscription.id)) - .limit(1); - - if (existingPayment.length === 0) { - console.warn( - '<< No payment record found for subscription deletion:', - stripeSubscription.id - ); - return; - } - const result = await db .update(payment) .set({ @@ -933,162 +970,30 @@ export class StripeProvider implements PaymentProvider { if (result.length > 0) { console.log('<< Marked payment record for subscription as canceled'); } else { - console.warn( - '<< No payment record found to cancel for Stripe subscription' - ); + console.warn('<< No payment record found for subscription deletion'); } } /** - * Handle one-time payment checkout completion - * Note: This creates the initial payment record. Credits and final processing - * are handled by invoice.payment_succeeded event for better reliability + * Handle checkout session completion - NEW ARCHITECTURE + * Only log the event, payment records created in invoice.payment_succeeded * @param session Stripe checkout session */ private async onOnetimePayment( session: Stripe.Checkout.Session ): Promise { - const customerId = session.customer as string; - console.log('>> Handle onetime payment checkout completion for customer'); - - // get userId from session metadata, we add it in the createCheckout session - const userId = session.metadata?.userId; - if (!userId) { - console.warn('No userId found for checkout session'); - return; - } - - // get priceId from session metadata, not from line items - const priceId = session.metadata?.priceId; - if (!priceId) { - console.warn('No priceId found for checkout session'); - return; - } - - try { - const db = await getDb(); - - // Check if this session has already been processed to prevent duplicate processing - const existingPayment = await db - .select({ id: payment.id }) - .from(payment) - .where(eq(payment.sessionId, session.id)) - .limit(1); - - if (existingPayment.length > 0) { - console.log( - 'One-time payment session already processed: ' + session.id - ); - return; - } - - // Create a one-time payment record - const now = new Date(); - const result = await db - .insert(payment) - .values({ - id: randomUUID(), - priceId: priceId, - type: PaymentTypes.ONE_TIME, - userId: userId, - customerId: customerId, - sessionId: session.id, // Track the session ID - status: 'completed', // One-time payments are considered completed when checkout succeeds - periodStart: now, - createdAt: now, - updatedAt: now, - }) - .returning({ id: payment.id }); - - if (result.length === 0) { - console.warn('<< Failed to create one-time payment record for user'); - return; - } - console.log( - 'Created one-time payment record for checkout session:', - session.id - ); - - // Note: Credits and notifications will be handled by invoice.payment_succeeded event - // This ensures credits are only added when payment is actually confirmed successful - console.log( - '<< One-time payment checkout recorded, awaiting payment confirmation via invoice webhook' - ); - } catch (error) { - console.error('onOnetimePayment error for session: ' + session.id, error); - throw error; - } + console.log('Handle checkout session completion:', session.id); } /** - * Handle credit purchase checkout completion - * Note: This creates the initial payment record. Credits are processed - * when invoice.payment_succeeded event is received for payment confirmation. + * Handle credit purchase checkout completion - NEW ARCHITECTURE + * Only log the event, payment records created in invoice.payment_succeeded * @param session Stripe checkout session */ private async onCreditPurchase( session: Stripe.Checkout.Session ): Promise { - const customerId = session.customer as string; - console.log('>> Handle credit purchase checkout completion for customer'); - - // get userId from session metadata, we add it in the createCheckout session - const userId = session.metadata?.userId; - if (!userId) { - console.warn('No userId found for checkout session'); - return; - } - - // get packageId from session metadata - const packageId = session.metadata?.packageId; - if (!packageId) { - console.warn('No packageId found for checkout session'); - return; - } - - try { - // Check if this session has already been processed to prevent duplicate processing - const db = await getDb(); - const existingPayment = await db - .select({ id: payment.id }) - .from(payment) - .where(eq(payment.sessionId, session.id)) - .limit(1); - - if (existingPayment.length > 0) { - console.log('Credit purchase session already processed: ' + session.id); - return; - } - - // Create payment record first to mark this session as processed - const now = new Date(); - await db.insert(payment).values({ - id: randomUUID(), - priceId: session.metadata?.priceId || '', - type: PaymentTypes.ONE_TIME, - userId: userId, - customerId: customerId, - sessionId: session.id, // Use sessionId to track processed sessions - status: 'completed', // Checkout completed, but waiting for payment confirmation - periodStart: now, - createdAt: now, - updatedAt: now, - }); - - console.log( - 'Created credit purchase payment record for checkout session:', - session.id - ); - - // Note: Credits will be added when invoice.payment_succeeded event is received - // This ensures credits are only added when payment is actually confirmed successful - console.log( - '<< Credit purchase checkout recorded, awaiting payment confirmation via invoice webhook' - ); - } catch (error) { - console.error('onCreditPurchase error for session: ' + session.id, error); - throw error; - } + console.log('Handle credit purchase checkout completion:', session.id); } /**