astroidjs 0.6.0 → 0.7.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,16 +1,42 @@
1
1
  import type { CatalogItem } from "./sync.js";
2
+ /** Where a Square object is sold, as `louise-toolkit/commerce/square` reports it.
3
+ * Optional throughout, so an item assembled by hand or by an older client still
4
+ * satisfies the type and reads as "sold everywhere". */
5
+ export interface SquarePresenceLike {
6
+ presentAtAllLocations?: boolean;
7
+ presentAtLocationIds?: string[];
8
+ absentAtLocationIds?: string[];
9
+ }
10
+ /** A per-location price override. A null/absent `priceCents` means the override
11
+ * adjusts something other than price, and the base price still applies. */
12
+ export interface SquareLocationOverrideLike {
13
+ locationId: string;
14
+ priceCents?: number | null;
15
+ currency?: string | null;
16
+ soldOut?: boolean | null;
17
+ }
2
18
  /** The subset of `SquareCatalogItem` the mirror reads. */
3
- export interface SquareItemLike {
19
+ export interface SquareItemLike extends SquarePresenceLike {
4
20
  id: string;
5
21
  name: string;
6
22
  imageUrl?: string | null;
7
- variations?: {
23
+ variations?: ({
8
24
  id: string;
9
25
  name: string;
10
26
  sku?: string | null;
27
+ /** The BASE price. A location override wins over it where one exists. */
11
28
  priceCents: number;
12
29
  currency?: string;
13
- }[];
30
+ locationOverrides?: SquareLocationOverrideLike[];
31
+ } & SquarePresenceLike)[];
32
+ }
33
+ /** Options for {@link squareToCatalogItem}. */
34
+ export interface SquareAdapterOptions {
35
+ /**
36
+ * Resolve prices and presence for one merchant location. Omit for a
37
+ * single-location account, where base prices are the only prices.
38
+ */
39
+ locationId?: string;
14
40
  }
15
41
  /** The subset of `FwProduct` the mirror reads. */
16
42
  export interface FourthwallProductLike {
@@ -32,6 +58,22 @@ export interface FourthwallProductLike {
32
58
  attributes?: unknown;
33
59
  }[];
34
60
  }
61
+ /**
62
+ * Is this item sold at `locationId` at all?
63
+ *
64
+ * Exported because a location-scoped sync needs to SKIP items the merchant
65
+ * doesn't carry, and `squareToCatalogItem` can't do that for you — it returns
66
+ * one item, and "don't store this row" isn't a `CatalogItem`. Without the guard
67
+ * an unstocked item mirrors as a $0 card with no variants, which looks like a
68
+ * pricing bug rather than a catalog decision.
69
+ *
70
+ * ```ts
71
+ * const rows = items
72
+ * .filter((i) => squareItemSoldAt(i, locationId))
73
+ * .map((i) => squareToCatalogItem(i, { locationId }));
74
+ * ```
75
+ */
76
+ export declare function squareItemSoldAt(item: SquareItemLike, locationId: string): boolean;
35
77
  /**
36
78
  * Square item → `CatalogItem`.
37
79
  *
@@ -39,8 +81,26 @@ export interface FourthwallProductLike {
39
81
  * beans" with 12oz and 2lb variations), so a single headline number has to mean
40
82
  * "from" — taking the first variation's price instead would change with Square's
41
83
  * ordering and quietly misprice the card.
84
+ *
85
+ * ## Scoping to one merchant
86
+ *
87
+ * Pass `locationId` and both halves resolve at that location: variations the
88
+ * merchant doesn't carry are dropped, and the rest price through
89
+ * `location_overrides` rather than the base price.
90
+ *
91
+ * The headline number has to be scoped for the same reason the variants are.
92
+ * "From $8" computed over the whole catalog, on a page where the $8 size isn't
93
+ * stocked, advertises a price this merchant will never honour — and because the
94
+ * dropped variation is usually the cheap one, the error runs in the direction a
95
+ * customer notices at the till.
96
+ *
97
+ * Unscoped behaviour is unchanged: no `locationId` means base prices and every
98
+ * variation, which is correct for a single-location account.
99
+ *
100
+ * An item sold nowhere at `locationId` yields no variants and a price of 0 —
101
+ * filter with {@link squareItemSoldAt} before calling rather than storing that.
42
102
  */
43
- export declare function squareToCatalogItem(item: SquareItemLike): CatalogItem;
103
+ export declare function squareToCatalogItem(item: SquareItemLike, options?: SquareAdapterOptions): CatalogItem;
44
104
  /**
45
105
  * Fourthwall product → `CatalogItem`. Same "lowest variant wins" rule as Square,
46
106
  * for the same reason.
@@ -13,9 +13,49 @@
13
13
  // shape the mirror stores. Deliberately pure — they take the provider's objects,
14
14
  // not credentials or an `env`, so they're trivially testable and the caller
15
15
  // keeps control of how the fetch happens (cached, paged, rate-limited).
16
+ /**
17
+ * Is this object sold at `locationId`?
18
+ *
19
+ * Mirrors `presentAt` in `louise-toolkit/commerce/square`, but tolerant of the
20
+ * fields being absent. The two lists are NOT symmetric — `presentAtLocationIds`
21
+ * is a whitelist consulted when `presentAtAllLocations` is false,
22
+ * `absentAtLocationIds` a blacklist consulted when it is true. Reading them the
23
+ * other way round shows a merchant products they do not carry.
24
+ */
25
+ function presentAtLocation(o, locationId) {
26
+ return o.presentAtAllLocations === false
27
+ ? (o.presentAtLocationIds ?? []).includes(locationId)
28
+ : !(o.absentAtLocationIds ?? []).includes(locationId);
29
+ }
30
+ /** The effective price of a variation at one location: the override's price if
31
+ * it sets one, otherwise the base price. */
32
+ function variationPriceAt(v, locationId) {
33
+ const override = (v.locationOverrides ?? []).find((o) => o.locationId === locationId);
34
+ return override?.priceCents != null ? override.priceCents : v.priceCents;
35
+ }
16
36
  /** Minor units → major. Square prices in cents; the mirror stores dollars, since
17
37
  * that's what a template renders and what an owner types into an overlay. */
18
38
  const toMajor = (cents) => Math.round(cents) / 100;
39
+ /**
40
+ * Is this item sold at `locationId` at all?
41
+ *
42
+ * Exported because a location-scoped sync needs to SKIP items the merchant
43
+ * doesn't carry, and `squareToCatalogItem` can't do that for you — it returns
44
+ * one item, and "don't store this row" isn't a `CatalogItem`. Without the guard
45
+ * an unstocked item mirrors as a $0 card with no variants, which looks like a
46
+ * pricing bug rather than a catalog decision.
47
+ *
48
+ * ```ts
49
+ * const rows = items
50
+ * .filter((i) => squareItemSoldAt(i, locationId))
51
+ * .map((i) => squareToCatalogItem(i, { locationId }));
52
+ * ```
53
+ */
54
+ export function squareItemSoldAt(item, locationId) {
55
+ if (!presentAtLocation(item, locationId))
56
+ return false;
57
+ return (item.variations ?? []).some((v) => presentAtLocation(v, locationId));
58
+ }
19
59
  /**
20
60
  * Square item → `CatalogItem`.
21
61
  *
@@ -23,20 +63,53 @@ const toMajor = (cents) => Math.round(cents) / 100;
23
63
  * beans" with 12oz and 2lb variations), so a single headline number has to mean
24
64
  * "from" — taking the first variation's price instead would change with Square's
25
65
  * ordering and quietly misprice the card.
66
+ *
67
+ * ## Scoping to one merchant
68
+ *
69
+ * Pass `locationId` and both halves resolve at that location: variations the
70
+ * merchant doesn't carry are dropped, and the rest price through
71
+ * `location_overrides` rather than the base price.
72
+ *
73
+ * The headline number has to be scoped for the same reason the variants are.
74
+ * "From $8" computed over the whole catalog, on a page where the $8 size isn't
75
+ * stocked, advertises a price this merchant will never honour — and because the
76
+ * dropped variation is usually the cheap one, the error runs in the direction a
77
+ * customer notices at the till.
78
+ *
79
+ * Unscoped behaviour is unchanged: no `locationId` means base prices and every
80
+ * variation, which is correct for a single-location account.
81
+ *
82
+ * An item sold nowhere at `locationId` yields no variants and a price of 0 —
83
+ * filter with {@link squareItemSoldAt} before calling rather than storing that.
26
84
  */
27
- export function squareToCatalogItem(item) {
28
- const prices = (item.variations ?? []).map((v) => v.priceCents).filter((c) => Number.isFinite(c));
85
+ export function squareToCatalogItem(item, options = {}) {
86
+ const locationId = options.locationId;
87
+ const variations = (item.variations ?? []).filter((v) => locationId === undefined || presentAtLocation(v, locationId));
88
+ const priceOf = (v) => locationId === undefined ? v.priceCents : variationPriceAt(v, locationId);
89
+ const prices = variations.map(priceOf).filter((c) => Number.isFinite(c));
29
90
  return {
30
91
  externalId: item.id,
31
92
  name: item.name,
32
93
  price: prices.length ? toMajor(Math.min(...prices)) : 0,
33
94
  images: item.imageUrl ? [item.imageUrl] : [],
34
- variants: (item.variations ?? []).map((v) => ({
95
+ variants: variations.map((v) => ({
35
96
  id: v.id,
36
97
  name: v.name,
37
98
  sku: v.sku ?? null,
38
- price: toMajor(v.priceCents),
39
- currency: v.currency ?? "USD",
99
+ price: toMajor(priceOf(v)),
100
+ currency: (locationId === undefined
101
+ ? undefined
102
+ : (v.locationOverrides ?? []).find((o) => o.locationId === locationId)?.currency) ??
103
+ v.currency ??
104
+ "USD",
105
+ // Only meaningful when scoped: `sold_out` is a per-location flag, so an
106
+ // unscoped read has no single answer and omits it rather than guessing.
107
+ ...(locationId === undefined
108
+ ? {}
109
+ : {
110
+ soldOut: (v.locationOverrides ?? []).find((o) => o.locationId === locationId)?.soldOut ??
111
+ false,
112
+ }),
40
113
  })),
41
114
  };
42
115
  }
@@ -20,7 +20,7 @@
20
20
  // redirects to its own hosted checkout (no card token to charge) and Stripe has
21
21
  // no catalog API, so it fills the `invoicing` role rather than `storefront`.
22
22
  import { astroidCatalogMirror } from "./mirror.js";
23
- import { astroidCommerceProviders, astroidCommerceRoles } from "./roles.js";
23
+ import { astroidCommerceProviders, astroidCommerceRoles, hasMultiLocation } from "./roles.js";
24
24
  /** Does this project take card payments in-page? Square storefront only. */
25
25
  export function usesCardCheckout(config) {
26
26
  return astroidCommerceRoles(config.commerce).storefront === "square";
@@ -38,6 +38,7 @@ export function generateAstroidCheckoutRoute(config) {
38
38
  if (!usesCardCheckout(config))
39
39
  return null;
40
40
  const { table } = astroidCatalogMirror(config);
41
+ const multi = hasMultiLocation(config.commerce);
41
42
  return [
42
43
  "// Server-authoritative checkout (POST /api/checkout).",
43
44
  "//",
@@ -45,9 +46,18 @@ export function generateAstroidCheckoutRoute(config) {
45
46
  "// receipt email all belong here. What should NOT change is the ORDER of the",
46
47
  "// steps below; each one is load-bearing:",
47
48
  "//",
48
- "// 1. Re-price every line from the D1 catalog mirror. The client's price is",
49
- "// a STALENESS CHECK, never an input to the charge. Accept `unitPrice`",
50
- "// from the request body and anyone can buy anything for a penny.",
49
+ multi
50
+ ? "// 0. Resolve WHICH MERCHANT this sale belongs to, from the host — never\n" +
51
+ "// from the request body. See `resolveLocationId` below."
52
+ : null,
53
+ multi
54
+ ? "// 1. Re-price every line AT THAT LOCATION, live from Square. The client's\n" +
55
+ "// price is a STALENESS CHECK, never an input to the charge. Accept\n" +
56
+ "// `unitPrice` from the request body and anyone can buy anything for a\n" +
57
+ "// penny."
58
+ : "// 1. Re-price every line from the D1 catalog mirror. The client's price is\n" +
59
+ "// a STALENESS CHECK, never an input to the charge. Accept `unitPrice`\n" +
60
+ "// from the request body and anyone can buy anything for a penny.",
51
61
  "// 2. Refuse on mismatch rather than charging the server's number silently.",
52
62
  "// Being charged more than the page said is worse than being asked to",
53
63
  "// review the cart.",
@@ -57,40 +67,99 @@ export function generateAstroidCheckoutRoute(config) {
57
67
  "// 4. Charge only when commerce is actually provisioned. With placeholder",
58
68
  "// secrets this simulates instead — it must never call Square with a",
59
69
  "// dummy credential.",
70
+ multi
71
+ ? "//\n" +
72
+ "// Step 4 runs BEFORE step 1 here, and only here: per-location re-pricing is\n" +
73
+ "// itself a Square call, so the provisioning check has to come first or the\n" +
74
+ "// rule above is broken by the very step that enforces it."
75
+ : null,
60
76
  'import type { APIRoute } from "astro";',
61
77
  'import { env } from "cloudflare:workers";',
62
78
  "import {",
63
79
  " checkoutIdempotencyKey,",
64
- " readCatalog,",
80
+ multi ? null : " readCatalog,",
65
81
  " resolveCommerceStatus,",
66
82
  " type SecretSource,",
67
83
  " verifyCheckout,",
68
84
  '} from "astroidjs";',
69
- 'import { createPayment } from "louise-toolkit/commerce/square";',
85
+ multi
86
+ ? 'import { createPayment, retrieveVariationPricesAt } from "louise-toolkit/commerce/square";'
87
+ : 'import { createPayment } from "louise-toolkit/commerce/square";',
70
88
  'import { isSameOrigin } from "louise-toolkit/security";',
71
89
  'import astroidConfig from "../../../astroid.config.js";',
72
90
  "",
73
91
  "export const prerender = false;",
74
92
  "",
75
- "/** Server-side prices, in minor units, straight from the catalog mirror. */",
76
- "async function serverPrices(variantIds: string[]): Promise<Map<string, number>> {",
77
- " // `readCatalog` returns the product ARRAY (published only by default).",
78
- " const items = await readCatalog({",
79
- " db: env.DB,",
80
- ` table: ${JSON.stringify(table)},`,
81
- " });",
82
- " const prices = new Map<string, number>();",
83
- " for (const item of items) {",
84
- " // The mirror stores MAJOR units (dollars); the charge is in minor units.",
85
- " // `Math.round` is not decoration — 19.99 * 100 is 1998.9999999999998, and",
86
- " // a float cent here fails the exact-equality staleness check on every",
87
- " // single checkout.",
88
- " if (variantIds.includes(item.externalId)) {",
89
- " prices.set(item.externalId, Math.round(item.price * 100));",
90
- " }",
91
- " }",
92
- " return prices;",
93
- "}",
93
+ ...(multi
94
+ ? [
95
+ "// ── Which merchant is this? ────────────────────────────────────────────────",
96
+ "//",
97
+ '// `square.locations: "multi"` means the location is a property of the',
98
+ "// REQUEST, not of the environment — which is why Astroid does not require a",
99
+ "// SQUARE_LOCATION_ID for this project. Fill this in and keep two rules:",
100
+ "//",
101
+ "// * Derive it from the HOST (or an authenticated session), never from the",
102
+ "// request body. A body-supplied location lets a customer name the",
103
+ "// cheapest merchant's id and pay that price at the dearest merchant's",
104
+ "// shop — the same exploit as a client-supplied price, one level back.",
105
+ "// * Return null for anything unrecognised. Falling back to a default",
106
+ "// rings one merchant's sale against another merchant's books, and looks",
107
+ "// completely successful while doing it.",
108
+ "//",
109
+ "// Returning null refuses the checkout. That is deliberate: an unwired",
110
+ "// multi-merchant store should take no money rather than the wrong money.",
111
+ "function resolveLocationId(request: Request): string | null {",
112
+ " const host = new URL(request.url).hostname.toLowerCase();",
113
+ " // TODO(you): map host → Square location id (a constant map here, a D1",
114
+ " // lookup, or `tenantLabel` from astroidjs if you use Astroid tenancy).",
115
+ " const locations: Record<string, string> = {};",
116
+ " return locations[host] ?? null;",
117
+ "}",
118
+ "",
119
+ "/** Server-side prices, in minor units, at ONE merchant's location. */",
120
+ "async function serverPrices(",
121
+ " variantIds: string[],",
122
+ " scope?: { locationId?: string },",
123
+ "): Promise<Map<string, number>> {",
124
+ " // Live from Square rather than the D1 mirror, and not by preference: the",
125
+ " // mirror holds ONE price per item, so it structurally cannot answer",
126
+ ' // "what does this cost at this location". `retrieveVariationPricesAt`',
127
+ " // resolves `location_overrides`, and omits any variation the merchant",
128
+ " // does not carry — so an unstocked id fails closed as `unavailable`",
129
+ " // instead of silently selling at the base price.",
130
+ " const locationId = scope?.locationId;",
131
+ " if (!locationId) return new Map();",
132
+ " const environment =",
133
+ ' env.SQUARE_ENVIRONMENT === "production" ? "production" : "sandbox";',
134
+ " const money = await retrieveVariationPricesAt(",
135
+ ' { accessToken: env.SQUARE_ACCESS_TOKEN ?? "", environment },',
136
+ " variantIds,",
137
+ " locationId,",
138
+ " );",
139
+ " return new Map([...money].map(([id, m]) => [id, m.amount]));",
140
+ "}",
141
+ ]
142
+ : [
143
+ "/** Server-side prices, in minor units, straight from the catalog mirror. */",
144
+ "async function serverPrices(variantIds: string[]): Promise<Map<string, number>> {",
145
+ " // `readCatalog` returns the product ARRAY (published only by default).",
146
+ " const items = await readCatalog({",
147
+ " db: env.DB,",
148
+ ` table: ${JSON.stringify(table)},`,
149
+ " });",
150
+ " const prices = new Map<string, number>();",
151
+ " for (const item of items) {",
152
+ " // The mirror stores MAJOR units (dollars); the charge is in minor units.",
153
+ " // `Math.round` is not decoration — 19.99 * 100 is 1998.9999999999998, and",
154
+ " // a float cent here fails the exact-equality staleness check on every",
155
+ " // single checkout.",
156
+ " if (variantIds.includes(item.externalId)) {",
157
+ " prices.set(item.externalId, Math.round(item.price * 100));",
158
+ " }",
159
+ " }",
160
+ " return prices;",
161
+ "}",
162
+ ]),
94
163
  "",
95
164
  "const json = (body: unknown, status = 200) =>",
96
165
  " new Response(JSON.stringify(body), {",
@@ -126,32 +195,75 @@ export function generateAstroidCheckoutRoute(config) {
126
195
  ' return json({ error: "A uuid `cartId` is required" }, 400);',
127
196
  " }",
128
197
  "",
129
- " // 1 + 2: re-price and refuse on mismatch.",
130
- " const check = await verifyCheckout(body.lines, serverPrices);",
131
- " if (!check.ok) return json({ error: check.message, reason: check.reason }, 409);",
132
- "",
133
- " // 3: stable per cart, distinct per customer.",
134
- ' const idempotencyKey = await checkoutIdempotencyKey(check, "order", cartId);',
135
- "",
136
- " // 4: dormant until provisioned. An unconfigured store still re-prices and",
137
- " // still refuses a stale cart — it just doesn't move money.",
138
- " // Cast as the toolkit's own `astroidModuleStatus` does: `readSecret`",
139
- " // accepts a plain string OR a Secrets Store binding, which CloudflareEnv",
140
- " // types more narrowly than the resolver's `SecretSource` map.",
141
- " const status = await resolveCommerceStatus(",
142
- " astroidConfig.commerce,",
143
- " env as unknown as Record<string, SecretSource>,",
144
- " );",
145
- " if (!status.configured) {",
146
- " console.info(",
147
- ' `[astroid:commerce] simulated checkout — unprovisioned: ${status.missing.join(", ")}`,',
148
- " );",
149
- " return json({",
150
- " simulated: true,",
151
- " subtotalCents: check.subtotalCents,",
152
- " idempotencyKey,",
153
- " });",
154
- " }",
198
+ ...(multi
199
+ ? [
200
+ " // 0: whose sale is this? Refuse rather than guess.",
201
+ " const locationId = resolveLocationId(request);",
202
+ " if (!locationId) {",
203
+ ' return json({ error: "This storefront is not open for orders." }, 409);',
204
+ " }",
205
+ "",
206
+ " // 4, EARLY — and the ordering is the point. Re-pricing per location is",
207
+ " // itself a live Square call, so the dormancy gate has to precede",
208
+ " // verification here rather than follow it; running it after would call",
209
+ " // Square with a placeholder token, which this route must never do.",
210
+ " //",
211
+ " // The cost is real and worth naming: an unprovisioned multi-merchant",
212
+ " // store cannot do the staleness check at all, because the prices live at",
213
+ " // the provider. It says so (`priced: false`) rather than echoing the",
214
+ " // client's total back as if the server had agreed to it.",
215
+ " //",
216
+ " // Cast as the toolkit's own `astroidModuleStatus` does: `readSecret`",
217
+ " // accepts a plain string OR a Secrets Store binding, which CloudflareEnv",
218
+ " // types more narrowly than the resolver's `SecretSource` map.",
219
+ " const status = await resolveCommerceStatus(",
220
+ " astroidConfig.commerce,",
221
+ " env as unknown as Record<string, SecretSource>,",
222
+ " );",
223
+ " if (!status.configured) {",
224
+ " console.info(",
225
+ ' `[astroid:commerce] simulated checkout — unprovisioned: ${status.missing.join(", ")}`,',
226
+ " );",
227
+ " return json({ simulated: true, priced: false });",
228
+ " }",
229
+ "",
230
+ " // 1 + 2: re-price AT THIS LOCATION and refuse on mismatch.",
231
+ " const check = await verifyCheckout(body.lines, serverPrices, {",
232
+ " scope: { locationId },",
233
+ " });",
234
+ " if (!check.ok) return json({ error: check.message, reason: check.reason }, 409);",
235
+ "",
236
+ " // 3: stable per cart, distinct per customer.",
237
+ ' const idempotencyKey = await checkoutIdempotencyKey(check, "order", cartId);',
238
+ ]
239
+ : [
240
+ " // 1 + 2: re-price and refuse on mismatch.",
241
+ " const check = await verifyCheckout(body.lines, serverPrices);",
242
+ " if (!check.ok) return json({ error: check.message, reason: check.reason }, 409);",
243
+ "",
244
+ " // 3: stable per cart, distinct per customer.",
245
+ ' const idempotencyKey = await checkoutIdempotencyKey(check, "order", cartId);',
246
+ "",
247
+ " // 4: dormant until provisioned. An unconfigured store still re-prices and",
248
+ " // still refuses a stale cart — it just doesn't move money.",
249
+ " // Cast as the toolkit's own `astroidModuleStatus` does: `readSecret`",
250
+ " // accepts a plain string OR a Secrets Store binding, which CloudflareEnv",
251
+ " // types more narrowly than the resolver's `SecretSource` map.",
252
+ " const status = await resolveCommerceStatus(",
253
+ " astroidConfig.commerce,",
254
+ " env as unknown as Record<string, SecretSource>,",
255
+ " );",
256
+ " if (!status.configured) {",
257
+ " console.info(",
258
+ ' `[astroid:commerce] simulated checkout — unprovisioned: ${status.missing.join(", ")}`,',
259
+ " );",
260
+ " return json({",
261
+ " simulated: true,",
262
+ " subtotalCents: check.subtotalCents,",
263
+ " idempotencyKey,",
264
+ " });",
265
+ " }",
266
+ ]),
155
267
  "",
156
268
  ' const sourceId = typeof body.sourceId === "string" ? body.sourceId : "";',
157
269
  ' if (!sourceId) return json({ error: "A card token (`sourceId`) is required" }, 400);',
@@ -170,7 +282,11 @@ export function generateAstroidCheckoutRoute(config) {
170
282
  " sourceId,",
171
283
  " // The SERVER's number, never the client's.",
172
284
  ' amountMoney: { amount: check.subtotalCents, currency: "USD" },',
173
- ' locationId: env.SQUARE_LOCATION_ID ?? "",',
285
+ multi
286
+ ? " // The location the cart was PRICED against — necessarily the same one,\n" +
287
+ " // or the sale rings against a merchant who never quoted this total."
288
+ : null,
289
+ multi ? " locationId," : ' locationId: env.SQUARE_LOCATION_ID ?? "",',
174
290
  " idempotencyKey,",
175
291
  ' ...(typeof body.verificationToken === "string"',
176
292
  " ? { verificationToken: body.verificationToken }",
@@ -187,7 +303,12 @@ export function generateAstroidCheckoutRoute(config) {
187
303
  " });",
188
304
  "};",
189
305
  "",
190
- ].join("\n");
306
+ // `null` marks a line that belongs to the other location mode. Dropped
307
+ // rather than emitted as "" so the generated file has no stray blank lines
308
+ // where a single-location project's checkout differs from a multi's.
309
+ ]
310
+ .filter((line) => line !== null)
311
+ .join("\n");
191
312
  }
192
313
  /**
193
314
  * `src/components/SquareCard.astro` — the card input.
@@ -202,6 +323,7 @@ export function generateAstroidCheckoutRoute(config) {
202
323
  export function generateAstroidSquareCard(config) {
203
324
  if (!usesCardCheckout(config))
204
325
  return null;
326
+ const multi = hasMultiLocation(config.commerce);
205
327
  return [
206
328
  "---",
207
329
  "// Square Web Payments card input.",
@@ -215,10 +337,24 @@ export function generateAstroidSquareCard(config) {
215
337
  "// commerce is on), so no policy change is needed.",
216
338
  'import { env } from "cloudflare:workers";',
217
339
  "",
340
+ ...(multi
341
+ ? [
342
+ "// Multi-location: the merchant is a property of the PAGE, so the id comes",
343
+ "// in as a prop. Only the card iframe is bound to it — the charge takes its",
344
+ "// location from the server, which resolves it independently in",
345
+ "// /api/checkout. This one cannot pick the merchant, and shouldn't: it is",
346
+ "// rendered from markup a customer can reach.",
347
+ "interface Props {",
348
+ " locationId: string;",
349
+ "}",
350
+ "const { locationId } = Astro.props;",
351
+ "",
352
+ ]
353
+ : []),
218
354
  "// The PUBLIC application id — safe in the browser, unlike the access token.",
219
355
  "// Absent (an unprovisioned store) → render nothing rather than a dead form.",
220
356
  "const appId = env.SQUARE_APP_ID;",
221
- "const locationId = env.SQUARE_LOCATION_ID;",
357
+ multi ? null : "const locationId = env.SQUARE_LOCATION_ID;",
222
358
  'const environment = env.SQUARE_ENVIRONMENT ?? "sandbox";',
223
359
  "const ready = Boolean(appId && locationId);",
224
360
  "---",
@@ -235,8 +371,9 @@ export function generateAstroidSquareCard(config) {
235
371
  " </div>",
236
372
  " ) : (",
237
373
  ' <p class="text-sm opacity-70">',
238
- " Card payments are not configured yet — set SQUARE_APP_ID and",
239
- " SQUARE_LOCATION_ID.",
374
+ multi
375
+ ? " Card payments are not configured yet — set SQUARE_APP_ID and pass a\n `locationId` prop."
376
+ : " Card payments are not configured yet — set SQUARE_APP_ID and\n SQUARE_LOCATION_ID.",
240
377
  " </p>",
241
378
  " )",
242
379
  "}",
@@ -268,7 +405,9 @@ export function generateAstroidSquareCard(config) {
268
405
  " }",
269
406
  "</script>",
270
407
  "",
271
- ].join("\n");
408
+ ]
409
+ .filter((line) => line !== null)
410
+ .join("\n");
272
411
  }
273
412
  /** Does this project talk to Square in ANY role — storefront, invoicing, or
274
413
  * otherwise? Distinct from {@link usesCardCheckout}, which asks the narrower