Includes all Phase 1 features: - Search-first navigation with auto-complete - Responsive reading interface (desktop/tablet/mobile) - 4 customization presets + full fine-tuning controls - Layered details panel with notes, bookmarks, highlights - Smart offline caching with IndexedDB and auto-sync - Full accessibility (WCAG 2.1 AA) 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
40 KiB
40 KiB
Payload CMS Payment System Integration Guide
Executive Summary
This guide details the integration of Stripe payment processing with Payload CMS for Biblical Guide, covering subscriptions, donations, and one-time payments while maintaining PCI compliance and ensuring seamless migration from the existing system.
Current Payment System Analysis
Existing Implementation
- Payment Processor: Stripe
- Subscription Plans: Free (10 chats/month) and Premium (unlimited)
- Donation Support: One-time donations via Stripe Checkout
- Webhooks: Complete implementation for subscription lifecycle
- Database: Subscription, Donation, StripeEvent tables
Current Payment Flow
graph TD
A[User Selects Plan] --> B[Create Stripe Checkout]
B --> C[Redirect to Stripe]
C --> D[Payment Processing]
D --> E[Webhook Event]
E --> F[Update Database]
F --> G[Grant Access]
Payload Payment Architecture
Stripe Plugin Features
- Native Stripe SDK integration
- Webhook handling with signature verification
- Automatic sync with Stripe objects
- TypeScript types for all Stripe entities
- Built-in error handling and retries
Enhanced Payment Flow with Payload
graph TD
A[User Action] --> B[Payload API]
B --> C[Stripe Plugin]
C --> D[Stripe API]
D --> E[Webhook]
E --> F[Payload Hook]
F --> G[Auto-sync Collections]
G --> H[User Access Updated]
Implementation Plan
Phase 1: Stripe Plugin Setup
Step 1.1: Install and Configure Plugin
npm install @payloadcms/plugin-stripe stripe
// payload.config.ts
import { stripePlugin } from '@payloadcms/plugin-stripe';
import { buildConfig } from 'payload/config';
export default buildConfig({
plugins: [
stripePlugin({
stripeSecretKey: process.env.STRIPE_SECRET_KEY!,
stripeWebhookEndpointSecret: process.env.STRIPE_WEBHOOK_SECRET!,
rest: true, // Enable REST API endpoints
graphQL: true, // Enable GraphQL endpoints
// Webhook configuration
webhooks: {
'checkout.session.completed': handleCheckoutComplete,
'customer.subscription.created': handleSubscriptionCreated,
'customer.subscription.updated': handleSubscriptionUpdated,
'customer.subscription.deleted': handleSubscriptionDeleted,
'payment_intent.succeeded': handlePaymentSuccess,
'invoice.payment_succeeded': handleInvoicePayment,
'invoice.payment_failed': handlePaymentFailure,
},
// Sync configuration
sync: [
{
collection: 'products',
stripeResourceType: 'products',
stripeResourceTypeSingular: 'product',
},
{
collection: 'prices',
stripeResourceType: 'prices',
stripeResourceTypeSingular: 'price',
},
{
collection: 'customers',
stripeResourceType: 'customers',
stripeResourceTypeSingular: 'customer',
},
{
collection: 'subscriptions',
stripeResourceType: 'subscriptions',
stripeResourceTypeSingular: 'subscription',
},
],
}),
],
});
Phase 2: Collection Definitions
Step 2.1: Products Collection
// collections/Products.ts
import { CollectionConfig } from 'payload/types';
export const Products: CollectionConfig = {
slug: 'products',
admin: {
useAsTitle: 'name',
group: 'E-commerce',
defaultColumns: ['name', 'active', 'prices', 'updatedAt'],
},
fields: [
{
name: 'name',
type: 'text',
required: true,
localized: true,
},
{
name: 'description',
type: 'richText',
localized: true,
},
{
name: 'stripeProductId',
type: 'text',
unique: true,
admin: {
readOnly: true,
position: 'sidebar',
},
},
{
name: 'active',
type: 'checkbox',
defaultValue: true,
required: true,
},
{
name: 'metadata',
type: 'group',
fields: [
{
name: 'planType',
type: 'select',
options: [
{ label: 'Free', value: 'free' },
{ label: 'Premium', value: 'premium' },
{ label: 'Enterprise', value: 'enterprise' },
],
},
{
name: 'features',
type: 'array',
fields: [
{
name: 'feature',
type: 'text',
required: true,
},
{
name: 'included',
type: 'checkbox',
defaultValue: true,
},
{
name: 'limit',
type: 'number',
admin: {
condition: (data, siblingData) => !siblingData.included,
},
},
],
},
],
},
{
name: 'prices',
type: 'relationship',
relationTo: 'prices',
hasMany: true,
},
],
hooks: {
beforeChange: [
async ({ data, operation, req }) => {
if (operation === 'create' && !data.stripeProductId) {
// Create product in Stripe
const stripe = req.payload.stripe;
const stripeProduct = await stripe.products.create({
name: data.name,
description: data.description,
active: data.active,
metadata: {
planType: data.metadata?.planType,
},
});
data.stripeProductId = stripeProduct.id;
}
return data;
},
],
afterChange: [
async ({ doc, operation, req }) => {
if (operation === 'update') {
// Sync updates to Stripe
const stripe = req.payload.stripe;
await stripe.products.update(doc.stripeProductId, {
name: doc.name,
description: doc.description,
active: doc.active,
});
}
},
],
},
};
Step 2.2: Prices Collection
// collections/Prices.ts
export const Prices: CollectionConfig = {
slug: 'prices',
admin: {
useAsTitle: 'displayName',
group: 'E-commerce',
},
fields: [
{
name: 'displayName',
type: 'text',
admin: {
readOnly: true,
},
hooks: {
beforeChange: [
({ data, siblingData }) => {
const amount = (siblingData.unitAmount || 0) / 100;
const currency = (siblingData.currency || 'USD').toUpperCase();
const interval = siblingData.recurring?.interval;
if (interval) {
return `${currency} ${amount}/${interval}`;
}
return `${currency} ${amount}`;
},
],
},
},
{
name: 'product',
type: 'relationship',
relationTo: 'products',
required: true,
},
{
name: 'stripePriceId',
type: 'text',
unique: true,
admin: {
readOnly: true,
},
},
{
name: 'unitAmount',
type: 'number',
required: true,
min: 0,
admin: {
description: 'Amount in cents',
},
},
{
name: 'currency',
type: 'select',
options: [
{ label: 'USD', value: 'usd' },
{ label: 'EUR', value: 'eur' },
{ label: 'GBP', value: 'gbp' },
{ label: 'RON', value: 'ron' },
],
defaultValue: 'usd',
required: true,
},
{
name: 'recurring',
type: 'group',
fields: [
{
name: 'interval',
type: 'select',
options: [
{ label: 'Daily', value: 'day' },
{ label: 'Weekly', value: 'week' },
{ label: 'Monthly', value: 'month' },
{ label: 'Yearly', value: 'year' },
],
},
{
name: 'intervalCount',
type: 'number',
defaultValue: 1,
min: 1,
},
{
name: 'trialPeriodDays',
type: 'number',
min: 0,
admin: {
description: 'Number of trial days',
},
},
],
},
{
name: 'active',
type: 'checkbox',
defaultValue: true,
},
],
};
Step 2.3: Subscriptions Collection
// collections/Subscriptions.ts
export const Subscriptions: CollectionConfig = {
slug: 'subscriptions',
admin: {
useAsTitle: 'id',
group: 'E-commerce',
defaultColumns: ['customer', 'status', 'currentPeriodEnd', 'plan'],
},
fields: [
{
name: 'stripeSubscriptionId',
type: 'text',
unique: true,
required: true,
admin: {
readOnly: true,
},
},
{
name: 'customer',
type: 'relationship',
relationTo: 'customers',
required: true,
},
{
name: 'user',
type: 'relationship',
relationTo: 'users',
required: true,
unique: true,
},
{
name: 'prices',
type: 'relationship',
relationTo: 'prices',
hasMany: true,
required: true,
},
{
name: 'status',
type: 'select',
options: [
{ label: 'Active', value: 'active' },
{ label: 'Past Due', value: 'past_due' },
{ label: 'Canceled', value: 'canceled' },
{ label: 'Incomplete', value: 'incomplete' },
{ label: 'Incomplete Expired', value: 'incomplete_expired' },
{ label: 'Trialing', value: 'trialing' },
{ label: 'Unpaid', value: 'unpaid' },
{ label: 'Paused', value: 'paused' },
],
required: true,
admin: {
readOnly: true,
},
},
{
name: 'currentPeriodStart',
type: 'date',
required: true,
admin: {
readOnly: true,
},
},
{
name: 'currentPeriodEnd',
type: 'date',
required: true,
admin: {
readOnly: true,
},
},
{
name: 'canceledAt',
type: 'date',
admin: {
readOnly: true,
},
},
{
name: 'cancelAtPeriodEnd',
type: 'checkbox',
defaultValue: false,
admin: {
readOnly: true,
},
},
{
name: 'metadata',
type: 'group',
fields: [
{
name: 'planName',
type: 'text',
},
{
name: 'conversationCount',
type: 'number',
defaultValue: 0,
admin: {
description: 'Monthly conversation count for free tier',
},
},
{
name: 'lastResetDate',
type: 'date',
},
],
},
],
hooks: {
afterChange: [
async ({ doc, operation, req }) => {
// Update user's subscription status
if (doc.user) {
await req.payload.update({
collection: 'users',
id: doc.user,
data: {
subscriptionStatus: doc.status,
subscriptionId: doc.id,
},
});
}
},
],
},
};
Phase 3: Webhook Handlers
Step 3.1: Subscription Webhook Handlers
// webhooks/subscription.handlers.ts
import { StripeWebhookHandler } from '@payloadcms/plugin-stripe';
import Stripe from 'stripe';
export const handleSubscriptionCreated: StripeWebhookHandler = async ({
event,
stripe,
payload,
}) => {
const subscription = event.data.object as Stripe.Subscription;
try {
// Find or create customer
let customer = await payload.find({
collection: 'customers',
where: {
stripeCustomerId: {
equals: subscription.customer as string,
},
},
});
if (customer.docs.length === 0) {
// Create customer if doesn't exist
const stripeCustomer = await stripe.customers.retrieve(
subscription.customer as string
);
customer = await payload.create({
collection: 'customers',
data: {
stripeCustomerId: stripeCustomer.id,
email: stripeCustomer.email,
name: stripeCustomer.name,
},
});
}
// Create subscription record
const subscriptionDoc = await payload.create({
collection: 'subscriptions',
data: {
stripeSubscriptionId: subscription.id,
customer: customer.docs[0].id,
user: subscription.metadata.userId, // Passed from checkout
prices: subscription.items.data.map(item => item.price.id),
status: subscription.status,
currentPeriodStart: new Date(subscription.current_period_start * 1000),
currentPeriodEnd: new Date(subscription.current_period_end * 1000),
metadata: {
planName: subscription.metadata.planName || 'premium',
conversationCount: 0,
},
},
});
// Send welcome email
await sendSubscriptionWelcomeEmail({
email: customer.docs[0].email,
planName: subscription.metadata.planName,
nextBillingDate: new Date(subscription.current_period_end * 1000),
});
console.log(`Subscription created: ${subscriptionDoc.id}`);
} catch (error) {
console.error('Error handling subscription creation:', error);
throw error; // Stripe will retry
}
};
export const handleSubscriptionUpdated: StripeWebhookHandler = async ({
event,
stripe,
payload,
}) => {
const subscription = event.data.object as Stripe.Subscription;
try {
const existingSubscription = await payload.find({
collection: 'subscriptions',
where: {
stripeSubscriptionId: {
equals: subscription.id,
},
},
});
if (existingSubscription.docs.length === 0) {
console.error(`Subscription not found: ${subscription.id}`);
return;
}
const doc = existingSubscription.docs[0];
// Check if billing period changed (renewal)
const oldPeriodEnd = new Date(doc.currentPeriodEnd).getTime();
const newPeriodEnd = subscription.current_period_end * 1000;
const isRenewal = newPeriodEnd > oldPeriodEnd;
// Update subscription
await payload.update({
collection: 'subscriptions',
id: doc.id,
data: {
status: subscription.status,
currentPeriodStart: new Date(subscription.current_period_start * 1000),
currentPeriodEnd: new Date(subscription.current_period_end * 1000),
canceledAt: subscription.canceled_at
? new Date(subscription.canceled_at * 1000)
: null,
cancelAtPeriodEnd: subscription.cancel_at_period_end,
metadata: {
...doc.metadata,
// Reset conversation count on renewal for free tier
conversationCount: isRenewal && doc.metadata.planName === 'free' ? 0 : doc.metadata.conversationCount,
lastResetDate: isRenewal ? new Date() : doc.metadata.lastResetDate,
},
},
});
// Handle status changes
if (subscription.status === 'past_due') {
await handlePastDueSubscription(doc.user, subscription);
} else if (subscription.status === 'canceled') {
await handleCanceledSubscription(doc.user, subscription);
} else if (subscription.status === 'active' && doc.status !== 'active') {
await handleReactivatedSubscription(doc.user, subscription);
}
} catch (error) {
console.error('Error updating subscription:', error);
throw error;
}
};
async function handlePastDueSubscription(userId: string, subscription: Stripe.Subscription) {
// Send payment failed email
await sendPaymentFailedEmail(userId, subscription);
// Restrict access after grace period
const gracePeriodDays = 3;
const cancelDate = new Date();
cancelDate.setDate(cancelDate.getDate() + gracePeriodDays);
await scheduleJob('cancel-past-due-subscription', cancelDate, {
subscriptionId: subscription.id,
userId,
});
}
async function handleCanceledSubscription(userId: string, subscription: Stripe.Subscription) {
// Update user access
await payload.update({
collection: 'users',
id: userId,
data: {
subscriptionStatus: 'canceled',
subscriptionEndDate: new Date(subscription.current_period_end * 1000),
},
});
// Send cancellation confirmation
await sendCancellationEmail(userId, subscription);
// Log for analytics
await logAnalyticsEvent({
event: 'subscription_canceled',
userId,
data: {
subscriptionId: subscription.id,
reason: subscription.cancellation_details?.reason,
},
});
}
Step 3.2: Payment Webhook Handlers
// webhooks/payment.handlers.ts
export const handleCheckoutComplete: StripeWebhookHandler = async ({
event,
stripe,
payload,
}) => {
const session = event.data.object as Stripe.Checkout.Session;
try {
// Determine payment type
const paymentType = session.metadata?.type || 'subscription';
if (paymentType === 'donation') {
await handleDonationPayment(session, payload);
} else if (paymentType === 'subscription') {
await handleSubscriptionPayment(session, payload, stripe);
} else if (paymentType === 'one-time') {
await handleOneTimePayment(session, payload);
}
// Track conversion
await trackConversion({
sessionId: session.id,
userId: session.metadata?.userId,
amount: session.amount_total,
currency: session.currency,
type: paymentType,
});
} catch (error) {
console.error('Error handling checkout completion:', error);
throw error;
}
};
async function handleDonationPayment(
session: Stripe.Checkout.Session,
payload: Payload
) {
const donation = await payload.create({
collection: 'donations',
data: {
donorName: session.customer_details?.name || 'Anonymous',
donorEmail: session.customer_details?.email || '',
amount: (session.amount_total || 0) / 100,
currency: session.currency?.toUpperCase() || 'USD',
stripeSessionId: session.id,
stripePaymentIntentId: session.payment_intent as string,
status: 'completed',
message: session.metadata?.message,
anonymous: session.metadata?.anonymous === 'true',
createdAt: new Date(),
},
});
// Send thank you email
if (!donation.anonymous && donation.donorEmail) {
await sendDonationThankYouEmail({
email: donation.donorEmail,
name: donation.donorName,
amount: donation.amount,
currency: donation.currency,
});
}
// Update donation statistics
await updateDonationStats({
amount: donation.amount,
currency: donation.currency,
});
}
export const handlePaymentFailure: StripeWebhookHandler = async ({
event,
stripe,
payload,
}) => {
const paymentIntent = event.data.object as Stripe.PaymentIntent;
try {
// Log failed payment
await payload.create({
collection: 'failed-payments',
data: {
stripePaymentIntentId: paymentIntent.id,
customerId: paymentIntent.customer,
amount: paymentIntent.amount / 100,
currency: paymentIntent.currency,
error: paymentIntent.last_payment_error?.message,
errorCode: paymentIntent.last_payment_error?.code,
timestamp: new Date(),
},
});
// Notify customer
if (paymentIntent.customer) {
const customer = await stripe.customers.retrieve(paymentIntent.customer as string);
if ('email' in customer && customer.email) {
await sendPaymentFailureEmail({
email: customer.email,
amount: paymentIntent.amount / 100,
currency: paymentIntent.currency,
error: paymentIntent.last_payment_error?.message,
});
}
}
// Update subscription if related
if (paymentIntent.invoice) {
const invoice = await stripe.invoices.retrieve(paymentIntent.invoice as string);
if (invoice.subscription) {
await handleFailedSubscriptionPayment(invoice.subscription as string, payload);
}
}
} catch (error) {
console.error('Error handling payment failure:', error);
}
};
Phase 4: API Endpoints
Step 4.1: Checkout API
// api/checkout.ts
import { PayloadRequest } from 'payload/types';
import Stripe from 'stripe';
export const checkoutEndpoints = [
{
path: '/api/checkout/session',
method: 'post',
handler: async (req: PayloadRequest, res) => {
try {
const { priceId, userId, type = 'subscription', metadata = {} } = req.body;
if (!req.user) {
return res.status(401).json({ error: 'Authentication required' });
}
const stripe = req.payload.stripe as Stripe;
// Get price details
const price = await stripe.prices.retrieve(priceId, {
expand: ['product'],
});
// Create checkout session
const session = await stripe.checkout.sessions.create({
payment_method_types: ['card'],
mode: type === 'subscription' ? 'subscription' : 'payment',
customer_email: req.user.email,
client_reference_id: req.user.id,
metadata: {
userId: req.user.id,
type,
...metadata,
},
line_items: [
{
price: priceId,
quantity: 1,
},
],
subscription_data: type === 'subscription' ? {
metadata: {
userId: req.user.id,
planName: metadata.planName || 'premium',
},
trial_period_days: price.recurring?.trial_period_days,
} : undefined,
success_url: `${process.env.FRONTEND_URL}/subscription/success?session_id={CHECKOUT_SESSION_ID}`,
cancel_url: `${process.env.FRONTEND_URL}/subscription`,
allow_promotion_codes: true,
});
// Log checkout initiation
await req.payload.create({
collection: 'checkout-sessions',
data: {
sessionId: session.id,
userId: req.user.id,
priceId,
type,
status: 'pending',
createdAt: new Date(),
},
});
return res.json({
sessionId: session.id,
url: session.url,
});
} catch (error) {
console.error('Checkout session error:', error);
return res.status(500).json({
error: 'Failed to create checkout session',
});
}
},
},
{
path: '/api/checkout/portal',
method: 'post',
handler: async (req: PayloadRequest, res) => {
if (!req.user) {
return res.status(401).json({ error: 'Authentication required' });
}
try {
const stripe = req.payload.stripe as Stripe;
// Get or create customer
let customerId = req.user.stripeCustomerId;
if (!customerId) {
const customer = await stripe.customers.create({
email: req.user.email,
name: req.user.name,
metadata: {
userId: req.user.id,
},
});
customerId = customer.id;
// Update user with customer ID
await req.payload.update({
collection: 'users',
id: req.user.id,
data: {
stripeCustomerId: customerId,
},
});
}
// Create portal session
const session = await stripe.billingPortal.sessions.create({
customer: customerId,
return_url: `${process.env.FRONTEND_URL}/subscription`,
});
return res.json({
url: session.url,
});
} catch (error) {
console.error('Portal session error:', error);
return res.status(500).json({
error: 'Failed to create portal session',
});
}
},
},
];
Step 4.2: Subscription Management API
// api/subscription.ts
export const subscriptionEndpoints = [
{
path: '/api/subscription/status',
method: 'get',
handler: async (req: PayloadRequest, res) => {
if (!req.user) {
return res.status(401).json({ error: 'Authentication required' });
}
try {
const subscription = await req.payload.find({
collection: 'subscriptions',
where: {
user: {
equals: req.user.id,
},
},
limit: 1,
});
if (subscription.docs.length === 0) {
return res.json({
hasSubscription: false,
status: 'none',
plan: 'free',
limits: {
conversations: 10,
conversationsUsed: 0,
},
});
}
const sub = subscription.docs[0];
return res.json({
hasSubscription: true,
status: sub.status,
plan: sub.metadata.planName,
currentPeriodEnd: sub.currentPeriodEnd,
cancelAtPeriodEnd: sub.cancelAtPeriodEnd,
limits: {
conversations: sub.metadata.planName === 'free' ? 10 : -1,
conversationsUsed: sub.metadata.conversationCount || 0,
},
});
} catch (error) {
console.error('Subscription status error:', error);
return res.status(500).json({
error: 'Failed to fetch subscription status',
});
}
},
},
{
path: '/api/subscription/cancel',
method: 'post',
handler: async (req: PayloadRequest, res) => {
if (!req.user) {
return res.status(401).json({ error: 'Authentication required' });
}
try {
const { immediate = false, reason } = req.body;
const subscription = await req.payload.find({
collection: 'subscriptions',
where: {
user: {
equals: req.user.id,
},
status: {
equals: 'active',
},
},
limit: 1,
});
if (subscription.docs.length === 0) {
return res.status(404).json({ error: 'No active subscription found' });
}
const sub = subscription.docs[0];
const stripe = req.payload.stripe as Stripe;
// Cancel subscription
const canceledSubscription = await stripe.subscriptions.update(
sub.stripeSubscriptionId,
{
cancel_at_period_end: !immediate,
cancellation_details: {
comment: reason,
},
}
);
if (immediate) {
await stripe.subscriptions.cancel(sub.stripeSubscriptionId);
}
// Log cancellation
await req.payload.create({
collection: 'cancellations',
data: {
userId: req.user.id,
subscriptionId: sub.id,
reason,
immediate,
timestamp: new Date(),
},
});
return res.json({
success: true,
cancelAtPeriodEnd: canceledSubscription.cancel_at_period_end,
currentPeriodEnd: new Date(canceledSubscription.current_period_end * 1000),
});
} catch (error) {
console.error('Subscription cancellation error:', error);
return res.status(500).json({
error: 'Failed to cancel subscription',
});
}
},
},
];
Phase 5: Frontend Integration
Step 5.1: Payment Components
// components/payment/CheckoutButton.tsx
import { useState } from 'react';
import { loadStripe } from '@stripe/stripe-js';
import { useAuth } from '@/hooks/useAuth';
const stripePromise = loadStripe(process.env.NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY!);
interface CheckoutButtonProps {
priceId: string;
planName: string;
children: React.ReactNode;
}
export function CheckoutButton({ priceId, planName, children }: CheckoutButtonProps) {
const [loading, setLoading] = useState(false);
const { user } = useAuth();
const handleCheckout = async () => {
if (!user) {
// Redirect to login
window.location.href = '/login?redirect=/subscription';
return;
}
setLoading(true);
try {
const response = await fetch('/api/checkout/session', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
credentials: 'include',
body: JSON.stringify({
priceId,
userId: user.id,
type: 'subscription',
metadata: {
planName,
},
}),
});
const { sessionId } = await response.json();
const stripe = await stripePromise;
const { error } = await stripe!.redirectToCheckout({ sessionId });
if (error) {
console.error('Stripe redirect error:', error);
alert('Payment failed. Please try again.');
}
} catch (error) {
console.error('Checkout error:', error);
alert('Failed to start checkout. Please try again.');
} finally {
setLoading(false);
}
};
return (
<button
onClick={handleCheckout}
disabled={loading}
className="checkout-button"
>
{loading ? 'Loading...' : children}
</button>
);
}
Step 5.2: Subscription Management Component
// components/subscription/SubscriptionManager.tsx
import { useEffect, useState } from 'react';
import { useAuth } from '@/hooks/useAuth';
interface SubscriptionStatus {
hasSubscription: boolean;
status: string;
plan: string;
currentPeriodEnd?: Date;
cancelAtPeriodEnd?: boolean;
limits: {
conversations: number;
conversationsUsed: number;
};
}
export function SubscriptionManager() {
const [subscription, setSubscription] = useState<SubscriptionStatus | null>(null);
const [loading, setLoading] = useState(true);
const { user } = useAuth();
useEffect(() => {
if (user) {
fetchSubscriptionStatus();
}
}, [user]);
const fetchSubscriptionStatus = async () => {
try {
const response = await fetch('/api/subscription/status', {
credentials: 'include',
});
const data = await response.json();
setSubscription(data);
} catch (error) {
console.error('Failed to fetch subscription:', error);
} finally {
setLoading(false);
}
};
const handleCancelSubscription = async (immediate: boolean = false) => {
const reason = prompt('Please tell us why you are canceling (optional):');
try {
const response = await fetch('/api/subscription/cancel', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
credentials: 'include',
body: JSON.stringify({
immediate,
reason,
}),
});
if (response.ok) {
const data = await response.json();
alert(
immediate
? 'Subscription canceled immediately.'
: `Subscription will cancel at the end of the billing period (${new Date(
data.currentPeriodEnd
).toLocaleDateString()}).`
);
fetchSubscriptionStatus();
}
} catch (error) {
console.error('Failed to cancel subscription:', error);
alert('Failed to cancel subscription. Please try again.');
}
};
const handleManageBilling = async () => {
try {
const response = await fetch('/api/checkout/portal', {
method: 'POST',
credentials: 'include',
});
const { url } = await response.json();
window.location.href = url;
} catch (error) {
console.error('Failed to open billing portal:', error);
alert('Failed to open billing portal. Please try again.');
}
};
if (loading) {
return <div>Loading subscription...</div>;
}
if (!subscription?.hasSubscription) {
return (
<div className="no-subscription">
<h3>No Active Subscription</h3>
<p>You are on the free plan with limited features.</p>
<CheckoutButton
priceId={process.env.NEXT_PUBLIC_PREMIUM_PRICE_ID!}
planName="premium"
>
Upgrade to Premium
</CheckoutButton>
</div>
);
}
return (
<div className="subscription-manager">
<h3>Subscription Details</h3>
<div className="subscription-info">
<p>Plan: <strong>{subscription.plan}</strong></p>
<p>Status: <strong>{subscription.status}</strong></p>
{subscription.currentPeriodEnd && (
<p>
Next billing date:{' '}
<strong>{new Date(subscription.currentPeriodEnd).toLocaleDateString()}</strong>
</p>
)}
{subscription.cancelAtPeriodEnd && (
<p className="warning">
Your subscription will end on{' '}
{new Date(subscription.currentPeriodEnd!).toLocaleDateString()}
</p>
)}
{subscription.plan === 'free' && (
<p>
Conversations:{' '}
<strong>
{subscription.limits.conversationsUsed} / {subscription.limits.conversations}
</strong>
</p>
)}
</div>
<div className="subscription-actions">
<button onClick={handleManageBilling}>Manage Billing</button>
{subscription.status === 'active' && !subscription.cancelAtPeriodEnd && (
<>
<button onClick={() => handleCancelSubscription(false)}>
Cancel at Period End
</button>
<button
onClick={() => handleCancelSubscription(true)}
className="danger"
>
Cancel Immediately
</button>
</>
)}
</div>
</div>
);
}
Phase 6: Testing & Monitoring
Step 6.1: Payment Testing Suite
// tests/payments.test.ts
import { describe, it, expect } from '@jest/globals';
import Stripe from 'stripe';
describe('Payment System Tests', () => {
describe('Checkout Flow', () => {
it('should create checkout session', async () => {
const session = await createCheckoutSession({
priceId: 'price_test',
userId: 'user_test',
});
expect(session).toHaveProperty('id');
expect(session).toHaveProperty('url');
expect(session.metadata.userId).toBe('user_test');
});
it('should handle successful payment', async () => {
const webhook = mockStripeWebhook('checkout.session.completed', {
id: 'cs_test',
payment_status: 'paid',
metadata: {
userId: 'user_test',
type: 'subscription',
},
});
const result = await processWebhook(webhook);
expect(result.success).toBe(true);
// Verify subscription created
const subscription = await getSubscriptionByUserId('user_test');
expect(subscription).toBeTruthy();
expect(subscription.status).toBe('active');
});
it('should handle failed payment', async () => {
const webhook = mockStripeWebhook('payment_intent.payment_failed', {
id: 'pi_test',
status: 'failed',
last_payment_error: {
message: 'Card declined',
},
});
const result = await processWebhook(webhook);
expect(result.success).toBe(true);
// Verify failed payment logged
const failedPayment = await getFailedPayment('pi_test');
expect(failedPayment).toBeTruthy();
expect(failedPayment.error).toBe('Card declined');
});
});
describe('Subscription Management', () => {
it('should cancel subscription at period end', async () => {
const subscription = await createTestSubscription();
const result = await cancelSubscription(subscription.id, {
immediate: false,
});
expect(result.cancelAtPeriodEnd).toBe(true);
expect(result.status).toBe('active'); // Still active until end
});
it('should reset conversation count on renewal', async () => {
const subscription = await createTestSubscription({
planName: 'free',
conversationCount: 10,
});
// Simulate renewal webhook
const webhook = mockStripeWebhook('customer.subscription.updated', {
id: subscription.stripeSubscriptionId,
current_period_end: Date.now() / 1000 + 30 * 24 * 60 * 60, // 30 days
});
await processWebhook(webhook);
const updated = await getSubscription(subscription.id);
expect(updated.metadata.conversationCount).toBe(0);
});
});
});
Step 6.2: Monitoring Dashboard
// components/admin/PaymentsDashboard.tsx
export function PaymentsDashboard() {
const [metrics, setMetrics] = useState({
mrr: 0,
activeSubscriptions: 0,
churnRate: 0,
ltv: 0,
conversionRate: 0,
});
const [recentTransactions, setRecentTransactions] = useState([]);
useEffect(() => {
fetchPaymentMetrics();
fetchRecentTransactions();
// Real-time updates via WebSocket
const ws = new WebSocket(process.env.NEXT_PUBLIC_WS_URL);
ws.on('payment-update', (data) => {
fetchPaymentMetrics();
fetchRecentTransactions();
});
return () => ws.close();
}, []);
return (
<div className="payments-dashboard">
<h2>Payment Analytics</h2>
<div className="metrics-grid">
<MetricCard
title="MRR"
value={`$${metrics.mrr.toFixed(2)}`}
trend="+12%"
/>
<MetricCard
title="Active Subscriptions"
value={metrics.activeSubscriptions}
trend="+5"
/>
<MetricCard
title="Churn Rate"
value={`${metrics.churnRate.toFixed(1)}%`}
trend="-0.5%"
/>
<MetricCard
title="LTV"
value={`$${metrics.ltv.toFixed(2)}`}
trend="+$15"
/>
</div>
<RecentTransactions transactions={recentTransactions} />
<SubscriptionChart />
<FailedPayments />
</div>
);
}
Migration Checklist
Pre-Migration
- Backup all payment data
- Document current Stripe configuration
- Test Stripe webhook signatures
- Set up staging Stripe account
- Create rollback plan
Migration Steps
- Install Payload Stripe plugin
- Configure webhook endpoints
- Create payment collections
- Migrate existing products to Stripe
- Migrate subscription data
- Test payment flows in staging
- Update frontend components
- Configure monitoring
Post-Migration
- Verify all webhooks working
- Confirm subscription renewals
- Test cancellation flows
- Monitor failed payments
- Generate payment reports
- Archive old payment code
Security Best Practices
PCI Compliance
- Never store card details - Use Stripe tokens
- Use HTTPS everywhere - Enforce SSL
- Implement webhook signatures - Verify all webhooks
- Secure API endpoints - Authentication required
- Log payment events - Audit trail
Data Protection
// security/payment-security.ts
export const paymentSecurity = {
// Sanitize payment data before logging
sanitizePaymentData(data: any) {
const sanitized = { ...data };
delete sanitized.cardNumber;
delete sanitized.cvv;
delete sanitized.expiryDate;
return sanitized;
},
// Verify webhook signatures
verifyWebhookSignature(payload: string, signature: string): boolean {
const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!);
try {
stripe.webhooks.constructEvent(
payload,
signature,
process.env.STRIPE_WEBHOOK_SECRET!
);
return true;
} catch (error) {
console.error('Invalid webhook signature:', error);
return false;
}
},
// Rate limiting for payment endpoints
rateLimits: {
checkout: '5 per minute per user',
portal: '3 per minute per user',
cancel: '2 per minute per user',
},
};
Conclusion
The Payload CMS payment integration provides:
- Robust webhook handling with automatic retries
- Type-safe Stripe integration with TypeScript
- Automated subscription management
- Comprehensive payment tracking
- PCI compliance out of the box
This migration ensures reliable payment processing while reducing custom code maintenance.
Document Version: 1.0 Last Updated: November 2024 Author: Biblical Guide Development Team