@vivoa/partner-sdk 0.3.0 → 0.4.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/CHANGELOG.md CHANGED
@@ -1,5 +1,17 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.4.0
4
+
5
+ Store setup (plugins, locations, catalog) + a contract re-alignment with the platform.
6
+
7
+ - feat: `merchants.importLocations()` and `provisionStore({ locations })` — upsert inventory locations by `code`, seeded **before** products so `inventory[].locationCode` resolves.
8
+ - feat: `merchants.importProducts()` and `provisionStore({ products })` — catalog seeding with URL images and per-country or per-location stock. `ProvisionProduct.channels` selects sales channels.
9
+ - feat: `merchants.configurePlugins()` and `provisionStore({ plugins })` — install + configure plugins (idempotent, doubles as re-sync).
10
+ - feat: `MerchantStatus` now carries `provisionedAt` and `lastError`; `VivoaProvisioningFailedError` surfaces the platform's `lastError` as its reason.
11
+ - **breaking:** `CreateMerchantInput.idempotencyKey` is now the dedup key (unique per operator). `externalId` becomes a non-unique CLIENT reference. `provisionStore()` now requires `idempotencyKey` (was `externalId`).
12
+ - **breaking:** `ImportProductsResult` is now `{ total, created, updated, skipped, errors[] }` (was `{ created, updated, skipped, failed }`) — matches the platform and exposes per-row failure reasons.
13
+ - **breaking:** `ProductTypeSlug` narrowed to `'physical' | 'service' | 'digital'` (was `string`); unknown slugs fall back to `physical` store-side.
14
+
3
15
  ## 0.1.1
4
16
 
5
17
  - fix: `provisionStore` now throws `VivoaProvisioningFailedError` on `'unknown'` runtime instead of returning a broken store silently.
package/README.md CHANGED
@@ -34,19 +34,20 @@ const vivoa = createPartnerClient({
34
34
  apiSecret: process.env.VIVOA_PARTNER_SECRET!,
35
35
  });
36
36
 
37
- // Provision a store for one of your users — idempotent on externalId
37
+ // Provision a store for one of your users — idempotent on idempotencyKey
38
38
  const { merchant, status } = await vivoa.provisionStore({
39
- externalId: 'cus_9f3a2e10', // your internal reference — customer id, order id, etc.
40
- email: 'owner@acme.com',
41
- name: 'Acme',
42
- countries: ['SV'],
39
+ idempotencyKey: 'order_42', // safe-retry key, unique per operator
40
+ externalId: 'cus_9f3a2e10', // your CLIENT reference — may repeat across stores
41
+ email: 'owner@acme.com',
42
+ name: 'Acme',
43
+ countries: ['SV'],
43
44
  });
44
45
 
45
46
  console.log(status.domain); // store is live at this domain
46
47
  console.log(merchant.handle); // Vivoa-assigned handle, guaranteed unique
47
48
  ```
48
49
 
49
- `externalId` is your key. Use it to find the store later (`findByExternalId`), and call `provisionStore` again safely — it won't create a duplicate.
50
+ `idempotencyKey` is your retry key: call `provisionStore` again with the same key and it resumes the same store — never a duplicate. `externalId` is a separate, non-unique reference to YOUR client (one client can own several stores); list them later with `merchants.list({ externalId })`.
50
51
 
51
52
  ---
52
53
 
@@ -54,12 +55,11 @@ console.log(merchant.handle); // Vivoa-assigned handle, guaranteed unique
54
55
 
55
56
  One call drives the entire lifecycle:
56
57
 
57
- 1. Looks up `externalId` in your fleet — if it exists, resumes from current state.
58
- 2. Creates the store if it doesn't exist (Vivoa assigns the `handle` from `name`).
59
- 3. Activates if `not_activated` or `failed`.
60
- 4. Polls until settled if `creating`.
61
- 5. Recovers from a dropped `activate` by reading `GET /status`.
62
- 6. Never silently retries a `failed` — throws `VivoaProvisioningFailedError` so you decide what happens next.
58
+ 1. Creates the store (Vivoa assigns the `handle` from `name`). The platform dedups on `idempotencyKey`, so a replay resumes the same store from its current state instead of creating a second one.
59
+ 2. Activates if `not_activated` or `failed`.
60
+ 3. Polls until settled if `creating`.
61
+ 4. Recovers from a dropped `activate` by reading `GET /status`.
62
+ 5. Never silently retries a `failed` — throws `VivoaProvisioningFailedError` so you decide what happens next.
63
63
 
64
64
  ---
65
65
 
@@ -90,7 +90,7 @@ All errors extend `VivoaError`. HTTP errors are `VivoaApiError` with `status`, `
90
90
  | `VivoaRateLimitError` (429) | Plan request limit exceeded |
91
91
  | `VivoaServerError` · `VivoaNetworkError` · `VivoaTimeoutError` · `VivoaProvisioningFailedError` | Server, network, polling, or provisioning failures |
92
92
 
93
- **Retry policy:** `GET` only, on 429 / 5xx / network errors, with exponential backoff. Writes are not retried at the transport level — but `merchants.create()` and `provisionStore()` are safe to repeat thanks to `externalId` idempotency.
93
+ **Retry policy:** `GET` only, on 429 / 5xx / network errors, with exponential backoff. Writes are not retried at the transport level — but `merchants.create()` and `provisionStore()` are safe to repeat thanks to `idempotencyKey` idempotency.
94
94
 
95
95
  ---
96
96
 
@@ -61,6 +61,7 @@ export declare class FakeVivoa implements HttpTransport {
61
61
  private readonly operators;
62
62
  private readonly merchants;
63
63
  private readonly handles;
64
+ private readonly merchantLocations;
64
65
  constructor(options?: FakeVivoaOptions);
65
66
  addOperator(seed: FakeOperatorSeed): OperatorProfile;
66
67
  setOperatorStatus(apiKey: string, status: OperatorStatus): void;
@@ -16,7 +16,7 @@ class HttpError extends Error {
16
16
  }
17
17
  }
18
18
  /** `POST /merchants` body — matches CreateMerchantDto's whitelist exactly (a stale `handle` 400s, like on the real platform). */
19
- const CREATE_MERCHANT_KEYS = new Set(['email', 'name', 'countries', 'industry', 'externalId']);
19
+ const CREATE_MERCHANT_KEYS = new Set(['email', 'name', 'countries', 'industry', 'externalId', 'idempotencyKey']);
20
20
  /** Mirrors `shared/utils/slugify.ts` on the platform closely enough for a fake. */
21
21
  function slugify(value) {
22
22
  return value
@@ -54,6 +54,7 @@ class FakeVivoa {
54
54
  operators = new Map();
55
55
  merchants = new Map();
56
56
  handles = new Set();
57
+ merchantLocations = new Map();
57
58
  constructor(options = {}) {
58
59
  this.clock = options.clock ?? system_clock_ts_1.systemClock;
59
60
  this.crypto = options.crypto ?? node_crypto_ts_1.nodeCrypto;
@@ -155,7 +156,7 @@ class FakeVivoa {
155
156
  async route(op, method, url, rawBody) {
156
157
  const path = url.pathname.replace(/^\/partner\/v1/, '');
157
158
  const body = rawBody ? JSON.parse(rawBody) : {};
158
- const merchantRoute = path.match(/^\/merchants\/([^/]+)\/(status|activate|suspend|reactivate|admin-ticket|plugins|products)$/);
159
+ const merchantRoute = path.match(/^\/merchants\/([^/]+)\/(status|activate|suspend|reactivate|admin-ticket|plugins|locations|products)$/);
159
160
  if (method === 'GET' && path === '/me')
160
161
  return this.profile(op);
161
162
  if (method === 'POST' && path === '/me/rotate-secret') {
@@ -212,9 +213,27 @@ class FakeVivoa {
212
213
  })),
213
214
  };
214
215
  }
216
+ if (action === 'locations') {
217
+ const locations = Array.isArray(body.locations) ? body.locations : [];
218
+ if (locations.length === 0)
219
+ throw new HttpError(400, 'locations must not be empty');
220
+ const m = this.merchantLocations.get(merchant.id) ?? [];
221
+ let created = 0;
222
+ let updated = 0;
223
+ for (const loc of locations) {
224
+ if (m.some((l) => l === loc.code))
225
+ updated++;
226
+ else {
227
+ m.push(loc.code);
228
+ created++;
229
+ }
230
+ }
231
+ this.merchantLocations.set(merchant.id, m);
232
+ return { total: locations.length, created, updated, errors: [] };
233
+ }
215
234
  if (action === 'products') {
216
235
  const products = Array.isArray(body.products) ? body.products : [];
217
- return { created: products.length, updated: 0, skipped: 0, failed: 0 };
236
+ return { total: products.length, created: products.length, updated: 0, skipped: 0, errors: [] };
218
237
  }
219
238
  }
220
239
  }
@@ -232,6 +251,7 @@ class FakeVivoa {
232
251
  const name = String(body.name ?? '');
233
252
  const countries = Array.isArray(body.countries) ? body.countries.map(String) : [];
234
253
  const externalId = body.externalId === undefined ? undefined : String(body.externalId);
254
+ const idempotencyKey = body.idempotencyKey === undefined ? undefined : String(body.idempotencyKey);
235
255
  const problems = [];
236
256
  if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email))
237
257
  problems.push('email must be an email');
@@ -242,11 +262,17 @@ class FakeVivoa {
242
262
  if (externalId !== undefined && externalId.length > merchant_ts_1.MAX_EXTERNAL_ID_LENGTH) {
243
263
  problems.push(`externalId must be shorter than or equal to ${merchant_ts_1.MAX_EXTERNAL_ID_LENGTH} characters`);
244
264
  }
265
+ if (idempotencyKey !== undefined && idempotencyKey.length > merchant_ts_1.MAX_IDEMPOTENCY_KEY_LENGTH) {
266
+ problems.push(`idempotencyKey must be shorter than or equal to ${merchant_ts_1.MAX_IDEMPOTENCY_KEY_LENGTH} characters`);
267
+ }
245
268
  if (problems.length)
246
269
  throw new HttpError(400, problems.join('; '));
247
- // Idempotent replay BEFORE quota/region checks — mirrors the platform.
248
- if (externalId) {
249
- const existing = this.fleet(op).find((m) => m.externalId === externalId);
270
+ // Idempotent replay BEFORE quota/region checks — mirrors the platform. The
271
+ // dedup key is idempotencyKey (unique per operator), NOT externalId: that is
272
+ // a non-unique client reference and may map to several of the operator's
273
+ // stores.
274
+ if (idempotencyKey) {
275
+ const existing = this.fleet(op).find((m) => m.idempotencyKey === idempotencyKey);
250
276
  if (existing)
251
277
  return publicMerchant(existing);
252
278
  }
@@ -271,6 +297,9 @@ class FakeVivoa {
271
297
  createdAt: new Date(this.clock.nowMs()).toISOString(),
272
298
  operatorId: op.id,
273
299
  pollsLeft: 0,
300
+ idempotencyKey: idempotencyKey ?? null,
301
+ provisionedAt: null,
302
+ lastError: null,
274
303
  };
275
304
  this.handles.add(merchant.handle);
276
305
  this.merchants.set(merchant.id, merchant);
@@ -305,6 +334,7 @@ class FakeVivoa {
305
334
  switch (this.activation) {
306
335
  case 'fail':
307
336
  m.runtime = 'failed';
337
+ m.lastError = this.failureReason;
308
338
  this.emit(op, 'merchant.failed', m, { reason: this.failureReason });
309
339
  throw new HttpError(500, this.failureReason);
310
340
  case 'slow':
@@ -323,6 +353,8 @@ class FakeVivoa {
323
353
  goLive(m) {
324
354
  m.runtime = 'live';
325
355
  m.domain = `${m.handle}.vivoa.store`;
356
+ m.provisionedAt = new Date(this.clock.nowMs()).toISOString();
357
+ m.lastError = null;
326
358
  const op = [...this.operators.values()].find((o) => o.id === m.operatorId);
327
359
  if (op)
328
360
  this.emit(op, 'merchant.live', m);
@@ -341,6 +373,8 @@ class FakeVivoa {
341
373
  deployStatus: m.runtime,
342
374
  domain: m.domain,
343
375
  apiBaseUrl: m.runtime === 'live' ? 'https://shared-api.vivoa.app' : null,
376
+ provisionedAt: m.provisionedAt,
377
+ lastError: m.runtime === 'failed' ? m.lastError : null,
344
378
  };
345
379
  }
346
380
  list(op, qs) {
@@ -471,7 +505,7 @@ class FakeVivoa {
471
505
  }
472
506
  exports.FakeVivoa = FakeVivoa;
473
507
  function publicMerchant(m) {
474
- const { operatorId: _op, pollsLeft: _polls, ...view } = m;
508
+ const { operatorId: _op, pollsLeft: _polls, idempotencyKey: _key, provisionedAt: _prov, lastError: _err, ...view } = m;
475
509
  return { ...view, countries: [...view.countries] };
476
510
  }
477
511
  function webhookView(op) {
@@ -1,5 +1,5 @@
1
1
  import type { SignedHttpClient } from '../signed-http.ts';
2
- import { type AdminTicketOptions, type CreateMerchantInput, type ImportProductsResult, type ListMerchantsQuery, type Merchant, type MerchantAdminTicket, type MerchantStatus, type Page, type PluginProvisionInput, type PluginProvisionResult, type ProvisionProduct } from '../../domain/merchant.ts';
2
+ import { type AdminTicketOptions, type CreateMerchantInput, type ImportLocationsResult, type ImportProductsResult, type ListMerchantsQuery, type Merchant, type MerchantAdminTicket, type MerchantStatus, type Page, type PluginProvisionInput, type PluginProvisionResult, type ProvisionLocation, type ProvisionProduct } from '../../domain/merchant.ts';
3
3
  /** Provisioning is synchronous on the platform: allow it minutes, not seconds. */
4
4
  export declare const DEFAULT_ACTIVATE_TIMEOUT_MS: number;
5
5
  export declare class MerchantsResource {
@@ -7,8 +7,9 @@ export declare class MerchantsResource {
7
7
  constructor(http: SignedHttpClient);
8
8
  /**
9
9
  * Creates owner + store in your fleet. Not live until `activate`. The
10
- * platform assigns the handle. Pass `externalId` and replaying the same
10
+ * platform assigns the handle. Pass `idempotencyKey` and replaying the same
11
11
  * value returns the merchant you already created — never a second one.
12
+ * `externalId` is a non-unique client reference, not a dedup key.
12
13
  */
13
14
  create(input: CreateMerchantInput): Promise<Merchant>;
14
15
  list(query?: ListMerchantsQuery): Promise<Page<Merchant>>;
@@ -47,6 +48,13 @@ export declare class MerchantsResource {
47
48
  configurePlugins(merchantId: string, plugins: PluginProvisionInput[]): Promise<{
48
49
  plugins: PluginProvisionResult[];
49
50
  }>;
51
+ /**
52
+ * Bulk-imports inventory locations (warehouses, pickup points) into the
53
+ * merchant's store. Upserted on `code` — idempotent, re-running updates
54
+ * rather than duplicates. Import locations **before** products when you
55
+ * reference `inventory[].locationCode`. 409 if the merchant is not live.
56
+ */
57
+ importLocations(merchantId: string, locations: ProvisionLocation[]): Promise<ImportLocationsResult>;
50
58
  /**
51
59
  * Bulk-imports products into the merchant's catalog (URL images, per-country
52
60
  * inventory). Idempotent on `handle`, so re-running updates rather than
@@ -15,8 +15,9 @@ class MerchantsResource {
15
15
  }
16
16
  /**
17
17
  * Creates owner + store in your fleet. Not live until `activate`. The
18
- * platform assigns the handle. Pass `externalId` and replaying the same
18
+ * platform assigns the handle. Pass `idempotencyKey` and replaying the same
19
19
  * value returns the merchant you already created — never a second one.
20
+ * `externalId` is a non-unique client reference, not a dedup key.
20
21
  */
21
22
  async create(input) {
22
23
  validateCreate(input);
@@ -84,6 +85,19 @@ class MerchantsResource {
84
85
  body: { plugins },
85
86
  });
86
87
  }
88
+ /**
89
+ * Bulk-imports inventory locations (warehouses, pickup points) into the
90
+ * merchant's store. Upserted on `code` — idempotent, re-running updates
91
+ * rather than duplicates. Import locations **before** products when you
92
+ * reference `inventory[].locationCode`. 409 if the merchant is not live.
93
+ */
94
+ async importLocations(merchantId, locations) {
95
+ if (locations.length === 0)
96
+ throw new errors_ts_1.VivoaValidationError('locations must not be empty');
97
+ return this.http.request('POST', `/partner/v1/merchants/${encodeId(merchantId)}/locations`, {
98
+ body: { locations },
99
+ });
100
+ }
87
101
  /**
88
102
  * Bulk-imports products into the merchant's catalog (URL images, per-country
89
103
  * inventory). Idempotent on `handle`, so re-running updates rather than
@@ -122,6 +136,13 @@ function validateCreate(input) {
122
136
  problems.push(`externalId must be at most ${merchant_ts_1.MAX_EXTERNAL_ID_LENGTH} chars`);
123
137
  }
124
138
  }
139
+ if (input.idempotencyKey !== undefined) {
140
+ if (!input.idempotencyKey.trim())
141
+ problems.push('idempotencyKey must not be empty');
142
+ else if (input.idempotencyKey.length > merchant_ts_1.MAX_IDEMPOTENCY_KEY_LENGTH) {
143
+ problems.push(`idempotencyKey must be at most ${merchant_ts_1.MAX_IDEMPOTENCY_KEY_LENGTH} chars`);
144
+ }
145
+ }
125
146
  if (problems.length > 0)
126
147
  throw new errors_ts_1.VivoaValidationError(problems.join('; '));
127
148
  }
@@ -1,6 +1,6 @@
1
1
  import type { Clock } from '../../ports/clock.port.ts';
2
2
  import type { MerchantsResource } from '../resources/merchants.ts';
3
- import { type CreateMerchantInput, type ImportProductsResult, type Merchant, type MerchantRuntimeStatus, type MerchantStatus, type PluginProvisionInput, type PluginProvisionResult, type ProvisionProduct } from '../../domain/merchant.ts';
3
+ import { type CreateMerchantInput, type ImportLocationsResult, type ImportProductsResult, type Merchant, type MerchantRuntimeStatus, type MerchantStatus, type PluginProvisionInput, type PluginProvisionResult, type ProvisionLocation, type ProvisionProduct } from '../../domain/merchant.ts';
4
4
  export type ProvisionPhase = 'found' | 'created' | 'activating' | 'waiting' | 'seeding' | 'settled';
5
5
  export interface ProvisionProgress {
6
6
  phase: ProvisionPhase;
@@ -24,28 +24,40 @@ export interface ProvisionStoreOptions {
24
24
  * resolves — the result carries the error and the caller can re-run.
25
25
  */
26
26
  plugins?: PluginProvisionInput[];
27
+ /**
28
+ * Inventory locations (warehouses, pickup points) to import once the store
29
+ * is live. Seeded **before** products so `inventory[].locationCode`
30
+ * references resolve. Best-effort.
31
+ */
32
+ locations?: ProvisionLocation[];
27
33
  /** Products to seed into the catalog once the store is live. Best-effort. */
28
34
  products?: ProvisionProduct[];
29
35
  }
30
36
  export interface ProvisionStoreResult {
31
37
  merchant: Merchant;
32
38
  status: MerchantStatus;
33
- /** false when a merchant with this externalId already existed in your fleet. */
39
+ /**
40
+ * false when this call replayed a merchant already past `not_activated` (a
41
+ * definite prior run). A fresh create — or a replay of a merchant that never
42
+ * activated — reads `true`.
43
+ */
34
44
  created: boolean;
35
45
  /** Per-plugin seed outcome, when `options.plugins` was supplied. */
36
46
  plugins?: PluginProvisionResult[];
47
+ /** Location import outcome, when `options.locations` was supplied. */
48
+ locations?: ImportLocationsResult;
37
49
  /** Catalog seed outcome, when `options.products` was supplied. */
38
50
  products?: ImportProductsResult;
39
51
  }
40
52
  /**
41
53
  * "Give me a live store" — the one call most partners need.
42
54
  *
43
- * Requires `input.externalId`: it is what makes retrying safe. Re-running
44
- * after a crash, a timeout or a double click resumes the same merchant
45
- * instead of creating a second one — the platform is idempotent on it (see
46
- * `PARTNERS.md` §5.2), and it never runs out (unlike a handle, which the
47
- * platform now assigns and a partner never sees before creation). Never
48
- * retries a failed activation on its own.
55
+ * Requires `input.idempotencyKey`: it is what makes retrying safe. Re-running
56
+ * after a crash, a timeout or a double click resumes the same merchant instead
57
+ * of creating a second one — the platform is idempotent on it (see
58
+ * `PARTNERS.md` §5.2). `externalId` is a separate, non-unique client reference
59
+ * (one client may own several stores) and is NOT a dedup key. Never retries a
60
+ * failed activation on its own.
49
61
  */
50
62
  export declare class ProvisionStoreUseCase {
51
63
  private readonly merchants;
@@ -8,12 +8,12 @@ const DEFAULT_POLL_MS = 5_000;
8
8
  /**
9
9
  * "Give me a live store" — the one call most partners need.
10
10
  *
11
- * Requires `input.externalId`: it is what makes retrying safe. Re-running
12
- * after a crash, a timeout or a double click resumes the same merchant
13
- * instead of creating a second one — the platform is idempotent on it (see
14
- * `PARTNERS.md` §5.2), and it never runs out (unlike a handle, which the
15
- * platform now assigns and a partner never sees before creation). Never
16
- * retries a failed activation on its own.
11
+ * Requires `input.idempotencyKey`: it is what makes retrying safe. Re-running
12
+ * after a crash, a timeout or a double click resumes the same merchant instead
13
+ * of creating a second one — the platform is idempotent on it (see
14
+ * `PARTNERS.md` §5.2). `externalId` is a separate, non-unique client reference
15
+ * (one client may own several stores) and is NOT a dedup key. Never retries a
16
+ * failed activation on its own.
17
17
  */
18
18
  class ProvisionStoreUseCase {
19
19
  merchants;
@@ -23,8 +23,8 @@ class ProvisionStoreUseCase {
23
23
  this.clock = clock;
24
24
  }
25
25
  async execute(input, options = {}) {
26
- if (!input.externalId?.trim()) {
27
- throw new errors_ts_1.VivoaValidationError('provisionStore requires input.externalId — it is the key that makes retrying safe. ' +
26
+ if (!input.idempotencyKey?.trim()) {
27
+ throw new errors_ts_1.VivoaValidationError('provisionStore requires input.idempotencyKey — it is the key that makes retrying safe. ' +
28
28
  'Use merchants.create() directly if you do not need that guarantee.');
29
29
  }
30
30
  const deadline = this.clock.nowMs() + (options.timeoutMs ?? DEFAULT_TIMEOUT_MS);
@@ -47,15 +47,17 @@ class ProvisionStoreUseCase {
47
47
  }
48
48
  if (status.deployStatus === 'failed' || status.deployStatus === 'unknown') {
49
49
  progress('settled', merchant, status.deployStatus);
50
- throw new errors_ts_1.VivoaProvisioningFailedError(merchant.id, `the platform reported runtime status "${status.deployStatus}"`);
50
+ throw new errors_ts_1.VivoaProvisioningFailedError(merchant.id, status.lastError ?? `the platform reported runtime status "${status.deployStatus}"`);
51
51
  }
52
- // Seed plugins + catalog once the store is live. Best-effort by design: a
53
- // failed seed leaves the store live and reports the error, so the caller can
54
- // re-run (`configurePlugins`/`importProducts` are idempotent) rather than
55
- // losing a provisioned store to a transient catalog/credential hiccup.
52
+ // Seed plugins → locations → catalog once the store is live. Best-effort
53
+ // by design: a failed seed leaves the store live and reports the error, so
54
+ // the caller can re-run (all three are idempotent) rather than losing a
55
+ // provisioned store to a transient credential/catalog hiccup.
56
+ // Locations must come before products — products may reference locationCode.
56
57
  let plugins;
58
+ let locations;
57
59
  let products;
58
- if (options.plugins?.length || options.products?.length) {
60
+ if (options.plugins?.length || options.locations?.length || options.products?.length) {
59
61
  progress('seeding', merchant, status.deployStatus);
60
62
  if (options.plugins?.length) {
61
63
  plugins = await this.merchants
@@ -68,10 +70,26 @@ class ProvisionStoreUseCase {
68
70
  error: err instanceof Error ? err.message : String(err),
69
71
  })));
70
72
  }
73
+ if (options.locations?.length) {
74
+ locations = await this.merchants
75
+ .importLocations(merchant.id, options.locations)
76
+ .catch((err) => ({
77
+ total: options.locations.length,
78
+ created: 0,
79
+ updated: 0,
80
+ errors: [{ row: 0, code: 'seed_error', message: err instanceof Error ? err.message : String(err) }],
81
+ }));
82
+ }
71
83
  if (options.products?.length) {
72
84
  products = await this.merchants
73
85
  .importProducts(merchant.id, options.products)
74
- .catch(() => ({ created: 0, updated: 0, skipped: 0, failed: options.products.length }));
86
+ .catch((err) => ({
87
+ total: options.products.length,
88
+ created: 0,
89
+ updated: 0,
90
+ skipped: 0,
91
+ errors: [{ row: 0, code: 'seed_error', message: err instanceof Error ? err.message : String(err) }],
92
+ }));
75
93
  }
76
94
  }
77
95
  progress('settled', merchant, status.deployStatus);
@@ -80,6 +98,7 @@ class ProvisionStoreUseCase {
80
98
  status,
81
99
  created,
82
100
  ...(plugins ? { plugins } : {}),
101
+ ...(locations ? { locations } : {}),
83
102
  ...(products ? { products } : {}),
84
103
  };
85
104
  }
@@ -98,15 +117,16 @@ class ProvisionStoreUseCase {
98
117
  }
99
118
  }
100
119
  async ensureMerchant(input) {
101
- const existing = await this.merchants.findByExternalId(input.externalId);
102
- if (existing)
103
- return { merchant: existing, created: false };
104
- // The platform is idempotent on externalId: even if a concurrent call for
105
- // the same externalId wins the race between the check above and this
106
- // request, `create` still resolves to ITS merchant — never a 409 or a
107
- // second store. `created` can read `true` in that narrow window; harmless,
108
- // since the merchant returned is the same either way.
109
- return { merchant: await this.merchants.create(input), created: true };
120
+ // The platform is idempotent on idempotencyKey: replaying create() with the
121
+ // same key returns the merchant it already made — never a duplicate, never a
122
+ // 409. So we just create; no pre-check needed. (externalId is NOT the key: it
123
+ // is a non-unique client reference and may map to several of your stores.)
124
+ const merchant = await this.merchants.create(input);
125
+ // A freshly created store is always 'not_activated' with nothing provisioned
126
+ // yet; any further-along runtime means this call replayed an existing
127
+ // merchant. A replay of a never-activated merchant reads created=true —
128
+ // harmless, since provisionStore then activates it, which is idempotent too.
129
+ return { merchant, created: merchant.runtime === 'not_activated' };
110
130
  }
111
131
  async activate(merchantId, deadline) {
112
132
  try {
@@ -32,6 +32,14 @@ export interface MerchantStatus {
32
32
  deployStatus: MerchantRuntimeStatus;
33
33
  domain: string | null;
34
34
  apiBaseUrl: string | null;
35
+ /** ISO-8601 when the store reached `live` (null before it provisions). */
36
+ provisionedAt: string | null;
37
+ /**
38
+ * Why the LAST provisioning attempt failed — surfaced only while
39
+ * `deployStatus === 'failed'`, null otherwise (a stale error never leaks once
40
+ * the store is live). The queryable "why" behind a failed activation.
41
+ */
42
+ lastError: string | null;
35
43
  }
36
44
  export interface CreateMerchantInput {
37
45
  /** Email of the store owner (receives their own merchant account). */
@@ -41,13 +49,20 @@ export interface CreateMerchantInput {
41
49
  countries: string[];
42
50
  industry?: string;
43
51
  /**
44
- * Your own reference for this merchant (customer id, order id…). Optional
45
- * here, but it is the idempotency key of `merchants.create()`: replaying the
46
- * same value returns the merchant you already created instead of a second
47
- * one. `provisionStore()` requires it for exactly that reason. Unique per
48
- * operator, max 120 chars.
52
+ * Your reference for the CLIENT who owns this store (your customer id). **Not
53
+ * unique**: one client can own several stores, all sharing this value — list
54
+ * them with `merchants.list({ externalId })`. This is NOT the idempotency key
55
+ * (see `idempotencyKey`). Max 120 chars.
49
56
  */
50
57
  externalId?: string;
58
+ /**
59
+ * Idempotency key for THIS creation (e.g. your order id). Replaying the same
60
+ * key returns the SAME merchant instead of creating a duplicate — safe
61
+ * retries. **Unique per operator**, max 120 chars. `provisionStore()`
62
+ * requires it for exactly that reason. Omit it and every call creates a new
63
+ * store.
64
+ */
65
+ idempotencyKey?: string;
51
66
  }
52
67
  /** Options for `merchants.adminTicket()`. */
53
68
  export interface AdminTicketOptions {
@@ -86,13 +101,33 @@ export interface PluginProvisionResult {
86
101
  configured: boolean;
87
102
  error?: string;
88
103
  }
104
+ /** Valid product type slugs (`physical` | `service` | `digital`). Unknown slugs fall back to `physical` store-side. */
105
+ export type ProductTypeSlug = 'physical' | 'service' | 'digital';
106
+ /**
107
+ * One inventory entry: stock by country (auto-creates a WH-XX location) or by an
108
+ * existing `locationCode`. `quantity` is a non-negative integer (the store
109
+ * rejects fractional stock).
110
+ */
111
+ export type InventoryEntry = {
112
+ country: string;
113
+ quantity: number;
114
+ locationCode?: never;
115
+ } | {
116
+ locationCode: string;
117
+ quantity: number;
118
+ country?: never;
119
+ };
89
120
  /** One product to seed into a merchant's catalog. Mirrors the store import item. */
90
121
  export interface ProvisionProduct {
91
122
  /** Slug, kebab-case `[a-z0-9-]`. */
92
123
  handle: string;
93
124
  name: string;
94
- /** Product type slug the store catalog knows (e.g. `default`). */
95
- productTypeSlug: string;
125
+ /**
126
+ * Product type: `physical` (has variants, requires shipping), `service` (no
127
+ * variants, no shipping), `digital` (has variants, no shipping). Unknown
128
+ * values fall back to `physical` store-side — prefer being explicit.
129
+ */
130
+ productTypeSlug: ProductTypeSlug;
96
131
  price: number;
97
132
  description?: string;
98
133
  vendor?: string;
@@ -100,20 +135,74 @@ export interface ProvisionProduct {
100
135
  costPrice?: number;
101
136
  barcode?: string;
102
137
  weight?: number;
103
- /** Remote image URLs — validated + ingested store-side. */
138
+ /** Remote image URLs — validated + ingested store-side. Max 20. */
104
139
  images?: string[];
105
- /** Per-country stock. */
106
- inventory?: Array<{
107
- country: string;
108
- quantity: number;
109
- }>;
140
+ /**
141
+ * Sales channel handles to publish this product on. Omit to publish on the
142
+ * store's default channel.
143
+ */
144
+ channels?: string[];
145
+ /** Per-country or per-location stock. Import locations before products when using `locationCode`. */
146
+ inventory?: InventoryEntry[];
147
+ }
148
+ /** One row that failed during a product import. Mirrors the store import error. */
149
+ export interface ImportProductError {
150
+ /** Zero-based index into the `products` array you sent. */
151
+ row: number;
152
+ /** Handle of the offending product, when the row carried one. */
153
+ handle?: string;
154
+ /** Machine code, e.g. `IMAGE_INVALID_URL`, `UNKNOWN_LOCATION`, `UNKNOWN_PRODUCT_TYPE`. */
155
+ code: string;
156
+ message: string;
157
+ /** Extra diagnostic payload, when the store attaches one. */
158
+ context?: Record<string, unknown>;
110
159
  }
111
160
  /** Aggregate result of `merchants.importProducts()`. */
112
161
  export interface ImportProductsResult {
162
+ /** Rows received (== `products.length`, or the rows that passed validation). */
163
+ total: number;
113
164
  created: number;
114
165
  updated: number;
115
166
  skipped: number;
116
- failed: number;
167
+ /** Per-row failures. Empty on full success. */
168
+ errors: ImportProductError[];
169
+ }
170
+ /** One warehouse / pickup point to seed into a merchant's inventory. Upserted by `code`. */
171
+ export interface ProvisionLocation {
172
+ /** Your stable identifier — the upsert key (e.g. warehouse id). Max 60 chars. */
173
+ code: string;
174
+ /** Display name shown in the store admin. Max 120 chars. */
175
+ name: string;
176
+ /** ISO-3166-1 alpha-2 (e.g. `"SV"`, `"GT"`, `"HN"`). */
177
+ country: string;
178
+ /** Location type hint (e.g. `warehouse`, `pickup`, `shop`). */
179
+ type?: string;
180
+ address?: string;
181
+ city?: string;
182
+ postalCode?: string;
183
+ phone?: string;
184
+ email?: string;
185
+ isActive?: boolean;
186
+ /** Whether this location ships orders. Default `true`. */
187
+ handlesFulfillment?: boolean;
188
+ /** Whether customers can pick up here. Default `false`. */
189
+ pickupAvailable?: boolean;
190
+ /** Preferred carrier code for shipments dispatched from this location. */
191
+ preferredCarrier?: string;
192
+ latitude?: number;
193
+ longitude?: number;
194
+ referencePoint?: string;
195
+ }
196
+ /** Aggregate result of `merchants.importLocations()`. */
197
+ export interface ImportLocationsResult {
198
+ total: number;
199
+ created: number;
200
+ updated: number;
201
+ errors: Array<{
202
+ row: number;
203
+ code: string;
204
+ message: string;
205
+ }>;
117
206
  }
118
207
  export interface ListMerchantsQuery {
119
208
  page?: number;
@@ -134,6 +223,8 @@ export interface Page<T> {
134
223
  }
135
224
  /** Max length the platform accepts for `CreateMerchantInput.externalId`. */
136
225
  export declare const MAX_EXTERNAL_ID_LENGTH = 120;
226
+ /** Max length the platform accepts for `CreateMerchantInput.idempotencyKey`. */
227
+ export declare const MAX_IDEMPOTENCY_KEY_LENGTH = 120;
137
228
  /** Runtime states where the platform accepts `activate` (first run or retry). */
138
229
  export declare function canActivate(runtime: MerchantRuntimeStatus): boolean;
139
230
  /** States that will not change without a new action from the partner. */
@@ -5,7 +5,7 @@
5
5
  * Pure: no I/O, no Node APIs.
6
6
  */
7
7
  Object.defineProperty(exports, "__esModule", { value: true });
8
- exports.MAX_EXTERNAL_ID_LENGTH = exports.MERCHANT_RUNTIME_STATUSES = void 0;
8
+ exports.MAX_IDEMPOTENCY_KEY_LENGTH = exports.MAX_EXTERNAL_ID_LENGTH = exports.MERCHANT_RUNTIME_STATUSES = void 0;
9
9
  exports.canActivate = canActivate;
10
10
  exports.isSettled = isSettled;
11
11
  exports.MERCHANT_RUNTIME_STATUSES = [
@@ -18,6 +18,8 @@ exports.MERCHANT_RUNTIME_STATUSES = [
18
18
  ];
19
19
  /** Max length the platform accepts for `CreateMerchantInput.externalId`. */
20
20
  exports.MAX_EXTERNAL_ID_LENGTH = 120;
21
+ /** Max length the platform accepts for `CreateMerchantInput.idempotencyKey`. */
22
+ exports.MAX_IDEMPOTENCY_KEY_LENGTH = 120;
21
23
  /** Runtime states where the platform accepts `activate` (first run or retry). */
22
24
  function canActivate(runtime) {
23
25
  return runtime === 'not_activated' || runtime === 'failed';
@@ -1 +1 @@
1
- export declare const SDK_VERSION = "0.3.0";
1
+ export declare const SDK_VERSION = "0.4.0";
@@ -1,4 +1,4 @@
1
1
  "use strict";
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
3
  exports.SDK_VERSION = void 0;
4
- exports.SDK_VERSION = '0.3.0';
4
+ exports.SDK_VERSION = '0.4.0';
@@ -61,6 +61,7 @@ export declare class FakeVivoa implements HttpTransport {
61
61
  private readonly operators;
62
62
  private readonly merchants;
63
63
  private readonly handles;
64
+ private readonly merchantLocations;
64
65
  constructor(options?: FakeVivoaOptions);
65
66
  addOperator(seed: FakeOperatorSeed): OperatorProfile;
66
67
  setOperatorStatus(apiKey: string, status: OperatorStatus): void;
@@ -3,7 +3,7 @@ import { nodeCrypto } from "../node-crypto.js";
3
3
  import { systemClock } from "../system-clock.js";
4
4
  import { VivoaNetworkError } from "../../domain/errors.js";
5
5
  import { canonicalRequestPayload, MAX_CLOCK_SKEW_SECONDS, PARTNER_HEADERS, } from "../../domain/signature.js";
6
- import { MAX_EXTERNAL_ID_LENGTH } from "../../domain/merchant.js";
6
+ import { MAX_EXTERNAL_ID_LENGTH, MAX_IDEMPOTENCY_KEY_LENGTH, } from "../../domain/merchant.js";
7
7
  import { MERCHANT_EVENT_TYPES } from "../../domain/webhook.js";
8
8
  class HttpError extends Error {
9
9
  status;
@@ -13,7 +13,7 @@ class HttpError extends Error {
13
13
  }
14
14
  }
15
15
  /** `POST /merchants` body — matches CreateMerchantDto's whitelist exactly (a stale `handle` 400s, like on the real platform). */
16
- const CREATE_MERCHANT_KEYS = new Set(['email', 'name', 'countries', 'industry', 'externalId']);
16
+ const CREATE_MERCHANT_KEYS = new Set(['email', 'name', 'countries', 'industry', 'externalId', 'idempotencyKey']);
17
17
  /** Mirrors `shared/utils/slugify.ts` on the platform closely enough for a fake. */
18
18
  function slugify(value) {
19
19
  return value
@@ -51,6 +51,7 @@ export class FakeVivoa {
51
51
  operators = new Map();
52
52
  merchants = new Map();
53
53
  handles = new Set();
54
+ merchantLocations = new Map();
54
55
  constructor(options = {}) {
55
56
  this.clock = options.clock ?? systemClock;
56
57
  this.crypto = options.crypto ?? nodeCrypto;
@@ -152,7 +153,7 @@ export class FakeVivoa {
152
153
  async route(op, method, url, rawBody) {
153
154
  const path = url.pathname.replace(/^\/partner\/v1/, '');
154
155
  const body = rawBody ? JSON.parse(rawBody) : {};
155
- const merchantRoute = path.match(/^\/merchants\/([^/]+)\/(status|activate|suspend|reactivate|admin-ticket|plugins|products)$/);
156
+ const merchantRoute = path.match(/^\/merchants\/([^/]+)\/(status|activate|suspend|reactivate|admin-ticket|plugins|locations|products)$/);
156
157
  if (method === 'GET' && path === '/me')
157
158
  return this.profile(op);
158
159
  if (method === 'POST' && path === '/me/rotate-secret') {
@@ -209,9 +210,27 @@ export class FakeVivoa {
209
210
  })),
210
211
  };
211
212
  }
213
+ if (action === 'locations') {
214
+ const locations = Array.isArray(body.locations) ? body.locations : [];
215
+ if (locations.length === 0)
216
+ throw new HttpError(400, 'locations must not be empty');
217
+ const m = this.merchantLocations.get(merchant.id) ?? [];
218
+ let created = 0;
219
+ let updated = 0;
220
+ for (const loc of locations) {
221
+ if (m.some((l) => l === loc.code))
222
+ updated++;
223
+ else {
224
+ m.push(loc.code);
225
+ created++;
226
+ }
227
+ }
228
+ this.merchantLocations.set(merchant.id, m);
229
+ return { total: locations.length, created, updated, errors: [] };
230
+ }
212
231
  if (action === 'products') {
213
232
  const products = Array.isArray(body.products) ? body.products : [];
214
- return { created: products.length, updated: 0, skipped: 0, failed: 0 };
233
+ return { total: products.length, created: products.length, updated: 0, skipped: 0, errors: [] };
215
234
  }
216
235
  }
217
236
  }
@@ -229,6 +248,7 @@ export class FakeVivoa {
229
248
  const name = String(body.name ?? '');
230
249
  const countries = Array.isArray(body.countries) ? body.countries.map(String) : [];
231
250
  const externalId = body.externalId === undefined ? undefined : String(body.externalId);
251
+ const idempotencyKey = body.idempotencyKey === undefined ? undefined : String(body.idempotencyKey);
232
252
  const problems = [];
233
253
  if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email))
234
254
  problems.push('email must be an email');
@@ -239,11 +259,17 @@ export class FakeVivoa {
239
259
  if (externalId !== undefined && externalId.length > MAX_EXTERNAL_ID_LENGTH) {
240
260
  problems.push(`externalId must be shorter than or equal to ${MAX_EXTERNAL_ID_LENGTH} characters`);
241
261
  }
262
+ if (idempotencyKey !== undefined && idempotencyKey.length > MAX_IDEMPOTENCY_KEY_LENGTH) {
263
+ problems.push(`idempotencyKey must be shorter than or equal to ${MAX_IDEMPOTENCY_KEY_LENGTH} characters`);
264
+ }
242
265
  if (problems.length)
243
266
  throw new HttpError(400, problems.join('; '));
244
- // Idempotent replay BEFORE quota/region checks — mirrors the platform.
245
- if (externalId) {
246
- const existing = this.fleet(op).find((m) => m.externalId === externalId);
267
+ // Idempotent replay BEFORE quota/region checks — mirrors the platform. The
268
+ // dedup key is idempotencyKey (unique per operator), NOT externalId: that is
269
+ // a non-unique client reference and may map to several of the operator's
270
+ // stores.
271
+ if (idempotencyKey) {
272
+ const existing = this.fleet(op).find((m) => m.idempotencyKey === idempotencyKey);
247
273
  if (existing)
248
274
  return publicMerchant(existing);
249
275
  }
@@ -268,6 +294,9 @@ export class FakeVivoa {
268
294
  createdAt: new Date(this.clock.nowMs()).toISOString(),
269
295
  operatorId: op.id,
270
296
  pollsLeft: 0,
297
+ idempotencyKey: idempotencyKey ?? null,
298
+ provisionedAt: null,
299
+ lastError: null,
271
300
  };
272
301
  this.handles.add(merchant.handle);
273
302
  this.merchants.set(merchant.id, merchant);
@@ -302,6 +331,7 @@ export class FakeVivoa {
302
331
  switch (this.activation) {
303
332
  case 'fail':
304
333
  m.runtime = 'failed';
334
+ m.lastError = this.failureReason;
305
335
  this.emit(op, 'merchant.failed', m, { reason: this.failureReason });
306
336
  throw new HttpError(500, this.failureReason);
307
337
  case 'slow':
@@ -320,6 +350,8 @@ export class FakeVivoa {
320
350
  goLive(m) {
321
351
  m.runtime = 'live';
322
352
  m.domain = `${m.handle}.vivoa.store`;
353
+ m.provisionedAt = new Date(this.clock.nowMs()).toISOString();
354
+ m.lastError = null;
323
355
  const op = [...this.operators.values()].find((o) => o.id === m.operatorId);
324
356
  if (op)
325
357
  this.emit(op, 'merchant.live', m);
@@ -338,6 +370,8 @@ export class FakeVivoa {
338
370
  deployStatus: m.runtime,
339
371
  domain: m.domain,
340
372
  apiBaseUrl: m.runtime === 'live' ? 'https://shared-api.vivoa.app' : null,
373
+ provisionedAt: m.provisionedAt,
374
+ lastError: m.runtime === 'failed' ? m.lastError : null,
341
375
  };
342
376
  }
343
377
  list(op, qs) {
@@ -467,7 +501,7 @@ export class FakeVivoa {
467
501
  }
468
502
  }
469
503
  function publicMerchant(m) {
470
- const { operatorId: _op, pollsLeft: _polls, ...view } = m;
504
+ const { operatorId: _op, pollsLeft: _polls, idempotencyKey: _key, provisionedAt: _prov, lastError: _err, ...view } = m;
471
505
  return { ...view, countries: [...view.countries] };
472
506
  }
473
507
  function webhookView(op) {
@@ -1,5 +1,5 @@
1
1
  import type { SignedHttpClient } from '../signed-http.ts';
2
- import { type AdminTicketOptions, type CreateMerchantInput, type ImportProductsResult, type ListMerchantsQuery, type Merchant, type MerchantAdminTicket, type MerchantStatus, type Page, type PluginProvisionInput, type PluginProvisionResult, type ProvisionProduct } from '../../domain/merchant.ts';
2
+ import { type AdminTicketOptions, type CreateMerchantInput, type ImportLocationsResult, type ImportProductsResult, type ListMerchantsQuery, type Merchant, type MerchantAdminTicket, type MerchantStatus, type Page, type PluginProvisionInput, type PluginProvisionResult, type ProvisionLocation, type ProvisionProduct } from '../../domain/merchant.ts';
3
3
  /** Provisioning is synchronous on the platform: allow it minutes, not seconds. */
4
4
  export declare const DEFAULT_ACTIVATE_TIMEOUT_MS: number;
5
5
  export declare class MerchantsResource {
@@ -7,8 +7,9 @@ export declare class MerchantsResource {
7
7
  constructor(http: SignedHttpClient);
8
8
  /**
9
9
  * Creates owner + store in your fleet. Not live until `activate`. The
10
- * platform assigns the handle. Pass `externalId` and replaying the same
10
+ * platform assigns the handle. Pass `idempotencyKey` and replaying the same
11
11
  * value returns the merchant you already created — never a second one.
12
+ * `externalId` is a non-unique client reference, not a dedup key.
12
13
  */
13
14
  create(input: CreateMerchantInput): Promise<Merchant>;
14
15
  list(query?: ListMerchantsQuery): Promise<Page<Merchant>>;
@@ -47,6 +48,13 @@ export declare class MerchantsResource {
47
48
  configurePlugins(merchantId: string, plugins: PluginProvisionInput[]): Promise<{
48
49
  plugins: PluginProvisionResult[];
49
50
  }>;
51
+ /**
52
+ * Bulk-imports inventory locations (warehouses, pickup points) into the
53
+ * merchant's store. Upserted on `code` — idempotent, re-running updates
54
+ * rather than duplicates. Import locations **before** products when you
55
+ * reference `inventory[].locationCode`. 409 if the merchant is not live.
56
+ */
57
+ importLocations(merchantId: string, locations: ProvisionLocation[]): Promise<ImportLocationsResult>;
50
58
  /**
51
59
  * Bulk-imports products into the merchant's catalog (URL images, per-country
52
60
  * inventory). Idempotent on `handle`, so re-running updates rather than
@@ -1,5 +1,5 @@
1
1
  import { VivoaValidationError } from "../../domain/errors.js";
2
- import { MAX_EXTERNAL_ID_LENGTH, } from "../../domain/merchant.js";
2
+ import { MAX_EXTERNAL_ID_LENGTH, MAX_IDEMPOTENCY_KEY_LENGTH, } from "../../domain/merchant.js";
3
3
  const EMAIL_RE = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
4
4
  const COUNTRY_RE = /^[A-Z]{2}$/;
5
5
  /** Provisioning is synchronous on the platform: allow it minutes, not seconds. */
@@ -11,8 +11,9 @@ export class MerchantsResource {
11
11
  }
12
12
  /**
13
13
  * Creates owner + store in your fleet. Not live until `activate`. The
14
- * platform assigns the handle. Pass `externalId` and replaying the same
14
+ * platform assigns the handle. Pass `idempotencyKey` and replaying the same
15
15
  * value returns the merchant you already created — never a second one.
16
+ * `externalId` is a non-unique client reference, not a dedup key.
16
17
  */
17
18
  async create(input) {
18
19
  validateCreate(input);
@@ -80,6 +81,19 @@ export class MerchantsResource {
80
81
  body: { plugins },
81
82
  });
82
83
  }
84
+ /**
85
+ * Bulk-imports inventory locations (warehouses, pickup points) into the
86
+ * merchant's store. Upserted on `code` — idempotent, re-running updates
87
+ * rather than duplicates. Import locations **before** products when you
88
+ * reference `inventory[].locationCode`. 409 if the merchant is not live.
89
+ */
90
+ async importLocations(merchantId, locations) {
91
+ if (locations.length === 0)
92
+ throw new VivoaValidationError('locations must not be empty');
93
+ return this.http.request('POST', `/partner/v1/merchants/${encodeId(merchantId)}/locations`, {
94
+ body: { locations },
95
+ });
96
+ }
83
97
  /**
84
98
  * Bulk-imports products into the merchant's catalog (URL images, per-country
85
99
  * inventory). Idempotent on `handle`, so re-running updates rather than
@@ -117,6 +131,13 @@ export function validateCreate(input) {
117
131
  problems.push(`externalId must be at most ${MAX_EXTERNAL_ID_LENGTH} chars`);
118
132
  }
119
133
  }
134
+ if (input.idempotencyKey !== undefined) {
135
+ if (!input.idempotencyKey.trim())
136
+ problems.push('idempotencyKey must not be empty');
137
+ else if (input.idempotencyKey.length > MAX_IDEMPOTENCY_KEY_LENGTH) {
138
+ problems.push(`idempotencyKey must be at most ${MAX_IDEMPOTENCY_KEY_LENGTH} chars`);
139
+ }
140
+ }
120
141
  if (problems.length > 0)
121
142
  throw new VivoaValidationError(problems.join('; '));
122
143
  }
@@ -1,6 +1,6 @@
1
1
  import type { Clock } from '../../ports/clock.port.ts';
2
2
  import type { MerchantsResource } from '../resources/merchants.ts';
3
- import { type CreateMerchantInput, type ImportProductsResult, type Merchant, type MerchantRuntimeStatus, type MerchantStatus, type PluginProvisionInput, type PluginProvisionResult, type ProvisionProduct } from '../../domain/merchant.ts';
3
+ import { type CreateMerchantInput, type ImportLocationsResult, type ImportProductsResult, type Merchant, type MerchantRuntimeStatus, type MerchantStatus, type PluginProvisionInput, type PluginProvisionResult, type ProvisionLocation, type ProvisionProduct } from '../../domain/merchant.ts';
4
4
  export type ProvisionPhase = 'found' | 'created' | 'activating' | 'waiting' | 'seeding' | 'settled';
5
5
  export interface ProvisionProgress {
6
6
  phase: ProvisionPhase;
@@ -24,28 +24,40 @@ export interface ProvisionStoreOptions {
24
24
  * resolves — the result carries the error and the caller can re-run.
25
25
  */
26
26
  plugins?: PluginProvisionInput[];
27
+ /**
28
+ * Inventory locations (warehouses, pickup points) to import once the store
29
+ * is live. Seeded **before** products so `inventory[].locationCode`
30
+ * references resolve. Best-effort.
31
+ */
32
+ locations?: ProvisionLocation[];
27
33
  /** Products to seed into the catalog once the store is live. Best-effort. */
28
34
  products?: ProvisionProduct[];
29
35
  }
30
36
  export interface ProvisionStoreResult {
31
37
  merchant: Merchant;
32
38
  status: MerchantStatus;
33
- /** false when a merchant with this externalId already existed in your fleet. */
39
+ /**
40
+ * false when this call replayed a merchant already past `not_activated` (a
41
+ * definite prior run). A fresh create — or a replay of a merchant that never
42
+ * activated — reads `true`.
43
+ */
34
44
  created: boolean;
35
45
  /** Per-plugin seed outcome, when `options.plugins` was supplied. */
36
46
  plugins?: PluginProvisionResult[];
47
+ /** Location import outcome, when `options.locations` was supplied. */
48
+ locations?: ImportLocationsResult;
37
49
  /** Catalog seed outcome, when `options.products` was supplied. */
38
50
  products?: ImportProductsResult;
39
51
  }
40
52
  /**
41
53
  * "Give me a live store" — the one call most partners need.
42
54
  *
43
- * Requires `input.externalId`: it is what makes retrying safe. Re-running
44
- * after a crash, a timeout or a double click resumes the same merchant
45
- * instead of creating a second one — the platform is idempotent on it (see
46
- * `PARTNERS.md` §5.2), and it never runs out (unlike a handle, which the
47
- * platform now assigns and a partner never sees before creation). Never
48
- * retries a failed activation on its own.
55
+ * Requires `input.idempotencyKey`: it is what makes retrying safe. Re-running
56
+ * after a crash, a timeout or a double click resumes the same merchant instead
57
+ * of creating a second one — the platform is idempotent on it (see
58
+ * `PARTNERS.md` §5.2). `externalId` is a separate, non-unique client reference
59
+ * (one client may own several stores) and is NOT a dedup key. Never retries a
60
+ * failed activation on its own.
49
61
  */
50
62
  export declare class ProvisionStoreUseCase {
51
63
  private readonly merchants;
@@ -5,12 +5,12 @@ const DEFAULT_POLL_MS = 5_000;
5
5
  /**
6
6
  * "Give me a live store" — the one call most partners need.
7
7
  *
8
- * Requires `input.externalId`: it is what makes retrying safe. Re-running
9
- * after a crash, a timeout or a double click resumes the same merchant
10
- * instead of creating a second one — the platform is idempotent on it (see
11
- * `PARTNERS.md` §5.2), and it never runs out (unlike a handle, which the
12
- * platform now assigns and a partner never sees before creation). Never
13
- * retries a failed activation on its own.
8
+ * Requires `input.idempotencyKey`: it is what makes retrying safe. Re-running
9
+ * after a crash, a timeout or a double click resumes the same merchant instead
10
+ * of creating a second one — the platform is idempotent on it (see
11
+ * `PARTNERS.md` §5.2). `externalId` is a separate, non-unique client reference
12
+ * (one client may own several stores) and is NOT a dedup key. Never retries a
13
+ * failed activation on its own.
14
14
  */
15
15
  export class ProvisionStoreUseCase {
16
16
  merchants;
@@ -20,8 +20,8 @@ export class ProvisionStoreUseCase {
20
20
  this.clock = clock;
21
21
  }
22
22
  async execute(input, options = {}) {
23
- if (!input.externalId?.trim()) {
24
- throw new VivoaValidationError('provisionStore requires input.externalId — it is the key that makes retrying safe. ' +
23
+ if (!input.idempotencyKey?.trim()) {
24
+ throw new VivoaValidationError('provisionStore requires input.idempotencyKey — it is the key that makes retrying safe. ' +
25
25
  'Use merchants.create() directly if you do not need that guarantee.');
26
26
  }
27
27
  const deadline = this.clock.nowMs() + (options.timeoutMs ?? DEFAULT_TIMEOUT_MS);
@@ -44,15 +44,17 @@ export class ProvisionStoreUseCase {
44
44
  }
45
45
  if (status.deployStatus === 'failed' || status.deployStatus === 'unknown') {
46
46
  progress('settled', merchant, status.deployStatus);
47
- throw new VivoaProvisioningFailedError(merchant.id, `the platform reported runtime status "${status.deployStatus}"`);
47
+ throw new VivoaProvisioningFailedError(merchant.id, status.lastError ?? `the platform reported runtime status "${status.deployStatus}"`);
48
48
  }
49
- // Seed plugins + catalog once the store is live. Best-effort by design: a
50
- // failed seed leaves the store live and reports the error, so the caller can
51
- // re-run (`configurePlugins`/`importProducts` are idempotent) rather than
52
- // losing a provisioned store to a transient catalog/credential hiccup.
49
+ // Seed plugins → locations → catalog once the store is live. Best-effort
50
+ // by design: a failed seed leaves the store live and reports the error, so
51
+ // the caller can re-run (all three are idempotent) rather than losing a
52
+ // provisioned store to a transient credential/catalog hiccup.
53
+ // Locations must come before products — products may reference locationCode.
53
54
  let plugins;
55
+ let locations;
54
56
  let products;
55
- if (options.plugins?.length || options.products?.length) {
57
+ if (options.plugins?.length || options.locations?.length || options.products?.length) {
56
58
  progress('seeding', merchant, status.deployStatus);
57
59
  if (options.plugins?.length) {
58
60
  plugins = await this.merchants
@@ -65,10 +67,26 @@ export class ProvisionStoreUseCase {
65
67
  error: err instanceof Error ? err.message : String(err),
66
68
  })));
67
69
  }
70
+ if (options.locations?.length) {
71
+ locations = await this.merchants
72
+ .importLocations(merchant.id, options.locations)
73
+ .catch((err) => ({
74
+ total: options.locations.length,
75
+ created: 0,
76
+ updated: 0,
77
+ errors: [{ row: 0, code: 'seed_error', message: err instanceof Error ? err.message : String(err) }],
78
+ }));
79
+ }
68
80
  if (options.products?.length) {
69
81
  products = await this.merchants
70
82
  .importProducts(merchant.id, options.products)
71
- .catch(() => ({ created: 0, updated: 0, skipped: 0, failed: options.products.length }));
83
+ .catch((err) => ({
84
+ total: options.products.length,
85
+ created: 0,
86
+ updated: 0,
87
+ skipped: 0,
88
+ errors: [{ row: 0, code: 'seed_error', message: err instanceof Error ? err.message : String(err) }],
89
+ }));
72
90
  }
73
91
  }
74
92
  progress('settled', merchant, status.deployStatus);
@@ -77,6 +95,7 @@ export class ProvisionStoreUseCase {
77
95
  status,
78
96
  created,
79
97
  ...(plugins ? { plugins } : {}),
98
+ ...(locations ? { locations } : {}),
80
99
  ...(products ? { products } : {}),
81
100
  };
82
101
  }
@@ -95,15 +114,16 @@ export class ProvisionStoreUseCase {
95
114
  }
96
115
  }
97
116
  async ensureMerchant(input) {
98
- const existing = await this.merchants.findByExternalId(input.externalId);
99
- if (existing)
100
- return { merchant: existing, created: false };
101
- // The platform is idempotent on externalId: even if a concurrent call for
102
- // the same externalId wins the race between the check above and this
103
- // request, `create` still resolves to ITS merchant — never a 409 or a
104
- // second store. `created` can read `true` in that narrow window; harmless,
105
- // since the merchant returned is the same either way.
106
- return { merchant: await this.merchants.create(input), created: true };
117
+ // The platform is idempotent on idempotencyKey: replaying create() with the
118
+ // same key returns the merchant it already made — never a duplicate, never a
119
+ // 409. So we just create; no pre-check needed. (externalId is NOT the key: it
120
+ // is a non-unique client reference and may map to several of your stores.)
121
+ const merchant = await this.merchants.create(input);
122
+ // A freshly created store is always 'not_activated' with nothing provisioned
123
+ // yet; any further-along runtime means this call replayed an existing
124
+ // merchant. A replay of a never-activated merchant reads created=true —
125
+ // harmless, since provisionStore then activates it, which is idempotent too.
126
+ return { merchant, created: merchant.runtime === 'not_activated' };
107
127
  }
108
128
  async activate(merchantId, deadline) {
109
129
  try {
@@ -32,6 +32,14 @@ export interface MerchantStatus {
32
32
  deployStatus: MerchantRuntimeStatus;
33
33
  domain: string | null;
34
34
  apiBaseUrl: string | null;
35
+ /** ISO-8601 when the store reached `live` (null before it provisions). */
36
+ provisionedAt: string | null;
37
+ /**
38
+ * Why the LAST provisioning attempt failed — surfaced only while
39
+ * `deployStatus === 'failed'`, null otherwise (a stale error never leaks once
40
+ * the store is live). The queryable "why" behind a failed activation.
41
+ */
42
+ lastError: string | null;
35
43
  }
36
44
  export interface CreateMerchantInput {
37
45
  /** Email of the store owner (receives their own merchant account). */
@@ -41,13 +49,20 @@ export interface CreateMerchantInput {
41
49
  countries: string[];
42
50
  industry?: string;
43
51
  /**
44
- * Your own reference for this merchant (customer id, order id…). Optional
45
- * here, but it is the idempotency key of `merchants.create()`: replaying the
46
- * same value returns the merchant you already created instead of a second
47
- * one. `provisionStore()` requires it for exactly that reason. Unique per
48
- * operator, max 120 chars.
52
+ * Your reference for the CLIENT who owns this store (your customer id). **Not
53
+ * unique**: one client can own several stores, all sharing this value — list
54
+ * them with `merchants.list({ externalId })`. This is NOT the idempotency key
55
+ * (see `idempotencyKey`). Max 120 chars.
49
56
  */
50
57
  externalId?: string;
58
+ /**
59
+ * Idempotency key for THIS creation (e.g. your order id). Replaying the same
60
+ * key returns the SAME merchant instead of creating a duplicate — safe
61
+ * retries. **Unique per operator**, max 120 chars. `provisionStore()`
62
+ * requires it for exactly that reason. Omit it and every call creates a new
63
+ * store.
64
+ */
65
+ idempotencyKey?: string;
51
66
  }
52
67
  /** Options for `merchants.adminTicket()`. */
53
68
  export interface AdminTicketOptions {
@@ -86,13 +101,33 @@ export interface PluginProvisionResult {
86
101
  configured: boolean;
87
102
  error?: string;
88
103
  }
104
+ /** Valid product type slugs (`physical` | `service` | `digital`). Unknown slugs fall back to `physical` store-side. */
105
+ export type ProductTypeSlug = 'physical' | 'service' | 'digital';
106
+ /**
107
+ * One inventory entry: stock by country (auto-creates a WH-XX location) or by an
108
+ * existing `locationCode`. `quantity` is a non-negative integer (the store
109
+ * rejects fractional stock).
110
+ */
111
+ export type InventoryEntry = {
112
+ country: string;
113
+ quantity: number;
114
+ locationCode?: never;
115
+ } | {
116
+ locationCode: string;
117
+ quantity: number;
118
+ country?: never;
119
+ };
89
120
  /** One product to seed into a merchant's catalog. Mirrors the store import item. */
90
121
  export interface ProvisionProduct {
91
122
  /** Slug, kebab-case `[a-z0-9-]`. */
92
123
  handle: string;
93
124
  name: string;
94
- /** Product type slug the store catalog knows (e.g. `default`). */
95
- productTypeSlug: string;
125
+ /**
126
+ * Product type: `physical` (has variants, requires shipping), `service` (no
127
+ * variants, no shipping), `digital` (has variants, no shipping). Unknown
128
+ * values fall back to `physical` store-side — prefer being explicit.
129
+ */
130
+ productTypeSlug: ProductTypeSlug;
96
131
  price: number;
97
132
  description?: string;
98
133
  vendor?: string;
@@ -100,20 +135,74 @@ export interface ProvisionProduct {
100
135
  costPrice?: number;
101
136
  barcode?: string;
102
137
  weight?: number;
103
- /** Remote image URLs — validated + ingested store-side. */
138
+ /** Remote image URLs — validated + ingested store-side. Max 20. */
104
139
  images?: string[];
105
- /** Per-country stock. */
106
- inventory?: Array<{
107
- country: string;
108
- quantity: number;
109
- }>;
140
+ /**
141
+ * Sales channel handles to publish this product on. Omit to publish on the
142
+ * store's default channel.
143
+ */
144
+ channels?: string[];
145
+ /** Per-country or per-location stock. Import locations before products when using `locationCode`. */
146
+ inventory?: InventoryEntry[];
147
+ }
148
+ /** One row that failed during a product import. Mirrors the store import error. */
149
+ export interface ImportProductError {
150
+ /** Zero-based index into the `products` array you sent. */
151
+ row: number;
152
+ /** Handle of the offending product, when the row carried one. */
153
+ handle?: string;
154
+ /** Machine code, e.g. `IMAGE_INVALID_URL`, `UNKNOWN_LOCATION`, `UNKNOWN_PRODUCT_TYPE`. */
155
+ code: string;
156
+ message: string;
157
+ /** Extra diagnostic payload, when the store attaches one. */
158
+ context?: Record<string, unknown>;
110
159
  }
111
160
  /** Aggregate result of `merchants.importProducts()`. */
112
161
  export interface ImportProductsResult {
162
+ /** Rows received (== `products.length`, or the rows that passed validation). */
163
+ total: number;
113
164
  created: number;
114
165
  updated: number;
115
166
  skipped: number;
116
- failed: number;
167
+ /** Per-row failures. Empty on full success. */
168
+ errors: ImportProductError[];
169
+ }
170
+ /** One warehouse / pickup point to seed into a merchant's inventory. Upserted by `code`. */
171
+ export interface ProvisionLocation {
172
+ /** Your stable identifier — the upsert key (e.g. warehouse id). Max 60 chars. */
173
+ code: string;
174
+ /** Display name shown in the store admin. Max 120 chars. */
175
+ name: string;
176
+ /** ISO-3166-1 alpha-2 (e.g. `"SV"`, `"GT"`, `"HN"`). */
177
+ country: string;
178
+ /** Location type hint (e.g. `warehouse`, `pickup`, `shop`). */
179
+ type?: string;
180
+ address?: string;
181
+ city?: string;
182
+ postalCode?: string;
183
+ phone?: string;
184
+ email?: string;
185
+ isActive?: boolean;
186
+ /** Whether this location ships orders. Default `true`. */
187
+ handlesFulfillment?: boolean;
188
+ /** Whether customers can pick up here. Default `false`. */
189
+ pickupAvailable?: boolean;
190
+ /** Preferred carrier code for shipments dispatched from this location. */
191
+ preferredCarrier?: string;
192
+ latitude?: number;
193
+ longitude?: number;
194
+ referencePoint?: string;
195
+ }
196
+ /** Aggregate result of `merchants.importLocations()`. */
197
+ export interface ImportLocationsResult {
198
+ total: number;
199
+ created: number;
200
+ updated: number;
201
+ errors: Array<{
202
+ row: number;
203
+ code: string;
204
+ message: string;
205
+ }>;
117
206
  }
118
207
  export interface ListMerchantsQuery {
119
208
  page?: number;
@@ -134,6 +223,8 @@ export interface Page<T> {
134
223
  }
135
224
  /** Max length the platform accepts for `CreateMerchantInput.externalId`. */
136
225
  export declare const MAX_EXTERNAL_ID_LENGTH = 120;
226
+ /** Max length the platform accepts for `CreateMerchantInput.idempotencyKey`. */
227
+ export declare const MAX_IDEMPOTENCY_KEY_LENGTH = 120;
137
228
  /** Runtime states where the platform accepts `activate` (first run or retry). */
138
229
  export declare function canActivate(runtime: MerchantRuntimeStatus): boolean;
139
230
  /** States that will not change without a new action from the partner. */
@@ -13,6 +13,8 @@ export const MERCHANT_RUNTIME_STATUSES = [
13
13
  ];
14
14
  /** Max length the platform accepts for `CreateMerchantInput.externalId`. */
15
15
  export const MAX_EXTERNAL_ID_LENGTH = 120;
16
+ /** Max length the platform accepts for `CreateMerchantInput.idempotencyKey`. */
17
+ export const MAX_IDEMPOTENCY_KEY_LENGTH = 120;
16
18
  /** Runtime states where the platform accepts `activate` (first run or retry). */
17
19
  export function canActivate(runtime) {
18
20
  return runtime === 'not_activated' || runtime === 'failed';
@@ -1 +1 @@
1
- export declare const SDK_VERSION = "0.3.0";
1
+ export declare const SDK_VERSION = "0.4.0";
@@ -1 +1 @@
1
- export const SDK_VERSION = '0.3.0';
1
+ export const SDK_VERSION = '0.4.0';
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@vivoa/partner-sdk",
3
- "version": "0.3.0",
3
+ "version": "0.4.0",
4
4
  "description": "Official SDK for the Vivoa Partner API — create and operate stores for your merchants",
5
5
  "license": "UNLICENSED",
6
6
  "type": "module",