@aglyn/plugins-commerce 1.0.0-beta.261 → 1.0.0-beta.262
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/package.json +13 -13
- package/src/lib/components/console/order-detail-dialog.component.js +17 -45
- package/src/lib/components/console/order-detail-dialog.component.js.map +1 -1
- package/src/lib/components/console/order-fulfillment-panel.component.js +12 -25
- package/src/lib/components/console/order-fulfillment-panel.component.js.map +1 -1
- package/src/lib/components/console/order-invoice.component.js +2 -10
- package/src/lib/components/console/order-invoice.component.js.map +1 -1
- package/src/lib/components/console/order-shipping-actions.component.d.ts +1 -15
- package/src/lib/components/console/order-shipping-actions.component.js +3 -23
- package/src/lib/components/console/order-shipping-actions.component.js.map +1 -1
- package/src/lib/components/console/product-labels-dialog.component.d.ts +2 -5
- package/src/lib/components/console/product-labels-dialog.component.js +6 -24
- package/src/lib/components/console/product-labels-dialog.component.js.map +1 -1
- package/src/lib/components/console/products-hub-card.component.js +7 -20
- package/src/lib/components/console/products-hub-card.component.js.map +1 -1
- package/src/lib/model/commerce-shipping.d.ts +2 -0
- package/src/lib/model/commerce-shipping.js +1 -0
- package/src/lib/model/commerce-shipping.js.map +1 -1
- package/src/lib/model/order-dialog.d.ts +52 -0
- package/src/lib/model/order-dialog.js +85 -0
- package/src/lib/model/order-dialog.js.map +1 -0
- package/src/lib/model/product-write.d.ts +71 -0
- package/src/lib/model/product-write.js +28 -0
- package/src/lib/model/product-write.js.map +1 -1
- package/src/lib/printing/product-labels.d.ts +22 -0
- package/src/lib/printing/product-labels.js +39 -2
- package/src/lib/printing/product-labels.js.map +1 -1
- package/src/lib/server/order-figures.js +31 -0
- package/src/lib/server/order-figures.js.map +1 -1
- package/src/lib/server/order-print.d.ts +40 -0
- package/src/lib/server/order-print.js +113 -0
- package/src/lib/server/order-print.js.map +1 -0
- package/src/lib/server/products-write.d.ts +26 -0
- package/src/lib/server/products-write.js +184 -1
- package/src/lib/server/products-write.js.map +1 -1
- package/src/lib/server.js +8 -0
- package/src/lib/server.js.map +1 -1
- package/src/lib/transfer/shipping-presets.d.ts +16 -0
- package/src/lib/transfer/shipping-presets.js +34 -1
- package/src/lib/transfer/shipping-presets.js.map +1 -1
- package/src/lib/utils/order-invoice.d.ts +5 -0
- package/src/lib/utils/order-invoice.js +14 -0
- package/src/lib/utils/order-invoice.js.map +1 -1
- package/src/lib/utils/order-packing-slip.d.ts +31 -0
- package/src/lib/utils/order-packing-slip.js +46 -0
- package/src/lib/utils/order-packing-slip.js.map +1 -0
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"sources":["../../../../../../../libs/plugins/commerce/src/lib/model/commerce-shipping.ts"],"sourcesContent":["/**\n * @license\n * Copyright 2026 Aglyn LLC\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n * http://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n\n/**\n * Shipping v1 (AGL-288): zones own countries; rates belong to a zone and\n * price as flat, free-over-threshold, or subtotal/weight tiers. Settings\n * live on `hosts/{hostId}/settings/store` under `shipping`. Pure.\n *\n * `resolveShippingRates` is the per-destination resolver;\n * `resolveCheckoutShippingOptions` is the Stripe Checkout adapter,\n * `planCheckoutShipping` decides which destinations one session may serve,\n * `appendCheckoutShippingParams` emits it onto a session, and\n * `summarizeShippingCoverage` answers the same question for the console, so a\n * merchant can see which destinations their zones refuse. AGL-288 shipped this\n * model with no production call site at all — the doc comment here claimed the\n * cart estimator, checkout and POS pickup used it, and none of them did, so\n * nothing ever read the settings a merchant saved and no shipping was ever\n * charged. Cart checkout calls it (AGL-1707), buy-now calls it for physical\n * one-time products (AGL-1720), and a draft order's payment link calls it for\n * physical products (AGL-1792).\n *\n * POS DOES NOT, AND SHOULD NOT (AGL-1792). A register has no destination\n * anywhere in it — no address field, no delivery choice — and all three\n * tenders settle at the counter: cash, a card QR the customer scans while\n * standing there, and a room folio collected at check-out. There is nothing to\n * price a parcel against, so the only rate POS could resolve is the free\n * `Local pickup` one this module appends, which is 0¢ and would add a step to\n * a register flow in exchange for nothing. A POS sale that must be shipped is\n * a draft order.\n */\n\nexport interface ShippingZone {\n id: string\n name: string\n /** ISO-3166 alpha-2 codes; '*' = rest of world. */\n countries: string[]\n}\n\nexport interface ShippingTier {\n /** Tier applies while subtotal (cents) or weight (grams) ≤ this. */\n upTo: number\n amountCents: number\n}\n\n/**\n * A live carrier rate (AGL-3612): the zone's price is what a carrier quotes\n * for the parcel to the shopper's address, asked through core's\n * `core.shipping-rate-quoter` seam of whichever plugin quotes carriers. With\n * no quoter, a quoter that is not available for the site, no postal code to\n * quote against, or a quote slower than {@link CARRIER_QUOTE_TIMEOUT_MS}, it\n * offers nothing and its fallback rate is offered instead.\n */\nexport interface CarrierRateSettings {\n /** Service keys (`carrier:service`) to offer; empty offers every quoted service. */\n services?: string[]\n /** Added to each quote, in percent. */\n markupPct?: number\n /** Added to each quote after the markup, in cents. */\n handlingCents?: number\n /**\n * A rate of this zone offered when no live quote is: it is HIDDEN while\n * quotes are offered, so the shopper picks between carriers, not between a\n * carrier and the merchant's guess.\n */\n fallbackRateId?: string\n}\n\n/** How long checkout waits for live carrier quotes before it falls back. */\nexport const CARRIER_QUOTE_TIMEOUT_MS = 3_000\n\nexport interface ShippingRate {\n id: string\n zoneId: string\n name: string\n kind: 'flat' | 'free_over' | 'price_tiers' | 'weight_tiers' | 'carrier'\n /** flat + free_over base amount. */\n amountCents?: number\n /** free_over: order subtotal (cents) at/above which shipping is free. */\n freeOverCents?: number\n /** price_tiers (upTo = subtotal cents) / weight_tiers (upTo = grams). */\n tiers?: ShippingTier[]\n /** carrier: the live rate's options (AGL-3612). */\n carrier?: CarrierRateSettings\n}\n\nexport interface ShippingSettings {\n zones?: ShippingZone[]\n rates?: ShippingRate[]\n /**\n * Offer free local pickup as a delivery choice. Does NOT widen where the\n * store ships: a destination no rate reaches is still refused (AGL-2325).\n */\n localPickup?: boolean\n}\n\nexport interface ResolvedShippingRate {\n rateId: string\n name: string\n amountCents: number\n}\n\n/**\n * The rate id appended for local pickup (AGL-2325).\n *\n * Exported because pickup is the one entry in a resolved list that is NOT a\n * shipping promise: it is appended AFTER and INDEPENDENT of zone matching, so\n * it resolves for every destination on earth. Anything asking \"does this store\n * ship there\" — the checkout refusal, the console coverage card — has to be\n * able to tell it apart from a rate, or it reads a merchant who offers\n * collection as a merchant who delivers worldwide.\n */\nexport const LOCAL_PICKUP_RATE_ID = 'pickup'\n\n/**\n * One stored country code, normalised (AGL-2298).\n *\n * TRIMMED as well as upper-cased. The console trims on save, so this only\n * matters for a document written any other way — an import, the REST API, a\n * hand edit — but the failure it produces is silent and expensive: `' US'`\n * upper-cases to `' US'`, matches nothing, and the store refuses every US\n * shopper with \"This store does not ship to the United States\" while its\n * settings card shows `US` right there.\n */\nconst zoneCode = (code: unknown): string => String(code ?? '').trim().toUpperCase()\n\n/** Whether a stored code is the rest-of-world wildcard. */\nconst isWildcard = (code: unknown): boolean => zoneCode(code) === '*'\n\nfunction zoneMatches(zone: ShippingZone, country: string): boolean {\n return (zone.countries ?? []).some(\n (code) => isWildcard(code) || zoneCode(code) === country,\n )\n}\n\nfunction tierAmount(\n tiers: ShippingTier[] | undefined,\n value: number,\n): number | null {\n const sorted = [...(tiers ?? [])].sort((a, b) => a.upTo - b.upTo)\n for (const tier of sorted) {\n if (value <= tier.upTo) return tier.amountCents\n }\n // Beyond the last tier the rate does not apply (merchant should add a\n // catch-all tier with a large upTo).\n return null\n}\n\n/**\n * The zones a destination falls in (AGL-1707, factored out for AGL-3612).\n * Specific country zones beat '*' zones: when any zone names the country\n * outright, rest-of-world zones are ignored.\n */\nexport function matchedZoneIds(\n settings: ShippingSettings | undefined,\n destinationCountry: string,\n): Set<string> {\n const country = destinationCountry.toUpperCase()\n const zones = settings?.zones ?? []\n // A zone is SPECIFIC when it names this country outright — not merely when\n // it is \"not wildcard-only\" (AGL-2298). The old test was `!every(code ===\n // '*')`, so a zone spelled `['*','US']` counted as specific for all six\n // destinations (it matches every country through the `*`) and suppressed\n // every genuine rest-of-world zone globally. Asking the narrower question\n // makes such a zone specific for `US` and wildcard for the rest, which is\n // what its two entries actually say.\n const specific = zones.filter((zone) =>\n (zone.countries ?? []).some((code) => zoneCode(code) === country),\n )\n const matched = specific.length\n ? specific\n : zones.filter((zone) => zoneMatches(zone, country))\n return new Set(matched.map((zone) => zone.id))\n}\n\n/** The live carrier rates a destination's zones carry (AGL-3612). */\nexport function carrierRatesFor(\n settings: ShippingSettings | undefined,\n destinationCountry: string,\n): ShippingRate[] {\n if (!settings) return []\n const zoneIds = matchedZoneIds(settings, destinationCountry)\n return (settings.rates ?? []).filter(\n (rate) => rate.kind === 'carrier' && zoneIds.has(rate.zoneId),\n )\n}\n\n/** Whether any zone carries a live carrier rate (AGL-3612). */\nexport function hasCarrierRates(settings: ShippingSettings | undefined): boolean {\n return (settings?.rates ?? []).some((rate) => rate.kind === 'carrier')\n}\n\n/** The rate id a quoted service is offered under: stable per rate and service. */\nexport function carrierOptionRateId(rateId: string, serviceKey: string): string {\n return `carrier:${rateId}:${serviceKey}`\n}\n\n/**\n * What a shopper pays for a carrier's quote under one carrier rate: the\n * quote, plus the markup, plus the handling fee, in whole cents.\n */\nexport function carrierOptionAmountCents(\n quotedCents: number,\n settings: CarrierRateSettings | undefined,\n): number {\n const quote = Math.max(0, Math.round(Number(quotedCents) || 0))\n const pct = Number(settings?.markupPct)\n const handling = Number(settings?.handlingCents)\n return (\n quote +\n (Number.isFinite(pct) && pct > 0 ? Math.round((quote * Math.min(pct, 500)) / 100) : 0) +\n (Number.isFinite(handling) && handling > 0 ? Math.round(handling) : 0)\n )\n}\n\n/**\n * Rates available for a destination + cart, cheapest first. Specific\n * country zones beat '*' zones: when any specific zone matches, '*'\n * zones are ignored (rest-of-world semantics).\n *\n * Local pickup is appended AFTER and INDEPENDENT of zone matching, so this\n * list is never empty for a merchant who offers it, at any destination. That\n * is correct for a resolver — a shopper anywhere may choose to come and\n * collect — but it means A NON-EMPTY RESULT IS NOT A PROMISE TO SHIP THERE.\n * Callers deciding whether a destination is served must filter pickup out\n * first; `planCheckoutShipping` and `summarizeShippingCoverage` do (AGL-2325).\n */\nexport function resolveShippingRates(\n settings: ShippingSettings | undefined,\n destinationCountry: string | undefined,\n cart: { subtotalCents: number; totalGrams?: number },\n): ResolvedShippingRate[] {\n if (!settings || !destinationCountry) return []\n const zoneIds = matchedZoneIds(settings, destinationCountry)\n // A zone is SPECIFIC when it names this country outright — not merely when\n // it is \"not wildcard-only\" (AGL-2298). The old test was `!every(code ===\n // '*')`, so a zone spelled `['*','US']` counted as specific for all six\n // destinations (it matches every country through the `*`) and suppressed\n // every genuine rest-of-world zone globally. Asking the narrower question\n // makes such a zone specific for `US` and wildcard for the rest, which is\n // what its two entries actually say.\n const resolved: ResolvedShippingRate[] = []\n for (const rate of settings.rates ?? []) {\n if (!zoneIds.has(rate.zoneId)) continue\n let amountCents: number | null = null\n switch (rate.kind) {\n case 'flat':\n amountCents = Math.max(0, Math.round(rate.amountCents ?? 0))\n break\n case 'free_over':\n amountCents =\n rate.freeOverCents != null &&\n cart.subtotalCents >= rate.freeOverCents\n ? 0\n : Math.max(0, Math.round(rate.amountCents ?? 0))\n break\n case 'price_tiers':\n amountCents = tierAmount(rate.tiers, cart.subtotalCents)\n break\n case 'weight_tiers':\n amountCents = tierAmount(rate.tiers, cart.totalGrams ?? 0)\n break\n // A carrier's price exists only once a carrier quoted it for an\n // address (AGL-3612), so a table read offers nothing for it and its\n // fallback rate is what resolves here. `carrier-shipping.ts` adds the\n // quotes where one can be had.\n case 'carrier':\n break\n }\n if (amountCents == null) continue\n resolved.push({ rateId: rate.id, name: rate.name, amountCents })\n }\n if (settings.localPickup) {\n resolved.push({\n rateId: LOCAL_PICKUP_RATE_ID,\n name: 'Local pickup',\n amountCents: 0,\n })\n }\n return resolved.sort((a, b) => a.amountCents - b.amountCents)\n}\n\n/**\n * The resolved options that actually carry a parcel (AGL-2325).\n *\n * Safe to apply AFTER `MAX_CHECKOUT_SHIPPING_OPTIONS` truncation: pickup is\n * 0¢ and sorts first, so it occupies at most one of the five slots and a\n * merchant with any shippable rate at all keeps at least one of them.\n */\nfunction shippableOnly(\n options: readonly ResolvedShippingRate[],\n): ResolvedShippingRate[] {\n return options.filter((option) => option.rateId !== LOCAL_PICKUP_RATE_ID)\n}\n\n/** Stripe Checkout accepts at most 5 `shipping_options` on one session. */\nexport const MAX_CHECKOUT_SHIPPING_OPTIONS = 5\n\n/**\n * Destinations a storefront Checkout Session collects an address for, and\n * therefore the destinations shipping rates are resolved against (AGL-1707).\n *\n * NOT narrowed to the zones the merchant configured: narrowing would block\n * checkouts that complete today, turning a money fix into lost sales. The\n * wider list only means a shopper outside the merchant's zones is offered some\n * rate rather than none. Shared by cart checkout and buy-now (AGL-1720) so the\n * two paths cannot drift into offering different destinations.\n */\nexport const CHECKOUT_SHIPPING_COUNTRIES = [\n 'US',\n 'CA',\n 'GB',\n 'AU',\n 'DE',\n 'FR',\n] as const\n\n/**\n * The rates to declare as `shipping_options` on a Stripe Checkout Session\n * (AGL-1707), unioned over `destinationCountries`. Stripe charges shipping\n * ONLY when the session declares them.\n *\n * A UNION IS ONLY HONEST OVER DESTINATIONS THE SESSION IS ALSO RESTRICTED TO.\n * Called with every collectable country while the session accepted an address\n * in any of them — which is what AGL-1707 shipped — it offers a shopper in one\n * zone a rate belonging to another, and Stripe applies whichever the shopper\n * picks (a `shipping_rate_data` carries no country of its own, so nothing\n * downstream re-checks it against the address). That is AGL-1721. Callers now\n * reach this through `planCheckoutShipping`, which pairs the country list it\n * unions over with the country list the session allows, so the two cannot\n * disagree. Do not call it directly from a handler.\n *\n * Returns `[]` for a merchant who has configured nothing, and the caller must\n * then declare no `shipping_options` at all rather than an empty array — a\n * merchant who never set shipping up keeps a session identical to today's.\n */\nexport function resolveCheckoutShippingOptions(\n settings: ShippingSettings | undefined,\n destinationCountries: readonly string[],\n cart: { subtotalCents: number; totalGrams?: number },\n): ResolvedShippingRate[] {\n if (!settings) return []\n const byRateId = new Map<string, ResolvedShippingRate>()\n for (const country of destinationCountries) {\n for (const rate of resolveShippingRates(settings, country, cart)) {\n // Stripe requires a display name, and a rate id is the dedupe key —\n // the console lets a merchant add a blank row, so neither is assured.\n if (!rate.rateId || !rate.name) continue\n const amountCents = Math.max(0, Math.round(Number(rate.amountCents) || 0))\n const existing = byRateId.get(rate.rateId)\n // One rate resolving differently for two countries should not be\n // possible (a zone selects a rate, it does not reprice one), but if it\n // ever is, keep the dearer. Under-charging is the defect being fixed.\n if (!existing || amountCents > existing.amountCents) {\n byRateId.set(rate.rateId, { ...rate, amountCents })\n }\n }\n }\n return (\n [...byRateId.values()]\n // Cheapest first, id as a tie-break so the emitted params are stable.\n .sort(\n (a, b) =>\n a.amountCents - b.amountCents || a.rateId.localeCompare(b.rateId),\n )\n // Past Stripe's cap the dearest are dropped, which is the harmless end\n // to lose: a shopper offered the cheap options would have taken one.\n .slice(0, MAX_CHECKOUT_SHIPPING_OPTIONS)\n )\n}\n\n/** Shopper-facing names for the collectable destinations (AGL-1721). */\nexport const CHECKOUT_SHIPPING_COUNTRY_NAMES: Readonly<Record<string, string>> =\n {\n US: 'United States',\n CA: 'Canada',\n GB: 'United Kingdom',\n AU: 'Australia',\n DE: 'Germany',\n FR: 'France',\n }\n\n/**\n * A destination a shopper declared, or `undefined` for anything this session\n * could not collect an address for anyway. Never throws: an unknown code is\n * an undeclared destination, not an error, so a stale storefront that posts\n * one is answered by the plan below rather than by a 400 nobody can act on.\n */\nexport function normalizeCheckoutShippingCountry(\n value: unknown,\n): string | undefined {\n // `typeof` rather than `String(value)`: a JSON body is caller-shaped, and\n // `String(['US'])` is 'US'. Coercing would let an array declare a\n // destination, which is not a spelling any storefront sends.\n if (typeof value !== 'string') return undefined\n const code = value.trim().toUpperCase()\n return (CHECKOUT_SHIPPING_COUNTRIES as readonly string[]).includes(code)\n ? code\n : undefined\n}\n\nexport interface CheckoutShippingPlan {\n /** `shipping_address_collection[allowed_countries]` for this session. */\n countries: readonly string[]\n /** `shipping_options` for this session — valid for EVERY `countries` entry. */\n options: ResolvedShippingRate[]\n /**\n * Set when no session may be created at all:\n *\n * - `destination-required` — the merchant's rates differ by destination, so\n * there is no set of options honest for every address Stripe would accept.\n * Ask the shopper where they are shipping and plan again.\n * - `destination-unserved` — the merchant ships somewhere, but not there.\n * - `cart-unpriceable` — the merchant priced shipping, but no rate of theirs\n * resolves for THIS cart at ANY destination (AGL-2232). A tier table that\n * stops at 2 kg says nothing about a 3 kg parcel, and the honest answer is\n * to refuse rather than to ship it for nothing.\n */\n refusal?: 'destination-required' | 'destination-unserved' | 'cart-unpriceable'\n}\n\n/**\n * Whether this merchant prices shipping AT ALL (AGL-2232).\n *\n * A pure question about the SETTINGS, deliberately taking no cart, and that is\n * the whole point of it existing. `planCheckoutShipping` used to ask \"does any\n * rate resolve for any destination?\" and pass the live cart into the probe, so\n * a cart that exhausted every tier made the probe empty too — and an empty\n * probe reads as \"this merchant never set shipping up\", the one case that must\n * complete with no `shipping_options` and nothing charged. A 3 kg order at a\n * store whose weight tiers stop at 2 kg therefore shipped free, silently, on\n * every one of the three doors.\n *\n * Answered from the stored zones and rates instead: a rate bound to a zone\n * that exists, or local pickup, means the merchant made a shipping decision\n * and a cart their table cannot price is a REFUSAL. A merchant with no zones,\n * no rates and no pickup has made no such decision, and their session stays\n * byte-identical to the one AGL-1707 shipped.\n */\nexport function merchantPricesShipping(\n settings: ShippingSettings | undefined,\n): boolean {\n if (!settings) return false\n if (settings.localPickup) return true\n const zoneIds = new Set((settings.zones ?? []).map((zone) => zone.id))\n // A rate whose `zoneId` names no zone is unreachable by every resolver here,\n // so it is not evidence of a decision — counting it would refuse carts at a\n // store with nothing but orphaned rows.\n return (settings.rates ?? []).some((rate) => zoneIds.has(rate.zoneId))\n}\n\n/**\n * What a shopper is told when `cart-unpriceable` refuses (AGL-2232).\n *\n * One string, shared by all three doors, because the three used to phrase the\n * two existing refusals identically and drifting here would make the same\n * store say different things depending on which button was pressed. It names\n * the cart rather than the destination on purpose — a `Ship to` select cannot\n * fix a parcel no tier covers, so the refusal deliberately does NOT set\n * `needsShippingCountry`.\n */\nexport const CART_UNPRICEABLE_SHIPPING_MESSAGE =\n 'This store cannot price shipping for this order — it falls outside every ' +\n 'shipping rate the store has set up. Try removing an item, or contact the ' +\n 'store.'\n\n/** Identity of a resolved list, for comparing two destinations' offers. */\nfunction optionsKey(options: readonly ResolvedShippingRate[]): string {\n return options\n .map((option) => `${option.rateId}:${option.amountCents}`)\n .join('|')\n}\n\n/**\n * The whole shipping half of a Checkout Session: which destinations it may\n * accept an address for, and which rates it may charge (AGL-1721).\n *\n * THE INVARIANT IS THAT EVERY RETURNED OPTION IS VALID FOR EVERY RETURNED\n * COUNTRY. Stripe offers a session's `shipping_options` to whoever reaches the\n * page and charges whichever the shopper picks — it does not filter them by\n * the address entered, and there is no field on a `shipping_rate_data` that\n * would let it. So the ONLY place a zone can be enforced is here, by choosing\n * the two lists together: narrowing the allowed countries to the declared\n * destination is what makes the rates offered for it unpickable by anyone\n * else. Filtering the rate list alone would leave a session that still accepts\n * a French address against a US rate.\n *\n * Three shapes come out of it:\n *\n * 1. A merchant whose rates resolve the SAME for every collectable country —\n * one zone, a single '*' zone, or none at all — needs no destination and\n * keeps the session AGL-1707 shipped, byte for byte. The issue notes this\n * is the common configuration, and it is the one that must not regress.\n * 2. A declared destination resolves exactly, and the session is restricted\n * to it. A caller may not skip the restriction: the country arrives in a\n * request body a shopper can craft, so a session that took the country's\n * cheap rate while still accepting any address would hand that shopper\n * the defect on purpose.\n * 3. Otherwise the caller must refuse — see `refusal`. Refusing is not lost\n * revenue in either case: with rates that differ by zone there is no\n * correct amount to charge without knowing where the parcel is going, and\n * the union AGL-1707 shipped chose the wrong one silently.\n *\n * `cart.subtotalCents` is the PRE-discount items total, matching what the cart\n * displays, so a free-over threshold means what the shopper saw it mean.\n */\nexport function planCheckoutShipping(\n settings: ShippingSettings | undefined,\n cart: { subtotalCents: number; totalGrams?: number },\n destinationCountry?: unknown,\n): CheckoutShippingPlan {\n const countries = CHECKOUT_SHIPPING_COUNTRIES as readonly string[]\n const destination = normalizeCheckoutShippingCountry(destinationCountry)\n if (destination) {\n const options = resolveCheckoutShippingOptions(\n settings,\n [destination],\n cart,\n )\n // REFUSAL IS DECIDED ON SHIPPABLE RATES, PICKUP IS OFFERED AFTERWARDS\n // (AGL-2325). `resolveShippingRates` appends pickup independent of zone\n // matching, so a merchant who turned it on had a non-empty list for every\n // destination on earth and this branch returned before it could refuse\n // anyone: a shopper in France picked `Local pickup — Free` on a physical\n // order from a US-only store, Stripe collected their French address\n // because the session still allowed it, and the merchant was paid nothing\n // to carry the parcel there. Pickup is collection, not delivery, so it\n // cannot answer \"do you ship to France\".\n //\n // `shipsElsewhere` is the same question the old probe asked, narrowed to\n // shippable rates, and it keeps the same two-way fork: a merchant who\n // configured NO shipping is not refusing anyone, so that store keeps\n // completing exactly as it does today, while a merchant who ships\n // elsewhere but not here is refusing and saying so is the honest answer.\n // A merchant who ships NOWHERE is untouched by this: pickup-only is a\n // real configuration — they deliver to no one and refuse no one — and it\n // falls through to the offer below exactly as it did.\n const shipsElsewhere = countries.some(\n (country) =>\n shippableOnly(resolveCheckoutShippingOptions(settings, [country], cart))\n .length > 0,\n )\n if (shippableOnly(options).length === 0 && shipsElsewhere) {\n return { countries, options: [], refusal: 'destination-unserved' }\n }\n if (options.length > 0) return { countries: [destination], options }\n // NOTHING resolves anywhere for this cart, and the two reasons that can be\n // true of are opposite (AGL-2232). Asked of the SETTINGS rather than of\n // this cart, because the probe above cannot tell them apart: a merchant\n // who priced shipping and whose table simply does not reach this order is\n // refusing, and used to be handed the \"never set shipping up\" branch.\n return merchantPricesShipping(settings)\n ? { countries, options: [], refusal: 'cart-unpriceable' }\n : { countries, options: [] }\n }\n const offers = new Set(\n countries.map((country) =>\n optionsKey(resolveCheckoutShippingOptions(settings, [country], cart)),\n ),\n )\n // Compared as EMITTED — capped and sanitized — because that is the list a\n // shopper can pick from. Two destinations whose rates differ only past\n // Stripe's cap still offer the same five, and offering them is honest.\n if (offers.size > 1) {\n return { countries, options: [], refusal: 'destination-required' }\n }\n const union = resolveCheckoutShippingOptions(settings, countries, cart)\n // Every destination agrees, and what they agree on is NOTHING. The same\n // fork as the declared-destination branch above (AGL-2232): a store with no\n // shipping settings completes untouched, a store whose rates cannot reach\n // this cart refuses instead of carrying the parcel for free.\n if (union.length === 0 && merchantPricesShipping(settings)) {\n return { countries, options: [], refusal: 'cart-unpriceable' }\n }\n return { countries, options: union }\n}\n\n/**\n * A cart chosen so that every rate a merchant could have written resolves if\n * it can resolve at all (AGL-1791). The settings page has no cart, and two of\n * the four rate kinds price against one: `tierAmount` takes the first tier\n * whose `upTo` is at least the value, so a zero subtotal and zero weight clear\n * every tier a merchant would write, and a `free_over` rate resolves at any\n * subtotal.\n *\n * That makes a coverage answer a LOWER BOUND on the gaps. A destination\n * reported unserved is unserved for every cart; a destination reported served\n * may still be refused for a cart dearer or heavier than the merchant's last\n * tier. Erring in this direction is the deliberate half: the console must not\n * warn a merchant whose settings are fine.\n */\nconst COVERAGE_PROBE_CART = { subtotalCents: 0, totalGrams: 0 }\n\n/** What a merchant's saved zones and rates cost their shoppers (AGL-1791). */\nexport interface ShippingCoverage {\n /** Collectable destinations at least one rate reaches. */\n served: string[]\n /**\n * Collectable destinations CHECKOUT REFUSES — `destination-unserved`.\n *\n * EMPTY FOR A MERCHANT WHOSE RATES REACH NOWHERE, who is not misconfigured:\n * `planCheckoutShipping` refuses nobody when no rate resolves anywhere, so\n * that store charges no shipping and completes exactly as it always has.\n * The rule lives here rather than in the caller because reading `served`\n * against a bare partition is how a surface comes to warn every store that\n * does not ship.\n */\n unserved: string[]\n /** No rate reaches anywhere: no shipping is charged, and nobody refused. */\n shipsNowhere: boolean\n /**\n * Rates differ by destination, so the storefront asks the shopper where the\n * parcel is going before it can price one — a checkout step the merchant\n * added by saving these zones, and never chose.\n */\n asksDestination: boolean\n /** Some zone already claims rest of world, so `*` is not the missing piece. */\n hasRestOfWorldZone: boolean\n /**\n * The merchant offers local pickup, so checkout presents a free collection\n * choice at every destination it does not refuse (AGL-2325).\n *\n * Deliberately NOT folded into `served`: pickup resolves for every country\n * because it is appended independent of zone matching, so counting it as\n * coverage told a US-only merchant who had switched it on that checkout\n * \"can price shipping to every destination\", which is the sentence that\n * hid the defect. It is reported alongside instead, so the card can say\n * both true things: these destinations are refused, and pickup is offered\n * to the ones that are not.\n */\n offersLocalPickup: boolean\n}\n\n/**\n * Which of the collectable destinations a merchant's settings actually cover\n * (AGL-1791), for a console surface to show them.\n *\n * Measured against `CHECKOUT_SHIPPING_COUNTRIES` and nothing wider: those six\n * are the only destinations a storefront session collects an address for, so\n * they are the only ones a merchant can lose an order to. Counting coverage\n * against every country on earth would report a gap for destinations no\n * shopper can reach checkout from.\n *\n * Answered through `resolveCheckoutShippingOptions` — the same function the\n * session is built from — so a row the console allows but Stripe would reject\n * (blank id, blank name) counts as the nothing it resolves to, and a specific\n * zone with no rates still hides a `*` zone here exactly as it does at\n * checkout.\n */\nexport function summarizeShippingCoverage(\n settings: ShippingSettings | undefined,\n): ShippingCoverage {\n const served: string[] = []\n const unserved: string[] = []\n const offers = new Set<string>()\n for (const country of CHECKOUT_SHIPPING_COUNTRIES) {\n const options = resolveCheckoutShippingOptions(\n settings,\n [country],\n COVERAGE_PROBE_CART,\n )\n // `asksDestination` is asked of the FULL list — pickup is a real option a\n // shopper picks, so a merchant whose US destination offers a rate plus\n // pickup while France offers only pickup genuinely does make checkout ask.\n offers.add(optionsKey(options))\n // `served` is asked of the SHIPPABLE list, so that it answers the same\n // question `planCheckoutShipping` refuses on (AGL-2325).\n ;(shippableOnly(options).length > 0 ? served : unserved).push(country)\n }\n return {\n served,\n unserved: served.length > 0 ? unserved : [],\n shipsNowhere: served.length === 0,\n asksDestination: offers.size > 1,\n offersLocalPickup: Boolean(settings?.localPickup),\n hasRestOfWorldZone: (settings?.zones ?? []).some((zone) =>\n zone.countries.some((code) => code === '*'),\n ),\n }\n}\n\n/**\n * What the Shipping card says about a store's coverage, in words (AGL-1791): the warning's headline and body when checkout\n * turns destinations away, otherwise the one line that says what it covers, and the line that says checkout asks where the\n * parcel is going. The console card and the native apps both read these, so the same store reads the same everywhere.\n */\nexport interface ShippingCoverageWords {\n /** Set when destinations are turned away: the warning's title. */\n headline: string\n /** The warning's body; empty when nothing is turned away. */\n detail: string\n /** The caption shown when nothing is turned away; empty when something is. */\n note: string\n /** Shown when rates differ by destination; empty otherwise. */\n asks: string\n}\n\nconst coverageCountryNames = (codes: readonly string[]) =>\n codes.map((code) => CHECKOUT_SHIPPING_COUNTRY_NAMES[code] ?? code).join(', ')\n\nexport function describeShippingCoverage(\n coverage: ShippingCoverage,\n): ShippingCoverageWords {\n const asks = coverage.asksDestination\n ? 'Rates differ by destination, so checkout asks the shopper where the parcel is going before it can price one.'\n : ''\n if (coverage.unserved.length > 0) {\n return {\n headline: `Checkout turns away ${coverage.unserved.length} of the ${CHECKOUT_SHIPPING_COUNTRIES.length} destinations it collects addresses for`,\n detail:\n `No rate reaches ${coverageCountryNames(coverage.unserved)}, so an order shipping there is refused rather than posted for free. ` +\n (coverage.offersLocalPickup\n ? 'Local pickup does not cover them: it is a collection choice, not a rate, so it is offered where checkout completes and closes no gap here. '\n : '') +\n (coverage.hasRestOfWorldZone\n ? 'A rest-of-world zone is already saved, so what those destinations are missing is a rate on the zone that covers them.'\n : 'Give them a zone with a rate, or add a rest-of-world zone and price one on it.'),\n note: '',\n asks,\n }\n }\n return {\n headline: '',\n detail: '',\n note: coverage.shipsNowhere\n ? coverage.offersLocalPickup\n ? 'No rate reaches any destination, so local pickup is the only delivery choice checkout offers. Nobody is turned away and no shipping is charged.'\n : 'No rate reaches any destination, so checkout charges no shipping and turns nobody away.'\n : `Checkout can price shipping to every destination it collects an address for: ${coverageCountryNames(coverage.served)}.`,\n asks,\n }\n}\n\n/**\n * Emit `shipping_address_collection[allowed_countries][n]` onto a session's\n * form body. Stripe will not apply a `shipping_options` rate without an\n * address to ship to, so the two always travel together — and the countries\n * are the OTHER half of what makes a rate honest (AGL-1721), so pass the list\n * `planCheckoutShipping` returned rather than defaulting it beside a rate list\n * resolved for something narrower.\n */\nexport function appendShippingAddressCollectionParams(\n params: URLSearchParams,\n countries: readonly string[] = CHECKOUT_SHIPPING_COUNTRIES,\n): void {\n countries.forEach((code, index) => {\n params.set(`shipping_address_collection[allowed_countries][${index}]`, code)\n })\n}\n\n/**\n * Emit resolved rates as `shipping_options` on a Checkout Session's form body.\n *\n * THIS IS THE ONLY THING THAT MAKES STRIPE CHARGE SHIPPING. Without these keys\n * Stripe presents no shipping choice and `total_details.amount_shipping` is 0\n * however many zones and rates the merchant saved (AGL-1707).\n *\n * An empty list emits NOTHING — not an empty array — so a merchant who\n * configured no shipping keeps a session byte-identical to the one built\n * before shipping was wired at all. That guarantee lives here rather than in\n * each caller, so neither checkout path can lose it independently (AGL-1720).\n */\nexport function appendCheckoutShippingParams(\n params: URLSearchParams,\n options: readonly ResolvedShippingRate[],\n): void {\n options.forEach((option, index) => {\n const field = `shipping_options[${index}][shipping_rate_data]`\n params.set(`${field}[type]`, 'fixed_amount')\n params.set(`${field}[display_name]`, option.name.slice(0, 100))\n params.set(`${field}[fixed_amount][amount]`, String(option.amountCents))\n params.set(`${field}[fixed_amount][currency]`, 'usd')\n })\n}\n"],"names":["CARRIER_QUOTE_TIMEOUT_MS","LOCAL_PICKUP_RATE_ID","zoneCode","code","String","trim","toUpperCase","isWildcard","zoneMatches","zone","country","countries","some","tierAmount","tiers","value","sorted","sort","a","b","upTo","tier","amountCents","matchedZoneIds","settings","destinationCountry","zones","specific","filter","matched","length","Set","map","id","carrierRatesFor","zoneIds","rates","rate","kind","has","zoneId","hasCarrierRates","carrierOptionRateId","rateId","serviceKey","carrierOptionAmountCents","quotedCents","quote","Math","max","round","Number","pct","markupPct","handling","handlingCents","isFinite","min","resolveShippingRates","cart","resolved","freeOverCents","subtotalCents","totalGrams","push","name","localPickup","shippableOnly","options","option","MAX_CHECKOUT_SHIPPING_OPTIONS","CHECKOUT_SHIPPING_COUNTRIES","resolveCheckoutShippingOptions","destinationCountries","byRateId","Map","existing","get","set","values","localeCompare","slice","CHECKOUT_SHIPPING_COUNTRY_NAMES","US","CA","GB","AU","DE","FR","normalizeCheckoutShippingCountry","undefined","includes","merchantPricesShipping","CART_UNPRICEABLE_SHIPPING_MESSAGE","optionsKey","join","planCheckoutShipping","destination","shipsElsewhere","refusal","offers","size","union","COVERAGE_PROBE_CART","summarizeShippingCoverage","served","unserved","add","shipsNowhere","asksDestination","offersLocalPickup","Boolean","hasRestOfWorldZone","coverageCountryNames","codes","describeShippingCoverage","coverage","asks","headline","detail","note","appendShippingAddressCollectionParams","params","forEach","index","appendCheckoutShippingParams","field"],"mappings":"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"}
|
|
1
|
+
{"version":3,"sources":["../../../../../../../libs/plugins/commerce/src/lib/model/commerce-shipping.ts"],"sourcesContent":["/**\n * @license\n * Copyright 2026 Aglyn LLC\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n * http://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n\n/**\n * Shipping v1 (AGL-288): zones own countries; rates belong to a zone and\n * price as flat, free-over-threshold, or subtotal/weight tiers. Settings\n * live on `hosts/{hostId}/settings/store` under `shipping`. Pure.\n *\n * `resolveShippingRates` is the per-destination resolver;\n * `resolveCheckoutShippingOptions` is the Stripe Checkout adapter,\n * `planCheckoutShipping` decides which destinations one session may serve,\n * `appendCheckoutShippingParams` emits it onto a session, and\n * `summarizeShippingCoverage` answers the same question for the console, so a\n * merchant can see which destinations their zones refuse. AGL-288 shipped this\n * model with no production call site at all — the doc comment here claimed the\n * cart estimator, checkout and POS pickup used it, and none of them did, so\n * nothing ever read the settings a merchant saved and no shipping was ever\n * charged. Cart checkout calls it (AGL-1707), buy-now calls it for physical\n * one-time products (AGL-1720), and a draft order's payment link calls it for\n * physical products (AGL-1792).\n *\n * POS DOES NOT, AND SHOULD NOT (AGL-1792). A register has no destination\n * anywhere in it — no address field, no delivery choice — and all three\n * tenders settle at the counter: cash, a card QR the customer scans while\n * standing there, and a room folio collected at check-out. There is nothing to\n * price a parcel against, so the only rate POS could resolve is the free\n * `Local pickup` one this module appends, which is 0¢ and would add a step to\n * a register flow in exchange for nothing. A POS sale that must be shipped is\n * a draft order.\n */\n\nexport interface ShippingZone {\n id: string\n name: string\n /** ISO-3166 alpha-2 codes; '*' = rest of world. */\n countries: string[]\n}\n\nexport interface ShippingTier {\n /** Tier applies while subtotal (cents) or weight (grams) ≤ this. */\n upTo: number\n amountCents: number\n}\n\n/**\n * A live carrier rate (AGL-3612): the zone's price is what a carrier quotes\n * for the parcel to the shopper's address, asked through core's\n * `core.shipping-rate-quoter` seam of whichever plugin quotes carriers. With\n * no quoter, a quoter that is not available for the site, no postal code to\n * quote against, or a quote slower than {@link CARRIER_QUOTE_TIMEOUT_MS}, it\n * offers nothing and its fallback rate is offered instead.\n */\nexport interface CarrierRateSettings {\n /** Service keys (`carrier:service`) to offer; empty offers every quoted service. */\n services?: string[]\n /** Added to each quote, in percent. */\n markupPct?: number\n /** Added to each quote after the markup, in cents. */\n handlingCents?: number\n /**\n * A rate of this zone offered when no live quote is: it is HIDDEN while\n * quotes are offered, so the shopper picks between carriers, not between a\n * carrier and the merchant's guess.\n */\n fallbackRateId?: string\n}\n\n/** How long checkout waits for live carrier quotes before it falls back. */\nexport const CARRIER_QUOTE_TIMEOUT_MS = 3_000\n\nexport interface ShippingRate {\n id: string\n zoneId: string\n name: string\n kind: 'flat' | 'free_over' | 'price_tiers' | 'weight_tiers' | 'carrier'\n /** flat + free_over base amount. */\n amountCents?: number\n /** free_over: order subtotal (cents) at/above which shipping is free. */\n freeOverCents?: number\n /** price_tiers (upTo = subtotal cents) / weight_tiers (upTo = grams). */\n tiers?: ShippingTier[]\n /** carrier: the live rate's options (AGL-3612). */\n carrier?: CarrierRateSettings\n}\n\nexport interface ShippingSettings {\n zones?: ShippingZone[]\n rates?: ShippingRate[]\n /**\n * Offer free local pickup as a delivery choice. Does NOT widen where the\n * store ships: a destination no rate reaches is still refused (AGL-2325).\n */\n localPickup?: boolean\n}\n\nexport interface ResolvedShippingRate {\n rateId: string\n name: string\n amountCents: number\n}\n\n/**\n * The rate id appended for local pickup (AGL-2325).\n *\n * Exported because pickup is the one entry in a resolved list that is NOT a\n * shipping promise: it is appended AFTER and INDEPENDENT of zone matching, so\n * it resolves for every destination on earth. Anything asking \"does this store\n * ship there\" — the checkout refusal, the console coverage card — has to be\n * able to tell it apart from a rate, or it reads a merchant who offers\n * collection as a merchant who delivers worldwide.\n */\nexport const LOCAL_PICKUP_RATE_ID = 'pickup'\n\n/**\n * One stored country code, normalised (AGL-2298).\n *\n * TRIMMED as well as upper-cased. The console trims on save, so this only\n * matters for a document written any other way — an import, the REST API, a\n * hand edit — but the failure it produces is silent and expensive: `' US'`\n * upper-cases to `' US'`, matches nothing, and the store refuses every US\n * shopper with \"This store does not ship to the United States\" while its\n * settings card shows `US` right there.\n */\nconst zoneCode = (code: unknown): string => String(code ?? '').trim().toUpperCase()\n\n/** Whether a stored code is the rest-of-world wildcard. */\nconst isWildcard = (code: unknown): boolean => zoneCode(code) === '*'\n\nfunction zoneMatches(zone: ShippingZone, country: string): boolean {\n return (zone.countries ?? []).some(\n (code) => isWildcard(code) || zoneCode(code) === country,\n )\n}\n\nfunction tierAmount(\n tiers: ShippingTier[] | undefined,\n value: number,\n): number | null {\n const sorted = [...(tiers ?? [])].sort((a, b) => a.upTo - b.upTo)\n for (const tier of sorted) {\n if (value <= tier.upTo) return tier.amountCents\n }\n // Beyond the last tier the rate does not apply (merchant should add a\n // catch-all tier with a large upTo).\n return null\n}\n\n/**\n * The zones a destination falls in (AGL-1707, factored out for AGL-3612).\n * Specific country zones beat '*' zones: when any zone names the country\n * outright, rest-of-world zones are ignored.\n */\nexport function matchedZoneIds(\n settings: ShippingSettings | undefined,\n destinationCountry: string,\n): Set<string> {\n const country = destinationCountry.toUpperCase()\n const zones = settings?.zones ?? []\n // A zone is SPECIFIC when it names this country outright — not merely when\n // it is \"not wildcard-only\" (AGL-2298). The old test was `!every(code ===\n // '*')`, so a zone spelled `['*','US']` counted as specific for all six\n // destinations (it matches every country through the `*`) and suppressed\n // every genuine rest-of-world zone globally. Asking the narrower question\n // makes such a zone specific for `US` and wildcard for the rest, which is\n // what its two entries actually say.\n const specific = zones.filter((zone) =>\n (zone.countries ?? []).some((code) => zoneCode(code) === country),\n )\n const matched = specific.length\n ? specific\n : zones.filter((zone) => zoneMatches(zone, country))\n return new Set(matched.map((zone) => zone.id))\n}\n\n/** The live carrier rates a destination's zones carry (AGL-3612). */\nexport function carrierRatesFor(\n settings: ShippingSettings | undefined,\n destinationCountry: string,\n): ShippingRate[] {\n if (!settings) return []\n const zoneIds = matchedZoneIds(settings, destinationCountry)\n return (settings.rates ?? []).filter(\n (rate) => rate.kind === 'carrier' && zoneIds.has(rate.zoneId),\n )\n}\n\n/** Whether any zone carries a live carrier rate (AGL-3612). */\nexport function hasCarrierRates(settings: ShippingSettings | undefined): boolean {\n return (settings?.rates ?? []).some((rate) => rate.kind === 'carrier')\n}\n\n/** The rate id a quoted service is offered under: stable per rate and service. */\nexport function carrierOptionRateId(rateId: string, serviceKey: string): string {\n return `carrier:${rateId}:${serviceKey}`\n}\n\n/**\n * What a shopper pays for a carrier's quote under one carrier rate: the\n * quote, plus the markup, plus the handling fee, in whole cents.\n */\nexport function carrierOptionAmountCents(\n quotedCents: number,\n settings: CarrierRateSettings | undefined,\n): number {\n const quote = Math.max(0, Math.round(Number(quotedCents) || 0))\n const pct = Number(settings?.markupPct)\n const handling = Number(settings?.handlingCents)\n return (\n quote +\n (Number.isFinite(pct) && pct > 0 ? Math.round((quote * Math.min(pct, 500)) / 100) : 0) +\n (Number.isFinite(handling) && handling > 0 ? Math.round(handling) : 0)\n )\n}\n\n/**\n * Rates available for a destination + cart, cheapest first. Specific\n * country zones beat '*' zones: when any specific zone matches, '*'\n * zones are ignored (rest-of-world semantics).\n *\n * Local pickup is appended AFTER and INDEPENDENT of zone matching, so this\n * list is never empty for a merchant who offers it, at any destination. That\n * is correct for a resolver — a shopper anywhere may choose to come and\n * collect — but it means A NON-EMPTY RESULT IS NOT A PROMISE TO SHIP THERE.\n * Callers deciding whether a destination is served must filter pickup out\n * first; `planCheckoutShipping` and `summarizeShippingCoverage` do (AGL-2325).\n */\nexport function resolveShippingRates(\n settings: ShippingSettings | undefined,\n destinationCountry: string | undefined,\n cart: { subtotalCents: number; totalGrams?: number },\n): ResolvedShippingRate[] {\n if (!settings || !destinationCountry) return []\n const zoneIds = matchedZoneIds(settings, destinationCountry)\n // A zone is SPECIFIC when it names this country outright — not merely when\n // it is \"not wildcard-only\" (AGL-2298). The old test was `!every(code ===\n // '*')`, so a zone spelled `['*','US']` counted as specific for all six\n // destinations (it matches every country through the `*`) and suppressed\n // every genuine rest-of-world zone globally. Asking the narrower question\n // makes such a zone specific for `US` and wildcard for the rest, which is\n // what its two entries actually say.\n const resolved: ResolvedShippingRate[] = []\n for (const rate of settings.rates ?? []) {\n if (!zoneIds.has(rate.zoneId)) continue\n let amountCents: number | null = null\n switch (rate.kind) {\n case 'flat':\n amountCents = Math.max(0, Math.round(rate.amountCents ?? 0))\n break\n case 'free_over':\n amountCents =\n rate.freeOverCents != null &&\n cart.subtotalCents >= rate.freeOverCents\n ? 0\n : Math.max(0, Math.round(rate.amountCents ?? 0))\n break\n case 'price_tiers':\n amountCents = tierAmount(rate.tiers, cart.subtotalCents)\n break\n case 'weight_tiers':\n amountCents = tierAmount(rate.tiers, cart.totalGrams ?? 0)\n break\n // A carrier's price exists only once a carrier quoted it for an\n // address (AGL-3612), so a table read offers nothing for it and its\n // fallback rate is what resolves here. `carrier-shipping.ts` adds the\n // quotes where one can be had.\n case 'carrier':\n break\n }\n if (amountCents == null) continue\n resolved.push({ rateId: rate.id, name: rate.name, amountCents })\n }\n if (settings.localPickup) {\n resolved.push({\n rateId: LOCAL_PICKUP_RATE_ID,\n name: 'Local pickup',\n amountCents: 0,\n })\n }\n return resolved.sort((a, b) => a.amountCents - b.amountCents)\n}\n\n/**\n * The resolved options that actually carry a parcel (AGL-2325).\n *\n * Safe to apply AFTER `MAX_CHECKOUT_SHIPPING_OPTIONS` truncation: pickup is\n * 0¢ and sorts first, so it occupies at most one of the five slots and a\n * merchant with any shippable rate at all keeps at least one of them.\n */\nfunction shippableOnly(\n options: readonly ResolvedShippingRate[],\n): ResolvedShippingRate[] {\n return options.filter((option) => option.rateId !== LOCAL_PICKUP_RATE_ID)\n}\n\n/** Stripe Checkout accepts at most 5 `shipping_options` on one session. */\nexport const MAX_CHECKOUT_SHIPPING_OPTIONS = 5\n\n/**\n * Destinations a storefront Checkout Session collects an address for, and\n * therefore the destinations shipping rates are resolved against (AGL-1707).\n *\n * NOT narrowed to the zones the merchant configured: narrowing would block\n * checkouts that complete today, turning a money fix into lost sales. The\n * wider list only means a shopper outside the merchant's zones is offered some\n * rate rather than none. Shared by cart checkout and buy-now (AGL-1720) so the\n * two paths cannot drift into offering different destinations.\n */\nexport const CHECKOUT_SHIPPING_COUNTRIES = [\n 'US',\n 'CA',\n 'GB',\n 'AU',\n 'DE',\n 'FR',\n] as const\n\n/**\n * The rates to declare as `shipping_options` on a Stripe Checkout Session\n * (AGL-1707), unioned over `destinationCountries`. Stripe charges shipping\n * ONLY when the session declares them.\n *\n * A UNION IS ONLY HONEST OVER DESTINATIONS THE SESSION IS ALSO RESTRICTED TO.\n * Called with every collectable country while the session accepted an address\n * in any of them — which is what AGL-1707 shipped — it offers a shopper in one\n * zone a rate belonging to another, and Stripe applies whichever the shopper\n * picks (a `shipping_rate_data` carries no country of its own, so nothing\n * downstream re-checks it against the address). That is AGL-1721. Callers now\n * reach this through `planCheckoutShipping`, which pairs the country list it\n * unions over with the country list the session allows, so the two cannot\n * disagree. Do not call it directly from a handler.\n *\n * Returns `[]` for a merchant who has configured nothing, and the caller must\n * then declare no `shipping_options` at all rather than an empty array — a\n * merchant who never set shipping up keeps a session identical to today's.\n */\nexport function resolveCheckoutShippingOptions(\n settings: ShippingSettings | undefined,\n destinationCountries: readonly string[],\n cart: { subtotalCents: number; totalGrams?: number },\n): ResolvedShippingRate[] {\n if (!settings) return []\n const byRateId = new Map<string, ResolvedShippingRate>()\n for (const country of destinationCountries) {\n for (const rate of resolveShippingRates(settings, country, cart)) {\n // Stripe requires a display name, and a rate id is the dedupe key —\n // the console lets a merchant add a blank row, so neither is assured.\n if (!rate.rateId || !rate.name) continue\n const amountCents = Math.max(0, Math.round(Number(rate.amountCents) || 0))\n const existing = byRateId.get(rate.rateId)\n // One rate resolving differently for two countries should not be\n // possible (a zone selects a rate, it does not reprice one), but if it\n // ever is, keep the dearer. Under-charging is the defect being fixed.\n if (!existing || amountCents > existing.amountCents) {\n byRateId.set(rate.rateId, { ...rate, amountCents })\n }\n }\n }\n return (\n [...byRateId.values()]\n // Cheapest first, id as a tie-break so the emitted params are stable.\n .sort(\n (a, b) =>\n a.amountCents - b.amountCents || a.rateId.localeCompare(b.rateId),\n )\n // Past Stripe's cap the dearest are dropped, which is the harmless end\n // to lose: a shopper offered the cheap options would have taken one.\n .slice(0, MAX_CHECKOUT_SHIPPING_OPTIONS)\n )\n}\n\n/** {@link CHECKOUT_SHIPPING_COUNTRIES} as plain codes, for the apps' draft order (AGL-3841). */\nexport const CHECKOUT_SHIPPING_COUNTRY_CODES: readonly string[] = CHECKOUT_SHIPPING_COUNTRIES\n\n/** Shopper-facing names for the collectable destinations (AGL-1721). */\nexport const CHECKOUT_SHIPPING_COUNTRY_NAMES: Readonly<Record<string, string>> =\n {\n US: 'United States',\n CA: 'Canada',\n GB: 'United Kingdom',\n AU: 'Australia',\n DE: 'Germany',\n FR: 'France',\n }\n\n/**\n * A destination a shopper declared, or `undefined` for anything this session\n * could not collect an address for anyway. Never throws: an unknown code is\n * an undeclared destination, not an error, so a stale storefront that posts\n * one is answered by the plan below rather than by a 400 nobody can act on.\n */\nexport function normalizeCheckoutShippingCountry(\n value: unknown,\n): string | undefined {\n // `typeof` rather than `String(value)`: a JSON body is caller-shaped, and\n // `String(['US'])` is 'US'. Coercing would let an array declare a\n // destination, which is not a spelling any storefront sends.\n if (typeof value !== 'string') return undefined\n const code = value.trim().toUpperCase()\n return (CHECKOUT_SHIPPING_COUNTRIES as readonly string[]).includes(code)\n ? code\n : undefined\n}\n\nexport interface CheckoutShippingPlan {\n /** `shipping_address_collection[allowed_countries]` for this session. */\n countries: readonly string[]\n /** `shipping_options` for this session — valid for EVERY `countries` entry. */\n options: ResolvedShippingRate[]\n /**\n * Set when no session may be created at all:\n *\n * - `destination-required` — the merchant's rates differ by destination, so\n * there is no set of options honest for every address Stripe would accept.\n * Ask the shopper where they are shipping and plan again.\n * - `destination-unserved` — the merchant ships somewhere, but not there.\n * - `cart-unpriceable` — the merchant priced shipping, but no rate of theirs\n * resolves for THIS cart at ANY destination (AGL-2232). A tier table that\n * stops at 2 kg says nothing about a 3 kg parcel, and the honest answer is\n * to refuse rather than to ship it for nothing.\n */\n refusal?: 'destination-required' | 'destination-unserved' | 'cart-unpriceable'\n}\n\n/**\n * Whether this merchant prices shipping AT ALL (AGL-2232).\n *\n * A pure question about the SETTINGS, deliberately taking no cart, and that is\n * the whole point of it existing. `planCheckoutShipping` used to ask \"does any\n * rate resolve for any destination?\" and pass the live cart into the probe, so\n * a cart that exhausted every tier made the probe empty too — and an empty\n * probe reads as \"this merchant never set shipping up\", the one case that must\n * complete with no `shipping_options` and nothing charged. A 3 kg order at a\n * store whose weight tiers stop at 2 kg therefore shipped free, silently, on\n * every one of the three doors.\n *\n * Answered from the stored zones and rates instead: a rate bound to a zone\n * that exists, or local pickup, means the merchant made a shipping decision\n * and a cart their table cannot price is a REFUSAL. A merchant with no zones,\n * no rates and no pickup has made no such decision, and their session stays\n * byte-identical to the one AGL-1707 shipped.\n */\nexport function merchantPricesShipping(\n settings: ShippingSettings | undefined,\n): boolean {\n if (!settings) return false\n if (settings.localPickup) return true\n const zoneIds = new Set((settings.zones ?? []).map((zone) => zone.id))\n // A rate whose `zoneId` names no zone is unreachable by every resolver here,\n // so it is not evidence of a decision — counting it would refuse carts at a\n // store with nothing but orphaned rows.\n return (settings.rates ?? []).some((rate) => zoneIds.has(rate.zoneId))\n}\n\n/**\n * What a shopper is told when `cart-unpriceable` refuses (AGL-2232).\n *\n * One string, shared by all three doors, because the three used to phrase the\n * two existing refusals identically and drifting here would make the same\n * store say different things depending on which button was pressed. It names\n * the cart rather than the destination on purpose — a `Ship to` select cannot\n * fix a parcel no tier covers, so the refusal deliberately does NOT set\n * `needsShippingCountry`.\n */\nexport const CART_UNPRICEABLE_SHIPPING_MESSAGE =\n 'This store cannot price shipping for this order — it falls outside every ' +\n 'shipping rate the store has set up. Try removing an item, or contact the ' +\n 'store.'\n\n/** Identity of a resolved list, for comparing two destinations' offers. */\nfunction optionsKey(options: readonly ResolvedShippingRate[]): string {\n return options\n .map((option) => `${option.rateId}:${option.amountCents}`)\n .join('|')\n}\n\n/**\n * The whole shipping half of a Checkout Session: which destinations it may\n * accept an address for, and which rates it may charge (AGL-1721).\n *\n * THE INVARIANT IS THAT EVERY RETURNED OPTION IS VALID FOR EVERY RETURNED\n * COUNTRY. Stripe offers a session's `shipping_options` to whoever reaches the\n * page and charges whichever the shopper picks — it does not filter them by\n * the address entered, and there is no field on a `shipping_rate_data` that\n * would let it. So the ONLY place a zone can be enforced is here, by choosing\n * the two lists together: narrowing the allowed countries to the declared\n * destination is what makes the rates offered for it unpickable by anyone\n * else. Filtering the rate list alone would leave a session that still accepts\n * a French address against a US rate.\n *\n * Three shapes come out of it:\n *\n * 1. A merchant whose rates resolve the SAME for every collectable country —\n * one zone, a single '*' zone, or none at all — needs no destination and\n * keeps the session AGL-1707 shipped, byte for byte. The issue notes this\n * is the common configuration, and it is the one that must not regress.\n * 2. A declared destination resolves exactly, and the session is restricted\n * to it. A caller may not skip the restriction: the country arrives in a\n * request body a shopper can craft, so a session that took the country's\n * cheap rate while still accepting any address would hand that shopper\n * the defect on purpose.\n * 3. Otherwise the caller must refuse — see `refusal`. Refusing is not lost\n * revenue in either case: with rates that differ by zone there is no\n * correct amount to charge without knowing where the parcel is going, and\n * the union AGL-1707 shipped chose the wrong one silently.\n *\n * `cart.subtotalCents` is the PRE-discount items total, matching what the cart\n * displays, so a free-over threshold means what the shopper saw it mean.\n */\nexport function planCheckoutShipping(\n settings: ShippingSettings | undefined,\n cart: { subtotalCents: number; totalGrams?: number },\n destinationCountry?: unknown,\n): CheckoutShippingPlan {\n const countries = CHECKOUT_SHIPPING_COUNTRIES as readonly string[]\n const destination = normalizeCheckoutShippingCountry(destinationCountry)\n if (destination) {\n const options = resolveCheckoutShippingOptions(\n settings,\n [destination],\n cart,\n )\n // REFUSAL IS DECIDED ON SHIPPABLE RATES, PICKUP IS OFFERED AFTERWARDS\n // (AGL-2325). `resolveShippingRates` appends pickup independent of zone\n // matching, so a merchant who turned it on had a non-empty list for every\n // destination on earth and this branch returned before it could refuse\n // anyone: a shopper in France picked `Local pickup — Free` on a physical\n // order from a US-only store, Stripe collected their French address\n // because the session still allowed it, and the merchant was paid nothing\n // to carry the parcel there. Pickup is collection, not delivery, so it\n // cannot answer \"do you ship to France\".\n //\n // `shipsElsewhere` is the same question the old probe asked, narrowed to\n // shippable rates, and it keeps the same two-way fork: a merchant who\n // configured NO shipping is not refusing anyone, so that store keeps\n // completing exactly as it does today, while a merchant who ships\n // elsewhere but not here is refusing and saying so is the honest answer.\n // A merchant who ships NOWHERE is untouched by this: pickup-only is a\n // real configuration — they deliver to no one and refuse no one — and it\n // falls through to the offer below exactly as it did.\n const shipsElsewhere = countries.some(\n (country) =>\n shippableOnly(resolveCheckoutShippingOptions(settings, [country], cart))\n .length > 0,\n )\n if (shippableOnly(options).length === 0 && shipsElsewhere) {\n return { countries, options: [], refusal: 'destination-unserved' }\n }\n if (options.length > 0) return { countries: [destination], options }\n // NOTHING resolves anywhere for this cart, and the two reasons that can be\n // true of are opposite (AGL-2232). Asked of the SETTINGS rather than of\n // this cart, because the probe above cannot tell them apart: a merchant\n // who priced shipping and whose table simply does not reach this order is\n // refusing, and used to be handed the \"never set shipping up\" branch.\n return merchantPricesShipping(settings)\n ? { countries, options: [], refusal: 'cart-unpriceable' }\n : { countries, options: [] }\n }\n const offers = new Set(\n countries.map((country) =>\n optionsKey(resolveCheckoutShippingOptions(settings, [country], cart)),\n ),\n )\n // Compared as EMITTED — capped and sanitized — because that is the list a\n // shopper can pick from. Two destinations whose rates differ only past\n // Stripe's cap still offer the same five, and offering them is honest.\n if (offers.size > 1) {\n return { countries, options: [], refusal: 'destination-required' }\n }\n const union = resolveCheckoutShippingOptions(settings, countries, cart)\n // Every destination agrees, and what they agree on is NOTHING. The same\n // fork as the declared-destination branch above (AGL-2232): a store with no\n // shipping settings completes untouched, a store whose rates cannot reach\n // this cart refuses instead of carrying the parcel for free.\n if (union.length === 0 && merchantPricesShipping(settings)) {\n return { countries, options: [], refusal: 'cart-unpriceable' }\n }\n return { countries, options: union }\n}\n\n/**\n * A cart chosen so that every rate a merchant could have written resolves if\n * it can resolve at all (AGL-1791). The settings page has no cart, and two of\n * the four rate kinds price against one: `tierAmount` takes the first tier\n * whose `upTo` is at least the value, so a zero subtotal and zero weight clear\n * every tier a merchant would write, and a `free_over` rate resolves at any\n * subtotal.\n *\n * That makes a coverage answer a LOWER BOUND on the gaps. A destination\n * reported unserved is unserved for every cart; a destination reported served\n * may still be refused for a cart dearer or heavier than the merchant's last\n * tier. Erring in this direction is the deliberate half: the console must not\n * warn a merchant whose settings are fine.\n */\nconst COVERAGE_PROBE_CART = { subtotalCents: 0, totalGrams: 0 }\n\n/** What a merchant's saved zones and rates cost their shoppers (AGL-1791). */\nexport interface ShippingCoverage {\n /** Collectable destinations at least one rate reaches. */\n served: string[]\n /**\n * Collectable destinations CHECKOUT REFUSES — `destination-unserved`.\n *\n * EMPTY FOR A MERCHANT WHOSE RATES REACH NOWHERE, who is not misconfigured:\n * `planCheckoutShipping` refuses nobody when no rate resolves anywhere, so\n * that store charges no shipping and completes exactly as it always has.\n * The rule lives here rather than in the caller because reading `served`\n * against a bare partition is how a surface comes to warn every store that\n * does not ship.\n */\n unserved: string[]\n /** No rate reaches anywhere: no shipping is charged, and nobody refused. */\n shipsNowhere: boolean\n /**\n * Rates differ by destination, so the storefront asks the shopper where the\n * parcel is going before it can price one — a checkout step the merchant\n * added by saving these zones, and never chose.\n */\n asksDestination: boolean\n /** Some zone already claims rest of world, so `*` is not the missing piece. */\n hasRestOfWorldZone: boolean\n /**\n * The merchant offers local pickup, so checkout presents a free collection\n * choice at every destination it does not refuse (AGL-2325).\n *\n * Deliberately NOT folded into `served`: pickup resolves for every country\n * because it is appended independent of zone matching, so counting it as\n * coverage told a US-only merchant who had switched it on that checkout\n * \"can price shipping to every destination\", which is the sentence that\n * hid the defect. It is reported alongside instead, so the card can say\n * both true things: these destinations are refused, and pickup is offered\n * to the ones that are not.\n */\n offersLocalPickup: boolean\n}\n\n/**\n * Which of the collectable destinations a merchant's settings actually cover\n * (AGL-1791), for a console surface to show them.\n *\n * Measured against `CHECKOUT_SHIPPING_COUNTRIES` and nothing wider: those six\n * are the only destinations a storefront session collects an address for, so\n * they are the only ones a merchant can lose an order to. Counting coverage\n * against every country on earth would report a gap for destinations no\n * shopper can reach checkout from.\n *\n * Answered through `resolveCheckoutShippingOptions` — the same function the\n * session is built from — so a row the console allows but Stripe would reject\n * (blank id, blank name) counts as the nothing it resolves to, and a specific\n * zone with no rates still hides a `*` zone here exactly as it does at\n * checkout.\n */\nexport function summarizeShippingCoverage(\n settings: ShippingSettings | undefined,\n): ShippingCoverage {\n const served: string[] = []\n const unserved: string[] = []\n const offers = new Set<string>()\n for (const country of CHECKOUT_SHIPPING_COUNTRIES) {\n const options = resolveCheckoutShippingOptions(\n settings,\n [country],\n COVERAGE_PROBE_CART,\n )\n // `asksDestination` is asked of the FULL list — pickup is a real option a\n // shopper picks, so a merchant whose US destination offers a rate plus\n // pickup while France offers only pickup genuinely does make checkout ask.\n offers.add(optionsKey(options))\n // `served` is asked of the SHIPPABLE list, so that it answers the same\n // question `planCheckoutShipping` refuses on (AGL-2325).\n ;(shippableOnly(options).length > 0 ? served : unserved).push(country)\n }\n return {\n served,\n unserved: served.length > 0 ? unserved : [],\n shipsNowhere: served.length === 0,\n asksDestination: offers.size > 1,\n offersLocalPickup: Boolean(settings?.localPickup),\n hasRestOfWorldZone: (settings?.zones ?? []).some((zone) =>\n zone.countries.some((code) => code === '*'),\n ),\n }\n}\n\n/**\n * What the Shipping card says about a store's coverage, in words (AGL-1791): the warning's headline and body when checkout\n * turns destinations away, otherwise the one line that says what it covers, and the line that says checkout asks where the\n * parcel is going. The console card and the native apps both read these, so the same store reads the same everywhere.\n */\nexport interface ShippingCoverageWords {\n /** Set when destinations are turned away: the warning's title. */\n headline: string\n /** The warning's body; empty when nothing is turned away. */\n detail: string\n /** The caption shown when nothing is turned away; empty when something is. */\n note: string\n /** Shown when rates differ by destination; empty otherwise. */\n asks: string\n}\n\nconst coverageCountryNames = (codes: readonly string[]) =>\n codes.map((code) => CHECKOUT_SHIPPING_COUNTRY_NAMES[code] ?? code).join(', ')\n\nexport function describeShippingCoverage(\n coverage: ShippingCoverage,\n): ShippingCoverageWords {\n const asks = coverage.asksDestination\n ? 'Rates differ by destination, so checkout asks the shopper where the parcel is going before it can price one.'\n : ''\n if (coverage.unserved.length > 0) {\n return {\n headline: `Checkout turns away ${coverage.unserved.length} of the ${CHECKOUT_SHIPPING_COUNTRIES.length} destinations it collects addresses for`,\n detail:\n `No rate reaches ${coverageCountryNames(coverage.unserved)}, so an order shipping there is refused rather than posted for free. ` +\n (coverage.offersLocalPickup\n ? 'Local pickup does not cover them: it is a collection choice, not a rate, so it is offered where checkout completes and closes no gap here. '\n : '') +\n (coverage.hasRestOfWorldZone\n ? 'A rest-of-world zone is already saved, so what those destinations are missing is a rate on the zone that covers them.'\n : 'Give them a zone with a rate, or add a rest-of-world zone and price one on it.'),\n note: '',\n asks,\n }\n }\n return {\n headline: '',\n detail: '',\n note: coverage.shipsNowhere\n ? coverage.offersLocalPickup\n ? 'No rate reaches any destination, so local pickup is the only delivery choice checkout offers. Nobody is turned away and no shipping is charged.'\n : 'No rate reaches any destination, so checkout charges no shipping and turns nobody away.'\n : `Checkout can price shipping to every destination it collects an address for: ${coverageCountryNames(coverage.served)}.`,\n asks,\n }\n}\n\n/**\n * Emit `shipping_address_collection[allowed_countries][n]` onto a session's\n * form body. Stripe will not apply a `shipping_options` rate without an\n * address to ship to, so the two always travel together — and the countries\n * are the OTHER half of what makes a rate honest (AGL-1721), so pass the list\n * `planCheckoutShipping` returned rather than defaulting it beside a rate list\n * resolved for something narrower.\n */\nexport function appendShippingAddressCollectionParams(\n params: URLSearchParams,\n countries: readonly string[] = CHECKOUT_SHIPPING_COUNTRIES,\n): void {\n countries.forEach((code, index) => {\n params.set(`shipping_address_collection[allowed_countries][${index}]`, code)\n })\n}\n\n/**\n * Emit resolved rates as `shipping_options` on a Checkout Session's form body.\n *\n * THIS IS THE ONLY THING THAT MAKES STRIPE CHARGE SHIPPING. Without these keys\n * Stripe presents no shipping choice and `total_details.amount_shipping` is 0\n * however many zones and rates the merchant saved (AGL-1707).\n *\n * An empty list emits NOTHING — not an empty array — so a merchant who\n * configured no shipping keeps a session byte-identical to the one built\n * before shipping was wired at all. That guarantee lives here rather than in\n * each caller, so neither checkout path can lose it independently (AGL-1720).\n */\nexport function appendCheckoutShippingParams(\n params: URLSearchParams,\n options: readonly ResolvedShippingRate[],\n): void {\n options.forEach((option, index) => {\n const field = `shipping_options[${index}][shipping_rate_data]`\n params.set(`${field}[type]`, 'fixed_amount')\n params.set(`${field}[display_name]`, option.name.slice(0, 100))\n params.set(`${field}[fixed_amount][amount]`, String(option.amountCents))\n params.set(`${field}[fixed_amount][currency]`, 'usd')\n })\n}\n"],"names":["CARRIER_QUOTE_TIMEOUT_MS","LOCAL_PICKUP_RATE_ID","zoneCode","code","String","trim","toUpperCase","isWildcard","zoneMatches","zone","country","countries","some","tierAmount","tiers","value","sorted","sort","a","b","upTo","tier","amountCents","matchedZoneIds","settings","destinationCountry","zones","specific","filter","matched","length","Set","map","id","carrierRatesFor","zoneIds","rates","rate","kind","has","zoneId","hasCarrierRates","carrierOptionRateId","rateId","serviceKey","carrierOptionAmountCents","quotedCents","quote","Math","max","round","Number","pct","markupPct","handling","handlingCents","isFinite","min","resolveShippingRates","cart","resolved","freeOverCents","subtotalCents","totalGrams","push","name","localPickup","shippableOnly","options","option","MAX_CHECKOUT_SHIPPING_OPTIONS","CHECKOUT_SHIPPING_COUNTRIES","resolveCheckoutShippingOptions","destinationCountries","byRateId","Map","existing","get","set","values","localeCompare","slice","CHECKOUT_SHIPPING_COUNTRY_CODES","CHECKOUT_SHIPPING_COUNTRY_NAMES","US","CA","GB","AU","DE","FR","normalizeCheckoutShippingCountry","undefined","includes","merchantPricesShipping","CART_UNPRICEABLE_SHIPPING_MESSAGE","optionsKey","join","planCheckoutShipping","destination","shipsElsewhere","refusal","offers","size","union","COVERAGE_PROBE_CART","summarizeShippingCoverage","served","unserved","add","shipsNowhere","asksDestination","offersLocalPickup","Boolean","hasRestOfWorldZone","coverageCountryNames","codes","describeShippingCoverage","coverage","asks","headline","detail","note","appendShippingAddressCollectionParams","params","forEach","index","appendCheckoutShippingParams","field"],"mappings":"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"}
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @license
|
|
3
|
+
* Copyright 2026 Aglyn LLC
|
|
4
|
+
*
|
|
5
|
+
* Licensed under the Apache License, Version 2.0 (the "License");
|
|
6
|
+
* you may not use this file except in compliance with the License.
|
|
7
|
+
* You may obtain a copy of the License at
|
|
8
|
+
*
|
|
9
|
+
* http://www.apache.org/licenses/LICENSE-2.0
|
|
10
|
+
*
|
|
11
|
+
* Unless required by applicable law or agreed to in writing, software
|
|
12
|
+
* distributed under the License is distributed on an "AS IS" BASIS,
|
|
13
|
+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
14
|
+
* See the License for the specific language governing permissions and
|
|
15
|
+
* limitations under the License.
|
|
16
|
+
*/
|
|
17
|
+
import { type HostOrder } from './commerce-orders';
|
|
18
|
+
import { type OrderFulfillmentLine } from './order-fulfillment';
|
|
19
|
+
/** The carrier select's value for a carrier typed by hand. */
|
|
20
|
+
export declare const FULFILLMENT_OTHER_CARRIER = "other";
|
|
21
|
+
/**
|
|
22
|
+
* The carrier select's value for a typed carrier: empty for none, the named
|
|
23
|
+
* carrier it spells (`ups` is UPS), or {@link FULFILLMENT_OTHER_CARRIER}.
|
|
24
|
+
*/
|
|
25
|
+
export declare function fulfillmentCarrierChoice(carrier: string | null | undefined): string;
|
|
26
|
+
/**
|
|
27
|
+
* Fulfill items' starting count for each line, by index: what is left of each
|
|
28
|
+
* shippable line, so the common case (ship everything) is one click. Digital
|
|
29
|
+
* and service lines start at zero, as there is nothing to ship; when nothing
|
|
30
|
+
* physical is left they start full instead, so the panel still closes a
|
|
31
|
+
* digital-only order in one click.
|
|
32
|
+
*/
|
|
33
|
+
export declare function fulfillItemsStartQuantities(order: Pick<HostOrder, 'lineItems' | 'fulfillments'>): number[];
|
|
34
|
+
/** The units picked in Fulfill items, by line index: the lines with a count above zero. */
|
|
35
|
+
export declare function fulfillItemsSelection(quantities: readonly number[]): OrderFulfillmentLine[];
|
|
36
|
+
/**
|
|
37
|
+
* Whether the picked units are exactly everything still to ship, in which
|
|
38
|
+
* case the request names no lines and the route ships what is left.
|
|
39
|
+
*/
|
|
40
|
+
export declare function fulfillItemsIsEverything(order: Pick<HostOrder, 'lineItems' | 'fulfillments'>, selection: readonly OrderFulfillmentLine[]): boolean;
|
|
41
|
+
/**
|
|
42
|
+
* The order dialog's refund question: the whole order, or the named lines
|
|
43
|
+
* (AGL-2454), with the figure the route will refund (the server's own
|
|
44
|
+
* `orderLineRefundCents` for lines) and, while an inquiry is open, that a full
|
|
45
|
+
* refund resolves it (AGL-1820).
|
|
46
|
+
*/
|
|
47
|
+
export declare function orderRefundPrompt(order: Pick<HostOrder, 'lineItems' | 'totals' | 'amountCents' | 'refundedCents' | 'dispute'>, lineItemIds?: readonly number[]): {
|
|
48
|
+
title: string;
|
|
49
|
+
text: string;
|
|
50
|
+
};
|
|
51
|
+
/** Why Refund is unavailable while a chargeback is open (AGL-1820), as the order dialog says it. */
|
|
52
|
+
export declare const REFUND_BLOCKED_BY_DISPUTE: string;
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @license
|
|
3
|
+
* Copyright 2026 Aglyn LLC
|
|
4
|
+
*
|
|
5
|
+
* Licensed under the Apache License, Version 2.0 (the "License");
|
|
6
|
+
* you may not use this file except in compliance with the License.
|
|
7
|
+
* You may obtain a copy of the License at
|
|
8
|
+
*
|
|
9
|
+
* http://www.apache.org/licenses/LICENSE-2.0
|
|
10
|
+
*
|
|
11
|
+
* Unless required by applicable law or agreed to in writing, software
|
|
12
|
+
* distributed under the License is distributed on an "AS IS" BASIS,
|
|
13
|
+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
14
|
+
* See the License for the specific language governing permissions and
|
|
15
|
+
* limitations under the License.
|
|
16
|
+
*/ import { orderLineRefundCents } from "./commerce-orders.js";
|
|
17
|
+
import { orderDisputeBlocksRefund, orderHasOpenDispute } from "./commerce-dispute.js";
|
|
18
|
+
import { FULFILLMENT_CARRIER_CHOICES, orderLineFulfillmentStates, remainingFulfillmentLines } from "./order-fulfillment.js";
|
|
19
|
+
import { carrierLabelFor } from "./tracking-url.js";
|
|
20
|
+
/*
|
|
21
|
+
* THE ORDER DIALOG'S OWN RULES (AGL-3841): the Fulfill items panel's starting
|
|
22
|
+
* counts, its selection and whether it is everything left to ship, the
|
|
23
|
+
* carrier select's value, and the refund question. Pure, so the apps replay
|
|
24
|
+
* them; kept out of the model barrel, which the storefront loads, as only the
|
|
25
|
+
* console's order dialog (loaded on demand) and the apps need them.
|
|
26
|
+
*/ /** The carrier select's value for a carrier typed by hand. */ export const FULFILLMENT_OTHER_CARRIER = 'other';
|
|
27
|
+
/**
|
|
28
|
+
* The carrier select's value for a typed carrier: empty for none, the named
|
|
29
|
+
* carrier it spells (`ups` is UPS), or {@link FULFILLMENT_OTHER_CARRIER}.
|
|
30
|
+
*/ export function fulfillmentCarrierChoice(carrier) {
|
|
31
|
+
if (!carrier) return '';
|
|
32
|
+
const label = carrierLabelFor(carrier);
|
|
33
|
+
return FULFILLMENT_CARRIER_CHOICES.includes(label) ? label : FULFILLMENT_OTHER_CARRIER;
|
|
34
|
+
}
|
|
35
|
+
/**
|
|
36
|
+
* Fulfill items' starting count for each line, by index: what is left of each
|
|
37
|
+
* shippable line, so the common case (ship everything) is one click. Digital
|
|
38
|
+
* and service lines start at zero, as there is nothing to ship; when nothing
|
|
39
|
+
* physical is left they start full instead, so the panel still closes a
|
|
40
|
+
* digital-only order in one click.
|
|
41
|
+
*/ export function fulfillItemsStartQuantities(order) {
|
|
42
|
+
const states = orderLineFulfillmentStates(order);
|
|
43
|
+
const physicalLeft = states.some((state)=>state.requiresShipping && state.remainingQuantity > 0);
|
|
44
|
+
return states.map((state)=>physicalLeft ? state.requiresShipping ? state.remainingQuantity : 0 : state.remainingQuantity);
|
|
45
|
+
}
|
|
46
|
+
/** The units picked in Fulfill items, by line index: the lines with a count above zero. */ export function fulfillItemsSelection(quantities) {
|
|
47
|
+
return quantities.map((quantity, lineItemId)=>({
|
|
48
|
+
lineItemId,
|
|
49
|
+
quantity: Math.max(0, Math.floor(Number(quantity) || 0))
|
|
50
|
+
})).filter((entry)=>entry.quantity > 0);
|
|
51
|
+
}
|
|
52
|
+
/**
|
|
53
|
+
* Whether the picked units are exactly everything still to ship, in which
|
|
54
|
+
* case the request names no lines and the route ships what is left.
|
|
55
|
+
*/ export function fulfillItemsIsEverything(order, selection) {
|
|
56
|
+
const remaining = remainingFulfillmentLines(order);
|
|
57
|
+
return remaining.length > 0 && remaining.length === selection.length && remaining.every((entry, index)=>{
|
|
58
|
+
var _selection_index, _selection_index1;
|
|
59
|
+
return ((_selection_index = selection[index]) == null ? void 0 : _selection_index.lineItemId) === entry.lineItemId && ((_selection_index1 = selection[index]) == null ? void 0 : _selection_index1.quantity) === entry.quantity;
|
|
60
|
+
});
|
|
61
|
+
}
|
|
62
|
+
/** Dollars as the order dialog prints them. */ const dialogUsd = (cents)=>`$${((cents != null ? cents : 0) / 100).toFixed(2)}`;
|
|
63
|
+
/**
|
|
64
|
+
* The order dialog's refund question: the whole order, or the named lines
|
|
65
|
+
* (AGL-2454), with the figure the route will refund (the server's own
|
|
66
|
+
* `orderLineRefundCents` for lines) and, while an inquiry is open, that a full
|
|
67
|
+
* refund resolves it (AGL-1820).
|
|
68
|
+
*/ export function orderRefundPrompt(order, lineItemIds = []) {
|
|
69
|
+
var _order_lineItems, _ref, _ref1, _order_refundedCents;
|
|
70
|
+
var _order_totals;
|
|
71
|
+
const lines = (_order_lineItems = order.lineItems) != null ? _order_lineItems : [];
|
|
72
|
+
const scoped = lineItemIds.length > 0;
|
|
73
|
+
const text = (scoped ? `Refunds ${dialogUsd(orderLineRefundCents(order, lineItemIds))} for ${lineItemIds.map((index)=>{
|
|
74
|
+
var _ref;
|
|
75
|
+
var _lines_index;
|
|
76
|
+
return (_ref = (_lines_index = lines[index]) == null ? void 0 : _lines_index.name) != null ? _ref : `line ${index}`;
|
|
77
|
+
}).join(', ')} through Stripe, and withdraws that line's ` + 'downloads, license keys and gated content. A license key already ' + 'sent to the buyer is retired, not returned to your pool — they ' + 'still hold the string, so reissuing it would give two people one ' + 'working key.' : `Refunds ${dialogUsd(((_ref = (_ref1 = (_order_totals = order.totals) == null ? void 0 : _order_totals.totalCents) != null ? _ref1 : order.amountCents) != null ? _ref : 0) - ((_order_refundedCents = order.refundedCents) != null ? _order_refundedCents : 0))} to the buyer through Stripe.`) + (orderHasOpenDispute(order) && !orderDisputeBlocksRefund(order) ? ' An inquiry is open on this charge — a full refund resolves it' + ' before it can become a chargeback.' : '');
|
|
78
|
+
return {
|
|
79
|
+
title: scoped ? 'Refund this line?' : 'Refund this order?',
|
|
80
|
+
text
|
|
81
|
+
};
|
|
82
|
+
}
|
|
83
|
+
/** Why Refund is unavailable while a chargeback is open (AGL-1820), as the order dialog says it. */ export const REFUND_BLOCKED_BY_DISPUTE = 'A chargeback is open on this order, and refunding would not' + ' withdraw it — the bank has already taken the disputed' + ' amount, so a refund on top would pay the shopper twice.' + ' Respond to the dispute or accept it in the Stripe' + ' dashboard; refund any remainder once it settles.';
|
|
84
|
+
|
|
85
|
+
//# sourceMappingURL=order-dialog.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"sources":["../../../../../../../libs/plugins/commerce/src/lib/model/order-dialog.ts"],"sourcesContent":["/**\n * @license\n * Copyright 2026 Aglyn LLC\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n * http://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n\nimport { orderLineRefundCents, type HostOrder } from './commerce-orders'\nimport { orderDisputeBlocksRefund, orderHasOpenDispute } from './commerce-dispute'\nimport {\n FULFILLMENT_CARRIER_CHOICES,\n orderLineFulfillmentStates,\n remainingFulfillmentLines,\n type OrderFulfillmentLine,\n} from './order-fulfillment'\nimport { carrierLabelFor } from './tracking-url'\n\n/*\n * THE ORDER DIALOG'S OWN RULES (AGL-3841): the Fulfill items panel's starting\n * counts, its selection and whether it is everything left to ship, the\n * carrier select's value, and the refund question. Pure, so the apps replay\n * them; kept out of the model barrel, which the storefront loads, as only the\n * console's order dialog (loaded on demand) and the apps need them.\n */\n\n/** The carrier select's value for a carrier typed by hand. */\nexport const FULFILLMENT_OTHER_CARRIER = 'other'\n\n/**\n * The carrier select's value for a typed carrier: empty for none, the named\n * carrier it spells (`ups` is UPS), or {@link FULFILLMENT_OTHER_CARRIER}.\n */\nexport function fulfillmentCarrierChoice(carrier: string | null | undefined): string {\n if (!carrier) return ''\n const label = carrierLabelFor(carrier)\n return FULFILLMENT_CARRIER_CHOICES.includes(label) ? label : FULFILLMENT_OTHER_CARRIER\n}\n\n/**\n * Fulfill items' starting count for each line, by index: what is left of each\n * shippable line, so the common case (ship everything) is one click. Digital\n * and service lines start at zero, as there is nothing to ship; when nothing\n * physical is left they start full instead, so the panel still closes a\n * digital-only order in one click.\n */\nexport function fulfillItemsStartQuantities(order: Pick<HostOrder, 'lineItems' | 'fulfillments'>): number[] {\n const states = orderLineFulfillmentStates(order)\n const physicalLeft = states.some((state) => state.requiresShipping && state.remainingQuantity > 0)\n return states.map((state) =>\n physicalLeft ? (state.requiresShipping ? state.remainingQuantity : 0) : state.remainingQuantity,\n )\n}\n\n/** The units picked in Fulfill items, by line index: the lines with a count above zero. */\nexport function fulfillItemsSelection(quantities: readonly number[]): OrderFulfillmentLine[] {\n return quantities\n .map((quantity, lineItemId) => ({ lineItemId, quantity: Math.max(0, Math.floor(Number(quantity) || 0)) }))\n .filter((entry) => entry.quantity > 0)\n}\n\n/**\n * Whether the picked units are exactly everything still to ship, in which\n * case the request names no lines and the route ships what is left.\n */\nexport function fulfillItemsIsEverything(\n order: Pick<HostOrder, 'lineItems' | 'fulfillments'>,\n selection: readonly OrderFulfillmentLine[],\n): boolean {\n const remaining = remainingFulfillmentLines(order)\n return (\n remaining.length > 0 &&\n remaining.length === selection.length &&\n remaining.every(\n (entry, index) =>\n selection[index]?.lineItemId === entry.lineItemId && selection[index]?.quantity === entry.quantity,\n )\n )\n}\n\n/** Dollars as the order dialog prints them. */\nconst dialogUsd = (cents: number | undefined) => `$${((cents ?? 0) / 100).toFixed(2)}`\n\n/**\n * The order dialog's refund question: the whole order, or the named lines\n * (AGL-2454), with the figure the route will refund (the server's own\n * `orderLineRefundCents` for lines) and, while an inquiry is open, that a full\n * refund resolves it (AGL-1820).\n */\nexport function orderRefundPrompt(\n order: Pick<HostOrder, 'lineItems' | 'totals' | 'amountCents' | 'refundedCents' | 'dispute'>,\n lineItemIds: readonly number[] = [],\n): { title: string; text: string } {\n const lines = order.lineItems ?? []\n const scoped = lineItemIds.length > 0\n const text =\n (scoped\n ? `Refunds ${dialogUsd(orderLineRefundCents(order, lineItemIds))} for ${lineItemIds\n .map((index) => lines[index]?.name ?? `line ${index}`)\n .join(', ')} through Stripe, and withdraws that line's ` +\n 'downloads, license keys and gated content. A license key already ' +\n 'sent to the buyer is retired, not returned to your pool — they ' +\n 'still hold the string, so reissuing it would give two people one ' +\n 'working key.'\n : `Refunds ${dialogUsd(\n (order.totals?.totalCents ?? order.amountCents ?? 0) - (order.refundedCents ?? 0),\n )} to the buyer through Stripe.`) +\n (orderHasOpenDispute(order) && !orderDisputeBlocksRefund(order)\n ? ' An inquiry is open on this charge — a full refund resolves it' +\n ' before it can become a chargeback.'\n : '')\n return { title: scoped ? 'Refund this line?' : 'Refund this order?', text }\n}\n\n/** Why Refund is unavailable while a chargeback is open (AGL-1820), as the order dialog says it. */\nexport const REFUND_BLOCKED_BY_DISPUTE =\n 'A chargeback is open on this order, and refunding would not' +\n ' withdraw it — the bank has already taken the disputed' +\n ' amount, so a refund on top would pay the shopper twice.' +\n ' Respond to the dispute or accept it in the Stripe' +\n ' dashboard; refund any remainder once it settles.'\n"],"names":["orderLineRefundCents","orderDisputeBlocksRefund","orderHasOpenDispute","FULFILLMENT_CARRIER_CHOICES","orderLineFulfillmentStates","remainingFulfillmentLines","carrierLabelFor","FULFILLMENT_OTHER_CARRIER","fulfillmentCarrierChoice","carrier","label","includes","fulfillItemsStartQuantities","order","states","physicalLeft","some","state","requiresShipping","remainingQuantity","map","fulfillItemsSelection","quantities","quantity","lineItemId","Math","max","floor","Number","filter","entry","fulfillItemsIsEverything","selection","remaining","length","every","index","dialogUsd","cents","toFixed","orderRefundPrompt","lineItemIds","lines","lineItems","scoped","text","name","join","totals","totalCents","amountCents","refundedCents","title","REFUND_BLOCKED_BY_DISPUTE"],"mappings":"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"}
|
|
@@ -165,6 +165,77 @@ export declare function productEditWrite(fields: ReturnType<typeof productSaveFi
|
|
|
165
165
|
posQuickKey?: boolean;
|
|
166
166
|
createdAtMs?: number;
|
|
167
167
|
};
|
|
168
|
+
/** A duplicate's name: the source's, marked as a copy. */
|
|
169
|
+
export declare function productDuplicateName(name: string): string;
|
|
170
|
+
/** The slug a duplicate starts from, before the store is asked which are taken. */
|
|
171
|
+
export declare function productDuplicateSlugBase(slug: string | undefined, name: string): string;
|
|
172
|
+
/**
|
|
173
|
+
* What Duplicate writes (the products list's Duplicate, AGL-3321): the source
|
|
174
|
+
* as a draft under the copy's name, with the COPY's search keys (spreading the
|
|
175
|
+
* source's would sort and find it by the source's name) and stock fields, and
|
|
176
|
+
* not the source's SKU and barcode keys, which are derived again from its
|
|
177
|
+
* variants. The caller adds the claimed `slug`'s smart-collection membership.
|
|
178
|
+
*/
|
|
179
|
+
export declare function productDuplicateFields(product: HostProduct & {
|
|
180
|
+
$id?: string;
|
|
181
|
+
skus?: string[];
|
|
182
|
+
barcodes?: string[];
|
|
183
|
+
}, slug: string, nowMs: number): {
|
|
184
|
+
slug: string;
|
|
185
|
+
status: "draft";
|
|
186
|
+
createdAtMs: number;
|
|
187
|
+
updatedAtMs: number;
|
|
188
|
+
inventory: number | null;
|
|
189
|
+
soldOut: boolean;
|
|
190
|
+
name: string;
|
|
191
|
+
nameLower: string;
|
|
192
|
+
nameTokens: string[];
|
|
193
|
+
nameReversed: string;
|
|
194
|
+
skus?: string[];
|
|
195
|
+
barcodes?: string[];
|
|
196
|
+
priceFromCents?: number;
|
|
197
|
+
description?: string;
|
|
198
|
+
type: import("./commerce").ProductType;
|
|
199
|
+
mediaUrls?: string[];
|
|
200
|
+
categoryIds?: string[];
|
|
201
|
+
collectionIds?: string[];
|
|
202
|
+
tags?: string[];
|
|
203
|
+
options?: import("./commerce").ProductOption[];
|
|
204
|
+
variants: import("./commerce").ProductVariant[];
|
|
205
|
+
seo?: {
|
|
206
|
+
title?: string;
|
|
207
|
+
description?: string;
|
|
208
|
+
imageUrl?: string;
|
|
209
|
+
};
|
|
210
|
+
channel?: import("./product-channel").ProductChannelFacts;
|
|
211
|
+
supplierId?: string;
|
|
212
|
+
oversellPolicy?: "deny" | "backorder";
|
|
213
|
+
taxExempt?: boolean;
|
|
214
|
+
digitalFiles?: Array<{
|
|
215
|
+
url: string;
|
|
216
|
+
fileName: string;
|
|
217
|
+
version?: string;
|
|
218
|
+
}>;
|
|
219
|
+
downloadLimit?: number;
|
|
220
|
+
subscription?: {
|
|
221
|
+
interval: "month" | "year";
|
|
222
|
+
trialDays?: number;
|
|
223
|
+
};
|
|
224
|
+
subscriptionOptional?: boolean;
|
|
225
|
+
gatedVideos?: Array<{
|
|
226
|
+
url: string;
|
|
227
|
+
title?: string;
|
|
228
|
+
}>;
|
|
229
|
+
relatedProductIds?: string[];
|
|
230
|
+
shipping?: import("./commerce").ProductShippingFacts;
|
|
231
|
+
giftCard?: boolean;
|
|
232
|
+
lowStockThreshold?: number;
|
|
233
|
+
modifierGroups?: import("./product-modifiers").ProductModifierGroup[];
|
|
234
|
+
posQuickKey?: boolean;
|
|
235
|
+
deletedAt?: number | null;
|
|
236
|
+
priceUsd?: number;
|
|
237
|
+
imageUrl?: string;
|
|
238
|
+
};
|
|
168
239
|
/** A smart collection as `hosts/{hostId}/collections` stores it, as the membership rules read it. */
|
|
169
240
|
export declare function smartCollectionRulesOf(id: string, data: Record<string, unknown>): SmartCollectionRules;
|
|
170
241
|
/** One stock adjustment, as the Adjust stock dialog asks for it. */
|
|
@@ -79,6 +79,34 @@ import { _ as _object_without_properties_loose } from "@swc/helpers/_/_object_wi
|
|
|
79
79
|
deletedAt: null
|
|
80
80
|
});
|
|
81
81
|
}
|
|
82
|
+
/** A duplicate's name: the source's, marked as a copy. */ export function productDuplicateName(name) {
|
|
83
|
+
return `${name} (copy)`;
|
|
84
|
+
}
|
|
85
|
+
/** The slug a duplicate starts from, before the store is asked which are taken. */ export function productDuplicateSlugBase(slug, name) {
|
|
86
|
+
return commerceSlug(`${slug || commerceSlug(name)}-copy`);
|
|
87
|
+
}
|
|
88
|
+
/**
|
|
89
|
+
* What Duplicate writes (the products list's Duplicate, AGL-3321): the source
|
|
90
|
+
* as a draft under the copy's name, with the COPY's search keys (spreading the
|
|
91
|
+
* source's would sort and find it by the source's name) and stock fields, and
|
|
92
|
+
* not the source's SKU and barcode keys, which are derived again from its
|
|
93
|
+
* variants. The caller adds the claimed `slug`'s smart-collection membership.
|
|
94
|
+
*/ export function productDuplicateFields(product, slug, nowMs) {
|
|
95
|
+
const { $id: _sourceId, skus: _skus, barcodes: _barcodes } = product, copy = _object_without_properties_loose(product, [
|
|
96
|
+
"$id",
|
|
97
|
+
"skus",
|
|
98
|
+
"barcodes"
|
|
99
|
+
]);
|
|
100
|
+
return _extends({}, copy, productSearchFields({
|
|
101
|
+
name: productDuplicateName(product.name),
|
|
102
|
+
variants: product.variants
|
|
103
|
+
}), productStockFields(product), {
|
|
104
|
+
slug,
|
|
105
|
+
status: 'draft',
|
|
106
|
+
createdAtMs: nowMs,
|
|
107
|
+
updatedAtMs: nowMs
|
|
108
|
+
});
|
|
109
|
+
}
|
|
82
110
|
/** A smart collection as `hosts/{hostId}/collections` stores it, as the membership rules read it. */ export function smartCollectionRulesOf(id, data) {
|
|
83
111
|
var _data_rules;
|
|
84
112
|
return {
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"sources":["../../../../../../../libs/plugins/commerce/src/lib/model/product-write.ts"],"sourcesContent":["/**\n * @license\n * Copyright 2026 Aglyn LLC\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n * http://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n\nimport {\n adjustVariantInventory,\n commerceSlug,\n type HostProduct,\n type InventoryAdjustment,\n type InventoryAdjustmentReason,\n productSearchFields,\n productStockFields,\n type SmartCollectionRules,\n variantHasPrice,\n} from './commerce'\n\n/*\n * WHAT A PRODUCT SAVE AND A STOCK ADJUSTMENT WRITE, worked out once.\n *\n * The console's product editor and its Adjust stock dialog compute these\n * fields in the browser before their Firestore writes; the native apps save\n * through `commerce/products/save` and `commerce/products/stock`, which run\n * this same code on the server. One computation, so a product saved from a\n * phone carries exactly the search keys, stock verdict, price, image and\n * smart-collection membership the console would have written.\n */\n\n/** A product as the editor holds it: the stored fields plus keys the listener or a seed may carry. */\nexport type EditedProduct = HostProduct & {\n $id?: string\n skus?: string[]\n barcodes?: string[]\n stockHolds?: Record<string, unknown>\n}\n\n/**\n * The fields a product save writes, before its collection membership and its\n * timestamps: the product as edited, with\n *\n * - `$id` dropped (AGL-1374): the listener's synthetic key, never a field;\n * - the seed's `skus` / `barcodes` dropped (AGL-3321): `productSearchFields`\n * omits each when no variant has one, so a seeded copy would outlive the\n * last SKU;\n * - the trimmed name with the search keys derived from it, together, so the\n * two cannot drift (AGL-2501);\n * - a slug, the primary variant's price, the stock fields, the first image\n * and `updatedAtMs`.\n */\nexport function productSaveFields(current: EditedProduct, nowMs: number) {\n const {\n $id: _syntheticId,\n skus: _seededSkus,\n barcodes: _seededBarcodes,\n ...currentFields\n } = current\n const primaryVariant = current.variants[0]\n return {\n ...currentFields,\n ...productSearchFields({\n name: current.name.trim().slice(0, 120),\n variants: current.variants,\n }),\n slug: current.slug || commerceSlug(current.name),\n // The legacy flat price, only where there is one (AGL-3676): an owner\n // may save a product before pricing it, and an unpriced product is not\n // a free one. `null` rather than absent, so an edit's full replace and\n // the native save write the same field.\n priceUsd: variantHasPrice(primaryVariant) ? primaryVariant.priceUsd : null,\n ...productStockFields({\n variants: current.variants,\n oversellPolicy: current.oversellPolicy,\n }),\n imageUrl: current.mediaUrls?.[0] ?? current.imageUrl ?? null,\n updatedAtMs: nowMs,\n }\n}\n\n/** What the smart-collection rules are matched against: the saved name with the product's own facts. */\nexport function productMembershipInput(\n fields: { name: string },\n current: Pick<HostProduct, 'type' | 'tags' | 'categoryIds' | 'variants'>,\n) {\n return {\n name: fields.name,\n type: current.type,\n tags: current.tags,\n categoryIds: current.categoryIds,\n variants: current.variants,\n }\n}\n\n/**\n * An EDIT's replacing write, without its membership and server timestamp:\n * live stock holds are not the editor's to write (AGL-2356), so the seed's\n * are dropped and the stored ones carried; the product stays live\n * (`deletedAt: null`, AGL-3321).\n */\nexport function productEditWrite(\n fields: ReturnType<typeof productSaveFields>,\n liveHolds: Record<string, unknown> | null | undefined,\n) {\n const { stockHolds: _seededHolds, ...withoutHolds } = fields as typeof fields & {\n stockHolds?: Record<string, unknown>\n }\n return {\n ...withoutHolds,\n ...(liveHolds ? { stockHolds: liveHolds } : {}),\n deletedAt: null,\n }\n}\n\n/** A smart collection as `hosts/{hostId}/collections` stores it, as the membership rules read it. */\nexport function smartCollectionRulesOf(id: string, data: Record<string, unknown>): SmartCollectionRules {\n return {\n id,\n rules: (data['rules'] as SmartCollectionRules['rules']) ?? [],\n matchAll: data['matchAll'] as boolean | undefined,\n }\n}\n\n/** One stock adjustment, as the Adjust stock dialog asks for it. */\nexport interface StockAdjustment {\n variantId: string\n delta: number\n reason: InventoryAdjustmentReason\n locationId?: string\n}\n\n/**\n * The product update and the ledger row one stock adjustment writes: the\n * variants with the count moved, the stock fields that move with it\n * (AGL-3321), and the same `inventoryAdjustments` row the sale webhook writes\n * (AGL-281). A zero or non-numeric delta writes nothing.\n */\nexport function stockAdjustmentWrite(\n product: HostProduct,\n productId: string,\n adjustment: StockAdjustment,\n nowMs: number,\n): { update: Record<string, unknown>; ledger: InventoryAdjustment } | null {\n const delta = Math.round(Number(adjustment.delta))\n if (!delta) return null\n const variants = adjustVariantInventory(product, adjustment.variantId, delta, adjustment.locationId || undefined)\n return {\n update: {\n variants,\n ...productStockFields({ ...product, variants }),\n updatedAtMs: nowMs,\n },\n ledger: {\n productId,\n variantId: adjustment.variantId,\n delta,\n reason: adjustment.reason,\n ...(adjustment.locationId ? { locationId: adjustment.locationId } : {}),\n atMs: nowMs,\n },\n }\n}\n"],"names":["adjustVariantInventory","commerceSlug","productSearchFields","productStockFields","variantHasPrice","productSaveFields","current","nowMs","$id","_syntheticId","skus","_seededSkus","barcodes","_seededBarcodes","currentFields","primaryVariant","variants","name","trim","slice","slug","priceUsd","oversellPolicy","imageUrl","mediaUrls","updatedAtMs","productMembershipInput","fields","type","tags","categoryIds","productEditWrite","liveHolds","stockHolds","_seededHolds","withoutHolds","deletedAt","smartCollectionRulesOf","id","data","rules","matchAll","stockAdjustmentWrite","product","productId","adjustment","delta","Math","round","Number","variantId","locationId","undefined","update","ledger","reason","atMs"],"mappings":";;AAAA;;;;;;;;;;;;;;;CAeC,GAED,SACEA,sBAAsB,EACtBC,YAAY,EAIZC,mBAAmB,EACnBC,kBAAkB,EAElBC,eAAe,QACV,gBAAY;AAqBnB;;;;;;;;;;;;CAYC,GACD,OAAO,SAASC,kBAAkBC,OAAsB,EAAEC,KAAa;QAwBzDD;QAAAA;IAvBZ,MAAM,EACJE,KAAKC,YAAY,EACjBC,MAAMC,WAAW,EACjBC,UAAUC,eAAe,EAE1B,GAAGP,SADCQ,iDACDR;;;;;IACJ,MAAMS,iBAAiBT,QAAQU,QAAQ,CAAC,EAAE;IAC1C,OAAO,aACFF,eACAZ,oBAAoB;QACrBe,MAAMX,QAAQW,IAAI,CAACC,IAAI,GAAGC,KAAK,CAAC,GAAG;QACnCH,UAAUV,QAAQU,QAAQ;IAC5B;QACAI,MAAMd,QAAQc,IAAI,IAAInB,aAAaK,QAAQW,IAAI;QAC/C,sEAAsE;QACtE,uEAAuE;QACvE,uEAAuE;QACvE,wCAAwC;QACxCI,UAAUjB,gBAAgBW,kBAAkBA,eAAeM,QAAQ,GAAG;OACnElB,mBAAmB;QACpBa,UAAUV,QAAQU,QAAQ;QAC1BM,gBAAgBhB,QAAQgB,cAAc;IACxC;QACAC,QAAQ,GAAEjB,iBAAAA,qBAAAA,QAAQkB,SAAS,qBAAjBlB,kBAAmB,CAAC,EAAE,oBAAIA,QAAQiB,QAAQ,YAA1CjB,OAA8C;QACxDmB,aAAalB;;AAEjB;AAEA,sGAAsG,GACtG,OAAO,SAASmB,uBACdC,MAAwB,EACxBrB,OAAwE;IAExE,OAAO;QACLW,MAAMU,OAAOV,IAAI;QACjBW,MAAMtB,QAAQsB,IAAI;QAClBC,MAAMvB,QAAQuB,IAAI;QAClBC,aAAaxB,QAAQwB,WAAW;QAChCd,UAAUV,QAAQU,QAAQ;IAC5B;AACF;AAEA;;;;;CAKC,GACD,OAAO,SAASe,iBACdJ,MAA4C,EAC5CK,SAAqD;IAErD,MAAM,EAAEC,YAAYC,YAAY,EAAmB,GAAGP,QAAjBQ,gDAAiBR;;;IAGtD,OAAO,aACFQ,cACCH,YAAY;QAAEC,YAAYD;IAAU,IAAI,CAAC;QAC7CI,WAAW;;AAEf;AAEA,mGAAmG,GACnG,OAAO,SAASC,uBAAuBC,EAAU,EAAEC,IAA6B;QAGpEA;IAFV,OAAO;QACLD;QACAE,KAAK,GAAGD,cAAAA,IAAI,CAAC,QAAQ,YAAbA,cAAmD,EAAE;QAC7DE,UAAUF,IAAI,CAAC,WAAW;IAC5B;AACF;AAUA;;;;;CAKC,GACD,OAAO,SAASG,qBACdC,OAAoB,EACpBC,SAAiB,EACjBC,UAA2B,EAC3BtC,KAAa;IAEb,MAAMuC,QAAQC,KAAKC,KAAK,CAACC,OAAOJ,WAAWC,KAAK;IAChD,IAAI,CAACA,OAAO,OAAO;IACnB,MAAM9B,WAAWhB,uBAAuB2C,SAASE,WAAWK,SAAS,EAAEJ,OAAOD,WAAWM,UAAU,IAAIC;IACvG,OAAO;QACLC,QAAQ;YACNrC;WACGb,mBAAmB,aAAKwC;YAAS3B;;YACpCS,aAAalB;;QAEf+C,QAAQ;YACNV;YACAM,WAAWL,WAAWK,SAAS;YAC/BJ;YACAS,QAAQV,WAAWU,MAAM;WACrBV,WAAWM,UAAU,GAAG;YAAEA,YAAYN,WAAWM,UAAU;QAAC,IAAI,CAAC;YACrEK,MAAMjD;;IAEV;AACF"}
|
|
1
|
+
{"version":3,"sources":["../../../../../../../libs/plugins/commerce/src/lib/model/product-write.ts"],"sourcesContent":["/**\n * @license\n * Copyright 2026 Aglyn LLC\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n * http://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n\nimport {\n adjustVariantInventory,\n commerceSlug,\n type HostProduct,\n type InventoryAdjustment,\n type InventoryAdjustmentReason,\n productSearchFields,\n productStockFields,\n type SmartCollectionRules,\n variantHasPrice,\n} from './commerce'\n\n/*\n * WHAT A PRODUCT SAVE AND A STOCK ADJUSTMENT WRITE, worked out once.\n *\n * The console's product editor and its Adjust stock dialog compute these\n * fields in the browser before their Firestore writes; the native apps save\n * through `commerce/products/save` and `commerce/products/stock`, which run\n * this same code on the server. One computation, so a product saved from a\n * phone carries exactly the search keys, stock verdict, price, image and\n * smart-collection membership the console would have written.\n */\n\n/** A product as the editor holds it: the stored fields plus keys the listener or a seed may carry. */\nexport type EditedProduct = HostProduct & {\n $id?: string\n skus?: string[]\n barcodes?: string[]\n stockHolds?: Record<string, unknown>\n}\n\n/**\n * The fields a product save writes, before its collection membership and its\n * timestamps: the product as edited, with\n *\n * - `$id` dropped (AGL-1374): the listener's synthetic key, never a field;\n * - the seed's `skus` / `barcodes` dropped (AGL-3321): `productSearchFields`\n * omits each when no variant has one, so a seeded copy would outlive the\n * last SKU;\n * - the trimmed name with the search keys derived from it, together, so the\n * two cannot drift (AGL-2501);\n * - a slug, the primary variant's price, the stock fields, the first image\n * and `updatedAtMs`.\n */\nexport function productSaveFields(current: EditedProduct, nowMs: number) {\n const {\n $id: _syntheticId,\n skus: _seededSkus,\n barcodes: _seededBarcodes,\n ...currentFields\n } = current\n const primaryVariant = current.variants[0]\n return {\n ...currentFields,\n ...productSearchFields({\n name: current.name.trim().slice(0, 120),\n variants: current.variants,\n }),\n slug: current.slug || commerceSlug(current.name),\n // The legacy flat price, only where there is one (AGL-3676): an owner\n // may save a product before pricing it, and an unpriced product is not\n // a free one. `null` rather than absent, so an edit's full replace and\n // the native save write the same field.\n priceUsd: variantHasPrice(primaryVariant) ? primaryVariant.priceUsd : null,\n ...productStockFields({\n variants: current.variants,\n oversellPolicy: current.oversellPolicy,\n }),\n imageUrl: current.mediaUrls?.[0] ?? current.imageUrl ?? null,\n updatedAtMs: nowMs,\n }\n}\n\n/** What the smart-collection rules are matched against: the saved name with the product's own facts. */\nexport function productMembershipInput(\n fields: { name: string },\n current: Pick<HostProduct, 'type' | 'tags' | 'categoryIds' | 'variants'>,\n) {\n return {\n name: fields.name,\n type: current.type,\n tags: current.tags,\n categoryIds: current.categoryIds,\n variants: current.variants,\n }\n}\n\n/**\n * An EDIT's replacing write, without its membership and server timestamp:\n * live stock holds are not the editor's to write (AGL-2356), so the seed's\n * are dropped and the stored ones carried; the product stays live\n * (`deletedAt: null`, AGL-3321).\n */\nexport function productEditWrite(\n fields: ReturnType<typeof productSaveFields>,\n liveHolds: Record<string, unknown> | null | undefined,\n) {\n const { stockHolds: _seededHolds, ...withoutHolds } = fields as typeof fields & {\n stockHolds?: Record<string, unknown>\n }\n return {\n ...withoutHolds,\n ...(liveHolds ? { stockHolds: liveHolds } : {}),\n deletedAt: null,\n }\n}\n\n/** A duplicate's name: the source's, marked as a copy. */\nexport function productDuplicateName(name: string): string {\n return `${name} (copy)`\n}\n\n/** The slug a duplicate starts from, before the store is asked which are taken. */\nexport function productDuplicateSlugBase(slug: string | undefined, name: string): string {\n return commerceSlug(`${slug || commerceSlug(name)}-copy`)\n}\n\n/**\n * What Duplicate writes (the products list's Duplicate, AGL-3321): the source\n * as a draft under the copy's name, with the COPY's search keys (spreading the\n * source's would sort and find it by the source's name) and stock fields, and\n * not the source's SKU and barcode keys, which are derived again from its\n * variants. The caller adds the claimed `slug`'s smart-collection membership.\n */\nexport function productDuplicateFields(\n product: HostProduct & { $id?: string; skus?: string[]; barcodes?: string[] },\n slug: string,\n nowMs: number,\n) {\n const { $id: _sourceId, skus: _skus, barcodes: _barcodes, ...copy } = product\n return {\n ...copy,\n ...productSearchFields({ name: productDuplicateName(product.name), variants: product.variants }),\n ...productStockFields(product),\n slug,\n status: 'draft' as const,\n createdAtMs: nowMs,\n updatedAtMs: nowMs,\n }\n}\n\n/** A smart collection as `hosts/{hostId}/collections` stores it, as the membership rules read it. */\nexport function smartCollectionRulesOf(id: string, data: Record<string, unknown>): SmartCollectionRules {\n return {\n id,\n rules: (data['rules'] as SmartCollectionRules['rules']) ?? [],\n matchAll: data['matchAll'] as boolean | undefined,\n }\n}\n\n/** One stock adjustment, as the Adjust stock dialog asks for it. */\nexport interface StockAdjustment {\n variantId: string\n delta: number\n reason: InventoryAdjustmentReason\n locationId?: string\n}\n\n/**\n * The product update and the ledger row one stock adjustment writes: the\n * variants with the count moved, the stock fields that move with it\n * (AGL-3321), and the same `inventoryAdjustments` row the sale webhook writes\n * (AGL-281). A zero or non-numeric delta writes nothing.\n */\nexport function stockAdjustmentWrite(\n product: HostProduct,\n productId: string,\n adjustment: StockAdjustment,\n nowMs: number,\n): { update: Record<string, unknown>; ledger: InventoryAdjustment } | null {\n const delta = Math.round(Number(adjustment.delta))\n if (!delta) return null\n const variants = adjustVariantInventory(product, adjustment.variantId, delta, adjustment.locationId || undefined)\n return {\n update: {\n variants,\n ...productStockFields({ ...product, variants }),\n updatedAtMs: nowMs,\n },\n ledger: {\n productId,\n variantId: adjustment.variantId,\n delta,\n reason: adjustment.reason,\n ...(adjustment.locationId ? { locationId: adjustment.locationId } : {}),\n atMs: nowMs,\n },\n }\n}\n"],"names":["adjustVariantInventory","commerceSlug","productSearchFields","productStockFields","variantHasPrice","productSaveFields","current","nowMs","$id","_syntheticId","skus","_seededSkus","barcodes","_seededBarcodes","currentFields","primaryVariant","variants","name","trim","slice","slug","priceUsd","oversellPolicy","imageUrl","mediaUrls","updatedAtMs","productMembershipInput","fields","type","tags","categoryIds","productEditWrite","liveHolds","stockHolds","_seededHolds","withoutHolds","deletedAt","productDuplicateName","productDuplicateSlugBase","productDuplicateFields","product","_sourceId","_skus","_barcodes","copy","status","createdAtMs","smartCollectionRulesOf","id","data","rules","matchAll","stockAdjustmentWrite","productId","adjustment","delta","Math","round","Number","variantId","locationId","undefined","update","ledger","reason","atMs"],"mappings":"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"}
|
|
@@ -14,6 +14,7 @@
|
|
|
14
14
|
* See the License for the specific language governing permissions and
|
|
15
15
|
* limitations under the License.
|
|
16
16
|
*/
|
|
17
|
+
import type { HostProduct } from '../model/commerce';
|
|
17
18
|
/**
|
|
18
19
|
* Product and shelf labels (AGL-3619): a product's name, its options, its
|
|
19
20
|
* price and a barcode the register scans back, on the small thermal labels
|
|
@@ -46,6 +47,27 @@ export declare const LABEL_SIZES: readonly LabelSize[];
|
|
|
46
47
|
/** One print run is capped; a typo of 5000 copies should not drain a roll. */
|
|
47
48
|
export declare const MAX_LABEL_COPIES = 500;
|
|
48
49
|
export declare function labelSize(id: string | undefined): LabelSize;
|
|
50
|
+
/** A label the products list prints for one variant, with the variant it is for. */
|
|
51
|
+
export type ProductLabelRow = ProductLabel & {
|
|
52
|
+
variantId: string;
|
|
53
|
+
};
|
|
54
|
+
/**
|
|
55
|
+
* The labels a product prints: one row per variant, its options as the
|
|
56
|
+
* detail, its price and the code the register looks up (the barcode, else
|
|
57
|
+
* the SKU). A row with no code prints nothing until one is added. One copy
|
|
58
|
+
* each to start; the person changes the counts before printing.
|
|
59
|
+
*/
|
|
60
|
+
export declare function productLabelRows(product: Pick<HostProduct, 'name' | 'variants'>): ProductLabelRow[];
|
|
61
|
+
/** The rows that print: a code and at least one copy, each count kept within {@link MAX_LABEL_COPIES}. */
|
|
62
|
+
export declare function printableLabelRows<T extends ProductLabel>(rows: readonly T[]): T[];
|
|
63
|
+
/** How many labels the rows print in all. */
|
|
64
|
+
export declare function labelCopiesTotal(rows: readonly ProductLabel[]): number;
|
|
65
|
+
/** A typed copy count as the dialog keeps it: whole, at least 0, at most {@link MAX_LABEL_COPIES}. */
|
|
66
|
+
export declare function labelCopiesFromInput(value: unknown): number;
|
|
67
|
+
/** The label's price line, empty when it has no price. */
|
|
68
|
+
export declare function labelPriceText(label: Pick<ProductLabel, 'priceCents' | 'currency'>): string;
|
|
69
|
+
/** The file name the ZPL download is saved under. */
|
|
70
|
+
export declare function labelsZplFileName(productName: string | undefined): string;
|
|
49
71
|
export declare function productLabelsHtml(labels: ProductLabel[], sizeId?: string): string;
|
|
50
72
|
/** Zebra's desktop printers print at 203 dpi; the 300 dpi models are listed by name. */
|
|
51
73
|
export type ZplDpi = 203 | 300;
|