CoreMVP
Features

Payments

Stripe integration for lifetime purchases and subscriptions.

Overview

Payments are handled by Stripe with support for:

  • Lifetime purchases - One-time payment, permanent access
  • Subscriptions - Monthly/yearly recurring billing
  • Customer portal - Self-service subscription management

Architecture

service.ts
queries.ts
types.ts
checkout/route.ts
portal/route.ts

Payment Flow

1. User clicks "Buy" on pricing page
2. Frontend calls /api/billing/checkout
3. API route calls checkoutWithStripe service
4. Service creates Stripe checkout session
5. User completes payment on Stripe
6. Stripe sends webhook to /api/webhooks/stripe
7. Webhook updates user's subscription status

Creating Checkout Sessions

The billing service creates checkout sessions for both one-time and recurring payments:

src/services/billing/service.ts
import "server-only";

import { stripe } from "@/utils/stripe/config";
import { createSupabaseServerClient } from "@/lib/supabase/server";

export async function checkoutWithStripe(
  priceId: string,
  redirectPath: string = "/account",
): Promise<CheckoutResponse> {
  const supabase = await createSupabaseServerClient();
  const { data: { user } } = await supabase.auth.getUser();

  if (!user) {
    throw new Error("Could not get user session.");
  }

  // Get price from database
  const priceData = await getPriceById(supabase, priceId);
  
  // Create or retrieve Stripe customer
  const customer = await createOrRetrieveCustomer({
    uuid: user.id,
    email: user.email ?? "",
  });

  // Build checkout params
  let params: Stripe.Checkout.SessionCreateParams = {
    customer,
    line_items: [{ price: priceId, quantity: 1 }],
    success_url: getURL(redirectPath),
    cancel_url: getURL(),
  };

  // Set mode based on price type
  if (priceData.type === "recurring") {
    params.mode = "subscription";
  } else {
    params.mode = "payment";
  }

  const session = await stripe.checkout.sessions.create(params);
  return { sessionId: session.id, checkoutUrl: session.url };
}

API Routes

The checkout API route:

src/app/api/billing/checkout/route.ts
import { NextResponse } from 'next/server';
import { checkoutWithStripe } from '@/services/billing';

export async function POST(request: Request) {
  const body = await request.json();
  const result = await checkoutWithStripe(body.priceId, body.redirectPath);
  
  return NextResponse.json(result, {
    status: result.errorRedirect ? 400 : 200,
  });
}

Webhook Handling

Webhooks update your database when Stripe events occur:

src/app/api/webhooks/stripe/route.ts
import Stripe from 'stripe';
import { stripe } from '@/utils/stripe/config';
import {
  manageSubscriptionStatusChange,
  upsertProductRecord,
  upsertPriceRecord,
} from '@/services/billing/service';

const relevantEvents = new Set([
  'product.created',
  'product.updated',
  'price.created',
  'price.updated',
  'checkout.session.completed',
  'customer.subscription.created',
  'customer.subscription.updated',
  'customer.subscription.deleted',
]);

export async function POST(req: Request) {
  const body = await req.text();
  const sig = req.headers.get('stripe-signature') as string;
  
  const event = stripe.webhooks.constructEvent(
    body,
    sig,
    process.env.STRIPE_WEBHOOK_SECRET!
  );

  if (relevantEvents.has(event.type)) {
    switch (event.type) {
      case 'product.created':
      case 'product.updated':
        await upsertProductRecord(event.data.object as Stripe.Product);
        break;
      case 'price.created':
      case 'price.updated':
        await upsertPriceRecord(event.data.object as Stripe.Price);
        break;
      case 'customer.subscription.created':
      case 'customer.subscription.updated':
      case 'customer.subscription.deleted':
        const subscription = event.data.object as Stripe.Subscription;
        await manageSubscriptionStatusChange(
          subscription.id,
          subscription.customer as string,
        );
        break;
    }
  }

  return new Response(JSON.stringify({ received: true }));
}

Customer Portal

Let users manage their subscriptions:

src/services/billing/service.ts
export async function createStripePortal(
  currentPath: string,
): Promise<StripePortalResponse> {
  const supabase = await createSupabaseServerClient();
  const { data: { user } } = await supabase.auth.getUser();

  const customer = await createOrRetrieveCustomer({
    uuid: user.id,
    email: user.email ?? "",
  });

  const { url } = await stripe.billingPortal.sessions.create({
    customer,
    return_url: getURL(currentPath || "/dashboard"),
  });

  return { url };
}

Client-Side Usage

Use the checkout hook:

src/hooks/billing/use-checkout.ts
export function useCheckout() {
  return useMutation({
    mutationFn: async ({ priceId }: { priceId: string }) => {
      const res = await fetch('/api/billing/checkout', {
        method: 'POST',
        body: JSON.stringify({ priceId }),
      });
      return res.json();
    },
    onSuccess: (data) => {
      if (data.checkoutUrl) {
        window.location.href = data.checkoutUrl;
      }
    },
  });
}

In components:

const { mutate: checkout, isPending } = useCheckout();

<Button onClick={() => checkout({ priceId: 'price_xxx' })}>
  {isPending ? 'Loading...' : 'Subscribe'}
</Button>

Stripe Setup

Create Products

In Stripe Dashboard, create products with prices:

  • Lifetime: One-time price
  • Monthly: Recurring monthly
  • Yearly: Recurring yearly

Configure Webhook

Add webhook endpoint in Stripe Dashboard:

  • URL: https://your-domain.com/api/webhooks/stripe
  • Events: product.*, price.*, checkout.session.completed, customer.subscription.*

Add Keys

For local billing tests, add Stripe test-mode keys to .env.local. The listener writes the active test-mode webhook secret when it starts:

STRIPE_SECRET_KEY=sk_test_...
STRIPE_WEBHOOK_SECRET=whsec_...
NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY=pk_test_...

Use live-mode keys only in the hosted production environment after the complete test-mode path passes. Do not mix test products or cards with live keys.

Testing

Use Stripe's test cards:

CardResult
4242 4242 4242 4242Success
4000 0000 0000 0002Declined
4000 0025 0000 3155Requires 3DS

Forward webhooks locally:

./coremvp stripe:listen

After the listener syncs STRIPE_WEBHOOK_SECRET, keep it running and restart bun run dev so the app loads the active signing secret.

From another terminal, create the test-mode catalogs you want to exercise:

stripe login
stripe fixtures supabase/stripe/stripe-fixtures-lifetime.json
stripe fixtures supabase/stripe/stripe-fixtures-subscription.json
stripe products list --limit 5
stripe prices list --limit 10

Copy the resulting product and price IDs into src/config/pricing.config.ts. The configured IDs must belong to the same Stripe test account as STRIPE_SECRET_KEY; the example IDs are not portable between Stripe accounts.

Then run the template-local browser proof:

./coremvp e2e billing:lifetime
./coremvp e2e billing:subscription

Was this page helpful?

On this page