@sixtyninecommerce/admin 0.11.0 → 0.12.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (147) hide show
  1. package/README.md +1501 -23
  2. package/dist/auth/index.d.ts +7 -1
  3. package/dist/auth/index.js +8 -1
  4. package/dist/catalog/documents.d.ts +142 -0
  5. package/dist/catalog/documents.js +114 -0
  6. package/dist/catalog/index.d.ts +48 -7
  7. package/dist/catalog/index.js +6 -1
  8. package/dist/catalog/metadata.d.ts +244 -0
  9. package/dist/catalog/metadata.js +220 -0
  10. package/dist/catalog/properties.d.ts +38 -0
  11. package/dist/catalog/properties.js +52 -1
  12. package/dist/catalog/recommendations.d.ts +33 -6
  13. package/dist/catalog/recommendations.js +94 -2
  14. package/dist/catalog/reviews.d.ts +17 -4
  15. package/dist/catalog/reviews.js +7 -0
  16. package/dist/catalog/variants.d.ts +60 -0
  17. package/dist/catalog/variants.js +39 -0
  18. package/dist/catalog/visibility.d.ts +18 -2
  19. package/dist/client.d.ts +29 -3
  20. package/dist/client.js +19 -8
  21. package/dist/content/assets.d.ts +3 -4
  22. package/dist/content/forms.d.ts +332 -1
  23. package/dist/content/forms.js +140 -4
  24. package/dist/content/items.d.ts +31 -8
  25. package/dist/content/items.js +1 -1
  26. package/dist/content/menus.d.ts +254 -0
  27. package/dist/content/menus.js +70 -0
  28. package/dist/content/strings.d.ts +137 -0
  29. package/dist/content/strings.js +82 -0
  30. package/dist/customers/delivery-terms.d.ts +44 -0
  31. package/dist/customers/delivery-terms.js +32 -0
  32. package/dist/customers/index.d.ts +15 -0
  33. package/dist/customers/index.js +4 -0
  34. package/dist/documents/emails.d.ts +150 -0
  35. package/dist/documents/emails.js +220 -0
  36. package/dist/documents/index.d.ts +13 -0
  37. package/dist/documents/index.js +2 -0
  38. package/dist/fulfillments/index.d.ts +119 -1
  39. package/dist/fulfillments/index.js +124 -4
  40. package/dist/index.d.ts +43 -23
  41. package/dist/index.js +11 -5
  42. package/dist/integrations/exact-online-types.d.ts +542 -0
  43. package/dist/integrations/exact-online.d.ts +541 -5
  44. package/dist/integrations/exact-online.js +714 -1
  45. package/dist/integrations/index.d.ts +23 -8
  46. package/dist/integrations/index.js +9 -4
  47. package/dist/integrations/pipeline-discoveries.d.ts +65 -0
  48. package/dist/integrations/pipeline-discoveries.js +65 -0
  49. package/dist/integrations/pipeline-runs.d.ts +119 -0
  50. package/dist/integrations/pipeline-runs.js +87 -0
  51. package/dist/integrations/pipelines.d.ts +73 -2
  52. package/dist/integrations/pipelines.js +36 -1
  53. package/dist/integrations/price-import.d.ts +223 -0
  54. package/dist/integrations/price-import.js +124 -0
  55. package/dist/integrations/types.d.ts +221 -3
  56. package/dist/integrations/types.js +4 -3
  57. package/dist/meta/index.d.ts +10 -0
  58. package/dist/metafields/index.d.ts +8 -1
  59. package/dist/metafields/index.js +10 -0
  60. package/dist/orders/cancel-with-money.d.ts +10 -0
  61. package/dist/orders/cancel-with-money.js +2 -0
  62. package/dist/orders/commit-edit.d.ts +16 -0
  63. package/dist/orders/commit-edit.js +14 -1
  64. package/dist/orders/deposit-policy.d.ts +3 -1
  65. package/dist/orders/index.d.ts +22 -11
  66. package/dist/orders/index.js +5 -2
  67. package/dist/orders/list.d.ts +26 -5
  68. package/dist/orders/list.js +123 -13
  69. package/dist/orders/manual-discounts.d.ts +13 -5
  70. package/dist/orders/money-view.d.ts +149 -0
  71. package/dist/orders/money-view.js +99 -0
  72. package/dist/orders/plan-edit.d.ts +34 -2
  73. package/dist/orders/plan-edit.js +110 -12
  74. package/dist/orders/surcharges.d.ts +12 -0
  75. package/dist/orders/surcharges.js +34 -9
  76. package/dist/payments/account-credit.d.ts +17 -1
  77. package/dist/payments/account-credit.js +25 -2
  78. package/dist/payments/credit-noted-fields.d.ts +8 -1
  79. package/dist/payments/credit-noted-fields.js +34 -2
  80. package/dist/payments/customer-consent.d.ts +30 -0
  81. package/dist/payments/customer-consent.js +49 -0
  82. package/dist/payments/index.d.ts +89 -14
  83. package/dist/payments/index.js +22 -2
  84. package/dist/payments/requests.d.ts +6 -3
  85. package/dist/payments/requests.js +1 -0
  86. package/dist/pickups/index.d.ts +5 -3
  87. package/dist/pickups/index.js +5 -2
  88. package/dist/platform/email-gateways.d.ts +165 -0
  89. package/dist/platform/email-gateways.js +78 -0
  90. package/dist/platform/tax.d.ts +11 -3
  91. package/dist/platform/tax.js +30 -0
  92. package/dist/pos/exchanges.d.ts +6 -1
  93. package/dist/pos/index.d.ts +229 -10
  94. package/dist/pos/index.js +113 -4
  95. package/dist/pos/offline-sales.d.ts +44 -3
  96. package/dist/pos/offline-sales.js +57 -3
  97. package/dist/pos/returns.d.ts +28 -1
  98. package/dist/pos/returns.js +36 -6
  99. package/dist/pricing/price-lists.d.ts +3 -1
  100. package/dist/purchasing/alerts.d.ts +19 -4
  101. package/dist/purchasing/alerts.js +29 -3
  102. package/dist/purchasing/backorders.d.ts +61 -0
  103. package/dist/purchasing/backorders.js +92 -0
  104. package/dist/purchasing/index.d.ts +21 -3
  105. package/dist/purchasing/index.js +8 -0
  106. package/dist/purchasing/links.d.ts +263 -0
  107. package/dist/purchasing/links.js +192 -0
  108. package/dist/purchasing/purchase-orders.d.ts +4 -1
  109. package/dist/purchasing/receipts.d.ts +396 -0
  110. package/dist/purchasing/receipts.js +287 -0
  111. package/dist/purchasing/returns.d.ts +273 -0
  112. package/dist/purchasing/returns.js +229 -0
  113. package/dist/quotes/index.d.ts +20 -3
  114. package/dist/returns/index.d.ts +62 -1
  115. package/dist/returns/index.js +61 -0
  116. package/dist/seo/config.d.ts +128 -0
  117. package/dist/seo/config.js +95 -0
  118. package/dist/seo/redirects.d.ts +125 -0
  119. package/dist/seo/redirects.js +60 -1
  120. package/dist/seo/settings.d.ts +147 -0
  121. package/dist/seo/settings.js +209 -0
  122. package/dist/shipping/index.d.ts +6 -0
  123. package/dist/shipping/index.js +2 -0
  124. package/dist/shipping/methods.d.ts +8 -1
  125. package/dist/shipping/myparcel.d.ts +415 -1
  126. package/dist/shipping/myparcel.js +291 -3
  127. package/dist/shipping/rate-tables.d.ts +149 -4
  128. package/dist/shipping/rate-tables.js +229 -23
  129. package/dist/shipping/ship-to-countries.d.ts +75 -0
  130. package/dist/shipping/ship-to-countries.js +47 -0
  131. package/dist/stock/by-location.d.ts +86 -0
  132. package/dist/stock/by-location.js +94 -0
  133. package/dist/stock/index.d.ts +8 -1
  134. package/dist/stock/index.js +4 -0
  135. package/dist/stock/settings.d.ts +106 -0
  136. package/dist/stock/settings.js +103 -0
  137. package/dist/stock/transfers.d.ts +171 -10
  138. package/dist/stock/transfers.js +82 -4
  139. package/dist/stored-value/index.d.ts +98 -7
  140. package/dist/stored-value/index.js +71 -4
  141. package/dist/tax/display-policy.d.ts +105 -0
  142. package/dist/tax/display-policy.js +84 -0
  143. package/dist/tax/index.d.ts +3 -0
  144. package/dist/tax/index.js +2 -0
  145. package/dist/tenant/email-templates.d.ts +222 -1
  146. package/dist/tenant/email-templates.js +227 -10
  147. package/package.json +2 -2
package/README.md CHANGED
@@ -498,6 +498,53 @@ or API key owns the store's Stock.
498
498
  `stock.allocations.release(orderId)` hands back everything a stuck, unfulfilled order has reserved
499
499
  without changing its state.
500
500
 
501
+ What one location holds, and what moved there, a page at a time:
502
+
503
+ ```ts
504
+ const here = await admin.stock.levelsAt(locationId, { search: 'rioja', limit: 25 }); // most on hand first
505
+ here.items[0]; // { variantId, sku, variantName, product: { id, name, featuredAsset }, onHand, allocated, updatedAt }
506
+ for await (const movement of await admin.stock.movementsAt(locationId)) {
507
+ console.log(movement.type, movement.quantity, movement.sku, movement.order?.code); // newest first
508
+ }
509
+ ```
510
+
511
+ `sort: 'ASC'` lists the fewest on hand first; the engine answers at most 200 levels a page. Both
512
+ need `ReadStockLocation`, and the location must be the store's own: another store's is refused
513
+ `LOCATION_NOT_IN_CHANNEL`, never read. A location several stores share lists only this store's
514
+ variants; there, a variant more than one of them sells also carries the other stores' order
515
+ movements, and the engine fails that ledger page as a whole (`GRAPHQL_ERROR`) rather than leave
516
+ the row out.
517
+
518
+ ### Stock settings
519
+
520
+ `stock.settings` is the store's stock settings as the engine reads them: whether the storefront
521
+ sees operational stock numbers, which warehouses back the storefront, and the backorder default,
522
+ beside the store's own tracking and threshold and the platform defaults.
523
+
524
+ ```ts
525
+ const settings = await admin.stock.settings.get();
526
+ // { channelId, trackInventory: true, outOfStockThreshold: 0,
527
+ // defaultTrackInventory: true, defaultOutOfStockThreshold: 0, exposeStockToStorefront: false,
528
+ // storefrontStockLocations: { mode: 'ALL', locationIds: [], reason: null }, backorderPolicy: 'deny' }
529
+
530
+ // How a change WOULD read. Writes nothing.
531
+ await admin.stock.settings.preview({ storefrontStockLocationIds: [warehouseId] });
532
+
533
+ // Only the named fields are written; [] clears the warehouse selection (every warehouse).
534
+ await admin.stock.settings.set({ storefrontStockLocationIds: [warehouseId], backorderPolicy: 'incoming' });
535
+ ```
536
+
537
+ `storefrontStockLocations.mode` is `ALL` (every warehouse), `SUBSET` (only `locationIds`) or
538
+ `MALFORMED`: the stored value cannot be read, the engine sells from every warehouse and logs it,
539
+ and `reason` says why; saving a selection replaces it. The engine's stock maths read a variant's
540
+ own tracking and threshold, else the platform defaults (`defaultTrackInventory`,
541
+ `defaultOutOfStockThreshold`), never the store's `trackInventory` / `outOfStockThreshold`.
542
+
543
+ Reading needs any administrator permission on the store; `set` needs `UpdateChannel`. Refusals,
544
+ from `preview` and from `set` before anything is written: `CHANNEL_SETTINGS_INVALID`
545
+ (`ValidationError`, `details[]` per field) for a `null` tracking, threshold, flag or policy, a
546
+ policy that is not `deny`, `incoming` or `unlimited`, a blank warehouse id or more than 500.
547
+
501
548
  ### Stock locations
502
549
 
503
550
  `stock.locations` is the store's stock locations themselves; a location's profile (code, time zone,
@@ -652,6 +699,33 @@ const after = await admin.stock.transfers.cancellation(transferId);
652
699
  is its own request and `get` / `list` / `cancel` select none of its fields, so a caller ahead of
653
700
  the engine sees only `cancellation` refused.
654
701
 
702
+ ### Stock transfer shipment, note and numbering
703
+
704
+ ```ts
705
+ // A wrong handler or carrier: back to an editable draft, the units back on the source.
706
+ const draft = await admin.stock.transfers.removeShipment(transferId);
707
+ // A Hendriks export that did not land at ship time (hendriksRemotePath still null).
708
+ const exported = await admin.stock.transfers.retryHendriksExport(transferId);
709
+ // The transfer note (a consumption's: its consumption note) again.
710
+ const noted = await admin.stock.transfers.regeneratePdf(transferId); // noted.pdfStorageKey
711
+
712
+ const format = await admin.stock.transfers.numbering.get(); // { prefix, suffix, digits, yearFormat, yearReset }
713
+ await admin.stock.transfers.numbering.set({ prefix: 'TR-', digits: 5 }); // a patch
714
+ ```
715
+
716
+ `removeShipment` undoes `ship`: it voids every MyParcel parcel inside the call (allowed up to
717
+ 120 s; a `REQUEST_TIMEOUT` does not mean it failed) and clears the handler, carrier, tracking code,
718
+ ETA, parcels and note, keeping the delivery address for the re-ship. It does NOT withdraw a Hendriks
719
+ export or any other 3PL's shipment: retract that at the 3PL. It is refused for a transfer not in
720
+ transit, so a repeat restores nothing twice, and once any unit was received or lost (close or
721
+ cancel instead). `retryHendriksExport` uploads the export once: a transfer already exported, not
722
+ shipped with Hendriks, or not in transit is refused, and so is a store without Hendriks set up
723
+ (that one with no code yet: it arrives as a `GRAPHQL_ERROR`, and nothing is uploaded).
724
+ `regeneratePdf` is refused for a draft. The numbering `get` answers the format the store's next
725
+ code takes (its organization's while it inherits `settings.numbering_formats`; the counter is
726
+ always the store's own); `set` writes the store's own row and answers it, so read `get` again
727
+ for the format in use. An unknown `yearFormat` is refused `NUMBERING_YEAR_FORMAT_INVALID`.
728
+
655
729
  ### Parcel count suggestion
656
730
 
657
731
  ```ts
@@ -779,6 +853,36 @@ buyer's name, email and addresses. A credential below `customerData: full` gets
779
853
  `redacted: true`. An unsaved `style` value `set` would refuse is drawn with the renderer's fallback,
780
854
  not refused.
781
855
 
856
+ ### Email templates
857
+
858
+ ```ts
859
+ const { templateTypes, blockTypes } = await admin.tenant.emailTemplates.catalog();
860
+ const t = await admin.tenant.emailTemplates.getByType('order_confirmation'); // or null
861
+ await admin.tenant.emailTemplates.setSubject('order_confirmation', 'nl', [
862
+ { type: 'text', value: 'Bestelling ' },
863
+ { type: 'var', key: 'order_code' }, // a variable of the type
864
+ ]);
865
+ const b = await admin.tenant.emailTemplates.blocks.add('order_confirmation', { type: 'heading' });
866
+ await admin.tenant.emailTemplates.blocks.setContent(b.id, 'nl', { text: [{ type: 'text', value: 'Bedankt' }] });
867
+ await admin.tenant.emailTemplates.blocks.update(b.id, { config: { ...b.config, level: 3 } });
868
+ await admin.tenant.emailTemplates.blocks.reorder('order_confirmation', [b.id, ...t!.blocks.map((x) => x.id)]);
869
+ await admin.tenant.emailTemplates.blocks.remove(b.id); // its texts go with it
870
+ ```
871
+
872
+ A template's body is a list of blocks (the engine's "chips"): each has a `config` shared by every
873
+ language and one `content` per language. `setSubject`, `blocks.add` and `blocks.reorder` address the
874
+ template by TYPE and look it up first (two requests), for the reason `preview` does; the block
875
+ methods go by block id. `blocks.add` puts a block at the end unless given a `position`.
876
+ `blocks.reorder` takes EVERY block of the template once (`EMAIL_CHIP_ORDER_INVALID` names the one
877
+ left out, repeated or foreign); `blocks.update` replaces the whole `config`. A subject part is text
878
+ with its `value` or a variable with its `key` (`EMAIL_SUBJECT_INVALID`), a config or content must be
879
+ a JSON object (`EMAIL_CHIP_CONFIG_INVALID`, `EMAIL_CHIP_CONTENT_INVALID`), and a language code must
880
+ be one (`EMAIL_LANGUAGE_CODE_INVALID`); all `ValidationError`, 400. A block or template the store
881
+ does not hold is `ENTITY_NOT_FOUND`, an unknown type `EMAIL_TEMPLATE_NOT_FOUND` (`NotFoundError`).
882
+ While the store takes `settings.email` from its organization every write is refused
883
+ (`ORGANIZATION_SETTINGS_INHERITED` / `MANAGED_BY_ORGANIZATION`, 422). Reads need `ReadSettings`,
884
+ writes `UpdateSettings`; `blocks.remove` is `@effect destructive`.
885
+
782
886
  ### Shipping methods
783
887
 
784
888
  ```ts
@@ -812,6 +916,40 @@ withdraws it from every one (orders already placed keep their shipping line). Bo
812
916
  of those stores. `create` refuses `ORGANIZATION_WORKSPACE_NOT_A_STORE` (`BusinessRuleError`) in an
813
917
  organization's workspace, and `create` / `update` refuse `PLUGIN_ACCESS_DENIED`
814
918
  (`PermissionDeniedError`) for a handler, checker or calculator of a plugin the store does not hold.
919
+ A `weight-zone-shipping-calculator` must name one of the store's rate tables (below) in its
920
+ `shippingZoneConfigId`, or `create` / `update` refuse `SHIPPING_ZONE_CONFIG_NOT_FOUND`
921
+ (`NotFoundError`) with `details[0].field` on the argument (`/input/calculator/arguments/<i>/value`):
922
+ empty or left out (`required`), or not one of the store's table ids (`enum`, another store's included).
923
+ An `update` that sends no calculator is not judged.
924
+
925
+ ### Ship-to countries
926
+
927
+ `shipping.shipToCountries` is the list of countries the store ships to, as checkout applies it:
928
+ the storefront offers only these (`availableCountries`), and an order addressed anywhere else
929
+ cannot be placed.
930
+
931
+ ```ts
932
+ const view = await admin.shipping.shipToCountries.get();
933
+ // { gate: 'SUBSET', reason: null, selected: ['NL', 'TR'], served: ['NL'], unservable: ['TR'],
934
+ // options: [{ code: 'NL', name: 'Netherlands', enabled: true }, …, { code: 'TR', name: 'TR', enabled: false }] }
935
+
936
+ // How a list WOULD read. Writes nothing.
937
+ await admin.shipping.shipToCountries.preview(['NL', 'BE']);
938
+
939
+ // Codes are trimmed, upper-cased and de-duplicated; [] means every enabled country.
940
+ await admin.shipping.shipToCountries.set(['nl', 'BE']);
941
+ ```
942
+
943
+ `gate` is `ALL` (no list: every enabled country), `SUBSET` (only `selected`) or `MALFORMED`: the
944
+ stored value cannot be read, checkout offers every enabled country and logs it, and `reason` says
945
+ why; saving a list replaces it. An empty list never means "ships nowhere". `served` is exactly
946
+ what the storefront offers. `unservable` lists selected codes that are not enabled countries: they
947
+ are stored, but no shopper can choose them until a platform administrator enables the country.
948
+
949
+ Reading needs any administrator permission on the store; `set` needs `UpdateChannel`. `preview`
950
+ and `set` refuse `SHIP_TO_COUNTRIES_INVALID` (`ValidationError`, one `details[]` entry per
951
+ offending entry, `field` = `countryCodes.<index>`) for an entry that is not a two-letter ISO-3166
952
+ code, or for more than 300 entries; `set` writes nothing then.
815
953
 
816
954
  ### Weight-zone rate tables
817
955
 
@@ -838,6 +976,30 @@ with the table, named in the message) and `ENTITY_NOT_FOUND`. A delete the engin
838
976
  `NOT_DELETED` (the store has no such table) is `SHIPPING_ZONE_CONFIG_NOT_DELETED`
839
977
  (`ConflictError`). Reads need `ReadSettings`, writes `UpdateSettings`.
840
978
 
979
+ ```ts
980
+ const zones = admin.shipping.rateTables.zones;
981
+ const z = await zones.create(t.id, { name: 'Randstad', type: 'domestic' }); // after the last zone
982
+ await zones.setMembers(z.id, [{ postalCodeFrom: '1000', postalCodeTo: '1099' }]);
983
+ await zones.setRates(z.id, [
984
+ { minWeightGrams: 0, maxWeightGrams: 5000, price: 695, currencyCode: 'EUR' }, // €6.95
985
+ { minWeightGrams: 0, maxWeightGrams: 5000, price: 120000, currencyCode: 'JPY' }, // ¥1,200
986
+ ]);
987
+ await zones.update(z.id, { name: 'Holland' }); // a patch
988
+ await zones.reorder(t.id, [eu.id, z.id]); // every zone of the table, once
989
+ await zones.delete(z.id);
990
+ ```
991
+
992
+ A zone matches by `type`: `domestic` by its members' postal-code prefix ranges, `international`
993
+ by country; zones are tried in position order and the first match prices the order. A rate price
994
+ is in hundredths of its currency for EVERY currency, as the platform stores all money (¥1,200 is
995
+ `120000`). `setMembers` and `setRates` replace the zone's whole set in one transaction: a refused
996
+ save keeps the previous one. Refusals: `SHIPPING_ZONE_INPUT_INVALID` with one detail per problem
997
+ (`/rates/1/maxWeightGrams` `overlap`, `/members/0/countryCode` `not`, `/zoneIds` `required` for a
998
+ reorder that leaves a zone out), `SHIPPING_ZONE_POSITION_TAKEN` (`ConflictError`),
999
+ `SHIPPING_ZONE_IN_USE` (`BusinessRuleError`: the last zone of a table a method prices with) and
1000
+ `ENTITY_NOT_FOUND`; a zone delete answered `NOT_DELETED` is `SHIPPING_ZONE_NOT_DELETED`
1001
+ (`ConflictError`). `setRates` is `@effect money`: it changes what customers pay for shipping.
1002
+
841
1003
  ### Parcel rule
842
1004
 
843
1005
  ```ts
@@ -946,6 +1108,87 @@ await admin.shipping.myparcel.senders.set({ stockLocationId, address, isDefault:
946
1108
  `ReadStockLocation` and `senders.set` `UpdateStockLocation`. Another store is configured with
947
1109
  `admin.withTenant(...)`, as above.
948
1110
 
1111
+ ### MyParcel shipments and labels
1112
+
1113
+ ```ts
1114
+ await admin.shipping.myparcel.shipments.get(shipmentId);
1115
+ // { id, referenceIdentifier, barcode, status, carrierId, created, modified, linkConsumerPortal },
1116
+ // read live from MyParcel; null when MyParcel no longer holds a shipment the store records
1117
+
1118
+ await admin.shipping.myparcel.labels.status([shipmentId], 'A6'); // { status: 'pending' | 'ready' }
1119
+ const label = await admin.shipping.myparcel.labels.download(shipmentId, 'A4');
1120
+ // { format, contentType: 'application/pdf', filename, bytes: Uint8Array }
1121
+ await admin.shipping.myparcel.labels.request({ fulfillmentId, shipmentId, format: 'A4' });
1122
+ // { status: 'pending' }: queued, the label lands when the job has run
1123
+ ```
1124
+
1125
+ - **Asking for a label buys it.** MyParcel registers a shipment it still holds as a concept on the
1126
+ first call that asks for its label (any of the three `labels` methods) and bills the store for
1127
+ it; a registered label cannot be deleted through MyParcel's API. All three are tagged
1128
+ `@effect money`. None takes an idempotency key: a repeated `request` queues another request for
1129
+ the same parcel.
1130
+ - **Only the store's own shipments.** A shipment no fulfillment or stock transfer of the store
1131
+ holds (another store's on a shared MyParcel account, a missing id) is `ENTITY_NOT_FOUND`
1132
+ (`NotFoundError`), refused before MyParcel is asked. A store whose MyParcel set-up was removed
1133
+ after booking is refused `MYPARCEL_NOT_CONFIGURED_ERROR` (`BusinessRuleError`, 422) by
1134
+ `shipments.get`, `labels.status` and `labels.download` (an older engine failed it uncoded,
1135
+ `GRAPHQL_ERROR` 500).
1136
+ - **`status` is a readiness signal, never a link**: a link MyParcel mints expires within hours.
1137
+ Give someone the label with `download` (one shipment) or `admin.fulfillments.myparcelLabel`
1138
+ (every parcel of a fulfillment merged into one PDF). `status` and `download` are allowed up to
1139
+ 55s (`MYPARCEL_LABEL_TIMEOUT_MS`): the engine retries a slow MyParcel past the default timeout.
1140
+ Poll `status` with a back-off; each call is a request to MyParcel.
1141
+ - **`request` refusals**, with nothing queued: `MYPARCEL_NOT_CONFIGURED_ERROR` (no set-up, or
1142
+ MyParcel switched off), `ENTITY_NOT_FOUND` (`NotFoundError`: another store's or a missing
1143
+ fulfillment), `MYPARCEL_VALIDATION_ERROR` with `details[].field` (a cancelled fulfillment, or a
1144
+ shipment it does not hold), `MYPARCEL_DECRYPTION_FAILED_ERROR`. A blank id, a shipment id that
1145
+ is not a positive integer or a format other than `A4`/`A6` is refused before any request
1146
+ (`MYPARCEL_LABEL_INPUT_INVALID`, `ValidationError`) by every `labels` method.
1147
+ - **Permissions:** `shipments.get`, `labels.status` and `labels.download` need `ReadOrder`;
1148
+ `labels.request` `UpdateOrder`. A store without the MyParcel plugin is refused
1149
+ `PLUGIN_ACCESS_DENIED` on the `labels` methods, and on `shipments.get` for any shipment none of
1150
+ its fulfillments holds (never for a SuperAdmin).
1151
+
1152
+ ### MyParcel shipment form, repairs and return labels
1153
+
1154
+ ```ts
1155
+ await admin.shipping.myparcel.carrierCapabilities();
1156
+ // [{ carrierId, name, packageTypes, deliveryTypes, countries, supportedOptions, supportsPickup, constraints }]
1157
+ await admin.shipping.myparcel.deliveryOptions({ postalCode: '1012 LG', countryCode: 'NL', carrierId: 1 });
1158
+ // { deliveryOptions: [{ deliveryType, date, startTime, endTime, price: { amount, currency } | null }],
1159
+ // pickupOptions: [{ locationCode, locationName, street, number, …, carrierId, distance }] }
1160
+
1161
+ await admin.shipping.myparcel.shipments.recreate({ fulfillmentId, handlerArguments: [{ name: 'carrier', value: '1' }] });
1162
+ await admin.shipping.myparcel.shipments.import({ fulfillmentId, shipmentId: 4812345 });
1163
+ // both: { shipmentId, labelFormat }
1164
+ await admin.shipping.myparcel.returns.create({ parentShipmentId, carrier: 1, name, email });
1165
+ // { shipmentId }
1166
+ ```
1167
+
1168
+ - **Reads.** `carrierCapabilities` is the engine's static carrier table (no MyParcel call,
1169
+ `ReadSettings`). `deliveryOptions` asks MyParcel live with the store's key (`ReadOrder`): a quote,
1170
+ in minor units, nothing booked. A blank postal code is refused before any request
1171
+ (`INVALID_INPUT`); a store with no set-up is `MYPARCEL_NOT_CONFIGURED_ERROR`.
1172
+ - **The three writes bill the store at MyParcel** and are tagged `@effect money`: `recreate` books
1173
+ new shipments (and the label job registers them), `import` asks for the imported shipment's label,
1174
+ and `returns.create` makes a return shipment MyParcel e-mails to the customer. None takes an
1175
+ idempotency key: a repeated `returns.create` makes another return; a repeated `import` is refused
1176
+ (the fulfillment then holds a shipment); a repeated `recreate` books again while the first set is
1177
+ still a concept, and is refused once MyParcel has registered it.
1178
+ - **Refusals name their fields.** Every union member that carries a field list
1179
+ (`MyparcelInvalidRecipientError.missingFields`, `MyparcelInvalidSenderError.fields`,
1180
+ `MyparcelPickupLocationInvalidError.fields`, `MyparcelValidationError.errors`) lands in
1181
+ `details[]`, one `{ field, code, message }` per field, so a form can mark each: a recipient
1182
+ without a house number is `MYPARCEL_INVALID_RECIPIENT_ERROR` with `details[0].field:
1183
+ 'streetLine1'`. `MYPARCEL_SHIPMENT_ALREADY_ATTACHED_ERROR` is an import of a shipment another
1184
+ fulfillment or transfer holds. `recreate` and `import` onto a fulfillment that does not ship
1185
+ with MyParcel (a manual one) are `MYPARCEL_VALIDATION_ERROR` with `details[0].field:
1186
+ 'fulfillmentId'`, and `returns.create` while MyParcel is switched off is
1187
+ `MYPARCEL_NOT_CONFIGURED_ERROR`, both before MyParcel is asked. Another store's fulfillment or
1188
+ shipment is `ENTITY_NOT_FOUND`, before MyParcel is asked. Malformed ids, arguments or a blank name/e-mail are refused before any
1189
+ request (`INVALID_INPUT`, the field in `details[]`).
1190
+ - **Permissions:** `recreate`, `import` and `returns.create` need `UpdateOrder`.
1191
+
949
1192
  ### Fulfillment profiles
950
1193
 
951
1194
  ```ts
@@ -1088,7 +1331,7 @@ write is never deduplicated.
1088
1331
 
1089
1332
  | Write | Deduplicated? |
1090
1333
  |---|---|
1091
- | `content.forms.upsertBySlug` | yes — the CMS replays the first response for the same key |
1334
+ | `content.forms.create`, `content.forms.upsertBySlug` | yes — the CMS replays the first response for the same key |
1092
1335
  | `seo.redirects.create` | yes — the plugins service does the same |
1093
1336
  | `locations.*`, `pickups.*`, `catalog.*`, `shipping.*`, `tenant.retention.*`, `tenant.notifications.*`, `apiTokens.*` | no — they are Admin API writes |
1094
1337
  | everything else | no; the key is recorded on the wire and acts on nothing |
@@ -1340,6 +1583,39 @@ that entry holds the whole order — every line, nothing of it in a fulfillment
1340
1583
  count was paid for the whole order; otherwise, and for an order not priced per parcel, it is
1341
1584
  `null`. Pass the `includeIneligible`/`routing` the entry you show was read with.
1342
1585
 
1586
+ ### Moving a fulfillment, and the 3PL label hand-off
1587
+
1588
+ ```ts
1589
+ await admin.fulfillments.transition(fulfillmentId, 'Shipped');
1590
+ // { id, state: 'Shipped', nextStates: ['Delivered', 'Cancelled'] }
1591
+ await admin.fulfillments.resendLabelHandoff(fulfillmentId);
1592
+ // 'QUEUED' | 'NOT_READY' | 'DISABLED' | 'NO_RECIPIENT' | 'UNAVAILABLE'
1593
+ ```
1594
+
1595
+ `transition` moves a fulfillment through the store's own fulfillment process (the order page's
1596
+ "Mark as Shipped/Delivered"), so the order's state and whatever the store does on a shipped or
1597
+ delivered fulfillment follow: the customer's shipped e-mail, and, when the order's last lines
1598
+ ship on a store with Exact Online's "fulfilled" auto-push, the order's push to the ERP. A move
1599
+ into `Cancelled` is a cancel: the fulfillment's delivery charge comes off what its orders bill,
1600
+ and it is refused while the customer PAID that charge (`FULFILLMENT_CANCEL_OVERPAYMENT_CHOICE_REQUIRED`):
1601
+ `cancel` (below) takes the choice of how the money goes back. Tagged `@effect money` for that
1602
+ strongest move.
1603
+
1604
+ A refused move answers the refusing step's OWN code, which core carries only as a `[CODE]` prefix
1605
+ on its text, with the text after it as the message: `ORDER_NOT_COVERED` (an order not paid in
1606
+ full on a store that requires it; `coverage` and `allowUncovered` above), `ORDER_ON_HOLD`,
1607
+ `BOOKING_REQUIRED`, … — classed as the warehouse API classes the same code. Any other refusal is
1608
+ `FULFILLMENT_STATE_TRANSITION_ERROR` (`BusinessRuleError`) in the step's words. Both carry the
1609
+ move on `error.extensions` (`fromState`, `toState`, `transitionError`, `errorCode`). Another
1610
+ store's fulfillment is `NotFoundError`; a blank id or state is `FULFILLMENT_TRANSITION_INVALID`
1611
+ before any request. No idempotency key: a repeated move that went through is refused as an edge
1612
+ the process no longer has.
1613
+
1614
+ `resendLabelHandoff` e-mails the fulfillment's ready MyParcel label to the store's 3PL recipient
1615
+ again (`@effect irreversible`). Only `QUEUED` sent anything; the other outcomes say why not (no
1616
+ ready label, the notification switched off or without a recipient, no e-mail delivery). A repeat
1617
+ sends it again.
1618
+
1343
1619
  ### Cancelling a fulfillment whose delivery charge was paid
1344
1620
 
1345
1621
  Cancelling a fulfillment takes its delivery charge off every order it charged. Where the customer
@@ -1675,6 +1951,38 @@ server picks them itself.
1675
1951
  Reading needs `ReadTaxCategory`, `ReadTaxRate`, `ReadCountry` and `ReadZone`, which every store
1676
1952
  role holds.
1677
1953
 
1954
+ ### Price display: who sees prices with or without tax
1955
+
1956
+ A store's price-display policy decides, per shopper, whether prices are shown incl. or excl. tax:
1957
+ a customer shipping to a listed country sees excl. tax (and is TAXED by that country's zone: the
1958
+ list changes what customers are charged), then B2B and trade customers follow `b2b` unless it is
1959
+ `inherit`, then everyone follows `default` unless it is `channel` (the store's "prices include
1960
+ tax"). The engine answers who sees what; the same rule decides the storefront's prices and the
1961
+ prices an order's emails, receipts and invoices show its customer.
1962
+
1963
+ ```ts
1964
+ const view = await admin.tax.displayPolicy.get();
1965
+ // { default: 'channel', b2b: 'net', countries: ['US'], pricesIncludeTax: true,
1966
+ // audiences: [{ audience: 'VISITOR', mode: 'GROSS', countryCode: null, dependsOnStore: false }, …] }
1967
+
1968
+ // Before saving: who a change WOULD show what. Writes nothing.
1969
+ const draft = await admin.tax.displayPolicy.preview({ ...view, b2b: 'gross' });
1970
+
1971
+ await admin.tax.displayPolicy.set({ default: 'channel', b2b: 'gross', countries: [] });
1972
+ ```
1973
+
1974
+ `audiences` lists the visitor, the retail customer, the B2B or trade customer and, while a country
1975
+ is listed, a customer in the first one. In an organization's workspace an answer that falls back
1976
+ to "prices include tax" is `mode: null, dependsOnStore: true`: each member store keeps its own. A
1977
+ store that inherits the policy from its organization reads the organization's.
1978
+
1979
+ Reading needs any administrator permission on the store; `set` needs `UpdateChannel`. Refusals:
1980
+ `CHANNEL_SETTINGS_INVALID` (`ValidationError`, `details[]` per field) from `preview`, and from
1981
+ `set` before anything is written, for a choice that is not exactly one of the values, a country
1982
+ code that is not two letters, a country listed twice or more than 300;
1983
+ `ORGANIZATION_SETTINGS_INHERITED` / `MANAGED_BY_ORGANIZATION` (`BusinessRuleError`) from `set`
1984
+ while the organization's policy applies.
1985
+
1678
1986
  ### Content types, fields and page values
1679
1987
 
1680
1988
  A content type is what the CMS calls a collection (blog posts, FAQs, a team page): its fields
@@ -1755,11 +2063,14 @@ const labels = await admin.content.items.lookup(referencedIds);
1755
2063
  - Writes answer the item's row (`status`, `slug`, `path`, …) without its values: read them back
1756
2064
  with `get`. `update` writes only the members and value slugs it is given.
1757
2065
  - `publish` refuses an item whose required field is empty (`ValidationError`, naming the
1758
- fields); `bulkSetStatus` does not check them. `bulkSetStatus` and `bulkDelete` answer how many
1759
- items they changed, skipping ids that are not live items of the type.
2066
+ fields). `bulkSetStatus` applies the same rule to each item and reports each on its own:
2067
+ `results` holds `{ id, status: 'OK' | 'FAILED', code, message }` per id named, so a refused item
2068
+ (`VALIDATION_ERROR`) keeps its status while the others change, and an id that is not a live
2069
+ item of the type is `NOT_FOUND`. `bulkDelete` answers how many items it removed, skipping ids
2070
+ that are not live items of the type.
1760
2071
  - `lookup(ids)` and `lookupWithFields(ids, fieldSlugs)` take any number of ids (sent in the
1761
2072
  CMS's batches of 50) and answer the ids that resolved, in the order given, each once (an
1762
- unknown id is simply absent; one that is not a UUID fails the whole call, `INVALID_DATA`). A
2073
+ unknown id, or one that is not a UUID, is simply absent). A
1763
2074
  credential holding `ReadCmsContent`, `UpdateCatalog` or `UpdateProduct` resolves every status;
1764
2075
  any other resolves published items only.
1765
2076
  - Reads need no permission, but only a `ReadCmsContent` holder is served drafts and archived
@@ -1796,8 +2107,8 @@ const fetched = await admin.content.assets.uploadFromUrl({ url: 'https://example
1796
2107
  never listed, and an upload lands under the store's own storage prefix (`storageKey`,
1797
2108
  `{store code}/content/…`), which the platform chooses.
1798
2109
  - `list` pages by offset with `Page.total`, newest first; it filters by `folderId` (`null`: no
1799
- folder), a MIME type prefix (`image/`) and a full-text `search` (relevance order; the CMS then
1800
- matches `mimeType` exactly and ignores `folderId: null`).
2110
+ folder), a MIME type prefix (`image/`) and a full-text `search` (relevance order, with the same
2111
+ two filters).
1801
2112
  - `upload` sends `multipart/form-data` through the CMS (the transport's `RestCall.multipart`); a
1802
2113
  file without a name is refused before sending (`ASSET_FILE_NAME_REQUIRED`). The CMS refuses a
1803
2114
  type it does not accept or a file over its size limit (`ValidationError`, 400), and a body over
@@ -1814,6 +2125,126 @@ const fetched = await admin.content.assets.uploadFromUrl({ url: 'https://example
1814
2125
  `update` and `folders.update` `UpdateCmsAsset`; `delete` and `folders.delete` `DeleteCmsAsset`.
1815
2126
  No write is deduplicated by an `idempotencyKey`.
1816
2127
 
2128
+ ### Navigation menus and content strings
2129
+
2130
+ A store's navigation menus (a header, a footer column) and its content strings: translatable pieces of
2131
+ storefront copy read by key (`cart.empty`, `footer.copyright`).
2132
+
2133
+ ```ts
2134
+ const footer = await admin.content.menus.create({
2135
+ name: 'Footer',
2136
+ slug: 'footer',
2137
+ metadata: { heading: 'Visit us', imageAlt: 'Our shop' }, // stored verbatim
2138
+ });
2139
+ const shop = await admin.content.menus.items.create(footer.id, {
2140
+ itemType: 'heading',
2141
+ translations: [{ languageCode: 'nl', label: 'Winkel' }],
2142
+ });
2143
+ await admin.content.menus.items.create(footer.id, {
2144
+ itemType: 'internal',
2145
+ url: '/sale',
2146
+ parentId: shop.id, // nested under the heading
2147
+ translations: [{ languageCode: 'nl', label: 'Uitverkoop' }],
2148
+ });
2149
+ await admin.content.menus.items.update(footer.id, shop.id, { position: 2 });
2150
+ const menu = await admin.content.menus.getBySlug('footer'); // what a storefront renders
2151
+
2152
+ await admin.content.strings.create({
2153
+ key: 'cart.empty',
2154
+ values: [{ languageCode: 'en', value: 'Your cart is empty' }],
2155
+ });
2156
+ await admin.content.strings.import([
2157
+ { key: 'cart.checkout_button', values: [{ languageCode: 'nl', value: 'Afrekenen' }] },
2158
+ ]);
2159
+ const all = await admin.content.strings.export(); // { 'cart.empty': { en: '…' }, … }
2160
+ ```
2161
+
2162
+ - `menus.list` pages by offset with `Page.total`, newest first or by `sort` (`name`, `created_at`,
2163
+ `updated_at`, `-` for descending), each menu with its `itemCount`; `search` matches the name.
2164
+ `menus.get` and `getBySlug` answer every live item as a FLAT `items` list: build the tree from
2165
+ each item's `parentId`.
2166
+ - A menu's and an item's `metadata` are stored verbatim and never case-converted.
2167
+ - `items.create` and `items.update` answer the item as stored, with its translations. A `parentId`
2168
+ must be a live item of the same menu, never the item itself or one of its children
2169
+ (`ValidationError`, `MENU_ITEM_PARENT_INVALID`). A changed `position` shifts the siblings in
2170
+ between. A move to another parent is not renumbered correctly (the new parent's children can
2171
+ end up sharing a number, and the old parent keeps a gap): read the menu again and set each
2172
+ sibling's `position` after it. `items.delete` leaves the item's children in the menu, naming the removed item as their
2173
+ parent: move or delete them first.
2174
+ - `strings.list` answers every string with its values in key order, a page at a time by offset; a
2175
+ `search` matches keys, descriptions and values. `strings.update` writes the languages it names
2176
+ and keeps the others: there is no way to remove one language's value. `import` creates missing
2177
+ keys and writes the values it names, all or nothing; `export` answers the whole store as one
2178
+ `key → language → value` map, keys exactly as stored.
2179
+ - Reads need no permission (storefronts read the same routes). Menu writes need `CreateCmsMenu`,
2180
+ `UpdateCmsMenu` or `DeleteCmsMenu`, content-string writes `CreateCmsContent`, `UpdateCmsContent`
2181
+ or `DeleteCmsContent`, as each method's TSDoc says. `menus.delete`, `menus.items.delete` and
2182
+ `strings.delete` are `@effect destructive`.
2183
+ - Refusals: `NotFoundError` (404) for a menu, item or string this store does not have,
2184
+ `ConflictError` (409) for a taken slug or key, `ValidationError` (400) for refused input
2185
+ (`VALIDATION_FAILED` with `details[]`). No write is deduplicated by an `idempotencyKey`.
2186
+
2187
+ ### Forms, fields and submissions
2188
+
2189
+ A store's forms (a contact form, a newsletter sign-up), the fields a visitor fills in, and what
2190
+ visitors submitted.
2191
+
2192
+ ```ts
2193
+ const contact = await admin.content.forms.create(
2194
+ {
2195
+ name: 'Contact',
2196
+ slug: 'contact', // lowercase words joined by hyphens
2197
+ notificationEmails: 'team@example.com',
2198
+ settings: { sendConfirmationEmail: true }, // stored verbatim
2199
+ translations: [{ languageCode: 'nl', title: 'Neem contact op' }],
2200
+ },
2201
+ { idempotencyKey: crypto.randomUUID() }
2202
+ );
2203
+ await admin.content.forms.fields.create(contact.id, {
2204
+ name: 'First name',
2205
+ slug: 'first_name', // words joined by UNDERSCORES
2206
+ fieldType: 'text',
2207
+ isRequired: true,
2208
+ validation: { max_length: 80 },
2209
+ translations: [{ languageCode: 'nl', label: 'Voornaam' }],
2210
+ });
2211
+ const { fieldTypes, slugRules } = await admin.content.forms.fieldTypes();
2212
+
2213
+ for await (const submission of await admin.content.forms.submissions.list(contact.id)) {
2214
+ console.log(submission.createdAt, submission.data.first_name); // answers keyed as stored
2215
+ }
2216
+ ```
2217
+
2218
+ - `forms.list` pages by offset with `Page.total`, newest first or by `sort` (`name`,
2219
+ `created_at`, `updated_at`, `-` for descending), each form with its `submissionCount`; `search`
2220
+ matches the name. `get` and `getBySlug` answer the form with its translations and its fields,
2221
+ REMOVED fields included with their `removedAt`, so the answers older submissions hold under a
2222
+ removed field's slug can still be labelled.
2223
+ - `references` and `reference` answer forms as id, name and slug for ANY caller (what a
2224
+ storefront reads): a content editor without `ReadCmsForm` can still name and pick the form a
2225
+ page shows, where `list` and `get` refuse with `AdminProjectionError`.
2226
+ - A form's `settings` and a field's `config` and `validation` are stored verbatim and never
2227
+ case-converted. `update` replaces each bag whole; `null` clears it.
2228
+ - `fields.update` writes what it names: a new `position` moves no other field, so give each field
2229
+ its number to reorder a form. `fields.delete` removes the field from the form; submissions keep
2230
+ what they answered under it.
2231
+ - **Submissions are personal data.** `data` keeps each answer under the field slug exactly as
2232
+ stored (`first_name`, never `firstName`), and `ipAddress` / `userAgent` identify the visitor's
2233
+ device. An app installation below `customerData: full` is refused (`PermissionDeniedError`,
2234
+ `CUSTOMER_DATA_NOT_GRANTED`); staff and personal API tokens need `ReadCmsForm`.
2235
+ `submissions.list` walks by the CMS's cursor without `after` (each submission once, even while
2236
+ visitors submit), and `after: offsetCursor(n)` opens a table's page by offset.
2237
+ `submissions.delete` removes a submission from every read, but the CMS keeps the row: it is not
2238
+ an erasure.
2239
+ - Permissions: reads `ReadCmsForm` (anyone else is answered the storefront projection, refused
2240
+ with `AdminProjectionError`); writes `CreateCmsForm`, `UpdateCmsForm` or `DeleteCmsForm`, as each
2241
+ method's TSDoc says. `forms.delete`, `fields.delete` and `submissions.delete` are
2242
+ `@effect destructive`.
2243
+ - Refusals: `NotFoundError` (404) for a form, field or submission this store does not have,
2244
+ `ConflictError` (409) for a taken slug (a form's in the store, a field's in its form),
2245
+ `ValidationError` (400) for refused input (`VALIDATION_FAILED` with `details[]`). `create` and
2246
+ `upsertBySlug` honour an `idempotencyKey`; the other writes are not deduplicated.
2247
+
1817
2248
  ### Catalog
1818
2249
 
1819
2250
  Products, variants, option groups, facets, collections, assets, storefront visibility, product
@@ -2098,13 +2529,41 @@ const result = await admin.catalog.syncContentFacets(propertyId);
2098
2529
  changes. Repeating the call is safe: an unchanged label is not written again.
2099
2530
  - `unresolved` names the ids the engine could not label (a removed content item, a product this
2100
2531
  store does not hold): their filter values keep the label they have.
2101
- - **Nothing runs it automatically yet**: call it after renaming a content item or a product.
2532
+ - **Automatic for published content items.** When a PUBLISHED content item is edited,
2533
+ published or unpublished, the platform syncs the properties naming it by itself within about a
2534
+ minute (`syncContentItemFacets` below). Call `syncContentFacets` after renaming a draft or
2535
+ archived item (nothing is announced for one until it is published), after renaming a product,
2536
+ or to sync one property at once.
2102
2537
  - Refusals: `PROPERTY_NOT_FOUND` (404), `PROPERTY_FACET_SYNC_INVALID` (400, not filterable or
2103
2538
  another type), `MANAGED_BY_ORGANIZATION` (422, the organization's property),
2104
2539
  `SHARED_FACET_NOT_ALLOWED` / `SHARED_PRODUCT_NOT_ALLOWED` (403), `CMS_REQUEST_FAILED` and
2105
2540
  `PROPERTY_SYNC_SOURCE_UNAVAILABLE` (422). Requires a platform release that serves
2106
2541
  `syncPropertyFacets`.
2107
2542
 
2543
+ #### Syncing the filter values that name some content items
2544
+
2545
+ ```ts
2546
+ // After importing or editing content items yourself:
2547
+ const result = await admin.catalog.syncContentItemFacets([itemId1, itemId2]);
2548
+ // { synced: [{ propertyId, items: 12, unresolved: [], fieldFacets: 0 }],
2549
+ // skipped: [{ propertyId, code: 'MANAGED_BY_ORGANIZATION', message: '…' }], changed: true }
2550
+ ```
2551
+
2552
+ - **Every property naming one of the items.** Each filterable `content_item` property of the store
2553
+ whose values name at least one of the items is synced as `syncContentFacets` syncs it. The
2554
+ platform calls this by itself for the items a change to a PUBLISHED item touched.
2555
+ - **A property's refusal skips that property alone.** It is reported in `skipped` with its code
2556
+ (`MANAGED_BY_ORGANIZATION` for the organization workspace's property, which is synced in the
2557
+ workspace) and nothing of it is written; the others are synced. `changed` says whether
2558
+ anything was written at all.
2559
+ - **All or nothing otherwise.** Every label is read first and the writes are one transaction, so a
2560
+ content system that cannot be read (`CMS_REQUEST_FAILED`, 422, try again) or any other failure
2561
+ writes nothing. Repeating the call is safe.
2562
+ - At most `MAX_CONTENT_ITEM_FACET_SYNC_IDS` (500) different ids per call, repeats counted once
2563
+ (`CONTENT_ITEM_IDS_INVALID`, 400, checked before sending too); an id that is not a uuid is
2564
+ `ENTITY_NOT_FOUND` (404). Needs `UpdateCatalog`. `@effect write`. Requires a platform release
2565
+ that serves `syncContentItemFacets`.
2566
+
2108
2567
  #### A property's field-level facets
2109
2568
 
2110
2569
  ```ts
@@ -2269,6 +2728,11 @@ await r.groups.reorder(groups.map((g) => g.id).reverse());
2269
2728
  or facet value: any other is refused `RECOMMENDATION_SOURCE_NOT_FOUND` (404), another store's
2270
2729
  exactly like an unknown id, and the storefront never draws from another store's collection or
2271
2730
  facet value, a rule stored before that refusal included.
2731
+ - **`maxItems`** is a whole number from 1 to 50 (`RECOMMENDATION_MAX_ITEMS_RANGE`). Any other value
2732
+ on `groups.create`, or sent on `groups.update`, is refused `RECOMMENDATION_MAX_ITEMS_INVALID`
2733
+ (`ValidationError`, 400, `details[0].field` `/input/maxItems`) before anything is sent; the
2734
+ platform refuses it with the same code and detail. An update that leaves `maxItems` out is not
2735
+ judged, so a group stored outside the range before the rule stays editable.
2272
2736
  - **`reorder`** numbers the ids given `0, 1, 2…`; an id the store (or, for entries, the group) does
2273
2737
  not hold is skipped without a word, and a row left out keeps its position: name them all.
2274
2738
  - **`manual.set`** replaces the product's picks in one group (`[]` clears them); the product itself
@@ -2320,6 +2784,13 @@ await r.attributes.reorder([fit.id, ...others]);
2320
2784
  history's texts, the moderation log's reasons and AI analysis), and `customerDisplayName` too
2321
2785
  below `identity`; each such record carries `redacted: true`. A `search` below `full` is refused
2322
2786
  `CUSTOMER_DATA_NOT_GRANTED` (403).
2787
+ - **Publishing needs `full`**: approving a review (`moderate`, or `bulkModerate` with `APPROVED`)
2788
+ and `reply.set` publish under the store's name next to text a caller below `full` reads
2789
+ blanked, so below `customerData: full` they are refused `CUSTOMER_DATA_NOT_GRANTED`
2790
+ (`PermissionDeniedError`, 403; `bulkModerate` reports it `FAILED` on each review and changes
2791
+ none). Rejecting, flagging, re-opening and `reply.remove` stay open at every level. A staff
2792
+ session and a personal API token act at `full`. Releases before this rule let any `UpdateCatalog`
2793
+ holder approve.
2323
2794
  - **`list`** pages by the offset cursor, `limit` 1 to 50 (`INVALID_PAGE_SIZE` otherwise, before
2324
2795
  sending: the engine answers no more). `search` matches the title or the text.
2325
2796
  - **`reply.remove` and `attributes.delete`** are `@effect destructive`. A deleted attribute's
@@ -2338,6 +2809,83 @@ await r.attributes.reorder([fit.id, ...others]);
2338
2809
  vendure-server first). The rest is served by every release; an older one answers each not-found
2339
2810
  and a taken slug as `USER_INPUT_ERROR` and takes another store's collection in `setCollections`.
2340
2811
 
2812
+ #### Documents: a product's downloads
2813
+
2814
+ ```ts
2815
+ const d = admin.catalog.documents;
2816
+ // A universal document: ONE translation, no language, naming the file (an asset of the library).
2817
+ const sheet = await d.create({
2818
+ productId,
2819
+ visibility: 'PUBLIC', // or 'AUTHENTICATED', or 'CUSTOMER_GROUP' with customerGroupIds
2820
+ isLocalized: false,
2821
+ translations: [{ title: 'Data sheet', assetId: pdfAssetId }],
2822
+ });
2823
+ // A localized one: one translation per language, at least one naming a file.
2824
+ await d.update(sheet.id, {
2825
+ isLocalized: true,
2826
+ translations: [
2827
+ { languageCode: 'en', title: 'Data sheet', assetId: pdfEnId },
2828
+ { languageCode: 'nl', title: 'Productblad', assetId: pdfNlId },
2829
+ ],
2830
+ });
2831
+ const documents = await d.list(productId); // in their order
2832
+ await d.reorder(productId, documents.map((x) => x.id).reverse());
2833
+ await d.delete(sheet.id); // @effect destructive; the file stays in the library
2834
+ ```
2835
+
2836
+ - **`update`** changes only what it names; `translations` and `customerGroupIds`, when sent,
2837
+ replace the document's. A `CUSTOMER_GROUP` document needs at least one of the store's own groups.
2838
+ - **Refusals**: the plugin's `USER_INPUT_ERROR` (`ValidationError`, 400) in its words for a product
2839
+ or document the store does not have and for a translation set that breaks the rules; a customer
2840
+ group of another store is `ENTITY_NOT_FOUND` (404); `delete` of a document the store does not
2841
+ have is `PRODUCT_DOCUMENT_NOT_FOUND` (`NotFoundError`, 404, raised by the SDK: the engine answers
2842
+ `false`); a new translation in a language the store does not offer is
2843
+ `TRANSLATION_LANGUAGE_NOT_OFFERED` (400). `list` needs `ReadCatalog`, writes `UpdateCatalog`.
2844
+ No release gate.
2845
+
2846
+ #### SEO metadata: a product's head, share cards and AI directives; the coverage list
2847
+
2848
+ ```ts
2849
+ const m = admin.catalog.metadata;
2850
+ const meta = await m.get(productId); // null until the product has any
2851
+ // Only the fields named change; a translation is written per language.
2852
+ await m.upsert(productId, {
2853
+ metaRobotsUnavailableAfter: '2026-12-31T23:59:00+01:00', // an offset is required
2854
+ translations: [{ languageCode: 'nl', seoTitle: 'Linnen overhemd | Winkel', seoDescription: null }],
2855
+ });
2856
+ // The SEO coverage list: products missing a Dutch meta title, filtered BEFORE paging.
2857
+ const missing = await m.coverage({ languageCode: 'nl', filter: 'MISSING_TITLE', limit: 25 });
2858
+ missing.total; // every product missing one, across all pages
2859
+ // Save many at once: each product on its own, in its own transaction (ruling G117).
2860
+ const outcomes = await m.bulkUpsert(
2861
+ missing.items.map((row) => ({
2862
+ productId: row.productId,
2863
+ input: { translations: [{ languageCode: 'nl', seoTitle: `${row.name} | Winkel` }] },
2864
+ }))
2865
+ );
2866
+ const failed = outcomes.filter((o) => o.status !== 'OK'); // [{ productId, status, code, message }]
2867
+ ```
2868
+
2869
+ - **`coverage`** lists the store's products, name first, never a deleted one, each with its own
2870
+ meta title and description in `languageCode` (no fallback to another language). `MISSING_TITLE`
2871
+ and `MISSING_DESCRIPTION` keep those whose value there is null or empty; `term` matches the
2872
+ name. Pages are full and `total` counts the filter and the term.
2873
+ - **`bulkUpsert`** takes 1 to 200 products, each named once (`BULK_ACTION_INPUT_INVALID`, 400,
2874
+ before sending). A refused product is `FAILED` with the door's code (`ENTITY_NOT_FOUND`,
2875
+ `PRODUCT_METADATA_INPUT_INVALID`, …) and changes nothing; the others still go through; a fault is
2876
+ `INTERNAL_SERVER_ERROR` (logged by the engine). Not keyed: a repeat writes again.
2877
+ - **`delete`** (`@effect destructive`) removes the product's metadata in this store; the
2878
+ storefront falls back to the product's own name and description.
2879
+ - **Refusals**: an input that breaks a rule is `PRODUCT_METADATA_INPUT_INVALID`
2880
+ (`ValidationError`, 400) with one `details` entry per failure (`field`
2881
+ `/input/translations/0/seoTitle`, `code` `INVALID_FIELD_LENGTH`); `upsert` of a product the
2882
+ store does not hold is `ENTITY_NOT_FOUND` (404); an unavailable-after instant without an offset
2883
+ is `INVALID_DATETIME` (400, before sending). `get` and `coverage` need `ReadCatalog`, writes
2884
+ `UpdateCatalog`.
2885
+ - **Release**: `coverage` and `bulkUpsert` need a platform release with them (deploy
2886
+ vendure-server first: an older one refuses the document). On an older release `upsert` answers
2887
+ a validation failure as an uncoded `INTERNAL_SERVER_ERROR` and accepts another store's product.
2888
+
2341
2889
  ### Pricing — promotions and coupons at the register
2342
2890
 
2343
2891
  ```ts
@@ -2567,7 +3115,9 @@ const assigned = await admin.pricing.priceLists.list({
2567
3115
  `forCustomer` answers the lists that apply to a customer of the store now, ranked by the
2568
3116
  engine with the function its own pricing uses: contracts first, then assigned to the customer,
2569
3117
  through one of their groups, to every customer (`assignmentType` `customer`, `group`,
2570
- `channel`), then higher priority first. Only enabled lists inside their date window count, and
3118
+ `channel`), then higher priority first. Lists of one standing (same contract flag, assignment and
3119
+ priority) are tried together for each line, and the lower price wins there, an equal price going to
3120
+ the lower list id; `forCustomer` lists them by id. Only enabled lists inside their date window count, and
2571
3121
  every list of the store is considered; an order's one-time discount never appears (read it as
2572
3122
  `Order.priceList`). Each list comes with its first `entriesTake` entries (1 to 100, 100 when
2573
3123
  omitted), labelled as `entries.list` labels them, `entries.total`, and its order rules. A customer
@@ -2986,6 +3536,41 @@ order as "not granted", never as "none on record".
2986
3536
  Why the order is the way it is (its lock, what may be done to it) is `orders.status`, below, not a
2987
3537
  field of the order.
2988
3538
 
3539
+ ### An order's money rows
3540
+
3541
+ ```ts
3542
+ const view = await admin.orders.moneyView(orderId); // the store's basis; or { basis: 'GROSS' | 'NET' | 'CUSTOMER' }
3543
+ // view.basis → 'NET'; view.goodsAdjustments → [{ key, description: 'Welcome 10% Discount', amount: -4340, taxRate: null }]
3544
+ // view.subtotal, view.shipping, view.shippingAdjustments, view.taxes → [{ taxRate: 9, base: 11669, amount: 1050 }, …]
3545
+ // view.rounding, view.total, view.balance → { paid, giftCard, storeCredit, credited, stillToPay }
3546
+ ```
3547
+
3548
+ The order's money as rows the ENGINE builds from the order's own figures, in one basis, adding up
3549
+ exactly to its own totals: print them as they come and the order page, an email and an invoice
3550
+ cannot disagree by a cent. Never re-derive a row or convert one into the other basis yourself: ask
3551
+ for that basis. Needs `ReadOrder`.
3552
+
3553
+ - **What adds up.** `Σ lines[].amount + Σ goodsAdjustments + Σ surcharges = subtotal`;
3554
+ `Σ shipping + Σ shippingAdjustments = shippingTotal`; on `GROSS` `subtotal + shippingTotal +
3555
+ (rounding ?? 0) = total`; on `NET` `subtotal + shippingTotal + taxTotal + (rounding ?? 0) =
3556
+ total`; `Σ taxes = taxTotal`. `reconciles` is false only when the order's stored totals disagree
3557
+ with its own rows.
3558
+ - **Units.** `lines[].unitAmount` is answered only in the basis the line was priced in, where it
3559
+ multiplies out to the line; in the other basis it is `null`: show the line total alone.
3560
+ - **VAT.** One row per rate. On a `GROSS` view (`taxIncluded`) the rows are inside the total: label
3561
+ them as included, never as an addition.
3562
+ - **Rounding.** A staff rounding is one row (`rounding`) just above the total; `total` is what the
3563
+ order bills, `totalBeforeRounding` the total before it.
3564
+ - **What is paid.** `balance.stillToPay` is money not received yet: a promised bank transfer or
3565
+ net-terms payment still counts. It does not read the B2B account ledger yet, so for an order a
3566
+ live account invoice bills (`orders.status`: `lock.lockingInvoice.ledger === 'AR'`), do not show
3567
+ it.
3568
+ - **Stored only.** It reads the order as stored, so it cannot preview an edit.
3569
+
3570
+ Refused before sending: `INVALID_ORDER_MONEY_BASIS` (`ValidationError`). An order the store does not
3571
+ hold: `ORDER_NOT_FOUND` (`NotFoundError`). An engine before `Order.moneyView` refuses the document as
3572
+ `ValidationError` `GRAPHQL_VALIDATION_FAILED` (400): fall back to the order's own figures.
3573
+
2989
3574
  ### Listing orders
2990
3575
 
2991
3576
  ```ts
@@ -3459,6 +4044,16 @@ refuse is found here. Nothing is saved, no event is published and no payment pro
3459
4044
  A store's tax provider app may be asked, as it is for any re-price, so call it after the merchant
3460
4045
  stops typing rather than on every keystroke. Needs `UpdateOrder`.
3461
4046
 
4047
+ Print the edit's money from `preview.moneyView` (the order after the edit) and
4048
+ `preview.moneyViewBefore`, the same rows `orders.moneyView` answers and the order page prints once
4049
+ the edit is saved: each promotion, fee, shipping line, VAT rate and staff rounding its own row,
4050
+ adding up exactly. Pass `{ moneyBasis: 'GROSS' | 'NET' | 'CUSTOMER' }` for another basis than the
4051
+ store's (a switch of basis plans again: the rows are read inside the run). The difference is
4052
+ `preview.totalDifference`, the billable totals' difference; `totalWithTax - totalWithTaxBefore`
4053
+ leaves a staff rounding out. The view's `balance` is the payments before the plan's `refund`. An
4054
+ engine before these fields answers them `null`, and `commitEdit`'s plan never carries them (read
4055
+ the saved order's `moneyView`).
4056
+
3462
4057
  Read `blockers` first. They are an answer, not an error, so that a head can show every reason
3463
4058
  beside the steps:
3464
4059
 
@@ -4426,12 +5021,28 @@ refusal is thrown as a coded error, including the ones the Admin API answers as
4426
5021
  | `PAYMENT_SETTLE_DECLINED` / `PAYMENT_CANCEL_DECLINED` (422) | The provider said no; the message is its reason |
4427
5022
  | `PAYMENT_STATE_TRANSITION_ERROR` (422) | The payment is not `Authorized` (settled, cancelled or declined already) |
4428
5023
  | `ORDER_CANCELLED_PAYMENT_CONFLICT` (409) | The order is cancelled, and a cancelled order is never paid: cancel the payment instead |
5024
+ | `PAYMENT_CAPTURE_EXCEEDS_BILLED` (422) | The order's credit notes lowered what it bills, and this capture would take more: the provider can only capture the payment in full, other payments already cover the bill, or the order's only invoice was cancelled. The message names the credit notes and what to ask for instead |
4429
5025
  | `PAYMENT_SETTLE_CONFLICT` / `PAYMENT_CANCEL_CONFLICT` (409) | The payment moved meanwhile, or a capture of it may have reached the provider; read it again |
4430
5026
  | `IDEMPOTENCY_OUTCOME_UNKNOWN` (409, `outcomeUnknown`) | A capture reached the provider and its answer was lost: every capture is refused for 24 hours, then one would ask the provider again, so check the payment with the provider first and never retry blind |
4431
5027
  | `ENTITY_NOT_FOUND` (404) | No payment of an order of this store has that id |
4432
5028
 
4433
5029
  A settled payment is never voided: its money was taken, so it goes back with `payments.refund`.
4434
5030
 
5031
+ On an order whose credit notes lowered what it bills, a capture takes only what the order still
5032
+ bills without that payment. A promise whose money moves by hand (bank transfer, invoice, net terms)
5033
+ is recorded at that amount: `settle` answers the payment with the lower `amount`, and the rest of
5034
+ the promise is dropped. A payment its provider can only capture in full is refused
5035
+ `PAYMENT_CAPTURE_EXCEEDS_BILLED`. Ask first, to show staff what the press will do:
5036
+
5037
+ ```ts
5038
+ const preview = await admin.payments.settlePreview(paymentId); // ReadOrder; writes nothing
5039
+ // { authorized: 208093, records: 147414, creditNoted: 60679, creditNotes: ['CN-00012'], refusal: null }
5040
+ ```
5041
+
5042
+ `refusal` carries the `{ code, message }` that `settle` would throw now. The capture still judges
5043
+ again (and the provider may still decline it). An engine without `settlePreview` answers a GraphQL
5044
+ validation error: treat it as "no preview" and let `settle` decide.
5045
+
4435
5046
  ### Refunding an invoiced order
4436
5047
 
4437
5048
  A refund of an order with a live invoice needs a credit note saying what the money was for, so
@@ -4477,13 +5088,31 @@ covered`: a refund that went back with its credit note is owed by nobody, and an
4477
5088
  a credit note owes only what is left. Show `owed` as what the customer still owes.
4478
5089
 
4479
5090
  `outstanding` keeps its meaning: what the payments are short of the order's total, the figure
4480
- `payments.record` caps a payment at and the settle measures. `overpaid` stays measured against
5091
+ `payments.record` caps a payment at. `overpaid` stays measured against
4481
5092
  `total`, so money a credit note gave back without a refund never reads as an overpayment. Only the
4482
5093
  retail invoices count; an order billed on the accounts-receivable ledger owes what its account
4483
5094
  says (both fields read 0 / `outstanding` there). The cancel-with-money preview and answer carry
4484
5095
  both on their `position` too. Against an engine that does not serve them yet, these reads send
4485
5096
  their document again without them, so both are absent: read `owed ?? outstanding`.
4486
5097
 
5098
+ An order paid down to what its invoice bills net of its credit notes is PAID. `payable` is what
5099
+ its settled money must reach: `total` less what a live invoice's credit notes took off.
5100
+ `orders.settle`, the staff move into `PaymentSettled` and the platform's own settlement measure it,
5101
+ and a payment that pays the order down to it settles the order (a held one waits for
5102
+ `orders.settle`, which is how staff accept it):
5103
+
5104
+ ```ts
5105
+ // 125.14 parked, 20.00 credited without a refund, 105.14 paid:
5106
+ // { total: 12514, creditNoted: 2000, owed: 0, payable: 10514, settled: 10514, outstanding: 2000, … }
5107
+ await admin.orders.settle(orderId); // settles and places it
5108
+ ```
5109
+
5110
+ An order whose only invoice was cancelled with nothing in its place reads `creditNoted: total`
5111
+ and `owed: 0` but `payable: total`: it takes no more money and counts as paid only once it is
5112
+ invoiced again or cancelled. `order.settlement_completed` then carries `credit_noted`, so
5113
+ `total_settled + credit_noted >= billable_total_with_tax`. An engine without `payable` gets the
5114
+ read again without it: read `payable ?? total`.
5115
+
4487
5116
  ### Accounts receivable: credit, AR documents and AR payments
4488
5117
 
4489
5118
  A store that keeps its own accounts-receivable ledger (B2B credit switched on) invoices customers
@@ -4884,15 +5513,31 @@ language answers each call `GRAPHQL_VALIDATION_FAILED`.
4884
5513
 
4885
5514
  **What a cashier may do without a manager** (engine sprint 18): each staff member carries a set of
4886
5515
  grants (`POS_STAFF_GRANTS`: `manual_price`, `discount`, `custom_item_over_limit`, `refund`,
4887
- `discard_held_sale`, `paid_out`, `no_sale`, `safe_drop`, `float_adjust`). A cashier holding one does
4888
- that action on the till on their own sign-in; without it a manager approves, as before. A manager
4889
- may do all of them, whatever is stored. Closing a drawer above the store's variance threshold, an
4890
- approval for a colleague and unpairing a till stay a manager's. The engine judges every action.
5516
+ `discard_held_sale`, `paid_out`, `no_sale`, `safe_drop`, `float_adjust`, `return_items`,
5517
+ `void_sale`, `no_receipt_return`, `online_return`, `manage_card_terminal`, `discount_over_limit`,
5518
+ `view_reports`). A cashier holding one does that action on the till on their own sign-in; without
5519
+ it a manager approves, as before. A manager may do all of them, whatever is stored. Closing a
5520
+ drawer above the store's variance threshold, an approval for a colleague and unpairing a till stay
5521
+ a manager's. The engine judges every action.
5522
+
5523
+ `view_reports` (engine POS programme PR 15) refuses no action: it decides what the till's day
5524
+ report (`posTillDayReport`, the till's Reports tab) shows. Without it the engine answers the day's
5525
+ counts only, every money figure `null` with `withheldReason: view_reports`, until a manager
5526
+ approves showing them; a manager holds it by role. `view_reports` governs the day report only. A
5527
+ store that wants cashiers to see no money at all sets `blindCount`: the drawer's X report, its
5528
+ movement list and the close follow the store's blind count alone.
5529
+
5530
+ `discount_over_limit` is EXPLICIT (`POS_EXPLICIT_STAFF_GRANTS`): it lets a till discount the
5531
+ person authorises (their own, or one they approve as a manager) go past the store's discount limit,
5532
+ and a manager holds it only when it is stored, like a cashier. It opens no door: a cashier still
5533
+ needs `discount` to give a discount at all. `knownGrants()` answers the grants the engine can store
5534
+ (its own `PosStaffGrant` values), so a caller deployed ahead of the engine offers only those.
4891
5535
 
4892
5536
  ```ts
4893
5537
  await admin.pos.staff.setGrants(alex.id, ['refund', 'paid_out']); // replaces the whole set
4894
5538
  const { grants } = (await admin.pos.staff.grants(alex.id)) ?? { grants: [] };
4895
5539
  const everyone = await admin.pos.staff.listGrants(); // id, role, grants per member
5540
+ const offered = await admin.pos.staff.knownGrants(); // the engine's own values
4896
5541
  ```
4897
5542
 
4898
5543
  `setGrants` sends the list as given; the engine stores it deduplicated in its own order and writes
@@ -5254,6 +5899,24 @@ is sent (`ValidationError`, `POS_DEVICE_KIND_INVALID`, the engine's own code for
5254
5899
  `lock` and `unlock` answer `device.kind`; `list` and `revoke` do not, where a device is a till
5255
5900
  unless the fleet says otherwise. Creating a device needs a human session (`pair`'s rule).
5256
5901
 
5902
+ **A code for a store reviewer** works 30 days instead of 10 minutes (`validity`, engine
5903
+ `emre/pos-reviewer-pairing`), for Apple's or Google's reviewer, who installs the app days after it
5904
+ was submitted:
5905
+
5906
+ ```ts
5907
+ const { code, expiresAt } = await admin.pos.devices.pair(reviewTill.id, { validity: 'APP_REVIEW' });
5908
+ ```
5909
+
5910
+ It is still claimed once, by one tablet, and a new code of either validity replaces the device's
5911
+ unclaimed one, so issue one per reviewer device and a fresh one per submission. Only a platform
5912
+ SuperAdmin may issue one (it is the demo store's tool): anyone else is refused
5913
+ `POS_PAIRING_REVIEW_CODE_NOT_ALLOWED` (`PermissionDeniedError`). `pair` without
5914
+ `validity` is a `STANDARD` code on any engine; a call that names one (`POS_PAIRING_CODE_VALIDITIES`:
5915
+ `STANDARD`, `APP_REVIEW`) needs an engine that has it (an older one refuses the document, and the
5916
+ SDK raises a 400 `ValidationError`, `BAD_REQUEST`), and any other value is refused before anything
5917
+ is sent (`ValidationError`, `POS_PAIRING_CODE_VALIDITY_INVALID`). The location rule and the till
5918
+ ceiling apply as to every code.
5919
+
5257
5920
  **Setting a till up** reads it and assigns where it sells, which drawer it opens and which card
5258
5921
  terminal it charges:
5259
5922
 
@@ -5289,6 +5952,14 @@ location `POS_LOCATION_MISMATCH` and a retired one `POS_REGISTER_INACTIVE` (both
5289
5952
  or not one word (`POS_MOLLIE_TERMINAL_ID_INVALID`, `ValidationError`); `clearTerminal` takes the
5290
5953
  terminal off.
5291
5954
 
5955
+ From engine `pos-device-move-refuses-open-drawer`, `assignLocation` and `assignRegister` refuse to
5956
+ change a device's location or register while it has an open drawer: `POS_DEVICE_SESSION_OPEN`
5957
+ (`BusinessRuleError`, the engine's sentence naming the drawer's register), and nothing changes. The
5958
+ drawer stays on the register it opened on, so a move would sell the new location's stock into the
5959
+ old place's cash. Close the drawer first (at the till, or `sessions.close` /
5960
+ `sessions.closeUncounted`; `sessions.openForDevice(deviceId)` reads it), then move the device. A
5961
+ call naming the location and register the device already holds still answers the device.
5962
+
5292
5963
  An older engine keeps a moved device's register, stores any terminal text and fails the
5293
5964
  `registerId` move and `clearTerminal` at validation. The engine does not ask Mollie about a
5294
5965
  terminal id: take it from `terminals.list()`. A display takes no terminal
@@ -5331,16 +6002,31 @@ staff session reads the switch.
5331
6002
  **The engine records each offline sale once**, at the prices the till charged, into the drawer it
5332
6003
  was rung in, when the till is back; the till's replay is its own door and no SDK method sends it.
5333
6004
  A sale it records with something to look at is `needs_review` (`price_changed`, `not_fulfilled`,
5334
- `cashier_changed`, `policy_off`, `over_cap`, `sold_at_suspect`); one it cannot record is `refused`
6005
+ `cashier_changed`, `policy_off`, `over_cap`, `sold_at_suspect`, `cashier_unverified`: the cashier
6006
+ the till names was not signed in, or had not unlocked it offline, on that till when it sold;
6007
+ `item_gone_stand_in` and `stock_short_stand_in`: a gone item or a shortfall recorded on a stand-in
6008
+ line; `total_corrected`: the order's total corrected to the cash taken; `rounding_as_rung`: settled
6009
+ with the rounding the till applied); one it cannot record is `refused`
5335
6010
  (`item_gone`, `drawer_closed`, `currency_changed`, `prices_exclude_tax`, `out_of_stock`,
5336
- `gift_card`, `total_mismatch`, `rounding_mismatch`) and only its row exists. More may come: show an unknown
6011
+ `gift_card`, `total_mismatch`, `rounding_mismatch`, `price_over_limit`: a till price further below the
6012
+ store's than its discount limit, which a manager who may go past the limit can record again) and only
6013
+ its row exists. More may come: show an unknown
5337
6014
  status or reason as it is (`POS_OFFLINE_SALE_REASONS` lists today's). **`offlineSales.list`** pages
5338
6015
  through them newest first: `filter: 'open'` (the default) or `'all'`, `deviceId`, `limit` at most
5339
- 100 (50 by default); a location-restricted caller sees its own tills only. **`resolve`** marks one
6016
+ 100 (50 by default); a location-restricted caller sees its own tills only; each row names who
6017
+ recorded it again (`recordedAgainAt`, `recordedAgainByUserName`; an engine before POS polish PR 6
6018
+ refuses those fields once, and the list then asks without them). **`resolve`** marks one
5340
6019
  handled with what was done (1 to 500 characters); nothing is recorded again, so fix the order, the
5341
6020
  stock or the drawer first. The same note again answers it unchanged; refused
5342
6021
  `POS_OFFLINE_NOTE_INVALID` (`ValidationError`), `POS_OFFLINE_SALE_NOT_FOUND` (`NotFoundError`, a
5343
6022
  `recorded` sale included) and `POS_OFFLINE_SALE_RESOLVED` (`BusinessRuleError`, another note).
6023
+ **`recordAgain`** (an engine with POS polish PR 6) records a `refused` sale again the way the till's
6024
+ replay does, into its own drawer while open, else the open drawer `registerSessionId` names, and
6025
+ answers it with `recordedAgainAt` and `recordedAgainByUserName`; refused again it stays `refused`
6026
+ with its new reasons. It needs `CreateOrder` too; refused `POS_OFFLINE_SALE_NOT_FOUND`
6027
+ (`NotFoundError`), `POS_OFFLINE_SALE_DRAWER_INVALID` (`ValidationError`,
6028
+ `extensions.registerSessionId`) and `LOCATION_NOT_ALLOWED` (`PermissionDeniedError`: the drawer
6029
+ stands at a location a restricted caller may not act at).
5344
6030
  **`devices.offlineSalesToReview`** is the fleet's count per device, its own document: `fleet` never
5345
6031
  selects it and keeps answering an engine before 45. The queue and the counts need `UpdateSettings`
5346
6032
  or `ManagePosDevices`. Each call is a document of its own: an engine before 45 answers it
@@ -5809,6 +6495,11 @@ await geo.countries.update(be.id, { enabled: false });
5809
6495
  - **Other refused deletes** answer `TAX_CATEGORY_NOT_DELETED`, `TAX_RATE_NOT_DELETED` or
5810
6496
  `ZONE_NOT_DELETED` (409 `ConflictError`) with the engine's reason: a zone is refused while a
5811
6497
  store uses it as its default tax or shipping zone, or a tax rate prices in it.
6498
+ - **A rate is a percentage from 0 to 100**, both included. A value outside it is refused
6499
+ `TAX_RATE_VALUE_INVALID` (400 `ValidationError`) before anything is sent, and by the engine for
6500
+ any other caller, with the same sentence and `details[0].field` `/input/value` (`code`
6501
+ `minimum` or `maximum`). Only a value you send is judged: a rate stored outside the range
6502
+ before the bound existed can still be renamed or switched off.
5812
6503
  - **A rate keeps its customer group.** The engine cannot take a group off a rate (it ignores a
5813
6504
  null group and keeps the stored one), so `rates.update` refuses `customerGroupId: null` with
5814
6505
  `INVALID_TAX_RATE_INPUT` instead of answering success. Create a rate without a group and delete
@@ -6344,6 +7035,44 @@ exactly as the engine sends it: compare instants with `Date.parse` and format it
6344
7035
  time zone only for display. Callers and refusals are those of `members`: a SuperAdmin with no tenant,
6345
7036
  or the organization admin in its workspace; an unknown id is `ORGANIZATION_NOT_FOUND` (404).
6346
7037
 
7038
+ ### Pre-order dates that follow the purchase orders
7039
+
7040
+ A variant's availability date (`availableFrom`, the end of a `preorder` or `comingSoon` state) can
7041
+ follow the purchase orders instead of the date staff set: with `incoming` it is kept equal to the
7042
+ earliest FUTURE day a supplier confirmed for an open purchase order into the store's storefront
7043
+ warehouses, recomputed whenever a purchase order of the variant is confirmed, revised, received or
7044
+ cancelled (purchase-orders spec §8.12.3).
7045
+
7046
+ ```ts
7047
+ const now = await admin.catalog.variants.availableFromSource(variantId);
7048
+ // { productVariantId, source: 'manual', availableFrom: '2026-11-04T23:00:00.000Z',
7049
+ // confirmedIncomingOn: '2026-11-05', sharedAcrossLegalEntities: false }
7050
+
7051
+ await admin.catalog.variants.setAvailableFromSource(variantId, 'incoming', { idempotencyKey });
7052
+ await admin.catalog.variants.setAvailableFromSource(variantId, 'manual');
7053
+ ```
7054
+
7055
+ - `confirmedIncomingOn` is the day `incoming` follows, read whatever the source; `null` means no
7056
+ confirmed purchase order, and the date then keeps its last value.
7057
+ - The day becomes the instant local midnight begins in the store's time zone. The date only takes
7058
+ effect under a variant state other than `inherit`.
7059
+ - `source` is typed as the two names but carries any string a newer engine adds.
7060
+
7061
+ Both need the Purchasing plugin on the store (`PLUGIN_ACCESS_DENIED`, 403). The read needs
7062
+ `ReadCatalog` or `ReadProduct`; the switch needs `UpdateCatalog` or `UpdateProduct` on every store
7063
+ holding the variant. Refusals, none of which writes anything:
7064
+
7065
+ - `AVAILABLE_FROM_SOURCE_INVALID` (400 `ValidationError`): a source other than `manual` or
7066
+ `incoming`, refused before sending.
7067
+ - `PURCHASING_NOT_ENABLED` (422): `incoming` while purchasing is off for the legal entity.
7068
+ - `AVAILABLE_FROM_SOURCE_SHARED_VARIANT` (422): `incoming` for a variant stores of more than one
7069
+ legal entity, or an organization, hold (`sharedAcrossLegalEntities`).
7070
+ - `PURCHASING_VARIANT_NOT_FOUND` / `ENTITY_NOT_FOUND` (404): a variant this store does not have.
7071
+
7072
+ The two are documents of their own, never fields of the variant reads, so `variants.get` and
7073
+ `list` keep working against an older engine. They need a vendure-server release with purchase
7074
+ orders step 5.3: deploy it first.
7075
+
6347
7076
  ### Variant prices from an organization
6348
7077
 
6349
7078
  A variant an organization shares from its workspace (catalog sharing on) reaches each member store
@@ -6780,8 +7509,8 @@ await exact.webhooks.sync();
6780
7509
  await exact.disconnect(); // true, or false when the store had no row
6781
7510
  ```
6782
7511
 
6783
- The store's Exact Online settings tab. The sync runs, the logs and the shipping-method mapping
6784
- follow in their own modules.
7512
+ The store's Exact Online settings tab. Its inbound sync, its shipping-method mapping and its
7513
+ connection logs are below.
6785
7514
 
6786
7515
  - **`config.update` takes one object; the wire keeps the engine's inline arguments.** A key left
6787
7516
  out keeps the stored value; `autoPush` and `customerSync` replace their whole setting (`null`:
@@ -6807,6 +7536,249 @@ follow in their own modules.
6807
7536
  `config.get` and `disconnect` (`PLUGIN_ACCESS_DENIED`, 403; never a SuperAdmin). A SuperAdmin head
6808
7537
  acts on another store through `admin.withTenant(...)`.
6809
7538
 
7539
+ ### Exact Online sync, shipping methods and connection logs
7540
+
7541
+ ```ts
7542
+ const exact = admin.integrations.exactOnline;
7543
+
7544
+ await exact.sync.logs({ entity: 'items', limit: 5 }); // the newest runs (1-100), not a page
7545
+ await exact.sync.health(); // how each area last ran: customers, catalogue, orders
7546
+ await exact.sync.runCatalogue(); // "Sync now": queued, answers true (@effect money)
7547
+ await exact.sync.runCustomers();
7548
+ await exact.sync.runPriceLists(); // false: one is already queued
7549
+ await exact.sync.reimportPictures(); // how many pictures were marked
7550
+ await exact.sync.cursors();
7551
+ await exact.sync.resetCursor('items'); // the next sweep re-reads the stream
7552
+ await exact.warehouseLinks(); // stock locations and their Exact warehouse
7553
+
7554
+ const { mappings, exact: catalogue } = await exact.shipping.methods();
7555
+ await exact.shipping.runSync(); // refresh the Exact catalogue, re-apply activated rates
7556
+ await exact.shipping.import(exactShippingMethodId); // activate one in checkout, from Exact's rate
7557
+ await exact.shipping.setMapping({ vendureShippingMethodId, exactShippingMethodId }); // null clears
7558
+ await exact.shipping.convertToMapping({ exactShippingMethodId, vendureShippingMethodId });
7559
+ await exact.shipping.retireImported(exactShippingMethodId);
7560
+
7561
+ for await (const call of await exact.connectionLogs.calls({ outcome: 'failed' })) {
7562
+ console.log(call.occurredAt, call.operation, call.statusCode);
7563
+ }
7564
+ await exact.connectionLogs.events({ dateFrom: '2026-10-01T00:00:00+02:00' });
7565
+ const csv = await exact.connectionLogs.export('calls', {
7566
+ dateFrom: '2026-10-01T00:00:00+02:00',
7567
+ dateTo: '2026-10-14T23:59:59.999+02:00',
7568
+ });
7569
+ ```
7570
+
7571
+ - **A run is queued, never awaited.** `runCatalogue` (catalogue, customers, sales and purchase
7572
+ orders), `runCustomers`, `runPriceLists` and `reimportPictures` answer once the work is on the
7573
+ worker's queue; its outcome is a `sync.logs` row. They overwrite what Exact owns (prices
7574
+ customers pay, stock, product and customer details, price lists and terms) and are tagged
7575
+ `@effect irreversible`, as is `shipping.runSync` (it re-applies Exact's rates to activated
7576
+ methods; a merchant's rename stays). `runCatalogue` is `@effect money`: its order import books
7577
+ Exact's sales orders as placed orders with a settled payment. The runs are refused
7578
+ `FIELD_GROUP_LOCKED` (422; the sentence names each owner and where to release it) while an
7579
+ app or API token owns a field group of the store: Exact writes all four. A store that is not
7580
+ connected is NOT refused: its run fails on the worker.
7581
+ - **`sync.logs` is the newest N, not a page.** `limit` is a whole number from 1 to
7582
+ `EXACT_ONLINE_SYNC_LOG_LIMIT` (100) and is refused otherwise, before sending
7583
+ (`INVALID_EXACT_ONLINE_INPUT`, 400), rather than answered short.
7584
+ - **The shipping methods.** A method Exact defines reaches checkout only through `import` or a
7585
+ mapping. `convertToMapping` and `retireImported` soft-delete the method Exact created
7586
+ (`@effect destructive`) and need `DeleteSettings` or `DeleteShippingMethod`; their refusals are
7587
+ `USER_INPUT_ERROR` (400). `import` and `setMapping` refusals (not connected, not active in the
7588
+ catalogue, no rate, mapped elsewhere) are not coded yet: `GRAPHQL_ERROR` (500).
7589
+ `setMapping` refuses a missing or blank `exactShippingMethodId` before sending: the engine
7590
+ would read either as null and clear the mapping; only an explicit `null` clears.
7591
+ - **The connection logs** are the store's own audit rows for Exact (`AuditCall`, `AuditEvent`),
7592
+ newest first, paged by cursor. Pass dates with an offset: the service pins only a bare
7593
+ `dateTo` (to the end of that UTC day) and reads any other zone-less value in its database's
7594
+ session time zone. `export` answers the CSV text of a range of at most 366 days (both dates required;
7595
+ `VALIDATION_ERROR`, 400, otherwise) and waits up to `EXACT_ONLINE_LOG_EXPORT_TIMEOUT_MS`
7596
+ (120 s). An app below `customerData: full` reads the rows with every customer-data field null,
7597
+ and may not search with `q`.
7598
+ - **Permissions:** reads `ReadSettings`; runs, `resetCursor`, `shipping.runSync`, `import` and
7599
+ `setMapping` `UpdateSettings`. A store without the Exact Online plugin is refused
7600
+ (`PLUGIN_ACCESS_DENIED`, 403; the connection logs `PermissionDeniedError`).
7601
+
7602
+ ### Exact Online terms, and the customer, order and product cards
7603
+
7604
+ ```ts
7605
+ const exact = admin.integrations.exactOnline;
7606
+
7607
+ await exact.terms.paymentConditions(); // the ACTIVE conditions mirrored from the division
7608
+ await exact.terms.paymentDiagnostics(); // how far the customers' conditions reach
7609
+ await exact.terms.paymentDryRun(); // live read of Exact, writes nothing
7610
+ await exact.terms.incoterms();
7611
+ await exact.terms.deliveryDiagnostics();
7612
+ await exact.terms.deliveryDryRun(); // live read of Exact, writes nothing
7613
+
7614
+ await exact.customers.paymentTerms(customerId); // null when Exact holds none
7615
+ await exact.customers.deliveryTerms(customerId); // null when Exact holds none
7616
+ await exact.customers.priceList(customerId); // status: assigned | pending | none | unlinked
7617
+
7618
+ const state = await exact.orders.state(orderId); // origin: 'pushed' | 'imported' | null
7619
+ await exact.orders.syncState(orderId); // null for an order never imported
7620
+ const pushed = await exact.orders.push(orderId); // @effect irreversible; refusal: success false
7621
+ await exact.orders.reprice(orderId); // @effect money: re-split VAT at Exact's rates
7622
+ await exact.orders.repriceAll(); // every imported order, counts
7623
+ await exact.orders.runSync(); // queue the inbound order sync (@effect money)
7624
+ await exact.orders.runBackfill(); // reset the cursor and re-read the history
7625
+
7626
+ await exact.products.status(productId); // null for a product the store does not hold
7627
+
7628
+ await admin.customers.deliveryTerms(customerId); // the provider-neutral terms an order takes
7629
+ ```
7630
+
7631
+ - **The dry runs read Exact live** and write nothing, here or in Exact, also while
7632
+ synchronization is off. A store that is not connected is refused
7633
+ `EXACT_ONLINE_NOT_CONNECTED` (`BusinessRuleError`, 422) before Exact is asked; a read Exact
7634
+ refuses is named in `resources` (`complete: false`), not thrown. Every other read here is a
7635
+ local read of what the sync last wrote.
7636
+ - **The customer cards answer the engine's spelling**: `status`, `classification` and
7637
+ `deliveryAdvice` are lowercase (`assigned`, `single_due_date`, `complete_order`), unlike the
7638
+ uppercase enums of `customers.credit.paymentTerms` and `customers.deliveryTerms`. They are read
7639
+ on any store, with or without the plugin.
7640
+ - **`orders.push` writes a sales order into the store's Exact division** and cannot be undone from
7641
+ the platform. It is write-once: an order already in Exact answers `alreadyPushed: true` and
7642
+ writes nothing. A refusal (guest order, lines not linked to an Exact item, a sandbox store, no
7643
+ active connection, another division's link, or Exact's own) is `success: false` with `error` (and
7644
+ `errorCode` when it has one), not a throw. A sales order that came out different from the order
7645
+ answers its differences in `warnings`. The engine takes no idempotency key for it yet. An engine
7646
+ that predates `warnings` refuses the document unread; `push` and `state` then send it once more
7647
+ without the field and answer `warnings: null` (push) or `[]` (state).
7648
+ - **`orders.reprice` and `repriceAll` change what an imported order bills**: each line's VAT is
7649
+ re-split at the rate Exact's sales order holds, the gross total unchanged. Exact is the source
7650
+ of truth for an Exact store's figures; nothing here recomputes them on the platform's side.
7651
+ - **The order runs are queued** and refused `FIELD_GROUP_LOCKED` (422) while an app or API token
7652
+ owns a field group of the store. A missing order, customer or product id is refused before
7653
+ sending (`INVALID_EXACT_ONLINE_INPUT`, 400).
7654
+ - **Permissions:** the terms `ReadSettings`; the customer cards and `customers.deliveryTerms`
7655
+ `ReadCustomer`; `orders.state` and `syncState` `ReadOrder`; `push` and `reprice` `UpdateOrder`;
7656
+ `repriceAll` and the runs `UpdateSettings`; `products.status` `ReadCatalog` or `ReadProduct`. A
7657
+ store without the Exact Online plugin is refused the terms and the order methods
7658
+ (`PLUGIN_ACCESS_DENIED`, 403).
7659
+
7660
+ ### Data pipelines
7661
+
7662
+ A pipeline imports from, or exports to, another system over one of the store's connections, by
7663
+ hand or on a schedule. It is created by committing a set-up (below); its runs and their logs are
7664
+ read and driven through `runs` (next section).
7665
+
7666
+ ```ts
7667
+ const pipelines = admin.integrations.pipelines;
7668
+
7669
+ for await (const pipeline of await pipelines.list({ direction: 'export' })) {
7670
+ console.log(pipeline.name, pipeline.scheduleType, pipeline.timezone, pipeline.isActive);
7671
+ }
7672
+ const pipeline = await pipelines.get(id); // null when the store has none with that id
7673
+
7674
+ await pipelines.update(id, { scheduleType: 'daily', timezone: 'Europe/Amsterdam' });
7675
+ await pipelines.update(id, { scheduleType: 'custom', cronExpression: '*/30 * * * *' });
7676
+ await pipelines.update(id, { config: { triggerFilter: { toState: 'PaymentSettled' } } });
7677
+ await pipelines.update(id, { isActive: false }); // pause: the schedule stops
7678
+ await pipelines.delete(id);
7679
+ ```
7680
+
7681
+ - **A schedule is a wall-clock time in the pipeline's own `timezone`:** `daily` runs at 03:00
7682
+ there, `weekly` on Mondays at 03:00, `custom` on its cron, through the zone's daylight saving
7683
+ changes. A set-up starts with the store's zone; a pipeline made before zones were asked for
7684
+ holds `UTC`.
7685
+ - **Pausing** (`isActive: false`) unregisters the schedule, and a commerce plugin's export through
7686
+ the pipeline (Hendriks) is refused until it is resumed. A run started by hand still works.
7687
+ - **An export's filter has one vocabulary**, the orders reader's: the key `toState` and the order
7688
+ states. A save naming anything else is refused, because an unhonoured key would export every
7689
+ order and an impossible value none: `PIPELINE_EXPORT_FILTER_UNSUPPORTED`, `_BLANK` or
7690
+ `_VALUE_UNKNOWN` (`ValidationError`, 400, `details[].field` `triggerFilter`). `config` is merged
7691
+ over the stored settings at the top level; `triggerFilter: {}` removes the filter.
7692
+ - A `custom` schedule with no cron is `PIPELINE_CRON_REQUIRED`, and one the scheduler can never run
7693
+ (`0 0 31 4 *`) `PIPELINE_CRON_NEVER_FIRES`; both save nothing.
7694
+ - `delete` unregisters the schedule and removes the pipeline from the list, and cancels its runs
7695
+ that have not ended: a queued or paused one at once, a running one after the batch it is
7696
+ working on, so nothing more is imported or sent. Each says so in its `errorMessage`. Its past
7697
+ runs and logs stay (`runs.get` still reads them).
7698
+ - Reads need `ReadCatalog`, writes `UpdateCatalog`. Another store's pipeline is not found (`get`
7699
+ answers `null`). An organization workspace may only pause (`ORGANIZATION_WORKSPACE_NOT_A_STORE`
7700
+ otherwise).
7701
+
7702
+ ### Data pipeline runs
7703
+
7704
+ ```ts
7705
+ const runs = admin.integrations.pipelines.runs;
7706
+
7707
+ const run = await runs.start(pipelineId); // `pending`; { syncPolicy?, collectionAssignments? }
7708
+ const now = await runs.get(run.id); // null when the store has none with that id
7709
+ console.log(now?.status, now?.progress.orders?.exported, now?.errorMessage);
7710
+
7711
+ for await (const past of await runs.list(pipelineId)) console.log(past.status, past.startedAt);
7712
+
7713
+ await runs.pause(run.id); // at the next batch boundary
7714
+ await runs.resume(run.id);
7715
+ await runs.cancel(run.id); // a running or paused run; what it did stays done
7716
+
7717
+ const errors = await runs.logs(run.id, { entityType: 'orders', result: 'errored', limit: 50 });
7718
+ console.log(errors.total, errors.items.map((log) => [log.sourceId, log.message]));
7719
+ ```
7720
+
7721
+ - **Pause and cancel act at the next batch boundary:** the batch in flight finishes and is
7722
+ recorded. Only a `running` run pauses; a `running` or `paused` one cancels; a queued (`pending`)
7723
+ one is refused `PIPELINE_RUN_NOT_STARTED`, and a run in any other state
7724
+ `PIPELINE_RUN_ALREADY_ENDED`, `PIPELINE_RUN_NOT_RUNNING` or `PIPELINE_RUN_NOT_PAUSED` (all
7725
+ `ConflictError`, 409). A run of another store is `PIPELINE_RUN_NOT_FOUND`.
7726
+ - **A run of a deleted pipeline is cancelled** (`pipelines.delete`): `status` `cancelled`,
7727
+ `errorMessage` "The pipeline was deleted, so this run stopped after the batch it was working on.
7728
+ Nothing more was sent." (or "...while this run was waiting to start..." / "...was paused...").
7729
+ - **The logs filter by `entityType` and `result`** (`created`, `updated`, `skipped`, `errored`,
7730
+ `exported`), newest first, with `total`. There is no log level: a record's outcome is its
7731
+ `result`.
7732
+ - **`start` is not idempotent**: each call queues another run. An export run sends orders to the
7733
+ other system, which cannot be taken back (`@effect irreversible`, as `resume`).
7734
+ - **A run is also a job** with the same id: `admin.jobs.list()` indexes the runs it has not seen,
7735
+ and `admin.jobs.wait(run.id)` then waits for one to end.
7736
+ - `start` and `resume` refuse a client whose channel token is no longer the store's
7737
+ (`PIPELINE_CHANNEL_TOKEN_MISMATCH`, 403: the store was renamed; build a client with the new
7738
+ token), and an organization workspace (`ORGANIZATION_WORKSPACE_NOT_A_STORE`). Reads need
7739
+ `ReadCatalog`, the rest `UpdateCatalog`.
7740
+
7741
+ ### Following a run or a discovery live
7742
+
7743
+ ```ts
7744
+ const pipelines = admin.integrations.pipelines;
7745
+ const controller = new AbortController();
7746
+
7747
+ for await (const event of pipelines.runs.watch(run.id, { signal: controller.signal })) {
7748
+ // 'progress' while it works; then one of 'complete' | 'error' | 'cancelled' | 'paused', and the loop ends
7749
+ console.log(event.type, event.phase, event.entityType, event.message, event.progress?.orders);
7750
+ }
7751
+
7752
+ const discovery = await pipelines.discoveries.start(connectionId); // `pending`
7753
+ for await (const event of pipelines.discoveries.watch(discovery.id)) {
7754
+ console.log(event.type, event.phase, `${event.percent}%`, event.message); // ends on 'complete' | 'error'
7755
+ }
7756
+ const read = await pipelines.discoveries.get(discovery.id); // null when the store has none
7757
+ const maps = await pipelines.discoveries.schemaMaps(discovery.id); // one per kind of record
7758
+ ```
7759
+
7760
+ - **`watch` ends on the job's terminal event**, which is yielded, never thrown: a failed run is an
7761
+ `error` event whose `message` is the reason. A job that had already ended answers its ended state
7762
+ at once; a queued run says nothing until it starts. A resumed run is watched again.
7763
+ - **A dropped connection is reopened** (at most `maxReconnects` times in a row, default 5;
7764
+ `onReconnect` is told each time), so a deploy or a proxy timeout does not end the loop. A refusal
7765
+ is never retried: another store's (or a deleted) run or discovery is a `NotFoundError` (404
7766
+ `PIPELINE_RUN_NOT_FOUND` / `PIPELINE_DISCOVERY_NOT_FOUND`, before the stream opens; an older
7767
+ plugins service refuses inside the opened stream, `PROGRESS_STREAM_REFUSED`, an
7768
+ `UpstreamError`), and a missing `ReadCatalog` is a
7769
+ `PermissionDeniedError` (403). Once the reopens are spent the loop throws
7770
+ `PROGRESS_STREAM_CLOSED` or `PROGRESS_STREAM_STALLED` (no data for `idleTimeoutMs`, 45 s by
7771
+ default); the job itself carries on, so read it with `runs.get` / `discoveries.get`. `signal`
7772
+ ends it with `REQUEST_ABORTED`.
7773
+ - **The counts are the stream's.** A run cancelled by its pipeline's deletion ends the stream at
7774
+ once while the batch in flight may still finish; `runs.get` reads the final counts.
7775
+ - An id that is not a run's or a discovery's id is refused `PROGRESS_STREAM_ID_INVALID` before
7776
+ anything is sent: the streams are a closed set of kinds in `@sixtyninecommerce/core`, never a
7777
+ path.
7778
+ - `discoveries.start` samples the source again on every call (a fixed-schema connector answers at
7779
+ once; any other asks the AI, cached 24 hours for an unchanged source). An import set-up starts
7780
+ its own (`drafts.discover`). It needs `UpdateCatalog`; the reads and `watch` need `ReadCatalog`.
7781
+
6810
7782
  ### Data pipeline set-ups (drafts)
6811
7783
 
6812
7784
  An import or export set-up is kept on the server while the merchant works through it, so it can be
@@ -6927,6 +7899,58 @@ await pipelines.connections.delete(connection.id);
6927
7899
  - `siteAnalysis`, `publicCredentials` and a probe's `entityCounts` (keyed by the source's own
6928
7900
  entity names, `product_brand` too) keep their keys exactly (`PIPELINE_CONNECTION_PRESERVE_KEYS`).
6929
7901
 
7902
+ ### Bulk price import
7903
+
7904
+ Variant prices from a CSV, JSON or XML document, matched by SKU in the store: a dry run first,
7905
+ then the rows to write. Every method needs `UpdateCatalog` or `UpdateProduct`.
7906
+
7907
+ ```ts
7908
+ const prices = admin.integrations.priceImport;
7909
+
7910
+ const document = { format: 'csv', content: await file.text(), fileTaxMode: 'inclusive' } as const;
7911
+ const preview = await prices.preview(document); // nothing written
7912
+ preview.summary; // { total, updated, unchanged, notFound, … }
7913
+ for (const row of preview.results) {
7914
+ row.rowNumber; row.sku; row.status; // 'updated' | 'unchanged' | 'not_found' | …
7915
+ row.oldPrice; row.newPrice; // minor units, in the store's tax mode
7916
+ }
7917
+
7918
+ const chosen = preview.results.filter((r) => r.status === 'updated').map((r) => r.rowNumber);
7919
+ const written = await prices.run({ ...document, selectedRows: chosen });
7920
+ written.summary.updated; written.summary.errored; // a failed row does not undo the others
7921
+
7922
+ // Any supplier file, converted by the platform's AI provider into a document `preview` accepts:
7923
+ const { conversionId } = await prices.convert({ file }); // .xlsx, .pdf, .docx, .csv, …
7924
+ const converted = await prices.follow(conversionId, {
7925
+ signal, // abort to stop the conversion
7926
+ onProgress: ({ step, message }) => console.log(step, message),
7927
+ });
7928
+ await prices.preview({ format: 'json', content: converted.document, fileTaxMode: converted.fileTaxMode });
7929
+ ```
7930
+
7931
+ - **`preview` writes nothing; `run` writes what customers pay and cannot be undone** except by
7932
+ importing each row's `oldPrice` again. A timeout after `run` was sent does not say whether the
7933
+ prices were written: `preview` the same document (rows already at their new price read
7934
+ `unchanged`) before repeating.
7935
+ - **`selectedRows`** names rows by `rowNumber`: a CSV row's line (the header is line 1), a JSON
7936
+ or XML entry's position. Left out, every `updated` row is written; `[]` writes nothing. Two selected rows setting one SKU and currency are refused whole,
7937
+ `PRICE_IMPORT_SELECTION_CONFLICT` (`BusinessRuleError`, 422, one `details[]` entry per pair).
7938
+ - **A document that cannot be read is refused whole** (`PRICE_IMPORT_FORMAT_ERROR`,
7939
+ `BusinessRuleError`, 422) and nothing is written; `details[]` name each problem (`field`:
7940
+ `header`, `document` or `row N`). A row whose content is wrong is reported `invalid` instead,
7941
+ and the rest still apply. At most 5,000 data rows.
7942
+ - **`fileTaxMode`** says whether the amounts include tax. Left out, they are read in the store's
7943
+ own mode; given and different, each is converted at its variant's rate (`taxConverted`).
7944
+ - **The AI conversion writes nothing.** `convert` uploads the file (refused before sending
7945
+ without a name: `PRICE_IMPORT_FILE_NAME_REQUIRED`); `follow` runs it, 30 seconds to 3 minutes,
7946
+ and resolves the converted document with what was read (`interpretation`, `warnings`,
7947
+ `fileTaxMode` and whether it was read from the file, `taxModeReason`). Its refusals keep their
7948
+ codes (`AI_IMPORT_NO_PRICES_FOUND`, `AI_IMPORT_NOT_FOUND`, `AI_IMPORT_BUSY`, …; the TSDoc maps
7949
+ each to its class); none is retryable, because an upload is read once: convert the file again.
7950
+ An upload belongs to the store it was sent to, so follow it with the same client.
7951
+ - An assistant should ask before `run`, never before `preview` (`@effect irreversible` and
7952
+ `read`).
7953
+
6930
7954
  ### Searching the store
6931
7955
 
6932
7956
  One call finds products, orders, customers and content items matching a term: each type's best
@@ -7045,6 +8069,88 @@ products (`MAX_VARIANT_FIND_PRODUCTS`); more distinct ids than `take`. A search
7045
8069
  `admin.reports.popularVariants({ take, sinceDays })` answers its ranked variants in the same
7046
8070
  shape (plus `productId`), so a "popular" list needs no second read.
7047
8071
 
8072
+ ### SEO: redirects, settings and automation
8073
+
8074
+ ```ts
8075
+ // Redirects: a page at a time, or every one.
8076
+ for await (const redirect of await admin.seo.redirects.list({ limit: 200 })) {
8077
+ redirect; // { id, oldPath, newPath, statusCode, source: 'manual' | 'import' | 'auto', … }
8078
+ }
8079
+ await admin.seo.redirects.create({ oldPath: '/old-page', newPath: '/new-page' }); // 301
8080
+ const result = await admin.seo.redirects.import([
8081
+ { oldPath: '/product/old', newPath: '/product/new' },
8082
+ { oldPath: '/sale', newPath: 'https://outlet.example.com/', statusCode: 302 },
8083
+ ]);
8084
+ // { created: 1, updated: 1, rejected: [{ index, oldPath, message }] }
8085
+ const nginx = await admin.seo.redirects.exportNginx(); // two `map` blocks, as text
8086
+
8087
+ // The channel settings and the SEO defaults (Admin API).
8088
+ const settings = await admin.seo.settings.get();
8089
+ // { channelId, defaultLanguageCode, availableLanguageCodes, storefrontUrl,
8090
+ // urlLocaleStrategy: 'path-prefix', urlLocalePrefixDefault: false,
8091
+ // twitterHandle, defaultOgImageId, aiBotDefaults: { GPTBot: 'deny' }, llmsTxtEnabled,
8092
+ // robotsTxtCustom,
8093
+ // defaultsSource: { source: 'organization', locked: false, inheritable: true,
8094
+ // overridden: false, organizationName: 'Vinvin Group', effective: { … } } }
8095
+ await admin.seo.settings.set({ urlLocaleStrategy: 'subdomain' });
8096
+ await admin.seo.settings.set({ aiBotDefaults: { GPTBot: 'deny', ClaudeBot: 'allow' } });
8097
+
8098
+ // The automation (plugins service).
8099
+ const config = await admin.seo.config.get();
8100
+ // { autoRedirectsEnabled, indexnowEnabled, indexnowKey, crawlSchedule: 'weekly', crawlMaxPages,
8101
+ // crawlRatePerSec, cruxEnabled, gscConnected, gscPropertyUrl, gscConnectedAt,
8102
+ // source, locked, overridden, inheritable, organizationName }
8103
+ await admin.seo.config.set({ autoRedirectsEnabled: false });
8104
+ const { key } = await admin.seo.config.rotateIndexnowKey();
8105
+ ```
8106
+
8107
+ SEO of the **active** store, in three parts.
8108
+
8109
+ **Redirects** (`seo.redirects`) are what the store's old URLs answer. Storefronts read them live
8110
+ from the public redirect map (`createRedirectMiddleware` in `@sixtyninecommerce/client/next`), so a
8111
+ write reaches the site within a few minutes, with no deployment. Each rule is one exact source path
8112
+ to a path or an `https://` URL, with 301 (the default), 302, 303, 307 or 308. A malformed path, a
8113
+ self-redirect or another status is a `ValidationError`; a source the store already redirects, a
8114
+ loop or a chain past the hop limit a `ConflictError`. `import` adds or updates up to 5,000 rules
8115
+ (`REDIRECT_IMPORT_MAX_ROWS`) in one call, matched on the source path, and judges each row on its
8116
+ own: a bad row is answered in `rejected` by its index (with the service's sentence; there is no
8117
+ code per row yet) and the rest still land, in order, so a loop split across two rows is refused on
8118
+ the second. More rows, or none, is `REDIRECT_IMPORT_INVALID` before anything is sent: send a large
8119
+ file in chunks. `exportNginx` answers the rules as two Nginx `map` blocks (`$redirect_uri` and
8120
+ `$redirect_status` on `$uri`) for a site not on the storefront SDK. Every redirect method needs
8121
+ `UpdateChannel`; in an organization's workspace `create`, `update` and `import` are refused with
8122
+ `ORGANIZATION_WORKSPACE_NOT_A_STORE` (`BusinessRuleError`), `delete` never is. `delete` is the one
8123
+ destructive method.
8124
+
8125
+ **Settings** (`seo.settings`) are Channel custom fields: the storefront URL and how languages show
8126
+ in its URLs (`URL_LOCALE_STRATEGIES`), always the store's own, and the SEO defaults (share card, AI
8127
+ crawlers, `llms.txt`, `robots.txt`), which are the `settings.seo` group. `get` answers the store's
8128
+ OWN values and `defaultsSource`: whether storefronts get them or the organization's
8129
+ (`defaultsSource.effective` is what they get). `set` is a patch: a key left out is unchanged, `null`
8130
+ clears it, and `aiBotDefaults` is an object the SDK encodes for the column (pass the map, never its
8131
+ JSON). The read-only keys `get` answers are ignored, so `set({ ...settings, … })` works. Refused
8132
+ before any request with `SEO_SETTINGS_INVALID` (`ValidationError`): an empty patch, a key that is not
8133
+ a setting, a bot map that is not an object. The engine refuses a directive other than `allow` or
8134
+ `deny` (`CHANNEL_SETTINGS_INVALID`, `details[].field` `customFields.aiBotDefaults`), another locale
8135
+ strategy (`USER_INPUT_ERROR`), a changed default the organization decides
8136
+ (`MANAGED_BY_ORGANIZATION` / `ORGANIZATION_SETTINGS_INHERITED`, `BusinessRuleError`; sending the
8137
+ stored value back passes) and a storefront URL for a workspace (`ORGANIZATION_WORKSPACE_NOT_A_STORE`).
8138
+ Writing needs `UpdateChannel`.
8139
+
8140
+ **Automation** (`seo.config`) lives on the plugins service and needs the store's `seo` plugin:
8141
+ automatic redirects on URL changes, IndexNow, Core Web Vitals and the audit schedule
8142
+ (`SEO_CRAWL_SCHEDULES`; 10 to 5,000 pages at 1 to 10 a second). Its keys are the service's wire
8143
+ names camelised, as the meta catalog's `seo` schema publishes them (`indexnow_enabled` →
8144
+ `indexnowEnabled`). The switches are part of `settings.seo`, so `get` answers the effective ones with
8145
+ where they come from, and `set` refuses a change the organization decides
8146
+ (`MANAGED_BY_ORGANIZATION` / `ORGANIZATION_SETTINGS_INHERITED`; in the workspace, during a switch-off,
8147
+ `ORGANIZATION_SETTINGS_SWITCH_OFF_CONFLICT`, a `ConflictError`). Refused before any request with
8148
+ `SEO_CONFIG_INVALID`: an empty patch, a key that is not a switch, a null. A value out of range is
8149
+ the service's `VALIDATION_FAILED`. `rotateIndexnowKey` voids the old key at once (destructive): the
8150
+ store's `/<key>.txt` serves the new one; it is the store's own, never refused for an inheriting
8151
+ store, but refused in a workspace (`ORGANIZATION_WORKSPACE_NOT_A_STORE`). `get` needs
8152
+ `ReadSettings`, the writes `UpdateSettings`.
8153
+
7048
8154
  ### Purchasing
7049
8155
 
7050
8156
  Buying stock from suppliers, per legal entity. The submodules so far are the legal entity's
@@ -7052,7 +8158,8 @@ purchasing settings with its document numbering, its suppliers with their contac
7052
8158
  ([Suppliers](#suppliers)), its purchase orders with their lines
7053
8159
  ([Purchase orders](#purchase-orders)), its reorder rules and suggestions
7054
8160
  ([Replenishment](#replenishment)), and what needs attention with who its alert emails go to
7055
- ([Alerts](#alerts)); receipts and returns join `admin.purchasing` as the engine ships them.
8161
+ ([Alerts](#alerts)), its goods receipts ([Receipts](#receipts)) and its returns of goods to
8162
+ suppliers ([Returns](#returns)).
7056
8163
 
7057
8164
  ```ts
7058
8165
  const settings = await admin.purchasing.settings.get();
@@ -7667,7 +8774,8 @@ cards), and who its three alert emails go to.
7667
8774
  ```ts
7668
8775
  const summary = await admin.purchasing.alerts.summary();
7669
8776
  // { localDay: '2026-10-13', awaitingApproval: 2, overdueLines: 5, overdueOrders: 3, attentionOrders: 4,
7670
- // reorderSuggestions: 14, newReorderSuggestions: 3 } — or null while purchasing is off
8777
+ // reorderSuggestions: 14, newReorderSuggestions: 3, backordersReadyLines: 2,
8778
+ // backordersReadyOrders: 1 } — or null while purchasing is off
7671
8779
 
7672
8780
  const who = await admin.purchasing.alerts.recipients.list();
7673
8781
  // [{ alert: 'purchase_order_approval_requested', permission: 'ApprovePurchaseOrder', emails: [],
@@ -7689,12 +8797,16 @@ await admin.purchasing.alerts.recipients.update([{ alert: 'purchase_order_lines_
7689
8797
  confirmed day, else the requested one) is before `localDay`, today in the legal entity's time
7690
8798
  zone, and the orders holding them. `attentionOrders` counts each order awaiting approval or
7691
8799
  overdue once. `reorderSuggestions`/`newReorderSuggestions`: the last compute's listed
7692
- suggestions and the new ones among them. `summary` answers `null` while purchasing is not
7693
- switched on, and needs `ReadPurchaseOrder`.
8800
+ suggestions and the new ones among them. `backordersReadyLines`/`backordersReadyOrders`: the
8801
+ ACTIVE store's backordered order lines that stock on hand can ship now, and the orders holding
8802
+ them (`backorders.ready()`, below). Against an engine before step 3.6 the SDK asks once more
8803
+ without those two and answers them `null`; the other counts are unchanged.
8804
+ `summary` answers `null` while purchasing is not switched on, and needs `ReadPurchaseOrder`.
7694
8805
  - **The alerts**, each an email under Management → Notifications (switched on, templates and
7695
8806
  sender there): `purchase_order_approval_requested` (an order or a revision waits for approval;
7696
8807
  default recipients the holders of `ApprovePurchaseOrder`), `purchase_order_lines_overdue` (the
7697
- daily overdue digest at 08:00 in the legal entity's time zone; `UpdatePurchaseOrder`) and
8808
+ daily overdue digest at 08:00 in the legal entity's time zone; `ReceivePurchaseOrder`, the
8809
+ staff who take the goods in; `UpdatePurchaseOrder` on an engine before step 3.6) and
7698
8810
  `reorder_suggestions_digest` (the new reorder suggestions, daily or on Mondays at 08:00 as the
7699
8811
  settings' `reorderDigest` says; `CreatePurchaseOrder`). The holders are the administrators of
7700
8812
  the legal entity's stores holding the permission; a platform administrator and an API
@@ -7709,6 +8821,372 @@ await admin.purchasing.alerts.recipients.update([{ alert: 'purchase_order_lines_
7709
8821
  purchases (`HUB_HAS_NO_PURCHASING`, `LEGAL_ENTITY_REQUIRED`, 422). An engine before step 4.3
7710
8822
  fails the request GraphQL validation (`GRAPHQL_VALIDATION_FAILED`).
7711
8823
 
8824
+ #### Backorders ready to ship
8825
+
8826
+ What received goods let the store ship: its open backordered order lines that stock on hand can
8827
+ cover now, oldest order first.
8828
+
8829
+ ```ts
8830
+ const ready = await admin.purchasing.backorders.ready({ limit: 50 });
8831
+ // items: [{ orderId, orderCode: 'ORD-1042', orderState: 'PaymentSettled',
8832
+ // orderPlacedAt: '2026-10-01T10:00:00.000Z', orderLineId, productVariantId, sku: 'TEE-M',
8833
+ // name: 'Tee M', quantity: 5, backorderedQuantity: 3, readyQuantity: 3, stockOnHand: 8,
8834
+ // supplierCancelled: false }, …]
8835
+
8836
+ // What one receipt let ship: only the variants it brought in.
8837
+ const fromReceipt = await admin.purchasing.backorders.ready({ goodsReceiptId: receipt.id });
8838
+ for await (const line of fromReceipt) { /* every page */ }
8839
+ ```
8840
+
8841
+ - **The rule.** Per variant, over the store's stock locations: free = on hand − (allocated − the
8842
+ claims allocation holds) − the in-stock parts no allocation holds, at most on hand. A paid
8843
+ backordered order allocates past on hand, so taking its claims back out of `allocated` leaves what
8844
+ the in-stock parts of open orders hold. An order whose reservation waits for fulfilment (net
8845
+ terms, a stock-bypass import) holds nothing: its claim stays in, and its in-stock part is held
8846
+ out of on hand as the stock it was promised. The free units go to the claims oldest order first
8847
+ (placed, else created for an order never placed, then order code); a line is listed with what it
8848
+ gets (`readyQuantity`, 1 to `backorderedQuantity`). Every open claim keeps its turn, a parked or
8849
+ approval-waiting order's too (`orderState` says which). Another store's allocations at a shared
8850
+ location stay in `allocated`, so the figure errs low there.
8851
+ - **Scope.** The ACTIVE store's own orders (a store of the same legal entity never lists another's
8852
+ customers' orders) and its own stock locations. Nothing is allocated: open the order and fulfil it.
8853
+ - **The count** is on `alerts.summary()`: `backordersReadyLines` and `backordersReadyOrders`
8854
+ (`null` from an engine before step 3.6, which refuses `ready()` itself at validation).
8855
+ - **Access.** Needs `ReadOrder`, the Purchasing plugin and purchasing switched on
8856
+ (`PURCHASING_NOT_ENABLED`, 422). Refusals: `GOODS_RECEIPT_NOT_FOUND` (404, a receipt of another
8857
+ legal entity too), `GOODS_RECEIPT_INVALID` (400, a page out of range: `limit` is 1 to 100).
8858
+
8859
+ Needs an engine with step 3.6; against an older engine the request fails GraphQL validation
8860
+ (`GRAPHQL_VALIDATION_FAILED`). `supplierCancelled` (a line whose back-to-back link the supplier
8861
+ side ended, an ordinary backorder again) needs an engine with step 5.1. A line still waiting for
8862
+ its own purchase order (an open back-to-back link while it holds less than its open units, or a
8863
+ drop-ship line) is never listed: its purchase order serves it, not free stock.
8864
+
8865
+ #### Order links (back-to-back)
8866
+
8867
+ "Order from supplier" on a customer's order line: a line on a draft purchase order, linked to the
8868
+ order line, so the goods are allocated to that order line when they are received, before anything
8869
+ else, whatever the order's payment terms (net terms included).
8870
+
8871
+ ```ts
8872
+ // Onto the variant's preferred supplier's newest draft into the destination, else a new draft.
8873
+ const { link, purchaseOrder, createdPurchaseOrder } = await admin.purchasing.links.backToBack.create(
8874
+ { orderLineId, baseQuantity: 3, stockLocationId },
8875
+ { idempotencyKey: 'order-line-42-b2b' }
8876
+ );
8877
+ // link: { id, kind: 'back_to_back', status: 'open', orderCode: 'WT-0042', purchaseOrderCode:
8878
+ // 'PO-2026-0007', baseQuantity: 3, allocatedBaseQuantity: 0, expectedOn: null, … }
8879
+
8880
+ // Later: what the order line waits for, and what arrived ("Supplier cancelled" included).
8881
+ const links = await admin.purchasing.links.forOrder(orderId);
8882
+ // [{ status: 'allocated', allocatedBaseQuantity: 3, stockLocationId, expectedOn: '2026-10-20',
8883
+ // expectedOnConfirmed: true, … }]
8884
+ const onTheOrder = await admin.purchasing.links.forPurchaseOrder(purchaseOrder.id);
8885
+
8886
+ // A dialog's defaults: what a link may order for the line, and what create takes by default.
8887
+ const { openBaseQuantity, linkedBaseQuantity, maxBaseQuantity, suggestedBaseQuantity } =
8888
+ await admin.purchasing.links.backToBack.quantities(orderLineId);
8889
+
8890
+ // The order line no longer waits for it; the purchase order line keeps its quantity.
8891
+ await admin.purchasing.links.backToBack.remove(link.id);
8892
+ ```
8893
+
8894
+ - **Where the line goes.** The draft named (`purchaseOrderId`: a standard draft of that supplier
8895
+ and destination), else the supplier's newest draft into the destination (`stockLocationId`, else
8896
+ the tenant's location, else the store's only one), else a new draft (`createdPurchaseOrder`). The
8897
+ supplier is the variant's preferred one unless `supplierId` names another. The line buys whole
8898
+ purchase units covering `baseQuantity`. A second link of the same order line onto the same draft
8899
+ grows its line and its link instead.
8900
+ - **How much.** At most the order line's open units (not yet in a live fulfilment) that its other
8901
+ links do not claim yet and their arrived, unshipped goods do not cover. Left out, all of those
8902
+ the line plainly lacks: when goods that arrived for its earlier links may still cover it (units
8903
+ shipped before they came cannot be told apart), the default refuses and asks for a quantity (`PURCHASE_LINK_EXCEEDS_OPEN_QUANTITY`, 422,
8904
+ `details[0].maxBaseQuantity`). `backToBack.quantities(orderLineId)` answers both figures before
8905
+ anything is written (`maxBaseQuantity`, and `suggestedBaseQuantity`: 0 when the default would
8906
+ refuse), with the line's open units and what its active links claim; it needs
8907
+ `UpdatePurchaseOrder`, as `create` does, and refuses a line `create` refuses whatever the quantity
8908
+ (`PURCHASE_LINK_ORDER_LINE_NOT_FOUND`, `PURCHASE_LINK_ORDER_NOT_LINKABLE`). It needs an engine with
8909
+ step 5.4.
8910
+ - **On receipt.** The accepted units of a linked purchase order line go to its links in the order
8911
+ they were made, each up to what it claims and what its order line still has open (goods that
8912
+ arrived for it earlier and have not shipped count as covering it), and are allocated to the
8913
+ order line at the receipt location, when one of the order's stores holds that location (a
8914
+ destination changed after the link leaves the link waiting). A prepaid backordered line already holds its
8915
+ quantity: nothing is booked twice, and the units still count as its link's. Imported, cancelled
8916
+ and fully shipped lines take nothing. What no link takes is ordinary stock.
8917
+ - **Status.** `open` → `partially_allocated` → `allocated`; `released` when the order or line is
8918
+ cancelled (`order_cancelled`, `line_cancelled`) or the link removed (`removed`);
8919
+ `supplier_cancelled` (`po_line_cancelled`) when its purchase order line can no longer supply it:
8920
+ the order is cancelled or closed short, a revision removes or lowers the line, a supplier with no
8921
+ backorders cancels the rest, a return credits refused goods. An ended link stays listed.
8922
+ - **Scope.** `create` takes a line of an order of the ACTIVE store (another store's answers
8923
+ `PURCHASE_LINK_ORDER_LINE_NOT_FOUND`); `forOrder` answers nothing for another store's order;
8924
+ `orderCode` is null on `forPurchaseOrder` for an order of another store of the legal entity.
8925
+ - **Access.** `create` and `remove` need `UpdatePurchaseOrder` on every store of the legal entity,
8926
+ and `create` also `CreatePurchaseOrder` when it makes a new draft (`PURCHASE_LINK_DRAFT_DENIED`,
8927
+ 403); the reads `ReadPurchaseOrder`. Refusals: `PURCHASE_LINK_ORDER_NOT_LINKABLE` (422: not
8928
+ placed, cancelled, imported, or drop-shipped), `PURCHASE_LINK_SUPPLIER_REQUIRED` (422: no
8929
+ preferred supplier), `PURCHASE_LINK_INVALID` (400), `PURCHASE_LINK_NOT_FOUND` (404),
8930
+ `PURCHASE_LINK_ALREADY_RELEASED` (422), `PURCHASE_ORDER_NOT_EDITABLE`, `SUPPLIER_ARCHIVED`,
8931
+ `MANAGED_BY_PROVIDER` (422), and the destination's location refusals.
8932
+ - A receipt that meets a linked line while it is being fulfilled answers `PURCHASE_LINK_LINE_BUSY`
8933
+ (try again; nothing was posted, and the receipt's key is free).
8934
+
8935
+ Needs an engine with step 5.1; against an older engine the request fails GraphQL validation
8936
+ (`GRAPHQL_VALIDATION_FAILED`).
8937
+
8938
+ #### Drop-ship
8939
+
8940
+ A supplier item whose drop-ship mode is `always`, or `optional` when the store cannot ship the
8941
+ whole line from stock, makes a placed order's line a drop-ship line: the supplier sends it straight
8942
+ to the customer, and none of our stock is reserved, sold or given back for it. The platform drafts
8943
+ one drop-ship purchase order per supplier when the order is placed, with the order's delivery
8944
+ address. Staff approve and send it as any purchase order, then record what the supplier shipped:
8945
+
8946
+ ```ts
8947
+ const dropShip = await admin.purchasing.links.dropShip.get(purchaseOrderId);
8948
+ // { orderCode: 'WT-0042', address: { fullName: 'Anna de Vries', streetLine1: 'Keizersgracht 12',
8949
+ // city: 'Amsterdam', postalCode: '1015 AB', countryCode: 'NL', … },
8950
+ // lines: [{ purchaseOrderLineId, orderLineId, sku: 'TEE-M', orderedBaseQuantity: 2,
8951
+ // shippedBaseQuantity: 0, toShipBaseQuantity: 2 }] }
8952
+
8953
+ const { fulfillment, closedPurchaseOrder } = await admin.purchasing.links.dropShip.recordShipment(
8954
+ { purchaseOrderId, carrier: 'PostNL', trackingCode: '3SABC1234567' },
8955
+ { idempotencyKey: 'po-0009-shipment-1' }
8956
+ );
8957
+ // fulfillment: { state: 'Shipped', method: 'PostNL', trackingCode: … }
8958
+ // closedPurchaseOrder: true once no line can ship more.
8959
+ ```
8960
+
8961
+ - **What it does.** A fulfilment of the customer's lines with the `drop-ship` handler, moved to
8962
+ `Shipped` at once, so the customer gets the usual shipping email. Its `method` is the carrier
8963
+ (else "Shipment" in the store's language) and never names the supplier, since the shopper reads
8964
+ it. No stock movement is written for those lines. Each line's cost (`OrderLine.customFields.cost`)
8965
+ becomes the purchase order line's base-unit cost in the order's currency, at the platform rate of
8966
+ `shippedOn` (or of up to 7 days earlier), or at the `fxRate` given (`ManageInventoryCost`). The
8967
+ purchase order closes once no line can ship more; what no customer line waits for (whole
8968
+ purchase units beyond the customer's quantity) is cancelled as a close short would. Its links
8969
+ read `partially_shipped` / `shipped`.
8970
+ - **Which store.** The address, the PDF and the shipment belong to the customer order's store: from
8971
+ another store of the legal entity, `get` answers `orderCode` and `address` null, the document is
8972
+ `PURCHASE_ORDER_DOCUMENT_NOT_FOUND`, and `recordShipment` is `PURCHASE_LINK_INVALID`.
8973
+ - **Order edits.** A drop-ship line's quantity cannot be raised past what its supplier was asked
8974
+ for (`DROP_SHIP_QUANTITY_RAISED`, 422): the extra units would be neither reserved nor ordered.
8975
+ - **How much.** `lines` left out ships everything each line still has to ship. A line ships at most
8976
+ `toShipBaseQuantity`: what it still orders, at most what the customer's line still waits for
8977
+ (`DROP_SHIP_SHIPMENT_EXCEEDS_ORDERED`, 422, `details[0].maxBaseQuantity`).
8978
+ - **The key is required.** A retried shipment never fulfils twice.
8979
+ - **Elsewhere.** A drop-ship line cannot be fulfilled from a warehouse (`LINE_DROP_SHIPPED`), and a
8980
+ drop-ship purchase order cannot be received (`DROP_SHIP_PO_NOT_RECEIVABLE`), given lines by
8981
+ hand, duplicated or cancelled after a shipment.
8982
+ - **Access.** `recordShipment` needs `UpdatePurchaseOrder` and `UpdateOrder`
8983
+ (`DROP_SHIP_SHIPMENT_DENIED`, 403), and a credential not restricted to stock locations
8984
+ (`LOCATION_NOT_ALLOWED`); `get` needs `ReadPurchaseOrder`. Other refusals:
8985
+ `PURCHASE_ORDER_INVALID_TRANSITION` (not sent), `PURCHASE_LINK_INVALID` (400),
8986
+ `DROP_SHIP_FULFILLMENT_REFUSED`, `FX_RATE_UNAVAILABLE`, `FX_RATE_OVERRIDE_NOT_ALLOWED` (403),
8987
+ `MANAGED_BY_PROVIDER`.
8988
+
8989
+ Needs an engine with step 5.2; against an older engine the request fails GraphQL validation
8990
+ (`GRAPHQL_VALIDATION_FAILED`), and its drop-ship links read `open` whatever shipped.
8991
+
8992
+ #### Receipts
8993
+
8994
+ Receiving goods against the legal entity's sent purchase orders: one receipt is the goods ONE
8995
+ supplier delivered at ONE stock location, covering lines of one or more of its orders; a reversal
8996
+ takes accepted goods of a receipt back out when the receipt was wrong.
8997
+
8998
+ ```ts
8999
+ // What can be received at a location (or for one supplier or order), with the scan keys.
9000
+ const lines = await admin.purchasing.receipts.receivable({ stockLocationId });
9001
+ // [{ purchaseOrderCode: 'PO-2026-0001', purchaseOrderLineId, sku, supplierSku, supplierBarcode,
9002
+ // gtin, purchaseUnitLabel: 'box of 6', purchaseUnitFactor: 6, remainingBaseQuantity: 12,
9003
+ // toleranceBaseQuantity: 1, maxAcceptedBaseQuantity: 13, expectedOn, … }]
9004
+
9005
+ const receipt = await admin.purchasing.receipts.post(
9006
+ {
9007
+ supplierDeliveryRef: 'DN-4711',
9008
+ lines: [
9009
+ { purchaseOrderLineId, acceptedBaseQuantity: 10, rejectedBaseQuantity: 2, rejectReason: 'Damaged' },
9010
+ ],
9011
+ },
9012
+ { idempotencyKey: idempotencyKey() } // required
9013
+ );
9014
+ // { code: 'GR-2026-0001', kind: 'receipt', receivedOn: '2026-10-13', lines: [{ acceptedBaseQuantity,
9015
+ // rejectedBaseQuantity, landedUnitCost: '1300.000000', fxRate: '1.00000000', … }], … }
9016
+
9017
+ await admin.purchasing.receipts.reverse(
9018
+ { goodsReceiptId: receipt.id, reason: 'Booked on the wrong order' }, // every line, or `lines`
9019
+ { idempotencyKey: idempotencyKey() } // required
9020
+ );
9021
+
9022
+ const { items } = await admin.purchasing.receipts.list({ purchaseOrderId, limit: 25 });
9023
+ const one = await admin.purchasing.receipts.get(receipt.id);
9024
+ ```
9025
+
9026
+ - **Quantities are base (stock) units.** `acceptedBaseQuantity` enters stock at the location (a
9027
+ `purchase_receipt` movement with the receipt's number as its reference, which
9028
+ `stock.level_changed` names in `reference_document`); `rejectedBaseQuantity` moves nothing and
9029
+ stays open on the order line until a return to the supplier resolves it (a reason is required).
9030
+ - **Tolerance.** A line may accept its remainder plus the supplier's over-receipt tolerance (the
9031
+ purchasing default when the supplier has none): `maxAcceptedBaseQuantity`. Beyond it the poster
9032
+ needs `ReceiveOverTolerance` and an `overReceiptReason` on the line, else
9033
+ `RECEIPT_EXCEEDS_TOLERANCE` (422, `details` per line).
9034
+ - **What a receipt does.** In one transaction: the stock movements, the weighted average cost
9035
+ (`inventory_cost.changed`), each order line's received quantity, the cancellation of a line's
9036
+ remainder when the supplier ships no backorders, the order closing when nothing more is to come,
9037
+ the incoming stock, and `goods_receipt.posted`. A reversal takes the goods back out at each
9038
+ line's original landed cost and reopens a closed order; it is refused `STOCK_WOULD_GO_NEGATIVE`
9039
+ (422) when the goods are no longer on hand: move or recount stock first.
9040
+ - **Exchange rates.** A line priced in another currency than the purchasing one converts at the
9041
+ platform rate of `receivedOn` (or the latest of the 7 days before); none is
9042
+ `FX_RATE_UNAVAILABLE` (422, the key is released: retry once the rates synced). A holder of
9043
+ `ManageInventoryCost` may state the rate (`fxRates: [{ currencyCode, rate }]`,
9044
+ `FX_RATE_OVERRIDE_NOT_ALLOWED` otherwise).
9045
+ - **Access.** Every method needs the Purchasing plugin and purchasing switched on for the legal
9046
+ entity (`PURCHASING_NOT_ENABLED`, 422). Reads need `ReadPurchaseOrder` or
9047
+ `ReceivePurchaseOrder`; `post` and `reverse` need `ReceivePurchaseOrder` on **every** store of
9048
+ the legal entity (`SHARED_SELLER_NOT_ALLOWED`, 403), at a location of the store the caller may
9049
+ act at (`LOCATION_NOT_ALLOWED`, 403: a restricted credential's locations, a warehouse staff
9050
+ member's assigned warehouses) that the legal entity owns. An app or token that owns a variant's
9051
+ stock refuses anyone else's receipt of it (`FIELD_GROUP_LOCKED`).
9052
+ - **`post` and `reverse` require `{ idempotencyKey }`** (the type says so): a retried receipt
9053
+ answers the first one and never books the stock twice.
9054
+
9055
+ These methods need an engine with supplier returns (purchase-orders step 3.5): every receipt they
9056
+ answer selects the two returned quantities below. Against an older engine, goods receipts without
9057
+ supplier returns included, the request fails GraphQL validation (`GRAPHQL_VALIDATION_FAILED`), so
9058
+ upgrade the engine before this release of the SDK.
9059
+
9060
+ A receipt line also answers `returnedBaseQuantity` (accepted goods sent back to the supplier from
9061
+ stock, which a reversal can no longer take back) and `returnedRejectedBaseQuantity` (refused goods
9062
+ sent back), counting every return not cancelled ([Returns](#returns)).
9063
+
9064
+ #### Returns
9065
+
9066
+ Goods going back to a supplier: refused goods from a goods receipt (`origin: 'rejected'`, they
9067
+ never entered stock), or goods found faulty later (`origin: 'on_hand'`, they leave stock when the
9068
+ return ships). A return goes to ONE supplier from ONE stock location; each line names the goods
9069
+ receipt line its goods came in on, which fixes the cost they leave at.
9070
+
9071
+ ```ts
9072
+ const draft = await admin.purchasing.returns.create({
9073
+ origin: 'rejected',
9074
+ supplierReference: 'RMA-88213', // the supplier's return authorisation, optional
9075
+ lines: [{ goodsReceiptLineId, baseQuantity: 2, outcome: 'credit_expected' }],
9076
+ });
9077
+ // { code: 'RS-2026-0001', status: 'draft', lines: [{ reason: 'Damaged' /* the receipt's */, … }] }
9078
+
9079
+ const shipped = await admin.purchasing.returns.ship(
9080
+ draft.id,
9081
+ { carrier: 'PostNL', trackingCode: '3S…' }, // notifySupplier: false skips the email
9082
+ { idempotencyKey: idempotencyKey() } // required
9083
+ );
9084
+ // { status: 'shipped', shippedAt, documentIssuedAt, … } and the supplier gets the PDF by email
9085
+
9086
+ await admin.purchasing.returns.resolveLine({
9087
+ supplierReturnLineId: shipped.lines[0].id,
9088
+ resolution: 'credited', // or 'replaced', 'written_off'
9089
+ creditAmount: 2600, // informational, minor units of the line's currencyCode
9090
+ resolutionReference: 'CN-77',
9091
+ });
9092
+ // the last line resolved: { status: 'resolved', resolvedAt, … }
9093
+
9094
+ await admin.purchasing.returns.update(id, { trackingCode: '3S…' }); // a draft: lines too
9095
+ await admin.purchasing.returns.cancel(id, { reason: 'Raised twice' }); // a draft only
9096
+ const { url } = await admin.purchasing.returns.document(shipped.id); // five minutes
9097
+ const { items } = await admin.purchasing.returns.list({ status: 'shipped', limit: 25 });
9098
+ ```
9099
+
9100
+ - **Lifecycle.** `draft → shipped → resolved`, and `draft → cancelled` (the meta catalog serves
9101
+ it as `transitions.supplierReturn`). A draft's lines, location, notes and references can change;
9102
+ a shipped return keeps what left, and changes only its carrier, tracking code and supplier
9103
+ reference. A shipped return is never cancelled (`SUPPLIER_RETURN_INVALID_TRANSITION`, 422).
9104
+ - **What a line may return.** At most what its receipt line still holds: its refused units for a
9105
+ return of refused goods, its accepted units less reversals for goods from stock, in both cases
9106
+ less the other returns not cancelled (a draft holds its quantity). More is
9107
+ `SUPPLIER_RETURN_EXCEEDS_RETURNABLE` (422, `details` per line with `returnableBaseQuantity`).
9108
+ - **What a ship does.** In one transaction: goods from stock leave the location (a
9109
+ `supplier_return` movement with the return's number as its reference) at their receipt lines'
9110
+ landed cost, which the weighted average takes out (`inventory_cost.changed`); refused goods move
9111
+ nothing. On the purchase order lines, refused goods credited or written off are cancelled (an
9112
+ order with nothing more to come closes), refused goods to be replaced stay open, and goods from
9113
+ stock to be replaced are open again (`returnedForReplacementBaseQuantity`; a closed order
9114
+ reopens). Then `supplier_return.status_changed`, the PDF (in the supplier's language), and after
9115
+ the commit the "Supplier Return Sent" email unless `notifySupplier: false`. Below zero is
9116
+ `STOCK_WOULD_GO_NEGATIVE` (422): move or recount stock first.
9117
+ - **Resolutions.** A line expecting a credit is `credited` or `written_off`; one expecting a
9118
+ replacement is `replaced`, `credited` or `written_off` (the last two cancel what the replacement
9119
+ kept open on the order line); a write-off is `written_off`. A line is resolved once.
9120
+ - **Access.** Every method needs the Purchasing plugin and purchasing switched on for the legal
9121
+ entity (`PURCHASING_NOT_ENABLED`, 422). Reads and `document` need `ReadPurchaseOrder` or
9122
+ `ReceivePurchaseOrder`; `create` needs `CreatePurchaseOrder`, `update`, `resolveLine` and
9123
+ `cancel` `UpdatePurchaseOrder`, and `ship` `ReceivePurchaseOrder`, each on **every** store of the
9124
+ legal entity (`SHARED_SELLER_NOT_ALLOWED`, 403), at a location the caller may act at
9125
+ (`LOCATION_NOT_ALLOWED`, 403). An app or token that owns a variant's stock refuses anyone else's
9126
+ ship of it from stock (`FIELD_GROUP_LOCKED`).
9127
+ - **The email.** It goes to the supplier's order contact, else its order email; before anything is
9128
+ written the ship is refused `SUPPLIER_HAS_NO_ORDER_EMAIL` or `SUPPLIER_EMAIL_UNDELIVERABLE` (the
9129
+ store's "Supplier Return Sent" email off, without a recipient, or no email delivery), and
9130
+ `PRIVATE_STORAGE_UNCONFIGURED` without a private document bucket (all 422, `details[0]` with
9131
+ `field: 'notifySupplier'` for the first two). With `notifySupplier: false` a deployment without
9132
+ the bucket ships with no document (`document` is then `SUPPLIER_RETURN_DOCUMENT_NOT_FOUND`).
9133
+ - **`ship` requires `{ idempotencyKey }`** (the type says so): a retried ship answers the first
9134
+ one and never takes the goods out of stock twice. The other writes take an optional key.
9135
+
9136
+ These methods need an engine with supplier returns (purchase-orders phase 3, step 3.5). Against an
9137
+ older engine the request fails GraphQL validation (`GRAPHQL_VALIDATION_FAILED`).
9138
+
9139
+ ##### Landed charges
9140
+
9141
+ Freight, duty and fees paid to get a receipt's goods in, spread over its lines and taken into their
9142
+ weighted average cost.
9143
+
9144
+ ```ts
9145
+ const receipt = await admin.purchasing.receipts.charges.add(
9146
+ {
9147
+ goodsReceiptId,
9148
+ kind: 'freight', // freight | duty | fee | other
9149
+ amount: 12500, // whole minor units of currencyCode
9150
+ currencyCode: 'USD', // the purchasing currency when left out
9151
+ allocation: 'value', // value (default) | quantity | weight
9152
+ incurredOn: '2026-10-12', // today in the legal entity's zone when left out
9153
+ payeeName: 'DHL Freight', // or payeeSupplierId
9154
+ },
9155
+ { idempotencyKey: idempotencyKey() } // optional
9156
+ );
9157
+ // { …receipt, lines: [{ landedUnitCost: '1150.000000', … }],
9158
+ // charges: [{ id, kind: 'freight', amountEntityCurrency: '11500.000000', fxRate: '0.92000000',
9159
+ // allocations: [{ goodsReceiptLineId, baseQuantity: 10, amount: '11500.000000',
9160
+ // unitCostDelta: '1150.000000' }], removedAt: null, … }] }
9161
+
9162
+ await admin.purchasing.receipts.charges.remove({ chargeId, reason: 'Billed twice' });
9163
+ const { charges } = (await admin.purchasing.receipts.get(goodsReceiptId)) ?? { charges: [] };
9164
+ ```
9165
+
9166
+ - **Allocation.** Over the units each line still holds (accepted less reversed): by value (each
9167
+ line's quantity × unit cost in the purchasing currency), by quantity, or by weight (package grams
9168
+ × quantity; a variant without a weight refuses the charge, `LANDED_CHARGE_WEIGHT_MISSING`, 422,
9169
+ `details` per variant). One rounding per line, the remainder on the largest line.
9170
+ - **Cost.** The share on units still on hand revalues the weighted average; the share on units
9171
+ sold since is recorded as `unabsorbedAmount` on the cost entry (`inventory_cost.changed`, cause
9172
+ `landed_charge`), never pushed into any cost of goods sold. Each line's `landedUnitCost` rises by
9173
+ its share per unit, so a later reversal or return takes the units out at the landed cost.
9174
+ - **Removal** takes the share still on the lines' units back out of the average (units sold since
9175
+ keep theirs: a negative `unabsorbedAmount`), lowers each line's landed cost by what the charge
9176
+ added, and keeps the charge with `removedAt` and the reason. A charge is never deleted.
9177
+ - **Exchange rates.** A charge in another currency converts at the platform rate of `incurredOn`
9178
+ (or the latest of the 7 days before); none is `FX_RATE_UNAVAILABLE` (422: retry once the rates
9179
+ synced, or state `fxRate`).
9180
+ - **Access.** Both writes need `ManageInventoryCost` on **every** store of the legal entity
9181
+ (`SHARED_SELLER_NOT_ALLOWED`, 403). Refusals: `GOODS_RECEIPT_NOT_FOUND`, `LANDED_CHARGE_NOT_FOUND`
9182
+ (404), `LANDED_CHARGE_INVALID` (400: a value, a reversal named, a day ahead),
9183
+ `LANDED_CHARGE_NOTHING_TO_ALLOCATE` (422: every unit reversed, or no value to allocate by),
9184
+ `LANDED_CHARGE_ALREADY_REMOVED` (422), `SUPPLIER_NOT_FOUND` (404, the payee),
9185
+ `MANAGED_BY_PROVIDER` (422).
9186
+
9187
+ These methods need an engine with landed charges (purchase-orders step 3.4); against an older
9188
+ engine the request, `receipts.get` included, fails GraphQL validation (`GRAPHQL_VALIDATION_FAILED`).
9189
+
7712
9190
  ### Errors
7713
9191
 
7714
9192
  One hierarchy, all re-exported from this package so you need no direct dependency on