astroidjs 0.5.0 → 0.7.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/commerce/adapters.d.ts +64 -4
- package/dist/commerce/adapters.js +78 -5
- package/dist/commerce/checkout-scaffold.js +196 -57
- package/dist/commerce/checkout.d.ts +74 -2
- package/dist/commerce/checkout.js +29 -2
- package/dist/commerce/index.d.ts +2 -2
- package/dist/commerce/index.js +1 -1
- package/dist/components/sections.d.ts +1 -1
- package/dist/config.d.ts +86 -18
- package/dist/config.js +87 -4
- package/dist/index.d.ts +1 -0
- package/dist/index.js +1 -0
- package/dist/project/generate.js +13 -1
- package/dist/project/scaffold.js +23 -6
- package/dist/pwa/generate.d.ts +34 -1
- package/dist/pwa/generate.js +33 -4
- package/dist/queues/consumer.d.ts +21 -1
- package/dist/queues/consumer.js +6 -3
- package/dist/queues/messages.js +4 -0
- package/dist/queues/scaffold.js +29 -0
- package/dist/schema/collections.js +9 -4
- package/dist/tenancy/index.d.ts +35 -0
- package/dist/tenancy/index.js +101 -0
- package/dist/worker/generate.js +89 -9
- package/package.json +7 -2
- package/src/components/Editable.astro +22 -10
- package/src/components/Section.astro +5 -4
- package/src/components/Sections.astro +1 -1
- package/src/components/sections.ts +1 -1
|
@@ -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
|
|
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:
|
|
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
|
|
39
|
-
currency:
|
|
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
|
-
|
|
49
|
-
|
|
50
|
-
|
|
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
|
-
|
|
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
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
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
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
239
|
-
|
|
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
|
-
]
|
|
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
|