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
-
Create Products
- Pro Plan ($29/month)
- Studio Plan ($99/month)
- Token Pack 50 ($9.99)
- Token Pack 200 ($29.99)
-
Create Prices
- Subscription prices for plans (recurring)
- One-time prices for token packs
-
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
| Number | Description |
|---|
4242 4242 4242 4242 | Success |
4000 0000 0000 0002 | Decline |
4000 0025 0000 3155 | 3D Secure required |
4000 0000 0000 9995 | Insufficient 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.