@sixtyninecommerce/admin 0.9.0 → 0.10.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (41) hide show
  1. package/README.md +294 -0
  2. package/dist/catalog/products.d.ts +162 -1
  3. package/dist/catalog/products.js +94 -1
  4. package/dist/catalog/variants.d.ts +50 -0
  5. package/dist/catalog/variants.js +48 -18
  6. package/dist/client.d.ts +21 -0
  7. package/dist/client.js +6 -0
  8. package/dist/fulfillments/index.d.ts +77 -0
  9. package/dist/fulfillments/index.js +109 -0
  10. package/dist/index.d.ts +15 -4
  11. package/dist/index.js +5 -0
  12. package/dist/integrations/index.d.ts +14 -0
  13. package/dist/integrations/index.js +9 -0
  14. package/dist/integrations/pipelines.d.ts +113 -0
  15. package/dist/integrations/pipelines.js +62 -0
  16. package/dist/integrations/types.d.ts +176 -0
  17. package/dist/integrations/types.js +8 -0
  18. package/dist/jobs/bulk-action-run.d.ts +11 -2
  19. package/dist/jobs/bulk-action-run.js +4 -5
  20. package/dist/orders/bulk.d.ts +75 -0
  21. package/dist/orders/bulk.js +150 -0
  22. package/dist/orders/deposit-policy.d.ts +103 -0
  23. package/dist/orders/deposit-policy.js +202 -0
  24. package/dist/orders/index.d.ts +80 -0
  25. package/dist/orders/index.js +13 -0
  26. package/dist/orders/manual-discounts.d.ts +11 -2
  27. package/dist/orders/manual-discounts.js +2 -0
  28. package/dist/payments/index.d.ts +7 -0
  29. package/dist/payments/index.js +2 -0
  30. package/dist/payments/methods.d.ts +37 -0
  31. package/dist/payments/methods.js +33 -0
  32. package/dist/pricing/index.d.ts +3 -0
  33. package/dist/pricing/index.js +2 -0
  34. package/dist/pricing/vouchers.d.ts +103 -0
  35. package/dist/pricing/vouchers.js +69 -0
  36. package/dist/returns/index.d.ts +225 -0
  37. package/dist/returns/index.js +160 -0
  38. package/dist/search/index.d.ts +99 -0
  39. package/dist/search/index.js +62 -0
  40. package/dist/types.d.ts +2 -2
  41. package/package.json +1 -1
package/README.md CHANGED
@@ -981,6 +981,32 @@ with the cancel and made right after it commits, so read the order again for the
981
981
  it cannot be made, the order's history says so and why. A cancel made with no merchant present (an
982
982
  integration's own path) refunds automatically.
983
983
 
984
+ ### Handing goods over before an order is paid
985
+
986
+ A store can require full payment before goods leave: its `fulfillmentPaymentPolicy`
987
+ (`{ requireFullPayment, exemptMethodCodes }`, a Channel setting, off by default). Then creating,
988
+ extending or shipping a fulfilment of an order whose COLLECTED money (settled, or held by a payment
989
+ provider — not a bank transfer, an invoice or cash on delivery still to be paid) does not cover what
990
+ it bills is refused `ORDER_NOT_COVERED` (a `BusinessRuleError`), unless the order is on net terms,
991
+ its latest payment is on an exempt method, or an override was recorded. A carrier or 3PL reporting
992
+ goods that already left is recorded, never refused.
993
+
994
+ ```ts
995
+ const coverage = await admin.fulfillments.coverage(orderId); // ReadOrder
996
+ // { policyActive: true, covered: false, outstanding: 8100, currencyCode: 'EUR',
997
+ // exempt: false, exemptReason: null, allowed: false, override: null, … }
998
+ const override = await admin.fulfillments.allowUncovered(
999
+ orderId,
1000
+ 'Customer pays on pickup', // required, at most 500 characters
1001
+ { idempotencyKey }
1002
+ ); // OverrideFulfillmentPaymentCoverage
1003
+ // { id, outstandingAtGrant: 8100, actorKind: 'user', active: true, … }
1004
+ ```
1005
+
1006
+ An override holds while the order owes no more than `outstandingAtGrant` (a refund after the grant
1007
+ ends it); a later grant supersedes an earlier one. Each grant is kept for good and writes a private
1008
+ entry on the order's history.
1009
+
984
1010
  ### Product feeds
985
1011
 
986
1012
  A feed turns the catalogue into the XML a channel hands Google, Meta, TikTok or Pinterest. The
@@ -1190,6 +1216,52 @@ const result = await admin.catalog.products.setCollections(productId, selectedId
1190
1216
  collection with such filters, until its membership job has run). Requires a platform
1191
1217
  release that serves `setProductCollections`.
1192
1218
 
1219
+ #### Saving a product page in one call
1220
+
1221
+ ```ts
1222
+ const { product, collections, warnings } = await admin.catalog.products.save(
1223
+ {
1224
+ id: productId,
1225
+ languageCode: 'nl', // names, slugs, descriptions and text values are written in it
1226
+ product: { name: 'Linnen hemd', brand: 'Acme' },
1227
+ variants: [
1228
+ { id: smallId, price: 5495, prices: [{ currencyCode: 'EUR', price: 5495 }], priceBasis: 'gross' },
1229
+ { id: mediumId, facetValueIds: [blueId, linenId], gtin: '4006381333931' },
1230
+ ],
1231
+ collectionIds: selectedIds,
1232
+ fromCollectionIds: loadedIds,
1233
+ propertyValues: [{ propertyId: originId, value: [portugalItemId] }],
1234
+ },
1235
+ { idempotencyKey }
1236
+ );
1237
+ for (const warning of warnings) console.warn(warning.code, warning.message);
1238
+ ```
1239
+
1240
+ - **All or nothing.** One engine transaction, in this order: the variants, the hand-picked
1241
+ collections (as `setCollections`), the property values (with each filterable product property's
1242
+ label sync), the product. Every step's check runs before the first write, so a refusal of any
1243
+ step leaves nothing written.
1244
+ - **Send the whole form if you like.** The input is a PATCH and the engine writes only what
1245
+ differs from what it holds: a value sent back unchanged is not a write. It never counts as a
1246
+ change of its field group (no "Managed by" badge moves, no `FIELD_GROUP_LOCKED`), and a product
1247
+ the store's organization shares is not refused for a shared field sent back unchanged.
1248
+ - **Prices** state the store's basis (`priceBasis`), checked before sending, as for
1249
+ `variants.update`; `price` and the default-currency entry of `prices` must agree. A currency
1250
+ left out of `prices` keeps its stored price: the list removes nothing.
1251
+ - **Content labels** of content-item values are read from the content system first. When it does
1252
+ not answer, the save still goes through, and `warnings` (`CONTENT_LABELS_NOT_SYNCED`) names the
1253
+ properties whose filter values were saved without their titles; run
1254
+ `catalog.syncContentFacets(propertyId)` on them later.
1255
+ - **Refusals**: `PRODUCT_SAVE_INPUT_INVALID` (400, `error.details` names each field),
1256
+ `PRODUCT_COLLECTIONS_INPUT_INVALID`, `FEED_IDENTIFIER_INVALID`, `ENTITY_NOT_FOUND` (a variant of
1257
+ another product included), `FORBIDDEN` (a step's own permission: `UpdateCatalog` or
1258
+ `UpdateCollection` to change collections, `UpdateCatalog` for property values),
1259
+ `SHARED_*_NOT_ALLOWED`, `MANAGED_BY_ORGANIZATION` (a CHANGED field the organization holds; the
1260
+ store's own price, facet values, collections and property values pass), `FIELD_GROUP_LOCKED`,
1261
+ `PRODUCT_COLLECTION_FILTER_INVALID`, `EXTERNAL_LINE_CARRIER_PROTECTED`.
1262
+ - `idempotencyKey`: a repeat answers the first call's result. Requires a platform release that
1263
+ serves `saveProduct`: deploy the engine before shipping a head that calls it.
1264
+
1193
1265
  #### Many products at once
1194
1266
 
1195
1267
  ```ts
@@ -2062,6 +2134,128 @@ An order this store does not hold is a `NotFoundError` (`ENTITY_NOT_FOUND`), the
2062
2134
  id. This is a root query of its own, never a field on an order list. It needs an engine that serves
2063
2135
  `orderStatus`; on an older one only this call fails.
2064
2136
 
2137
+ ### Many orders at once
2138
+
2139
+ ```ts
2140
+ const handle = await admin.orders.bulk(
2141
+ { action: 'MARK_PAID', orderIds: selectedIds }, // TRANSITION (with toState) | CANCEL (reason?) | DELETE | MARK_PAID
2142
+ { idempotencyKey } // minted when omitted
2143
+ );
2144
+ const run = await handle.wait(); // or later: admin.orders.bulkRun(handle.runId)
2145
+ for (const item of run.items) {
2146
+ if (item.status !== 'FAILED') continue;
2147
+ // ORDER_ACTION_UNAVAILABLE: the order's own status did not offer it; reasonCode says why
2148
+ console.log(item.id, item.code, item.reasonCode, item.message);
2149
+ for (const payment of item.captured ?? []) console.log(payment.paymentId, payment.state, payment.code);
2150
+ }
2151
+
2152
+ await admin.orders.delete(orderId); // one draft or cart, as the order page's Delete
2153
+ ```
2154
+
2155
+ - **A job, answered at once**, read with `ReadOrder`; `run.items` holds one outcome per order, in
2156
+ the order you named them, and the run's `status` is the run's (a `SUCCEEDED` run can hold
2157
+ `FAILED` orders).
2158
+ - **Each order as on its own page, and on its own.** Its status (`orders.status`) is asked first:
2159
+ an action it does not offer, or a `toState` outside its own `nextStates`, is
2160
+ `ORDER_ACTION_UNAVAILABLE` with `reasonCode` (`STATE`, `INVOICE_LOCKED`, `SHOPPER_CART`,
2161
+ `HAS_RECORDS`, `UNITS_SHIPPED`, …), before anything is written. Then its door, under the door's
2162
+ lock and with its own code (`ORDER_STATE_TRANSITION_ERROR`, `ORDER_CART_NOT_CANCELLABLE`,
2163
+ `ORDER_INVOICE_LOCKED`, `ORDER_HAS_RECORDS`, `ORDER_NOT_DELETED`, …). Another store's order is
2164
+ `ENTITY_NOT_FOUND`.
2165
+ - **`CANCEL` moves no money.** A paid order keeps its payment; refund it with `payments.refund`.
2166
+ - **`MARK_PAID` captures** every payment of the order that is `Authorized` when the run reaches it
2167
+ (read by the engine, never your list), each as `settlePayment` and on its own; `captured` lists
2168
+ them. A decline is the order's result (`SETTLE_PAYMENT_ERROR`), never retried.
2169
+ `IDEMPOTENCY_OUTCOME_UNKNOWN` means the provider was asked and its answer was lost: reconcile
2170
+ with the provider, never retry blind. An order with no authorized payment is
2171
+ `NOTHING_TO_CAPTURE`: it is never marked paid without money. Use `TRANSITION` for any other
2172
+ status; `PaymentSettled`, `Cancelled`, `Modifying` and `AddingItems` are refused there.
2173
+ - **One key, one run, one capture.** The same key with the same input answers the same run;
2174
+ another input under it is `IDEMPOTENCY_CONFLICT`. Each order and each capture is keyed within
2175
+ the run, so a run the platform processes again never asks a provider twice.
2176
+ - **No dry run**, and **at most `MAX_BULK_ORDERS` (50) per run** (`BULK_ACTION_INPUT_INVALID`,
2177
+ refused before sending too). Needs `UpdateOrder` (`DeleteOrder` for `DELETE`).
2178
+ - `delete(orderId)` removes one draft or cart that holds no payment or stock movement; anything
2179
+ else is kept (`ORDER_INVOICE_LOCKED`, `ORDER_HAS_RECORDS`, or `ORDER_NOT_DELETED`, a
2180
+ `ConflictError` like every `*_NOT_DELETED`, with the
2181
+ engine's reason).
2182
+
2183
+ Both need an engine that serves `bulkOrderAction`; on an older one only these calls fail
2184
+ (`GRAPHQL_VALIDATION_FAILED`).
2185
+
2186
+ ### Returns
2187
+
2188
+ ```ts
2189
+ const rma = await admin.returns.get(returnId); // with its status log; null when absent
2190
+ const returns = await admin.returns.forOrder(orderId); // newest first
2191
+ const eligibility = await admin.returns.eligibility(orderId);
2192
+
2193
+ const plan = await admin.returns.settlementOptions(returnId);
2194
+ // plan.settleable / plan.blockedBy → true / null, or false / 'RECEIPT_REQUIRED'
2195
+ // plan.refund → { available: true, reasonCode: null, payments: [{ paymentId, method, refundable }] }
2196
+ // plan.creditNote → { available: true, ledger: 'RETAIL', reasonCode: null, invoiceNumber: '00042' }
2197
+ // plan.initialMethod → 'refund' | 'store_credit'; plan.defaultAmount → the approved line total
2198
+ ```
2199
+
2200
+ The single-return reads need `ReadOrderReturn`. `eligibility` reports the return window and never
2201
+ enforces it: an admin may return outside it, and the quantity caps always apply.
2202
+
2203
+ `settlementOptions` is the plan for settling one return, so a head does not re-derive it. It needs
2204
+ `ReadOrderReturn` and `ReadOrder`, because it names the order's payments. A return this store does
2205
+ not hold is a `NotFoundError`.
2206
+
2207
+ - `settleable` and `blockedBy`: `OUTCOME_UNKNOWN` (the refund it waits on was never confirmed;
2208
+ record the provider's answer first, then it either completes or opens again), `ALREADY_SETTLED`,
2209
+ `IN_PROGRESS` (a refund is on its way), `RECEIPT_REQUIRED` or `STATE`.
2210
+ - `refund`: the settled payments a refund may name, each with what the platform's refund position
2211
+ says it can still give back. That figure holds back a refund whose outcome is unknown and money
2212
+ moved to the customer's account. `NO_REFUNDABLE_PAYMENT` when there is none (net terms, among
2213
+ others).
2214
+ - `creditNote`: `ledger: 'AR'` on a store with an accounts-receivable ledger, where the credit note
2215
+ credits the customer's account; `RETAIL` elsewhere, where it credits the order's invoice and
2216
+ moves no money. Unavailable reasons: `ISSUER_UNAVAILABLE`, `CURRENCY_MISMATCH` (AR),
2217
+ `NO_CREDITABLE_INVOICE` (invoice).
2218
+ - `REFUND_ORPHAN_EXISTS` withholds the refund and an AR credit note while a refund of the invoiced
2219
+ order stands that no credit note names: another payout could give the same money back twice.
2220
+ The invoice credit note stays open.
2221
+ - `initialMethod` preselects the credit note when it was asked for or when no refund is possible,
2222
+ as long as it is available.
2223
+
2224
+ It is a plan: the write (`settleReturn`, not in the SDK yet) loads its own facts and judges again.
2225
+ Against an engine that does not serve `returnSettlementOptions` the call fails validation and
2226
+ nothing else does: deploy vendure-server before relying on it.
2227
+
2228
+ ### Deposit policy
2229
+
2230
+ ```ts
2231
+ const policy = await admin.orders.depositPolicy.get();
2232
+ // { enabled: false, minimum: 0, allowZeroDepositPark: false, showParkReasonToCustomer: false,
2233
+ // autoCancelAfterDays: null, storefront: { enabled: false, methods: [], minimumPercent: 0 } }
2234
+
2235
+ await admin.orders.depositPolicy.set({ ...policy, enabled: true, minimum: 2_500 }); // €25.00
2236
+ await admin.orders.depositPolicy.set({
2237
+ ...policy,
2238
+ enabled: true,
2239
+ storefront: { enabled: true, methods: ['bank-transfer'], minimumPercent: 30 },
2240
+ });
2241
+ ```
2242
+
2243
+ Whether the **active** store takes deposits and parks orders (`orders.park`), and whether a shopper
2244
+ may pay a deposit at checkout. Off unless a merchant turns it on: a store that never set one, and a
2245
+ stored value the engine cannot read, answer the defaults above, because `get` reads the policy as
2246
+ the engine applies it. `minimum` is in minor units of the store's DEFAULT currency (an order in
2247
+ another currency is held to it converted at the checkout's rate); `autoCancelAfterDays` is saved
2248
+ but not yet honoured; `storefront` applies only while `enabled` is on too.
2249
+
2250
+ `set` replaces the whole policy: read it, change what you mean to, send it back. Every field is
2251
+ required, only the schema's fields are stored, and a value outside the meta catalog's
2252
+ `order_deposit_policy` bounds is refused before any request with `DEPOSIT_POLICY_INVALID`
2253
+ (`ValidationError`, one `details[]` entry per failing field, such as `storefront.minimumPercent`).
2254
+ The engine does not validate the column, so this check is what keeps a malformed value from
2255
+ silently reading as "deposits off". An absent policy and one set to the defaults mean the same to
2256
+ the engine and the column is never backfilled: write it when a merchant changed it. Writing needs
2257
+ `UpdateChannel`.
2258
+
2065
2259
  ### Payment providers
2066
2260
 
2067
2261
  Onboard the PSP account a store is paid through, place its card terminals, and ask whether it can
@@ -2114,6 +2308,23 @@ response carries a link that can be single-use, an idempotency store keeps a res
2114
2308
  and re-serves it, and retrying is safe anyway — a repeat replaces the pending attempt and answers
2115
2309
  with a fresh url and state, which is what a merchant who lost the first link needs.
2116
2310
 
2311
+ ### Payment methods
2312
+
2313
+ ```ts
2314
+ const page = await admin.payments.methods.list();
2315
+ page.items; // [{ id, code: 'bank-transfer', name: 'Bank transfer', enabled: true, handlerCode: 'manual-payment' }, …]
2316
+
2317
+ for await (const method of await admin.payments.methods.list()) {
2318
+ if (method.enabled) offer(method.code); // the `method` payments.record takes
2319
+ }
2320
+ ```
2321
+
2322
+ The **active** store's payment methods, enabled or not, as staff choose one: `code` is what
2323
+ `payments.record` takes as `method` and what a deposit policy names in `storefront.methods`, and
2324
+ `handlerCode` says what kind of method it is. A page of 100 by default (at most 1,000, or
2325
+ `INVALID_PAGE_SIZE`), walked with `.next()` or `for await`. Needs `ReadSettings` or
2326
+ `ReadPaymentMethod`.
2327
+
2117
2328
  ### Unapplied cash: an overpayment on the customer's account
2118
2329
 
2119
2330
  A customer who paid more than an order bills (a second transfer, an edit that lowered a paid
@@ -3239,6 +3450,89 @@ does not hold on the product, names one twice, repeats a SKU, or has a blank or
3239
3450
  `ORGANIZATION_WORKSPACE_NOT_A_STORE` (422 — called in the workspace) and `ENTITY_NOT_FOUND` (404).
3240
3451
  Requires a platform release that serves `makeLocalCopy`.
3241
3452
 
3453
+ ### Data pipeline set-ups (drafts)
3454
+
3455
+ An import or export set-up is kept on the server while the merchant works through it, so it can be
3456
+ continued later, from another device, by any admin of the store with pipeline rights
3457
+ (`UpdateCatalog`). A draft lives **7 days after its last change**; after that it answers
3458
+ `PIPELINE_DRAFT_NOT_FOUND` and the platform deletes it, together with the connection it created and
3459
+ that connection's stored password, unless a pipeline or another draft uses the connection.
3460
+
3461
+ ```ts
3462
+ const drafts = admin.integrations.pipelines.drafts;
3463
+
3464
+ let draft = await drafts.create({ direction: 'import' }); // step 'connector', the store's zone
3465
+ draft = await drafts.update(draft.id, { version: draft.version, connectorType: 'woocommerce' });
3466
+ draft = await drafts.update(draft.id, { version: draft.version, sourceUrl: 'https://shop.example.com' });
3467
+
3468
+ // The ONE call that takes credentials: probed, then stored encrypted on the connection.
3469
+ draft = await drafts.connect(draft.id, {
3470
+ version: draft.version,
3471
+ credentials: { consumerKey, consumerSecret },
3472
+ });
3473
+ draft = await drafts.discover(draft.id, { version: draft.version });
3474
+ while (draft.discovery?.status === 'pending' || draft.discovery?.status === 'running') {
3475
+ await sleep(2_000);
3476
+ draft = await drafts.get(draft.id);
3477
+ }
3478
+ draft.dependencyWarnings; // [{ entityType: 'orders', missing: ['shipping_methods'] }]
3479
+
3480
+ draft = await drafts.update(draft.id, { version: draft.version, step: 'configure' }); // gets its name
3481
+ const { pipeline, run, runError } = await drafts.commit(
3482
+ draft.id,
3483
+ { version: draft.version, startRun: true },
3484
+ { idempotencyKey: crypto.randomUUID() }
3485
+ );
3486
+ ```
3487
+
3488
+ - **A draft never holds a secret.** Credentials go only to `connect`; a `credentials` key anywhere
3489
+ else is refused. A set-up resumed before its connection exists asks for them again.
3490
+ - **Every change names `version`** and answers the next one. A change made on an older version is a
3491
+ `ConflictError` (`PIPELINE_DRAFT_CONFLICT`, 409) that changed nothing: read the draft again.
3492
+ - `connectorType` starts the connection over (even the same connector), `connect` and
3493
+ `useConnection` let go of the previous connection and its discovery, and what a draft lets go of
3494
+ is deleted when a draft created it and nothing else uses it. `useConnection` never deletes a
3495
+ connection that existed before any draft; one another draft created and still holds becomes
3496
+ this draft's too (`connection.createdByDraft`), and the last draft to let go of it unused
3497
+ deletes it.
3498
+ - The default name and the schedule's zone are the **store's** (`storeTimeZone`), DST-safe.
3499
+ - `dependencies` and `importDependencies()` come from the import writers themselves;
3500
+ `dependencyWarnings` names each enabled entity type whose discovered dependencies are switched
3501
+ off. The words are yours.
3502
+ - What a step still needs is a `BusinessRuleError` (`PIPELINE_DRAFT_INCOMPLETE`, 422) with one
3503
+ `details[]` entry per field; a refused change is a `ValidationError` (`PIPELINE_DRAFT_INVALID`).
3504
+ - A commit with `startRun` whose run does not start keeps the pipeline and answers `runError`.
3505
+ - `triggerFilter`, `siteAnalysis`, `publicCredentials` and a schema map's `sourceSchema`,
3506
+ `targetSchema` and `subCounts` are stored data and keep their keys exactly
3507
+ (`PIPELINE_DRAFT_PRESERVE_KEYS`).
3508
+ - Discovery progress: `discovery.status` is read from the draft; the per-phase progress messages
3509
+ are only on the discovery's event stream.
3510
+
3511
+ ### Searching the store
3512
+
3513
+ One call finds products, orders, customers and content items matching a term: each type's best
3514
+ matches, **most relevant first**, every one already loaded in the store you act on.
3515
+
3516
+ ```ts
3517
+ const found = await admin.search.entities('W4-00087');
3518
+ found.orders[0]; // { id, code, state, orderPlacedAt, totalWithTax, currencyCode, lifecycleStatus }
3519
+
3520
+ const some = await admin.search.entities('serum', { types: ['PRODUCTS', 'CONTENT'], take: 3 });
3521
+ for (const skip of some.skipped) console.log(`${skip.type} not searched: ${skip.reason}`);
3522
+ ```
3523
+
3524
+ - **A type you may not read is skipped and named**, never a refusal: its list is empty and
3525
+ `skipped` says why. `PERMISSION` (no `ReadCatalog`/`ReadProduct`, `ReadOrder` or `ReadCustomer`
3526
+ in the store), `CUSTOMER_DATA_NOT_GRANTED` (an app reading customer data below `full` is never
3527
+ searched for orders or customers, which match customers' names and emails), `UNAVAILABLE` (the
3528
+ search service, or the content system, did not answer).
3529
+ - **Content may be missing when the content system is down**: the other types still answer.
3530
+ Content is published items, plus drafts and archived items for a `ReadCmsContent` holder.
3531
+ - A match the store no longer holds is left out, so a list can be shorter than `take`.
3532
+ - `take` is per type, 1 to 10 (`SEARCH_ENTITIES_MAX_TAKE`), 5 when omitted. A term under 2
3533
+ characters (`SEARCH_ENTITIES_MIN_TERM_LENGTH`) or over 500 once trimmed, a `take` out of range or
3534
+ an empty `types` list is refused with `SEARCH_INPUT_INVALID` (`ValidationError`).
3535
+
3242
3536
  ### Errors
3243
3537
 
3244
3538
  One hierarchy, all re-exported from this package so you need no direct dependency on
@@ -2,7 +2,8 @@ import { type Page, type Transport } from '@sixtyninecommerce/core';
2
2
  import type { JobHandle } from '../jobs/index.js';
3
3
  import type { Call, ListOptions } from '../types.js';
4
4
  import { type ProductBulkInput, type ProductBulkRun } from './products-bulk.js';
5
- import { type AssetRef, type Availability, type AvailabilityInput, type CatalogueOwnership, type FacetValueRef, type LanguageCode, type ProductAvailabilityState, type TranslationInput } from './types.js';
5
+ import { type AssetRef, type Availability, type AvailabilityInput, type CatalogueOwnership, type FacetValueRef, type LanguageCode, type PriceBasis, type ProductAvailabilityState, type TranslationInput, type VariantAvailabilityState } from './types.js';
6
+ import { type ParcelPacking, type VariantBackorderPolicy, type VariantPriceInput } from './variants.js';
6
7
  export interface ProductOptionRef {
7
8
  id: string;
8
9
  code: string;
@@ -67,7 +68,15 @@ export interface CreateProductInput extends AvailabilityInput<ProductAvailabilit
67
68
  assetIds?: string[];
68
69
  /** Replaces the product's facet values on update — send the whole set. */
69
70
  facetValueIds?: string[];
71
+ /**
72
+ * The brand feeds name; at most 70 characters when new or changed (`FEED_IDENTIFIER_INVALID`
73
+ * otherwise). Null or blank clears it. A stored value sent back unchanged is never judged.
74
+ */
75
+ brand?: string | null;
76
+ condition?: ProductCondition;
70
77
  }
78
+ /** A product's condition, as feeds state it. */
79
+ export type ProductCondition = 'new' | 'refurbished' | 'used';
71
80
  /** A PATCH: omitted fields are left alone. */
72
81
  export interface UpdateProductInput extends Partial<CreateProductInput> {
73
82
  id: string;
@@ -195,6 +204,109 @@ export interface ProductCollectionsResult {
195
204
  reason: ProductCollectionNotRemovedReason | (string & {});
196
205
  }>;
197
206
  }
207
+ /** The product's own fields in a {@link ProductsModule.save}; a PATCH. */
208
+ export interface SaveProductFieldsInput extends AvailabilityInput<ProductAvailabilityState> {
209
+ /** In the save's `languageCode`. */
210
+ name?: string;
211
+ slug?: string;
212
+ description?: string;
213
+ enabled?: boolean;
214
+ /** Null: no featured image. */
215
+ featuredAssetId?: string | null;
216
+ /** The product's images, in display order; replaces the list. */
217
+ assetIds?: string[];
218
+ /** At most 70 characters when new or changed; null or blank clears it. */
219
+ brand?: string | null;
220
+ condition?: ProductCondition;
221
+ }
222
+ /**
223
+ * One variant in a {@link ProductsModule.save}; a PATCH. A price (`price` or `prices`) travels
224
+ * with `priceBasis`, the store's basis, exactly as for `catalog.variants.update`.
225
+ */
226
+ export interface SaveProductVariantInput extends AvailabilityInput<VariantAvailabilityState> {
227
+ id: string;
228
+ /** In the save's `languageCode`; trimmed. */
229
+ name?: string;
230
+ sku?: string;
231
+ enabled?: boolean;
232
+ /** Integer minor units of the store's default currency. */
233
+ price?: number;
234
+ /**
235
+ * The price in each named currency. A currency left out keeps its stored price (the list
236
+ * removes nothing), and one equal to what is stored is no change. Its default-currency entry
237
+ * must equal `price` when both are sent.
238
+ */
239
+ prices?: VariantPriceInput[];
240
+ priceBasis?: PriceBasis;
241
+ taxCategoryId?: string;
242
+ /** Compared as a set. */
243
+ facetValueIds?: string[];
244
+ /** Null: no featured image. */
245
+ featuredAssetId?: string | null;
246
+ assetIds?: string[];
247
+ minOrderQuantity?: number | null;
248
+ salesUnit?: number | null;
249
+ /** Integer minor units of the store's default currency. */
250
+ cost?: number | null;
251
+ packageWeightGrams?: number | null;
252
+ packageLengthCm?: number | null;
253
+ packageWidthCm?: number | null;
254
+ packageHeightCm?: number | null;
255
+ /** 1 to 10,000; null: the store's items per parcel. */
256
+ parcelCapacity?: number | null;
257
+ /** Null: `shared`. */
258
+ parcelPacking?: ParcelPacking | null;
259
+ /** Null: `inherit`. */
260
+ backorderPolicy?: VariantBackorderPolicy | null;
261
+ backorderLimit?: number | null;
262
+ /** 8, 12, 13 or 14 digits when new or changed; null or blank clears it. */
263
+ gtin?: string | null;
264
+ mpn?: string | null;
265
+ }
266
+ /** One property value in a {@link ProductsModule.save}. */
267
+ export interface SaveProductPropertyValueInput {
268
+ propertyId: string;
269
+ /** A translatable property's language; the save's `languageCode` when left out. */
270
+ languageCode?: LanguageCode;
271
+ /** Null, `''` or `[]` clears it; a content-item or product property takes a list of ids. */
272
+ value: unknown;
273
+ }
274
+ /** A product page's whole change, for {@link ProductsModule.save}. */
275
+ export interface SaveProductInput {
276
+ id: string;
277
+ /** The editing language: names, slugs, descriptions and text values are written in it. */
278
+ languageCode: LanguageCode;
279
+ product?: SaveProductFieldsInput;
280
+ variants?: SaveProductVariantInput[];
281
+ /** The collections the product is in by hand: the whole wanted list. */
282
+ collectionIds?: string[];
283
+ /**
284
+ * The collections the caller last read the product in: only the difference is applied, so a
285
+ * change someone else made meanwhile stands (as `setCollections`' `from`). Needs `collectionIds`.
286
+ */
287
+ fromCollectionIds?: string[];
288
+ /** An absent property keeps its value. */
289
+ propertyValues?: SaveProductPropertyValueInput[];
290
+ }
291
+ /**
292
+ * Something a save did not do although everything it wrote is saved. A string on the wire: a
293
+ * newer engine may add a code.
294
+ * - `CONTENT_LABELS_NOT_SYNCED`: the content system did not answer, so the filter values of the
295
+ * properties in `propertyIds` were saved without their titles; run
296
+ * `catalog.syncContentFacets(propertyId)` on them once it is back.
297
+ */
298
+ export interface SaveProductWarning {
299
+ code: 'CONTENT_LABELS_NOT_SYNCED' | (string & {});
300
+ message: string;
301
+ propertyIds: string[];
302
+ }
303
+ export interface SaveProductResult {
304
+ /** The product as saved. */
305
+ product: Product;
306
+ /** What the collection step did; null when the save changed no collection. */
307
+ collections: ProductCollectionsResult | null;
308
+ warnings: SaveProductWarning[];
309
+ }
198
310
  /**
199
311
  * Product-level catalog writes. Reading needs `ReadCatalog` (or `ReadProduct`),
200
312
  * creating `CreateCatalog`, updating `UpdateCatalog`, deleting `DeleteCatalog`.
@@ -403,6 +515,54 @@ export interface ProductsModule {
403
515
  setCollections(productId: string, collectionIds: readonly string[], options?: Call & {
404
516
  from?: readonly string[];
405
517
  }): Promise<ProductCollectionsResult>;
518
+ /**
519
+ * Save a product page's whole change in ONE engine transaction: all of it, or none of it
520
+ * (headless spec §7 `products.save`). In this order: the variants, the hand-picked collections
521
+ * (as {@link setCollections}), the property values (and the filter-label sync of each
522
+ * filterable product property), the product. Every step's check runs before the first write.
523
+ *
524
+ * A PATCH with an echo rule: only what the input names is compared, and only what differs from
525
+ * what the store holds is written. A head may send the whole form: an unchanged value is not a
526
+ * write, so it never records a change of its field group, and a product the store's
527
+ * organization shares is not refused for a shared field sent back unchanged.
528
+ *
529
+ * Labels of content-item values are read from the content system before the transaction. When
530
+ * it does not answer, the save still goes through and `warnings` names the properties whose
531
+ * filter values were saved without their titles.
532
+ *
533
+ * Needs `UpdateCatalog` or `UpdateProduct`, plus `UpdateCatalog` or `UpdateCollection` when it
534
+ * changes collections and `UpdateCatalog` when it changes property values. `idempotencyKey`: a
535
+ * repeat answers the first call's result. Platform releases before this method do not have the
536
+ * mutation and refuse it as `ValidationError` `GRAPHQL_VALIDATION_FAILED` (400): deploy the
537
+ * engine first.
538
+ *
539
+ * Refusals (nothing is written), GraphQL errors mapped by `core`:
540
+ * - `PRICE_BASIS_REQUIRED` / `PRICE_BASIS_MISMATCH` / `INVALID_PRICE` → `ValidationError`
541
+ * (400): a variant price without the store's basis; refused before sending.
542
+ * - `PRODUCT_SAVE_INPUT_INVALID` → `ValidationError` (400): the input names nothing, names a
543
+ * variant, currency or property value twice, uses a language the store does not offer, gives
544
+ * a value outside its field's range, or `price` and the default-currency entry of `prices`
545
+ * differ. `error.details` names each (`field` is a pointer into the call).
546
+ * - `PRODUCT_COLLECTIONS_INPUT_INVALID` → `ValidationError` (400): a collection list names one
547
+ * twice, or more than {@link MAX_PRODUCT_COLLECTION_IDS}; refused before sending too.
548
+ * - `FEED_IDENTIFIER_INVALID` → `ValidationError` (400): a new or changed brand over 70
549
+ * characters, or a GTIN of another shape.
550
+ * - `ENTITY_NOT_FOUND` → `NotFoundError` (404): a product, variant (of another product
551
+ * included), collection or property this store does not hold.
552
+ * - `FORBIDDEN` → `PermissionDeniedError` (403): a step's permission above is missing.
553
+ * - `SHARED_PRODUCT_NOT_ALLOWED`, `SHARED_PRODUCT_VARIANT_NOT_ALLOWED`,
554
+ * `SHARED_COLLECTION_NOT_ALLOWED` → `PermissionDeniedError` (403): a row another store also
555
+ * holds, which you cannot change there.
556
+ * - `MANAGED_BY_ORGANIZATION` → `BusinessRuleError` (422): a CHANGED field of a product, variant,
557
+ * collection or property value the store's organization holds. The store's own price, its own
558
+ * facet values, its own collections and its own property values pass.
559
+ * - `FIELD_GROUP_LOCKED` → `BusinessRuleError` (422): a field group an app or API token owns.
560
+ * - `PRODUCT_COLLECTION_FILTER_INVALID` → `ValidationError` (400): a collection whose
561
+ * hand-picked list cannot be read.
562
+ * - `EXTERNAL_LINE_CARRIER_PROTECTED` → `BusinessRuleError` (422): the platform's carrier.
563
+ * - `INVALID_TAX_CATEGORY_FOR_CLASS` → `ValidationError` (400).
564
+ */
565
+ save(input: SaveProductInput, options?: Call): Promise<SaveProductResult>;
406
566
  /**
407
567
  * Enable, disable, delete or clone up to `MAX_BULK_PRODUCTS` (50) products of the store in ONE
408
568
  * call (headless spec §7 `products.bulkClone`; §6.11).
@@ -472,6 +632,7 @@ export declare const SET_ORGANIZATION_PRODUCT_AVAILABILITY_MUTATION = "\n mutat
472
632
  export declare const ORGANIZATION_PRODUCT_AVAILABILITY_QUERY = "\n query CatalogOrganizationProductAvailability($productIds: [ID!]!, $channelIds: [ID!]!) {\n organizationProductAvailability(productIds: $productIds, channelIds: $channelIds) {\n productId\n channelId\n state\n localCopyId\n }\n }\n";
473
633
  export declare const MAKE_LOCAL_COPY_MUTATION = "\n mutation CatalogMakeLocalCopy($productId: ID!, $skus: [LocalCopySkuInput!]) {\n makeLocalCopy(productId: $productId, skus: $skus) {\n product { \n id\n \n catalogueOwnership {\n state\n organizationName\n editableHere\n }\n\n createdAt\n updatedAt\n languageCode\n name\n slug\n description\n enabled\n featuredAsset { \n id\n name\n preview\n source\n }\n assets { \n id\n name\n preview\n source\n }\n facetValues { \n id\n code\n name\n facet {\n id\n code\n name\n }\n }\n optionGroups {\n id\n code\n name\n options {\n id\n code\n name\n }\n }\n translations {\n id\n languageCode\n name\n slug\n description\n }\n excludedFromStore\n variantList(options: { take: 1 }) {\n totalItems\n }\n customFields {\n availabilityState\n availableFrom\n preorderLimit\n }\n }\n originalProductId\n original\n droppedOrganizationPropertyCount\n }\n }\n";
474
634
  export declare const SET_PRODUCT_COLLECTIONS_MUTATION = "\n mutation CatalogSetProductCollections(\n $productId: ID!\n $collectionIds: [ID!]!\n $fromCollectionIds: [ID!]\n ) {\n setProductCollections(\n productId: $productId\n collectionIds: $collectionIds\n fromCollectionIds: $fromCollectionIds\n ) {\n added\n removed\n unchanged\n notRemoved {\n collectionId\n reason\n }\n }\n }\n";
635
+ export declare const SAVE_PRODUCT_MUTATION = "\n mutation CatalogSaveProduct($input: SaveProductInput!, $idempotencyKey: String) {\n saveProduct(input: $input, idempotencyKey: $idempotencyKey) {\n product { \n id\n \n catalogueOwnership {\n state\n organizationName\n editableHere\n }\n\n createdAt\n updatedAt\n languageCode\n name\n slug\n description\n enabled\n featuredAsset { \n id\n name\n preview\n source\n }\n assets { \n id\n name\n preview\n source\n }\n facetValues { \n id\n code\n name\n facet {\n id\n code\n name\n }\n }\n optionGroups {\n id\n code\n name\n options {\n id\n code\n name\n }\n }\n translations {\n id\n languageCode\n name\n slug\n description\n }\n excludedFromStore\n variantList(options: { take: 1 }) {\n totalItems\n }\n customFields {\n availabilityState\n availableFrom\n preorderLimit\n }\n }\n collections {\n added\n removed\n unchanged\n notRemoved {\n collectionId\n reason\n }\n }\n warnings {\n code\n message\n propertyIds\n }\n }\n }\n";
475
636
  /** The product as the Admin API answers it, before `toProduct`. */
476
637
  export interface WireProduct extends Omit<Product, keyof Availability<string> | 'variantCount'> {
477
638
  variantList: {
@@ -1,6 +1,7 @@
1
1
  import { ValidationError } from '@sixtyninecommerce/core';
2
2
  import { bulkProducts, readProductBulkRun, } from './products-bulk.js';
3
3
  import { ASSET_REF_FIELDS, assertDeleted, availabilityCustomFields, CATALOGUE_OWNERSHIP_FIELDS, DELETION_FIELDS, FACET_VALUE_REF_FIELDS, fieldOf, unwrapResult, vendureListPage, } from './types.js';
4
+ import { assertPricesStateBasis, } from './variants.js';
4
5
  /** The engine's bound on one `exclude` or `include` call (`STORE_EXCLUSION_BATCH_LIMIT`). */
5
6
  export const MAX_STORE_EXCLUSION_PRODUCTS = 500;
6
7
  /**
@@ -168,6 +169,27 @@ export const SET_PRODUCT_COLLECTIONS_MUTATION = /* GraphQL */ `
168
169
  }
169
170
  }
170
171
  `;
172
+ export const SAVE_PRODUCT_MUTATION = /* GraphQL */ `
173
+ mutation CatalogSaveProduct($input: SaveProductInput!, $idempotencyKey: String) {
174
+ saveProduct(input: $input, idempotencyKey: $idempotencyKey) {
175
+ product { ${PRODUCT_FIELDS} }
176
+ collections {
177
+ added
178
+ removed
179
+ unchanged
180
+ notRemoved {
181
+ collectionId
182
+ reason
183
+ }
184
+ }
185
+ warnings {
186
+ code
187
+ message
188
+ propertyIds
189
+ }
190
+ }
191
+ }
192
+ `;
171
193
  /**
172
194
  * Lift the availability custom fields to first-class fields. A server older
173
195
  * than the pre-order engine has no such fields and would refuse the document
@@ -184,12 +206,16 @@ export function toProduct(wire) {
184
206
  };
185
207
  }
186
208
  function toWireInput(input) {
187
- const { availabilityState, availableFrom, preorderLimit, ...rest } = input;
209
+ const { availabilityState, availableFrom, preorderLimit, brand, condition, ...rest } = input;
188
210
  const customFields = availabilityCustomFields({
189
211
  availabilityState,
190
212
  availableFrom,
191
213
  preorderLimit,
192
214
  });
215
+ if (brand !== undefined)
216
+ customFields.brand = brand;
217
+ if (condition !== undefined)
218
+ customFields.condition = condition;
193
219
  return Object.keys(customFields).length > 0 ? { ...rest, customFields } : rest;
194
220
  }
195
221
  export function productFilter(filter) {
@@ -355,6 +381,15 @@ export function createProductsModule(transport) {
355
381
  fromCollectionIds: from === undefined ? null : [...from],
356
382
  }, call);
357
383
  },
384
+ async save(input, options) {
385
+ assertCollectionLists({
386
+ collectionIds: input.collectionIds,
387
+ fromCollectionIds: input.fromCollectionIds,
388
+ });
389
+ await assertPricesStateBasis(transport, input.variants ?? [], options);
390
+ const result = await fieldOf(transport, SAVE_PRODUCT_MUTATION, 'saveProduct', { input: toWireSaveInput(input), idempotencyKey: options?.idempotencyKey ?? null }, options);
391
+ return { ...result, product: toProduct(result.product) };
392
+ },
358
393
  bulk(input, options) {
359
394
  return bulkProducts(transport, input, options);
360
395
  },
@@ -363,3 +398,61 @@ export function createProductsModule(transport) {
363
398
  },
364
399
  };
365
400
  }
401
+ /** The variant fields a save writes as custom fields on the wire. */
402
+ const SAVE_VARIANT_CUSTOM_FIELDS = [
403
+ 'minOrderQuantity',
404
+ 'salesUnit',
405
+ 'cost',
406
+ 'packageWeightGrams',
407
+ 'packageLengthCm',
408
+ 'packageWidthCm',
409
+ 'packageHeightCm',
410
+ 'parcelCapacity',
411
+ 'parcelPacking',
412
+ 'backorderPolicy',
413
+ 'backorderLimit',
414
+ 'gtin',
415
+ 'mpn',
416
+ ];
417
+ /** The SDK's flat save as the engine's `SaveProductInput`: custom fields nested, no basis. */
418
+ function toWireSaveInput(input) {
419
+ const wire = { id: input.id, languageCode: input.languageCode };
420
+ if (input.product) {
421
+ const { availabilityState, availableFrom, preorderLimit, brand, condition, ...rest } = input.product;
422
+ const customFields = availabilityCustomFields({
423
+ availabilityState,
424
+ availableFrom,
425
+ preorderLimit,
426
+ });
427
+ if (brand !== undefined)
428
+ customFields.brand = brand;
429
+ if (condition !== undefined)
430
+ customFields.condition = condition;
431
+ wire.product = Object.keys(customFields).length > 0 ? { ...rest, customFields } : rest;
432
+ }
433
+ if (input.variants) {
434
+ wire.variants = input.variants.map((variant) => {
435
+ const { availabilityState, availableFrom, preorderLimit, priceBasis, ...rest } = variant;
436
+ void priceBasis;
437
+ const customFields = availabilityCustomFields({
438
+ availabilityState,
439
+ availableFrom,
440
+ preorderLimit,
441
+ });
442
+ const out = { ...rest };
443
+ for (const key of SAVE_VARIANT_CUSTOM_FIELDS) {
444
+ if (key in out) {
445
+ if (out[key] !== undefined)
446
+ customFields[key] = out[key];
447
+ delete out[key];
448
+ }
449
+ }
450
+ return Object.keys(customFields).length > 0 ? { ...out, customFields } : out;
451
+ });
452
+ }
453
+ for (const key of ['collectionIds', 'fromCollectionIds', 'propertyValues']) {
454
+ if (input[key] !== undefined)
455
+ wire[key] = input[key];
456
+ }
457
+ return wire;
458
+ }