장바구니: 서명된 게스트 쿠키, 로그인 시 병합, 서버 측 가격, 확장 가능한 합계 계산
코드파일 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 적용 후 주입될 정확한 트리입니다. 고정 대상:
// Cálculo de totales siempre en servidor, con precios actuales de @core/catalog, y una tubería de pasos que otros
// módulos registran: descuentos (@core/discounts), envío (@core/shipping), impuestos (checkout/Stripe Tax).
import type { Money } from '../contracts/index.ts';
import type { Executor } from '../db/index.ts';
import { add, allocate, money, sub, sum, zero } from '../money/index.ts';
export type TotalsLine = {
variantId: string;
productId: string;
slug: string;
name: string;
title: string;
sku: string | null;
mediaId: string | null;
kind: 'physical' | 'digital';
quantity: number;
unitPrice: Money;
/** unitPrice × quantity, antes de descuentos. */
subtotal: Money;
/** Parte de los descuentos de pedido asignada a esta línea (para impuestos y reembolsos). */
discount: Money;
weightGrams: number | null;
};
export type Adjustment = { source: string; label: string; amount: Money; code?: string };
export type CartTotals = {
cartId: string;
currency: string;
lines: TotalsLine[];
subtotal: Money;
discounts: Adjustment[];
discountTotal: Money;
shipping: Adjustment | null;
/** Impuestos añadidos al total (si los precios no los incluyen). */
tax: Adjustment | null;
total: Money;
/** Avisos para el cliente: producto agotado, código no válido, sin envío a ese país… */
issues: Array<{ code: string; message: string; variantId?: string }>;
/** Lo que el carrito pide a los pasos (códigos, país, tarifa elegida). */
context: { discountCodes: string[]; meta: Record<string, string>; userId: string | null; email?: string };
};
export type TotalsStep = { name: string; order: number; run(t: CartTotals): Promise<CartTotals> | CartTotals };
const steps: TotalsStep[] = [];
/** Registra un paso. Orden recomendado: descuentos 100, envío 200, impuestos 300. */
export function registerTotalsStep(step: TotalsStep): void {
const i = steps.findIndex((s) => s.name === step.name);
if (i >= 0) steps.splice(i, 1);
steps.push(step);
steps.sort((a, b) => a.order - b.order);
}
/** Solo tests. */
export const clearTotalsSteps = () => {
steps.splice(0);
pendingCounters.clear();
};
/**
* Usos de un código de descuento que aún no son pedido (p. ej. pagos abiertos de @core/checkout): `total` de todos los
* clientes y `mine` de este cliente. Los cuenta quien abre pagos y los consulta quien limita usos (@core/discounts).
*/
export type PendingCodeUses = (code: string, who: { email?: string; userId: string | null; excludeCartId: string }, db: Executor) => Promise<{ total: number; mine: number }>;
const pendingCounters = new Map<string, PendingCodeUses>();
export function registerPendingCodeUses(name: string, fn: PendingCodeUses): void {
pendingCounters.set(name, fn);
}
/** Suma de los usos pendientes de `code` según todos los contadores registrados (excluye el carrito `excludeCartId`). */
export async function pendingCodeUses(code: string, who: Parameters<PendingCodeUses>[1], db: Executor): Promise<{ total: number; mine: number }> {
let total = 0;
let mine = 0;
for (const fn of pendingCounters.values()) {
const r = await fn(code, who, db);
total += r.total;
mine += r.mine;
}
return { total, mine };
}
/** Aplica un descuento de pedido: lo reparte entre líneas proporcionalmente (sin perder céntimos). */
export function applyOrderDiscount(t: CartTotals, adj: Adjustment): CartTotals {
const remaining = sub(sub(t.subtotal, t.discountTotal), adj.amount);
const amount = remaining.amount < 0 ? sub(t.subtotal, t.discountTotal) : adj.amount;
const parts = allocate(amount, t.lines.map((l) => sub(l.subtotal, l.discount).amount));
return {
...t,
lines: t.lines.map((l, i) => ({ ...l, discount: add(l.discount, parts[i]!) })),
discounts: [...t.discounts, { ...adj, amount }],
discountTotal: add(t.discountTotal, amount),
};
}
export async function runSteps(base: CartTotals): Promise<CartTotals> {
let t = base;
for (const s of steps) t = await s.run(t);
const total = add(sub(t.subtotal, t.discountTotal), t.shipping?.amount ?? zero(t.currency), t.tax?.amount ?? zero(t.currency));
return { ...t, total: total.amount < 0 ? zero(t.currency) : total };
}
export const emptyTotals = (cartId: string, currency: string, context: CartTotals['context']): CartTotals => ({
cartId,
currency,
lines: [],
subtotal: zero(currency),
discounts: [],
discountTotal: zero(currency),
shipping: null,
tax: null,
total: zero(currency),
issues: [],
context,
});
export const lineSubtotal = (unit: number, qty: number, currency: string) => money(unit * qty, currency);
export const sumLines = (lines: TotalsLine[], currency: string) => sum(lines.map((l) => l.subtotal), currency);
이 패키지는 MCP 서버를 선언하지 않습니다.
| 버전 | 커밋 | 게시일 | 검사 |
|---|---|---|---|
| 1.1.0 | 787ea88 | 3시간 전 | 검사 통과 |
- npm
- zod ^4.0.0
- 제안됨
- GenPM은 npm 명령을 제안하고, 동의한 경우에만 실행합니다.
- 검사
- 검사 통과 · 문제 0건
- 커밋
- v1.1.0 → 787ea88d1eb25e11257f051f971c0bcce52803f6 · 가져온 뒤 검증됨
- 스크립트
- 없음. GenPM은 패키지 코드를 절대 실행하지 않습니다.
- 라이선스
- MIT
- 품질
- 100/100
- 인정된 라이선스충족
- AGENTS.md에 목적 설명충족
- AGENTS.md에 통합 단계충족
- AGENTS.md에 규칙 또는 금지 사항충족
- 테스트 포함충족
- 보안 검사 통과충족
- 최근 6개월 내 게시충족
- 인증된 게시자충족
- 요약과 키워드충족
- 신고
- 문제가 있나요?