shopstack 0.2.0 → 0.2.2
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/LICENSE +21 -0
- package/README.md +39 -13
- package/SKILL.md +24 -7
- package/bin/shopstack +3 -1
- package/package.json +29 -5
- package/src/cli.js +210 -37
- package/src/client.d.ts +70 -9
- package/src/client.js +356 -81
- package/src/config.d.ts +22 -3
- package/src/config.js +56 -4
package/src/client.d.ts
CHANGED
|
@@ -2,6 +2,7 @@ export interface ShopstackClientOptions {
|
|
|
2
2
|
apiKey?: string;
|
|
3
3
|
baseUrl?: string;
|
|
4
4
|
fetch?: typeof fetch;
|
|
5
|
+
webSocket?: typeof WebSocket;
|
|
5
6
|
}
|
|
6
7
|
|
|
7
8
|
export interface PaymentCard {
|
|
@@ -33,6 +34,7 @@ export interface Checkout {
|
|
|
33
34
|
| "failed"
|
|
34
35
|
| "cancelled";
|
|
35
36
|
revision: number;
|
|
37
|
+
presentation_revision: number;
|
|
36
38
|
activity: string;
|
|
37
39
|
intent?: {
|
|
38
40
|
name: string;
|
|
@@ -40,6 +42,7 @@ export interface Checkout {
|
|
|
40
42
|
updated_at: string;
|
|
41
43
|
};
|
|
42
44
|
item_url: string;
|
|
45
|
+
live_view_url?: string;
|
|
43
46
|
required_input?: { type: "payment_card" };
|
|
44
47
|
approval?: PaymentApproval;
|
|
45
48
|
result?: Record<string, unknown>;
|
|
@@ -51,6 +54,7 @@ export interface Checkout {
|
|
|
51
54
|
|
|
52
55
|
export interface RunCheckoutOptions {
|
|
53
56
|
idempotencyKey?: string;
|
|
57
|
+
onCreated?(checkout: Checkout): void | Promise<void>;
|
|
54
58
|
pollIntervalMs?: number;
|
|
55
59
|
timeoutMs?: number;
|
|
56
60
|
onProgress?(checkout: Checkout): void | Promise<void>;
|
|
@@ -66,6 +70,14 @@ export interface RunCheckoutOptions {
|
|
|
66
70
|
): string | Promise<string | undefined> | undefined;
|
|
67
71
|
}
|
|
68
72
|
|
|
73
|
+
export interface CheckoutUpdateSubscription {
|
|
74
|
+
socket_url: string;
|
|
75
|
+
protocol: "shopstack.v1";
|
|
76
|
+
token: string;
|
|
77
|
+
expires_at: string;
|
|
78
|
+
presentation_revision: number;
|
|
79
|
+
}
|
|
80
|
+
|
|
69
81
|
export interface AccountSignupStarted {
|
|
70
82
|
id: string;
|
|
71
83
|
account_type: "personal" | "developer";
|
|
@@ -89,14 +101,58 @@ export interface VerifiedAccountSignup {
|
|
|
89
101
|
export interface SignupOptions {
|
|
90
102
|
accountType: "personal" | "developer";
|
|
91
103
|
email: string;
|
|
92
|
-
idempotencyKey?: string;
|
|
93
104
|
pollIntervalMs?: number;
|
|
94
105
|
timeoutMs?: number;
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
106
|
+
persistence?: SignupPersistence;
|
|
107
|
+
onProgress?(progress: SignupProgress): void | Promise<void>;
|
|
108
|
+
onVerificationRequired?(
|
|
109
|
+
signup: Omit<AccountSignupStarted, "poll_token">,
|
|
110
|
+
): void | Promise<void>;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
export interface PendingSignupState {
|
|
114
|
+
accountType: "personal" | "developer";
|
|
115
|
+
attemptId: string;
|
|
116
|
+
email: string;
|
|
117
|
+
expiresAt?: string;
|
|
118
|
+
idempotencyKey: string;
|
|
119
|
+
pollToken?: string;
|
|
120
|
+
recoveryKey: string;
|
|
121
|
+
signupId?: string;
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
export interface SignupPersistence {
|
|
125
|
+
loadPending(input: {
|
|
126
|
+
accountType: "personal" | "developer";
|
|
127
|
+
email: string;
|
|
128
|
+
}): Promise<PendingSignupState | undefined>;
|
|
129
|
+
savePending(state: PendingSignupState): Promise<void>;
|
|
130
|
+
completePending(
|
|
131
|
+
state: PendingSignupState,
|
|
132
|
+
result: VerifiedAccountSignup,
|
|
133
|
+
): Promise<void>;
|
|
134
|
+
deletePending(state: PendingSignupState): Promise<void>;
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
export interface SignupProgress {
|
|
138
|
+
state:
|
|
139
|
+
| "pending"
|
|
140
|
+
| "email_sent"
|
|
141
|
+
| "waiting"
|
|
142
|
+
| "verified"
|
|
143
|
+
| "expired"
|
|
144
|
+
| "rate_limited"
|
|
145
|
+
| "conflict"
|
|
146
|
+
| "retryable_failure"
|
|
147
|
+
| "complete";
|
|
148
|
+
account_type?: "personal" | "developer";
|
|
149
|
+
email?: string;
|
|
150
|
+
expires_at?: string;
|
|
151
|
+
key_type?: "user" | "developer";
|
|
98
152
|
}
|
|
99
153
|
|
|
154
|
+
export type CompletedAccountSignup = Omit<VerifiedAccountSignup, "api_key">;
|
|
155
|
+
|
|
100
156
|
export class ShopstackApiError extends Error {
|
|
101
157
|
code: string;
|
|
102
158
|
requestId?: string;
|
|
@@ -108,20 +164,17 @@ export class ShopstackClient {
|
|
|
108
164
|
startAccountSignup(input: {
|
|
109
165
|
accountType: "personal" | "developer";
|
|
110
166
|
email: string;
|
|
111
|
-
idempotencyKey?: string;
|
|
112
167
|
}): Promise<AccountSignupStarted>;
|
|
113
168
|
pollAccountSignup(
|
|
114
169
|
signupId: string,
|
|
115
170
|
pollToken: string,
|
|
116
|
-
): Promise<
|
|
117
|
-
Omit<AccountSignupStarted, "poll_token"> | VerifiedAccountSignup
|
|
118
|
-
>;
|
|
171
|
+
): Promise<Omit<AccountSignupStarted, "poll_token"> | VerifiedAccountSignup>;
|
|
119
172
|
waitForAccountSignup(
|
|
120
173
|
signupId: string,
|
|
121
174
|
pollToken: string,
|
|
122
175
|
options?: { pollIntervalMs?: number; timeoutMs?: number },
|
|
123
176
|
): Promise<VerifiedAccountSignup>;
|
|
124
|
-
signup(input: SignupOptions): Promise<
|
|
177
|
+
signup(input: SignupOptions): Promise<CompletedAccountSignup>;
|
|
125
178
|
createUser(input: {
|
|
126
179
|
externalId: string;
|
|
127
180
|
idempotencyKey?: string;
|
|
@@ -147,6 +200,14 @@ export class ShopstackClient {
|
|
|
147
200
|
options?: { idempotencyKey?: string },
|
|
148
201
|
): Promise<Checkout>;
|
|
149
202
|
getCheckout(checkoutId: string): Promise<Checkout>;
|
|
203
|
+
createCheckoutUpdateSubscription(
|
|
204
|
+
checkoutId: string,
|
|
205
|
+
): Promise<CheckoutUpdateSubscription>;
|
|
206
|
+
waitForCheckoutUpdate(
|
|
207
|
+
checkoutId: string,
|
|
208
|
+
afterRevision: number,
|
|
209
|
+
waitSeconds?: number,
|
|
210
|
+
): Promise<Checkout | undefined>;
|
|
150
211
|
cancelCheckout(
|
|
151
212
|
checkoutId: string,
|
|
152
213
|
options?: { idempotencyKey?: string },
|
package/src/client.js
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
|
-
import { randomUUID } from "node:crypto";
|
|
1
|
+
import { randomBytes, randomUUID } from "node:crypto";
|
|
2
|
+
import WebSocket from "ws";
|
|
2
3
|
|
|
3
4
|
const TERMINAL_STATUSES = new Set(["complete", "failed", "cancelled"]);
|
|
4
5
|
|
|
@@ -16,17 +17,133 @@ function idempotencyKey(prefix) {
|
|
|
16
17
|
return `${prefix}-${randomUUID()}`;
|
|
17
18
|
}
|
|
18
19
|
|
|
20
|
+
function signupRecoveryKey() {
|
|
21
|
+
return `signup_recovery_${randomBytes(32).toString("base64url")}`;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
function signupAttemptKey(accountType, email) {
|
|
25
|
+
return `${accountType}\u0000${email.trim().toLowerCase()}`;
|
|
26
|
+
}
|
|
27
|
+
|
|
19
28
|
function delay(milliseconds) {
|
|
20
29
|
return milliseconds <= 0
|
|
21
30
|
? Promise.resolve()
|
|
22
31
|
: new Promise((resolve) => setTimeout(resolve, milliseconds));
|
|
23
32
|
}
|
|
24
33
|
|
|
34
|
+
function presentationRevision(checkout) {
|
|
35
|
+
return Number.isInteger(checkout?.presentation_revision)
|
|
36
|
+
? checkout.presentation_revision
|
|
37
|
+
: checkout.revision;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
class CheckoutUpdateWatcher {
|
|
41
|
+
constructor(client, checkoutId) {
|
|
42
|
+
this.client = client;
|
|
43
|
+
this.checkoutId = checkoutId;
|
|
44
|
+
this.latestRevision = 0;
|
|
45
|
+
this.waiter = undefined;
|
|
46
|
+
this.socket = undefined;
|
|
47
|
+
this.unavailable = typeof client.WebSocket !== "function";
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
async connect() {
|
|
51
|
+
if (this.socket || this.unavailable) return;
|
|
52
|
+
try {
|
|
53
|
+
const subscription = await this.client.createCheckoutUpdateSubscription(
|
|
54
|
+
this.checkoutId,
|
|
55
|
+
);
|
|
56
|
+
const socket = new this.client.WebSocket(subscription.socket_url, [
|
|
57
|
+
subscription.protocol,
|
|
58
|
+
subscription.token,
|
|
59
|
+
]);
|
|
60
|
+
this.socket = socket;
|
|
61
|
+
socket.addEventListener("message", (event) => {
|
|
62
|
+
let payload;
|
|
63
|
+
try {
|
|
64
|
+
payload = JSON.parse(String(event.data));
|
|
65
|
+
} catch {
|
|
66
|
+
return;
|
|
67
|
+
}
|
|
68
|
+
if (
|
|
69
|
+
payload?.type !== "checkout_changed" ||
|
|
70
|
+
!Number.isInteger(payload.presentation_revision) ||
|
|
71
|
+
payload.presentation_revision < 1 ||
|
|
72
|
+
typeof payload.terminal !== "boolean"
|
|
73
|
+
) {
|
|
74
|
+
return;
|
|
75
|
+
}
|
|
76
|
+
this.latestRevision = Math.max(
|
|
77
|
+
this.latestRevision,
|
|
78
|
+
payload.presentation_revision,
|
|
79
|
+
);
|
|
80
|
+
this.waiter?.();
|
|
81
|
+
});
|
|
82
|
+
const unavailable = () => {
|
|
83
|
+
this.unavailable = true;
|
|
84
|
+
this.waiter?.();
|
|
85
|
+
};
|
|
86
|
+
socket.addEventListener("error", unavailable);
|
|
87
|
+
socket.addEventListener("close", unavailable);
|
|
88
|
+
} catch {
|
|
89
|
+
this.unavailable = true;
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
async wait(afterRevision, timeoutMs) {
|
|
94
|
+
await this.connect();
|
|
95
|
+
if (this.latestRevision > afterRevision) return { kind: "changed" };
|
|
96
|
+
if (!this.unavailable) {
|
|
97
|
+
const changed = await new Promise((resolve) => {
|
|
98
|
+
let settled = false;
|
|
99
|
+
const settle = (value) => {
|
|
100
|
+
if (settled) return;
|
|
101
|
+
settled = true;
|
|
102
|
+
this.waiter = undefined;
|
|
103
|
+
clearTimeout(timer);
|
|
104
|
+
resolve(value);
|
|
105
|
+
};
|
|
106
|
+
this.waiter = () =>
|
|
107
|
+
settle(
|
|
108
|
+
this.latestRevision > afterRevision ? "changed" : "unavailable",
|
|
109
|
+
);
|
|
110
|
+
const timer = setTimeout(
|
|
111
|
+
() => settle("timeout"),
|
|
112
|
+
Math.max(1, Math.min(timeoutMs, 25_000)),
|
|
113
|
+
);
|
|
114
|
+
});
|
|
115
|
+
if (changed === "changed") return { kind: "changed" };
|
|
116
|
+
if (changed === "timeout") return { kind: "refresh" };
|
|
117
|
+
}
|
|
118
|
+
try {
|
|
119
|
+
const checkout = await this.client.waitForCheckoutUpdate(
|
|
120
|
+
this.checkoutId,
|
|
121
|
+
afterRevision,
|
|
122
|
+
Math.max(1, Math.min(25, Math.ceil(timeoutMs / 1_000))),
|
|
123
|
+
);
|
|
124
|
+
return checkout === undefined
|
|
125
|
+
? { kind: "refresh" }
|
|
126
|
+
: { checkout, kind: "checkout" };
|
|
127
|
+
} catch {
|
|
128
|
+
return { kind: "unavailable" };
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
close() {
|
|
133
|
+
try {
|
|
134
|
+
this.socket?.close(1000, "Checkout monitoring complete");
|
|
135
|
+
} catch {
|
|
136
|
+
// Monitoring teardown must not alter the checkout result.
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
|
|
25
141
|
export class ShopstackClient {
|
|
26
142
|
constructor({
|
|
27
143
|
apiKey,
|
|
28
|
-
baseUrl = "https://
|
|
144
|
+
baseUrl = "https://api.shopstack.ai/v1",
|
|
29
145
|
fetch: fetchImplementation = globalThis.fetch,
|
|
146
|
+
webSocket: webSocketImplementation = WebSocket,
|
|
30
147
|
} = {}) {
|
|
31
148
|
if (typeof baseUrl !== "string" || !/^https?:\/\//u.test(baseUrl)) {
|
|
32
149
|
throw new TypeError("baseUrl must be an HTTP(S) URL");
|
|
@@ -37,6 +154,7 @@ export class ShopstackClient {
|
|
|
37
154
|
this.apiKey = apiKey;
|
|
38
155
|
this.baseUrl = baseUrl.replace(/\/+$/u, "");
|
|
39
156
|
this.fetch = fetchImplementation;
|
|
157
|
+
this.WebSocket = webSocketImplementation;
|
|
40
158
|
}
|
|
41
159
|
|
|
42
160
|
async request(
|
|
@@ -46,6 +164,7 @@ export class ShopstackClient {
|
|
|
46
164
|
body,
|
|
47
165
|
idempotencyKey: mutationKey,
|
|
48
166
|
method = "GET",
|
|
167
|
+
signupRecoveryKey: recoveryKey,
|
|
49
168
|
} = {},
|
|
50
169
|
) {
|
|
51
170
|
const headers = new Headers({ Accept: "application/json" });
|
|
@@ -55,6 +174,7 @@ export class ShopstackClient {
|
|
|
55
174
|
}
|
|
56
175
|
if (body !== undefined) headers.set("Content-Type", "application/json");
|
|
57
176
|
if (mutationKey) headers.set("Idempotency-Key", mutationKey);
|
|
177
|
+
if (recoveryKey) headers.set("Signup-Recovery-Key", recoveryKey);
|
|
58
178
|
const response = await this.fetch(`${this.baseUrl}${path}`, {
|
|
59
179
|
body: body === undefined ? undefined : JSON.stringify(body),
|
|
60
180
|
headers,
|
|
@@ -88,11 +208,26 @@ export class ShopstackClient {
|
|
|
88
208
|
return payload;
|
|
89
209
|
}
|
|
90
210
|
|
|
91
|
-
|
|
211
|
+
_startAccountSignup({
|
|
212
|
+
accountType,
|
|
213
|
+
email,
|
|
214
|
+
idempotencyKey: key,
|
|
215
|
+
recoveryKey,
|
|
216
|
+
}) {
|
|
92
217
|
return this.request("/accounts", {
|
|
93
218
|
body: { account_type: accountType, email },
|
|
94
|
-
idempotencyKey: key
|
|
219
|
+
idempotencyKey: key,
|
|
95
220
|
method: "POST",
|
|
221
|
+
signupRecoveryKey: recoveryKey,
|
|
222
|
+
});
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
startAccountSignup({ accountType, email } = {}) {
|
|
226
|
+
return this._startAccountSignup({
|
|
227
|
+
accountType,
|
|
228
|
+
email,
|
|
229
|
+
idempotencyKey: idempotencyKey("account"),
|
|
230
|
+
recoveryKey: signupRecoveryKey(),
|
|
96
231
|
});
|
|
97
232
|
}
|
|
98
233
|
|
|
@@ -123,29 +258,135 @@ export class ShopstackClient {
|
|
|
123
258
|
async signup({
|
|
124
259
|
accountType,
|
|
125
260
|
email,
|
|
126
|
-
|
|
261
|
+
onProgress,
|
|
127
262
|
onVerificationRequired,
|
|
263
|
+
persistence,
|
|
128
264
|
pollIntervalMs = 2_000,
|
|
129
265
|
timeoutMs = 15 * 60 * 1_000,
|
|
130
266
|
} = {}) {
|
|
131
|
-
const
|
|
267
|
+
const normalizedEmail = email.trim().toLowerCase();
|
|
268
|
+
const memoryKey = signupAttemptKey(accountType, normalizedEmail);
|
|
269
|
+
this._pendingSignups ??= new Map();
|
|
270
|
+
const storage = persistence ?? {
|
|
271
|
+
completePending: async (state, result) => {
|
|
272
|
+
this.apiKey = result.api_key;
|
|
273
|
+
this._pendingSignups.delete(memoryKey);
|
|
274
|
+
},
|
|
275
|
+
deletePending: async () => this._pendingSignups.delete(memoryKey),
|
|
276
|
+
loadPending: async () => this._pendingSignups.get(memoryKey),
|
|
277
|
+
savePending: async (state) => {
|
|
278
|
+
this._pendingSignups.set(memoryKey, structuredClone(state));
|
|
279
|
+
},
|
|
280
|
+
};
|
|
281
|
+
const publish = async (state, details = {}) => {
|
|
282
|
+
if (typeof onProgress === "function")
|
|
283
|
+
await onProgress({ state, ...details });
|
|
284
|
+
};
|
|
285
|
+
let pending = await storage.loadPending({
|
|
132
286
|
accountType,
|
|
133
|
-
email,
|
|
134
|
-
idempotencyKey: key,
|
|
287
|
+
email: normalizedEmail,
|
|
135
288
|
});
|
|
136
|
-
if (
|
|
137
|
-
await
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
id: signup.id,
|
|
142
|
-
status: signup.status,
|
|
289
|
+
if (pending?.expiresAt && Date.parse(pending.expiresAt) <= Date.now()) {
|
|
290
|
+
await storage.deletePending(pending);
|
|
291
|
+
await publish("expired", {
|
|
292
|
+
account_type: accountType,
|
|
293
|
+
email: normalizedEmail,
|
|
143
294
|
});
|
|
295
|
+
pending = undefined;
|
|
144
296
|
}
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
297
|
+
pending ??= {
|
|
298
|
+
accountType,
|
|
299
|
+
attemptId: `attempt-${randomUUID()}`,
|
|
300
|
+
email: normalizedEmail,
|
|
301
|
+
idempotencyKey: idempotencyKey("account"),
|
|
302
|
+
recoveryKey: signupRecoveryKey(),
|
|
303
|
+
};
|
|
304
|
+
await storage.savePending(pending);
|
|
305
|
+
await publish("pending", {
|
|
306
|
+
account_type: accountType,
|
|
307
|
+
email: normalizedEmail,
|
|
148
308
|
});
|
|
309
|
+
try {
|
|
310
|
+
if (!pending.signupId || !pending.pollToken) {
|
|
311
|
+
const started = await this._startAccountSignup({
|
|
312
|
+
accountType,
|
|
313
|
+
email: normalizedEmail,
|
|
314
|
+
idempotencyKey: pending.idempotencyKey,
|
|
315
|
+
recoveryKey: pending.recoveryKey,
|
|
316
|
+
});
|
|
317
|
+
pending = {
|
|
318
|
+
...pending,
|
|
319
|
+
expiresAt: started.expires_at,
|
|
320
|
+
pollToken: started.poll_token,
|
|
321
|
+
signupId: started.id,
|
|
322
|
+
};
|
|
323
|
+
await storage.savePending(pending);
|
|
324
|
+
if (typeof onVerificationRequired === "function") {
|
|
325
|
+
await onVerificationRequired({
|
|
326
|
+
account_type: started.account_type,
|
|
327
|
+
email: started.email,
|
|
328
|
+
expires_at: started.expires_at,
|
|
329
|
+
id: started.id,
|
|
330
|
+
status: started.status,
|
|
331
|
+
});
|
|
332
|
+
}
|
|
333
|
+
await publish("email_sent", {
|
|
334
|
+
account_type: accountType,
|
|
335
|
+
email: normalizedEmail,
|
|
336
|
+
expires_at: started.expires_at,
|
|
337
|
+
});
|
|
338
|
+
}
|
|
339
|
+
await publish("waiting", {
|
|
340
|
+
account_type: accountType,
|
|
341
|
+
email: normalizedEmail,
|
|
342
|
+
expires_at: pending.expiresAt,
|
|
343
|
+
});
|
|
344
|
+
const result = await this.waitForAccountSignup(
|
|
345
|
+
pending.signupId,
|
|
346
|
+
pending.pollToken,
|
|
347
|
+
{ pollIntervalMs, timeoutMs },
|
|
348
|
+
);
|
|
349
|
+
await storage.completePending(pending, result);
|
|
350
|
+
await publish("verified", {
|
|
351
|
+
account_type: accountType,
|
|
352
|
+
email: normalizedEmail,
|
|
353
|
+
});
|
|
354
|
+
await publish("complete", {
|
|
355
|
+
account_type: accountType,
|
|
356
|
+
email: normalizedEmail,
|
|
357
|
+
key_type: result.key_type,
|
|
358
|
+
});
|
|
359
|
+
const { api_key: _credential, ...completed } = result;
|
|
360
|
+
return completed;
|
|
361
|
+
} catch (error) {
|
|
362
|
+
if (error?.code === "signup_expired") {
|
|
363
|
+
await storage.deletePending(pending);
|
|
364
|
+
await publish("expired", {
|
|
365
|
+
account_type: accountType,
|
|
366
|
+
email: normalizedEmail,
|
|
367
|
+
});
|
|
368
|
+
} else if (error?.code === "rate_limited") {
|
|
369
|
+
await publish("rate_limited", {
|
|
370
|
+
account_type: accountType,
|
|
371
|
+
email: normalizedEmail,
|
|
372
|
+
});
|
|
373
|
+
} else if (
|
|
374
|
+
error?.code === "idempotency_conflict" ||
|
|
375
|
+
error?.code === "invalid_signup_recovery"
|
|
376
|
+
) {
|
|
377
|
+
await storage.deletePending(pending);
|
|
378
|
+
await publish("conflict", {
|
|
379
|
+
account_type: accountType,
|
|
380
|
+
email: normalizedEmail,
|
|
381
|
+
});
|
|
382
|
+
} else {
|
|
383
|
+
await publish("retryable_failure", {
|
|
384
|
+
account_type: accountType,
|
|
385
|
+
email: normalizedEmail,
|
|
386
|
+
});
|
|
387
|
+
}
|
|
388
|
+
throw error;
|
|
389
|
+
}
|
|
149
390
|
}
|
|
150
391
|
|
|
151
392
|
createUser({ externalId, idempotencyKey: key } = {}) {
|
|
@@ -203,6 +444,22 @@ export class ShopstackClient {
|
|
|
203
444
|
return this.request(`/checkout/${encodeURIComponent(checkoutId)}`);
|
|
204
445
|
}
|
|
205
446
|
|
|
447
|
+
createCheckoutUpdateSubscription(checkoutId) {
|
|
448
|
+
return this.request(`/checkout/${encodeURIComponent(checkoutId)}/updates`, {
|
|
449
|
+
method: "POST",
|
|
450
|
+
});
|
|
451
|
+
}
|
|
452
|
+
|
|
453
|
+
waitForCheckoutUpdate(checkoutId, afterRevision, waitSeconds = 25) {
|
|
454
|
+
const query = new URLSearchParams({
|
|
455
|
+
after: String(afterRevision),
|
|
456
|
+
wait: String(waitSeconds),
|
|
457
|
+
});
|
|
458
|
+
return this.request(
|
|
459
|
+
`/checkout/${encodeURIComponent(checkoutId)}/updates?${query.toString()}`,
|
|
460
|
+
);
|
|
461
|
+
}
|
|
462
|
+
|
|
206
463
|
cancelCheckout(checkoutId, { idempotencyKey: key } = {}) {
|
|
207
464
|
return this.request(`/checkout/${encodeURIComponent(checkoutId)}/cancel`, {
|
|
208
465
|
idempotencyKey: key ?? idempotencyKey("cancel"),
|
|
@@ -268,12 +525,16 @@ export class ShopstackClient {
|
|
|
268
525
|
let checkout = await this.createCheckout(checkoutRequest, {
|
|
269
526
|
idempotencyKey: options.idempotencyKey,
|
|
270
527
|
});
|
|
528
|
+
if (typeof options.onCreated === "function") {
|
|
529
|
+
await options.onCreated(checkout);
|
|
530
|
+
}
|
|
271
531
|
let progressFingerprint;
|
|
272
532
|
const publishProgress = async (current) => {
|
|
273
533
|
if (typeof options.onProgress !== "function") return;
|
|
274
534
|
const fingerprint = JSON.stringify([
|
|
275
535
|
current.status,
|
|
276
536
|
current.revision,
|
|
537
|
+
current.presentation_revision,
|
|
277
538
|
current.activity,
|
|
278
539
|
current.intent?.name,
|
|
279
540
|
current.intent?.phase,
|
|
@@ -285,74 +546,88 @@ export class ShopstackClient {
|
|
|
285
546
|
progressFingerprint = fingerprint;
|
|
286
547
|
await options.onProgress(current);
|
|
287
548
|
};
|
|
288
|
-
|
|
549
|
+
const watcher = new CheckoutUpdateWatcher(this, checkout.id);
|
|
289
550
|
let handledPaymentRevision;
|
|
290
551
|
let handledApprovalId;
|
|
291
552
|
let handledMessageRevision;
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
checkout.
|
|
324
|
-
checkout
|
|
325
|
-
|
|
326
|
-
|
|
553
|
+
try {
|
|
554
|
+
while (true) {
|
|
555
|
+
await publishProgress(checkout);
|
|
556
|
+
if (TERMINAL_STATUSES.has(checkout.status)) return checkout;
|
|
557
|
+
const elapsed = Date.now() - startedAt;
|
|
558
|
+
if (elapsed >= timeoutMs) {
|
|
559
|
+
throw new ShopstackApiError("Checkout monitoring timed out.", {
|
|
560
|
+
code: "checkout_timeout",
|
|
561
|
+
});
|
|
562
|
+
}
|
|
563
|
+
if (
|
|
564
|
+
checkout.required_input?.type === "payment_card" &&
|
|
565
|
+
handledPaymentRevision !== checkout.revision
|
|
566
|
+
) {
|
|
567
|
+
if (typeof options.paymentDetails !== "function") return checkout;
|
|
568
|
+
handledPaymentRevision = checkout.revision;
|
|
569
|
+
const card = await options.paymentDetails(checkout);
|
|
570
|
+
if (card === undefined) return checkout;
|
|
571
|
+
checkout = await this.providePaymentDetails(checkout.id, card, {
|
|
572
|
+
idempotencyKey: `payment-details-${checkout.id}-${checkout.revision}`,
|
|
573
|
+
});
|
|
574
|
+
handledMessageRevision = checkout.revision;
|
|
575
|
+
continue;
|
|
576
|
+
}
|
|
577
|
+
if (
|
|
578
|
+
checkout.status === "approval_required" &&
|
|
579
|
+
checkout.approval &&
|
|
580
|
+
handledApprovalId !== checkout.approval.id
|
|
581
|
+
) {
|
|
582
|
+
if (typeof options.approve !== "function") return checkout;
|
|
583
|
+
handledApprovalId = checkout.approval.id;
|
|
584
|
+
const approved = await options.approve(checkout.approval, checkout);
|
|
585
|
+
if (typeof approved !== "boolean") return checkout;
|
|
586
|
+
checkout = await this.decidePaymentApproval(
|
|
587
|
+
checkout.id,
|
|
588
|
+
checkout.approval.id,
|
|
589
|
+
approved,
|
|
590
|
+
{ idempotencyKey: `approval-${checkout.approval.id}` },
|
|
591
|
+
);
|
|
592
|
+
if (!approved) return checkout;
|
|
593
|
+
continue;
|
|
594
|
+
}
|
|
595
|
+
if (
|
|
596
|
+
checkout.status === "help_required" &&
|
|
597
|
+
checkout.required_input === undefined &&
|
|
598
|
+
handledMessageRevision !== checkout.revision
|
|
599
|
+
) {
|
|
600
|
+
if (typeof options.message !== "function") return checkout;
|
|
601
|
+
handledMessageRevision = checkout.revision;
|
|
602
|
+
const content = await options.message(checkout);
|
|
603
|
+
if (typeof content !== "string" || content.length === 0)
|
|
604
|
+
return checkout;
|
|
605
|
+
await this.sendMessage(checkout.id, content, {
|
|
606
|
+
idempotencyKey: `message-${checkout.id}-${checkout.revision}`,
|
|
607
|
+
});
|
|
608
|
+
continue;
|
|
609
|
+
}
|
|
610
|
+
const remainingMs = timeoutMs - (Date.now() - startedAt);
|
|
611
|
+
const update = await watcher.wait(
|
|
612
|
+
presentationRevision(checkout),
|
|
613
|
+
remainingMs,
|
|
327
614
|
);
|
|
328
|
-
if (
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
await this.sendMessage(checkout.id, content, {
|
|
342
|
-
idempotencyKey: `message-${checkout.id}-${checkout.revision}`,
|
|
343
|
-
});
|
|
344
|
-
continue;
|
|
345
|
-
}
|
|
346
|
-
if (!TERMINAL_STATUSES.has(checkout.status)) {
|
|
347
|
-
const pollIntervalMs =
|
|
348
|
-
options.pollIntervalMs ??
|
|
349
|
-
(checkout.status === "help_required" ||
|
|
350
|
-
checkout.status === "approval_required"
|
|
351
|
-
? 5_000
|
|
352
|
-
: 2_000);
|
|
353
|
-
await delay(pollIntervalMs);
|
|
615
|
+
if (update.kind === "unavailable") {
|
|
616
|
+
const pollIntervalMs =
|
|
617
|
+
options.pollIntervalMs ??
|
|
618
|
+
(checkout.status === "help_required" ||
|
|
619
|
+
checkout.status === "approval_required"
|
|
620
|
+
? 5_000
|
|
621
|
+
: 2_000);
|
|
622
|
+
await delay(Math.min(pollIntervalMs, remainingMs));
|
|
623
|
+
}
|
|
624
|
+
checkout =
|
|
625
|
+
update.kind === "checkout"
|
|
626
|
+
? update.checkout
|
|
627
|
+
: await this.getCheckout(checkout.id);
|
|
354
628
|
}
|
|
629
|
+
} finally {
|
|
630
|
+
watcher.close();
|
|
355
631
|
}
|
|
356
|
-
return checkout;
|
|
357
632
|
}
|
|
358
633
|
}
|