@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 +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 +41 -7
- package/dist/cjs/application/resources/merchants.d.ts +10 -2
- package/dist/cjs/application/resources/merchants.js +22 -1
- package/dist/cjs/application/use-cases/provision-store.d.ts +20 -8
- package/dist/cjs/application/use-cases/provision-store.js +44 -24
- package/dist/cjs/domain/merchant.d.ts +105 -14
- 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 +42 -8
- package/dist/esm/application/resources/merchants.d.ts +10 -2
- package/dist/esm/application/resources/merchants.js +23 -2
- package/dist/esm/application/use-cases/provision-store.d.ts +20 -8
- package/dist/esm/application/use-cases/provision-store.js +44 -24
- package/dist/esm/domain/merchant.d.ts +105 -14
- 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|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,
|
|
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
|
-
|
|
249
|
-
|
|
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 `
|
|
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 `
|
|
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
|
-
/**
|
|
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.
|
|
44
|
-
* after a crash, a timeout or a double click resumes the same merchant
|
|
45
|
-
*
|
|
46
|
-
* `PARTNERS.md` §5.2)
|
|
47
|
-
*
|
|
48
|
-
*
|
|
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.
|
|
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);
|
|
@@ -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
|
|
53
|
-
// failed seed leaves the store live and reports the error, so
|
|
54
|
-
// re-run (
|
|
55
|
-
//
|
|
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(() => ({
|
|
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
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
//
|
|
105
|
-
|
|
106
|
-
//
|
|
107
|
-
//
|
|
108
|
-
//
|
|
109
|
-
|
|
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
|
|
45
|
-
*
|
|
46
|
-
*
|
|
47
|
-
*
|
|
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
|
-
/**
|
|
95
|
-
|
|
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
|
-
/**
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
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
|
-
|
|
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';
|
package/dist/cjs/version.d.ts
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
export declare const SDK_VERSION = "0.
|
|
1
|
+
export declare const SDK_VERSION = "0.4.0";
|
package/dist/cjs/version.js
CHANGED
|
@@ -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,
|
|
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
|
-
|
|
246
|
-
|
|
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 `
|
|
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 `
|
|
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
|
-
/**
|
|
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.
|
|
44
|
-
* after a crash, a timeout or a double click resumes the same merchant
|
|
45
|
-
*
|
|
46
|
-
* `PARTNERS.md` §5.2)
|
|
47
|
-
*
|
|
48
|
-
*
|
|
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.
|
|
9
|
-
* after a crash, a timeout or a double click resumes the same merchant
|
|
10
|
-
*
|
|
11
|
-
* `PARTNERS.md` §5.2)
|
|
12
|
-
*
|
|
13
|
-
*
|
|
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.
|
|
24
|
-
throw new VivoaValidationError('provisionStore requires input.
|
|
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
|
|
50
|
-
// failed seed leaves the store live and reports the error, so
|
|
51
|
-
// re-run (
|
|
52
|
-
//
|
|
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(() => ({
|
|
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
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
//
|
|
102
|
-
|
|
103
|
-
//
|
|
104
|
-
//
|
|
105
|
-
//
|
|
106
|
-
|
|
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
|
|
45
|
-
*
|
|
46
|
-
*
|
|
47
|
-
*
|
|
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
|
-
/**
|
|
95
|
-
|
|
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
|
-
/**
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
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
|
-
|
|
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';
|
package/dist/esm/version.d.ts
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
export declare const SDK_VERSION = "0.
|
|
1
|
+
export declare const SDK_VERSION = "0.4.0";
|
package/dist/esm/version.js
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
export const SDK_VERSION = '0.
|
|
1
|
+
export const SDK_VERSION = '0.4.0';
|