295 lines
6.8 KiB
TypeScript
295 lines
6.8 KiB
TypeScript
/**
|
|
* Stripe Payment Integration
|
|
*
|
|
* Supports:
|
|
* - Payment Intents (Cards, etc.)
|
|
* - Webhook handling
|
|
* - Refunds
|
|
*/
|
|
|
|
import Stripe from 'stripe';
|
|
|
|
// Initialize Stripe client
|
|
const stripeSecretKey = import.meta.env.STRIPE_SECRET_KEY;
|
|
|
|
export const stripe = new Stripe(stripeSecretKey || 'sk_test_placeholder', {
|
|
apiVersion: '2025-01-27.acacia' as any,
|
|
});
|
|
|
|
// ============================================
|
|
// Types
|
|
// ============================================
|
|
|
|
export interface CreatePaymentIntentParams {
|
|
amount: number; // in satang (THB = 100 satang)
|
|
currency?: string;
|
|
metadata?: Record<string, string>;
|
|
customerId?: string;
|
|
description?: string;
|
|
}
|
|
|
|
export interface PaymentIntentResult {
|
|
clientSecret: string;
|
|
paymentIntentId: string;
|
|
}
|
|
|
|
// ============================================
|
|
// Payment Intents
|
|
// ============================================
|
|
|
|
/**
|
|
* Create a payment intent for checkout
|
|
* Amount should be in satang (baht * 100)
|
|
*/
|
|
export async function createPaymentIntent(
|
|
params: CreatePaymentIntentParams
|
|
): Promise<PaymentIntentResult> {
|
|
const {
|
|
amount,
|
|
currency = 'thb',
|
|
metadata = {},
|
|
customerId,
|
|
description,
|
|
} = params;
|
|
|
|
const paymentIntent = await stripe.paymentIntents.create({
|
|
amount,
|
|
currency,
|
|
automatic_payment_methods: { enabled: true },
|
|
metadata,
|
|
...(customerId && { customer: customerId }),
|
|
...(description && { description }),
|
|
});
|
|
|
|
if (!paymentIntent.client_secret) {
|
|
throw new Error('Failed to create payment intent');
|
|
}
|
|
|
|
return {
|
|
clientSecret: paymentIntent.client_secret,
|
|
paymentIntentId: paymentIntent.id,
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Retrieve a payment intent
|
|
*/
|
|
export async function getPaymentIntent(paymentIntentId: string) {
|
|
return stripe.paymentIntents.retrieve(paymentIntentId);
|
|
}
|
|
|
|
/**
|
|
* Confirm a payment intent
|
|
*/
|
|
export async function confirmPaymentIntent(
|
|
paymentIntentId: string,
|
|
paymentMethodId: string
|
|
) {
|
|
return stripe.paymentIntents.confirm(paymentIntentId, {
|
|
payment_method: paymentMethodId,
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Cancel a payment intent
|
|
*/
|
|
export async function cancelPaymentIntent(paymentIntentId: string) {
|
|
return stripe.paymentIntents.cancel(paymentIntentId);
|
|
}
|
|
|
|
// ============================================
|
|
// Webhooks
|
|
// ============================================
|
|
|
|
/**
|
|
* Construct and verify webhook event
|
|
*/
|
|
export async function constructWebhookEvent(
|
|
payload: string | Buffer,
|
|
signature: string
|
|
) {
|
|
const webhookSecret = import.meta.env.STRIPE_WEBHOOK_SECRET;
|
|
|
|
if (!webhookSecret) {
|
|
throw new Error('STRIPE_WEBHOOK_SECRET not configured');
|
|
}
|
|
|
|
return stripe.webhooks.constructEvent(payload, signature, webhookSecret);
|
|
}
|
|
|
|
/**
|
|
* Parse webhook event safely
|
|
*/
|
|
export async function handleWebhook(
|
|
payload: string | Buffer,
|
|
signature: string,
|
|
handlers: {
|
|
onPaymentIntentSucceeded?: (intent: Stripe.PaymentIntent) => Promise<void>;
|
|
onPaymentIntentFailed?: (intent: Stripe.PaymentIntent) => Promise<void>;
|
|
onPaymentIntentProcessing?: (intent: Stripe.PaymentIntent) => Promise<void>;
|
|
}
|
|
) {
|
|
const event = await constructWebhookEvent(payload, signature);
|
|
|
|
switch (event.type) {
|
|
case 'payment_intent.succeeded':
|
|
if (handlers.onPaymentIntentSucceeded) {
|
|
await handlers.onPaymentIntentSucceeded(event.data.object as Stripe.PaymentIntent);
|
|
}
|
|
break;
|
|
|
|
case 'payment_intent.payment_failed':
|
|
if (handlers.onPaymentIntentFailed) {
|
|
await handlers.onPaymentIntentFailed(event.data.object as Stripe.PaymentIntent);
|
|
}
|
|
break;
|
|
|
|
case 'payment_intent.processing':
|
|
if (handlers.onPaymentIntentProcessing) {
|
|
await handlers.onPaymentIntentProcessing(event.data.object as Stripe.PaymentIntent);
|
|
}
|
|
break;
|
|
|
|
default:
|
|
console.log(`Unhandled webhook event type: ${event.type}`);
|
|
}
|
|
|
|
return event;
|
|
}
|
|
|
|
// ============================================
|
|
// Customers
|
|
// ============================================
|
|
|
|
/**
|
|
* Create a Stripe customer
|
|
*/
|
|
export async function createCustomer(params: {
|
|
email: string;
|
|
name?: string;
|
|
metadata?: Record<string, string>;
|
|
}) {
|
|
return stripe.customers.create({
|
|
email: params.email,
|
|
name: params.name,
|
|
metadata: params.metadata,
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Get customer by ID
|
|
*/
|
|
export async function getCustomer(customerId: string) {
|
|
return stripe.customers.retrieve(customerId);
|
|
}
|
|
|
|
/**
|
|
* Update customer
|
|
*/
|
|
export async function updateCustomer(
|
|
customerId: string,
|
|
params: {
|
|
email?: string;
|
|
name?: string;
|
|
metadata?: Record<string, string>;
|
|
}
|
|
) {
|
|
return stripe.customers.update(customerId, params);
|
|
}
|
|
|
|
// ============================================
|
|
// Refunds
|
|
// ============================================
|
|
|
|
/**
|
|
* Create a refund
|
|
*/
|
|
export async function createRefund(params: {
|
|
paymentIntentId: string;
|
|
amount?: number; // partial refund amount in satang
|
|
reason?: 'duplicate' | 'fraudulent' | 'requested_by_customer';
|
|
}) {
|
|
return stripe.refunds.create({
|
|
payment_intent: params.paymentIntentId,
|
|
amount: params.amount,
|
|
reason: params.reason,
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Get refund status
|
|
*/
|
|
export async function getRefund(refundId: string) {
|
|
return stripe.refunds.retrieve(refundId);
|
|
}
|
|
|
|
// ============================================
|
|
// Checkout Sessions
|
|
// ============================================
|
|
|
|
/**
|
|
* Create a checkout session for hosted checkout
|
|
*/
|
|
export async function createCheckoutSession(params: {
|
|
lineItems: Array<{
|
|
price_data?: {
|
|
currency: string;
|
|
product_data: { name: string; description?: string; images?: string[] };
|
|
unit_amount: number;
|
|
};
|
|
price?: string;
|
|
quantity: number;
|
|
}>;
|
|
successUrl: string;
|
|
cancelUrl: string;
|
|
customerEmail?: string;
|
|
metadata?: Record<string, string>;
|
|
mode?: 'payment' | 'subscription';
|
|
}) {
|
|
return stripe.checkout.sessions.create({
|
|
mode: params.mode || 'payment',
|
|
line_items: params.lineItems,
|
|
success_url: params.successUrl,
|
|
cancel_url: params.cancelUrl,
|
|
customer_email: params.customerEmail,
|
|
metadata: params.metadata,
|
|
});
|
|
}
|
|
|
|
// ============================================
|
|
// Helpers
|
|
// ============================================
|
|
|
|
/**
|
|
* Convert baht to satang (THB * 100)
|
|
*/
|
|
export function bahtToSatang(amount: number): number {
|
|
return Math.round(amount * 100);
|
|
}
|
|
|
|
/**
|
|
* Convert satang to baht
|
|
*/
|
|
export function satangToBaht(amount: number): number {
|
|
return amount / 100;
|
|
}
|
|
|
|
/**
|
|
* Format amount for Stripe (in smallest currency unit)
|
|
*/
|
|
export function formatAmount(amount: number, currency = 'thb'): number {
|
|
// For THB, no decimal places
|
|
if (currency.toLowerCase() === 'thb') {
|
|
return Math.round(amount);
|
|
}
|
|
// For others, convert to cents
|
|
return Math.round(amount * 100);
|
|
}
|
|
|
|
/**
|
|
* Check if Stripe is configured
|
|
*/
|
|
export function isStripeConfigured(): boolean {
|
|
return !!stripeSecretKey && !stripeSecretKey.includes('placeholder');
|
|
}
|