@conto_finance/sdk 0.7.1 → 0.8.1

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
@@ -22,7 +22,7 @@ var require_package = __commonJS({
22
22
  "package.json"(exports, module) {
23
23
  module.exports = {
24
24
  name: "@conto_finance/sdk",
25
- version: "0.7.1",
25
+ version: "0.8.1",
26
26
  description: "Conto SDK for AI agent payment authorization",
27
27
  main: "./dist/index.js",
28
28
  module: "./dist/index.mjs",
@@ -68,7 +68,7 @@ var require_package = __commonJS({
68
68
  dev: "tsup --watch",
69
69
  lint: "tsc --noEmit",
70
70
  test: "npm run lint && npm run test:contract && npm run test:consumer",
71
- "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",
71
+ "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 test/spending.test.mjs ../../examples/link-wallet-agent/verify.test.mjs",
72
72
  "test:consumer": "npm run build && node --test test/consumer.test.mjs",
73
73
  "verify:release": "node scripts/verify-release.mjs",
74
74
  prepublishOnly: "npm run verify:release && npm run lint && npm run test:contract && npm run test:consumer"
@@ -198,9 +198,9 @@ function watchRead(pending, signal) {
198
198
  pending.then(resolve, reject).finally(() => signal.removeEventListener("abort", abort));
199
199
  });
200
200
  }
201
- function requirePurchaseId(id) {
202
- if (!/^[a-z0-9]{8,64}$/i.test(id)) throw new TypeError("purchase id is not valid");
203
- return encodeURIComponent(id);
201
+ function requirePurchaseId(id2) {
202
+ if (!/^[a-z0-9]{8,64}$/i.test(id2)) throw new TypeError("purchase id is not valid");
203
+ return encodeURIComponent(id2);
204
204
  }
205
205
  function requireCycleKey(key) {
206
206
  if (!/^[A-Za-z0-9._:-]{1,100}$/.test(key)) throw new TypeError("cycleKey is not valid");
@@ -220,24 +220,24 @@ var RecurringPurchasesClient = class {
220
220
  buyerId ? `${__privateGet(this, _base)}?buyerId=${encodeURIComponent(buyerId)}` : __privateGet(this, _base)
221
221
  );
222
222
  }
223
- async get(id) {
224
- return __privateGet(this, _transport).request("GET", `${__privateGet(this, _base)}/${requirePurchaseId(id)}`);
223
+ async get(id2) {
224
+ return __privateGet(this, _transport).request("GET", `${__privateGet(this, _base)}/${requirePurchaseId(id2)}`);
225
225
  }
226
226
  /** Starts one order. The same cycle key returns the same order. */
227
- async startCycle(id, cycleKey) {
227
+ async startCycle(id2, cycleKey) {
228
228
  requireCycleKey(cycleKey);
229
229
  return __privateGet(this, _transport).request(
230
230
  "POST",
231
- `${__privateGet(this, _base)}/${requirePurchaseId(id)}/cycles`,
231
+ `${__privateGet(this, _base)}/${requirePurchaseId(id2)}/cycles`,
232
232
  { cycleKey },
233
233
  { idempotent: true }
234
234
  );
235
235
  }
236
236
  /** Skips one order, also before it starts. */
237
- async skipCycle(id, cycleKey) {
237
+ async skipCycle(id2, cycleKey) {
238
238
  return __privateGet(this, _transport).request(
239
239
  "POST",
240
- `${__privateGet(this, _base)}/${requirePurchaseId(id)}/cycles/${requireCycleKey(cycleKey)}/skip`
240
+ `${__privateGet(this, _base)}/${requirePurchaseId(id2)}/cycles/${requireCycleKey(cycleKey)}/skip`
241
241
  );
242
242
  }
243
243
  };
@@ -251,10 +251,10 @@ var OrganizationRecurringPurchasesClient = class extends RecurringPurchasesClien
251
251
  __privateSet(this, _client, client);
252
252
  }
253
253
  /** Stops a repeat order. An order waiting for its payment time closes unpaid. */
254
- async cancel(id) {
254
+ async cancel(id2) {
255
255
  return __privateGet(this, _client).request(
256
256
  "POST",
257
- `/api/purchasing/recurring-purchases/${requirePurchaseId(id)}/cancel`
257
+ `/api/purchasing/recurring-purchases/${requirePurchaseId(id2)}/cancel`
258
258
  );
259
259
  }
260
260
  };
@@ -291,36 +291,36 @@ var PurchasesClient = class {
291
291
  const suffix = query.toString();
292
292
  return __privateGet(this, _transport2).request("GET", `${__privateGet(this, _base2)}${suffix ? `?${suffix}` : ""}`);
293
293
  }
294
- async get(id) {
295
- return __privateGet(this, _transport2).request("GET", `${__privateGet(this, _base2)}/${requirePurchaseId(id)}`);
294
+ async get(id2) {
295
+ return __privateGet(this, _transport2).request("GET", `${__privateGet(this, _base2)}/${requirePurchaseId(id2)}`);
296
296
  }
297
297
  /**
298
298
  * Answers an open action whose authority is `agent_or_buyer` with the fields
299
299
  * it lists. The acknowledgment means recorded; the purchase shows the result.
300
300
  * The same answer again returns the same acknowledgment.
301
301
  */
302
- async respond(id, input) {
302
+ async respond(id2, input) {
303
303
  return __privateGet(this, _transport2).request(
304
304
  "POST",
305
- `${__privateGet(this, _base2)}/${requirePurchaseId(id)}/responses`,
305
+ `${__privateGet(this, _base2)}/${requirePurchaseId(id2)}/responses`,
306
306
  input,
307
307
  { idempotent: true }
308
308
  );
309
309
  }
310
- async events(id, params = {}) {
310
+ async events(id2, params = {}) {
311
311
  const query = new URLSearchParams({ after: String(params.after ?? 0) });
312
312
  if (params.limit) query.set("limit", String(params.limit));
313
- return __privateGet(this, _transport2).request("GET", `${__privateGet(this, _base2)}/${requirePurchaseId(id)}/events?${query}`);
313
+ return __privateGet(this, _transport2).request("GET", `${__privateGet(this, _base2)}/${requirePurchaseId(id2)}/events?${query}`);
314
314
  }
315
315
  /**
316
316
  * Requests cancellation. Before payment the purchase stops. Once payment may
317
317
  * have been submitted, `cancellationOutcome` is `too_late` and the purchase
318
318
  * stays in reconciliation. Nothing is refunded by this call.
319
319
  */
320
- async cancel(id) {
320
+ async cancel(id2) {
321
321
  return __privateGet(this, _transport2).request(
322
322
  "POST",
323
- `${__privateGet(this, _base2)}/${requirePurchaseId(id)}/cancel`,
323
+ `${__privateGet(this, _base2)}/${requirePurchaseId(id2)}/cancel`,
324
324
  void 0,
325
325
  { idempotent: true }
326
326
  );
@@ -332,8 +332,8 @@ var PurchasesClient = class {
332
332
  * after a reconnect is dropped. A `purchase.resync` event carries the
333
333
  * current purchase when the cursor could not be resumed.
334
334
  */
335
- async *watch(id, options = {}) {
336
- const path = `${__privateGet(this, _base2)}/${requirePurchaseId(id)}`;
335
+ async *watch(id2, options = {}) {
336
+ const path = `${__privateGet(this, _base2)}/${requirePurchaseId(id2)}`;
337
337
  const mode = options.mode ?? "auto";
338
338
  const interval = Math.max(500, options.pollIntervalMs ?? 2e3);
339
339
  if (options.maxWaitMs !== void 0 && (!Number.isFinite(options.maxWaitMs) || options.maxWaitMs < 0 || options.maxWaitMs > 2147483647))
@@ -398,10 +398,10 @@ var PurchasesClient = class {
398
398
  }
399
399
  let page;
400
400
  try {
401
- page = await watchRead(this.events(id, { after: cursor, limit: 100 }), signal);
401
+ page = await watchRead(this.events(id2, { after: cursor, limit: 100 }), signal);
402
402
  } catch (error) {
403
403
  if (codeOf(error) !== "CURSOR_INVALID") throw error;
404
- const purchase = await watchRead(this.get(id), signal);
404
+ const purchase = await watchRead(this.get(id2), signal);
405
405
  cursor = purchase.version;
406
406
  yield {
407
407
  id: `resync:${purchase.version}`,
@@ -456,10 +456,10 @@ var OrganizationPurchasesClient = class extends PurchasesClient {
456
456
  return __privateGet(this, _client2).request("GET", `/api/purchasing/purchases${suffix ? `?${suffix}` : ""}`);
457
457
  }
458
458
  /** A single-use link that opens the purchase's buyer actions on Conto's hosted page. */
459
- createActionLink(id, input = {}) {
459
+ createActionLink(id2, input = {}) {
460
460
  return __privateGet(this, _client2).request(
461
461
  "POST",
462
- `/api/purchasing/purchases/${requirePurchaseId(id)}/action-sessions`,
462
+ `/api/purchasing/purchases/${requirePurchaseId(id2)}/action-sessions`,
463
463
  input
464
464
  );
465
465
  }
@@ -476,52 +476,52 @@ var AdminPurchasingClient = class {
476
476
  "GET",
477
477
  `/api/purchasing/allowances?${new URLSearchParams({ buyerId: params.buyerId })}`
478
478
  ),
479
- get: (id) => __privateGet(this, _client3).request("GET", `/api/purchasing/allowances/${enc(id)}`),
479
+ get: (id2) => __privateGet(this, _client3).request("GET", `/api/purchasing/allowances/${enc(id2)}`),
480
480
  /** Stops future credential release; does not undo payments already submitted. */
481
- revoke: (id) => __privateGet(this, _client3).request(
481
+ revoke: (id2) => __privateGet(this, _client3).request(
482
482
  "POST",
483
- `/api/purchasing/allowances/${enc(id)}/revoke`
483
+ `/api/purchasing/allowances/${enc(id2)}/revoke`
484
484
  )
485
485
  };
486
486
  this.buyers = {
487
487
  /** Maps one of your users to a buyer with its own agent, or returns the existing one. */
488
488
  create: (input) => __privateGet(this, _client3).request("POST", "/api/purchasing/buyers", input),
489
- get: (id) => __privateGet(this, _client3).request("GET", `/api/purchasing/buyers/${enc(id)}`),
489
+ get: (id2) => __privateGet(this, _client3).request("GET", `/api/purchasing/buyers/${enc(id2)}`),
490
490
  list: (params = {}) => __privateGet(this, _client3).request("GET", `/api/purchasing/buyers?limit=${params.limit ?? 20}&offset=${params.offset ?? 0}`),
491
491
  /** Stops the buyer's agent. Purchases that may already be paid stay visible. */
492
- stop: (id) => __privateGet(this, _client3).request("POST", `/api/purchasing/buyers/${enc(id)}/stop`),
492
+ stop: (id2) => __privateGet(this, _client3).request("POST", `/api/purchasing/buyers/${enc(id2)}/stop`),
493
493
  /**
494
494
  * Lets the buyer pay with your company card within limits you set for
495
495
  * them, or replaces those limits. The buyer must not have their own card.
496
496
  */
497
- setCompanyCard: (id, input) => __privateGet(this, _client3).request(
497
+ setCompanyCard: (id2, input) => __privateGet(this, _client3).request(
498
498
  "PUT",
499
- `/api/purchasing/buyers/${enc(id)}/company-card`,
499
+ `/api/purchasing/buyers/${enc(id2)}/company-card`,
500
500
  input
501
501
  ),
502
502
  /** The buyer stops paying with your company card. */
503
- removeCompanyCard: (id) => __privateGet(this, _client3).request(
503
+ removeCompanyCard: (id2) => __privateGet(this, _client3).request(
504
504
  "DELETE",
505
- `/api/purchasing/buyers/${enc(id)}/company-card`
505
+ `/api/purchasing/buyers/${enc(id2)}/company-card`
506
506
  ),
507
507
  /**
508
508
  * Replaces what the buyer has at home: inventory, usuals, preferences,
509
509
  * chosen recipes and their list. The whole snapshot, every time. Needs
510
510
  * Conto to enable kitchen context for your organization.
511
511
  */
512
- setKitchenContext: (id, context) => __privateGet(this, _client3).request(
512
+ setKitchenContext: (id2, context) => __privateGet(this, _client3).request(
513
513
  "PUT",
514
- `/api/purchasing/buyers/${enc(id)}/context`,
514
+ `/api/purchasing/buyers/${enc(id2)}/context`,
515
515
  context
516
516
  ),
517
517
  /** The snapshot Conto holds, with context null when nothing was pushed. */
518
- getKitchenContext: (id) => __privateGet(this, _client3).request(
518
+ getKitchenContext: (id2) => __privateGet(this, _client3).request(
519
519
  "GET",
520
- `/api/purchasing/buyers/${enc(id)}/context`
520
+ `/api/purchasing/buyers/${enc(id2)}/context`
521
521
  ),
522
- clearKitchenContext: (id) => __privateGet(this, _client3).request(
522
+ clearKitchenContext: (id2) => __privateGet(this, _client3).request(
523
523
  "DELETE",
524
- `/api/purchasing/buyers/${enc(id)}/context`
524
+ `/api/purchasing/buyers/${enc(id2)}/context`
525
525
  )
526
526
  };
527
527
  /** Cards your organization pays its buyers' purchases with. Test cards only for now. */
@@ -531,17 +531,17 @@ var AdminPurchasingClient = class {
531
531
  "GET",
532
532
  "/api/purchasing/company-cards"
533
533
  ),
534
- get: (id) => __privateGet(this, _client3).request("GET", `/api/purchasing/company-cards/${enc(id)}`),
534
+ get: (id2) => __privateGet(this, _client3).request("GET", `/api/purchasing/company-cards/${enc(id2)}`),
535
535
  /** Changes the card's own caps. Each buyer's limits stay as they are. */
536
- update: (id, input) => __privateGet(this, _client3).request(
536
+ update: (id2, input) => __privateGet(this, _client3).request(
537
537
  "PATCH",
538
- `/api/purchasing/company-cards/${enc(id)}`,
538
+ `/api/purchasing/company-cards/${enc(id2)}`,
539
539
  input
540
540
  ),
541
541
  /** No buyer can start or pay a purchase with the card after this. */
542
- remove: (id) => __privateGet(this, _client3).request(
542
+ remove: (id2) => __privateGet(this, _client3).request(
543
543
  "DELETE",
544
- `/api/purchasing/company-cards/${enc(id)}`
544
+ `/api/purchasing/company-cards/${enc(id2)}`
545
545
  )
546
546
  };
547
547
  this.paymentMethods = {
@@ -555,9 +555,9 @@ var AdminPurchasingClient = class {
555
555
  "GET",
556
556
  `/api/purchasing/payment-methods?buyerId=${enc(buyerId)}`
557
557
  ),
558
- remove: (id) => __privateGet(this, _client3).request(
558
+ remove: (id2) => __privateGet(this, _client3).request(
559
559
  "DELETE",
560
- `/api/purchasing/payment-methods/${enc(id)}`
560
+ `/api/purchasing/payment-methods/${enc(id2)}`
561
561
  )
562
562
  };
563
563
  this.delegations = {
@@ -565,10 +565,10 @@ var AdminPurchasingClient = class {
565
565
  "GET",
566
566
  `/api/purchasing/delegations?buyerId=${enc(buyerId)}`
567
567
  ),
568
- get: (id) => __privateGet(this, _client3).request("GET", `/api/purchasing/delegations/${enc(id)}`),
569
- revoke: (id) => __privateGet(this, _client3).request(
568
+ get: (id2) => __privateGet(this, _client3).request("GET", `/api/purchasing/delegations/${enc(id2)}`),
569
+ revoke: (id2) => __privateGet(this, _client3).request(
570
570
  "POST",
571
- `/api/purchasing/delegations/${enc(id)}/revoke`
571
+ `/api/purchasing/delegations/${enc(id2)}/revoke`
572
572
  )
573
573
  };
574
574
  this.settings = {
@@ -598,6 +598,204 @@ function verifyPurchaseWebhook(input) {
598
598
  return payload;
599
599
  }
600
600
 
601
+ // src/spending.ts
602
+ var id = encodeURIComponent;
603
+ var SpendingClient = class {
604
+ constructor(client) {
605
+ this.client = client;
606
+ }
607
+ /**
608
+ * CSV of this agent's purchases against budgets in its own organization. Fails with `reasonCode`
609
+ * `EXPORT_TOO_LARGE` above 10,000 purchases.
610
+ */
611
+ exportPurchases() {
612
+ return this.client.download("/api/sdk/spending/purchases/export");
613
+ }
614
+ downloadEvidence(purchaseId, evidenceId) {
615
+ return this.client.download(
616
+ `/api/sdk/spending/purchases/${id(purchaseId)}/evidence/${id(evidenceId)}`
617
+ );
618
+ }
619
+ /** The budgets that cover this agent, with what is held, settled and available. */
620
+ budgets() {
621
+ return this.client.request("GET", "/api/sdk/budgets");
622
+ }
623
+ tasks(cursor) {
624
+ return this.client.request(
625
+ "GET",
626
+ `/api/sdk/tasks${cursor ? `?cursor=${id(cursor)}` : ""}`
627
+ );
628
+ }
629
+ /**
630
+ * Ask the budget holder to approve a task budget. Once it is approved, pass its id as
631
+ * `context.taskRequestId` on `payments.request()` from a managed wallet, or as `taskRequestId` on
632
+ * `connectedCards.draw()`. A task's budget applies only to a payment or draw that names the task,
633
+ * and it stops nothing on other paths.
634
+ */
635
+ requestTask(input) {
636
+ return this.client.request(
637
+ "POST",
638
+ "/api/sdk/tasks",
639
+ { ...input }
640
+ );
641
+ }
642
+ /** This agent's purchases against budgets in its own organization. Empty when none has applied. */
643
+ purchases(cursor) {
644
+ return this.client.request(
645
+ "GET",
646
+ `/api/sdk/spending/purchases${cursor ? `?cursor=${id(cursor)}` : ""}`
647
+ );
648
+ }
649
+ purchase(purchaseId) {
650
+ return this.client.request(
651
+ "GET",
652
+ `/api/sdk/spending/purchases/${id(purchaseId)}`
653
+ );
654
+ }
655
+ /**
656
+ * Check a purchase against shared budgets before requesting the payment. It holds nothing and
657
+ * approves nothing. `sourceId` must be a managed wallet linked to this agent, and `currency` must
658
+ * be that wallet's stablecoin, such as `USDC` on Base, `USDC.e` on Tempo or `pathUSD` on Tempo
659
+ * Testnet. A purchase the budgets would refuse throws a `ContoError`.
660
+ */
661
+ preview(input) {
662
+ return this.client.request("POST", "/api/sdk/spending/purchases/preview", { ...input });
663
+ }
664
+ /**
665
+ * Attach a PDF, PNG or JPEG of at most 2 MiB, with up to 10 files per purchase. Set the Blob's
666
+ * `type` to `application/pdf`, `image/png` or `image/jpeg`. A Blob with no type, or with a type
667
+ * that does not match the file's first bytes, fails with `reasonCode` `INVALID_FILE`.
668
+ */
669
+ attachEvidence(purchaseId, file, filename) {
670
+ const form = new FormData();
671
+ form.set("file", file, filename);
672
+ return this.client.request(
673
+ "POST",
674
+ `/api/sdk/spending/purchases/${id(purchaseId)}/evidence`,
675
+ form
676
+ );
677
+ }
678
+ };
679
+ var AdminSpendingClient = class {
680
+ constructor(client) {
681
+ this.client = client;
682
+ }
683
+ /**
684
+ * CSV of the organization's purchases against shared budgets. Fails with `reasonCode`
685
+ * `EXPORT_TOO_LARGE` above 10,000 purchases.
686
+ */
687
+ exportPurchases() {
688
+ return this.client.download("/api/spending/purchases/export");
689
+ }
690
+ /**
691
+ * Attach a PDF, PNG or JPEG of at most 2 MiB, with up to 10 files per purchase. Set the Blob's
692
+ * `type` to `application/pdf`, `image/png` or `image/jpeg`. A Blob with no type, or with a type
693
+ * that does not match the file's first bytes, fails with `reasonCode` `INVALID_FILE`.
694
+ */
695
+ attachEvidence(purchaseId, file, filename) {
696
+ const form = new FormData();
697
+ form.set("file", file, filename);
698
+ return this.client.request(
699
+ "POST",
700
+ `/api/spending/purchases/${id(purchaseId)}/evidence`,
701
+ form
702
+ );
703
+ }
704
+ downloadEvidence(purchaseId, evidenceId) {
705
+ return this.client.download(
706
+ `/api/spending/purchases/${id(purchaseId)}/evidence/${id(evidenceId)}`
707
+ );
708
+ }
709
+ budgets() {
710
+ return this.client.request("GET", "/api/spending/budgets");
711
+ }
712
+ /**
713
+ * Create a shared budget. Needs an organization API key with `policies:write`.
714
+ *
715
+ * While a budget exists, it stops x402, MPP, external wallet payments, agent-to-agent payments,
716
+ * payment grants, checkout relay, hosted Stripe Link, guarded browser checkout
717
+ * (`runGuardedCheckout()`), card overlays and checkout checkpoints, including
718
+ * `checkout.getLinkCard()`, for every agent it covers. A paused, expired or not yet started budget
719
+ * still stops them. Until its start date, while paused, and after its end date, it also refuses
720
+ * the managed wallet payments and connected card draws it covers, for those agents. A start date
721
+ * cannot be changed.
722
+ * `deleteBudget()` ends a budget. Fails with `reasonCode`
723
+ * `ENROLLMENT_RECONCILIATION_REQUIRED` while the organization or a participating agent has an
724
+ * open commitment.
725
+ */
726
+ createBudget(input) {
727
+ return this.client.request("POST", "/api/spending/budgets", {
728
+ ...input
729
+ });
730
+ }
731
+ /**
732
+ * Change a budget's amount or active state, or move its end date to a later time in the future.
733
+ * Needs the budget's current `version`. A task budget can only change its active state. Pausing
734
+ * stops new purchases on it, and does not lift what it stops on other payment paths.
735
+ */
736
+ updateBudget(budgetId, input) {
737
+ return this.client.request(
738
+ "PATCH",
739
+ `/api/spending/budgets/${id(budgetId)}`,
740
+ input
741
+ );
742
+ }
743
+ /**
744
+ * End a budget, so it stops applying on every path. Needs the budget's current `version`. Fails
745
+ * with `reasonCode` `BUDGET_HAS_HOLDS` while a purchase holds spend against it, and `TASK_BUDGET`
746
+ * for a task's budget, which ends by closing the task. A linked buyer with no budget left fails
747
+ * with `reasonCode` `LINKED_BUDGET_REQUIRED`.
748
+ */
749
+ deleteBudget(budgetId, input) {
750
+ return this.client.request(
751
+ "DELETE",
752
+ `/api/spending/budgets/${id(budgetId)}?version=${input.version}`
753
+ );
754
+ }
755
+ tasks(cursor) {
756
+ return this.client.request(
757
+ "GET",
758
+ `/api/spending/tasks${cursor ? `?cursor=${id(cursor)}` : ""}`
759
+ );
760
+ }
761
+ /** Approve, reject or close a task. Needs an organization API key with `policies:write`. */
762
+ decideTask(taskId, input) {
763
+ return this.client.request(
764
+ "POST",
765
+ `/api/spending/tasks/${id(taskId)}/decide`,
766
+ input
767
+ );
768
+ }
769
+ purchases(cursor) {
770
+ return this.client.request(
771
+ "GET",
772
+ `/api/spending/purchases${cursor ? `?cursor=${id(cursor)}` : ""}`
773
+ );
774
+ }
775
+ purchase(purchaseId) {
776
+ return this.client.request("GET", `/api/spending/purchases/${id(purchaseId)}`);
777
+ }
778
+ /** Record what happened to a purchase. Needs an API key with the `spending:reconcile` scope. */
779
+ reconcile(purchaseId, input) {
780
+ return this.client.request(
781
+ "POST",
782
+ `/api/spending/purchases/${id(purchaseId)}/reconcile`,
783
+ input
784
+ );
785
+ }
786
+ accounting(purchaseId, input) {
787
+ return this.client.request("PATCH", `/api/spending/purchases/${id(purchaseId)}`, input);
788
+ }
789
+ /** Record the amount, currency and merchant on a file. Conto does not take them from the file. */
790
+ reviewEvidence(purchaseId, evidenceId, input) {
791
+ return this.client.request(
792
+ "PATCH",
793
+ `/api/spending/purchases/${id(purchaseId)}/evidence/${id(evidenceId)}`,
794
+ input
795
+ );
796
+ }
797
+ };
798
+
601
799
  // src/http.ts
602
800
  var PUBLIC_PAYMENT_REASONS = /* @__PURE__ */ new Set([
603
801
  "The available balance is insufficient for this payment.",
@@ -639,7 +837,34 @@ var VALIDATION_FIELDS = /* @__PURE__ */ new Set([
639
837
  "walletId",
640
838
  "idempotencyKey",
641
839
  "txHash",
642
- "approvalToken"
840
+ "approvalToken",
841
+ "currency",
842
+ "merchant",
843
+ "sourceId",
844
+ "expiresAt",
845
+ "taskRequestId",
846
+ "purchaseKey",
847
+ "permittedMerchants",
848
+ "permittedSourceIds",
849
+ "name",
850
+ "scope",
851
+ "participantIds",
852
+ "requiresTask",
853
+ "period",
854
+ "periodStart",
855
+ "periodEnd",
856
+ "version",
857
+ "isActive",
858
+ "decision",
859
+ "eventKey",
860
+ "kind",
861
+ "settledAmount",
862
+ "refundedAmount",
863
+ "final",
864
+ "evidenceRef",
865
+ "department",
866
+ "project",
867
+ "accountingCode"
643
868
  ]);
644
869
  function customerFieldErrors(body, status) {
645
870
  if (![400, 422].includes(status) || !["VALIDATION_ERROR", "INVALID_REQUEST", "INVALID_INPUT"].includes(String(body.code)))
@@ -648,9 +873,7 @@ function customerFieldErrors(body, status) {
648
873
  if (!Array.isArray(fields)) return void 0;
649
874
  const names = [
650
875
  ...new Set(
651
- fields.map((item) => record(item).field).filter(
652
- (field) => typeof field === "string" && VALIDATION_FIELDS.has(field)
653
- )
876
+ fields.map((item) => record(item).field).filter((field) => typeof field === "string").map((field) => field.split(".")[0]).filter((field) => VALIDATION_FIELDS.has(field))
654
877
  )
655
878
  ];
656
879
  return names.length ? names.map((field) => ({
@@ -793,14 +1016,68 @@ function fallbackRetryGuidance(status) {
793
1016
  function responseErrorMessage(_data, status) {
794
1017
  return safeErrorMessage(status);
795
1018
  }
1019
+ var SHARED_REASON_CODES = /* @__PURE__ */ new Set([
1020
+ "AGENT_NOT_AUTHORIZED",
1021
+ "AGENT_UNAVAILABLE",
1022
+ "BUDGET_CURRENCY_UNSUPPORTED",
1023
+ "BUDGET_EXPIRED",
1024
+ "BUDGET_HAS_HOLDS",
1025
+ "BUDGET_PAUSED",
1026
+ "BUYER_LINK_REVOKED",
1027
+ "DUPLICATE_PURCHASE",
1028
+ "ENROLLMENT_RECONCILIATION_REQUIRED",
1029
+ "EVIDENCE_CONFLICT",
1030
+ "EVIDENCE_LIMIT",
1031
+ "EVIDENCE_REQUIRED",
1032
+ "EXECUTION_PATH_NOT_CONNECTED",
1033
+ "EXPORT_TOO_LARGE",
1034
+ "INVALID_AMOUNT",
1035
+ "INVALID_EXPIRY",
1036
+ "INVALID_FILE",
1037
+ "INVALID_PARTICIPANTS",
1038
+ "INVALID_RENEWAL",
1039
+ "INVALID_TASK_STATE",
1040
+ "LEGACY_BUDGET_REQUEST",
1041
+ "LINKED_BUDGET_REQUIRED",
1042
+ "LINKED_TASK_UNSUPPORTED",
1043
+ "MERCHANT_NOT_AUTHORIZED",
1044
+ "NOT_FOUND",
1045
+ "PURCHASE_ALREADY_SUBMITTED",
1046
+ "PURCHASE_CONFLICT",
1047
+ "SETTLED_PURCHASE",
1048
+ "SHARED_BUDGET_EXCEEDED",
1049
+ "SOURCE_CURRENCY_MISMATCH",
1050
+ "SOURCE_NOT_AUTHORIZED",
1051
+ "SOURCE_UNAVAILABLE",
1052
+ "STALE_PAYMENT_EVIDENCE",
1053
+ "STALE_VERSION",
1054
+ "TASK_AUTHORITY_UNAVAILABLE",
1055
+ "TASK_BUDGET",
1056
+ "TASK_EXPIRED",
1057
+ "TASK_REAPPROVAL_REQUIRED",
1058
+ "TASK_REQUIRED",
1059
+ "UNSUPPORTED_BUDGET_PERIOD"
1060
+ ]);
1061
+ var SHARED_NEXT_ACTIONS = /* @__PURE__ */ new Set([
1062
+ "REQUEST_BUDGET_INCREASE",
1063
+ "INSPECT_BUDGETS",
1064
+ "OWNER_RECONCILIATION",
1065
+ "CHECK_STATUS"
1066
+ ]);
796
1067
  function responseErrorMetadata(response, data) {
797
1068
  const body = record(data);
798
1069
  const code = safeErrorCode(body.code, response.status);
799
1070
  const bodyRetryAfter = typeof body.retryAfter === "number" || typeof body.retryAfter === "string" ? parseRetryAfter(String(body.retryAfter)) : void 0;
800
1071
  const reasons = customerPaymentReasons(body.reasons);
1072
+ const details = record(body.details);
1073
+ const reasonCode = typeof details.reasonCode === "string" && SHARED_REASON_CODES.has(details.reasonCode) ? details.reasonCode : void 0;
1074
+ const nextActions = reasonCode && Array.isArray(details.nextActions) ? details.nextActions.filter(
1075
+ (value) => typeof value === "string" && SHARED_NEXT_ACTIONS.has(value)
1076
+ ) : void 0;
801
1077
  return {
802
1078
  code,
803
1079
  status: response.status,
1080
+ ...reasonCode ? { reasonCode, nextActions } : {},
804
1081
  ...reasons.length > 0 ? { reasons } : {},
805
1082
  fieldErrors: customerFieldErrors(body, response.status),
806
1083
  requestId: safeRequestId(
@@ -826,6 +1103,9 @@ var ContoAdminError = class extends Error {
826
1103
  this.requestId = metadata.requestId;
827
1104
  this.retryAfter = metadata.retryAfter;
828
1105
  this.retryGuidance = metadata.retryGuidance;
1106
+ this.reasonCode = metadata.reasonCode;
1107
+ this.nextActions = metadata.nextActions;
1108
+ this.fieldErrors = metadata.fieldErrors;
829
1109
  }
830
1110
  };
831
1111
  var _orgApiKey;
@@ -850,6 +1130,7 @@ var ContoAdmin = class {
850
1130
  this.baseUrl = (config.baseUrl || "https://conto.finance").replace(/\/$/, "");
851
1131
  assertSecureBaseUrl(this.baseUrl);
852
1132
  this.timeout = config.timeout || 3e4;
1133
+ this.spending = new AdminSpendingClient(this);
853
1134
  this.agents = new AgentsClient(this);
854
1135
  this.wallets = new WalletsClient(this);
855
1136
  this.cards = new AdminCardsClient(this);
@@ -890,6 +1171,49 @@ var ContoAdmin = class {
890
1171
  clearTimeout(timeoutId);
891
1172
  }
892
1173
  }
1174
+ /**
1175
+ * Download a file from the Conto API as bytes. It retries like a read, and
1176
+ * failures throw ContoAdminError as request() does.
1177
+ */
1178
+ async download(path) {
1179
+ const controller = new AbortController();
1180
+ const timeoutId = setTimeout(() => controller.abort(), this.timeout);
1181
+ try {
1182
+ const response = await fetchWithRetry(
1183
+ `${this.baseUrl}${path}`,
1184
+ {
1185
+ headers: {
1186
+ Authorization: `Bearer ${__privateGet(this, _orgApiKey)}`,
1187
+ "User-Agent": `@conto_finance/sdk-admin/${import_package_version.SDK_PACKAGE_VERSION}`
1188
+ },
1189
+ // A redirect is an error response here, so the key never follows it.
1190
+ redirect: "manual",
1191
+ signal: controller.signal
1192
+ },
1193
+ { retryable: true }
1194
+ );
1195
+ if (!response.ok) {
1196
+ const data = await readResponseData(response);
1197
+ throw new ContoAdminError(
1198
+ responseErrorMessage(data, response.status),
1199
+ responseErrorMetadata(response, data)
1200
+ );
1201
+ }
1202
+ return new Uint8Array(await response.arrayBuffer());
1203
+ } catch (error) {
1204
+ if (error instanceof ContoAdminError) throw error;
1205
+ const timedOut = error instanceof Error && error.name === "AbortError";
1206
+ const failure = new ContoAdminError(timedOut ? "Request timeout" : "Network request failed", {
1207
+ code: timedOut ? "TIMEOUT" : "NETWORK_ERROR",
1208
+ status: 0,
1209
+ retryGuidance: "The request is safe to retry."
1210
+ });
1211
+ Sentry.captureException(failure, { extra: { method: "GET", path } });
1212
+ throw failure;
1213
+ } finally {
1214
+ clearTimeout(timeoutId);
1215
+ }
1216
+ }
893
1217
  /**
894
1218
  * Make an authenticated request to the Conto API. Reads retry. A write retries
895
1219
  * only when `idempotent` says the API deduplicates it, as purchase creation does.
@@ -907,10 +1231,10 @@ var ContoAdmin = class {
907
1231
  method: normalizedMethod,
908
1232
  headers: {
909
1233
  Authorization: `Bearer ${__privateGet(this, _orgApiKey)}`,
910
- "Content-Type": "application/json",
1234
+ ...body instanceof FormData ? {} : { "Content-Type": "application/json" },
911
1235
  "User-Agent": `@conto_finance/sdk-admin/${import_package_version.SDK_PACKAGE_VERSION}`
912
1236
  },
913
- body: body ? JSON.stringify(body) : void 0,
1237
+ body: body instanceof FormData ? body : body ? JSON.stringify(body) : void 0,
914
1238
  signal: controller.signal
915
1239
  },
916
1240
  { retryable }
@@ -1415,6 +1739,8 @@ var ContoError = class extends Error {
1415
1739
  this.actionUrl = metadata.actionUrl;
1416
1740
  this.fieldErrors = metadata.fieldErrors;
1417
1741
  this.reasons = metadata.reasons;
1742
+ this.reasonCode = metadata.reasonCode;
1743
+ this.nextActions = metadata.nextActions;
1418
1744
  if (diagnostics !== null && typeof diagnostics === "object" && !Array.isArray(diagnostics) && "version" in diagnostics && diagnostics.version === "conto.setup-diagnostics.v1") {
1419
1745
  this.diagnostics = diagnostics;
1420
1746
  }
@@ -1449,7 +1775,13 @@ var BrowserActionFinalizationPendingError = class extends ContoError {
1449
1775
  };
1450
1776
  var CheckoutCheckpointClosedError = class extends ContoError {
1451
1777
  constructor(checkpoint, message = `The checkpoint is ${checkpoint.stage.toLowerCase().replace(/_/g, " ")}. Stop this checkout.`, code = "CHECKOUT_CHECKPOINT_CLOSED") {
1452
- super(message, { code, status: 0 });
1778
+ super(message, {
1779
+ code,
1780
+ status: 0,
1781
+ ...checkpoint.paymentReference?.provider === "LINK" ? {
1782
+ retryGuidance: "Closing a checkpoint does not close its Link request, so the card may still work. Cancel it with wallet.cancel(error.checkpoint.paymentReference.id). If that call throws, the card may have been used, so do not begin a new purchase. Fix what stopped it and try again, and if Link still refuses, ask the owner to check the order. Once the cancel succeeds, begin a new purchase with a new idempotency key if the buyer still wants it."
1783
+ } : {}
1784
+ });
1453
1785
  this.name = "CheckoutCheckpointClosedError";
1454
1786
  this.checkpoint = checkpoint;
1455
1787
  this.stage = checkpoint.stage;
@@ -1595,6 +1927,7 @@ var Conto = class {
1595
1927
  assertSecureBaseUrl(this.baseUrl);
1596
1928
  this.timeout = config.timeout || 3e4;
1597
1929
  this.checkout = new CheckoutClient(this);
1930
+ this.spending = new SpendingClient(this);
1598
1931
  this.payments = new PaymentsClient(this);
1599
1932
  this.cards = new CardsClient(this);
1600
1933
  this.connectedCards = new ConnectedCardsClient(this);
@@ -1920,6 +2253,49 @@ var Conto = class {
1920
2253
  clearTimeout(timeoutId);
1921
2254
  }
1922
2255
  }
2256
+ /**
2257
+ * Download a file from the Conto API as bytes. It retries like a read, and
2258
+ * failures throw ContoError as request() does.
2259
+ */
2260
+ async download(path) {
2261
+ const controller = new AbortController();
2262
+ const timeoutId = setTimeout(() => controller.abort(), this.timeout);
2263
+ try {
2264
+ const response = await fetchWithRetry(
2265
+ `${this.baseUrl}${path}`,
2266
+ {
2267
+ headers: {
2268
+ Authorization: `Bearer ${__privateGet(this, _apiKey)}`,
2269
+ "User-Agent": `@conto_finance/sdk/${import_package_version2.SDK_PACKAGE_VERSION}`
2270
+ },
2271
+ // A redirect is an error response here, so the key never follows it.
2272
+ redirect: "manual",
2273
+ signal: controller.signal
2274
+ },
2275
+ { retryable: true }
2276
+ );
2277
+ if (!response.ok) {
2278
+ const data = await readResponseData(response);
2279
+ throw new ContoError(
2280
+ responseErrorMessage(data, response.status),
2281
+ responseErrorMetadata(response, data)
2282
+ );
2283
+ }
2284
+ return new Uint8Array(await response.arrayBuffer());
2285
+ } catch (error) {
2286
+ if (error instanceof ContoError) throw error;
2287
+ const timedOut = error instanceof Error && error.name === "AbortError";
2288
+ const failure = new ContoError(timedOut ? "Request timeout" : "Network request failed", {
2289
+ code: timedOut ? "TIMEOUT" : "NETWORK_ERROR",
2290
+ status: 0,
2291
+ retryGuidance: "The request is safe to retry."
2292
+ });
2293
+ Sentry2.captureException(failure, { extra: { method: "GET", path } });
2294
+ throw failure;
2295
+ } finally {
2296
+ clearTimeout(timeoutId);
2297
+ }
2298
+ }
1923
2299
  /** Make an authenticated request to the Conto API */
1924
2300
  async request(method, path, body, options = {}) {
1925
2301
  const url = `${this.baseUrl}${path}`;
@@ -1934,10 +2310,10 @@ var Conto = class {
1934
2310
  method: normalizedMethod,
1935
2311
  headers: {
1936
2312
  Authorization: `Bearer ${__privateGet(this, _apiKey)}`,
1937
- "Content-Type": "application/json",
2313
+ ...body instanceof FormData ? {} : { "Content-Type": "application/json" },
1938
2314
  "User-Agent": `@conto_finance/sdk/${import_package_version2.SDK_PACKAGE_VERSION}`
1939
2315
  },
1940
- body: body ? JSON.stringify(body) : void 0,
2316
+ body: body instanceof FormData ? body : body ? JSON.stringify(body) : void 0,
1941
2317
  signal: controller.signal
1942
2318
  },
1943
2319
  { retryable, maxRetries: options.maxRetries }
@@ -2298,11 +2674,11 @@ var TestCardsClient = class {
2298
2674
  }
2299
2675
  };
2300
2676
  function approvalRetryGuidance(requestId) {
2301
- const id = JSON.stringify(requestId);
2302
- return `An approver in Conto executes the payment when they approve it. After approval, call payments.execute(${id}) or payments.status(${id}) to get the receipt. Do not request the payment again.`;
2677
+ const id2 = JSON.stringify(requestId);
2678
+ return `An approver in Conto executes the payment when they approve it. After approval, call payments.execute(${id2}) or payments.status(${id2}) to get the receipt. Do not request the payment again.`;
2303
2679
  }
2304
2680
  function executeRetryGuidance(code, requestId) {
2305
- const id = JSON.stringify(requestId);
2681
+ const id2 = JSON.stringify(requestId);
2306
2682
  switch (code) {
2307
2683
  case "APPROVAL_PENDING":
2308
2684
  return approvalRetryGuidance(requestId);
@@ -2313,7 +2689,7 @@ function executeRetryGuidance(code, requestId) {
2313
2689
  return "This payment now needs approval and will not execute. Request a new payment and wait for approval.";
2314
2690
  case "PAYMENT_PROCESSING":
2315
2691
  case "NEEDS_RECONCILIATION":
2316
- return `Do not execute again. Check payments.status(${id}) for the outcome.`;
2692
+ return `Do not execute again. Check payments.status(${id2}) for the outcome.`;
2317
2693
  default:
2318
2694
  return void 0;
2319
2695
  }
@@ -2617,6 +2993,12 @@ function checkpointCart(amountMinor, lineItems, totals) {
2617
2993
  if (!Array.isArray(lineItems) || lineItems.length === 0) {
2618
2994
  throw checkoutError("Supply at least one line item");
2619
2995
  }
2996
+ if (totals !== void 0 && totals !== null) {
2997
+ const finalTotal = totals.filter((total) => total.type === "total");
2998
+ if (finalTotal.length !== 1 || finalTotal[0].amountMinor !== amountMinor) {
2999
+ throw checkoutError("totals must have one row with type total that equals amountMinor");
3000
+ }
3001
+ }
2620
3002
  return {
2621
3003
  lineItems,
2622
3004
  totals: totals ?? [{ type: "total", label: "Total", amountMinor }]
@@ -2656,6 +3038,13 @@ var LINK_REQUEST_ID = /^lsrq_[A-Za-z0-9]{1,64}$/;
2656
3038
  var LINK_WAITING_STATUSES = /* @__PURE__ */ new Set(["created", "pending_approval"]);
2657
3039
  var LINK_APPROVED_STATUSES = /* @__PURE__ */ new Set(["approved", "submitted", "succeeded"]);
2658
3040
  var LINK_CLOSED_STATUSES = /* @__PURE__ */ new Set(["denied", "expired", "canceled", "failed"]);
3041
+ var CLOSED_LINK_RESULTS = /* @__PURE__ */ new WeakSet();
3042
+ var CHECKPOINT_RELEASED_STAGES = [
3043
+ "CANCELLED",
3044
+ "EXPIRED",
3045
+ "DENIED",
3046
+ "NOT_PAID"
3047
+ ];
2659
3048
  var INSPECT = /* @__PURE__ */ Symbol.for("nodejs.util.inspect.custom");
2660
3049
  function assertPaymentReference(reference) {
2661
3050
  if (reference === void 0) return;
@@ -2737,6 +3126,14 @@ function errorCode(error) {
2737
3126
  function errorReason(error) {
2738
3127
  return error && typeof error === "object" && typeof error.reason === "string" ? error.reason : void 0;
2739
3128
  }
3129
+ function duplicateGuidance(requestId, owner) {
3130
+ const wait2 = `This purchase cannot start until Link request ${requestId} closes, so do not retry yet.`;
3131
+ const then = "Once it closes, begin this purchase with a new idempotency key if the buyer still wants it.";
3132
+ if (typeof owner !== "string" || !/^[A-Za-z0-9_-]{1,64}$/.test(owner)) {
3133
+ return `${wait2} Finish the other purchase, or wait for Link to close the request. Do not cancel it, because a payment may be in flight. ${then}`;
3134
+ }
3135
+ return `${wait2} It belongs to checkpoint ${owner}, not error.checkpoint, which is this purchase. Read that one with checkout.get("${owner}"). If Conto cannot find it, another agent owns it, so leave the request alone and wait for Link to close it. At APPROVED, finish that purchase, or call wallet.cancel("${requestId}") and then checkout.cancel("${owner}"). At OUTCOME_UNKNOWN with linkRequestOpen true, if the card was used, report PAID with checkout.reportOutcome(), or UNKNOWN if you cannot tell. Otherwise call wallet.cancel("${requestId}") and then checkout.cancel("${owner}"). At CANCELLED, EXPIRED, DENIED, or NOT_PAID, call only wallet.cancel("${requestId}"). At any other stage a payment may be in flight or done, so do not cancel it. If wallet.cancel() throws, the card may have been used, so do not call checkout.cancel() or begin a new purchase. ${then}`;
3136
+ }
2740
3137
  function linkFailure(error, signal) {
2741
3138
  if (error instanceof LinkCardError) {
2742
3139
  return {
@@ -2752,6 +3149,24 @@ function linkFailure(error, signal) {
2752
3149
  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".'
2753
3150
  };
2754
3151
  }
3152
+ const duplicate = error;
3153
+ const duplicateOf = String(duplicate?.duplicateOf);
3154
+ if (errorReason(error) === "invalid_input" && LINK_REQUEST_ID.test(duplicateOf)) {
3155
+ return {
3156
+ kind: LinkCardError,
3157
+ code: "LINK_CARD_DUPLICATE_REQUEST",
3158
+ message: "Link has an open request for another purchase with the same details.",
3159
+ retryGuidance: duplicateGuidance(duplicateOf, duplicate?.duplicateCheckpoint)
3160
+ };
3161
+ }
3162
+ if (errorReason(error) === "cli_failed" && LINK_REQUEST_ID.test(duplicateOf)) {
3163
+ return {
3164
+ kind: LinkCardError,
3165
+ code: "LINK_CARD_FAILED",
3166
+ message: "Link has an open request with the same details and no checkpoint on it, so a retry cannot resume it.",
3167
+ retryGuidance: `Do not retry yet. Link request ${duplicateOf} has the same details and is not this checkout's, so do not cancel it yourself. Ask the person to finish or close it in Link, then retry with the same input. To stop this purchase instead, call checkout.cancel(error.checkpoint.id).`
3168
+ };
3169
+ }
2755
3170
  if (signal?.aborted || errorReason(error) === "aborted") {
2756
3171
  return {
2757
3172
  kind: LinkCardError,
@@ -2792,7 +3207,7 @@ function closedLinkFailure(request) {
2792
3207
  return {
2793
3208
  kind: LinkCardError,
2794
3209
  code: "LINK_CARD_ACTION_REQUIRED",
2795
- message: "Link needs the person to finish a step in Link before it can issue a card."
3210
+ message: "Link cannot continue this request. The person has to fix a problem in Link before a new request can issue a card."
2796
3211
  };
2797
3212
  default:
2798
3213
  return {
@@ -2803,13 +3218,15 @@ function closedLinkFailure(request) {
2803
3218
  };
2804
3219
  }
2805
3220
  }
2806
- async function closeLinkRequest(wallet, id) {
3221
+ async function closeLinkRequest(wallet, id2, onRefused) {
2807
3222
  try {
2808
- await wallet.cancel(id);
3223
+ await wallet.cancel(id2);
2809
3224
  return true;
2810
- } catch {
2811
- const status = await wallet.waitForApproval(id, { timeoutSeconds: 0 }).then((request) => request.status).catch(() => void 0);
2812
- return status !== void 0 && LINK_CLOSED_STATUSES.has(status);
3225
+ } catch (error) {
3226
+ const status = await Promise.resolve().then(() => wallet.waitForApproval(id2, { timeoutSeconds: 0 })).then((request) => request.status).catch(() => void 0);
3227
+ if (status !== void 0 && LINK_CLOSED_STATUSES.has(status)) return true;
3228
+ onRefused?.(error);
3229
+ return false;
2813
3230
  }
2814
3231
  }
2815
3232
  function assertLinkApprovalCallback(callback) {
@@ -2817,6 +3234,65 @@ function assertLinkApprovalCallback(callback) {
2817
3234
  throw checkoutError("onLinkApproval must be a function");
2818
3235
  }
2819
3236
  }
3237
+ function linkCardClosable(checkpoint) {
3238
+ return CHECKPOINT_RELEASED_STAGES.includes(checkpoint.stage) || ["AWAITING_APPROVAL", "APPROVED"].includes(checkpoint.stage) || checkpoint.linkRequestOpen === true;
3239
+ }
3240
+ var CONFIRMING_GUIDANCE = "Wait a few seconds, then read the checkpoint with checkout.get(). Act only after the stage changes, and do not cancel the Link request before then, because the check can still end in CONFIRMED. If it is still CONFIRMING after a few minutes, stop and ask the owner to record the outcome on the checkout page.";
3241
+ var PAID_GUIDANCE = "Do not buy it again.";
3242
+ var CONFIRMED_GUIDANCE = "Report what happened with checkout.reportOutcome(), and do not cancel the Link request while a payment may be in flight. If you know no attempt paid or is paying, report NOT_PAID and cancel the Link request with wallet.cancel() so its card cannot be used. The owner can then release the hold, and you can begin a new purchase with a new idempotency key.";
3243
+ var LINK_CANCEL_STUCK_GUIDANCE = "If wallet.cancel() keeps failing, stop and do not call checkout.cancel(). The owner gets a review alert after the approval lapses and records the outcome on the checkout page.";
3244
+ var LINK_CANCEL_STUCK_NO_RECORD_GUIDANCE = "If wallet.cancel() keeps failing, fix what stopped it, then retry with the same input. Do not call checkout.cancel(). While Conto has no record of this request, the hold ends when the approval lapses, the card may still work, and the owner gets no alert, so tell the owner.";
3245
+ var LINK_REQUEST_OPEN_GUIDANCE = `If the card was used, report PAID with checkout.reportOutcome(), or UNKNOWN if you cannot tell, which keeps the spend held until the owner records what happened. Otherwise cancel the Link request with wallet.cancel(error.linkRequestId), then call checkout.cancel(error.checkpoint.id). ${LINK_CANCEL_STUCK_GUIDANCE}`;
3246
+ var LINK_ACTION_REQUIRED_GUIDANCE = `A retry cannot resume this request, and Link will not cancel it, but Link closes it on its own. wallet.waitForApproval(error.linkRequestId, { timeoutSeconds: 0 }) returns its actionUrl, where the person can fix the problem. To buy this, begin a new purchase with a new idempotency key once they have. Do not call checkout.cancel() yet. This hold stays until Link closes the request, and then a retry releases it. ${LINK_CANCEL_STUCK_GUIDANCE}`;
3247
+ function linkCardNotReplaced(stage) {
3248
+ if (stage === "CONFIRMING") {
3249
+ return {
3250
+ message: "Conto is still checking this checkpoint, so the Link card cannot be replaced yet. Nothing changed. Do not pay this total.",
3251
+ retryGuidance: CONFIRMING_GUIDANCE
3252
+ };
3253
+ }
3254
+ if (stage === "PAID") {
3255
+ return {
3256
+ message: "This purchase is already recorded as paid, so the Link card cannot be replaced. Nothing changed. Do not pay again.",
3257
+ retryGuidance: PAID_GUIDANCE
3258
+ };
3259
+ }
3260
+ return {
3261
+ message: "This checkpoint was already confirmed and a payment may be in flight, so the Link card cannot be replaced. Nothing changed. Do not pay this total.",
3262
+ retryGuidance: CONFIRMED_GUIDANCE
3263
+ };
3264
+ }
3265
+ function linkCardNotIssued(stage) {
3266
+ if (stage === "CONFIRMING") {
3267
+ return {
3268
+ message: "Conto is still checking this checkpoint, so Link was not asked for a card. Do not pay, and do not cancel its Link request.",
3269
+ retryGuidance: CONFIRMING_GUIDANCE
3270
+ };
3271
+ }
3272
+ if (stage === "PAID") {
3273
+ return {
3274
+ message: "This purchase is already recorded as paid, so Link was not asked for a card. Do not pay again.",
3275
+ retryGuidance: PAID_GUIDANCE
3276
+ };
3277
+ }
3278
+ return {
3279
+ message: "This checkpoint was already confirmed and a payment may be in flight, so Link was not asked for a card. Do not pay again, and do not cancel its Link request.",
3280
+ retryGuidance: CONFIRMED_GUIDANCE
3281
+ };
3282
+ }
3283
+ function openLinkRequestGuidance(latest) {
3284
+ if (!latest) {
3285
+ return "Read the checkpoint with checkout.get(error.checkpoint.id) before you do anything else. Do not cancel the Link request until you know its stage, because a payment may be in flight. If the stage is CANCELLED, EXPIRED, DENIED, or NOT_PAID, cancel it with wallet.cancel(error.linkRequestId) and do not pay with it. Otherwise retry with the same input, which resumes the request or tells you what to do at that stage.";
3286
+ }
3287
+ if (CHECKPOINT_RELEASED_STAGES.includes(latest.stage)) {
3288
+ return "The checkpoint has closed, so a retry cannot resume this request. Cancel it with wallet.cancel(error.linkRequestId), and do not pay with it.";
3289
+ }
3290
+ if (latest.linkRequestOpen) return LINK_REQUEST_OPEN_GUIDANCE;
3291
+ if (linkCardClosable(latest)) {
3292
+ return `Retry with the same input to resume this Link request. To stop instead, read the checkpoint with checkout.get(). If it is still APPROVED, cancel the request with wallet.cancel(error.linkRequestId) and then call checkout.cancel(error.checkpoint.id). ${latest.paymentReference ? LINK_CANCEL_STUCK_GUIDANCE : LINK_CANCEL_STUCK_NO_RECORD_GUIDANCE}`;
3293
+ }
3294
+ return linkCardNotIssued(latest.stage).retryGuidance;
3295
+ }
2820
3296
  function linkCreatedNothing(error) {
2821
3297
  return errorCode(error) === "LINK_WALLET_UNAVAILABLE" || errorReason(error) === "invalid_input";
2822
3298
  }
@@ -2843,7 +3319,7 @@ function notifyLinkApproval(callback, event) {
2843
3319
  } catch {
2844
3320
  }
2845
3321
  }
2846
- var _client4, _CheckoutClient_instances, linkCardFor_fn;
3322
+ var _client4, _CheckoutClient_instances, recordLinkRequest_fn, waitForLinkOwner_fn, linkCardFor_fn;
2847
3323
  var CheckoutClient = class {
2848
3324
  constructor(client) {
2849
3325
  __privateAdd(this, _CheckoutClient_instances);
@@ -2928,17 +3404,37 @@ var CheckoutClient = class {
2928
3404
  assertPaymentReference(input.paymentReference);
2929
3405
  let current = checkpoint;
2930
3406
  if (input.amountMinor !== checkpoint.amountMinor) {
2931
- if (input.paymentReference) {
2932
- throw new ContoError("The Link card covers the approved total, not this one. Do not pay.", {
2933
- code: "LINK_CARD_TOTAL_CHANGED",
2934
- status: 0,
2935
- retryGuidance: "Call checkout.replaceLinkCard(result, { wallet, amountMinor }) for a card at the final total, then confirm the checkpoint it returns."
2936
- });
3407
+ if (input.paymentReference ?? checkpoint.paymentReference ?? (await this.get(checkpoint.id)).paymentReference) {
3408
+ let notRecorded = false;
3409
+ if (input.paymentReference && !checkpoint.paymentReference) {
3410
+ notRecorded = await __privateGet(this, _client4).request(
3411
+ "POST",
3412
+ `/api/sdk/checkout-checkpoints/${encodeURIComponent(checkpoint.id)}/payment-reference`,
3413
+ { paymentReference: { provider: "LINK", id: input.paymentReference.id } },
3414
+ { idempotent: true }
3415
+ ).then(
3416
+ () => false,
3417
+ async (error) => {
3418
+ if (!(error instanceof ContoError && error.status === 409)) return true;
3419
+ const latest = await this.get(checkpoint.id).catch(() => void 0);
3420
+ return latest?.paymentReference?.id !== input.paymentReference?.id;
3421
+ }
3422
+ );
3423
+ }
3424
+ const note = notRecorded ? " Conto could not record this Link request, so its hold may end when the approval lapses." : "";
3425
+ throw new ContoError(
3426
+ `The Link card covers the approved total, not this one. Do not pay.${note}`,
3427
+ {
3428
+ code: "LINK_CARD_TOTAL_CHANGED",
3429
+ status: 0,
3430
+ retryGuidance: "Call checkout.replaceLinkCard(result, { wallet, amountMinor }) for a card at the final total, then confirm the checkpoint it returns."
3431
+ }
3432
+ );
2937
3433
  }
2938
3434
  try {
2939
3435
  await this.cancel(checkpoint.id);
2940
3436
  } catch (error) {
2941
- const closed = error instanceof ContoError && error.status === 409 && ["CANCELLED", "EXPIRED", "DENIED"].includes((await this.get(checkpoint.id)).stage);
3437
+ const closed = error instanceof ContoError && error.status === 409 && CHECKPOINT_RELEASED_STAGES.includes((await this.get(checkpoint.id)).stage);
2942
3438
  if (!closed) throw error;
2943
3439
  }
2944
3440
  current = await this.begin({
@@ -3002,7 +3498,9 @@ var CheckoutClient = class {
3002
3498
  }
3003
3499
  /**
3004
3500
  * Cancel a checkpoint that was not confirmed and release its spend. A
3005
- * checkpoint waiting for the owner can be cancelled too.
3501
+ * checkpoint waiting for the owner can be cancelled too. This does not close
3502
+ * a Link card, which can still work with nothing held behind it, so cancel
3503
+ * the Link request first.
3006
3504
  */
3007
3505
  async cancel(checkpointId) {
3008
3506
  const result = await __privateGet(this, _client4).request(
@@ -3028,9 +3526,10 @@ var CheckoutClient = class {
3028
3526
  * 1. Conto runs checkpoint 1 and holds the spend. When a rule requires the
3029
3527
  * owner's approval, this waits for it.
3030
3528
  * 2. Only then does the wallet ask Link for a card for exactly the approved
3031
- * amount at this merchant. The person approves it in Link. Pass
3032
- * onLinkApproval to get Link's approval page, so they can approve on the
3033
- * web without the Link app.
3529
+ * amount at this merchant. Conto records the Link request at once, so the
3530
+ * hold stays while the card may be usable. The person approves it in
3531
+ * Link. Pass onLinkApproval to get Link's approval page, so they can
3532
+ * approve on the web without the Link app.
3034
3533
  * 3. The card is returned. It never reaches Conto.
3035
3534
  *
3036
3535
  * Then fill in the card, call confirm(result.checkpoint, { amountMinor,
@@ -3043,7 +3542,11 @@ var CheckoutClient = class {
3043
3542
  * held spend and throws a LinkCardError. When Conto cannot cancel an open Link
3044
3543
  * request, it keeps the hold and throws LINK_CARD_NOT_CANCELLED with the
3045
3544
  * request id. Retrying with the same input resumes the same checkpoint and
3046
- * Link request.
3545
+ * Link request while Conto's approval is open. If its approval ended with the
3546
+ * request open, a retry throws CHECKOUT_CHECKPOINT_NOT_APPROVED. Cancel the Link
3547
+ * request, then the checkpoint. Once a confirm has started, a retry throws
3548
+ * LINK_CARD_NOT_ISSUED. Do not cancel the Link request while a payment may be in
3549
+ * flight. Follow error.retryGuidance, which depends on the stage.
3047
3550
  */
3048
3551
  async getLinkCard(input) {
3049
3552
  const {
@@ -3060,10 +3563,7 @@ var CheckoutClient = class {
3060
3563
  assertLinkApprovalCallback(onLinkApproval);
3061
3564
  let checkpoint = await this.begin({ ...beginInput, checkoutMethod: "LINK" });
3062
3565
  if (checkpoint.stage === "AWAITING_APPROVAL") {
3063
- checkpoint = await this.waitForApproval(checkpoint, {
3064
- ...ownerApprovalTimeoutMs !== void 0 ? { timeoutMs: ownerApprovalTimeoutMs } : {},
3065
- ...signal ? { signal } : {}
3066
- });
3566
+ checkpoint = await __privateMethod(this, _CheckoutClient_instances, waitForLinkOwner_fn).call(this, checkpoint, ownerApprovalTimeoutMs, signal);
3067
3567
  }
3068
3568
  return __privateMethod(this, _CheckoutClient_instances, linkCardFor_fn).call(this, checkpoint, wallet, {
3069
3569
  linkApprovalTimeoutMs,
@@ -3075,7 +3575,11 @@ var CheckoutClient = class {
3075
3575
  * The final total differs from the one the Link card covers. This cancels
3076
3576
  * the old Link request and checkpoint, begins a checkpoint at the final
3077
3577
  * total (waiting for the owner when a rule requires it), and gets a new card
3078
- * for it. Confirm the returned checkpoint with the same total.
3578
+ * for it. Confirm the returned checkpoint with the same total. It reads the
3579
+ * checkpoint first. At a stage where a payment may be in flight, or with a
3580
+ * different Link request on record, this throws LINK_CARD_NOT_REPLACED and
3581
+ * leaves the card and the hold alone. After the old card is closed, trying
3582
+ * again with the same input resumes the replacement.
3079
3583
  */
3080
3584
  async replaceLinkCard(previous, input) {
3081
3585
  const {
@@ -3090,23 +3594,73 @@ var CheckoutClient = class {
3090
3594
  throw checkoutError("amountMinor must be a positive integer number of cents");
3091
3595
  }
3092
3596
  assertLinkApprovalCallback(onLinkApproval);
3093
- if (amountMinor === previous.checkpoint.amountMinor) return previous;
3094
3597
  const old = previous.checkpoint;
3095
- if (!await closeLinkRequest(wallet, previous.linkRequestId)) {
3598
+ checkpointCart(amountMinor, input.lineItems ?? old.cart.lineItems, input.totals);
3599
+ const current = await this.get(old.id);
3600
+ const released = CHECKPOINT_RELEASED_STAGES.includes(current.stage);
3601
+ if (!linkCardClosable(current)) {
3602
+ throw new LinkCardError({
3603
+ ...linkCardNotReplaced(current.stage),
3604
+ code: "LINK_CARD_NOT_REPLACED",
3605
+ checkpoint: current,
3606
+ linkRequestId: previous.linkRequestId,
3607
+ holdReleased: false
3608
+ });
3609
+ }
3610
+ const recordedRequestId = current.paymentReference?.id;
3611
+ if (recordedRequestId && recordedRequestId !== previous.linkRequestId) {
3612
+ throw new LinkCardError({
3613
+ message: released ? "A different Link request is on record for this checkpoint. The held spend is already released, but either Link card may still work. Do not pay with them." : "A different Link request is on record for this checkpoint, so the Link card cannot be replaced. Nothing changed. Do not pay.",
3614
+ code: "LINK_CARD_NOT_REPLACED",
3615
+ checkpoint: current,
3616
+ linkRequestId: recordedRequestId,
3617
+ holdReleased: released,
3618
+ retryGuidance: released ? `Cancel both ${recordedRequestId} and ${previous.linkRequestId} with wallet.cancel().` : `Use the result for that request, or cancel both ${recordedRequestId} and ${previous.linkRequestId} with wallet.cancel() and then call checkout.cancel(error.checkpoint.id).`
3619
+ });
3620
+ }
3621
+ if (amountMinor === old.amountMinor && !released && !current.linkRequestOpen && !CLOSED_LINK_RESULTS.has(previous)) {
3622
+ return previous;
3623
+ }
3624
+ if (signal?.aborted) {
3096
3625
  throw new LinkCardError({
3097
- message: "Conto could not cancel the earlier Link card. Nothing changed. Do not pay.",
3626
+ message: released ? "The caller stopped before the Link card was replaced. The held spend is already released, and the earlier Link card may still work. Do not pay with it." : "The caller stopped before the Link card was replaced. Nothing changed. Do not pay.",
3627
+ code: "LINK_CARD_ABORTED",
3628
+ checkpoint: current,
3629
+ linkRequestId: previous.linkRequestId,
3630
+ holdReleased: released,
3631
+ retryGuidance: released ? "Try replaceLinkCard again, or cancel the request with wallet.cancel(error.linkRequestId)." : "Try replaceLinkCard again, or cancel the request with wallet.cancel(error.linkRequestId) and then call checkout.cancel(error.checkpoint.id)."
3632
+ });
3633
+ }
3634
+ let refusal;
3635
+ const oldCardClosed = await closeLinkRequest(wallet, previous.linkRequestId, (error) => {
3636
+ refusal = error;
3637
+ });
3638
+ if (!oldCardClosed) {
3639
+ const walletDown = errorCode(refusal) === "LINK_WALLET_UNAVAILABLE";
3640
+ throw new LinkCardError({
3641
+ message: `${walletDown ? `${linkFailure(refusal).message} ` : ""}${released ? "The held spend is already released, but Conto could not cancel the earlier Link card, so it may still work. Do not pay with it." : "Conto could not cancel the earlier Link card. Nothing changed. Do not pay."}`,
3098
3642
  code: "LINK_CARD_NOT_CANCELLED",
3099
- checkpoint: old,
3643
+ checkpoint: current,
3100
3644
  linkRequestId: previous.linkRequestId,
3101
- holdReleased: false,
3102
- retryGuidance: "Try replaceLinkCard again."
3645
+ holdReleased: released,
3646
+ retryGuidance: `${walletDown ? "Fix the Link wallet first, as the message says. Until then a retry fails the same way. " : ""}${current.linkRequestOpen ? "Try replaceLinkCard again. If the card was already used, report PAID with checkout.reportOutcome(), or UNKNOWN if you cannot tell, which keeps the spend held until the owner records what happened." : released ? "Try replaceLinkCard again, or cancel the request with wallet.cancel(error.linkRequestId) and do not pay with it." : "Try replaceLinkCard again."}`
3103
3647
  });
3104
3648
  }
3649
+ CLOSED_LINK_RESULTS.add(previous);
3105
3650
  try {
3106
3651
  await this.cancel(old.id);
3107
3652
  } catch (error) {
3108
- const closed = error instanceof ContoError && error.status === 409 && ["CANCELLED", "EXPIRED", "DENIED"].includes((await this.get(old.id)).stage);
3109
- if (!closed) throw error;
3653
+ const latest = error instanceof ContoError && error.status === 409 ? await this.get(old.id).catch(() => void 0) : void 0;
3654
+ if (!released && (!latest || !CHECKPOINT_RELEASED_STAGES.includes(latest.stage))) {
3655
+ throw new LinkCardError({
3656
+ message: "The earlier Link request is cancelled, but Conto did not release the held spend. Do not confirm or pay.",
3657
+ code: "CHECKPOINT_NOT_CANCELLED",
3658
+ checkpoint: latest ?? current,
3659
+ linkRequestId: previous.linkRequestId,
3660
+ holdReleased: false,
3661
+ retryGuidance: "Try replaceLinkCard again. If the checkpoint was confirmed, report its outcome instead."
3662
+ });
3663
+ }
3110
3664
  }
3111
3665
  let checkpoint = await this.begin({
3112
3666
  idempotencyKey: `${old.idempotencyKey.slice(0, 200)}:final-${amountMinor}`,
@@ -3123,10 +3677,7 @@ var CheckoutClient = class {
3123
3677
  totals: input.totals
3124
3678
  });
3125
3679
  if (checkpoint.stage === "AWAITING_APPROVAL") {
3126
- checkpoint = await this.waitForApproval(checkpoint, {
3127
- ...ownerApprovalTimeoutMs !== void 0 ? { timeoutMs: ownerApprovalTimeoutMs } : {},
3128
- ...signal ? { signal } : {}
3129
- });
3680
+ checkpoint = await __privateMethod(this, _CheckoutClient_instances, waitForLinkOwner_fn).call(this, checkpoint, ownerApprovalTimeoutMs, signal);
3130
3681
  }
3131
3682
  return __privateMethod(this, _CheckoutClient_instances, linkCardFor_fn).call(this, checkpoint, wallet, {
3132
3683
  linkApprovalTimeoutMs,
@@ -3137,19 +3688,74 @@ var CheckoutClient = class {
3137
3688
  };
3138
3689
  _client4 = new WeakMap();
3139
3690
  _CheckoutClient_instances = new WeakSet();
3691
+ recordLinkRequest_fn = async function(checkpoint, linkRequestId) {
3692
+ try {
3693
+ const result = await __privateGet(this, _client4).request(
3694
+ "POST",
3695
+ `/api/sdk/checkout-checkpoints/${encodeURIComponent(checkpoint.id)}/payment-reference`,
3696
+ { paymentReference: { provider: "LINK", id: linkRequestId } },
3697
+ { idempotent: true }
3698
+ );
3699
+ return result.checkpoint;
3700
+ } catch (error) {
3701
+ if (!(error instanceof ContoError)) throw error;
3702
+ throw new LinkFailureSignal({
3703
+ kind: LinkCardError,
3704
+ code: "LINK_CARD_NOT_RECORDED",
3705
+ message: "Conto could not record the Link card request."
3706
+ });
3707
+ }
3708
+ };
3709
+ waitForLinkOwner_fn = async function(checkpoint, timeoutMs, signal) {
3710
+ try {
3711
+ return await this.waitForApproval(checkpoint, {
3712
+ ...timeoutMs !== void 0 ? { timeoutMs } : {},
3713
+ ...signal ? { signal } : {}
3714
+ });
3715
+ } catch (error) {
3716
+ if (!signal?.aborted || error instanceof ContoError && error !== signal.reason) throw error;
3717
+ const latest = await this.get(checkpoint.id).catch(() => void 0);
3718
+ const linkRequestId = latest?.paymentReference?.id;
3719
+ const released = Boolean(latest && CHECKPOINT_RELEASED_STAGES.includes(latest.stage));
3720
+ throw new LinkCardError({
3721
+ message: released ? "The caller stopped while waiting for the owner, and the held spend is already released. Do not pay." : "The caller stopped while waiting for the owner, and the held spend stays in place. Do not pay.",
3722
+ code: "LINK_CARD_ABORTED",
3723
+ checkpoint: latest ?? checkpoint,
3724
+ ...linkRequestId ? { linkRequestId } : {},
3725
+ holdReleased: released,
3726
+ ...released && !linkRequestId ? {} : {
3727
+ retryGuidance: latest && !linkRequestId && linkCardClosable(latest) ? "Retry with the same input, or call checkout.cancel(error.checkpoint.id) to release the held spend." : openLinkRequestGuidance(latest)
3728
+ }
3729
+ });
3730
+ }
3731
+ };
3140
3732
  linkCardFor_fn = async function(checkpoint, wallet, options) {
3141
3733
  const { linkApprovalTimeoutMs = 10 * 6e4, signal, onLinkApproval } = options;
3734
+ if (checkpoint.linkRequestOpen) {
3735
+ throw new LinkCardError({
3736
+ message: `Conto's approval for this checkpoint ended while its Link request may still work (stage ${checkpoint.stage}). Do not pay.`,
3737
+ code: "CHECKOUT_CHECKPOINT_NOT_APPROVED",
3738
+ checkpoint,
3739
+ ...checkpoint.paymentReference ? { linkRequestId: checkpoint.paymentReference.id } : {},
3740
+ holdReleased: false,
3741
+ retryGuidance: LINK_REQUEST_OPEN_GUIDANCE
3742
+ });
3743
+ }
3142
3744
  if (checkpoint.stage !== "APPROVED") {
3143
- throw checkoutError(
3144
- `Conto has not approved a card for this checkpoint (stage ${checkpoint.stage}). Do not pay.`,
3145
- "CHECKOUT_CHECKPOINT_NOT_APPROVED"
3146
- );
3745
+ throw new LinkCardError({
3746
+ ...linkCardNotIssued(checkpoint.stage),
3747
+ code: "LINK_CARD_NOT_ISSUED",
3748
+ checkpoint,
3749
+ holdReleased: false
3750
+ });
3147
3751
  }
3148
3752
  const deadline = Date.now() + linkApprovalTimeoutMs;
3149
3753
  const call = signal ? { signal } : {};
3150
- let linkRequestId;
3754
+ const recordedRequestId = checkpoint.paymentReference?.id;
3755
+ let linkRequestId = recordedRequestId;
3151
3756
  let approvalUrl;
3152
3757
  let createStarted = false;
3758
+ let createAnswered = false;
3153
3759
  try {
3154
3760
  const cardRequest = linkCardRequest(checkpoint);
3155
3761
  createStarted = true;
@@ -3162,6 +3768,7 @@ linkCardFor_fn = async function(checkpoint, wallet, options) {
3162
3768
  });
3163
3769
  }
3164
3770
  linkRequestId = request.id;
3771
+ createAnswered = true;
3165
3772
  if (request.amountMinor !== void 0 && request.amountMinor !== checkpoint.amountMinor) {
3166
3773
  throw new LinkFailureSignal({
3167
3774
  kind: LinkCardError,
@@ -3169,6 +3776,14 @@ linkCardFor_fn = async function(checkpoint, wallet, options) {
3169
3776
  message: "Link returned a request for a different amount."
3170
3777
  });
3171
3778
  }
3779
+ checkpoint = await __privateMethod(this, _CheckoutClient_instances, recordLinkRequest_fn).call(this, checkpoint, request.id);
3780
+ if (checkpoint.stage !== "APPROVED") {
3781
+ throw new LinkFailureSignal({
3782
+ kind: LinkCardError,
3783
+ code: "LINK_CARD_NOT_ISSUED",
3784
+ message: "Conto no longer has this checkpoint approved, so no Link card was returned."
3785
+ });
3786
+ }
3172
3787
  if (LINK_WAITING_STATUSES.has(request.status)) {
3173
3788
  const details = linkApprovalDetails(request);
3174
3789
  approvalUrl = details.approvalUrl;
@@ -3207,42 +3822,80 @@ linkCardFor_fn = async function(checkpoint, wallet, options) {
3207
3822
  };
3208
3823
  } catch (error) {
3209
3824
  const failure = error instanceof LinkFailureSignal ? error.failure : linkFailure(error, signal);
3210
- if (!linkRequestId && createStarted && !linkCreatedNothing(error)) {
3825
+ const latest = createStarted || linkRequestId || !linkCardClosable(checkpoint) ? await this.get(checkpoint.id).catch(() => void 0) : checkpoint;
3826
+ const onRecordId = latest?.paymentReference?.id ?? recordedRequestId;
3827
+ const anotherFlow = !recordedRequestId && onRecordId && onRecordId !== linkRequestId;
3828
+ const alreadyReleased = Boolean(latest && CHECKPOINT_RELEASED_STAGES.includes(latest.stage));
3829
+ const unread = recordedRequestId || linkRequestId && !failure.linkClosed;
3830
+ if (latest ? !linkCardClosable(latest) || anotherFlow : unread) {
3831
+ const extraRequestId = onRecordId && linkRequestId !== onRecordId ? linkRequestId : void 0;
3832
+ const extraOpen = extraRequestId && !failure.linkClosed && !await closeLinkRequest(wallet, extraRequestId);
3833
+ const keptId = onRecordId ?? linkRequestId;
3211
3834
  throw new LinkCardError({
3212
- message: `${failure.message} Conto could not tell whether Link created the card request, so the held spend stays in place. Do not pay.`,
3835
+ message: alreadyReleased ? `${failure.message} A different Link request is on record, so Conto did not cancel it. The held spend is already released, but its card may still work. Do not pay with it.` : `${failure.message} The checkpoint changed or could not be read, so Conto did not cancel the Link request, and the held spend stays in place${latest || onRecordId || checkpoint.paymentReference ? "" : ", and may be released when the approval lapses"}. Do not pay.`,
3213
3836
  code: "LINK_CARD_NOT_CANCELLED",
3214
- checkpoint,
3215
- holdReleased: false,
3216
- retryGuidance: "Retry with the same input to resume the Link request, or call checkout.cancel(error.checkpoint.id) once you know Link created none."
3837
+ checkpoint: latest ?? checkpoint,
3838
+ ...keptId ? { linkRequestId: keptId } : {},
3839
+ holdReleased: alreadyReleased,
3840
+ retryGuidance: `${extraOpen ? `Cancel ${extraRequestId} with wallet.cancel() first, because it is not the request on record. ` : ""}${openLinkRequestGuidance(latest)}`
3217
3841
  });
3218
3842
  }
3219
- if (linkRequestId && !failure.linkClosed && !await closeLinkRequest(wallet, linkRequestId)) {
3843
+ if (createStarted && !createAnswered && !linkCreatedNothing(error)) {
3844
+ const blockedBy = !recordedRequestId && !alreadyReleased && failure.retryGuidance;
3220
3845
  throw new LinkCardError({
3221
- message: `${failure.message} Conto could not cancel the Link request, so the held spend stays in place. Do not pay.`,
3846
+ message: alreadyReleased ? `${failure.message} Conto could not tell whether Link created the card request. The held spend is already released, but ${recordedRequestId ? "the Link request on record" : "a request Link created"} may still work. Do not pay with it.` : blockedBy ? `${failure.message} The held spend stays in place, and may be released when the approval lapses. Do not pay.` : `${failure.message} Conto could not tell whether Link created the card request, so the held spend stays in place${recordedRequestId ? "" : ", and may be released when the approval lapses"}. Do not pay.`,
3222
3847
  code: "LINK_CARD_NOT_CANCELLED",
3223
- checkpoint,
3224
- linkRequestId,
3225
- ...approvalUrl ? { approvalUrl } : {},
3226
- holdReleased: false,
3227
- 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)."
3848
+ checkpoint: latest ?? checkpoint,
3849
+ ...recordedRequestId ? { linkRequestId: recordedRequestId } : {},
3850
+ holdReleased: alreadyReleased,
3851
+ retryGuidance: recordedRequestId ? openLinkRequestGuidance(latest) : alreadyReleased ? "The checkpoint has closed, so a retry cannot resume a Link request. Begin a new purchase with a new idempotency key if the buyer still wants it. If Link then reports a duplicate, follow that error." : blockedBy || "Retry with the same input to resume the Link request, or call checkout.cancel(error.checkpoint.id) once you know Link created none."
3228
3852
  });
3229
3853
  }
3230
- const holdReleased = await this.cancel(checkpoint.id).then(
3231
- () => true,
3854
+ const requestsToClose = /* @__PURE__ */ new Set();
3855
+ if (linkRequestId && !failure.linkClosed) requestsToClose.add(linkRequestId);
3856
+ if (recordedRequestId && recordedRequestId !== linkRequestId) {
3857
+ requestsToClose.add(recordedRequestId);
3858
+ }
3859
+ const stillOpen = [];
3860
+ for (const id2 of requestsToClose) {
3861
+ if (!await closeLinkRequest(wallet, id2)) stillOpen.push(id2);
3862
+ }
3863
+ if (stillOpen.length > 0) {
3864
+ const held = onRecordId ? "" : ", and may be released when the approval lapses";
3865
+ const bothOpen = stillOpen.length > 1 ? ` Link requests ${stillOpen.join(" and ")} are both open.` : "";
3866
+ throw new LinkCardError({
3867
+ message: alreadyReleased ? `${failure.message} The held spend is already released, but Conto could not cancel the Link request, so its card may still work.${bothOpen} Do not pay with it.` : `${failure.message} Conto could not cancel the Link request, so the held spend stays in place${held}.${bothOpen} Do not pay.`,
3868
+ code: "LINK_CARD_NOT_CANCELLED",
3869
+ checkpoint: latest ?? checkpoint,
3870
+ // The one on record first, because a card may already be out on it.
3871
+ linkRequestId: onRecordId && stillOpen.includes(onRecordId) ? onRecordId : stillOpen[0],
3872
+ ...approvalUrl && latest?.stage === "APPROVED" ? { approvalUrl } : {},
3873
+ holdReleased: alreadyReleased,
3874
+ retryGuidance: stillOpen.length > 1 ? `Cancel ${stillOpen.join(" and ")} with wallet.cancel()${alreadyReleased ? "" : ", then call checkout.cancel(error.checkpoint.id)"}.` : failure.code === "LINK_CARD_ACTION_REQUIRED" && latest?.stage === "APPROVED" ? LINK_ACTION_REQUIRED_GUIDANCE : openLinkRequestGuidance(latest)
3875
+ });
3876
+ }
3877
+ let cancelled;
3878
+ const holdReleased = alreadyReleased || await this.cancel(checkpoint.id).then(
3879
+ (result) => {
3880
+ cancelled = result;
3881
+ return true;
3882
+ },
3232
3883
  () => false
3233
3884
  );
3234
3885
  throw new failure.kind({
3235
- message: `${failure.message} ${holdReleased ? "Conto cancelled the checkpoint and released the held spend." : "Conto could not cancel the checkpoint yet."} Do not pay.`,
3886
+ message: `${failure.message} ${alreadyReleased ? "The held spend was already released." : holdReleased ? "Conto cancelled the checkpoint and released the held spend." : "Conto could not cancel the checkpoint yet."} Do not pay.`,
3236
3887
  code: failure.code,
3237
- checkpoint,
3888
+ checkpoint: cancelled ?? latest ?? checkpoint,
3238
3889
  ...linkRequestId ? { linkRequestId } : {},
3239
3890
  ...linkRequestId && approvalUrl ? { approvalUrl } : {},
3240
- holdReleased
3891
+ holdReleased,
3892
+ ...holdReleased && failure.retryGuidance ? { retryGuidance: failure.retryGuidance } : {}
3241
3893
  });
3242
3894
  }
3243
3895
  };
3244
3896
  export {
3245
3897
  AdminPurchasingClient,
3898
+ AdminSpendingClient,
3246
3899
  BrowserActionFinalizationPendingError,
3247
3900
  BrowserCheckoutTargetVerificationError,
3248
3901
  CardsClient,
@@ -3269,6 +3922,7 @@ export {
3269
3922
  OrganizationRecurringPurchasesClient,
3270
3923
  PurchasesClient,
3271
3924
  RecurringPurchasesClient,
3925
+ SpendingClient,
3272
3926
  TestCardsClient,
3273
3927
  canonicalizeBrowserCheckoutTarget,
3274
3928
  createVerifiedBrowserCheckoutDriver,