UNPKG

skills-ws

Version:

87 agent skills for AI coding assistants — marketing, growth, web3, dev, design & operations. Built for OpenClaw, Claude Code, Cursor, and Codex.

222 lines (185 loc) • 8.65 kB
## Contents - 2. Subscription Lifecycle - Create subscription - Upgrade / Downgrade - Cancel (at period end) - Reactivate ## 2. Subscription Lifecycle ### Create subscription ```typescript // app/api/subscribe/route.ts import { NextRequest, NextResponse } from 'next/server'; import { stripe } from '@/lib/stripe'; import { auth } from '@/lib/auth'; import { db } from '@/lib/db'; export async function POST(req: NextRequest) { const session = await auth(); if (!session?.user) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }); // requestId is sent by the client and held stable across retries (e.g. crypto.randomUUID() // generated once when the form mounts). It is the basis for idempotency keys below. const { priceId, paymentMethodId, requestId } = await req.json(); if (!priceId || !paymentMethodId || !requestId) { return NextResponse.json({ error: 'Missing priceId, paymentMethodId, or requestId' }, { status: 400 }); } try { let user = await db.user.findUniqueOrThrow({ where: { id: session.user.id }, select: { stripeCustomerId: true, email: true }, }); let customerId = user.stripeCustomerId; if (!customerId) { const customer = await stripe.customers.create({ email: user.email!, metadata: { userId: session.user.id }, payment_method: paymentMethodId, invoice_settings: { default_payment_method: paymentMethodId }, }, { idempotencyKey: `cust-create-${session.user.id}` }); customerId = customer.id; await db.user.update({ where: { id: session.user.id }, data: { stripeCustomerId: customerId } }); } else { await stripe.paymentMethods.attach(paymentMethodId, { customer: customerId }); await stripe.customers.update(customerId, { invoice_settings: { default_payment_method: paymentMethodId }, }); } // Prevent duplicate subscriptions. "active" alone is not enough — trialing/past_due/ // incomplete/paused are all live states that would conflict. Treat anything not // fully ended as occupying the slot. (incomplete_expired and canceled are terminal.) const existing = await stripe.subscriptions.list({ customer: customerId, status: 'all', limit: 100 }); const LIVE = new Set(['active', 'trialing', 'past_due', 'incomplete', 'paused', 'unpaid']); if (existing.data.some((s) => LIVE.has(s.status))) { return NextResponse.json({ error: 'Active subscription exists. Use the change-plan endpoint.' }, { status: 409 }); } const subscription = await stripe.subscriptions.create({ customer: customerId, items: [{ price: priceId }], payment_behavior: 'default_incomplete', billing_mode: { type: 'flexible' }, // enables confirmation_secret (Basil-line) payment_settings: { save_default_payment_method: 'on_subscription' }, expand: ['latest_invoice.confirmation_secret'], // NOT latest_invoice.payment_intent on this API metadata: { userId: session.user.id }, automatic_tax: { enabled: true }, }, { // Stable across retries: a network timeout that retries this POST won't create a 2nd sub. idempotencyKey: `sub-create-${session.user.id}-${priceId}-${requestId}`, }); const invoice = subscription.latest_invoice as Stripe.Invoice | null; // $0 invoices, fully-trial subs, and PM-on-file flows can settle with no client_secret. // Don't assume one exists — let the client skip confirmation when it's null. const clientSecret = invoice?.confirmation_secret?.client_secret ?? null; return NextResponse.json({ subscriptionId: subscription.id, clientSecret, // null ⇒ nothing to confirm client-side status: subscription.status, // 'active' | 'trialing' | 'incomplete' | ... }); } catch (err) { if (err instanceof Stripe.errors.StripeCardError) { return NextResponse.json({ error: err.message }, { status: 402 }); } console.error('Subscription creation failed:', err); return NextResponse.json({ error: 'Internal error' }, { status: 500 }); } } ``` ### Upgrade / Downgrade ```typescript // app/api/subscription/change-plan/route.ts export async function POST(req: NextRequest) { const session = await auth(); if (!session?.user) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }); const { newPriceId, requestId } = await req.json(); const user = await db.user.findUniqueOrThrow({ where: { id: session.user.id }, select: { stripeSubscriptionId: true }, }); if (!user.stripeSubscriptionId) { return NextResponse.json({ error: 'No active subscription' }, { status: 400 }); } const subscription = await stripe.subscriptions.retrieve(user.stripeSubscriptionId, { expand: ['items.data.price'], }); // Don't try to classify upgrade vs downgrade from unit_amount: that breaks for tiered/ // metered prices, unit_amount_decimal, different intervals, coupons, and multi-item subs. // Instead, map your priceIds to a plan rank you control, and always settle the proration // immediately. If the proration nets to a charge, the client may need to confirm it. const PLAN_RANK: Record<string, number> = { [process.env.STRIPE_PRICE_PRO!]: 1, [process.env.STRIPE_PRICE_TEAM!]: 2, }; const currentItem = subscription.items.data[0]; const currentPrice = currentItem.price as Stripe.Price; const isUpgrade = (PLAN_RANK[newPriceId] ?? 0) > (PLAN_RANK[currentPrice.id] ?? 0); const updated = await stripe.subscriptions.update(subscription.id, { items: [{ id: currentItem.id, price: newPriceId }], proration_behavior: 'always_invoice', // Upgrades may owe money now (SCA) → default_incomplete so we can confirm. // Downgrades/credits → pending_if_incomplete keeps the sub active and applies the change. payment_behavior: isUpgrade ? 'default_incomplete' : 'pending_if_incomplete', expand: ['latest_invoice.confirmation_secret'], metadata: { previousPriceId: currentPrice.id, changeType: isUpgrade ? 'upgrade' : 'downgrade' }, }, { idempotencyKey: `sub-change-${subscription.id}-${newPriceId}-${requestId}`, }); const invoice = updated.latest_invoice as Stripe.Invoice | null; return NextResponse.json({ subscription: updated.id, status: updated.status, // Present only when the change created an open invoice needing confirmation. clientSecret: invoice?.confirmation_secret?.client_secret ?? null, }); } ``` ### Cancel (at period end) ```typescript // app/api/subscription/cancel/route.ts export async function POST(req: NextRequest) { const session = await auth(); if (!session?.user) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }); const { reason, feedback } = await req.json(); const user = await db.user.findUniqueOrThrow({ where: { id: session.user.id }, select: { stripeSubscriptionId: true }, }); // Cancel at period end — user keeps access until billing cycle ends const subscription = await stripe.subscriptions.update(user.stripeSubscriptionId!, { cancel_at_period_end: true, metadata: { cancelReason: reason, cancelFeedback: feedback, canceledAt: new Date().toISOString() }, }); // Basil-line: period end is on the subscription item, not the subscription. const periodEnd = subscription.items.data[0].current_period_end; await db.cancellation.create({ data: { userId: session.user.id, reason, feedback, effectiveDate: new Date(periodEnd * 1000), }, }); return NextResponse.json({ cancelAt: new Date(periodEnd * 1000).toISOString(), }); } ``` ### Reactivate ```typescript export async function POST(req: NextRequest) { const session = await auth(); if (!session?.user) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }); const user = await db.user.findUniqueOrThrow({ where: { id: session.user.id }, select: { stripeSubscriptionId: true }, }); const sub = await stripe.subscriptions.retrieve(user.stripeSubscriptionId!); if (sub.cancel_at_period_end) { // Still in billing period — just undo cancellation const reactivated = await stripe.subscriptions.update(sub.id, { cancel_at_period_end: false, metadata: { reactivatedAt: new Date().toISOString() }, }); return NextResponse.json({ status: reactivated.status }); } if (sub.status === 'canceled') { return NextResponse.json({ error: 'Subscription expired. Create a new one.' }, { status: 410 }); } return NextResponse.json({ error: 'Cannot reactivate' }, { status: 400 }); } ``` ---