skills/engineering-team/stripe-integration-expert/SKILL.md
Stripe Integration Expert
npx skillsauth add neekware/ehayeskills stripe-integration-expertInstall this skill globally with one command. Works with Claude Code, Cursor, and Windsurf.
3 of 9 scanners reported clean
Some scanners were skipped, did not run, or reported a non-clean status. Review each row below.
Tier: POWERFUL
Category: Engineering Team
Domain: Payments / Billing Infrastructure
Implement production-grade Stripe integrations: subscriptions with trials and proration, one-time payments, usage-based billing, checkout sessions, idempotent webhook handlers, customer portal, and invoicing. Covers Next.js, Express, and Django patterns.
FREE_TRIAL ──paid──► ACTIVE ──cancel──► CANCEL_PENDING ──period_end──► CANCELED
│ │ │
│ downgrade reactivate
│ ▼ │
│ DOWNGRADING ──period_end──► ACTIVE (lower plan) │
│ │
└──trial_end without payment──► PAST_DUE ──payment_failed 3x──► CANCELED
│
payment_success
│
▼
ACTIVE
trialing | active | past_due | canceled | cancel_pending | paused | unpaid
// lib/stripe.ts
import Stripe from "stripe";
export const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!, {
apiVersion: "2024-04-10",
typescript: true,
appInfo: {
name: "myapp",
version: "1.0.0",
},
});
// Price IDs by plan (set in env)
export const PLANS = {
starter: {
monthly: process.env.STRIPE_STARTER_MONTHLY_PRICE_ID!,
yearly: process.env.STRIPE_STARTER_YEARLY_PRICE_ID!,
features: ["5 projects", "10k events"],
},
pro: {
monthly: process.env.STRIPE_PRO_MONTHLY_PRICE_ID!,
yearly: process.env.STRIPE_PRO_YEARLY_PRICE_ID!,
features: ["Unlimited projects", "1M events"],
},
} as const;
// app/api/billing/checkout/route.ts
import { NextResponse } from "next/server"
import { stripe } from "@/lib/stripe"
import { getAuthUser } from "@/lib/auth"
import { db } from "@/lib/db"
export async function POST(req: Request) {
const user = await getAuthUser()
if (!user) return NextResponse.json({ error: "Unauthorized" }, { status: 401 })
const { priceId, interval = "monthly" } = await req.json()
// Get or create Stripe customer
let stripeCustomerId = user.stripeCustomerId
if (!stripeCustomerId) {
const customer = await stripe.customers.create({
email: user.email,
name: "username-undefined"
metadata: { userId: user.id },
})
stripeCustomerId = customer.id
await db.user.update({ where: { id: user.id }, data: { stripeCustomerId } })
}
const session = await stripe.checkout.sessions.create({
customer: stripeCustomerId,
mode: "subscription",
payment_method_types: ["card"],
line_items: [{ price: priceId, quantity: 1 }],
allow_promotion_codes: true,
subscription_data: {
trial_period_days: user.hasHadTrial ? undefined : 14,
metadata: { userId: user.id },
},
success_url: `${process.env.NEXT_PUBLIC_APP_URL}/dashboard?session_id={CHECKOUT_SESSION_ID}`,
cancel_url: `${process.env.NEXT_PUBLIC_APP_URL}/pricing`,
metadata: { userId: user.id },
})
return NextResponse.json({ url: session.url })
}
// lib/billing.ts
export async function changeSubscriptionPlan(subscriptionId: string, newPriceId: string, immediate = false) {
const subscription = await stripe.subscriptions.retrieve(subscriptionId);
const currentItem = subscription.items.data[0];
if (immediate) {
// Upgrade: apply immediately with proration
return stripe.subscriptions.update(subscriptionId, {
items: [{ id: currentItem.id, price: newPriceId }],
proration_behavior: "always_invoice",
billing_cycle_anchor: "unchanged",
});
} else {
// Downgrade: apply at period end, no proration
return stripe.subscriptions.update(subscriptionId, {
items: [{ id: currentItem.id, price: newPriceId }],
proration_behavior: "none",
billing_cycle_anchor: "unchanged",
});
}
}
// Preview proration before confirming upgrade
export async function previewProration(subscriptionId: string, newPriceId: string) {
const subscription = await stripe.subscriptions.retrieve(subscriptionId);
const prorationDate = Math.floor(Date.now() / 1000);
const invoice = await stripe.invoices.retrieveUpcoming({
customer: subscription.customer as string,
subscription: subscriptionId,
subscription_items: [{ id: subscription.items.data[0].id, price: newPriceId }],
subscription_proration_date: prorationDate,
});
return {
amountDue: invoice.amount_due,
prorationDate,
lineItems: invoice.lines.data,
};
}
// app/api/webhooks/stripe/route.ts
import { NextResponse } from "next/server";
import { headers } from "next/headers";
import { stripe } from "@/lib/stripe";
import { db } from "@/lib/db";
import Stripe from "stripe";
// Processed events table to ensure idempotency
async function hasProcessedEvent(eventId: string): Promise<boolean> {
const existing = await db.stripeEvent.findUnique({ where: { id: eventId } });
return !!existing;
}
async function markEventProcessed(eventId: string, type: string) {
await db.stripeEvent.create({ data: { id: eventId, type, processedAt: new Date() } });
}
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:", err);
return NextResponse.json({ error: "Invalid signature" }, { status: 400 });
}
// Idempotency check
if (await hasProcessedEvent(event.id)) {
return NextResponse.json({ received: true, skipped: true });
}
try {
switch (event.type) {
case "checkout.session.completed":
await handleCheckoutCompleted(event.data.object as Stripe.Checkout.Session);
break;
case "customer.subscription.created":
case "customer.subscription.updated":
await handleSubscriptionUpdated(event.data.object as Stripe.Subscription);
break;
case "customer.subscription.deleted":
await handleSubscriptionDeleted(event.data.object as Stripe.Subscription);
break;
case "invoice.payment_succeeded":
await handleInvoicePaymentSucceeded(event.data.object as Stripe.Invoice);
break;
case "invoice.payment_failed":
await handleInvoicePaymentFailed(event.data.object as Stripe.Invoice);
break;
default:
console.log(`Unhandled event type: ${event.type}`);
}
await markEventProcessed(event.id, event.type);
return NextResponse.json({ received: true });
} catch (err) {
console.error(`Error processing webhook ${event.type}:`, err);
// Return 500 so Stripe retries — don't mark as processed
return NextResponse.json({ error: "Processing failed" }, { status: 500 });
}
}
async function handleCheckoutCompleted(session: Stripe.Checkout.Session) {
if (session.mode !== "subscription") return;
const userId = session.metadata?.userId;
if (!userId) throw new Error("No userId in checkout session metadata");
const subscription = await stripe.subscriptions.retrieve(session.subscription as string);
await db.user.update({
where: { id: userId },
data: {
stripeCustomerId: session.customer as string,
stripeSubscriptionId: subscription.id,
stripePriceId: subscription.items.data[0].price.id,
stripeCurrentPeriodEnd: new Date(subscription.current_period_end * 1000),
subscriptionStatus: subscription.status,
hasHadTrial: true,
},
});
}
async function handleSubscriptionUpdated(subscription: Stripe.Subscription) {
const user = await db.user.findUnique({
where: { stripeSubscriptionId: subscription.id },
});
if (!user) {
// Look up by customer ID as fallback
const customer = await db.user.findUnique({
where: { stripeCustomerId: subscription.customer as string },
});
if (!customer) throw new Error(`No user found for subscription ${subscription.id}`);
}
await db.user.update({
where: { stripeSubscriptionId: subscription.id },
data: {
stripePriceId: subscription.items.data[0].price.id,
stripeCurrentPeriodEnd: new Date(subscription.current_period_end * 1000),
subscriptionStatus: subscription.status,
cancelAtPeriodEnd: subscription.cancel_at_period_end,
},
});
}
async function handleSubscriptionDeleted(subscription: Stripe.Subscription) {
await db.user.update({
where: { stripeSubscriptionId: subscription.id },
data: {
stripeSubscriptionId: null,
stripePriceId: null,
stripeCurrentPeriodEnd: null,
subscriptionStatus: "canceled",
},
});
}
async function handleInvoicePaymentFailed(invoice: Stripe.Invoice) {
if (!invoice.subscription) return;
const attemptCount = invoice.attempt_count;
await db.user.update({
where: { stripeSubscriptionId: invoice.subscription as string },
data: { subscriptionStatus: "past_due" },
});
if (attemptCount >= 3) {
// Send final dunning email
await sendDunningEmail(invoice.customer_email!, "final");
} else {
await sendDunningEmail(invoice.customer_email!, "retry");
}
}
async function handleInvoicePaymentSucceeded(invoice: Stripe.Invoice) {
if (!invoice.subscription) return;
await db.user.update({
where: { stripeSubscriptionId: invoice.subscription as string },
data: {
subscriptionStatus: "active",
stripeCurrentPeriodEnd: new Date(invoice.period_end * 1000),
},
});
}
// Report usage for metered subscriptions
export async function reportUsage(subscriptionItemId: string, quantity: number) {
await stripe.subscriptionItems.createUsageRecord(subscriptionItemId, {
quantity,
timestamp: Math.floor(Date.now() / 1000),
action: "increment",
});
}
// Example: report API calls in middleware
export async function trackApiCall(userId: string) {
const user = await db.user.findUnique({ where: { id: userId } });
if (user?.stripeSubscriptionId) {
const subscription = await stripe.subscriptions.retrieve(user.stripeSubscriptionId);
const meteredItem = subscription.items.data.find((item) => item.price.recurring?.usage_type === "metered");
if (meteredItem) {
await reportUsage(meteredItem.id, 1);
}
}
}
// app/api/billing/portal/route.ts
import { NextResponse } from "next/server";
import { stripe } from "@/lib/stripe";
import { getAuthUser } from "@/lib/auth";
export async function POST() {
const user = await getAuthUser();
if (!user?.stripeCustomerId) {
return NextResponse.json({ error: "No billing account" }, { status: 400 });
}
const portalSession = await stripe.billingPortal.sessions.create({
customer: user.stripeCustomerId,
return_url: `${process.env.NEXT_PUBLIC_APP_URL}/settings/billing`,
});
return NextResponse.json({ url: portalSession.url });
}
# Install Stripe CLI
brew install stripe/stripe-cli/stripe
# Login
stripe login
# Forward webhooks to local dev
stripe listen --forward-to localhost:3000/api/webhooks/stripe
# Trigger specific events for testing
stripe trigger checkout.session.completed
stripe trigger customer.subscription.updated
stripe trigger invoice.payment_failed
# Test with specific customer
stripe trigger customer.subscription.updated \
--override subscription:customer=cus_xxx
# View recent events
stripe events list --limit 10
# Test cards
# Success: 4242 4242 4242 4242
# Requires auth: 4000 0025 0000 3155
# Decline: 4000 0000 0000 9995
# Insufficient funds: 4000 0000 0000 9995
// lib/subscription.ts
export function isSubscriptionActive(user: { subscriptionStatus: string | null; stripeCurrentPeriodEnd: Date | null }) {
if (!user.subscriptionStatus) return false;
if (user.subscriptionStatus === "active" || user.subscriptionStatus === "trialing") return true;
// Grace period: past_due but not yet expired
if (user.subscriptionStatus === "past_due" && user.stripeCurrentPeriodEnd) {
return user.stripeCurrentPeriodEnd > new Date();
}
return false;
}
// Middleware usage
export async function requireActiveSubscription() {
const user = await getAuthUser();
if (!isSubscriptionActive(user)) {
redirect("/billing?reason=subscription_required");
}
}
hasHadTrial: true in DB to prevent trial abuseuserId in metadata; can't link subscription to user without ittools
# ehAye Multimedia Use this skill for **video, audio, images, media conversion, previews, transcription, thumbnails, frame extraction, Spotter visual search, or FFmpeg-backed processing**. Core rule: use ehAye native media tools first. Do not reach first for shell `ffmpeg`, `ffprobe`, Python, or `mediainfo` when a native media tool can do the job. Native tools use bundled engines, show proper tool UI, respect cancellation/timeouts, integrate with Preview/Spotter, and avoid cross-platform shell
development
Test-driven development skill for writing unit tests, generating test fixtures and mocks, analyzing coverage gaps, and guiding red-green-refactor workflows across Jest, Pytest, JUnit, Vitest, and Mocha. Use when the user asks to write tests, improve test coverage, practice TDD, generate mocks or stubs, or mentions testing frameworks like Jest, pytest, or JUnit. Handles test generation from source code, coverage report parsing (LCOV/JSON/XML), quality scoring, and framework conversion for TypeScript, JavaScript, Python, and Java projects.
tools
Help a user set up Telegram for ehAye Dojo. Default to Personal private bots (recommended). Group setup is advanced for teams/observers/demos.
development
# Writing Skills ## Overview **Writing skills IS Test-Driven Development applied to process documentation.** **Personal skills live in agent-specific directories (`~/.claude/skills` for Claude Code, `~/.agents/skills/` for Codex)** You write test cases (pressure scenarios with subagents), watch them fail (baseline behavior), write the skill (documentation), watch tests pass (agents comply), and refactor (close loopholes). **Core principle:** If you didn't watch an agent fail without the ski