Stripe-Abos: Checkout, Kundenportal und idempotente Webhooks mit Drizzle
Code9 DateienKontext~633 TokensMCP stripePrüfung bestanden
Installieren
$
genpm add @core/billingWas du bekommst
- Quellcode in src/lib/billing/, 9 Dateien. (15,7 kB)
- KI-Regeln in src/lib/billing/AGENTS.md, dazu Regeldateien für die IDE.
- Umgebungsvariablen in .env.example ergänzt: STRIPE_SECRET_KEY, STRIPE_WEBHOOK_SECRET.
- Löst @core/auth, @core/db für dich auf.
README
Dieses Paket hat keine README.
~633 Tokens→ src/lib/billing/AGENTS.md→ .cursor/rules/genpm-core-billing.mdc
Genau das liest deine KI, wenn sie in src/lib/billing arbeitet. Sonst wird ihrem Kontext nichts hinzugefügt.
@core/billing — rules for AI agents
Purpose
Stripe subscriptions: checkout, customer portal, an idempotent webhook handler that mirrors subscriptions into the
database, and hasEntitlement(user, feature) for gating. Stripe is the source of truth. No usage-based billing,
invoices UI or taxes logic (configure those in Stripe).
Map
index.ts— public API:createCheckoutSession,createPortalSession,handleStripeWebhook,hasEntitlement,activeSubscriptions.plans.ts— your config: Stripe price id → features. Edit this file.schema.ts—billing_customers,subscriptions,stripe_events. Depends on../authand../db.adapters/hono.ts,adapters/next.ts—POST /billing/checkout,/billing/portal,/billing/webhook.
Integration
- Env:
STRIPE_SECRET_KEY,STRIPE_WEBHOOK_SECRET. Use test-mode keys in development. - Use the Stripe MCP server (if the user enabled it) to list real products and prices; otherwise ask the user for price ids.
Put them in
plans.ts:FEATURES_BY_PRICE['price_123'] = ['pro']. - Generate and apply migrations (see
src/lib/db/AGENTS.md). - Mount routes after
@core/auth'ssessionMiddleware:- Hono:
app.route('/billing', billingRoutes())from./lib/billing/adapters/hono.js. - Next.js:
app/billing/{checkout,portal,webhook}/route.tsexportingcheckoutRoute/portalRoute/webhookRouteasPOST. Delete the adapter of the framework you don't use.
- Hono:
- Webhook endpoint in Stripe:
<origin>/billing/webhookwith eventscheckout.session.completedandcustomer.subscription.created|updated|deleted. Locally:stripe listen --forward-to localhost:3000/billing/webhook. - Gate features:
if (!(await hasEntitlement(user, 'pro'))) return c.json({ error: 'upgrade' }, 402). - Frontend:
POST /billing/checkout {priceId}returns{url}; redirect the browser there.
Conventions
- Read subscription state from the database (
hasEntitlement,activeSubscriptions), never from Stripe on each request. - All Stripe writes go through this module; keep amounts and prices in Stripe, not in code.
- The webhook must receive the raw body; do not put a JSON body parser in front of it.
Don't
- Don't skip webhook signature verification or process events outside
handleStripeWebhook. - Don't grant access from the checkout success page: wait for the webhook.
- Don't log card data, full webhook payloads or
STRIPE_SECRET_KEY. - Don't use live keys in tests or with the MCP server unless the user asks.
# @core/billing — rules for AI agents
## Purpose
Stripe subscriptions: checkout, customer portal, an idempotent webhook handler that mirrors subscriptions into the
database, and `hasEntitlement(user, feature)` for gating. Stripe is the source of truth. No usage-based billing,
invoices UI or taxes logic (configure those in Stripe).
## Map
- `index.ts` — public API: `createCheckoutSession`, `createPortalSession`, `handleStripeWebhook`, `hasEntitlement`, `activeSubscriptions`.
- `plans.ts` — **your config**: Stripe price id → features. Edit this file.
- `schema.ts` — `billing_customers`, `subscriptions`, `stripe_events`. Depends on `../auth` and `../db`.
- `adapters/hono.ts`, `adapters/next.ts` — `POST /billing/checkout`, `/billing/portal`, `/billing/webhook`.
## Integration
1. Env: `STRIPE_SECRET_KEY`, `STRIPE_WEBHOOK_SECRET`. Use test-mode keys in development.
2. Use the Stripe MCP server (if the user enabled it) to list real products and prices; otherwise ask the user for price ids.
Put them in `plans.ts`: `FEATURES_BY_PRICE['price_123'] = ['pro']`.
3. Generate and apply migrations (see `src/lib/db/AGENTS.md`).
4. Mount routes after `@core/auth`'s `sessionMiddleware`:
- Hono: `app.route('/billing', billingRoutes())` from `./lib/billing/adapters/hono.js`.
- Next.js: `app/billing/{checkout,portal,webhook}/route.ts` exporting `checkoutRoute`/`portalRoute`/`webhookRoute` as `POST`.
Delete the adapter of the framework you don't use.
5. Webhook endpoint in Stripe: `<origin>/billing/webhook` with events `checkout.session.completed` and
`customer.subscription.created|updated|deleted`. Locally: `stripe listen --forward-to localhost:3000/billing/webhook`.
6. Gate features: `if (!(await hasEntitlement(user, 'pro'))) return c.json({ error: 'upgrade' }, 402)`.
7. Frontend: `POST /billing/checkout {priceId}` returns `{url}`; redirect the browser there.
## Conventions
- Read subscription state from the database (`hasEntitlement`, `activeSubscriptions`), never from Stripe on each request.
- All Stripe writes go through this module; keep amounts and prices in Stripe, not in code.
- The webhook must receive the raw body; do not put a JSON body parser in front of it.
## Don't
- Don't skip webhook signature verification or process events outside `handleStripeWebhook`.
- Don't grant access from the checkout success page: wait for the webhook.
- Don't log card data, full webhook payloads or `STRIPE_SECRET_KEY`.
- Don't use live keys in tests or with the MCP server unless the user asks.
Der genaue Baum, der nach .genpmignore eingebunden wird. Gepinnt an
// Checkout, portal, webhooks idempotentes y entitlements. Sin framework: los adaptadores solo traducen HTTP.
import { and, eq, inArray } from 'drizzle-orm';
import type Stripe from 'stripe';
import type { User } from '../auth/index.js';
import { type Executor, getDb, withTransaction } from '../db/index.js';
import { ACTIVE_STATUSES, FEATURES_BY_PRICE } from './plans.js';
import { billingCustomers, type Subscription, stripeEvents, subscriptions } from './schema.js';
import { getStripe } from './stripe.js';
/** Cliente de Stripe del usuario; lo crea la primera vez (con `metadata.userId` para poder volver al usuario). */
export async function ensureCustomer(user: Pick<User, 'id' | 'email' | 'name'>, db: Executor = getDb()): Promise<string> {
const [row] = await db.select().from(billingCustomers).where(eq(billingCustomers.userId, user.id));
if (row) return row.stripeCustomerId;
const customer = await getStripe().customers.create(
{ email: user.email ?? undefined, name: user.name ?? undefined, metadata: { userId: user.id } },
{ idempotencyKey: `customer-${user.id}` },
);
await db.insert(billingCustomers).values({ userId: user.id, stripeCustomerId: customer.id }).onConflictDoNothing();
const [saved] = await db.select().from(billingCustomers).where(eq(billingCustomers.userId, user.id));
return saved!.stripeCustomerId;
}
export async function createCheckoutSession(opts: {
user: Pick<User, 'id' | 'email' | 'name'>;
priceId: string;
successUrl: string;
cancelUrl: string;
}): Promise<{ url: string }> {
if (!(opts.priceId in FEATURES_BY_PRICE)) throw new Error(`Unknown price ${opts.priceId}: add it to plans.ts`);
const customer = await ensureCustomer(opts.user);
const session = await getStripe().checkout.sessions.create({
mode: 'subscription',
customer,
client_reference_id: opts.user.id,
line_items: [{ price: opts.priceId, quantity: 1 }],
success_url: opts.successUrl,
cancel_url: opts.cancelUrl,
subscription_data: { metadata: { userId: opts.user.id } },
allow_promotion_codes: true,
});
if (!session.url) throw new Error('Stripe did not return a checkout URL');
return { url: session.url };
}
export async function createPortalSession(user: Pick<User, 'id' | 'email' | 'name'>, returnUrl: string): Promise<{ url: string }> {
const customer = await ensureCustomer(user);
const s = await getStripe().billingPortal.sessions.create({ customer, return_url: returnUrl });
return { url: s.url };
}
async function userIdFor(sub: Stripe.Subscription, db: Executor): Promise<string | null> {
if (sub.metadata?.userId) return sub.metadata.userId;
const customer = typeof sub.customer === 'string' ? sub.customer : sub.customer.id;
const [row] = await db.select().from(billingCustomers).where(eq(billingCustomers.stripeCustomerId, customer));
return row?.userId ?? null;
}
/** Copia el estado de una suscripción de Stripe a la tabla `subscriptions`. */
export async function syncSubscription(sub: Stripe.Subscription, db: Executor = getDb()): Promise<void> {
const userId = await userIdFor(sub, db);
if (!userId) return;
const item = sub.items.data[0];
const values = {
userId,
status: sub.status,
priceId: item?.price.id ?? '',
currentPeriodEnd: item?.current_period_end ? new Date(item.current_period_end * 1000) : null,
cancelAtPeriodEnd: sub.cancel_at_period_end,
};
await db
.insert(subscriptions)
.values({ id: sub.id, ...values })
.onConflictDoUpdate({ target: subscriptions.id, set: values });
}
export type WebhookResult = { received: true; duplicate: boolean; type: string };
/**
* Verifica la firma del webhook y procesa el evento UNA vez: el registro en `stripe_events` y los cambios van en la
* misma transacción, así que si algo falla Stripe reintenta y se vuelve a procesar.
*/
export async function handleStripeWebhook(rawBody: string, signature: string | null): Promise<WebhookResult> {
const secret = process.env.STRIPE_WEBHOOK_SECRET;
if (!secret) throw new Error('STRIPE_WEBHOOK_SECRET is not set');
if (!signature) throw new WebhookSignatureError('missing stripe-signature header');
let event: Stripe.Event;
try {
event = await getStripe().webhooks.constructEventAsync(rawBody, signature, secret);
} catch {
throw new WebhookSignatureError('invalid signature');
}
return withTransaction(async (tx) => {
const inserted = await tx
.insert(stripeEvents)
.values({ id: event.id, type: event.type })
.onConflictDoNothing()
.returning({ id: stripeEvents.id });
if (inserted.length === 0) return { received: true, duplicate: true, type: event.type };
switch (event.type) {
case 'customer.subscription.created':
case 'customer.subscription.updated':
case 'customer.subscription.deleted':
await syncSubscription(event.data.object, tx);
break;
case 'checkout.session.completed': {
const s = event.data.object;
if (s.mode === 'subscription' && s.subscription) {
const id = typeof s.subscription === 'string' ? s.subscription : s.subscription.id;
await syncSubscription(await getStripe().subscriptions.retrieve(id), tx);
}
break;
}
}
return { received: true, duplicate: false, type: event.type };
});
}
export class WebhookSignatureError extends Error {}
export async function activeSubscriptions(userId: string, db: Executor = getDb()): Promise<Subscription[]> {
return db
.select()
.from(subscriptions)
.where(and(eq(subscriptions.userId, userId), inArray(subscriptions.status, [...ACTIVE_STATUSES])));
}
/** ¿Tiene el usuario una suscripción activa cuyo precio desbloquea `feature` (según plans.ts)? */
export async function hasEntitlement(user: Pick<User, 'id'>, feature: string, db: Executor = getDb()): Promise<boolean> {
const subs = await activeSubscriptions(user.id, db);
return subs.some((s) => FEATURES_BY_PRICE[s.priceId]?.includes(feature));
}
- Server
- stripe
- Befehl
- npx -y @stripe/mcp
- env
- STRIPE_SECRET_KEY
| Version | Commit | Veröffentlicht | Prüfung |
|---|---|---|---|
| 1.0.0 | a8a5ac1 | vor 5 Stunden | ✔ Prüfung bestanden |
- vorgeschlagen
- GenPM schlägt den npm-Befehl vor und führt ihn nur aus, wenn du zustimmst.
- Prüfung
- Prüfung bestanden · 0 Befunde
- Commit
- v1.0.0 → a8a5ac11d1027ce8ae3bad366f625d135bdf6246 · nach dem Abruf verifiziert
- Skripte
- Keine. GenPM führt niemals Paketcode aus.
- Lizenz
- MIT
- Meldung
- Stimmt etwas nicht?