@conto_finance/sdk 0.8.0 → 0.8.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.js CHANGED
@@ -47,7 +47,7 @@ var require_package = __commonJS({
47
47
  "package.json"(exports2, module2) {
48
48
  module2.exports = {
49
49
  name: "@conto_finance/sdk",
50
- version: "0.8.0",
50
+ version: "0.8.2",
51
51
  description: "Conto SDK for AI agent payment authorization",
52
52
  main: "./dist/index.js",
53
53
  module: "./dist/index.mjs",
@@ -804,16 +804,16 @@ var AdminSpendingClient = class {
804
804
  /**
805
805
  * Create a shared budget. Needs an organization API key with `policies:write`.
806
806
  *
807
+ * Checkout checkpoints, including `checkout.getLinkCard()`, and guarded browser checkout
808
+ * (`runGuardedCheckout()`) hold spend against each budget that covers the agent.
809
+ *
807
810
  * While a budget exists, it stops x402, MPP, external wallet payments, agent-to-agent payments,
808
- * payment grants, checkout relay, hosted Stripe Link, guarded browser checkout
809
- * (`runGuardedCheckout()`), card overlays and checkout checkpoints, including
810
- * `checkout.getLinkCard()`, for every agent it covers. A paused, expired or not yet started budget
811
- * still stops them. Until its start date, while paused, and after its end date, it also refuses
812
- * the managed wallet payments and connected card draws it covers, for those agents. A start date
813
- * cannot be changed.
814
- * `deleteBudget()` ends a budget. Fails with `reasonCode`
815
- * `ENROLLMENT_RECONCILIATION_REQUIRED` while the organization or a participating agent has an
816
- * open commitment.
811
+ * payment grants, checkout relay, hosted Stripe Link and card overlays, for every agent it covers.
812
+ * A paused or expired budget still stops them. While paused and after its end date, it also
813
+ * refuses the managed wallet payments, connected card draws and checkouts it covers, for those
814
+ * agents. A start date cannot be changed.
815
+ * `deleteBudget()` ends a budget. Fails with `reasonCode` `BUDGET_HAS_HOLDS` while a purchase holds
816
+ * spend against it.
817
817
  */
818
818
  createBudget(input) {
819
819
  return this.client.request("POST", "/api/spending/budgets", {
@@ -1110,12 +1110,18 @@ function responseErrorMessage(_data, status) {
1110
1110
  }
1111
1111
  var SHARED_REASON_CODES = /* @__PURE__ */ new Set([
1112
1112
  "AGENT_NOT_AUTHORIZED",
1113
+ "AGENT_LIMIT_EXCEEDED",
1113
1114
  "AGENT_UNAVAILABLE",
1115
+ "BUDGET_CHANGE_NOT_ALLOWED",
1114
1116
  "BUDGET_CURRENCY_UNSUPPORTED",
1115
1117
  "BUDGET_EXPIRED",
1116
1118
  "BUDGET_HAS_HOLDS",
1119
+ "BUDGET_METHOD_NOT_ALLOWED",
1117
1120
  "BUDGET_PAUSED",
1121
+ "BUDGET_SOURCE_NOT_ATTACHED",
1118
1122
  "BUYER_LINK_REVOKED",
1123
+ "CATEGORY_NOT_AUTHORIZED",
1124
+ "CATEGORY_UNKNOWN",
1119
1125
  "DUPLICATE_PURCHASE",
1120
1126
  "ENROLLMENT_RECONCILIATION_REQUIRED",
1121
1127
  "EVIDENCE_CONFLICT",
@@ -1124,6 +1130,7 @@ var SHARED_REASON_CODES = /* @__PURE__ */ new Set([
1124
1130
  "EXECUTION_PATH_NOT_CONNECTED",
1125
1131
  "EXPORT_TOO_LARGE",
1126
1132
  "INVALID_AMOUNT",
1133
+ "INVALID_BUDGET",
1127
1134
  "INVALID_EXPIRY",
1128
1135
  "INVALID_FILE",
1129
1136
  "INVALID_PARTICIPANTS",
@@ -1132,6 +1139,7 @@ var SHARED_REASON_CODES = /* @__PURE__ */ new Set([
1132
1139
  "LEGACY_BUDGET_REQUEST",
1133
1140
  "LINKED_BUDGET_REQUIRED",
1134
1141
  "LINKED_TASK_UNSUPPORTED",
1142
+ "MEMBER_LIMIT_EXCEEDED",
1135
1143
  "MERCHANT_NOT_AUTHORIZED",
1136
1144
  "NOT_FOUND",
1137
1145
  "PURCHASE_ALREADY_SUBMITTED",
@@ -1984,7 +1992,13 @@ var BrowserActionFinalizationPendingError = class extends ContoError {
1984
1992
  };
1985
1993
  var CheckoutCheckpointClosedError = class extends ContoError {
1986
1994
  constructor(checkpoint, message = `The checkpoint is ${checkpoint.stage.toLowerCase().replace(/_/g, " ")}. Stop this checkout.`, code = "CHECKOUT_CHECKPOINT_CLOSED") {
1987
- super(message, { code, status: 0 });
1995
+ super(message, {
1996
+ code,
1997
+ status: 0,
1998
+ ...checkpoint.paymentReference?.provider === "LINK" ? {
1999
+ 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."
2000
+ } : {}
2001
+ });
1988
2002
  this.name = "CheckoutCheckpointClosedError";
1989
2003
  this.checkpoint = checkpoint;
1990
2004
  this.stage = checkpoint.stage;
@@ -3196,6 +3210,12 @@ function checkpointCart(amountMinor, lineItems, totals) {
3196
3210
  if (!Array.isArray(lineItems) || lineItems.length === 0) {
3197
3211
  throw checkoutError("Supply at least one line item");
3198
3212
  }
3213
+ if (totals !== void 0 && totals !== null) {
3214
+ const finalTotal = totals.filter((total) => total.type === "total");
3215
+ if (finalTotal.length !== 1 || finalTotal[0].amountMinor !== amountMinor) {
3216
+ throw checkoutError("totals must have one row with type total that equals amountMinor");
3217
+ }
3218
+ }
3199
3219
  return {
3200
3220
  lineItems,
3201
3221
  totals: totals ?? [{ type: "total", label: "Total", amountMinor }]
@@ -3235,6 +3255,13 @@ var LINK_REQUEST_ID = /^lsrq_[A-Za-z0-9]{1,64}$/;
3235
3255
  var LINK_WAITING_STATUSES = /* @__PURE__ */ new Set(["created", "pending_approval"]);
3236
3256
  var LINK_APPROVED_STATUSES = /* @__PURE__ */ new Set(["approved", "submitted", "succeeded"]);
3237
3257
  var LINK_CLOSED_STATUSES = /* @__PURE__ */ new Set(["denied", "expired", "canceled", "failed"]);
3258
+ var CLOSED_LINK_RESULTS = /* @__PURE__ */ new WeakSet();
3259
+ var CHECKPOINT_RELEASED_STAGES = [
3260
+ "CANCELLED",
3261
+ "EXPIRED",
3262
+ "DENIED",
3263
+ "NOT_PAID"
3264
+ ];
3238
3265
  var INSPECT = /* @__PURE__ */ Symbol.for("nodejs.util.inspect.custom");
3239
3266
  function assertPaymentReference(reference) {
3240
3267
  if (reference === void 0) return;
@@ -3316,6 +3343,14 @@ function errorCode(error) {
3316
3343
  function errorReason(error) {
3317
3344
  return error && typeof error === "object" && typeof error.reason === "string" ? error.reason : void 0;
3318
3345
  }
3346
+ function duplicateGuidance(requestId, owner) {
3347
+ const wait2 = `This purchase cannot start until Link request ${requestId} closes, so do not retry yet.`;
3348
+ const then = "Once it closes, begin this purchase with a new idempotency key if the buyer still wants it.";
3349
+ if (typeof owner !== "string" || !/^[A-Za-z0-9_-]{1,64}$/.test(owner)) {
3350
+ 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}`;
3351
+ }
3352
+ 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}`;
3353
+ }
3319
3354
  function linkFailure(error, signal) {
3320
3355
  if (error instanceof LinkCardError) {
3321
3356
  return {
@@ -3331,6 +3366,24 @@ function linkFailure(error, signal) {
3331
3366
  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".'
3332
3367
  };
3333
3368
  }
3369
+ const duplicate = error;
3370
+ const duplicateOf = String(duplicate?.duplicateOf);
3371
+ if (errorReason(error) === "invalid_input" && LINK_REQUEST_ID.test(duplicateOf)) {
3372
+ return {
3373
+ kind: LinkCardError,
3374
+ code: "LINK_CARD_DUPLICATE_REQUEST",
3375
+ message: "Link has an open request for another purchase with the same details.",
3376
+ retryGuidance: duplicateGuidance(duplicateOf, duplicate?.duplicateCheckpoint)
3377
+ };
3378
+ }
3379
+ if (errorReason(error) === "cli_failed" && LINK_REQUEST_ID.test(duplicateOf)) {
3380
+ return {
3381
+ kind: LinkCardError,
3382
+ code: "LINK_CARD_FAILED",
3383
+ message: "Link has an open request with the same details and no checkpoint on it, so a retry cannot resume it.",
3384
+ 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).`
3385
+ };
3386
+ }
3334
3387
  if (signal?.aborted || errorReason(error) === "aborted") {
3335
3388
  return {
3336
3389
  kind: LinkCardError,
@@ -3371,7 +3424,7 @@ function closedLinkFailure(request) {
3371
3424
  return {
3372
3425
  kind: LinkCardError,
3373
3426
  code: "LINK_CARD_ACTION_REQUIRED",
3374
- message: "Link needs the person to finish a step in Link before it can issue a card."
3427
+ message: "Link cannot continue this request. The person has to fix a problem in Link before a new request can issue a card."
3375
3428
  };
3376
3429
  default:
3377
3430
  return {
@@ -3382,13 +3435,15 @@ function closedLinkFailure(request) {
3382
3435
  };
3383
3436
  }
3384
3437
  }
3385
- async function closeLinkRequest(wallet, id2) {
3438
+ async function closeLinkRequest(wallet, id2, onRefused) {
3386
3439
  try {
3387
3440
  await wallet.cancel(id2);
3388
3441
  return true;
3389
- } catch {
3390
- const status = await wallet.waitForApproval(id2, { timeoutSeconds: 0 }).then((request) => request.status).catch(() => void 0);
3391
- return status !== void 0 && LINK_CLOSED_STATUSES.has(status);
3442
+ } catch (error) {
3443
+ const status = await Promise.resolve().then(() => wallet.waitForApproval(id2, { timeoutSeconds: 0 })).then((request) => request.status).catch(() => void 0);
3444
+ if (status !== void 0 && LINK_CLOSED_STATUSES.has(status)) return true;
3445
+ onRefused?.(error);
3446
+ return false;
3392
3447
  }
3393
3448
  }
3394
3449
  function assertLinkApprovalCallback(callback) {
@@ -3396,6 +3451,65 @@ function assertLinkApprovalCallback(callback) {
3396
3451
  throw checkoutError("onLinkApproval must be a function");
3397
3452
  }
3398
3453
  }
3454
+ function linkCardClosable(checkpoint) {
3455
+ return CHECKPOINT_RELEASED_STAGES.includes(checkpoint.stage) || ["AWAITING_APPROVAL", "APPROVED"].includes(checkpoint.stage) || checkpoint.linkRequestOpen === true;
3456
+ }
3457
+ 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.";
3458
+ var PAID_GUIDANCE = "Do not buy it again.";
3459
+ 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.";
3460
+ 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.";
3461
+ 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.";
3462
+ 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}`;
3463
+ 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}`;
3464
+ function linkCardNotReplaced(stage) {
3465
+ if (stage === "CONFIRMING") {
3466
+ return {
3467
+ message: "Conto is still checking this checkpoint, so the Link card cannot be replaced yet. Nothing changed. Do not pay this total.",
3468
+ retryGuidance: CONFIRMING_GUIDANCE
3469
+ };
3470
+ }
3471
+ if (stage === "PAID") {
3472
+ return {
3473
+ message: "This purchase is already recorded as paid, so the Link card cannot be replaced. Nothing changed. Do not pay again.",
3474
+ retryGuidance: PAID_GUIDANCE
3475
+ };
3476
+ }
3477
+ return {
3478
+ 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.",
3479
+ retryGuidance: CONFIRMED_GUIDANCE
3480
+ };
3481
+ }
3482
+ function linkCardNotIssued(stage) {
3483
+ if (stage === "CONFIRMING") {
3484
+ return {
3485
+ 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.",
3486
+ retryGuidance: CONFIRMING_GUIDANCE
3487
+ };
3488
+ }
3489
+ if (stage === "PAID") {
3490
+ return {
3491
+ message: "This purchase is already recorded as paid, so Link was not asked for a card. Do not pay again.",
3492
+ retryGuidance: PAID_GUIDANCE
3493
+ };
3494
+ }
3495
+ return {
3496
+ 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.",
3497
+ retryGuidance: CONFIRMED_GUIDANCE
3498
+ };
3499
+ }
3500
+ function openLinkRequestGuidance(latest) {
3501
+ if (!latest) {
3502
+ 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.";
3503
+ }
3504
+ if (CHECKPOINT_RELEASED_STAGES.includes(latest.stage)) {
3505
+ 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.";
3506
+ }
3507
+ if (latest.linkRequestOpen) return LINK_REQUEST_OPEN_GUIDANCE;
3508
+ if (linkCardClosable(latest)) {
3509
+ 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}`;
3510
+ }
3511
+ return linkCardNotIssued(latest.stage).retryGuidance;
3512
+ }
3399
3513
  function linkCreatedNothing(error) {
3400
3514
  return errorCode(error) === "LINK_WALLET_UNAVAILABLE" || errorReason(error) === "invalid_input";
3401
3515
  }
@@ -3422,7 +3536,7 @@ function notifyLinkApproval(callback, event) {
3422
3536
  } catch {
3423
3537
  }
3424
3538
  }
3425
- var _client4, _CheckoutClient_instances, recordLinkRequest_fn, linkCardFor_fn;
3539
+ var _client4, _CheckoutClient_instances, recordLinkRequest_fn, waitForLinkOwner_fn, linkCardFor_fn;
3426
3540
  var CheckoutClient = class {
3427
3541
  constructor(client) {
3428
3542
  __privateAdd(this, _CheckoutClient_instances);
@@ -3507,17 +3621,37 @@ var CheckoutClient = class {
3507
3621
  assertPaymentReference(input.paymentReference);
3508
3622
  let current = checkpoint;
3509
3623
  if (input.amountMinor !== checkpoint.amountMinor) {
3510
- if (input.paymentReference ?? checkpoint.paymentReference) {
3511
- throw new ContoError("The Link card covers the approved total, not this one. Do not pay.", {
3512
- code: "LINK_CARD_TOTAL_CHANGED",
3513
- status: 0,
3514
- retryGuidance: "Call checkout.replaceLinkCard(result, { wallet, amountMinor }) for a card at the final total, then confirm the checkpoint it returns."
3515
- });
3624
+ if (input.paymentReference ?? checkpoint.paymentReference ?? (await this.get(checkpoint.id)).paymentReference) {
3625
+ let notRecorded = false;
3626
+ if (input.paymentReference && !checkpoint.paymentReference) {
3627
+ notRecorded = await __privateGet(this, _client4).request(
3628
+ "POST",
3629
+ `/api/sdk/checkout-checkpoints/${encodeURIComponent(checkpoint.id)}/payment-reference`,
3630
+ { paymentReference: { provider: "LINK", id: input.paymentReference.id } },
3631
+ { idempotent: true }
3632
+ ).then(
3633
+ () => false,
3634
+ async (error) => {
3635
+ if (!(error instanceof ContoError && error.status === 409)) return true;
3636
+ const latest = await this.get(checkpoint.id).catch(() => void 0);
3637
+ return latest?.paymentReference?.id !== input.paymentReference?.id;
3638
+ }
3639
+ );
3640
+ }
3641
+ const note = notRecorded ? " Conto could not record this Link request, so its hold may end when the approval lapses." : "";
3642
+ throw new ContoError(
3643
+ `The Link card covers the approved total, not this one. Do not pay.${note}`,
3644
+ {
3645
+ code: "LINK_CARD_TOTAL_CHANGED",
3646
+ status: 0,
3647
+ retryGuidance: "Call checkout.replaceLinkCard(result, { wallet, amountMinor }) for a card at the final total, then confirm the checkpoint it returns."
3648
+ }
3649
+ );
3516
3650
  }
3517
3651
  try {
3518
3652
  await this.cancel(checkpoint.id);
3519
3653
  } catch (error) {
3520
- const closed = error instanceof ContoError && error.status === 409 && ["CANCELLED", "EXPIRED", "DENIED"].includes((await this.get(checkpoint.id)).stage);
3654
+ const closed = error instanceof ContoError && error.status === 409 && CHECKPOINT_RELEASED_STAGES.includes((await this.get(checkpoint.id)).stage);
3521
3655
  if (!closed) throw error;
3522
3656
  }
3523
3657
  current = await this.begin({
@@ -3581,7 +3715,9 @@ var CheckoutClient = class {
3581
3715
  }
3582
3716
  /**
3583
3717
  * Cancel a checkpoint that was not confirmed and release its spend. A
3584
- * checkpoint waiting for the owner can be cancelled too.
3718
+ * checkpoint waiting for the owner can be cancelled too. This does not close
3719
+ * a Link card, which can still work with nothing held behind it, so cancel
3720
+ * the Link request first.
3585
3721
  */
3586
3722
  async cancel(checkpointId) {
3587
3723
  const result = await __privateGet(this, _client4).request(
@@ -3623,9 +3759,11 @@ var CheckoutClient = class {
3623
3759
  * held spend and throws a LinkCardError. When Conto cannot cancel an open Link
3624
3760
  * request, it keeps the hold and throws LINK_CARD_NOT_CANCELLED with the
3625
3761
  * request id. Retrying with the same input resumes the same checkpoint and
3626
- * Link request while Conto's approval is open. Once the stage is OUTCOME_UNKNOWN,
3627
- * a retry throws CHECKOUT_CHECKPOINT_NOT_APPROVED: cancel the Link request, then
3628
- * the checkpoint.
3762
+ * Link request while Conto's approval is open. If its approval ended with the
3763
+ * request open, a retry throws CHECKOUT_CHECKPOINT_NOT_APPROVED. Cancel the Link
3764
+ * request, then the checkpoint. Once a confirm has started, a retry throws
3765
+ * LINK_CARD_NOT_ISSUED. Do not cancel the Link request while a payment may be in
3766
+ * flight. Follow error.retryGuidance, which depends on the stage.
3629
3767
  */
3630
3768
  async getLinkCard(input) {
3631
3769
  const {
@@ -3642,10 +3780,7 @@ var CheckoutClient = class {
3642
3780
  assertLinkApprovalCallback(onLinkApproval);
3643
3781
  let checkpoint = await this.begin({ ...beginInput, checkoutMethod: "LINK" });
3644
3782
  if (checkpoint.stage === "AWAITING_APPROVAL") {
3645
- checkpoint = await this.waitForApproval(checkpoint, {
3646
- ...ownerApprovalTimeoutMs !== void 0 ? { timeoutMs: ownerApprovalTimeoutMs } : {},
3647
- ...signal ? { signal } : {}
3648
- });
3783
+ checkpoint = await __privateMethod(this, _CheckoutClient_instances, waitForLinkOwner_fn).call(this, checkpoint, ownerApprovalTimeoutMs, signal);
3649
3784
  }
3650
3785
  return __privateMethod(this, _CheckoutClient_instances, linkCardFor_fn).call(this, checkpoint, wallet, {
3651
3786
  linkApprovalTimeoutMs,
@@ -3657,7 +3792,11 @@ var CheckoutClient = class {
3657
3792
  * The final total differs from the one the Link card covers. This cancels
3658
3793
  * the old Link request and checkpoint, begins a checkpoint at the final
3659
3794
  * total (waiting for the owner when a rule requires it), and gets a new card
3660
- * for it. Confirm the returned checkpoint with the same total.
3795
+ * for it. Confirm the returned checkpoint with the same total. It reads the
3796
+ * checkpoint first. At a stage where a payment may be in flight, or with a
3797
+ * different Link request on record, this throws LINK_CARD_NOT_REPLACED and
3798
+ * leaves the card and the hold alone. After the old card is closed, trying
3799
+ * again with the same input resumes the replacement.
3661
3800
  */
3662
3801
  async replaceLinkCard(previous, input) {
3663
3802
  const {
@@ -3672,23 +3811,73 @@ var CheckoutClient = class {
3672
3811
  throw checkoutError("amountMinor must be a positive integer number of cents");
3673
3812
  }
3674
3813
  assertLinkApprovalCallback(onLinkApproval);
3675
- if (amountMinor === previous.checkpoint.amountMinor) return previous;
3676
3814
  const old = previous.checkpoint;
3677
- if (!await closeLinkRequest(wallet, previous.linkRequestId)) {
3815
+ checkpointCart(amountMinor, input.lineItems ?? old.cart.lineItems, input.totals);
3816
+ const current = await this.get(old.id);
3817
+ const released = CHECKPOINT_RELEASED_STAGES.includes(current.stage);
3818
+ if (!linkCardClosable(current)) {
3819
+ throw new LinkCardError({
3820
+ ...linkCardNotReplaced(current.stage),
3821
+ code: "LINK_CARD_NOT_REPLACED",
3822
+ checkpoint: current,
3823
+ linkRequestId: previous.linkRequestId,
3824
+ holdReleased: false
3825
+ });
3826
+ }
3827
+ const recordedRequestId = current.paymentReference?.id;
3828
+ if (recordedRequestId && recordedRequestId !== previous.linkRequestId) {
3829
+ throw new LinkCardError({
3830
+ 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.",
3831
+ code: "LINK_CARD_NOT_REPLACED",
3832
+ checkpoint: current,
3833
+ linkRequestId: recordedRequestId,
3834
+ holdReleased: released,
3835
+ 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).`
3836
+ });
3837
+ }
3838
+ if (amountMinor === old.amountMinor && !released && !current.linkRequestOpen && !CLOSED_LINK_RESULTS.has(previous)) {
3839
+ return previous;
3840
+ }
3841
+ if (signal?.aborted) {
3678
3842
  throw new LinkCardError({
3679
- message: "Conto could not cancel the earlier Link card. Nothing changed. Do not pay.",
3843
+ 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.",
3844
+ code: "LINK_CARD_ABORTED",
3845
+ checkpoint: current,
3846
+ linkRequestId: previous.linkRequestId,
3847
+ holdReleased: released,
3848
+ 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)."
3849
+ });
3850
+ }
3851
+ let refusal;
3852
+ const oldCardClosed = await closeLinkRequest(wallet, previous.linkRequestId, (error) => {
3853
+ refusal = error;
3854
+ });
3855
+ if (!oldCardClosed) {
3856
+ const walletDown = errorCode(refusal) === "LINK_WALLET_UNAVAILABLE";
3857
+ throw new LinkCardError({
3858
+ 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."}`,
3680
3859
  code: "LINK_CARD_NOT_CANCELLED",
3681
- checkpoint: old,
3860
+ checkpoint: current,
3682
3861
  linkRequestId: previous.linkRequestId,
3683
- holdReleased: false,
3684
- retryGuidance: "Try replaceLinkCard again."
3862
+ holdReleased: released,
3863
+ 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."}`
3685
3864
  });
3686
3865
  }
3866
+ CLOSED_LINK_RESULTS.add(previous);
3687
3867
  try {
3688
3868
  await this.cancel(old.id);
3689
3869
  } catch (error) {
3690
- const closed = error instanceof ContoError && error.status === 409 && ["CANCELLED", "EXPIRED", "DENIED"].includes((await this.get(old.id)).stage);
3691
- if (!closed) throw error;
3870
+ const latest = error instanceof ContoError && error.status === 409 ? await this.get(old.id).catch(() => void 0) : void 0;
3871
+ if (!released && (!latest || !CHECKPOINT_RELEASED_STAGES.includes(latest.stage))) {
3872
+ throw new LinkCardError({
3873
+ message: "The earlier Link request is cancelled, but Conto did not release the held spend. Do not confirm or pay.",
3874
+ code: "CHECKPOINT_NOT_CANCELLED",
3875
+ checkpoint: latest ?? current,
3876
+ linkRequestId: previous.linkRequestId,
3877
+ holdReleased: false,
3878
+ retryGuidance: "Try replaceLinkCard again. If the checkpoint was confirmed, report its outcome instead."
3879
+ });
3880
+ }
3692
3881
  }
3693
3882
  let checkpoint = await this.begin({
3694
3883
  idempotencyKey: `${old.idempotencyKey.slice(0, 200)}:final-${amountMinor}`,
@@ -3705,10 +3894,7 @@ var CheckoutClient = class {
3705
3894
  totals: input.totals
3706
3895
  });
3707
3896
  if (checkpoint.stage === "AWAITING_APPROVAL") {
3708
- checkpoint = await this.waitForApproval(checkpoint, {
3709
- ...ownerApprovalTimeoutMs !== void 0 ? { timeoutMs: ownerApprovalTimeoutMs } : {},
3710
- ...signal ? { signal } : {}
3711
- });
3897
+ checkpoint = await __privateMethod(this, _CheckoutClient_instances, waitForLinkOwner_fn).call(this, checkpoint, ownerApprovalTimeoutMs, signal);
3712
3898
  }
3713
3899
  return __privateMethod(this, _CheckoutClient_instances, linkCardFor_fn).call(this, checkpoint, wallet, {
3714
3900
  linkApprovalTimeoutMs,
@@ -3737,19 +3923,56 @@ recordLinkRequest_fn = async function(checkpoint, linkRequestId) {
3737
3923
  });
3738
3924
  }
3739
3925
  };
3926
+ waitForLinkOwner_fn = async function(checkpoint, timeoutMs, signal) {
3927
+ try {
3928
+ return await this.waitForApproval(checkpoint, {
3929
+ ...timeoutMs !== void 0 ? { timeoutMs } : {},
3930
+ ...signal ? { signal } : {}
3931
+ });
3932
+ } catch (error) {
3933
+ if (!signal?.aborted || error instanceof ContoError && error !== signal.reason) throw error;
3934
+ const latest = await this.get(checkpoint.id).catch(() => void 0);
3935
+ const linkRequestId = latest?.paymentReference?.id;
3936
+ const released = Boolean(latest && CHECKPOINT_RELEASED_STAGES.includes(latest.stage));
3937
+ throw new LinkCardError({
3938
+ 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.",
3939
+ code: "LINK_CARD_ABORTED",
3940
+ checkpoint: latest ?? checkpoint,
3941
+ ...linkRequestId ? { linkRequestId } : {},
3942
+ holdReleased: released,
3943
+ ...released && !linkRequestId ? {} : {
3944
+ retryGuidance: latest && !linkRequestId && linkCardClosable(latest) ? "Retry with the same input, or call checkout.cancel(error.checkpoint.id) to release the held spend." : openLinkRequestGuidance(latest)
3945
+ }
3946
+ });
3947
+ }
3948
+ };
3740
3949
  linkCardFor_fn = async function(checkpoint, wallet, options) {
3741
3950
  const { linkApprovalTimeoutMs = 10 * 6e4, signal, onLinkApproval } = options;
3951
+ if (checkpoint.linkRequestOpen) {
3952
+ throw new LinkCardError({
3953
+ message: `Conto's approval for this checkpoint ended while its Link request may still work (stage ${checkpoint.stage}). Do not pay.`,
3954
+ code: "CHECKOUT_CHECKPOINT_NOT_APPROVED",
3955
+ checkpoint,
3956
+ ...checkpoint.paymentReference ? { linkRequestId: checkpoint.paymentReference.id } : {},
3957
+ holdReleased: false,
3958
+ retryGuidance: LINK_REQUEST_OPEN_GUIDANCE
3959
+ });
3960
+ }
3742
3961
  if (checkpoint.stage !== "APPROVED") {
3743
- throw checkoutError(
3744
- `Conto has not approved a card for this checkpoint (stage ${checkpoint.stage}). Do not pay.`,
3745
- "CHECKOUT_CHECKPOINT_NOT_APPROVED"
3746
- );
3962
+ throw new LinkCardError({
3963
+ ...linkCardNotIssued(checkpoint.stage),
3964
+ code: "LINK_CARD_NOT_ISSUED",
3965
+ checkpoint,
3966
+ holdReleased: false
3967
+ });
3747
3968
  }
3748
3969
  const deadline = Date.now() + linkApprovalTimeoutMs;
3749
3970
  const call = signal ? { signal } : {};
3750
- let linkRequestId;
3971
+ const recordedRequestId = checkpoint.paymentReference?.id;
3972
+ let linkRequestId = recordedRequestId;
3751
3973
  let approvalUrl;
3752
3974
  let createStarted = false;
3975
+ let createAnswered = false;
3753
3976
  try {
3754
3977
  const cardRequest = linkCardRequest(checkpoint);
3755
3978
  createStarted = true;
@@ -3762,6 +3985,7 @@ linkCardFor_fn = async function(checkpoint, wallet, options) {
3762
3985
  });
3763
3986
  }
3764
3987
  linkRequestId = request.id;
3988
+ createAnswered = true;
3765
3989
  if (request.amountMinor !== void 0 && request.amountMinor !== checkpoint.amountMinor) {
3766
3990
  throw new LinkFailureSignal({
3767
3991
  kind: LinkCardError,
@@ -3770,6 +3994,13 @@ linkCardFor_fn = async function(checkpoint, wallet, options) {
3770
3994
  });
3771
3995
  }
3772
3996
  checkpoint = await __privateMethod(this, _CheckoutClient_instances, recordLinkRequest_fn).call(this, checkpoint, request.id);
3997
+ if (checkpoint.stage !== "APPROVED") {
3998
+ throw new LinkFailureSignal({
3999
+ kind: LinkCardError,
4000
+ code: "LINK_CARD_NOT_ISSUED",
4001
+ message: "Conto no longer has this checkpoint approved, so no Link card was returned."
4002
+ });
4003
+ }
3773
4004
  if (LINK_WAITING_STATUSES.has(request.status)) {
3774
4005
  const details = linkApprovalDetails(request);
3775
4006
  approvalUrl = details.approvalUrl;
@@ -3808,37 +4039,74 @@ linkCardFor_fn = async function(checkpoint, wallet, options) {
3808
4039
  };
3809
4040
  } catch (error) {
3810
4041
  const failure = error instanceof LinkFailureSignal ? error.failure : linkFailure(error, signal);
3811
- if (!linkRequestId && createStarted && !linkCreatedNothing(error)) {
4042
+ const latest = createStarted || linkRequestId || !linkCardClosable(checkpoint) ? await this.get(checkpoint.id).catch(() => void 0) : checkpoint;
4043
+ const onRecordId = latest?.paymentReference?.id ?? recordedRequestId;
4044
+ const anotherFlow = !recordedRequestId && onRecordId && onRecordId !== linkRequestId;
4045
+ const alreadyReleased = Boolean(latest && CHECKPOINT_RELEASED_STAGES.includes(latest.stage));
4046
+ const unread = recordedRequestId || linkRequestId && !failure.linkClosed;
4047
+ if (latest ? !linkCardClosable(latest) || anotherFlow : unread) {
4048
+ const extraRequestId = onRecordId && linkRequestId !== onRecordId ? linkRequestId : void 0;
4049
+ const extraOpen = extraRequestId && !failure.linkClosed && !await closeLinkRequest(wallet, extraRequestId);
4050
+ const keptId = onRecordId ?? linkRequestId;
4051
+ throw new LinkCardError({
4052
+ 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.`,
4053
+ code: "LINK_CARD_NOT_CANCELLED",
4054
+ checkpoint: latest ?? checkpoint,
4055
+ ...keptId ? { linkRequestId: keptId } : {},
4056
+ holdReleased: alreadyReleased,
4057
+ retryGuidance: `${extraOpen ? `Cancel ${extraRequestId} with wallet.cancel() first, because it is not the request on record. ` : ""}${openLinkRequestGuidance(latest)}`
4058
+ });
4059
+ }
4060
+ if (createStarted && !createAnswered && !linkCreatedNothing(error)) {
4061
+ const blockedBy = !recordedRequestId && !alreadyReleased && failure.retryGuidance;
3812
4062
  throw new LinkCardError({
3813
- message: `${failure.message} Conto could not tell whether Link created the card request, so the held spend stays in place. Do not pay.`,
4063
+ 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.`,
3814
4064
  code: "LINK_CARD_NOT_CANCELLED",
3815
- checkpoint,
3816
- holdReleased: false,
3817
- retryGuidance: "Retry with the same input to resume the Link request, or call checkout.cancel(error.checkpoint.id) once you know Link created none."
4065
+ checkpoint: latest ?? checkpoint,
4066
+ ...recordedRequestId ? { linkRequestId: recordedRequestId } : {},
4067
+ holdReleased: alreadyReleased,
4068
+ 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."
3818
4069
  });
3819
4070
  }
3820
- if (linkRequestId && !failure.linkClosed && !await closeLinkRequest(wallet, linkRequestId)) {
4071
+ const requestsToClose = /* @__PURE__ */ new Set();
4072
+ if (linkRequestId && !failure.linkClosed) requestsToClose.add(linkRequestId);
4073
+ if (recordedRequestId && recordedRequestId !== linkRequestId) {
4074
+ requestsToClose.add(recordedRequestId);
4075
+ }
4076
+ const stillOpen = [];
4077
+ for (const id2 of requestsToClose) {
4078
+ if (!await closeLinkRequest(wallet, id2)) stillOpen.push(id2);
4079
+ }
4080
+ if (stillOpen.length > 0) {
4081
+ const held = onRecordId ? "" : ", and may be released when the approval lapses";
4082
+ const bothOpen = stillOpen.length > 1 ? ` Link requests ${stillOpen.join(" and ")} are both open.` : "";
3821
4083
  throw new LinkCardError({
3822
- message: `${failure.message} Conto could not cancel the Link request, so the held spend stays in place. Do not pay.`,
4084
+ 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.`,
3823
4085
  code: "LINK_CARD_NOT_CANCELLED",
3824
- checkpoint,
3825
- linkRequestId,
3826
- ...approvalUrl ? { approvalUrl } : {},
3827
- holdReleased: false,
3828
- 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)."
4086
+ checkpoint: latest ?? checkpoint,
4087
+ // The one on record first, because a card may already be out on it.
4088
+ linkRequestId: onRecordId && stillOpen.includes(onRecordId) ? onRecordId : stillOpen[0],
4089
+ ...approvalUrl && latest?.stage === "APPROVED" ? { approvalUrl } : {},
4090
+ holdReleased: alreadyReleased,
4091
+ 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)
3829
4092
  });
3830
4093
  }
3831
- const holdReleased = await this.cancel(checkpoint.id).then(
3832
- () => true,
4094
+ let cancelled;
4095
+ const holdReleased = alreadyReleased || await this.cancel(checkpoint.id).then(
4096
+ (result) => {
4097
+ cancelled = result;
4098
+ return true;
4099
+ },
3833
4100
  () => false
3834
4101
  );
3835
4102
  throw new failure.kind({
3836
- message: `${failure.message} ${holdReleased ? "Conto cancelled the checkpoint and released the held spend." : "Conto could not cancel the checkpoint yet."} Do not pay.`,
4103
+ 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.`,
3837
4104
  code: failure.code,
3838
- checkpoint,
4105
+ checkpoint: cancelled ?? latest ?? checkpoint,
3839
4106
  ...linkRequestId ? { linkRequestId } : {},
3840
4107
  ...linkRequestId && approvalUrl ? { approvalUrl } : {},
3841
- holdReleased
4108
+ holdReleased,
4109
+ ...holdReleased && failure.retryGuidance ? { retryGuidance: failure.retryGuidance } : {}
3842
4110
  });
3843
4111
  }
3844
4112
  };