@saastemly/voidcommerce 0.1.0 → 0.2.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/catalog.js CHANGED
@@ -14,7 +14,7 @@ import {
14
14
  SIGN_IN,
15
15
  TAX,
16
16
  UI
17
- } from "./index-s7sq41qs.js";
17
+ } from "./index-z4qazajn.js";
18
18
  export {
19
19
  UI,
20
20
  TAX,
package/dist/cli.js CHANGED
@@ -22,16 +22,16 @@ import {
22
22
  runVoid,
23
23
  version,
24
24
  voidAppsIn
25
- } from "./index-wzy1xtr1.js";
25
+ } from "./index-xdsp97e4.js";
26
26
  import {
27
27
  LAYOUTS,
28
28
  readManifest,
29
29
  validate,
30
30
  zoneOf
31
- } from "./index-ssv3a6wc.js";
31
+ } from "./index-zh3tmj08.js";
32
32
  import {
33
33
  GROUPS
34
- } from "./index-s7sq41qs.js";
34
+ } from "./index-z4qazajn.js";
35
35
 
36
36
  // src/deploy/index.ts
37
37
  import color from "picocolors";
@@ -20,4 +20,17 @@ export declare function renderNotificationsTs(): string;
20
20
  * because the choice lives on the line, not in the catalogue.
21
21
  */
22
22
  export declare function renderMintTs(): string;
23
+ /**
24
+ * Where parcels leave from. Written once, then yours.
25
+ *
26
+ * Every carrier needs an origin to quote against, and it is shop data rather
27
+ * than a credential — a postcode changes when you move, not when a key
28
+ * rotates. So it is a file you edit, not an env var, and it ships with your
29
+ * own details already in the shape the carriers want.
30
+ *
31
+ * DHL in particular rejects a shipment whose origin has no name and phone,
32
+ * which is a failure that shows up at the first real order rather than at
33
+ * deploy, so both are marked rather than left blank.
34
+ */
35
+ export declare function renderShippingTs(manifest: Manifest): string;
23
36
  export declare function renderErpTs(): string;
@@ -11,11 +11,11 @@ import {
11
11
  workerHosts,
12
12
  writeManifest,
13
13
  zone
14
- } from "./index-ssv3a6wc.js";
14
+ } from "./index-zh3tmj08.js";
15
15
  import {
16
16
  CHOICES,
17
17
  __require
18
- } from "./index-s7sq41qs.js";
18
+ } from "./index-z4qazajn.js";
19
19
 
20
20
  // src/generate/auth.ts
21
21
  var VAT = {
@@ -38,9 +38,46 @@ var social = (manifest, id, envPrefix) => has(manifest, id) ? ` ${id}: { clien
38
38
  function renderAuthTs(manifest) {
39
39
  const { shop } = manifest;
40
40
  const lines = [];
41
+ const CARRIER_CALLS = {
42
+ "dhl-express": {
43
+ label: "DHL Express",
44
+ call: `dhlExpressProvider({ apiKey: must(env, "DHL_API_KEY"), apiSecret: must(env, "DHL_API_SECRET"), accountNumber: must(env, "DHL_ACCOUNT_NUMBER"), from: shipFrom })`,
45
+ import: `import { dhlExpressProvider } from "@saastemly/better-commerce/providers/dhl-express";`
46
+ },
47
+ shippo: {
48
+ label: "Shippo",
49
+ call: `shippoProvider({ apiToken: must(env, "SHIPPO_API_TOKEN"), from: shipFrom })`,
50
+ import: `import { shippoProvider } from "@saastemly/better-commerce/providers/shippo";`
51
+ },
52
+ easypost: {
53
+ label: "EasyPost",
54
+ call: `easypostProvider({ apiKey: must(env, "EASYPOST_API_KEY"), from: shipFrom })`,
55
+ import: `import { easypostProvider } from "@saastemly/better-commerce/providers/easypost";`
56
+ },
57
+ ups: {
58
+ label: "UPS",
59
+ call: `upsProvider({ clientId: must(env, "UPS_CLIENT_ID"), clientSecret: must(env, "UPS_CLIENT_SECRET"), accountNumber: must(env, "UPS_ACCOUNT_NUMBER"), from: shipFrom })`,
60
+ import: `import { upsProvider } from "@saastemly/better-commerce/providers/ups";`
61
+ },
62
+ fedex: {
63
+ label: "FedEx",
64
+ call: `fedexProvider({ clientId: must(env, "FEDEX_CLIENT_ID"), clientSecret: must(env, "FEDEX_CLIENT_SECRET"), accountNumber: must(env, "FEDEX_ACCOUNT_NUMBER"), from: shipFrom })`,
65
+ import: `import { fedexProvider } from "@saastemly/better-commerce/providers/fedex";`
66
+ },
67
+ nft: {
68
+ label: "A token, not a parcel",
69
+ call: `nftDelivery({ mint: mintProvider, destinationFor })`,
70
+ import: `import { nftDelivery } from "@saastemly/better-commerce/providers/nft-delivery";
71
+ import { mintProvider, destinationFor } from "./lib/mint.ts";`
72
+ }
73
+ };
74
+ const carriers = Object.keys(CARRIER_CALLS).filter((id) => has(manifest, id));
75
+ const carrierImports = carriers.map((id) => CARRIER_CALLS[id].import);
76
+ if (carriers.some((id) => id !== "nft"))
77
+ carrierImports.push(`import { shipFrom } from "./lib/shipping.ts";`);
41
78
  const wantsNft = has(manifest, "nft");
42
- const nftProvider = wantsNft ? `, nftOrPhysical({ mint: mintProvider, physical: ${has(manifest, "business-central") ? `erpFulfillment("${shop.currency.toUpperCase()}")` : "manualFulfillment()"} })` : "";
43
- const nftDefault = wantsNft ? `, defaultProvider: "nft-or-physical"` : "";
79
+ const carrierFulfillment = carriers.length ? `, deliveryFulfillment([${carriers.map((id) => CARRIER_CALLS[id].call).join(", ")}])` : "";
80
+ const nftDefault = carriers.length ? `, defaultProvider: "delivery:${carriers.join("+")}"` : "";
44
81
  const vat = VAT[shop.country];
45
82
  lines.push({
46
83
  import: `import { admin } from "better-auth/plugins/admin";`,
@@ -168,9 +205,12 @@ import { memorySearchProvider } from "@saastemly/better-commerce/providers/searc
168
205
  commerce("reviews", `reviews()`, "verified badge derived from a paid order");
169
206
  commerce("customer-groups", `customerGroups()`, "segments with their own prices");
170
207
  commerce("acp", `acp({ merchantDisplayName: "${shop.name}" })`, "agentic commerce", `import { acp } from "@saastemly/better-commerce/acp";`);
171
- commerce("fulfillments", `fulfillments({ providers: [authFulfillment(), creditsFulfillment()${has(manifest, "gift-cards") ? ", giftCardFulfillment()" : ""}${has(manifest, "business-central") ? `, erpFulfillment("${shop.currency.toUpperCase()}")` : ""}${nftProvider}]${nftDefault} })`, "what happens when the money is confirmed", `import { authFulfillment, creditsFulfillment, fulfillments, manualFulfillment } from "@saastemly/better-commerce/plugins/fulfillments";${has(manifest, "business-central") ? `
208
+ commerce("fulfillments", `fulfillments({ providers: [authFulfillment(), creditsFulfillment()${has(manifest, "gift-cards") ? ", giftCardFulfillment()" : ""}${has(manifest, "business-central") ? `, erpFulfillment("${shop.currency.toUpperCase()}")` : ""}${carrierFulfillment}]${nftDefault} })`, "what happens when the money is confirmed", `import { authFulfillment, creditsFulfillment, fulfillments, manualFulfillment } from "@saastemly/better-commerce/plugins/fulfillments";${carriers.length ? `
209
+ import { deliveryFulfillment } from "@saastemly/better-commerce/providers/delivery";` : ""}${carrierImports.length ? `
210
+ ${carrierImports.join(`
211
+ `)}` : ""}${has(manifest, "business-central") ? `
172
212
  import { erpFulfillment } from "./lib/erp.ts";` : ""}`);
173
- commerce("shipping-zones", `shippingZones({ zones: [{ id: "domestic", name: "${shop.country}", countries: ["${shop.country}"], rank: 0 }], shippingOptions: [{ id: "standard", zoneId: "domestic", name: "Standard", priceType: "flat", amount: Number(must(env, "SHIPPING_DOMESTIC_MINOR")), currency: "${shop.currency}", rank: 0 }, { id: "free", zoneId: "domestic", name: "Free", priceType: "free", currency: "${shop.currency}", minSubtotal: Number(must(env, "SHIPPING_FREE_FROM_MINOR")), rank: 1 }] })`, "quoted from required env; a shop with no option quotes FREE delivery silently");
213
+ commerce("shipping-zones", `shippingZones({ zones: [{ id: "domestic", name: "${shop.country}", countries: ["${shop.country}"], rank: 0 }], shippingOptions: [${carriers.length === 0 ? `{ id: "standard", zoneId: "domestic", name: "Standard", priceType: "flat", amount: Number(must(env, "SHIPPING_DOMESTIC_MINOR")), currency: "${shop.currency}", rank: 0 }, { id: "free", zoneId: "domestic", name: "Free", priceType: "free", currency: "${shop.currency}", minSubtotal: Number(must(env, "SHIPPING_FREE_FROM_MINOR")), rank: 1 }` : carriers.map((id, rank) => `{ id: "${id}", zoneId: "domestic", name: "${CARRIER_CALLS[id].label}", priceType: "calculated", carrierProviderId: "${id === "nft" ? "nft" : id}", currency: "${shop.currency}", rank: ${rank} }`).join(", ")}]${carriers.length ? `, carriers: [${carriers.map((id) => CARRIER_CALLS[id].call).join(", ")}]` : ""} })`, "quoted from required env; a shop with no option quotes FREE delivery silently");
174
214
  const registered = shop.taxRegistered === true;
175
215
  const askedLive = has(manifest, "stripe-tax");
176
216
  commerce("tax-regions", askedLive ? `taxRegions({ regions: [{ countryCode: "${shop.country}", name: "${shop.country}", rates: [] }], attachTax: false })` : !registered ? `taxRegions({ regions: [{ countryCode: "${shop.country}", name: "${shop.country}", rates: [] }] })` : vat ? `taxRegions({ regions: [{ countryCode: "${shop.country}", name: "${shop.country}", rates: [{ name: "${vat.name}", code: "vat", rate: ${vat.rate} }] }] })` : `taxRegions({ regions: [{ countryCode: "${shop.country}", name: "${shop.country}", rates: [] }] /* TODO VERIFY: no standard rate known for ${shop.country} */ })`, askedLive ? "Stripe is asked per basket, so an unregistered shop charges nothing and a registered one charges the buyer's own rate — with nothing here to keep up to date" : !registered ? `NOT registered for ${vat?.name ?? "VAT"}: no tax is added, because a business below the threshold must not charge it. Set taxRegistered in voidcommerce.json when you register.` : vat ? `${vat.rate / 100}% ${vat.name} declared in code, so a fresh deploy is never tax-free` : "TODO VERIFY: registered, but no standard rate is known for this country — declare it");
@@ -191,8 +231,7 @@ import { memoryFileProvider } from "@saastemly/better-commerce/providers/file";`
191
231
  commerce("merchants", `merchants()`, "several sellers, with payouts");
192
232
  if (has(manifest, "nft"))
193
233
  lines.push({
194
- import: `import { nft, nftOrPhysical } from "@saastemly/better-commerce/plugins/nft";
195
- import { mintProvider } from "./lib/mint.ts";`,
234
+ import: `import { nft } from "@saastemly/better-commerce/plugins/nft";`,
196
235
  call: `nft({ blurb: "Rather not have the object? Take an authentic, completely useless token of it instead. Nothing gets printed, nothing gets posted." })`,
197
236
  ring: "commerce",
198
237
  why: "the buyer chooses a parcel or a token; the catalogue is the same either way"
@@ -1397,7 +1436,7 @@ import color from "picocolors";
1397
1436
  // package.json
1398
1437
  var package_default = {
1399
1438
  name: "@saastemly/voidcommerce",
1400
- version: "0.1.0",
1439
+ version: "0.2.1",
1401
1440
  description: "Void, with a shop in it. `vc init` walks you through Better Auth, betterCommerce and every plugin; everything else passes through to `void`.",
1402
1441
  type: "module",
1403
1442
  license: "MIT",
@@ -2138,6 +2177,60 @@ function renderMintTs() {
2138
2177
  * after payment, and an exception there strands a paid order.
2139
2178
  */
2140
2179
  export const mintProvider: MintProvider = recordedMint();
2180
+
2181
+ /**
2182
+ * Where THIS order's token goes.
2183
+ *
2184
+ * The buyer's own email, because a custodial mint provider will make them a
2185
+ * wallet from it — so somebody who has never held one still receives the
2186
+ * token, and the checkout never has to ask for a wallet address. Returning
2187
+ * null refuses the shipment rather than minting into the void.
2188
+ *
2189
+ * The adapter is reached the way the emailer reaches it: through the current
2190
+ * auth context, which exists because this runs inside a request.
2191
+ */
2192
+ export async function destinationFor(orderId: string): Promise<string | null> {
2193
+ try {
2194
+ const endpoint = (await getCurrentAuthContext()) as unknown as {
2195
+ context?: { adapter?: { findOne: (q: unknown) => Promise<unknown> } };
2196
+ };
2197
+ const adapter = endpoint.context?.adapter;
2198
+ if (!adapter) return null;
2199
+
2200
+ const order = (await adapter.findOne({
2201
+ model: "order",
2202
+ where: [{ field: "id", value: orderId }],
2203
+ })) as { userId?: string | null } | null;
2204
+ if (!order?.userId) return null;
2205
+
2206
+ const user = (await adapter.findOne({
2207
+ model: "user",
2208
+ where: [{ field: "id", value: order.userId }],
2209
+ })) as { email?: string | null } | null;
2210
+ return user?.email ?? null;
2211
+ } catch {
2212
+ return null;
2213
+ }
2214
+ }
2215
+ `;
2216
+ }
2217
+ function renderShippingTs(manifest) {
2218
+ return `import type { DeliveryAddress } from "@saastemly/better-commerce/providers/delivery";
2219
+
2220
+ /**
2221
+ * The address every parcel is quoted and shipped FROM.
2222
+ *
2223
+ * TODO VERIFY: this is a placeholder. A carrier quotes against it, and DHL
2224
+ * refuses a shipment whose origin has no \`name\` and \`phone\`.
2225
+ */
2226
+ export const shipFrom: DeliveryAddress = {
2227
+ name: "${manifest.shop.name}",
2228
+ line1: "TODO VERIFY: street",
2229
+ city: "TODO VERIFY: town",
2230
+ postalCode: "TODO VERIFY",
2231
+ country: "${manifest.shop.country}",
2232
+ phone: "TODO VERIFY: +44…",
2233
+ };
2141
2234
  `;
2142
2235
  }
2143
2236
  function renderErpTs() {
@@ -2248,6 +2341,9 @@ async function generateApi(root, dir, manifest, result, opts) {
2248
2341
  if (has(manifest, "nft")) {
2249
2342
  await put(root, at("lib/mint.ts"), renderMintTs(), result, own);
2250
2343
  }
2344
+ if (["dhl-express", "shippo", "easypost", "ups", "fedex"].some((id) => has(manifest, id))) {
2345
+ await put(root, at("lib/shipping.ts"), renderShippingTs(manifest), result, own);
2346
+ }
2251
2347
  if (has(manifest, "business-central")) {
2252
2348
  await put(root, at("lib/erp.ts"), renderErpTs(), result, own);
2253
2349
  }
@@ -340,12 +340,6 @@ var COMMERCE_OPERATIONS = {
340
340
  hint: "order confirmations and shipping updates, queued and retried",
341
341
  required: true
342
342
  },
343
- {
344
- id: "nft",
345
- label: "Token instead of a parcel",
346
- hint: "a buyer who would rather not have the object can take an NFT of it instead — same price, same order, nothing shipped. Needs a mint provider before it mints anything",
347
- recommended: false
348
- },
349
343
  {
350
344
  id: "events",
351
345
  label: "Webhooks",
@@ -445,20 +439,25 @@ var TAX = {
445
439
  };
446
440
  var CARRIERS = {
447
441
  id: "carriers",
448
- title: "Carriers for calculated shipping rates",
449
- intro: "Optional. Flat-rate shipping needs none of these.",
442
+ title: "How an order reaches a buyer",
443
+ intro: "Live rates at checkout, quoted per carrier. Flat-rate shipping needs none of these.",
450
444
  kind: "multiselect",
451
445
  choices: [
452
- { id: "shippo", label: "Shippo", hint: "many carriers through one API", env: [{ key: "SHIPPO_API_KEY", breaks: "calculated rates fall back to flat" }] },
446
+ { id: "shippo", label: "Shippo", hint: "many carriers through one API", env: [{ key: "SHIPPO_API_TOKEN", breaks: "calculated rates fall back to flat" }] },
453
447
  { id: "easypost", label: "EasyPost", hint: "the same, US-leaning", env: [{ key: "EASYPOST_API_KEY", breaks: "calculated rates fall back to flat" }] },
454
- { id: "ups", label: "UPS", hint: "your own UPS account, for negotiated rates an aggregator cannot see", env: [{ key: "UPS_CLIENT_ID", breaks: "UPS rates are not quoted" }, { key: "UPS_CLIENT_SECRET", breaks: "the same" }] },
455
- { id: "fedex", label: "FedEx", hint: "your own FedEx account, for negotiated rates an aggregator cannot see", env: [{ key: "FEDEX_CLIENT_ID", breaks: "FedEx rates are not quoted" }, { key: "FEDEX_CLIENT_SECRET", breaks: "the same" }] },
456
- { id: "dhl-express", label: "DHL Express", hint: "international", env: [{ key: "DHL_API_KEY", breaks: "DHL rates are not quoted" }] },
448
+ { id: "ups", label: "UPS", hint: "your own UPS account, for negotiated rates an aggregator cannot see", env: [{ key: "UPS_CLIENT_ID", breaks: "UPS rates are not quoted" }, { key: "UPS_CLIENT_SECRET", breaks: "the same" }, { key: "UPS_ACCOUNT_NUMBER", breaks: "UPS has no account to bill the transport to" }] },
449
+ { id: "fedex", label: "FedEx", hint: "your own FedEx account, for negotiated rates an aggregator cannot see", env: [{ key: "FEDEX_CLIENT_ID", breaks: "FedEx rates are not quoted" }, { key: "FEDEX_CLIENT_SECRET", breaks: "the same" }, { key: "FEDEX_ACCOUNT_NUMBER", breaks: "FedEx has no account to bill the transport to" }] },
450
+ { id: "dhl-express", label: "DHL Express", hint: "international express, and the one worth having for a shop that posts abroad", env: [{ key: "DHL_API_KEY", breaks: "DHL rates are not quoted" }, { key: "DHL_API_SECRET", breaks: "the same" }, { key: "DHL_ACCOUNT_NUMBER", breaks: "DHL has no account to bill the transport to" }] },
457
451
  { id: "usps", label: "USPS", hint: "US domestic post — the cheapest way to ship a small parcel inside the US", env: [{ key: "USPS_CLIENT_ID", breaks: "USPS rates are not quoted" }, { key: "USPS_CLIENT_SECRET", breaks: "the same" }] },
458
452
  { id: "royal-mail", label: "Royal Mail", hint: "UK post, for a shop whose customers are mostly in Britain", env: [{ key: "ROYAL_MAIL_CLIENT_ID", breaks: "Royal Mail rates are not quoted" }] },
459
453
  { id: "aramex", label: "Aramex", hint: "the carrier to reach the Gulf and wider Middle East reliably", env: [{ key: "ARAMEX_API_KEY", breaks: "Aramex rates are not quoted" }] },
460
454
  { id: "doordash-drive", label: "DoorDash Drive", hint: "same-day local delivery", env: [{ key: "DOORDASH_DEVELOPER_ID", breaks: "DoorDash is not offered" }] },
461
- { id: "stuart", label: "Stuart", hint: "same-day courier, Europe", env: [{ key: "STUART_CLIENT_ID", breaks: "Stuart is not offered" }] }
455
+ { id: "stuart", label: "Stuart", hint: "same-day courier, Europe", env: [{ key: "STUART_CLIENT_ID", breaks: "Stuart is not offered" }] },
456
+ {
457
+ id: "nft",
458
+ label: "A token, not a parcel",
459
+ hint: "the buyer takes an NFT of what they bought instead of the object — quotes free, ships nothing, needs no address. It mints to their email, so they need no wallet"
460
+ }
462
461
  ]
463
462
  };
464
463
  var ERP = {
@@ -1,7 +1,7 @@
1
1
  import {
2
2
  CHOICES,
3
3
  GROUPS
4
- } from "./index-s7sq41qs.js";
4
+ } from "./index-z4qazajn.js";
5
5
 
6
6
  // src/manifest.ts
7
7
  import { readFile, writeFile } from "node:fs/promises";
package/dist/index.js CHANGED
@@ -57,7 +57,7 @@ import {
57
57
  routeProblem,
58
58
  strictDependencies,
59
59
  upsertJsonc
60
- } from "./index-wzy1xtr1.js";
60
+ } from "./index-xdsp97e4.js";
61
61
  import {
62
62
  LAYOUTS,
63
63
  MANIFEST_FILE,
@@ -77,7 +77,7 @@ import {
77
77
  writeManifest,
78
78
  zone,
79
79
  zoneOf
80
- } from "./index-ssv3a6wc.js";
80
+ } from "./index-zh3tmj08.js";
81
81
  import {
82
82
  AUTH_PLUGINS,
83
83
  CARRIERS,
@@ -94,7 +94,7 @@ import {
94
94
  SIGN_IN,
95
95
  TAX,
96
96
  UI
97
- } from "./index-s7sq41qs.js";
97
+ } from "./index-z4qazajn.js";
98
98
  export {
99
99
  zoneOf,
100
100
  zone,
package/dist/manifest.js CHANGED
@@ -17,8 +17,8 @@ import {
17
17
  writeManifest,
18
18
  zone,
19
19
  zoneOf
20
- } from "./index-ssv3a6wc.js";
21
- import"./index-s7sq41qs.js";
20
+ } from "./index-zh3tmj08.js";
21
+ import"./index-z4qazajn.js";
22
22
  export {
23
23
  zoneOf,
24
24
  zone,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@saastemly/voidcommerce",
3
- "version": "0.1.0",
3
+ "version": "0.2.1",
4
4
  "description": "Void, with a shop in it. `vc init` walks you through Better Auth, betterCommerce and every plugin; everything else passes through to `void`.",
5
5
  "type": "module",
6
6
  "license": "MIT",
package/src/catalog.ts CHANGED
@@ -404,12 +404,6 @@ export const COMMERCE_OPERATIONS: Group = {
404
404
  hint: "order confirmations and shipping updates, queued and retried",
405
405
  required: true,
406
406
  },
407
- {
408
- id: "nft",
409
- label: "Token instead of a parcel",
410
- hint: "a buyer who would rather not have the object can take an NFT of it instead — same price, same order, nothing shipped. Needs a mint provider before it mints anything",
411
- recommended: false,
412
- },
413
407
  {
414
408
  id: "events",
415
409
  label: "Webhooks",
@@ -515,20 +509,25 @@ export const TAX: Group = {
515
509
 
516
510
  export const CARRIERS: Group = {
517
511
  id: "carriers",
518
- title: "Carriers for calculated shipping rates",
519
- intro: "Optional. Flat-rate shipping needs none of these.",
512
+ title: "How an order reaches a buyer",
513
+ intro: "Live rates at checkout, quoted per carrier. Flat-rate shipping needs none of these.",
520
514
  kind: "multiselect",
521
515
  choices: [
522
- { id: "shippo", label: "Shippo", hint: "many carriers through one API", env: [{ key: "SHIPPO_API_KEY", breaks: "calculated rates fall back to flat" }] },
516
+ { id: "shippo", label: "Shippo", hint: "many carriers through one API", env: [{ key: "SHIPPO_API_TOKEN", breaks: "calculated rates fall back to flat" }] },
523
517
  { id: "easypost", label: "EasyPost", hint: "the same, US-leaning", env: [{ key: "EASYPOST_API_KEY", breaks: "calculated rates fall back to flat" }] },
524
- { id: "ups", label: "UPS", hint: "your own UPS account, for negotiated rates an aggregator cannot see", env: [{ key: "UPS_CLIENT_ID", breaks: "UPS rates are not quoted" }, { key: "UPS_CLIENT_SECRET", breaks: "the same" }] },
525
- { id: "fedex", label: "FedEx", hint: "your own FedEx account, for negotiated rates an aggregator cannot see", env: [{ key: "FEDEX_CLIENT_ID", breaks: "FedEx rates are not quoted" }, { key: "FEDEX_CLIENT_SECRET", breaks: "the same" }] },
526
- { id: "dhl-express", label: "DHL Express", hint: "international", env: [{ key: "DHL_API_KEY", breaks: "DHL rates are not quoted" }] },
518
+ { id: "ups", label: "UPS", hint: "your own UPS account, for negotiated rates an aggregator cannot see", env: [{ key: "UPS_CLIENT_ID", breaks: "UPS rates are not quoted" }, { key: "UPS_CLIENT_SECRET", breaks: "the same" }, { key: "UPS_ACCOUNT_NUMBER", breaks: "UPS has no account to bill the transport to" }] },
519
+ { id: "fedex", label: "FedEx", hint: "your own FedEx account, for negotiated rates an aggregator cannot see", env: [{ key: "FEDEX_CLIENT_ID", breaks: "FedEx rates are not quoted" }, { key: "FEDEX_CLIENT_SECRET", breaks: "the same" }, { key: "FEDEX_ACCOUNT_NUMBER", breaks: "FedEx has no account to bill the transport to" }] },
520
+ { id: "dhl-express", label: "DHL Express", hint: "international express, and the one worth having for a shop that posts abroad", env: [{ key: "DHL_API_KEY", breaks: "DHL rates are not quoted" }, { key: "DHL_API_SECRET", breaks: "the same" }, { key: "DHL_ACCOUNT_NUMBER", breaks: "DHL has no account to bill the transport to" }] },
527
521
  { id: "usps", label: "USPS", hint: "US domestic post — the cheapest way to ship a small parcel inside the US", env: [{ key: "USPS_CLIENT_ID", breaks: "USPS rates are not quoted" }, { key: "USPS_CLIENT_SECRET", breaks: "the same" }] },
528
522
  { id: "royal-mail", label: "Royal Mail", hint: "UK post, for a shop whose customers are mostly in Britain", env: [{ key: "ROYAL_MAIL_CLIENT_ID", breaks: "Royal Mail rates are not quoted" }] },
529
523
  { id: "aramex", label: "Aramex", hint: "the carrier to reach the Gulf and wider Middle East reliably", env: [{ key: "ARAMEX_API_KEY", breaks: "Aramex rates are not quoted" }] },
530
524
  { id: "doordash-drive", label: "DoorDash Drive", hint: "same-day local delivery", env: [{ key: "DOORDASH_DEVELOPER_ID", breaks: "DoorDash is not offered" }] },
531
525
  { id: "stuart", label: "Stuart", hint: "same-day courier, Europe", env: [{ key: "STUART_CLIENT_ID", breaks: "Stuart is not offered" }] },
526
+ {
527
+ id: "nft",
528
+ label: "A token, not a parcel",
529
+ hint: "the buyer takes an NFT of what they bought instead of the object — quotes free, ships nothing, needs no address. It mints to their email, so they need no wallet",
530
+ },
532
531
  ],
533
532
  };
534
533
 
@@ -60,11 +60,56 @@ export function renderAuthTs(manifest: Manifest): string {
60
60
  * the intent and mints nothing, which is the honest state for a shop that
61
61
  * has not chosen a chain.
62
62
  */
63
+ /**
64
+ * Carriers. Each chosen one becomes a `calculated` shipping option naming
65
+ * it, so the buyer picks a delivery method at checkout and the total
66
+ * follows — including the token, which quotes zero and ships nothing.
67
+ */
68
+ const CARRIER_CALLS: Record<string, { call: string; label: string; import: string }> = {
69
+ // Every real carrier needs the ORIGIN it ships from, which is shop data
70
+ // rather than a credential — so it lives in an owned lib/shipping.ts.
71
+ "dhl-express": {
72
+ label: "DHL Express",
73
+ call: `dhlExpressProvider({ apiKey: must(env, "DHL_API_KEY"), apiSecret: must(env, "DHL_API_SECRET"), accountNumber: must(env, "DHL_ACCOUNT_NUMBER"), from: shipFrom })`,
74
+ import: `import { dhlExpressProvider } from "@saastemly/better-commerce/providers/dhl-express";`,
75
+ },
76
+ shippo: {
77
+ label: "Shippo",
78
+ call: `shippoProvider({ apiToken: must(env, "SHIPPO_API_TOKEN"), from: shipFrom })`,
79
+ import: `import { shippoProvider } from "@saastemly/better-commerce/providers/shippo";`,
80
+ },
81
+ easypost: {
82
+ label: "EasyPost",
83
+ call: `easypostProvider({ apiKey: must(env, "EASYPOST_API_KEY"), from: shipFrom })`,
84
+ import: `import { easypostProvider } from "@saastemly/better-commerce/providers/easypost";`,
85
+ },
86
+ ups: {
87
+ label: "UPS",
88
+ call: `upsProvider({ clientId: must(env, "UPS_CLIENT_ID"), clientSecret: must(env, "UPS_CLIENT_SECRET"), accountNumber: must(env, "UPS_ACCOUNT_NUMBER"), from: shipFrom })`,
89
+ import: `import { upsProvider } from "@saastemly/better-commerce/providers/ups";`,
90
+ },
91
+ fedex: {
92
+ label: "FedEx",
93
+ call: `fedexProvider({ clientId: must(env, "FEDEX_CLIENT_ID"), clientSecret: must(env, "FEDEX_CLIENT_SECRET"), accountNumber: must(env, "FEDEX_ACCOUNT_NUMBER"), from: shipFrom })`,
94
+ import: `import { fedexProvider } from "@saastemly/better-commerce/providers/fedex";`,
95
+ },
96
+ nft: {
97
+ label: "A token, not a parcel",
98
+ call: `nftDelivery({ mint: mintProvider, destinationFor })`,
99
+ import: `import { nftDelivery } from "@saastemly/better-commerce/providers/nft-delivery";\nimport { mintProvider, destinationFor } from "./lib/mint.ts";`,
100
+ },
101
+ };
102
+ const carriers = Object.keys(CARRIER_CALLS).filter((id) => has(manifest, id));
103
+ const carrierImports = carriers.map((id) => CARRIER_CALLS[id]!.import);
104
+ // The token needs no origin; every other carrier does.
105
+ if (carriers.some((id) => id !== "nft")) carrierImports.push(`import { shipFrom } from "./lib/shipping.ts";`);
63
106
  const wantsNft = has(manifest, "nft");
64
- const nftProvider = wantsNft
65
- ? `, nftOrPhysical({ mint: mintProvider, physical: ${has(manifest, "business-central") ? `erpFulfillment("${shop.currency.toUpperCase()}")` : "manualFulfillment()"} })`
107
+ // One bridge for every carrier: which ships is decided by whose rate the
108
+ // buyer paid for, so a token order mints and a parcel order posts.
109
+ const carrierFulfillment = carriers.length
110
+ ? `, deliveryFulfillment([${carriers.map((id) => CARRIER_CALLS[id]!.call).join(", ")}])`
66
111
  : "";
67
- const nftDefault = wantsNft ? `, defaultProvider: "nft-or-physical"` : "";
112
+ const nftDefault = carriers.length ? `, defaultProvider: "delivery:${carriers.join("+")}"` : "";
68
113
 
69
114
  const vat = VAT[shop.country];
70
115
 
@@ -197,13 +242,22 @@ export function renderAuthTs(manifest: Manifest): string {
197
242
  commerce("acp", `acp({ merchantDisplayName: "${shop.name}" })`, "agentic commerce", `import { acp } from "@saastemly/better-commerce/acp";`);
198
243
  commerce(
199
244
  "fulfillments",
200
- `fulfillments({ providers: [authFulfillment(), creditsFulfillment()${has(manifest, "gift-cards") ? ", giftCardFulfillment()" : ""}${has(manifest, "business-central") ? `, erpFulfillment("${shop.currency.toUpperCase()}")` : ""}${nftProvider}]${nftDefault} })`,
245
+ `fulfillments({ providers: [authFulfillment(), creditsFulfillment()${has(manifest, "gift-cards") ? ", giftCardFulfillment()" : ""}${has(manifest, "business-central") ? `, erpFulfillment("${shop.currency.toUpperCase()}")` : ""}${carrierFulfillment}]${nftDefault} })`,
201
246
  "what happens when the money is confirmed",
202
- `import { authFulfillment, creditsFulfillment, fulfillments, manualFulfillment } from "@saastemly/better-commerce/plugins/fulfillments";${has(manifest, "business-central") ? `\nimport { erpFulfillment } from "./lib/erp.ts";` : ""}`,
247
+ `import { authFulfillment, creditsFulfillment, fulfillments, manualFulfillment } from "@saastemly/better-commerce/plugins/fulfillments";${carriers.length ? `\nimport { deliveryFulfillment } from "@saastemly/better-commerce/providers/delivery";` : ""}${carrierImports.length ? `\n${carrierImports.join("\n")}` : ""}${has(manifest, "business-central") ? `\nimport { erpFulfillment } from "./lib/erp.ts";` : ""}`,
203
248
  );
204
249
  commerce(
205
250
  "shipping-zones",
206
- `shippingZones({ zones: [{ id: "domestic", name: "${shop.country}", countries: ["${shop.country}"], rank: 0 }], shippingOptions: [{ id: "standard", zoneId: "domestic", name: "Standard", priceType: "flat", amount: Number(must(env, "SHIPPING_DOMESTIC_MINOR")), currency: "${shop.currency}", rank: 0 }, { id: "free", zoneId: "domestic", name: "Free", priceType: "free", currency: "${shop.currency}", minSubtotal: Number(must(env, "SHIPPING_FREE_FROM_MINOR")), rank: 1 }] })`,
251
+ `shippingZones({ zones: [{ id: "domestic", name: "${shop.country}", countries: ["${shop.country}"], rank: 0 }], shippingOptions: [${
252
+ carriers.length === 0
253
+ ? `{ id: "standard", zoneId: "domestic", name: "Standard", priceType: "flat", amount: Number(must(env, "SHIPPING_DOMESTIC_MINOR")), currency: "${shop.currency}", rank: 0 }, { id: "free", zoneId: "domestic", name: "Free", priceType: "free", currency: "${shop.currency}", minSubtotal: Number(must(env, "SHIPPING_FREE_FROM_MINOR")), rank: 1 }`
254
+ : carriers
255
+ .map(
256
+ (id, rank) =>
257
+ `{ id: "${id}", zoneId: "domestic", name: "${CARRIER_CALLS[id]!.label}", priceType: "calculated", carrierProviderId: "${id === "nft" ? "nft" : id}", currency: "${shop.currency}", rank: ${rank} }`,
258
+ )
259
+ .join(", ")
260
+ }]${carriers.length ? `, carriers: [${carriers.map((id) => CARRIER_CALLS[id]!.call).join(", ")}]` : ""} })`,
207
261
  "quoted from required env; a shop with no option quotes FREE delivery silently",
208
262
  );
209
263
  /**
@@ -247,7 +301,7 @@ export function renderAuthTs(manifest: Manifest): string {
247
301
  commerce("merchants", `merchants()`, "several sellers, with payouts");
248
302
  if (has(manifest, "nft"))
249
303
  lines.push({
250
- import: `import { nft, nftOrPhysical } from "@saastemly/better-commerce/plugins/nft";\nimport { mintProvider } from "./lib/mint.ts";`,
304
+ import: `import { nft } from "@saastemly/better-commerce/plugins/nft";`,
251
305
  call: `nft({ blurb: "Rather not have the object? Take an authentic, completely useless token of it instead. Nothing gets printed, nothing gets posted." })`,
252
306
  ring: "commerce",
253
307
  why: "the buyer chooses a parcel or a token; the catalogue is the same either way",
@@ -50,7 +50,7 @@ import {
50
50
  renderVoidPatch,
51
51
  strictDependencies,
52
52
  } from "./strict";
53
- import { renderDomainTs, renderErpTs, renderMintTs, renderNotificationsTs, renderPaymentTs } from "./support";
53
+ import { renderDomainTs, renderErpTs, renderMintTs, renderNotificationsTs, renderPaymentTs, renderShippingTs } from "./support";
54
54
 
55
55
  /**
56
56
  * Turn a manifest into files.
@@ -173,6 +173,10 @@ async function generateApi(root: string, dir: string, manifest: Manifest, result
173
173
  if (has(manifest, "nft")) {
174
174
  await put(root, at("lib/mint.ts"), renderMintTs(), result, own);
175
175
  }
176
+ // Any carrier but the token quotes against an origin, which is shop data.
177
+ if (["dhl-express", "shippo", "easypost", "ups", "fedex"].some((id) => has(manifest, id))) {
178
+ await put(root, at("lib/shipping.ts"), renderShippingTs(manifest), result, own);
179
+ }
176
180
  if (has(manifest, "business-central")) {
177
181
  await put(root, at("lib/erp.ts"), renderErpTs(), result, own);
178
182
  }
@@ -220,6 +220,73 @@ export function renderMintTs(): string {
220
220
  * after payment, and an exception there strands a paid order.
221
221
  */
222
222
  export const mintProvider: MintProvider = recordedMint();
223
+
224
+ /**
225
+ * Where THIS order's token goes.
226
+ *
227
+ * The buyer's own email, because a custodial mint provider will make them a
228
+ * wallet from it — so somebody who has never held one still receives the
229
+ * token, and the checkout never has to ask for a wallet address. Returning
230
+ * null refuses the shipment rather than minting into the void.
231
+ *
232
+ * The adapter is reached the way the emailer reaches it: through the current
233
+ * auth context, which exists because this runs inside a request.
234
+ */
235
+ export async function destinationFor(orderId: string): Promise<string | null> {
236
+ try {
237
+ const endpoint = (await getCurrentAuthContext()) as unknown as {
238
+ context?: { adapter?: { findOne: (q: unknown) => Promise<unknown> } };
239
+ };
240
+ const adapter = endpoint.context?.adapter;
241
+ if (!adapter) return null;
242
+
243
+ const order = (await adapter.findOne({
244
+ model: "order",
245
+ where: [{ field: "id", value: orderId }],
246
+ })) as { userId?: string | null } | null;
247
+ if (!order?.userId) return null;
248
+
249
+ const user = (await adapter.findOne({
250
+ model: "user",
251
+ where: [{ field: "id", value: order.userId }],
252
+ })) as { email?: string | null } | null;
253
+ return user?.email ?? null;
254
+ } catch {
255
+ return null;
256
+ }
257
+ }
258
+ `;
259
+ }
260
+
261
+ /**
262
+ * Where parcels leave from. Written once, then yours.
263
+ *
264
+ * Every carrier needs an origin to quote against, and it is shop data rather
265
+ * than a credential — a postcode changes when you move, not when a key
266
+ * rotates. So it is a file you edit, not an env var, and it ships with your
267
+ * own details already in the shape the carriers want.
268
+ *
269
+ * DHL in particular rejects a shipment whose origin has no name and phone,
270
+ * which is a failure that shows up at the first real order rather than at
271
+ * deploy, so both are marked rather than left blank.
272
+ */
273
+ export function renderShippingTs(manifest: Manifest): string {
274
+ return `import type { DeliveryAddress } from "@saastemly/better-commerce/providers/delivery";
275
+
276
+ /**
277
+ * The address every parcel is quoted and shipped FROM.
278
+ *
279
+ * TODO VERIFY: this is a placeholder. A carrier quotes against it, and DHL
280
+ * refuses a shipment whose origin has no \`name\` and \`phone\`.
281
+ */
282
+ export const shipFrom: DeliveryAddress = {
283
+ name: "${manifest.shop.name}",
284
+ line1: "TODO VERIFY: street",
285
+ city: "TODO VERIFY: town",
286
+ postalCode: "TODO VERIFY",
287
+ country: "${manifest.shop.country}",
288
+ phone: "TODO VERIFY: +44…",
289
+ };
223
290
  `;
224
291
  }
225
292