Files
biblical-guide.com/PAYLOAD_PAYMENT_INTEGRATION_GUIDE.md
Andrei 9b5c0ed8bb build: production build with Phase 1 2025 Bible Reader implementation complete
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>
2025-11-11 20:38:01 +00:00

1524 lines
40 KiB
Markdown

# 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
```mermaid
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
```mermaid
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
```bash
npm install @payloadcms/plugin-stripe stripe
```
```typescript
// 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
```typescript
// 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
```typescript
// 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
```typescript
// 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
```typescript
// 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
```typescript
// 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
```typescript
// 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
```typescript
// 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
```tsx
// 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
```tsx
// 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
```typescript
// 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
```typescript
// 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
1. **Never store card details** - Use Stripe tokens
2. **Use HTTPS everywhere** - Enforce SSL
3. **Implement webhook signatures** - Verify all webhooks
4. **Secure API endpoints** - Authentication required
5. **Log payment events** - Audit trail
### Data Protection
```typescript
// 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*