@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.
- package/README.md +2445 -17
- package/dist/api-tokens/index.d.ts +15 -5
- package/dist/api-tokens/index.js +1 -0
- package/dist/apps/event-subscription.d.ts +59 -0
- package/dist/apps/event-subscription.js +32 -0
- package/dist/apps/hook-secret.d.ts +22 -0
- package/dist/apps/hook-secret.js +36 -0
- package/dist/apps/index.d.ts +54 -3
- package/dist/apps/index.js +17 -0
- package/dist/audit/index.d.ts +7 -0
- package/dist/auth/queries.d.ts +5 -1
- package/dist/auth/queries.js +6 -0
- package/dist/bulk/index.d.ts +323 -0
- package/dist/bulk/index.js +162 -0
- package/dist/catalog/assets.d.ts +147 -0
- package/dist/catalog/assets.js +164 -0
- package/dist/catalog/collections.d.ts +109 -0
- package/dist/catalog/collections.js +91 -0
- package/dist/catalog/facets.d.ts +103 -0
- package/dist/catalog/facets.js +100 -0
- package/dist/catalog/index.d.ts +115 -0
- package/dist/catalog/index.js +21 -0
- package/dist/catalog/options.d.ts +89 -0
- package/dist/catalog/options.js +88 -0
- package/dist/catalog/products-bulk.d.ts +21 -0
- package/dist/catalog/products-bulk.js +58 -0
- package/dist/catalog/products.d.ts +489 -0
- package/dist/catalog/products.js +365 -0
- package/dist/catalog/properties.d.ts +234 -0
- package/dist/catalog/properties.js +158 -0
- package/dist/catalog/types.d.ts +174 -0
- package/dist/catalog/types.js +141 -0
- package/dist/catalog/variants.d.ts +274 -0
- package/dist/catalog/variants.js +258 -0
- package/dist/catalog/visibility.d.ts +57 -0
- package/dist/catalog/visibility.js +59 -0
- package/dist/client.d.ts +171 -3
- package/dist/client.js +81 -12
- package/dist/compliance/index.d.ts +88 -0
- package/dist/compliance/index.js +88 -0
- package/dist/customer-data-redaction.d.ts +14 -0
- package/dist/customer-data-redaction.js +22 -0
- package/dist/customers/index.d.ts +428 -17
- package/dist/customers/index.js +322 -15
- package/dist/documents/index.d.ts +513 -16
- package/dist/documents/index.js +229 -3
- package/dist/events/index.d.ts +259 -0
- package/dist/events/index.js +147 -0
- package/dist/feeds/ai.d.ts +46 -0
- package/dist/feeds/ai.js +43 -0
- package/dist/feeds/categories.d.ts +50 -0
- package/dist/feeds/categories.js +56 -0
- package/dist/feeds/configs.d.ts +54 -0
- package/dist/feeds/configs.js +85 -0
- package/dist/feeds/identifier-fields.d.ts +25 -0
- package/dist/feeds/identifier-fields.js +29 -0
- package/dist/feeds/index.d.ts +54 -0
- package/dist/feeds/index.js +46 -0
- package/dist/feeds/preserve.d.ts +38 -0
- package/dist/feeds/preserve.js +74 -0
- package/dist/feeds/rules.d.ts +42 -0
- package/dist/feeds/rules.js +56 -0
- package/dist/feeds/types.d.ts +654 -0
- package/dist/feeds/types.js +135 -0
- package/dist/fulfillments/index.d.ts +360 -0
- package/dist/fulfillments/index.js +425 -0
- package/dist/heads/index.d.ts +60 -0
- package/dist/heads/index.js +45 -0
- package/dist/hooks/assignments.d.ts +90 -0
- package/dist/hooks/assignments.js +53 -0
- package/dist/hooks/index.d.ts +97 -0
- package/dist/hooks/index.js +76 -0
- package/dist/index.d.ts +84 -9
- package/dist/index.js +16 -1
- package/dist/jobs/bulk-action-run.d.ts +65 -0
- package/dist/jobs/bulk-action-run.js +70 -0
- package/dist/locations/index.d.ts +6 -2
- package/dist/meta/index.d.ts +32 -0
- package/dist/orders/commit-edit.d.ts +123 -0
- package/dist/orders/commit-edit.js +142 -0
- package/dist/orders/get.d.ts +722 -0
- package/dist/orders/get.js +530 -0
- package/dist/orders/index.d.ts +105 -20
- package/dist/orders/index.js +44 -0
- package/dist/orders/line-price-preview.d.ts +297 -0
- package/dist/orders/line-price-preview.js +50 -0
- package/dist/orders/manual-discounts.d.ts +132 -0
- package/dist/orders/manual-discounts.js +95 -0
- package/dist/orders/manual-pricing.d.ts +192 -0
- package/dist/orders/manual-pricing.js +72 -0
- package/dist/orders/order-entry.d.ts +566 -0
- package/dist/orders/order-entry.js +129 -0
- package/dist/orders/parcel-charge.d.ts +45 -0
- package/dist/orders/parcel-charge.js +55 -0
- package/dist/orders/plan-edit.d.ts +489 -0
- package/dist/orders/plan-edit.js +93 -0
- package/dist/orders/status.d.ts +235 -0
- package/dist/orders/status.js +96 -0
- package/dist/orders/surcharges.d.ts +75 -2
- package/dist/orders/surcharges.js +110 -4
- package/dist/payments/account-credit.d.ts +171 -0
- package/dist/payments/account-credit.js +107 -0
- package/dist/payments/index.d.ts +234 -9
- package/dist/payments/index.js +90 -0
- package/dist/platform/catalog.d.ts +148 -0
- package/dist/platform/catalog.js +74 -0
- package/dist/platform/entitlements.d.ts +111 -0
- package/dist/platform/entitlements.js +86 -0
- package/dist/platform/legal-entities.d.ts +139 -0
- package/dist/platform/legal-entities.js +126 -0
- package/dist/platform/legal-entity-numbering.d.ts +103 -0
- package/dist/platform/legal-entity-numbering.js +101 -0
- package/dist/platform/organization-membership.d.ts +80 -0
- package/dist/platform/organization-membership.js +71 -0
- package/dist/platform/organization-settings.d.ts +33 -0
- package/dist/platform/organization-settings.js +42 -0
- package/dist/platform/organization-sharing.d.ts +172 -0
- package/dist/platform/organization-sharing.js +120 -0
- package/dist/platform/organizations.d.ts +205 -0
- package/dist/platform/organizations.js +180 -0
- package/dist/platform/provision-store.d.ts +88 -8
- package/dist/platform/provision-store.js +12 -0
- package/dist/platform/reports.d.ts +46 -0
- package/dist/platform/reports.js +31 -0
- package/dist/platform/server-metrics.d.ts +58 -0
- package/dist/platform/server-metrics.js +11 -0
- package/dist/pos/index.d.ts +567 -0
- package/dist/pos/index.js +205 -0
- package/dist/pricing/index.d.ts +405 -0
- package/dist/pricing/index.js +351 -0
- package/dist/provenance/index.d.ts +93 -0
- package/dist/provenance/index.js +53 -0
- package/dist/reports/index.d.ts +199 -0
- package/dist/reports/index.js +154 -0
- package/dist/shipping/parcel-count-suggestion.d.ts +52 -0
- package/dist/shipping/parcel-count-suggestion.js +73 -0
- package/dist/shipping/parcel-rule.d.ts +48 -0
- package/dist/shipping/parcel-rule.js +91 -0
- package/dist/stock/index.d.ts +166 -0
- package/dist/stock/index.js +115 -0
- package/dist/stock/transfers.d.ts +520 -0
- package/dist/stock/transfers.js +437 -0
- package/dist/stored-value/index.d.ts +154 -0
- package/dist/stored-value/index.js +130 -0
- package/dist/tenant/capabilities.d.ts +10 -0
- package/dist/tenant/entitlements.d.ts +35 -0
- package/dist/tenant/entitlements.js +15 -0
- package/dist/tenant/field-groups.d.ts +64 -0
- package/dist/tenant/field-groups.js +34 -0
- package/dist/tenant/legal-entity.d.ts +32 -0
- package/dist/tenant/legal-entity.js +41 -0
- package/dist/tenant/notifications.d.ts +148 -0
- package/dist/tenant/notifications.js +79 -0
- package/dist/tenant/retention.d.ts +10 -4
- package/dist/tenant/settings-sources.d.ts +103 -0
- package/dist/tenant/settings-sources.js +92 -0
- package/dist/types.d.ts +86 -11
- package/dist/uploads/index.d.ts +11 -7
- package/dist/users/index.d.ts +104 -0
- package/dist/users/index.js +80 -0
- 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.
|
|
82
|
-
|
|
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
|
|
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 (
|
|
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.
|
|
408
|
-
|
|
409
|
-
|
|
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
|
|
412
|
-
|
|
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`
|
|
640
|
-
|
|
641
|
-
|
|
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
|
|
679
|
-
|
|
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
|