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

40 KiB

Payload CMS Payment System Integration Guide

Executive Summary

This guide details the integration of Stripe payment processing with Payload CMS for Biblical Guide, covering subscriptions, donations, and one-time payments while maintaining PCI compliance and ensuring seamless migration from the existing system.

Current Payment System Analysis

Existing Implementation

  • Payment Processor: Stripe
  • Subscription Plans: Free (10 chats/month) and Premium (unlimited)
  • Donation Support: One-time donations via Stripe Checkout
  • Webhooks: Complete implementation for subscription lifecycle
  • Database: Subscription, Donation, StripeEvent tables

Current Payment Flow

graph TD
    A[User Selects Plan] --> B[Create Stripe Checkout]
    B --> C[Redirect to Stripe]
    C --> D[Payment Processing]
    D --> E[Webhook Event]
    E --> F[Update Database]
    F --> G[Grant Access]

Payload Payment Architecture

Stripe Plugin Features

  • Native Stripe SDK integration
  • Webhook handling with signature verification
  • Automatic sync with Stripe objects
  • TypeScript types for all Stripe entities
  • Built-in error handling and retries

Enhanced Payment Flow with Payload

graph TD
    A[User Action] --> B[Payload API]
    B --> C[Stripe Plugin]
    C --> D[Stripe API]
    D --> E[Webhook]
    E --> F[Payload Hook]
    F --> G[Auto-sync Collections]
    G --> H[User Access Updated]

Implementation Plan

Phase 1: Stripe Plugin Setup

Step 1.1: Install and Configure Plugin

npm install @payloadcms/plugin-stripe stripe
// payload.config.ts
import { stripePlugin } from '@payloadcms/plugin-stripe';
import { buildConfig } from 'payload/config';

export default buildConfig({
  plugins: [
    stripePlugin({
      stripeSecretKey: process.env.STRIPE_SECRET_KEY!,
      stripeWebhookEndpointSecret: process.env.STRIPE_WEBHOOK_SECRET!,
      rest: true, // Enable REST API endpoints
      graphQL: true, // Enable GraphQL endpoints

      // Webhook configuration
      webhooks: {
        'checkout.session.completed': handleCheckoutComplete,
        'customer.subscription.created': handleSubscriptionCreated,
        'customer.subscription.updated': handleSubscriptionUpdated,
        'customer.subscription.deleted': handleSubscriptionDeleted,
        'payment_intent.succeeded': handlePaymentSuccess,
        'invoice.payment_succeeded': handleInvoicePayment,
        'invoice.payment_failed': handlePaymentFailure,
      },

      // Sync configuration
      sync: [
        {
          collection: 'products',
          stripeResourceType: 'products',
          stripeResourceTypeSingular: 'product',
        },
        {
          collection: 'prices',
          stripeResourceType: 'prices',
          stripeResourceTypeSingular: 'price',
        },
        {
          collection: 'customers',
          stripeResourceType: 'customers',
          stripeResourceTypeSingular: 'customer',
        },
        {
          collection: 'subscriptions',
          stripeResourceType: 'subscriptions',
          stripeResourceTypeSingular: 'subscription',
        },
      ],
    }),
  ],
});

Phase 2: Collection Definitions

Step 2.1: Products Collection

// collections/Products.ts
import { CollectionConfig } from 'payload/types';

export const Products: CollectionConfig = {
  slug: 'products',
  admin: {
    useAsTitle: 'name',
    group: 'E-commerce',
    defaultColumns: ['name', 'active', 'prices', 'updatedAt'],
  },
  fields: [
    {
      name: 'name',
      type: 'text',
      required: true,
      localized: true,
    },
    {
      name: 'description',
      type: 'richText',
      localized: true,
    },
    {
      name: 'stripeProductId',
      type: 'text',
      unique: true,
      admin: {
        readOnly: true,
        position: 'sidebar',
      },
    },
    {
      name: 'active',
      type: 'checkbox',
      defaultValue: true,
      required: true,
    },
    {
      name: 'metadata',
      type: 'group',
      fields: [
        {
          name: 'planType',
          type: 'select',
          options: [
            { label: 'Free', value: 'free' },
            { label: 'Premium', value: 'premium' },
            { label: 'Enterprise', value: 'enterprise' },
          ],
        },
        {
          name: 'features',
          type: 'array',
          fields: [
            {
              name: 'feature',
              type: 'text',
              required: true,
            },
            {
              name: 'included',
              type: 'checkbox',
              defaultValue: true,
            },
            {
              name: 'limit',
              type: 'number',
              admin: {
                condition: (data, siblingData) => !siblingData.included,
              },
            },
          ],
        },
      ],
    },
    {
      name: 'prices',
      type: 'relationship',
      relationTo: 'prices',
      hasMany: true,
    },
  ],
  hooks: {
    beforeChange: [
      async ({ data, operation, req }) => {
        if (operation === 'create' && !data.stripeProductId) {
          // Create product in Stripe
          const stripe = req.payload.stripe;
          const stripeProduct = await stripe.products.create({
            name: data.name,
            description: data.description,
            active: data.active,
            metadata: {
              planType: data.metadata?.planType,
            },
          });

          data.stripeProductId = stripeProduct.id;
        }
        return data;
      },
    ],
    afterChange: [
      async ({ doc, operation, req }) => {
        if (operation === 'update') {
          // Sync updates to Stripe
          const stripe = req.payload.stripe;
          await stripe.products.update(doc.stripeProductId, {
            name: doc.name,
            description: doc.description,
            active: doc.active,
          });
        }
      },
    ],
  },
};

Step 2.2: Prices Collection

// collections/Prices.ts
export const Prices: CollectionConfig = {
  slug: 'prices',
  admin: {
    useAsTitle: 'displayName',
    group: 'E-commerce',
  },
  fields: [
    {
      name: 'displayName',
      type: 'text',
      admin: {
        readOnly: true,
      },
      hooks: {
        beforeChange: [
          ({ data, siblingData }) => {
            const amount = (siblingData.unitAmount || 0) / 100;
            const currency = (siblingData.currency || 'USD').toUpperCase();
            const interval = siblingData.recurring?.interval;

            if (interval) {
              return `${currency} ${amount}/${interval}`;
            }
            return `${currency} ${amount}`;
          },
        ],
      },
    },
    {
      name: 'product',
      type: 'relationship',
      relationTo: 'products',
      required: true,
    },
    {
      name: 'stripePriceId',
      type: 'text',
      unique: true,
      admin: {
        readOnly: true,
      },
    },
    {
      name: 'unitAmount',
      type: 'number',
      required: true,
      min: 0,
      admin: {
        description: 'Amount in cents',
      },
    },
    {
      name: 'currency',
      type: 'select',
      options: [
        { label: 'USD', value: 'usd' },
        { label: 'EUR', value: 'eur' },
        { label: 'GBP', value: 'gbp' },
        { label: 'RON', value: 'ron' },
      ],
      defaultValue: 'usd',
      required: true,
    },
    {
      name: 'recurring',
      type: 'group',
      fields: [
        {
          name: 'interval',
          type: 'select',
          options: [
            { label: 'Daily', value: 'day' },
            { label: 'Weekly', value: 'week' },
            { label: 'Monthly', value: 'month' },
            { label: 'Yearly', value: 'year' },
          ],
        },
        {
          name: 'intervalCount',
          type: 'number',
          defaultValue: 1,
          min: 1,
        },
        {
          name: 'trialPeriodDays',
          type: 'number',
          min: 0,
          admin: {
            description: 'Number of trial days',
          },
        },
      ],
    },
    {
      name: 'active',
      type: 'checkbox',
      defaultValue: true,
    },
  ],
};

Step 2.3: Subscriptions Collection

// collections/Subscriptions.ts
export const Subscriptions: CollectionConfig = {
  slug: 'subscriptions',
  admin: {
    useAsTitle: 'id',
    group: 'E-commerce',
    defaultColumns: ['customer', 'status', 'currentPeriodEnd', 'plan'],
  },
  fields: [
    {
      name: 'stripeSubscriptionId',
      type: 'text',
      unique: true,
      required: true,
      admin: {
        readOnly: true,
      },
    },
    {
      name: 'customer',
      type: 'relationship',
      relationTo: 'customers',
      required: true,
    },
    {
      name: 'user',
      type: 'relationship',
      relationTo: 'users',
      required: true,
      unique: true,
    },
    {
      name: 'prices',
      type: 'relationship',
      relationTo: 'prices',
      hasMany: true,
      required: true,
    },
    {
      name: 'status',
      type: 'select',
      options: [
        { label: 'Active', value: 'active' },
        { label: 'Past Due', value: 'past_due' },
        { label: 'Canceled', value: 'canceled' },
        { label: 'Incomplete', value: 'incomplete' },
        { label: 'Incomplete Expired', value: 'incomplete_expired' },
        { label: 'Trialing', value: 'trialing' },
        { label: 'Unpaid', value: 'unpaid' },
        { label: 'Paused', value: 'paused' },
      ],
      required: true,
      admin: {
        readOnly: true,
      },
    },
    {
      name: 'currentPeriodStart',
      type: 'date',
      required: true,
      admin: {
        readOnly: true,
      },
    },
    {
      name: 'currentPeriodEnd',
      type: 'date',
      required: true,
      admin: {
        readOnly: true,
      },
    },
    {
      name: 'canceledAt',
      type: 'date',
      admin: {
        readOnly: true,
      },
    },
    {
      name: 'cancelAtPeriodEnd',
      type: 'checkbox',
      defaultValue: false,
      admin: {
        readOnly: true,
      },
    },
    {
      name: 'metadata',
      type: 'group',
      fields: [
        {
          name: 'planName',
          type: 'text',
        },
        {
          name: 'conversationCount',
          type: 'number',
          defaultValue: 0,
          admin: {
            description: 'Monthly conversation count for free tier',
          },
        },
        {
          name: 'lastResetDate',
          type: 'date',
        },
      ],
    },
  ],
  hooks: {
    afterChange: [
      async ({ doc, operation, req }) => {
        // Update user's subscription status
        if (doc.user) {
          await req.payload.update({
            collection: 'users',
            id: doc.user,
            data: {
              subscriptionStatus: doc.status,
              subscriptionId: doc.id,
            },
          });
        }
      },
    ],
  },
};

Phase 3: Webhook Handlers

Step 3.1: Subscription Webhook Handlers

// webhooks/subscription.handlers.ts
import { StripeWebhookHandler } from '@payloadcms/plugin-stripe';
import Stripe from 'stripe';

export const handleSubscriptionCreated: StripeWebhookHandler = async ({
  event,
  stripe,
  payload,
}) => {
  const subscription = event.data.object as Stripe.Subscription;

  try {
    // Find or create customer
    let customer = await payload.find({
      collection: 'customers',
      where: {
        stripeCustomerId: {
          equals: subscription.customer as string,
        },
      },
    });

    if (customer.docs.length === 0) {
      // Create customer if doesn't exist
      const stripeCustomer = await stripe.customers.retrieve(
        subscription.customer as string
      );

      customer = await payload.create({
        collection: 'customers',
        data: {
          stripeCustomerId: stripeCustomer.id,
          email: stripeCustomer.email,
          name: stripeCustomer.name,
        },
      });
    }

    // Create subscription record
    const subscriptionDoc = await payload.create({
      collection: 'subscriptions',
      data: {
        stripeSubscriptionId: subscription.id,
        customer: customer.docs[0].id,
        user: subscription.metadata.userId, // Passed from checkout
        prices: subscription.items.data.map(item => item.price.id),
        status: subscription.status,
        currentPeriodStart: new Date(subscription.current_period_start * 1000),
        currentPeriodEnd: new Date(subscription.current_period_end * 1000),
        metadata: {
          planName: subscription.metadata.planName || 'premium',
          conversationCount: 0,
        },
      },
    });

    // Send welcome email
    await sendSubscriptionWelcomeEmail({
      email: customer.docs[0].email,
      planName: subscription.metadata.planName,
      nextBillingDate: new Date(subscription.current_period_end * 1000),
    });

    console.log(`Subscription created: ${subscriptionDoc.id}`);
  } catch (error) {
    console.error('Error handling subscription creation:', error);
    throw error; // Stripe will retry
  }
};

export const handleSubscriptionUpdated: StripeWebhookHandler = async ({
  event,
  stripe,
  payload,
}) => {
  const subscription = event.data.object as Stripe.Subscription;

  try {
    const existingSubscription = await payload.find({
      collection: 'subscriptions',
      where: {
        stripeSubscriptionId: {
          equals: subscription.id,
        },
      },
    });

    if (existingSubscription.docs.length === 0) {
      console.error(`Subscription not found: ${subscription.id}`);
      return;
    }

    const doc = existingSubscription.docs[0];

    // Check if billing period changed (renewal)
    const oldPeriodEnd = new Date(doc.currentPeriodEnd).getTime();
    const newPeriodEnd = subscription.current_period_end * 1000;

    const isRenewal = newPeriodEnd > oldPeriodEnd;

    // Update subscription
    await payload.update({
      collection: 'subscriptions',
      id: doc.id,
      data: {
        status: subscription.status,
        currentPeriodStart: new Date(subscription.current_period_start * 1000),
        currentPeriodEnd: new Date(subscription.current_period_end * 1000),
        canceledAt: subscription.canceled_at
          ? new Date(subscription.canceled_at * 1000)
          : null,
        cancelAtPeriodEnd: subscription.cancel_at_period_end,
        metadata: {
          ...doc.metadata,
          // Reset conversation count on renewal for free tier
          conversationCount: isRenewal && doc.metadata.planName === 'free' ? 0 : doc.metadata.conversationCount,
          lastResetDate: isRenewal ? new Date() : doc.metadata.lastResetDate,
        },
      },
    });

    // Handle status changes
    if (subscription.status === 'past_due') {
      await handlePastDueSubscription(doc.user, subscription);
    } else if (subscription.status === 'canceled') {
      await handleCanceledSubscription(doc.user, subscription);
    } else if (subscription.status === 'active' && doc.status !== 'active') {
      await handleReactivatedSubscription(doc.user, subscription);
    }

  } catch (error) {
    console.error('Error updating subscription:', error);
    throw error;
  }
};

async function handlePastDueSubscription(userId: string, subscription: Stripe.Subscription) {
  // Send payment failed email
  await sendPaymentFailedEmail(userId, subscription);

  // Restrict access after grace period
  const gracePeriodDays = 3;
  const cancelDate = new Date();
  cancelDate.setDate(cancelDate.getDate() + gracePeriodDays);

  await scheduleJob('cancel-past-due-subscription', cancelDate, {
    subscriptionId: subscription.id,
    userId,
  });
}

async function handleCanceledSubscription(userId: string, subscription: Stripe.Subscription) {
  // Update user access
  await payload.update({
    collection: 'users',
    id: userId,
    data: {
      subscriptionStatus: 'canceled',
      subscriptionEndDate: new Date(subscription.current_period_end * 1000),
    },
  });

  // Send cancellation confirmation
  await sendCancellationEmail(userId, subscription);

  // Log for analytics
  await logAnalyticsEvent({
    event: 'subscription_canceled',
    userId,
    data: {
      subscriptionId: subscription.id,
      reason: subscription.cancellation_details?.reason,
    },
  });
}

Step 3.2: Payment Webhook Handlers

// webhooks/payment.handlers.ts
export const handleCheckoutComplete: StripeWebhookHandler = async ({
  event,
  stripe,
  payload,
}) => {
  const session = event.data.object as Stripe.Checkout.Session;

  try {
    // Determine payment type
    const paymentType = session.metadata?.type || 'subscription';

    if (paymentType === 'donation') {
      await handleDonationPayment(session, payload);
    } else if (paymentType === 'subscription') {
      await handleSubscriptionPayment(session, payload, stripe);
    } else if (paymentType === 'one-time') {
      await handleOneTimePayment(session, payload);
    }

    // Track conversion
    await trackConversion({
      sessionId: session.id,
      userId: session.metadata?.userId,
      amount: session.amount_total,
      currency: session.currency,
      type: paymentType,
    });

  } catch (error) {
    console.error('Error handling checkout completion:', error);
    throw error;
  }
};

async function handleDonationPayment(
  session: Stripe.Checkout.Session,
  payload: Payload
) {
  const donation = await payload.create({
    collection: 'donations',
    data: {
      donorName: session.customer_details?.name || 'Anonymous',
      donorEmail: session.customer_details?.email || '',
      amount: (session.amount_total || 0) / 100,
      currency: session.currency?.toUpperCase() || 'USD',
      stripeSessionId: session.id,
      stripePaymentIntentId: session.payment_intent as string,
      status: 'completed',
      message: session.metadata?.message,
      anonymous: session.metadata?.anonymous === 'true',
      createdAt: new Date(),
    },
  });

  // Send thank you email
  if (!donation.anonymous && donation.donorEmail) {
    await sendDonationThankYouEmail({
      email: donation.donorEmail,
      name: donation.donorName,
      amount: donation.amount,
      currency: donation.currency,
    });
  }

  // Update donation statistics
  await updateDonationStats({
    amount: donation.amount,
    currency: donation.currency,
  });
}

export const handlePaymentFailure: StripeWebhookHandler = async ({
  event,
  stripe,
  payload,
}) => {
  const paymentIntent = event.data.object as Stripe.PaymentIntent;

  try {
    // Log failed payment
    await payload.create({
      collection: 'failed-payments',
      data: {
        stripePaymentIntentId: paymentIntent.id,
        customerId: paymentIntent.customer,
        amount: paymentIntent.amount / 100,
        currency: paymentIntent.currency,
        error: paymentIntent.last_payment_error?.message,
        errorCode: paymentIntent.last_payment_error?.code,
        timestamp: new Date(),
      },
    });

    // Notify customer
    if (paymentIntent.customer) {
      const customer = await stripe.customers.retrieve(paymentIntent.customer as string);
      if ('email' in customer && customer.email) {
        await sendPaymentFailureEmail({
          email: customer.email,
          amount: paymentIntent.amount / 100,
          currency: paymentIntent.currency,
          error: paymentIntent.last_payment_error?.message,
        });
      }
    }

    // Update subscription if related
    if (paymentIntent.invoice) {
      const invoice = await stripe.invoices.retrieve(paymentIntent.invoice as string);
      if (invoice.subscription) {
        await handleFailedSubscriptionPayment(invoice.subscription as string, payload);
      }
    }

  } catch (error) {
    console.error('Error handling payment failure:', error);
  }
};

Phase 4: API Endpoints

Step 4.1: Checkout API

// api/checkout.ts
import { PayloadRequest } from 'payload/types';
import Stripe from 'stripe';

export const checkoutEndpoints = [
  {
    path: '/api/checkout/session',
    method: 'post',
    handler: async (req: PayloadRequest, res) => {
      try {
        const { priceId, userId, type = 'subscription', metadata = {} } = req.body;

        if (!req.user) {
          return res.status(401).json({ error: 'Authentication required' });
        }

        const stripe = req.payload.stripe as Stripe;

        // Get price details
        const price = await stripe.prices.retrieve(priceId, {
          expand: ['product'],
        });

        // Create checkout session
        const session = await stripe.checkout.sessions.create({
          payment_method_types: ['card'],
          mode: type === 'subscription' ? 'subscription' : 'payment',
          customer_email: req.user.email,
          client_reference_id: req.user.id,
          metadata: {
            userId: req.user.id,
            type,
            ...metadata,
          },
          line_items: [
            {
              price: priceId,
              quantity: 1,
            },
          ],
          subscription_data: type === 'subscription' ? {
            metadata: {
              userId: req.user.id,
              planName: metadata.planName || 'premium',
            },
            trial_period_days: price.recurring?.trial_period_days,
          } : undefined,
          success_url: `${process.env.FRONTEND_URL}/subscription/success?session_id={CHECKOUT_SESSION_ID}`,
          cancel_url: `${process.env.FRONTEND_URL}/subscription`,
          allow_promotion_codes: true,
        });

        // Log checkout initiation
        await req.payload.create({
          collection: 'checkout-sessions',
          data: {
            sessionId: session.id,
            userId: req.user.id,
            priceId,
            type,
            status: 'pending',
            createdAt: new Date(),
          },
        });

        return res.json({
          sessionId: session.id,
          url: session.url,
        });

      } catch (error) {
        console.error('Checkout session error:', error);
        return res.status(500).json({
          error: 'Failed to create checkout session',
        });
      }
    },
  },
  {
    path: '/api/checkout/portal',
    method: 'post',
    handler: async (req: PayloadRequest, res) => {
      if (!req.user) {
        return res.status(401).json({ error: 'Authentication required' });
      }

      try {
        const stripe = req.payload.stripe as Stripe;

        // Get or create customer
        let customerId = req.user.stripeCustomerId;

        if (!customerId) {
          const customer = await stripe.customers.create({
            email: req.user.email,
            name: req.user.name,
            metadata: {
              userId: req.user.id,
            },
          });

          customerId = customer.id;

          // Update user with customer ID
          await req.payload.update({
            collection: 'users',
            id: req.user.id,
            data: {
              stripeCustomerId: customerId,
            },
          });
        }

        // Create portal session
        const session = await stripe.billingPortal.sessions.create({
          customer: customerId,
          return_url: `${process.env.FRONTEND_URL}/subscription`,
        });

        return res.json({
          url: session.url,
        });

      } catch (error) {
        console.error('Portal session error:', error);
        return res.status(500).json({
          error: 'Failed to create portal session',
        });
      }
    },
  },
];

Step 4.2: Subscription Management API

// api/subscription.ts
export const subscriptionEndpoints = [
  {
    path: '/api/subscription/status',
    method: 'get',
    handler: async (req: PayloadRequest, res) => {
      if (!req.user) {
        return res.status(401).json({ error: 'Authentication required' });
      }

      try {
        const subscription = await req.payload.find({
          collection: 'subscriptions',
          where: {
            user: {
              equals: req.user.id,
            },
          },
          limit: 1,
        });

        if (subscription.docs.length === 0) {
          return res.json({
            hasSubscription: false,
            status: 'none',
            plan: 'free',
            limits: {
              conversations: 10,
              conversationsUsed: 0,
            },
          });
        }

        const sub = subscription.docs[0];

        return res.json({
          hasSubscription: true,
          status: sub.status,
          plan: sub.metadata.planName,
          currentPeriodEnd: sub.currentPeriodEnd,
          cancelAtPeriodEnd: sub.cancelAtPeriodEnd,
          limits: {
            conversations: sub.metadata.planName === 'free' ? 10 : -1,
            conversationsUsed: sub.metadata.conversationCount || 0,
          },
        });

      } catch (error) {
        console.error('Subscription status error:', error);
        return res.status(500).json({
          error: 'Failed to fetch subscription status',
        });
      }
    },
  },
  {
    path: '/api/subscription/cancel',
    method: 'post',
    handler: async (req: PayloadRequest, res) => {
      if (!req.user) {
        return res.status(401).json({ error: 'Authentication required' });
      }

      try {
        const { immediate = false, reason } = req.body;

        const subscription = await req.payload.find({
          collection: 'subscriptions',
          where: {
            user: {
              equals: req.user.id,
            },
            status: {
              equals: 'active',
            },
          },
          limit: 1,
        });

        if (subscription.docs.length === 0) {
          return res.status(404).json({ error: 'No active subscription found' });
        }

        const sub = subscription.docs[0];
        const stripe = req.payload.stripe as Stripe;

        // Cancel subscription
        const canceledSubscription = await stripe.subscriptions.update(
          sub.stripeSubscriptionId,
          {
            cancel_at_period_end: !immediate,
            cancellation_details: {
              comment: reason,
            },
          }
        );

        if (immediate) {
          await stripe.subscriptions.cancel(sub.stripeSubscriptionId);
        }

        // Log cancellation
        await req.payload.create({
          collection: 'cancellations',
          data: {
            userId: req.user.id,
            subscriptionId: sub.id,
            reason,
            immediate,
            timestamp: new Date(),
          },
        });

        return res.json({
          success: true,
          cancelAtPeriodEnd: canceledSubscription.cancel_at_period_end,
          currentPeriodEnd: new Date(canceledSubscription.current_period_end * 1000),
        });

      } catch (error) {
        console.error('Subscription cancellation error:', error);
        return res.status(500).json({
          error: 'Failed to cancel subscription',
        });
      }
    },
  },
];

Phase 5: Frontend Integration

Step 5.1: Payment Components

// components/payment/CheckoutButton.tsx
import { useState } from 'react';
import { loadStripe } from '@stripe/stripe-js';
import { useAuth } from '@/hooks/useAuth';

const stripePromise = loadStripe(process.env.NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY!);

interface CheckoutButtonProps {
  priceId: string;
  planName: string;
  children: React.ReactNode;
}

export function CheckoutButton({ priceId, planName, children }: CheckoutButtonProps) {
  const [loading, setLoading] = useState(false);
  const { user } = useAuth();

  const handleCheckout = async () => {
    if (!user) {
      // Redirect to login
      window.location.href = '/login?redirect=/subscription';
      return;
    }

    setLoading(true);

    try {
      const response = await fetch('/api/checkout/session', {
        method: 'POST',
        headers: {
          'Content-Type': 'application/json',
        },
        credentials: 'include',
        body: JSON.stringify({
          priceId,
          userId: user.id,
          type: 'subscription',
          metadata: {
            planName,
          },
        }),
      });

      const { sessionId } = await response.json();

      const stripe = await stripePromise;
      const { error } = await stripe!.redirectToCheckout({ sessionId });

      if (error) {
        console.error('Stripe redirect error:', error);
        alert('Payment failed. Please try again.');
      }
    } catch (error) {
      console.error('Checkout error:', error);
      alert('Failed to start checkout. Please try again.');
    } finally {
      setLoading(false);
    }
  };

  return (
    <button
      onClick={handleCheckout}
      disabled={loading}
      className="checkout-button"
    >
      {loading ? 'Loading...' : children}
    </button>
  );
}

Step 5.2: Subscription Management Component

// components/subscription/SubscriptionManager.tsx
import { useEffect, useState } from 'react';
import { useAuth } from '@/hooks/useAuth';

interface SubscriptionStatus {
  hasSubscription: boolean;
  status: string;
  plan: string;
  currentPeriodEnd?: Date;
  cancelAtPeriodEnd?: boolean;
  limits: {
    conversations: number;
    conversationsUsed: number;
  };
}

export function SubscriptionManager() {
  const [subscription, setSubscription] = useState<SubscriptionStatus | null>(null);
  const [loading, setLoading] = useState(true);
  const { user } = useAuth();

  useEffect(() => {
    if (user) {
      fetchSubscriptionStatus();
    }
  }, [user]);

  const fetchSubscriptionStatus = async () => {
    try {
      const response = await fetch('/api/subscription/status', {
        credentials: 'include',
      });
      const data = await response.json();
      setSubscription(data);
    } catch (error) {
      console.error('Failed to fetch subscription:', error);
    } finally {
      setLoading(false);
    }
  };

  const handleCancelSubscription = async (immediate: boolean = false) => {
    const reason = prompt('Please tell us why you are canceling (optional):');

    try {
      const response = await fetch('/api/subscription/cancel', {
        method: 'POST',
        headers: {
          'Content-Type': 'application/json',
        },
        credentials: 'include',
        body: JSON.stringify({
          immediate,
          reason,
        }),
      });

      if (response.ok) {
        const data = await response.json();
        alert(
          immediate
            ? 'Subscription canceled immediately.'
            : `Subscription will cancel at the end of the billing period (${new Date(
                data.currentPeriodEnd
              ).toLocaleDateString()}).`
        );
        fetchSubscriptionStatus();
      }
    } catch (error) {
      console.error('Failed to cancel subscription:', error);
      alert('Failed to cancel subscription. Please try again.');
    }
  };

  const handleManageBilling = async () => {
    try {
      const response = await fetch('/api/checkout/portal', {
        method: 'POST',
        credentials: 'include',
      });

      const { url } = await response.json();
      window.location.href = url;
    } catch (error) {
      console.error('Failed to open billing portal:', error);
      alert('Failed to open billing portal. Please try again.');
    }
  };

  if (loading) {
    return <div>Loading subscription...</div>;
  }

  if (!subscription?.hasSubscription) {
    return (
      <div className="no-subscription">
        <h3>No Active Subscription</h3>
        <p>You are on the free plan with limited features.</p>
        <CheckoutButton
          priceId={process.env.NEXT_PUBLIC_PREMIUM_PRICE_ID!}
          planName="premium"
        >
          Upgrade to Premium
        </CheckoutButton>
      </div>
    );
  }

  return (
    <div className="subscription-manager">
      <h3>Subscription Details</h3>
      <div className="subscription-info">
        <p>Plan: <strong>{subscription.plan}</strong></p>
        <p>Status: <strong>{subscription.status}</strong></p>
        {subscription.currentPeriodEnd && (
          <p>
            Next billing date:{' '}
            <strong>{new Date(subscription.currentPeriodEnd).toLocaleDateString()}</strong>
          </p>
        )}
        {subscription.cancelAtPeriodEnd && (
          <p className="warning">
            Your subscription will end on{' '}
            {new Date(subscription.currentPeriodEnd!).toLocaleDateString()}
          </p>
        )}
        {subscription.plan === 'free' && (
          <p>
            Conversations:{' '}
            <strong>
              {subscription.limits.conversationsUsed} / {subscription.limits.conversations}
            </strong>
          </p>
        )}
      </div>

      <div className="subscription-actions">
        <button onClick={handleManageBilling}>Manage Billing</button>
        {subscription.status === 'active' && !subscription.cancelAtPeriodEnd && (
          <>
            <button onClick={() => handleCancelSubscription(false)}>
              Cancel at Period End
            </button>
            <button
              onClick={() => handleCancelSubscription(true)}
              className="danger"
            >
              Cancel Immediately
            </button>
          </>
        )}
      </div>
    </div>
  );
}

Phase 6: Testing & Monitoring

Step 6.1: Payment Testing Suite

// tests/payments.test.ts
import { describe, it, expect } from '@jest/globals';
import Stripe from 'stripe';

describe('Payment System Tests', () => {
  describe('Checkout Flow', () => {
    it('should create checkout session', async () => {
      const session = await createCheckoutSession({
        priceId: 'price_test',
        userId: 'user_test',
      });

      expect(session).toHaveProperty('id');
      expect(session).toHaveProperty('url');
      expect(session.metadata.userId).toBe('user_test');
    });

    it('should handle successful payment', async () => {
      const webhook = mockStripeWebhook('checkout.session.completed', {
        id: 'cs_test',
        payment_status: 'paid',
        metadata: {
          userId: 'user_test',
          type: 'subscription',
        },
      });

      const result = await processWebhook(webhook);
      expect(result.success).toBe(true);

      // Verify subscription created
      const subscription = await getSubscriptionByUserId('user_test');
      expect(subscription).toBeTruthy();
      expect(subscription.status).toBe('active');
    });

    it('should handle failed payment', async () => {
      const webhook = mockStripeWebhook('payment_intent.payment_failed', {
        id: 'pi_test',
        status: 'failed',
        last_payment_error: {
          message: 'Card declined',
        },
      });

      const result = await processWebhook(webhook);
      expect(result.success).toBe(true);

      // Verify failed payment logged
      const failedPayment = await getFailedPayment('pi_test');
      expect(failedPayment).toBeTruthy();
      expect(failedPayment.error).toBe('Card declined');
    });
  });

  describe('Subscription Management', () => {
    it('should cancel subscription at period end', async () => {
      const subscription = await createTestSubscription();

      const result = await cancelSubscription(subscription.id, {
        immediate: false,
      });

      expect(result.cancelAtPeriodEnd).toBe(true);
      expect(result.status).toBe('active'); // Still active until end
    });

    it('should reset conversation count on renewal', async () => {
      const subscription = await createTestSubscription({
        planName: 'free',
        conversationCount: 10,
      });

      // Simulate renewal webhook
      const webhook = mockStripeWebhook('customer.subscription.updated', {
        id: subscription.stripeSubscriptionId,
        current_period_end: Date.now() / 1000 + 30 * 24 * 60 * 60, // 30 days
      });

      await processWebhook(webhook);

      const updated = await getSubscription(subscription.id);
      expect(updated.metadata.conversationCount).toBe(0);
    });
  });
});

Step 6.2: Monitoring Dashboard

// components/admin/PaymentsDashboard.tsx
export function PaymentsDashboard() {
  const [metrics, setMetrics] = useState({
    mrr: 0,
    activeSubscriptions: 0,
    churnRate: 0,
    ltv: 0,
    conversionRate: 0,
  });

  const [recentTransactions, setRecentTransactions] = useState([]);

  useEffect(() => {
    fetchPaymentMetrics();
    fetchRecentTransactions();

    // Real-time updates via WebSocket
    const ws = new WebSocket(process.env.NEXT_PUBLIC_WS_URL);
    ws.on('payment-update', (data) => {
      fetchPaymentMetrics();
      fetchRecentTransactions();
    });

    return () => ws.close();
  }, []);

  return (
    <div className="payments-dashboard">
      <h2>Payment Analytics</h2>

      <div className="metrics-grid">
        <MetricCard
          title="MRR"
          value={`$${metrics.mrr.toFixed(2)}`}
          trend="+12%"
        />
        <MetricCard
          title="Active Subscriptions"
          value={metrics.activeSubscriptions}
          trend="+5"
        />
        <MetricCard
          title="Churn Rate"
          value={`${metrics.churnRate.toFixed(1)}%`}
          trend="-0.5%"
        />
        <MetricCard
          title="LTV"
          value={`$${metrics.ltv.toFixed(2)}`}
          trend="+$15"
        />
      </div>

      <RecentTransactions transactions={recentTransactions} />

      <SubscriptionChart />

      <FailedPayments />
    </div>
  );
}

Migration Checklist

Pre-Migration

  • Backup all payment data
  • Document current Stripe configuration
  • Test Stripe webhook signatures
  • Set up staging Stripe account
  • Create rollback plan

Migration Steps

  • Install Payload Stripe plugin
  • Configure webhook endpoints
  • Create payment collections
  • Migrate existing products to Stripe
  • Migrate subscription data
  • Test payment flows in staging
  • Update frontend components
  • Configure monitoring

Post-Migration

  • Verify all webhooks working
  • Confirm subscription renewals
  • Test cancellation flows
  • Monitor failed payments
  • Generate payment reports
  • Archive old payment code

Security Best Practices

PCI Compliance

  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

// 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