Skip to main content

Stripe Integration

Garmint uses Stripe for subscription management, token purchases, and production order payments.

Overview

Subscriptions

Monthly plans with token allocation

Token Packs

One-time purchase for extra tokens

Production

Pay per order for physical prints

Configuration

Environment Variables

# API Keys
STRIPE_SECRET_KEY=sk_live_xxxxxxxxxxxxxxxxxxxxx
NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY=pk_live_xxxxxxxxxxxxxxxxxxxxx

# Webhook
STRIPE_WEBHOOK_SECRET=whsec_xxxxxxxxxxxxxxxxxxxxx

# Product/Price IDs
STRIPE_PRICE_ID_PRO=price_xxxxxxxxxxxxxxxxxxxxx
STRIPE_PRICE_ID_STUDIO=price_xxxxxxxxxxxxxxxxxxxxx
STRIPE_PRICE_ID_TOKENS_50=price_xxxxxxxxxxxxxxxxxxxxx
STRIPE_PRICE_ID_TOKENS_200=price_xxxxxxxxxxxxxxxxxxxxx

Stripe Dashboard Setup

  1. Create Products
    • Pro Plan ($29/month)
    • Studio Plan ($99/month)
    • Token Pack 50 ($9.99)
    • Token Pack 200 ($29.99)
  2. Create Prices
    • Subscription prices for plans (recurring)
    • One-time prices for token packs
  3. Configure Webhook
    • Endpoint: https://yourapp.com/api/stripe/webhook
    • Events: checkout.session.completed, invoice.paid, customer.subscription.*

Stripe Client

// lib/stripe.ts
import Stripe from 'stripe';

export const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!, {
  apiVersion: '2023-10-16',
  typescript: true,
});

Checkout Flow

Subscription Checkout

// Create subscription checkout
const session = await stripe.checkout.sessions.create({
  mode: 'subscription',
  customer_email: user.email,
  line_items: [
    {
      price: process.env.STRIPE_PRICE_ID_PRO,
      quantity: 1,
    },
  ],
  metadata: {
    userId: user.id,
    projectId: project.id,
  },
  success_url: `${origin}/sub-success?session_id={CHECKOUT_SESSION_ID}`,
  cancel_url: `${origin}/pricing`,
  subscription_data: {
    metadata: {
      userId: user.id,
    },
  },
});

Token Pack Checkout

// Create one-time checkout for token pack
const session = await stripe.checkout.sessions.create({
  mode: 'payment',
  customer_email: user.email,
  line_items: [
    {
      price: process.env.STRIPE_PRICE_ID_TOKENS_50,
      quantity: 1,
    },
  ],
  metadata: {
    userId: user.id,
    tokens: '50',
    type: 'token_pack',
  },
  success_url: `${origin}/projects?tokens=purchased`,
  cancel_url: `${origin}/pricing`,
});

Production Order Checkout

// Create checkout for production order
const session = await stripe.checkout.sessions.create({
  mode: 'payment',
  line_items: lineItems.map(item => ({
    price_data: {
      currency: 'usd',
      product_data: {
        name: `${garment.title} - ${item.size}`,
        images: [designUrl],
        metadata: {
          garmentId: garment.id,
          size: item.size,
        },
      },
      unit_amount: Math.round(garment.price * 100), // cents
    },
    quantity: item.quantity,
  })),
  metadata: {
    userId: user.id,
    orderId: order.id,
    type: 'production_order',
  },
  success_url: `${origin}/order-success?order=${order.id}`,
  cancel_url: `${origin}/orders/${order.id}`,
});

Webhook Handler

// app/api/stripe/webhook/route.ts
import { stripe } from '@/lib/stripe';
import { headers } from 'next/headers';

export async function POST(req: Request) {
  const body = await req.text();
  const signature = headers().get('stripe-signature')!;

  let event: Stripe.Event;

  try {
    event = stripe.webhooks.constructEvent(
      body,
      signature,
      process.env.STRIPE_WEBHOOK_SECRET!
    );
  } catch (err) {
    console.error('Webhook signature verification failed');
    return Response.json({ error: 'Invalid signature' }, { status: 400 });
  }

  switch (event.type) {
    case 'checkout.session.completed':
      await handleCheckoutComplete(event.data.object);
      break;
      
    case 'invoice.paid':
      await handleInvoicePaid(event.data.object);
      break;
      
    case 'customer.subscription.updated':
      await handleSubscriptionUpdate(event.data.object);
      break;
      
    case 'customer.subscription.deleted':
      await handleSubscriptionDeleted(event.data.object);
      break;
  }

  return Response.json({ received: true });
}

Handle Checkout Complete

async function handleCheckoutComplete(session: Stripe.Checkout.Session) {
  const { userId, type, tokens, orderId } = session.metadata ?? {};

  if (type === 'token_pack' && tokens) {
    // Grant tokens from pack purchase
    await supabase
      .from('token_grants')
      .insert({
        user_id: userId,
        source: 'payg_purchase',
        tokens: parseInt(tokens),
        metadata: {
          stripe_session: session.id,
          amount: session.amount_total,
        },
      });
  } else if (type === 'production_order' && orderId) {
    // Update production order status
    await supabase
      .from('production_orders')
      .update({ status: 'submitted' })
      .eq('id', orderId);
      
    await supabase
      .from('fulfillment_events')
      .insert({
        order_id: orderId,
        status: 'Queued',
        message: 'Payment confirmed',
      });
  } else if (session.mode === 'subscription') {
    // Handle subscription creation
    await createSubscriptionRecord(session);
  }
}

Handle Invoice Paid

async function handleInvoicePaid(invoice: Stripe.Invoice) {
  if (!invoice.subscription) return;

  const { data: subscription } = await supabase
    .from('subscriptions')
    .select('*, plan:billing_plans(*)')
    .eq('external_reference', invoice.subscription)
    .single();

  if (!subscription) return;

  // Grant monthly tokens
  await supabase
    .from('token_grants')
    .insert({
      user_id: subscription.user_id,
      subscription_id: subscription.id,
      source: 'plan',
      tokens: subscription.plan.monthly_tokens,
      expires_at: subscription.plan.rollover 
        ? null 
        : new Date(invoice.period_end * 1000).toISOString(),
    });

  // Update subscription period
  await supabase
    .from('subscriptions')
    .update({
      current_period_start: new Date(invoice.period_start * 1000).toISOString(),
      current_period_end: new Date(invoice.period_end * 1000).toISOString(),
    })
    .eq('id', subscription.id);
}

Customer Portal

Allow customers to manage their subscription:
// Create portal session
const session = await stripe.billingPortal.sessions.create({
  customer: customerId,
  return_url: `${origin}/projects`,
});

// Redirect to portal
return Response.json({ url: session.url });
Portal features:
  • Update payment method
  • View invoices
  • Change plan
  • Cancel subscription

Testing

Test Mode

Use test API keys for development:
STRIPE_SECRET_KEY=sk_test_xxxxxxxxxxxxxxxxxxxxx
NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY=pk_test_xxxxxxxxxxxxxxxxxxxxx

Test Cards

NumberDescription
4242 4242 4242 4242Success
4000 0000 0000 0002Decline
4000 0025 0000 31553D Secure required
4000 0000 0000 9995Insufficient funds

Local Webhook Testing

# Install Stripe CLI
brew install stripe/stripe-cli/stripe

# Login
stripe login

# Forward webhooks to local server
stripe listen --forward-to localhost:3000/api/stripe/webhook

# Copy the webhook signing secret to .env.local

Trigger Test Events

# Trigger checkout completed
stripe trigger checkout.session.completed

# Trigger invoice paid
stripe trigger invoice.paid

# Trigger subscription deleted
stripe trigger customer.subscription.deleted

Database Schema

subscriptions table

create table public.subscriptions (
  id uuid primary key,
  user_id uuid references profiles(id),
  plan_id uuid references billing_plans(id),
  status text, -- 'trialing' | 'active' | 'past_due' | 'canceled'
  current_period_start timestamptz,
  current_period_end timestamptz,
  cancel_at_period_end boolean,
  external_reference text, -- Stripe subscription ID
  created_at timestamptz,
  updated_at timestamptz
);

Error Handling

try {
  const session = await stripe.checkout.sessions.create(params);
  return Response.json({ url: session.url });
} catch (error) {
  if (error instanceof Stripe.errors.StripeError) {
    console.error('Stripe error:', error.message);
    return Response.json(
      { error: error.message },
      { status: error.statusCode ?? 500 }
    );
  }
  throw error;
}

Best Practices

Always verify webhook signatures before processing events.
Never expose STRIPE_SECRET_KEY to the client. Use only in server-side code.
Store Stripe customer IDs in your database to avoid creating duplicate customers.