skills/stripe/SKILL.md
--- name: stripe description: Wire Stripe payments into a TanStack Start + Cloudflare Workers app. Install, env config, Workers-friendly webhook signature verification (constructEventAsync), Drizzle subscriptions + events tables, WorkOS-to-Stripe customer linking, Checkout flow (subscription + one-time), customer portal, EU defaults (iDEAL + SEPA + SOFORT + Stripe Tax + B2B VAT reverse charge), local stripe listen + trigger. Use when user wants to add Stripe, wire payments, checkout, subscriptio
npx skillsauth add RonanCodes/ronan-skills skills/stripeInstall 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.
Wire Stripe payments into a TanStack Start + Drizzle + D1 app on Cloudflare Workers. Hosted Checkout, customer portal, Workers-compatible webhook signature verification, EU defaults baked in (iDEAL + SEPA + SOFORT + Stripe Tax + B2B VAT reverse charge).
This is the canonical payments pick for the user's stack as of 2026-04-30. Mollie wins only when Dutch-bank perception matters more than platform tooling. Lemon Squeezy or Polar (Merchant of Record) wins for indie SaaS that wants VAT-handling outsourced. See llm-wiki-research/wiki/research-notes/alex-finn-payments.md for the full alternatives analysis.
/ro:stripe install # initial wiring (env + client + Drizzle tables)
/ro:stripe add-checkout --mode subscription # subscription Checkout Session route
/ro:stripe add-checkout --mode payment # one-time payment Checkout Session route
/ro:stripe add-portal # customer portal route for self-service
/ro:stripe add-webhook # /api/webhooks/stripe with constructEventAsync
/ro:stripe add-tax # enable Stripe Tax + tax_id_collection
/ro:stripe sync-prices # push code-defined plans to Stripe
/ro:new-tanstack-app or /ro:migrate-to-tanstack)src/db/schema.ts, wrangler.toml with [[d1_databases]])/ro:workos or /ro:better-auth). Customer rows are keyed off the auth userbrew install stripe/stripe-cli/stripe then stripe loginpnpm add stripe # server SDK, v19+, uses 2025-03-31.basil API
pnpm add @stripe/stripe-js # client (only if using Stripe.js Elements; skip if Hosted Checkout only)
The server SDK works on Workers via constructEventAsync (Web Crypto). No httpClient override needed in v19+; the SDK auto-detects the runtime.
# .dev.vars
STRIPE_SECRET_KEY=sk_test_...
STRIPE_WEBHOOK_SECRET=whsec_...
STRIPE_PUBLISHABLE_KEY=pk_test_...
Production secrets:
wrangler secret put STRIPE_SECRET_KEY
wrangler secret put STRIPE_WEBHOOK_SECRET
wrangler secret put STRIPE_PUBLISHABLE_KEY
Test keys are sk_test_... + pk_test_.... Live keys are sk_live_... + pk_live_.... Each environment gets a separate STRIPE_WEBHOOK_SECRET (one per webhook endpoint).
src/lib/stripe.tsimport Stripe from 'stripe';
let client: Stripe | null = null;
export function getStripe(env: Env) {
if (!client) {
client = new Stripe(env.STRIPE_SECRET_KEY, {
apiVersion: '2025-03-31.basil',
typescript: true,
});
}
return client;
}
Add STRIPE_SECRET_KEY, STRIPE_WEBHOOK_SECRET, STRIPE_PUBLISHABLE_KEY to your Env shape (worker-configuration.d.ts, regenerated by wrangler types).
src/db/schema.tsimport { sqliteTable, text, integer } from 'drizzle-orm/sqlite-core';
import { users } from './schema';
export const stripeCustomers = sqliteTable('stripe_customers', {
userId: text('user_id').primaryKey().references(() => users.id),
stripeCustomerId: text('stripe_customer_id').notNull().unique(),
createdAt: integer('created_at', { mode: 'timestamp' }).notNull().$defaultFn(() => new Date()),
});
export const subscriptions = sqliteTable('subscriptions', {
id: text('id').primaryKey(), // stripe subscription id, sub_...
userId: text('user_id').notNull().references(() => users.id),
stripeCustomerId: text('stripe_customer_id').notNull(),
status: text('status').notNull(), // trialing | active | past_due | canceled | unpaid | incomplete
priceId: text('price_id').notNull(), // price_...
planKey: text('plan_key'), // your code-side plan key, see sync-prices
currentPeriodEnd: integer('current_period_end', { mode: 'timestamp' }).notNull(),
cancelAtPeriodEnd: integer('cancel_at_period_end', { mode: 'boolean' }).notNull().default(false),
createdAt: integer('created_at', { mode: 'timestamp' }).notNull().$defaultFn(() => new Date()),
updatedAt: integer('updated_at', { mode: 'timestamp' }).notNull().$defaultFn(() => new Date()),
});
export const stripeEvents = sqliteTable('stripe_events', {
id: text('id').primaryKey(), // stripe event id, evt_..., used for idempotency
type: text('type').notNull(),
receivedAt: integer('received_at', { mode: 'timestamp' }).notNull().$defaultFn(() => new Date()),
});
stripe_events is the idempotency log: every webhook lookup checks here first to skip replays.
Run pnpm drizzle-kit generate then pnpm drizzle-kit migrate (or your repo's migration script).
src/lib/stripe-customers.tsimport { getStripe } from './stripe';
import { db } from '@/db';
import { stripeCustomers } from '@/db/schema';
import { eq } from 'drizzle-orm';
export async function getOrCreateStripeCustomer(env: Env, userId: string, email: string) {
const existing = await db.select().from(stripeCustomers).where(eq(stripeCustomers.userId, userId)).get();
if (existing) return existing.stripeCustomerId;
const stripe = getStripe(env);
const customer = await stripe.customers.create({
email,
metadata: { workos_user_id: userId },
});
await db.insert(stripeCustomers).values({ userId, stripeCustomerId: customer.id });
return customer.id;
}
Call this on first checkout. The metadata.workos_user_id is also how you reverse-lookup from Stripe back to your auth system if you ever inspect the Stripe dashboard.
// src/routes/api/billing/checkout.ts
import { createServerFileRoute } from '@tanstack/react-start/server';
import { getStripe } from '@/lib/stripe';
import { getOrCreateStripeCustomer } from '@/lib/stripe-customers';
import { requireSession } from '@/lib/auth-server';
export const ServerRoute = createServerFileRoute('/api/billing/checkout').methods({
POST: async ({ request, context }) => {
const env = context.cloudflare.env;
const session = await requireSession(); // /ro:workos helper
const form = await request.formData();
const priceId = form.get('priceId') as string;
const stripe = getStripe(env);
const customerId = await getOrCreateStripeCustomer(env, session.userId, session.email);
const origin = new URL(request.url).origin;
const checkout = await stripe.checkout.sessions.create({
mode: 'subscription',
customer: customerId,
line_items: [{ price: priceId, quantity: 1 }],
success_url: `${origin}/billing/success?session_id={CHECKOUT_SESSION_ID}`,
cancel_url: `${origin}/billing`,
automatic_tax: { enabled: true }, // see add-tax
tax_id_collection: { enabled: true }, // EU B2B reverse charge
customer_update: { address: 'auto', name: 'auto' }, // required when automatic_tax is on
subscription_data: { metadata: { workos_user_id: session.userId } },
allow_promotion_codes: true,
});
return new Response(null, { status: 302, headers: { Location: checkout.url! } });
},
});
Client trigger:
<form method="POST" action="/api/billing/checkout">
<input type="hidden" name="priceId" value="price_1MoBy5LkdIwHu7ixZhnattbh" />
<button type="submit">Upgrade to Pro</button>
</form>
For one-time payments use --mode payment and either line_items with price_data (ad-hoc) or a pre-created price ID. Drop subscription_data. The rest of the call shape is identical.
// src/routes/api/billing/portal.ts
import { createServerFileRoute } from '@tanstack/react-start/server';
import { getStripe } from '@/lib/stripe';
import { getOrCreateStripeCustomer } from '@/lib/stripe-customers';
import { requireSession } from '@/lib/auth-server';
export const ServerRoute = createServerFileRoute('/api/billing/portal').methods({
POST: async ({ request, context }) => {
const env = context.cloudflare.env;
const session = await requireSession();
const stripe = getStripe(env);
const customerId = await getOrCreateStripeCustomer(env, session.userId, session.email);
const origin = new URL(request.url).origin;
const portal = await stripe.billingPortal.sessions.create({
customer: customerId,
return_url: `${origin}/billing`,
});
return new Response(null, { status: 302, headers: { Location: portal.url } });
},
});
Configure the portal once in the Stripe dashboard (Settings → Billing → Customer portal): which products customers can switch between, whether they can cancel directly, invoice history visibility. The portal URL above just opens what you configured.
Workers-compatible signature verification via constructEventAsync. Idempotency via the stripe_events table.
// src/routes/api/webhooks/stripe.ts
import { createServerFileRoute } from '@tanstack/react-start/server';
import { getStripe } from '@/lib/stripe';
import { db } from '@/db';
import { subscriptions, stripeEvents } from '@/db/schema';
import { eq } from 'drizzle-orm';
import type Stripe from 'stripe';
export const ServerRoute = createServerFileRoute('/api/webhooks/stripe').methods({
POST: async ({ request, context }) => {
const env = context.cloudflare.env;
const stripe = getStripe(env);
const sig = request.headers.get('stripe-signature');
if (!sig) return new Response('Missing signature', { status: 400 });
const body = await request.text();
let event: Stripe.Event;
try {
event = await stripe.webhooks.constructEventAsync(body, sig, env.STRIPE_WEBHOOK_SECRET);
} catch (err) {
return new Response(`Bad signature: ${(err as Error).message}`, { status: 400 });
}
// Idempotency: skip if we have already processed this event
const seen = await db.select().from(stripeEvents).where(eq(stripeEvents.id, event.id)).get();
if (seen) return new Response('ok (replay)');
await db.insert(stripeEvents).values({ id: event.id, type: event.type });
switch (event.type) {
case 'checkout.session.completed': {
const s = event.data.object as Stripe.Checkout.Session;
if (s.mode === 'subscription' && s.subscription) {
const sub = await stripe.subscriptions.retrieve(s.subscription as string);
await upsertSubscription(sub);
}
break;
}
case 'customer.subscription.created':
case 'customer.subscription.updated':
case 'customer.subscription.deleted': {
await upsertSubscription(event.data.object as Stripe.Subscription);
break;
}
case 'invoice.payment_failed': {
// Mark dunning state. Email user. Optional: cancel after N retries.
break;
}
}
return new Response('ok');
},
});
async function upsertSubscription(sub: Stripe.Subscription) {
const userId = (sub.metadata?.workos_user_id ?? '') as string;
if (!userId) return; // not one of ours
await db.insert(subscriptions).values({
id: sub.id,
userId,
stripeCustomerId: sub.customer as string,
status: sub.status,
priceId: sub.items.data[0]?.price.id ?? '',
currentPeriodEnd: new Date((sub as any).current_period_end * 1000),
cancelAtPeriodEnd: sub.cancel_at_period_end,
}).onConflictDoUpdate({
target: subscriptions.id,
set: {
status: sub.status,
priceId: sub.items.data[0]?.price.id ?? '',
currentPeriodEnd: new Date((sub as any).current_period_end * 1000),
cancelAtPeriodEnd: sub.cancel_at_period_end,
updatedAt: new Date(),
},
});
}
Register the webhook in the Stripe dashboard (Developers → Webhooks → Add endpoint), URL https://your-app.com/api/webhooks/stripe, select the events above. Copy the signing secret into STRIPE_WEBHOOK_SECRET.
Always return 200 fast. If a handler is slow, queue the work (Durable Object alarm, Cloudflare Queue) rather than blocking the response. Stripe retries on 5xx for up to 3 days, which is helpful but noisy.
Stripe Tax handles VAT calculation. You still register for VAT in destination jurisdictions once thresholds are crossed. For B2B in the EU, tax_id_collection enables reverse-charge: the customer enters their VAT ID at checkout, Stripe drops the VAT line, you issue an invoice with "VAT reverse charged" wording.
Steps:
add-checkout: automatic_tax: { enabled: true } + tax_id_collection: { enabled: true } + customer_update: { address: 'auto', name: 'auto' }.stripe.invoices.create) set automatic_tax: { enabled: true } on the invoice.stripe.subscriptions.create), set automatic_tax: { enabled: true } on the subscription.Stripe Tax pricing: 0.5% per transaction with a registration in the country, 0.4% on Stripe Connect platform charges. See https://stripe.com/tax/pricing.
Two ways to enable.
Recommended: use Automatic Payment Methods. In the Stripe dashboard go Settings → Payments → Payment methods, enable iDEAL, SEPA Direct Debit, SOFORT (Bancontact too if Belgium is in scope). Stripe shows methods automatically based on customer location and currency. No code change.
Manual: pin specific methods on the session. Useful only if you want to force a method or hide cards.
const checkout = await stripe.checkout.sessions.create({
mode: 'subscription',
customer: customerId,
line_items: [{ price: priceId, quantity: 1 }],
payment_method_types: ['card', 'ideal', 'sepa_debit', 'sofort'],
// ... rest as above
});
For SEPA Direct Debit on subscriptions you also need a mandate. Stripe collects this in Hosted Checkout automatically. For Stripe.js Elements you build the mandate UI yourself.
Pricing-plan source of truth lives in code (src/lib/billing/plans.ts), pushed to Stripe via a script. Reasons: code review, version control, easy rebuild in a fresh Stripe account, no dashboard drift.
// src/lib/billing/plans.ts
export const PLANS = {
starter: {
name: 'Starter',
priceEur: 19_00, // cents
interval: 'month' as const,
features: ['10 stores', '1 user'],
},
pro: {
name: 'Pro',
priceEur: 79_00,
interval: 'month' as const,
features: ['100 stores', '5 users', 'Priority support'],
},
} as const;
export type PlanKey = keyof typeof PLANS;
Sync script (Node, run locally with the test or live key in env):
// scripts/stripe-sync.ts
import 'dotenv/config';
import Stripe from 'stripe';
import { PLANS } from '../src/lib/billing/plans';
const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!, { apiVersion: '2025-03-31.basil' });
for (const [key, plan] of Object.entries(PLANS)) {
// Find or create Product
const products = await stripe.products.search({ query: `metadata['plan_key']:'${key}'` });
const product = products.data[0] ?? await stripe.products.create({
name: plan.name,
metadata: { plan_key: key },
});
// Find or create Price (Stripe prices are immutable; create a new one if anything changed)
const prices = await stripe.prices.search({
query: `product:'${product.id}' AND metadata['plan_key']:'${key}' AND active:'true'`,
});
const wantedAmount = plan.priceEur;
const existing = prices.data.find(p => p.unit_amount === wantedAmount && p.recurring?.interval === plan.interval);
if (!existing) {
const newPrice = await stripe.prices.create({
product: product.id,
unit_amount: wantedAmount,
currency: 'eur',
recurring: { interval: plan.interval },
metadata: { plan_key: key },
});
// Optionally archive the old prices
for (const p of prices.data) if (p.id !== newPrice.id) await stripe.prices.update(p.id, { active: false });
console.log(`Created price ${newPrice.id} for ${key}`);
} else {
console.log(`Price unchanged for ${key}`);
}
}
Run with pnpm tsx scripts/stripe-sync.ts. After a sync, copy the Stripe price_... IDs into your client form (or expose via a server route that returns the current price IDs).
Webhook forwarding:
stripe listen --forward-to localhost:3000/api/webhooks/stripe
# prints a one-off whsec_..., put that in .dev.vars as STRIPE_WEBHOOK_SECRET while listening
Replay events:
stripe trigger checkout.session.completed
stripe trigger customer.subscription.updated
stripe trigger invoice.payment_failed
Trigger from a real test checkout: card 4242 4242 4242 4242, any future date, any CVC. Use 4000 0027 6000 3184 to test 3DS.
| Case | Pick instead | Why | |---|---|---| | Indie SaaS, founder wants zero VAT paperwork, fine paying 4 to 5% MoR fee | Polar (newer) or Lemon Squeezy (Stripe-owned) | Merchant of Record handles VAT in 80+ jurisdictions | | Dutch B2B SME merchants who specifically prefer Mollie | Mollie | Local-bank perception, Dutch-speaking support; weaker subscription tooling | | Marketplace / split payments | Stripe Connect (still Stripe) | No MoR alternative competes | | High-volume EU subscription, want VAT filing automated, OK with platform fee | Stripe + Quaderno or TaxJar | Stripe Tax computes; Quaderno files |
For a deeper case-by-case, see llm-wiki-research/wiki/research-notes/alex-finn-payments.md.
| Var | Where | Source |
|---|---|---|
| STRIPE_SECRET_KEY | .dev.vars + wrangler secret | Stripe dashboard, Developers → API keys (separate test + live) |
| STRIPE_WEBHOOK_SECRET | .dev.vars + wrangler secret | Stripe dashboard, Developers → Webhooks → endpoint detail (one per endpoint) |
| STRIPE_PUBLISHABLE_KEY | .dev.vars + wrangler secret | Stripe dashboard, Developers → API keys (matching test/live pair) |
STRIPE_PUBLISHABLE_KEY is only needed if you use Stripe.js Elements client-side. Hosted Checkout-only apps can skip it.
sk_live_... key in ~/.claude/.env. Per-app secrets only. A leaked live key is a financial exposure, not just an auth one.constructEventAsync before trusting payload data. Without this, anyone can post fake checkout.session.completed events and unlock paid features.stripe_events table guarantees a side-effect runs once.dev, live keys only in production wrangler secrets. Mixing them silently sends real charges.success_url query string alone. Confirm the subscription exists via webhook before granting access; the user could fake the URL./ro:workos (or /ro:better-auth) for the auth that runs first; Stripe customers are linked to authenticated users/ro:nango if also wiring third-party integrations alongside payments/ro:cf-ship to ship after wiring/stripe/stripe-node) for current syntaxllm-wiki-research/wiki/research-notes/alex-finn-payments.md (alternatives: Mollie, Lemon Squeezy, Polar, Paddle)development
Close the loop on a Linear ticket when its work ships - move the status and post a deploy comment with the PR link, what shipped, and a try-it link, mentioning the collaborator. Used as the tail of /ro:linear-nightshift for every merged mirror, or manually after an ad-hoc build. Triggers on "linear update", "update the linear ticket", "mark NUT-x done", "tell eoin it shipped", "/ro:linear-update".
devops
Run a night-shift against a collaborator's Linear board. Pulls the team's Grilled tickets (/ro:linear-grill moves a ticket to Grilled once its questions are answered), VERIFIES the questions were actually answered (unanswered → bounce the ticket to the "Question for <name>" state), mirrors verified tickets to ephemeral GitHub issues with ready-for-agent, then runs the standard /ro:night-shift machinery on GitHub. Tail-calls /ro:linear-update for everything that merged + deployed. Triggers on "linear nightshift", "nightshift linear", "drain the linear board", "run the shift off linear", "/ro:linear-nightshift".
development
Grill a collaborator's Linear tickets and move every processed ticket to where it belongs. Resolves the board from the repo's .ro-linear.json, reads the collaborator's Backlog / Ready-for-agent issues, then per ticket either posts 3-5 decision-extracting questions (state moves to "Question for <name>") or confirms it build-ready (state moves to "Grilled", the gate /ro:linear-nightshift consumes); shipped-and-confirmed tickets close as Done. The async-collaborator counterpart of /ro:day-shift for people who never touch GitHub. Triggers on "grill linear", "grill eoin's tickets", "linear grill", "add questions to the linear tickets", "/ro:linear-grill".
development
--- name: about-page description: Add a standard About page to any web app, what it is, the tech stack, and an FAQ, wired into a footer link with a sticky footer. Built with Spartan + Tailwind (the canonical component layer) and falls back to semantic HTML so it ships reliably. Use whenever building, polishing, or shipping an app, every app should have one. Triggers on "add an about page", "about page", "footer about link", or as a standard step in app build/polish. category: frontend argument-h