Stripe Subscriptions
Plans, upgrades, downgrades, and webhook handling
ShipAddons implements a complete subscription system with Stripe, supporting multiple plans, upgrades, downgrades, and webhook-driven state management.
Subscription Architecture
┌─────────────┐ checkout ┌─────────────┐
│ Add-on │───────────────>│ Backend │
│ Client │ │ (/api) │
└─────────────┘ └──────┬──────┘
│
┌──────▼──────┐
│ Stripe │
│ Checkout │
└──────┬──────┘
│
┌──────▼──────┐
│ Webhook │───> Supabase
│ Handler │
└─────────────┘State changes flow through Stripe webhooks—the add-on never directly updates subscription state.
Plans Configuration
Plans are defined in constants.ts:
export interface Plan {
id: "BASIC" | "ESSENTIAL" | "PRO";
name: string;
description: string;
imageUrls: string[];
priceInCents: number;
mode: "payment" | "subscription";
features: string[];
stripePriceId?: string; // Populated by web/scripts/stripe-sync.ts
}
export const plans: Record<Plan["id"], Plan> = {
BASIC: {
id: "BASIC",
name: "Basic",
description: "Basic plan",
imageUrls: ["https://dummyimage.com/96x96/9/fff.png&text=Logo"],
priceInCents: 0,
mode: "subscription",
features: ["basic_feature"],
},
ESSENTIAL: {
id: "ESSENTIAL",
name: "Essential",
description: "Essential plan",
imageUrls: ["https://dummyimage.com/96x96/9/fff.png&text=Logo"],
priceInCents: 1000,
mode: "subscription",
features: ["essential_feature"],
stripePriceId: "price_1SedRfRgqD2Y1UgSc0mp1Vcb",
},
PRO: {
id: "PRO",
name: "Pro",
description: "Pro plan",
imageUrls: ["https://dummyimage.com/96x96/9/fff.png&text=Logo"],
priceInCents: 2000,
mode: "subscription",
features: ["pro_feature"],
stripePriceId: "price_1SedRgRgqD2Y1UgSfiv60YIY",
},
};Checkout Flow
1. Client Initiates Checkout
// In add-on client
const { purchase } = usePurchase();
const handleUpgrade = async (packageId: string) => {
const checkoutUrl = await purchase(packageId);
window.open(checkoutUrl, "_blank");
};2. Backend Creates Checkout Session
// POST /api/stripe/checkout
export async function POST(request: NextRequest) {
// Verify JWT and get user
const user = await validateRequest(request);
// Get package details
const { packageId } = await request.json();
const pkg = packages[packageId];
// Check for existing customer
const { data: subscription } = await supabaseAdmin
.from("subscriptions")
.select("stripe_customer_id")
.eq("user_id", user.id)
.single();
// Create checkout session
const session = await stripe.checkout.sessions.create({
mode: "subscription",
line_items: [
{
price: pkg.stripePriceId,
quantity: 1,
},
],
customer: subscription?.stripe_customer_id,
customer_email: !subscription ? user.email : undefined,
client_reference_id: user.id,
success_url: `${process.env.NEXT_PUBLIC_URL}/pricing?success=true`,
cancel_url: `${process.env.NEXT_PUBLIC_URL}/pricing?canceled=true`,
metadata: {
user_id: user.id,
package_id: packageId,
},
});
return Response.json({ data: session.url });
}3. User Completes Payment
Stripe handles the payment flow on their hosted checkout page. After completion, the user is redirected to success_url.
4. Webhook Updates Database
Stripe sends a checkout.session.completed event to your webhook endpoint.
Webhook Events
checkout.session.completed
Fired when a customer completes checkout.
case "checkout.session.completed": {
const session = event.data.object as Stripe.Checkout.Session;
const userId = session.client_reference_id;
const customerId = session.customer as string;
const subscriptionId = session.subscription as string;
const packageId = session.metadata?.package_id;
// Get subscription details
const subscription = await stripe.subscriptions.retrieve(subscriptionId);
// Upsert subscription record
await supabaseAdmin.from("subscriptions").upsert({
user_id: userId,
stripe_customer_id: customerId,
stripe_subscription_id: subscriptionId,
status: subscription.status,
plan_id: packageId,
current_period_start: new Date(subscription.current_period_start * 1000),
current_period_end: new Date(subscription.current_period_end * 1000),
}, {
onConflict: "user_id",
});
// Log the event
await supabaseAdmin.from("audit_logs").insert({
user_id: userId,
action: "SUBSCRIPTION_CREATED",
context: JSON.stringify({ plan_id: packageId }),
});
break;
}customer.subscription.updated
Fired when a subscription changes (plan change, renewal, status change).
case "customer.subscription.updated": {
const subscription = event.data.object as Stripe.Subscription;
// Determine new plan from price ID
const priceId = subscription.items.data[0]?.price.id;
const newPlanId = Object.entries(packages)
.find(([, pkg]) => pkg.stripePriceId === priceId)?.[0];
// Update subscription record
await supabaseAdmin
.from("subscriptions")
.update({
status: subscription.status,
plan_id: newPlanId,
current_period_start: new Date(subscription.current_period_start * 1000),
current_period_end: new Date(subscription.current_period_end * 1000),
})
.eq("stripe_subscription_id", subscription.id);
// Log status changes
await supabaseAdmin.from("audit_logs").insert({
user_id: userId,
action: "SUBSCRIPTION_UPDATED",
context: JSON.stringify({
status: subscription.status,
plan_id: newPlanId,
}),
});
break;
}customer.subscription.deleted
Fired when a subscription is canceled (immediately or at period end).
case "customer.subscription.deleted": {
const subscription = event.data.object as Stripe.Subscription;
await supabaseAdmin
.from("subscriptions")
.update({ status: "canceled" })
.eq("stripe_subscription_id", subscription.id);
break;
}Billing Portal
Allow customers to manage their subscription through Stripe's hosted billing portal.
Backend Endpoint
// POST /api/stripe/billing_portal
export async function POST(request: NextRequest) {
const user = await validateRequest(request);
// Get customer ID
const { data: subscription } = await supabaseAdmin
.from("subscriptions")
.select("stripe_customer_id")
.eq("user_id", user.id)
.single();
if (!subscription?.stripe_customer_id) {
return Response.json({ error: "No subscription found" }, { status: 404 });
}
// Create portal session
const session = await stripe.billingPortal.sessions.create({
customer: subscription.stripe_customer_id,
return_url: `${process.env.NEXT_PUBLIC_URL}/pricing`,
configuration: process.env.STRIPE_PORTAL_CONFIG_ID,
});
return Response.json({ data: session.url });
}Client Usage
const { openBillingPortal } = usePurchase();
const handleManageSubscription = async () => {
await openBillingPortal();
// Opens Stripe billing portal in new tab
};Upgrades and Downgrades
Stripe handles proration automatically when customers switch plans via the billing portal.
- Upgrades: Customer is charged the prorated difference immediately
- Downgrades: Credit is applied to the next billing cycle
No additional code is needed—the customer.subscription.updated webhook handles plan changes.
Checking Subscription Status
In the Add-on Client
const { user, subscription } = useAuth();
// Check if user has active subscription
const hasActiveSubscription = subscription?.status === "active";
// Check specific plan
const isPro = subscription?.plan_id === "PRO";
// Check if subscription exists
const hasSubscription = !!subscription;In the Backend
// Get subscription for user
const { data: subscription } = await supabaseAdmin
.from("subscriptions")
.select("*")
.eq("user_id", userId)
.single();
// Check access
if (subscription?.status !== "active") {
return Response.json({ error: "Subscription required" }, { status: 403 });
}
// Check plan level
const allowedPlans = ["ESSENTIAL", "PRO"];
if (!allowedPlans.includes(subscription.plan_id)) {
return Response.json({ error: "Upgrade required" }, { status: 403 });
}Testing Subscriptions
Use Stripe test mode and test cards:
| Card Number | Scenario |
|---|---|
4242 4242 4242 4242 | Successful payment |
4000 0000 0000 0002 | Card declined |
4000 0000 0000 3220 | 3D Secure required |
Trigger test webhooks via CLI:
stripe trigger checkout.session.completed
stripe trigger customer.subscription.updated
stripe trigger customer.subscription.deleted