@sixtyninecommerce/admin 0.11.0 → 0.13.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +1509 -28
- package/dist/auth/index.d.ts +7 -1
- package/dist/auth/index.js +8 -1
- package/dist/catalog/documents.d.ts +142 -0
- package/dist/catalog/documents.js +114 -0
- package/dist/catalog/index.d.ts +48 -7
- package/dist/catalog/index.js +6 -1
- package/dist/catalog/metadata.d.ts +244 -0
- package/dist/catalog/metadata.js +220 -0
- package/dist/catalog/properties.d.ts +38 -0
- package/dist/catalog/properties.js +52 -1
- package/dist/catalog/recommendations.d.ts +33 -6
- package/dist/catalog/recommendations.js +94 -2
- package/dist/catalog/reviews.d.ts +17 -4
- package/dist/catalog/reviews.js +7 -0
- package/dist/catalog/variants.d.ts +60 -0
- package/dist/catalog/variants.js +39 -0
- package/dist/catalog/visibility.d.ts +18 -2
- package/dist/client.d.ts +29 -3
- package/dist/client.js +19 -8
- package/dist/content/assets.d.ts +3 -4
- package/dist/content/forms.d.ts +332 -1
- package/dist/content/forms.js +140 -4
- package/dist/content/items.d.ts +31 -8
- package/dist/content/items.js +1 -1
- package/dist/content/menus.d.ts +254 -0
- package/dist/content/menus.js +70 -0
- package/dist/content/strings.d.ts +137 -0
- package/dist/content/strings.js +82 -0
- package/dist/customers/delivery-terms.d.ts +44 -0
- package/dist/customers/delivery-terms.js +32 -0
- package/dist/customers/groups.d.ts +16 -5
- package/dist/customers/groups.js +6 -2
- package/dist/customers/index.d.ts +15 -0
- package/dist/customers/index.js +4 -0
- package/dist/documents/emails.d.ts +154 -0
- package/dist/documents/emails.js +220 -0
- package/dist/documents/index.d.ts +13 -0
- package/dist/documents/index.js +2 -0
- package/dist/fulfillments/create.d.ts +1 -1
- package/dist/fulfillments/create.js +5 -2
- package/dist/fulfillments/index.d.ts +119 -1
- package/dist/fulfillments/index.js +124 -4
- package/dist/fulfillments/warehouse.d.ts +29 -17
- package/dist/fulfillments/warehouse.js +32 -1
- package/dist/index.d.ts +43 -23
- package/dist/index.js +11 -5
- package/dist/integrations/exact-online-types.d.ts +542 -0
- package/dist/integrations/exact-online.d.ts +541 -5
- package/dist/integrations/exact-online.js +714 -1
- package/dist/integrations/index.d.ts +23 -8
- package/dist/integrations/index.js +9 -4
- package/dist/integrations/pipeline-discoveries.d.ts +65 -0
- package/dist/integrations/pipeline-discoveries.js +65 -0
- package/dist/integrations/pipeline-runs.d.ts +119 -0
- package/dist/integrations/pipeline-runs.js +87 -0
- package/dist/integrations/pipelines.d.ts +73 -2
- package/dist/integrations/pipelines.js +36 -1
- package/dist/integrations/price-import.d.ts +223 -0
- package/dist/integrations/price-import.js +124 -0
- package/dist/integrations/types.d.ts +221 -3
- package/dist/integrations/types.js +4 -3
- package/dist/meta/index.d.ts +10 -0
- package/dist/metafields/index.d.ts +8 -1
- package/dist/metafields/index.js +10 -0
- package/dist/orders/cancel-with-money.d.ts +10 -0
- package/dist/orders/cancel-with-money.js +2 -0
- package/dist/orders/commit-edit.d.ts +16 -0
- package/dist/orders/commit-edit.js +14 -1
- package/dist/orders/deposit-policy.d.ts +3 -1
- package/dist/orders/index.d.ts +22 -11
- package/dist/orders/index.js +5 -2
- package/dist/orders/list.d.ts +26 -5
- package/dist/orders/list.js +123 -13
- package/dist/orders/manual-discounts.d.ts +13 -5
- package/dist/orders/money-view.d.ts +149 -0
- package/dist/orders/money-view.js +99 -0
- package/dist/orders/plan-edit.d.ts +34 -2
- package/dist/orders/plan-edit.js +110 -12
- package/dist/orders/surcharges.d.ts +12 -0
- package/dist/orders/surcharges.js +34 -9
- package/dist/payments/account-credit.d.ts +17 -1
- package/dist/payments/account-credit.js +25 -2
- package/dist/payments/credit-noted-fields.d.ts +8 -1
- package/dist/payments/credit-noted-fields.js +34 -2
- package/dist/payments/customer-consent.d.ts +30 -0
- package/dist/payments/customer-consent.js +49 -0
- package/dist/payments/index.d.ts +89 -14
- package/dist/payments/index.js +22 -2
- package/dist/payments/requests.d.ts +6 -3
- package/dist/payments/requests.js +1 -0
- package/dist/pickups/index.d.ts +5 -3
- package/dist/pickups/index.js +5 -2
- package/dist/platform/email-gateways.d.ts +165 -0
- package/dist/platform/email-gateways.js +78 -0
- package/dist/platform/tax.d.ts +11 -3
- package/dist/platform/tax.js +30 -0
- package/dist/pos/exchanges.d.ts +6 -1
- package/dist/pos/index.d.ts +229 -10
- package/dist/pos/index.js +113 -4
- package/dist/pos/offline-sales.d.ts +44 -3
- package/dist/pos/offline-sales.js +57 -3
- package/dist/pos/returns.d.ts +28 -1
- package/dist/pos/returns.js +36 -6
- package/dist/pricing/price-lists.d.ts +3 -1
- package/dist/purchasing/alerts.d.ts +19 -4
- package/dist/purchasing/alerts.js +29 -3
- package/dist/purchasing/backorders.d.ts +61 -0
- package/dist/purchasing/backorders.js +92 -0
- package/dist/purchasing/index.d.ts +21 -3
- package/dist/purchasing/index.js +8 -0
- package/dist/purchasing/links.d.ts +263 -0
- package/dist/purchasing/links.js +192 -0
- package/dist/purchasing/purchase-orders.d.ts +4 -1
- package/dist/purchasing/receipts.d.ts +396 -0
- package/dist/purchasing/receipts.js +287 -0
- package/dist/purchasing/returns.d.ts +273 -0
- package/dist/purchasing/returns.js +229 -0
- package/dist/quotes/index.d.ts +20 -3
- package/dist/returns/index.d.ts +62 -1
- package/dist/returns/index.js +61 -0
- package/dist/seo/config.d.ts +128 -0
- package/dist/seo/config.js +95 -0
- package/dist/seo/redirects.d.ts +125 -0
- package/dist/seo/redirects.js +60 -1
- package/dist/seo/settings.d.ts +147 -0
- package/dist/seo/settings.js +209 -0
- package/dist/shipping/index.d.ts +6 -0
- package/dist/shipping/index.js +2 -0
- package/dist/shipping/methods.d.ts +8 -1
- package/dist/shipping/myparcel.d.ts +415 -1
- package/dist/shipping/myparcel.js +291 -3
- package/dist/shipping/rate-tables.d.ts +149 -4
- package/dist/shipping/rate-tables.js +229 -23
- package/dist/shipping/ship-to-countries.d.ts +75 -0
- package/dist/shipping/ship-to-countries.js +47 -0
- package/dist/stock/by-location.d.ts +86 -0
- package/dist/stock/by-location.js +94 -0
- package/dist/stock/index.d.ts +8 -1
- package/dist/stock/index.js +4 -0
- package/dist/stock/settings.d.ts +106 -0
- package/dist/stock/settings.js +103 -0
- package/dist/stock/transfers.d.ts +171 -10
- package/dist/stock/transfers.js +82 -4
- package/dist/stored-value/index.d.ts +98 -7
- package/dist/stored-value/index.js +71 -4
- package/dist/tax/display-policy.d.ts +105 -0
- package/dist/tax/display-policy.js +84 -0
- package/dist/tax/index.d.ts +3 -0
- package/dist/tax/index.js +2 -0
- package/dist/tenant/email-templates.d.ts +222 -1
- package/dist/tenant/email-templates.js +227 -10
- 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)
|
|
1759
|
-
|
|
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
|
|
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
|
|
1800
|
-
|
|
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
|
-
- **
|
|
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.
|
|
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
|
|
@@ -3368,7 +3953,7 @@ const page = await admin.customers.groups.list({
|
|
|
3368
3953
|
include: ['memberCount'],
|
|
3369
3954
|
take: 50,
|
|
3370
3955
|
});
|
|
3371
|
-
page.items[0]; // { id, name, createdAt, updatedAt, canViewIncomingStock, canBackorder, memberCount }
|
|
3956
|
+
page.items[0]; // { id, name, createdAt, updatedAt, canViewIncomingStock, canBackorder, viewOnly, memberCount }
|
|
3372
3957
|
|
|
3373
3958
|
const vip = await admin.customers.groups.create({ name: 'VIP', canBackorder: true });
|
|
3374
3959
|
await admin.customers.groups.add(vip.id, [customerId]);
|
|
@@ -3396,7 +3981,7 @@ await admin.customers.groups.b2b.set([...trade.map((group) => group.id), wholesa
|
|
|
3396
3981
|
group holds. Anything else, and a `take` outside 1 to 1000, is refused
|
|
3397
3982
|
`INVALID_CUSTOMER_GROUP_LIST_OPTIONS` (`ValidationError`) before anything is sent. To search by
|
|
3398
3983
|
text, take ids from `search.index('customer_groups', …)` and list them with `filter: { ids }`.
|
|
3399
|
-
- **`get`** answers the group, its
|
|
3984
|
+
- **`get`** answers the group, its three flags and whose it is (`catalogueOwnership`), or `null` for a
|
|
3400
3985
|
group the store does not hold. With `members` it adds one page of the group's members in this
|
|
3401
3986
|
store and their count, in one fixed order (by id, so pages never overlap);
|
|
3402
3987
|
`members.filter.ids` narrows the page to those customers, so the answer says which of them are
|
|
@@ -3410,8 +3995,11 @@ await admin.customers.groups.b2b.set([...trade.map((group) => group.id), wholesa
|
|
|
3410
3995
|
small the page. Ask for `memberCount` only where you show it, and expect a large group to be
|
|
3411
3996
|
slow.
|
|
3412
3997
|
- **`create` and `update`** send only what you name: a flag left out is not touched. The flags are
|
|
3413
|
-
`canViewIncomingStock` (members see incoming stock on the storefront)
|
|
3414
|
-
may order more than is in stock, within the store's backorder policy)
|
|
3998
|
+
`canViewIncomingStock` (members see incoming stock on the storefront), `canBackorder` (members
|
|
3999
|
+
may order more than is in stock, within the store's backorder policy) and `viewOnly` (signed-in
|
|
4000
|
+
members see the store's retail prices incl. VAT and fill a basket, but checkout, payments and
|
|
4001
|
+
quote requests are refused `VIEW_ONLY_ACCOUNT`; staff can still order for them). `viewOnly` needs
|
|
4002
|
+
a platform release that has it: an older engine refuses the whole document.
|
|
3415
4003
|
- **A group is one row for every store that holds it.** `update`, `delete` and `deleteMany` are
|
|
3416
4004
|
refused `MANAGED_BY_ORGANIZATION` (`BusinessRuleError`) for a group the store holds from its
|
|
3417
4005
|
organization, and `SHARED_CUSTOMER_GROUP_NOT_ALLOWED` (`PermissionDeniedError`) for one other
|
|
@@ -3459,6 +4047,16 @@ refuse is found here. Nothing is saved, no event is published and no payment pro
|
|
|
3459
4047
|
A store's tax provider app may be asked, as it is for any re-price, so call it after the merchant
|
|
3460
4048
|
stops typing rather than on every keystroke. Needs `UpdateOrder`.
|
|
3461
4049
|
|
|
4050
|
+
Print the edit's money from `preview.moneyView` (the order after the edit) and
|
|
4051
|
+
`preview.moneyViewBefore`, the same rows `orders.moneyView` answers and the order page prints once
|
|
4052
|
+
the edit is saved: each promotion, fee, shipping line, VAT rate and staff rounding its own row,
|
|
4053
|
+
adding up exactly. Pass `{ moneyBasis: 'GROSS' | 'NET' | 'CUSTOMER' }` for another basis than the
|
|
4054
|
+
store's (a switch of basis plans again: the rows are read inside the run). The difference is
|
|
4055
|
+
`preview.totalDifference`, the billable totals' difference; `totalWithTax - totalWithTaxBefore`
|
|
4056
|
+
leaves a staff rounding out. The view's `balance` is the payments before the plan's `refund`. An
|
|
4057
|
+
engine before these fields answers them `null`, and `commitEdit`'s plan never carries them (read
|
|
4058
|
+
the saved order's `moneyView`).
|
|
4059
|
+
|
|
3462
4060
|
Read `blockers` first. They are an answer, not an error, so that a head can show every reason
|
|
3463
4061
|
beside the steps:
|
|
3464
4062
|
|
|
@@ -4426,12 +5024,28 @@ refusal is thrown as a coded error, including the ones the Admin API answers as
|
|
|
4426
5024
|
| `PAYMENT_SETTLE_DECLINED` / `PAYMENT_CANCEL_DECLINED` (422) | The provider said no; the message is its reason |
|
|
4427
5025
|
| `PAYMENT_STATE_TRANSITION_ERROR` (422) | The payment is not `Authorized` (settled, cancelled or declined already) |
|
|
4428
5026
|
| `ORDER_CANCELLED_PAYMENT_CONFLICT` (409) | The order is cancelled, and a cancelled order is never paid: cancel the payment instead |
|
|
5027
|
+
| `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
5028
|
| `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
5029
|
| `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
5030
|
| `ENTITY_NOT_FOUND` (404) | No payment of an order of this store has that id |
|
|
4432
5031
|
|
|
4433
5032
|
A settled payment is never voided: its money was taken, so it goes back with `payments.refund`.
|
|
4434
5033
|
|
|
5034
|
+
On an order whose credit notes lowered what it bills, a capture takes only what the order still
|
|
5035
|
+
bills without that payment. A promise whose money moves by hand (bank transfer, invoice, net terms)
|
|
5036
|
+
is recorded at that amount: `settle` answers the payment with the lower `amount`, and the rest of
|
|
5037
|
+
the promise is dropped. A payment its provider can only capture in full is refused
|
|
5038
|
+
`PAYMENT_CAPTURE_EXCEEDS_BILLED`. Ask first, to show staff what the press will do:
|
|
5039
|
+
|
|
5040
|
+
```ts
|
|
5041
|
+
const preview = await admin.payments.settlePreview(paymentId); // ReadOrder; writes nothing
|
|
5042
|
+
// { authorized: 208093, records: 147414, creditNoted: 60679, creditNotes: ['CN-00012'], refusal: null }
|
|
5043
|
+
```
|
|
5044
|
+
|
|
5045
|
+
`refusal` carries the `{ code, message }` that `settle` would throw now. The capture still judges
|
|
5046
|
+
again (and the provider may still decline it). An engine without `settlePreview` answers a GraphQL
|
|
5047
|
+
validation error: treat it as "no preview" and let `settle` decide.
|
|
5048
|
+
|
|
4435
5049
|
### Refunding an invoiced order
|
|
4436
5050
|
|
|
4437
5051
|
A refund of an order with a live invoice needs a credit note saying what the money was for, so
|
|
@@ -4477,13 +5091,31 @@ covered`: a refund that went back with its credit note is owed by nobody, and an
|
|
|
4477
5091
|
a credit note owes only what is left. Show `owed` as what the customer still owes.
|
|
4478
5092
|
|
|
4479
5093
|
`outstanding` keeps its meaning: what the payments are short of the order's total, the figure
|
|
4480
|
-
`payments.record` caps a payment at
|
|
5094
|
+
`payments.record` caps a payment at. `overpaid` stays measured against
|
|
4481
5095
|
`total`, so money a credit note gave back without a refund never reads as an overpayment. Only the
|
|
4482
5096
|
retail invoices count; an order billed on the accounts-receivable ledger owes what its account
|
|
4483
5097
|
says (both fields read 0 / `outstanding` there). The cancel-with-money preview and answer carry
|
|
4484
5098
|
both on their `position` too. Against an engine that does not serve them yet, these reads send
|
|
4485
5099
|
their document again without them, so both are absent: read `owed ?? outstanding`.
|
|
4486
5100
|
|
|
5101
|
+
An order paid down to what its invoice bills net of its credit notes is PAID. `payable` is what
|
|
5102
|
+
its settled money must reach: `total` less what a live invoice's credit notes took off.
|
|
5103
|
+
`orders.settle`, the staff move into `PaymentSettled` and the platform's own settlement measure it,
|
|
5104
|
+
and a payment that pays the order down to it settles the order (a held one waits for
|
|
5105
|
+
`orders.settle`, which is how staff accept it):
|
|
5106
|
+
|
|
5107
|
+
```ts
|
|
5108
|
+
// 125.14 parked, 20.00 credited without a refund, 105.14 paid:
|
|
5109
|
+
// { total: 12514, creditNoted: 2000, owed: 0, payable: 10514, settled: 10514, outstanding: 2000, … }
|
|
5110
|
+
await admin.orders.settle(orderId); // settles and places it
|
|
5111
|
+
```
|
|
5112
|
+
|
|
5113
|
+
An order whose only invoice was cancelled with nothing in its place reads `creditNoted: total`
|
|
5114
|
+
and `owed: 0` but `payable: total`: it takes no more money and counts as paid only once it is
|
|
5115
|
+
invoiced again or cancelled. `order.settlement_completed` then carries `credit_noted`, so
|
|
5116
|
+
`total_settled + credit_noted >= billable_total_with_tax`. An engine without `payable` gets the
|
|
5117
|
+
read again without it: read `payable ?? total`.
|
|
5118
|
+
|
|
4487
5119
|
### Accounts receivable: credit, AR documents and AR payments
|
|
4488
5120
|
|
|
4489
5121
|
A store that keeps its own accounts-receivable ledger (B2B credit switched on) invoices customers
|
|
@@ -4884,15 +5516,31 @@ language answers each call `GRAPHQL_VALIDATION_FAILED`.
|
|
|
4884
5516
|
|
|
4885
5517
|
**What a cashier may do without a manager** (engine sprint 18): each staff member carries a set of
|
|
4886
5518
|
grants (`POS_STAFF_GRANTS`: `manual_price`, `discount`, `custom_item_over_limit`, `refund`,
|
|
4887
|
-
`discard_held_sale`, `paid_out`, `no_sale`, `safe_drop`, `float_adjust`
|
|
4888
|
-
|
|
4889
|
-
|
|
4890
|
-
|
|
5519
|
+
`discard_held_sale`, `paid_out`, `no_sale`, `safe_drop`, `float_adjust`, `return_items`,
|
|
5520
|
+
`void_sale`, `no_receipt_return`, `online_return`, `manage_card_terminal`, `discount_over_limit`,
|
|
5521
|
+
`view_reports`). A cashier holding one does that action on the till on their own sign-in; without
|
|
5522
|
+
it a manager approves, as before. A manager may do all of them, whatever is stored. Closing a
|
|
5523
|
+
drawer above the store's variance threshold, an approval for a colleague and unpairing a till stay
|
|
5524
|
+
a manager's. The engine judges every action.
|
|
5525
|
+
|
|
5526
|
+
`view_reports` (engine POS programme PR 15) refuses no action: it decides what the till's day
|
|
5527
|
+
report (`posTillDayReport`, the till's Reports tab) shows. Without it the engine answers the day's
|
|
5528
|
+
counts only, every money figure `null` with `withheldReason: view_reports`, until a manager
|
|
5529
|
+
approves showing them; a manager holds it by role. `view_reports` governs the day report only. A
|
|
5530
|
+
store that wants cashiers to see no money at all sets `blindCount`: the drawer's X report, its
|
|
5531
|
+
movement list and the close follow the store's blind count alone.
|
|
5532
|
+
|
|
5533
|
+
`discount_over_limit` is EXPLICIT (`POS_EXPLICIT_STAFF_GRANTS`): it lets a till discount the
|
|
5534
|
+
person authorises (their own, or one they approve as a manager) go past the store's discount limit,
|
|
5535
|
+
and a manager holds it only when it is stored, like a cashier. It opens no door: a cashier still
|
|
5536
|
+
needs `discount` to give a discount at all. `knownGrants()` answers the grants the engine can store
|
|
5537
|
+
(its own `PosStaffGrant` values), so a caller deployed ahead of the engine offers only those.
|
|
4891
5538
|
|
|
4892
5539
|
```ts
|
|
4893
5540
|
await admin.pos.staff.setGrants(alex.id, ['refund', 'paid_out']); // replaces the whole set
|
|
4894
5541
|
const { grants } = (await admin.pos.staff.grants(alex.id)) ?? { grants: [] };
|
|
4895
5542
|
const everyone = await admin.pos.staff.listGrants(); // id, role, grants per member
|
|
5543
|
+
const offered = await admin.pos.staff.knownGrants(); // the engine's own values
|
|
4896
5544
|
```
|
|
4897
5545
|
|
|
4898
5546
|
`setGrants` sends the list as given; the engine stores it deduplicated in its own order and writes
|
|
@@ -5254,6 +5902,24 @@ is sent (`ValidationError`, `POS_DEVICE_KIND_INVALID`, the engine's own code for
|
|
|
5254
5902
|
`lock` and `unlock` answer `device.kind`; `list` and `revoke` do not, where a device is a till
|
|
5255
5903
|
unless the fleet says otherwise. Creating a device needs a human session (`pair`'s rule).
|
|
5256
5904
|
|
|
5905
|
+
**A code for a store reviewer** works 30 days instead of 10 minutes (`validity`, engine
|
|
5906
|
+
`emre/pos-reviewer-pairing`), for Apple's or Google's reviewer, who installs the app days after it
|
|
5907
|
+
was submitted:
|
|
5908
|
+
|
|
5909
|
+
```ts
|
|
5910
|
+
const { code, expiresAt } = await admin.pos.devices.pair(reviewTill.id, { validity: 'APP_REVIEW' });
|
|
5911
|
+
```
|
|
5912
|
+
|
|
5913
|
+
It is still claimed once, by one tablet, and a new code of either validity replaces the device's
|
|
5914
|
+
unclaimed one, so issue one per reviewer device and a fresh one per submission. Only a platform
|
|
5915
|
+
SuperAdmin may issue one (it is the demo store's tool): anyone else is refused
|
|
5916
|
+
`POS_PAIRING_REVIEW_CODE_NOT_ALLOWED` (`PermissionDeniedError`). `pair` without
|
|
5917
|
+
`validity` is a `STANDARD` code on any engine; a call that names one (`POS_PAIRING_CODE_VALIDITIES`:
|
|
5918
|
+
`STANDARD`, `APP_REVIEW`) needs an engine that has it (an older one refuses the document, and the
|
|
5919
|
+
SDK raises a 400 `ValidationError`, `BAD_REQUEST`), and any other value is refused before anything
|
|
5920
|
+
is sent (`ValidationError`, `POS_PAIRING_CODE_VALIDITY_INVALID`). The location rule and the till
|
|
5921
|
+
ceiling apply as to every code.
|
|
5922
|
+
|
|
5257
5923
|
**Setting a till up** reads it and assigns where it sells, which drawer it opens and which card
|
|
5258
5924
|
terminal it charges:
|
|
5259
5925
|
|
|
@@ -5289,6 +5955,14 @@ location `POS_LOCATION_MISMATCH` and a retired one `POS_REGISTER_INACTIVE` (both
|
|
|
5289
5955
|
or not one word (`POS_MOLLIE_TERMINAL_ID_INVALID`, `ValidationError`); `clearTerminal` takes the
|
|
5290
5956
|
terminal off.
|
|
5291
5957
|
|
|
5958
|
+
From engine `pos-device-move-refuses-open-drawer`, `assignLocation` and `assignRegister` refuse to
|
|
5959
|
+
change a device's location or register while it has an open drawer: `POS_DEVICE_SESSION_OPEN`
|
|
5960
|
+
(`BusinessRuleError`, the engine's sentence naming the drawer's register), and nothing changes. The
|
|
5961
|
+
drawer stays on the register it opened on, so a move would sell the new location's stock into the
|
|
5962
|
+
old place's cash. Close the drawer first (at the till, or `sessions.close` /
|
|
5963
|
+
`sessions.closeUncounted`; `sessions.openForDevice(deviceId)` reads it), then move the device. A
|
|
5964
|
+
call naming the location and register the device already holds still answers the device.
|
|
5965
|
+
|
|
5292
5966
|
An older engine keeps a moved device's register, stores any terminal text and fails the
|
|
5293
5967
|
`registerId` move and `clearTerminal` at validation. The engine does not ask Mollie about a
|
|
5294
5968
|
terminal id: take it from `terminals.list()`. A display takes no terminal
|
|
@@ -5331,16 +6005,31 @@ staff session reads the switch.
|
|
|
5331
6005
|
**The engine records each offline sale once**, at the prices the till charged, into the drawer it
|
|
5332
6006
|
was rung in, when the till is back; the till's replay is its own door and no SDK method sends it.
|
|
5333
6007
|
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`
|
|
6008
|
+
`cashier_changed`, `policy_off`, `over_cap`, `sold_at_suspect`, `cashier_unverified`: the cashier
|
|
6009
|
+
the till names was not signed in, or had not unlocked it offline, on that till when it sold;
|
|
6010
|
+
`item_gone_stand_in` and `stock_short_stand_in`: a gone item or a shortfall recorded on a stand-in
|
|
6011
|
+
line; `total_corrected`: the order's total corrected to the cash taken; `rounding_as_rung`: settled
|
|
6012
|
+
with the rounding the till applied); one it cannot record is `refused`
|
|
5335
6013
|
(`item_gone`, `drawer_closed`, `currency_changed`, `prices_exclude_tax`, `out_of_stock`,
|
|
5336
|
-
`gift_card`, `total_mismatch`, `rounding_mismatch`
|
|
6014
|
+
`gift_card`, `total_mismatch`, `rounding_mismatch`, `price_over_limit`: a till price further below the
|
|
6015
|
+
store's than its discount limit, which a manager who may go past the limit can record again) and only
|
|
6016
|
+
its row exists. More may come: show an unknown
|
|
5337
6017
|
status or reason as it is (`POS_OFFLINE_SALE_REASONS` lists today's). **`offlineSales.list`** pages
|
|
5338
6018
|
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
|
|
6019
|
+
100 (50 by default); a location-restricted caller sees its own tills only; each row names who
|
|
6020
|
+
recorded it again (`recordedAgainAt`, `recordedAgainByUserName`; an engine before POS polish PR 6
|
|
6021
|
+
refuses those fields once, and the list then asks without them). **`resolve`** marks one
|
|
5340
6022
|
handled with what was done (1 to 500 characters); nothing is recorded again, so fix the order, the
|
|
5341
6023
|
stock or the drawer first. The same note again answers it unchanged; refused
|
|
5342
6024
|
`POS_OFFLINE_NOTE_INVALID` (`ValidationError`), `POS_OFFLINE_SALE_NOT_FOUND` (`NotFoundError`, a
|
|
5343
6025
|
`recorded` sale included) and `POS_OFFLINE_SALE_RESOLVED` (`BusinessRuleError`, another note).
|
|
6026
|
+
**`recordAgain`** (an engine with POS polish PR 6) records a `refused` sale again the way the till's
|
|
6027
|
+
replay does, into its own drawer while open, else the open drawer `registerSessionId` names, and
|
|
6028
|
+
answers it with `recordedAgainAt` and `recordedAgainByUserName`; refused again it stays `refused`
|
|
6029
|
+
with its new reasons. It needs `CreateOrder` too; refused `POS_OFFLINE_SALE_NOT_FOUND`
|
|
6030
|
+
(`NotFoundError`), `POS_OFFLINE_SALE_DRAWER_INVALID` (`ValidationError`,
|
|
6031
|
+
`extensions.registerSessionId`) and `LOCATION_NOT_ALLOWED` (`PermissionDeniedError`: the drawer
|
|
6032
|
+
stands at a location a restricted caller may not act at).
|
|
5344
6033
|
**`devices.offlineSalesToReview`** is the fleet's count per device, its own document: `fleet` never
|
|
5345
6034
|
selects it and keeps answering an engine before 45. The queue and the counts need `UpdateSettings`
|
|
5346
6035
|
or `ManagePosDevices`. Each call is a document of its own: an engine before 45 answers it
|
|
@@ -5809,6 +6498,11 @@ await geo.countries.update(be.id, { enabled: false });
|
|
|
5809
6498
|
- **Other refused deletes** answer `TAX_CATEGORY_NOT_DELETED`, `TAX_RATE_NOT_DELETED` or
|
|
5810
6499
|
`ZONE_NOT_DELETED` (409 `ConflictError`) with the engine's reason: a zone is refused while a
|
|
5811
6500
|
store uses it as its default tax or shipping zone, or a tax rate prices in it.
|
|
6501
|
+
- **A rate is a percentage from 0 to 100**, both included. A value outside it is refused
|
|
6502
|
+
`TAX_RATE_VALUE_INVALID` (400 `ValidationError`) before anything is sent, and by the engine for
|
|
6503
|
+
any other caller, with the same sentence and `details[0].field` `/input/value` (`code`
|
|
6504
|
+
`minimum` or `maximum`). Only a value you send is judged: a rate stored outside the range
|
|
6505
|
+
before the bound existed can still be renamed or switched off.
|
|
5812
6506
|
- **A rate keeps its customer group.** The engine cannot take a group off a rate (it ignores a
|
|
5813
6507
|
null group and keeps the stored one), so `rates.update` refuses `customerGroupId: null` with
|
|
5814
6508
|
`INVALID_TAX_RATE_INPUT` instead of answering success. Create a rate without a group and delete
|
|
@@ -6344,6 +7038,44 @@ exactly as the engine sends it: compare instants with `Date.parse` and format it
|
|
|
6344
7038
|
time zone only for display. Callers and refusals are those of `members`: a SuperAdmin with no tenant,
|
|
6345
7039
|
or the organization admin in its workspace; an unknown id is `ORGANIZATION_NOT_FOUND` (404).
|
|
6346
7040
|
|
|
7041
|
+
### Pre-order dates that follow the purchase orders
|
|
7042
|
+
|
|
7043
|
+
A variant's availability date (`availableFrom`, the end of a `preorder` or `comingSoon` state) can
|
|
7044
|
+
follow the purchase orders instead of the date staff set: with `incoming` it is kept equal to the
|
|
7045
|
+
earliest FUTURE day a supplier confirmed for an open purchase order into the store's storefront
|
|
7046
|
+
warehouses, recomputed whenever a purchase order of the variant is confirmed, revised, received or
|
|
7047
|
+
cancelled (purchase-orders spec §8.12.3).
|
|
7048
|
+
|
|
7049
|
+
```ts
|
|
7050
|
+
const now = await admin.catalog.variants.availableFromSource(variantId);
|
|
7051
|
+
// { productVariantId, source: 'manual', availableFrom: '2026-11-04T23:00:00.000Z',
|
|
7052
|
+
// confirmedIncomingOn: '2026-11-05', sharedAcrossLegalEntities: false }
|
|
7053
|
+
|
|
7054
|
+
await admin.catalog.variants.setAvailableFromSource(variantId, 'incoming', { idempotencyKey });
|
|
7055
|
+
await admin.catalog.variants.setAvailableFromSource(variantId, 'manual');
|
|
7056
|
+
```
|
|
7057
|
+
|
|
7058
|
+
- `confirmedIncomingOn` is the day `incoming` follows, read whatever the source; `null` means no
|
|
7059
|
+
confirmed purchase order, and the date then keeps its last value.
|
|
7060
|
+
- The day becomes the instant local midnight begins in the store's time zone. The date only takes
|
|
7061
|
+
effect under a variant state other than `inherit`.
|
|
7062
|
+
- `source` is typed as the two names but carries any string a newer engine adds.
|
|
7063
|
+
|
|
7064
|
+
Both need the Purchasing plugin on the store (`PLUGIN_ACCESS_DENIED`, 403). The read needs
|
|
7065
|
+
`ReadCatalog` or `ReadProduct`; the switch needs `UpdateCatalog` or `UpdateProduct` on every store
|
|
7066
|
+
holding the variant. Refusals, none of which writes anything:
|
|
7067
|
+
|
|
7068
|
+
- `AVAILABLE_FROM_SOURCE_INVALID` (400 `ValidationError`): a source other than `manual` or
|
|
7069
|
+
`incoming`, refused before sending.
|
|
7070
|
+
- `PURCHASING_NOT_ENABLED` (422): `incoming` while purchasing is off for the legal entity.
|
|
7071
|
+
- `AVAILABLE_FROM_SOURCE_SHARED_VARIANT` (422): `incoming` for a variant stores of more than one
|
|
7072
|
+
legal entity, or an organization, hold (`sharedAcrossLegalEntities`).
|
|
7073
|
+
- `PURCHASING_VARIANT_NOT_FOUND` / `ENTITY_NOT_FOUND` (404): a variant this store does not have.
|
|
7074
|
+
|
|
7075
|
+
The two are documents of their own, never fields of the variant reads, so `variants.get` and
|
|
7076
|
+
`list` keep working against an older engine. They need a vendure-server release with purchase
|
|
7077
|
+
orders step 5.3: deploy it first.
|
|
7078
|
+
|
|
6347
7079
|
### Variant prices from an organization
|
|
6348
7080
|
|
|
6349
7081
|
A variant an organization shares from its workspace (catalog sharing on) reaches each member store
|
|
@@ -6449,7 +7181,7 @@ reads it by id:
|
|
|
6449
7181
|
|
|
6450
7182
|
```ts
|
|
6451
7183
|
const group = await admin.customers.groups.get(groupId);
|
|
6452
|
-
// { id, createdAt, updatedAt, name, externalKey, canViewIncomingStock, canBackorder,
|
|
7184
|
+
// { id, createdAt, updatedAt, name, externalKey, canViewIncomingStock, canBackorder, viewOnly,
|
|
6453
7185
|
// catalogueOwnership }, or null
|
|
6454
7186
|
```
|
|
6455
7187
|
|
|
@@ -6780,8 +7512,8 @@ await exact.webhooks.sync();
|
|
|
6780
7512
|
await exact.disconnect(); // true, or false when the store had no row
|
|
6781
7513
|
```
|
|
6782
7514
|
|
|
6783
|
-
The store's Exact Online settings tab.
|
|
6784
|
-
|
|
7515
|
+
The store's Exact Online settings tab. Its inbound sync, its shipping-method mapping and its
|
|
7516
|
+
connection logs are below.
|
|
6785
7517
|
|
|
6786
7518
|
- **`config.update` takes one object; the wire keeps the engine's inline arguments.** A key left
|
|
6787
7519
|
out keeps the stored value; `autoPush` and `customerSync` replace their whole setting (`null`:
|
|
@@ -6807,6 +7539,249 @@ follow in their own modules.
|
|
|
6807
7539
|
`config.get` and `disconnect` (`PLUGIN_ACCESS_DENIED`, 403; never a SuperAdmin). A SuperAdmin head
|
|
6808
7540
|
acts on another store through `admin.withTenant(...)`.
|
|
6809
7541
|
|
|
7542
|
+
### Exact Online sync, shipping methods and connection logs
|
|
7543
|
+
|
|
7544
|
+
```ts
|
|
7545
|
+
const exact = admin.integrations.exactOnline;
|
|
7546
|
+
|
|
7547
|
+
await exact.sync.logs({ entity: 'items', limit: 5 }); // the newest runs (1-100), not a page
|
|
7548
|
+
await exact.sync.health(); // how each area last ran: customers, catalogue, orders
|
|
7549
|
+
await exact.sync.runCatalogue(); // "Sync now": queued, answers true (@effect money)
|
|
7550
|
+
await exact.sync.runCustomers();
|
|
7551
|
+
await exact.sync.runPriceLists(); // false: one is already queued
|
|
7552
|
+
await exact.sync.reimportPictures(); // how many pictures were marked
|
|
7553
|
+
await exact.sync.cursors();
|
|
7554
|
+
await exact.sync.resetCursor('items'); // the next sweep re-reads the stream
|
|
7555
|
+
await exact.warehouseLinks(); // stock locations and their Exact warehouse
|
|
7556
|
+
|
|
7557
|
+
const { mappings, exact: catalogue } = await exact.shipping.methods();
|
|
7558
|
+
await exact.shipping.runSync(); // refresh the Exact catalogue, re-apply activated rates
|
|
7559
|
+
await exact.shipping.import(exactShippingMethodId); // activate one in checkout, from Exact's rate
|
|
7560
|
+
await exact.shipping.setMapping({ vendureShippingMethodId, exactShippingMethodId }); // null clears
|
|
7561
|
+
await exact.shipping.convertToMapping({ exactShippingMethodId, vendureShippingMethodId });
|
|
7562
|
+
await exact.shipping.retireImported(exactShippingMethodId);
|
|
7563
|
+
|
|
7564
|
+
for await (const call of await exact.connectionLogs.calls({ outcome: 'failed' })) {
|
|
7565
|
+
console.log(call.occurredAt, call.operation, call.statusCode);
|
|
7566
|
+
}
|
|
7567
|
+
await exact.connectionLogs.events({ dateFrom: '2026-10-01T00:00:00+02:00' });
|
|
7568
|
+
const csv = await exact.connectionLogs.export('calls', {
|
|
7569
|
+
dateFrom: '2026-10-01T00:00:00+02:00',
|
|
7570
|
+
dateTo: '2026-10-14T23:59:59.999+02:00',
|
|
7571
|
+
});
|
|
7572
|
+
```
|
|
7573
|
+
|
|
7574
|
+
- **A run is queued, never awaited.** `runCatalogue` (catalogue, customers, sales and purchase
|
|
7575
|
+
orders), `runCustomers`, `runPriceLists` and `reimportPictures` answer once the work is on the
|
|
7576
|
+
worker's queue; its outcome is a `sync.logs` row. They overwrite what Exact owns (prices
|
|
7577
|
+
customers pay, stock, product and customer details, price lists and terms) and are tagged
|
|
7578
|
+
`@effect irreversible`, as is `shipping.runSync` (it re-applies Exact's rates to activated
|
|
7579
|
+
methods; a merchant's rename stays). `runCatalogue` is `@effect money`: its order import books
|
|
7580
|
+
Exact's sales orders as placed orders with a settled payment. The runs are refused
|
|
7581
|
+
`FIELD_GROUP_LOCKED` (422; the sentence names each owner and where to release it) while an
|
|
7582
|
+
app or API token owns a field group of the store: Exact writes all four. A store that is not
|
|
7583
|
+
connected is NOT refused: its run fails on the worker.
|
|
7584
|
+
- **`sync.logs` is the newest N, not a page.** `limit` is a whole number from 1 to
|
|
7585
|
+
`EXACT_ONLINE_SYNC_LOG_LIMIT` (100) and is refused otherwise, before sending
|
|
7586
|
+
(`INVALID_EXACT_ONLINE_INPUT`, 400), rather than answered short.
|
|
7587
|
+
- **The shipping methods.** A method Exact defines reaches checkout only through `import` or a
|
|
7588
|
+
mapping. `convertToMapping` and `retireImported` soft-delete the method Exact created
|
|
7589
|
+
(`@effect destructive`) and need `DeleteSettings` or `DeleteShippingMethod`; their refusals are
|
|
7590
|
+
`USER_INPUT_ERROR` (400). `import` and `setMapping` refusals (not connected, not active in the
|
|
7591
|
+
catalogue, no rate, mapped elsewhere) are not coded yet: `GRAPHQL_ERROR` (500).
|
|
7592
|
+
`setMapping` refuses a missing or blank `exactShippingMethodId` before sending: the engine
|
|
7593
|
+
would read either as null and clear the mapping; only an explicit `null` clears.
|
|
7594
|
+
- **The connection logs** are the store's own audit rows for Exact (`AuditCall`, `AuditEvent`),
|
|
7595
|
+
newest first, paged by cursor. Pass dates with an offset: the service pins only a bare
|
|
7596
|
+
`dateTo` (to the end of that UTC day) and reads any other zone-less value in its database's
|
|
7597
|
+
session time zone. `export` answers the CSV text of a range of at most 366 days (both dates required;
|
|
7598
|
+
`VALIDATION_ERROR`, 400, otherwise) and waits up to `EXACT_ONLINE_LOG_EXPORT_TIMEOUT_MS`
|
|
7599
|
+
(120 s). An app below `customerData: full` reads the rows with every customer-data field null,
|
|
7600
|
+
and may not search with `q`.
|
|
7601
|
+
- **Permissions:** reads `ReadSettings`; runs, `resetCursor`, `shipping.runSync`, `import` and
|
|
7602
|
+
`setMapping` `UpdateSettings`. A store without the Exact Online plugin is refused
|
|
7603
|
+
(`PLUGIN_ACCESS_DENIED`, 403; the connection logs `PermissionDeniedError`).
|
|
7604
|
+
|
|
7605
|
+
### Exact Online terms, and the customer, order and product cards
|
|
7606
|
+
|
|
7607
|
+
```ts
|
|
7608
|
+
const exact = admin.integrations.exactOnline;
|
|
7609
|
+
|
|
7610
|
+
await exact.terms.paymentConditions(); // the ACTIVE conditions mirrored from the division
|
|
7611
|
+
await exact.terms.paymentDiagnostics(); // how far the customers' conditions reach
|
|
7612
|
+
await exact.terms.paymentDryRun(); // live read of Exact, writes nothing
|
|
7613
|
+
await exact.terms.incoterms();
|
|
7614
|
+
await exact.terms.deliveryDiagnostics();
|
|
7615
|
+
await exact.terms.deliveryDryRun(); // live read of Exact, writes nothing
|
|
7616
|
+
|
|
7617
|
+
await exact.customers.paymentTerms(customerId); // null when Exact holds none
|
|
7618
|
+
await exact.customers.deliveryTerms(customerId); // null when Exact holds none
|
|
7619
|
+
await exact.customers.priceList(customerId); // status: assigned | pending | none | unlinked
|
|
7620
|
+
|
|
7621
|
+
const state = await exact.orders.state(orderId); // origin: 'pushed' | 'imported' | null
|
|
7622
|
+
await exact.orders.syncState(orderId); // null for an order never imported
|
|
7623
|
+
const pushed = await exact.orders.push(orderId); // @effect irreversible; refusal: success false
|
|
7624
|
+
await exact.orders.reprice(orderId); // @effect money: re-split VAT at Exact's rates
|
|
7625
|
+
await exact.orders.repriceAll(); // every imported order, counts
|
|
7626
|
+
await exact.orders.runSync(); // queue the inbound order sync (@effect money)
|
|
7627
|
+
await exact.orders.runBackfill(); // reset the cursor and re-read the history
|
|
7628
|
+
|
|
7629
|
+
await exact.products.status(productId); // null for a product the store does not hold
|
|
7630
|
+
|
|
7631
|
+
await admin.customers.deliveryTerms(customerId); // the provider-neutral terms an order takes
|
|
7632
|
+
```
|
|
7633
|
+
|
|
7634
|
+
- **The dry runs read Exact live** and write nothing, here or in Exact, also while
|
|
7635
|
+
synchronization is off. A store that is not connected is refused
|
|
7636
|
+
`EXACT_ONLINE_NOT_CONNECTED` (`BusinessRuleError`, 422) before Exact is asked; a read Exact
|
|
7637
|
+
refuses is named in `resources` (`complete: false`), not thrown. Every other read here is a
|
|
7638
|
+
local read of what the sync last wrote.
|
|
7639
|
+
- **The customer cards answer the engine's spelling**: `status`, `classification` and
|
|
7640
|
+
`deliveryAdvice` are lowercase (`assigned`, `single_due_date`, `complete_order`), unlike the
|
|
7641
|
+
uppercase enums of `customers.credit.paymentTerms` and `customers.deliveryTerms`. They are read
|
|
7642
|
+
on any store, with or without the plugin.
|
|
7643
|
+
- **`orders.push` writes a sales order into the store's Exact division** and cannot be undone from
|
|
7644
|
+
the platform. It is write-once: an order already in Exact answers `alreadyPushed: true` and
|
|
7645
|
+
writes nothing. A refusal (guest order, lines not linked to an Exact item, a sandbox store, no
|
|
7646
|
+
active connection, another division's link, or Exact's own) is `success: false` with `error` (and
|
|
7647
|
+
`errorCode` when it has one), not a throw. A sales order that came out different from the order
|
|
7648
|
+
answers its differences in `warnings`. The engine takes no idempotency key for it yet. An engine
|
|
7649
|
+
that predates `warnings` refuses the document unread; `push` and `state` then send it once more
|
|
7650
|
+
without the field and answer `warnings: null` (push) or `[]` (state).
|
|
7651
|
+
- **`orders.reprice` and `repriceAll` change what an imported order bills**: each line's VAT is
|
|
7652
|
+
re-split at the rate Exact's sales order holds, the gross total unchanged. Exact is the source
|
|
7653
|
+
of truth for an Exact store's figures; nothing here recomputes them on the platform's side.
|
|
7654
|
+
- **The order runs are queued** and refused `FIELD_GROUP_LOCKED` (422) while an app or API token
|
|
7655
|
+
owns a field group of the store. A missing order, customer or product id is refused before
|
|
7656
|
+
sending (`INVALID_EXACT_ONLINE_INPUT`, 400).
|
|
7657
|
+
- **Permissions:** the terms `ReadSettings`; the customer cards and `customers.deliveryTerms`
|
|
7658
|
+
`ReadCustomer`; `orders.state` and `syncState` `ReadOrder`; `push` and `reprice` `UpdateOrder`;
|
|
7659
|
+
`repriceAll` and the runs `UpdateSettings`; `products.status` `ReadCatalog` or `ReadProduct`. A
|
|
7660
|
+
store without the Exact Online plugin is refused the terms and the order methods
|
|
7661
|
+
(`PLUGIN_ACCESS_DENIED`, 403).
|
|
7662
|
+
|
|
7663
|
+
### Data pipelines
|
|
7664
|
+
|
|
7665
|
+
A pipeline imports from, or exports to, another system over one of the store's connections, by
|
|
7666
|
+
hand or on a schedule. It is created by committing a set-up (below); its runs and their logs are
|
|
7667
|
+
read and driven through `runs` (next section).
|
|
7668
|
+
|
|
7669
|
+
```ts
|
|
7670
|
+
const pipelines = admin.integrations.pipelines;
|
|
7671
|
+
|
|
7672
|
+
for await (const pipeline of await pipelines.list({ direction: 'export' })) {
|
|
7673
|
+
console.log(pipeline.name, pipeline.scheduleType, pipeline.timezone, pipeline.isActive);
|
|
7674
|
+
}
|
|
7675
|
+
const pipeline = await pipelines.get(id); // null when the store has none with that id
|
|
7676
|
+
|
|
7677
|
+
await pipelines.update(id, { scheduleType: 'daily', timezone: 'Europe/Amsterdam' });
|
|
7678
|
+
await pipelines.update(id, { scheduleType: 'custom', cronExpression: '*/30 * * * *' });
|
|
7679
|
+
await pipelines.update(id, { config: { triggerFilter: { toState: 'PaymentSettled' } } });
|
|
7680
|
+
await pipelines.update(id, { isActive: false }); // pause: the schedule stops
|
|
7681
|
+
await pipelines.delete(id);
|
|
7682
|
+
```
|
|
7683
|
+
|
|
7684
|
+
- **A schedule is a wall-clock time in the pipeline's own `timezone`:** `daily` runs at 03:00
|
|
7685
|
+
there, `weekly` on Mondays at 03:00, `custom` on its cron, through the zone's daylight saving
|
|
7686
|
+
changes. A set-up starts with the store's zone; a pipeline made before zones were asked for
|
|
7687
|
+
holds `UTC`.
|
|
7688
|
+
- **Pausing** (`isActive: false`) unregisters the schedule, and a commerce plugin's export through
|
|
7689
|
+
the pipeline (Hendriks) is refused until it is resumed. A run started by hand still works.
|
|
7690
|
+
- **An export's filter has one vocabulary**, the orders reader's: the key `toState` and the order
|
|
7691
|
+
states. A save naming anything else is refused, because an unhonoured key would export every
|
|
7692
|
+
order and an impossible value none: `PIPELINE_EXPORT_FILTER_UNSUPPORTED`, `_BLANK` or
|
|
7693
|
+
`_VALUE_UNKNOWN` (`ValidationError`, 400, `details[].field` `triggerFilter`). `config` is merged
|
|
7694
|
+
over the stored settings at the top level; `triggerFilter: {}` removes the filter.
|
|
7695
|
+
- A `custom` schedule with no cron is `PIPELINE_CRON_REQUIRED`, and one the scheduler can never run
|
|
7696
|
+
(`0 0 31 4 *`) `PIPELINE_CRON_NEVER_FIRES`; both save nothing.
|
|
7697
|
+
- `delete` unregisters the schedule and removes the pipeline from the list, and cancels its runs
|
|
7698
|
+
that have not ended: a queued or paused one at once, a running one after the batch it is
|
|
7699
|
+
working on, so nothing more is imported or sent. Each says so in its `errorMessage`. Its past
|
|
7700
|
+
runs and logs stay (`runs.get` still reads them).
|
|
7701
|
+
- Reads need `ReadCatalog`, writes `UpdateCatalog`. Another store's pipeline is not found (`get`
|
|
7702
|
+
answers `null`). An organization workspace may only pause (`ORGANIZATION_WORKSPACE_NOT_A_STORE`
|
|
7703
|
+
otherwise).
|
|
7704
|
+
|
|
7705
|
+
### Data pipeline runs
|
|
7706
|
+
|
|
7707
|
+
```ts
|
|
7708
|
+
const runs = admin.integrations.pipelines.runs;
|
|
7709
|
+
|
|
7710
|
+
const run = await runs.start(pipelineId); // `pending`; { syncPolicy?, collectionAssignments? }
|
|
7711
|
+
const now = await runs.get(run.id); // null when the store has none with that id
|
|
7712
|
+
console.log(now?.status, now?.progress.orders?.exported, now?.errorMessage);
|
|
7713
|
+
|
|
7714
|
+
for await (const past of await runs.list(pipelineId)) console.log(past.status, past.startedAt);
|
|
7715
|
+
|
|
7716
|
+
await runs.pause(run.id); // at the next batch boundary
|
|
7717
|
+
await runs.resume(run.id);
|
|
7718
|
+
await runs.cancel(run.id); // a running or paused run; what it did stays done
|
|
7719
|
+
|
|
7720
|
+
const errors = await runs.logs(run.id, { entityType: 'orders', result: 'errored', limit: 50 });
|
|
7721
|
+
console.log(errors.total, errors.items.map((log) => [log.sourceId, log.message]));
|
|
7722
|
+
```
|
|
7723
|
+
|
|
7724
|
+
- **Pause and cancel act at the next batch boundary:** the batch in flight finishes and is
|
|
7725
|
+
recorded. Only a `running` run pauses; a `running` or `paused` one cancels; a queued (`pending`)
|
|
7726
|
+
one is refused `PIPELINE_RUN_NOT_STARTED`, and a run in any other state
|
|
7727
|
+
`PIPELINE_RUN_ALREADY_ENDED`, `PIPELINE_RUN_NOT_RUNNING` or `PIPELINE_RUN_NOT_PAUSED` (all
|
|
7728
|
+
`ConflictError`, 409). A run of another store is `PIPELINE_RUN_NOT_FOUND`.
|
|
7729
|
+
- **A run of a deleted pipeline is cancelled** (`pipelines.delete`): `status` `cancelled`,
|
|
7730
|
+
`errorMessage` "The pipeline was deleted, so this run stopped after the batch it was working on.
|
|
7731
|
+
Nothing more was sent." (or "...while this run was waiting to start..." / "...was paused...").
|
|
7732
|
+
- **The logs filter by `entityType` and `result`** (`created`, `updated`, `skipped`, `errored`,
|
|
7733
|
+
`exported`), newest first, with `total`. There is no log level: a record's outcome is its
|
|
7734
|
+
`result`.
|
|
7735
|
+
- **`start` is not idempotent**: each call queues another run. An export run sends orders to the
|
|
7736
|
+
other system, which cannot be taken back (`@effect irreversible`, as `resume`).
|
|
7737
|
+
- **A run is also a job** with the same id: `admin.jobs.list()` indexes the runs it has not seen,
|
|
7738
|
+
and `admin.jobs.wait(run.id)` then waits for one to end.
|
|
7739
|
+
- `start` and `resume` refuse a client whose channel token is no longer the store's
|
|
7740
|
+
(`PIPELINE_CHANNEL_TOKEN_MISMATCH`, 403: the store was renamed; build a client with the new
|
|
7741
|
+
token), and an organization workspace (`ORGANIZATION_WORKSPACE_NOT_A_STORE`). Reads need
|
|
7742
|
+
`ReadCatalog`, the rest `UpdateCatalog`.
|
|
7743
|
+
|
|
7744
|
+
### Following a run or a discovery live
|
|
7745
|
+
|
|
7746
|
+
```ts
|
|
7747
|
+
const pipelines = admin.integrations.pipelines;
|
|
7748
|
+
const controller = new AbortController();
|
|
7749
|
+
|
|
7750
|
+
for await (const event of pipelines.runs.watch(run.id, { signal: controller.signal })) {
|
|
7751
|
+
// 'progress' while it works; then one of 'complete' | 'error' | 'cancelled' | 'paused', and the loop ends
|
|
7752
|
+
console.log(event.type, event.phase, event.entityType, event.message, event.progress?.orders);
|
|
7753
|
+
}
|
|
7754
|
+
|
|
7755
|
+
const discovery = await pipelines.discoveries.start(connectionId); // `pending`
|
|
7756
|
+
for await (const event of pipelines.discoveries.watch(discovery.id)) {
|
|
7757
|
+
console.log(event.type, event.phase, `${event.percent}%`, event.message); // ends on 'complete' | 'error'
|
|
7758
|
+
}
|
|
7759
|
+
const read = await pipelines.discoveries.get(discovery.id); // null when the store has none
|
|
7760
|
+
const maps = await pipelines.discoveries.schemaMaps(discovery.id); // one per kind of record
|
|
7761
|
+
```
|
|
7762
|
+
|
|
7763
|
+
- **`watch` ends on the job's terminal event**, which is yielded, never thrown: a failed run is an
|
|
7764
|
+
`error` event whose `message` is the reason. A job that had already ended answers its ended state
|
|
7765
|
+
at once; a queued run says nothing until it starts. A resumed run is watched again.
|
|
7766
|
+
- **A dropped connection is reopened** (at most `maxReconnects` times in a row, default 5;
|
|
7767
|
+
`onReconnect` is told each time), so a deploy or a proxy timeout does not end the loop. A refusal
|
|
7768
|
+
is never retried: another store's (or a deleted) run or discovery is a `NotFoundError` (404
|
|
7769
|
+
`PIPELINE_RUN_NOT_FOUND` / `PIPELINE_DISCOVERY_NOT_FOUND`, before the stream opens; an older
|
|
7770
|
+
plugins service refuses inside the opened stream, `PROGRESS_STREAM_REFUSED`, an
|
|
7771
|
+
`UpstreamError`), and a missing `ReadCatalog` is a
|
|
7772
|
+
`PermissionDeniedError` (403). Once the reopens are spent the loop throws
|
|
7773
|
+
`PROGRESS_STREAM_CLOSED` or `PROGRESS_STREAM_STALLED` (no data for `idleTimeoutMs`, 45 s by
|
|
7774
|
+
default); the job itself carries on, so read it with `runs.get` / `discoveries.get`. `signal`
|
|
7775
|
+
ends it with `REQUEST_ABORTED`.
|
|
7776
|
+
- **The counts are the stream's.** A run cancelled by its pipeline's deletion ends the stream at
|
|
7777
|
+
once while the batch in flight may still finish; `runs.get` reads the final counts.
|
|
7778
|
+
- An id that is not a run's or a discovery's id is refused `PROGRESS_STREAM_ID_INVALID` before
|
|
7779
|
+
anything is sent: the streams are a closed set of kinds in `@sixtyninecommerce/core`, never a
|
|
7780
|
+
path.
|
|
7781
|
+
- `discoveries.start` samples the source again on every call (a fixed-schema connector answers at
|
|
7782
|
+
once; any other asks the AI, cached 24 hours for an unchanged source). An import set-up starts
|
|
7783
|
+
its own (`drafts.discover`). It needs `UpdateCatalog`; the reads and `watch` need `ReadCatalog`.
|
|
7784
|
+
|
|
6810
7785
|
### Data pipeline set-ups (drafts)
|
|
6811
7786
|
|
|
6812
7787
|
An import or export set-up is kept on the server while the merchant works through it, so it can be
|
|
@@ -6927,6 +7902,58 @@ await pipelines.connections.delete(connection.id);
|
|
|
6927
7902
|
- `siteAnalysis`, `publicCredentials` and a probe's `entityCounts` (keyed by the source's own
|
|
6928
7903
|
entity names, `product_brand` too) keep their keys exactly (`PIPELINE_CONNECTION_PRESERVE_KEYS`).
|
|
6929
7904
|
|
|
7905
|
+
### Bulk price import
|
|
7906
|
+
|
|
7907
|
+
Variant prices from a CSV, JSON or XML document, matched by SKU in the store: a dry run first,
|
|
7908
|
+
then the rows to write. Every method needs `UpdateCatalog` or `UpdateProduct`.
|
|
7909
|
+
|
|
7910
|
+
```ts
|
|
7911
|
+
const prices = admin.integrations.priceImport;
|
|
7912
|
+
|
|
7913
|
+
const document = { format: 'csv', content: await file.text(), fileTaxMode: 'inclusive' } as const;
|
|
7914
|
+
const preview = await prices.preview(document); // nothing written
|
|
7915
|
+
preview.summary; // { total, updated, unchanged, notFound, … }
|
|
7916
|
+
for (const row of preview.results) {
|
|
7917
|
+
row.rowNumber; row.sku; row.status; // 'updated' | 'unchanged' | 'not_found' | …
|
|
7918
|
+
row.oldPrice; row.newPrice; // minor units, in the store's tax mode
|
|
7919
|
+
}
|
|
7920
|
+
|
|
7921
|
+
const chosen = preview.results.filter((r) => r.status === 'updated').map((r) => r.rowNumber);
|
|
7922
|
+
const written = await prices.run({ ...document, selectedRows: chosen });
|
|
7923
|
+
written.summary.updated; written.summary.errored; // a failed row does not undo the others
|
|
7924
|
+
|
|
7925
|
+
// Any supplier file, converted by the platform's AI provider into a document `preview` accepts:
|
|
7926
|
+
const { conversionId } = await prices.convert({ file }); // .xlsx, .pdf, .docx, .csv, …
|
|
7927
|
+
const converted = await prices.follow(conversionId, {
|
|
7928
|
+
signal, // abort to stop the conversion
|
|
7929
|
+
onProgress: ({ step, message }) => console.log(step, message),
|
|
7930
|
+
});
|
|
7931
|
+
await prices.preview({ format: 'json', content: converted.document, fileTaxMode: converted.fileTaxMode });
|
|
7932
|
+
```
|
|
7933
|
+
|
|
7934
|
+
- **`preview` writes nothing; `run` writes what customers pay and cannot be undone** except by
|
|
7935
|
+
importing each row's `oldPrice` again. A timeout after `run` was sent does not say whether the
|
|
7936
|
+
prices were written: `preview` the same document (rows already at their new price read
|
|
7937
|
+
`unchanged`) before repeating.
|
|
7938
|
+
- **`selectedRows`** names rows by `rowNumber`: a CSV row's line (the header is line 1), a JSON
|
|
7939
|
+
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,
|
|
7940
|
+
`PRICE_IMPORT_SELECTION_CONFLICT` (`BusinessRuleError`, 422, one `details[]` entry per pair).
|
|
7941
|
+
- **A document that cannot be read is refused whole** (`PRICE_IMPORT_FORMAT_ERROR`,
|
|
7942
|
+
`BusinessRuleError`, 422) and nothing is written; `details[]` name each problem (`field`:
|
|
7943
|
+
`header`, `document` or `row N`). A row whose content is wrong is reported `invalid` instead,
|
|
7944
|
+
and the rest still apply. At most 5,000 data rows.
|
|
7945
|
+
- **`fileTaxMode`** says whether the amounts include tax. Left out, they are read in the store's
|
|
7946
|
+
own mode; given and different, each is converted at its variant's rate (`taxConverted`).
|
|
7947
|
+
- **The AI conversion writes nothing.** `convert` uploads the file (refused before sending
|
|
7948
|
+
without a name: `PRICE_IMPORT_FILE_NAME_REQUIRED`); `follow` runs it, 30 seconds to 3 minutes,
|
|
7949
|
+
and resolves the converted document with what was read (`interpretation`, `warnings`,
|
|
7950
|
+
`fileTaxMode` and whether it was read from the file, `taxModeReason`). Its refusals keep their
|
|
7951
|
+
codes (`AI_IMPORT_NO_PRICES_FOUND`, `AI_IMPORT_NOT_FOUND`, `AI_IMPORT_BUSY`, …; the TSDoc maps
|
|
7952
|
+
each to its class); none is retryable, because an upload is read once: convert the file again.
|
|
7953
|
+
An upload belongs to the store it was sent to, so follow it with the same client.
|
|
7954
|
+
- An assistant should ask before `run`, never before `preview` (`@effect irreversible` and
|
|
7955
|
+
`read`).
|
|
7956
|
+
|
|
6930
7957
|
### Searching the store
|
|
6931
7958
|
|
|
6932
7959
|
One call finds products, orders, customers and content items matching a term: each type's best
|
|
@@ -7045,6 +8072,88 @@ products (`MAX_VARIANT_FIND_PRODUCTS`); more distinct ids than `take`. A search
|
|
|
7045
8072
|
`admin.reports.popularVariants({ take, sinceDays })` answers its ranked variants in the same
|
|
7046
8073
|
shape (plus `productId`), so a "popular" list needs no second read.
|
|
7047
8074
|
|
|
8075
|
+
### SEO: redirects, settings and automation
|
|
8076
|
+
|
|
8077
|
+
```ts
|
|
8078
|
+
// Redirects: a page at a time, or every one.
|
|
8079
|
+
for await (const redirect of await admin.seo.redirects.list({ limit: 200 })) {
|
|
8080
|
+
redirect; // { id, oldPath, newPath, statusCode, source: 'manual' | 'import' | 'auto', … }
|
|
8081
|
+
}
|
|
8082
|
+
await admin.seo.redirects.create({ oldPath: '/old-page', newPath: '/new-page' }); // 301
|
|
8083
|
+
const result = await admin.seo.redirects.import([
|
|
8084
|
+
{ oldPath: '/product/old', newPath: '/product/new' },
|
|
8085
|
+
{ oldPath: '/sale', newPath: 'https://outlet.example.com/', statusCode: 302 },
|
|
8086
|
+
]);
|
|
8087
|
+
// { created: 1, updated: 1, rejected: [{ index, oldPath, message }] }
|
|
8088
|
+
const nginx = await admin.seo.redirects.exportNginx(); // two `map` blocks, as text
|
|
8089
|
+
|
|
8090
|
+
// The channel settings and the SEO defaults (Admin API).
|
|
8091
|
+
const settings = await admin.seo.settings.get();
|
|
8092
|
+
// { channelId, defaultLanguageCode, availableLanguageCodes, storefrontUrl,
|
|
8093
|
+
// urlLocaleStrategy: 'path-prefix', urlLocalePrefixDefault: false,
|
|
8094
|
+
// twitterHandle, defaultOgImageId, aiBotDefaults: { GPTBot: 'deny' }, llmsTxtEnabled,
|
|
8095
|
+
// robotsTxtCustom,
|
|
8096
|
+
// defaultsSource: { source: 'organization', locked: false, inheritable: true,
|
|
8097
|
+
// overridden: false, organizationName: 'Vinvin Group', effective: { … } } }
|
|
8098
|
+
await admin.seo.settings.set({ urlLocaleStrategy: 'subdomain' });
|
|
8099
|
+
await admin.seo.settings.set({ aiBotDefaults: { GPTBot: 'deny', ClaudeBot: 'allow' } });
|
|
8100
|
+
|
|
8101
|
+
// The automation (plugins service).
|
|
8102
|
+
const config = await admin.seo.config.get();
|
|
8103
|
+
// { autoRedirectsEnabled, indexnowEnabled, indexnowKey, crawlSchedule: 'weekly', crawlMaxPages,
|
|
8104
|
+
// crawlRatePerSec, cruxEnabled, gscConnected, gscPropertyUrl, gscConnectedAt,
|
|
8105
|
+
// source, locked, overridden, inheritable, organizationName }
|
|
8106
|
+
await admin.seo.config.set({ autoRedirectsEnabled: false });
|
|
8107
|
+
const { key } = await admin.seo.config.rotateIndexnowKey();
|
|
8108
|
+
```
|
|
8109
|
+
|
|
8110
|
+
SEO of the **active** store, in three parts.
|
|
8111
|
+
|
|
8112
|
+
**Redirects** (`seo.redirects`) are what the store's old URLs answer. Storefronts read them live
|
|
8113
|
+
from the public redirect map (`createRedirectMiddleware` in `@sixtyninecommerce/client/next`), so a
|
|
8114
|
+
write reaches the site within a few minutes, with no deployment. Each rule is one exact source path
|
|
8115
|
+
to a path or an `https://` URL, with 301 (the default), 302, 303, 307 or 308. A malformed path, a
|
|
8116
|
+
self-redirect or another status is a `ValidationError`; a source the store already redirects, a
|
|
8117
|
+
loop or a chain past the hop limit a `ConflictError`. `import` adds or updates up to 5,000 rules
|
|
8118
|
+
(`REDIRECT_IMPORT_MAX_ROWS`) in one call, matched on the source path, and judges each row on its
|
|
8119
|
+
own: a bad row is answered in `rejected` by its index (with the service's sentence; there is no
|
|
8120
|
+
code per row yet) and the rest still land, in order, so a loop split across two rows is refused on
|
|
8121
|
+
the second. More rows, or none, is `REDIRECT_IMPORT_INVALID` before anything is sent: send a large
|
|
8122
|
+
file in chunks. `exportNginx` answers the rules as two Nginx `map` blocks (`$redirect_uri` and
|
|
8123
|
+
`$redirect_status` on `$uri`) for a site not on the storefront SDK. Every redirect method needs
|
|
8124
|
+
`UpdateChannel`; in an organization's workspace `create`, `update` and `import` are refused with
|
|
8125
|
+
`ORGANIZATION_WORKSPACE_NOT_A_STORE` (`BusinessRuleError`), `delete` never is. `delete` is the one
|
|
8126
|
+
destructive method.
|
|
8127
|
+
|
|
8128
|
+
**Settings** (`seo.settings`) are Channel custom fields: the storefront URL and how languages show
|
|
8129
|
+
in its URLs (`URL_LOCALE_STRATEGIES`), always the store's own, and the SEO defaults (share card, AI
|
|
8130
|
+
crawlers, `llms.txt`, `robots.txt`), which are the `settings.seo` group. `get` answers the store's
|
|
8131
|
+
OWN values and `defaultsSource`: whether storefronts get them or the organization's
|
|
8132
|
+
(`defaultsSource.effective` is what they get). `set` is a patch: a key left out is unchanged, `null`
|
|
8133
|
+
clears it, and `aiBotDefaults` is an object the SDK encodes for the column (pass the map, never its
|
|
8134
|
+
JSON). The read-only keys `get` answers are ignored, so `set({ ...settings, … })` works. Refused
|
|
8135
|
+
before any request with `SEO_SETTINGS_INVALID` (`ValidationError`): an empty patch, a key that is not
|
|
8136
|
+
a setting, a bot map that is not an object. The engine refuses a directive other than `allow` or
|
|
8137
|
+
`deny` (`CHANNEL_SETTINGS_INVALID`, `details[].field` `customFields.aiBotDefaults`), another locale
|
|
8138
|
+
strategy (`USER_INPUT_ERROR`), a changed default the organization decides
|
|
8139
|
+
(`MANAGED_BY_ORGANIZATION` / `ORGANIZATION_SETTINGS_INHERITED`, `BusinessRuleError`; sending the
|
|
8140
|
+
stored value back passes) and a storefront URL for a workspace (`ORGANIZATION_WORKSPACE_NOT_A_STORE`).
|
|
8141
|
+
Writing needs `UpdateChannel`.
|
|
8142
|
+
|
|
8143
|
+
**Automation** (`seo.config`) lives on the plugins service and needs the store's `seo` plugin:
|
|
8144
|
+
automatic redirects on URL changes, IndexNow, Core Web Vitals and the audit schedule
|
|
8145
|
+
(`SEO_CRAWL_SCHEDULES`; 10 to 5,000 pages at 1 to 10 a second). Its keys are the service's wire
|
|
8146
|
+
names camelised, as the meta catalog's `seo` schema publishes them (`indexnow_enabled` →
|
|
8147
|
+
`indexnowEnabled`). The switches are part of `settings.seo`, so `get` answers the effective ones with
|
|
8148
|
+
where they come from, and `set` refuses a change the organization decides
|
|
8149
|
+
(`MANAGED_BY_ORGANIZATION` / `ORGANIZATION_SETTINGS_INHERITED`; in the workspace, during a switch-off,
|
|
8150
|
+
`ORGANIZATION_SETTINGS_SWITCH_OFF_CONFLICT`, a `ConflictError`). Refused before any request with
|
|
8151
|
+
`SEO_CONFIG_INVALID`: an empty patch, a key that is not a switch, a null. A value out of range is
|
|
8152
|
+
the service's `VALIDATION_FAILED`. `rotateIndexnowKey` voids the old key at once (destructive): the
|
|
8153
|
+
store's `/<key>.txt` serves the new one; it is the store's own, never refused for an inheriting
|
|
8154
|
+
store, but refused in a workspace (`ORGANIZATION_WORKSPACE_NOT_A_STORE`). `get` needs
|
|
8155
|
+
`ReadSettings`, the writes `UpdateSettings`.
|
|
8156
|
+
|
|
7048
8157
|
### Purchasing
|
|
7049
8158
|
|
|
7050
8159
|
Buying stock from suppliers, per legal entity. The submodules so far are the legal entity's
|
|
@@ -7052,7 +8161,8 @@ purchasing settings with its document numbering, its suppliers with their contac
|
|
|
7052
8161
|
([Suppliers](#suppliers)), its purchase orders with their lines
|
|
7053
8162
|
([Purchase orders](#purchase-orders)), its reorder rules and suggestions
|
|
7054
8163
|
([Replenishment](#replenishment)), and what needs attention with who its alert emails go to
|
|
7055
|
-
([Alerts](#alerts))
|
|
8164
|
+
([Alerts](#alerts)), its goods receipts ([Receipts](#receipts)) and its returns of goods to
|
|
8165
|
+
suppliers ([Returns](#returns)).
|
|
7056
8166
|
|
|
7057
8167
|
```ts
|
|
7058
8168
|
const settings = await admin.purchasing.settings.get();
|
|
@@ -7667,7 +8777,8 @@ cards), and who its three alert emails go to.
|
|
|
7667
8777
|
```ts
|
|
7668
8778
|
const summary = await admin.purchasing.alerts.summary();
|
|
7669
8779
|
// { localDay: '2026-10-13', awaitingApproval: 2, overdueLines: 5, overdueOrders: 3, attentionOrders: 4,
|
|
7670
|
-
// reorderSuggestions: 14, newReorderSuggestions: 3
|
|
8780
|
+
// reorderSuggestions: 14, newReorderSuggestions: 3, backordersReadyLines: 2,
|
|
8781
|
+
// backordersReadyOrders: 1 } — or null while purchasing is off
|
|
7671
8782
|
|
|
7672
8783
|
const who = await admin.purchasing.alerts.recipients.list();
|
|
7673
8784
|
// [{ alert: 'purchase_order_approval_requested', permission: 'ApprovePurchaseOrder', emails: [],
|
|
@@ -7689,12 +8800,16 @@ await admin.purchasing.alerts.recipients.update([{ alert: 'purchase_order_lines_
|
|
|
7689
8800
|
confirmed day, else the requested one) is before `localDay`, today in the legal entity's time
|
|
7690
8801
|
zone, and the orders holding them. `attentionOrders` counts each order awaiting approval or
|
|
7691
8802
|
overdue once. `reorderSuggestions`/`newReorderSuggestions`: the last compute's listed
|
|
7692
|
-
suggestions and the new ones among them. `
|
|
7693
|
-
|
|
8803
|
+
suggestions and the new ones among them. `backordersReadyLines`/`backordersReadyOrders`: the
|
|
8804
|
+
ACTIVE store's backordered order lines that stock on hand can ship now, and the orders holding
|
|
8805
|
+
them (`backorders.ready()`, below). Against an engine before step 3.6 the SDK asks once more
|
|
8806
|
+
without those two and answers them `null`; the other counts are unchanged.
|
|
8807
|
+
`summary` answers `null` while purchasing is not switched on, and needs `ReadPurchaseOrder`.
|
|
7694
8808
|
- **The alerts**, each an email under Management → Notifications (switched on, templates and
|
|
7695
8809
|
sender there): `purchase_order_approval_requested` (an order or a revision waits for approval;
|
|
7696
8810
|
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; `
|
|
8811
|
+
daily overdue digest at 08:00 in the legal entity's time zone; `ReceivePurchaseOrder`, the
|
|
8812
|
+
staff who take the goods in; `UpdatePurchaseOrder` on an engine before step 3.6) and
|
|
7698
8813
|
`reorder_suggestions_digest` (the new reorder suggestions, daily or on Mondays at 08:00 as the
|
|
7699
8814
|
settings' `reorderDigest` says; `CreatePurchaseOrder`). The holders are the administrators of
|
|
7700
8815
|
the legal entity's stores holding the permission; a platform administrator and an API
|
|
@@ -7709,6 +8824,372 @@ await admin.purchasing.alerts.recipients.update([{ alert: 'purchase_order_lines_
|
|
|
7709
8824
|
purchases (`HUB_HAS_NO_PURCHASING`, `LEGAL_ENTITY_REQUIRED`, 422). An engine before step 4.3
|
|
7710
8825
|
fails the request GraphQL validation (`GRAPHQL_VALIDATION_FAILED`).
|
|
7711
8826
|
|
|
8827
|
+
#### Backorders ready to ship
|
|
8828
|
+
|
|
8829
|
+
What received goods let the store ship: its open backordered order lines that stock on hand can
|
|
8830
|
+
cover now, oldest order first.
|
|
8831
|
+
|
|
8832
|
+
```ts
|
|
8833
|
+
const ready = await admin.purchasing.backorders.ready({ limit: 50 });
|
|
8834
|
+
// items: [{ orderId, orderCode: 'ORD-1042', orderState: 'PaymentSettled',
|
|
8835
|
+
// orderPlacedAt: '2026-10-01T10:00:00.000Z', orderLineId, productVariantId, sku: 'TEE-M',
|
|
8836
|
+
// name: 'Tee M', quantity: 5, backorderedQuantity: 3, readyQuantity: 3, stockOnHand: 8,
|
|
8837
|
+
// supplierCancelled: false }, …]
|
|
8838
|
+
|
|
8839
|
+
// What one receipt let ship: only the variants it brought in.
|
|
8840
|
+
const fromReceipt = await admin.purchasing.backorders.ready({ goodsReceiptId: receipt.id });
|
|
8841
|
+
for await (const line of fromReceipt) { /* every page */ }
|
|
8842
|
+
```
|
|
8843
|
+
|
|
8844
|
+
- **The rule.** Per variant, over the store's stock locations: free = on hand − (allocated − the
|
|
8845
|
+
claims allocation holds) − the in-stock parts no allocation holds, at most on hand. A paid
|
|
8846
|
+
backordered order allocates past on hand, so taking its claims back out of `allocated` leaves what
|
|
8847
|
+
the in-stock parts of open orders hold. An order whose reservation waits for fulfilment (net
|
|
8848
|
+
terms, a stock-bypass import) holds nothing: its claim stays in, and its in-stock part is held
|
|
8849
|
+
out of on hand as the stock it was promised. The free units go to the claims oldest order first
|
|
8850
|
+
(placed, else created for an order never placed, then order code); a line is listed with what it
|
|
8851
|
+
gets (`readyQuantity`, 1 to `backorderedQuantity`). Every open claim keeps its turn, a parked or
|
|
8852
|
+
approval-waiting order's too (`orderState` says which). Another store's allocations at a shared
|
|
8853
|
+
location stay in `allocated`, so the figure errs low there.
|
|
8854
|
+
- **Scope.** The ACTIVE store's own orders (a store of the same legal entity never lists another's
|
|
8855
|
+
customers' orders) and its own stock locations. Nothing is allocated: open the order and fulfil it.
|
|
8856
|
+
- **The count** is on `alerts.summary()`: `backordersReadyLines` and `backordersReadyOrders`
|
|
8857
|
+
(`null` from an engine before step 3.6, which refuses `ready()` itself at validation).
|
|
8858
|
+
- **Access.** Needs `ReadOrder`, the Purchasing plugin and purchasing switched on
|
|
8859
|
+
(`PURCHASING_NOT_ENABLED`, 422). Refusals: `GOODS_RECEIPT_NOT_FOUND` (404, a receipt of another
|
|
8860
|
+
legal entity too), `GOODS_RECEIPT_INVALID` (400, a page out of range: `limit` is 1 to 100).
|
|
8861
|
+
|
|
8862
|
+
Needs an engine with step 3.6; against an older engine the request fails GraphQL validation
|
|
8863
|
+
(`GRAPHQL_VALIDATION_FAILED`). `supplierCancelled` (a line whose back-to-back link the supplier
|
|
8864
|
+
side ended, an ordinary backorder again) needs an engine with step 5.1. A line still waiting for
|
|
8865
|
+
its own purchase order (an open back-to-back link while it holds less than its open units, or a
|
|
8866
|
+
drop-ship line) is never listed: its purchase order serves it, not free stock.
|
|
8867
|
+
|
|
8868
|
+
#### Order links (back-to-back)
|
|
8869
|
+
|
|
8870
|
+
"Order from supplier" on a customer's order line: a line on a draft purchase order, linked to the
|
|
8871
|
+
order line, so the goods are allocated to that order line when they are received, before anything
|
|
8872
|
+
else, whatever the order's payment terms (net terms included).
|
|
8873
|
+
|
|
8874
|
+
```ts
|
|
8875
|
+
// Onto the variant's preferred supplier's newest draft into the destination, else a new draft.
|
|
8876
|
+
const { link, purchaseOrder, createdPurchaseOrder } = await admin.purchasing.links.backToBack.create(
|
|
8877
|
+
{ orderLineId, baseQuantity: 3, stockLocationId },
|
|
8878
|
+
{ idempotencyKey: 'order-line-42-b2b' }
|
|
8879
|
+
);
|
|
8880
|
+
// link: { id, kind: 'back_to_back', status: 'open', orderCode: 'WT-0042', purchaseOrderCode:
|
|
8881
|
+
// 'PO-2026-0007', baseQuantity: 3, allocatedBaseQuantity: 0, expectedOn: null, … }
|
|
8882
|
+
|
|
8883
|
+
// Later: what the order line waits for, and what arrived ("Supplier cancelled" included).
|
|
8884
|
+
const links = await admin.purchasing.links.forOrder(orderId);
|
|
8885
|
+
// [{ status: 'allocated', allocatedBaseQuantity: 3, stockLocationId, expectedOn: '2026-10-20',
|
|
8886
|
+
// expectedOnConfirmed: true, … }]
|
|
8887
|
+
const onTheOrder = await admin.purchasing.links.forPurchaseOrder(purchaseOrder.id);
|
|
8888
|
+
|
|
8889
|
+
// A dialog's defaults: what a link may order for the line, and what create takes by default.
|
|
8890
|
+
const { openBaseQuantity, linkedBaseQuantity, maxBaseQuantity, suggestedBaseQuantity } =
|
|
8891
|
+
await admin.purchasing.links.backToBack.quantities(orderLineId);
|
|
8892
|
+
|
|
8893
|
+
// The order line no longer waits for it; the purchase order line keeps its quantity.
|
|
8894
|
+
await admin.purchasing.links.backToBack.remove(link.id);
|
|
8895
|
+
```
|
|
8896
|
+
|
|
8897
|
+
- **Where the line goes.** The draft named (`purchaseOrderId`: a standard draft of that supplier
|
|
8898
|
+
and destination), else the supplier's newest draft into the destination (`stockLocationId`, else
|
|
8899
|
+
the tenant's location, else the store's only one), else a new draft (`createdPurchaseOrder`). The
|
|
8900
|
+
supplier is the variant's preferred one unless `supplierId` names another. The line buys whole
|
|
8901
|
+
purchase units covering `baseQuantity`. A second link of the same order line onto the same draft
|
|
8902
|
+
grows its line and its link instead.
|
|
8903
|
+
- **How much.** At most the order line's open units (not yet in a live fulfilment) that its other
|
|
8904
|
+
links do not claim yet and their arrived, unshipped goods do not cover. Left out, all of those
|
|
8905
|
+
the line plainly lacks: when goods that arrived for its earlier links may still cover it (units
|
|
8906
|
+
shipped before they came cannot be told apart), the default refuses and asks for a quantity (`PURCHASE_LINK_EXCEEDS_OPEN_QUANTITY`, 422,
|
|
8907
|
+
`details[0].maxBaseQuantity`). `backToBack.quantities(orderLineId)` answers both figures before
|
|
8908
|
+
anything is written (`maxBaseQuantity`, and `suggestedBaseQuantity`: 0 when the default would
|
|
8909
|
+
refuse), with the line's open units and what its active links claim; it needs
|
|
8910
|
+
`UpdatePurchaseOrder`, as `create` does, and refuses a line `create` refuses whatever the quantity
|
|
8911
|
+
(`PURCHASE_LINK_ORDER_LINE_NOT_FOUND`, `PURCHASE_LINK_ORDER_NOT_LINKABLE`). It needs an engine with
|
|
8912
|
+
step 5.4.
|
|
8913
|
+
- **On receipt.** The accepted units of a linked purchase order line go to its links in the order
|
|
8914
|
+
they were made, each up to what it claims and what its order line still has open (goods that
|
|
8915
|
+
arrived for it earlier and have not shipped count as covering it), and are allocated to the
|
|
8916
|
+
order line at the receipt location, when one of the order's stores holds that location (a
|
|
8917
|
+
destination changed after the link leaves the link waiting). A prepaid backordered line already holds its
|
|
8918
|
+
quantity: nothing is booked twice, and the units still count as its link's. Imported, cancelled
|
|
8919
|
+
and fully shipped lines take nothing. What no link takes is ordinary stock.
|
|
8920
|
+
- **Status.** `open` → `partially_allocated` → `allocated`; `released` when the order or line is
|
|
8921
|
+
cancelled (`order_cancelled`, `line_cancelled`) or the link removed (`removed`);
|
|
8922
|
+
`supplier_cancelled` (`po_line_cancelled`) when its purchase order line can no longer supply it:
|
|
8923
|
+
the order is cancelled or closed short, a revision removes or lowers the line, a supplier with no
|
|
8924
|
+
backorders cancels the rest, a return credits refused goods. An ended link stays listed.
|
|
8925
|
+
- **Scope.** `create` takes a line of an order of the ACTIVE store (another store's answers
|
|
8926
|
+
`PURCHASE_LINK_ORDER_LINE_NOT_FOUND`); `forOrder` answers nothing for another store's order;
|
|
8927
|
+
`orderCode` is null on `forPurchaseOrder` for an order of another store of the legal entity.
|
|
8928
|
+
- **Access.** `create` and `remove` need `UpdatePurchaseOrder` on every store of the legal entity,
|
|
8929
|
+
and `create` also `CreatePurchaseOrder` when it makes a new draft (`PURCHASE_LINK_DRAFT_DENIED`,
|
|
8930
|
+
403); the reads `ReadPurchaseOrder`. Refusals: `PURCHASE_LINK_ORDER_NOT_LINKABLE` (422: not
|
|
8931
|
+
placed, cancelled, imported, or drop-shipped), `PURCHASE_LINK_SUPPLIER_REQUIRED` (422: no
|
|
8932
|
+
preferred supplier), `PURCHASE_LINK_INVALID` (400), `PURCHASE_LINK_NOT_FOUND` (404),
|
|
8933
|
+
`PURCHASE_LINK_ALREADY_RELEASED` (422), `PURCHASE_ORDER_NOT_EDITABLE`, `SUPPLIER_ARCHIVED`,
|
|
8934
|
+
`MANAGED_BY_PROVIDER` (422), and the destination's location refusals.
|
|
8935
|
+
- A receipt that meets a linked line while it is being fulfilled answers `PURCHASE_LINK_LINE_BUSY`
|
|
8936
|
+
(try again; nothing was posted, and the receipt's key is free).
|
|
8937
|
+
|
|
8938
|
+
Needs an engine with step 5.1; against an older engine the request fails GraphQL validation
|
|
8939
|
+
(`GRAPHQL_VALIDATION_FAILED`).
|
|
8940
|
+
|
|
8941
|
+
#### Drop-ship
|
|
8942
|
+
|
|
8943
|
+
A supplier item whose drop-ship mode is `always`, or `optional` when the store cannot ship the
|
|
8944
|
+
whole line from stock, makes a placed order's line a drop-ship line: the supplier sends it straight
|
|
8945
|
+
to the customer, and none of our stock is reserved, sold or given back for it. The platform drafts
|
|
8946
|
+
one drop-ship purchase order per supplier when the order is placed, with the order's delivery
|
|
8947
|
+
address. Staff approve and send it as any purchase order, then record what the supplier shipped:
|
|
8948
|
+
|
|
8949
|
+
```ts
|
|
8950
|
+
const dropShip = await admin.purchasing.links.dropShip.get(purchaseOrderId);
|
|
8951
|
+
// { orderCode: 'WT-0042', address: { fullName: 'Anna de Vries', streetLine1: 'Keizersgracht 12',
|
|
8952
|
+
// city: 'Amsterdam', postalCode: '1015 AB', countryCode: 'NL', … },
|
|
8953
|
+
// lines: [{ purchaseOrderLineId, orderLineId, sku: 'TEE-M', orderedBaseQuantity: 2,
|
|
8954
|
+
// shippedBaseQuantity: 0, toShipBaseQuantity: 2 }] }
|
|
8955
|
+
|
|
8956
|
+
const { fulfillment, closedPurchaseOrder } = await admin.purchasing.links.dropShip.recordShipment(
|
|
8957
|
+
{ purchaseOrderId, carrier: 'PostNL', trackingCode: '3SABC1234567' },
|
|
8958
|
+
{ idempotencyKey: 'po-0009-shipment-1' }
|
|
8959
|
+
);
|
|
8960
|
+
// fulfillment: { state: 'Shipped', method: 'PostNL', trackingCode: … }
|
|
8961
|
+
// closedPurchaseOrder: true once no line can ship more.
|
|
8962
|
+
```
|
|
8963
|
+
|
|
8964
|
+
- **What it does.** A fulfilment of the customer's lines with the `drop-ship` handler, moved to
|
|
8965
|
+
`Shipped` at once, so the customer gets the usual shipping email. Its `method` is the carrier
|
|
8966
|
+
(else "Shipment" in the store's language) and never names the supplier, since the shopper reads
|
|
8967
|
+
it. No stock movement is written for those lines. Each line's cost (`OrderLine.customFields.cost`)
|
|
8968
|
+
becomes the purchase order line's base-unit cost in the order's currency, at the platform rate of
|
|
8969
|
+
`shippedOn` (or of up to 7 days earlier), or at the `fxRate` given (`ManageInventoryCost`). The
|
|
8970
|
+
purchase order closes once no line can ship more; what no customer line waits for (whole
|
|
8971
|
+
purchase units beyond the customer's quantity) is cancelled as a close short would. Its links
|
|
8972
|
+
read `partially_shipped` / `shipped`.
|
|
8973
|
+
- **Which store.** The address, the PDF and the shipment belong to the customer order's store: from
|
|
8974
|
+
another store of the legal entity, `get` answers `orderCode` and `address` null, the document is
|
|
8975
|
+
`PURCHASE_ORDER_DOCUMENT_NOT_FOUND`, and `recordShipment` is `PURCHASE_LINK_INVALID`.
|
|
8976
|
+
- **Order edits.** A drop-ship line's quantity cannot be raised past what its supplier was asked
|
|
8977
|
+
for (`DROP_SHIP_QUANTITY_RAISED`, 422): the extra units would be neither reserved nor ordered.
|
|
8978
|
+
- **How much.** `lines` left out ships everything each line still has to ship. A line ships at most
|
|
8979
|
+
`toShipBaseQuantity`: what it still orders, at most what the customer's line still waits for
|
|
8980
|
+
(`DROP_SHIP_SHIPMENT_EXCEEDS_ORDERED`, 422, `details[0].maxBaseQuantity`).
|
|
8981
|
+
- **The key is required.** A retried shipment never fulfils twice.
|
|
8982
|
+
- **Elsewhere.** A drop-ship line cannot be fulfilled from a warehouse (`LINE_DROP_SHIPPED`), and a
|
|
8983
|
+
drop-ship purchase order cannot be received (`DROP_SHIP_PO_NOT_RECEIVABLE`), given lines by
|
|
8984
|
+
hand, duplicated or cancelled after a shipment.
|
|
8985
|
+
- **Access.** `recordShipment` needs `UpdatePurchaseOrder` and `UpdateOrder`
|
|
8986
|
+
(`DROP_SHIP_SHIPMENT_DENIED`, 403), and a credential not restricted to stock locations
|
|
8987
|
+
(`LOCATION_NOT_ALLOWED`); `get` needs `ReadPurchaseOrder`. Other refusals:
|
|
8988
|
+
`PURCHASE_ORDER_INVALID_TRANSITION` (not sent), `PURCHASE_LINK_INVALID` (400),
|
|
8989
|
+
`DROP_SHIP_FULFILLMENT_REFUSED`, `FX_RATE_UNAVAILABLE`, `FX_RATE_OVERRIDE_NOT_ALLOWED` (403),
|
|
8990
|
+
`MANAGED_BY_PROVIDER`.
|
|
8991
|
+
|
|
8992
|
+
Needs an engine with step 5.2; against an older engine the request fails GraphQL validation
|
|
8993
|
+
(`GRAPHQL_VALIDATION_FAILED`), and its drop-ship links read `open` whatever shipped.
|
|
8994
|
+
|
|
8995
|
+
#### Receipts
|
|
8996
|
+
|
|
8997
|
+
Receiving goods against the legal entity's sent purchase orders: one receipt is the goods ONE
|
|
8998
|
+
supplier delivered at ONE stock location, covering lines of one or more of its orders; a reversal
|
|
8999
|
+
takes accepted goods of a receipt back out when the receipt was wrong.
|
|
9000
|
+
|
|
9001
|
+
```ts
|
|
9002
|
+
// What can be received at a location (or for one supplier or order), with the scan keys.
|
|
9003
|
+
const lines = await admin.purchasing.receipts.receivable({ stockLocationId });
|
|
9004
|
+
// [{ purchaseOrderCode: 'PO-2026-0001', purchaseOrderLineId, sku, supplierSku, supplierBarcode,
|
|
9005
|
+
// gtin, purchaseUnitLabel: 'box of 6', purchaseUnitFactor: 6, remainingBaseQuantity: 12,
|
|
9006
|
+
// toleranceBaseQuantity: 1, maxAcceptedBaseQuantity: 13, expectedOn, … }]
|
|
9007
|
+
|
|
9008
|
+
const receipt = await admin.purchasing.receipts.post(
|
|
9009
|
+
{
|
|
9010
|
+
supplierDeliveryRef: 'DN-4711',
|
|
9011
|
+
lines: [
|
|
9012
|
+
{ purchaseOrderLineId, acceptedBaseQuantity: 10, rejectedBaseQuantity: 2, rejectReason: 'Damaged' },
|
|
9013
|
+
],
|
|
9014
|
+
},
|
|
9015
|
+
{ idempotencyKey: idempotencyKey() } // required
|
|
9016
|
+
);
|
|
9017
|
+
// { code: 'GR-2026-0001', kind: 'receipt', receivedOn: '2026-10-13', lines: [{ acceptedBaseQuantity,
|
|
9018
|
+
// rejectedBaseQuantity, landedUnitCost: '1300.000000', fxRate: '1.00000000', … }], … }
|
|
9019
|
+
|
|
9020
|
+
await admin.purchasing.receipts.reverse(
|
|
9021
|
+
{ goodsReceiptId: receipt.id, reason: 'Booked on the wrong order' }, // every line, or `lines`
|
|
9022
|
+
{ idempotencyKey: idempotencyKey() } // required
|
|
9023
|
+
);
|
|
9024
|
+
|
|
9025
|
+
const { items } = await admin.purchasing.receipts.list({ purchaseOrderId, limit: 25 });
|
|
9026
|
+
const one = await admin.purchasing.receipts.get(receipt.id);
|
|
9027
|
+
```
|
|
9028
|
+
|
|
9029
|
+
- **Quantities are base (stock) units.** `acceptedBaseQuantity` enters stock at the location (a
|
|
9030
|
+
`purchase_receipt` movement with the receipt's number as its reference, which
|
|
9031
|
+
`stock.level_changed` names in `reference_document`); `rejectedBaseQuantity` moves nothing and
|
|
9032
|
+
stays open on the order line until a return to the supplier resolves it (a reason is required).
|
|
9033
|
+
- **Tolerance.** A line may accept its remainder plus the supplier's over-receipt tolerance (the
|
|
9034
|
+
purchasing default when the supplier has none): `maxAcceptedBaseQuantity`. Beyond it the poster
|
|
9035
|
+
needs `ReceiveOverTolerance` and an `overReceiptReason` on the line, else
|
|
9036
|
+
`RECEIPT_EXCEEDS_TOLERANCE` (422, `details` per line).
|
|
9037
|
+
- **What a receipt does.** In one transaction: the stock movements, the weighted average cost
|
|
9038
|
+
(`inventory_cost.changed`), each order line's received quantity, the cancellation of a line's
|
|
9039
|
+
remainder when the supplier ships no backorders, the order closing when nothing more is to come,
|
|
9040
|
+
the incoming stock, and `goods_receipt.posted`. A reversal takes the goods back out at each
|
|
9041
|
+
line's original landed cost and reopens a closed order; it is refused `STOCK_WOULD_GO_NEGATIVE`
|
|
9042
|
+
(422) when the goods are no longer on hand: move or recount stock first.
|
|
9043
|
+
- **Exchange rates.** A line priced in another currency than the purchasing one converts at the
|
|
9044
|
+
platform rate of `receivedOn` (or the latest of the 7 days before); none is
|
|
9045
|
+
`FX_RATE_UNAVAILABLE` (422, the key is released: retry once the rates synced). A holder of
|
|
9046
|
+
`ManageInventoryCost` may state the rate (`fxRates: [{ currencyCode, rate }]`,
|
|
9047
|
+
`FX_RATE_OVERRIDE_NOT_ALLOWED` otherwise).
|
|
9048
|
+
- **Access.** Every method needs the Purchasing plugin and purchasing switched on for the legal
|
|
9049
|
+
entity (`PURCHASING_NOT_ENABLED`, 422). Reads need `ReadPurchaseOrder` or
|
|
9050
|
+
`ReceivePurchaseOrder`; `post` and `reverse` need `ReceivePurchaseOrder` on **every** store of
|
|
9051
|
+
the legal entity (`SHARED_SELLER_NOT_ALLOWED`, 403), at a location of the store the caller may
|
|
9052
|
+
act at (`LOCATION_NOT_ALLOWED`, 403: a restricted credential's locations, a warehouse staff
|
|
9053
|
+
member's assigned warehouses) that the legal entity owns. An app or token that owns a variant's
|
|
9054
|
+
stock refuses anyone else's receipt of it (`FIELD_GROUP_LOCKED`).
|
|
9055
|
+
- **`post` and `reverse` require `{ idempotencyKey }`** (the type says so): a retried receipt
|
|
9056
|
+
answers the first one and never books the stock twice.
|
|
9057
|
+
|
|
9058
|
+
These methods need an engine with supplier returns (purchase-orders step 3.5): every receipt they
|
|
9059
|
+
answer selects the two returned quantities below. Against an older engine, goods receipts without
|
|
9060
|
+
supplier returns included, the request fails GraphQL validation (`GRAPHQL_VALIDATION_FAILED`), so
|
|
9061
|
+
upgrade the engine before this release of the SDK.
|
|
9062
|
+
|
|
9063
|
+
A receipt line also answers `returnedBaseQuantity` (accepted goods sent back to the supplier from
|
|
9064
|
+
stock, which a reversal can no longer take back) and `returnedRejectedBaseQuantity` (refused goods
|
|
9065
|
+
sent back), counting every return not cancelled ([Returns](#returns)).
|
|
9066
|
+
|
|
9067
|
+
#### Returns
|
|
9068
|
+
|
|
9069
|
+
Goods going back to a supplier: refused goods from a goods receipt (`origin: 'rejected'`, they
|
|
9070
|
+
never entered stock), or goods found faulty later (`origin: 'on_hand'`, they leave stock when the
|
|
9071
|
+
return ships). A return goes to ONE supplier from ONE stock location; each line names the goods
|
|
9072
|
+
receipt line its goods came in on, which fixes the cost they leave at.
|
|
9073
|
+
|
|
9074
|
+
```ts
|
|
9075
|
+
const draft = await admin.purchasing.returns.create({
|
|
9076
|
+
origin: 'rejected',
|
|
9077
|
+
supplierReference: 'RMA-88213', // the supplier's return authorisation, optional
|
|
9078
|
+
lines: [{ goodsReceiptLineId, baseQuantity: 2, outcome: 'credit_expected' }],
|
|
9079
|
+
});
|
|
9080
|
+
// { code: 'RS-2026-0001', status: 'draft', lines: [{ reason: 'Damaged' /* the receipt's */, … }] }
|
|
9081
|
+
|
|
9082
|
+
const shipped = await admin.purchasing.returns.ship(
|
|
9083
|
+
draft.id,
|
|
9084
|
+
{ carrier: 'PostNL', trackingCode: '3S…' }, // notifySupplier: false skips the email
|
|
9085
|
+
{ idempotencyKey: idempotencyKey() } // required
|
|
9086
|
+
);
|
|
9087
|
+
// { status: 'shipped', shippedAt, documentIssuedAt, … } and the supplier gets the PDF by email
|
|
9088
|
+
|
|
9089
|
+
await admin.purchasing.returns.resolveLine({
|
|
9090
|
+
supplierReturnLineId: shipped.lines[0].id,
|
|
9091
|
+
resolution: 'credited', // or 'replaced', 'written_off'
|
|
9092
|
+
creditAmount: 2600, // informational, minor units of the line's currencyCode
|
|
9093
|
+
resolutionReference: 'CN-77',
|
|
9094
|
+
});
|
|
9095
|
+
// the last line resolved: { status: 'resolved', resolvedAt, … }
|
|
9096
|
+
|
|
9097
|
+
await admin.purchasing.returns.update(id, { trackingCode: '3S…' }); // a draft: lines too
|
|
9098
|
+
await admin.purchasing.returns.cancel(id, { reason: 'Raised twice' }); // a draft only
|
|
9099
|
+
const { url } = await admin.purchasing.returns.document(shipped.id); // five minutes
|
|
9100
|
+
const { items } = await admin.purchasing.returns.list({ status: 'shipped', limit: 25 });
|
|
9101
|
+
```
|
|
9102
|
+
|
|
9103
|
+
- **Lifecycle.** `draft → shipped → resolved`, and `draft → cancelled` (the meta catalog serves
|
|
9104
|
+
it as `transitions.supplierReturn`). A draft's lines, location, notes and references can change;
|
|
9105
|
+
a shipped return keeps what left, and changes only its carrier, tracking code and supplier
|
|
9106
|
+
reference. A shipped return is never cancelled (`SUPPLIER_RETURN_INVALID_TRANSITION`, 422).
|
|
9107
|
+
- **What a line may return.** At most what its receipt line still holds: its refused units for a
|
|
9108
|
+
return of refused goods, its accepted units less reversals for goods from stock, in both cases
|
|
9109
|
+
less the other returns not cancelled (a draft holds its quantity). More is
|
|
9110
|
+
`SUPPLIER_RETURN_EXCEEDS_RETURNABLE` (422, `details` per line with `returnableBaseQuantity`).
|
|
9111
|
+
- **What a ship does.** In one transaction: goods from stock leave the location (a
|
|
9112
|
+
`supplier_return` movement with the return's number as its reference) at their receipt lines'
|
|
9113
|
+
landed cost, which the weighted average takes out (`inventory_cost.changed`); refused goods move
|
|
9114
|
+
nothing. On the purchase order lines, refused goods credited or written off are cancelled (an
|
|
9115
|
+
order with nothing more to come closes), refused goods to be replaced stay open, and goods from
|
|
9116
|
+
stock to be replaced are open again (`returnedForReplacementBaseQuantity`; a closed order
|
|
9117
|
+
reopens). Then `supplier_return.status_changed`, the PDF (in the supplier's language), and after
|
|
9118
|
+
the commit the "Supplier Return Sent" email unless `notifySupplier: false`. Below zero is
|
|
9119
|
+
`STOCK_WOULD_GO_NEGATIVE` (422): move or recount stock first.
|
|
9120
|
+
- **Resolutions.** A line expecting a credit is `credited` or `written_off`; one expecting a
|
|
9121
|
+
replacement is `replaced`, `credited` or `written_off` (the last two cancel what the replacement
|
|
9122
|
+
kept open on the order line); a write-off is `written_off`. A line is resolved once.
|
|
9123
|
+
- **Access.** Every method needs the Purchasing plugin and purchasing switched on for the legal
|
|
9124
|
+
entity (`PURCHASING_NOT_ENABLED`, 422). Reads and `document` need `ReadPurchaseOrder` or
|
|
9125
|
+
`ReceivePurchaseOrder`; `create` needs `CreatePurchaseOrder`, `update`, `resolveLine` and
|
|
9126
|
+
`cancel` `UpdatePurchaseOrder`, and `ship` `ReceivePurchaseOrder`, each on **every** store of the
|
|
9127
|
+
legal entity (`SHARED_SELLER_NOT_ALLOWED`, 403), at a location the caller may act at
|
|
9128
|
+
(`LOCATION_NOT_ALLOWED`, 403). An app or token that owns a variant's stock refuses anyone else's
|
|
9129
|
+
ship of it from stock (`FIELD_GROUP_LOCKED`).
|
|
9130
|
+
- **The email.** It goes to the supplier's order contact, else its order email; before anything is
|
|
9131
|
+
written the ship is refused `SUPPLIER_HAS_NO_ORDER_EMAIL` or `SUPPLIER_EMAIL_UNDELIVERABLE` (the
|
|
9132
|
+
store's "Supplier Return Sent" email off, without a recipient, or no email delivery), and
|
|
9133
|
+
`PRIVATE_STORAGE_UNCONFIGURED` without a private document bucket (all 422, `details[0]` with
|
|
9134
|
+
`field: 'notifySupplier'` for the first two). With `notifySupplier: false` a deployment without
|
|
9135
|
+
the bucket ships with no document (`document` is then `SUPPLIER_RETURN_DOCUMENT_NOT_FOUND`).
|
|
9136
|
+
- **`ship` requires `{ idempotencyKey }`** (the type says so): a retried ship answers the first
|
|
9137
|
+
one and never takes the goods out of stock twice. The other writes take an optional key.
|
|
9138
|
+
|
|
9139
|
+
These methods need an engine with supplier returns (purchase-orders phase 3, step 3.5). Against an
|
|
9140
|
+
older engine the request fails GraphQL validation (`GRAPHQL_VALIDATION_FAILED`).
|
|
9141
|
+
|
|
9142
|
+
##### Landed charges
|
|
9143
|
+
|
|
9144
|
+
Freight, duty and fees paid to get a receipt's goods in, spread over its lines and taken into their
|
|
9145
|
+
weighted average cost.
|
|
9146
|
+
|
|
9147
|
+
```ts
|
|
9148
|
+
const receipt = await admin.purchasing.receipts.charges.add(
|
|
9149
|
+
{
|
|
9150
|
+
goodsReceiptId,
|
|
9151
|
+
kind: 'freight', // freight | duty | fee | other
|
|
9152
|
+
amount: 12500, // whole minor units of currencyCode
|
|
9153
|
+
currencyCode: 'USD', // the purchasing currency when left out
|
|
9154
|
+
allocation: 'value', // value (default) | quantity | weight
|
|
9155
|
+
incurredOn: '2026-10-12', // today in the legal entity's zone when left out
|
|
9156
|
+
payeeName: 'DHL Freight', // or payeeSupplierId
|
|
9157
|
+
},
|
|
9158
|
+
{ idempotencyKey: idempotencyKey() } // optional
|
|
9159
|
+
);
|
|
9160
|
+
// { …receipt, lines: [{ landedUnitCost: '1150.000000', … }],
|
|
9161
|
+
// charges: [{ id, kind: 'freight', amountEntityCurrency: '11500.000000', fxRate: '0.92000000',
|
|
9162
|
+
// allocations: [{ goodsReceiptLineId, baseQuantity: 10, amount: '11500.000000',
|
|
9163
|
+
// unitCostDelta: '1150.000000' }], removedAt: null, … }] }
|
|
9164
|
+
|
|
9165
|
+
await admin.purchasing.receipts.charges.remove({ chargeId, reason: 'Billed twice' });
|
|
9166
|
+
const { charges } = (await admin.purchasing.receipts.get(goodsReceiptId)) ?? { charges: [] };
|
|
9167
|
+
```
|
|
9168
|
+
|
|
9169
|
+
- **Allocation.** Over the units each line still holds (accepted less reversed): by value (each
|
|
9170
|
+
line's quantity × unit cost in the purchasing currency), by quantity, or by weight (package grams
|
|
9171
|
+
× quantity; a variant without a weight refuses the charge, `LANDED_CHARGE_WEIGHT_MISSING`, 422,
|
|
9172
|
+
`details` per variant). One rounding per line, the remainder on the largest line.
|
|
9173
|
+
- **Cost.** The share on units still on hand revalues the weighted average; the share on units
|
|
9174
|
+
sold since is recorded as `unabsorbedAmount` on the cost entry (`inventory_cost.changed`, cause
|
|
9175
|
+
`landed_charge`), never pushed into any cost of goods sold. Each line's `landedUnitCost` rises by
|
|
9176
|
+
its share per unit, so a later reversal or return takes the units out at the landed cost.
|
|
9177
|
+
- **Removal** takes the share still on the lines' units back out of the average (units sold since
|
|
9178
|
+
keep theirs: a negative `unabsorbedAmount`), lowers each line's landed cost by what the charge
|
|
9179
|
+
added, and keeps the charge with `removedAt` and the reason. A charge is never deleted.
|
|
9180
|
+
- **Exchange rates.** A charge in another currency converts at the platform rate of `incurredOn`
|
|
9181
|
+
(or the latest of the 7 days before); none is `FX_RATE_UNAVAILABLE` (422: retry once the rates
|
|
9182
|
+
synced, or state `fxRate`).
|
|
9183
|
+
- **Access.** Both writes need `ManageInventoryCost` on **every** store of the legal entity
|
|
9184
|
+
(`SHARED_SELLER_NOT_ALLOWED`, 403). Refusals: `GOODS_RECEIPT_NOT_FOUND`, `LANDED_CHARGE_NOT_FOUND`
|
|
9185
|
+
(404), `LANDED_CHARGE_INVALID` (400: a value, a reversal named, a day ahead),
|
|
9186
|
+
`LANDED_CHARGE_NOTHING_TO_ALLOCATE` (422: every unit reversed, or no value to allocate by),
|
|
9187
|
+
`LANDED_CHARGE_ALREADY_REMOVED` (422), `SUPPLIER_NOT_FOUND` (404, the payee),
|
|
9188
|
+
`MANAGED_BY_PROVIDER` (422).
|
|
9189
|
+
|
|
9190
|
+
These methods need an engine with landed charges (purchase-orders step 3.4); against an older
|
|
9191
|
+
engine the request, `receipts.get` included, fails GraphQL validation (`GRAPHQL_VALIDATION_FAILED`).
|
|
9192
|
+
|
|
7712
9193
|
### Errors
|
|
7713
9194
|
|
|
7714
9195
|
One hierarchy, all re-exported from this package so you need no direct dependency on
|