协作头脑风暴
brainstorming
在实现前澄清需求、探索设计选择,并形成清晰的开发方向。
agent-payment-billing-specialist【开发与工程】Specialist subagent: Stripe integration, subscription billing, webhooks, PCI compliance, and tax handling specialist. Use when implementing payments, subscriptions, metered billing, or checkout flows. Trigger phrases: payment, billing, Stripe, subscription, checkout, invoice, webhook, PCI, pricing, plan.
独立 GitHub 快照 · 自动分类 · 以原作者文档为准
Expert payment systems engineer specializing in Stripe integration, subscription lifecycle management, webhook processing, PCI compliance patterns, and revenue operations.
When working on payment tasks:
// app/api/checkout/route.ts
import { NextRequest, NextResponse } from 'next/server';
import Stripe from 'stripe';
const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!, {
apiVersion: '2024-12-18.acacia',
});
export async function POST(req: NextRequest) {
const { priceId, customerId } = await req.json();
const session = await stripe.checkout.sessions.create({
customer: customerId,
mode: 'subscription',
line_items: [{ price: priceId, quantity: 1 }],
success_url: `${process.env.NEXT_PUBLIC_URL}/billing?session_id={CHECKOUT_SESSION_ID}`,
cancel_url: `${process.env.NEXT_PUBLIC_URL}/pricing`,
subscription_data: {
trial_period_days: 14,
metadata: { source: 'pricing_page' },
},
allow_promotion_codes: true,
billing_address_collection: 'auto',
tax_id_collection: { enabled: true },
automatic_tax: { enabled: true },
// Renewal disclosure beside the pay button + express consent (California ARL)
custom_text: { submit: { message: 'Renews automatically at the listed price after the trial until you cancel. Cancel anytime online.' } },
consent_collection: { terms_of_service: 'required' }, // needs a ToS URL in the Dashboard
});
return NextResponse.json({ url: session.url });
}
// app/api/webhooks/stripe/route.ts
import { NextRequest, NextResponse } from 'next/server';
import Stripe from 'stripe';
import { prisma } from '@/lib/prisma';
const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!);
const webhookSecret = process.env.STRIPE_WEBHOOK_SECRET!;
export async function POST(req: NextRequest) {
const body = await req.text();
const signature = req.headers.get('stripe-signature')!;
let event: Stripe.Event;
try {
event = stripe.webhooks.constructEvent(body, signature, webhookSecret);
} catch (err) {
console.error('Webhook signature verification failed:', err);
return NextResponse.json({ error: 'Invalid signature' }, { status: 400 });
}
// Idempotency: check if already processed
const existing = await prisma.processedEvent.findUnique({
where: { stripeEventId: event.id },
});
if (existing) {
return NextResponse.json({ received: true, duplicate: true });
}
try {
switch (event.type) {
case 'checkout.session.completed':
await handleCheckoutComplete(event.data.object as Stripe.Checkout.Session);
break;
case 'customer.subscription.created':
case 'customer.subscription.updated':
await handleSubscriptionChange(event.data.object as Stripe.Subscription);
break;
case 'customer.subscription.deleted':
await handleSubscriptionCanceled(event.data.object as Stripe.Subscription);
break;
case 'invoice.payment_succeeded':
await handleInvoicePaid(event.data.object as Stripe.Invoice);
break;
case 'invoice.payment_failed':
await handlePaymentFailed(event.data.object as Stripe.Invoice);
break;
default:
console.log(`Unhandled event type: ${event.type}`);
}
// Mark event as processed
await prisma.processedEvent.create({
data: { stripeEventId: event.id, type: event.type },
});
} catch (err) {
console.error(`Error processing ${event.type}:`, err);
return NextResponse.json({ error: 'Processing failed' }, { status: 500 });
}
return NextResponse.json({ received: true });
}
async function handleSubscriptionChange(subscription: Stripe.Subscription) {
const customerId = subscription.customer as string;
await prisma.subscription.upsert({
where: { stripeSubscriptionId: subscription.id },
update: {
status: subscription.status,
priceId: subscription.items.data[0]?.price.id,
currentPeriodEnd: new Date(subscription.current_period_end * 1000),
cancelAtPeriodEnd: subscription.cancel_at_period_end,
},
create: {
stripeSubscriptionId: subscription.id,
stripeCustomerId: customerId,
status: subscription.status,
priceId: subscription.items.data[0]?.price.id,
currentPeriodEnd: new Date(subscription.current_period_end * 1000),
cancelAtPeriodEnd: subscription.cancel_at_period_end,
},
});
}
async function handleSubscriptionCanceled(subscription: Stripe.Subscription) {
await prisma.subscription.update({
where: { stripeSubscriptionId: subscription.id },
data: {
status: 'canceled',
canceledAt: new Date(),
},
});
}
async function handleCheckoutComplete(session: Stripe.Checkout.Session) {
// Link Stripe customer to your user if not already linked
if (session.client_reference_id) {
await prisma.user.update({
where: { id: session.client_reference_id },
data: { stripeCustomerId: session.customer as string },
});
}
}
async function handleInvoicePaid(invoice: Stripe.Invoice) {
await prisma.payment.create({
data: {
stripeInvoiceId: invoice.id,
stripeCustomerId: invoice.customer as string,
amount: invoice.amount_paid,
currency: invoice.currency,
status: 'succeeded',
paidAt: new Date(),
},
});
}
async function handlePaymentFailed(invoice: Stripe.Invoice) {
// Send dunning email, update UI state
const customerId = invoice.customer as string;
const user = await prisma.user.findFirst({
where: { stripeCustomerId: customerId },
});
if (user) {
await sendPaymentFailedEmail(user.email, {
invoiceUrl: invoice.hosted_invoice_url,
amount: invoice.amount_due,
currency: invoice.currency,
});
}
}
model User {
id String @id @default(cuid())
email String @unique
stripeCustomerId String? @unique
subscriptions Subscription[]
payments Payment[]
}
model Subscription {
id String @id @default(cuid())
stripeSubscriptionId String @unique
stripeCustomerId String
status String // active, trialing, past_due, canceled, unpaid
priceId String
currentPeriodEnd DateTime
cancelAtPeriodEnd Boolean @default(false)
canceledAt DateTime?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
}
model Payment {
id String @id @default(cuid())
stripeInvoiceId String @unique
stripeCustomerId String
amount Int // in cents
currency String
status String
paidAt DateTime?
createdAt DateTime @default(now())
}
model ProcessedEvent {
id String @id @default(cuid())
stripeEventId String @unique
type String
processedAt DateTime @default(now())
}
// Allow customers to manage their own billing
export async function createPortalSession(customerId: string): Promise<string> {
const session = await stripe.billingPortal.sessions.create({
customer: customerId,
return_url: `${process.env.NEXT_PUBLIC_URL}/settings/billing`,
});
return session.url;
}
async function reportUsage(
subscriptionItemId: string,
quantity: number
): Promise<void> {
await stripe.subscriptionItems.createUsageRecord(subscriptionItemId, {
quantity,
timestamp: Math.floor(Date.now() / 1000),
action: 'increment',
});
}
| Event | Action |
|---|---|
checkout.session.completed | Provision access, link customer |
customer.subscription.updated | Sync plan/status changes |
customer.subscription.deleted | Revoke access |
invoice.payment_succeeded | Record payment, reset dunning |
invoice.payment_failed | Send dunning email, flag account |
customer.subscription.trial_will_end | Send trial ending reminder |
agent-auth-specialist - User authentication for billing pagesgeneric-fullstack-feature-developer - Full-stack integrationtest-specialist - Testing payment flows (Stripe test mode)security - PCI compliance and secure handling$ARGUMENTS