@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.
@@ -5,12 +5,12 @@ const DEFAULT_POLL_MS = 5_000;
5
5
  /**
6
6
  * "Give me a live store" — the one call most partners need.
7
7
  *
8
- * Requires `input.externalId`: it is what makes retrying safe. Re-running
9
- * after a crash, a timeout or a double click resumes the same merchant
10
- * instead of creating a second one — the platform is idempotent on it (see
11
- * `PARTNERS.md` §5.2), and it never runs out (unlike a handle, which the
12
- * platform now assigns and a partner never sees before creation). Never
13
- * retries a failed activation on its own.
8
+ * Requires `input.idempotencyKey`: it is what makes retrying safe. Re-running
9
+ * after a crash, a timeout or a double click resumes the same merchant instead
10
+ * of creating a second one — the platform is idempotent on it (see
11
+ * `PARTNERS.md` §5.2). `externalId` is a separate, non-unique client reference
12
+ * (one client may own several stores) and is NOT a dedup key. Never retries a
13
+ * failed activation on its own.
14
14
  */
15
15
  export class ProvisionStoreUseCase {
16
16
  merchants;
@@ -20,8 +20,8 @@ export class ProvisionStoreUseCase {
20
20
  this.clock = clock;
21
21
  }
22
22
  async execute(input, options = {}) {
23
- if (!input.externalId?.trim()) {
24
- throw new VivoaValidationError('provisionStore requires input.externalId — it is the key that makes retrying safe. ' +
23
+ if (!input.idempotencyKey?.trim()) {
24
+ throw new VivoaValidationError('provisionStore requires input.idempotencyKey — it is the key that makes retrying safe. ' +
25
25
  'Use merchants.create() directly if you do not need that guarantee.');
26
26
  }
27
27
  const deadline = this.clock.nowMs() + (options.timeoutMs ?? DEFAULT_TIMEOUT_MS);
@@ -42,11 +42,62 @@ export class ProvisionStoreUseCase {
42
42
  pollIntervalMs: options.pollIntervalMs,
43
43
  });
44
44
  }
45
- progress('settled', merchant, status.deployStatus);
46
45
  if (status.deployStatus === 'failed' || status.deployStatus === 'unknown') {
47
- throw new VivoaProvisioningFailedError(merchant.id, `the platform reported runtime status "${status.deployStatus}"`);
46
+ progress('settled', merchant, status.deployStatus);
47
+ throw new VivoaProvisioningFailedError(merchant.id, status.lastError ?? `the platform reported runtime status "${status.deployStatus}"`);
48
+ }
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.
54
+ let plugins;
55
+ let locations;
56
+ let products;
57
+ if (options.plugins?.length || options.locations?.length || options.products?.length) {
58
+ progress('seeding', merchant, status.deployStatus);
59
+ if (options.plugins?.length) {
60
+ plugins = await this.merchants
61
+ .configurePlugins(merchant.id, options.plugins)
62
+ .then((r) => r.plugins)
63
+ .catch((err) => options.plugins.map((p) => ({
64
+ code: p.code,
65
+ installed: false,
66
+ configured: false,
67
+ error: err instanceof Error ? err.message : String(err),
68
+ })));
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
+ }
80
+ if (options.products?.length) {
81
+ products = await this.merchants
82
+ .importProducts(merchant.id, options.products)
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
+ }));
90
+ }
48
91
  }
49
- return { merchant: { ...merchant, runtime: status.deployStatus, domain: status.domain }, status, created };
92
+ progress('settled', merchant, status.deployStatus);
93
+ return {
94
+ merchant: { ...merchant, runtime: status.deployStatus, domain: status.domain },
95
+ status,
96
+ created,
97
+ ...(plugins ? { plugins } : {}),
98
+ ...(locations ? { locations } : {}),
99
+ ...(products ? { products } : {}),
100
+ };
50
101
  }
51
102
  /** Polls `GET /status` until the runtime leaves `creating`. */
52
103
  async waitUntilSettled(merchantId, options = {}) {
@@ -63,15 +114,16 @@ export class ProvisionStoreUseCase {
63
114
  }
64
115
  }
65
116
  async ensureMerchant(input) {
66
- const existing = await this.merchants.findByExternalId(input.externalId);
67
- if (existing)
68
- return { merchant: existing, created: false };
69
- // The platform is idempotent on externalId: even if a concurrent call for
70
- // the same externalId wins the race between the check above and this
71
- // request, `create` still resolves to ITS merchant — never a 409 or a
72
- // second store. `created` can read `true` in that narrow window; harmless,
73
- // since the merchant returned is the same either way.
74
- return { merchant: await this.merchants.create(input), created: true };
117
+ // The platform is idempotent on idempotencyKey: replaying create() with the
118
+ // same key returns the merchant it already made — never a duplicate, never a
119
+ // 409. So we just create; no pre-check needed. (externalId is NOT the key: it
120
+ // is a non-unique client reference and may map to several of your stores.)
121
+ const merchant = await this.merchants.create(input);
122
+ // A freshly created store is always 'not_activated' with nothing provisioned
123
+ // yet; any further-along runtime means this call replayed an existing
124
+ // merchant. A replay of a never-activated merchant reads created=true —
125
+ // harmless, since provisionStore then activates it, which is idempotent too.
126
+ return { merchant, created: merchant.runtime === 'not_activated' };
75
127
  }
76
128
  async activate(merchantId, deadline) {
77
129
  try {
@@ -32,6 +32,14 @@ export interface MerchantStatus {
32
32
  deployStatus: MerchantRuntimeStatus;
33
33
  domain: string | null;
34
34
  apiBaseUrl: string | null;
35
+ /** ISO-8601 when the store reached `live` (null before it provisions). */
36
+ provisionedAt: string | null;
37
+ /**
38
+ * Why the LAST provisioning attempt failed — surfaced only while
39
+ * `deployStatus === 'failed'`, null otherwise (a stale error never leaks once
40
+ * the store is live). The queryable "why" behind a failed activation.
41
+ */
42
+ lastError: string | null;
35
43
  }
36
44
  export interface CreateMerchantInput {
37
45
  /** Email of the store owner (receives their own merchant account). */
@@ -41,13 +49,20 @@ export interface CreateMerchantInput {
41
49
  countries: string[];
42
50
  industry?: string;
43
51
  /**
44
- * Your own reference for this merchant (customer id, order id…). Optional
45
- * here, but it is the idempotency key of `merchants.create()`: replaying the
46
- * same value returns the merchant you already created instead of a second
47
- * one. `provisionStore()` requires it for exactly that reason. Unique per
48
- * operator, max 120 chars.
52
+ * Your reference for the CLIENT who owns this store (your customer id). **Not
53
+ * unique**: one client can own several stores, all sharing this value — list
54
+ * them with `merchants.list({ externalId })`. This is NOT the idempotency key
55
+ * (see `idempotencyKey`). Max 120 chars.
49
56
  */
50
57
  externalId?: string;
58
+ /**
59
+ * Idempotency key for THIS creation (e.g. your order id). Replaying the same
60
+ * key returns the SAME merchant instead of creating a duplicate — safe
61
+ * retries. **Unique per operator**, max 120 chars. `provisionStore()`
62
+ * requires it for exactly that reason. Omit it and every call creates a new
63
+ * store.
64
+ */
65
+ idempotencyKey?: string;
51
66
  }
52
67
  /** Options for `merchants.adminTicket()`. */
53
68
  export interface AdminTicketOptions {
@@ -65,6 +80,130 @@ export interface AdminTicketOptions {
65
80
  export interface MerchantAdminTicket {
66
81
  redirectUrl: string;
67
82
  }
83
+ /** One plugin to install + configure on a merchant's store. */
84
+ export interface PluginProvisionInput {
85
+ /** Plugin code (e.g. `boxful`). */
86
+ code: string;
87
+ /** Global-scope config, merged into the plugin's existing config. */
88
+ global?: Record<string, unknown>;
89
+ /** Per-country config slices (credentials, pickup origin…), merged per country. */
90
+ countries?: Array<{
91
+ countryCode: string;
92
+ config: Record<string, unknown>;
93
+ }>;
94
+ /** Activate the plugin (and its countries) so the store is ready to use. */
95
+ activate?: boolean;
96
+ }
97
+ /** Outcome of `merchants.configurePlugins()` for one plugin. */
98
+ export interface PluginProvisionResult {
99
+ code: string;
100
+ installed: boolean;
101
+ configured: boolean;
102
+ error?: string;
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
+ };
120
+ /** One product to seed into a merchant's catalog. Mirrors the store import item. */
121
+ export interface ProvisionProduct {
122
+ /** Slug, kebab-case `[a-z0-9-]`. */
123
+ handle: string;
124
+ name: string;
125
+ /**
126
+ * Product type: `physical` (has variants, requires shipping), `service` (no
127
+ * variants, no shipping), `digital` (has variants, no shipping). Unknown
128
+ * values fall back to `physical` store-side — prefer being explicit.
129
+ */
130
+ productTypeSlug: ProductTypeSlug;
131
+ price: number;
132
+ description?: string;
133
+ vendor?: string;
134
+ tags?: string[];
135
+ costPrice?: number;
136
+ barcode?: string;
137
+ weight?: number;
138
+ /** Remote image URLs — validated + ingested store-side. Max 20. */
139
+ images?: string[];
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>;
159
+ }
160
+ /** Aggregate result of `merchants.importProducts()`. */
161
+ export interface ImportProductsResult {
162
+ /** Rows received (== `products.length`, or the rows that passed validation). */
163
+ total: number;
164
+ created: number;
165
+ updated: number;
166
+ skipped: number;
167
+ /** Per-row failures. Empty on full success. */
168
+ errors: ImportProductError[];
169
+ }
170
+ /** One warehouse / pickup point to seed into a merchant's inventory. Upserted by `code`. */
171
+ export interface ProvisionLocation {
172
+ /** Your stable identifier — the upsert key (e.g. warehouse id). Max 60 chars. */
173
+ code: string;
174
+ /** Display name shown in the store admin. Max 120 chars. */
175
+ name: string;
176
+ /** ISO-3166-1 alpha-2 (e.g. `"SV"`, `"GT"`, `"HN"`). */
177
+ country: string;
178
+ /** Location type hint (e.g. `warehouse`, `pickup`, `shop`). */
179
+ type?: string;
180
+ address?: string;
181
+ city?: string;
182
+ postalCode?: string;
183
+ phone?: string;
184
+ email?: string;
185
+ isActive?: boolean;
186
+ /** Whether this location ships orders. Default `true`. */
187
+ handlesFulfillment?: boolean;
188
+ /** Whether customers can pick up here. Default `false`. */
189
+ pickupAvailable?: boolean;
190
+ /** Preferred carrier code for shipments dispatched from this location. */
191
+ preferredCarrier?: string;
192
+ latitude?: number;
193
+ longitude?: number;
194
+ referencePoint?: string;
195
+ }
196
+ /** Aggregate result of `merchants.importLocations()`. */
197
+ export interface ImportLocationsResult {
198
+ total: number;
199
+ created: number;
200
+ updated: number;
201
+ errors: Array<{
202
+ row: number;
203
+ code: string;
204
+ message: string;
205
+ }>;
206
+ }
68
207
  export interface ListMerchantsQuery {
69
208
  page?: number;
70
209
  /** Max 100. */
@@ -84,6 +223,8 @@ export interface Page<T> {
84
223
  }
85
224
  /** Max length the platform accepts for `CreateMerchantInput.externalId`. */
86
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;
87
228
  /** Runtime states where the platform accepts `activate` (first run or retry). */
88
229
  export declare function canActivate(runtime: MerchantRuntimeStatus): boolean;
89
230
  /** States that will not change without a new action from the partner. */
@@ -13,6 +13,8 @@ export const MERCHANT_RUNTIME_STATUSES = [
13
13
  ];
14
14
  /** Max length the platform accepts for `CreateMerchantInput.externalId`. */
15
15
  export const MAX_EXTERNAL_ID_LENGTH = 120;
16
+ /** Max length the platform accepts for `CreateMerchantInput.idempotencyKey`. */
17
+ export const MAX_IDEMPOTENCY_KEY_LENGTH = 120;
16
18
  /** Runtime states where the platform accepts `activate` (first run or retry). */
17
19
  export function canActivate(runtime) {
18
20
  return runtime === 'not_activated' || runtime === 'failed';
@@ -1 +1 @@
1
- export declare const SDK_VERSION = "0.2.0";
1
+ export declare const SDK_VERSION = "0.4.0";
@@ -1 +1 @@
1
- export const SDK_VERSION = '0.2.0';
1
+ export const SDK_VERSION = '0.4.0';
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@vivoa/partner-sdk",
3
- "version": "0.2.0",
3
+ "version": "0.4.0",
4
4
  "description": "Official SDK for the Vivoa Partner API — create and operate stores for your merchants",
5
5
  "license": "UNLICENSED",
6
6
  "type": "module",