@sixtyninecommerce/admin 0.8.0 → 0.9.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (161) hide show
  1. package/README.md +2445 -17
  2. package/dist/api-tokens/index.d.ts +15 -5
  3. package/dist/api-tokens/index.js +1 -0
  4. package/dist/apps/event-subscription.d.ts +59 -0
  5. package/dist/apps/event-subscription.js +32 -0
  6. package/dist/apps/hook-secret.d.ts +22 -0
  7. package/dist/apps/hook-secret.js +36 -0
  8. package/dist/apps/index.d.ts +54 -3
  9. package/dist/apps/index.js +17 -0
  10. package/dist/audit/index.d.ts +7 -0
  11. package/dist/auth/queries.d.ts +5 -1
  12. package/dist/auth/queries.js +6 -0
  13. package/dist/bulk/index.d.ts +323 -0
  14. package/dist/bulk/index.js +162 -0
  15. package/dist/catalog/assets.d.ts +147 -0
  16. package/dist/catalog/assets.js +164 -0
  17. package/dist/catalog/collections.d.ts +109 -0
  18. package/dist/catalog/collections.js +91 -0
  19. package/dist/catalog/facets.d.ts +103 -0
  20. package/dist/catalog/facets.js +100 -0
  21. package/dist/catalog/index.d.ts +115 -0
  22. package/dist/catalog/index.js +21 -0
  23. package/dist/catalog/options.d.ts +89 -0
  24. package/dist/catalog/options.js +88 -0
  25. package/dist/catalog/products-bulk.d.ts +21 -0
  26. package/dist/catalog/products-bulk.js +58 -0
  27. package/dist/catalog/products.d.ts +489 -0
  28. package/dist/catalog/products.js +365 -0
  29. package/dist/catalog/properties.d.ts +234 -0
  30. package/dist/catalog/properties.js +158 -0
  31. package/dist/catalog/types.d.ts +174 -0
  32. package/dist/catalog/types.js +141 -0
  33. package/dist/catalog/variants.d.ts +274 -0
  34. package/dist/catalog/variants.js +258 -0
  35. package/dist/catalog/visibility.d.ts +57 -0
  36. package/dist/catalog/visibility.js +59 -0
  37. package/dist/client.d.ts +171 -3
  38. package/dist/client.js +81 -12
  39. package/dist/compliance/index.d.ts +88 -0
  40. package/dist/compliance/index.js +88 -0
  41. package/dist/customer-data-redaction.d.ts +14 -0
  42. package/dist/customer-data-redaction.js +22 -0
  43. package/dist/customers/index.d.ts +428 -17
  44. package/dist/customers/index.js +322 -15
  45. package/dist/documents/index.d.ts +513 -16
  46. package/dist/documents/index.js +229 -3
  47. package/dist/events/index.d.ts +259 -0
  48. package/dist/events/index.js +147 -0
  49. package/dist/feeds/ai.d.ts +46 -0
  50. package/dist/feeds/ai.js +43 -0
  51. package/dist/feeds/categories.d.ts +50 -0
  52. package/dist/feeds/categories.js +56 -0
  53. package/dist/feeds/configs.d.ts +54 -0
  54. package/dist/feeds/configs.js +85 -0
  55. package/dist/feeds/identifier-fields.d.ts +25 -0
  56. package/dist/feeds/identifier-fields.js +29 -0
  57. package/dist/feeds/index.d.ts +54 -0
  58. package/dist/feeds/index.js +46 -0
  59. package/dist/feeds/preserve.d.ts +38 -0
  60. package/dist/feeds/preserve.js +74 -0
  61. package/dist/feeds/rules.d.ts +42 -0
  62. package/dist/feeds/rules.js +56 -0
  63. package/dist/feeds/types.d.ts +654 -0
  64. package/dist/feeds/types.js +135 -0
  65. package/dist/fulfillments/index.d.ts +360 -0
  66. package/dist/fulfillments/index.js +425 -0
  67. package/dist/heads/index.d.ts +60 -0
  68. package/dist/heads/index.js +45 -0
  69. package/dist/hooks/assignments.d.ts +90 -0
  70. package/dist/hooks/assignments.js +53 -0
  71. package/dist/hooks/index.d.ts +97 -0
  72. package/dist/hooks/index.js +76 -0
  73. package/dist/index.d.ts +84 -9
  74. package/dist/index.js +16 -1
  75. package/dist/jobs/bulk-action-run.d.ts +65 -0
  76. package/dist/jobs/bulk-action-run.js +70 -0
  77. package/dist/locations/index.d.ts +6 -2
  78. package/dist/meta/index.d.ts +32 -0
  79. package/dist/orders/commit-edit.d.ts +123 -0
  80. package/dist/orders/commit-edit.js +142 -0
  81. package/dist/orders/get.d.ts +722 -0
  82. package/dist/orders/get.js +530 -0
  83. package/dist/orders/index.d.ts +105 -20
  84. package/dist/orders/index.js +44 -0
  85. package/dist/orders/line-price-preview.d.ts +297 -0
  86. package/dist/orders/line-price-preview.js +50 -0
  87. package/dist/orders/manual-discounts.d.ts +132 -0
  88. package/dist/orders/manual-discounts.js +95 -0
  89. package/dist/orders/manual-pricing.d.ts +192 -0
  90. package/dist/orders/manual-pricing.js +72 -0
  91. package/dist/orders/order-entry.d.ts +566 -0
  92. package/dist/orders/order-entry.js +129 -0
  93. package/dist/orders/parcel-charge.d.ts +45 -0
  94. package/dist/orders/parcel-charge.js +55 -0
  95. package/dist/orders/plan-edit.d.ts +489 -0
  96. package/dist/orders/plan-edit.js +93 -0
  97. package/dist/orders/status.d.ts +235 -0
  98. package/dist/orders/status.js +96 -0
  99. package/dist/orders/surcharges.d.ts +75 -2
  100. package/dist/orders/surcharges.js +110 -4
  101. package/dist/payments/account-credit.d.ts +171 -0
  102. package/dist/payments/account-credit.js +107 -0
  103. package/dist/payments/index.d.ts +234 -9
  104. package/dist/payments/index.js +90 -0
  105. package/dist/platform/catalog.d.ts +148 -0
  106. package/dist/platform/catalog.js +74 -0
  107. package/dist/platform/entitlements.d.ts +111 -0
  108. package/dist/platform/entitlements.js +86 -0
  109. package/dist/platform/legal-entities.d.ts +139 -0
  110. package/dist/platform/legal-entities.js +126 -0
  111. package/dist/platform/legal-entity-numbering.d.ts +103 -0
  112. package/dist/platform/legal-entity-numbering.js +101 -0
  113. package/dist/platform/organization-membership.d.ts +80 -0
  114. package/dist/platform/organization-membership.js +71 -0
  115. package/dist/platform/organization-settings.d.ts +33 -0
  116. package/dist/platform/organization-settings.js +42 -0
  117. package/dist/platform/organization-sharing.d.ts +172 -0
  118. package/dist/platform/organization-sharing.js +120 -0
  119. package/dist/platform/organizations.d.ts +205 -0
  120. package/dist/platform/organizations.js +180 -0
  121. package/dist/platform/provision-store.d.ts +88 -8
  122. package/dist/platform/provision-store.js +12 -0
  123. package/dist/platform/reports.d.ts +46 -0
  124. package/dist/platform/reports.js +31 -0
  125. package/dist/platform/server-metrics.d.ts +58 -0
  126. package/dist/platform/server-metrics.js +11 -0
  127. package/dist/pos/index.d.ts +567 -0
  128. package/dist/pos/index.js +205 -0
  129. package/dist/pricing/index.d.ts +405 -0
  130. package/dist/pricing/index.js +351 -0
  131. package/dist/provenance/index.d.ts +93 -0
  132. package/dist/provenance/index.js +53 -0
  133. package/dist/reports/index.d.ts +199 -0
  134. package/dist/reports/index.js +154 -0
  135. package/dist/shipping/parcel-count-suggestion.d.ts +52 -0
  136. package/dist/shipping/parcel-count-suggestion.js +73 -0
  137. package/dist/shipping/parcel-rule.d.ts +48 -0
  138. package/dist/shipping/parcel-rule.js +91 -0
  139. package/dist/stock/index.d.ts +166 -0
  140. package/dist/stock/index.js +115 -0
  141. package/dist/stock/transfers.d.ts +520 -0
  142. package/dist/stock/transfers.js +437 -0
  143. package/dist/stored-value/index.d.ts +154 -0
  144. package/dist/stored-value/index.js +130 -0
  145. package/dist/tenant/capabilities.d.ts +10 -0
  146. package/dist/tenant/entitlements.d.ts +35 -0
  147. package/dist/tenant/entitlements.js +15 -0
  148. package/dist/tenant/field-groups.d.ts +64 -0
  149. package/dist/tenant/field-groups.js +34 -0
  150. package/dist/tenant/legal-entity.d.ts +32 -0
  151. package/dist/tenant/legal-entity.js +41 -0
  152. package/dist/tenant/notifications.d.ts +148 -0
  153. package/dist/tenant/notifications.js +79 -0
  154. package/dist/tenant/retention.d.ts +10 -4
  155. package/dist/tenant/settings-sources.d.ts +103 -0
  156. package/dist/tenant/settings-sources.js +92 -0
  157. package/dist/types.d.ts +86 -11
  158. package/dist/uploads/index.d.ts +11 -7
  159. package/dist/users/index.d.ts +104 -0
  160. package/dist/users/index.js +80 -0
  161. package/package.json +3 -2
package/README.md CHANGED
@@ -49,6 +49,10 @@ const me = await admin.auth.login('user@example.com', 'secret');
49
49
  const tenant = admin.withTenant({ scope: { kind: 'channel', code: me.channels[0]!.code } });
50
50
  ```
51
51
 
52
+ Each of `me.channels` carries `channelKind` — `'hub'` for an organization's workspace, which is
53
+ never a store, anything else a store — and `organizationId`, the organization a store belongs to or
54
+ whose workspace a hub is (`null` for neither).
55
+
52
56
  **The session object you pass IS where the bearer is stored.** Vendure returns a fresh one on a
53
57
  response header and the SDK writes it back into `session.token`, so it must be writable — a plain
54
58
  property, or a getter with a setter over your own store. A read-only one is refused at construction
@@ -78,8 +82,14 @@ Treat "signed in" as "`auth.me()` resolved", never as "a token is stored".
78
82
 
79
83
  `apiTokens.*` needs a human session. The engine strips `ManageApiToken` from every token it
80
84
  issues, so a token can never manage tokens — including its own — and `platform.*` refuses a token
81
- in the SDK before the request leaves the process. Mint and revoke credentials from a signed-in
82
- session:
85
+ in the SDK before the request leaves the process. A token also never obtains a login that
86
+ outlives it: `pos.devices.pair` (and creating a POS device), creating or re-roling a staff member,
87
+ setting a staff password or login email, and creating or rotating a core API key all answer
88
+ `PermissionDeniedError` with `TOKEN_CREDENTIAL_NOT_ALLOWED` or `TOKEN_API_KEY_NOT_ALLOWED`. A token
89
+ may invite a new staff address with a role within its own permissions, when it carries no
90
+ location restriction (`TOKEN_INVITE_NOT_ALLOWED` otherwise), and send a staff member a password reset (`requestAdministratorPasswordReset`): both mail
91
+ the link to the recipient's own address. Mint and revoke credentials, and pair tills, from a
92
+ signed-in session:
83
93
 
84
94
  ```ts
85
95
  const session: { token?: string } = {};
@@ -130,6 +140,87 @@ await admin.apps.approveConsent(installation.id, {
130
140
  Omit `grantedPermissions` and the installation keeps exactly what it had — the right default for an
131
141
  app that grew an event type or a metafield rather than a permission.
132
142
 
143
+ An app may also ask to own **field groups** — `price`, `stock`, `product_details` and
144
+ `customer_details`, each a fixed list of fields the platform defines (`App.declaredFieldOwnership`).
145
+ Grant them by key in `grantedFieldOwnership` on `install` or `approveConsent`; any other value
146
+ (an old `product.price` claim included) is refused with `FIELD_GROUP_INVALID`.
147
+
148
+ #### Field groups: one owner per group per store
149
+
150
+ A store hands each field group to **one** owner at a time — an app installation or a personal API
151
+ token (`apiTokens.create({ fieldOwnership })`). While it is owned, other writers are refused it
152
+ (below).
153
+ Every grant, on `install`, `approveConsent`, `apiTokens.create` and `apiTokens.update`, is judged
154
+ by the same rules, and only for the groups it ADDS (a group the owner holds already passes):
155
+
156
+ | Refusal | Class | When |
157
+ | --- | --- | --- |
158
+ | `FIELD_GROUP_PERMISSION_MISSING` | `BusinessRuleError` | the app or token holds no permission that changes the group (Price and Product details: `UpdateCatalog` or `UpdateProduct`; Stock: one of those, `AdjustStock` or `UpdateStockLocation`; Customer details: `UpdateCustomer`) |
159
+ | `FIELD_GROUP_GRANT_NOT_ALLOWED` | `PermissionDeniedError` | you cannot change that data yourself on the store |
160
+ | `FIELD_GROUP_OWNER_CONFLICT` | `ConflictError` | another app or token owns it here (a suspended app's claim still counts) |
161
+ | `FIELD_GROUP_ERP_SYNCED` | `BusinessRuleError` | Exact Online is connected (it writes all four groups), or the store belongs to an organization (Price and Product details) |
162
+
163
+ ```ts
164
+ const owners = await admin.tenant.fieldGroupOwners();
165
+ // [{ group: 'price', ownerKind: 'app_installation', ownerId, label: 'Acme ERP', appSlug: 'acme-erp', paused: false }]
166
+ const synced = await admin.tenant.fieldGroupSyncSources();
167
+ // [{ group: 'price', source: 'exact', label: 'Exact Online' }, …]
168
+
169
+ // One click, recorded: the group comes off its owner, audited and announced as
170
+ // `app.field_ownership_changed`. InstallApps for an app's group, ManageApiToken for a token's.
171
+ await admin.apps.releaseFieldGroup({ kind: 'app_installation', id: owners[0].ownerId }, 'price', {
172
+ idempotencyKey: crypto.randomUUID(),
173
+ });
174
+ ```
175
+
176
+ An approval or a token update that does not name the groups keeps them, but drops a group the new
177
+ permissions can no longer change. An app or token reads what it owns from `auth.whoami()`
178
+ (`fieldOwnership`).
179
+
180
+ #### Field groups: what an owned group refuses
181
+
182
+ While an app or API token owns a group on a store, every OTHER writer through the Admin API is
183
+ refused a change of that group's data with `FIELD_GROUP_LOCKED` (`BusinessRuleError`, 422): staff
184
+ (a SuperAdmin included), other apps and tokens, bulk imports and pipelines. The owner's own calls
185
+ pass, and so does a bulk import it submitted. A suspended owner holds nothing until it resumes; an
186
+ installation waiting for consent still holds its groups.
187
+
188
+ ```ts
189
+ try {
190
+ await admin.catalog.variants.update([{ id, price: 1999, priceBasis: 'gross' }]);
191
+ } catch (error) {
192
+ if (error instanceof BusinessRuleError && error.code === 'FIELD_GROUP_LOCKED') {
193
+ // error.details: [{ field: '/input/0/price', code: 'FIELD_GROUP_LOCKED',
194
+ // message: 'Prices on this store are managed by the app "Acme ERP". …' }]
195
+ }
196
+ }
197
+ ```
198
+
199
+ - **An unchanged value passes.** A field sent with the value it already holds is not a change: it
200
+ is left out of the write, so the owner keeps its "managed by" provenance. Send whole records if
201
+ you like; only the values you change are judged.
202
+ - **What counts:** updating a group's fields on an existing product, variant, customer or address;
203
+ deleting a product, variant or customer whose groups are owned; adding, changing or removing a
204
+ customer's address; linking or unlinking a product's option groups; a stock adjustment, a stock
205
+ transfer's stock moves, and deleting a location that holds stock.
206
+ - **What never counts:** creating a record; a stock move that follows an order (a sale, a
207
+ reservation, a fulfilment, a return put back in stock); a shopper editing their own details on
208
+ the storefront; price lists, visibility, metadata and documents, which are in no group.
209
+ - **Shared rows.** A record several stores hold is one row: its product details, its customer
210
+ details, and a variant's tax class, cost price and stock settings (tracking, thresholds,
211
+ backorders). An owner in another store holding the record counts for those too; the refusal
212
+ names it only as "another store's app" (or API token), and only that owner can change them. The
213
+ same app installed in both stores is one owner. A variant's prices and stock levels are each
214
+ store's own, so only this store's owner counts for them.
215
+ - **Customer data your app cannot read.** When your app's customer-data level hides a field, a
216
+ value you send for it is judged a change even when it equals the stored one: the lock never
217
+ tells you whether you guessed a value you may not read.
218
+ - **Not refused yet** (a later release): product property values, values a deleted facet, option
219
+ group or asset takes off products, filter values a collection's rules assign, a store's price
220
+ written when products are assigned to it, the platform's translation repair, an order import
221
+ filling a customer's empty fields, an order's address saved into an empty address book, and the
222
+ warehouse stock feed.
223
+
133
224
  ```ts
134
225
  // App side — the app authenticates as itself. No token handling.
135
226
  const app = createAdminClient({
@@ -284,7 +375,10 @@ exception — it writes through to the stock location's own field, so omit it to
284
375
  alone and pass `null` to clear it. `get()` answers `null` when a stock location has no profile, and
285
376
  `delete()` answers `false` when there was none; neither is an error. Refusals arrive as
286
377
  `LOCATION_CODE_TAKEN` (`ConflictError`), `INVALID_TIMEZONE` / `INVALID_OPENING_HOURS` /
287
- `INVALID_ADDRESS` (`ValidationError`) and `LOCATION_NOT_FOUND` (`NotFoundError`).
378
+ `INVALID_ADDRESS` (`ValidationError`) and `LOCATION_NOT_FOUND` (`NotFoundError`). A token or app
379
+ installation restricted with `locationIds` changes or clears the `address` (the location's parcel
380
+ sender) only at a location it holds, `LOCATION_NOT_ALLOWED` (`PermissionDeniedError`) otherwise;
381
+ sending the stored address back unchanged passes.
288
382
 
289
383
  An API token can be pinned to a set of them, after which it may only send an `x-location` naming
290
384
  one of those (`LOCATION_NOT_ALLOWED` otherwise, enforced on the Admin API today):
@@ -305,6 +399,195 @@ caller means, so pass `null` to lift the restriction instead.
305
399
  plugins `GET /me` projection still answers `null` for every principal — read `apiTokens.list()` for
306
400
  the restriction a token actually carries until that is wired.
307
401
 
402
+ ### Stock
403
+
404
+ Stock per location by state, the ledger with the reason each movement was made, and adjustments
405
+ that cannot be made without one. The tenant's `location` is the default place for all three.
406
+
407
+ ```ts
408
+ const counter = admin.withTenant({ location: 'the-stock-location-id' });
409
+
410
+ const [here] = await counter.stock.position('variant-id');
411
+ // { onHand, allocated, available, incoming, reserved, trackInventory } — integer units
412
+
413
+ await counter.stock.adjust(
414
+ { variantId: 'variant-id', delta: -2, reason: 'damaged in transit', referenceDocumentRef: 'DMG-114' },
415
+ { idempotencyKey: idempotencyKey() },
416
+ );
417
+
418
+ for await (const movement of await admin.stock.movements('variant-id', { type: 'ADJUSTMENT' })) {
419
+ console.log(movement.quantity, movement.reason, movement.referenceDocumentRef);
420
+ }
421
+ ```
422
+
423
+ `available` is `onHand − allocated − threshold` (the location's out-of-stock threshold, else the
424
+ variant's), exactly what the allocator can still reserve there, and it is **not** floored: an order
425
+ allowed to backorder shows as a negative figure. `reserved` is what a positive threshold holds back.
426
+
427
+ `reason` is required on every adjustment and is yours to word — the platform never interprets it.
428
+ A location another store owns is refused with `LOCATION_NOT_IN_CHANNEL`, and a token or app pinned to some locations (`locationIds`) is refused `LOCATION_NOT_ALLOWED` elsewhere and reads only its own when it names none; at a location you share
429
+ with another store, both see the same levels and movements but each only its own reasons. A
430
+ decrease may not take on-hand below zero (`STOCK_WOULD_GO_NEGATIVE`). The adjustment reaches event
431
+ subscribers as `stock.level_changed` (`cause: 'adjustment'`, no reason) and as `stock.adjusted`,
432
+ which carries the reason and reference and is flagged as personal data.
433
+
434
+ `stock.transfers.*` wraps the stock-transfer module (create → ship → receive → close, or cancel;
435
+ `postConsumption` for a one-location consumption) — its `note` and `cancelReason` stay on the
436
+ transfer. A credential restricted with `locationIds` is refused `LOCATION_NOT_ALLOWED`
437
+ (`PermissionDeniedError`) unless it holds the end the call acts for: the source (whose stock is
438
+ debited) to `create`, `createReturn`, `update`, `ship`, `postConsumption` or `cancel`, the
439
+ destination to `receive`, either end to `close` (the source when the close sends a line back to
440
+ it); it lists, and can `get`, only transfers with an end it holds. Each step reaches event
441
+ subscribers as `stock_transfer.created`, `.shipped`, `.shipment_removed`, `.received`
442
+ (`completed_by`: `receive`, `close`, or `post` for a consumption) and `.cancelled`, which does not
443
+ carry the `cancelReason`: read it with `stock.transfers.cancellation(id)`.
444
+ `stock.allocations.release(orderId)` hands back everything a stuck, unfulfilled order has reserved
445
+ without changing its state.
446
+
447
+ ### Closing a transfer with a shortfall
448
+
449
+ `close` ends an in-transit transfer as received. A line still short is settled per line: `LOST`
450
+ writes its unaccounted units off, `RETURN_TO_SOURCE` credits them back to the source location
451
+ (they never left, or came back). A line `shortfall` does not name is written off as lost only with
452
+ `acceptShortfallAsLost`; otherwise the close is refused, as it is for a line named twice, a line of
453
+ another transfer or a line with nothing outstanding (`ValidationError`, nothing changed).
454
+
455
+ ```ts
456
+ await admin.stock.transfers.close(transferId, {
457
+ acceptShortfallAsLost: true, // every line not named below is written off as lost
458
+ shortfall: [{ lineId, disposition: 'RETURN_TO_SOURCE' }],
459
+ });
460
+ const settled = await admin.stock.transfers.shortfall(transferId); // null if absent
461
+ // { transferId, state, totalLostQuantity, totalReturnedToSourceQuantity,
462
+ // lines: [{ lineId, variantId, sku, quantity, receivedQuantity, lostQuantity,
463
+ // returnedToSourceQuantity, outstandingQuantity }, …] }
464
+ ```
465
+
466
+ `shortfall(id)` is its own request and `get` never asks for the returned quantity, so a caller
467
+ ahead of the engine sees only `shortfall(id)` and a `close` with `shortfall` refused.
468
+
469
+ ### Stock transfer returns
470
+
471
+ Stock sent somewhere temporary (an event, a pop-up) comes back as a **return**: an ordinary
472
+ transfer with its own code, linked to the transfer it reverses. Its source is pinned to that
473
+ transfer's destination, where the goods are; its destination is any other location of the store.
474
+ Each variant is capped at what the transfer received minus its returns that are not cancelled, so
475
+ nothing received means nothing returnable, and what is not returned stays where it is.
476
+
477
+ ```ts
478
+ const links = await admin.stock.transfers.returns(transferId); // ReadStockLocation; null if absent
479
+ // { returnOfTransferId, returnOf, returns: [{ id, code, state, label, sourceLocation,
480
+ // destinationLocation, createdAt, totalQuantity }, …],
481
+ // returnableLines: [{ variantId, sku, variantName, receivedQuantity, returnedQuantity,
482
+ // returnableQuantity }, …] }
483
+ const back = await admin.stock.transfers.createReturn(transferId, {
484
+ destinationLocationId: warehouseId, // any location of the store but the return's source
485
+ label: 'Event ABC',
486
+ lines: [{ variantId, quantity: 12 }], // ≤ returnableQuantity per variant
487
+ });
488
+ await admin.stock.transfers.ship(back.id); // then receive / close / cancel like any transfer
489
+
490
+ const eventReturns = await admin.stock.transfers.list({ returnOfTransferId: transferId });
491
+ const plainTransfers = await admin.stock.transfers.list({ isReturn: false });
492
+ ```
493
+
494
+ Only a `transfer` (not a consumption, not itself a return) in transit or received can be
495
+ returned (`STOCK_TRANSFER_NOT_RETURNABLE`); a quantity above the cap, or a variant not on the
496
+ transfer, is `RETURN_QUANTITY_EXCEEDS_RECEIVED` (also on an `update` of a return draft's lines),
497
+ and an `update` moving a return's source is `RETURN_SOURCE_LOCKED` — each a
498
+ `BusinessRuleError`. `createReturn` needs `AdjustStock` (or the older `UpdateStockLocation`), like
499
+ every transfer write, and a credential restricted with `locationIds` must hold the return's
500
+ source, which is the original transfer's destination. A cancelled return frees its quantity
501
+ again. `returns(id)` is its own request and `get` / `list` select none of its fields, so a caller
502
+ ahead of the engine sees only `returns`, `createReturn` and a list naming the return filters
503
+ refused.
504
+
505
+ ### Stock transfer parcels
506
+
507
+ A transfer shipped with MyParcel as N parcels (`ship(id, { fulfillmentHandlerCode: 'myparcel',
508
+ handlerArgs: { parcelCount: 2, … } })`) is booked as N MyParcel shipments, one label and one
509
+ barcode each. The booking happens inside `ship`, one MyParcel call per parcel, so `ship` (like
510
+ `cancel`, which voids every parcel, and `retryMyparcelShipments`) is allowed up to 120 s; a
511
+ `REQUEST_TIMEOUT` or an abort there does not mean the call failed, so read the transfer again
512
+ before repeating it. The labels and barcodes follow in jobs after `ship` returns.
513
+
514
+ ```ts
515
+ const shipment = await admin.stock.transfers.parcels(transferId); // ReadStockLocation
516
+ // { parcelCount: 2, parcels: [{ ordinal: 1, shipmentId, barcode, labelReady, lastError }, …],
517
+ // trackingCodes, lastError, state, fulfillmentHandlerCode }
518
+ if (shipment?.lastError && shipment.parcels.length < (shipment.parcelCount ?? 1)) {
519
+ await admin.stock.transfers.retryMyparcelShipments(transferId); // books only what is missing
520
+ } else if (shipment?.parcels.some((parcel) => !parcel.barcode)) {
521
+ await admin.stock.transfers.labelMyparcelParcels(transferId); // labels only, one job
522
+ }
523
+ const label = await admin.stock.transfers.myparcelLabel(transferId, 'A6'); // one merged PDF
524
+ ```
525
+
526
+ `parcels` lists the parcels BOOKED so far, so it is shorter than `parcelCount` while the booking
527
+ runs or after it stopped part-way; `lastError` then says why, and `retryMyparcelShipments` books
528
+ exactly the missing ones (a parcel MyParcel already holds is adopted, never booked twice). On a
529
+ draft whose shipment was removed, `lastError` names any parcel MyParcel refused to void. Several
530
+ parcels to a destination outside the EU customs union are refused at `ship`
531
+ (`PARCEL_COUNT_NEEDS_CUSTOMS`) before any stock moves. The merged label is printable only while the
532
+ transfer is in transit (`MYPARCEL_LABEL_NOT_AVAILABLE` otherwise).
533
+
534
+ `labelMyparcelParcels(transferId)` queues one job that labels every booked parcel still missing its
535
+ label or barcode (never asking twice for one that has it) and answers the parcels as they stand;
536
+ read `parcels()` again for the labels. Once every parcel is booked it does what
537
+ `retryMyparcelShipments` does, without the booking step; with a parcel not booked it is refused
538
+ (`MYPARCEL_LABEL_NOT_AVAILABLE`), as it is outside transit.
539
+
540
+ `unretractedShipments` lists the MyParcel shipments a removed shipment or a cancel could not
541
+ retract: MyParcel deletes only concepts, and keeps a registered label. The same record as a
542
+ fulfillment's (`unretractedShipmentsOfOrder`, below); it survives a re-ship.
543
+
544
+ ### Stock transfer cancellation
545
+
546
+ `cancel(id, { reason })` cancels a draft, or an in-transit transfer whose units are back on the
547
+ source (less any recorded lost). It voids every MyParcel parcel itself, but nothing reaches
548
+ Hendriks: a transfer exported to Hendriks is cancelled here while its BluJay order still stands.
549
+
550
+ ```ts
551
+ const before = await admin.stock.transfers.cancellation(transferId); // ReadStockLocation
552
+ if (before?.hendriksExport) {
553
+ // Warn first: after the cancel, Hendriks must be asked to retract this order, or it ships.
554
+ console.warn(`Ask Hendriks to retract ${before.hendriksExport.bluJayOrderNumber}`);
555
+ }
556
+ await admin.stock.transfers.cancel(transferId, { reason: 'Event called off' });
557
+ const after = await admin.stock.transfers.cancellation(transferId);
558
+ // { state: 'cancelled', cancelledAt, cancelReason, cancelledBy: { id, firstName, lastName },
559
+ // hendriksExport: { bluJayOrderNumber } | null }
560
+ ```
561
+
562
+ `cancelledBy` is null for a transfer cancelled before the engine recorded who did. `cancellation`
563
+ is its own request and `get` / `list` / `cancel` select none of its fields, so a caller ahead of
564
+ the engine sees only `cancellation` refused.
565
+
566
+ ### Parcel count suggestion
567
+
568
+ ```ts
569
+ const suggestion = await admin.shipping.parcelCountSuggestion({
570
+ items: [
571
+ { productVariantId: bottleId, quantity: 6 }, // from one order
572
+ { productVariantId: whiteId, quantity: 6 }, // from another
573
+ ],
574
+ destinationCountryCode: 'CH',
575
+ });
576
+ // { count: 1, resolvable: true, exact: true, destinationRequiresCustoms: true, ... }
577
+ ```
578
+
579
+ How many parcels the **active** channel's parcel-count rule gives a set of variant + quantity items,
580
+ for prefilling a fulfilment: a consolidated group sends the selected items of all its orders. Items
581
+ of one variant are summed. `resolvable: false` (`reason: 'NO_PARCEL_RULE'`) means the store has no
582
+ rule and `count` is 1; `exact: false` means some variants lack the data the rule needs, so `count`
583
+ is a lower bound. `reason` and `warnings` are codes (`UNIT_OVER_MAX_WEIGHT`, `CAPPED`), and new ones
584
+ may appear. The destination is optional and only answers `destinationRequiresCustoms`.
585
+
586
+ 1 to 500 items with a quantity of 1 to 100,000 each, and a two-letter country code; anything else is
587
+ refused before any request with `PARCEL_COUNT_INPUT_INVALID` (`ValidationError`). A variant that is
588
+ unknown, or another store's, is `PARCEL_COUNT_VARIANT_NOT_FOUND` (`NotFoundError`). Needs
589
+ `ReadCatalog` or `ReadFulfillment`.
590
+
308
591
  ### Retention and legal holds
309
592
 
310
593
  ```ts
@@ -318,10 +601,76 @@ Always the **active** channel — there is no channel argument, which is what le
318
601
  token hold its own store and reach no other. `retentionClass` is free text (default `standard`) that
319
602
  the platform stores and does not interpret yet. While any hold stands, or `holdUntil` is in the
320
603
  future, a hard delete of the store is refused with `RETENTION_HOLD_ACTIVE` (`BusinessRuleError`).
604
+ A named hold also refuses every customer erasure on the store (`customers.erase`); the `holdUntil`
605
+ window alone does not, because an erasure keeps the records the window protects.
321
606
  `release()` lifts only the calling principal's own hold: another's is `RETENTION_HOLD_NOT_YOURS`
322
607
  (`PermissionDeniedError`) unless you are a SuperAdmin, which releases every hold. A bad class or
323
608
  instant is `RETENTION_INPUT_INVALID` (`ValidationError`).
324
609
 
610
+ A **sandbox store** (`platform.provisionStore({ sandbox: true })`) bypasses holds: a hold can be
611
+ placed on it, but never stops its hard delete. The bypass leaves an audit row
612
+ (`retention.hold.sandbox_bypass`) naming what it stepped over.
613
+
614
+ ### Transactional emails per event
615
+
616
+ ```ts
617
+ const configs = await admin.tenant.notifications.list(); // one entry per notification event
618
+ await admin.tenant.notifications.update('order_placed', { sender: 'none' }); // your system sends it
619
+ await admin.tenant.notifications.update('order_placed', {
620
+ enabled: true,
621
+ recipients: { bcc: [{ type: 'text', value: 'orders@example.com' }] }, // replaces that list only
622
+ });
623
+ ```
624
+
625
+ `sender: 'none'` stops the platform sending that email; your own system sends one in its place from
626
+ the matching platform event, and every dispatch the platform skips is still written to the store's
627
+ email delivery log (status `skipped`, `metadata.skipReason: 'SENDER_NONE'`). Each accepted update
628
+ emits one `notification_config.changed` with the resulting `enabled` and `sender`. Identity emails
629
+ (the `account` category: password reset, magic link, invitation, email change, email and chat
630
+ verification) refuse `none` with `EVENT_ALWAYS_PLATFORM_SENT` (`ValidationError`): they carry a
631
+ one-time credential no platform event carries. Every entry's `replacementEventTypes` names the
632
+ platform events your system sends that email from (`order_placed` → `['order.created']`); where it
633
+ is `null` no emitted event replaces the email and `none` is refused with `NO_REPLACEMENT_EVENT`
634
+ (`ValidationError`), so a store never silently loses an email. An unknown event is `NOTIFICATION_EVENT_NOT_FOUND`
635
+ (`NotFoundError`). `password_reset`, `email_change` and `account_setup` cannot be switched off: each is
636
+ the only way a customer resets a password (or sets the first one on an imported account), confirms
637
+ a new address, or finishes a storefront registration. `enabled: false` on them is refused with
638
+ `EVENT_ALWAYS_ENABLED` (`ValidationError`, the message naming the event's reason), and `list`
639
+ always answers them enabled. `magic_link_login` and `customer_invite` can be switched off. Both methods need `UpdateSettings` and act on the active channel.
640
+
641
+ The identity emails (the `account` category) and `new_customer` carry sign-in links or codes, and
642
+ one login serves the person in every store, so they go to the account holder alone: their
643
+ `recipients` are fixed (`to` the `customer_email` variable, `cc` and `bcc` empty), `list` answers
644
+ exactly that, and any other list is refused with `IDENTITY_EMAIL_RECIPIENTS_FIXED`
645
+ (`ValidationError`, `details[].field` naming each offending list). Resending what `list` answered
646
+ is accepted.
647
+
648
+ A store that takes `settings.email` from its organization (`admin.tenant.settingsSources`) sends the
649
+ emails the platform sends for it by the organization's `enabled` and recipients (the platform default
650
+ for an event the organization has none for), and `list` answers what applies. `sender` stays the
651
+ store's own, and an email your system already sends (`sender: 'none'`) keeps the store's own entry
652
+ whole, so the organization never changes what your head must do. `update` refuses any change,
653
+ `sender` included, with `ORGANIZATION_SETTINGS_INHERITED`, or `MANAGED_BY_ORGANIZATION` when the
654
+ organization locked the group (both `BusinessRuleError`, 422); resending an entry unchanged is
655
+ accepted. Override the group to change them. The From address is always the store's own gateway's.
656
+
657
+ ### Parcel rule
658
+
659
+ ```ts
660
+ await admin.shipping.parcelRule.get(); // { channelId, defaultParcelCapacity, maxParcelWeightGrams }
661
+ await admin.shipping.parcelRule.set({ defaultParcelCapacity: 12 }); // 12 items per parcel
662
+ await admin.shipping.parcelRule.set({ maxParcelWeightGrams: 20_000 }); // other key unchanged
663
+ await admin.shipping.parcelRule.set({ defaultParcelCapacity: null }); // no rule: one parcel
664
+ ```
665
+
666
+ How the **active** channel counts parcels for extra-parcel shipping charges. `set` is a patch: a key
667
+ left out is unchanged and `null` clears it. `defaultParcelCapacity` is a whole number from 1 to
668
+ 10,000 (a variant may override it); `maxParcelWeightGrams` is 1 to 70,000 grams and only adds
669
+ parcels when variants carry a package weight. A value out of range, or an empty input, is refused
670
+ before any request with `PARCEL_RULE_INVALID` (`ValidationError`); the engine enforces the same
671
+ bounds for every other writer. Writing needs `UpdateChannel`. Pricing reads the rule from the
672
+ engine's channel cache, so another process sees a change once that cache refreshes.
673
+
325
674
  ### Audit
326
675
 
327
676
  The tenant's own forensic trail. It needs the `ReadAudit` permission, which is deliberately outside
@@ -342,6 +691,74 @@ and the two arrays are meant to be read together. `metadata`, `requestBody` and
342
691
  their own key spellings, because they are evidence of what a vendor actually sent. A trace this
343
692
  tenant did not touch answers two empty arrays rather than an error.
344
693
 
694
+ ### Provenance: who changed it last
695
+
696
+ Every change to a product, a variant or a customer records who made it, per **field group**:
697
+ `price`, `stock`, `product_details` and `customer_details`, the four groups an app can be granted.
698
+ Staff, apps, API keys, imports and shoppers are all recorded, in the change's own transaction.
699
+
700
+ ```ts
701
+ const groups = await admin.provenance.get('VARIANT', variantId);
702
+ for (const group of groups) {
703
+ // Show "Managed by <name>" only when managedBy is set: an app or key of this store made the
704
+ // last change of that group. A staff, customer or job change clears it.
705
+ console.log(group.fieldGroup, group.actor.type, group.managedBy?.label ?? '-', group.writtenAt);
706
+ }
707
+
708
+ const page = await admin.provenance.list('PRODUCT', productIds); // at most 250 ids; one page
709
+ ```
710
+
711
+ It keeps the **last writer only**, one row per record and group. What changed and when, in order,
712
+ is the platform event log filtered by the record (`GET /event-log?aggregate_type=&aggregate_id=` in
713
+ the plugins service, with `ReadPlatformEvents`).
714
+
715
+ Only the store you act on: a record the store does not hold, and one it holds with nothing recorded
716
+ since provenance began, both answer no rows (`get` throws `NotFoundError` `ENTITY_NOT_FOUND`). Products
717
+ and variants need `ReadCatalog` or `ReadProduct`, customers `ReadCustomer`. `actor.label` is the
718
+ app's or key's name, a job's `system:<name>`, or a staff member's name for a caller holding
719
+ `ReadAdministrator`; it is never a customer's name. More than 250 ids is refused with
720
+ `PROVENANCE_REQUEST_INVALID` (`ValidationError`).
721
+
722
+ ### Staff: adding a person to a store
723
+
724
+ `users.ensure` brings a person to exactly the roles you name in the active store, as one call that
725
+ is safe to repeat. Roles are named by store role template (`admin`, `orders`, `warehouse`,
726
+ `catalog`, `content`, `viewer`; the store's `<store code>-<template>` role, created the first time a
727
+ store needs it) or by the id of a role of the store.
728
+
729
+ ```ts
730
+ const { outcome, administrator, invitationSent } = await admin.users.ensure({
731
+ emailAddress: 'jane@example.com',
732
+ roles: [{ template: 'orders' }],
733
+ });
734
+ // outcome: 'created' a new account, invited by email to set a password
735
+ // 'attached' an existing account: its roles of this store alone replaced, others kept
736
+ // 'restored' a removed member of this store, brought back and invited again
737
+ // 'unchanged' already exactly these roles here: nothing written, nothing mailed
738
+
739
+ await admin.users.update({ id: administrator.id, firstName: 'Jane', lastName: 'Doe' });
740
+ ```
741
+
742
+ - **Repeatable.** The same call again answers `unchanged`, so a retry after a lost answer is safe
743
+ and mails nobody twice. Two calls for one address at once make one account.
744
+ - **One email per real change.** A new account and a restored one are invited; so is an account
745
+ given its first role in the store. Someone who already works there and only changes role is not
746
+ mailed.
747
+ - **Your own ceiling.** Adding someone (a restore included), or a template role the store does
748
+ not have yet, needs `CreateAdministrator`; changing the roles of someone who already works in
749
+ the store `UpdateAdministrator`. Every role you hand out, and the account you change, must be
750
+ within your own permissions (`ROLE_CEILING_NOT_ALLOWED`, `ADMINISTRATOR_INVITE_NOT_ALLOWED`,
751
+ both 403).
752
+ - **API tokens** may only invite a NEW address, with roles within the token's own permissions and
753
+ no location restriction (`TOKEN_INVITE_NOT_ALLOWED`); repeating an invite the account already
754
+ satisfies answers `unchanged`.
755
+ - **Stores only.** An organization's workspace is refused (`ORGANIZATION_WORKSPACE_NOT_A_STORE`):
756
+ its `<code>-admin` role is the organization admin role.
757
+ - A failed invitation email changes nothing (`INVITATION_EMAIL_NOT_SENT`, 422): repeat the call.
758
+
759
+ `users.update` changes a staff member's names or login email (an API token may not change the
760
+ login email). Roles change through `ensure`.
761
+
345
762
  ### Tenancy
346
763
 
347
764
  A client with no `tenant.scope` may only call `auth.*` and `channels.byCode()`; every other module
@@ -360,7 +777,7 @@ write is never deduplicated.
360
777
  |---|---|
361
778
  | `content.forms.upsertBySlug` | yes — the CMS replays the first response for the same key |
362
779
  | `seo.redirects.create` | yes — the plugins service does the same |
363
- | `locations.*`, `tenant.retention.*`, `apiTokens.*` | no — they are Admin API writes |
780
+ | `locations.*`, `catalog.*`, `shipping.*`, `tenant.retention.*`, `tenant.notifications.*`, `apiTokens.*` | no — they are Admin API writes |
364
781
  | everything else | no; the key is recorded on the wire and acts on nothing |
365
782
 
366
783
  Where it is honoured, the same key with a **different** body is refused with 409. This SDK never
@@ -389,11 +806,11 @@ meta.settingsSchemas.ai_bot_defaults; // …keyed by SETTING name, not c
389
806
 
390
807
  | Member | What it holds |
391
808
  |---|---|
392
- | `labels` | `orderState`, `returnState`, `returnReason`, `paymentStatus`, `fulfillmentStatus`, `fulfillmentHoldReason`, `subscriptionStatus`, `quoteStatus`, `settlementMethod`, `creditNoteKind`, `auditService`, `auditCategory`, `auditEventOutcome`, `auditCallOutcome` |
809
+ | `labels` | `orderState`, `returnState`, `returnReason`, `paymentStatus`, `fulfillmentStatus`, `fulfillmentHoldReason`, `subscriptionStatus`, `quoteStatus`, `settlementMethod`, `creditNoteKind` (retail), `arCreditNoteKind` (accounts receivable), `auditService`, `auditCategory`, `auditEventOutcome`, `auditCallOutcome`, `notificationSender` |
393
810
  | `transitions` | `quote`, `return` (`from` → allowed `to`), `fulfillmentRank` |
394
811
  | `permissions` | `catalog`, `groups` (the role-editor grid), `roleTemplates`, `tokenTemplates` |
395
812
  | `plugins` | `catalog`, `groupLabels` |
396
- | `settingsSchemas` | a schema per opaque `Channel.customFields` column (ten today) |
813
+ | `settingsSchemas` | a schema per opaque `Channel.customFields` column (eleven today) |
397
814
 
398
815
  **Member names are camelCase; the keys inside them are not.** Everything below a member is keyed by
399
816
  platform data — `items_received`, `past_due`, `PaymentSettled`, `order_status_rules`, a schema's own
@@ -404,12 +821,21 @@ Three things the settings schemas do not say out loud:
404
821
 
405
822
  - each describes the **decoded** value, while the column stores `JSON.stringify` of it — so
406
823
  validate `JSON.parse(customFields.socialLinks)` and write a string back;
407
- - a schema states the shape, not every rule. Only four of the ten columns are validated server-side
408
- at all — for the other six a bad value is not refused on write, it is dropped or ignored on read,
409
- with no error anywhere (each description says which it is). And one rule is inexpressible:
824
+ - a schema states the shape, not every rule. Every column is refused on write when malformed, and
825
+ each description names the refusal. Six (`social_links`, both exempt-id lists,
826
+ `order_status_rules`, `order_deposit_policy`, `ai_bot_defaults`) answer `CHANNEL_SETTINGS_INVALID`
827
+ through `createChannel`/`updateChannel`: a `ValidationError` whose `details[].field` is
828
+ `customFields.<column>` and whose `details[].code` is the JSON Schema keyword the value broke
829
+ (`json` when it is not JSON at all), with the place inside the value in the message. Their rule
830
+ IS the published schema, plus one the schema cannot state: the two exempt-id lists hold at most
831
+ 255 characters of JSON (`maxLength`). An update that sends a column's stored value back
832
+ unchanged is not judged, so a value stored before the check is still dropped or ignored on read
833
+ (each description says which). Some rules are inexpressible or looser:
410
834
  `shipping_countries` and `tax_exclusive_countries` both refuse `["US","us"]` on write as a
411
- duplicate, which `uniqueItems` cannot see. Schema-valid means "worth sending", not "certain to be
412
- accepted" — handle the `ValidationError`, and do not skip your own validation;
835
+ duplicate, which `uniqueItems` cannot see, while `commerce_path_prefix_translations` and
836
+ `storefront_stock_location_ids` are judged by a check looser than their schema. Schema-valid
837
+ means "worth sending", not "certain to be accepted" — handle the `ValidationError`, and do not
838
+ skip your own validation;
413
839
  - `transitions` is the state machine, not the whole gate. `return.approved → settled` is a legal
414
840
  edge that the service still refuses when the channel requires a receipt first, and
415
841
  `quote.approved → converted_to_order` is refused when the projected order misses the channel's
@@ -481,6 +907,157 @@ Read with `ReadSettings` and written with `UpdateSettings` — the same grants t
481
907
  `storefrontUrl` itself. A head an app installed (`installationId` set) is managed through that app's
482
908
  declarations and is refused here with `HEAD_APP_OWNED`.
483
909
 
910
+ ### Fulfillment parcels
911
+
912
+ A fulfillment can ship as several parcels: one carrier shipment and one barcode each.
913
+
914
+ ```ts
915
+ const parcels = await admin.fulfillments.parcelsOfOrder(orderId); // ReadOrder
916
+ const tracking = await admin.fulfillments.parcelTracking(fulfillmentId); // warehouse entitlement
917
+ const label = await admin.fulfillments.myparcelLabel(fulfillmentId, 'A6'); // one merged PDF
918
+ const paid = await admin.fulfillments.chargedParcelCount({ stockLocationId, orderId }); // no ReadOrder
919
+ const asked = await admin.fulfillments.labelMyparcelParcels(fulfillmentId); // UpdateOrder
920
+ // { fulfillmentId, parcels: 2, missing: 1, queued: true }
921
+ const kept = await admin.fulfillments.unretractedShipmentsOfOrder(orderId); // ReadOrder
922
+ // [{ fulfillmentId, shipments: [{ shipmentId, verdict: 'KEPT', billable: true, resolvedAt: null, … }] }]
923
+ ```
924
+
925
+ `parcelTracking().parcelTrackingCodes[i]` is parcel i+1's own barcode, `null` until it has one; the
926
+ whole list is `null` when the carrier bookkeeping could not be read. `parcelsOfOrder()` returns
927
+ the carrier's barcode list only while it starts with the fulfillment's `trackingCode`: a code typed
928
+ at ship replaces `trackingCode` alone, and the list is then stale, so you get `[trackingCode]`.
929
+
930
+ `labelMyparcelParcels(fulfillmentId, { format? })` acquires the label of every parcel that still
931
+ misses its label or barcode, in one job, and reads each barcode back. `queued: false` means every
932
+ parcel already had both and nothing was queued; `queued: true` means the job was queued, and the
933
+ labels land when it has run, so re-read the fulfillment rather than trusting the answer as the
934
+ outcome. With a `format` it asks again for every parcel's label in that format (the operator's
935
+ request, even beside a ready label). It labels only: a parcel never booked is booked by the
936
+ booking's Retry or a recreate. A refusal throws its coded error (`MYPARCEL_ORDER_NOT_FOUND_ERROR`
937
+ for another store's fulfillment, `MYPARCEL_VALIDATION_ERROR` for a cancelled one or one holding no
938
+ shipment) with the engine's message; a blank id or a format other than `A4`/`A6` is refused
939
+ before any request with `MYPARCEL_LABEL_INPUT_INVALID`.
940
+
941
+ `unretractedShipmentsOfOrder(orderId)` is, per fulfillment, what a cancel, a recreate or an
942
+ abandoned booking could not retract at MyParcel, which deletes only concepts. Each entry's label
943
+ must not be handed to the carrier. `verdict` is `KEPT` (MyParcel keeps it), `UNREACHABLE` (not
944
+ deleted yet: MyParcel could not be reached, or refused; the engine retries), or `DELETED` /
945
+ `ALREADY_GONE` for an entry a later attempt resolved (`resolvedAt` set; it stays listed).
946
+ `billable` is `true`, `false` or `null`, and `null` means unknown. Times are ISO-8601 UTC. Its own
947
+ request: an engine without the field refuses this call alone.
948
+
949
+ `chargedParcelCount()` is the parcel count the customer paid shipping for, read from the order's
950
+ "To ship" entry at that warehouse, so the Warehouse role can prefill it. It is a number only while
951
+ that entry holds the whole order — every line, nothing of it in a fulfillment yet — since the
952
+ count was paid for the whole order; otherwise, and for an order not priced per parcel, it is
953
+ `null`. Pass the `includeIneligible`/`routing` the entry you show was read with.
954
+
955
+ ### Cancelling a fulfillment whose delivery charge was paid
956
+
957
+ Cancelling a fulfillment takes its delivery charge off every order it charged. Where the customer
958
+ had already paid it, the cancel says for EACH such order how the money goes back: a refund to their
959
+ payment, or a credit to their account (offered only where the store keeps the customer's balance
960
+ itself).
961
+
962
+ ```ts
963
+ const { overpayments } = await admin.fulfillments.cancelPreview(fulfillmentId); // UpdateOrder
964
+ // [{ orderId, orderCode, amount: 605, currencyCode: 'EUR',
965
+ // refund: { available: true, code: null, message: null },
966
+ // accountCredit: { available: false, code: 'AR_ACCOUNT_REQUIRED', message: '…' } }]
967
+ const { fulfillment, settlements } = await admin.fulfillments.cancel(
968
+ fulfillmentId,
969
+ { overpayment: overpayments.map(({ orderId }) => ({ orderId, choice: 'REFUND' })) },
970
+ { idempotencyKey }
971
+ );
972
+ // settlements: [{ orderId, orderCode, amount: 605, currencyCode: 'EUR', choice: 'REFUND' }]
973
+ ```
974
+
975
+ An order owing money with no choice is refused `FULFILLMENT_CANCEL_OVERPAYMENT_CHOICE_REQUIRED`
976
+ (a `BusinessRuleError`), before anything is written and before the carrier is told; so is core's
977
+ `transitionFulfillmentToState(id, "Cancelled")`, which cannot carry a choice. Neither way open for
978
+ an order is `FULFILLMENT_CANCEL_OVERPAYMENT_NOT_SETTLEABLE`; a closed way chosen anyway answers its
979
+ own refusal (`REFUND_METHOD_UNAVAILABLE`, `AR_ACCOUNT_IN_ERP`, …). The refund or credit is QUEUED
980
+ with the cancel and made right after it commits, so read the order again for the refund itself; if
981
+ it cannot be made, the order's history says so and why. A cancel made with no merchant present (an
982
+ integration's own path) refunds automatically.
983
+
984
+ ### Product feeds
985
+
986
+ A feed turns the catalogue into the XML a channel hands Google, Meta, TikTok or Pinterest. The
987
+ configuration is the destination, the locale it is built in, and a **rule AST** that decides every
988
+ output field.
989
+
990
+ ```ts
991
+ const feed = await admin.feeds.create({
992
+ name: 'Google NL',
993
+ destination: 'google',
994
+ languageCode: 'nl',
995
+ country: 'NL',
996
+ currencyCode: 'EUR',
997
+ mappingJson: {
998
+ fields: [
999
+ {
1000
+ target: 'g:color',
1001
+ rules: [
1002
+ {
1003
+ when: { logic: 'and', conditions: [{ field: 'facet:colour', op: 'not_blank' }] },
1004
+ value: { kind: 'lookup', source: { kind: 'field', field: 'facet:colour' }, tableName: 'colour_map' },
1005
+ },
1006
+ ],
1007
+ transforms: [{ kind: 'truncate', maxLen: 70 }],
1008
+ },
1009
+ ],
1010
+ },
1011
+ });
1012
+
1013
+ const preview = await admin.feeds.preview({ ...draft, sampleSize: 20 }); // persists NOTHING
1014
+ await admin.feeds.regenerate(feed.id);
1015
+ const runs = await admin.feeds.runs(feed.id); // newest first, failed runs included
1016
+ const issues = await admin.feeds.issues(feed.id); // the LATEST successful run's findings
1017
+ await admin.feeds.rotateToken(feed.id); // the old public URL stops resolving
1018
+ ```
1019
+
1020
+ `admin.feeds.destinations()` is the registry a builder offers targets from: per destination a
1021
+ `template` of `{ target, required, maxLen?, defaultSource? }`, where `defaultSource` fills a target
1022
+ the channel's own mapping left empty.
1023
+
1024
+ Four namespaces hang off the same noun, because they are one workflow:
1025
+
1026
+ | Namespace | What it holds |
1027
+ |---|---|
1028
+ | `internalFields` | computed values a mapping references as `internal:<name>`; `position` is server-assigned and a field may only read fields at a strictly LOWER position |
1029
+ | `lookupTables` | ordered substitution tables a `{ kind: 'lookup', tableName }` resolves through; first match wins, a miss yields `''`, and `entriesJson` is replaced WHOLESALE |
1030
+ | `categoryMappings` | the catalogue mapped onto the Google product taxonomy (`collection` → `facet_value` → `default`, only `approved` rows apply), plus `bulkStatus` for deciding a review queue by id or by confidence |
1031
+ | `taxonomy` | ranked search over the vendored Google taxonomy; the one part of this module that is not channel-scoped |
1032
+ | `ai` | queue a categorisation pass over the channel's collections, or propose a better `title` mapping for one feed |
1033
+
1034
+ **The rule payloads ride verbatim.** `mappingJson`, `filtersJson`, `settingsJson`, `rulesJson`,
1035
+ `entriesJson`, `perDestinationJson`, `payloadJson`, `statsJson` and a template's `defaultSource` are
1036
+ stored and echoed byte-for-byte, so the AST inside them stays camelCase (`tableName`, `maxLen`,
1037
+ `caseInsensitive`, `excludeOutOfStock`) while every DTO around them converts. The same holds for the
1038
+ identifier-keyed maps a preview returns — `values` is keyed by feed target (`custom_label_0`),
1039
+ `appliedInternal` by internal-field name, `issueCounts` by issue code. Nothing inside any of them is
1040
+ renamed; a corrupted `table_name` resolves to the empty string at generation time and reaches a
1041
+ merchant as a blank field in a live feed, never as an error.
1042
+
1043
+ Rules are compiled before they are written, so a bad one is a `ValidationError` with code
1044
+ `INVALID_RULES` whose `details[0].field` is the AST coordinate that failed
1045
+ (`mapping.fields[2].rules[0].value.pattern`) — enough to put the message on the rule that caused it.
1046
+ Deleting an internal field or a lookup table a rule still references is a `ConflictError`
1047
+ (`FIELD_IN_USE` / `TABLE_IN_USE`).
1048
+
1049
+ The AI methods split availability two ways on purpose: the READS answer 200 with
1050
+ `aiAvailable: false` when the platform has no model configured, so a page can render the hint
1051
+ without an error state, while the WRITES refuse with a 409 `AI_UNAVAILABLE` — `isAiUnavailable(error)`
1052
+ tells that apart from a model that merely failed, which is worth retrying. Nothing the model
1053
+ produces is ever applied on its own: a pass writes `suggested` category mappings for
1054
+ `categoryMappings.bulkStatus` to decide, and a title preset is a proposal until
1055
+ `ai.acceptSuggestion(id)` merges it through the same compile-before-write path a hand-edited
1056
+ mapping takes.
1057
+
1058
+ Reads need `ReadCatalog` and writes `UpdateCatalog`, and every route is gated on the
1059
+ `product-feeds` entitlement.
1060
+
484
1061
  ### Tax classes
485
1062
 
486
1063
  Tax categories and their rates are **platform-wide**, shared by every store. A tax class is your
@@ -512,6 +1089,338 @@ order, because `delete` is refused with `TAX_CLASS_IN_USE` while variants still
512
1089
  Read with `ReadSettings` and written with `UpdateSettings` — deliberately not the tax-category
513
1090
  permissions, which govern the table every store shares.
514
1091
 
1092
+ ### Catalog
1093
+
1094
+ Products, variants, option groups, facets, collections, assets, storefront visibility and product
1095
+ properties, one namespace each (`admin.catalog.*`). Lists are `Page`s over the Admin API's
1096
+ `skip/take`; every following page keeps the filter and the per-call tenant.
1097
+
1098
+ ```ts
1099
+ const size = await admin.catalog.options.groups.create({
1100
+ code: 'size',
1101
+ translations: [{ languageCode: 'en', name: 'Size' }],
1102
+ options: [
1103
+ { code: 'm', translations: [{ languageCode: 'en', name: 'M' }] },
1104
+ { code: 'l', translations: [{ languageCode: 'en', name: 'L' }] },
1105
+ ],
1106
+ });
1107
+ const product = await admin.catalog.products.create({
1108
+ translations: [{ languageCode: 'en', name: 'Linen shirt', slug: 'linen-shirt' }],
1109
+ availabilityState: 'preorder', // none | comingSoon | preorder
1110
+ availableFrom: '2026-11-01T09:00:00+01:00', // an offset is required
1111
+ preorderLimit: 50,
1112
+ });
1113
+ await admin.catalog.products.addOptionGroup(product.id, size.id);
1114
+ await admin.catalog.variants.create([
1115
+ {
1116
+ productId: product.id,
1117
+ sku: 'LS-M',
1118
+ translations: [{ languageCode: 'en', name: 'Linen shirt M' }],
1119
+ optionIds: [size.options[0]!.id],
1120
+ price: 4995,
1121
+ priceBasis: 'gross', // must be the store's own basis
1122
+ taxClassKey: 'reduced', // the engine sets taxCategoryId from it
1123
+ },
1124
+ ]);
1125
+ await admin.catalog.properties.values.set(product.id, [{ propertyId, value: 'Linen' }]);
1126
+ ```
1127
+
1128
+ - **Prices state their basis.** A price without `priceBasis` is refused (`PRICE_BASIS_REQUIRED`), and
1129
+ a basis other than the store's `pricesIncludeTax` is refused (`PRICE_BASIS_MISMATCH`) rather than
1130
+ converted — the rate that would convert it depends on each order's zone.
1131
+ On read, `price` is always net and `priceWithTax` always gross: a read-modify-write on a gross
1132
+ store starts from `priceWithTax`, never from `price`.
1133
+ - **Tax class.** `taxClassKey` on a variant write is resolved by the engine, for the store's own
1134
+ business country (`*` when it names none), into the `taxCategoryId` written in the same
1135
+ transaction. `TAX_CLASS_NOT_FOUND`, `TAX_CLASS_UNMAPPED`, and `INVALID_TAX_CATEGORY_FOR_CLASS`
1136
+ (a `taxCategoryId` that contradicts the class) refuse the whole write. Editing a class's mappings
1137
+ later does not re-price existing variants: write the variant again to pick the new mapping up.
1138
+ - **Parcel rule.** A variant's `parcelCapacity` (units per parcel, 1 to 10,000; null uses the
1139
+ store's items per parcel, or 1 per parcel when packing is `separate`) and `parcelPacking` (`shared` | `separate` | `none`) feed the
1140
+ shipping calculator's parcel count. `shared` is stored as null and an unset packing reads as
1141
+ `shared`. Out-of-range values are refused before sending (`INVALID_PARCEL_CAPACITY`,
1142
+ `INVALID_PARCEL_PACKING`).
1143
+ - **Not atomic across resources.** A product form is several calls here and they are not one
1144
+ transaction; `products.save()` as one intent operation is a later phase. No catalogue write
1145
+ deduplicates on `idempotencyKey`, except `products.bulk` (below).
1146
+ - **Feed identifiers.** A product's `brand` and a variant's `gtin` (custom fields the typed inputs
1147
+ here do not carry; a bulk row's `customFields` does) are judged when a write makes them NEW or
1148
+ CHANGED: a brand over 70 characters or a GTIN that is not 8, 12, 13 or 14 digits (after
1149
+ trimming) is refused `FEED_IDENTIFIER_INVALID` (`ValidationError`, 400), creates included, with
1150
+ `details[].field` an RFC 6901 pointer into the call (`/input/customFields/brand`,
1151
+ `/input/0/customFields/gtin`). A value the record already holds passes unchanged, so an older
1152
+ stored value never blocks a write of the record's other fields.
1153
+ - **Deletes** the engine declines (`NOT_DELETED`, at HTTP 200) throw a `ConflictError` named
1154
+ `<RESOURCE>_NOT_DELETED` carrying the engine's reason; `force` exists where the engine has it.
1155
+ - Collection membership is derived from the collection's `filters` and recomputed in a background
1156
+ job, so a product appears shortly after the write, not during it.
1157
+
1158
+ #### Putting a product in collections
1159
+
1160
+ ```ts
1161
+ // Put the product in Summer, and touch nothing else.
1162
+ await admin.catalog.products.setCollections(productId, [summerId], { from: [] });
1163
+ // Take it out of Sale only.
1164
+ await admin.catalog.products.setCollections(productId, [], { from: [saleId] });
1165
+ // A screen listing the product's collections: what the merchant left selected, and what it loaded.
1166
+ const result = await admin.catalog.products.setCollections(productId, selectedIds, {
1167
+ from: loadedIds,
1168
+ });
1169
+ // { added: [...], removed: [...], unchanged: [...], notRemoved: [{ collectionId, reason }] }
1170
+ ```
1171
+
1172
+ - **Hand-picked membership only.** The engine adds the product to the hand-picked list
1173
+ (`product-id-filter`) of each collection it should join, and takes it off the lists of each one it
1174
+ should leave. A collection it is in only through other filters (facet values, names) is never
1175
+ rewritten: it comes back in `notRemoved` with `MEMBER_BY_FILTER`, and the product stays in it.
1176
+ - **Safe beside other writers.** One transaction; every collection written is row-locked first, so
1177
+ two products saved into one collection at once both stay listed. Never put a product in a
1178
+ collection with `collections.update` and a filter list you read earlier: that write replaces the
1179
+ list another writer may have just changed.
1180
+ - **`from` applies only your change.** With `from`, only the difference between it and the list
1181
+ you send is applied; every other collection stays as it is, including one someone else changed
1182
+ since you read. Without it, the list you send is the WHOLE hand-picked membership: the product
1183
+ leaves every other collection that lists it by hand.
1184
+ - **All or nothing**, refused before anything is written: `PRODUCT_COLLECTIONS_INPUT_INVALID`
1185
+ (a collection named twice, more than `MAX_PRODUCT_COLLECTION_IDS`), `PRODUCT_COLLECTION_FILTER_INVALID`
1186
+ (a collection's hand-picked list cannot be read; the message names it), `SHARED_COLLECTION_NOT_ALLOWED`,
1187
+ `MANAGED_BY_ORGANIZATION` (a collection of the store's organization), `ENTITY_NOT_FOUND`.
1188
+ - A repeat of a call that went through writes nothing and answers `unchanged`, except that a
1189
+ collection the product stays in through other filters answers `notRemoved` again (also, for a
1190
+ collection with such filters, until its membership job has run). Requires a platform
1191
+ release that serves `setProductCollections`.
1192
+
1193
+ #### Many products at once
1194
+
1195
+ ```ts
1196
+ const handle = await admin.catalog.products.bulk(
1197
+ { action: 'DISABLE', productIds: selectedIds }, // ENABLE | DISABLE | DELETE | CLONE, 1 to 50 ids
1198
+ { idempotencyKey } // minted when omitted
1199
+ );
1200
+ const run = await handle.wait(); // or later: admin.catalog.products.bulkRun(handle.runId)
1201
+ for (const item of run.items) {
1202
+ if (item.status === 'FAILED') console.log(item.id, item.code, item.message);
1203
+ }
1204
+ // a CLONE item's `newId` is the copy
1205
+ ```
1206
+
1207
+ - **A job, answered at once.** The call answers a receipt (`runId`) as soon as the run is queued;
1208
+ `wait()` polls it until it ends and resolves with it whatever the ending. `run.items` holds one
1209
+ outcome per product reached, in the order you named them; `status` is the RUN's (`SUCCEEDED`
1210
+ means every product has an outcome, a `FAILED` one included).
1211
+ - **Each product on its own.** Every product goes through its single door (`update` with
1212
+ `enabled`, `delete`, `clone`) in a transaction of its own, so a refused product leaves the others
1213
+ done: another store's product is `ENTITY_NOT_FOUND`, a product another store also holds
1214
+ `SHARED_PRODUCT_NOT_ALLOWED`, one the store's organization holds `MANAGED_BY_ORGANIZATION`, an
1215
+ owned field group `FIELD_GROUP_LOCKED` (a delete takes every group; `enabled` is in none), the
1216
+ external-line carrier `EXTERNAL_LINE_CARRIER_PROTECTED`, a delete core declines
1217
+ `PRODUCT_NOT_DELETED`.
1218
+ - **One key, one run.** The same key with the same action and ids answers the same run, so a
1219
+ resubmit never clones twice; another selection under it is `IDEMPOTENCY_CONFLICT`.
1220
+ - **No dry run**: the doors' refusals are made under the write, so a rehearsal could not promise
1221
+ the real outcome.
1222
+ - **At most `MAX_BULK_PRODUCTS` (50) per run**, each named once (`BULK_ACTION_INPUT_INVALID`,
1223
+ refused before sending too); split a larger selection. Needs the single door's permission for
1224
+ the action (`BULK_ACTION_NOT_ALLOWED` otherwise); reading a run needs `ReadCatalog` or
1225
+ `ReadProduct`, and another store's run is `BULK_ACTION_RUN_NOT_FOUND`. Requires a platform release
1226
+ that serves `bulkProductAction`.
1227
+
1228
+ #### Syncing a property's filter values
1229
+
1230
+ ```ts
1231
+ // After renaming a content item (or a product) a filterable property links to:
1232
+ const result = await admin.catalog.syncContentFacets(propertyId);
1233
+ // { propertyId, items: 12, unresolved: ['…'], fieldFacets: 2 }
1234
+ ```
1235
+
1236
+ - **The engine reads the labels.** A filterable `content_item` or `product` property mirrors each
1237
+ item or product its values name as a filter value. The call gives every one of them its current
1238
+ label: the content item's title (read from the store's content, drafts and archived items
1239
+ included) or the product's name in the request's language. Send nothing but the property id.
1240
+ - **Field-level facets follow the saved configuration.** For a `content_item` property saved with
1241
+ fields to filter by, the products' field-facet values follow the items' field values. What the
1242
+ property was saved with decides, never an unsaved form.
1243
+ - **One transaction**, labels and field facets together. The labels are read first, so a content
1244
+ system that cannot be read refuses the call (`CMS_REQUEST_FAILED`, try again) before anything
1245
+ changes. Repeating the call is safe: an unchanged label is not written again.
1246
+ - `unresolved` names the ids the engine could not label (a removed content item, a product this
1247
+ store does not hold): their filter values keep the label they have.
1248
+ - **Nothing runs it automatically yet**: call it after renaming a content item or a product.
1249
+ - Refusals: `PROPERTY_NOT_FOUND` (404), `PROPERTY_FACET_SYNC_INVALID` (400, not filterable or
1250
+ another type), `MANAGED_BY_ORGANIZATION` (422, the organization's property),
1251
+ `SHARED_FACET_NOT_ALLOWED` / `SHARED_PRODUCT_NOT_ALLOWED` (403), `CMS_REQUEST_FAILED` and
1252
+ `PROPERTY_SYNC_SOURCE_UNAVAILABLE` (422). Requires a platform release that serves
1253
+ `syncPropertyFacets`.
1254
+
1255
+ #### An image's descriptions (alt text)
1256
+
1257
+ ```ts
1258
+ const current = await admin.catalog.assets.descriptions.get(assetId);
1259
+ // [{ languageCode: 'en', description: 'A red wine bottle' }, { languageCode: 'nl', … }]
1260
+ const saved = await admin.catalog.assets.descriptions.set(assetId, [
1261
+ { languageCode: 'en', description: 'A red wine bottle on a wooden table' },
1262
+ { languageCode: 'nl', description: 'Een fles rode wijn op een houten tafel' },
1263
+ ]);
1264
+ // { assetId, descriptions: [...], altText: 'A red wine bottle on a wooden table' }
1265
+ ```
1266
+
1267
+ - **Two homes, one change.** The content system keeps an image's descriptions per store and
1268
+ language (what a storefront shows as the alt text); the engine keeps one alt text per image,
1269
+ `customFields.altText`, set to the store's default-language description (null when it has
1270
+ none). `set` writes both, or neither: the engine writes the alt text and the content system the
1271
+ descriptions in one engine transaction, the content system last.
1272
+ - **The whole set.** `set` takes every description of the image in the store, one per language the
1273
+ store offers. A language left out is removed; leaving out the store's default language clears
1274
+ the alt text. The alt text is ONE value wherever the image is used: another store holding the
1275
+ same image reads the new one.
1276
+ - **Safe to repeat.** Both writes set the image to the call's input, so repeating a call converges
1277
+ both stores whatever an earlier attempt left. A `CMS_REQUEST_FAILED` with `outcomeUnknown` (the
1278
+ content system did not answer in time) may have saved the descriptions without the alt text:
1279
+ send the same call again. No idempotency key is needed.
1280
+ - **Refused, nothing written:** `CMS_PERMISSION_NOT_ALLOWED` (403: the descriptions need
1281
+ UpdateCmsAsset in the store, besides UpdateCatalog or UpdateAsset), `ENTITY_NOT_FOUND` (an image
1282
+ this store does not hold), `MANAGED_BY_ORGANIZATION` (422: an image the store's organization
1283
+ shares; change it in the workspace), `SHARED_ASSET_NOT_ALLOWED` (403: another store also holds it
1284
+ and you cannot change that store), `ASSET_DESCRIPTIONS_INVALID` (400: a language the store does
1285
+ not offer or named twice, a blank text, more than 500 characters; `details[]` points at each
1286
+ entry), `CMS_REQUEST_FAILED` (422).
1287
+ - `get` is the content system's read and can answer a language the store no longer offers; `set`
1288
+ refuses one, so leave it out when you send the set back (it is removed with the rest).
1289
+ - Requires a platform release that serves `setAssetDescriptions`; against an older engine `set`
1290
+ fails as `GRAPHQL_VALIDATION_FAILED`.
1291
+
1292
+ ### Pricing — promotions and coupons at the register
1293
+
1294
+ ```ts
1295
+ // The promotions a register can offer now, for this customer (a Page; the list is whole).
1296
+ const offers = await admin.pricing.promotions.active({ customerId, locationId });
1297
+ offers.items[0]?.summary; // "If order total is greater than 50.00 EUR: Discount order by 10%"
1298
+
1299
+ // A coupon on a Draft order (a back-office draft, or createOrRecover's order left as a Draft).
1300
+ const order = await admin.pricing.coupons.apply(orderId, 'SUMMER-7KQ2', { idempotencyKey });
1301
+ order.lines[0]?.discounts; // [{ promotionId, description, amount, amountWithTax, … }]
1302
+ order.discounts; // the order-level ones
1303
+ await admin.pricing.coupons.remove(orderId, 'SUMMER-7KQ2');
1304
+ await admin.pricing.discounts(orderId); // the same readout, by id (ReadOrder)
1305
+
1306
+ // A till cart (a `pos` token at its location): the same rules, before any tender.
1307
+ await admin.pos.sale.applyCouponCode({ orderId: cartId, couponCode: 'SUMMER-7KQ2' });
1308
+ ```
1309
+
1310
+ A code is a promotion's own coupon code or a unique code generated for it; a unique code stands
1311
+ in for its promotion's code, so `couponCodes` shows the promotion's code. Every refusal leaves
1312
+ the order as it was: `COUPON_INVALID` (unknown here, deactivated, or another store's — one answer
1313
+ for all), `COUPON_EXPIRED`, `COUPON_NOT_YET_VALID`, `COUPON_USAGE_EXHAUSTED` (with
1314
+ `details.limit`), `COUPON_NOT_APPLICABLE` (valid, but the promotion does not apply to the order as
1315
+ it stands), `EXTERNAL_LINE_PROMOTION_UNSUPPORTED`, and `ORDER_NOT_DRAFT` for an order past Draft.
1316
+ Surcharges are never touched by a coupon. `promotions.active` judges dates, usage and customer
1317
+ groups; conditions on the basket are summarised, not judged.
1318
+
1319
+ #### Targeting: where, when, for whom
1320
+
1321
+ Three conditions target the sale rather than the basket. Build them with `promotionConditions`,
1322
+ which encodes the arguments as the engine stores them:
1323
+
1324
+ ```ts
1325
+ import { promotionConditions } from '@sixtyninecommerce/admin';
1326
+
1327
+ await admin.pricing.promotions.create({
1328
+ translations: [{ languageCode: 'en', name: 'Happy hour at the shop' }],
1329
+ enabled: true,
1330
+ conditions: [
1331
+ promotionConditions.sellingLocation([shopLocationId]),
1332
+ promotionConditions.timeWindow({ days: ['mon', 'tue', 'wed', 'thu', 'fri'], from: '17:00', until: '19:00' }),
1333
+ ],
1334
+ actions: [{ code: 'order_percentage_discount', arguments: [{ name: 'discount', value: '15' }] }],
1335
+ });
1336
+ await admin.pricing.promotions.create({
1337
+ translations: [{ languageCode: 'en', name: 'Welcome' }],
1338
+ enabled: true,
1339
+ conditions: [promotionConditions.firstOrder()],
1340
+ actions: [{ code: 'order_fixed_discount', arguments: [{ name: 'discount', value: '500' }] }],
1341
+ perCustomerUsageLimit: 1,
1342
+ });
1343
+
1344
+ // Read back for a sale at that shop, now, for this customer:
1345
+ const offers = await admin.pricing.promotions.active({ locationId: shopLocationId, customerId });
1346
+ offers.items[0]?.summary; // "Sold at Shop Centraal and Monday to Friday, from 17:00 to 19:00 (local time): …"
1347
+ ```
1348
+
1349
+ - **`selling_location`** — the order is sold from one of the locations: a till there, an order
1350
+ `orders.createOrRecover` recorded for it (`location`), or a register that sends the location as
1351
+ `x-location` on the Admin API (the client's `tenant.location`). A web order has no location and
1352
+ never matches. A placed order keeps a location promotion it was placed with.
1353
+ - **`time_window`** — days and a local `from`–`until` (until excluded), on the location's profile
1354
+ clock, else the store's. An `until` not after `from` runs past midnight and belongs to the day it
1355
+ opened; `00:00`–`00:00` is all day. The wall clock decides: a window inside the autumn repeated
1356
+ hour is open twice, one inside the spring skipped hour never opens. A cart is judged when it is
1357
+ priced, a placed order at its placement — and one placed with the window keeps it.
1358
+ - **`first_order`** — the customer has no earlier placed (not cancelled) order in this store. No
1359
+ customer, or the till's walk-in, never matches. Two carts of a new customer priced before either
1360
+ is placed both match: add `perCustomerUsageLimit: 1` for a hard cap.
1361
+
1362
+ `promotions.active` judges all three for the location (`locationId`, else `x-location`; with
1363
+ neither a location promotion is left out), now, and `customerId`. `create` / `update` add the
1364
+ dashboard's stacking guard when the conditions lack it, so — as in the dashboard — an empty
1365
+ `conditions` list is never refused: it creates a promotion that applies to every order (with no
1366
+ coupon code, automatically). Refusals: `PROMOTION_CONDITION_INVALID` (an argument the engine
1367
+ cannot read; `details` names it), `LOCATION_NOT_IN_CHANNEL`, `LOCATION_NOT_ALLOWED`; and on
1368
+ `update` only, `MISSING_CONDITIONS_ERROR` when the input names no `conditions`, the stored
1369
+ promotion has none (an imported coupon, a seed) and its coupon code would be gone
1370
+ (`couponCode: ''`, or none stored) — send `conditions` (even `[]`) to keep it without a code.
1371
+
1372
+ #### Sets: bundles, the nth set, mix and match
1373
+
1374
+ Three promotions price a set of lines together. Each is a condition carrying the definition and
1375
+ an action of the same code with no arguments; put both on the promotion (the engine refuses one
1376
+ without the other):
1377
+
1378
+ ```ts
1379
+ import { promotionActions, promotionConditions } from '@sixtyninecommerce/admin';
1380
+
1381
+ // A frame and two lenses for 199.00 (store default currency, store tax basis).
1382
+ await admin.pricing.promotions.create({
1383
+ translations: [{ languageCode: 'en', name: 'Complete glasses' }],
1384
+ enabled: true,
1385
+ conditions: [
1386
+ promotionConditions.bundleFixedPrice({
1387
+ components: [
1388
+ { collectionIds: [framesId], quantity: 1 },
1389
+ { collectionIds: [lensesId], quantity: 2 },
1390
+ ],
1391
+ price: 19_900,
1392
+ }),
1393
+ ],
1394
+ actions: [promotionActions.bundleFixedPrice()],
1395
+ });
1396
+ // Second pair at 50%; 3 socks for 10.00.
1397
+ promotionConditions.nthSetPercentage({ components: [{ collectionIds: [framesId], quantity: 1 }], nth: 2, discount: 50 });
1398
+ promotionConditions.mixAndMatch({ collectionIds: [socksId], quantity: 3, price: 1_000 });
1399
+
1400
+ const { packages } = await admin.pricing.discounts(orderId);
1401
+ // [{ promotionId, label: 'Complete glasses', priceWithTax: 19900, lineIds: [...], quantity: 1 }]
1402
+ ```
1403
+
1404
+ - A unit fills a component when it carries ALL its facet values and sits in ANY of its
1405
+ collections. When more units qualify than a set needs, the most valuable ones form the sets
1406
+ (ties by line), so "3 for 10.00" applies to the best three, as a cashier would ring it.
1407
+ - The set's discount is spread over its lines in proportion to their value, in exact minor units,
1408
+ as an ordinary promotion discount on the lines: mixed VAT rates each drop by their share, and a
1409
+ returned component refunds its discounted price. A set worth the set price or less stays at
1410
+ list. Lines a caller or staff priced (external, imported, quoted, till or manual prices) never
1411
+ join a set, and a line belongs to one set promotion at a time (the one tested first).
1412
+ - Swapping a component for another qualifying one keeps the set; removing one dissolves it.
1413
+ - `nth_set_percentage` discounts the cheapest `floor(sets / nth)` sets. "The last one added" is
1414
+ not offered: a quantity change adds units with no time of their own.
1415
+ - The set and group `price` is required and above 0: the builders throw `ValidationError`
1416
+ (`PROMOTION_CONDITION_INVALID`) before anything is sent, and the engine refuses it too. To
1417
+ give sets away use `nthSetPercentage` with `discount: 100`.
1418
+ - Refusals on `create` / `update`: `PROMOTION_CONDITION_INVALID` (`details.argument` names the
1419
+ argument, `conditions` for a second set condition or a set action without its condition,
1420
+ `actions` for a missing action or a second copy of one; an `update` is judged on the pair it
1421
+ leaves),
1422
+ `FACET_VALUE_NOT_FOUND`, `COLLECTION_NOT_FOUND` (another store's answers the same).
1423
+
515
1424
  ### Order surcharges
516
1425
 
517
1426
  A surcharge is an adjustment on a placed order that is not a line: a delivery charge, a negotiated
@@ -557,6 +1466,602 @@ surcharge with no tax line and lowers the order total — re-send whichever of t
557
1466
  carry. A `taxClass` is then re-resolved on every write, so the rate that applies now is the one
558
1467
  stored. All three need `UpdateOrder` and are refused once the order's invoice has been approved.
559
1468
 
1469
+ **On an order the customer has paid**, lowering or removing a charge (or adding a negative one)
1470
+ can leave them overpaid. The change is then refused `ORDER_EDIT_OVERPAYMENT_CHOICE_REQUIRED`
1471
+ (`BusinessRuleError`), nothing written, until the merchant chooses how to settle it:
1472
+
1473
+ ```ts
1474
+ const preview = await admin.orders.surcharges.preview(orderId, { surchargeId, remove: true });
1475
+ // { currencyCode, totalBefore, totalAfter,
1476
+ // overpayment: { amount: 545, currencyCode: 'EUR',
1477
+ // refund: { available: true, … }, accountCredit: { available: false, code, message } } }
1478
+
1479
+ await admin.orders.surcharges.remove(orderId, surchargeId, { overpayment: 'REFUND' });
1480
+ await admin.orders.surcharges.update(orderId, surchargeId, input, { overpayment: 'ACCOUNT_CREDIT' });
1481
+ ```
1482
+
1483
+ `REFUND` returns it to the customer's payment, after every write; it is refused
1484
+ `REFUND_EXCEEDS_PAYMENT` when no single payment can carry it (refund in parts, one payment at a
1485
+ time, then save the change), and `ORDER_OVERPAYMENT_REFUND_ORDER_STATE` on an order in a state core
1486
+ does not refund (its message names the way on: settle the payment, or move the order to
1487
+ `PaymentSettled`), and `REFUND_METHOD_UNAVAILABLE` when that payment's
1488
+ method can no longer be reached. `ACCOUNT_CREDIT` puts it on the customer's account as unapplied cash
1489
+ (`payments.accountCredit`); it is open only where `preview` says so, and otherwise refused with the
1490
+ account's own code. The amount is what the change ADDS to the overpayment, held to what the
1491
+ payments can still hand back: an order still owing gets back only what goes beyond what it owes.
1492
+ A refund of unknown outcome on the order refuses such a change
1493
+ (`ORDER_OVERPAYMENT_REFUND_UNRECONCILED`) until it is reconciled. A choice sent when nothing is
1494
+ owed back is ignored, and the `overpayment` variable is sent (and declared) only when given, so an
1495
+ older engine still accepts every call that does not use it.
1496
+
1497
+ ### Order parcel charge
1498
+
1499
+ ```ts
1500
+ const charge = await admin.orders.parcelCharge(orderId);
1501
+ // { orderId, chargedParcelCount: 2, lineIds, fulfillments: [{ id, state, parcelCount, orderIds, orderLineIds }] }
1502
+ ```
1503
+
1504
+ The parcel count a placed order's shipping was charged for (`null` when it was not priced per
1505
+ parcel), frozen when it left the cart, and the parcels each fulfilment's pack step recorded. A
1506
+ fulfilment can ship several orders of one customer: `orderIds` and `orderLineIds` say which, so do
1507
+ not compare its parcels with one order's charge unless it ships that order alone. `null` for an
1508
+ order this channel does not have. Needs `ReadOrder` and an engine that serves
1509
+ `Order.customFields.chargedParcelCount`; on an older one only this call fails.
1510
+
1511
+ ### Order line price preview
1512
+
1513
+ ```ts
1514
+ const [stored, added] = await admin.orders.previewLinePrices(
1515
+ orderId,
1516
+ [
1517
+ {
1518
+ lineId, // a line of the order
1519
+ productVariantId: bottleId,
1520
+ quantity: 12,
1521
+ override: { amount: 12100, basis: 'GROSS' }, // stage a manual unit price (see below on basis)
1522
+ oneTimeDiscount: null, // clear the stored one-time discount
1523
+ },
1524
+ { productVariantId: glassId, quantity: 6 }, // not on the order yet
1525
+ ],
1526
+ { orderDiscount: { discountType: 'PERCENTAGE', discountValue: 500 } }, // stage 5% off the order
1527
+ );
1528
+ // stored: { lineId, productVariantId, purchasable: true, notPurchasableCode: null,
1529
+ // chargedUnitPrice: 9500, chargedUnitPriceWithTax: 11495, basis: 'NET', basisKnown: true,
1530
+ // retailBase: 12000, catalogueChanged: false, overrideInert: false, taxProvisional: false,
1531
+ // stacking: [{ source: 'RETAIL', amount: 12000 }, { source: 'OVERRIDE', amount: 10000 },
1532
+ // { source: 'ONE_TIME', amount: 9500 }] }
1533
+
1534
+ if (added.purchasable) {
1535
+ show({
1536
+ net: added.chargedUnitPrice, // numbers only after this check
1537
+ gross: added.chargedUnitPriceWithTax,
1538
+ // With a tax provider app, the figure that is not in `added.basis` may still move.
1539
+ provisional: added.taxProvisional ? (added.basis === 'NET' ? 'gross' : 'net') : null,
1540
+ });
1541
+ } else {
1542
+ explain(added.notPurchasableCode); // 'VARIANT_NOT_FOR_SALE_IN_CURRENCY', no money
1543
+ }
1544
+ ```
1545
+
1546
+ What lines of an order would cost before anything is saved: the pricing engine's own answer for a
1547
+ staged manual unit price, one-time discount and quantity, on stored lines and on lines that are not
1548
+ on the order yet. One result per line, in the order of `lines`. It is a pure read: nothing is saved,
1549
+ no event is published, and no payment or tax provider is contacted. Needs `ReadOrder` and an engine
1550
+ that serves `previewOrderLinePrices`; on an older one only this call fails.
1551
+
1552
+ The answer is what a re-price of the line gives now: today's catalogue price, price lists, tiers and
1553
+ tax. A stored line that a save does not re-price keeps its stored price, so compare
1554
+ `chargedUnitPrice` and `chargedUnitPriceWithTax` with the line's own `unitPrice` and
1555
+ `unitPriceWithTax` to see whether a re-price would move it (a changed tax driver can move one of
1556
+ the two and not the other). Two kinds of stored line keep the tax rate they carry, and their tax is not
1557
+ re-evaluated: every line of the order on a store with an active tax provider app (the app is not
1558
+ asked), and a line whose variant was moved to another tax category since. A tax driver that changed
1559
+ since (a VAT number validated, tax removed) does not show in such a line's figures, so the
1560
+ comparison does not see it.
1561
+ `catalogueChanged` reports a moved catalogue retail only: a re-price starts from another catalogue
1562
+ retail than `retailBase`. For a line a price list priced, the line recorded that retail gross and no
1563
+ tax rate of its day, so the two are compared gross: besides a moved catalogue price, a change of the
1564
+ rate the catalogue taxes the variant at for this order (the rate of the order's tax zone, the zone
1565
+ the order falls in, the variant's tax category) can set it too, while the net catalogue price did
1566
+ not move. A changed price list or quantity tier, and a tax driver that changes only the line's own
1567
+ tax (a reverse charge, tax removed), can move the previewed figures and leave it `false`, and it is
1568
+ always `false` for a list-priced line of an order outside the store's default currency. The preview
1569
+ does not predict what a save refuses: a staged one-time discount that
1570
+ the Admin API's `setOrderPriceListDiscounts` would refuse is still priced, and shows no `ONE_TIME`
1571
+ step when the pricing rules skipped it.
1572
+
1573
+ Check `purchasable` before reading a price: the result type is a union on it. A variant the store
1574
+ does not sell in the order's currency is answered `purchasable: false` with `notPurchasableCode` set
1575
+ to `VARIANT_NOT_FOR_SALE_IN_CURRENCY`, every money field `null` (never 0, never a rate-derived
1576
+ figure) and an empty `stacking`, unless a price is stated for the line, for example a manual unit
1577
+ price. The own lines of an order placed at a recorded exchange rate are not judged; a line that is
1578
+ not on the order yet always is.
1579
+
1580
+ Every figure is minor units in the order's currency, before promotions. `basis` is the basis the
1581
+ engine prices the line in, and the basis a manual unit price must be sent in to the Admin API's
1582
+ `applyOrderLinePriceOverrides`: the `OVERRIDE` step of `stacking` is that figure. `stacking` starts
1583
+ with a `RETAIL` step at today's catalogue price when the line has one, and its last step is the
1584
+ charged unit price in `basis`. `retailBase` is the retail the line was priced at, the base a
1585
+ percentage is measured against: a stored line's recorded retail, or today's catalogue price for a
1586
+ line that is not on the order yet. It is `null` when a stored line recorded none, and when the line
1587
+ has no catalogue price (an externally priced line, or a variant not for sale in the order's currency
1588
+ that a stated price makes purchasable). Each line is taxed as a re-price would tax it now, apart
1589
+ from the two kinds of stored line named above.
1590
+
1591
+ A store with an active tax provider app gets a platform-taxed preview for new lines. There a line of
1592
+ the order keeps the rate it carries, but a line that is not on the order yet is taxed at the
1593
+ platform's rate: a preview never asks the app, which answers when the line is added.
1594
+ `taxProvisional` marks those lines. Their figure in `basis` is firm. The other one (the gross of a
1595
+ `NET` line, the net of a `GROSS` line) is derived at the platform's rate and may then differ, so
1596
+ show it as provisional. `taxProvisional` is `false` everywhere else, and on a line that is not
1597
+ purchasable.
1598
+
1599
+ A manual price stated in the line's own `basis` is always accepted. One stated in the other basis is
1600
+ converted only at a tax rate the preview evaluated: the rate the platform taxes the line at on the
1601
+ order as it is now. Where the preview evaluates no rate it is refused with `INVALID_PREVIEW_INPUT`,
1602
+ in a message that names the basis to state it in: on every line, stored or new, of a store with an
1603
+ active tax provider app, and on a line of the order whose variant was moved to another tax category
1604
+ since. Converted at a rate the line merely carries, the price a head then saves would be taxed at
1605
+ whatever rate the order applies on the save. To learn a line's basis first, preview it without an
1606
+ `override` and read `basis`.
1607
+
1608
+ `override` and `oneTimeDiscount` on a line, and `orderDiscount` in the options, each have three
1609
+ states, and the SDK sends them as given: leave the key out to keep what the order has, pass `null`
1610
+ to clear it, pass a value to stage it. An `override` is stated in the basis you name, and the engine
1611
+ converts it into the line's only where the preview evaluated the line's tax rate (above): the
1612
+ example's gross price on a `NET` line is converted on a store the platform taxes, and refused on a
1613
+ store with an active tax provider app. A discount value is basis points for `PERCENTAGE` (1 to
1614
+ 10000), and minor units in the channel's tax basis for `FIXED_AMOUNT` (off each unit) and `FIXED_PRICE`. A
1615
+ one-time discount applies to the product variant, so to every line of it. `orderDiscount` is
1616
+ `PERCENTAGE` or `FIXED_AMOUNT`.
1617
+
1618
+ At least 1 and at most 100 lines per call. Each call prices from the saved order plus what that call
1619
+ stages: stage a whole-order discount, and every quantity a collection or facet tier depends on, in
1620
+ the same call as the lines they affect. A removed line cannot be staged (a quantity is 1 or more):
1621
+ the collection and facet tiers of the other lines still count it at its stored quantity.
1622
+
1623
+ Refusals: `PREVIEW_ORDER_NOT_FOUND`, `PREVIEW_ORDER_LINE_NOT_FOUND` and `PREVIEW_VARIANT_NOT_FOUND`
1624
+ (`NotFoundError`, 404; an id of another store answers like a missing one), `INVALID_PREVIEW_INPUT`
1625
+ (`ValidationError`, 400; it covers `override: null` on an externally priced line, which has no
1626
+ catalogue price to fall back to, and an `override` in the other basis on a line whose tax rate the
1627
+ preview does not evaluate)
1628
+ and, from the default channel, `PREVIEW_CHANNEL_NOT_ALLOWED` (`PermissionDeniedError`, 403). An id
1629
+ that is not a uuid is refused before that with core's `ENTITY_NOT_FOUND`, also a `NotFoundError`, so
1630
+ branch on the class for not-found.
1631
+
1632
+ ### Manual prices and one-time discounts on an order
1633
+
1634
+ The two doors that save what `previewLinePrices` stages. Both re-price the lines they change and
1635
+ re-total the order in one transaction, with no refund, so both refuse an order whose payment was
1636
+ collected. Change that one through an order modification: plan it with `orders.planEdit` (which
1637
+ saves and refunds nothing), then carry it out through the Admin API's `modifyOrder` directly, which
1638
+ issues the refund; this SDK has no method for that step until `orders.commitEdit` ships. Both need
1639
+ `UpdateOrder` and take `idempotencyKey`.
1640
+
1641
+ ```ts
1642
+ const reasons = await admin.orders.manualDiscounts.reasons.list(); // the store's codes
1643
+
1644
+ // A manual unit price, in the LINE's basis (`basis` from previewLinePrices, or the line's
1645
+ // `listPriceIncludesTax` from orders.get). Lines not named keep their price.
1646
+ const order = await admin.orders.setLinePrices(
1647
+ orderId,
1648
+ {
1649
+ lines: [
1650
+ { orderLineId, unitPriceOverride: 9500, reasonCode: 'price-match' }, // set, with a reason
1651
+ { orderLineId: otherLineId, unitPriceOverride: null }, // clear: back to the catalogue
1652
+ { orderLineId: thirdLineId, priceOverrideReason: 'Agreed by phone' }, // the note alone
1653
+ ],
1654
+ },
1655
+ { idempotencyKey },
1656
+ );
1657
+ order.lines[0]?.unitPrice; // the price before promotions, where a manual price shows
1658
+
1659
+ // One-time pricing: the COMPLETE state the order should end with, not a change.
1660
+ await admin.orders.setOneTimeDiscounts(
1661
+ orderId,
1662
+ {
1663
+ orderDiscount: { discountType: 'PERCENTAGE', discountValue: 500 }, // 5% off every other line
1664
+ lines: [{ orderLineId, discountType: 'FIXED_PRICE', discountValue: 8000, reasonCode: 'loyalty' }],
1665
+ reason: 'Opening offer', // a note, kept once per order
1666
+ reasonCode: 'loyalty', // for every line it lowers
1667
+ },
1668
+ { idempotencyKey: otherKey },
1669
+ );
1670
+
1671
+ await admin.orders.manualDiscounts.list(orderId); // what each call moved, with its reason
1672
+ ```
1673
+
1674
+ `setLinePrices`: `unitPriceOverride` is minor units, 0 or more, in the line's tax basis (gross when
1675
+ its `listPriceIncludesTax`, net otherwise; a line priced in a customer-country tax zone is net on a
1676
+ store that prices gross), and the engine takes no basis with it. Left out keeps the price (a
1677
+ note-only change), `null` clears it, a number sets it; `priceOverrideReason` left out keeps the
1678
+ note, `null` or `''` clears it. A manual price is a layer: the customer's price list and the order's
1679
+ one-time discounts apply on top, and a contract's fixed price wins over it. Setting or clearing it
1680
+ takes the line out of the exact-price state an imported, quoted or till-priced line arrives in.
1681
+
1682
+ `setOneTimeDiscounts` replaces the order's one-time pricing: a live line missing from `lines` loses
1683
+ its line discount, and `orderDiscount: null` removes the whole-order one, so send what should stay.
1684
+ `discountValue` is basis points for `PERCENTAGE` (1 to 10000), and minor units in the STORE's tax
1685
+ basis and the order's currency for `FIXED_AMOUNT` (off each unit, 1 or more) and `FIXED_PRICE` (the
1686
+ unit price, 0 or more; never on the whole order). A one-time discount applies to the product
1687
+ variant, so a variant on two live lines is refused. `reason` left out or `null` keeps the note,
1688
+ `''` clears it.
1689
+
1690
+ Both answer the order with the fields of `pricing.discounts(orderId)`. A manual price and a
1691
+ one-time discount move a line's `unitPrice` / `unitPriceWithTax` (before promotions); they are not
1692
+ promotions, so they never appear in `discounts`.
1693
+
1694
+ A line whose price before promotions the call lowers received a manual discount. When the store
1695
+ requires a reason (`manualDiscounts.policy`) each such line needs one, from its entry or the call,
1696
+ or nothing is written; a raise or a note-only change needs none. Refusals:
1697
+
1698
+ - `DISCOUNT_REASON_REQUIRED` (`BusinessRuleError`, 422): a lowered line has no reason. Ask for one.
1699
+ - `DISCOUNT_REASON_UNKNOWN` (`BusinessRuleError`, 422): a code sent is not one of the store's
1700
+ active reasons. Checked before anything is written, also when no price falls.
1701
+ - `IDEMPOTENCY_CONFLICT` (`ConflictError`, 409): the key was used for another request (the other
1702
+ door included). `IDEMPOTENCY_IN_PROGRESS` (`ConflictError`, 409): the first call is still
1703
+ running.
1704
+ - Every other refusal is the engine's `USER_INPUT_ERROR` (`ValidationError`, 400) with a sentence
1705
+ that says what to do: the payment was collected (offline authorizations such as bank transfer or
1706
+ net terms do not count), the order is invoiced, completed or cancelled, the call came from the
1707
+ default channel, the order is not this store's, a line is not on it, cancelled or named twice,
1708
+ and each door's own input rules (a negative price, a line asking for nothing, clearing the price
1709
+ of an externally priced line; a value out of range, a `FIXED_PRICE` whole-order discount, an
1710
+ exactly priced or free line, a discount the pricing rules skipped). These carry no code of their
1711
+ own: branch on the class and show the message.
1712
+
1713
+ Under an `idempotencyKey` the first answer is kept for 24 hours and given again to the same
1714
+ request without running it: a success (the order, re-read as it is now) and every refusal with a
1715
+ 4xx status, the `USER_INPUT_ERROR` ones above included. Only a 5xx, 408, 425 or 429 frees the key.
1716
+ So after fixing the cause of a refusal (reopening the order, picking a reason), send the corrected
1717
+ request under a NEW key; under the old one it is `IDEMPOTENCY_CONFLICT`.
1718
+
1719
+ Both need an engine with the discount-reason layer (`discountReasons`); on an older one only these
1720
+ two calls fail.
1721
+
1722
+ ### Reading an order
1723
+
1724
+ ```ts
1725
+ const order = await admin.orders.get(orderId);
1726
+ // order.code, order.state, order.totalWithTax, order.lines[0].outstandingAllocations, … | null
1727
+
1728
+ const withTerms = await admin.orders.get(orderId, { include: ['paymentTerms', 'priceList'] });
1729
+ // withTerms.paymentTerms → { status: 'ASSIGNED', summary: 'Payment due within 30 days', … } | null
1730
+ ```
1731
+
1732
+ One order of the store, or `null` when the store holds no order with that id (another store's order
1733
+ answers `null` too). Needs `ReadOrder`.
1734
+
1735
+ Money is an integer in minor units of the order's `currencyCode`, with three exceptions that are
1736
+ not converted to it: a line's `productVariant.price` and `priceWithTax` are today's catalogue price
1737
+ in the currency of the request (the store's default, unless the call names another with
1738
+ `tenant.currency`), and a line's `customFields.cost` is in the store's default currency. On an order
1739
+ in another currency, do not subtract them from the line's own prices.
1740
+
1741
+ `active` is true only for a cart still open to its shopper. A draft, a placed order and a cancelled
1742
+ one are not active, and it says nothing about payment.
1743
+
1744
+ Without `include` the answer is the order itself, as the engine serves it without any plugin:
1745
+ totals, the customer and their address book, the lines (prices, the variant's stock, what each line
1746
+ reserves now, how it was priced), surcharges, discounts, payments and refunds, fulfillments, both
1747
+ address snapshots, the 50 most recent history entries, the tax summary and the engine's own custom
1748
+ fields.
1749
+
1750
+ Each name in `include` adds the block one plugin contributes, on the answer and on its type. A read
1751
+ that did not include a block has no such field, at runtime or in TypeScript:
1752
+
1753
+ | `include` | Adds |
1754
+ |---|---|
1755
+ | `paymentTerms` | `paymentTerms`, and the order's own override (`customFields.paymentTermsDays`, `paymentConditionCode`) |
1756
+ | `deliveryTerms` | `deliveryTerms` |
1757
+ | `priceList` | `priceList`: the order's own price list, which is its one-time pricing |
1758
+ | `lineAllocations` | `lines[].allocations`: the raw allocation rows. They never decrement; `outstandingAllocations` is what a line reserves now, and needs no include |
1759
+ | `fulfillmentDetails` | `fulfillments[].handlerCode`, `handlerName`, `handlerDetails` and `packingSlips` |
1760
+ | `vatValidation` | `customer.customFields.vatNumber` and `vatValidationStatus` |
1761
+ | `afas` | the AFAS references on the order, its customer and the customer's addresses |
1762
+ | `parasut` | the Parasut references on the order and its customer |
1763
+ | `myparcel` | `fulfillments[].customFields.myparcel*`: the MyParcel shipment and label state |
1764
+
1765
+ `ORDER_INCLUDES` lists them all. A name this version does not know is refused with a
1766
+ `ValidationError` (`INVALID_ORDER_INCLUDE`) before anything is asked.
1767
+
1768
+ The type follows the options as they are TYPED (`OrderGetAnswer`), and promises only the blocks they
1769
+ are sure to hold. A literal list of block names, or `ORDER_INCLUDES`, puts each named block on the
1770
+ type. Nothing else promises a block, though the answer still carries it: a list typed
1771
+ `OrderInclude[]`, an element that may be one of several blocks (`[block]` for a
1772
+ `block: OrderInclude`), and an `include` or options that may be `undefined`. A list that is one of
1773
+ several (`wants ? ['paymentTerms'] : []`) answers one of the matching orders; tell them apart with
1774
+ `'paymentTerms' in order`.
1775
+
1776
+ An app installation below `customerData: full` reads the order with the customer data its level
1777
+ withholds blanked: `null`, or an empty value where a field is never null (`""` for a string, `{}`
1778
+ for a history entry's `data`). That can be any part of the answer, in the core read and in every
1779
+ block: the customer, the addresses, what the buyer typed, and text a carrier, an ERP or a plugin
1780
+ composed from the customer's record (a label link, a handler's detail rows, an error, the delivery
1781
+ terms in a sentence). The answer then carries `redacted: true`: read every empty value on such an
1782
+ order as "not granted", never as "none on record".
1783
+
1784
+ Why the order is the way it is (its lock, what may be done to it) is `orders.status`, below, not a
1785
+ field of the order.
1786
+
1787
+ ### Order edit plan
1788
+
1789
+ ```ts
1790
+ const plan = await admin.orders.planEdit(orderId, {
1791
+ adjustLines: [{ orderLineId: bottles, quantity: 6 }], // was 12
1792
+ addItems: [{ productVariantId: glassId, quantity: 6 }],
1793
+ addSurcharges: [{ description: 'Pallet', price: 2500, priceIncludesTax: true, taxRate: 21 }],
1794
+ shippingAddress: { address: { streetLine1: 'Herengracht 9' }, applyToCustomer: true },
1795
+ note: 'Agreed by phone',
1796
+ });
1797
+
1798
+ if (plan.blockers.length > 0) {
1799
+ explain(plan.blockers); // [{ code: 'ORDER_EDIT_FULFILLED_FLOOR', message, details }]
1800
+ } else {
1801
+ show({
1802
+ steps: plan.steps, // [{ kind: 'MODIFY_ORDER', input, moneyEffect: -4840 }, …]
1803
+ refund: plan.refund, // { paymentId, amount: 4840 } on a paid order, else null
1804
+ total: plan.preview?.totalWithTax, // the engine's own total after the edit
1805
+ was: plan.preview?.totalWithTaxBefore,
1806
+ });
1807
+ keep(plan.planHash); // names what the merchant saw
1808
+ }
1809
+ ```
1810
+
1811
+ `planEdit` answers how an edit of a placed order would be carried out and what the order would cost
1812
+ after it. You state what should change as a diff of intents; the server decides the steps: which
1813
+ changes are a cancellation, which are written directly because no money was collected, which go
1814
+ through an order modification, and what is refunded. It then runs those steps in a transaction it
1815
+ always rolls back, so `preview` holds the pricing engine's own totals and a step the engine would
1816
+ refuse is found here. Nothing is saved, no event is published and no payment provider is contacted.
1817
+ A store's tax provider app may be asked, as it is for any re-price, so call it after the merchant
1818
+ stops typing rather than on every keystroke. Needs `UpdateOrder`.
1819
+
1820
+ Read `blockers` first. They are an answer, not an error, so that a head can show every reason
1821
+ beside the steps:
1822
+
1823
+ | Code | Meaning |
1824
+ |---|---|
1825
+ | `ORDER_EDIT_NOT_ALLOWED` | The order's state, invoice lock or lifecycle does not allow an edit (`details.reasonCode`, `details.state`). |
1826
+ | `ORDER_EDIT_NOTHING_TO_CHANGE` | The diff changes nothing the order does not already have. A note alone is not a change. |
1827
+ | `ORDER_EDIT_FULFILLED_FLOOR` | A line would go below what its fulfillments hold (`details.lines`). |
1828
+ | `ORDER_EDIT_REFUND_TARGET_MISSING` | The edit lowers the total and no payment of the order can be refunded. |
1829
+ | `ORDER_EDIT_REFUND_EXCEEDS_PAYMENT` | The edit lowers the total by more than any one payment can still return (`details.amount`, `largestHeadroom`, `refundable`). An edit never splits a refund over payments. |
1830
+ | `ORDER_EDIT_OVERPAYMENT_NOT_SETTLEABLE` | A surcharge is lowered or removed on a paid order, and neither a refund nor an account credit can settle what that leaves the customer overpaid by (`details.amount`, and each closed option's `code`; `overpayment` says why in words). |
1831
+ | `ORDER_EDIT_OVERPAYMENT_CHOICE_REQUIRED` | Answered only by an engine before the overpayment choice (below): a surcharge lowered or removed on a paid order. A current engine answers `overpayment` instead. |
1832
+ | `ORDER_EDIT_PERMISSION_REQUIRED` | The caller may edit the order and lacks `details.permission` for another write the edit includes (the customer's saved addresses). |
1833
+ | `ORDER_EDIT_STEP_REFUSED` | `details.step` was refused; `message` is the refusing operation's own sentence. |
1834
+ | `ORDER_OVERPAYMENT_REFUND_UNRECONCILED` | The edit gives money back while a refund on the order has an outcome nobody knows yet (`details.unreconciled`). Record the provider's answer first (`admin.payments.reconcileRefund`). |
1835
+ | `ORDER_EDIT_PAYMENT_STATE_UNREACHABLE` | The edit leaves a paid order owing on a payment collected online, so the order must wait for that payment in `ArrangingAdditionalPayment`, and either it cannot be moved there from `details.fromState`, or the payment could not be recorded once it waits: part of it is an existing surcharge changed or removed (`details.outsideModifications`) beside the order's modification, so `details.outstanding` ≠ `details.unsettledModifications`. When the payment could not be recorded (`details.outsideModifications` is set): if the edit's other changes (items, quantities, prices, coupons, addresses, tax, an added surcharge) also change the total, save the change or removal of the existing surcharges as an edit of its own; if they do not, an earlier edit's changes are unpaid: record what the order owes for them first. |
1836
+
1837
+ A refused step carries the refusing operation's own code when it has one (for example
1838
+ `VARIANT_NOT_FOR_SALE_IN_CURRENCY`, or a core code such as `INSUFFICIENT_STOCK_ERROR`), so treat the
1839
+ code as an open set and fall back to `message`. `preview` is `null` when a blocker kept the run from
1840
+ reaching the end. It is still answered for a plan with no steps (the order as it is) and beside
1841
+ `ORDER_EDIT_REFUND_EXCEEDS_PAYMENT` and `ORDER_EDIT_OVERPAYMENT_NOT_SETTLEABLE` (what the edit
1842
+ would cost). `notices` are things the merchant should know that do not stop the edit:
1843
+ `ORDER_EDIT_TAX_UNCHANGED`, `ORDER_EDIT_CUSTOMER_ADDRESS_NOT_SAVED`, and, for an edit that raises
1844
+ what the customer of a paid order owes, `ORDER_EDIT_PAYMENT_OWED` (a credit or manual payment: the
1845
+ order stays paid and `details.amount` is owed, collected by hand or billed) or
1846
+ `ORDER_EDIT_ADDITIONAL_PAYMENT_REQUIRED` (a payment collected online, a card or a POS terminal, or a
1847
+ method the store no longer has: the order waits in `ArrangingAdditionalPayment`, and cannot ship,
1848
+ until `details.amount` is paid).
1849
+
1850
+ `steps` are in the order they run, each with its `moneyEffect` on the order's total with tax
1851
+ (`null` for a step the run did not reach). A step's `input` is for showing the step; its shape
1852
+ follows `kind` and is not a contract to build on. `refund` is what an edit of a paid order returns
1853
+ to the customer automatically, on the one payment that can carry the whole amount: what the edit as
1854
+ a whole takes off the total (a cut offset by a surcharge raised in the same edit returns only the
1855
+ difference), less what a lowered surcharge accounts for, which is `overpayment.amount` (below).
1856
+ Neither is ever more than the edit as a whole adds to what the customer has paid beyond what the
1857
+ order bills: an order still owing (its total was raised after it was paid) has a cut pay that down
1858
+ first, one already overpaid before the edit gets back only what the edit adds, and money moved to
1859
+ the customer's account no longer counts as paid. `returnState` is the state the edit leaves the
1860
+ order in: the one it returns to after a `MODIFY_ORDER` step, or `ArrangingAdditionalPayment` when
1861
+ it waits for a payment.
1862
+
1863
+ `overpayment` is set when the edit lowers or removes a surcharge on a paid order. The merchant
1864
+ chooses, before saving, how the difference is settled: refunded to the customer's payment, or
1865
+ credited to their account as unapplied cash (`payments.accountCredit`). The edit's other cuts
1866
+ (item prices and quantities) refund automatically in `refund`; only the surcharge's share asks.
1867
+
1868
+ ```ts
1869
+ // plan.overpayment →
1870
+ // { amount: 500, currencyCode: 'EUR', surchargeIds: ['…'],
1871
+ // refund: { available: true, code: null, message: null },
1872
+ // accountCredit: { available: false, code: 'AR_ACCOUNT_IN_ERP', message: '…' },
1873
+ // refundWhenChosen: { paymentId, amount: 1710 } } // this share and plan.refund, one refund
1874
+ ```
1875
+
1876
+ Show the amount and both options; a closed one keeps its `message`. `REFUND` is closed when no one
1877
+ payment can return this share together with `refund` (`REFUND_EXCEEDS_PAYMENT`), when that payment's
1878
+ method can no longer be reached (`REFUND_METHOD_UNAVAILABLE`), or while a refund of unknown outcome
1879
+ is unreconciled; the order's state never closes it. `ACCOUNT_CREDIT` is closed by the account's own
1880
+ answer: a store without the AR ledger, a customer without a credit account, a balance kept in the
1881
+ ERP (`AR_ACCOUNT_IN_ERP`: refund only), an order on a live AR invoice, or settled money that cannot
1882
+ carry it beside `refund`. When neither is open the plan has the blocker
1883
+ `ORDER_EDIT_OVERPAYMENT_NOT_SETTLEABLE`. The plan's hash names the amount, which option is open and
1884
+ `refundWhenChosen`, so a plan whose options moved is a stale plan to `commitEdit`.
1885
+
1886
+ The diff is sent as given. `orderDiscount` has three states: leave the key out to keep the stored
1887
+ whole-order discount, pass `null` to remove it, pass a value to set it. An entry of `lineDiscounts`
1888
+ with no `discount` removes that line's one-time discount, so leave a line out of the list to keep
1889
+ what it has. `couponCodes` is the complete list the order should end with: an empty list removes
1890
+ them all, and only a left-out key leaves them alone. An address is a patch: a field left out keeps
1891
+ what the order has. A manual unit price is in the line's own tax basis, which
1892
+ `orders.previewLinePrices` answers. Every list holds at most 500 entries.
1893
+
1894
+ `planHash` names this plan of this edit of the order as it stands, and `orderVersion` names the
1895
+ order as the plan read it: it moves when anything a plan depends on moves, and stays for a change a
1896
+ plan does not read (a note, for one). Keep `planHash` with what you show: `commitEdit` carries the
1897
+ edit out and is handed it, so what is saved is what the merchant saw.
1898
+
1899
+ A store can require a reason for a manual discount. `discountReasonCode` on the diff is the edit's
1900
+ reason (one of the store's `discountReasons`, by code): it covers every line whose price a manual
1901
+ unit price or the one-time pricing lowers and that names no reason of its own, and a discount in
1902
+ `lineDiscounts` or `orderDiscount` can name its own `reasonCode`. Without one, such an edit answers
1903
+ the blocker `DISCOUNT_REASON_REQUIRED`; a code the store does not have answers
1904
+ `DISCOUNT_REASON_UNKNOWN`. A manual unit price lowered through the order modification (an order
1905
+ money was collected for) is recorded with this reason but never refused for lacking one.
1906
+
1907
+ While it runs the plan holds the order, so another change of the same order waits for it. A plan
1908
+ that cannot get the order within a few seconds is refused with `ORDER_BUSY` (`ConflictError`, 409):
1909
+ nothing was read or changed, so ask again. The SDK does not repeat the call itself.
1910
+
1911
+ Refusals of the call: `ORDER_EDIT_ORDER_NOT_FOUND`, `ORDER_EDIT_LINE_NOT_FOUND`,
1912
+ `ORDER_EDIT_SURCHARGE_NOT_FOUND` and `ORDER_EDIT_VARIANT_NOT_FOUND` (`NotFoundError`, 404; an order
1913
+ of another store answers like a missing one, and so does a line or surcharge of another order and a
1914
+ deleted variant), `INVALID_ORDER_EDIT_DIFF` (`ValidationError`, 400, with a message that names the
1915
+ field) and, from the default channel, `ORDER_EDIT_CHANNEL_NOT_ALLOWED` (`PermissionDeniedError`,
1916
+ 403). An id that is not a uuid is refused before that with core's `ENTITY_NOT_FOUND`, also a
1917
+ `NotFoundError`, so branch on the class for not-found.
1918
+
1919
+ ### Committing an order edit
1920
+
1921
+ ```ts
1922
+ const plan = await admin.orders.planEdit(orderId, diff);
1923
+ // … show plan.preview and plan.blockers; the merchant saves …
1924
+ const saved = await admin.orders.commitEdit(orderId, diff, {
1925
+ expectedPlanHash: plan.planHash,
1926
+ idempotencyKey: attemptKey, // one per attempt to save; re-sent on a retry of that attempt
1927
+ // Only when plan.overpayment is set: the merchant's answer.
1928
+ overpaymentChoice: 'REFUND', // or 'ACCOUNT_CREDIT'
1929
+ });
1930
+ // saved.order → { id, code, state, currencyCode, totalWithTax }
1931
+ // saved.plan → the plan that was carried out (its planHash is yours)
1932
+ // saved.refund → { id, state, total, paymentId } | null (the ONE refund)
1933
+ // saved.overpayment → { choice, amount, currencyCode, unappliedCashIds } | null
1934
+ ```
1935
+
1936
+ `commitEdit` carries out the plan of the SAME `diff`, once. The engine runs the plan again inside
1937
+ one transaction and keeps that run only when its `planHash` is `expectedPlanHash` and it has no
1938
+ blockers: otherwise nothing of the edit is saved. The steps run in the plan's order; the plan's
1939
+ refund, if it has one, is made LAST, after every other step, through the platform's refund guard,
1940
+ so a payment provider is never asked before everything else has succeeded. Needs `UpdateOrder`.
1941
+
1942
+ `idempotencyKey` is required on the wire: one key per attempt to save, re-sent unchanged on a retry
1943
+ of that attempt (a timeout, a dropped connection), which then answers what the first call answered
1944
+ and does nothing twice. Pass your own: left out, the SDK makes one up and returns it as
1945
+ `idempotencyKey` on the result, but on a thrown error only as an untyped property of the error.
1946
+
1947
+ | Refusal | Meaning |
1948
+ |---|---|
1949
+ | `ORDER_EDIT_PLAN_CONFLICT` (`ConflictError`, 409) | The order changed since the plan. Plan again, show the new totals, save under a NEW key. |
1950
+ | `ORDER_EDIT_BLOCKED` (`BusinessRuleError`, 422) | The plan has blockers; `error.details` lists them (`code` = the blocker's code). |
1951
+ | `ORDER_BUSY` (`ConflictError`, 409) | Another change of the order was being applied. Retry under the same key. |
1952
+ | `ORDER_EDIT_OVERPAYMENT_CHOICE_REQUIRED` (`BusinessRuleError`, 422) | The plan has an `overpayment` and no `overpaymentChoice` was given. Ask the merchant, then save under a NEW key (the choice is part of the request). |
1953
+ | A closed option's code (`REFUND_EXCEEDS_PAYMENT`, `REFUND_METHOD_UNAVAILABLE`, `AR_ACCOUNT_IN_ERP`, …) | The option chosen is closed, as the plan's `overpayment` names it. |
1954
+ | `IDEMPOTENCY_CONFLICT` (409) | The key was used for another request. |
1955
+ | `planEdit`'s refusals, and `REFUND_*` | The call is refused as `planEdit` refuses it; the refund guard refused the refund before money moved. |
1956
+
1957
+ `IDEMPOTENCY_OUTCOME_UNKNOWN` (`error.outcomeUnknown`): the refund reached the payment provider and
1958
+ its answer never came. The edit is NOT saved, the attempt is recorded and reserved on the order, and
1959
+ the key answers the same until the provider's answer is recorded (`admin.payments.reconcileRefund`),
1960
+ which frees it. Until then an edit that gives money back is blocked
1961
+ (`ORDER_OVERPAYMENT_REFUND_UNRECONCILED`). Then plan and save again: a refund recorded as made is not
1962
+ made twice.
1963
+
1964
+ An edit that raises what the customer of a paid order owes leaves the order in its paid state when
1965
+ the order is paid on credit or by hand, and in `ArrangingAdditionalPayment` (it cannot ship until
1966
+ the difference is paid) when it is paid online; the plan says which in `returnState` and its
1967
+ notices.
1968
+
1969
+ With `overpaymentChoice`, the plan's `overpayment` is settled in the same transaction: `REFUND`
1970
+ makes `overpayment.refundWhenChosen`, ONE refund of the surcharge's share and the plan's `refund`
1971
+ together, last; `ACCOUNT_CREDIT` credits the share to the customer's account first (receipts in
1972
+ `saved.overpayment.unappliedCashIds`), then makes the plan's `refund`. A choice sent with a plan
1973
+ that has no `overpayment` is ignored. Needs an engine that serves `commitOrderEdit`'s
1974
+ `overpaymentChoice` and `OrderEditPlan.overpayment`: deploy the engine before this SDK version.
1975
+
1976
+ ### Order status
1977
+
1978
+ ```ts
1979
+ const status = await admin.orders.status(orderId);
1980
+ // status.lock → { locked: true, reason: 'INVOICE', lockingInvoice: { id, number, voided } }
1981
+ // status.editBlock → { code: 'INVOICE_LOCKED', message } | null
1982
+ // status.actions → [{ action: 'RELEASE_ALLOCATION', eligible: false, allowed: false, reasonCode: 'INVOICE_LOCKED' }, …]
1983
+ ```
1984
+
1985
+ Why an order is the way it is, in one read, so a head does not re-derive it from the order's
1986
+ fields. Needs `ReadOrder`. It answers:
1987
+
1988
+ - the engine `state` and the four projected `lifecycle` dimensions. `lifecycle.status` is `draft`,
1989
+ `open`, `completed` or `cancelled`. Each dimension is null on an order that predates them, and
1990
+ for a moment on an order just created or changed;
1991
+ - the `lock` and the invoice behind it (`IMPORTED` when the source platform had already invoiced
1992
+ it, `UNKNOWN` when the cause is not established);
1993
+ - the `editBlock`, with the message every content edit is refused with;
1994
+ - whether the Edit Order flow opens (`modifiable`);
1995
+ - `deletable`: a deletable state, no invoice lock, and no payment or stock movement on the order;
1996
+ - `releasableUnits`, the units a release would free now: the sum of the lines that still hold
1997
+ stock. A line whose ledger nets below zero cancels nothing out. `releaseAllowed` is the order's
1998
+ answer on a release;
1999
+ - the `allocationBadge`. `EXPIRED` means the allocation sweeper will cancel the order. An order it
2000
+ leaves alone (placed, holding money, parked, held by a fulfillment) never shows it;
2001
+ - the edit block on correcting a fulfillment's quantities (`fulfillmentCorrection`);
2002
+ - `sourceFulfilled`: an import the source platform already handed to its own fulfillment provider,
2003
+ on a store that blocks shipping it twice;
2004
+ - the `park` and the review `hold`;
2005
+ - the app providers the order runs without (`providerFallbacks`);
2006
+ - the states a change-status menu lists (`nextStates`, empty on a live cart).
2007
+
2008
+ #### Actions
2009
+
2010
+ Each of `actions` says whether the ORDER allows it (`eligible`) and whether THIS caller may take it
2011
+ now (`allowed`), so a head can show an action greyed out with its reason. `reasonCode` is null
2012
+ exactly when `allowed`.
2013
+
2014
+ | Action | The write behind it | The caller needs |
2015
+ |---|---|---|
2016
+ | `MODIFY` | the Edit Order flow: move the order to `Modifying`, `modifyOrder`, move it out again | `UpdateOrder` |
2017
+ | `DELETE` | `deleteOrder` | `DeleteOrder` |
2018
+ | `RELEASE_ALLOCATION` | `releaseOrderAllocation` | `UpdateOrder`, and every stock location the reservation sits at when the credential is restricted to locations |
2019
+ | `CORRECT_FULFILLMENT` | one of two writes, see below | see below |
2020
+ | `CANCEL` | `cancelOrder` for the whole order | `UpdateOrder` |
2021
+ | `TRANSITION` | `transitionOrderToState` into one of `nextStates` | `UpdateOrder` |
2022
+ | `MARK_COMPLETED` | `markOrderCompleted` | `UpdateOrder` |
2023
+ | `REOPEN` | `reopenOrder` | `UpdateOrder` |
2024
+
2025
+ `MODIFY` says the flow can open from the order's present state, so an order already in `Modifying`
2026
+ answers `STATE`.
2027
+
2028
+ **`CORRECT_FULFILLMENT` has two writes with different gates.**
2029
+
2030
+ | Write | Needs | Works on |
2031
+ |---|---|---|
2032
+ | `updateFulfillmentLineQuantities` | `UpdateOrder` | any store, any fulfillment of the order |
2033
+ | `correctFulfillmentQuantities` | `UpdateFulfillment` or `UpdateOrder` | only a store with the warehouse workspace (`WAREHOUSE_NOT_ENABLED` otherwise). For a caller without `UpdateOrder`, only a fulfillment that ships from a warehouse the caller is assigned to (`LOCATION_OUT_OF_SCOPE` otherwise), and never to lower the ordered quantity (`OFFICE_ONLY`) |
2034
+
2035
+ So a caller with `UpdateOrder` is allowed whenever the order is eligible. A caller holding
2036
+ `UpdateFulfillment` without `UpdateOrder` is allowed only on a store with the warehouse workspace,
2037
+ and only while at least one of this order's fulfillments ships from one of the caller's warehouses.
2038
+ That caller must call `correctFulfillmentQuantities`: the other write answers `FORBIDDEN`. An order
2039
+ with no fulfillment gives such a caller `PERMISSION`. The answer is about the order: whether one
2040
+ given fulfillment can still be corrected is that fulfillment's own state.
2041
+
2042
+ #### Reason codes
2043
+
2044
+ | `reasonCode` | Meaning |
2045
+ |---|---|
2046
+ | `INVOICE_LOCKED`, `COMPLETED`, `CANCELLED` | the edit block |
2047
+ | `STATE` | the order's state does not allow it |
2048
+ | `NOTHING_ALLOCATED` | no line holds stock to release |
2049
+ | `HAS_RECORDS` | the order carries a payment or a stock movement, which a delete cannot remove. Cancel a draft, or a shopper's live cart, instead: `CANCEL` is open on it |
2050
+ | `NOTHING_TO_CANCEL` | the order's lines hold no unit. Delete an empty draft instead |
2051
+ | `SHOPPER_CART` | a live cart, a shopper's or a till's open sale in the cart state: its status is not moved, completed or reopened. `CANCEL` answers it too on a cart with no payment and no stock movement (delete it instead) and on a till's open sale (ended at the till) |
2052
+ | `UNITS_SHIPPED` | a shipped or delivered fulfillment holds a line. Those units come back through a return |
2053
+ | `ACTIVE_FULFILLMENT` | a fulfillment not yet shipped holds a line. Detach the order from it or correct its quantities first |
2054
+ | `PERMISSION` | the order allows it and this caller may not. The only reason about the caller |
2055
+
2056
+ Both lists grow, so `action` and `reasonCode` are typed to accept a string this version does not
2057
+ name. Treat an unknown one as "not available". `ORDER_STATUS_ACTIONS`, `ORDER_STATUS_REASON_CODES`,
2058
+ `ORDER_LOCK_REASONS`, `ORDER_ALLOCATION_BADGES` and `ORDER_EDIT_BLOCK_CODES` are the lists this
2059
+ version knows.
2060
+
2061
+ An order this store does not hold is a `NotFoundError` (`ENTITY_NOT_FOUND`), the same as a missing
2062
+ id. This is a root query of its own, never a field on an order list. It needs an engine that serves
2063
+ `orderStatus`; on an older one only this call fails.
2064
+
560
2065
  ### Payment providers
561
2066
 
562
2067
  Onboard the PSP account a store is paid through, place its card terminals, and ask whether it can
@@ -609,6 +2114,46 @@ response carries a link that can be single-use, an idempotency store keeps a res
609
2114
  and re-serves it, and retrying is safe anyway — a repeat replaces the pending attempt and answers
610
2115
  with a fresh url and state, which is what a merchant who lost the first link needs.
611
2116
 
2117
+ ### Unapplied cash: an overpayment on the customer's account
2118
+
2119
+ A customer who paid more than an order bills (a second transfer, an edit that lowered a paid
2120
+ order's total) can keep that money on their account instead of having it refunded. It is held
2121
+ there as unapplied cash until it pays an invoice or is paid back.
2122
+
2123
+ ```ts
2124
+ // Move what the order holds over what it bills. One receipt per payment it came from.
2125
+ const [cash] = await admin.payments.accountCredit.creditOverpayment(
2126
+ { orderId, reason: 'Paid twice by bank transfer' },
2127
+ { idempotencyKey }
2128
+ );
2129
+
2130
+ await admin.payments.accountCredit.list(customerId); // oldest first
2131
+ await admin.payments.accountCredit.apply({ unappliedCashId: cash.id, invoiceId });
2132
+ await admin.payments.accountCredit.refund({ unappliedCashId: cash.id, amount: 500 });
2133
+
2134
+ const onAccount = await admin.payments.accountCredit.onOrder(orderId);
2135
+ onAccount.creditedToAccount; // no longer refundable from the order, no longer `overpaid`
2136
+ ```
2137
+
2138
+ **For stores that keep their own accounts-receivable ledger, and customers with a credit account in
2139
+ it.** Anywhere else the calls are refused (`AR_LEDGER_NOT_ENABLED`, `AR_ACCOUNT_REQUIRED`, or
2140
+ `AR_ACCOUNT_IN_ERP` on a store whose ERP integration is on) and the overpayment is refunded with
2141
+ `payments.refund`. An order that is already on an AR invoice is refused too
2142
+ (`AR_ORDER_ALREADY_INVOICED`): refund its overpayment.
2143
+
2144
+ **It is never a credit note.** Nothing that was billed is credited; the customer's balance simply
2145
+ shows the store owes them the money. Applying it to an invoice leaves the balance where it was.
2146
+
2147
+ **The money leaves the order.** `payments.refundPosition` takes it off `refundable` and
2148
+ `payments.position` stops reporting it as `overpaid`. It goes back to the customer only through
2149
+ `accountCredit.refund`, which refunds the payment it came from and follows `payments.refund`'s rules,
2150
+ including `IDEMPOTENCY_OUTCOME_UNKNOWN`: the cash then stays on the account, frozen, until
2151
+ `payments.reconcileRefund` records the provider's answer.
2152
+
2153
+ A customer who still has unapplied cash cannot be erased (`CUSTOMER_HAS_UNAPPLIED_CASH`): pay it back
2154
+ or apply it first, and wait for the payout's refund to complete. `accountCredit.list(customerId)`
2155
+ shows what is held.
2156
+
612
2157
  **One connection, every store of the seller.** `UpdatePaymentMethod` in ANY of a seller's stores can
613
2158
  disconnect and re-connect, which re-points revenue for all of them. That is the same sharing that
614
2159
  makes onboarding happen once, but it means the permission's blast radius is the seller, not the
@@ -636,9 +2181,20 @@ itself refused, with its own message kept out of the response),
636
2181
  terminal or a readiness question names another channel's location — the same code the rest of the
637
2182
  platform uses for that one rule.
638
2183
 
639
- `LIVE_CONNECTION_REFUSED` is declared and classified but nothing throws it yet: the rule that a
640
- sandbox store may hold only a `test` connection needs a way to identify a sandbox store, which
641
- arrives with the sandbox flag. `readiness.mode` already reports which kind a store holds.
2184
+ `LIVE_CONNECTION_REFUSED` (`BusinessRuleError`): a sandbox store may hold only a `test`
2185
+ connection, so `startOnboarding`/`completeOnboarding` refuse a provider account that reports
2186
+ `live` on one, before any connection is written. The legacy path follows the same rule: a
2187
+ Mollie/Stripe key stored on a sandbox's payment method must be a test key (`test_…`,
2188
+ `sk_test_…`). `readiness.mode` reports which kind a store holds, and a live connection or legacy
2189
+ key seen from a sandbox store (through a shared seller, or a method assigned from elsewhere) reads
2190
+ not ready, with the reason as its first blocker.
2191
+
2192
+ `LIVE_PAYMENT_REFUSED` (`BusinessRuleError`): the same rule when the store PAYS. A sandbox store
2193
+ never moves money through its seller's live account. A card-terminal charge on a live connection
2194
+ is refused before the provider is asked, and so is a Stripe payment through the seller's Connect
2195
+ account when a live store also trades under that seller. A POS card tender reports it as
2196
+ `POS_CARD_PAYMENT_FAILED` with the refusal's sentence, and nothing is recorded on the order. A live
2197
+ store on a live connection, and a sandbox on a test one, pay as before.
642
2198
 
643
2199
  Reading needs `ReadPaymentMethod`, writing `UpdatePaymentMethod`.
644
2200
 
@@ -656,7 +2212,7 @@ const invoice = await admin.documents.generate(orderId, 'invoice', {
656
2212
  });
657
2213
 
658
2214
  const data = await admin.documents.data(invoice.id, 'invoice');
659
- data.numbering; // { scope: 'channel', scopeId, series, number }
2215
+ data.numbering; // { scope: 'legal_entity' | 'channel', scopeId, series, number }
660
2216
  data.totals?.totalWithTax; // the figures the PDF printed, in minor units (null on a lump-sum note)
661
2217
  data.reconciled; // false when the ORDER moved after the invoice was numbered
662
2218
 
@@ -664,6 +2220,13 @@ await admin.documents.invoices(orderId); // a Page; complete today
664
2220
  await admin.documents.creditNotes(orderId);
665
2221
  await admin.documents.previewInvoice(orderId, { invoicedOn: '2026-09-18' }); // PDF bytes, nothing stored
666
2222
  await admin.documents.sendInvoiceEmail({ orderId, recipientsTo: ['ap@buyer.example'] });
2223
+ await admin.documents.sendCreditNoteEmail({ creditNoteId, recipientsTo: ['ap@buyer.example'] });
2224
+ await admin.documents.creditNoteEmailSends(orderId); // who each credit note was emailed to
2225
+
2226
+ // A customer's invoices and credit notes in this store, newest first, paged.
2227
+ const page = await admin.documents.forCustomer(customerId, { limit: 10 });
2228
+ page.items[0]?.type; // 'invoice' | 'credit-note'; the document is on `invoice` or `creditNote`
2229
+ const next = await page.next(); // null on the last page
667
2230
  ```
668
2231
 
669
2232
  **`documentData` is the read to build on.** It returns what the document was numbered with —
@@ -674,9 +2237,138 @@ standard invoice derives its lines from the order, and an order can be modified
674
2237
  numbered), and `taxSummary` is `null` rather than `[]` on a lump-sum credit note that never stated a
675
2238
  per-rate split.
676
2239
 
2240
+ **A refused correction says why in a code.** `issuePartialCreditNote` answers `INVOICE_NOT_FOUND`,
2241
+ `ORDER_NOT_INVOICED`, `INVOICE_ALREADY_VOIDED`, `INVOICE_CREDIT_EXCEEDS_INVOICE`, the `REFUND_*`
2242
+ pre-checks and the others its TSDoc lists, each before any money moves or any number is drawn. Branch
2243
+ on `error.code`, never on the sentence. An older engine answered all of them as `USER_INPUT_ERROR`.
2244
+ `issueSupplementaryInvoice` codes its refusals the same way (its TSDoc lists them); an older engine
2245
+ answered them as `USER_INPUT_ERROR`, or as an uncoded error for the order's state and a store with
2246
+ invoicing switched off.
2247
+
677
2248
  Numbering, the VAT summary and issuer identity stay platform-owned — there is no method that
678
- allocates a number. `numbering.scope` is modelled for the per-legal-entity series multi-region will
679
- add, so a consumer reading `scope`/`scopeId` keeps working when it lands.
2249
+ allocates a number. `numbering.scope` is `legal_entity` for an invoice or credit note numbered in its
2250
+ legal entity's series (shared by every store of that entity; `scopeId` is the entity), and `channel`
2251
+ for a store numbering on its own. `issuer` is the one FROZEN when the document was issued: editing the
2252
+ legal entity later never changes what an issued document reports or reprints.
2253
+
2254
+ #### Planning a correction
2255
+
2256
+ An issued invoice is never edited: it is credited, voided, reissued or supplemented. Ask the server
2257
+ what a correction would do before anything is written.
2258
+
2259
+ ```ts
2260
+ // Step 1: which outcomes this invoice can take, and the lines a correction starts from.
2261
+ const start = await admin.documents.planCorrection({ ledger: 'RETAIL', invoiceId });
2262
+ start.outcomes; // [{ outcome: 'DETAILS_WRONG', available: false, unavailableCode: 'INVOICE_HAS_PARTIAL_CREDITS', … }, …]
2263
+ start.prefill; // { source: 'order', basis: 'gross', lines, seededGross, billedGross, differs }
2264
+
2265
+ // Step 2: plan one outcome as the merchant fills it in. Only a plan with an outcome has a
2266
+ // refund (`start.refund` is null): its options are what this outcome's amount can go back to.
2267
+ const credit: InvoiceCorrectionInput = {
2268
+ ledger: 'RETAIL',
2269
+ invoiceId,
2270
+ outcome: 'OVER_CHARGE',
2271
+ reason: 'Priced 3 bottles at the wrong tier',
2272
+ lines: [{ description: 'Merlot 2021', quantity: 3, unitPrice: 250, unitPriceBasis: 'gross', taxRate: 21 }],
2273
+ };
2274
+ const draft = await admin.documents.planCorrection(credit);
2275
+ draft.refund; // { amount, available, options: [{ paymentId, refundable }], recommendedPaymentId }
2276
+
2277
+ // Step 3: plan it again with the payment the merchant picked: that plan is the one to commit.
2278
+ const plan = await admin.documents.planCorrection({
2279
+ ...credit,
2280
+ refundPaymentId: draft.refund?.recommendedPaymentId ?? undefined,
2281
+ });
2282
+ plan.settlement; // 'REFUND' (or 'NONE' when no payment was named)
2283
+ plan.blockers; // [] when it can be committed; else [{ code, message, path }]
2284
+ plan.lineTotals; // the issue's own figure (round once at the line)
2285
+ plan.documents; // [{ type: 'CREDIT_NOTE', kind: 'adjustment', documentDate, totals, numbering: { provider, external, ready } }]
2286
+ plan.planHash; // names what the merchant saw
2287
+ ```
2288
+
2289
+ `planCorrection` writes nothing and **never shows or reserves a number**: every issue draws its
2290
+ number in its own committed transaction, so a preview that drew one would use it up. A credit note
2291
+ follows its invoice's provider: where `numbering.external` is true (an ERP such as AFAS), committing
2292
+ the correction books the documents in the ERP, and that cannot be undone from the platform.
2293
+
2294
+ The four outcomes are `OVER_CHARGE` (a partial credit note; the invoice stands), `DETAILS_WRONG`
2295
+ (a full credit note and a corrected invoice: a reissue when no line changed, a re-bill when one
2296
+ did), `SHOULD_NOT_EXIST` (a full credit note; the invoice is voided) and `UNDER_CHARGE` (a
2297
+ supplementary invoice). A details correction that changes no address field and no line is refused
2298
+ (`INVOICE_CORRECTION_NOTHING_CHANGED`): it would only use up two numbers. A reissue also prints the
2299
+ issuer, legal entity, payment terms and PO reference as they stand now (an AR replacement keeps the
2300
+ invoice's own payment terms), so one that would print any of them differently from the invoice is a
2301
+ change and is planned (an invoice issued before the reference was recorded is never refused on it). Refund options are the
2302
+ order's refund position (`payments.refundPosition`), newest payment first; the AR ledger never
2303
+ refunds (its credit note credits the customer's account). On a store that keeps the customer's
2304
+ account itself, a retail over-charge or void may put the money on the account instead
2305
+ (`creditToAccount`, offered in `accountCredit`). Needs `UpdateOrder`. A plan that stages an
2306
+ address field other than the country is compared with the order's stored address, so an app below
2307
+ `customerData: full` is refused it (`CUSTOMER_DATA_NOT_GRANTED`).
2308
+
2309
+ #### Committing a correction
2310
+
2311
+ ```ts
2312
+ const plan = await admin.documents.planCorrection(input);
2313
+ // … show plan.documents, plan.refund and plan.blockers; the merchant confirms …
2314
+ const done = await admin.documents.commitCorrection(input, {
2315
+ expectedPlanHash: plan.planHash,
2316
+ idempotencyKey: attemptKey, // one per attempt to commit; re-sent on a retry of that attempt
2317
+ });
2318
+ done.documents; // [{ ledger, type: 'CREDIT_NOTE', id, number, kind, totalWithTax, issuedOn, numberingProvider }]
2319
+ done.refund; // { id, state, total, paymentId } | null (with refundPaymentId)
2320
+ done.accountCredit; // { amount, currencyCode, unappliedCashIds } | null (with creditToAccount)
2321
+ done.plan; // the plan that was carried out (its planHash is yours)
2322
+ ```
2323
+
2324
+ `commitCorrection` carries out the plan once. Hand it the SAME `input` you planned and the plan's
2325
+ `planHash` as `expectedPlanHash`. The engine plans again under the issuing services' locks and
2326
+ issues only when the hash is yours and nothing blocks: otherwise nothing is issued. Every document
2327
+ is put to its numbering provider's checks before the first one is numbered. Needs `UpdateOrder`.
2328
+
2329
+ **Two paths.** Without a refund, everything is one transaction: the documents, and the account
2330
+ credit when there is one. With `refundPaymentId`, the refund comes first, outside any transaction,
2331
+ and the credit note follows. A refund the provider confirmed stays, even when the credit note
2332
+ cannot follow.
2333
+
2334
+ **Account credit.** With `creditToAccount: true` (offered in the plan's `accountCredit`), nothing
2335
+ is refunded: the credited amount goes to the customer's account as unapplied cash, one receipt per
2336
+ payment it came from (`done.accountCredit.unappliedCashIds`). `admin.payments.accountCredit.apply`
2337
+ (the engine's `applyArUnappliedCash`) applies it to an open AR invoice later.
2338
+
2339
+ `idempotencyKey`: one key per attempt to commit, re-sent unchanged on a retry of that attempt (a
2340
+ timeout, a dropped connection), which then answers what the first call answered and issues nothing
2341
+ twice. Pass your own: left out, the SDK makes one up and returns it as `idempotencyKey` on the
2342
+ result, but on a thrown error only as an untyped property of the error.
2343
+
2344
+ | Refusal | Meaning |
2345
+ |---|---|
2346
+ | `INVOICE_CORRECTION_PLAN_CONFLICT` (`ConflictError`, 409) | The invoice, its order, the payments, the numbering or the date moved since the plan. Plan again, show the merchant the new plan, commit under a NEW key. |
2347
+ | `INVOICE_CORRECTION_BLOCKED` (`BusinessRuleError`, 422) | The plan has blockers, or a numbering provider refused a document before anything was numbered (an ERP's debtor, article or VAT checks). `error.details` lists them (`code` = the blocker's code). |
2348
+ | `IDEMPOTENCY_CONFLICT` (409) | The key was used for another request. |
2349
+ | `REFUND_REFUSED` (`BusinessRuleError`, 422) | The payment provider, or the engine before it asked the provider, refused the refund; nothing moved. A provider's refusal stays on the payment as a `Failed` refund, which moves the plan: once the payment can take the refund, plan again and commit the new plan under a NEW key. |
2350
+ | `planCorrection`'s refusals | The call is refused as `planCorrection` refuses it. |
2351
+
2352
+ Nothing is issued on a refusal.
2353
+
2354
+ `IDEMPOTENCY_OUTCOME_UNKNOWN` (`error.outcomeUnknown`) is neither success nor refusal, so never
2355
+ retry it blind. Either the refund reached the payment provider and its answer never came: no credit
2356
+ note was issued, and the key answers the same until the provider's answer is recorded with
2357
+ `admin.payments.reconcileRefund`, which frees it; then plan again. Or the refund was made and the
2358
+ credit note could not follow: plan again, and the credit note is issued without a refund.
2359
+
2360
+ On an ERP-numbered store (`documents[].numberingProvider` is not `platform`), the documents are
2361
+ booked in the ERP. They cannot be undone from the platform: correct them there.
2362
+
2363
+ `Invoice` and `CreditNote` now say how they were corrected and who numbered them:
2364
+ `reversedByCreditNoteId`, `supersededByInvoiceId`, `reissueOfInvoiceId` and `supplementsInvoiceId`
2365
+ on an invoice; `reversalOfInvoiceId`, `returnId` and `refundId` on a credit note; `lines` and
2366
+ `numberingProvider` on both.
2367
+
2368
+ Needs an engine that serves `commitInvoiceCorrection`. The widened `Invoice` and `CreditNote`
2369
+ selections (`generate`, `invoices`, `creditNotes`, `issuePartialCreditNote`,
2370
+ `issueSupplementaryInvoice`) need an engine that serves those fields (`numberingProvider`, …):
2371
+ deploy the engine before this SDK version.
680
2372
 
681
2373
  #### Extension blocks
682
2374
 
@@ -811,6 +2503,742 @@ not currently distinguish a plain retail channel from one where quoting is enabl
811
2503
  The service caches the manifest 60 s per channel and serves it with an `ETag`, so polling it is
812
2504
  cheap. `installedApps` is on the wire already and stays empty until the app registry ships.
813
2505
 
2506
+ `sandbox` says whether the store is a sandbox (`platform.provisionStore({ sandbox: true })`):
2507
+ test-mode payments only, no ERP or marketing push, left out of platform metrics, purged past retention
2508
+ holds. It is `null` when the platform could not read the flag this time — such a manifest is not
2509
+ cached — and absent on a platform older than the field. Treat anything but `true` as "not a
2510
+ sandbox".
2511
+
2512
+ ### Plugin entitlements
2513
+
2514
+ `admin.tenant.entitlements()` lists the plugins the **active** store may use: its own grants and
2515
+ the ones its organization holds for every member store. It reads the same union the engine reads
2516
+ when it decides whether a plugin's back-office operations are allowed.
2517
+
2518
+ ```ts
2519
+ const grants = await admin.tenant.entitlements();
2520
+ // [{ pluginKey: 'pos', source: 'channel' }, { pluginKey: 'seo', source: 'organization' }]
2521
+
2522
+ const showPos = grants.some((grant) => grant.pluginKey === 'pos');
2523
+ ```
2524
+
2525
+ - **Fresh on every call.** A grant switched in Platform → Plugins (or on the organization) answers
2526
+ on the next call, so a head can hide or show a plugin's screens without a reload. Only a store's
2527
+ organization membership is remembered (up to 60 s per server process), so a store that has just
2528
+ joined one may take a minute to list its organization's grants.
2529
+ - **`source`:** `channel` for the store's own grant, `organization` for one its organization
2530
+ holds. When both exist the store's own wins, so a key appears once. `source` is typed as the
2531
+ names this SDK knows but carries any string, so a source a newer engine adds reaches you
2532
+ unchanged.
2533
+ - **Permission:** `ReadCatalog` or `ReadSettings` on the store; without either the engine answers
2534
+ `FORBIDDEN` (403). There is no channel argument: a store's token reads its own store, and a
2535
+ SuperAdmin reads any store with that store as the tenant.
2536
+ - **Use it to hide or show; never treat it as a grant.** The engine checks the grant itself on
2537
+ every operation, so a plugin listed here can still be refused if Platform → Plugins switched it
2538
+ off in between.
2539
+ - `tenant.capabilities()` also reports `entitled` per plugin. That answer can be up to 60 s old,
2540
+ and it says whether the plugin is set up as well. Read `entitlements()` when you only need the
2541
+ grant, and need it current.
2542
+
2543
+ ### Platform — plugin entitlements
2544
+
2545
+ `admin.platform.entitlements` is the platform's control plane for which external plugins each store
2546
+ may use (Platform → Plugins in the dashboard). Every method needs a human SuperAdmin session — an API
2547
+ token or an app credential is refused with `PLATFORM_REQUIRES_SESSION` (403) before anything is sent
2548
+ — and none needs a tenant on the client:
2549
+
2550
+ ```ts
2551
+ const entitlements = human.platform.entitlements;
2552
+
2553
+ // One store's grants, its organization's included.
2554
+ const grants = await entitlements.list(store.id);
2555
+ // [{ pluginKey: 'mollie', source: 'channel', connectedAt: '2026-10-07T08:00:00.000Z', … },
2556
+ // { pluginKey: 'seo', source: 'organization', … }]
2557
+
2558
+ // Every grant on the platform, e.g. to count each store's connected plugins.
2559
+ const all = await entitlements.listAll();
2560
+
2561
+ // Connect a plugin for a store (answers its own row), then disconnect it (answers null).
2562
+ await entitlements.set(store.id, 'pos', { connected: true, notes: 'contract 2026-114' });
2563
+ await entitlements.set(store.id, 'pos', { connected: false });
2564
+ ```
2565
+
2566
+ - **`source`:** `channel` for the store's own grant, `organization` for one its organization holds
2567
+ for every member store and its workspace. When both exist the store's own row wins, so `list`
2568
+ answers a key once. `listAll` repeats an organization grant once per member store and once for
2569
+ the organization's workspace, each under that channel's id (and the organization grant's `id`,
2570
+ so key a row by `channelId` and `pluginKey`). `source` is typed as the names this SDK knows but
2571
+ carries any string.
2572
+ - **An organization's grant is changed on the organization**
2573
+ (`platform.organizations.entitlements.set`). Disconnecting a plugin a store holds only through its
2574
+ organization is refused `MANAGED_BY_ORGANIZATION` (422 `BusinessRuleError`). Disconnecting a store's
2575
+ own grant of a plugin its organization also grants answers `null`, and the store keeps the plugin
2576
+ through the organization: `list` then shows it as `source: 'organization'`.
2577
+ - **Connecting again** refreshes `connectedAt` and `connectedByUserId`. Omitted `notes` keep the
2578
+ stored ones; `''` clears them. Notes hold at most 512 characters; a longer value fails the write.
2579
+ - **Other refusals:** `ORGANIZATION_WORKSPACE_NOT_A_STORE` (422: an organization's workspace holds
2580
+ plugins only through its organization), `USER_INPUT_ERROR` (400 `ValidationError`: the key is not an
2581
+ external plugin of the platform catalog; core plugins such as search and email have no
2582
+ entitlement), `FORBIDDEN` (403: not a SuperAdmin).
2583
+ - **The engine does not check that a channel id names a store**, so pass ids read from the
2584
+ platform's channel list.
2585
+ - The engine reads the grants on every operation; the plugins service's own gates follow a change
2586
+ within its 60-second entitlement cache.
2587
+
2588
+ ### Platform — organizations
2589
+
2590
+ An organization groups stores that are run together. Its workspace is a channel of its own,
2591
+ `org-<code>`, which owns what the organization shares with its member stores. Every method needs
2592
+ a human session — an API token or an app credential is refused with `PLATFORM_REQUIRES_SESSION`
2593
+ before anything is sent — and a SuperAdmin needs no tenant on the client:
2594
+
2595
+ ```ts
2596
+ const organizations = human.platform.organizations;
2597
+
2598
+ const org = await organizations.create({
2599
+ code: 'acme-eu', // immutable: the workspace channel becomes `org-acme-eu`
2600
+ name: 'Acme Europe',
2601
+ defaultLanguageCode: 'en',
2602
+ defaultCurrencyCode: 'EUR',
2603
+ country: 'NL', // or both defaultTaxZoneId and defaultShippingZoneId
2604
+ });
2605
+
2606
+ await organizations.update({ id: org.id, assortmentMode: 'selected' });
2607
+ await organizations.getByCode('acme-eu'); // the organization code; null when none has it
2608
+ for await (const each of await organizations.list()) console.log(each.code, each.memberCount);
2609
+
2610
+ // The organization tier of plugin entitlements: a grant reaches every member store.
2611
+ await organizations.entitlements.set({ organizationId: org.id, pluginKey: 'mollie', connected: true });
2612
+ await organizations.entitlements.list(org.id);
2613
+
2614
+ // Repair: register the workspace in the CMS channel mirror (safe to repeat).
2615
+ await organizations.registerWorkspace(org.id);
2616
+ ```
2617
+
2618
+ An organization answers its workspace as `hubChannel { id, code, pricesIncludeTax }`. The tax mode
2619
+ is the organization's: every member store enters prices the same way, so it is what a store created
2620
+ under the organization starts from (`provisionStore` with `organization` and no `pricesIncludeTax`
2621
+ takes it).
2622
+
2623
+ `create` registers the workspace channel in the CMS channel mirror once the organization is
2624
+ committed; that mirror is what a client scoped to `org-<code>` resolves the channel through. If the
2625
+ CMS is unavailable the organization is still created — the failed call is in the audit trail — and
2626
+ `registerWorkspace(id)` completes it later. Until then a client scoped to that workspace is refused
2627
+ with `CHANNEL_NOT_FOUND` (404) before anything is sent. `registerWorkspace` answers the organization,
2628
+ is SuperAdmin-only, and refuses with `ORGANIZATION_NOT_FOUND` / `ORGANIZATION_WORKSPACE_NOT_FOUND`
2629
+ (404) or, when the CMS does not confirm it, `ORGANIZATION_WORKSPACE_REGISTRATION_FAILED` (422
2630
+ `BusinessRuleError` — retry later).
2631
+
2632
+ The writes are Admin API mutations, so an idempotency key deduplicates nothing. `create` is safe
2633
+ to repeat anyway: a second create on a taken code is refused with `ORGANIZATION_CODE_TAKEN`
2634
+ (`ConflictError`, 409) — read a create whose answer was lost back with `getByCode`. Revoking a
2635
+ plugin (`connected: false`) removes only the organization's grant; a store that holds the plugin
2636
+ itself keeps it. The plugins service follows a change within its 60-second entitlement cache.
2637
+
2638
+ **Member stores.** A store joins an organization with `join`, and `members` lists them:
2639
+
2640
+ ```ts
2641
+ const store = await human.channels.byCode('acme-nl');
2642
+ const membership = await organizations.join({
2643
+ organizationCode: 'acme-eu', // the organization code, not the `org-acme-eu` channel code
2644
+ channelId: store.id,
2645
+ region: 'EMEA', // optional grouping label
2646
+ keepOwnSettings: true, // optional: keep this store's own settings instead of inheriting
2647
+ });
2648
+ const stores = await organizations.members(org.id); // oldest member first
2649
+ ```
2650
+
2651
+ Joining grants the organization's roles on the store and widens the workspace's currencies and
2652
+ languages to include the store's. It is idempotent: joining the organization a store is already in
2653
+ answers the same membership (`joinedAt` unchanged), so a join whose answer was lost is safe to
2654
+ repeat. An omitted `region` keeps the one a re-join finds; `null` (or `''`) clears it. By default
2655
+ the store inherits every settings group the organization shares; `keepOwnSettings: true` makes it
2656
+ keep its own values for every group instead (each can be switched to inherit later), and on a
2657
+ re-join adds only the groups it does not keep yet: one it inherits unlocked at that moment starts
2658
+ from a copy of the organization's values, so nothing changes for its customers. `false` or omitted
2659
+ changes nothing it already keeps, and is not sent, so an engine that predates the option still
2660
+ accepts the join. A store belongs to one organization at a time, and there is no way to leave yet. `join` is SuperAdmin-only
2661
+ and refuses with `ORGANIZATION_MEMBERSHIP_CONFLICT` (409 `ConflictError` — the store belongs to
2662
+ another organization), `ORGANIZATION_NOT_FOUND` / `ORGANIZATION_MEMBER_STORE_NOT_FOUND` (404),
2663
+ `ORGANIZATION_MEMBER_NOT_A_STORE` (the platform's root channel or a workspace) /
2664
+ `ORGANIZATION_MEMBER_STORE_CLOSED` (restore the store first) /
2665
+ `ORGANIZATION_TAX_MODE_MISMATCH` (the store's `pricesIncludeTax` differs from the organization's;
2666
+ every store of an organization enters prices the same way, judged on a first join) (422
2667
+ `BusinessRuleError`), or
2668
+ `ORGANIZATION_MEMBER_REGION_INVALID` (400: at most 64 characters, no line breaks). `members` leaves
2669
+ out stores that no longer exist, and refuses an unknown id with `ORGANIZATION_NOT_FOUND` (404)
2670
+ rather than answering an empty list.
2671
+
2672
+ **An organization admin** — the organization's `org-<code>-admin` role, not a SuperAdmin — may call
2673
+ `get`, `getByCode`, `list`, `update` and `members` for its own organization, with the workspace
2674
+ channel as the tenant:
2675
+
2676
+ ```ts
2677
+ const workspace = human.withTenant({ scope: { kind: 'channel', code: 'org-acme-eu' } });
2678
+ const own = await workspace.platform.organizations.getByCode('acme-eu');
2679
+ ```
2680
+
2681
+ The engine decides whose organization it is from the active channel, so anywhere else the call is a
2682
+ `PermissionDeniedError` (403): `FORBIDDEN` with no tenant or on a channel where the caller holds no
2683
+ `ManageOrganization`, `ORGANIZATION_ACCESS_DENIED` for another organization, whether or not it
2684
+ exists. `list` answers only the caller's own. `create`, `registerWorkspace`, `join` and
2685
+ `entitlements.*` stay SuperAdmin-only.
2686
+
2687
+ **A new store inside an organization:** pass its code to `platform.provisionStore`, and the store
2688
+ joins it as the run's last step. An unknown code is `ORGANIZATION_NOT_FOUND` (404) before any run
2689
+ exists. Leave `pricesIncludeTax` out and the store takes the organization's mode; a different value
2690
+ is `ORGANIZATION_TAX_MODE_MISMATCH` (422 `BusinessRuleError`), also before any run. If only the join
2691
+ fails, the store is kept, not rolled back: `wait()` throws
2692
+ `PROVISION_RUN_FAILED` saying so, `provisionRun(runId).result` still names the store, and
2693
+ `organizations.join` (safe to repeat) finishes the job. A store can also be created under an
2694
+ existing legal entity (`legalEntity`, see [Legal entities](#legal-entities)).
2695
+
2696
+ ```ts
2697
+ const store = await human.platform
2698
+ .provisionStore({ ...storeInput, organization: 'acme-eu' }, { idempotencyKey: idempotencyKey() })
2699
+ .then((run) => run.wait());
2700
+ ```
2701
+
2702
+ #### Sharing policy
2703
+
2704
+ What an organization shares with its member stores is decided at two gates: the platform (a
2705
+ SuperAdmin) **allows** a kind for the organization, and the organization admin **enables** an
2706
+ allowed kind and, for the kinds that can be, **locks** it so no member store can override it.
2707
+ Every method answers the kind's state after the call; `get` answers every kind the platform can
2708
+ share, the ones not allowed included, each with its `name`, `lockable` and `phase` (the
2709
+ multi-region phase that makes it take effect — until then it is only a setting). Two kinds,
2710
+ `entitlements` and `roles`, are `alwaysOn`: every organization shares them with no policy, so they
2711
+ always read `allowed` and `enabled`, never `locked`, and `allow`, `disallow` or `set` on them is
2712
+ refused with `ORGANIZATION_SHARING_KIND_ALWAYS_ON` (`BusinessRuleError`, 422).
2713
+
2714
+ ```ts
2715
+ const sharing = human.platform.organizations.sharing;
2716
+
2717
+ await sharing.allow({ organizationId: org.id, kind: 'settings.branding' }); // SuperAdmin; starts off
2718
+ await sharing.set({ organizationId: org.id, kind: 'settings.branding', enabled: true, locked: true });
2719
+ const policy = await sharing.get(org.id); // [{ kind, name, allowed, enabled, locked, lockable, phase, alwaysOn }, …]
2720
+
2721
+ // Switching a locked kind off unlocks it in the same call; then the platform may withdraw it.
2722
+ await sharing.set({ organizationId: org.id, kind: 'settings.branding', enabled: false, locked: false });
2723
+ await sharing.disallow({ organizationId: org.id, kind: 'settings.branding' });
2724
+ ```
2725
+
2726
+ `allow` and `disallow` are SuperAdmin-only and safe to repeat (allowing an allowed kind keeps what
2727
+ the organization set; withdrawing one that is not allowed changes nothing). `get` and `set` also
2728
+ answer the organization admin for its own organization, with the workspace channel as the tenant,
2729
+ exactly like `get` above. `set` changes only the flags it is given. Refusals: a kind the engine does
2730
+ not know is `ORGANIZATION_SHARING_KIND_INVALID` (400); `set` on a kind the platform has not allowed
2731
+ is `ORGANIZATION_SHARING_KIND_NOT_ALLOWED` (403); locking a kind that cannot be locked
2732
+ (`settings.tax_display` never can) is `ORGANIZATION_SHARING_LOCK_INVALID` (400); any change that
2733
+ would leave a kind locked while off is `ORGANIZATION_SHARING_DISABLED_LOCK_INVALID` (400);
2734
+ switching off a kind something still depends on is `ORGANIZATION_SHARING_KIND_IN_USE_CONFLICT`
2735
+ (409); switching `catalog` on while a member store's `pricesIncludeTax` differs from the
2736
+ organization's is `ORGANIZATION_TAX_MODE_MISMATCH` (422, naming the stores); and `disallow` while
2737
+ the organization has the kind on is `ORGANIZATION_SHARING_KIND_ENABLED_CONFLICT` (409). While
2738
+ `catalog` is on, changing `pricesIncludeTax` on the workspace or a member store is refused with
2739
+ `ORGANIZATION_TAX_MODE_LOCKED` (422).
2740
+
2741
+ A kind whose `phase` the platform has not released yet reads like any other, but `allow`, and a
2742
+ `set` that switches it on or locks it, are refused with `ORGANIZATION_SHARING_KIND_NOT_YET_AVAILABLE`
2743
+ (`BusinessRuleError`, 422): no store, worker or dashboard of that release handles it. Nothing is
2744
+ queued; allow it again once the platform ships the phase. Switching such a kind off and withdrawing
2745
+ it stay open, so a policy written before the gate can always be backed out.
2746
+
2747
+ Switching a `settings.*` kind off (locked or not) is never refused for its dependents. It first
2748
+ copies the organization's current values into every member store that uses them, in the same
2749
+ call: those stores keep them as their own, so their customers see no change, and a store that
2750
+ kept its own values keeps them. When the copy cannot be made nothing changes, the kind stays on,
2751
+ and `set` is refused (`BusinessRuleError`, 422): `ORGANIZATION_SETTINGS_COPY_FAILED` or
2752
+ `ORGANIZATION_SETTINGS_COPY_UNAVAILABLE` (the SEO service could not copy `settings.seo`; try
2753
+ again later), or `ORGANIZATION_SETTINGS_NOT_COPIED` (try again). Before a platform release that
2754
+ copies, switching a settings kind off leaves those stores on their own earlier values.
2755
+
2756
+ When more member stores use the values than one call copies into (25), `set` answers at once
2757
+ with the kind still `enabled`, and the switch-off runs in the background: a job copies into the
2758
+ stores in batches, then switches the kind off about 35 s after the last copy (a run can read
2759
+ `storesDone === storesTotal` and still be `running` meanwhile). Until then every store keeps
2760
+ using the organization's values, and any change of that kind (`set`, `disallow`, a store's
2761
+ Override or Inherit, the workspace's own edit of those settings) is refused with
2762
+ `ORGANIZATION_SETTINGS_SWITCH_OFF_CONFLICT` (`ConflictError`, 409). Withdrawing such a kind is
2763
+ therefore: switch it off, wait for the run, then `disallow`. Follow the run with `switchOffs`
2764
+ (each settings kind's latest run while it agrees with the kind: a `running` one always, a
2765
+ `failed` one while the kind is still on, a `completed` one while it is off, a settled one only
2766
+ until the kind's policy changes again; otherwise null) or
2767
+ `switchOff(id)`. A `failed` run leaves the kind on; switching
2768
+ it off again starts a new run. Both methods need a platform release that runs switch-offs in the
2769
+ background; against an older engine they fail as a whole, and `get`/`set` are unaffected.
2770
+
2771
+ ```ts
2772
+ await sharing.set({ organizationId: org.id, kind: 'settings.email', enabled: false });
2773
+ const [email] = (await sharing.switchOffs(org.id)).filter((entry) => entry.kind === 'settings.email');
2774
+ // email.switchOff → { id, status: 'running', storesDone: 25, storesTotal: 60, errorCode: null, … }
2775
+ const run = await sharing.switchOff(email.switchOff.id); // 'completed' once the kind is off
2776
+ ```
2777
+
2778
+ ### Legal entities
2779
+
2780
+ A legal entity is the company that issues a store's invoices: its legal name, VAT, UK VAT, EORI and
2781
+ registration numbers, registered office and fiscal time zone. One or more stores trade under it. The
2782
+ store's own address, country and time zone stay the store's.
2783
+
2784
+ The active store's entity is `admin.tenant.legalEntity`:
2785
+
2786
+ ```ts
2787
+ const entity = await admin.tenant.legalEntity.get();
2788
+ // { id, legalName, vatNumber, registeredAddressLine1, …, registeredCountry, timezone,
2789
+ // stores: [{ id, code }], storeCount, editable } — null for a store with none
2790
+
2791
+ if (entity?.editable) {
2792
+ await admin.tenant.legalEntity.update({ vatNumber: 'NL123456789B01', registeredCity: 'Utrecht' });
2793
+ }
2794
+ ```
2795
+
2796
+ - **Permissions:** reading needs `ReadSettings` on the store. Changing needs `UpdateSeller` on
2797
+ **every** store of the entity, because the entity prints on all of their invoices.
2798
+ - **A shared entity:** a store whose entity it shares with stores you don't run reads it with
2799
+ `editable: false`. `stores` lists only the stores you may read, and `storeCount` counts all of
2800
+ them. `update` is then refused with `SHARED_SELLER_NOT_ALLOWED` (403 `PermissionDeniedError`).
2801
+ - **What an update writes:** the legal name and the four numbers are copied onto every store of
2802
+ the entity in the same transaction. The registered office and time zone are the entity's alone.
2803
+ - **Two requests:** `update` reads the active store's entity first. A store with none is refused
2804
+ with `LEGAL_ENTITY_NOT_FOUND` (404) before anything is written.
2805
+
2806
+ The platform list and the SuperAdmin writes are `admin.platform.legalEntities`. Every method needs
2807
+ a human session, and a SuperAdmin needs no tenant on the client:
2808
+
2809
+ ```ts
2810
+ const legalEntities = human.platform.legalEntities;
2811
+
2812
+ const created = await legalEntities.create({
2813
+ legalName: 'Acme Group B.V.', // required; never empty
2814
+ registeredCountry: 'NL', // upper-case ISO 3166-1 alpha-2
2815
+ timezone: 'Europe/Amsterdam', // IANA
2816
+ });
2817
+ await legalEntities.update(created.id, { vatNumber: 'NL123456789B01', registeredProvince: null });
2818
+ for await (const each of await legalEntities.list({ term: 'acme' })) console.log(each.legalName);
2819
+ await legalEntities.get(created.id); // null when none has that id, or you may read none of its stores
2820
+ ```
2821
+
2822
+ - **`list` and `create`:** SuperAdmin only.
2823
+ - `list` answers every entity with at least one store, by legal name. Pages default to 50.
2824
+ - A `limit` above 100 is refused with `INVALID_LIMIT`, and a bad offset with `INVALID_OFFSET`
2825
+ (400 `ValidationError`).
2826
+ - `term` matches the legal name, name, VAT or registration number as typed.
2827
+ - **A new entity has no store,** so it is not listed until a store is created under it, and only a
2828
+ SuperAdmin reads it by id. A store created under it, or moved onto it, takes its legal name and
2829
+ numbers.
2830
+ - **`get` and `update` work through every store,** like the tenant module. Anyone but a SuperAdmin
2831
+ needs the permission on every store of the entity (`SHARED_SELLER_NOT_ALLOWED` otherwise).
2832
+ - **Field values:** the engine trims every value. `null` or `''` clears a field, except the legal
2833
+ name, which is never cleared (`LEGAL_ENTITY_FIELD_INVALID`). A field left out is kept.
2834
+ - **Other refusals:** a country that is not upper-case alpha-2 is `INVALID_COUNTRY_CODE`. A fixed
2835
+ offset or unknown zone is `INVALID_TIMEZONE`. Both are 400 `ValidationError`.
2836
+ - **No idempotency key:** the writes are Admin API mutations, so a key deduplicates nothing.
2837
+ `update` is safe to repeat. A `create` whose answer was lost cannot be recovered through this
2838
+ module: the new entity has no store yet, so `list` does not return it, and its id went with the
2839
+ answer. Repeating it makes a second entity, so confirm with the platform operator before retrying
2840
+ a create whose outcome is unknown. Creating the entity WITH its first store avoids this: see
2841
+ below.
2842
+
2843
+ These methods need an engine with the legal-entity Admin API (multi-region phase 2, B4). Against an
2844
+ older engine the request fails GraphQL validation (`GRAPHQL_VALIDATION_FAILED`).
2845
+
2846
+ **A new store under a legal entity** is `platform.provisionStore`. Two ways:
2847
+
2848
+ - **A new entity (the default):** send the store's `legalName` (and `vatNumber`,
2849
+ `registrationNumber`) and no `legalEntity`. The run creates the entity with the store and undoes
2850
+ both if it fails, so there is no lost create to recover, and the run's `idempotencyKey` covers it.
2851
+ - **An existing entity:** send its id as `legalEntity` and none of the three legal fields. The
2852
+ store takes the entity's legal name and numbers; its address, country and time zone are its own.
2853
+ A run that fails undoes the store and never the entity.
2854
+
2855
+ ```ts
2856
+ const store = await human.platform
2857
+ .provisionStore({ ...storeInputWithoutLegalFields, legalEntity: created.id }, { idempotencyKey: idempotencyKey() })
2858
+ .then((run) => run.wait());
2859
+ // store.sellerId === created.id; legalEntities.get(created.id) now lists the store
2860
+ ```
2861
+
2862
+ Refused before any run exists, so nothing is built:
2863
+
2864
+ | Code | Error | When |
2865
+ | --- | --- | --- |
2866
+ | `LEGAL_ENTITY_FIELDS_CONFLICT` | 400 `ValidationError` | `legalName`, `vatNumber` or `registrationNumber` sent with `legalEntity`, even equal to the entity's. Change the entity with `legalEntities.update`. |
2867
+ | `LEGAL_ENTITY_NOT_FOUND` | 404 `NotFoundError` | No legal entity has the id. An organization workspace's Seller and the platform's own are not legal entities. |
2868
+ | `LEGAL_ENTITY_INCOMPLETE` | 422 `BusinessRuleError` | The entity has no legal name, so the store would have none. Set it with `legalEntities.update` first. |
2869
+ | `ORGANIZATIONS_NOT_AVAILABLE` | 400 `ValidationError` | The commerce engine predates legal entities. |
2870
+
2871
+ A sandbox store (`sandbox: true`) may be created under an entity that live stores trade under, and
2872
+ the other way round. Its invoices and credit notes are numbered on its own sequence with a fixed
2873
+ `TEST-` prefix, so they never take a number of the entity's series, and it never pays through the
2874
+ entity's live provider accounts (`LIVE_PAYMENT_REFUSED`).
2875
+
2876
+ An entity that stops being one between the request and the run (it was deleted meanwhile) fails
2877
+ the run at step `seller` before the store exists, and the run ends `compensated`.
2878
+
2879
+ #### A legal entity's numbering
2880
+
2881
+ The entity's stores share its numbering series: `invoice` and `credit_note`, and `ar_credit_note`
2882
+ where B2B credit is installed. A store's invoice, credit note or AR credit note takes the next number
2883
+ of the entity's series, whichever store issues it. A sandbox store numbers on its own rows and is not
2884
+ one of the series' stores.
2885
+
2886
+ ```ts
2887
+ const numbering = await admin.tenant.legalEntity.numbering.get();
2888
+ // { legalEntityId, series: [{ series: 'invoice', configured, prefix, suffix, digits, yearFormat,
2889
+ // yearReset, nextNumber, lastResetYear, invoicingProvider }, …], stores, storeCount, editable }
2890
+
2891
+ if (numbering?.editable) {
2892
+ await admin.tenant.legalEntity.numbering.update({ series: 'invoice', prefix: 'ACME-', digits: 6 });
2893
+ }
2894
+ // The platform's, by id (human session):
2895
+ const entityId = created.id; // a legal entity's id, e.g. from legalEntities.create above
2896
+ await human.platform.legalEntities.numbering.update(entityId, { series: 'credit_note', yearFormat: 'full' });
2897
+ ```
2898
+
2899
+ - **Permissions:** `get` without an id needs `ReadSettings` on the store; by id, `ReadSettings` on
2900
+ every store of the entity. `update` needs `UpdateSettings` on **every** store of the entity
2901
+ (`SHARED_SELLER_NOT_ALLOWED`, 403). A SuperAdmin passes. The platform methods need the id: a
2902
+ missing or blank one is refused before sending (`LEGAL_ENTITY_ID_REQUIRED`, 400); for the active
2903
+ store's entity use the tenant module.
2904
+ - **Format only.** `nextNumber` and `lastResetYear` are read-only: only issuing a document moves
2905
+ them. A field left out is kept, and only the fields that differ from the series count, so on a
2906
+ configured series sending back what you read changes nothing.
2907
+ - **What an update writes:** the format on the entity and on every store row of the series, in one
2908
+ transaction, holding the same locks as the allocator (the store rows, then the entity's).
2909
+ - **A series not set up yet** (`configured: false`, every field null): a lone numbering store's
2910
+ first document sets it up. With two or more (AR credit notes: two or more that issued), every
2911
+ store's documents are refused with `LEGAL_ENTITY_NUMBERING_UNSET` until an `update` sets the
2912
+ series up, and **any** `update` does, even an empty one or one sending back the nulls you read.
2913
+ The series then continues past every number any store issued, in the format of the one store that
2914
+ issued (or the one format the stores chose, else the defaults) with your change on top. Its
2915
+ invoicing provider and ERP number field are the ones the stores share; stores that differ in
2916
+ either are refused with `LEGAL_ENTITY_NUMBERING_PROVIDERS_DIFFER` (422) until they agree.
2917
+ - **Changes that would print a number again** are refused with `NUMBERING_RESTART_WOULD_REPEAT`
2918
+ (422), judged on the result whatever else the save changes; ask the platform operator:
2919
+ - turning the yearly restart on (`yearReset`, or a year format on a series that has it) while the
2920
+ series would then start again at 1 before the next fiscal year: it has issued numbers and has
2921
+ not restarted this year;
2922
+ - taking the year out (`yearFormat: 'none'`) of a series that counts under a yearly restart (it
2923
+ holds a restart year, including one a set-up gave it), or a set-up that would take it out of
2924
+ a store that does.
2925
+ - **Other refusals:**
2926
+ - 400 `ValidationError`: `NUMBERING_SERIES_INVALID`; `NUMBERING_YEAR_FORMAT_INVALID` (a year
2927
+ format other than `none`/`short`/`full`); `NUMBERING_DIGITS_INVALID` (3 to 8);
2928
+ `NUMBERING_AFFIX_INVALID` (30 characters at most; no control, invisible-format or line
2929
+ separator characters).
2930
+ - 422 `BusinessRuleError`: `NUMBER_PREFIX_RESERVED` (a prefix starting with `TEST-`, a
2931
+ sandbox's); `NUMBERING_YEAR_FORMAT_UNKNOWN` (setting up a series that restarts yearly over a
2932
+ stored year format the engine does not know).
2933
+ - 404: `ENTITY_NOT_FOUND`, a store with no legal entity.
2934
+ - **The store doors** (`updateInvoiceConfig`, `updateCreditNoteNumberingConfig`) refuse a format
2935
+ change to a series a legal entity shares with other stores (`LEGAL_ENTITY_NUMBERING_SHARED`):
2936
+ change it here.
2937
+
2938
+ These methods need an engine with the legal-entity numbering API (multi-region phase 2, B6c).
2939
+ Against an older engine the request fails GraphQL validation (`GRAPHQL_VALIDATION_FAILED`).
2940
+
2941
+ ### Settings from an organization
2942
+
2943
+ A member store takes each settings group its organization shares (`settings.returns`,
2944
+ `settings.branding`, … — `SETTINGS_GROUPS` lists them in catalog order) from the organization,
2945
+ unless it keeps its own. `admin.tenant.settingsSources` answers, for the **active** store, where
2946
+ each group comes from, and switches one group between the two:
2947
+
2948
+ ```ts
2949
+ const sources = await admin.tenant.settingsSources.list();
2950
+ // [{ group, source: 'channel' | 'organization', locked, overridden, inheritable, organizationName, values }, …]
2951
+
2952
+ await admin.tenant.settingsSources.override('settings.returns'); // keep this store's own values
2953
+ await admin.tenant.settingsSources.inherit('settings.returns'); // back to the organization's
2954
+ ```
2955
+
2956
+ A settings page reads three states off an entry: **managed by the organization** (`locked` — the
2957
+ store cannot keep its own), **inherited** (`inheritable && !overridden`) and **overridden**
2958
+ (`inheritable && overridden`). Anything else is the store's own setting with no organization behind
2959
+ it. `values` holds the group's effective channel custom fields (`null` for `settings.email` and
2960
+ `settings.numbering_formats`, whose own reads already answer the effective values), and
2961
+ `organizationName` is `null` outside an organization. `group` and `source` are typed as the names
2962
+ this SDK knows but carry any string, so a group a newer engine adds reaches you unchanged.
2963
+
2964
+ A group's `values` name its images by asset id (`logoAssetId`, `faviconAssetId`, …). While the
2965
+ store inherits the group those are its organization's assets, which the store's own library does not
2966
+ hold, so `catalog.assets.get(id)` answers `null` for them. `assets(group)` reads them from the channel
2967
+ the values come from:
2968
+
2969
+ ```ts
2970
+ const images = await admin.tenant.settingsSources.assets('settings.branding');
2971
+ // [{ id, name, type, mimeType, preview, source, … }] — only the assets the group's values name
2972
+ ```
2973
+
2974
+ It answers nothing for a group without images, needs the same permission as `list`, and refuses an
2975
+ unknown group with `ORGANIZATION_SETTINGS_GROUP_INVALID`.
2976
+
2977
+ `override` starts the store from a copy of the organization's current values, so nothing changes for
2978
+ its customers until someone edits them; `inherit` leaves the store's own values stored but unused.
2979
+ Both answer the group's state afterwards and are safe to repeat. `list` needs any permission on the
2980
+ store; `override` and `inherit` need `UpdateChannel`, or `UpdateSettings` for `settings.email` and
2981
+ `settings.numbering_formats`. A SuperAdmin reads any store the same way, with that store as the
2982
+ tenant. Refusals: `MANAGED_BY_ORGANIZATION` (422 `BusinessRuleError` — the organization locked the
2983
+ group, SuperAdmins included), `ORGANIZATION_SETTINGS_NOT_A_MEMBER` (422 — the store is in no
2984
+ organization, or is a workspace), `ORGANIZATION_SETTINGS_GROUP_INVALID` (400 `ValidationError` — a
2985
+ group the engine does not know; a blank one is refused before sending). While a store inherits a
2986
+ group, `override` it before changing its values. A platform release that guards settings writes
2987
+ refuses a write to an inherited group with `ORGANIZATION_SETTINGS_INHERITED` (422); an engine
2988
+ without that guard accepts the write, but the group still resolves to the organization's values.
2989
+
2990
+ Before an organization admin locks a group for every store, `settingsOverrides` names the stores that
2991
+ would lose their own values:
2992
+
2993
+ ```ts
2994
+ const workspace = human.withTenant({ scope: { kind: 'channel', code: 'org-acme-eu' } });
2995
+ const overrides = await workspace.platform.organizations.settingsOverrides(org.id);
2996
+ // [{ channelId, channelCode, group, overriddenAt: '2026-10-25T00:30:00.000Z' }, …]
2997
+ ```
2998
+
2999
+ It lists every member store keeping its own values, one entry per store and group, whether or not
3000
+ the organization shares that group today, ordered by store code. `overriddenAt` is ISO-8601 in UTC,
3001
+ exactly as the engine sends it: compare instants with `Date.parse` and format it in the viewer's
3002
+ time zone only for display. Callers and refusals are those of `members`: a SuperAdmin with no tenant,
3003
+ or the organization admin in its workspace; an unknown id is `ORGANIZATION_NOT_FOUND` (404).
3004
+
3005
+ ### Variant prices from an organization
3006
+
3007
+ A variant an organization shares from its workspace (catalog sharing on) reaches each member store
3008
+ with the organization's prices copied in, and a store may change them. `priceSources` answers where
3009
+ each price of the **active** store comes from; `revertPrice` takes changed prices back to the
3010
+ organization's:
3011
+
3012
+ ```ts
3013
+ const sources = await admin.catalog.variants.priceSources(variantId);
3014
+ // [{ currencyCode: 'EUR', price: 2500, source: 'OWN', organizationPrice: 2099, organizationName: 'Acme Europe' },
3015
+ // { currencyCode: 'USD', price: 2508, source: 'CONVERTED', organizationPrice: null, organizationName: 'Acme Europe' }]
3016
+
3017
+ await admin.catalog.variants.revertPrice([variantId]); // every currency
3018
+ await admin.catalog.variants.revertPrice([variantId], { currencyCodes: ['EUR'] }); // EUR only
3019
+ ```
3020
+
3021
+ One entry per currency the store sells, in the store's currency order:
3022
+
3023
+ - **`OWN`** — the store's own price: on a variant of its own, or its change of the organization's
3024
+ price, in which case `organizationPrice` shows the price it replaces.
3025
+ - **`INHERITED`** — the organization's price, unchanged since it was copied. It follows the
3026
+ organization's later changes.
3027
+ - **`CONVERTED`** — nothing stored: the storefront converts the store's EUR price at the store's
3028
+ exchange rate, and `price` is what that answers now. Null when there is no EUR price or rate, or
3029
+ the store marked the currency not for sale for the variant.
3030
+
3031
+ `price` and `organizationPrice` are minor units on the **store's** basis — gross on a store with
3032
+ `pricesIncludeTax`, net otherwise, the basis a variant write takes. That is not
3033
+ `ProductVariant.price`, which reads net everywhere. An organization and its stores enter prices the
3034
+ same way, so the two compare directly. A store outside any organization, and a variant of the
3035
+ store's own, read `OWN` or `CONVERTED` only, with `organizationPrice` and `organizationName` null.
3036
+ `source` is typed as the three names but carries any string, so a source a newer engine adds
3037
+ reaches you unchanged. An id the store does not have is `VARIANT_NOT_FOUND` (404 `NotFoundError`).
3038
+
3039
+ `revertPrice` makes the prices `INHERITED` again: the organization's stored price, followed from then
3040
+ on until the store changes it again (`variants.update`). Without `currencyCodes` it takes every
3041
+ currency the store sells and the organization prices, except a currency the store marked not for
3042
+ sale for the variant and stores no price in (a stored price sells the currency whatever its mark,
3043
+ so it is reverted); an empty list reverts nothing. It is all or nothing, writes one audit row per
3044
+ variant (`product_variant.price.revert`, each currency's price before and after) and answers the
3045
+ variants as the store sees them. At most `MAX_REVERT_PRICE_VARIANTS` (500) distinct variants per
3046
+ call; more is refused before sending. `priceSources` needs `ReadCatalog`; `revertPrice` needs
3047
+ `UpdateCatalog` or `UpdateProduct`, what a variant price write needs. Refusals, none of which writes
3048
+ anything: `ORGANIZATION_PRICE_LOCAL_VARIANT` (422 `BusinessRuleError` — a variant of the store's
3049
+ own, or the organization does not share its catalog), `ORGANIZATION_PRICE_MISSING` (422 — the
3050
+ organization stores no price in a currency named, or in any the store sells),
3051
+ `ORGANIZATION_PRICE_NOT_REVERTIBLE` (422 — a named currency the store does not sell, or marked not
3052
+ for sale for the variant with no price stored in it), `ORGANIZATION_WORKSPACE_NOT_A_STORE` (422 — called in the workspace,
3053
+ whose prices are the organization's own), `ORGANIZATION_PRICE_REVERT_INVALID` (400
3054
+ `ValidationError` — more than 500 variants) and `ENTITY_NOT_FOUND` (404 — an id the store does not
3055
+ have).
3056
+
3057
+ ### Catalogue from an organization
3058
+
3059
+ An organization keeps catalogue rows in its workspace, and its member stores hold them too, kind by
3060
+ kind: products, variants, option groups, facets and assets while it shares `catalog`, collections
3061
+ while it shares `collections`, customer groups while it shares `customer_groups`. Members also read
3062
+ the workspace's property groups on the products they hold. Every catalogue read says whose row it
3063
+ is, as the **active** store sees it:
3064
+
3065
+ ```ts
3066
+ const product = await admin.catalog.products.get(productId);
3067
+ product.catalogueOwnership;
3068
+ // { state: 'SHARED', organizationName: 'Acme Europe', editableHere: false }
3069
+ ```
3070
+
3071
+ - **`LOCAL`** — the store's own row (or, in the workspace, one no member store holds yet).
3072
+ `organizationName` is null.
3073
+ - **`SHARED`** — in a member store, a row of its organization's workspace; in the workspace, a row at
3074
+ least one member store holds. `organizationName` names the organization.
3075
+
3076
+ `editableHere` says whether the caller may change the row from the active store: it holds the update
3077
+ permission there and on every other store holding the row, and the row is not a workspace row read
3078
+ from a member store. Show the form read-only while it is false; the engine refuses the write anyway.
3079
+ Property groups carry `editableHere` alone, and it means less there: only whether the group is the
3080
+ active store's own, whatever the caller's permissions. `catalog.properties.values.get` of a product the store
3081
+ holds from its organization answers the workspace's groups and values beside the store's own, with
3082
+ `editableHere` false, and a `values.set` changing one of those values is refused whole with
3083
+ `MANAGED_BY_ORGANIZATION` (one echoing them unchanged is accepted). `catalog.properties.groups.list`
3084
+ answers the store's own groups only. Changing a workspace group or property from the store
3085
+ (`properties.groups.update`, `create` in it, `update`, `reorder`, `groups.reorder`, a delete) is
3086
+ refused `MANAGED_BY_ORGANIZATION` too. Any other id the store does not own is
3087
+ `PROPERTY_GROUP_NOT_FOUND` or `PROPERTY_NOT_FOUND` (404) for `groups.update`, `create`, `update`,
3088
+ `reorder` and `groups.reorder` (which moves nothing then), and a delete of one is the
3089
+ `ConflictError` `PROPERTY_GROUP_NOT_DELETED` / `PROPERTY_NOT_DELETED`.
3090
+
3091
+ A member store's write to a workspace product, variant, collection, facet, asset or option group, or
3092
+ one that reaches such a row through a product, a parent collection or an option group, is refused with `MANAGED_BY_ORGANIZATION` (`BusinessRuleError`, 422) for
3093
+ everyone, SuperAdmin included, and the message says what to do instead (edit it in the workspace,
3094
+ copy the product, create a facet of the store's own, …). Make the change in the workspace:
3095
+
3096
+ ```ts
3097
+ const workspace = admin.withTenant({ scope: { kind: 'channel', code: 'org-acme-eu' } });
3098
+ await workspace.catalog.products.update({ id: productId, enabled: false });
3099
+ ```
3100
+
3101
+ What the store adds to a shared row stays its own: its prices (see
3102
+ [Variant prices from an organization](#variant-prices-from-an-organization)), its stock at its own
3103
+ locations, its own facet values on the product and its own collections under a workspace collection.
3104
+ `customers.groups.byExternalKey`, `upsertByExternalKey` and `replaceMembers` answer `catalogueOwnership` too, and
3105
+ renaming a workspace group from a member store is refused the same way. A customer group's badge
3106
+ reads it by id:
3107
+
3108
+ ```ts
3109
+ const group = await admin.customers.groups.get(groupId);
3110
+ // { id, createdAt, updatedAt, name, externalKey, catalogueOwnership }, or null
3111
+ ```
3112
+
3113
+ `groups.get` needs `ReadCustomer` or `ReadCustomerGroup`. It answers `null` for a group the active
3114
+ store does not hold (another store's, a deleted one, an unknown id), like every other entity `get`;
3115
+ show that as "not found", never as the store's own group. An id that is not a uuid is refused before
3116
+ that with core's `ENTITY_NOT_FOUND`, a `NotFoundError`: branch on the class, not the code. `state` is typed as the two
3117
+ names but carries any string, so a state a newer engine adds reaches you unchanged. Every product,
3118
+ collection, facet, asset and option group read and write answer selects `catalogueOwnership`, as do
3119
+ the external-key customer group answers, and every property group answer (`properties.groups.*`,
3120
+ `properties.values.get`) selects `editableHere`, so this release needs a platform release that
3121
+ serves both; against an older engine those calls fail as a whole and write nothing. `tenant.settingsSources.assets` answers
3122
+ `AssetFile` (an `Asset` without `catalogueOwnership`) and keeps working there.
3123
+
3124
+ ### Which stores offer an organization's products
3125
+
3126
+ A member store sells the workspace products it holds. Two switches take one out of a store without
3127
+ touching the others: the store **excludes** it itself, or the organization sets its **availability**
3128
+ per store from the workspace.
3129
+
3130
+ ```ts
3131
+ // In the member store: off its storefront and out of its carts; its orders keep it.
3132
+ const [lamp] = await admin.catalog.products.exclude([productId]);
3133
+ lamp.excludedFromStore; // true
3134
+ await admin.catalog.products.include([productId]); // offered again
3135
+
3136
+ // In the organization's workspace: on or off in member stores, by their channel ids.
3137
+ const workspace = admin.withTenant({ scope: { kind: 'channel', code: 'org-acme-eu' } });
3138
+ const changes = await workspace.catalog.products.setAvailabilityInStores({
3139
+ productIds: [productId],
3140
+ channelIds: [nlStoreId, deStoreId],
3141
+ available: false,
3142
+ });
3143
+ // [{ productId, channelId: nlStoreId, action: 'excluded' }, …]
3144
+
3145
+ // …and read where each product stands in each store, for a list column or before a toggle.
3146
+ const rows = await workspace.catalog.products.availabilityInStores({
3147
+ productIds: [productId, otherProductId],
3148
+ channelIds: [nlStoreId, deStoreId],
3149
+ });
3150
+ // [{ productId, channelId: nlStoreId, state: 'excluded', localCopyId: null }, …]
3151
+ ```
3152
+
3153
+ `exclude` and `include` act on the **active** store only. An excluded product leaves its storefront
3154
+ and cannot be added to its carts, while its orders, returns and invoices keep it, and every product
3155
+ read answers `excludedFromStore: true` there (always false for the store's own products and in a
3156
+ workspace). Excluding an excluded product, or including one that is not excluded, changes nothing.
3157
+ Both are all or nothing, audited, take at most `MAX_STORE_EXCLUSION_PRODUCTS` (500) distinct ids per
3158
+ call (more is refused before sending), answer the products as the store sees them, and need
3159
+ `UpdateCatalog` or `UpdateProduct`. Refusals: `STORE_EXCLUSION_LOCAL_PRODUCT` (`BusinessRuleError`,
3160
+ 422 — `exclude` of the store's own product, which it disables or deletes instead),
3161
+ `STORE_EXCLUSION_INPUT_INVALID` (`ValidationError`, 400 — too many ids),
3162
+ `ORGANIZATION_WORKSPACE_NOT_A_STORE` (422 — called in a workspace, which sells nothing) and
3163
+ `ENTITY_NOT_FOUND` (404 — a product the store does not hold, or for `include` neither holds nor
3164
+ excludes).
3165
+
3166
+ `setAvailabilityInStores` is the organization's Availability toggle, called with its workspace as
3167
+ the tenant by an organization admin (`ManageOrganization`) or a SuperAdmin, who also needs
3168
+ `UpdateCatalog` in each store named. Off, under the `all` assortment, the product is **excluded** from the store, as
3169
+ above. Off, under `selected`, it is **unlinked** from a store that never sold or quoted it, and
3170
+ excluded from one that did. On, it is **included** again, or **assigned** to a store that does not
3171
+ hold it, its images and variants following within moments. It answers only what changed, sorted by
3172
+ store and product; `action` is typed as those four names but carries any string. All or nothing and
3173
+ audited, for 1 to `MAX_AVAILABILITY_PRODUCTS` (100) products and 1 to `MAX_AVAILABILITY_STORES`
3174
+ (50) stores per call. Refusals: `ORGANIZATION_AVAILABILITY_INPUT_INVALID` (`ValidationError`, 400 —
3175
+ no products or stores, too many, or `available` not a boolean; refused before sending too),
3176
+ `ORGANIZATION_ACCESS_DENIED` (`PermissionDeniedError`, 403 — the tenant is not a workspace),
3177
+ `CHANNEL_ACCESS_DENIED` (403 — a store whose catalogue the caller may not change),
3178
+ `ORGANIZATION_CATALOG_NOT_SHARED` (`BusinessRuleError`, 422 — `catalog` sharing is off),
3179
+ `ORGANIZATION_PRICE_MISSING` (422 — a store would be offered a variant it has no price for) and
3180
+ `ENTITY_NOT_FOUND` (404 — a product that is not the workspace's, or a store that is not a member).
3181
+
3182
+ `availabilityInStores` is the toggle's read, behind the same door: called with the workspace as the
3183
+ tenant by an organization admin or a SuperAdmin, who also needs `ReadCatalog`, `ReadProduct` or
3184
+ `UpdateCatalog` in each store named. It answers one row per product and store, products first, in
3185
+ the order named (each distinct id once): `state` is `offered` (the store holds the product and does
3186
+ not exclude it; the product's state, not a promise that a variant is buyable there), `excluded`
3187
+ (kept for its orders, off its storefront and carts; also a product excluded ahead of the
3188
+ organization's fan-out under `all`, not held yet) or `not-held`, typed as those three but carrying
3189
+ any string, and `localCopyId` names the store's own local copy of the product, if it has
3190
+ one. It answers whether or not the organization shares its products, for 1 to
3191
+ `MAX_AVAILABILITY_PRODUCTS` (100) products and 1 to `MAX_AVAILABILITY_STORES` (50) stores per call:
3192
+ page a longer list of stores in calls of 50. Refusals: `ORGANIZATION_AVAILABILITY_INPUT_INVALID`
3193
+ (400, also before sending), `ORGANIZATION_ACCESS_DENIED` (403), `CHANNEL_ACCESS_DENIED` (403 — a
3194
+ store whose catalogue the caller may not read) and `ENTITY_NOT_FOUND` (404). A platform release
3195
+ older than this method does not have the query: the request fails GraphQL validation
3196
+ (`GRAPHQL_VALIDATION_FAILED`, also a 400 `ValidationError`, told apart by its code).
3197
+
3198
+ `excludedFromStore` is selected on every product answer, so this release needs a platform release
3199
+ that serves it, as it needs `catalogueOwnership`.
3200
+
3201
+ ### A store's own copy of an organization's product
3202
+
3203
+ A member store that wants to change a product its organization shares makes a **local copy**: its
3204
+ own product, replacing the shared one in that store.
3205
+
3206
+ ```ts
3207
+ const { product, original, droppedOrganizationPropertyCount } =
3208
+ await admin.catalog.products.makeLocalCopy(sharedProductId);
3209
+ // product: the store's own copy, DISABLED; original: 'excluded' | 'unlinked'
3210
+
3211
+ // Choose some SKUs; the rest end in -<store code> (first free).
3212
+ await admin.catalog.products.makeLocalCopy(sharedProductId, {
3213
+ skus: [{ productVariantId, sku: 'TEE-NL-M' }],
3214
+ });
3215
+ ```
3216
+
3217
+ The copy is a `clone` created disabled, so review it and enable it with `products.update`. It takes
3218
+ the store's current prices in every currency (inherited or its own), its not-for-sale marks and
3219
+ its price-list entries, and joins only the store's own collections. Its `catalogueOwnership` reads
3220
+ `LOCAL`: its product and variant rows, names, prices and properties are the store's own and no
3221
+ longer follow the workspace. The workspace's option groups, assets and facet values it still uses
3222
+ do follow it (a workspace rename of an option shows on the copy), and stay the workspace's to
3223
+ change (`MANAGED_BY_ORGANIZATION` from the store). The organization's property values are
3224
+ not copied; `droppedOrganizationPropertyCount` says how many there were, so a form can warn before
3225
+ the copy is enabled. The shared product leaves the store in the same transaction: `excluded`
3226
+ (`excludedFromStore`) when the store sold or quoted it, and always under the organization's `all`
3227
+ assortment, otherwise `unlinked`; `original` says which, typed as the two names but carrying any
3228
+ string.
3229
+
3230
+ A store keeps at most one live copy of a product: a repeat (a double submit, a retry) is refused,
3231
+ never a second copy, with `LOCAL_COPY_ALREADY_EXISTS`, whether the first call excluded or unlinked
3232
+ the original. (A platform release older than that answers `ENTITY_NOT_FOUND` after an unlink, the
3233
+ store no longer holding the product: there, look for the copy before treating a 404 as a bad id.) Needs `CreateCatalog` (or `CreateProduct`) and `UpdateCatalog` in the store.
3234
+ Refusals, none of which writes anything: `LOCAL_COPY_PRODUCT_NOT_SHARED` (`BusinessRuleError`, 422
3235
+ — the store's own product; `clone` it instead), `LOCAL_COPY_ALREADY_EXISTS` (`ConflictError`, 409),
3236
+ `LOCAL_COPY_SKU_CONFLICT` (409 — a SKU you named is another product's in the store; `error.details`
3237
+ names each, its `field` the SKU's place in `skus`, `/skus/<index>/sku`), `LOCAL_COPY_INPUT_INVALID` (`ValidationError`, 400 — `skus` names a variant the store
3238
+ does not hold on the product, names one twice, repeats a SKU, or has a blank or overlong one),
3239
+ `ORGANIZATION_WORKSPACE_NOT_A_STORE` (422 — called in the workspace) and `ENTITY_NOT_FOUND` (404).
3240
+ Requires a platform release that serves `makeLocalCopy`.
3241
+
814
3242
  ### Errors
815
3243
 
816
3244
  One hierarchy, all re-exported from this package so you need no direct dependency on