割引コードと自動割引:率・定額・送料無料、商品/カテゴリ指定、利用回数と初回限定の制限
インストール
genpm add @core/discounts含まれるもの
- src/lib/discounts/ にソースコード(6 ファイル)。 (21.5 KB)
- src/lib/discounts/AGENTS.md に AI ルール、加えて IDE 用のルールファイル。
- @core/cart, @core/catalog, @core/db, @core/money, @core/orders を自動で解決します。
README
このパッケージには README がありません。
これは AI が src/lib/discounts で作業するときに読む内容そのものです。それ以外はコンテキストに追加されません。
@core/discounts — rules for AI agents
Purpose
Discount codes and automatic discounts for @core/cart: percent, fixed amount or free shipping; optional scope by
product or category slugs, minimum order, start/end dates, total and per-customer usage limits, first-order-only and
combinability (non-combinable ones compete: the biggest wins). Registers the discounts (100) and free-shipping
(250) totals steps and records redemptions inside the transaction that creates/pays the order (onOrderPaidTx of
@core/orders), with the discount row locked. Limits count recorded redemptions plus open payments carrying the code
(pendingCodeUses of @core/cart, fed by @core/checkout). If a limit was exceeded anyway (the customer already paid),
the order is kept, the redemption recorded and a discount_over_limit event is added to the order timeline for review.
Tables: discounts, discount_redemptions.
Map
index.ts— public API:upsertDiscount,DiscountInput,discountsStep,freeShippingStep,recordRedemptions,discountsAdminResource.discounts.ts— validation, application and the redemption handler (registered on import).admin.ts— admin resource.
Integration
- Migrations as in
src/lib/db/AGENTS.md. Import this module at startup (it registers its steps and handler). - Cart page: a code field calling
PUT /api/cart/codes {codes: [code]}(@core/cart); showtotals.discountsand anyinvalid_codeissues. - Pass the customer email to
computeTotals(cart, { email })once known so per-customer and first-order limits apply. - Add
discountsAdminResourcetosrc/genpm/admin.ts(permissionsdiscounts:read|update). - Verify: create
TEN(10 %), apply it, the total drops and each line shows its share.
Conventions
- Codes are stored uppercase (
A–Z 0–9 _ -); values: percent 1–100, fixed in minor units. - Deleting a discount deactivates it (redemption history stays).
- Free-shipping adjustments carry their
code(amount 0), so they count towards usage limits. - Check the order timeline (
discount_over_limit) when a usage-limited campaign ends. - Prefer automatic discounts for store-wide sales; codes for campaigns.
Don't
- Don't show a "was" price or a percentage off that isn't real: in the EU a price reduction must refer to the lowest price of the previous 30 days (Omnibus Directive). Use @core/pricing's price history for compare-at prices.
- Don't apply discounts on the client or trust discount amounts sent by the browser.
- Don't create fake urgency (countdowns that reset, "only today" when it isn't).
# @core/discounts — rules for AI agents
## Purpose
Discount codes and automatic discounts for @core/cart: percent, fixed amount or free shipping; optional scope by
product or category slugs, minimum order, start/end dates, total and per-customer usage limits, first-order-only and
combinability (non-combinable ones compete: the biggest wins). Registers the `discounts` (100) and `free-shipping`
(250) totals steps and records redemptions inside the transaction that creates/pays the order (`onOrderPaidTx` of
@core/orders), with the discount row locked. Limits count recorded redemptions plus open payments carrying the code
(`pendingCodeUses` of @core/cart, fed by @core/checkout). If a limit was exceeded anyway (the customer already paid),
the order is kept, the redemption recorded and a `discount_over_limit` event is added to the order timeline for review.
Tables: `discounts`, `discount_redemptions`.
## Map
- `index.ts` — public API: `upsertDiscount`, `DiscountInput`, `discountsStep`, `freeShippingStep`, `recordRedemptions`, `discountsAdminResource`.
- `discounts.ts` — validation, application and the redemption handler (registered on import). `admin.ts` — admin resource.
## Integration
1. Migrations as in `src/lib/db/AGENTS.md`. Import this module at startup (it registers its steps and handler).
2. Cart page: a code field calling `PUT /api/cart/codes {codes: [code]}` (@core/cart); show `totals.discounts` and any `invalid_code` issues.
3. Pass the customer email to `computeTotals(cart, { email })` once known so per-customer and first-order limits apply.
4. Add `discountsAdminResource` to `src/genpm/admin.ts` (permissions `discounts:read|update`).
5. Verify: create `TEN` (10 %), apply it, the total drops and each line shows its share.
## Conventions
- Codes are stored uppercase (`A–Z 0–9 _ -`); values: percent 1–100, fixed in minor units.
- Deleting a discount deactivates it (redemption history stays).
- Free-shipping adjustments carry their `code` (amount 0), so they count towards usage limits.
- Check the order timeline (`discount_over_limit`) when a usage-limited campaign ends.
- Prefer automatic discounts for store-wide sales; codes for campaigns.
## Don't
- Don't show a "was" price or a percentage off that isn't real: in the EU a price reduction must refer to the lowest
price of the previous 30 days (Omnibus Directive). Use @core/pricing's price history for compare-at prices.
- Don't apply discounts on the client or trust discount amounts sent by the browser.
- Don't create fake urgency (countdowns that reset, "only today" when it isn't).
.genpmignore 適用後に組み込まれる正確なツリーです。固定先:
// Validación y aplicación de descuentos como pasos de la tubería de totales de @core/cart, y registro de usos al
// pagarse el pedido (@core/orders), en la misma transacción y con la fila del descuento bloqueada.
import { and, asc, count, eq, inArray, isNull, ne, or, sql } from 'drizzle-orm';
import { z } from 'zod';
import { type CartTotals, pendingCodeUses, registerTotalsStep } from '../cart/index.ts';
import { categories, productCategories } from '../catalog/index.ts';
import { type Executor, getDb } from '../db/index.ts';
import { add, allocate, money, percent, sub, zero } from '../money/index.ts';
import { type Order, onOrderPaidTx, orderEvents, orders } from '../orders/index.ts';
import { type Discount, discountRedemptions, discounts } from './schema.ts';
export const DiscountInput = z
.object({
code: z
.string()
.trim()
.toUpperCase()
.regex(/^[A-Z0-9_-]{2,40}$/)
.nullish(),
name: z.string().min(1).max(80),
type: z.enum(['percent', 'fixed', 'free_shipping']),
value: z.number().int().min(0).default(0),
minSubtotal: z.number().int().min(0).nullish(),
productSlugs: z.array(z.string()).max(100).default([]),
categorySlugs: z.array(z.string()).max(50).default([]),
firstOrderOnly: z.boolean().default(false),
usageLimit: z.number().int().min(1).nullish(),
perCustomerLimit: z.number().int().min(1).nullish(),
combinable: z.boolean().default(false),
startsAt: z.coerce.date().nullish(),
endsAt: z.coerce.date().nullish(),
active: z.boolean().default(true),
})
.refine((d) => d.type !== 'percent' || (d.value >= 1 && d.value <= 100), 'percent must be 1–100')
.refine((d) => d.type !== 'fixed' || d.value > 0, 'fixed amount must be positive')
.refine((d) => !d.startsAt || !d.endsAt || d.startsAt < d.endsAt, 'end must be after start');
export async function upsertDiscount(input: z.input<typeof DiscountInput>, id?: string, db: Executor = getDb()): Promise<Discount> {
const d = DiscountInput.parse(input);
const values = { ...d, code: d.code ?? null, minSubtotal: d.minSubtotal ?? null, usageLimit: d.usageLimit ?? null, perCustomerLimit: d.perCustomerLimit ?? null, startsAt: d.startsAt ?? null, endsAt: d.endsAt ?? null };
const [row] = id ? await db.update(discounts).set(values).where(eq(discounts.id, id)).returning() : await db.insert(discounts).values(values).returning();
return row!;
}
type Candidate = { d: Discount; amount: number; lineAmounts: number[] };
async function eligibleLines(t: CartTotals, d: Discount, db: Executor): Promise<boolean[]> {
if (!d.productSlugs.length && !d.categorySlugs.length) return t.lines.map(() => true);
let inCategory = new Set<string>();
if (d.categorySlugs.length) {
const rows = await db
.select({ productId: productCategories.productId })
.from(productCategories)
.innerJoin(categories, eq(categories.id, productCategories.categoryId))
.where(and(inArray(categories.slug, d.categorySlugs), inArray(productCategories.productId, t.lines.map((l) => l.productId))));
inCategory = new Set(rows.map((r) => r.productId));
}
return t.lines.map((l) => d.productSlugs.includes(l.slug) || inCategory.has(l.productId));
}
const PAID = ['paid', 'partially_refunded', 'refunded'] as const;
type Who = { email?: string; userId?: string | null };
const whoRedeemed = (who: Who) => or(who.email ? eq(discountRedemptions.email, who.email) : undefined, who.userId ? eq(discountRedemptions.userId, who.userId) : undefined);
const whoOrdered = (who: Who) => or(who.email ? eq(orders.email, who.email) : undefined, who.userId ? eq(orders.userId, who.userId) : undefined);
/** Usos ya registrados (sin contar el pedido `exceptOrderId`): de todos y de este cliente. */
async function redemptions(d: Discount, who: Who, db: Executor, exceptOrderId?: string): Promise<{ total: number; mine: number }> {
const base = and(eq(discountRedemptions.discountId, d.id), exceptOrderId ? ne(discountRedemptions.orderId, exceptOrderId) : undefined);
const [all] = d.usageLimit != null ? await db.select({ n: count() }).from(discountRedemptions).where(base) : [{ n: 0 }];
const [mine] = d.perCustomerLimit != null && (who.email || who.userId) ? await db.select({ n: count() }).from(discountRedemptions).where(and(base, whoRedeemed(who))) : [{ n: 0 }];
return { total: all?.n ?? 0, mine: mine?.n ?? 0 };
}
/** ¿Tiene el cliente pedidos pagados (sin contar `exceptOrderId`)? */
async function hasPaidOrders(who: Who, db: Executor, exceptOrderId?: string): Promise<boolean> {
const [r] = await db
.select({ n: count() })
.from(orders)
.where(and(whoOrdered(who), inArray(orders.status, [...PAID]), exceptOrderId ? ne(orders.id, exceptOrderId) : undefined));
return (r?.n ?? 0) > 0;
}
/**
* Por qué no aplica un descuento (o null si aplica). Los límites cuentan los usos registrados más los pagos abiertos
* con ese código (reservas de @core/checkout vía `pendingCodeUses`), salvo los del propio carrito.
*/
async function rejection(t: CartTotals, d: Discount, now: Date, db: Executor): Promise<string | null> {
if (!d.active || (d.startsAt && d.startsAt > now) || (d.endsAt && d.endsAt <= now)) return 'This code is not active';
const remaining = sub(t.subtotal, t.discountTotal).amount;
if (d.minSubtotal != null && remaining < d.minSubtotal) return 'Minimum order not reached';
const email = t.context.email?.toLowerCase();
const who: Who = { email, userId: t.context.userId };
if (d.firstOrderOnly && !email && !t.context.userId) return 'Sign in or enter your email to use this code';
const limited = d.usageLimit != null || d.perCustomerLimit != null || d.firstOrderOnly;
const pending = limited && d.code ? await pendingCodeUses(d.code, { email, userId: t.context.userId, excludeCartId: t.cartId }, db) : { total: 0, mine: 0 };
const used = await redemptions(d, who, db);
if (d.usageLimit != null && used.total + pending.total >= d.usageLimit) return 'This code has been used up';
if (d.perCustomerLimit != null && (email || t.context.userId) && used.mine + pending.mine >= d.perCustomerLimit) return 'You have already used this code';
if (d.firstOrderOnly && (pending.mine > 0 || (await hasPaidOrders(who, db)))) return 'This code is only for first orders';
return null;
}
async function candidate(t: CartTotals, d: Discount, db: Executor): Promise<Candidate | null> {
if (d.type === 'free_shipping') return { d, amount: 0, lineAmounts: t.lines.map(() => 0) };
const eligible = await eligibleLines(t, d, db);
const base = t.lines.map((l, i) => (eligible[i] ? sub(l.subtotal, l.discount).amount : 0));
const baseTotal = base.reduce((a, b) => a + b, 0);
if (baseTotal <= 0) return null;
const amount = Math.min(d.type === 'percent' ? percent(money(baseTotal, t.currency), d.value).amount : d.value, baseTotal);
return { d, amount, lineAmounts: allocate(money(amount, t.currency), base).map((m) => m.amount) };
}
function apply(t: CartTotals, c: Candidate): CartTotals {
const amount = money(c.amount, t.currency);
return {
...t,
lines: t.lines.map((l, i) => ({ ...l, discount: add(l.discount, money(c.lineAmounts[i] ?? 0, t.currency)) })),
discounts: [...t.discounts, { source: 'discounts', label: c.d.name, amount, ...(c.d.code && { code: c.d.code }) }],
discountTotal: add(t.discountTotal, amount),
};
}
/**
* Paso de descuentos (orden 100): automáticos vigentes + códigos del carrito. Los combinables se suman; de los no
* combinables gana el de mayor importe. Los códigos que no aplican generan un aviso `invalid_code`.
*/
export async function discountsStep(t: CartTotals, db: Executor = getDb(), now = new Date()): Promise<CartTotals> {
if (!t.lines.length) return t;
const codes = t.context.discountCodes;
const rows = await db
.select()
.from(discounts)
.where(or(and(isNull(discounts.code), eq(discounts.active, true)), codes.length ? inArray(discounts.code, codes) : sql`false`));
const issues = [...t.issues];
for (const code of codes) if (!rows.some((r) => r.code === code)) issues.push({ code: 'invalid_code', message: `Code ${code} is not valid` });
const valid: Candidate[] = [];
for (const d of rows) {
const why = await rejection(t, d, now, db);
if (why) {
if (d.code) issues.push({ code: 'invalid_code', message: why });
continue;
}
const c = await candidate(t, d, db);
if (c) valid.push(c);
else if (d.code) issues.push({ code: 'invalid_code', message: `Code ${d.code} does not apply to these products` });
}
let out: CartTotals = { ...t, issues };
const exclusive = valid.filter((c) => !c.d.combinable && c.d.type !== 'free_shipping').sort((a, b) => b.amount - a.amount);
const chosen = [...valid.filter((c) => c.d.combinable && c.d.type !== 'free_shipping'), ...exclusive.slice(0, 1)];
for (const c of chosen) {
// Recalcula sobre lo que queda tras los anteriores para no descontar dos veces la misma parte.
const fresh = await candidate(out, c.d, db);
if (fresh && fresh.amount > 0) out = apply(out, fresh);
}
const free = valid.find((c) => c.d.type === 'free_shipping');
if (free) out = { ...out, context: { ...out.context, meta: { ...out.context.meta, freeShipping: free.d.code ?? free.d.name, ...(free.d.code && { freeShippingCode: free.d.code }) } } };
return out;
}
/**
* Paso de envío gratis (orden 250, tras el envío): pone a cero la tarifa si un descuento lo concede. El ajuste lleva
* el código (si lo hay) para que su uso se registre y cuente para los límites.
*/
export function freeShippingStep(t: CartTotals): CartTotals {
const label = t.context.meta.freeShipping;
if (!label || !t.shipping || t.shipping.amount.amount === 0) return t;
const code = t.context.meta.freeShippingCode;
return {
...t,
shipping: { ...t.shipping, amount: zero(t.currency) },
discounts: [...t.discounts, { source: 'discounts', label: `Free shipping (${label})`, amount: zero(t.currency), ...(code && { code }) }],
};
}
/** Por qué este pedido ya no cabía en los límites del descuento (o null). Se evalúa con la fila bloqueada. */
async function overLimit(d: Discount, o: Order, db: Executor): Promise<string | null> {
const who: Who = { email: o.email, userId: o.userId };
const used = await redemptions(d, who, db, o.id);
if (d.usageLimit != null && used.total >= d.usageLimit) return 'usage_limit';
if (d.perCustomerLimit != null && used.mine >= d.perCustomerLimit) return 'per_customer_limit';
if (d.firstOrderOnly && (await hasPaidOrders(who, db, o.id))) return 'first_order_only';
return null;
}
/**
* Registra los usos de los códigos de un pedido pagado en su misma transacción, bloqueando las filas de los
* descuentos (FOR UPDATE) para que pagos simultáneos se serialicen y vuelvan a comprobar los límites. Si el límite
* ya se había superado, el pedido sigue adelante (el cliente ya pagó con ese precio) pero el uso se registra igual y
* queda un evento `discount_over_limit` en el historial del pedido para que el equipo lo revise.
*/
export async function recordRedemptions(o: Order, tx: Executor): Promise<void> {
const codes = [...new Set(o.discounts.flatMap((d) => (d.code ? [d.code] : [])))];
if (!codes.length) return;
const rows = await tx.select().from(discounts).where(inArray(discounts.code, codes)).orderBy(asc(discounts.id)).for('update');
for (const d of rows) {
const why = await overLimit(d, o, tx);
const amount = o.discounts.filter((x) => x.code === d.code).reduce((a, x) => a + x.amount, 0);
const [inserted] = await tx.insert(discountRedemptions).values({ discountId: d.id, orderId: o.id, email: o.email, userId: o.userId, amount }).onConflictDoNothing().returning();
if (inserted && why) await tx.insert(orderEvents).values({ orderId: o.id, type: 'discount_over_limit', data: { code: d.code, discountId: d.id, limit: why, amount } });
}
}
/** Registra los pasos y el registro de usos al pagarse un pedido. Se llama al importar el módulo. */
export function registerDiscounts(): void {
registerTotalsStep({ name: 'discounts', order: 100, run: (t) => discountsStep(t) });
registerTotalsStep({ name: 'free-shipping', order: 250, run: freeShippingStep });
onOrderPaidTx('discounts.redemptions', recordRedemptions);
}
registerDiscounts();
このパッケージは MCP サーバーを宣言していません。
| バージョン | コミット | 公開日 | スキャン |
|---|---|---|---|
| 1.0.1 | 6b23d34 | 6 時間前 | スキャン合格 |
- npm
- zod ^4.0.0
- 提案
- GenPM は npm コマンドを提案し、あなたが承認した場合にのみ実行します。
- 利用元(1)
- @core/kit-store ^1.0.0
- スキャン
- スキャン合格 · 指摘 0 件
- コミット
- v1.0.1 → 6b23d346ddd5be7a9154a7106859687008386baa · 取得後に検証済み
- スクリプト
- なし。GenPM はパッケージのコードを実行しません。
- ライセンス
- MIT
- 品質
- 100/100
- 認識されたライセンス達成
- AGENTS.md に目的の説明がある達成
- AGENTS.md に統合手順がある達成
- AGENTS.md に規約や禁止事項がある達成
- テストを含む達成
- セキュリティスキャンに合格達成
- 過去 6 か月以内に公開達成
- 認証済みの公開者達成
- 概要とキーワード達成
- 報告
- 問題を見つけましたか?