MkSaas支付集成Creem
更新时间:
概述MKSaaS 模板默认集成了 Stripe 作为支付提供商。本文档介绍如何扩展支付模块以支持 Creem 支付系统,实现多支付渠道的灵活切换。# ------------------------------------------------------------------------...
概述
MKSaaS 模板默认集成了 Stripe 作为支付提供商。本文档介绍如何扩展支付模块以支持 Creem 支付系统,实现多支付渠道的灵活切换。
# -----------------------------------------------------------------------------
# Payment (Creem) - Alternative payment provider
# https://docs.creem.io/getting-started/introduction
# Get Creem API key from https://creem.io/dashboard/developers
# Creem is a Merchant of Record (MoR) that handles tax compliance globally
# To use Creem, set `payment.provider: 'creem'` in website.tsx
# -----------------------------------------------------------------------------
CREEM_API_KEY=""
CREEM_WEBHOOK_SECRET=""
'use server';
import { getDb } from '@/db';
import { payment } from '@/db/schema';
import { userActionClient } from '@/lib/safe-action';
import { eq, or } from 'drizzle-orm';
import { z } from 'zod';
const checkPaymentCompletionSchema = z.object({
sessionId: z.string().optional(),
checkoutId: z.string().optional(),
});
/**
* Check if a payment is completed for the given session ID or checkout ID
* - sessionId: Used by Stripe
* - checkoutId: Used by Creem
*/
export const checkPaymentCompletionAction = userActionClient
.schema(checkPaymentCompletionSchema)
.action(async ({ parsedInput: { sessionId, checkoutId } }) => {
try {
// Need at least one identifier
if (!sessionId && !checkoutId) {
console.log('Check payment completion: no sessionId or checkoutId provided');
return {
success: true,
isPaid: false,
};
}
const db = await getDb();
// Build query conditions
const conditions = [];
if (sessionId) {
conditions.push(eq(payment.sessionId, sessionId));
}
if (checkoutId) {
conditions.push(eq(payment.sessionId, checkoutId));
}
const paymentRecord = await db
.select()
.from(payment)
.where(conditions.length > 1 ? or(...conditions) : conditions[0])
.limit(1);
const paymentData = paymentRecord[0] || null;
const isPaid = paymentData ? paymentData.paid : false;
console.log('Check payment completion, sessionId:', sessionId, 'checkoutId:', checkoutId, 'isPaid:', isPaid);
return {
success: true,
isPaid,
};
} catch (error) {
console.error('Check payment completion error:', error);
return {
success: false,
error: 'Failed to check payment completion',
};
}
});
import { handleWebhookEvent } from '@/payment';
import { type NextRequest, NextResponse } from 'next/server';
/**
* Creem webhook handler
* This endpoint receives webhook events from Creem and processes them
*
* Creem uses HMAC-SHA256 signature verification with the `creem-signature` header
*
* Supported events:
* - checkout.completed: Checkout session completed successfully
* - subscription.active: Subscription is now active
* - subscription.paid: Subscription payment received (renewal)
* - subscription.canceled: Subscription was canceled
* - subscription.expired: Subscription has expired
* - subscription.trialing: Subscription is in trial period
* - subscription.paused: Subscription is paused
* - subscription.update: Subscription was updated
* - subscription.unpaid: Subscription payment failed
* - subscription.past_due: Subscription payment is past due
* - refund.created: Refund was created
* - dispute.created: Dispute was created
*
* @see https://docs.creem.io/code/webhooks
*
* @param req The incoming request
* @returns NextResponse
*/
export async function POST(req: NextRequest): Promise<NextResponse> {
// Get the request body as text
const payload = await req.text();
// Get the Creem signature from headers
const signature = req.headers.get('creem-signature') || '';
try {
// Validate inputs
if (!payload) {
return NextResponse.json(
{ error: 'Missing webhook payload' },
{ status: 400 }
);
}
if (!signature) {
return NextResponse.json(
{ error: 'Missing Creem signature' },
{ status: 400 }
);
}
// Process the webhook event
await handleWebhookEvent(payload, signature);
// Return success - Creem expects HTTP 200 OK to confirm delivery
return NextResponse.json({ received: true }, { status: 200 });
} catch (error) {
console.error('Error in Creem webhook route:', error);
// Return error - Creem will retry with progressive backoff
// (30 seconds, 1 minute, 5 minutes, 1 hour)
return NextResponse.json(
{ error: 'Webhook handler failed' },
{ status: 400 }
);
}
}
'use client';
import {
Card,
CardDescription,
CardHeader,
CardTitle,
} from '@/components/ui/card';
import { usePaymentCompletion } from '@/hooks/use-payment-completion';
import { useLocaleRouter } from '@/i18n/navigation';
import { PAYMENT_MAX_POLL_TIME, PAYMENT_POLL_INTERVAL } from '@/lib/constants';
import { Routes } from '@/routes';
import { useQueryClient } from '@tanstack/react-query';
import {
AlertCircleIcon,
CheckCircleIcon,
RefreshCwIcon,
XCircleIcon,
} from 'lucide-react';
import { useTranslations } from 'next-intl';
import { useSearchParams } from 'next/navigation';
import { useEffect, useRef, useState } from 'react';
type PaymentStatus = 'processing' | 'success' | 'failed' | 'timeout';
/**
* Payment card component to display the payment status and redirect to the callback url
*/
export function PaymentCard() {
const t = useTranslations('Dashboard.settings.payment');
const localeRouter = useLocaleRouter();
const queryClient = useQueryClient();
const searchParams = useSearchParams();
const [status, setStatus] = useState<PaymentStatus>('processing');
const pollStartTime = useRef<number | undefined>(undefined);
const timeoutRef = useRef<NodeJS.Timeout | undefined>(undefined);
// Get URL parameters
// - session_id: Used by Stripe
// - checkout_id: Used by Creem
const callback = searchParams.get('callback');
const sessionId = searchParams.get('session_id');
const checkoutId = searchParams.get('checkout_id');
// Determine if we have valid payment identifiers
const hasPaymentIdentifier = !!sessionId || !!checkoutId;
// Check payment completion using the existing hook
const { data: paymentCheck } = usePaymentCompletion(
{ sessionId, checkoutId },
status === 'processing' && hasPaymentIdentifier
);
// Handle payment completion polling and timeout
useEffect(() => {
if (hasPaymentIdentifier && status === 'processing') {
pollStartTime.current = Date.now();
const checkTimeout = () => {
if (pollStartTime.current) {
const elapsed = Date.now() - pollStartTime.current;
if (elapsed > PAYMENT_MAX_POLL_TIME) {
setStatus('timeout');
return;
}
}
// Continue checking if still processing
if (status === 'processing') {
timeoutRef.current = setTimeout(checkTimeout, PAYMENT_POLL_INTERVAL);
}
};
checkTimeout();
}
// Cleanup function, clear timeout
return () => {
if (timeoutRef.current) {
clearTimeout(timeoutRef.current);
}
};
}, [hasPaymentIdentifier, status]);
// Handle payment completion, if payment is paid, change status to success
useEffect(() => {
if (paymentCheck?.isPaid && status === 'processing') {
setStatus('success');
}
}, [paymentCheck, status]);
// Handle auto-redirect for success, if status is success, redirect to callback url
useEffect(() => {
if (status === 'success' && callback) {
// Async function to handle cache invalidation and redirect
const handleRedirect = async () => {
// Invalidate relevant cache based on callback destination
if (callback === Routes.SettingsCredits) {
// Invalidate and refetch credits related queries
await queryClient.invalidateQueries({
queryKey: ['credits'],
});
// Wait for the refetch to complete
await queryClient.refetchQueries({
queryKey: ['credits'],
});
console.log('Refetched credits cache for credits page');
} else if (callback === Routes.SettingsBilling) {
// Invalidate and refetch payment/subscription related queries
await queryClient.invalidateQueries({
queryKey: ['payment'],
});
// Wait for the refetch to complete
await queryClient.refetchQueries({
queryKey: ['payment'],
});
console.log('Refetched payment cache for billing page');
}
// Redirect to callback url after cache is updated
localeRouter.push(callback);
};
handleRedirect();
}
}, [status, localeRouter, callback, queryClient]);
// Cleanup on unmount, clear timeout
useEffect(() => {
return () => {
if (timeoutRef.current) {
clearTimeout(timeoutRef.current);
}
};
}, []);
const getStatusIcon = () => {
switch (status) {
case 'processing':
return (
<RefreshCwIcon className="h-12 w-12 text-cyan-600 animate-spin" />
);
case 'success':
return <CheckCircleIcon className="h-12 w-12 text-green-600" />;
case 'failed':
return <XCircleIcon className="h-12 w-12 text-red-600" />;
case 'timeout':
return <AlertCircleIcon className="h-12 w-12 text-yellow-600" />;
default:
return <RefreshCwIcon className="h-12 w-12 text-gray-600" />;
}
};
const getStatusMessage = () => {
switch (status) {
case 'processing':
return {
title: t('processing.title'),
description: t('processing.description'),
};
case 'success':
return {
title: t('success.title'),
description: t('success.description'),
};
case 'failed':
return {
title: t('failed.title'),
description: t('failed.description'),
};
case 'timeout':
return {
title: t('timeout.title'),
description: t('timeout.description'),
};
default:
return { title: '', description: '' };
}
};
const { title, description } = getStatusMessage();
return (
<div className="min-h-[60vh] flex items-center justify-center">
<Card className="w-full max-w-md">
<CardHeader className="text-center py-4">
<div className="flex justify-center mb-8">{getStatusIcon()}</div>
<CardTitle>{title}</CardTitle>
<CardDescription>{description}</CardDescription>
</CardHeader>
</Card>
</div>
);
}
import { checkPaymentCompletionAction } from '@/actions/check-payment-completion';
import { PAYMENT_POLL_INTERVAL } from '@/lib/constants';
import { useQuery } from '@tanstack/react-query';
// Query keys for payment completion
export const paymentCompletionKeys = {
all: ['paymentCompletion'] as const,
session: (sessionId: string, checkoutId?: string) =>
[...paymentCompletionKeys.all, 'session', sessionId || checkoutId || ''] as const,
};
interface PaymentCompletionParams {
sessionId: string | null;
checkoutId?: string | null;
}
// Hook to check if payment is completed by session ID or checkout ID
// - sessionId: Used by Stripe
// - checkoutId: Used by Creem
export function usePaymentCompletion(
params: PaymentCompletionParams | string | null,
enablePolling = false
) {
// Support both old string interface and new object interface
const { sessionId, checkoutId } = typeof params === 'string' || params === null
? { sessionId: params, checkoutId: undefined }
: params;
const hasIdentifier = !!sessionId || !!checkoutId;
return useQuery({
queryKey: paymentCompletionKeys.session(sessionId || '', checkoutId || undefined),
queryFn: async () => {
if (!hasIdentifier) {
return {
isPaid: false,
};
}
console.log('>>> Check payment completion for sessionId:', sessionId, 'checkoutId:', checkoutId);
const result = await checkPaymentCompletionAction({
sessionId: sessionId || undefined,
checkoutId: checkoutId || undefined
});
if (!result?.data?.success) {
console.log('<<< Check payment completion error:', result?.data?.error);
throw new Error(
result?.data?.error || 'Failed to check payment completion'
);
}
const { isPaid } = result.data;
console.log('<<< Check payment completion, paid:', isPaid);
return {
isPaid,
};
},
enabled: hasIdentifier,
refetchInterval: enablePolling ? PAYMENT_POLL_INTERVAL : false,
refetchIntervalInBackground: true,
refetchOnWindowFocus: true,
refetchOnReconnect: true,
});
}
import { websiteConfig } from '@/config/website';
import { CreemProvider } from './provider/creem';
import { StripeProvider } from './provider/stripe';
import type {
CheckoutResult,
CreateCheckoutParams,
CreateCreditCheckoutParams,
CreatePortalParams,
PaymentProvider,
PortalResult,
} from './types';
/**
* Global payment provider instance
*/
let paymentProvider: PaymentProvider | null = null;
/**
* Get the payment provider
* @returns current payment provider instance
* @throws Error if provider is not initialized
*/
export const getPaymentProvider = (): PaymentProvider => {
if (!paymentProvider) {
return initializePaymentProvider();
}
return paymentProvider;
};
/**
* Initialize the payment provider
* @returns initialized payment provider
*/
export const initializePaymentProvider = (): PaymentProvider => {
if (!paymentProvider) {
if (websiteConfig.payment.provider === 'stripe') {
paymentProvider = new StripeProvider();
} else if (websiteConfig.payment.provider === 'creem') {
paymentProvider = new CreemProvider();
} else {
throw new Error(
`Unsupported payment provider: ${websiteConfig.payment.provider}`
);
}
}
return paymentProvider;
};
/**
* Create a checkout session for a plan
* @param params Parameters for creating the checkout session
* @returns Checkout result
*/
export const createCheckout = async (
params: CreateCheckoutParams
): Promise<CheckoutResult> => {
const provider = getPaymentProvider();
return provider.createCheckout(params);
};
/**
* Create a checkout session for a credit package
* @param params Parameters for creating the checkout session
* @returns Checkout result
*/
export const createCreditCheckout = async (
params: CreateCreditCheckoutParams
): Promise<CheckoutResult> => {
const provider = getPaymentProvider();
return provider.createCreditCheckout(params);
};
/**
* Create a customer portal session
* @param params Parameters for creating the portal
* @returns Portal result
*/
export const createCustomerPortal = async (
params: CreatePortalParams
): Promise<PortalResult> => {
const provider = getPaymentProvider();
return provider.createCustomerPortal(params);
};
/**
* Handle webhook event
* @param payload Raw webhook payload
* @param signature Webhook signature
*/
export const handleWebhookEvent = async (
payload: string,
signature: string
): Promise<void> => {
const provider = getPaymentProvider();
await provider.handleWebhookEvent(payload, signature);
};
import { randomUUID } from 'crypto';
import * as crypto from 'crypto';
import { websiteConfig } from '@/config/website';
import {
addCredits,
addLifetimeMonthlyCredits,
addSubscriptionCredits,
} from '@/credits/credits';
import { getCreditPackageById } from '@/credits/server';
import { CREDIT_TRANSACTION_TYPE } from '@/credits/types';
import { getDb } from '@/db';
import { payment, user } from '@/db/schema';
import type { Payment } from '@/db/types';
import {
PAYMENT_RECORD_RETRY_ATTEMPTS,
PAYMENT_RECORD_RETRY_DELAY,
} from '@/lib/constants';
import { findPlanByPlanId, findPriceInPlan } from '@/lib/price-plan';
import { sendNotification } from '@/notification/notification';
import { desc, eq, or } from 'drizzle-orm';
import {
type CheckoutResult,
type CreateCheckoutParams,
type CreateCreditCheckoutParams,
type CreatePortalParams,
type PaymentProvider,
PaymentScenes,
type PaymentStatus,
PaymentTypes,
type PlanInterval,
PlanIntervals,
type PortalResult,
} from '../types';
/**
* Creem API Response Types
*/
interface CreemCheckoutResponse {
id: string;
mode: 'test' | 'prod' | 'sandbox';
object: string;
status: 'pending' | 'processing' | 'completed' | 'expired';
product: string;
request_id?: string;
units: number;
order?: CreemOrder;
subscription?: string;
customer?: string;
checkout_url: string;
success_url?: string;
metadata?: Record<string, string>;
}
interface CreemOrder {
id: string;
mode: 'test' | 'prod' | 'sandbox';
object: string;
customer: string;
product: string;
transaction: string;
amount: number;
sub_total: number;
tax_amount: number;
discount_amount: number;
amount_due: number;
amount_paid: number;
currency: string;
status: string;
type: 'one_time' | 'recurring';
}
interface CreemSubscription {
id: string;
mode: 'test' | 'prod' | 'sandbox';
object: string;
product: CreemProduct;
customer: CreemCustomer;
collection_method: string;
status: 'active' | 'canceled' | 'unpaid' | 'paused' | 'trialing' | 'scheduled_cancel';
items?: CreemSubscriptionItem[];
last_transaction_id?: string;
last_transaction_date?: string;
next_transaction_date?: string;
current_period_start_date?: string;
current_period_end_date?: string;
canceled_at?: string;
created_at: string;
updated_at: string;
}
interface CreemSubscriptionItem {
id: string;
mode: string;
object: string;
product_id: string;
price_id: string;
units: number;
}
interface CreemProduct {
id: string;
mode: string;
object: string;
name: string;
description?: string;
price: number;
currency: string;
billing_type: 'one_time' | 'recurring';
billing_period?: 'every-month' | 'every-3-months' | 'every-6-months' | 'every-year';
}
interface CreemCustomer {
id: string;
mode: string;
object: string;
email: string;
name?: string;
country?: string;
created_at: string;
updated_at: string;
}
interface CreemPortalResponse {
customer_portal_link: string;
}
interface CreemTransaction {
id: string;
object: string;
amount: number;
amount_paid: number;
currency: string;
type: 'payment' | 'refund';
tax_country?: string;
tax_amount?: number;
status: string;
refunded_amount?: number;
order?: string;
customer?: string;
description?: string;
created_at: number;
mode: string;
}
interface CreemRefund {
id: string;
object: string;
status: 'pending' | 'succeeded' | 'failed';
refund_amount: number;
refund_currency: string;
reason?: string;
transaction?: CreemTransaction;
checkout?: CreemCheckoutResponse;
order?: CreemOrder;
customer?: CreemCustomer;
created_at: number;
mode: string;
}
/**
* Creem Webhook Event Types
*/
type CreemWebhookEventType =
| 'checkout.completed'
| 'subscription.active'
| 'subscription.paid'
| 'subscription.canceled'
| 'subscription.expired'
| 'subscription.trialing'
| 'subscription.paused'
| 'subscription.update'
| 'subscription.unpaid'
| 'subscription.past_due'
| 'refund.created'
| 'dispute.created';
interface CreemWebhookEvent {
id: string;
eventType: CreemWebhookEventType;
created_at: number;
object: {
// checkout.completed event fields
id?: string;
object?: string;
request_id?: string;
status?: string;
checkout_url?: string;
// Nested objects in webhook payload
checkout?: CreemCheckoutResponse;
subscription?: CreemSubscription;
customer?: CreemCustomer;
product?: CreemProduct;
order?: CreemOrder;
metadata?: Record<string, string>;
custom_fields?: unknown[];
// Direct subscription fields (for subscription.* events)
collection_method?: string;
last_transaction_id?: string;
last_transaction_date?: string;
next_transaction_date?: string;
current_period_start_date?: string;
current_period_end_date?: string;
canceled_at?: string;
created_at?: string;
updated_at?: string;
// Refund-specific fields (for refund.created event)
refund_amount?: number;
refund_currency?: string;
reason?: string;
transaction?: CreemTransaction;
};
}
/**
* Creem payment provider implementation
*
* Creem is a Merchant of Record (MoR) payment solution that handles
* tax compliance, payment processing, and regulatory requirements.
*
* docs:
* https://docs.creem.io/getting-started/introduction
*/
export class CreemProvider implements PaymentProvider {
private apiKey: string;
private webhookSecret: string;
private baseUrl: string;
private testMode: boolean;
/**
* Initialize Creem provider with API key
*/
constructor() {
const apiKey = process.env.CREEM_API_KEY;
if (!apiKey) {
throw new Error('CREEM_API_KEY environment variable is not set');
}
const webhookSecret = process.env.CREEM_WEBHOOK_SECRET;
if (!webhookSecret) {
throw new Error('CREEM_WEBHOOK_SECRET environment variable is not set.');
}
this.apiKey = apiKey;
this.webhookSecret = webhookSecret;
this.testMode = process.env.NODE_ENV !== 'production';
this.baseUrl = this.testMode
? 'https://test-api.creem.io'
: 'https://api.creem.io';
}
/**
* Make an API request to Creem
*/
private async request<T>(
method: string,
endpoint: string,
body?: Record<string, unknown>
): Promise<T> {
const url = `${this.baseUrl}${endpoint}`;
const options: RequestInit = {
method,
headers: {
'Content-Type': 'application/json',
'x-api-key': this.apiKey,
},
};
if (body) {
options.body = JSON.stringify(body);
}
const response = await fetch(url, options);
if (!response.ok) {
const errorText = await response.text();
console.error(`Creem API error: ${response.status} - ${errorText}`);
throw new Error(`Creem API error: ${response.status}`);
}
return response.json() as Promise<T>;
}
/**
* Get or create a customer by email
* Note: Creem creates customers automatically during checkout,
* but we can query existing customers by email
*/
private async getCustomerByEmail(email: string): Promise<CreemCustomer | null> {
try {
const response = await this.request<CreemCustomer>(
'GET',
`/v1/customers?email=${encodeURIComponent(email)}`
);
return response;
} catch (error) {
console.log('Customer not found by email, will be created during checkout');
return null;
}
}
/**
* Updates a user record with a Creem customer ID
*/
private async updateUserWithCustomerId(
customerId: string,
email: string
): Promise<void> {
try {
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 with Creem customer ID');
} else {
console.log('No user found with given 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
*/
private async findUserIdByCustomerId(
customerId: string
): Promise<string | undefined> {
try {
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 given customerId');
return undefined;
} catch (error) {
console.error('Find user by customer ID error:', error);
return undefined;
}
}
/**
* Create a checkout session for a plan
*
* Note: Creem uses productId instead of priceId
* You need to create products in Creem dashboard and use their IDs
*/
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`);
}
const price = findPriceInPlan(planId, priceId);
if (!price) {
throw new Error(`Price ID ${priceId} not found in plan ${planId}`);
}
// In Creem, we need to map priceId to productId
// The priceId in your config should be the Creem product ID
const productId = priceId;
// Build request body
const requestBody: Record<string, unknown> = {
product_id: productId,
request_id: metadata?.userId || randomUUID(),
success_url: successUrl,
customer: {
email: customerEmail,
},
metadata: {
...metadata,
planId,
priceId,
},
};
// Add discount code if provided
if (price.allowPromotionCode && metadata?.discountCode) {
requestBody.discount_code = metadata.discountCode;
}
// Create checkout session
const response = await this.request<CreemCheckoutResponse>(
'POST',
'/v1/checkouts',
requestBody
);
return {
url: response.checkout_url,
id: response.id,
};
} catch (error) {
console.error('Create checkout session error:', error);
throw new Error('Failed to create checkout session');
}
}
/**
* Create a checkout session for credits
*/
public async createCreditCheckout(
params: CreateCreditCheckoutParams
): Promise<CheckoutResult> {
const {
packageId,
customerEmail,
successUrl,
cancelUrl,
metadata,
locale,
} = params;
try {
const creditPackage = getCreditPackageById(packageId);
if (!creditPackage) {
throw new Error(`Credit package with ID ${packageId} not found`);
}
// In Creem, priceId should be the Creem product ID for the credit package
const productId = creditPackage.price.priceId;
if (!productId) {
throw new Error(`Product ID not found for credit package ${packageId}`);
}
const requestBody: Record<string, unknown> = {
product_id: productId,
request_id: metadata?.userId || randomUUID(),
success_url: successUrl,
customer: {
email: customerEmail,
},
metadata: {
...metadata,
packageId,
priceId: productId,
type: 'credit_purchase',
credits: creditPackage.amount.toString(),
},
};
if (creditPackage.price.allowPromotionCode && metadata?.discountCode) {
requestBody.discount_code = metadata.discountCode;
}
const response = await this.request<CreemCheckoutResponse>(
'POST',
'/v1/checkouts',
requestBody
);
return {
url: response.checkout_url,
id: response.id,
};
} catch (error) {
console.error('Create credit checkout session error:', error);
throw new Error('Failed to create credit checkout session');
}
}
/**
* Create a customer portal session
* API Endpoint: POST /v1/customers/billing
*/
public async createCustomerPortal(
params: CreatePortalParams
): Promise<PortalResult> {
const { customerId, returnUrl, locale } = params;
try {
const response = await this.request<CreemPortalResponse>(
'POST',
'/v1/customers/billing',
{
customer_id: customerId,
}
);
return {
url: response.customer_portal_link,
};
} catch (error) {
console.error('Create customer portal error:', error);
throw new Error('Failed to create customer portal');
}
}
/**
* Verify webhook signature using HMAC-SHA256
* Creem signature is sent in the `creem-signature` header
*/
private verifySignature(payload: string, signature: string): boolean {
const computedSignature = crypto
.createHmac('sha256', this.webhookSecret)
.update(payload)
.digest('hex');
// Ensure both signatures have the same length before comparing
if (signature.length !== computedSignature.length) {
console.warn('Webhook signature length mismatch');
return false;
}
try {
return crypto.timingSafeEqual(
Buffer.from(signature, 'hex'),
Buffer.from(computedSignature, 'hex')
);
} catch (error) {
console.error('Signature comparison error:', error);
return false;
}
}
/**
* Handle webhook event
*/
public async handleWebhookEvent(
payload: string,
signature: string
): Promise<void> {
try {
// Verify signature
if (!this.verifySignature(payload, signature)) {
throw new Error('Invalid webhook signature');
}
const event: CreemWebhookEvent = JSON.parse(payload);
const eventType = event.eventType;
console.log(`Handle Creem webhook event, type: ${eventType}`);
switch (eventType) {
case 'checkout.completed':
await this.onCheckoutCompleted(event);
break;
case 'subscription.active':
await this.onSubscriptionActive(event);
break;
case 'subscription.paid':
await this.onSubscriptionPaid(event);
break;
case 'subscription.canceled':
case 'subscription.expired':
await this.onSubscriptionEnded(event);
break;
case 'subscription.trialing':
await this.onSubscriptionTrialing(event);
break;
case 'subscription.paused':
await this.onSubscriptionPaused(event);
break;
case 'subscription.update':
await this.onSubscriptionUpdate(event);
break;
case 'subscription.unpaid':
case 'subscription.past_due':
await this.onSubscriptionUnpaid(event);
break;
case 'refund.created':
await this.onRefundCreated(event);
break;
default:
console.log(`Unhandled Creem event type: ${eventType}`);
}
} catch (error) {
console.error('Handle Creem webhook event error:', error);
throw new Error('Failed to handle webhook event');
}
}
/**
* Handle checkout.completed event
* Creates payment record and processes initial benefits
*
* In checkout.completed, the object contains:
* - id, object, request_id, status, checkout_url
* - order (nested), product (nested), customer (nested), subscription (nested)
* - metadata, custom_fields
*/
private async onCheckoutCompleted(event: CreemWebhookEvent): Promise<void> {
console.log('>> Handle Creem checkout completed');
const eventObject = event.object;
// For checkout.completed, the nested objects are inside event.object
const customer = eventObject.customer;
const product = eventObject.product;
const order = eventObject.order;
const subscription = eventObject.subscription;
const metadata = eventObject.metadata;
if (!customer || !product) {
console.warn('<< Missing required data in checkout.completed event');
return;
}
try {
const userId = metadata?.userId;
if (!userId) {
console.warn('<< No userId found in metadata');
return;
}
// Update user with Creem customer ID
await this.updateUserWithCustomerId(customer.id, customer.email);
const currentDate = new Date();
const isSubscription = product.billing_type === 'recurring';
const isCreditPurchase = metadata?.type === 'credit_purchase';
// Determine payment scene
let scene: typeof PaymentScenes[keyof typeof PaymentScenes];
if (isSubscription) {
scene = PaymentScenes.SUBSCRIPTION;
} else if (isCreditPurchase) {
scene = PaymentScenes.CREDIT;
} else {
scene = PaymentScenes.LIFETIME;
}
// Create payment record
const db = await getDb();
const priceId = metadata?.priceId || product.id;
// Get subscription ID from nested subscription object
const subscriptionId = subscription?.id || null;
// Session ID is the checkout id (event.object.id)
const sessionId = eventObject.id || null;
try {
await db.insert(payment).values({
id: randomUUID(),
priceId,
type: isSubscription ? PaymentTypes.SUBSCRIPTION : PaymentTypes.ONE_TIME,
scene,
userId,
customerId: customer.id,
subscriptionId: subscriptionId,
sessionId: sessionId,
invoiceId: order?.id || null,
paid: true, // Creem sends checkout.completed after payment is successful
interval: isSubscription
? this.mapCreemBillingPeriodToInterval(product.billing_period)
: null,
status: isSubscription ? 'active' : 'completed',
createdAt: currentDate,
updatedAt: currentDate,
});
console.log('<< Created payment record for Creem checkout');
} catch (error) {
if (
error instanceof Error &&
error.message.includes('unique constraint')
) {
console.log('<< Payment record already exists, skipping creation');
return;
}
throw error;
}
// Process benefits
if (isSubscription) {
await this.processSubscriptionPurchase(userId, priceId);
} else if (isCreditPurchase) {
await this.processCreditPurchase(userId, metadata, order?.amount_paid || 0);
} else {
await this.processLifetimePurchase(userId, priceId, customer.id, order?.id || '', order?.amount_paid || 0);
}
} catch (error) {
console.error('<< Handle checkout completed error:', error);
throw error;
}
console.log('<< Handle Creem checkout completed success');
}
/**
* Handle subscription.active event
* In subscription.* events, event.object IS the subscription object itself
*/
private async onSubscriptionActive(event: CreemWebhookEvent): Promise<void> {
console.log('>> Handle Creem subscription active');
// For subscription events, the object IS the subscription
const subscription = this.extractSubscriptionFromEvent(event);
if (!subscription) {
console.warn('<< No subscription data in event');
return;
}
await this.updateSubscriptionPaymentRecord(subscription, 'active');
console.log('<< Handle Creem subscription active success');
}
/**
* Handle subscription.paid event - subscription renewal
*/
private async onSubscriptionPaid(event: CreemWebhookEvent): Promise<void> {
console.log('>> Handle Creem subscription paid (renewal)');
const subscription = this.extractSubscriptionFromEvent(event);
if (!subscription) {
console.warn('<< No subscription data in event');
return;
}
// Update payment record
await this.updateSubscriptionPaymentRecord(subscription, 'active');
// Find user and process renewal credits
const userId = await this.findUserIdByCustomerId(subscription.customer.id);
if (userId) {
const priceId = subscription.items?.[0]?.price_id || subscription.product.id;
await this.processSubscriptionPurchase(userId, priceId);
}
console.log('<< Handle Creem subscription paid success');
}
/**
* Handle subscription ended (canceled/expired)
*/
private async onSubscriptionEnded(event: CreemWebhookEvent): Promise<void> {
console.log('>> Handle Creem subscription ended');
const subscription = this.extractSubscriptionFromEvent(event);
if (!subscription) {
console.warn('<< No subscription data in event');
return;
}
await this.updateSubscriptionPaymentRecord(subscription, 'canceled');
console.log('<< Handle Creem subscription ended success');
}
/**
* Handle subscription.trialing event
*/
private async onSubscriptionTrialing(event: CreemWebhookEvent): Promise<void> {
console.log('>> Handle Creem subscription trialing');
const subscription = this.extractSubscriptionFromEvent(event);
if (!subscription) {
console.warn('<< No subscription data in event');
return;
}
await this.updateSubscriptionPaymentRecord(subscription, 'trialing');
console.log('<< Handle Creem subscription trialing success');
}
/**
* Handle subscription.paused event
*/
private async onSubscriptionPaused(event: CreemWebhookEvent): Promise<void> {
console.log('>> Handle Creem subscription paused');
const subscription = this.extractSubscriptionFromEvent(event);
if (!subscription) {
console.warn('<< No subscription data in event');
return;
}
await this.updateSubscriptionPaymentRecord(subscription, 'paused');
console.log('<< Handle Creem subscription paused success');
}
/**
* Handle subscription.update event
*/
private async onSubscriptionUpdate(event: CreemWebhookEvent): Promise<void> {
console.log('>> Handle Creem subscription update');
const subscription = this.extractSubscriptionFromEvent(event);
if (!subscription) {
console.warn('<< No subscription data in event');
return;
}
await this.updateSubscriptionPaymentRecord(
subscription,
this.mapCreemStatusToPaymentStatus(subscription.status)
);
console.log('<< Handle Creem subscription update success');
}
/**
* Handle subscription.unpaid or subscription.past_due event
*/
private async onSubscriptionUnpaid(event: CreemWebhookEvent): Promise<void> {
console.log('>> Handle Creem subscription unpaid/past_due');
const subscription = this.extractSubscriptionFromEvent(event);
if (!subscription) {
console.warn('<< No subscription data in event');
return;
}
const status = event.eventType === 'subscription.past_due' ? 'past_due' : 'unpaid';
await this.updateSubscriptionPaymentRecord(subscription, status);
console.log('<< Handle Creem subscription unpaid/past_due success');
}
/**
* Extract subscription from webhook event
* For subscription.* events, the event.object IS the subscription object
*/
private extractSubscriptionFromEvent(event: CreemWebhookEvent): CreemSubscription | null {
const obj = event.object;
// Check if the object itself looks like a subscription
if (obj.id && obj.product && obj.customer && obj.status) {
return {
id: obj.id as string,
mode: (obj as any).mode || 'prod',
object: 'subscription',
product: obj.product as CreemProduct,
customer: obj.customer as CreemCustomer,
collection_method: obj.collection_method || 'charge_automatically',
status: obj.status as CreemSubscription['status'],
items: (obj as any).items,
last_transaction_id: obj.last_transaction_id,
last_transaction_date: obj.last_transaction_date,
next_transaction_date: obj.next_transaction_date,
current_period_start_date: obj.current_period_start_date,
current_period_end_date: obj.current_period_end_date,
canceled_at: obj.canceled_at,
created_at: obj.created_at || new Date().toISOString(),
updated_at: obj.updated_at || new Date().toISOString(),
};
}
// Fallback: check if there's a nested subscription object
if (obj.subscription) {
return obj.subscription;
}
return null;
}
/**
* Handle refund.created event
* Updates payment record to refunded status
*/
private async onRefundCreated(event: CreemWebhookEvent): Promise<void> {
console.log('>> Handle Creem refund created');
const eventObject = event.object;
const checkout = eventObject.checkout;
const order = eventObject.order;
const customer = eventObject.customer;
const refundAmount = eventObject.refund_amount;
const refundReason = eventObject.reason;
// Try to find the payment record by sessionId (checkout.id) or invoiceId (order.id)
const sessionId = checkout?.id;
const invoiceId = order?.id;
if (!sessionId && !invoiceId) {
console.warn('<< No sessionId or invoiceId found in refund event, cannot locate payment record');
return;
}
try {
const db = await getDb();
// Build query conditions to find the payment record
const conditions = [];
if (sessionId) {
conditions.push(eq(payment.sessionId, sessionId));
}
if (invoiceId) {
conditions.push(eq(payment.invoiceId, invoiceId));
}
// Find the payment record
const paymentRecords = await db
.select()
.from(payment)
.where(conditions.length > 1 ? or(...conditions) : conditions[0])
.limit(1);
if (paymentRecords.length === 0) {
console.warn('<< No payment record found for refund, sessionId:', sessionId, 'invoiceId:', invoiceId);
return;
}
const paymentRecord = paymentRecords[0];
console.log('Found payment record for refund:', paymentRecord.id);
// Update payment record to refunded status
await db
.update(payment)
.set({
status: 'refunded',
paid: false,
updatedAt: new Date(),
})
.where(eq(payment.id, paymentRecord.id));
console.log('Updated payment record to refunded status');
// Optionally: Revoke credits if this was a credit purchase or lifetime plan
// Note: You may want to implement credit revocation logic here
// For now, we'll just log a warning
if (paymentRecord.scene === 'credit' || paymentRecord.scene === 'lifetime') {
console.warn(
`Refund processed for ${paymentRecord.scene} purchase. ` +
`You may need to manually revoke credits for user: ${paymentRecord.userId}`
);
}
// Log refund details for auditing
console.log('Refund details:', {
paymentId: paymentRecord.id,
userId: paymentRecord.userId,
customerId: customer?.id,
refundAmount: refundAmount ? refundAmount / 100 : 'unknown',
reason: refundReason || 'not specified',
});
} catch (error) {
console.error('<< Handle refund created error:', error);
throw error;
}
console.log('<< Handle Creem refund created success');
}
/**
* Update subscription payment record
*/
private async updateSubscriptionPaymentRecord(
subscription: CreemSubscription,
status: PaymentStatus
): Promise<void> {
const db = await getDb();
const periodStart = subscription.current_period_start_date
? new Date(subscription.current_period_start_date)
: undefined;
const periodEnd = subscription.current_period_end_date
? new Date(subscription.current_period_end_date)
: undefined;
const cancelAtPeriodEnd = subscription.status === 'scheduled_cancel';
const updateFields: Record<string, unknown> = {
status,
interval: this.mapCreemBillingPeriodToInterval(subscription.product.billing_period),
periodStart,
periodEnd,
cancelAtPeriodEnd,
updatedAt: new Date(),
};
const result = await db
.update(payment)
.set(updateFields)
.where(eq(payment.subscriptionId, subscription.id))
.returning({ id: payment.id });
if (result.length > 0) {
console.log('Updated payment record for subscription');
} else {
console.warn('No payment record found for subscription update');
}
}
/**
* Process subscription purchase - add credits
*/
private async processSubscriptionPurchase(
userId: string,
priceId: string
): Promise<void> {
console.log('>> Process subscription purchase');
if (websiteConfig.credits?.enableCredits) {
await addSubscriptionCredits(userId, priceId);
console.log('Added subscription credits for user:', userId);
}
console.log('<< Process subscription purchase success');
}
/**
* Process credit package purchase
*/
private async processCreditPurchase(
userId: string,
metadata: Record<string, string> | undefined,
amountPaid: number
): Promise<void> {
console.log('>> Process credit purchase');
const packageId = metadata?.packageId;
const credits = metadata?.credits;
if (!packageId || !credits) {
console.warn('<< Missing packageId or credits in metadata');
return;
}
const creditPackage = getCreditPackageById(packageId);
if (!creditPackage) {
console.warn('<< Credit package not found:', packageId);
return;
}
const amount = amountPaid / 100;
await addCredits({
userId,
amount: Number.parseInt(credits),
type: CREDIT_TRANSACTION_TYPE.PURCHASE_PACKAGE,
description: `+${credits} credits for package ${packageId} ($${amount.toLocaleString()})`,
paymentId: randomUUID(),
expireDays: creditPackage.expireDays,
});
console.log('<< Process credit purchase success');
}
/**
* Process lifetime plan purchase
*/
private async processLifetimePurchase(
userId: string,
priceId: string,
customerId: string,
orderId: string,
amountPaid: number
): Promise<void> {
console.log('>> Process lifetime plan purchase');
if (websiteConfig.credits?.enableCredits) {
await addLifetimeMonthlyCredits(userId, priceId);
console.log('Added lifetime credits for user:', userId);
}
const amount = amountPaid / 100;
await sendNotification(orderId, customerId, userId, amount);
console.log('<< Process lifetime plan purchase success');
}
/**
* Map Creem billing period to PlanInterval
*/
private mapCreemBillingPeriodToInterval(
billingPeriod?: string
): PlanInterval {
switch (billingPeriod) {
case 'every-month':
case 'every-3-months':
case 'every-6-months':
return PlanIntervals.MONTH;
case 'every-year':
return PlanIntervals.YEAR;
default:
return PlanIntervals.MONTH;
}
}
/**
* Map Creem subscription status to PaymentStatus
*/
private mapCreemStatusToPaymentStatus(
status: CreemSubscription['status']
): PaymentStatus {
const statusMap: Record<CreemSubscription['status'], PaymentStatus> = {
active: 'active',
canceled: 'canceled',
unpaid: 'unpaid',
paused: 'paused',
trialing: 'trialing',
scheduled_cancel: 'active', // Still active until period end
};
return statusMap[status] || 'failed';
}
}
最后将src/config/website.tsx 中provider选项设置为 creem ,src/types/index.d.ts中的相应类型设置如下:
/**
* Payment configuration
*/
export interface PaymentConfig {
provider: 'stripe' | 'creem'; // The payment provider
}
MuseMVP 文档