一次性购买的 Stripe Checkout:服务端定价、仅由 Webhook 创建订单、售罄时自动退款
安装
genpm add @core/checkout你将获得
- 源代码位于 src/lib/checkout/,共 8 个文件。 (23.9 kB)
- AI 规则位于 src/lib/checkout/AGENTS.md,另附 IDE 规则文件。
- 添加到 .env.example 的环境变量:SITE_URL, STRIPE_TAX, CHECKOUT_ALERT_TO。
- 自动为你解析 @core/cart, @core/catalog, @core/email, @core/jobs, @core/money, @core/orders, @core/shipping, @core/stripe。
README
此包没有 README。
这正是你的 AI 在 src/lib/checkout 中工作时读取的内容。不会向其上下文添加其他任何内容。
@core/checkout — rules for AI agents
Purpose
Pays a @core/cart with Stripe Checkout (hosted page: no card data on your server). startCheckout recomputes totals,
blocks carts with issues or without shipping, sends server prices, a one-off coupon for the computed discounts and
the chosen shipping rate, and stores a snapshot. The order (@core/orders) is created ONLY in the Stripe webhook,
idempotently, from that snapshot after checking the charged amount; if the order can never be created (stock ran
out, a variant was withdrawn, invalid address…), the payment is refunded automatically, the customer and the team
are told and the webhook still answers 2xx (transient errors are rethrown so Stripe retries). Open sessions reserve
the discount codes they carry (registerPendingCodeUses of @core/cart); if a concurrent checkout took the last use,
the new session is expired and startCheckout throws issues. Also registers Stripe as the refund provider for the admin.
Map
index.ts— public API:startCheckout,getCheckoutStatus,finalizeSession,stripePaymentProvider,beginCheckoutEvent,purchaseEvent.checkout.ts— logic and webhook handlers (registered on import).schema.ts—checkout_sessions.adapters/hono.ts—checkoutRoutes({ currentUserId }).adapters/next.ts—checkoutRoute,checkoutStatusRoute.
Integration
- Install and wire @core/stripe (one webhook endpoint) with events
checkout.session.completed,checkout.session.async_payment_succeededandcheckout.session.expired. Env:SITE_URL,EMAIL_FROM, optionalCHECKOUT_ALERT_TO(team alerts) andSTRIPE_TAX=1(Stripe Tax; configure it in Stripe first). - Import this module and @core/shipping at startup. Migrations as in
src/lib/db/AGENTS.md; the @core/jobs cron must run. - Cart page → ask country and shipping rate (
setCartMeta), thenPOST /api/checkout {email}→ redirect tourl. - Success page
/checkout/success?session_id=…: pollGET /api/checkout/statusuntilpaid(show the order number),refunded(sold out or order could not be created) or keep "processing" — never trust the URL alone. - Verify locally:
stripe listen --forward-to localhost:3000/api/stripe/webhook, pay with card 4242 4242 4242 4242, an order appears.
Conventions
- Amounts always come from
computeTotals; the snapshot is what the customer saw and paid. - Show delivery estimate, total with shipping and taxes, and legal links before redirecting to Stripe.
- Use
purchaseEvent(order)(sameeventIdin browser and server) for @core/pixels.
Don't
- Don't create or mark orders paid from the success redirect or from client calls.
- Don't send prices, discounts or shipping amounts from the browser to Stripe.
- Don't log card data, full webhook payloads or Stripe keys.
# @core/checkout — rules for AI agents
## Purpose
Pays a @core/cart with Stripe Checkout (hosted page: no card data on your server). `startCheckout` recomputes totals,
blocks carts with issues or without shipping, sends server prices, a one-off coupon for the computed discounts and
the chosen shipping rate, and stores a snapshot. The order (@core/orders) is created ONLY in the Stripe webhook,
idempotently, from that snapshot after checking the charged amount; if the order can never be created (stock ran
out, a variant was withdrawn, invalid address…), the payment is refunded automatically, the customer and the team
are told and the webhook still answers 2xx (transient errors are rethrown so Stripe retries). Open sessions reserve
the discount codes they carry (`registerPendingCodeUses` of @core/cart); if a concurrent checkout took the last use,
the new session is expired and `startCheckout` throws `issues`. Also registers Stripe as the refund provider for the admin.
## Map
- `index.ts` — public API: `startCheckout`, `getCheckoutStatus`, `finalizeSession`, `stripePaymentProvider`, `beginCheckoutEvent`, `purchaseEvent`.
- `checkout.ts` — logic and webhook handlers (registered on import). `schema.ts` — `checkout_sessions`.
- `adapters/hono.ts` — `checkoutRoutes({ currentUserId })`. `adapters/next.ts` — `checkoutRoute`, `checkoutStatusRoute`.
## Integration
1. Install and wire @core/stripe (one webhook endpoint) with events `checkout.session.completed`,
`checkout.session.async_payment_succeeded` and `checkout.session.expired`. Env: `SITE_URL`, `EMAIL_FROM`,
optional `CHECKOUT_ALERT_TO` (team alerts) and `STRIPE_TAX=1` (Stripe Tax; configure it in Stripe first).
2. Import this module and @core/shipping at startup. Migrations as in `src/lib/db/AGENTS.md`; the @core/jobs cron must run.
3. Cart page → ask country and shipping rate (`setCartMeta`), then `POST /api/checkout {email}` → redirect to `url`.
4. Success page `/checkout/success?session_id=…`: poll `GET /api/checkout/status` until `paid` (show the order number),
`refunded` (sold out or order could not be created) or keep "processing" — never trust the URL alone.
5. Verify locally: `stripe listen --forward-to localhost:3000/api/stripe/webhook`, pay with card 4242 4242 4242 4242, an order appears.
## Conventions
- Amounts always come from `computeTotals`; the snapshot is what the customer saw and paid.
- Show delivery estimate, total with shipping and taxes, and legal links before redirecting to Stripe.
- Use `purchaseEvent(order)` (same `eventId` in browser and server) for @core/pixels.
## Don't
- Don't create or mark orders paid from the success redirect or from client calls.
- Don't send prices, discounts or shipping amounts from the browser to Stripe.
- Don't log card data, full webhook payloads or Stripe keys.
应用 .genpmignore 后将被注入的确切目录树。固定于
// Del carrito al pago con Stripe Checkout. El pedido SOLO se crea en el webhook (pago confirmado), desde la
// instantánea guardada al iniciar el pago, de forma idempotente y reservando stock; si el pedido no se puede crear
// (stock agotado, variante retirada, datos no válidos), se reembolsa automáticamente.
import { and, eq, gt, ne, sql } from 'drizzle-orm';
import type Stripe from 'stripe';
import { z } from 'zod';
import { type Cart, type CartTotals, clearCart, computeTotals, registerPendingCodeUses } from '../cart/index.ts';
import { CatalogError } from '../catalog/index.ts';
import type { CommerceEvent, PaymentProvider } from '../contracts/index.ts';
import { type Executor, getDb } from '../db/index.ts';
import { getEmailProvider } from '../email/index.ts';
import { defineJob } from '../jobs/index.ts';
import { money, toDecimalString } from '../money/index.ts';
import { type Address, createOrder, getOrder, type OrderDetail, OrderError, registerPaymentProvider } from '../orders/index.ts';
import { getStripe, onStripeEvent } from '../stripe/index.ts';
import { type CheckoutSession, checkoutSessions } from './schema.ts';
export class CheckoutError extends Error {
constructor(
readonly code: 'empty' | 'issues' | 'needs_shipping' | 'invalid',
message: string = code,
readonly issues: CartTotals['issues'] = [],
) {
super(message);
this.name = 'CheckoutError';
}
}
const BLOCKING = new Set(['no_shipping']);
/** Vida de la sesión de Stripe Checkout (y de la reserva de los códigos de descuento que lleva). */
const SESSION_TTL_MS = 30 * 60_000;
export type StartCheckoutInput = {
cart: Cart;
email: string;
locale?: string;
/** URLs absolutas https. A `successUrl` se le añade `session_id`. */
successUrl: string;
cancelUrl: string;
};
/** Crea la sesión de Stripe Checkout con los totales calculados en servidor y guarda su instantánea. */
export async function startCheckout(input: StartCheckoutInput, db: Executor = getDb()): Promise<{ url: string; sessionId: string }> {
const email = z.email().safeParse(input.email.trim().toLowerCase());
if (!email.success) throw new CheckoutError('invalid', 'invalid email');
const t = await computeTotals(input.cart, { email: email.data }, db);
if (!t.lines.length) throw new CheckoutError('empty', 'cart is empty', t.issues);
if (t.issues.some((i) => BLOCKING.has(i.code))) throw new CheckoutError('issues', 'cart has blocking issues', t.issues);
const physical = t.lines.some((l) => l.kind === 'physical');
const country = t.context.meta.country;
if (physical && (!country || !t.shipping)) throw new CheckoutError('needs_shipping', 'choose a shipping country and method first');
const stripe = getStripe();
let coupon: string | undefined;
if (t.discountTotal.amount > 0) {
// Cupón de un solo uso por el importe exacto de los descuentos calculados aquí.
const c = await stripe.coupons.create({ amount_off: t.discountTotal.amount, currency: t.currency.toLowerCase(), duration: 'once', max_redemptions: 1, name: t.discounts.map((d) => d.label).join(' + ').slice(0, 40) || 'Discount' });
coupon = c.id;
}
const session = await stripe.checkout.sessions.create({
mode: 'payment',
customer_email: email.data,
client_reference_id: t.cartId,
locale: (input.locale as Stripe.Checkout.SessionCreateParams.Locale | undefined) ?? 'auto',
line_items: t.lines.map((l) => ({
quantity: l.quantity,
price_data: { currency: t.currency.toLowerCase(), unit_amount: l.unitPrice.amount, product_data: { name: l.title ? `${l.name} (${l.title})` : l.name, metadata: { variantId: l.variantId } } },
})),
...(coupon && { discounts: [{ coupon }] }),
...(physical &&
t.shipping && {
shipping_address_collection: { allowed_countries: [country as Stripe.Checkout.SessionCreateParams.ShippingAddressCollection.AllowedCountry] },
shipping_options: [{ shipping_rate_data: { type: 'fixed_amount', display_name: t.shipping.label, fixed_amount: { amount: t.shipping.amount.amount, currency: t.currency.toLowerCase() } } }],
}),
...(process.env.STRIPE_TAX === '1' && { automatic_tax: { enabled: true } }),
metadata: { cartId: t.cartId, ...(input.cart.userId && { userId: input.cart.userId }) },
payment_intent_data: { metadata: { cartId: t.cartId } },
success_url: `${input.successUrl}${input.successUrl.includes('?') ? '&' : '?'}session_id={CHECKOUT_SESSION_ID}`,
cancel_url: input.cancelUrl,
expires_at: Math.floor((Date.now() + SESSION_TTL_MS) / 1000),
});
if (!session.url) throw new Error('Stripe did not return a checkout URL');
await db
.insert(checkoutSessions)
.values({ id: session.id, cartId: t.cartId, userId: input.cart.userId, email: email.data, locale: input.locale ?? null, currency: t.currency, total: t.total.amount, snapshot: t })
.onConflictDoNothing();
// Con la sesión ya guardada (y contando como reserva de sus códigos), se recalcula: si otro pago simultáneo agotó
// un código limitado, los totales cambian y esta sesión se anula en vez de sobrepasar el límite.
const again = await computeTotals(input.cart, { email: email.data }, db);
if (again.total.amount !== t.total.amount || again.discountTotal.amount !== t.discountTotal.amount) {
await stripe.checkout.sessions.expire(session.id).catch(() => undefined);
await db.update(checkoutSessions).set({ status: 'expired' }).where(eq(checkoutSessions.id, session.id));
throw new CheckoutError('issues', 'cart totals changed while starting checkout', again.issues);
}
return { url: session.url, sessionId: session.id };
}
function addressOf(s: Stripe.Checkout.Session): Address | null {
const d = s.collected_information?.shipping_details;
const a = d?.address;
if (!d || !a?.line1 || !a.city || !a.postal_code || !a.country) return null;
return {
name: d.name ?? s.customer_details?.name ?? '',
line1: a.line1,
...(a.line2 && { line2: a.line2 }),
city: a.city,
postalCode: a.postal_code,
...(a.state && { region: a.state }),
country: a.country,
...(s.customer_details?.phone && { phone: s.customer_details.phone }),
};
}
/** Convierte una sesión pagada en pedido. Idempotente: el pedido usa el id de la sesión como referencia única. */
export async function finalizeSession(s: Stripe.Checkout.Session, tx: Executor): Promise<CheckoutSession | null> {
if (s.payment_status !== 'paid' && s.payment_status !== 'no_payment_required') return null;
const [cs] = await tx.select().from(checkoutSessions).where(eq(checkoutSessions.id, s.id)).for('update');
if (!cs || cs.status !== 'open') return cs ?? null;
const tax = s.total_details?.amount_tax ?? 0;
const paymentRef = typeof s.payment_intent === 'string' ? s.payment_intent : (s.payment_intent?.id ?? s.id);
if (s.amount_total !== cs.total + tax || (s.currency ?? '').toUpperCase() !== cs.currency) {
// No debería ocurrir (los importes salen de la instantánea); si ocurre, no se crea el pedido y se avisa.
const [row] = await tx.update(checkoutSessions).set({ status: 'amount_mismatch' }).where(eq(checkoutSessions.id, s.id)).returning();
await alertJob.enqueue({ sessionId: s.id, reason: 'amount_mismatch' }, {}, tx);
return row!;
}
const t = cs.snapshot;
try {
const order = await savepoint(tx, (inner) =>
createOrder(
{
email: cs.email,
userId: cs.userId,
locale: cs.locale,
currency: cs.currency,
lines: t.lines.map((l) => ({ variantId: l.variantId, productId: l.productId, sku: l.sku, name: l.name, title: l.title, kind: l.kind, quantity: l.quantity, unitPrice: l.unitPrice.amount, discount: l.discount.amount })),
discounts: t.discounts.map((d) => ({ source: d.source, label: d.label, amount: d.amount.amount, ...(d.code && { code: d.code }) })),
shipping: t.shipping ? { label: t.shipping.label, amount: t.shipping.amount.amount, ...(t.shipping.code && { rateId: t.shipping.code }) } : null,
taxTotal: tax,
shippingAddress: addressOf(s),
cartId: s.id,
payment: { provider: 'stripe', ref: paymentRef, status: 'paid' },
},
inner,
),
);
await clearCart(cs.cartId, tx);
const [row] = await tx.update(checkoutSessions).set({ status: 'completed', orderId: order.id }).where(eq(checkoutSessions.id, s.id)).returning();
return row!;
} catch (e) {
// Solo los fallos permanentes (el pedido nunca se podrá crear) se reembolsan; el resto (base de datos, red) se
// relanza para que Stripe reintente el webhook.
if (!permanent(e)) throw e;
const oos = e instanceof CatalogError && e.code === 'out_of_stock';
// Reembolso completo automático y aviso al cliente y al equipo: nunca un cobro sin pedido.
await getStripe().refunds.create({ payment_intent: paymentRef, reason: 'requested_by_customer' }, { idempotencyKey: `${oos ? 'oos' : 'fail'}:${s.id}` });
const [row] = await tx.update(checkoutSessions).set({ status: oos ? 'refunded_out_of_stock' : 'refunded_order_failed' }).where(eq(checkoutSessions.id, s.id)).returning();
await alertJob.enqueue({ sessionId: s.id, reason: oos ? 'out_of_stock' : 'order_failed', ...(!oos && { detail: (e instanceof Error ? e.message : String(e)).slice(0, 300) }) }, {}, tx);
return row!;
}
}
/** Errores de validación del catálogo o del pedido: reintentar no los arregla (variante retirada, datos no válidos…). */
const permanent = (e: unknown) => e instanceof CatalogError || e instanceof OrderError || e instanceof z.ZodError;
// createOrder abre su propia lógica transaccional; con SAVEPOINT un fallo de stock deshace solo su parte.
async function savepoint<T>(tx: Executor, fn: (inner: Executor) => Promise<T>): Promise<T> {
return (tx as unknown as { transaction<R>(f: (t: Executor) => Promise<R>): Promise<R> }).transaction((inner) => fn(inner));
}
/** Avisos por email: al cliente si se le reembolsó; al equipo (`CHECKOUT_ALERT_TO`) siempre. */
export const alertJob = defineJob('checkout.alert', z.object({ sessionId: z.string(), reason: z.enum(['out_of_stock', 'order_failed', 'amount_mismatch']), detail: z.string().max(300).optional() }), async ({ sessionId, reason, detail }) => {
const [cs] = await getDb().select().from(checkoutSessions).where(eq(checkoutSessions.id, sessionId));
const from = process.env.EMAIL_FROM;
if (!cs || !from) return;
const total = toDecimalString(money(cs.total, cs.currency));
if (reason === 'out_of_stock')
await getEmailProvider().send({
from,
to: cs.email,
subject: 'Your payment has been refunded',
text: `Sorry: an item in your order sold out while you were paying, so we have refunded the full amount (${total} ${cs.currency}). No order was created.`,
html: `<p>Sorry: an item in your order sold out while you were paying, so we have refunded the full amount (${total} ${cs.currency}). No order was created.</p>`,
});
if (reason === 'order_failed')
await getEmailProvider().send({
from,
to: cs.email,
subject: 'Your payment has been refunded',
text: `Sorry: we could not complete your order (an item is no longer available), so we have refunded the full amount (${total} ${cs.currency}). No order was created.`,
html: `<p>Sorry: we could not complete your order (an item is no longer available), so we have refunded the full amount (${total} ${cs.currency}). No order was created.</p>`,
});
const team = (process.env.CHECKOUT_ALERT_TO ?? '').split(',').map((x) => x.trim()).filter(Boolean);
const why = detail ? `${reason} (${detail})` : reason;
const esc = (x: string) => x.replaceAll('&', '&').replaceAll('<', '<').replaceAll('>', '>');
if (team.length) await getEmailProvider().send({ from, to: team, subject: `Checkout ${reason}: ${sessionId}`, text: `Session ${sessionId} (${cs.email}, ${total} ${cs.currency}): ${why}.`, html: `<p>Session ${sessionId}: ${esc(why)}.</p>` });
});
/** Estado para la página de éxito: consulta la base de datos (lo que dijo el webhook), nunca la URL. */
export async function getCheckoutStatus(sessionId: string, db: Executor = getDb()): Promise<{ status: 'pending' | 'paid' | 'expired' | 'refunded'; orderNumber?: string }> {
const [cs] = await db.select().from(checkoutSessions).where(eq(checkoutSessions.id, sessionId));
if (!cs) return { status: 'pending' };
if (cs.status === 'completed' && cs.orderId) {
const o = await getOrder(cs.orderId, db);
return { status: 'paid', ...(o && { orderNumber: o.number }) };
}
if (cs.status === 'expired') return { status: 'expired' };
if (cs.status === 'refunded_out_of_stock' || cs.status === 'refunded_order_failed') return { status: 'refunded' };
return { status: 'pending' };
}
/** Pasarela Stripe para @core/orders (reembolsos desde el panel). */
export const stripePaymentProvider: PaymentProvider = {
name: 'stripe',
async getPayment(ref) {
const pi = await getStripe().paymentIntents.retrieve(ref);
const status = pi.status === 'succeeded' ? 'succeeded' : pi.status === 'canceled' ? 'failed' : 'pending';
return { status, amount: money(pi.amount_received || pi.amount, pi.currency.toUpperCase()) };
},
async refund(ref, amount, opts) {
const r = await getStripe().refunds.create({ payment_intent: ref, amount: amount.amount, ...(opts.reason && { metadata: { reason: opts.reason.slice(0, 200) } }) }, { idempotencyKey: opts.idempotencyKey });
return { refundRef: r.id };
},
};
/** Registra webhooks y la pasarela. Se llama al importar el módulo. */
export function registerCheckout(): void {
onStripeEvent(['checkout.session.completed', 'checkout.session.async_payment_succeeded'], async (event, tx) => {
await finalizeSession(event.data.object as Stripe.Checkout.Session, tx);
});
onStripeEvent('checkout.session.expired', async (event, tx) => {
await tx.update(checkoutSessions).set({ status: 'expired' }).where(eq(checkoutSessions.id, (event.data.object as Stripe.Checkout.Session).id));
});
registerPaymentProvider(stripePaymentProvider);
registerPendingCodeUses('checkout', pendingCheckoutCodeUses);
}
/**
* Reservas de códigos de descuento: sesiones de pago abiertas y no caducadas que llevan `code` en su instantánea (sin
* contar las del mismo carrito, que se sustituyen al volver a pagar). Las usa @core/discounts para sus límites.
*/
async function pendingCheckoutCodeUses(code: string, who: { email?: string; userId: string | null; excludeCartId: string }, db: Executor) {
const rows = await db
.select({ email: checkoutSessions.email, userId: checkoutSessions.userId })
.from(checkoutSessions)
.where(
and(
eq(checkoutSessions.status, 'open'),
gt(checkoutSessions.createdAt, new Date(Date.now() - SESSION_TTL_MS)),
ne(checkoutSessions.cartId, who.excludeCartId),
sql`${checkoutSessions.snapshot}->'discounts' @> ${JSON.stringify([{ code }])}::jsonb`,
),
);
const mine = rows.filter((r) => (who.email && r.email === who.email) || (who.userId && r.userId === who.userId)).length;
return { total: rows.length, mine };
}
registerCheckout();
// —— eventos para píxeles (@core/pixels) ——
export const beginCheckoutEvent = (t: CartTotals, eventId: string): CommerceEvent => ({
type: 'begin_checkout',
eventId,
value: t.total,
items: t.lines.map((l) => ({ id: l.variantId, name: l.name, price: l.unitPrice, quantity: l.quantity, ...(l.title && { variant: l.title }) })),
});
/** Evento `purchase` con `eventId` = id del pedido (el mismo en navegador y servidor para deduplicar). */
export const purchaseEvent = (o: OrderDetail): CommerceEvent => ({
type: 'purchase',
eventId: o.id,
orderId: o.number,
value: money(o.total, o.currency),
items: o.lines.map((l) => ({ id: l.variantId ?? l.id, name: l.name, price: money(l.unitPrice, o.currency), quantity: l.quantity, ...(l.title && { variant: l.title }) })),
});
- 服务器
- stripe
- 命令
- npx -y @stripe/mcp
- env
- STRIPE_SECRET_KEY
| 版本 | 提交 | 发布时间 | 扫描 |
|---|---|---|---|
| 1.0.1 | 6590fd9 | 6小时前 | 扫描通过 |
- genpm
- @core/cart ^1.1.0@core/catalog ^1.0.0@core/email ^1.0.1@core/jobs ^1.0.0@core/money ^1.0.0@core/orders ^1.0.0@core/shipping ^1.0.0@core/stripe ^1.0.0
- 建议
- GenPM 会给出 npm 命令建议,只有你同意时才会运行。
- 被以下包使用(1)
- @core/kit-store ^1.0.0
- 扫描
- 扫描通过 · 0 个问题
- 提交
- v1.0.1 → 6590fd9f566c5f55262488b266ce60ce5a433b2b · 获取后已校验
- 脚本
- 无。GenPM 从不运行包中的代码。
- 许可证
- MIT
- 质量
- 100/100
- 可识别的许可证已满足
- AGENTS.md 说明了用途已满足
- AGENTS.md 包含集成步骤已满足
- AGENTS.md 列出约定或禁止事项已满足
- 包含测试已满足
- 通过安全扫描已满足
- 最近 6 个月内发布已满足
- 已验证的发布者已满足
- 摘要和关键词已满足
- 举报
- 发现问题了吗?