- Introduced credit purchase payment intent actions in credits.action.ts. - Created new components for credit packages and Stripe payment form. - Added routes and layout for credits settings page. - Updated sidebar configuration to include credits settings. - Enhanced constants for credit packages with detailed pricing and descriptions. - Implemented loading and layout components for credits page. - Integrated payment confirmation handling in Stripe provider.
838 lines
24 KiB
TypeScript
838 lines
24 KiB
TypeScript
import { randomUUID } from 'crypto';
|
|
import { getDb } from '@/db';
|
|
import { payment, session, user } from '@/db/schema';
|
|
import {
|
|
findPlanByPlanId,
|
|
findPlanByPriceId,
|
|
findPriceInPlan,
|
|
} from '@/lib/price-plan';
|
|
import { sendNotification } from '@/notification/notification';
|
|
import { addCredits } from '@/lib/credits';
|
|
import { CREDIT_TRANSACTION_TYPE } from '@/lib/constants';
|
|
import { desc, eq } from 'drizzle-orm';
|
|
import { Stripe } from 'stripe';
|
|
import {
|
|
type CheckoutResult,
|
|
type ConfirmPaymentIntentParams,
|
|
type CreateCheckoutParams,
|
|
type CreatePaymentIntentParams,
|
|
type CreatePortalParams,
|
|
type PaymentIntentResult,
|
|
type PaymentProvider,
|
|
type PaymentStatus,
|
|
PaymentTypes,
|
|
type PlanInterval,
|
|
PlanIntervals,
|
|
type PortalResult,
|
|
type Subscription,
|
|
type getSubscriptionsParams,
|
|
} from '../types';
|
|
|
|
/**
|
|
* Stripe payment provider implementation
|
|
*
|
|
* docs:
|
|
* https://mksaas.com/docs/payment
|
|
*/
|
|
export class StripeProvider implements PaymentProvider {
|
|
private stripe: Stripe;
|
|
private webhookSecret: string;
|
|
|
|
/**
|
|
* Initialize Stripe provider with API key
|
|
*/
|
|
constructor() {
|
|
const apiKey = process.env.STRIPE_SECRET_KEY;
|
|
if (!apiKey) {
|
|
throw new Error('STRIPE_SECRET_KEY environment variable is not set');
|
|
}
|
|
|
|
const webhookSecret = process.env.STRIPE_WEBHOOK_SECRET;
|
|
if (!webhookSecret) {
|
|
throw new Error('STRIPE_WEBHOOK_SECRET environment variable is not set.');
|
|
}
|
|
|
|
// Initialize Stripe without specifying apiVersion to use default/latest version
|
|
this.stripe = new Stripe(apiKey);
|
|
this.webhookSecret = webhookSecret;
|
|
}
|
|
|
|
/**
|
|
* Create a customer in Stripe if not exists
|
|
* @param email Customer email
|
|
* @param name Optional customer name
|
|
* @returns Stripe customer ID
|
|
*/
|
|
private async createOrGetCustomer(
|
|
email: string,
|
|
name?: string
|
|
): Promise<string> {
|
|
try {
|
|
// Search for existing customer
|
|
const customers = await this.stripe.customers.list({
|
|
email,
|
|
limit: 1,
|
|
});
|
|
|
|
// Find existing customer
|
|
if (customers.data && customers.data.length > 0) {
|
|
const customerId = customers.data[0].id;
|
|
|
|
// Find user id by customer id
|
|
const userId = await this.findUserIdByCustomerId(customerId);
|
|
// user does not exist, update user with customer id
|
|
// in case you deleted user in database, but forgot to delete customer in Stripe
|
|
if (!userId) {
|
|
console.log(
|
|
`User ${email} does not exist, update with customer id ${customerId}`
|
|
);
|
|
await this.updateUserWithCustomerId(customerId, email);
|
|
}
|
|
return customerId;
|
|
}
|
|
|
|
// Create new customer
|
|
const customer = await this.stripe.customers.create({
|
|
email,
|
|
name: name || undefined,
|
|
});
|
|
|
|
// Update user record in database with the new customer ID
|
|
await this.updateUserWithCustomerId(customer.id, email);
|
|
|
|
return customer.id;
|
|
} catch (error) {
|
|
console.error('Create or get customer error:', error);
|
|
throw new Error('Failed to create or get customer');
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Updates a user record with a Stripe customer ID
|
|
* @param customerId Stripe customer ID
|
|
* @param email Customer email
|
|
* @returns Promise that resolves when the update is complete
|
|
*/
|
|
private async updateUserWithCustomerId(
|
|
customerId: string,
|
|
email: string
|
|
): Promise<void> {
|
|
try {
|
|
// Update user record with customer ID if email matches
|
|
const db = await getDb();
|
|
const result = await db
|
|
.update(user)
|
|
.set({
|
|
customerId: customerId,
|
|
updatedAt: new Date(),
|
|
})
|
|
.where(eq(user.email, email))
|
|
.returning({ id: user.id });
|
|
|
|
if (result.length > 0) {
|
|
console.log(`Updated user ${email} with customer ID ${customerId}`);
|
|
} else {
|
|
console.log(`No user found with email ${email}`);
|
|
}
|
|
} catch (error) {
|
|
console.error('Update user with customer ID error:', error);
|
|
throw new Error('Failed to update user with customer ID');
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Finds a user by customerId
|
|
* @param customerId Stripe customer ID
|
|
* @returns User ID or undefined if not found
|
|
*/
|
|
private async findUserIdByCustomerId(
|
|
customerId: string
|
|
): Promise<string | undefined> {
|
|
try {
|
|
// Query the user table for a matching customerId
|
|
const db = await getDb();
|
|
const result = await db
|
|
.select({ id: user.id })
|
|
.from(user)
|
|
.where(eq(user.customerId, customerId))
|
|
.limit(1);
|
|
|
|
if (result.length > 0) {
|
|
return result[0].id;
|
|
}
|
|
console.warn(`No user found with customerId ${customerId}`);
|
|
|
|
return undefined;
|
|
} catch (error) {
|
|
console.error('Find user by customer ID error:', error);
|
|
return undefined;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Create a checkout session for a plan
|
|
* @param params Parameters for creating the checkout session
|
|
* @returns Checkout result
|
|
*/
|
|
public async createCheckout(
|
|
params: CreateCheckoutParams
|
|
): Promise<CheckoutResult> {
|
|
const {
|
|
planId,
|
|
priceId,
|
|
customerEmail,
|
|
successUrl,
|
|
cancelUrl,
|
|
metadata,
|
|
locale,
|
|
} = params;
|
|
|
|
try {
|
|
// Get plan and price
|
|
const plan = findPlanByPlanId(planId);
|
|
if (!plan) {
|
|
throw new Error(`Plan with ID ${planId} not found`);
|
|
}
|
|
|
|
// Find price in plan
|
|
const price = findPriceInPlan(planId, priceId);
|
|
if (!price) {
|
|
throw new Error(`Price ID ${priceId} not found in plan ${planId}`);
|
|
}
|
|
|
|
// Get userName from metadata if available
|
|
const userName = metadata?.userName;
|
|
|
|
// Create or get customer
|
|
const customerId = await this.createOrGetCustomer(
|
|
customerEmail,
|
|
userName
|
|
);
|
|
|
|
// Add planId and priceId to metadata, so we can get it in the webhook event
|
|
const customMetadata = {
|
|
...metadata,
|
|
planId,
|
|
priceId,
|
|
};
|
|
|
|
// Set up the line items
|
|
const lineItems = [
|
|
{
|
|
price: priceId,
|
|
quantity: 1,
|
|
},
|
|
];
|
|
|
|
// Create checkout session parameters
|
|
const checkoutParams: Stripe.Checkout.SessionCreateParams = {
|
|
line_items: lineItems,
|
|
mode:
|
|
price.type === PaymentTypes.SUBSCRIPTION ? 'subscription' : 'payment',
|
|
success_url: successUrl ?? '',
|
|
cancel_url: cancelUrl ?? '',
|
|
metadata: customMetadata,
|
|
allow_promotion_codes: price.allowPromotionCode ?? false,
|
|
};
|
|
|
|
// Add customer to checkout session
|
|
checkoutParams.customer = customerId;
|
|
|
|
// Add locale if provided
|
|
if (locale) {
|
|
checkoutParams.locale = this.mapLocaleToStripeLocale(
|
|
locale
|
|
) as Stripe.Checkout.SessionCreateParams.Locale;
|
|
}
|
|
|
|
// Add payment intent data for one-time payments
|
|
if (price.type === PaymentTypes.ONE_TIME) {
|
|
checkoutParams.payment_intent_data = {
|
|
metadata: customMetadata,
|
|
};
|
|
// Automatically create an invoice for the one-time payment
|
|
checkoutParams.invoice_creation = {
|
|
enabled: true,
|
|
};
|
|
}
|
|
|
|
// Add subscription data for recurring payments
|
|
if (price.type === PaymentTypes.SUBSCRIPTION) {
|
|
// Initialize subscription_data with metadata
|
|
checkoutParams.subscription_data = {
|
|
metadata: customMetadata,
|
|
};
|
|
|
|
// Add trial period if applicable
|
|
if (price.trialPeriodDays && price.trialPeriodDays > 0) {
|
|
checkoutParams.subscription_data.trial_period_days =
|
|
price.trialPeriodDays;
|
|
}
|
|
}
|
|
|
|
// Create the checkout session
|
|
const session =
|
|
await this.stripe.checkout.sessions.create(checkoutParams);
|
|
|
|
return {
|
|
url: session.url!,
|
|
id: session.id,
|
|
};
|
|
} catch (error) {
|
|
console.error('Create checkout session error:', error);
|
|
throw new Error('Failed to create checkout session');
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Create a customer portal session
|
|
* @param params Parameters for creating the portal
|
|
* @returns Portal result
|
|
*/
|
|
public async createCustomerPortal(
|
|
params: CreatePortalParams
|
|
): Promise<PortalResult> {
|
|
const { customerId, returnUrl, locale } = params;
|
|
|
|
try {
|
|
const session = await this.stripe.billingPortal.sessions.create({
|
|
customer: customerId,
|
|
return_url: returnUrl ?? '',
|
|
locale: locale
|
|
? (this.mapLocaleToStripeLocale(
|
|
locale
|
|
) as Stripe.BillingPortal.SessionCreateParams.Locale)
|
|
: undefined,
|
|
});
|
|
|
|
return {
|
|
url: session.url,
|
|
};
|
|
} catch (error) {
|
|
console.error('Create customer portal error:', error);
|
|
throw new Error('Failed to create customer portal');
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Get subscriptions
|
|
* @param params Parameters for getting subscriptions
|
|
* @returns Array of subscription objects
|
|
*/
|
|
public async getSubscriptions(
|
|
params: getSubscriptionsParams
|
|
): Promise<Subscription[]> {
|
|
const { userId } = params;
|
|
|
|
try {
|
|
// Build query to fetch subscriptions from database
|
|
const db = await getDb();
|
|
const subscriptions = await db
|
|
.select()
|
|
.from(payment)
|
|
.where(eq(payment.userId, userId))
|
|
.orderBy(desc(payment.createdAt)); // Sort by creation date, newest first
|
|
|
|
// Map database records to our subscription model
|
|
return subscriptions.map((subscription) => ({
|
|
id: subscription.subscriptionId || '',
|
|
customerId: subscription.customerId,
|
|
priceId: subscription.priceId,
|
|
status: subscription.status as PaymentStatus,
|
|
type: subscription.type as PaymentTypes,
|
|
interval: subscription.interval as PlanInterval,
|
|
currentPeriodStart: subscription.periodStart || undefined,
|
|
currentPeriodEnd: subscription.periodEnd || undefined,
|
|
cancelAtPeriodEnd: subscription.cancelAtPeriodEnd || false,
|
|
trialStartDate: subscription.trialStart || undefined,
|
|
trialEndDate: subscription.trialEnd || undefined,
|
|
createdAt: subscription.createdAt,
|
|
}));
|
|
} catch (error) {
|
|
console.error('List customer subscriptions error:', error);
|
|
return [];
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Handle webhook event
|
|
* @param payload Raw webhook payload
|
|
* @param signature Webhook signature
|
|
*/
|
|
public async handleWebhookEvent(
|
|
payload: string,
|
|
signature: string
|
|
): Promise<void> {
|
|
try {
|
|
// Verify the event signature if webhook secret is available
|
|
const event = this.stripe.webhooks.constructEvent(
|
|
payload,
|
|
signature,
|
|
this.webhookSecret
|
|
);
|
|
const eventType = event.type;
|
|
console.log(`handle webhook event, type: ${eventType}`);
|
|
|
|
// Handle subscription events
|
|
if (eventType.startsWith('customer.subscription.')) {
|
|
const stripeSubscription = event.data.object as Stripe.Subscription;
|
|
|
|
// Process based on subscription status and event type
|
|
switch (eventType) {
|
|
case 'customer.subscription.created': {
|
|
await this.onCreateSubscription(stripeSubscription);
|
|
break;
|
|
}
|
|
case 'customer.subscription.updated': {
|
|
await this.onUpdateSubscription(stripeSubscription);
|
|
break;
|
|
}
|
|
case 'customer.subscription.deleted': {
|
|
await this.onDeleteSubscription(stripeSubscription);
|
|
break;
|
|
}
|
|
}
|
|
} else if (eventType.startsWith('checkout.')) {
|
|
// Handle checkout events
|
|
if (eventType === 'checkout.session.completed') {
|
|
const session = event.data.object as Stripe.Checkout.Session;
|
|
|
|
// Only process one-time payments (likely for lifetime plan)
|
|
if (session.mode === 'payment') {
|
|
await this.onOnetimePayment(session);
|
|
}
|
|
}
|
|
} else if (eventType.startsWith('payment_intent.')) {
|
|
// Handle payment intent events
|
|
if (eventType === 'payment_intent.succeeded') {
|
|
const paymentIntent = event.data.object as Stripe.PaymentIntent;
|
|
await this.onPaymentIntentSucceeded(paymentIntent);
|
|
}
|
|
}
|
|
} catch (error) {
|
|
console.error('handle webhook event error:', error);
|
|
throw new Error('Failed to handle webhook event');
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Create payment record
|
|
* @param stripeSubscription Stripe subscription
|
|
*/
|
|
private async onCreateSubscription(
|
|
stripeSubscription: Stripe.Subscription
|
|
): Promise<void> {
|
|
console.log(
|
|
`>> Create payment record for Stripe subscription ${stripeSubscription.id}`
|
|
);
|
|
const customerId = stripeSubscription.customer as string;
|
|
|
|
// 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 ${stripeSubscription.id}`
|
|
);
|
|
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 ${stripeSubscription.id}`
|
|
);
|
|
return;
|
|
}
|
|
|
|
// 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: stripeSubscription.current_period_start
|
|
? new Date(stripeSubscription.current_period_start * 1000)
|
|
: null,
|
|
periodEnd: stripeSubscription.current_period_end
|
|
? new Date(stripeSubscription.current_period_end * 1000)
|
|
: null,
|
|
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 db = await getDb();
|
|
const result = await db
|
|
.insert(payment)
|
|
.values(createFields)
|
|
.returning({ id: payment.id });
|
|
|
|
if (result.length > 0) {
|
|
console.log(
|
|
`<< Created new payment record ${result[0].id} for Stripe subscription ${stripeSubscription.id}`
|
|
);
|
|
} else {
|
|
console.warn(
|
|
`<< No payment record created for Stripe subscription ${stripeSubscription.id}`
|
|
);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Update payment record
|
|
* @param stripeSubscription Stripe subscription
|
|
*/
|
|
private async onUpdateSubscription(
|
|
stripeSubscription: Stripe.Subscription
|
|
): Promise<void> {
|
|
console.log(
|
|
`>> Update payment record for Stripe subscription ${stripeSubscription.id}`
|
|
);
|
|
|
|
// 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 ${stripeSubscription.id}`
|
|
);
|
|
return;
|
|
}
|
|
|
|
// update fields
|
|
const updateFields: any = {
|
|
priceId: priceId,
|
|
interval: this.mapStripeIntervalToPlanInterval(stripeSubscription),
|
|
status: this.mapSubscriptionStatusToPaymentStatus(
|
|
stripeSubscription.status
|
|
),
|
|
periodStart: stripeSubscription.current_period_start
|
|
? new Date(stripeSubscription.current_period_start * 1000)
|
|
: undefined,
|
|
periodEnd: stripeSubscription.current_period_end
|
|
? new Date(stripeSubscription.current_period_end * 1000)
|
|
: undefined,
|
|
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,
|
|
updatedAt: new Date(),
|
|
};
|
|
|
|
const db = await getDb();
|
|
const result = await db
|
|
.update(payment)
|
|
.set(updateFields)
|
|
.where(eq(payment.subscriptionId, stripeSubscription.id))
|
|
.returning({ id: payment.id });
|
|
|
|
if (result.length > 0) {
|
|
console.log(
|
|
`<< Updated payment record ${result[0].id} for Stripe subscription ${stripeSubscription.id}`
|
|
);
|
|
} else {
|
|
console.warn(
|
|
`<< No payment record found for Stripe subscription ${stripeSubscription.id}`
|
|
);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Update payment record, set status to canceled
|
|
* @param stripeSubscription Stripe subscription
|
|
*/
|
|
private async onDeleteSubscription(
|
|
stripeSubscription: Stripe.Subscription
|
|
): Promise<void> {
|
|
console.log(
|
|
`>> Mark payment record for Stripe subscription ${stripeSubscription.id} as canceled`
|
|
);
|
|
const db = await getDb();
|
|
const result = await db
|
|
.update(payment)
|
|
.set({
|
|
status: this.mapSubscriptionStatusToPaymentStatus(
|
|
stripeSubscription.status
|
|
),
|
|
updatedAt: new Date(),
|
|
})
|
|
.where(eq(payment.subscriptionId, stripeSubscription.id))
|
|
.returning({ id: payment.id });
|
|
|
|
if (result.length > 0) {
|
|
console.log(
|
|
`<< Marked payment record for subscription ${stripeSubscription.id} as canceled`
|
|
);
|
|
} else {
|
|
console.warn(
|
|
`<< No payment record found to cancel for Stripe subscription ${stripeSubscription.id}`
|
|
);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Handle one-time payment
|
|
* @param session Stripe checkout session
|
|
*/
|
|
private async onOnetimePayment(
|
|
session: Stripe.Checkout.Session
|
|
): Promise<void> {
|
|
const customerId = session.customer as string;
|
|
console.log(`>> Handle onetime payment for customer ${customerId}`);
|
|
|
|
// 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 ${session.id}`);
|
|
return;
|
|
}
|
|
|
|
// get priceId from session metadata, not from line items
|
|
// const priceId = session.line_items?.data[0]?.price?.id;
|
|
const priceId = session.metadata?.priceId;
|
|
if (!priceId) {
|
|
console.warn(`<< No priceId found for checkout session ${session.id}`);
|
|
return;
|
|
}
|
|
|
|
// Create a one-time payment record
|
|
const now = new Date();
|
|
const db = await getDb();
|
|
const result = await db
|
|
.insert(payment)
|
|
.values({
|
|
id: randomUUID(),
|
|
priceId: priceId,
|
|
type: PaymentTypes.ONE_TIME,
|
|
userId: userId,
|
|
customerId: customerId,
|
|
status: 'completed', // One-time payments are always completed
|
|
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 ${userId}`
|
|
);
|
|
return;
|
|
}
|
|
console.log(
|
|
`<< Created one-time payment record for user ${userId}, price: ${priceId}`
|
|
);
|
|
|
|
// Send notification
|
|
const amount = session.amount_total ? session.amount_total / 100 : 0;
|
|
await sendNotification(session.id, customerId, userId, amount);
|
|
}
|
|
|
|
/**
|
|
* Handle payment intent succeeded event
|
|
* @param paymentIntent Stripe payment intent
|
|
*/
|
|
private async onPaymentIntentSucceeded(
|
|
paymentIntent: Stripe.PaymentIntent
|
|
): Promise<void> {
|
|
console.log(`>> Handle payment intent succeeded: ${paymentIntent.id}`);
|
|
|
|
// Get metadata from payment intent
|
|
const { packageId, userId, credits } = paymentIntent.metadata;
|
|
|
|
if (!packageId || !userId || !credits) {
|
|
console.warn(
|
|
`<< Missing metadata for payment intent ${paymentIntent.id}: packageId=${packageId}, userId=${userId}, credits=${credits}`
|
|
);
|
|
return;
|
|
}
|
|
|
|
try {
|
|
// Add credits to user account using existing addCredits method
|
|
await addCredits({
|
|
userId,
|
|
amount: parseInt(credits),
|
|
type: CREDIT_TRANSACTION_TYPE.PURCHASE,
|
|
description: `Credit package purchase: ${packageId} - ${credits} credits for $${paymentIntent.amount / 100}`,
|
|
paymentId: paymentIntent.id,
|
|
});
|
|
|
|
console.log(
|
|
`<< Successfully processed payment intent ${paymentIntent.id}: Added ${credits} credits to user ${userId}`
|
|
);
|
|
} catch (error) {
|
|
console.error(
|
|
`<< Error processing payment intent ${paymentIntent.id}:`,
|
|
error
|
|
);
|
|
throw error;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Create a payment intent
|
|
* @param params Parameters for creating the payment intent
|
|
* @returns Payment intent result
|
|
*/
|
|
public async createPaymentIntent(
|
|
params: CreatePaymentIntentParams
|
|
): Promise<PaymentIntentResult> {
|
|
const { amount, currency, metadata } = params;
|
|
|
|
try {
|
|
const paymentIntent = await this.stripe.paymentIntents.create({
|
|
amount,
|
|
currency,
|
|
metadata,
|
|
automatic_payment_methods: {
|
|
enabled: true,
|
|
},
|
|
});
|
|
|
|
return {
|
|
id: paymentIntent.id,
|
|
clientSecret: paymentIntent.client_secret!,
|
|
};
|
|
} catch (error) {
|
|
console.error('Create payment intent error:', error);
|
|
throw new Error('Failed to create payment intent');
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Confirm a payment intent
|
|
* @param params Parameters for confirming the payment intent
|
|
* @returns True if successful
|
|
*/
|
|
public async confirmPaymentIntent(
|
|
params: ConfirmPaymentIntentParams
|
|
): Promise<boolean> {
|
|
const { paymentIntentId } = params;
|
|
|
|
try {
|
|
const paymentIntent = await this.stripe.paymentIntents.retrieve(paymentIntentId);
|
|
|
|
return paymentIntent.status === 'succeeded';
|
|
} catch (error) {
|
|
console.error('Confirm payment intent error:', error);
|
|
throw new Error('Failed to confirm payment intent');
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Map Stripe subscription interval to our own interval types
|
|
* @param subscription Stripe subscription
|
|
* @returns PlanInterval
|
|
*/
|
|
private mapStripeIntervalToPlanInterval(
|
|
subscription: Stripe.Subscription
|
|
): PlanInterval {
|
|
switch (subscription.items.data[0]?.plan.interval) {
|
|
case 'month':
|
|
return PlanIntervals.MONTH;
|
|
case 'year':
|
|
return PlanIntervals.YEAR;
|
|
default:
|
|
return PlanIntervals.MONTH;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Convert Stripe subscription status to PaymentStatus,
|
|
* we narrow down the status to our own status types
|
|
* @param status Stripe subscription status
|
|
* @returns PaymentStatus
|
|
*/
|
|
private mapSubscriptionStatusToPaymentStatus(
|
|
status: Stripe.Subscription.Status
|
|
): PaymentStatus {
|
|
const statusMap: Record<string, PaymentStatus> = {
|
|
active: 'active',
|
|
canceled: 'canceled',
|
|
incomplete: 'incomplete',
|
|
incomplete_expired: 'incomplete_expired',
|
|
past_due: 'past_due',
|
|
trialing: 'trialing',
|
|
unpaid: 'unpaid',
|
|
paused: 'paused',
|
|
};
|
|
|
|
return statusMap[status] || 'failed';
|
|
}
|
|
|
|
/**
|
|
* Map application locale to Stripe's supported locales
|
|
* @param locale Application locale (e.g., 'en', 'zh-CN')
|
|
* @returns Stripe locale string
|
|
*/
|
|
private mapLocaleToStripeLocale(locale: string): string {
|
|
// Stripe supported locales as of 2023:
|
|
// https://stripe.com/docs/js/appendix/supported_locales
|
|
const stripeLocales = [
|
|
'bg',
|
|
'cs',
|
|
'da',
|
|
'de',
|
|
'el',
|
|
'en',
|
|
'es',
|
|
'et',
|
|
'fi',
|
|
'fil',
|
|
'fr',
|
|
'hr',
|
|
'hu',
|
|
'id',
|
|
'it',
|
|
'ja',
|
|
'ko',
|
|
'lt',
|
|
'lv',
|
|
'ms',
|
|
'mt',
|
|
'nb',
|
|
'nl',
|
|
'pl',
|
|
'pt',
|
|
'ro',
|
|
'ru',
|
|
'sk',
|
|
'sl',
|
|
'sv',
|
|
'th',
|
|
'tr',
|
|
'vi',
|
|
'zh',
|
|
];
|
|
|
|
// First check if the exact locale is supported
|
|
if (stripeLocales.includes(locale)) {
|
|
return locale;
|
|
}
|
|
|
|
// If not, try to get the base language
|
|
const baseLocale = locale.split('-')[0];
|
|
if (stripeLocales.includes(baseLocale)) {
|
|
return baseLocale;
|
|
}
|
|
|
|
// Default to auto to let Stripe detect the language
|
|
return 'auto';
|
|
}
|
|
}
|