Skip to main content

Payment Processing Guide

Learn how to integrate payment processing with Fanfare admission flows for secure, validated transactions.

Overview

When a consumer is admitted through Fanfare, they have a limited time window to complete their purchase. This guide covers integrating payment providers while validating admission status. What you’ll learn:
  • Validating admission before payment
  • Integrating with Stripe, PayPal, and other providers
  • Handling payment failures
  • Securing the payment flow
Complexity: Intermediate Time to complete: 35 minutes

Prerequisites

  • Checkout Overview guide completed
  • Payment provider account (Stripe, PayPal, etc.)
  • Backend server for secure payment processing

Payment Flow Architecture

Consumer Admitted


┌──────────────────┐
│  Checkout Page   │
│  (Collect Info)  │
└──────────────────┘


┌──────────────────┐     ┌──────────────────┐
│ Create Payment   │────▶│ Validate         │
│ Intent (Server)  │     │ Admission Token  │
└──────────────────┘     └──────────────────┘
       │                          │
       │                          │ Invalid? Return Error
       │◄─────────────────────────┘


┌──────────────────┐
│ Process Payment  │
│ (Stripe/PayPal)  │
└──────────────────┘

       ├─── Success ──▶ Complete Admission
       │                Create Order

       └─── Failure ──▶ Allow Retry
                       (Keep Admission Valid)

Stripe Integration

Step 1: Create Payment Intent with Admission Validation

// routes/payments.ts
import Stripe from "stripe";
import express from "express";

const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!);
const router = express.Router();

interface CreatePaymentParams {
  admissionToken: string;
  consumerId: string;
  distributionId: string;
  distributionType: "queue" | "draw" | "auction";
  amount: number;
  currency: string;
  items: Array<{ productId: string; quantity: number }>;
}

router.post("/create-payment-intent", async (req, res) => {
  try {
    const { admissionToken, consumerId, distributionId, distributionType, amount, currency, items } =
      req.body as CreatePaymentParams;

    // 1. Validate admission with Fanfare FIRST
    const isValid = await validateAdmission({
      token: admissionToken,
      consumerId,
      distributionId,
      distributionType,
    });

    if (!isValid) {
      return res.status(403).json({
        error: "ADMISSION_INVALID",
        message: "Your access has expired. Please rejoin the experience.",
      });
    }

    // 2. Create Stripe PaymentIntent
    const paymentIntent = await stripe.paymentIntents.create({
      amount: Math.round(amount * 100), // Stripe uses cents
      currency,
      automatic_payment_methods: { enabled: true },
      metadata: {
        admissionToken,
        consumerId,
        distributionId,
        distributionType,
        items: JSON.stringify(items),
      },
    });

    // 3. Return client secret
    res.json({
      clientSecret: paymentIntent.client_secret,
      paymentIntentId: paymentIntent.id,
    });
  } catch (error) {
    console.error("Payment intent creation failed:", error);
    res.status(500).json({ error: "Failed to create payment" });
  }
});

Step 2: Frontend Payment Form

import { loadStripe } from "@stripe/stripe-js";
import { Elements, PaymentElement, useStripe, useElements } from "@stripe/react-stripe-js";
import { useState, useEffect } from "react";

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

interface CheckoutFormProps {
  admissionToken: string;
  consumerId: string;
  distributionId: string;
  distributionType: "queue" | "draw" | "auction";
  cart: CartItem[];
  totalAmount: number;
}

function PaymentForm({ onSuccess, onError }: { onSuccess: () => void; onError: (msg: string) => void }) {
  const stripe = useStripe();
  const elements = useElements();
  const [isProcessing, setIsProcessing] = useState(false);

  const handleSubmit = async (e: React.FormEvent) => {
    e.preventDefault();

    if (!stripe || !elements) return;

    setIsProcessing(true);

    const { error, paymentIntent } = await stripe.confirmPayment({
      elements,
      confirmParams: {
        return_url: `${window.location.origin}/checkout/complete`,
      },
      redirect: "if_required",
    });

    if (error) {
      onError(error.message ?? "Payment failed");
      setIsProcessing(false);
    } else if (paymentIntent?.status === "succeeded") {
      onSuccess();
    }
  };

  return (
    <form onSubmit={handleSubmit}>
      <PaymentElement />
      <button type="submit" disabled={!stripe || isProcessing}>
        {isProcessing ? "Processing..." : "Pay Now"}
      </button>
    </form>
  );
}

export function StripeCheckout(props: CheckoutFormProps) {
  const [clientSecret, setClientSecret] = useState<string | null>(null);
  const [error, setError] = useState<string | null>(null);

  useEffect(() => {
    async function createIntent() {
      try {
        const res = await fetch("/api/payments/create-payment-intent", {
          method: "POST",
          headers: { "Content-Type": "application/json" },
          body: JSON.stringify({
            admissionToken: props.admissionToken,
            consumerId: props.consumerId,
            distributionId: props.distributionId,
            distributionType: props.distributionType,
            amount: props.totalAmount,
            currency: "usd",
            items: props.cart,
          }),
        });

        if (!res.ok) {
          const data = await res.json();
          if (data.error === "ADMISSION_INVALID") {
            setError("Your access has expired. Please return to the experience.");
            return;
          }
          throw new Error(data.message);
        }

        const { clientSecret } = await res.json();
        setClientSecret(clientSecret);
      } catch (err) {
        setError(err instanceof Error ? err.message : "Failed to initialize payment");
      }
    }

    createIntent();
  }, [props]);

  if (error) {
    return (
      <div className="payment-error">
        <p>{error}</p>
        <a href="/experience">Return to Experience</a>
      </div>
    );
  }

  if (!clientSecret) {
    return <div className="loading">Preparing payment...</div>;
  }

  return (
    <Elements stripe={stripePromise} options={{ clientSecret }}>
      <PaymentForm onSuccess={() => (window.location.href = "/checkout/success")} onError={setError} />
    </Elements>
  );
}

Step 3: Handle Stripe Webhooks

// routes/webhooks/stripe.ts
import Stripe from "stripe";
import express from "express";

const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!);
const webhookSecret = process.env.STRIPE_WEBHOOK_SECRET!;

const router = express.Router();

router.post("/webhook", express.raw({ type: "application/json" }), async (req, res) => {
  const sig = req.headers["stripe-signature"]!;

  let event: Stripe.Event;
  try {
    event = stripe.webhooks.constructEvent(req.body, sig, webhookSecret);
  } catch (err) {
    console.error("Webhook signature verification failed");
    return res.status(400).send("Invalid signature");
  }

  switch (event.type) {
    case "payment_intent.succeeded": {
      const paymentIntent = event.data.object as Stripe.PaymentIntent;
      await handlePaymentSuccess(paymentIntent);
      break;
    }

    case "payment_intent.payment_failed": {
      const paymentIntent = event.data.object as Stripe.PaymentIntent;
      await handlePaymentFailure(paymentIntent);
      break;
    }
  }

  res.json({ received: true });
});

async function handlePaymentSuccess(paymentIntent: Stripe.PaymentIntent) {
  const { admissionToken, consumerId, distributionId, distributionType, items } = paymentIntent.metadata;

  // 1. Create order in your system
  const order = await createOrder({
    consumerId,
    items: JSON.parse(items),
    paymentIntentId: paymentIntent.id,
    amount: paymentIntent.amount / 100,
    currency: paymentIntent.currency,
  });

  // 2. Complete admission with Fanfare
  await completeAdmission({
    distributionId,
    distributionType: distributionType as "queue" | "draw" | "auction",
    consumerId,
  });

  // 3. Send confirmation email
  await sendOrderConfirmation(order);

  console.log(`Order ${order.id} created for payment ${paymentIntent.id}`);
}

async function handlePaymentFailure(paymentIntent: Stripe.PaymentIntent) {
  console.log(`Payment failed: ${paymentIntent.id}`);
  // Don't invalidate admission - allow retry
}

PayPal Integration

Step 1: Create PayPal Order

// routes/paypal.ts
import { PayPalHttpClient, OrdersApi, Order } from "@paypal/paypal-server-sdk";

const paypalClient = new PayPalHttpClient({
  clientId: process.env.PAYPAL_CLIENT_ID!,
  clientSecret: process.env.PAYPAL_CLIENT_SECRET!,
  environment: process.env.NODE_ENV === "production" ? "production" : "sandbox",
});

const ordersApi = new OrdersApi(paypalClient);

router.post("/create-paypal-order", async (req, res) => {
  const { admissionToken, consumerId, distributionId, distributionType, amount, currency, items } = req.body;

  // 1. Validate admission
  const isValid = await validateAdmission({
    token: admissionToken,
    consumerId,
    distributionId,
    distributionType,
  });

  if (!isValid) {
    return res.status(403).json({
      error: "ADMISSION_INVALID",
      message: "Your access has expired.",
    });
  }

  // 2. Create PayPal order
  const order = await ordersApi.ordersCreate({
    body: {
      intent: "CAPTURE",
      purchaseUnits: [
        {
          amount: {
            currencyCode: currency.toUpperCase(),
            value: amount.toFixed(2),
          },
          customId: JSON.stringify({
            admissionToken,
            consumerId,
            distributionId,
            distributionType,
          }),
        },
      ],
    },
  });

  res.json({ orderId: order.result.id });
});

router.post("/capture-paypal-order", async (req, res) => {
  const { orderId } = req.body;

  const capture = await ordersApi.ordersCapture({
    id: orderId,
  });

  if (capture.result.status === "COMPLETED") {
    const customData = JSON.parse(capture.result.purchaseUnits![0].customId!);

    // Create order and complete admission
    await handlePayPalSuccess(capture.result, customData);

    res.json({ success: true });
  } else {
    res.status(400).json({ error: "Capture failed" });
  }
});

Step 2: PayPal Frontend

import { PayPalScriptProvider, PayPalButtons } from "@paypal/react-paypal-js";

export function PayPalCheckout(props: CheckoutFormProps) {
  const createOrder = async () => {
    const res = await fetch("/api/payments/create-paypal-order", {
      method: "POST",
      headers: { "Content-Type": "application/json" },
      body: JSON.stringify({
        admissionToken: props.admissionToken,
        consumerId: props.consumerId,
        distributionId: props.distributionId,
        distributionType: props.distributionType,
        amount: props.totalAmount,
        currency: "USD",
        items: props.cart,
      }),
    });

    const { orderId } = await res.json();
    return orderId;
  };

  const onApprove = async (data: { orderID: string }) => {
    const res = await fetch("/api/payments/capture-paypal-order", {
      method: "POST",
      headers: { "Content-Type": "application/json" },
      body: JSON.stringify({ orderId: data.orderID }),
    });

    if (res.ok) {
      window.location.href = "/checkout/success";
    }
  };

  return (
    <PayPalScriptProvider options={{ clientId: process.env.NEXT_PUBLIC_PAYPAL_CLIENT_ID! }}>
      <PayPalButtons
        createOrder={createOrder}
        onApprove={onApprove}
        onError={(err) => console.error("PayPal error:", err)}
      />
    </PayPalScriptProvider>
  );
}

Error Handling

Payment Failures

function PaymentErrorHandler({ error, onRetry }: { error: PaymentError; onRetry: () => void }) {
  const getErrorMessage = (error: PaymentError) => {
    switch (error.code) {
      case "card_declined":
        return "Your card was declined. Please try a different payment method.";
      case "insufficient_funds":
        return "Insufficient funds. Please try a different card.";
      case "expired_card":
        return "Your card has expired. Please use a different card.";
      case "ADMISSION_INVALID":
        return "Your access has expired. Please return to the experience.";
      default:
        return "Payment failed. Please try again.";
    }
  };

  const canRetry = error.code !== "ADMISSION_INVALID";

  return (
    <div className="payment-error">
      <h3>Payment Failed</h3>
      <p>{getErrorMessage(error)}</p>

      {canRetry ? <button onClick={onRetry}>Try Again</button> : <a href="/experience">Return to Experience</a>}
    </div>
  );
}

Idempotency

// Ensure payments aren't processed twice
async function createPaymentWithIdempotency(params: CreatePaymentParams, idempotencyKey: string) {
  return await stripe.paymentIntents.create(
    {
      amount: params.amount,
      currency: params.currency,
      metadata: params.metadata,
    },
    {
      idempotencyKey,
    }
  );
}

// Generate idempotency key from admission context
function generateIdempotencyKey(admissionToken: string, consumerId: string) {
  return `${admissionToken}-${consumerId}-${Date.now()}`;
}

Best Practices

1. Always Validate Before Payment

// Never create payment intent without validation
async function createPayment(params: CreatePaymentParams) {
  // ALWAYS validate first
  const isValid = await validateAdmission(params);
  if (!isValid) {
    throw new AdmissionError("Invalid admission");
  }

  // Then proceed with payment
  return stripe.paymentIntents.create(/* ... */);
}

2. Don’t Invalidate on Payment Failure

// On payment failure
async function handlePaymentFailure(paymentIntent: Stripe.PaymentIntent) {
  // Log the failure
  console.log("Payment failed:", paymentIntent.id);

  // DON'T complete/invalidate admission
  // User should be able to retry with same admission
}

3. Use Webhooks for Reliability

// Don't rely solely on client-side confirmation
// Always use webhooks for order creation
router.post("/webhook", async (req, res) => {
  // Webhook ensures order is created even if user closes browser
});

4. Monitor Payment + Admission Metrics

analytics.track("payment_attempted", {
  admissionToken,
  amount,
  distributionType,
});

analytics.track("payment_completed", {
  admissionToken,
  orderId,
  amount,
});

Troubleshooting

Payment Created But Admission Invalid

The admission may have expired between page load and payment submission:
  • Validate admission at payment creation time
  • Show clear expiration countdown to users
  • Handle gracefully with clear error messages

Double Charges

  • Use idempotency keys
  • Check for existing payments before creating new ones
  • Use webhooks as source of truth

What’s Next