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>
1524 lines
40 KiB
Markdown
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* |