@conto_finance/sdk 0.8.0 → 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.8.0",
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",
@@ -1775,7 +1775,13 @@ var BrowserActionFinalizationPendingError = class extends ContoError {
1775
1775
  };
1776
1776
  var CheckoutCheckpointClosedError = class extends ContoError {
1777
1777
  constructor(checkpoint, message = `The checkpoint is ${checkpoint.stage.toLowerCase().replace(/_/g, " ")}. Stop this checkout.`, code = "CHECKOUT_CHECKPOINT_CLOSED") {
1778
- 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
+ });
1779
1785
  this.name = "CheckoutCheckpointClosedError";
1780
1786
  this.checkpoint = checkpoint;
1781
1787
  this.stage = checkpoint.stage;
@@ -2987,6 +2993,12 @@ function checkpointCart(amountMinor, lineItems, totals) {
2987
2993
  if (!Array.isArray(lineItems) || lineItems.length === 0) {
2988
2994
  throw checkoutError("Supply at least one line item");
2989
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
+ }
2990
3002
  return {
2991
3003
  lineItems,
2992
3004
  totals: totals ?? [{ type: "total", label: "Total", amountMinor }]
@@ -3026,6 +3038,13 @@ var LINK_REQUEST_ID = /^lsrq_[A-Za-z0-9]{1,64}$/;
3026
3038
  var LINK_WAITING_STATUSES = /* @__PURE__ */ new Set(["created", "pending_approval"]);
3027
3039
  var LINK_APPROVED_STATUSES = /* @__PURE__ */ new Set(["approved", "submitted", "succeeded"]);
3028
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
+ ];
3029
3048
  var INSPECT = /* @__PURE__ */ Symbol.for("nodejs.util.inspect.custom");
3030
3049
  function assertPaymentReference(reference) {
3031
3050
  if (reference === void 0) return;
@@ -3107,6 +3126,14 @@ function errorCode(error) {
3107
3126
  function errorReason(error) {
3108
3127
  return error && typeof error === "object" && typeof error.reason === "string" ? error.reason : void 0;
3109
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
+ }
3110
3137
  function linkFailure(error, signal) {
3111
3138
  if (error instanceof LinkCardError) {
3112
3139
  return {
@@ -3122,6 +3149,24 @@ function linkFailure(error, signal) {
3122
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".'
3123
3150
  };
3124
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
+ }
3125
3170
  if (signal?.aborted || errorReason(error) === "aborted") {
3126
3171
  return {
3127
3172
  kind: LinkCardError,
@@ -3162,7 +3207,7 @@ function closedLinkFailure(request) {
3162
3207
  return {
3163
3208
  kind: LinkCardError,
3164
3209
  code: "LINK_CARD_ACTION_REQUIRED",
3165
- 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."
3166
3211
  };
3167
3212
  default:
3168
3213
  return {
@@ -3173,13 +3218,15 @@ function closedLinkFailure(request) {
3173
3218
  };
3174
3219
  }
3175
3220
  }
3176
- async function closeLinkRequest(wallet, id2) {
3221
+ async function closeLinkRequest(wallet, id2, onRefused) {
3177
3222
  try {
3178
3223
  await wallet.cancel(id2);
3179
3224
  return true;
3180
- } catch {
3181
- const status = await wallet.waitForApproval(id2, { timeoutSeconds: 0 }).then((request) => request.status).catch(() => void 0);
3182
- 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;
3183
3230
  }
3184
3231
  }
3185
3232
  function assertLinkApprovalCallback(callback) {
@@ -3187,6 +3234,65 @@ function assertLinkApprovalCallback(callback) {
3187
3234
  throw checkoutError("onLinkApproval must be a function");
3188
3235
  }
3189
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
+ }
3190
3296
  function linkCreatedNothing(error) {
3191
3297
  return errorCode(error) === "LINK_WALLET_UNAVAILABLE" || errorReason(error) === "invalid_input";
3192
3298
  }
@@ -3213,7 +3319,7 @@ function notifyLinkApproval(callback, event) {
3213
3319
  } catch {
3214
3320
  }
3215
3321
  }
3216
- var _client4, _CheckoutClient_instances, recordLinkRequest_fn, linkCardFor_fn;
3322
+ var _client4, _CheckoutClient_instances, recordLinkRequest_fn, waitForLinkOwner_fn, linkCardFor_fn;
3217
3323
  var CheckoutClient = class {
3218
3324
  constructor(client) {
3219
3325
  __privateAdd(this, _CheckoutClient_instances);
@@ -3298,17 +3404,37 @@ var CheckoutClient = class {
3298
3404
  assertPaymentReference(input.paymentReference);
3299
3405
  let current = checkpoint;
3300
3406
  if (input.amountMinor !== checkpoint.amountMinor) {
3301
- if (input.paymentReference ?? checkpoint.paymentReference) {
3302
- throw new ContoError("The Link card covers the approved total, not this one. Do not pay.", {
3303
- code: "LINK_CARD_TOTAL_CHANGED",
3304
- status: 0,
3305
- retryGuidance: "Call checkout.replaceLinkCard(result, { wallet, amountMinor }) for a card at the final total, then confirm the checkpoint it returns."
3306
- });
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
+ );
3307
3433
  }
3308
3434
  try {
3309
3435
  await this.cancel(checkpoint.id);
3310
3436
  } catch (error) {
3311
- 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);
3312
3438
  if (!closed) throw error;
3313
3439
  }
3314
3440
  current = await this.begin({
@@ -3372,7 +3498,9 @@ var CheckoutClient = class {
3372
3498
  }
3373
3499
  /**
3374
3500
  * Cancel a checkpoint that was not confirmed and release its spend. A
3375
- * 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.
3376
3504
  */
3377
3505
  async cancel(checkpointId) {
3378
3506
  const result = await __privateGet(this, _client4).request(
@@ -3414,9 +3542,11 @@ var CheckoutClient = class {
3414
3542
  * held spend and throws a LinkCardError. When Conto cannot cancel an open Link
3415
3543
  * request, it keeps the hold and throws LINK_CARD_NOT_CANCELLED with the
3416
3544
  * request id. Retrying with the same input resumes the same checkpoint and
3417
- * Link request while Conto's approval is open. Once the stage is OUTCOME_UNKNOWN,
3418
- * a retry throws CHECKOUT_CHECKPOINT_NOT_APPROVED: cancel the Link request, then
3419
- * the checkpoint.
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.
3420
3550
  */
3421
3551
  async getLinkCard(input) {
3422
3552
  const {
@@ -3433,10 +3563,7 @@ var CheckoutClient = class {
3433
3563
  assertLinkApprovalCallback(onLinkApproval);
3434
3564
  let checkpoint = await this.begin({ ...beginInput, checkoutMethod: "LINK" });
3435
3565
  if (checkpoint.stage === "AWAITING_APPROVAL") {
3436
- checkpoint = await this.waitForApproval(checkpoint, {
3437
- ...ownerApprovalTimeoutMs !== void 0 ? { timeoutMs: ownerApprovalTimeoutMs } : {},
3438
- ...signal ? { signal } : {}
3439
- });
3566
+ checkpoint = await __privateMethod(this, _CheckoutClient_instances, waitForLinkOwner_fn).call(this, checkpoint, ownerApprovalTimeoutMs, signal);
3440
3567
  }
3441
3568
  return __privateMethod(this, _CheckoutClient_instances, linkCardFor_fn).call(this, checkpoint, wallet, {
3442
3569
  linkApprovalTimeoutMs,
@@ -3448,7 +3575,11 @@ var CheckoutClient = class {
3448
3575
  * The final total differs from the one the Link card covers. This cancels
3449
3576
  * the old Link request and checkpoint, begins a checkpoint at the final
3450
3577
  * total (waiting for the owner when a rule requires it), and gets a new card
3451
- * 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.
3452
3583
  */
3453
3584
  async replaceLinkCard(previous, input) {
3454
3585
  const {
@@ -3463,23 +3594,73 @@ var CheckoutClient = class {
3463
3594
  throw checkoutError("amountMinor must be a positive integer number of cents");
3464
3595
  }
3465
3596
  assertLinkApprovalCallback(onLinkApproval);
3466
- if (amountMinor === previous.checkpoint.amountMinor) return previous;
3467
3597
  const old = previous.checkpoint;
3468
- 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) {
3469
3625
  throw new LinkCardError({
3470
- 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."}`,
3471
3642
  code: "LINK_CARD_NOT_CANCELLED",
3472
- checkpoint: old,
3643
+ checkpoint: current,
3473
3644
  linkRequestId: previous.linkRequestId,
3474
- holdReleased: false,
3475
- 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."}`
3476
3647
  });
3477
3648
  }
3649
+ CLOSED_LINK_RESULTS.add(previous);
3478
3650
  try {
3479
3651
  await this.cancel(old.id);
3480
3652
  } catch (error) {
3481
- const closed = error instanceof ContoError && error.status === 409 && ["CANCELLED", "EXPIRED", "DENIED"].includes((await this.get(old.id)).stage);
3482
- 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
+ }
3483
3664
  }
3484
3665
  let checkpoint = await this.begin({
3485
3666
  idempotencyKey: `${old.idempotencyKey.slice(0, 200)}:final-${amountMinor}`,
@@ -3496,10 +3677,7 @@ var CheckoutClient = class {
3496
3677
  totals: input.totals
3497
3678
  });
3498
3679
  if (checkpoint.stage === "AWAITING_APPROVAL") {
3499
- checkpoint = await this.waitForApproval(checkpoint, {
3500
- ...ownerApprovalTimeoutMs !== void 0 ? { timeoutMs: ownerApprovalTimeoutMs } : {},
3501
- ...signal ? { signal } : {}
3502
- });
3680
+ checkpoint = await __privateMethod(this, _CheckoutClient_instances, waitForLinkOwner_fn).call(this, checkpoint, ownerApprovalTimeoutMs, signal);
3503
3681
  }
3504
3682
  return __privateMethod(this, _CheckoutClient_instances, linkCardFor_fn).call(this, checkpoint, wallet, {
3505
3683
  linkApprovalTimeoutMs,
@@ -3528,19 +3706,56 @@ recordLinkRequest_fn = async function(checkpoint, linkRequestId) {
3528
3706
  });
3529
3707
  }
3530
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
+ };
3531
3732
  linkCardFor_fn = async function(checkpoint, wallet, options) {
3532
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
+ }
3533
3744
  if (checkpoint.stage !== "APPROVED") {
3534
- throw checkoutError(
3535
- `Conto has not approved a card for this checkpoint (stage ${checkpoint.stage}). Do not pay.`,
3536
- "CHECKOUT_CHECKPOINT_NOT_APPROVED"
3537
- );
3745
+ throw new LinkCardError({
3746
+ ...linkCardNotIssued(checkpoint.stage),
3747
+ code: "LINK_CARD_NOT_ISSUED",
3748
+ checkpoint,
3749
+ holdReleased: false
3750
+ });
3538
3751
  }
3539
3752
  const deadline = Date.now() + linkApprovalTimeoutMs;
3540
3753
  const call = signal ? { signal } : {};
3541
- let linkRequestId;
3754
+ const recordedRequestId = checkpoint.paymentReference?.id;
3755
+ let linkRequestId = recordedRequestId;
3542
3756
  let approvalUrl;
3543
3757
  let createStarted = false;
3758
+ let createAnswered = false;
3544
3759
  try {
3545
3760
  const cardRequest = linkCardRequest(checkpoint);
3546
3761
  createStarted = true;
@@ -3553,6 +3768,7 @@ linkCardFor_fn = async function(checkpoint, wallet, options) {
3553
3768
  });
3554
3769
  }
3555
3770
  linkRequestId = request.id;
3771
+ createAnswered = true;
3556
3772
  if (request.amountMinor !== void 0 && request.amountMinor !== checkpoint.amountMinor) {
3557
3773
  throw new LinkFailureSignal({
3558
3774
  kind: LinkCardError,
@@ -3561,6 +3777,13 @@ linkCardFor_fn = async function(checkpoint, wallet, options) {
3561
3777
  });
3562
3778
  }
3563
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
+ }
3564
3787
  if (LINK_WAITING_STATUSES.has(request.status)) {
3565
3788
  const details = linkApprovalDetails(request);
3566
3789
  approvalUrl = details.approvalUrl;
@@ -3599,37 +3822,74 @@ linkCardFor_fn = async function(checkpoint, wallet, options) {
3599
3822
  };
3600
3823
  } catch (error) {
3601
3824
  const failure = error instanceof LinkFailureSignal ? error.failure : linkFailure(error, signal);
3602
- 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;
3834
+ throw new LinkCardError({
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.`,
3836
+ code: "LINK_CARD_NOT_CANCELLED",
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)}`
3841
+ });
3842
+ }
3843
+ if (createStarted && !createAnswered && !linkCreatedNothing(error)) {
3844
+ const blockedBy = !recordedRequestId && !alreadyReleased && failure.retryGuidance;
3603
3845
  throw new LinkCardError({
3604
- message: `${failure.message} Conto could not tell whether Link created the card 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.`,
3605
3847
  code: "LINK_CARD_NOT_CANCELLED",
3606
- checkpoint,
3607
- holdReleased: false,
3608
- retryGuidance: "Retry with the same input to resume the Link request, or call checkout.cancel(error.checkpoint.id) once you know Link created none."
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."
3609
3852
  });
3610
3853
  }
3611
- if (linkRequestId && !failure.linkClosed && !await closeLinkRequest(wallet, linkRequestId)) {
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.` : "";
3612
3866
  throw new LinkCardError({
3613
- message: `${failure.message} Conto could not cancel the Link request, so the held spend stays in place. Do not pay.`,
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.`,
3614
3868
  code: "LINK_CARD_NOT_CANCELLED",
3615
- checkpoint,
3616
- linkRequestId,
3617
- ...approvalUrl ? { approvalUrl } : {},
3618
- holdReleased: false,
3619
- 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)."
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)
3620
3875
  });
3621
3876
  }
3622
- const holdReleased = await this.cancel(checkpoint.id).then(
3623
- () => true,
3877
+ let cancelled;
3878
+ const holdReleased = alreadyReleased || await this.cancel(checkpoint.id).then(
3879
+ (result) => {
3880
+ cancelled = result;
3881
+ return true;
3882
+ },
3624
3883
  () => false
3625
3884
  );
3626
3885
  throw new failure.kind({
3627
- 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.`,
3628
3887
  code: failure.code,
3629
- checkpoint,
3888
+ checkpoint: cancelled ?? latest ?? checkpoint,
3630
3889
  ...linkRequestId ? { linkRequestId } : {},
3631
3890
  ...linkRequestId && approvalUrl ? { approvalUrl } : {},
3632
- holdReleased
3891
+ holdReleased,
3892
+ ...holdReleased && failure.retryGuidance ? { retryGuidance: failure.retryGuidance } : {}
3633
3893
  });
3634
3894
  }
3635
3895
  };
@@ -49,6 +49,8 @@ interface LinkSpendRequest {
49
49
  actionResolution?: string;
50
50
  /** Set with requires_action when Link gives the person a page to finish the step. */
51
51
  actionUrl?: string;
52
+ /** The metadata sent with the request, when Link returns it. */
53
+ metadata?: Record<string, string>;
52
54
  }
53
55
  /**
54
56
  * The single-use virtual card Link issued for an approved request. Live card
@@ -85,7 +87,10 @@ interface LinkWallet {
85
87
  waitForApproval(id: string, options?: LinkWaitOptions): Promise<LinkSpendRequest>;
86
88
  /** Read the card of an approved request. */
87
89
  retrieveCard(id: string, options?: LinkCallOptions): Promise<LinkCardCredential>;
88
- /** Cancel a request that is created, pending approval, or approved. */
90
+ /**
91
+ * Cancel a request, or confirm Link already closed it. Throws when its card
92
+ * may still work or may have been used, so keep the hold.
93
+ */
89
94
  cancel(id: string, options?: LinkCallOptions): Promise<void>;
90
95
  }
91
96
  type LinkWalletErrorReason = 'not_installed' | 'not_logged_in' | 'not_found' | 'not_approved' | 'invalid_input' | 'invalid_response' | 'timed_out' | 'aborted' | 'cli_failed';
@@ -98,7 +103,11 @@ declare class LinkWalletError extends Error {
98
103
  readonly reason: LinkWalletErrorReason;
99
104
  /** The error code link-cli reported, when it reported a safe one. */
100
105
  readonly linkCode?: string;
101
- constructor(reason: LinkWalletErrorReason, message: string, linkCode?: string);
106
+ /** The open Link request with the same details that blocks this one. */
107
+ readonly duplicateOf?: string;
108
+ /** The checkpoint Link has on that request, as Link reported it. */
109
+ readonly duplicateCheckpoint?: string;
110
+ constructor(reason: LinkWalletErrorReason, message: string, linkCode?: string, duplicateOf?: string, duplicateCheckpoint?: string);
102
111
  }
103
112
  interface LinkCliWalletOptions {
104
113
  /** The link-cli executable. Defaults to `link-cli` on PATH. */
@@ -49,6 +49,8 @@ interface LinkSpendRequest {
49
49
  actionResolution?: string;
50
50
  /** Set with requires_action when Link gives the person a page to finish the step. */
51
51
  actionUrl?: string;
52
+ /** The metadata sent with the request, when Link returns it. */
53
+ metadata?: Record<string, string>;
52
54
  }
53
55
  /**
54
56
  * The single-use virtual card Link issued for an approved request. Live card
@@ -85,7 +87,10 @@ interface LinkWallet {
85
87
  waitForApproval(id: string, options?: LinkWaitOptions): Promise<LinkSpendRequest>;
86
88
  /** Read the card of an approved request. */
87
89
  retrieveCard(id: string, options?: LinkCallOptions): Promise<LinkCardCredential>;
88
- /** Cancel a request that is created, pending approval, or approved. */
90
+ /**
91
+ * Cancel a request, or confirm Link already closed it. Throws when its card
92
+ * may still work or may have been used, so keep the hold.
93
+ */
89
94
  cancel(id: string, options?: LinkCallOptions): Promise<void>;
90
95
  }
91
96
  type LinkWalletErrorReason = 'not_installed' | 'not_logged_in' | 'not_found' | 'not_approved' | 'invalid_input' | 'invalid_response' | 'timed_out' | 'aborted' | 'cli_failed';
@@ -98,7 +103,11 @@ declare class LinkWalletError extends Error {
98
103
  readonly reason: LinkWalletErrorReason;
99
104
  /** The error code link-cli reported, when it reported a safe one. */
100
105
  readonly linkCode?: string;
101
- constructor(reason: LinkWalletErrorReason, message: string, linkCode?: string);
106
+ /** The open Link request with the same details that blocks this one. */
107
+ readonly duplicateOf?: string;
108
+ /** The checkpoint Link has on that request, as Link reported it. */
109
+ readonly duplicateCheckpoint?: string;
110
+ constructor(reason: LinkWalletErrorReason, message: string, linkCode?: string, duplicateOf?: string, duplicateCheckpoint?: string);
102
111
  }
103
112
  interface LinkCliWalletOptions {
104
113
  /** The link-cli executable. Defaults to `link-cli` on PATH. */