refactor: add support for webhook event of invoice.payment_succeeded & optimize the whole process
This commit is contained in:
parent
5cebf2ef00
commit
b8d3d09d9e
@ -8,12 +8,8 @@ import {
|
|||||||
import { getCreditPackageById } from '@/credits/server';
|
import { getCreditPackageById } from '@/credits/server';
|
||||||
import { CREDIT_TRANSACTION_TYPE } from '@/credits/types';
|
import { CREDIT_TRANSACTION_TYPE } from '@/credits/types';
|
||||||
import { getDb } from '@/db';
|
import { getDb } from '@/db';
|
||||||
import { creditTransaction, payment, user } from '@/db/schema';
|
import { payment, user } from '@/db/schema';
|
||||||
import {
|
import { findPlanByPlanId, findPriceInPlan } from '@/lib/price-plan';
|
||||||
findPlanByPlanId,
|
|
||||||
findPlanByPriceId,
|
|
||||||
findPriceInPlan,
|
|
||||||
} from '@/lib/price-plan';
|
|
||||||
import { sendNotification } from '@/notification/notification';
|
import { sendNotification } from '@/notification/notification';
|
||||||
import { desc, eq } from 'drizzle-orm';
|
import { desc, eq } from 'drizzle-orm';
|
||||||
import { Stripe } from 'stripe';
|
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
|
* @param invoice Stripe invoice
|
||||||
*/
|
*/
|
||||||
private async onInvoicePaymentSucceeded(
|
private async onInvoicePaymentSucceeded(
|
||||||
invoice: Stripe.Invoice
|
invoice: Stripe.Invoice
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
console.log('>> Handle invoice payment succeeded');
|
console.log('>> Handle invoice payment succeeded');
|
||||||
const subscriptionId = invoice.subscription as string | null;
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
const subscriptionId = invoice.subscription as string | null;
|
||||||
|
|
||||||
if (subscriptionId) {
|
if (subscriptionId) {
|
||||||
// This is a subscription payment
|
// This is a subscription payment
|
||||||
await this.handleSubscriptionPayment(invoice, subscriptionId);
|
await this.createSubscriptionPayment(invoice, subscriptionId);
|
||||||
} else {
|
} else {
|
||||||
// This is a one-time payment with invoice
|
// This is a one-time payment
|
||||||
await this.handleOneTimeInvoicePayment(invoice);
|
await this.createOneTimePayment(invoice);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
console.log('<< Successfully processed invoice payment');
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('<< Handle invoice payment succeeded error:', 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;
|
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 invoice Stripe invoice
|
||||||
* @param subscriptionId Subscription ID
|
* @param subscriptionId Subscription ID
|
||||||
*/
|
*/
|
||||||
private async handleSubscriptionPayment(
|
private async createSubscriptionPayment(
|
||||||
invoice: Stripe.Invoice,
|
invoice: Stripe.Invoice,
|
||||||
subscriptionId: string
|
subscriptionId: string
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
console.log(
|
console.log(
|
||||||
'>> Handle subscription payment for subscription:',
|
'>> Create subscription payment record for subscription:',
|
||||||
subscriptionId
|
subscriptionId
|
||||||
);
|
);
|
||||||
|
|
||||||
try {
|
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
|
// Get priceId from subscription items
|
||||||
const payments = await db
|
const priceId = subscription.items.data[0]?.price.id;
|
||||||
.select({
|
if (!priceId) {
|
||||||
id: payment.id,
|
console.warn('<< No priceId found for subscription');
|
||||||
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
|
|
||||||
);
|
|
||||||
return;
|
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
|
// Add subscription credits if enabled
|
||||||
if (userId && priceId && websiteConfig.credits?.enableCredits) {
|
if (websiteConfig.credits?.enableCredits) {
|
||||||
await addSubscriptionCredits(userId, priceId);
|
await addSubscriptionCredits(userId, priceId);
|
||||||
console.log('Added subscription credits for invoice:', invoice.id);
|
console.log('Added subscription credits for invoice:', invoice.id);
|
||||||
}
|
}
|
||||||
|
|
||||||
console.log('<< Successfully processed subscription payment');
|
console.log('<< Successfully processed subscription payment');
|
||||||
} catch (error) {
|
} 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;
|
throw error;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Handle one-time payment with invoice
|
* Create one-time payment record and process benefits - NEW ARCHITECTURE
|
||||||
* @param invoice Stripe invoice
|
* @param invoice Stripe invoice
|
||||||
*/
|
*/
|
||||||
private async handleOneTimeInvoicePayment(
|
private async createOneTimePayment(invoice: Stripe.Invoice): Promise<void> {
|
||||||
invoice: Stripe.Invoice
|
console.log('>> Create one-time payment record for invoice:', invoice.id);
|
||||||
): Promise<void> {
|
|
||||||
console.log('>> Handle one-time invoice payment:', invoice.id);
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const customerId = invoice.customer as string;
|
const customerId = invoice.customer as string;
|
||||||
@ -620,67 +690,61 @@ export class StripeProvider implements PaymentProvider {
|
|||||||
await this.stripe.paymentIntents.retrieve(paymentIntentId);
|
await this.stripe.paymentIntents.retrieve(paymentIntentId);
|
||||||
const metadata = paymentIntent.metadata;
|
const metadata = paymentIntent.metadata;
|
||||||
|
|
||||||
// Get userId from payment intent metadata
|
// Get userId from payment intent metadata or fallback to customerId lookup
|
||||||
const userId = metadata?.userId;
|
let userId: string | undefined = metadata?.userId;
|
||||||
if (!userId) {
|
if (!userId) {
|
||||||
console.warn('<< No userId found in payment intent metadata');
|
console.log('No userId in metadata, finding by customerId');
|
||||||
return;
|
userId = await this.findUserIdByCustomerId(customerId);
|
||||||
|
|
||||||
|
if (!userId) {
|
||||||
|
console.error('<< No userId found, this should not happen');
|
||||||
|
return;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check if this is a credit purchase
|
// Check if this is a credit purchase
|
||||||
const isCreditPurchase = metadata?.type === 'credit_purchase';
|
const isCreditPurchase = metadata?.type === 'credit_purchase';
|
||||||
|
|
||||||
if (isCreditPurchase) {
|
if (isCreditPurchase) {
|
||||||
// Process credit purchase using metadata
|
// Process credit purchase
|
||||||
await this.processCreditPurchaseFromMetadata(metadata, userId, invoice);
|
await this.createCreditPurchasePayment(invoice, metadata, userId);
|
||||||
} else {
|
} else {
|
||||||
// This is a lifetime plan purchase
|
// Process lifetime plan purchase
|
||||||
const priceId = metadata?.priceId;
|
await this.createLifetimePlanPayment(
|
||||||
if (!priceId) {
|
invoice,
|
||||||
console.warn('<< No priceId found in payment intent metadata');
|
metadata,
|
||||||
return;
|
userId,
|
||||||
}
|
customerId
|
||||||
|
);
|
||||||
// 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 processed one-time invoice payment');
|
console.log('<< Successfully created one-time payment record');
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('<< Handle one-time invoice payment error:', error);
|
console.error('<< Create one-time payment error:', error);
|
||||||
throw 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 metadata Payment intent metadata
|
||||||
* @param userId User ID
|
* @param userId User ID
|
||||||
* @param invoice Stripe invoice
|
|
||||||
*/
|
*/
|
||||||
private async processCreditPurchaseFromMetadata(
|
private async createCreditPurchasePayment(
|
||||||
|
invoice: Stripe.Invoice,
|
||||||
metadata: { [key: string]: string },
|
metadata: { [key: string]: string },
|
||||||
userId: string,
|
userId: string
|
||||||
invoice: Stripe.Invoice
|
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
console.log('>> Process credit purchase payment from metadata');
|
console.log('>> Create credit purchase payment record');
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const packageId = metadata.packageId;
|
const packageId = metadata.packageId;
|
||||||
const credits = metadata.credits;
|
const credits = metadata.credits;
|
||||||
const paymentIntentId = invoice.payment_intent as string;
|
const customerId = invoice.customer as string;
|
||||||
|
|
||||||
if (!packageId || !credits) {
|
if (!packageId || !credits) {
|
||||||
console.warn(
|
console.warn('<< Missing packageId or credits in metadata');
|
||||||
'<< Missing packageId or credits in payment intent metadata'
|
|
||||||
);
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -691,19 +755,27 @@ export class StripeProvider implements PaymentProvider {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check if credits have already been added to prevent duplicates
|
// Create payment record
|
||||||
const db = await getDb();
|
const db = await getDb();
|
||||||
const existingCreditTransaction = await db
|
const currentDate = new Date();
|
||||||
.select({ id: creditTransaction.id })
|
const paymentResult = await db
|
||||||
.from(creditTransaction)
|
.insert(payment)
|
||||||
.where(eq(creditTransaction.paymentId, paymentIntentId))
|
.values({
|
||||||
.limit(1);
|
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) {
|
if (paymentResult.length === 0) {
|
||||||
console.log(
|
console.warn('<< Failed to create credit purchase payment record');
|
||||||
'<< Credits already added for payment intent:',
|
|
||||||
paymentIntentId
|
|
||||||
);
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -714,98 +786,109 @@ export class StripeProvider implements PaymentProvider {
|
|||||||
amount: Number.parseInt(credits),
|
amount: Number.parseInt(credits),
|
||||||
type: CREDIT_TRANSACTION_TYPE.PURCHASE_PACKAGE,
|
type: CREDIT_TRANSACTION_TYPE.PURCHASE_PACKAGE,
|
||||||
description: `+${credits} credits for package ${packageId} ($${amount.toLocaleString()})`,
|
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,
|
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) {
|
} 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;
|
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<void> {
|
||||||
|
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
|
* @param stripeSubscription Stripe subscription
|
||||||
*/
|
*/
|
||||||
private async onCreateSubscription(
|
private async onCreateSubscription(
|
||||||
stripeSubscription: Stripe.Subscription
|
stripeSubscription: Stripe.Subscription
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
console.log('>> Create payment record for Stripe subscription');
|
console.log('Handle subscription creation:', stripeSubscription.id);
|
||||||
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
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@ -815,48 +898,24 @@ export class StripeProvider implements PaymentProvider {
|
|||||||
private async onUpdateSubscription(
|
private async onUpdateSubscription(
|
||||||
stripeSubscription: Stripe.Subscription
|
stripeSubscription: Stripe.Subscription
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
console.log('>> Update payment record for Stripe subscription');
|
console.log('>> Handle subscription update');
|
||||||
|
|
||||||
// get priceId from subscription items (this is always available)
|
// get priceId from subscription items (this is always available)
|
||||||
const priceId = stripeSubscription.items.data[0]?.price.id;
|
const priceId = stripeSubscription.items.data[0]?.price.id;
|
||||||
if (!priceId) {
|
if (!priceId) {
|
||||||
console.warn('No priceId found for subscription');
|
console.warn('<< No priceId found for subscription');
|
||||||
return;
|
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
|
// get new period start and end
|
||||||
const newPeriodStart = this.getPeriodStart(stripeSubscription);
|
const newPeriodStart = this.getPeriodStart(stripeSubscription);
|
||||||
const newPeriodEnd = this.getPeriodEnd(stripeSubscription);
|
const newPeriodEnd = this.getPeriodEnd(stripeSubscription);
|
||||||
|
const trialStart = stripeSubscription.trial_start
|
||||||
// Check if this is a renewal (period has changed and subscription is active)
|
? new Date(stripeSubscription.trial_start * 1000)
|
||||||
const isRenewal =
|
: undefined;
|
||||||
stripeSubscription.status === 'active' &&
|
const trialEnd = stripeSubscription.trial_end
|
||||||
currentPayment.periodStart &&
|
? new Date(stripeSubscription.trial_end * 1000)
|
||||||
newPeriodStart &&
|
: undefined;
|
||||||
currentPayment.periodStart.getTime() !== newPeriodStart.getTime();
|
|
||||||
|
|
||||||
// update fields
|
// update fields
|
||||||
const updateFields: any = {
|
const updateFields: any = {
|
||||||
@ -868,15 +927,12 @@ export class StripeProvider implements PaymentProvider {
|
|||||||
periodStart: newPeriodStart,
|
periodStart: newPeriodStart,
|
||||||
periodEnd: newPeriodEnd,
|
periodEnd: newPeriodEnd,
|
||||||
cancelAtPeriodEnd: stripeSubscription.cancel_at_period_end,
|
cancelAtPeriodEnd: stripeSubscription.cancel_at_period_end,
|
||||||
trialStart: stripeSubscription.trial_start
|
trialStart: trialStart,
|
||||||
? new Date(stripeSubscription.trial_start * 1000)
|
trialEnd: trialEnd,
|
||||||
: undefined,
|
|
||||||
trialEnd: stripeSubscription.trial_end
|
|
||||||
? new Date(stripeSubscription.trial_end * 1000)
|
|
||||||
: undefined,
|
|
||||||
updatedAt: new Date(),
|
updatedAt: new Date(),
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const db = await getDb();
|
||||||
const result = await db
|
const result = await db
|
||||||
.update(payment)
|
.update(payment)
|
||||||
.set(updateFields)
|
.set(updateFields)
|
||||||
@ -884,12 +940,9 @@ export class StripeProvider implements PaymentProvider {
|
|||||||
.returning({ id: payment.id });
|
.returning({ id: payment.id });
|
||||||
|
|
||||||
if (result.length > 0) {
|
if (result.length > 0) {
|
||||||
console.log('<< Updated payment record for Stripe subscription');
|
console.log('<< Updated payment record for 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
|
|
||||||
} else {
|
} 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(
|
private async onDeleteSubscription(
|
||||||
stripeSubscription: Stripe.Subscription
|
stripeSubscription: Stripe.Subscription
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
console.log('>> Mark payment record for Stripe subscription as canceled');
|
console.log('>> Handle subscription deletion');
|
||||||
|
|
||||||
const db = await getDb();
|
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
|
const result = await db
|
||||||
.update(payment)
|
.update(payment)
|
||||||
.set({
|
.set({
|
||||||
@ -933,162 +970,30 @@ export class StripeProvider implements PaymentProvider {
|
|||||||
if (result.length > 0) {
|
if (result.length > 0) {
|
||||||
console.log('<< Marked payment record for subscription as canceled');
|
console.log('<< Marked payment record for subscription as canceled');
|
||||||
} else {
|
} else {
|
||||||
console.warn(
|
console.warn('<< No payment record found for subscription deletion');
|
||||||
'<< No payment record found to cancel for Stripe subscription'
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Handle one-time payment checkout completion
|
* Handle checkout session completion - NEW ARCHITECTURE
|
||||||
* Note: This creates the initial payment record. Credits and final processing
|
* Only log the event, payment records created in invoice.payment_succeeded
|
||||||
* are handled by invoice.payment_succeeded event for better reliability
|
|
||||||
* @param session Stripe checkout session
|
* @param session Stripe checkout session
|
||||||
*/
|
*/
|
||||||
private async onOnetimePayment(
|
private async onOnetimePayment(
|
||||||
session: Stripe.Checkout.Session
|
session: Stripe.Checkout.Session
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
const customerId = session.customer as string;
|
console.log('Handle checkout session completion:', session.id);
|
||||||
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;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Handle credit purchase checkout completion
|
* Handle credit purchase checkout completion - NEW ARCHITECTURE
|
||||||
* Note: This creates the initial payment record. Credits are processed
|
* Only log the event, payment records created in invoice.payment_succeeded
|
||||||
* when invoice.payment_succeeded event is received for payment confirmation.
|
|
||||||
* @param session Stripe checkout session
|
* @param session Stripe checkout session
|
||||||
*/
|
*/
|
||||||
private async onCreditPurchase(
|
private async onCreditPurchase(
|
||||||
session: Stripe.Checkout.Session
|
session: Stripe.Checkout.Session
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
const customerId = session.customer as string;
|
console.log('Handle credit purchase checkout completion:', session.id);
|
||||||
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;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
Loading…
Reference in New Issue
Block a user