배송 지역과 요금(정액·무게·금액·X 이상 무료), 예상 배송일, 배송 추적 URL
코드파일 6개컨텍스트약 445토큰검사 통과
설치
$
genpm add @core/shipping포함 내용
- src/lib/shipping/에 소스 코드, 파일 6개. (15kB)
- src/lib/shipping/AGENTS.md에 AI 규칙, 그리고 IDE 규칙 파일.
- @core/cart, @core/contracts, @core/db, @core/money을(를) 자동으로 해결합니다.
README
이 패키지에는 README가 없습니다.
약 445토큰→ src/lib/shipping/AGENTS.md→ .cursor/rules/genpm-core-shipping.mdc
이것이 AI가 src/lib/shipping에서 작업할 때 읽는 내용 그대로입니다. 그 외에는 컨텍스트에 아무것도 추가되지 않습니다.
@core/shipping — rules for AI agents
Purpose
Shipping zones (ISO country lists, * = rest of world) and rates per zone: flat price, weight and order-value
ranges, free shipping over an amount (after discounts) and delivery estimates in days. Registers the shipping
step (order 200) of the @core/cart totals pipeline and builds tracking URLs for common carriers.
No label purchase or live carrier quotes. Tables: shipping_zones, shipping_rates.
Map
index.ts— public API:upsertZone,upsertRate,ratesFor,zoneFor,trackingUrl,CARRIERS,shippingAdminResources.shipping.ts— rules and the totals step.admin.ts— admin resources.schema.ts— tables.
Integration
- Migrations as in
src/lib/db/AGENTS.md. Import this module at startup (it registers the totals step). - Create zones and rates in the admin (
...shippingAdminResources()insrc/genpm/admin.ts, permissionsshipping:read|update). - At checkout, store the country and the chosen rate on the cart:
setCartMeta(cart.id, { country: 'ES', shippingRateId }); list options withratesFor(totals, country)and showminDays–maxDaysbefore payment. - Tracking:
trackingUrl(carrier, number)when marking an order shipped (@core/orders). - Verify: a cart with country
ESgets the cheapest Spanish rate; one over the free threshold pays 0.
Conventions
- Prices in minor units of
STORE_CURRENCY; weights in grams from catalog variants. - Digital-only carts never get shipping.
- Show
no_shippingissues clearly and block checkout for those countries.
Don't
- Don't hide delivery times or shipping costs until after payment (EU consumer law requires them upfront).
- Don't trust a rate id or amount sent by the browser; the totals step recomputes it.
# @core/shipping — rules for AI agents
## Purpose
Shipping zones (ISO country lists, `*` = rest of world) and rates per zone: flat price, weight and order-value
ranges, free shipping over an amount (after discounts) and delivery estimates in days. Registers the `shipping`
step (order 200) of the @core/cart totals pipeline and builds tracking URLs for common carriers.
No label purchase or live carrier quotes. Tables: `shipping_zones`, `shipping_rates`.
## Map
- `index.ts` — public API: `upsertZone`, `upsertRate`, `ratesFor`, `zoneFor`, `trackingUrl`, `CARRIERS`, `shippingAdminResources`.
- `shipping.ts` — rules and the totals step. `admin.ts` — admin resources. `schema.ts` — tables.
## Integration
1. Migrations as in `src/lib/db/AGENTS.md`. Import this module at startup (it registers the totals step).
2. Create zones and rates in the admin (`...shippingAdminResources()` in `src/genpm/admin.ts`, permissions `shipping:read|update`).
3. At checkout, store the country and the chosen rate on the cart: `setCartMeta(cart.id, { country: 'ES', shippingRateId })`;
list options with `ratesFor(totals, country)` and show `minDays–maxDays` before payment.
4. Tracking: `trackingUrl(carrier, number)` when marking an order shipped (@core/orders).
5. Verify: a cart with country `ES` gets the cheapest Spanish rate; one over the free threshold pays 0.
## Conventions
- Prices in minor units of `STORE_CURRENCY`; weights in grams from catalog variants.
- Digital-only carts never get shipping.
- Show `no_shipping` issues clearly and block checkout for those countries.
## Don't
- Don't hide delivery times or shipping costs until after payment (EU consumer law requires them upfront).
- Don't trust a rate id or amount sent by the browser; the totals step recomputes it.
.genpmignore 적용 후 주입될 정확한 트리입니다. 고정 대상:
// Tarifas por zona y paso de envío de la tubería de totales de @core/cart.
import { and, asc, eq } from 'drizzle-orm';
import { z } from 'zod';
import { type CartTotals, registerTotalsStep } from '../cart/index.ts';
import { type Executor, getDb } from '../db/index.ts';
import { money, sub } from '../money/index.ts';
import { type ShippingRate, type ShippingZone, shippingRates, shippingZones } from './schema.ts';
const Country = z.string().regex(/^([A-Z]{2}|\*)$/);
export const ZoneInput = z.object({ id: z.string().optional(), name: z.string().min(1).max(80), countries: z.array(Country).min(1).max(250), position: z.number().int().default(0) });
const Int = z.number().int().min(0).nullish();
export const RateInput = z.object({
id: z.string().optional(),
zoneId: z.string(),
name: z.string().min(1).max(80),
amount: z.number().int().min(0),
freeOver: Int,
minWeightGrams: Int,
maxWeightGrams: Int,
minSubtotal: Int,
maxSubtotal: Int,
minDays: Int,
maxDays: Int,
active: z.boolean().default(true),
position: z.number().int().default(0),
});
export async function upsertZone(input: z.input<typeof ZoneInput>, db: Executor = getDb()): Promise<ShippingZone> {
const z1 = ZoneInput.parse(input);
const values = { name: z1.name, countries: [...new Set(z1.countries)], position: z1.position };
const [row] = z1.id
? await db.update(shippingZones).set(values).where(eq(shippingZones.id, z1.id)).returning()
: await db.insert(shippingZones).values(values).returning();
return row!;
}
export async function upsertRate(input: z.input<typeof RateInput>, db: Executor = getDb()): Promise<ShippingRate> {
const r = RateInput.parse(input);
const { id, ...rest } = r;
const n = (v: number | null | undefined) => v ?? null;
const values = {
...rest,
freeOver: n(rest.freeOver),
minWeightGrams: n(rest.minWeightGrams),
maxWeightGrams: n(rest.maxWeightGrams),
minSubtotal: n(rest.minSubtotal),
maxSubtotal: n(rest.maxSubtotal),
minDays: n(rest.minDays),
maxDays: n(rest.maxDays),
};
const [row] = id ? await db.update(shippingRates).set(values).where(eq(shippingRates.id, id)).returning() : await db.insert(shippingRates).values(values).returning();
return row!;
}
/** Zona de un país: la que lo nombra explícitamente o, si no hay, la de `*`. */
export async function zoneFor(country: string, db: Executor = getDb()): Promise<ShippingZone | null> {
const zones = await db.select().from(shippingZones).orderBy(asc(shippingZones.position), asc(shippingZones.id));
return zones.find((z1) => z1.countries.includes(country)) ?? zones.find((z1) => z1.countries.includes('*')) ?? null;
}
export type RateOption = { id: string; name: string; amount: number; currency: string; minDays: number | null; maxDays: number | null; free: boolean };
/** Tarifas aplicables a un carrito y país (peso y subtotal tras descuentos), de la más barata a la más cara. */
export async function ratesFor(t: Pick<CartTotals, 'lines' | 'subtotal' | 'discountTotal' | 'currency'>, country: string, db: Executor = getDb()): Promise<RateOption[]> {
const zone = await zoneFor(country, db);
if (!zone) return [];
const rates = await db.select().from(shippingRates).where(and(eq(shippingRates.zoneId, zone.id), eq(shippingRates.active, true))).orderBy(asc(shippingRates.position));
const weight = t.lines.reduce((a, l) => a + (l.weightGrams ?? 0) * l.quantity, 0);
const subtotal = sub(t.subtotal, t.discountTotal).amount;
return rates
.filter(
(r) =>
(r.minWeightGrams == null || weight >= r.minWeightGrams) &&
(r.maxWeightGrams == null || weight <= r.maxWeightGrams) &&
(r.minSubtotal == null || subtotal >= r.minSubtotal) &&
(r.maxSubtotal == null || subtotal <= r.maxSubtotal),
)
.map((r) => {
const free = r.freeOver != null && subtotal >= r.freeOver;
return { id: r.id, name: r.name, amount: free ? 0 : r.amount, currency: t.currency, minDays: r.minDays, maxDays: r.maxDays, free };
})
.sort((a, b) => a.amount - b.amount);
}
/**
* Paso de totales (orden 200). Usa `meta.country` y `meta.shippingRateId` del carrito: sin país no hay envío
* todavía; con país, la tarifa elegida si sigue valiendo o la más barata; sin ninguna, avisa `no_shipping`.
* Carritos solo con productos digitales no llevan envío.
*/
export async function shippingStep(t: CartTotals): Promise<CartTotals> {
if (!t.lines.some((l) => l.kind === 'physical')) return t;
const country = t.context.meta.country;
if (!country) return t;
const options = await ratesFor(t, country);
if (!options.length) return { ...t, issues: [...t.issues, { code: 'no_shipping', message: `We don't ship to ${country}` }] };
const chosen = options.find((o) => o.id === t.context.meta.shippingRateId) ?? options[0]!;
return { ...t, shipping: { source: 'shipping', label: chosen.name, code: chosen.id, amount: money(chosen.amount, t.currency) } };
}
/** Activa el paso de envío en @core/cart (se llama al importar el módulo). */
export const registerShippingStep = () => registerTotalsStep({ name: 'shipping', order: 200, run: shippingStep });
registerShippingStep();
/** Plantillas de seguimiento de transportistas habituales (verifica las URL vigentes de los que uses). */
export const CARRIERS: Record<string, { name: string; url: (n: string) => string }> = {
correos: { name: 'Correos', url: (n) => `https://www.correos.es/es/es/herramientas/localizador/envios/detalle?tracking-number=${n}` },
seur: { name: 'SEUR', url: (n) => `https://www.seur.com/livetracking/?segOnlineIdentificador=${n}` },
gls: { name: 'GLS', url: (n) => `https://gls-group.com/track/${n}` },
dhl: { name: 'DHL', url: (n) => `https://www.dhl.com/global-en/home/tracking/tracking-express.html?tracking-id=${n}` },
ups: { name: 'UPS', url: (n) => `https://www.ups.com/track?tracknum=${n}` },
fedex: { name: 'FedEx', url: (n) => `https://www.fedex.com/fedextrack/?trknbr=${n}` },
usps: { name: 'USPS', url: (n) => `https://tools.usps.com/go/TrackConfirmAction?tLabels=${n}` },
royalmail: { name: 'Royal Mail', url: (n) => `https://www.royalmail.com/track-your-item#/tracking-results/${n}` },
generic: { name: '17TRACK', url: (n) => `https://t.17track.net/en#nums=${n}` },
};
/** URL de seguimiento (https) para un transportista conocido; si no se conoce, el rastreador genérico. */
export function trackingUrl(carrier: string | null | undefined, number: string): string {
const n = encodeURIComponent(number.trim().slice(0, 60));
return (CARRIERS[(carrier ?? '').toLowerCase().replace(/[^a-z]/g, '')] ?? CARRIERS.generic!).url(n);
}
이 패키지는 MCP 서버를 선언하지 않습니다.
| 버전 | 커밋 | 게시일 | 검사 |
|---|---|---|---|
| 1.0.0 | 4e8e68d | 4시간 전 | 검사 통과 |
- npm
- zod ^4.0.0
- 제안됨
- GenPM은 npm 명령을 제안하고, 동의한 경우에만 실행합니다.
- 사용하는 패키지 (2)
- @core/checkout ^1.0.0@core/kit-store ^1.0.0
- 검사
- 검사 통과 · 문제 0건
- 커밋
- v1.0.0 → 4e8e68d047c764a7a689d286d2e4119529ae11ee · 가져온 뒤 검증됨
- 스크립트
- 없음. GenPM은 패키지 코드를 절대 실행하지 않습니다.
- 라이선스
- MIT
- 품질
- 100/100
- 인정된 라이선스충족
- AGENTS.md에 목적 설명충족
- AGENTS.md에 통합 단계충족
- AGENTS.md에 규칙 또는 금지 사항충족
- 테스트 포함충족
- 보안 검사 통과충족
- 최근 6개월 내 게시충족
- 인증된 게시자충족
- 요약과 키워드충족
- 신고
- 문제가 있나요?