@conto_finance/sdk 0.6.1 → 0.7.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/dist/index.mjs CHANGED
@@ -8,16 +8,17 @@ import {
8
8
  __commonJS,
9
9
  __privateAdd,
10
10
  __privateGet,
11
+ __privateMethod,
11
12
  __privateSet,
12
13
  __toESM
13
- } from "./chunk-WSIKPUZJ.mjs";
14
+ } from "./chunk-TGOYFBKZ.mjs";
14
15
 
15
16
  // package.json
16
17
  var require_package = __commonJS({
17
18
  "package.json"(exports, module) {
18
19
  module.exports = {
19
20
  name: "@conto_finance/sdk",
20
- version: "0.6.1",
21
+ version: "0.7.0",
21
22
  description: "Conto SDK for AI agent payment authorization",
22
23
  main: "./dist/index.js",
23
24
  module: "./dist/index.mjs",
@@ -42,6 +43,11 @@ var require_package = __commonJS({
42
43
  types: "./dist/checkout-verifier.d.ts",
43
44
  import: "./dist/checkout-verifier.mjs",
44
45
  require: "./dist/checkout-verifier.js"
46
+ },
47
+ "./link-wallet": {
48
+ types: "./dist/link-wallet.d.ts",
49
+ import: "./dist/link-wallet.mjs",
50
+ require: "./dist/link-wallet.js"
45
51
  }
46
52
  },
47
53
  publishConfig: {
@@ -58,7 +64,7 @@ var require_package = __commonJS({
58
64
  dev: "tsup --watch",
59
65
  lint: "tsc --noEmit",
60
66
  test: "npm run lint && npm run test:contract && npm run test:consumer",
61
- "test:contract": "npm run build && node --test test/contract.test.mjs test/playwright.test.mjs test/browserbase.test.mjs test/checkout-verifier.test.mjs test/developer-experience.test.mjs test/checkout.test.mjs test/purchases.test.mjs",
67
+ "test:contract": "npm run build && node --test test/contract.test.mjs test/playwright.test.mjs test/browserbase.test.mjs test/checkout-verifier.test.mjs test/developer-experience.test.mjs test/checkout.test.mjs test/link-wallet.test.mjs test/purchases.test.mjs test/allowances.test.mjs test/purchase-watch-lifecycle.test.mjs ../../examples/link-wallet-agent/verify.test.mjs",
62
68
  "test:consumer": "npm run build && node --test test/consumer.test.mjs",
63
69
  "verify:release": "node scripts/verify-release.mjs",
64
70
  prepublishOnly: "npm run verify:release && npm run lint && npm run test:contract && npm run test:consumer"
@@ -83,7 +89,7 @@ var require_package = __commonJS({
83
89
  email: "support@conto.finance"
84
90
  },
85
91
  dependencies: {
86
- "@sentry/node": "^10.63.0"
92
+ "@sentry/node": "^11.8.0"
87
93
  },
88
94
  peerDependencies: {
89
95
  "playwright-core": ">=1.50.0"
@@ -97,10 +103,10 @@ var require_package = __commonJS({
97
103
  esbuild: "0.28.1"
98
104
  },
99
105
  devDependencies: {
100
- "@types/node": "22.19.18",
106
+ "@types/node": "26.6.5",
101
107
  "playwright-core": "1.61.0",
102
- tsup: "^8.5.1",
103
- typescript: "^5.9.3"
108
+ tsup: "^9.0.0",
109
+ typescript: "^6.0.3"
104
110
  }
105
111
  };
106
112
  }
@@ -121,24 +127,31 @@ var require_package_version = __commonJS({
121
127
  // src/index.ts
122
128
  var import_package_version2 = __toESM(require_package_version());
123
129
  import * as Sentry2 from "@sentry/node";
124
- import { randomUUID } from "crypto";
130
+ import { createHash, randomUUID } from "crypto";
125
131
 
126
132
  // src/purchases.ts
127
133
  import { createHmac, timingSafeEqual } from "crypto";
128
134
  var TERMINAL = /* @__PURE__ */ new Set(["succeeded", "blocked", "failed", "cancelled"]);
129
135
  var isFinalPurchaseStatus = (status) => TERMINAL.has(status);
130
- async function* parseServerSentEvents(body) {
136
+ async function* parseServerSentEvents(body, signal) {
131
137
  const reader = body.getReader();
138
+ const cancel = () => {
139
+ void reader.cancel().catch(() => {
140
+ });
141
+ };
142
+ signal?.addEventListener("abort", cancel, { once: true });
143
+ if (signal?.aborted) cancel();
132
144
  const decoder = new TextDecoder();
133
145
  let buffer = "";
134
146
  let frame = { data: [] };
135
147
  try {
136
148
  while (true) {
137
149
  const { value, done } = await reader.read();
138
- if (done) break;
150
+ if (done || signal?.aborted) break;
139
151
  buffer += decoder.decode(value, { stream: true });
140
152
  let newline;
141
153
  while ((newline = buffer.search(/\r?\n/)) >= 0) {
154
+ if (signal?.aborted) return;
142
155
  const line = buffer.slice(0, newline);
143
156
  buffer = buffer.slice(newline + (buffer[newline] === "\r" ? 2 : 1));
144
157
  if (line === "") {
@@ -157,20 +170,30 @@ async function* parseServerSentEvents(body) {
157
170
  }
158
171
  }
159
172
  } finally {
173
+ signal?.removeEventListener("abort", cancel);
174
+ await reader.cancel().catch(() => {
175
+ });
160
176
  reader.releaseLock();
161
177
  }
162
178
  }
163
179
  var sleep = (ms, signal) => new Promise((resolve) => {
164
- const timer = setTimeout(resolve, ms);
165
- signal?.addEventListener(
166
- "abort",
167
- () => {
168
- clearTimeout(timer);
169
- resolve();
170
- },
171
- { once: true }
172
- );
180
+ if (signal?.aborted) return resolve();
181
+ const finish = () => {
182
+ clearTimeout(timer);
183
+ signal?.removeEventListener("abort", finish);
184
+ resolve();
185
+ };
186
+ const timer = setTimeout(finish, ms);
187
+ signal?.addEventListener("abort", finish, { once: true });
173
188
  });
189
+ function watchRead(pending, signal) {
190
+ return new Promise((resolve, reject) => {
191
+ const abort = () => reject(new DOMException("Watch stopped", "AbortError"));
192
+ signal.addEventListener("abort", abort, { once: true });
193
+ if (signal.aborted) abort();
194
+ pending.then(resolve, reject).finally(() => signal.removeEventListener("abort", abort));
195
+ });
196
+ }
174
197
  function requirePurchaseId(id) {
175
198
  if (!/^[a-z0-9]{8,64}$/i.test(id)) throw new TypeError("purchase id is not valid");
176
199
  return encodeURIComponent(id);
@@ -309,82 +332,100 @@ var PurchasesClient = class {
309
332
  const path = `${__privateGet(this, _base2)}/${requirePurchaseId(id)}`;
310
333
  const mode = options.mode ?? "auto";
311
334
  const interval = Math.max(500, options.pollIntervalMs ?? 2e3);
312
- const deadline = options.maxWaitMs ? Date.now() + options.maxWaitMs : Infinity;
313
- let cursor = Math.max(0, options.after ?? 0);
314
- let streaming = mode !== "poll";
315
- const emit = (event) => {
316
- if (event.version <= cursor && event.type !== "purchase.resync") return null;
317
- cursor = Math.max(cursor, event.version);
318
- return event;
319
- };
320
- while (!options.signal?.aborted && Date.now() < deadline) {
321
- if (streaming) {
322
- try {
323
- const response = await __privateGet(this, _transport2).openStream(
324
- `${path}/events/stream?after=${cursor}`,
325
- { signal: options.signal, lastEventId: String(cursor) }
326
- );
327
- if (!response.body) throw new Error("streaming unavailable");
328
- for await (const frame of parseServerSentEvents(response.body)) {
329
- if (frame.event === "end") return;
330
- if (frame.event === "error") break;
331
- if (frame.event === "resync") {
332
- const purchase = JSON.parse(frame.data);
333
- const event2 = {
334
- id: `resync:${purchase.version}`,
335
- purchaseId: purchase.id,
336
- version: purchase.version,
337
- type: "purchase.resync",
338
- createdAt: purchase.updatedAt,
339
- data: purchase
340
- };
341
- cursor = purchase.version;
342
- yield event2;
343
- if (isFinalPurchaseStatus(purchase.status)) return;
335
+ if (options.maxWaitMs !== void 0 && (!Number.isFinite(options.maxWaitMs) || options.maxWaitMs < 0 || options.maxWaitMs > 2147483647))
336
+ throw new TypeError("maxWaitMs must be between 0 and 2147483647");
337
+ if (options.signal?.aborted || options.maxWaitMs === 0) return;
338
+ const controller = new AbortController();
339
+ const signal = controller.signal;
340
+ const abort = () => controller.abort();
341
+ options.signal?.addEventListener("abort", abort, { once: true });
342
+ const timeout = options.maxWaitMs === void 0 ? void 0 : setTimeout(abort, options.maxWaitMs);
343
+ try {
344
+ let cursor = Math.max(0, options.after ?? 0);
345
+ let streaming = mode !== "poll";
346
+ const emit = (event) => {
347
+ if (event.version <= cursor && event.type !== "purchase.resync") return null;
348
+ cursor = Math.max(cursor, event.version);
349
+ return event;
350
+ };
351
+ while (!signal.aborted) {
352
+ if (streaming) {
353
+ try {
354
+ const response = await __privateGet(this, _transport2).openStream(
355
+ `${path}/events/stream?after=${cursor}`,
356
+ { signal, lastEventId: String(cursor) }
357
+ );
358
+ if (!response.body) throw new Error("streaming unavailable");
359
+ for await (const frame of parseServerSentEvents(response.body, signal)) {
360
+ if (frame.event === "end") return;
361
+ if (frame.event === "error") break;
362
+ if (frame.event === "resync") {
363
+ const purchase = JSON.parse(frame.data);
364
+ const event2 = {
365
+ id: `resync:${purchase.version}`,
366
+ purchaseId: purchase.id,
367
+ version: purchase.version,
368
+ type: "purchase.resync",
369
+ createdAt: purchase.updatedAt,
370
+ data: purchase
371
+ };
372
+ cursor = purchase.version;
373
+ yield event2;
374
+ if (isFinalPurchaseStatus(purchase.status)) return;
375
+ continue;
376
+ }
377
+ if (!frame.data) continue;
378
+ const event = emit(JSON.parse(frame.data));
379
+ if (!event) continue;
380
+ yield event;
381
+ if (isFinalPurchaseStatus(event.data.status)) return;
382
+ }
383
+ await sleep(interval, signal);
384
+ continue;
385
+ } catch (error) {
386
+ if (signal.aborted) return;
387
+ if (isClientError(error)) throw error;
388
+ if (mode === "stream") {
389
+ await sleep(interval, signal);
344
390
  continue;
345
391
  }
346
- if (!frame.data) continue;
347
- const event = emit(JSON.parse(frame.data));
348
- if (!event) continue;
349
- yield event;
350
- if (isFinalPurchaseStatus(event.data.status)) return;
392
+ streaming = false;
351
393
  }
352
- continue;
394
+ }
395
+ let page;
396
+ try {
397
+ page = await watchRead(this.events(id, { after: cursor, limit: 100 }), signal);
353
398
  } catch (error) {
354
- if (options.signal?.aborted) return;
355
- if (isClientError(error)) throw error;
356
- if (mode === "stream") {
357
- await sleep(interval, options.signal);
358
- continue;
359
- }
360
- streaming = false;
399
+ if (codeOf(error) !== "CURSOR_INVALID") throw error;
400
+ const purchase = await watchRead(this.get(id), signal);
401
+ cursor = purchase.version;
402
+ yield {
403
+ id: `resync:${purchase.version}`,
404
+ purchaseId: purchase.id,
405
+ version: purchase.version,
406
+ type: "purchase.resync",
407
+ createdAt: purchase.updatedAt,
408
+ data: purchase
409
+ };
410
+ if (isFinalPurchaseStatus(purchase.status)) return;
411
+ continue;
361
412
  }
413
+ if (signal.aborted) return;
414
+ for (const item of page.items) {
415
+ if (signal.aborted) return;
416
+ const event = emit(item);
417
+ if (!event) continue;
418
+ yield event;
419
+ if (isFinalPurchaseStatus(event.data.status)) return;
420
+ }
421
+ if (!page.hasMore) await sleep(interval, signal);
362
422
  }
363
- let page;
364
- try {
365
- page = await this.events(id, { after: cursor, limit: 100 });
366
- } catch (error) {
367
- if (codeOf(error) !== "CURSOR_INVALID") throw error;
368
- const purchase = await this.get(id);
369
- cursor = purchase.version;
370
- yield {
371
- id: `resync:${purchase.version}`,
372
- purchaseId: purchase.id,
373
- version: purchase.version,
374
- type: "purchase.resync",
375
- createdAt: purchase.updatedAt,
376
- data: purchase
377
- };
378
- if (isFinalPurchaseStatus(purchase.status)) return;
379
- continue;
380
- }
381
- for (const item of page.items) {
382
- const event = emit(item);
383
- if (!event) continue;
384
- yield event;
385
- if (isFinalPurchaseStatus(event.data.status)) return;
386
- }
387
- if (!page.hasMore) await sleep(interval, options.signal);
423
+ } catch (error) {
424
+ if (!signal.aborted) throw error;
425
+ } finally {
426
+ if (timeout !== void 0) clearTimeout(timeout);
427
+ options.signal?.removeEventListener("abort", abort);
428
+ controller.abort();
388
429
  }
389
430
  }
390
431
  };
@@ -424,13 +465,80 @@ var _client3;
424
465
  var AdminPurchasingClient = class {
425
466
  constructor(client) {
426
467
  __privateAdd(this, _client3);
468
+ /** Inspect or revoke buyer-approved allowances. Grants remain on the buyer's hosted page. */
469
+ this.allowances = {
470
+ /** Returns up to 50 allowances for this buyer, newest first. */
471
+ list: (params) => __privateGet(this, _client3).request(
472
+ "GET",
473
+ `/api/purchasing/allowances?${new URLSearchParams({ buyerId: params.buyerId })}`
474
+ ),
475
+ get: (id) => __privateGet(this, _client3).request("GET", `/api/purchasing/allowances/${enc(id)}`),
476
+ /** Stops future credential release; does not undo payments already submitted. */
477
+ revoke: (id) => __privateGet(this, _client3).request(
478
+ "POST",
479
+ `/api/purchasing/allowances/${enc(id)}/revoke`
480
+ )
481
+ };
427
482
  this.buyers = {
428
483
  /** Maps one of your users to a buyer with its own agent, or returns the existing one. */
429
484
  create: (input) => __privateGet(this, _client3).request("POST", "/api/purchasing/buyers", input),
430
485
  get: (id) => __privateGet(this, _client3).request("GET", `/api/purchasing/buyers/${enc(id)}`),
431
486
  list: (params = {}) => __privateGet(this, _client3).request("GET", `/api/purchasing/buyers?limit=${params.limit ?? 20}&offset=${params.offset ?? 0}`),
432
487
  /** Stops the buyer's agent. Purchases that may already be paid stay visible. */
433
- stop: (id) => __privateGet(this, _client3).request("POST", `/api/purchasing/buyers/${enc(id)}/stop`)
488
+ stop: (id) => __privateGet(this, _client3).request("POST", `/api/purchasing/buyers/${enc(id)}/stop`),
489
+ /**
490
+ * Lets the buyer pay with your company card within limits you set for
491
+ * them, or replaces those limits. The buyer must not have their own card.
492
+ */
493
+ setCompanyCard: (id, input) => __privateGet(this, _client3).request(
494
+ "PUT",
495
+ `/api/purchasing/buyers/${enc(id)}/company-card`,
496
+ input
497
+ ),
498
+ /** The buyer stops paying with your company card. */
499
+ removeCompanyCard: (id) => __privateGet(this, _client3).request(
500
+ "DELETE",
501
+ `/api/purchasing/buyers/${enc(id)}/company-card`
502
+ ),
503
+ /**
504
+ * Replaces what the buyer has at home: inventory, usuals, preferences,
505
+ * chosen recipes and their list. The whole snapshot, every time. Needs
506
+ * Conto to enable kitchen context for your organization.
507
+ */
508
+ setKitchenContext: (id, context) => __privateGet(this, _client3).request(
509
+ "PUT",
510
+ `/api/purchasing/buyers/${enc(id)}/context`,
511
+ context
512
+ ),
513
+ /** The snapshot Conto holds, with context null when nothing was pushed. */
514
+ getKitchenContext: (id) => __privateGet(this, _client3).request(
515
+ "GET",
516
+ `/api/purchasing/buyers/${enc(id)}/context`
517
+ ),
518
+ clearKitchenContext: (id) => __privateGet(this, _client3).request(
519
+ "DELETE",
520
+ `/api/purchasing/buyers/${enc(id)}/context`
521
+ )
522
+ };
523
+ /** Cards your organization pays its buyers' purchases with. Test cards only for now. */
524
+ this.companyCards = {
525
+ create: (input) => __privateGet(this, _client3).request("POST", "/api/purchasing/company-cards", input),
526
+ list: () => __privateGet(this, _client3).request(
527
+ "GET",
528
+ "/api/purchasing/company-cards"
529
+ ),
530
+ get: (id) => __privateGet(this, _client3).request("GET", `/api/purchasing/company-cards/${enc(id)}`),
531
+ /** Changes the card's own caps. Each buyer's limits stay as they are. */
532
+ update: (id, input) => __privateGet(this, _client3).request(
533
+ "PATCH",
534
+ `/api/purchasing/company-cards/${enc(id)}`,
535
+ input
536
+ ),
537
+ /** No buyer can start or pay a purchase with the card after this. */
538
+ remove: (id) => __privateGet(this, _client3).request(
539
+ "DELETE",
540
+ `/api/purchasing/company-cards/${enc(id)}`
541
+ )
434
542
  };
435
543
  this.paymentMethods = {
436
544
  /** A single-use link to the hosted setup page: limits, card and delegation. */
@@ -461,11 +569,8 @@ var AdminPurchasingClient = class {
461
569
  };
462
570
  this.settings = {
463
571
  get: () => __privateGet(this, _client3).request("GET", "/api/purchasing/settings"),
464
- update: (input) => __privateGet(this, _client3).request(
465
- "PUT",
466
- "/api/purchasing/settings",
467
- input
468
- )
572
+ /** Changes only the fields you pass. */
573
+ update: (input) => __privateGet(this, _client3).request("PUT", "/api/purchasing/settings", input)
469
574
  };
470
575
  __privateSet(this, _client3, client);
471
576
  this.purchases = new OrganizationPurchasesClient(client);
@@ -1123,6 +1228,25 @@ var WalletsClient = class {
1123
1228
  {}
1124
1229
  );
1125
1230
  }
1231
+ /**
1232
+ * Get a one-time message for a self-custody wallet's address to sign. It
1233
+ * expires after 10 minutes and replaces any earlier challenge.
1234
+ */
1235
+ async createOwnershipChallenge(walletId) {
1236
+ return this.client.request(
1237
+ "POST",
1238
+ `/api/wallets/${encodeURIComponent(walletId)}/ownership-proof/challenge`,
1239
+ {}
1240
+ );
1241
+ }
1242
+ /** Prove your organization controls a self-custody wallet's address. */
1243
+ async proveOwnership(walletId, input) {
1244
+ return this.client.request(
1245
+ "POST",
1246
+ `/api/wallets/${encodeURIComponent(walletId)}/ownership-proof`,
1247
+ input
1248
+ );
1249
+ }
1126
1250
  };
1127
1251
  var PoliciesClient = class {
1128
1252
  constructor(client) {
@@ -1365,6 +1489,43 @@ var CheckoutApprovalTimeoutError = class extends ContoError {
1365
1489
  this.checkpoint = checkpoint;
1366
1490
  }
1367
1491
  };
1492
+ var LinkCardError = class extends ContoError {
1493
+ constructor(input) {
1494
+ super(input.message, {
1495
+ code: input.code,
1496
+ status: 0,
1497
+ retryGuidance: input.retryGuidance ?? (input.holdReleased ? "Start a new purchase with a new idempotency key if the buyer still wants it." : "Call checkout.cancel(error.checkpoint.id) to release the held spend.")
1498
+ });
1499
+ this.name = "LinkCardError";
1500
+ this.checkpoint = input.checkpoint;
1501
+ if (input.linkRequestId) this.linkRequestId = input.linkRequestId;
1502
+ this.holdReleased = input.holdReleased;
1503
+ }
1504
+ };
1505
+ var LinkCardDeniedError = class extends LinkCardError {
1506
+ constructor(input) {
1507
+ super(input);
1508
+ this.name = "LinkCardDeniedError";
1509
+ }
1510
+ };
1511
+ var LinkCardExpiredError = class extends LinkCardError {
1512
+ constructor(input) {
1513
+ super(input);
1514
+ this.name = "LinkCardExpiredError";
1515
+ }
1516
+ };
1517
+ var LinkCardTimeoutError = class extends LinkCardError {
1518
+ constructor(input) {
1519
+ super(input);
1520
+ this.name = "LinkCardTimeoutError";
1521
+ }
1522
+ };
1523
+ var LinkWalletUnavailableError = class extends LinkCardError {
1524
+ constructor(input) {
1525
+ super(input);
1526
+ this.name = "LinkWalletUnavailableError";
1527
+ }
1528
+ };
1368
1529
  function generatedIdempotencyKey(prefix) {
1369
1530
  return `${prefix}_${randomUUID()}`;
1370
1531
  }
@@ -2485,9 +2646,179 @@ function sleep2(ms, signal) {
2485
2646
  signal?.addEventListener("abort", onAbort, { once: true });
2486
2647
  });
2487
2648
  }
2488
- var _client4;
2649
+ var LINK_REQUEST_ID = /^lsrq_[A-Za-z0-9]{1,64}$/;
2650
+ var LINK_WAITING_STATUSES = /* @__PURE__ */ new Set(["created", "pending_approval"]);
2651
+ var LINK_APPROVED_STATUSES = /* @__PURE__ */ new Set(["approved", "submitted", "succeeded"]);
2652
+ var LINK_CLOSED_STATUSES = /* @__PURE__ */ new Set(["denied", "expired", "canceled", "failed"]);
2653
+ var INSPECT = /* @__PURE__ */ Symbol.for("nodejs.util.inspect.custom");
2654
+ function assertPaymentReference(reference) {
2655
+ if (reference === void 0) return;
2656
+ if (reference === null || typeof reference !== "object" || reference.provider !== "LINK" || typeof reference.id !== "string" || !LINK_REQUEST_ID.test(reference.id) || /\d{12,}/.test(reference.id)) {
2657
+ throw checkoutError(
2658
+ 'paymentReference must be { provider: "LINK", id } with a Link spend request id',
2659
+ "INVALID_PAYMENT_REFERENCE"
2660
+ );
2661
+ }
2662
+ }
2663
+ function linkRequestWaiting(request) {
2664
+ return LINK_WAITING_STATUSES.has(request.status) || // 3D Secure: Link continues on its own once the person finishes it.
2665
+ request.status === "requires_action" && request.actionResolution === "auto_resume";
2666
+ }
2667
+ function usd(amountMinor) {
2668
+ return `$${(amountMinor / 100).toFixed(2)}`;
2669
+ }
2670
+ function clip(value, maxLength) {
2671
+ const single = value.replace(/\s+/g, " ").trim();
2672
+ return single.length > maxLength ? `${single.slice(0, maxLength - 3).trimEnd()}...` : single;
2673
+ }
2674
+ function linkCardContext(checkpoint, merchantHost) {
2675
+ const items = checkpoint.cart.lineItems;
2676
+ const listed = items.slice(0, 5).map((item) => `${item.quantity} x ${clip(item.name, 80)}`).join(", ");
2677
+ const more = items.length > 5 ? ` and ${items.length - 5} more` : "";
2678
+ return `Your agent asked for a card to buy ${clip(listed, 300)}${more} from ${clip(checkpoint.merchant.name, 120)} (${merchantHost}) for ${usd(checkpoint.amountMinor)}. Purpose: ${clip(checkpoint.purpose, 200)}. Conto checked this purchase against your rules and approved it. The card is limited to this amount. Approve only if you expect this purchase.`;
2679
+ }
2680
+ function linkCardRequest(checkpoint) {
2681
+ if (!checkpoint.merchant.checkoutUrl) {
2682
+ throw checkoutError("The checkpoint has no checkout URL", "INVALID_CHECKOUT_CHECKPOINT");
2683
+ }
2684
+ const merchantUrl = new URL(checkpoint.merchant.checkoutUrl);
2685
+ return {
2686
+ amountMinor: checkpoint.amountMinor,
2687
+ currency: "USD",
2688
+ merchantName: checkpoint.merchant.name,
2689
+ merchantUrl: merchantUrl.origin,
2690
+ context: linkCardContext(checkpoint, merchantUrl.hostname),
2691
+ lineItems: checkpoint.cart.lineItems.map((item) => ({
2692
+ name: item.name,
2693
+ quantity: item.quantity,
2694
+ unitAmountMinor: item.unitAmountMinor
2695
+ })),
2696
+ // Conto approved amountMinor, so the Link total is exactly that.
2697
+ totals: [
2698
+ ...checkpoint.cart.totals.filter((total) => total.type !== "total").map((total) => ({ type: total.type, label: total.label, amountMinor: total.amountMinor })),
2699
+ { type: "total", label: "Total", amountMinor: checkpoint.amountMinor }
2700
+ ],
2701
+ // One checkpoint and amount is one Link request, so a retry resumes it.
2702
+ idempotencyKey: `conto_${createHash("sha256").update(`${checkpoint.id}:${checkpoint.amountMinor}`).digest("hex").slice(0, 40)}`,
2703
+ metadata: { conto_checkpoint: checkpoint.id }
2704
+ };
2705
+ }
2706
+ function protectLinkCard(card) {
2707
+ const safe = Object.freeze({
2708
+ last4: card.number.slice(-4),
2709
+ ...card.brand ? { brand: card.brand } : {},
2710
+ expMonth: card.expMonth,
2711
+ expYear: card.expYear,
2712
+ ...card.validUntil ? { validUntil: card.validUntil } : {},
2713
+ redacted: true
2714
+ });
2715
+ const result = {
2716
+ number: card.number,
2717
+ securityValue: card.securityValue,
2718
+ expMonth: card.expMonth,
2719
+ expYear: card.expYear,
2720
+ ...card.validUntil ? { validUntil: card.validUntil } : {},
2721
+ last4: card.number.slice(-4),
2722
+ ...card.brand ? { brand: card.brand } : {}
2723
+ };
2724
+ Object.defineProperty(result, "toJSON", { value: () => safe, enumerable: false });
2725
+ Object.defineProperty(result, INSPECT, { value: () => safe, enumerable: false });
2726
+ return Object.freeze(result);
2727
+ }
2728
+ function errorCode(error) {
2729
+ return error && typeof error === "object" && typeof error.code === "string" ? error.code : void 0;
2730
+ }
2731
+ function errorReason(error) {
2732
+ return error && typeof error === "object" && typeof error.reason === "string" ? error.reason : void 0;
2733
+ }
2734
+ function linkFailure(error, signal) {
2735
+ if (error instanceof LinkCardError) {
2736
+ return {
2737
+ kind: error.constructor,
2738
+ code: error.code,
2739
+ message: error.message
2740
+ };
2741
+ }
2742
+ if (errorCode(error) === "LINK_WALLET_UNAVAILABLE") {
2743
+ return {
2744
+ kind: LinkWalletUnavailableError,
2745
+ code: "LINK_WALLET_UNAVAILABLE",
2746
+ message: errorReason(error) === "not_logged_in" ? 'link-cli is not logged in on this machine. Run "link-cli auth login".' : 'The Link wallet is not available here. Install link-cli with "npm i -g @stripe/link-cli", then run "link-cli auth login".'
2747
+ };
2748
+ }
2749
+ if (signal?.aborted || errorReason(error) === "aborted") {
2750
+ return {
2751
+ kind: LinkCardError,
2752
+ code: "LINK_CARD_ABORTED",
2753
+ message: "The Link card request was stopped."
2754
+ };
2755
+ }
2756
+ return {
2757
+ kind: LinkCardError,
2758
+ code: "LINK_CARD_FAILED",
2759
+ message: "The Link card request failed."
2760
+ };
2761
+ }
2762
+ function closedLinkFailure(request) {
2763
+ switch (request.status) {
2764
+ case "denied":
2765
+ return {
2766
+ kind: LinkCardDeniedError,
2767
+ code: "LINK_CARD_DENIED",
2768
+ message: "The person declined the Link card request.",
2769
+ linkClosed: true
2770
+ };
2771
+ case "canceled":
2772
+ return {
2773
+ kind: LinkCardDeniedError,
2774
+ code: "LINK_CARD_DENIED",
2775
+ message: "The Link card request was cancelled in Link.",
2776
+ linkClosed: true
2777
+ };
2778
+ case "expired":
2779
+ return {
2780
+ kind: LinkCardExpiredError,
2781
+ code: "LINK_CARD_EXPIRED",
2782
+ message: "The Link card request expired before the person approved it.",
2783
+ linkClosed: true
2784
+ };
2785
+ case "requires_action":
2786
+ return {
2787
+ kind: LinkCardError,
2788
+ code: "LINK_CARD_ACTION_REQUIRED",
2789
+ message: "Link needs the person to finish a step in Link before it can issue a card."
2790
+ };
2791
+ default:
2792
+ return {
2793
+ kind: LinkCardError,
2794
+ code: "LINK_CARD_FAILED",
2795
+ message: "Link did not issue a card for this request.",
2796
+ linkClosed: LINK_CLOSED_STATUSES.has(request.status)
2797
+ };
2798
+ }
2799
+ }
2800
+ async function closeLinkRequest(wallet, id) {
2801
+ try {
2802
+ await wallet.cancel(id);
2803
+ return true;
2804
+ } catch {
2805
+ const status = await wallet.waitForApproval(id, { timeoutSeconds: 0 }).then((request) => request.status).catch(() => void 0);
2806
+ return status !== void 0 && LINK_CLOSED_STATUSES.has(status);
2807
+ }
2808
+ }
2809
+ function linkCreatedNothing(error) {
2810
+ return errorCode(error) === "LINK_WALLET_UNAVAILABLE" || errorReason(error) === "invalid_input";
2811
+ }
2812
+ var LinkFailureSignal = class extends Error {
2813
+ constructor(failure) {
2814
+ super(failure.message);
2815
+ this.failure = failure;
2816
+ }
2817
+ };
2818
+ var _client4, _CheckoutClient_instances, linkCardFor_fn;
2489
2819
  var CheckoutClient = class {
2490
2820
  constructor(client) {
2821
+ __privateAdd(this, _CheckoutClient_instances);
2491
2822
  __privateAdd(this, _client4);
2492
2823
  __privateSet(this, _client4, client);
2493
2824
  }
@@ -2566,8 +2897,16 @@ var CheckoutClient = class {
2566
2897
  if (!Number.isSafeInteger(input.amountMinor) || input.amountMinor <= 0) {
2567
2898
  throw checkoutError("amountMinor must be a positive integer number of cents");
2568
2899
  }
2900
+ assertPaymentReference(input.paymentReference);
2569
2901
  let current = checkpoint;
2570
2902
  if (input.amountMinor !== checkpoint.amountMinor) {
2903
+ if (input.paymentReference) {
2904
+ throw new ContoError("The Link card covers the approved total, not this one. Do not pay.", {
2905
+ code: "LINK_CARD_TOTAL_CHANGED",
2906
+ status: 0,
2907
+ retryGuidance: "Call checkout.replaceLinkCard(result, { wallet, amountMinor }) for a card at the final total, then confirm the checkpoint it returns."
2908
+ });
2909
+ }
2571
2910
  try {
2572
2911
  await this.cancel(checkpoint.id);
2573
2912
  } catch (error) {
@@ -2597,7 +2936,15 @@ var CheckoutClient = class {
2597
2936
  {
2598
2937
  amountMinor: input.amountMinor,
2599
2938
  currency: "USD",
2600
- ...input.pageUrl ? { pageUrl: input.pageUrl } : {}
2939
+ ...input.lineItems !== void 0 || input.totals !== void 0 ? {
2940
+ cart: checkpointCart(
2941
+ input.amountMinor,
2942
+ input.lineItems ?? current.cart.lineItems,
2943
+ input.totals ?? current.cart.totals
2944
+ )
2945
+ } : {},
2946
+ ...input.pageUrl ? { pageUrl: input.pageUrl } : {},
2947
+ ...input.paymentReference ? { paymentReference: { provider: "LINK", id: input.paymentReference.id } } : {}
2601
2948
  },
2602
2949
  { idempotent: true }
2603
2950
  );
@@ -2612,12 +2959,14 @@ var CheckoutClient = class {
2612
2959
  }
2613
2960
  /** Report what happened after a confirmed checkpoint. Safe to repeat with the same outcome. */
2614
2961
  async reportOutcome(checkpointId, input) {
2962
+ assertPaymentReference(input.paymentReference);
2615
2963
  const result = await __privateGet(this, _client4).request(
2616
2964
  "POST",
2617
2965
  `/api/sdk/checkout-checkpoints/${encodeURIComponent(checkpointId)}/outcome`,
2618
2966
  {
2619
2967
  outcome: input.outcome,
2620
- ...input.reference ? { reference: input.reference } : {}
2968
+ ...input.reference ? { reference: input.reference } : {},
2969
+ ...input.paymentReference ? { paymentReference: { provider: "LINK", id: input.paymentReference.id } } : {}
2621
2970
  },
2622
2971
  { idempotent: true }
2623
2972
  );
@@ -2643,8 +2992,189 @@ var CheckoutClient = class {
2643
2992
  );
2644
2993
  return result.checkpoint;
2645
2994
  }
2995
+ /**
2996
+ * Get a Stripe Link card for one purchase, issued only after Conto approves
2997
+ * it. Call this at the payment step, once the final total with shipping and
2998
+ * tax is known.
2999
+ *
3000
+ * 1. Conto runs checkpoint 1 and holds the spend. When a rule requires the
3001
+ * owner's approval, this waits for it.
3002
+ * 2. Only then does the wallet ask Link for a card for exactly the approved
3003
+ * amount at this merchant. The person approves it in Link.
3004
+ * 3. The card is returned. It never reaches Conto.
3005
+ *
3006
+ * Then fill in the card, call confirm(result.checkpoint, { amountMinor,
3007
+ * paymentReference: result.paymentReference }) right before paying, pay only
3008
+ * if it resolves, and report the outcome. If the final total differs, call
3009
+ * replaceLinkCard() instead of confirming.
3010
+ *
3011
+ * A Conto denial throws before Link is called. When Link declines, expires,
3012
+ * times out, or is unavailable, this cancels the checkpoint to release the
3013
+ * held spend and throws a LinkCardError. When Conto cannot cancel an open Link
3014
+ * request, it keeps the hold and throws LINK_CARD_NOT_CANCELLED with the
3015
+ * request id. Retrying with the same input resumes the same checkpoint and
3016
+ * Link request.
3017
+ */
3018
+ async getLinkCard(input) {
3019
+ const { wallet, ownerApprovalTimeoutMs, linkApprovalTimeoutMs, signal, ...beginInput } = input;
3020
+ if (!wallet || typeof wallet.createCardRequest !== "function") {
3021
+ throw checkoutError("wallet must be a LinkWallet, such as linkCliWallet()");
3022
+ }
3023
+ let checkpoint = await this.begin({ ...beginInput, checkoutMethod: "LINK" });
3024
+ if (checkpoint.stage === "AWAITING_APPROVAL") {
3025
+ checkpoint = await this.waitForApproval(checkpoint, {
3026
+ ...ownerApprovalTimeoutMs !== void 0 ? { timeoutMs: ownerApprovalTimeoutMs } : {},
3027
+ ...signal ? { signal } : {}
3028
+ });
3029
+ }
3030
+ return __privateMethod(this, _CheckoutClient_instances, linkCardFor_fn).call(this, checkpoint, wallet, linkApprovalTimeoutMs, signal);
3031
+ }
3032
+ /**
3033
+ * The final total differs from the one the Link card covers. This cancels
3034
+ * the old Link request and checkpoint, begins a checkpoint at the final
3035
+ * total (waiting for the owner when a rule requires it), and gets a new card
3036
+ * for it. Confirm the returned checkpoint with the same total.
3037
+ */
3038
+ async replaceLinkCard(previous, input) {
3039
+ const { wallet, amountMinor, ownerApprovalTimeoutMs, linkApprovalTimeoutMs, signal } = input;
3040
+ if (!Number.isSafeInteger(amountMinor) || amountMinor <= 0) {
3041
+ throw checkoutError("amountMinor must be a positive integer number of cents");
3042
+ }
3043
+ if (amountMinor === previous.checkpoint.amountMinor) return previous;
3044
+ const old = previous.checkpoint;
3045
+ if (!await closeLinkRequest(wallet, previous.linkRequestId)) {
3046
+ throw new LinkCardError({
3047
+ message: "Conto could not cancel the earlier Link card. Nothing changed. Do not pay.",
3048
+ code: "LINK_CARD_NOT_CANCELLED",
3049
+ checkpoint: old,
3050
+ linkRequestId: previous.linkRequestId,
3051
+ holdReleased: false,
3052
+ retryGuidance: "Try replaceLinkCard again."
3053
+ });
3054
+ }
3055
+ try {
3056
+ await this.cancel(old.id);
3057
+ } catch (error) {
3058
+ const closed = error instanceof ContoError && error.status === 409 && ["CANCELLED", "EXPIRED", "DENIED"].includes((await this.get(old.id)).stage);
3059
+ if (!closed) throw error;
3060
+ }
3061
+ let checkpoint = await this.begin({
3062
+ idempotencyKey: `${old.idempotencyKey.slice(0, 200)}:final-${amountMinor}`,
3063
+ merchant: {
3064
+ id: old.merchant.id,
3065
+ name: old.merchant.name,
3066
+ checkoutUrl: old.merchant.checkoutUrl ?? ""
3067
+ },
3068
+ amountMinor,
3069
+ purpose: old.purpose,
3070
+ ...old.category ? { category: old.category } : {},
3071
+ checkoutMethod: "LINK",
3072
+ lineItems: input.lineItems ?? old.cart.lineItems,
3073
+ totals: input.totals
3074
+ });
3075
+ if (checkpoint.stage === "AWAITING_APPROVAL") {
3076
+ checkpoint = await this.waitForApproval(checkpoint, {
3077
+ ...ownerApprovalTimeoutMs !== void 0 ? { timeoutMs: ownerApprovalTimeoutMs } : {},
3078
+ ...signal ? { signal } : {}
3079
+ });
3080
+ }
3081
+ return __privateMethod(this, _CheckoutClient_instances, linkCardFor_fn).call(this, checkpoint, wallet, linkApprovalTimeoutMs, signal);
3082
+ }
2646
3083
  };
2647
3084
  _client4 = new WeakMap();
3085
+ _CheckoutClient_instances = new WeakSet();
3086
+ linkCardFor_fn = async function(checkpoint, wallet, linkApprovalTimeoutMs = 10 * 6e4, signal) {
3087
+ if (checkpoint.stage !== "APPROVED") {
3088
+ throw checkoutError(
3089
+ `Conto has not approved a card for this checkpoint (stage ${checkpoint.stage}). Do not pay.`,
3090
+ "CHECKOUT_CHECKPOINT_NOT_APPROVED"
3091
+ );
3092
+ }
3093
+ const deadline = Date.now() + linkApprovalTimeoutMs;
3094
+ const call = signal ? { signal } : {};
3095
+ let linkRequestId;
3096
+ let createStarted = false;
3097
+ try {
3098
+ const cardRequest = linkCardRequest(checkpoint);
3099
+ createStarted = true;
3100
+ let request = await wallet.createCardRequest(cardRequest, call);
3101
+ if (!LINK_REQUEST_ID.test(request.id)) {
3102
+ throw new LinkFailureSignal({
3103
+ kind: LinkCardError,
3104
+ code: "LINK_CARD_FAILED",
3105
+ message: "The Link wallet returned an invalid request id."
3106
+ });
3107
+ }
3108
+ linkRequestId = request.id;
3109
+ if (request.amountMinor !== void 0 && request.amountMinor !== checkpoint.amountMinor) {
3110
+ throw new LinkFailureSignal({
3111
+ kind: LinkCardError,
3112
+ code: "LINK_CARD_MISMATCH",
3113
+ message: "Link returned a request for a different amount."
3114
+ });
3115
+ }
3116
+ while (linkRequestWaiting(request)) {
3117
+ const remainingSeconds = Math.floor((deadline - Date.now()) / 1e3);
3118
+ if (remainingSeconds <= 0) {
3119
+ throw new LinkFailureSignal({
3120
+ kind: LinkCardTimeoutError,
3121
+ code: "LINK_CARD_TIMEOUT",
3122
+ message: "The person did not approve the Link card request in time."
3123
+ });
3124
+ }
3125
+ const started = Date.now();
3126
+ request = await wallet.waitForApproval(request.id, {
3127
+ ...call,
3128
+ timeoutSeconds: remainingSeconds
3129
+ });
3130
+ if (linkRequestWaiting(request) && Date.now() - started < 1e3) {
3131
+ await sleep2(1e3, signal);
3132
+ }
3133
+ }
3134
+ if (!LINK_APPROVED_STATUSES.has(request.status)) {
3135
+ throw new LinkFailureSignal(closedLinkFailure(request));
3136
+ }
3137
+ const card = await wallet.retrieveCard(request.id, call);
3138
+ return {
3139
+ checkpoint,
3140
+ card: protectLinkCard(card),
3141
+ linkRequestId: request.id,
3142
+ paymentReference: { provider: "LINK", id: request.id }
3143
+ };
3144
+ } catch (error) {
3145
+ const failure = error instanceof LinkFailureSignal ? error.failure : linkFailure(error, signal);
3146
+ if (!linkRequestId && createStarted && !linkCreatedNothing(error)) {
3147
+ throw new LinkCardError({
3148
+ message: `${failure.message} Conto could not tell whether Link created the card request, so the held spend stays in place. Do not pay.`,
3149
+ code: "LINK_CARD_NOT_CANCELLED",
3150
+ checkpoint,
3151
+ holdReleased: false,
3152
+ retryGuidance: "Retry with the same input to resume the Link request, or call checkout.cancel(error.checkpoint.id) once you know Link created none."
3153
+ });
3154
+ }
3155
+ if (linkRequestId && !failure.linkClosed && !await closeLinkRequest(wallet, linkRequestId)) {
3156
+ throw new LinkCardError({
3157
+ message: `${failure.message} Conto could not cancel the Link request, so the held spend stays in place. Do not pay.`,
3158
+ code: "LINK_CARD_NOT_CANCELLED",
3159
+ checkpoint,
3160
+ linkRequestId,
3161
+ holdReleased: false,
3162
+ retryGuidance: "Retry with the same input to resume this Link request, or cancel it with wallet.cancel(error.linkRequestId) and then call checkout.cancel(error.checkpoint.id)."
3163
+ });
3164
+ }
3165
+ const holdReleased = await this.cancel(checkpoint.id).then(
3166
+ () => true,
3167
+ () => false
3168
+ );
3169
+ throw new failure.kind({
3170
+ message: `${failure.message} ${holdReleased ? "Conto cancelled the checkpoint and released the held spend." : "Conto could not cancel the checkpoint yet."} Do not pay.`,
3171
+ code: failure.code,
3172
+ checkpoint,
3173
+ ...linkRequestId ? { linkRequestId } : {},
3174
+ holdReleased
3175
+ });
3176
+ }
3177
+ };
2648
3178
  export {
2649
3179
  AdminPurchasingClient,
2650
3180
  BrowserActionFinalizationPendingError,
@@ -2664,6 +3194,11 @@ export {
2664
3194
  ControlledExternalCardPaymentTool,
2665
3195
  ExternalCardConfirmationPendingError,
2666
3196
  Conto as Giro,
3197
+ LinkCardDeniedError,
3198
+ LinkCardError,
3199
+ LinkCardExpiredError,
3200
+ LinkCardTimeoutError,
3201
+ LinkWalletUnavailableError,
2667
3202
  OrganizationPurchasesClient,
2668
3203
  OrganizationRecurringPurchasesClient,
2669
3204
  PurchasesClient,