@vivoa/partner-sdk 0.2.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)$/);
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') {
@@ -202,6 +203,38 @@ class FakeVivoa {
202
203
  redirectUrl: `${base}/auth/admin-redeem?token=fake-${merchant.id}${returnPath === '/admin' ? '' : `&rp=${encodeURIComponent(returnPath)}`}`,
203
204
  };
204
205
  }
206
+ if (action === 'plugins') {
207
+ const plugins = Array.isArray(body.plugins) ? body.plugins : [];
208
+ return {
209
+ plugins: plugins.map((p) => ({
210
+ code: p.code,
211
+ installed: true,
212
+ configured: true,
213
+ })),
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
+ }
234
+ if (action === 'products') {
235
+ const products = Array.isArray(body.products) ? body.products : [];
236
+ return { total: products.length, created: products.length, updated: 0, skipped: 0, errors: [] };
237
+ }
205
238
  }
206
239
  }
207
240
  if (method === 'GET' && path === '/fleet/analytics')
@@ -218,6 +251,7 @@ class FakeVivoa {
218
251
  const name = String(body.name ?? '');
219
252
  const countries = Array.isArray(body.countries) ? body.countries.map(String) : [];
220
253
  const externalId = body.externalId === undefined ? undefined : String(body.externalId);
254
+ const idempotencyKey = body.idempotencyKey === undefined ? undefined : String(body.idempotencyKey);
221
255
  const problems = [];
222
256
  if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email))
223
257
  problems.push('email must be an email');
@@ -228,11 +262,17 @@ class FakeVivoa {
228
262
  if (externalId !== undefined && externalId.length > merchant_ts_1.MAX_EXTERNAL_ID_LENGTH) {
229
263
  problems.push(`externalId must be shorter than or equal to ${merchant_ts_1.MAX_EXTERNAL_ID_LENGTH} characters`);
230
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
+ }
231
268
  if (problems.length)
232
269
  throw new HttpError(400, problems.join('; '));
233
- // Idempotent replay BEFORE quota/region checks — mirrors the platform.
234
- if (externalId) {
235
- 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);
236
276
  if (existing)
237
277
  return publicMerchant(existing);
238
278
  }
@@ -257,6 +297,9 @@ class FakeVivoa {
257
297
  createdAt: new Date(this.clock.nowMs()).toISOString(),
258
298
  operatorId: op.id,
259
299
  pollsLeft: 0,
300
+ idempotencyKey: idempotencyKey ?? null,
301
+ provisionedAt: null,
302
+ lastError: null,
260
303
  };
261
304
  this.handles.add(merchant.handle);
262
305
  this.merchants.set(merchant.id, merchant);
@@ -291,6 +334,7 @@ class FakeVivoa {
291
334
  switch (this.activation) {
292
335
  case 'fail':
293
336
  m.runtime = 'failed';
337
+ m.lastError = this.failureReason;
294
338
  this.emit(op, 'merchant.failed', m, { reason: this.failureReason });
295
339
  throw new HttpError(500, this.failureReason);
296
340
  case 'slow':
@@ -309,6 +353,8 @@ class FakeVivoa {
309
353
  goLive(m) {
310
354
  m.runtime = 'live';
311
355
  m.domain = `${m.handle}.vivoa.store`;
356
+ m.provisionedAt = new Date(this.clock.nowMs()).toISOString();
357
+ m.lastError = null;
312
358
  const op = [...this.operators.values()].find((o) => o.id === m.operatorId);
313
359
  if (op)
314
360
  this.emit(op, 'merchant.live', m);
@@ -327,6 +373,8 @@ class FakeVivoa {
327
373
  deployStatus: m.runtime,
328
374
  domain: m.domain,
329
375
  apiBaseUrl: m.runtime === 'live' ? 'https://shared-api.vivoa.app' : null,
376
+ provisionedAt: m.provisionedAt,
377
+ lastError: m.runtime === 'failed' ? m.lastError : null,
330
378
  };
331
379
  }
332
380
  list(op, qs) {
@@ -457,7 +505,7 @@ class FakeVivoa {
457
505
  }
458
506
  exports.FakeVivoa = FakeVivoa;
459
507
  function publicMerchant(m) {
460
- const { operatorId: _op, pollsLeft: _polls, ...view } = m;
508
+ const { operatorId: _op, pollsLeft: _polls, idempotencyKey: _key, provisionedAt: _prov, lastError: _err, ...view } = m;
461
509
  return { ...view, countries: [...view.countries] };
462
510
  }
463
511
  function webhookView(op) {
@@ -1,5 +1,5 @@
1
1
  import type { SignedHttpClient } from '../signed-http.ts';
2
- import { type AdminTicketOptions, type CreateMerchantInput, type ListMerchantsQuery, type Merchant, type MerchantAdminTicket, type MerchantStatus, type Page } 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>>;
@@ -39,5 +40,26 @@ export declare class MerchantsResource {
39
40
  * not store it. 403 if the merchant is not in your fleet or is not active.
40
41
  */
41
42
  adminTicket(merchantId: string, options?: AdminTicketOptions): Promise<MerchantAdminTicket>;
43
+ /**
44
+ * Installs + configures plugins on the merchant's store (credentials, pickup
45
+ * origin, activation). Idempotent — config is merged, not replaced — so it
46
+ * doubles as a re-sync. 403 if the merchant is not in your fleet.
47
+ */
48
+ configurePlugins(merchantId: string, plugins: PluginProvisionInput[]): Promise<{
49
+ plugins: PluginProvisionResult[];
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>;
58
+ /**
59
+ * Bulk-imports products into the merchant's catalog (URL images, per-country
60
+ * inventory). Idempotent on `handle`, so re-running updates rather than
61
+ * duplicates. 403 if the merchant is not in your fleet.
62
+ */
63
+ importProducts(merchantId: string, products: ProvisionProduct[]): Promise<ImportProductsResult>;
42
64
  }
43
65
  export declare function validateCreate(input: CreateMerchantInput): void;
@@ -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);
@@ -72,6 +73,43 @@ class MerchantsResource {
72
73
  body: options.returnPath ? { returnPath: options.returnPath } : {},
73
74
  });
74
75
  }
76
+ /**
77
+ * Installs + configures plugins on the merchant's store (credentials, pickup
78
+ * origin, activation). Idempotent — config is merged, not replaced — so it
79
+ * doubles as a re-sync. 403 if the merchant is not in your fleet.
80
+ */
81
+ async configurePlugins(merchantId, plugins) {
82
+ if (plugins.length === 0)
83
+ throw new errors_ts_1.VivoaValidationError('plugins must not be empty');
84
+ return this.http.request('POST', `/partner/v1/merchants/${encodeId(merchantId)}/plugins`, {
85
+ body: { plugins },
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
+ }
101
+ /**
102
+ * Bulk-imports products into the merchant's catalog (URL images, per-country
103
+ * inventory). Idempotent on `handle`, so re-running updates rather than
104
+ * duplicates. 403 if the merchant is not in your fleet.
105
+ */
106
+ async importProducts(merchantId, products) {
107
+ if (products.length === 0)
108
+ throw new errors_ts_1.VivoaValidationError('products must not be empty');
109
+ return this.http.request('POST', `/partner/v1/merchants/${encodeId(merchantId)}/products`, {
110
+ body: { products },
111
+ });
112
+ }
75
113
  }
76
114
  exports.MerchantsResource = MerchantsResource;
77
115
  function encodeId(id) {
@@ -98,6 +136,13 @@ function validateCreate(input) {
98
136
  problems.push(`externalId must be at most ${merchant_ts_1.MAX_EXTERNAL_ID_LENGTH} chars`);
99
137
  }
100
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
+ }
101
146
  if (problems.length > 0)
102
147
  throw new errors_ts_1.VivoaValidationError(problems.join('; '));
103
148
  }
@@ -1,7 +1,7 @@
1
1
  import type { Clock } from '../../ports/clock.port.ts';
2
2
  import type { MerchantsResource } from '../resources/merchants.ts';
3
- import { type CreateMerchantInput, type Merchant, type MerchantRuntimeStatus, type MerchantStatus } from '../../domain/merchant.ts';
4
- export type ProvisionPhase = 'found' | 'created' | 'activating' | 'waiting' | 'settled';
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
+ export type ProvisionPhase = 'found' | 'created' | 'activating' | 'waiting' | 'seeding' | 'settled';
5
5
  export interface ProvisionProgress {
6
6
  phase: ProvisionPhase;
7
7
  merchantId: string;
@@ -18,22 +18,46 @@ export interface ProvisionStoreOptions {
18
18
  /** Status poll cadence while `creating`. Default 5 s. */
19
19
  pollIntervalMs?: number;
20
20
  onProgress?: (progress: ProvisionProgress) => void;
21
+ /**
22
+ * Plugins to install + configure once the store is live (credentials, pickup
23
+ * origin, activation). Best-effort: a live store with a failed seed still
24
+ * resolves — the result carries the error and the caller can re-run.
25
+ */
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[];
33
+ /** Products to seed into the catalog once the store is live. Best-effort. */
34
+ products?: ProvisionProduct[];
21
35
  }
22
36
  export interface ProvisionStoreResult {
23
37
  merchant: Merchant;
24
38
  status: MerchantStatus;
25
- /** 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
+ */
26
44
  created: boolean;
45
+ /** Per-plugin seed outcome, when `options.plugins` was supplied. */
46
+ plugins?: PluginProvisionResult[];
47
+ /** Location import outcome, when `options.locations` was supplied. */
48
+ locations?: ImportLocationsResult;
49
+ /** Catalog seed outcome, when `options.products` was supplied. */
50
+ products?: ImportProductsResult;
27
51
  }
28
52
  /**
29
53
  * "Give me a live store" — the one call most partners need.
30
54
  *
31
- * Requires `input.externalId`: it is what makes retrying safe. Re-running
32
- * after a crash, a timeout or a double click resumes the same merchant
33
- * instead of creating a second one — the platform is idempotent on it (see
34
- * `PARTNERS.md` §5.2), and it never runs out (unlike a handle, which the
35
- * platform now assigns and a partner never sees before creation). Never
36
- * 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.
37
61
  */
38
62
  export declare class ProvisionStoreUseCase {
39
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);
@@ -45,11 +45,62 @@ class ProvisionStoreUseCase {
45
45
  pollIntervalMs: options.pollIntervalMs,
46
46
  });
47
47
  }
48
- progress('settled', merchant, status.deployStatus);
49
48
  if (status.deployStatus === 'failed' || status.deployStatus === 'unknown') {
50
- throw new errors_ts_1.VivoaProvisioningFailedError(merchant.id, `the platform reported runtime status "${status.deployStatus}"`);
49
+ progress('settled', merchant, status.deployStatus);
50
+ throw new errors_ts_1.VivoaProvisioningFailedError(merchant.id, status.lastError ?? `the platform reported runtime status "${status.deployStatus}"`);
51
+ }
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.
57
+ let plugins;
58
+ let locations;
59
+ let products;
60
+ if (options.plugins?.length || options.locations?.length || options.products?.length) {
61
+ progress('seeding', merchant, status.deployStatus);
62
+ if (options.plugins?.length) {
63
+ plugins = await this.merchants
64
+ .configurePlugins(merchant.id, options.plugins)
65
+ .then((r) => r.plugins)
66
+ .catch((err) => options.plugins.map((p) => ({
67
+ code: p.code,
68
+ installed: false,
69
+ configured: false,
70
+ error: err instanceof Error ? err.message : String(err),
71
+ })));
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
+ }
83
+ if (options.products?.length) {
84
+ products = await this.merchants
85
+ .importProducts(merchant.id, options.products)
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
+ }));
93
+ }
51
94
  }
52
- return { merchant: { ...merchant, runtime: status.deployStatus, domain: status.domain }, status, created };
95
+ progress('settled', merchant, status.deployStatus);
96
+ return {
97
+ merchant: { ...merchant, runtime: status.deployStatus, domain: status.domain },
98
+ status,
99
+ created,
100
+ ...(plugins ? { plugins } : {}),
101
+ ...(locations ? { locations } : {}),
102
+ ...(products ? { products } : {}),
103
+ };
53
104
  }
54
105
  /** Polls `GET /status` until the runtime leaves `creating`. */
55
106
  async waitUntilSettled(merchantId, options = {}) {
@@ -66,15 +117,16 @@ class ProvisionStoreUseCase {
66
117
  }
67
118
  }
68
119
  async ensureMerchant(input) {
69
- const existing = await this.merchants.findByExternalId(input.externalId);
70
- if (existing)
71
- return { merchant: existing, created: false };
72
- // The platform is idempotent on externalId: even if a concurrent call for
73
- // the same externalId wins the race between the check above and this
74
- // request, `create` still resolves to ITS merchant — never a 409 or a
75
- // second store. `created` can read `true` in that narrow window; harmless,
76
- // since the merchant returned is the same either way.
77
- 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' };
78
130
  }
79
131
  async activate(merchantId, deadline) {
80
132
  try {