単発購入向け Stripe Checkout:サーバー価格、Webhook のみで注文作成、在庫切れ時は自動返金
インストール
genpm add @core/checkout含まれるもの
- src/lib/checkout/ にソースコード(8 ファイル)。 (23.9 KB)
- src/lib/checkout/AGENTS.md に AI ルール、加えて 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 | 7 時間前 | スキャン合格 |
- 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 か月以内に公開達成
- 認証済みの公開者達成
- 概要とキーワード達成
- 報告
- 問題を見つけましたか?