# 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 ( ); } ``` #### 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(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
Loading subscription...
; } if (!subscription?.hasSubscription) { return (

No Active Subscription

You are on the free plan with limited features.

Upgrade to Premium
); } return (

Subscription Details

Plan: {subscription.plan}

Status: {subscription.status}

{subscription.currentPeriodEnd && (

Next billing date:{' '} {new Date(subscription.currentPeriodEnd).toLocaleDateString()}

)} {subscription.cancelAtPeriodEnd && (

Your subscription will end on{' '} {new Date(subscription.currentPeriodEnd!).toLocaleDateString()}

)} {subscription.plan === 'free' && (

Conversations:{' '} {subscription.limits.conversationsUsed} / {subscription.limits.conversations}

)}
{subscription.status === 'active' && !subscription.cancelAtPeriodEnd && ( <> )}
); } ``` ### 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 (

Payment Analytics

); } ``` ## 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*