@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 +12 -0
- package/README.md +13 -13
- package/dist/cjs/adapters/in-memory/fake-vivoa.d.ts +1 -0
- package/dist/cjs/adapters/in-memory/fake-vivoa.js +54 -6
- package/dist/cjs/application/resources/merchants.d.ts +24 -2
- package/dist/cjs/application/resources/merchants.js +46 -1
- package/dist/cjs/application/use-cases/provision-store.d.ts +33 -9
- package/dist/cjs/application/use-cases/provision-store.js +72 -20
- package/dist/cjs/domain/merchant.d.ts +146 -5
- package/dist/cjs/domain/merchant.js +3 -1
- package/dist/cjs/version.d.ts +1 -1
- package/dist/cjs/version.js +1 -1
- package/dist/esm/adapters/in-memory/fake-vivoa.d.ts +1 -0
- package/dist/esm/adapters/in-memory/fake-vivoa.js +55 -7
- package/dist/esm/application/resources/merchants.d.ts +24 -2
- package/dist/esm/application/resources/merchants.js +47 -2
- package/dist/esm/application/use-cases/provision-store.d.ts +33 -9
- package/dist/esm/application/use-cases/provision-store.js +72 -20
- package/dist/esm/domain/merchant.d.ts +146 -5
- package/dist/esm/domain/merchant.js +2 -0
- package/dist/esm/version.d.ts +1 -1
- package/dist/esm/version.js +1 -1
- package/package.json +1 -1
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
|
|
37
|
+
// Provision a store for one of your users — idempotent on idempotencyKey
|
|
38
38
|
const { merchant, status } = await vivoa.provisionStore({
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
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
|
-
`
|
|
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.
|
|
58
|
-
2.
|
|
59
|
-
3.
|
|
60
|
-
4.
|
|
61
|
-
5.
|
|
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 `
|
|
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
|
-
|
|
235
|
-
|
|
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 `
|
|
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 `
|
|
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
|
-
/**
|
|
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.
|
|
32
|
-
* after a crash, a timeout or a double click resumes the same merchant
|
|
33
|
-
*
|
|
34
|
-
* `PARTNERS.md` §5.2)
|
|
35
|
-
*
|
|
36
|
-
*
|
|
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.
|
|
12
|
-
* after a crash, a timeout or a double click resumes the same merchant
|
|
13
|
-
*
|
|
14
|
-
* `PARTNERS.md` §5.2)
|
|
15
|
-
*
|
|
16
|
-
*
|
|
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.
|
|
27
|
-
throw new errors_ts_1.VivoaValidationError('provisionStore requires input.
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
//
|
|
73
|
-
|
|
74
|
-
//
|
|
75
|
-
//
|
|
76
|
-
//
|
|
77
|
-
|
|
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 {
|