local-storefront 0.2.0 → 0.3.0-rc.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.
@@ -0,0 +1,1609 @@
1
+ # Checkout
2
+
3
+ Run the Shopify Functions and show the checkout and customer account UI extensions of a Shopify
4
+ app, on your machine.
5
+ local-storefront serves a fake checkout for it: no store, no payment, and no network except the
6
+ requests of the Functions that have [network access](#network-access). It is a way to
7
+ see what your Functions do to a cart, and how your extensions look and behave, not a copy of
8
+ Shopify's checkout.
9
+
10
+ ## Run it
11
+
12
+ ```bash
13
+ npx local-storefront app dev --app ../my-app
14
+ ```
15
+
16
+ Open <http://127.0.0.1:9292/>: the [home page](#the-home-page) lists what is wrong first, then
17
+ the [checkout scenarios](#scenarios) and the extensions of the app, and opens a checkout for each. <http://127.0.0.1:9292/checkout>
18
+ is the checkout of the cart of the session, which the store data defines (`session.cart`, see
19
+ [data format](data-format.md#customers-and-sessions)). It needs no theme.
20
+
21
+ The pages need a recent browser: Chrome 123+, Safari 17.2+ or Firefox 138+ (they import JSON
22
+ modules).
23
+
24
+ With a theme, `serve` does the same and the cart form of the theme leads to that checkout. The
25
+ theme owns `/`, so the home page is at <http://127.0.0.1:9292/__lsf/checkouts>, and the server
26
+ prints its address when it starts:
27
+
28
+ ```bash
29
+ npx local-storefront serve --app ../my-app
30
+ ```
31
+
32
+ No scenarios yet? `npx local-storefront app init --app ../my-app` writes a set made for
33
+ the extensions of the app, see [scenarios](#scenarios).
34
+
35
+ In the app's folder, `--app` can be left out: `lsf app dev`, `lsf app function run` and the other
36
+ commands of an app take the app the current folder is in. The store data is `--data`, else the
37
+ `shopify-local/` of the current folder (or of the theme), else the app's
38
+ (`<app>/shopify-local`), else the built-in demo store. One that `lsf init` creates while the
39
+ server runs is read at its next reload.
40
+
41
+ The app is a directory with `shopify.app.toml` and `extensions/*/shopify.extension.toml`. API
42
+ versions 2026-10 and 2027-01 are supported. Functions run from their built Wasm module and
43
+ extensions from their built bundle (`dist/function.wasm`, `dist/<handle>.js`). lsf builds them
44
+ as `shopify app build` does, without Node and without Shopify's CLI
45
+ ([what it builds](#what-lsf-builds-without-node)): `lsf app build`, or `--build` before it
46
+ serves:
47
+
48
+ ```bash
49
+ npx local-storefront app build --app ../my-app # every extension
50
+ npx local-storefront app dev --app ../my-app --build
51
+ ```
52
+
53
+ ### Coming from Shopify's CLI
54
+
55
+ | Shopify CLI | lsf | What lsf does not do |
56
+ |---|---|---|
57
+ | `shopify app dev` | `lsf app dev --watch`: the checkout, the customer account and the Admin API, built again at each save ([watch mode](#watch-mode)) | No development store, tunnel or Partner account. The app's web server is yours to start: give its URL with `--app-url`. |
58
+ | `shopify app build` | `lsf app build` | `wasm-opt` is not run on a Rust Function ([Rust Functions](#rust-functions)). |
59
+ | `shopify app function build` | `lsf app function build` | `@shopify/shopify_function` 1.x ([JavaScript Functions](#javascript-and-typescript-functions)). |
60
+ | `shopify app function run` | `lsf app function run --input <file>` (or `-`, or a folder of fixtures), `--export <name>`, `--format json`; with `--scenario`, on an input lsf builds from the store data ([run a Function](#run-a-function-without-a-server)). As Shopify's, it builds the Function first when its files changed after its module was built. | |
61
+ | `shopify app function replay` | `lsf app function run --scenario <name> --save-input <file>`, then `--input <file>` | No run of a store to replay: lsf does not read Shopify's logs. |
62
+ | `shopify app function typegen` | `lsf app function typegen` | A Rust Function's types come from the `typegen` macro, at its build. |
63
+ | `shopify app function schema` | `lsf app function schema` | The schemas of fulfillment constraints, order routing and pickup generators. |
64
+ | `shopify app webhook trigger` | `lsf app webhook trigger` ([Admin API](admin.md)) | |
65
+ | `shopify app generate extension` | Not covered: copy an extension of the [playground](https://github.com/0xtlt/local-storefront/tree/main/examples/checkout-playground/extensions) ([add a JavaScript Function](#add-a-javascript-function)). | |
66
+ | `shopify app init`, `deploy`, `release`, `versions`, `env`, `config`, `info` | Not covered: they act on Shopify's servers. `lsf app init` writes checkout scenarios, not an app. | |
67
+
68
+ ### What lsf builds without Node
69
+
70
+ | Extension | Built by lsf when | Output |
71
+ |---|---|---|
72
+ | A Function with a build command (Rust, …) | always: the command runs in its folder, then a module that calls the Function Wasm API by name is linked to it ([below](#rust-functions)) | its module |
73
+ | A Function in JavaScript or TypeScript (`src/index.js`, `src/index.ts`), no build command | always ([below](#javascript-and-typescript-functions)) | `dist/function.wasm` |
74
+ | A checkout UI extension whose targets name their sources (`module`), and the sources are there | always ([below](#checkout-ui-extensions)) | `dist/<handle>.js` |
75
+ | A checkout UI extension with only its bundle | never: the bundle is used as it is | |
76
+
77
+ The npm packages they import come from the app's `node_modules`, when it has them. Without, lsf
78
+ has its own copy of those Shopify's templates use: `@shopify/shopify_function` 2.0.1, `preact`
79
+ 10.29.8, `@preact/signals` 2.11.4 and `@shopify/ui-extensions` 2026.10.0 (MIT, but
80
+ `@shopify/shopify_function`: Apache-2.0), written once in the user's cache folder
81
+ (`~/Library/Caches/lsf/` on macOS, `~/.cache/lsf/` on Linux). Any other package needs a
82
+ `node_modules`.
83
+
84
+ ### JavaScript and TypeScript Functions
85
+
86
+ A Function written in JavaScript or TypeScript (`src/index.js` or `src/index.ts`) with no build
87
+ command, as `shopify app generate extension` makes it, is built by lsf the way
88
+ `shopify app function build` builds it, without Node and without Shopify's CLI:
89
+
90
+ ```bash
91
+ npx local-storefront app function build --app ../my-app # every Function
92
+ npx local-storefront app function build discount --app ../my-app
93
+ ```
94
+
95
+ `--build` and `--watch` build it the same way, and `lsf app function typegen` writes its types
96
+ alone, as `shopify app function typegen` does (the `typegen` script of Shopify's templates'
97
+ `package.json`). The steps are the CLI's:
98
+
99
+ 1. **Types**: the `codegen` section of its `package.json` (graphql-codegen's config) is written
100
+ as graphql-codegen writes it (`generated/api.ts`), from `schema.graphql`, or, when the
101
+ Function has none, lsf's schema of its API version.
102
+ 2. **One JavaScript file** (`dist/function.js`), as esbuild bundles it: TypeScript removed, the
103
+ modules it imports (with `node_modules`, when there is one), enum members inlined, and what
104
+ nothing uses left out. `process.env.NAME` is the value of `NAME` in the environment or in the
105
+ app's `.env`. `@shopify/shopify_function` (2.x) need not be installed: lsf has its own copy
106
+ of the part a Function runs.
107
+ 3. **The Wasm module**, as Javy writes it for Shopify's plugin: for the same JavaScript, the
108
+ same bytes. It holds the QuickJS bytecode of the Function and imports the JavaScript engine
109
+ from the plugin, which lsf runs it with, as Shopify does.
110
+
111
+ The module costs the instructions the CLI's would, within a few hundred. What lsf does not
112
+ build: `import()`, a `require` of an ES module, and Functions written with
113
+ `@shopify/shopify_function` 1.x (`shopify app build` builds those).
114
+
115
+ #### Add a JavaScript Function
116
+
117
+ `shopify app generate extension` needs a Partner account and the network. Without it, copy a
118
+ Function of the playground:
119
+ [`discount-typescript`](https://github.com/0xtlt/local-storefront/tree/main/examples/checkout-playground/extensions/discount-typescript)
120
+ or [`validation-javascript`](https://github.com/0xtlt/local-storefront/tree/main/examples/checkout-playground/extensions/validation-javascript).
121
+ A Function is a folder of `extensions/`:
122
+
123
+ | File | What it holds |
124
+ |---|---|
125
+ | `shopify.extension.toml` | `type = "function"`, its `handle`, a `[[extensions.targeting]]` with its `target`, `input_query` and `export`, and `[extensions.build]` with `command = ""` and `path = "dist/function.wasm"`. |
126
+ | `package.json` | `"@shopify/shopify_function": "2.0.1"` in `dependencies`: lsf needs it declared, not installed. The `codegen` section, for the types. |
127
+ | `src/index.js` (or `.ts`) | `export * from "./<target>";`, the target with `_` for `.`: `./cart_lines_discounts_generate_run` for a discount. |
128
+ | `src/<target>.graphql`, `src/<target>.js` | The input query, and the function the `export` names, in camel case (`cart-validations-generate-run`: `cartValidationsGenerateRun`). |
129
+
130
+ Then build it at each save with `npx local-storefront app dev --watch`. A discount Function runs
131
+ only for a discount that names it, and the scenarios written before it leave it out: see
132
+ [scenarios](#scenarios).
133
+
134
+ ### The schema of a Function
135
+
136
+ A Function's `schema.graphql` is the GraphQL schema of its API at its `api_version`: the Rust
137
+ `shopify_function` crate generates its types from it (`#[typegen("schema.graphql")]`), and a
138
+ JavaScript Function's graphql-codegen too. `shopify app function schema` writes it from
139
+ Shopify's servers; lsf writes it from the schemas it carries, offline:
140
+
141
+ ```bash
142
+ cd extensions/payment-rules && npx local-storefront app function schema # in the Function's folder
143
+ npx local-storefront app function schema payment-rules # or by handle, from the app
144
+ npx local-storefront app function schema payment-rules --stdout > other.graphql
145
+ ```
146
+
147
+ lsf carries the schemas of the checkout's Function APIs (cart transform, discounts, delivery and
148
+ payment customizations, cart and checkout validations) at 2026-10 and 2027-01: the ones it runs
149
+ the Functions with. Fulfillment constraints, order routing and pickup generators are not among
150
+ them. After changing `api_version`, write it again.
151
+
152
+ ### Rust Functions
153
+
154
+ `cargo build` writes a module that calls the Function Wasm API of the host by name
155
+ (`shopify_function_v2::shopify_function_input_get`); `shopify app build` links it to the host's
156
+ provider with Shopify's `shopify-function-trampoline` after the build command. lsf runs that same
157
+ trampoline, as a library, at the version Shopify's CLI 5.0.0 runs (2.0.1): after the build command
158
+ of `--build`, `--watch` and `lsf app function build`, the module is the one the CLI would write, to the
159
+ byte. A module already linked is left as it is. Modules of the first version of the API
160
+ (`shopify_function_v1`) are refused, as by the trampoline: update the `shopify_function` crate.
161
+
162
+ The `typegen` macro of the `shopify_function` crate reads the input queries and
163
+ `schema.graphql` without telling cargo, so a change to one of them alone compiles nothing. When
164
+ one is newer than the module, lsf's builds (`--build`, `--watch`, `lsf app function build`)
165
+ touch `src/main.rs` and `src/lib.rs` first (their time, not their text), and cargo compiles the
166
+ crate again: `↻ delivery-rules: src/run.graphql changed: src/main.rs touched, for cargo to
167
+ compile the crate again`. A compiler cache (sccache, a `RUSTC_WRAPPER`) may still answer the old
168
+ build, as it does not see those files either: the watch then says that the module did not
169
+ change.
170
+
171
+ lsf does not run `wasm-opt -Oz`, which the CLI runs on a module before it links it: on the
172
+ playground's Rust Functions it takes 5 to 6% off the module's size, and 0.25 to 0.5% off the
173
+ instructions a run costs.
174
+
175
+ ### Checkout UI extensions
176
+
177
+ A checkout or customer account UI extension whose targets name their sources
178
+ (`module = "./src/Checkout.jsx"`), in JavaScript, TypeScript or JSX, with Preact as Shopify's
179
+ templates or another runtime, is bundled by lsf the way `shopify app build` bundles it, without
180
+ Node:
181
+
182
+ ```bash
183
+ npx local-storefront app build banner --app ../my-app
184
+ ```
185
+
186
+ `--build` builds it too, and `--watch` builds it again when its files change, in a moment, then
187
+ reloads the open pages; at start, it builds those whose bundle is missing or older than their
188
+ files. As Shopify's CLI does:
189
+
190
+ - **The entry**: from API version 2025-10, each target's module is imported and its default
191
+ export registered with `shopify.extend('<target>', …)`; before, each module is imported.
192
+ - **JSX** calls the automatic runtime of the `jsxImportSource` of the closest `tsconfig.json`
193
+ (`preact` in Shopify's templates; `react` without one), in `.jsx`, `.tsx` and `.js` files. A
194
+ Preact extension without its `tsconfig.json` fails on `react/jsx-runtime`, and the error says
195
+ what to add: `{"compilerOptions": {"jsx": "react-jsx", "jsxImportSource": "preact"}}`.
196
+ - **The bundle** is one minified script (`dist/<handle>.js`): the modules it imports, what nothing
197
+ uses left out, `process.env.NODE_ENV` `production` and the other values of `process.env` from
198
+ the environment or the app's `.env`.
199
+
200
+ The bundle does what the CLI's does: the playground's tests draw both at their target and act on
201
+ them, and compare. It is not the same text (each is minified by its own tool), it is not lowered
202
+ to ES2015 (the page runs in a browser that needs none of it), and a target's `should_render`
203
+ module is not built (the page does not run it). A `.graphql` import is an error.
204
+
205
+ What is wrong with the app (a missing build, an unknown Function target, a checkout UI target
206
+ the page has no place for, an `owner_type` that is missing or not one of Shopify's) is printed
207
+ when the server starts (`app: …`), and is in the `diagnostics` of the state and on the home
208
+ page. A missing build
209
+ (``no Wasm module at …: build it with `npx local-storefront app build <handle>` ``, `not built`):
210
+ `npx local-storefront app build`, or `--build`. With `--build`,
211
+ `app dev`, `serve` and `app function run` build first, then print what is still wrong: a
212
+ module the build wrote is not reported missing.
213
+
214
+ | Option | What it does |
215
+ |---|---|
216
+ | `--app <dir>` (`LSF_APP`) | The app. The commands of `lsf app` default to the app the current folder is in. |
217
+ | `--extension <handle>` | Uses only this extension, a Function or a checkout UI extension. Repeat it for several. Without it, every extension of the app is used. |
218
+ | `--build` | Builds the extensions first, as `lsf app build` does: each Function (its build command, or lsf's build of a JavaScript Function) and the UI extensions that name their sources. With `--no-functions`, the UI extensions only. |
219
+ | `--no-functions` | Serves the UI extensions and the Admin API, and runs no Function: for a backend's tests ([Admin API](admin.md#place-an-order)). |
220
+ | `--only <parts>` (`LSF_ONLY`) | Runs only these parts of lsf: `app dev` runs `checkout,admin`, `serve --app` adds `theme` ([what runs](cli.md#what-runs)). Without `checkout`, the home page, `/__lsf/checkout` and `/__lsf/checkouts` answer `404` with an `X-LSF-Reason`, and so do `/checkout` and `/checkouts/…` without a theme; with one, `/checkout` is the theme's summary of the cart. Without `admin`, `complete` records no order in the Admin API and sends no webhook. |
221
+ | `--watch` | Develops the app as `shopify app dev` does: builds a Function again when its files change, and reloads the open pages. With `--build`, builds every Function at start. See [watch mode](#watch-mode). |
222
+ | `--variant <standard\|shop>` | The look of the page: Shopify's one-page checkout, or the Shop app's. A URL can ask for the other with `?variant=shop`. |
223
+
224
+ `app dev` takes the other options of [`serve`](cli.md#serve) (`--port`, `--host`, `--quiet`,
225
+ `--static`, `--customer`, ...).
226
+
227
+ ### Watch mode
228
+
229
+ ```bash
230
+ npx local-storefront app dev --app ../my-app --watch
231
+ ```
232
+
233
+ It works with `serve --app` too. Three things happen:
234
+
235
+ - **A save builds the Function again**: its build command, or lsf's build of a JavaScript
236
+ Function. The terminal says `↻ building discount-codes…`, then
237
+ `✓ discount-codes built in 1.4s`.
238
+ - **A build that fails keeps the last module**, as `shopify app dev` does: the checkout runs it.
239
+ The terminal, a banner of the checkout page and the home page show the error; the next build
240
+ that works clears it.
241
+ - **The open pages reload** once the builds a change started are over: the checkout, the
242
+ thank-you page and the home page. What the buyer entered is kept, it is in the session.
243
+
244
+ A change to a checkout UI extension's bundle, an extension added or removed,
245
+ `shopify.app.toml`, a scenario or the store data reloads the pages without a build.
246
+ `lsf app function run` has no watch mode: it runs the modules in `dist/`, as they are.
247
+
248
+ #### Details
249
+
250
+ - **At the start**, the modules that are there are used, as without `--watch`, when nothing
251
+ changed since the last watch of the app. For each Function that has a build command
252
+ (`[extensions.build] command`), the user's cache folder (`~/Library/Caches/lsf/watch/` on
253
+ macOS, `~/.cache/lsf/watch/` on Linux, never the app) records what its last build was made
254
+ from (its files, the files the builds share, its command) and a hash of the module it
255
+ published. A module is trusted only when both still match, and no build of it runs (see the
256
+ write lock below). Otherwise it is built: a Function with no module a run can load, one whose
257
+ files changed, and one whose module changed since, whether a build was stopped halfway,
258
+ failed after writing it, or another process wrote it (a build left running by a server that
259
+ was killed, another server of the same app). A Function with nothing recorded, on the first
260
+ watch of an app, keeps the module it has, and the terminal says `using the existing builds;
261
+ --build rebuilds them all at start`. `--watch --build` builds every Function that has a build
262
+ command. A build that fails does not stop the server.
263
+ - **One build at a time writes a Function's module**, across the servers of an app: each
264
+ Function has a write lock, next to the record (`locks/<handle>.lock`). A build takes it before
265
+ it starts, and the server lets go of it once it read, checked, published and recorded the
266
+ module. Another build of that Function waits meanwhile, and the terminal says
267
+ `↻ discount-codes: waiting for another build of it to end (another lsf, or one left running)`;
268
+ Ctrl-C still stops it at once. On macOS and Linux the build holds the lock too, and so does
269
+ what it starts (`cargo`, `rustc`): a build left running by a server that was killed holds it
270
+ until it is over, so what it writes is never taken for the module of another build. A lock
271
+ that only a process the build started still holds, once the build is over (a compiler cache's
272
+ server, such as sccache), is not waited for. Limits: a build that closes the descriptors it
273
+ inherits, or starts its writer apart from itself, does not hold the lock to its end; a
274
+ process that stays in a killed server's build's process group is waited for while it runs. On
275
+ Windows only the server holds the lock: a build that a killed server left running is not
276
+ waited for there (the next start still builds again a module that is not the one recorded).
277
+ - **What is watched**: the folder of each extension (`src/`, the input queries, its
278
+ `shopify.extension.toml`, `locales/`, and the `dist/` bundle of a checkout UI extension), what
279
+ the builds of the Functions read in the app's own folder (a Cargo workspace's `Cargo.toml` and
280
+ `Cargo.lock`, `rust-toolchain.toml`, `package.json` and its lock file, `pnpm-workspace.yaml`,
281
+ `tsconfig.json`, and the folders that hold a crate or a package the Functions may share: a
282
+ `Cargo.toml` or a `package.json` at most three levels down, as `crates/common/Cargo.toml`),
283
+ `shopify.app.toml`, and the extension folders that are added or removed. A symbolic link is
284
+ watched where it leads. The store data and its scenarios are watched as always.
285
+ - **What is not**: the rest of the app's own folder, which is the app's and its dev server's: its
286
+ sources (`src/`, `app/`), its pages (`index.html`), and what its tools build or fetch at its
287
+ root (`dist/` of trunk or Vite, `build/`, `target/`, `node_modules/`), so that an app whose
288
+ web front end rebuilds on every save (an openshop project) does not build its Functions and
289
+ reload the pages each time. Nor `.git/`, `.shopify/` and other dot folders, editors' temporary
290
+ files, Markdown documents (in any folder), the store data folder (`shopify-local/`, or the one
291
+ `--data` names), and what a build writes: the `dist/` folder of a Function that has a build
292
+ command, its module (`[extensions.build] path`), `schema.graphql`, and the types of a
293
+ JavaScript Function's typegen (`generated/api.ts`). A build does not start itself again.
294
+ - **A change to a Function** with a build command runs that command in its folder, as `--build`
295
+ does. A save that writes several files builds once. One build at a time per Function: a change
296
+ during its build makes one more build after it. Up to 3 Functions build at once (the Functions
297
+ of one Cargo workspace wait for each other on its lock). A JavaScript Function without a build
298
+ command counts as one with a build command: lsf builds it (`lsf app function build`, in a
299
+ process of its own).
300
+ - **A change to the other files of the app** builds every Function that has a build command:
301
+ `↻ Cargo.toml changed: building 12 Functions`.
302
+ - **The pages reload** once per change, however many Functions it builds. The home page runs the
303
+ Functions of the scenarios again.
304
+ - **The checkout never runs what a build is writing.** It runs a copy of the last module of each
305
+ Function that built, kept out of the app (in the system's temporary folder, removed when the
306
+ server stops). Once a build is over and its module compiles as a run compiles it (and exports
307
+ a function), the copy is replaced in one step. The checkout, the Functions dialog and the runs
308
+ of the home page all read it. `dist/` is only written by the build command.
309
+ - **A build that fails**, or writes a module that does not compile (`the build wrote an invalid
310
+ module: …`), leaves the last copy. The terminal prints
311
+ `✗ discount-codes: build failed (exit status: 101)`, the first error (from its `error` line to
312
+ the end of its source context) and the last lines of the output; the checkout page shows them
313
+ in a banner and in the overview of the Functions dialog, and the home page lists the build
314
+ first among its problems. A Function that never built is not built until a build works, and
315
+ the banner and the home page say `no module of it built yet`.
316
+ - **A build that hangs** does not hold the server: it is stopped after 10 minutes and fails.
317
+ Ctrl-C stops the builds that run, and so do `SIGTERM` and `SIGHUP` on macOS and Linux. One
318
+ stopped halfway is built again at the next start, unless what it left (its files and the bytes
319
+ of its module) is exactly what was last published and recorded. On Windows, only Ctrl-C
320
+ stops the builds.
321
+
322
+ The files are watched through the system's events (FSEvents on macOS, inotify on Linux, folder
323
+ by folder, without the tools' folders): at rest, watching an app of 90 extensions costs about
324
+ 0.2 % of a core. Where a file system has no events, they are polled.
325
+
326
+ ## The look of the page
327
+
328
+ The standard look follows Shopify's one-page checkout, in lsf's own CSS: a header with the name
329
+ of the shop and a cart icon over a rule that runs the page's width; on a screen of 1000px or more
330
+ two columns (the form on white, 500px wide, and the order summary on a grey panel as tall as the
331
+ page, 400px wide, that stays in view while the form scrolls); below that one column with the
332
+ summary folded into a bar under the header that shows the total. Above the contact section, the
333
+ express buttons: the payment methods of the `ACCELERATED_CHECKOUT` placement that the payment
334
+ customizations left (Shop Pay, PayPal, Apple Pay, Google Pay… in their colors), then "OR". A click
335
+ says that lsf does not simulate the wallet. Fields have their label inside
336
+ the box, which floats to the top once there is something to label; payment and shipping methods
337
+ are one bordered box of rows, the chosen row tinted with its accent border; the order summary
338
+ has 64px thumbnails with the quantity hanging off a corner, the discount field with its Apply
339
+ button inside, and the Total row.
340
+
341
+ Everything that sets the look is a CSS custom property in `crates/app/assets/tokens.css`,
342
+ read by the page and by the frame of the extensions, so that an extension looks like the page
343
+ around it. The defaults are those of a store with no branding: system fonts and a 14px base text
344
+ (`--lsf-font`, `--lsf-font-size-base`, `--lsf-font-step`), black on white and a blue accent
345
+ (`--lsf-color-accent`, `--lsf-color-text`, `--lsf-color-bg`, `--lsf-color-border`, ...), a grey
346
+ order summary (`--lsf-summary-*`), corner radii of 8, 12 and 16px (`--lsf-radius-small`, `-base`,
347
+ `-large`, or one `--lsf-radius-global` for all), spacing (`--lsf-space-*`) and control sizes. The
348
+ hover color, the three light tints, the focus ring and the subdued text on a tint are derived
349
+ from the accent, so one color brands the page. The Shop skin sets its own accent, page color and
350
+ radii in the same file.
351
+
352
+ ## The home page
353
+
354
+ `GET /` of `lsf app dev` (`/__lsf/checkouts` with a theme; `?format=json` answers what the
355
+ page says, for tools) has a bar at the top and six tabs. It has the look of the checkout and
356
+ needs no network: it sends no request of the Functions, only the checkout UI extensions loaded to
357
+ check them can call a server (see below).
358
+
359
+ - **The bar**: the app and its store data, how many problems there are (a link to the Problems
360
+ tab), "More" (a checkout with [every extension target](#every-extension-target), the Shop
361
+ look), "Admin" (the app in lsf's admin, when the Admin API runs:
362
+ [preview the embedded app](admin.md#preview-the-embedded-app)), the
363
+ [customer account](#customer-account) and "Open the checkout".
364
+ - **Scenarios**: the groups on the left, the scenarios on the right (see below).
365
+ - **Functions**, **Checkout UI extensions**, **Customer account UI extensions**: a tab for
366
+ each kind of extension of the app.
367
+ - **Customers**: a card for each customer of the store data, with a checkout logged in as them
368
+ and their customer account.
369
+ - **Problems**, errors first, each on one line. The first eight of each kind show, a "Show N
370
+ more" opens the others. The link on the right of a problem opens a fresh checkout where it
371
+ shows, as the Open button of a scenario does, and its name is under the link. Its details also
372
+ link to the rows of this page it is about: a link opens the tab of what it leads to.
373
+ - A Function that fails: "Open the run", the first scenario it fails in, with the [Functions
374
+ dialog](#what-runs) open on that run (`?functions=<handle>`, and `:<target>` when the
375
+ Function has several). The details link to the run in each of the other scenarios.
376
+ - Another problem of a Function: the scenario that runs it (of those that name it in
377
+ `extensions`, the one with the fewest; otherwise the first that runs every extension), with
378
+ the dialog on it, on the target the message names. A Function that has a run there (a legacy target, a missing build, whose run
379
+ fails) opens on that run, "Open the run"; one that never runs (an `api_version` that is not
380
+ supported, an unknown or listed-only target) opens on the Overview, which says it did not
381
+ run, "Open in checkout".
382
+ - A checkout UI extension: the scenario that runs it, chosen the same way, without the dialog, where it shows
383
+ on the page, with what goes wrong. One the page does not load (not built, an API version that
384
+ is not supported, an `owner_type` that is wrong or missing) opens the Overview of the dialog on
385
+ it.
386
+ - An extension that no scenario runs: the checkout of the store's cart, `/checkout`, the same
387
+ way.
388
+ - A scenario that does not validate cannot open: the link goes to its row.
389
+ - Errors: an extension that cannot load or run (no build, an API version that is not
390
+ supported or missing, an unknown Function target, a missing or wrong `owner_type`, what the
391
+ loader refuses), a scenario that does not validate or names an extension the app lacks, an
392
+ error in the store data, and **a Function that fails when a scenario runs**.
393
+ - Warnings: legacy targets, targets the local checkout knows of and does not run, targets the
394
+ page has no place for, and what else the loader says that does not stop anything.
395
+ - **The scenarios** ([scenarios](#scenarios)) are in groups: the part of their titles before
396
+ `: ` ("Discount: buy 2, get 1" is in "Discount") when two scenarios or more share it, the others
397
+ first under "Checkouts". The menu on the left shows them all or one group, with the worst of
398
+ their dots and how many they hold; a row in its group drops what the group says of its title.
399
+ In a group, those with problems come first, then by name. A row has a status dot: the worst
400
+ of its own problems (it does not validate, it names an extension the app does not have) and of the Functions that fail in it, which are errors, and of the problems of the
401
+ extensions it runs, each at its own level (a Function that is not built makes it an error, a
402
+ legacy target a warning). Then the title, its description (else a few words for its cart,
403
+ buyer, country, language and codes), and Open, a fresh checkout for it. Its details say what it
404
+ runs ("every extension of the app" without an `extensions` list), its name and file, the cart,
405
+ the buyer, what was entered, the settings it overrides, the extensions it runs, and a link that
406
+ opens it with every target. The discounts are listed only when the scenario sets its own
407
+ (`checkout.discounts`); otherwise they are "the store's N discounts". A search box (it looks in
408
+ every group) and a filter (all, with problems, Functions, UI) narrow the list. A link to a row
409
+ (`#scenario-…`, from a problem) shows every group and opens the row.
410
+ - **The customers** ([login profiles](#log-in-as-a-customer)): name, email, company, tags,
411
+ orders, saved addresses and country, "Checkout as …", which opens the checkout logged in as
412
+ them (the first scenario's, for its cart, when there is one), and "Account", their customer
413
+ account.
414
+ - **The extensions**: the Functions, the checkout UI extensions and the customer account UI
415
+ extensions, each in its tab. An extension with targets in both the checkout and the customer
416
+ account is in both. A problem of an extension that only renders in
417
+ the customer account opens it ("Open the customer account"). One line each: its status, handle and
418
+ name, Function API or capabilities, API version, how many scenarios run it, its targets, and its
419
+ first problem. Its details list each target (the Function API, a `.fetch` target that reaches
420
+ the network, a legacy target; for a UI extension, the place it has on the page, such as
421
+ "Checkout summary: under each line"), how to build it, and all of its problems.
422
+
423
+ An extension that `--extension` leaves out is listed and marked so. The page follows the locale
424
+ prefix of its URL (`/fr/` links to `/fr/checkout`; `/__lsf/checkouts?locale=fr` does the same).
425
+
426
+ ### The Functions that fail
427
+
428
+ The home page runs the Functions of every scenario that opens, as its checkout would when it
429
+ opens (what `lsf app function run --scenario` does), and lists each Function target that fails, with
430
+ the error and the scenarios it fails in: a trap or a panic, more than 11 million instructions,
431
+ an output that does not fit the schema of its API version, a `.fetch` request out of Shopify's
432
+ limits. A target that fails in several ways is listed once per error, each with its own
433
+ scenarios and logs. The scenario and the Function get an error status.
434
+
435
+ lsf stops a run at 110 million instructions, ten times Shopify's limit: an endless loop fails in
436
+ a moment (`exceeded Shopify's limit of 11,000,000 instructions: stopped at 110,000,000`), and a
437
+ run under that has its exact count.
438
+
439
+ This check sends no HTTP request: opening the home page never calls a server. A `.fetch` target
440
+ runs, and the request it asks for is checked against [Shopify's limits](#network-access) (a
441
+ request out of them fails the target), but it is not sent: the `.run` target reads what it reads
442
+ when the connection fails, `502 Bad Gateway (Connection Failed)`. A Function that only fails on
443
+ some answer of its server is found by opening its scenario, which sends the request.
444
+
445
+ Running them all takes a moment (under a second for the ~90 scenarios of the
446
+ [playground](https://github.com/0xtlt/local-storefront/tree/main/examples/checkout-playground)),
447
+ so the page does not wait for it: it shows "Running the Functions…" and asks
448
+ `GET /__lsf/checkouts/check`, which answers what it found. The check has 20 s: it looks at the
449
+ time before each Function runs (each run is bounded on its own by the instruction limit), and
450
+ once they are spent it stops and says so ("Checked 40 of 86
451
+ scenarios"). A complete check is kept until the store data or the app (a Function, its Wasm
452
+ module) changes: the page then has it at once, and the next check runs on the next request after
453
+ a change. A partial check, or one whose app changed while it ran (a module rebuilt), is not kept.
454
+ A check that fails or takes more than 30 s says so, and the rest of the page stays. Without
455
+ JavaScript, the page links to `?check=1`, which runs the check before it answers.
456
+
457
+ ### The checkout UI extensions that do not load
458
+
459
+ The page also loads the bundle of each checkout UI extension in a hidden frame of the sandbox,
460
+ as the checkout does, without rendering it. Unlike the check of the Functions, this loads the
461
+ real bundles: a bundle that calls the network as it loads does so. A bundle that does not load
462
+ (no file), throws while it loads, or does not register a target it declares (`shopify.extend`),
463
+ is listed with the problems with what it threw, and the scenarios that run it take the level of
464
+ that problem, as for any extension with a problem. A frame that says nothing within 5 s is a
465
+ warning that the page could not tell; one that reported an error and never finished is that
466
+ error. An extension that throws only when it renders or when the buyer acts is not found this
467
+ way.
468
+
469
+ Messages show what is between backticks as code, and the paths inside the app relative to its
470
+ folder (the full path is in the tooltip).
471
+
472
+ ## Scenarios
473
+
474
+ A scenario is a named checkout setup: one file of `checkouts/` in the
475
+ [store data](data-format.md#checkout-scenarios) (`<data>/checkouts/<name>.json`, the data folder
476
+ being `--data`, `LSF_DATA` or `shopify-local/`). It sets what a checkout depends on, on top of
477
+ the store data: the cart (lines, quantities, selling plans, note, attributes), the buyer (a guest,
478
+ a customer, a company location), the country, the language, what the buyer already entered
479
+ (email, address, discount codes, delivery and payment), overrides of the `checkout` section
480
+ (shipping rates, payment methods, discounts, Function settings, tax), the look and the
481
+ extensions that run, and what the checkout must look like once the Functions ran
482
+ ([`expect`](#expected-results), which `app check` checks). The files are reloaded when they
483
+ change, and `validate` checks them: run it in the app folder, no theme needed
484
+ (`npx local-storefront validate`).
485
+
486
+ ```json
487
+ {
488
+ "title": "Three tees in French",
489
+ "session": { "customer": "jane.doe@example.com", "cart": { "items": [{ "variant": "TEE-BLK-M", "quantity": 3 }] } },
490
+ "entered": { "discount_codes": ["WELCOME"] },
491
+ "language": "fr",
492
+ "checkout": { "tax_rate": 20 },
493
+ "variant": "shop"
494
+ }
495
+ ```
496
+
497
+ - `GET /checkout?scenario=<name>` opens it: a **new session** (a new cookie, a new checkout token,
498
+ nothing kept from the checkout before, such as what the extensions stored) with the cart,
499
+ buyer and entries of the scenario, then a redirect to its checkout, under the locale root of its
500
+ language. The browser keeps that session, so the theme (with `serve --app`) shows its cart and
501
+ customer too. A scenario that does not exist answers `404`, and one with errors `422`, with a
502
+ page that says what is wrong.
503
+ - The checkout keeps the settings and the extensions of its scenario for the whole session, an
504
+ order included, and follows the file when it changes. The state has `scenario`
505
+ (`{name, title}`) and the page names it in its header.
506
+ - `lsf app function run --scenario <name>` runs the Functions for a scenario.
507
+ - Only one scenario is open per browser: the session is the cookie's.
508
+
509
+ `npx local-storefront app init --app ../my-app` writes the scenarios, made for the app:
510
+
511
+ | The app has | Scenarios |
512
+ |---|---|
513
+ | a cart transform | `cart-transform`: several lines of more than one unit and one of three, for a merge, an expand or an update |
514
+ | a discount Function | `discount-automatic` (a cart of six, three and two units) and `discount-code` (the same cart with `SAVE10` entered; the code of a discount of the store data is kept) |
515
+ | a delivery customization | `delivery-options`: standard, express, local delivery and store pickup |
516
+ | a payment customization | `payment-methods`: card, PayPal, cash on delivery and bank deposit in the list; PayPal, Shop Pay and Apple Pay as express buttons (PayPal in both placements, as with PayPal Express on) |
517
+ | a validation Function | `validation`: a big cart, for the Function to refuse |
518
+ | a checkout UI extension | `all-extensions`: a customer, several lines (one with a subscription), shipping options, and with a discount Function, a discount of it with the code `SAVE10` entered |
519
+ | (always) | `guest`, `customer` (with a note and an attribute), `b2b` (a company customer at another location), `abroad` (another country and language), `subscription`, `pre-order` (a deposit, the rest later), `shop-look`, `large-cart` |
520
+
521
+ A scenario about one kind of Function runs that Function and the checkout UI extensions, and no
522
+ other Function (`extensions`), so that its effect shows alone; `all-extensions` names every
523
+ extension. A scenario that names a Function runs it whether or not the shop has an owner of it
524
+ (see [what runs](#owners-and-scenarios)): it tries the Function alone. The others
525
+ (`guest`, `b2b`, `abroad`, `large-cart`, …) name none: they show the shop's checkout, where only
526
+ the Functions with an enabled owner run. A Function you add later is not in those lists: add its
527
+ handle to them. The settings of your Functions are the store data's: the scenarios leave
528
+ `checkout.functions` alone, and the `discount-*` scenarios list the discount Functions again
529
+ with the settings (and the code) `checkout.discounts` gives them. The scenarios are a place to
530
+ start: edit them.
531
+
532
+ **A discount Function runs in `discount-automatic`, `discount-code` and `all-extensions` only.** It runs only for a
533
+ discount that names it ([set up the checkout](#set-up-the-checkout)), and the other scenarios
534
+ (`b2b`, `abroad`, `large-cart`, …) have none: `app check` passes on them without running it. It
535
+ warns only about a Function no scenario ran at all (`warning <handle>: ran in no scenario, so
536
+ nothing checks it`, `1 Function ran in no scenario` in the summary, and `ranInNoScenario` in
537
+ JSON), and its exit code stays the same. To run it in
538
+ every scenario, a B2B buyer, another country and a large cart included, give it a discount in the
539
+ `checkout` section of the store data (`store.json`, or the file that has the section):
540
+
541
+ ```json
542
+ {
543
+ "checkout": {
544
+ "discounts": [
545
+ {
546
+ "function": "my-discount",
547
+ "title": "My discount",
548
+ "metafields": { "$app.function-configuration": { "type": "json", "value": { "percentage": 10 } } }
549
+ }
550
+ ]
551
+ }
552
+ }
553
+ ```
554
+
555
+ The `discount-*` scenarios keep their own `checkout.discounts`.
556
+
557
+ `app init` creates the data folder with the demo store when it does not exist (and a
558
+ selling plan with a deposit for `pre-order`). When it exists, it uses the products, customers,
559
+ countries, languages and selling plans it has, and says which scenarios it skipped and why. It
560
+ never overwrites a file without `--force`, and refreshes `schema/` so that editors complete the
561
+ scenarios (`"$schema": "../schema/checkout-scenario.schema.json"`).
562
+
563
+ A Function that reads its settings as the variables of its input query
564
+ (`[extensions.input.variables]`) cannot run without them. `app init` gives each one the
565
+ settings the store data lacks: those of the app's own `shopify-local/` when it has them, or empty
566
+ values of the type of each variable (`[]`, `""`, `0`, `false`) to fill in. Those of a discount
567
+ Function go in the `metafields` of its discounts in the scenarios; the others in
568
+ `checkout.functions` of `app-settings.json`. When another data file has the `checkout` section,
569
+ it prints what to add to it instead.
570
+
571
+ ### Expected results
572
+
573
+ `expect` says what the checkout of a scenario must look like once its Functions ran, and
574
+ `npx local-storefront app check` fails when it does not. An app that hides PayPal from the express
575
+ buttons, and only from them, asserts that PayPal is gone from the buttons and still in the list:
576
+
577
+ ```json
578
+ {
579
+ "title": "PayPal Express hidden, PayPal kept",
580
+ "session": { "cart": { "items": [{ "variant": "TEE-BLK-M", "quantity": 3 }] } },
581
+ "checkout": {
582
+ "payment_methods": [
583
+ { "handle": "card", "name": "Credit card" },
584
+ { "handle": "paypal", "name": "PayPal", "placements": ["PAYMENT_METHOD", "ACCELERATED_CHECKOUT"] },
585
+ { "handle": "shop_pay", "name": "Shop Pay", "placements": ["ACCELERATED_CHECKOUT"] }
586
+ ]
587
+ },
588
+ "extensions": ["payment-rules"],
589
+ "expect": {
590
+ "express_methods": { "excludes": ["paypal"], "equals": ["shop_pay"] },
591
+ "payment_methods": { "includes": ["paypal"] },
592
+ "errors": { "equals": [] },
593
+ "totals": { "total": "96.00" }
594
+ }
595
+ }
596
+ ```
597
+
598
+ Each list takes `includes` (each of these matches an entry of the list, in any order),
599
+ `excludes` (none of these matches one) and `equals` (the whole list, in order; `[]` is an empty
600
+ list, so `"errors": { "equals": [] }` says there is no error):
601
+
602
+ | List | What it is | A string matches | An object can give |
603
+ |---|---|---|---|
604
+ | `payment_methods` | The payment methods of the list (`PAYMENT_METHOD`), in order, once the payment customizations ran | the handle or the name | `handle`, `name` |
605
+ | `express_methods` | The express buttons (`ACCELERATED_CHECKOUT`), in order | the handle or the name | `handle`, `name` |
606
+ | `delivery_options` | The delivery options, in the order the buyer sees them, once the delivery customizations and the shipping discounts ran | the handle or the title | `handle`, `title`, `cost` |
607
+ | `discounts` | The discounts applied: product, order and shipping ones | the title or the code | `title`, `code`, `class` (`PRODUCT`, `ORDER`, `SHIPPING`), `amount` |
608
+ | `lines` | The lines, once the cart transforms ran | the SKU or the title | `sku`, `title`, `quantity`, `price` (of one unit), `total` (after its discounts), `components`, `attributes` |
609
+ | `errors` | The errors that block the order: those validation Functions report, and those of Functions that failed | the message | `message`, `target` |
610
+
611
+ A string names an entry, so a renamed method is matched by its new name (`"Card (secure)"`) or by
612
+ its handle. An object gives the fields the entry must have, and leaves the others free:
613
+ `{ "handle": "standard", "cost": "0.00" }` is the option `standard`, free, whatever its title.
614
+ Amounts are written as in the store data: cents (`490`) or a decimal string (`"4.90"`). A line's
615
+ `components` are the lines a cart transform merged into it or split it into, in order, each by
616
+ its SKU or title (`[]`: none); its `attributes`, those it must have, by key. `totals` takes
617
+ amounts, each checked exactly: `subtotal`, `discounts` (product and order ones), `shipping`
618
+ (after the shipping discounts), `tax`, `total`, `due_now` and `due_later`.
619
+
620
+ The Functions run for `expect` as the order is placed (`buyerJourney.step` is
621
+ `CHECKOUT_COMPLETION`), as `app function run` runs them. `"step": "interaction"` checks the page
622
+ as the buyer sees it before: a validation Function that waits for "Pay now" reports nothing then.
623
+ Only a validation Function tells the two apart.
624
+
625
+ `app check` prints each expectation that is not met: the scenario and the assertion, what the
626
+ scenario expects, and what the checkout has. With a Function that hides nothing:
627
+
628
+ ```text
629
+ expect express-buttons: express_methods.excludes
630
+ expected: no entry matching "paypal"
631
+ actual: [{"handle": "paypal", "name": "PayPal"}, {"handle": "shop_pay", "name": "Shop Pay"}]
632
+ expect express-buttons: express_methods.equals
633
+ expected: ["shop_pay"]
634
+ actual: [{"handle": "paypal", "name": "PayPal"}, {"handle": "shop_pay", "name": "Shop Pay"}]
635
+ checked 1 of 1 scenario in 0.0 s: 0 failures, 0 invalid scenarios, 2 of 5 expectations unmet
636
+ ```
637
+
638
+ `actual` shows each entry with the fields the expectation names, amounts as decimal strings, so
639
+ that it can be pasted into the scenario. Each entry of `includes` and `excludes`, each `equals`
640
+ and each amount is one expectation: when all are met, the summary says `5 expectations met`.
641
+ `--format json` has them in `expectations` ([`app check`](cli.md#app-check)), where `expected`
642
+ is the entry, the list or the amount as the scenario writes it:
643
+
644
+ ```json
645
+ "expectations": {
646
+ "checked": 5,
647
+ "unchecked": [],
648
+ "unmet": [
649
+ {
650
+ "scenario": "express-buttons",
651
+ "file": "checkouts/express-buttons.json",
652
+ "step": "completion",
653
+ "assertion": "express_methods.excludes",
654
+ "expected": "paypal",
655
+ "actual": [{ "handle": "paypal", "name": "PayPal" }, { "handle": "shop_pay", "name": "Shop Pay" }]
656
+ }
657
+ ]
658
+ }
659
+ ```
660
+
661
+ A scenario without `expect` is checked as before: its Functions must run without an error. With
662
+ `--extension`, the `expect` of a scenario that runs a Function left out is not checked (its
663
+ checkout is another one). The scenario still runs, its expectations are not counted in
664
+ `checked`, `expectations.unchecked` names it, and the exit code does not change. The summary
665
+ says so: ``the `expect` of 1 scenario not checked (--extension leaves out a Function it runs)``.
666
+
667
+ A name in `excludes` that the checkout does not offer, such as `"paypall"`, is never in the list,
668
+ so the expectation would always pass: `validate` warns about it (`expect_checks_nothing`), with
669
+ the closest handle. The scenario still opens.
670
+
671
+ ## Log in as a customer
672
+
673
+ The customers of the [store data](data-format.md#customers-and-sessions) are the login profiles
674
+ of the checkout. "Log in", at the right of the Contact heading, opens a menu of them: name,
675
+ email, and what tells them apart (B2B company and its locations, tags, orders, saved addresses,
676
+ country). Pick one and the checkout is logged in as them; "Continue as a guest" logs it out. The
677
+ menu is a menu button: the arrows, Home and End move between the customers, Escape closes it and
678
+ gives the focus back to the button, Tab and a click elsewhere close it.
679
+
680
+ Logged in, the checkout does what Shopify's does for a customer who has an account:
681
+
682
+ - **Contact** shows the account (name and email) instead of the email field, with "Switch" (the
683
+ same menu) and "Log out". The email and the phone are the customer's. A contact email that
684
+ is not the account's (a scenario's `entered.email`, or one typed later) is the one the
685
+ Functions, the extensions and the order get, so Contact shows it under the account, as "Email
686
+ for this order", where it can be corrected, with "Use &lt;account email&gt;" to go back to the
687
+ account's.
688
+ - **Delivery** starts with the customer's default address (the first of their `addresses`) and
689
+ offers the others in a "Saved addresses" select, with "Use a new address" for one they type.
690
+ The fields below hold the chosen address and can be edited.
691
+ - **A B2B customer** shows their company and chooses the location they buy for, among the ones
692
+ they have access to; the order ships to the address of that location ("Ship to the location").
693
+ - Everything that reads the buyer follows: the Functions (`buyerIdentity.customer` with `id`,
694
+ `email`, `displayName`, `hasTags`, `hasAnyTag`, `metafield`, `numberOfOrders`, `amountSpent`;
695
+ `buyerIdentity.purchasingCompany`; `isAuthenticated`), the checkout UI extensions
696
+ (`shopify.buyerIdentity.customer`, `email`, `phone`, `purchasingCompany`, whose subscribers are
697
+ called; the `sub` of the session token), the shipping rates (an address means rates), the
698
+ discounts and the payment methods the Functions decide on.
699
+
700
+ A login changes the session, the way `session.customer` of the data, a scenario or
701
+ `PUT /__lsf/session` set it: the theme (with `serve --app`) shows the same customer. The cart,
702
+ the checkout token, the choices of delivery and payment, the codes and the scenario the checkout
703
+ opened from stay. What belonged to the buyer before goes: the email and the phone they typed,
704
+ and an address the customer before had saved (an address typed as a guest stays for a customer
705
+ who has none). The checkout follows a login made anywhere else too (`/__lsf/login`, the control
706
+ API): the next time it is read, it is the new customer's.
707
+
708
+ - `GET /checkout?customer=<email>` opens the checkout logged in as a customer (`default` for the
709
+ first one, `none` for a guest); `/checkout?scenario=<name>&customer=<email>` opens a scenario
710
+ as someone else. Someone the data does not have answers `404` with the list of customers.
711
+ - A login from a URL is the login of the menu: `?scenario=<name>&customer=<email>` gives the same
712
+ checkout as opening `?scenario=<name>` and picking the customer in the menu. The scenario is
713
+ set up first (its `entered` included), then the login applies what is written above: the
714
+ contact (email and phone) becomes the customer's, the address their default one, and the
715
+ scenario's address stays only for a customer without one. When `customer` is the one the
716
+ scenario already logs in as, nothing changes, and everything the scenario entered stays.
717
+ - The page sends the [operations](#operations) `customer` and `companyLocation`.
718
+ - The state has `account`: `profiles` (the customers to log in as), `addresses` (the saved
719
+ addresses of the customer logged in, the default one first, each with an `id` and the
720
+ `address`; for a B2B customer, the one of their location) and `locations` (the locations a B2B
721
+ customer can buy for, with `current`). Extensions do not see it.
722
+
723
+ The Shop skin has the same menu, account and select.
724
+
725
+ The demo store (`lsf init`, `lsf app init`, and the built-in data) has a profile of each
726
+ kind:
727
+
728
+ | Customer | What for |
729
+ |---|---|
730
+ | `jane.doe@example.com` | A returning customer: two saved addresses, two orders, the tag `vip`, a `custom.loyalty_tier` metafield |
731
+ | `new.customer@example.com` | A new customer: no order, no address |
732
+ | `alex.morgan@example.com` | A B2B buyer of Northwind Hotels, with two locations (Portland, Seattle) |
733
+ | `camille.martin@example.com` | A customer in France: an address in Lyon, one order |
734
+
735
+ Not done: logging in with a password or a one-time code (any customer of the data, at once),
736
+ Shop Pay, store credit, the customer's language (the checkout is in the language of its URL),
737
+ and the B2B settings that depend on the location in Shopify (catalogs, price lists, payment
738
+ terms, deposits, and whether a buyer may ship to another address): a B2B buyer pays what the
739
+ store data says and may type an address.
740
+
741
+ ### Where each target renders
742
+
743
+ Every target of Shopify's checkout UI extensions has a place (`assets/api.json`), besides those
744
+ of the [customer account](#customer-account):
745
+
746
+ - **Pickup**: the shipping rates of kind `pick_up` are a "Pick up" list of their own in each
747
+ delivery group, between `pickup-location-list.render-before` and `-after`, with
748
+ `pickup-location-option-item.render-after` under each. The pickup points come from Shopify's
749
+ pickup point Functions, which do not run here: with an extension at `pickup-point-list.render-*`,
750
+ the page shows the list with no point in it.
751
+ - **Payment apps**: under the selected method, `payment-option-item.details.render`, and under the
752
+ card fields of the credit card, `payment-option-item.hosted-fields.render-after`.
753
+ `payment-option-item.action-required.render` is a dialog over the page that opens when the buyer
754
+ selects a method ("Complete the payment form" opens it again). `shopify.target` is the payment
755
+ option; there are no payment method attributes.
756
+ - **Gift cards**: with an extension at `gift-card.render`, the payment step has "Pay with a gift
757
+ card"; ticking it shows the extension. `applyGiftCardChange` and `applyRedeemableChange` are
758
+ refused.
759
+ - **Bundles**: a line a cart transform expanded lists its products in the summary, or
760
+ `purchase.cart-line-item.line-components.render` renders in their place, on the checkout and the
761
+ thank-you page.
762
+ - **Chat**: `purchase.checkout.chat.render` and `purchase.thank-you.chat.render` sit over the page
763
+ in the bottom right corner.
764
+ - **Address autocomplete**: `purchase.address-autocomplete.suggest` renders nothing. As the buyer
765
+ types the address field, the page runs it (in a hidden frame) with `shopify.target` (`value`,
766
+ `field`, `selectedCountryCode`) and shows the suggestions it returns under the field. Picking one
767
+ fills the address from its `formattedAddress`, or from what
768
+ `purchase.address-autocomplete.format-suggestion` returns for it. A new request aborts the one
769
+ before (`shopify.signal`).
770
+
771
+ ### Every extension target
772
+
773
+ `/checkout?targets=all` (or `&targets=all` after `?scenario=`) opens a checkout where each
774
+ extension renders at every target it declares: the steps of a cart that ships are shown whatever
775
+ the cart, and the thank-you page is shown under the checkout with the same cart (no order is
776
+ placed), so the thank-you targets are there too. A panel at the top lists every target of every
777
+ extension: shown, without a place on the page, waiting for something the cart lacks (shipping
778
+ methods need an address, a line target needs a line, a pickup target a rate of kind `pick_up`),
779
+ run as the buyer types (the address autocomplete), or in the customer account. The
780
+ `all-extensions` and `ui-every-target` scenarios are made for it.
781
+
782
+ ## Customer account
783
+
784
+ With an app, `/account` is the customer account where its customer account UI extensions render:
785
+ the pages of Shopify's new customer accounts, filled with the customers and orders of the store
786
+ data. The home page links to it ("Customer account").
787
+
788
+ | Path | Page | Targets |
789
+ |---|---|---|
790
+ | `/account` | The orders of the customer logged in | `order-index.announcement.render`, `order-index.block.render`, `order.action.menu-item.render` on each order |
791
+ | `/account/orders/<id>` | The status of an order: its shipments, what is not shipped, its returns, the details, the payment and the summary | the 9 `order-status.*` targets, `order.action.menu-item.render` |
792
+ | `/account/profile` | Name, contact, addresses, payment methods; for a B2B customer, the company and each location it buys for (addresses, payment, staff) | the 8 `profile.*` targets |
793
+ | `/account/pages/<handle>/…` | The full page of an extension | `customer-account.page.render` |
794
+ | `/account/orders/<id>/pages/<handle>/…` | The full page of an extension for an order | `customer-account.order.page.render` |
795
+
796
+ `customer-account.footer.render-after` is at the bottom of every page. Pressing the button of an
797
+ extension's `order.action.menu-item.render` opens its `order.action.render` in a dialog, which
798
+ `shopify.close()` closes; a menu item with an `href` leads there instead.
799
+
800
+ - **Logged out**, the page lists the customers of the store data to log in as
801
+ ([log in as a customer](#log-in-as-a-customer)); the avatar in the header switches customer or
802
+ logs out.
803
+ - **The orders** are those of the customer in the store data (`orders` of a customer), the most
804
+ recent first, and the last one the session placed in the local checkout, logged in as them.
805
+ The orders of a session are numbered after the last one of the store data (#1003 when its last
806
+ is #1002), one after the other, whatever the other sessions do, and placed at the frozen clock
807
+ of the data (`now`) if it has one: a test reads the same number and date every time. They live
808
+ as long as the session (`DELETE /__lsf/sessions` forgets every one). An order's shipments come
809
+ from its `fulfillment_status` (`fulfilled`: one shipment of every line; `partial`: its first
810
+ line), its returns from its `returns` ([data reference](data-reference.md#orderreturn)).
811
+ - **The `shopify` global** of a customer account target has what every target has (`i18n`,
812
+ `storage`, `sessionToken`, `settings`, `localization`, `extension`, `analytics`), and:
813
+ `authenticatedAccount` (`customer`, `purchasingCompany`); `navigation`; `toast.show`, which shows
814
+ a toast at the bottom of the page; `intents.invoke`, which answers `closed`. The order status
815
+ targets and the full page of an order have the Order Status API (`order`, `lines`, `cost`,
816
+ `shippingAddress`, `billingAddress`, `buyerIdentity`, `discountCodes`, `authenticationState`,
817
+ …) and `appMetafields`, with the order's own metafields (`owner_type = "ORDER"`, see
818
+ `shopify.appMetafields` below), the line, shipment, return, location and order targets their
819
+ `target`, `fulfillmentId`, `returnId`, `locationId` and `orderId`.
820
+ - **Navigation**: `shopify:customer-account/orders`, `…/orders/<id>` and `…/profile` lead to the
821
+ pages; `extension://<handle>` (or `extension:<handle>/<path>`) to the full page of another
822
+ extension; `extension://<route>` and `/<route>`, on a full page, to a route of its own. The page
823
+ loads again: `navigation.currentEntry.url` is `extension:<handle>/<route>`.
824
+ - **Components**: the ones of checkout, and those only customer accounts have: `s-avatar`,
825
+ `s-button-group`, `s-customer-account-action`, `s-image-group`, `s-menu`, `s-page`.
826
+ - `?targets=all` adds a panel that lists the customer account targets of each extension, and where
827
+ each one renders.
828
+ - **An uninstalled app** (`POST /__lsf/admin/uninstall`) renders no customer account extension: a
829
+ banner and the panel say "the app is not installed on the shop", and a full page answers `404`
830
+ with that reason. They render again once it is installed ([uninstall](admin.md#tokens)).
831
+
832
+ ## Set up the checkout
833
+
834
+ The `checkout` section of the [store data](data-format.md#checkout) sets what Shopify holds
835
+ outside the app: shipping rates, payment methods, the discounts that use your Functions, the
836
+ settings of your other Functions, and a tax rate.
837
+
838
+ ```json
839
+ {
840
+ "checkout": {
841
+ "shipping_rates": [
842
+ { "handle": "standard", "title": "Standard", "price": "4.90" },
843
+ { "handle": "express", "title": "Express", "price": "12.90" }
844
+ ],
845
+ "payment_methods": [
846
+ { "handle": "card", "name": "Credit card" },
847
+ { "handle": "cod", "name": "Cash on Delivery" }
848
+ ],
849
+ "discounts": [
850
+ {
851
+ "function": "volume-discount",
852
+ "title": "Volume",
853
+ "code": null,
854
+ "classes": ["PRODUCT"],
855
+ "metafields": {
856
+ "$app.function-configuration": { "type": "json", "value": { "minimumQuantity": 3, "percentage": 10 } }
857
+ }
858
+ }
859
+ ],
860
+ "functions": {
861
+ "delivery-rules": {
862
+ "metafields": { "$app.function-configuration": { "type": "json", "value": { "hide": ["express"] } } }
863
+ }
864
+ },
865
+ "tax_rate": 20
866
+ }
867
+ }
868
+ ```
869
+
870
+ - A **discount** is a discount Function with its title, its metafields and, if it has one, the
871
+ code that triggers it. Without a code it is automatic. `classes` lists what it may reduce:
872
+ `PRODUCT`, `ORDER`, `SHIPPING`. All three when omitted.
873
+ - **A discount Function runs only for a discount that names it**, as on Shopify, where the
874
+ merchant creates the discount: one that no discount of the store data (nor of a scenario)
875
+ names never runs, and the home page and `lsf app function run` say so. The demo store of
876
+ `lsf init` has none: add yours here, in a scenario, or for one test in the `data.checkout` of
877
+ `PUT /__lsf/session` (see [testing](testing.md#a-checkout-in-a-test)).
878
+ - A Function that is not a discount (cart transform, delivery or payment customization,
879
+ validation) has its metafields under `functions`, by its handle, and `"enabled"` when the shop
880
+ has an owner of it: see [what runs](#owners-and-scenarios).
881
+ - A metafield is written `"namespace.key"`, with a [typed value](data-format.md#metafields-and-metaobjects):
882
+ the namespace and the key the input query reads, joined by a dot. Shopify's templates read
883
+ `metafield(namespace: "$app", key: "function-configuration")`, and name it in
884
+ `[extensions.input.variables]` (`namespace = "$app"`, `key = "function-configuration"`):
885
+ that is `"$app.function-configuration": {"type": "json", "value": {…}}`. A namespace
886
+ `$app:<name>` keeps its colon: `"$app:config.settings"` is the key `settings` of
887
+ `$app:config`. Shopify's list of `{namespace, key, type, value}`, as `metafieldsSet` takes
888
+ it, is read too:
889
+ `"metafields": [{"namespace": "$app", "key": "function-configuration", "type": "json", "value": "{\"percentage\": 10}"}]`.
890
+ - The **settings of a UI extension** are what the merchant fills in in the checkout editor:
891
+ `checkout.extensions.<handle>.settings`, by the `key` of each field of its
892
+ `[extensions.settings]`, read as `shopify.settings` (`useSettings`). A scenario's replace the
893
+ store data's for the extensions it names. A field without a value is left out, as on Shopify.
894
+ `validate` and the home page warn about a key the extension does not declare, a value not of
895
+ its field's type (`number_integer`: a whole number, `boolean`, text and dates: a string) or
896
+ not among its `choices`:
897
+ `{"checkout": {"extensions": {"gift-banner": {"settings": {"title": "Free gift wrap", "max_items": 3}}}}}`.
898
+ - A **payment method** is in the list of the page by default (`PAYMENT_METHOD`). Its
899
+ `placements` can put it among the express buttons above the contact section
900
+ (`["ACCELERATED_CHECKOUT"]`), or in both, as PayPal with PayPal Express on
901
+ (`["PAYMENT_METHOD", "ACCELERATED_CHECKOUT"]`).
902
+ - `tax_rate` is a percentage added to the discounted subtotal.
903
+ - A line bought with a [selling plan](data-format.md#selling-plans) that defers a part of the
904
+ price is [paid in two times](#deferred-payments).
905
+
906
+ Without the section: one shipping rate, "Standard" at 5.00, the payment methods "Credit card",
907
+ "Cash on Delivery" and "Bank Deposit", and no tax.
908
+
909
+ The rest of what a Function reads comes from the other sections of the store data:
910
+
911
+ - `buyerIdentity.purchasingCompany` (company, location and `contact`, with their dates, locale
912
+ and, for a location, `ordersCount` and `totalSpent`) from the
913
+ [companies and the customers](data-format.md#b2b-customers).
914
+ - `localization.market` and `MailingAddress.market` from
915
+ [`localization.markets`](data-format.md#localization): a country in no market is a market of
916
+ its own.
917
+ - `shop.metaobject(handle: {type:, handle:})` from the
918
+ [metaobjects](data-format.md#metafields-and-metaobjects).
919
+ - `paymentMethods { placements }` from `payment_methods[].placements` (the list of the checkout
920
+ by default). A payment customization that hides a method for one placement only keeps the
921
+ other, and a method left in no placement is removed.
922
+ - `cart.deliverableLines` is the lines that ship: the ones the delivery group holds.
923
+
924
+ ## Deferred payments
925
+
926
+ A selling plan whose `checkout_charge` is less than the price (a pre-order with a deposit, `0`
927
+ for try before you buy) is paid in part at checkout, and the rest later. The storefront cart
928
+ reports the part paid at checkout as `cart.checkout_charge_amount`; the checkout does the same
929
+ and splits what the buyer owes:
930
+
931
+ ```text
932
+ Subtotal · 3 items 96.00
933
+ Shipping 4.90
934
+ Total 100.90
935
+ Due today 38.50
936
+ Due later 62.40
937
+ ```
938
+
939
+ - `total` is what the order costs in all, as without a plan. *Due today* (`cost.dueNow`) is
940
+ what the buyer pays, and *due later* (`cost.dueLater`) what the plan defers. The page, the
941
+ Pay button of the Shop skin and the payment line of the thank-you page charge `dueNow`.
942
+ - A discount comes off the balance first: it only reduces the deposit once the balance is gone.
943
+ A line with a balance has `dueLater` in the state, and what it owes today is its `total` less
944
+ that.
945
+ - Shipping is due today. Tax follows the amount it is on: the tax of the balance is due later.
946
+ - `cost.checkoutChargeAmount` is the subtotal less the balances, before discounts, shipping and
947
+ tax: the `cart.checkout_charge_amount` of the cart, for the same lines.
948
+ - The order keeps `order.totalReceived` (what was due today) and `order.totalOutstanding` (what
949
+ is due later), named as the `totalReceivedSet` and `totalOutstandingSet` of Shopify's `Order`.
950
+ The thank-you page says the remaining balance is due later. Nothing is charged later.
951
+ - A cart transform cannot change a line with a selling plan, as on Shopify.
952
+
953
+ Shopify documents none of this split in the places an app can read. The Cost API of checkout UI
954
+ extensions (2026-10 and 2027-01) has the subtotal, shipping, tax and total (`totalAmount`, the
955
+ whole order) and no due-today amount; a line's `merchandise.sellingPlan` has `id` and
956
+ `recurringDeliveries`; the input of a Function has the selling plan (`id`, `name`,
957
+ `description`, `recurringDeliveries`) and its price adjustments, no checkout charge or balance.
958
+ The `shopify` global and the Function input are the same as without a plan, and an extension
959
+ reads what is due today from `cost.dueNow` of the page state only, not from the API. What
960
+ Shopify's help center says is that the deposit due at checkout shows in the order summary, and
961
+ that the remaining payment is collected later. How discounts, shipping and tax split between
962
+ today and later, and the labels, are not documented: those above are this checkout's choices.
963
+
964
+ ## What runs
965
+
966
+ Every time the page changes something, the Functions run again, in the order Shopify runs
967
+ them:
968
+
969
+ 1. cart transform (`cart.transform.run`)
970
+ 2. product and order discounts (`cart.lines.discounts.generate.run`, and the older
971
+ `purchase.product-discount.run` and `purchase.order-discount.run`)
972
+ 3. the delivery groups, from `shipping_rates`
973
+ 4. delivery customization (`cart.delivery-options.transform.run`, `purchase.delivery-customization.run`)
974
+ 5. shipping discounts (`cart.delivery-options.discounts.generate.run`, `purchase.shipping-discount.run`)
975
+ 6. payment customization (`cart.payment-methods.transform.run`, `purchase.payment-customization.run`)
976
+ 7. validation (`cart.validations.generate.run`, `purchase.validation.run`)
977
+
978
+ Not every Function of the app runs: see [owners and scenarios](#owners-and-scenarios).
979
+
980
+ A `linesMerge` of a cart transform takes `quantity` units of each cart line it names (no more than
981
+ the line holds). The merged line is as many bundles as the greatest common divisor of those
982
+ quantities, and each component is in a bundle as many times as its quantity says divided by that
983
+ number: four tees and six totes are two bundles of two tees and three totes. The price of a bundle
984
+ is the sum of the prices of its components, `percentageDecrease` applies to it, and the line costs
985
+ the price of a bundle times its quantity. The discounts, the validation and the `shopify` global
986
+ see that quantity (`lineComponents[].quantity` is the number of units of the component in the whole
987
+ line, as for `lineExpand`). The units of a cart line that the merge does not take stay in a line of
988
+ their own. Changing the quantity of a merged line in the checkout scales the lines it is made of,
989
+ by bundles. Shopify's documentation gives the examples (every merged quantity is the smallest of
990
+ the lines) and the units that stay as "a separate item", but does not say how the quantity of the
991
+ merged line follows from the quantities: the greatest common divisor is the one rule that keeps
992
+ every merged unit in a whole number of identical bundles.
993
+
994
+ Several delivery customizations, and several payment customizations, run one after the other in the
995
+ order of their folders in `extensions/` (by name), and each sees what the ones before it did: a
996
+ method that one hides is not in the input of the next, and a method one renames comes to the next
997
+ with its new name; when two rename the same method, the last one wins. Shopify's documentation does
998
+ not say how several of them combine (the Delivery Customization and Payment Customization Function
999
+ API pages only say that a store can activate 25 of each, and the cart transform page is the only
1000
+ one that says how the operations of several Functions are merged), so lsf keeps this chain. A store
1001
+ with one customization of each kind, the common case, sees no difference.
1002
+
1003
+ Each Function sees the checkout as the steps before it left it, as on Shopify. A Function that
1004
+ asks for `cart.discountApplications`, `CartLine.discountAllocations` or
1005
+ `CartDeliveryGroup.discountAllocations` reads the discounts applied so far: the cart transform
1006
+ sees none, the delivery customization the product and order discounts, the payment
1007
+ customization and the validation all of them, shipping included. The discount Functions are the
1008
+ exception: Shopify runs them concurrently, with no knowledge of one another, so none of them
1009
+ (`cart.lines.discounts.generate.run`, `cart.delivery-options.discounts.generate.run` and the
1010
+ legacy ones) sees a discount, whether it comes from another discount or an earlier stage. Their
1011
+ answers are applied once all of them have run: product discounts first, then order discounts on
1012
+ what is left of the lines, then shipping, so the order of the discounts in the configuration
1013
+ changes nothing (see [how discounts stack](#how-discounts-stack)). An application has the value of the
1014
+ discount (`PricingPercentageValue` or `MoneyV2`), its allocation method (`EACH` for a percentage,
1015
+ `ACROSS` for a fixed amount shared between the lines), what it targets (`EXPLICIT` for the lines
1016
+ the Function named, `ALL` for the whole order, `ENTITLED` when an order discount leaves lines
1017
+ out) and what it took off in total; its `metafield` is one of the discount's.
1018
+
1019
+ ### Owners and scenarios
1020
+
1021
+ On Shopify, a Function runs only through an object of the shop that runs it, its **owner**, which
1022
+ the app creates once installed: a payment customization (`paymentCustomizationCreate`), a
1023
+ delivery customization, a cart and checkout validation (`validationCreate`), a cart transform
1024
+ (`cartTransformCreate`); a discount Function, through a discount. A shop the app was just
1025
+ installed on has none, and its checkout runs none of the app's Functions until the app sets
1026
+ them up. lsf follows that rule at the shop's checkout (`/checkout` from the storefront's cart,
1027
+ and a scenario that names no `extensions`):
1028
+
1029
+ | Function | Runs when |
1030
+ |---|---|
1031
+ | payment customization | the shop has an enabled `PaymentCustomization` of it |
1032
+ | delivery customization | the shop has an enabled `DeliveryCustomization` of it |
1033
+ | cart and checkout validation | the shop has an enabled `Validation` of it (`validationCreate` takes `enable`, `false` by default) |
1034
+ | cart transform | the shop has a `CartTransform` of it (it has no `enabled`: deleting it stops it) |
1035
+ | discount | a discount names it that is active by the shop's clock (`startsAt` ≤ now < `endsAt`), automatic or with a code the buyer entered |
1036
+ | fulfillment constraints, order routing, pickup generators | never: the local checkout lists them and does not run them |
1037
+
1038
+ The owners come from two places:
1039
+
1040
+ - **The app**, through the [Admin API](admin.md): what it creates, updates (`enabled`), deletes
1041
+ or turns on and off (`paymentCustomizationActivation`) is what the next checkout runs, with the
1042
+ metafields it gives them.
1043
+ - **The store data**, for a shop already set up: `"enabled": true` on the Function's entry of
1044
+ `checkout.functions` (`false`: an owner that is turned off). An entry without `enabled` is
1045
+ settings alone: the shop has no owner of the Function.
1046
+
1047
+ ```json
1048
+ { "checkout": { "functions": { "payment-rules": { "enabled": true, "metafields": { "$app.function-configuration": { "type": "json", "value": { "hide": ["paypal"] } } } } } } }
1049
+ ```
1050
+
1051
+ lsf creates no owner at install: `paymentCustomizations` answers none, as Shopify does, and an
1052
+ app that checks whether it is set up sees that it is not.
1053
+
1054
+ **Uninstalled, the app runs nothing.** Shopify removes an uninstalled app's Functions, and a
1055
+ Function goes with its owners: `POST /__lsf/admin/uninstall` (or `appUninstall`) deletes every
1056
+ owner of the app's Functions, those of the store data and the app discounts included. While the
1057
+ app is not installed, the shop's checkout runs none of its Functions, whatever a scenario
1058
+ declares or names in its `extensions`, and lists each as not run because "the app is not
1059
+ installed on the shop". Installing it again brings no owner back: the app creates them, as after
1060
+ a first install ([uninstall](admin.md#tokens)). Nor does the checkout load any of the app's UI
1061
+ extensions while it is not installed, the thank-you page of an order placed before included: a
1062
+ banner, the Functions dialog ("Extensions not loaded") and `?targets=all` say why, and the state
1063
+ has `"extensions": []` with `"uninstalled": {"reason", "extensions"}`. They render again once
1064
+ the app is installed again.
1065
+
1066
+ **Trying a Function alone** does not need an owner. A [scenario](#scenarios) that names it in
1067
+ `extensions` runs it, and so does `lsf app function run <handle>`, with the metafields of
1068
+ `checkout.functions` (the scenario's, or the store data's). That is how the scenarios of
1069
+ `app init` try each Function; the shop's checkout shows what the app has set up. `--extension`
1070
+ of `app dev` only narrows the extensions: the shop's owners still decide which Functions run.
1071
+
1072
+ A Function the shop's checkout leaves out is said: the home page warns that the shop has no
1073
+ enabled owner of it when no scenario runs it either (the scenarios of `app init` name each
1074
+ Function, so it says nothing after them), the "Functions" dialog of the checkout lists it under "Functions not run",
1075
+ with why, and so does `lsf app function run` (`Not run: …`; `idle` in JSON).
1076
+
1077
+ A scenario added or edited while lsf runs is read again without seeding the shops of the Admin
1078
+ API: the owners the app created stay ([when the data changes on disk](cli.md#when-the-data-changes-on-disk)).
1079
+
1080
+ ### How discounts stack
1081
+
1082
+ Discounts apply class after class, as on Shopify: product discounts first, then order
1083
+ discounts on the subtotal they left, then shipping. Within a class:
1084
+
1085
+ - **Order discounts.** Every percentage is a share of the subtotal after the product discounts,
1086
+ not of what the order discounts before it left: 10% and 20% off 100.00 take 10.00 and 20.00,
1087
+ 70.00 is left. The fixed amounts come off afterwards, from what is left, and never take the
1088
+ order below zero (a percentage and a $20 discount on 350.00: 10% leaves 315.00, then 295.00).
1089
+ - **Product discounts on one line.** The percentages come off before the fixed amounts. A
1090
+ percentage is a share of the price of the line before any discount, so 10% and 20% off a line
1091
+ of 100.00 take 10.00 and 20.00. A fixed amount never takes more than the line has left.
1092
+ - **A fixed amount across several lines** (`appliesToEachItem: false`) is taken once: it is
1093
+ shared between the lines in proportion to their prices, and what a line cannot take (a
1094
+ discount before it took its price) goes to the others, so the amount is taken in full as long
1095
+ as the lines have that much left. 30.00 across a free 32.00 line and a 24.00 line takes the
1096
+ 24.00 of the second.
1097
+ - **Shipping discounts.** One applies per order, the best one: the one that takes the most off
1098
+ the delivery option the buyer selected (then off all the options). The others are not applied
1099
+ and are not dropped silently: the Functions dialog lists them under "Discounts not applied"
1100
+ (`skippedDiscounts` in the state, and a `discount not applied` line in `lsf app function run`)
1101
+ with their amount and why. 50% off a 4.90 Standard rate and 3.00 off it: the 3.00 applies,
1102
+ the 50% (2.45) is listed. Free Express does not beat 1.00 off Standard while the buyer has
1103
+ Standard selected, and wins when they select Express. The candidates of one discount are
1104
+ one discount: they all apply, one after the other. A tie (the same amount) goes to the
1105
+ discount that comes first by Function, title, message and code, not by its place in the
1106
+ configuration.
1107
+ - **A discount of 0.** A candidate whose value is 0 (a `percentage` of 0, a `fixedAmount` of
1108
+ 0.00) takes nothing off and still applies: its code is accepted (`applicable` in
1109
+ `discountCodes`), and it shows at 0 on the lines or the delivery group it targets, in
1110
+ `discounts` and `discountAllocations`, in the page and in `lsf app function run`. A Function
1111
+ answers one to accept a code before the line it stands for (a gift) reaches the cart. A
1112
+ shipping discount of 0 applies to the rate the buyer selected (none is before an address with a
1113
+ country) when no other one takes something off that rate, and is listed as not applied
1114
+ otherwise: it beats one that only cuts the rates the buyer did not select, which would apply
1115
+ nothing. An automatic discount (one without a code) of 0 applies the same way and shows at 0:
1116
+ the playground's `discount-vip-customer`, whose percentage comes from settings, shows "VIP: 0%
1117
+ off −€0.00" while its settings are not set. A candidate of more than 0 that finds nothing left
1118
+ to take off (a line another discount made free) is not applied.
1119
+
1120
+ An amount is shared between the lines in proportion to what they cost, to the cent, and the
1121
+ allocations of the lines (`discountAllocations`, in the page and for the Functions that run
1122
+ later) add up to the discount.
1123
+
1124
+ **What is guaranteed, and what is not.** Product and order discounts come out the same whatever
1125
+ the order of the discounts in the configuration, of the `[[extensions]]` of the app and of the
1126
+ candidates in an answer: the checkout applies the candidates of a class in an order of its own.
1127
+ Percentages first, the larger one first; then the fixed amounts, the one that covers fewer lines
1128
+ first. The second rule is what makes overlapping fixed amounts predictable: two lines of 100.00,
1129
+ a fixed 100.00 across both and a fixed 100.00 on the second alone leave 0.00 in either
1130
+ configuration order (the narrow one first takes the second line, the general one takes the
1131
+ first), and not 50.00 in one and 0.00 in the other. Candidates that cover the same lines are
1132
+ told apart by what they would take from each line, their amount, their targets with the
1133
+ quantities they name, Function, title, code and message, never by their place in the
1134
+ configuration or in the answer; two that agree on all of these take the same from the same
1135
+ lines, and only their metafields tell them apart. The order is this
1136
+ checkout's choice, and so is the sharing of a fixed amount between lines: Shopify documents
1137
+ neither for overlapping amounts. It is a fixed order, not the one that takes the most in every
1138
+ case.
1139
+
1140
+ Shopify documents the order of the classes, the percentages of order discounts being all
1141
+ calculated on the subtotal, the percentage before the fixed amount in the order and on a line, and
1142
+ the fixed amounts after (https://help.shopify.com/en/manual/discounts/discount-combinations).
1143
+ It does not say how two percentages on one line combine, how two fixed amounts combine, nor
1144
+ whether a fixed amount may exceed what is left: the rules above for those are this checkout's
1145
+ choices. It also says that only one shipping discount applies per order and that, when discounts
1146
+ cannot combine, "the best discount for the customer's cart is always applied". It does not say
1147
+ how the best shipping discount is measured, what happens to a tie, whether the rule holds for
1148
+ each delivery group of a split order (this checkout has one group), nor what to show about the
1149
+ others: measuring by the selected option, breaking ties as above and listing the others are
1150
+ this checkout's choices. Shopify's checkout applies a code whose product discount is 0 and shows
1151
+ it at 0; that an order or a shipping discount of 0 does the same, and that an automatic discount
1152
+ of 0 shows, are this checkout's choices: Shopify does not say what its checkout shows for them.
1153
+
1154
+ ### Network access
1155
+
1156
+ A Function with network access has a `.fetch` target next to its `.run` target:
1157
+ `cart.lines.discounts.generate.fetch`, `cart.delivery-options.discounts.generate.fetch` and
1158
+ `cart.validations.generate.fetch` (the Discount and Cart and Checkout Validation APIs, in
1159
+ 2026-10 and 2027-01). The checkout runs the `.fetch` target right before its `.run` target, sends
1160
+ the HTTP request it answers from your machine, and gives the response to the `.run` target as
1161
+ `fetchResult` (`status`, `body`, `jsonBody`, `header(name:)`). The request and the response
1162
+ show in the Functions dialog and in `lsf app function run`.
1163
+
1164
+ Shopify's limits apply: `GET` and `POST`; an HTTPS URL (an `http` URL is accepted for
1165
+ `localhost` and loopback addresses, so a server of the app under development can answer); a
1166
+ `policy.readTimeoutMs` from 100 to 2000; 100 KB for the headers and the body of the response; no
1167
+ redirect followed; a UTF-8 body. A request that is out of these limits fails the `.fetch`
1168
+ target, and its `.run` target does not run. A request that cannot be made is a response,
1169
+ as on Shopify: `504` after the timeout, `502` when the connection or the TLS handshake fails
1170
+ or the response is too big, `501` for another charset, with Shopify's body (`504 Gateway
1171
+ Timeout`). A `.fetch` target that answers no request leaves `fetchResult` null, and its input
1172
+ query cannot ask for `fetchResult`.
1173
+
1174
+ Not simulated: the JWT Shopify signs the request with (`x-shopify-request-jwt` needs the secret
1175
+ of the app) and its cache of responses (every run of the Function sends its request, so a page
1176
+ that changes the checkout sends it again). The pickup point and local pickup generators, which
1177
+ have `.fetch` targets too, are not run.
1178
+
1179
+ A validation Function can check a rule at the placing of the order only: `buyerJourney.step` is
1180
+ `CHECKOUT_INTERACTION` when the page loads (and for `.json`) or changes (`update`, a line included),
1181
+ and `CHECKOUT_COMPLETION` for `complete` alone. The errors of an interaction show on the page and
1182
+ refuse nothing; `complete` runs the Functions again, as a completion, and only its errors refuse
1183
+ the order. (Shopify's documentation does not say which step blocks; this is how the local checkout
1184
+ treats them.)
1185
+
1186
+ The input of each Function is the result of its input query, run against the cart, the buyer and
1187
+ the store data. A Function that fails to run adds an error that blocks the order.
1188
+
1189
+ The "Functions" badge in the bottom left corner of every page (the checkout, the thank-you page,
1190
+ `?targets=all`) says how many Functions ran, and turns red when one failed or the checkout has
1191
+ errors. Below 1644px wide it is just "ƒ" and the number of runs, so that it stays clear of the
1192
+ checkout; its tooltip says the rest. It opens the Functions dialog, which takes most of the window (all of it on a narrow
1193
+ screen): the runs on the left, and for the one picked its error, its logs, its instructions
1194
+ (Shopify stops a Function past 11 million) and its input and output side by side, each with a
1195
+ Copy button; a `.fetch` run adds its HTTP request and response. It opens on the first run that
1196
+ failed. Its Overview lists the errors that block the checkout, the discounts a Function answered
1197
+ that were not applied, the checkout UI extensions, and a link to the other skin. Escape, the
1198
+ close button or a click outside closes it.
1199
+
1200
+ `?functions=` on the URL of a checkout opens the dialog as the page loads, which is what the
1201
+ links of the [home page](#the-home-page) problems do. `/checkout?scenario=<name>` and
1202
+ `/checkout` keep it through their redirect, as they keep `variant` and `targets`.
1203
+
1204
+ | Value | The dialog opens on |
1205
+ |---|---|
1206
+ | `<handle>` | the run of that Function that failed first, else its first run |
1207
+ | `<handle>:<target>` | the same, among the runs of that target |
1208
+ | empty (`?functions=`) | the Overview |
1209
+
1210
+ A value with no run in the checkout (a Function that does not run there, an unknown handle, a
1211
+ checkout UI extension) opens the Overview: what it names is outlined there (an error, an
1212
+ extension), or a line says it did not run or load in this checkout. The page then drops the
1213
+ parameter from its URL (`history.replaceState`), so a reload does not open the dialog again;
1214
+ once closed, nothing reopens it. The run stays picked across updates, as a run picked by hand
1215
+ does.
1216
+
1217
+ The checkout UI extensions render where their targets say: in the page, one sandboxed frame
1218
+ each. They read the checkout through the `shopify` global and change it with the `apply*Change`
1219
+ methods, `buyerJourney.intercept` can block the payment, and they draw with the Polaris web
1220
+ components (`s-text`, `s-button`, ...), as from API version 2025-10. React and remote-ui
1221
+ extensions (2025-07 and older) do not run: lsf runs 2026-10 and 2027-01 only.
1222
+
1223
+ What an extension throws, while its bundle loads, when a target renders or in a listener, and
1224
+ the promises it rejects with nobody to catch them, show above its frame with their message and
1225
+ stack, and in the overview of the Functions dialog. The frame keeps an opaque origin. The page
1226
+ fetches the bundle (same origin) and hands its text to the frame, which runs it from a `blob:`
1227
+ URL of its own: the browser does not hide those errors ("Script error."), and lsf serves the
1228
+ bundles without CORS, so another site cannot read them.
1229
+
1230
+ An extension needs a supported `api_version`, or it is reported as an error and not shown. From
1231
+ 2027-01, each `[[extensions.metafields]]` entry needs an `owner_type` (`CART`, `CUSTOMER`,
1232
+ `ORDER`, ...), or the loader reports an error and the page says why it does not load the
1233
+ extension.
1234
+
1235
+ `shopify.appMetafields` has the metafields the extension declares: those of
1236
+ `[[extensions.metafields]]`, and those of `[[extensions.targeting.metafields]]` at the target
1237
+ they are declared for. Each entry is a metafield with the resource it belongs to, its `target`:
1238
+ a `type` (`shop`, `product`, `variant`, `customer`, `company`, `companyLocation`, `cart`, and
1239
+ `order` on the order status page) and the `id` of the resource, a GID. They are read from the store data, with what the backend of the
1240
+ app sets through the [Admin API](admin.md) (`metafieldsSet`): the shop's, those of the products
1241
+ and variants of the lines (and of the components of a bundle a cart transform made), of the
1242
+ customer logged in and of the company and location they buy for, and the cart's, which the
1243
+ extensions write with `applyMetafieldChange`. A declaration with an `owner_type` reads that
1244
+ resource only, one without reads every resource that has the metafield. The app's own namespace
1245
+ is written `$app` (or `$app:<name>`) in the toml, in the store data and in what the extension
1246
+ reads; Shopify's `app--<id>--<name>`, which the Admin API writes, is the same namespace. The toml
1247
+ takes `$app` only, as on Shopify: a declaration written `app--<id>--<name>` reads nothing, and the
1248
+ loader warns with the spelling to use.
1249
+
1250
+ ```toml
1251
+ [[extensions.metafields]]
1252
+ namespace = "$app"
1253
+ key = "gift-codes"
1254
+ owner_type = "SHOP"
1255
+ ```
1256
+
1257
+ ```json
1258
+ { "shop": { "metafields": { "$app": { "gift-codes": { "type": "json", "value": ["GIFT-1"] } } } } }
1259
+ ```
1260
+
1261
+ ```js
1262
+ // [{target: {type: 'shop', id: 'gid://shopify/Shop/…'},
1263
+ // metafield: {namespace: '$app', key: 'gift-codes', value: '["GIFT-1"]', type: 'json', valueType: 'json_string'}}]
1264
+ shopify.appMetafields.value;
1265
+ ```
1266
+
1267
+ On the order status page of the customer account, the products and variants are those of the
1268
+ order, the company location is the one the order was placed for (whichever the buyer buys for
1269
+ now; an order of the store data records none, and reads the customer's first location), and the
1270
+ cart is the one of the order placed in the local checkout. As in Shopify's types, a `value` there
1271
+ is typed by its `valueType` (`true`, `2`, `1.5`; a JSON value stays serialized), where the
1272
+ checkout's is the text whatever the type (`"true"`).
1273
+
1274
+ The order's own metafields are `order` entries (`target: {type: 'order', id: 'gid://shopify/Order/…'}`),
1275
+ for a declaration of `owner_type = "ORDER"` or of no owner. They are read from the order in the
1276
+ [Admin API](admin.md#what-the-storefront-and-the-checkout-see): what the backend sets on the
1277
+ order (`metafieldsSet`), on an order placed in the local checkout or on one of the store data,
1278
+ and the cart metafields copied onto an order placed here. As on
1279
+ Shopify, a cart metafield is copied when the order is placed if an ORDER definition has its
1280
+ namespace and key with `cartToOrderCopyable` enabled, made by the backend or declared in
1281
+ `shopify.app.toml`; without one, it stays the cart's only:
1282
+
1283
+ ```toml
1284
+ # shopify.app.toml
1285
+ [order.metafields.app.gift_message]
1286
+ type = "single_line_text_field"
1287
+ capabilities.cart_to_order_copyable = true
1288
+
1289
+ # shopify.extension.toml
1290
+ [[extensions.metafields]]
1291
+ namespace = "$app"
1292
+ key = "gift_message"
1293
+ owner_type = "ORDER"
1294
+ ```
1295
+
1296
+ The checkout and the thank-you page have no `order` entry (Shopify's `AppMetafieldEntryTarget`
1297
+ of checkout UI extensions has no such type): an `ORDER` declaration reads nothing there, without
1298
+ a warning. Shopify lists `CART` as an owner type of checkout UI extensions only and `ORDER` of
1299
+ customer account UI extensions only; lsf takes both on either, as one extension can target both.
1300
+
1301
+ ## Run a Function without a server
1302
+
1303
+ ```bash
1304
+ npx local-storefront app function run volume-discount --app ../my-app --scenario discount-automatic
1305
+ ```
1306
+
1307
+ Builds the input of each Function from the store data and the [scenario](#scenarios), runs them
1308
+ in checkout order, and prints each input, output and log with the checkout that results. The
1309
+ Functions it names run whether or not the shop has an owner of them, and a named one that the
1310
+ checkout of the store data would leave out (its payment customization is disabled, or there is
1311
+ none) is said first (`Named: …`; `forced` in JSON), so that its run is not taken for the
1312
+ checkout's. It reads the store data on disk, not a running lsf: a customization the app created
1313
+ or enabled there through the Admin API is not seen. To see what a running lsf's checkout does, open
1314
+ its checkout (`/__lsf/checkouts`, a scenario), whose debug panel lists each run.
1315
+ Without a handle, it runs those the checkout runs (an enabled owner, a discount, or the
1316
+ `extensions` of the scenario), and the others are listed as not run, with why. Without `--scenario` or `--session`, the cart is the
1317
+ `session` of the store data, empty in the demo store: a Function that runs gets an empty cart
1318
+ and changes nothing. Give `--scenario`.
1319
+
1320
+ ```text
1321
+ == scenario discount-automatic: An automatic discount
1322
+
1323
+ == volume-discount · cart.lines.discounts.generate.run (cart-lines-discounts-generate-run)
1324
+ input:
1325
+ { … }
1326
+ output:
1327
+ { … }
1328
+ logs:
1329
+ Canvas Tote Bag: 6 units
1330
+ Ceramic Mug: 3 units
1331
+ instructions: 337817
1332
+
1333
+ == checkout
1334
+ 6 × Canvas Tote Bag 144.00 USD → 129.60 USD
1335
+ 3 × Ceramic Mug (Oat) 66.00 USD → 59.40 USD
1336
+ 2 × Linen Overshirt (S) 136.00 USD
1337
+ discount 10% off 3 or more: -21.00 USD (Product, volume-discount)
1338
+ delivery Standard (standard): 5.00 USD
1339
+ payment: Credit card [PAYMENT_METHOD], Cash on Delivery [PAYMENT_METHOD], Bank Deposit [PAYMENT_METHOD]
1340
+ subtotal 346.00 USD · discounts -21.00 USD · shipping 0.00 USD · tax 0.00 USD · total 325.00 USD
1341
+ ```
1342
+
1343
+ `*` marks the selected shipping rate: none until the address has a country (the scenario's
1344
+ `entered.shipping_address`, or `--checkout`), so the shipping is not in the total yet. Each
1345
+ payment method has its placements: `[ACCELERATED_CHECKOUT]` is an express button.
1346
+
1347
+ | Option | What it does |
1348
+ |---|---|
1349
+ | `--scenario <name>` | A [scenario](#scenarios) of the store data: its cart, buyer, language, `checkout` settings and the extensions it names. `--session`, `--checkout` and `--locale` go on top of it. |
1350
+ | `--session <file or JSON>` | The session to run for: a [session](data-reference.md#session) with a `cart`, a `customer`, a `country`. Defaults to the `session` of the store data. |
1351
+ | `--checkout <file or JSON>` | What the buyer entered: `email`, `phone`, `shippingAddress`, `selectedDelivery`, `selectedPayment`, `discountCodes`, `metafields`. |
1352
+ | `--locale <iso>` | The language of the buyer, one of the store's (`fr`, `pt-BR`): Functions read it as `localization.language`. Defaults to the primary language. |
1353
+ | `--step interaction\|completion` | The step of the buyer journey, which validation Functions read as `buyerJourney.step`: `completion` (default) is the placing of the order, `interaction` is the page and its updates. |
1354
+ | `--format json` | The checkout as the page reads it, with every run in `runs`. For tools. |
1355
+ | `--build` | Builds the extensions first, as `lsf app build` does: each Function (its build command, or lsf's build of a JavaScript Function), then each UI extension that names its sources. |
1356
+ | `--input <file>` | Runs one Function on this input instead, as `shopify app function run --input` does: a JSON file, or `-` for stdin (see below). |
1357
+ | `--export <name>` | The target to run, by its Wasm export or its name, when the Function has several: for `--input` and `--save-input`. |
1358
+ | `--save-input <file>` | Writes the input lsf built for the run of the Function named to this file. |
1359
+
1360
+ It exits with `1` when a Function fails to run (no module, a trap, an output that does not
1361
+ parse), or when a Function named on the command line did not run: it says why (no discount
1362
+ names a discount Function, the cart is empty, the scenario's `extensions` leaves it out…).
1363
+ Errors that a validation Function reports are part of the checkout, not a failure.
1364
+
1365
+ ### Run a Function on an input
1366
+
1367
+ `--save-input` keeps the input lsf builds for a scenario, and `--input` runs a Function on an
1368
+ input, without the store data, as `shopify app function run --input` does:
1369
+
1370
+ ```bash
1371
+ npx local-storefront app function run volume-discount --scenario discount-code \
1372
+ --export cart.lines.discounts.generate.run --save-input tests/fixtures/code.json
1373
+ npx local-storefront app function run volume-discount --input tests/fixtures/code.json
1374
+ cat input.json | npx local-storefront app function run volume-discount --input -
1375
+ ```
1376
+
1377
+ It prints the run (input, output, logs, instructions), and `--format json` the run as JSON. The
1378
+ output is checked as in a checkout: against the schema of the API version, and the instruction
1379
+ limit. A fixture of the unit tests of Shopify's templates (`tests/fixtures/*.json`,
1380
+ `{"payload": {"export", "target", "input", "output"}}`) runs the target it names, and fails
1381
+ when its `output` is not the run's, with each path that differs (it says `the output is the one
1382
+ the fixture expects` when it is). Its `target` and `export` must be the Function's: a wrong one
1383
+ is an error, not left aside. A Function with
1384
+ several targets needs `--export` (its export, `cart-lines-discounts-generate-run`, or its name).
1385
+ In the Function's folder, with a fixture in it, or in an app with one Function, the handle can
1386
+ be left out. `--input` with a folder runs each of its `*.json` fixtures and exits with `1` when
1387
+ one fails, as the unit tests of the templates do:
1388
+
1389
+ ```bash
1390
+ npx local-storefront app function run volume-discount --input extensions/volume-discount/tests/fixtures
1391
+ ```
1392
+
1393
+ `--save-input` creates the folders of its file. When a file of the Function changed after its
1394
+ module was built, the run builds it first, as `shopify app function run` does.
1395
+
1396
+ ## Test it
1397
+
1398
+ The checkout is part of the [control API](control-api.md). A test puts a session in a known
1399
+ state, then drives the checkout like the page does, and places the order. The checkout belongs to
1400
+ the session: Playwright's `request` keeps its cookie; a client without cookies names its session
1401
+ with an `x-lsf-session` header.
1402
+
1403
+ ```ts
1404
+ await request.put('/__lsf/session', {
1405
+ data: { cart: { items: [{ variant: 'TEE-BLK-M', quantity: 3 }] } },
1406
+ });
1407
+ const checkout = await request.get('/checkout', { maxRedirects: 0 });
1408
+ const path = checkout.headers()['location']; // /checkouts/cn/<token>
1409
+
1410
+ const state = await (await request.get(`${path}.json`)).json();
1411
+ await request.post(`${path}/update`, {
1412
+ data: {
1413
+ operations: [
1414
+ { type: 'email', value: 'buyer@example.com' },
1415
+ { type: 'shippingAddress', address: { lastName: 'Buyer', address1: '1 Main St', city: 'New York', countryCode: 'US', provinceCode: 'NY', zip: '10001' } },
1416
+ { type: 'paymentMethod', handle: 'card' },
1417
+ ],
1418
+ },
1419
+ });
1420
+ const { order } = await (await request.post(`${path}/complete`)).json(); // { id, number, name }
1421
+ ```
1422
+
1423
+ The order is an order of the [Admin API](admin.md#place-an-order), and its `orders/create`
1424
+ reaches the backend of the app. A test that only needs an order (for its webhooks) serves the
1425
+ Functions it needs and no others, `--extension <handle>` repeated, or none, `--no-functions`: a
1426
+ Function that fails blocks `complete`.
1427
+
1428
+ | Request | What it does | Answer |
1429
+ |---|---|---|
1430
+ | `GET /checkout` | Leads to the checkout of the session's cart. `?variant=shop` for the other look, `?targets=all` for [every extension target](#every-extension-target). | A redirect to `/checkouts/cn/<token>` (`/fr/checkouts/cn/<token>` from `/fr/checkout`) |
1431
+ | `GET /checkout?scenario=<name>` | Opens a checkout for a [scenario](#scenarios), in a new session. | A redirect to its checkout with a new session cookie, or `404`/`422` and a page that says why |
1432
+ | `GET /checkout?customer=<email>` | Logs the session in as a [customer of the data](#log-in-as-a-customer) (`default`, `none` too), then leads to its checkout. With `scenario=`, opens the scenario as them. | A redirect, or `404` and a page that lists the customers |
1433
+ | `GET /` (`lsf app dev`) or `GET /__lsf/checkouts` | The [home page](#the-home-page). `?format=json` for what it says, `?check=1` to [run the Functions of the scenarios](#the-functions-that-fail) first. | HTML, or JSON |
1434
+ | `GET /__lsf/checkouts/check` | Runs the Functions of every scenario (or takes the last run on the same app and data): [the ones that fail](#the-functions-that-fail). | JSON: `failures` (`handle`, `target`, `error`, `logs`, `scenarios`), and what the page shows of it |
1435
+ | `GET /checkouts/cn/<token>` | The page. | HTML, with the state in it |
1436
+ | `GET /checkouts/cn/<token>.json` | The state of the checkout. | JSON |
1437
+ | `POST /checkouts/cn/<token>/update` | Applies `{"operations": [...]}`, in order. | `200` and the state, or `400` `{"error": "…"}` and nothing applied, or `409` and the state as it is now when the order was placed meanwhile or the session changed under it |
1438
+ | `POST /checkouts/cn/<token>/complete` | Places the order. | `200` `{"redirect": "…/thank-you", "order": {"id", "number", "name"}}`, the order as the Admin API recorded it: `number` is an integer (`1004`), `name` the string (`"#1004"`), `id` the `admin_graphql_api_id` of its `orders/create` (without the Admin API, `--only checkout`: `gid://shopify/Order/<number>`, the session's number). The state's `order.number` is a string, the session's, which can differ (below). Or `422` and the state with `errors`, or `409` and the fresh state when the cart changed meanwhile. Asked again once the order is placed: `200` and the same `redirect` and `order` |
1439
+ | `GET /checkouts/cn/<token>/thank-you` | The page of the order. | HTML |
1440
+ | `GET /__lsf/checkout?session=<id>` | The state of a session's checkout, from outside its browser. | JSON |
1441
+
1442
+ The token is the one of the session's cart. Another session gets a `404`.
1443
+
1444
+ ### Language
1445
+
1446
+ The buyer reads the checkout in the language its URL names, as the storefront is: every path above also
1447
+ exists under a locale prefix (`/fr/checkout`, `/fr/checkouts/cn/<token>`,
1448
+ `/fr/checkouts/cn/<token>/update`, …). The cart form of a theme leads to `/fr/checkout` from the
1449
+ French storefront, the redirect keeps the prefix, and so do the page's own requests, the links
1450
+ back to the storefront and the way to the thank-you page. Without a prefix the checkout is in
1451
+ the primary language of the store. `/__lsf/checkout` has no prefix to read: it takes
1452
+ `?locale=fr`.
1453
+
1454
+ The state tells both languages apart: `shop.locale` is the primary language of the store, and
1455
+ `localization.language` the one the buyer reads. Extensions get the latter as
1456
+ `shopify.localization.language`, translate and format numbers and money with it. The Functions
1457
+ read it too, as `localization.language` of their input. The words of lsf's own pages (the
1458
+ checkout, the thank-you page, the customer account: "Delivery", "Pay now", "Orders") are not
1459
+ translated: they are English, and the document says so (`lang="en"`), whatever the language of
1460
+ the URL. A thank-you page is read in the language of its URL, not the one the order was
1461
+ placed in. A language that a session adds with `data.localization.languages` is a locale of that
1462
+ session's checkout.
1463
+
1464
+ ### Buyer and localization
1465
+
1466
+ `buyer` is what `shopify.buyerIdentity` reads: the `email` and `phone` the buyer typed or the
1467
+ logged-in customer has, `customer` (`id`, `email`, `phone`, `firstName`, `lastName`,
1468
+ `acceptsMarketing`, `ordersCount`) and, for a B2B customer, `purchasingCompany` (the `company`
1469
+ and the `location` they buy for, each with `id`, `name` and `externalId`). `localization` is what
1470
+ `shopify.localization` reads: `language` and `rootUrl` as above, the `country` (the one of the
1471
+ shipping address, otherwise the one the visitor selected on the storefront), the `currency` and
1472
+ the `market` of that country. The store has no avatar, no consent to SMS marketing and no store
1473
+ credit for a customer, nor a time zone for a buyer (`timezone` is the one of the browser).
1474
+
1475
+ A buyer with no address and no selected country is in the shop's country
1476
+ (`shop.address.country_code`), the one of its primary market, as on Shopify. The Functions read
1477
+ it as `localization.country`, the extensions as `shopify.localization.country`, and the
1478
+ address form starts on it. Both read the same `market`. A shop in a country the store does not
1479
+ sell to uses the first country of the store. The storefront shows the same country to a visitor
1480
+ who selected none (Liquid's `localization.country`, `Shopify.country`), so the checkout opens in
1481
+ the country they browsed in.
1482
+
1483
+ The state has the lines, discounts, discount codes, delivery groups, payment methods (each with
1484
+ its `placements`), express buttons (`expressMethods`), errors and cost that the Functions
1485
+ produced, `runs` (a run of a `.fetch` target has its `exchange`, the request and the response),
1486
+ the buyer, the address, the note, the attributes and
1487
+ the metafields (the cart's), `appMetafields` (the metafields the extensions declare of the shop,
1488
+ the products and variants of the lines and the buyer), the `extensions` to load and
1489
+ `diagnostics`. Money is `{"amount": 19.99}`.
1490
+
1491
+ No shipping rate is selected until the address has a country, as on Shopify. Until then, the
1492
+ page shows "Enter shipping address", and no total has the shipping: `cost.shipping` is 0,
1493
+ `cost.total` and `cost.dueNow` leave it out, and so does `shopify.cost.totalAmount`.
1494
+ `shopify.cost.totalShippingAmount` is undefined. The Functions read `selectedDeliveryOption:
1495
+ null` and a `cart.cost.totalAmount` without shipping. The rates are still offered, so a delivery
1496
+ customization runs on them.
1497
+
1498
+ ### Operations
1499
+
1500
+ | `type` | Fields | Does |
1501
+ |---|---|---|
1502
+ | `customer` | `customer`: an email, `default`, or `null` to log out; `location` (optional): a company location by id or name | Logs the checkout in as a [customer of the data](#log-in-as-a-customer). The session gets the customer; the contact and the address become theirs. |
1503
+ | `companyLocation` | `location`: a `gid://shopify/CompanyLocation/…`, its number or its name | Chooses the location the logged-in B2B customer buys for; the order ships there. |
1504
+ | `email`, `phone` | `value` | Sets the contact. An empty value clears it. |
1505
+ | `shippingAddress` | `address`: `firstName`, `lastName`, `company`, `address1`, `address2`, `city`, `countryCode`, `provinceCode`, `zip`, `phone` | Sets the address. A country of the store becomes the country of the session. A `countryCode` that is not a country (ISO 3166-1 alpha-2, or `XK`, `AC`, `TA` as Shopify takes them; not `ZZ`) is refused and changes nothing. The store data has no shipping zones: any real country is taken. |
1506
+ | `deliveryOption` | `group` (`group-1`), `handle` | Chooses a shipping rate. |
1507
+ | `paymentMethod` | `handle` | Chooses a payment method. No handle clears it. |
1508
+ | `discountCode` | `action` (`add` or `remove`), `code` | Enters or takes back a code. |
1509
+ | `cartLines` | `change`: `addCartLine`, `removeCartLine` or `updateCartLine` of the UI extension API | Changes the cart of the session, the one the theme shows. |
1510
+ | `attribute` | `change`: `updateAttribute` or `removeAttribute`, with `key` and `value` | Changes the attributes of the cart. |
1511
+ | `note` | `change`: `updateNote` (`note`) or `removeNote` | Changes the note of the cart. |
1512
+ | `metafield` | `change`: `updateMetafield`, `updateCartMetafield`, `removeMetafield` or `removeCartMetafield`, with `namespace`, `key`, `valueType`, `value` | Sets a metafield for the Functions to read. |
1513
+
1514
+ `complete` refuses an empty cart, an order with errors from a Function, and one without an
1515
+ email, a payment method or, for a cart that ships, an address with a last name, a first line and
1516
+ a city. It empties the cart: the order is what the thank-you page shows, and the state's `order`
1517
+ (`id`, `number`, `processedAt`, `totalReceived`, `totalOutstanding`) once `status` is `completed`.
1518
+ That `number` is a string, the session's (`"#1004"` every run). The `order` of `complete` is the
1519
+ Admin API's, and there `number` is an integer (`1004`) and `name` the string (`"#1004"`); they
1520
+ are `1005` and `"#1005"` when the shop has a `#1004` already. To match the two, use `id`, which
1521
+ both have; never compare the two `number`s. The state's `number` is a string since 0.2.0, and
1522
+ stays one. `complete` takes the cart only if the cart is still the one the Functions ran on: if
1523
+ it changed meanwhile (another tab), nothing is bought and the reply is `409`. Completing an order that was placed already leads to the same thank-you page, never to a
1524
+ second order: `200`, with the same `redirect` and `order`. An `update` that comes once the order
1525
+ is placed (a line changed in another tab, a request sent late) changes nothing either: the reply
1526
+ is `409` and the state as it is now, whose `status` is `completed`.
1527
+
1528
+ ### In CI
1529
+
1530
+ Once, in the app's folder: `npm install --save-dev local-storefront`, then commit
1531
+ `package.json` and `package-lock.json`. In the job, `actions/setup-node` and `npm ci` come
1532
+ before these steps, as in [testing](testing.md#in-ci). `dist/` is not committed: build the
1533
+ extensions before checking them.
1534
+
1535
+ ```yaml
1536
+ - run: npx local-storefront app build # every Function and UI extension, without Node
1537
+ - run: npx local-storefront validate # the store data, the scenarios and the app
1538
+ - run: npx local-storefront app check # every Function of every scenario runs, and each `expect` holds (`--build` builds first)
1539
+ ```
1540
+
1541
+ `app check` fails on a scenario that does not validate, on a Function that traps or panics,
1542
+ runs past 11 million instructions or answers an output out of its schema, and on an
1543
+ [expectation](#expected-results) of a scenario that is not met: a discount of the wrong amount,
1544
+ PayPal left in the express buttons. It also fails on an extension the checkout does not offer
1545
+ (an `api_version` other than `2026-10` and `2027-01`, no module or bundle), on a Function whose
1546
+ files changed after its module was built (`stale`, unless `--build`: its folder, and the app's
1547
+ files every build reads, such as a workspace's `Cargo.toml` or a shared package; a lock file
1548
+ the build writes itself does not count), and on what Shopify rejects in a theme app extension.
1549
+ `--extension` naming theme app extensions only checks those. A trap ends with the last message the Function logged
1550
+ (`the Function trapped: Error: boom (in b2b)` for a `throw new Error("boom")`), and the JSON has
1551
+ the `logs` of each failure. For what `expect` cannot say, `app function run --format json` has the
1552
+ whole checkout (`lines`, `discounts`, `deliveryGroups`, `paymentMethods`, `expressMethods`,
1553
+ `errors`, `cost`, and each run in `runs`) for `jq`, and a test can drive the checkout, as above.
1554
+ The browser tests of a theme in CI: [testing](testing.md#in-ci).
1555
+
1556
+ ## What it does not do
1557
+
1558
+ - Payments, gift cards, and payment method attributes. Choosing a payment method pays nothing,
1559
+ and the balance of a [deferred payment](#deferred-payments) is never collected.
1560
+ - One delivery group holds every line that ships. No pickup points (their list stays empty), no
1561
+ split shipments, no prices by address: the rates are the same for every address.
1562
+ - Fulfillment constraints, order routing, and the pickup point and local pickup generators (with
1563
+ their `.fetch` targets) are listed and not run.
1564
+ - Discounts have no `conditions` and no combinations: all of them stack, as described in
1565
+ [how discounts stack](#how-discounts-stack). Shopify lets the merchant choose which discounts
1566
+ combine, and applies the best of those that cannot; here all the product and order discounts
1567
+ combine, and of the shipping discounts the best one applies.
1568
+ - Tax is one rate on the discounted subtotal, with no tax lines.
1569
+ - No Storefront API: `query` in an extension rejects. No Customer Account API either: a customer
1570
+ account extension reads the account through its `shopify` global only.
1571
+ - `shopify.appMetafields` has no `shopUser` entries (the store data has no staff accounts).
1572
+ - The customer account shows; it changes nothing (no address to edit, no order to cancel or
1573
+ return), and its intents (`shopify.intents.invoke`) are closed at once.
1574
+ - Some fields of the Function input hold what the store data says and nothing more. The orders of
1575
+ the customers are not counted in the `ordersCount` of a company location, nor spent in its
1576
+ `totalSpent` (set them in the data); a `Market` is a country, or the group of countries that
1577
+ `localization.markets` declares, with no catalog or price list; the `id` of a `Metaobject`
1578
+ is made up from its type and handle.
1579
+ - Shop Pay and express checkout: the express buttons show (the methods of the
1580
+ `ACCELERATED_CHECKOUT` placement that the payment customizations left), but a click only says
1581
+ that lsf does not simulate the wallet. Logging in is picking a customer of the data, see
1582
+ [log in as a customer](#log-in-as-a-customer).
1583
+ - With `app dev` (no theme), the links to the storefront (return to cart, continue shopping)
1584
+ lead to pages the server does not have. Use `serve --app` for those.
1585
+ - A scenario is one session per browser: opening another replaces it. The scenario cannot set
1586
+ what the theme shows outside the cart and the customer (`theme_settings`, a frozen clock).
1587
+
1588
+ ## What to test on a real store
1589
+
1590
+ What lsf leaves out, or decides where Shopify does not document it. Test these on a development
1591
+ store before a release:
1592
+
1593
+ - **Discount combinations and conditions**: lsf combines every product and order discount
1594
+ ([how discounts stack](#how-discounts-stack)).
1595
+ - **Currencies and price lists**: one price list, in the shop's currency; a buyer abroad pays it
1596
+ too ([what it does not do](#what-it-does-not-do), [markets](compatibility.md#known-differences)).
1597
+ - **B2B catalogs, pickup points, local pickup, fulfillment constraints and order routing**: not
1598
+ run.
1599
+ - **Network access**: the request JWT and Shopify's cache of responses
1600
+ ([network access](#network-access)).
1601
+ - **The settings of an extension** (`shopify.settings`): those of the store data or the
1602
+ scenario here, not the checkout editor's.
1603
+ - **lsf's choices**: how several customizations chain and how `linesMerge` counts
1604
+ ([what runs](#what-runs)), which validation step blocks the order
1605
+ ([network access](#network-access)), the split between due today and due later
1606
+ ([deferred payments](#deferred-payments)).
1607
+ - **Instruction counts near the 11 million limit**: lsf does not run `wasm-opt`, so a Rust
1608
+ Function costs 0.25 to 0.5% more here ([Rust Functions](#rust-functions)).
1609
+ - **Payments**, Shop Pay and express checkout.