Shipping zones and rates (flat, weight, order value, free over X) with delivery estimates and tracking URLs
Install
genpm add @core/shippingWhat you get
- Source in src/lib/shipping/, 6 files. (15 kB)
- AI rules in src/lib/shipping/AGENTS.md, plus IDE rule files.
- Resolves @core/cart, @core/contracts, @core/db, @core/money for you.
README
This package has no README.
This is exactly what your AI reads when it works in src/lib/shipping. Nothing else is added to its context.
@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.
The exact tree that will be injected, after .genpmignore. Pinned to
// Panel: zonas y tarifas de envío.
import { asc, count, eq } from 'drizzle-orm';
import type { AdminContext, AdminResource } from '../contracts/index.ts';
import { getDb } from '../db/index.ts';
import { RateInput, upsertRate, upsertZone, ZoneInput } from './shipping.ts';
import { type ShippingRate, type ShippingZone, shippingRates, shippingZones } from './schema.ts';
class ShippingForbidden extends Error {
readonly code = 'forbidden';
}
async function need(ctx: AdminContext, action: 'read' | 'update') {
if (!(await ctx.can(`shipping:${action}`))) throw new ShippingForbidden(`forbidden: shipping:${action}`);
}
function resource<T extends { id: string }>(cfg: {
name: string;
label: { singular: string; plural: string };
table: typeof shippingZones | typeof shippingRates;
fields: AdminResource<T>['fields'];
input: AdminResource<T>['input'];
title: (r: T) => string;
save: (input: unknown) => Promise<T>;
}): AdminResource<T> {
return {
name: cfg.name,
label: cfg.label,
group: 'Store',
fields: cfg.fields,
input: cfg.input,
title: cfg.title,
async list(q, ctx) {
await need(ctx, 'read');
const [total] = await getDb().select({ n: count() }).from(cfg.table);
const rows = (await getDb().select().from(cfg.table).orderBy(asc(cfg.table.position)).limit(q.pageSize).offset((Math.max(q.page, 1) - 1) * q.pageSize)) as unknown as T[];
return { rows, total: total?.n ?? 0 };
},
async get(id, ctx) {
await need(ctx, 'read');
const [row] = (await getDb().select().from(cfg.table).where(eq(cfg.table.id, id))) as unknown as T[];
return row ?? null;
},
async create(input, ctx) {
await need(ctx, 'update');
return cfg.save(input);
},
async update(id, input, ctx) {
await need(ctx, 'update');
return cfg.save({ ...(input as object), id });
},
async delete(id, ctx) {
await need(ctx, 'update');
await getDb().delete(cfg.table).where(eq(cfg.table.id, id));
},
};
}
export const shippingZonesAdminResource = resource<ShippingZone>({
name: 'shipping-zones',
label: { singular: 'Shipping zone', plural: 'Shipping zones' },
table: shippingZones,
fields: [
{ name: 'name', label: 'Name', type: 'text', required: true, list: true },
{ name: 'countries', label: 'Countries (ISO codes, * = rest of world)', type: 'json', required: true, list: true },
{ name: 'position', label: 'Order', type: 'number' },
],
input: ZoneInput,
title: (z) => z.name,
save: (i) => upsertZone(i as never),
});
export const shippingRatesAdminResource = resource<ShippingRate>({
name: 'shipping-rates',
label: { singular: 'Shipping rate', plural: 'Shipping rates' },
table: shippingRates,
fields: [
{ name: 'zoneId', label: 'Zone', type: 'relation', relationTo: 'shipping-zones', required: true, list: true },
{ name: 'name', label: 'Name', type: 'text', required: true, list: true },
{ name: 'amount', label: 'Price', type: 'money', required: true, list: true },
{ name: 'freeOver', label: 'Free from', type: 'money' },
{ name: 'minWeightGrams', label: 'Min weight (g)', type: 'number' },
{ name: 'maxWeightGrams', label: 'Max weight (g)', type: 'number' },
{ name: 'minDays', label: 'Delivery from (days)', type: 'number' },
{ name: 'maxDays', label: 'Delivery to (days)', type: 'number' },
{ name: 'active', label: 'Active', type: 'boolean', list: true },
],
input: RateInput,
title: (r) => r.name,
save: (i) => upsertRate(i as never),
});
export const shippingAdminResources = () => [shippingZonesAdminResource, shippingRatesAdminResource];
This package declares no MCP servers.
| Version | Commit | Published | Scan |
|---|---|---|---|
| 1.0.0 | 4e8e68d | 4 hours ago | scan passed |
- npm
- zod ^4.0.0
- proposed
- GenPM proposes the npm command and runs it only if you say yes.
- Used by (2)
- @core/checkout ^1.0.0@core/kit-store ^1.0.0
- scan
- scan passed · 0 findings
- commit
- v1.0.0 → 4e8e68d047c764a7a689d286d2e4119529ae11ee · verified after fetch
- scripts
- None. GenPM never runs package code.
- license
- MIT
- Quality
- 100/100
- Recognized licensepassed
- AGENTS.md explains its purposepassed
- AGENTS.md has integration stepspassed
- AGENTS.md lists conventions or don'tspassed
- Includes testspassed
- Security scan passedpassed
- Released in the last 6 monthspassed
- Verified publisherpassed
- Summary and keywordspassed
- report
- See something wrong?