장바구니: 서명된 게스트 쿠키, 로그인 시 병합, 서버 측 가격, 확장 가능한 합계 계산
코드파일 10개컨텍스트약 611토큰검사 통과
설치
$
genpm add @core/cart포함 내용
- src/lib/cart/에 소스 코드, 파일 10개. (24.9kB)
- src/lib/cart/AGENTS.md에 AI 규칙, 그리고 IDE 규칙 파일.
- .env.example에 추가되는 환경 변수: CART_COOKIE_SECRET.
- @core/auth, @core/catalog, @core/contracts, @core/db, @core/money을(를) 자동으로 해결합니다.
README
이 패키지에는 README가 없습니다.
약 611토큰→ src/lib/cart/AGENTS.md→ .cursor/rules/genpm-core-cart.mdc
이것이 AI가 src/lib/cart에서 작업할 때 읽는 내용 그대로입니다. 그 외에는 컨텍스트에 아무것도 추가되지 않습니다.
@core/cart — rules for AI agents
Purpose
Carts for guests (id in an HMAC-signed HttpOnly cookie) and users (merged on login), lines per variant with stock
and quantity checks, and computeTotals: lines are always priced from @core/catalog in the database, unavailable
items are dropped with an issues entry, then registered steps run (discounts 100, shipping 200, taxes 300).
No payments or orders. Tables: carts, cart_lines.
Map
index.ts— public API:getCart,getOrCreateCart,addLine,setLine,setDiscountCodes,setCartMeta,computeTotals,registerTotalsStep,applyOrderDiscount,registerPendingCodeUses,pendingCodeUses,pruneCarts.cart.ts— operations.totals.ts— pipeline and types.cookie.ts— signed cookie.adapters/hono.ts—cartRoutes({ currentUserId }).adapters/next.ts—cartRoute,cartLinesRoute,cartLineRoute,cartCodesRoute.
Integration
- Env:
CART_COOKIE_SECRET(≥ 32 chars). Migrations as insrc/lib/db/AGENTS.md. - Mount the JSON API at
/api/cartwithcurrentUserIdfrom @core/auth (orasync () => nullfor guest-only shops). Calls:GET /api/cart,POST /api/cart/lines {variantId, quantity},PUT /api/cart/lines/:variantId {quantity}(0 removes),PUT /api/cart/codes {codes}. - Server pages:
const cart = await getCart(cookieHeader, userId); const totals = cart && await computeTotals(cart). - Modules that change totals register a step once at startup:
registerTotalsStep({ name, order, run: (t) => … }); order discounts must go throughapplyOrderDiscountso they are allocated to lines. Modules that open payments before the order exists (@core/checkout) registerregisterPendingCodeUsesso usage-limited codes (@core/discounts) count in-flight payments as reserved. - Schedule
pruneCarts()daily (@core/jobs; with kit-store, itsstore.maintenancejob does it). - Verify: add a product, change its price in the admin, the cart shows the new price.
Conventions
- Totals shown and charged come from
computeTotalsonly; never from values sent by the browser. - Show every
issuesentry to the customer before checkout. - Money values are
{ amount, currency }in minor units (@core/money).
Don't
- Don't reserve stock in the cart (that happens when the order is created).
- Don't trust prices, discounts or totals in request bodies, and don't read the cart cookie without verifying it.
# @core/cart — rules for AI agents
## Purpose
Carts for guests (id in an HMAC-signed HttpOnly cookie) and users (merged on login), lines per variant with stock
and quantity checks, and `computeTotals`: lines are always priced from @core/catalog in the database, unavailable
items are dropped with an `issues` entry, then registered steps run (discounts 100, shipping 200, taxes 300).
No payments or orders. Tables: `carts`, `cart_lines`.
## Map
- `index.ts` — public API: `getCart`, `getOrCreateCart`, `addLine`, `setLine`, `setDiscountCodes`, `setCartMeta`, `computeTotals`, `registerTotalsStep`, `applyOrderDiscount`, `registerPendingCodeUses`, `pendingCodeUses`, `pruneCarts`.
- `cart.ts` — operations. `totals.ts` — pipeline and types. `cookie.ts` — signed cookie.
- `adapters/hono.ts` — `cartRoutes({ currentUserId })`. `adapters/next.ts` — `cartRoute`, `cartLinesRoute`, `cartLineRoute`, `cartCodesRoute`.
## Integration
1. Env: `CART_COOKIE_SECRET` (≥ 32 chars). Migrations as in `src/lib/db/AGENTS.md`.
2. Mount the JSON API at `/api/cart` with `currentUserId` from @core/auth (or `async () => null` for guest-only shops).
Calls: `GET /api/cart`, `POST /api/cart/lines {variantId, quantity}`, `PUT /api/cart/lines/:variantId {quantity}` (0 removes), `PUT /api/cart/codes {codes}`.
3. Server pages: `const cart = await getCart(cookieHeader, userId); const totals = cart && await computeTotals(cart)`.
4. Modules that change totals register a step once at startup: `registerTotalsStep({ name, order, run: (t) => … })`;
order discounts must go through `applyOrderDiscount` so they are allocated to lines.
Modules that open payments before the order exists (@core/checkout) register `registerPendingCodeUses` so
usage-limited codes (@core/discounts) count in-flight payments as reserved.
5. Schedule `pruneCarts()` daily (@core/jobs; with kit-store, its `store.maintenance` job does it).
6. Verify: add a product, change its price in the admin, the cart shows the new price.
## Conventions
- Totals shown and charged come from `computeTotals` only; never from values sent by the browser.
- Show every `issues` entry to the customer before checkout.
- Money values are `{ amount, currency }` in minor units (@core/money).
## Don't
- Don't reserve stock in the cart (that happens when the order is created).
- Don't trust prices, discounts or totals in request bodies, and don't read the cart cookie without verifying it.
.genpmignore 적용 후 주입될 정확한 트리입니다. 고정 대상:
// Carrito: crear/recuperar por cookie, líneas, fusión al iniciar sesión y totales calculados en servidor.
import { and, eq, inArray, lt, sql } from 'drizzle-orm';
import { availability, getVariants, storeCurrency } from '../catalog/index.ts';
import { type Executor, getDb, withTransaction } from '../db/index.ts';
import { money } from '../money/index.ts';
import { CART_TTL_DAYS, cartCookie, cartIdFromCookie } from './cookie.ts';
import { type Cart, cartLines, carts } from './schema.ts';
import { type CartTotals, emptyTotals, lineSubtotal, runSteps, sumLines, type TotalsLine } from './totals.ts';
export class CartError extends Error {
constructor(
readonly code: 'not_found' | 'invalid' | 'unavailable' | 'insufficient_stock',
message: string = code,
) {
super(message);
this.name = 'CartError';
}
}
export const MAX_QTY = 99;
export const MAX_LINES = 100;
const expires = () => new Date(Date.now() + CART_TTL_DAYS * 86_400_000);
async function byId(id: string, db: Executor): Promise<Cart | null> {
const [c] = await db.select().from(carts).where(and(eq(carts.id, id), sql`${carts.expiresAt} > now()`));
return c ?? null;
}
/**
* Carrito de la petición: el del usuario si tiene sesión (fusionando el de invitado de la cookie) o el de la cookie.
* No crea ninguno.
*/
export async function getCart(cookieHeader: string | null | undefined, userId: string | null, db: Executor = getDb()): Promise<Cart | null> {
const guestId = await cartIdFromCookie(cookieHeader);
const guest = guestId ? await byId(guestId, db) : null;
if (!userId) return guest && !guest.userId ? guest : null;
const [own] = await db.select().from(carts).where(and(eq(carts.userId, userId), sql`${carts.expiresAt} > now()`)).limit(1);
if (guest && !guest.userId && guest.id !== own?.id) return mergeInto(guest, own ?? null, userId, db);
return own ?? null;
}
/** Como `getCart`, pero crea el carrito si no existe y devuelve la cookie a fijar. */
export async function getOrCreateCart(cookieHeader: string | null | undefined, userId: string | null, db: Executor = getDb()): Promise<{ cart: Cart; setCookie: string | null }> {
const existing = await getCart(cookieHeader, userId, db);
if (existing) return { cart: existing, setCookie: userId ? null : await cartCookie(existing.id) };
const [cart] = await db.insert(carts).values({ userId, currency: storeCurrency(), expiresAt: expires() }).returning();
return { cart: cart!, setCookie: await cartCookie(cart!.id) };
}
/** El carrito de invitado pasa al usuario; si ya tenía uno, se suman las cantidades (con tope). */
async function mergeInto(guest: Cart, own: Cart | null, userId: string, db: Executor): Promise<Cart> {
if (!own) {
const [c] = await db.update(carts).set({ userId, expiresAt: expires() }).where(eq(carts.id, guest.id)).returning();
return c!;
}
return tx(db, async (t) => {
const lines = await t.select().from(cartLines).where(eq(cartLines.cartId, guest.id));
for (const l of lines)
await t
.insert(cartLines)
.values({ cartId: own.id, variantId: l.variantId, quantity: l.quantity })
.onConflictDoUpdate({ target: [cartLines.cartId, cartLines.variantId], set: { quantity: sql`least(${cartLines.quantity} + ${l.quantity}, ${MAX_QTY})` } });
const codes = [...new Set([...own.discountCodes, ...guest.discountCodes])];
await t.delete(carts).where(eq(carts.id, guest.id));
const [c] = await t.update(carts).set({ discountCodes: codes, expiresAt: expires() }).where(eq(carts.id, own.id)).returning();
return c!;
});
}
/** Fija la cantidad de una variante (0 = quitar). Comprueba que se puede vender y el stock disponible. */
export async function setLine(cartId: string, variantId: string, quantity: number, db: Executor = getDb()): Promise<void> {
if (!Number.isInteger(quantity) || quantity < 0 || quantity > MAX_QTY) throw new CartError('invalid', `quantity must be 0–${MAX_QTY}`);
if (quantity === 0) {
await db.delete(cartLines).where(and(eq(cartLines.cartId, cartId), eq(cartLines.variantId, variantId)));
return;
}
const [row] = await getVariants([variantId], db);
if (!row || !row.variant.active || row.product.status !== 'active') throw new CartError('unavailable', 'product not available');
const v = row.variant;
if (availability(v) === 'OutOfStock') throw new CartError('unavailable', 'out of stock');
if (v.trackStock && !v.allowBackorder && quantity > v.stock) throw new CartError('insufficient_stock', `only ${v.stock} left`);
const [{ n } = { n: 0 }] = await db.select({ n: sql<number>`count(*)`.mapWith(Number) }).from(cartLines).where(eq(cartLines.cartId, cartId));
if (n >= MAX_LINES) throw new CartError('invalid', 'cart is full');
await db
.insert(cartLines)
.values({ cartId, variantId, quantity })
.onConflictDoUpdate({ target: [cartLines.cartId, cartLines.variantId], set: { quantity } });
await db.update(carts).set({ expiresAt: expires() }).where(eq(carts.id, cartId));
}
/** Suma `quantity` a la línea existente (botón "añadir al carrito"). */
export async function addLine(cartId: string, variantId: string, quantity = 1, db: Executor = getDb()): Promise<void> {
const [cur] = await db.select().from(cartLines).where(and(eq(cartLines.cartId, cartId), eq(cartLines.variantId, variantId)));
await setLine(cartId, variantId, Math.min((cur?.quantity ?? 0) + quantity, MAX_QTY), db);
}
export async function setDiscountCodes(cartId: string, codes: string[], db: Executor = getDb()): Promise<void> {
const clean = [...new Set(codes.map((c) => c.trim().toUpperCase()).filter((c) => /^[A-Z0-9_-]{2,40}$/.test(c)))].slice(0, 5);
await db.update(carts).set({ discountCodes: clean }).where(eq(carts.id, cartId));
}
export async function setCartMeta(cartId: string, meta: Record<string, string>, db: Executor = getDb()): Promise<void> {
const [c] = await db.select().from(carts).where(eq(carts.id, cartId));
if (!c) throw new CartError('not_found');
const next = { ...c.meta };
for (const [k, v] of Object.entries(meta)) if (/^[a-z][a-zA-Z0-9_]{0,31}$/.test(k)) next[k] = String(v).slice(0, 200);
await db.update(carts).set({ meta: next }).where(eq(carts.id, cartId));
}
/**
* Totales con precios actuales de la base de datos. Las líneas que ya no se pueden vender se excluyen y se avisa en
* `issues`. Después corren los pasos registrados (descuentos, envío, impuestos).
*/
export async function computeTotals(cart: Cart, opts: { email?: string } = {}, db: Executor = getDb()): Promise<CartTotals> {
const base = emptyTotals(cart.id, cart.currency, { discountCodes: cart.discountCodes, meta: cart.meta, userId: cart.userId, ...(opts.email && { email: opts.email }) });
const rows = await db.select().from(cartLines).where(eq(cartLines.cartId, cart.id));
const variants = await getVariants(rows.map((r) => r.variantId), db);
const lines: TotalsLine[] = [];
for (const r of rows) {
const found = variants.find((x) => x.variant.id === r.variantId);
const v = found?.variant;
if (!found || !v || !v.active || found.product.status !== 'active' || availability(v) === 'OutOfStock' || v.currency !== cart.currency) {
base.issues.push({ code: 'unavailable', message: 'A product in your cart is no longer available', variantId: r.variantId });
continue;
}
let quantity = r.quantity;
if (v.trackStock && !v.allowBackorder && quantity > v.stock) {
quantity = v.stock;
base.issues.push({ code: 'quantity_reduced', message: `Only ${v.stock} left`, variantId: r.variantId });
}
lines.push({
variantId: v.id,
productId: found.product.id,
slug: found.product.slug,
name: found.product.name,
title: v.title,
sku: v.sku,
mediaId: v.mediaId,
kind: found.product.kind,
quantity,
unitPrice: money(v.priceAmount, v.currency),
subtotal: lineSubtotal(v.priceAmount, quantity, v.currency),
discount: money(0, v.currency),
weightGrams: v.weightGrams,
});
}
return runSteps({ ...base, lines, subtotal: sumLines(lines, cart.currency) });
}
export async function clearCart(cartId: string, db: Executor = getDb()): Promise<void> {
await db.delete(cartLines).where(eq(cartLines.cartId, cartId));
await db.update(carts).set({ discountCodes: [] }).where(eq(carts.id, cartId));
}
/** Borra carritos caducados (prográmalo a diario con @core/jobs). */
export async function pruneCarts(db: Executor = getDb()): Promise<number> {
const rows = await db.delete(carts).where(lt(carts.expiresAt, new Date())).returning({ id: carts.id });
return rows.length;
}
export async function cartLineCount(cartId: string, db: Executor = getDb()): Promise<number> {
const [r] = await db.select({ n: sql<number>`coalesce(sum(${cartLines.quantity}), 0)`.mapWith(Number) }).from(cartLines).where(inArray(cartLines.cartId, [cartId]));
return r?.n ?? 0;
}
function tx<T>(db: Executor, fn: (t: Executor) => Promise<T>): Promise<T> {
return 'rollback' in db ? fn(db) : withTransaction((t) => fn(t), db as Parameters<typeof withTransaction>[1]);
}
이 패키지는 MCP 서버를 선언하지 않습니다.
| 버전 | 커밋 | 게시일 | 검사 |
|---|---|---|---|
| 1.1.0 | 787ea88 | 7시간 전 | 검사 통과 |
- npm
- zod ^4.0.0
- 제안됨
- GenPM은 npm 명령을 제안하고, 동의한 경우에만 실행합니다.
- 검사
- 검사 통과 · 문제 0건
- 커밋
- v1.1.0 → 787ea88d1eb25e11257f051f971c0bcce52803f6 · 가져온 뒤 검증됨
- 스크립트
- 없음. GenPM은 패키지 코드를 절대 실행하지 않습니다.
- 라이선스
- MIT
- 품질
- 100/100
- 인정된 라이선스충족
- AGENTS.md에 목적 설명충족
- AGENTS.md에 통합 단계충족
- AGENTS.md에 규칙 또는 금지 사항충족
- 테스트 포함충족
- 보안 검사 통과충족
- 최근 6개월 내 게시충족
- 인증된 게시자충족
- 요약과 키워드충족
- 신고
- 문제가 있나요?