@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.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.2",
26
26
  description: "Conto SDK for AI agent payment authorization",
27
27
  main: "./dist/index.js",
28
28
  module: "./dist/index.mjs",
@@ -712,16 +712,16 @@ var AdminSpendingClient = class {
712
712
  /**
713
713
  * Create a shared budget. Needs an organization API key with `policies:write`.
714
714
  *
715
+ * Checkout checkpoints, including `checkout.getLinkCard()`, and guarded browser checkout
716
+ * (`runGuardedCheckout()`) hold spend against each budget that covers the agent.
717
+ *
715
718
  * 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.
719
+ * payment grants, checkout relay, hosted Stripe Link and card overlays, for every agent it covers.
720
+ * A paused or expired budget still stops them. While paused and after its end date, it also
721
+ * refuses the managed wallet payments, connected card draws and checkouts it covers, for those
722
+ * agents. A start date cannot be changed.
723
+ * `deleteBudget()` ends a budget. Fails with `reasonCode` `BUDGET_HAS_HOLDS` while a purchase holds
724
+ * spend against it.
725
725
  */
726
726
  createBudget(input) {
727
727
  return this.client.request("POST", "/api/spending/budgets", {
@@ -1018,12 +1018,18 @@ function responseErrorMessage(_data, status) {
1018
1018
  }
1019
1019
  var SHARED_REASON_CODES = /* @__PURE__ */ new Set([
1020
1020
  "AGENT_NOT_AUTHORIZED",
1021
+ "AGENT_LIMIT_EXCEEDED",
1021
1022
  "AGENT_UNAVAILABLE",
1023
+ "BUDGET_CHANGE_NOT_ALLOWED",
1022
1024
  "BUDGET_CURRENCY_UNSUPPORTED",
1023
1025
  "BUDGET_EXPIRED",
1024
1026
  "BUDGET_HAS_HOLDS",
1027
+ "BUDGET_METHOD_NOT_ALLOWED",
1025
1028
  "BUDGET_PAUSED",
1029
+ "BUDGET_SOURCE_NOT_ATTACHED",
1026
1030
  "BUYER_LINK_REVOKED",
1031
+ "CATEGORY_NOT_AUTHORIZED",
1032
+ "CATEGORY_UNKNOWN",
1027
1033
  "DUPLICATE_PURCHASE",
1028
1034
  "ENROLLMENT_RECONCILIATION_REQUIRED",
1029
1035
  "EVIDENCE_CONFLICT",
@@ -1032,6 +1038,7 @@ var SHARED_REASON_CODES = /* @__PURE__ */ new Set([
1032
1038
  "EXECUTION_PATH_NOT_CONNECTED",
1033
1039
  "EXPORT_TOO_LARGE",
1034
1040
  "INVALID_AMOUNT",
1041
+ "INVALID_BUDGET",
1035
1042
  "INVALID_EXPIRY",
1036
1043
  "INVALID_FILE",
1037
1044
  "INVALID_PARTICIPANTS",
@@ -1040,6 +1047,7 @@ var SHARED_REASON_CODES = /* @__PURE__ */ new Set([
1040
1047
  "LEGACY_BUDGET_REQUEST",
1041
1048
  "LINKED_BUDGET_REQUIRED",
1042
1049
  "LINKED_TASK_UNSUPPORTED",
1050
+ "MEMBER_LIMIT_EXCEEDED",
1043
1051
  "MERCHANT_NOT_AUTHORIZED",
1044
1052
  "NOT_FOUND",
1045
1053
  "PURCHASE_ALREADY_SUBMITTED",
@@ -1775,7 +1783,13 @@ var BrowserActionFinalizationPendingError = class extends ContoError {
1775
1783
  };
1776
1784
  var CheckoutCheckpointClosedError = class extends ContoError {
1777
1785
  constructor(checkpoint, message = `The checkpoint is ${checkpoint.stage.toLowerCase().replace(/_/g, " ")}. Stop this checkout.`, code = "CHECKOUT_CHECKPOINT_CLOSED") {
1778
- super(message, { code, status: 0 });
1786
+ super(message, {
1787
+ code,
1788
+ status: 0,
1789
+ ...checkpoint.paymentReference?.provider === "LINK" ? {
1790
+ 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."
1791
+ } : {}
1792
+ });
1779
1793
  this.name = "CheckoutCheckpointClosedError";
1780
1794
  this.checkpoint = checkpoint;
1781
1795
  this.stage = checkpoint.stage;
@@ -2987,6 +3001,12 @@ function checkpointCart(amountMinor, lineItems, totals) {
2987
3001
  if (!Array.isArray(lineItems) || lineItems.length === 0) {
2988
3002
  throw checkoutError("Supply at least one line item");
2989
3003
  }
3004
+ if (totals !== void 0 && totals !== null) {
3005
+ const finalTotal = totals.filter((total) => total.type === "total");
3006
+ if (finalTotal.length !== 1 || finalTotal[0].amountMinor !== amountMinor) {
3007
+ throw checkoutError("totals must have one row with type total that equals amountMinor");
3008
+ }
3009
+ }
2990
3010
  return {
2991
3011
  lineItems,
2992
3012
  totals: totals ?? [{ type: "total", label: "Total", amountMinor }]
@@ -3026,6 +3046,13 @@ var LINK_REQUEST_ID = /^lsrq_[A-Za-z0-9]{1,64}$/;
3026
3046
  var LINK_WAITING_STATUSES = /* @__PURE__ */ new Set(["created", "pending_approval"]);
3027
3047
  var LINK_APPROVED_STATUSES = /* @__PURE__ */ new Set(["approved", "submitted", "succeeded"]);
3028
3048
  var LINK_CLOSED_STATUSES = /* @__PURE__ */ new Set(["denied", "expired", "canceled", "failed"]);
3049
+ var CLOSED_LINK_RESULTS = /* @__PURE__ */ new WeakSet();
3050
+ var CHECKPOINT_RELEASED_STAGES = [
3051
+ "CANCELLED",
3052
+ "EXPIRED",
3053
+ "DENIED",
3054
+ "NOT_PAID"
3055
+ ];
3029
3056
  var INSPECT = /* @__PURE__ */ Symbol.for("nodejs.util.inspect.custom");
3030
3057
  function assertPaymentReference(reference) {
3031
3058
  if (reference === void 0) return;
@@ -3107,6 +3134,14 @@ function errorCode(error) {
3107
3134
  function errorReason(error) {
3108
3135
  return error && typeof error === "object" && typeof error.reason === "string" ? error.reason : void 0;
3109
3136
  }
3137
+ function duplicateGuidance(requestId, owner) {
3138
+ const wait2 = `This purchase cannot start until Link request ${requestId} closes, so do not retry yet.`;
3139
+ const then = "Once it closes, begin this purchase with a new idempotency key if the buyer still wants it.";
3140
+ if (typeof owner !== "string" || !/^[A-Za-z0-9_-]{1,64}$/.test(owner)) {
3141
+ 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}`;
3142
+ }
3143
+ 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}`;
3144
+ }
3110
3145
  function linkFailure(error, signal) {
3111
3146
  if (error instanceof LinkCardError) {
3112
3147
  return {
@@ -3122,6 +3157,24 @@ function linkFailure(error, signal) {
3122
3157
  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
3158
  };
3124
3159
  }
3160
+ const duplicate = error;
3161
+ const duplicateOf = String(duplicate?.duplicateOf);
3162
+ if (errorReason(error) === "invalid_input" && LINK_REQUEST_ID.test(duplicateOf)) {
3163
+ return {
3164
+ kind: LinkCardError,
3165
+ code: "LINK_CARD_DUPLICATE_REQUEST",
3166
+ message: "Link has an open request for another purchase with the same details.",
3167
+ retryGuidance: duplicateGuidance(duplicateOf, duplicate?.duplicateCheckpoint)
3168
+ };
3169
+ }
3170
+ if (errorReason(error) === "cli_failed" && LINK_REQUEST_ID.test(duplicateOf)) {
3171
+ return {
3172
+ kind: LinkCardError,
3173
+ code: "LINK_CARD_FAILED",
3174
+ message: "Link has an open request with the same details and no checkpoint on it, so a retry cannot resume it.",
3175
+ 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).`
3176
+ };
3177
+ }
3125
3178
  if (signal?.aborted || errorReason(error) === "aborted") {
3126
3179
  return {
3127
3180
  kind: LinkCardError,
@@ -3162,7 +3215,7 @@ function closedLinkFailure(request) {
3162
3215
  return {
3163
3216
  kind: LinkCardError,
3164
3217
  code: "LINK_CARD_ACTION_REQUIRED",
3165
- message: "Link needs the person to finish a step in Link before it can issue a card."
3218
+ message: "Link cannot continue this request. The person has to fix a problem in Link before a new request can issue a card."
3166
3219
  };
3167
3220
  default:
3168
3221
  return {
@@ -3173,13 +3226,15 @@ function closedLinkFailure(request) {
3173
3226
  };
3174
3227
  }
3175
3228
  }
3176
- async function closeLinkRequest(wallet, id2) {
3229
+ async function closeLinkRequest(wallet, id2, onRefused) {
3177
3230
  try {
3178
3231
  await wallet.cancel(id2);
3179
3232
  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);
3233
+ } catch (error) {
3234
+ const status = await Promise.resolve().then(() => wallet.waitForApproval(id2, { timeoutSeconds: 0 })).then((request) => request.status).catch(() => void 0);
3235
+ if (status !== void 0 && LINK_CLOSED_STATUSES.has(status)) return true;
3236
+ onRefused?.(error);
3237
+ return false;
3183
3238
  }
3184
3239
  }
3185
3240
  function assertLinkApprovalCallback(callback) {
@@ -3187,6 +3242,65 @@ function assertLinkApprovalCallback(callback) {
3187
3242
  throw checkoutError("onLinkApproval must be a function");
3188
3243
  }
3189
3244
  }
3245
+ function linkCardClosable(checkpoint) {
3246
+ return CHECKPOINT_RELEASED_STAGES.includes(checkpoint.stage) || ["AWAITING_APPROVAL", "APPROVED"].includes(checkpoint.stage) || checkpoint.linkRequestOpen === true;
3247
+ }
3248
+ 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.";
3249
+ var PAID_GUIDANCE = "Do not buy it again.";
3250
+ 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.";
3251
+ 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.";
3252
+ 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.";
3253
+ 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}`;
3254
+ 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}`;
3255
+ function linkCardNotReplaced(stage) {
3256
+ if (stage === "CONFIRMING") {
3257
+ return {
3258
+ message: "Conto is still checking this checkpoint, so the Link card cannot be replaced yet. Nothing changed. Do not pay this total.",
3259
+ retryGuidance: CONFIRMING_GUIDANCE
3260
+ };
3261
+ }
3262
+ if (stage === "PAID") {
3263
+ return {
3264
+ message: "This purchase is already recorded as paid, so the Link card cannot be replaced. Nothing changed. Do not pay again.",
3265
+ retryGuidance: PAID_GUIDANCE
3266
+ };
3267
+ }
3268
+ return {
3269
+ 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.",
3270
+ retryGuidance: CONFIRMED_GUIDANCE
3271
+ };
3272
+ }
3273
+ function linkCardNotIssued(stage) {
3274
+ if (stage === "CONFIRMING") {
3275
+ return {
3276
+ 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.",
3277
+ retryGuidance: CONFIRMING_GUIDANCE
3278
+ };
3279
+ }
3280
+ if (stage === "PAID") {
3281
+ return {
3282
+ message: "This purchase is already recorded as paid, so Link was not asked for a card. Do not pay again.",
3283
+ retryGuidance: PAID_GUIDANCE
3284
+ };
3285
+ }
3286
+ return {
3287
+ 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.",
3288
+ retryGuidance: CONFIRMED_GUIDANCE
3289
+ };
3290
+ }
3291
+ function openLinkRequestGuidance(latest) {
3292
+ if (!latest) {
3293
+ 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.";
3294
+ }
3295
+ if (CHECKPOINT_RELEASED_STAGES.includes(latest.stage)) {
3296
+ 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.";
3297
+ }
3298
+ if (latest.linkRequestOpen) return LINK_REQUEST_OPEN_GUIDANCE;
3299
+ if (linkCardClosable(latest)) {
3300
+ 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}`;
3301
+ }
3302
+ return linkCardNotIssued(latest.stage).retryGuidance;
3303
+ }
3190
3304
  function linkCreatedNothing(error) {
3191
3305
  return errorCode(error) === "LINK_WALLET_UNAVAILABLE" || errorReason(error) === "invalid_input";
3192
3306
  }
@@ -3213,7 +3327,7 @@ function notifyLinkApproval(callback, event) {
3213
3327
  } catch {
3214
3328
  }
3215
3329
  }
3216
- var _client4, _CheckoutClient_instances, recordLinkRequest_fn, linkCardFor_fn;
3330
+ var _client4, _CheckoutClient_instances, recordLinkRequest_fn, waitForLinkOwner_fn, linkCardFor_fn;
3217
3331
  var CheckoutClient = class {
3218
3332
  constructor(client) {
3219
3333
  __privateAdd(this, _CheckoutClient_instances);
@@ -3298,17 +3412,37 @@ var CheckoutClient = class {
3298
3412
  assertPaymentReference(input.paymentReference);
3299
3413
  let current = checkpoint;
3300
3414
  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
- });
3415
+ if (input.paymentReference ?? checkpoint.paymentReference ?? (await this.get(checkpoint.id)).paymentReference) {
3416
+ let notRecorded = false;
3417
+ if (input.paymentReference && !checkpoint.paymentReference) {
3418
+ notRecorded = await __privateGet(this, _client4).request(
3419
+ "POST",
3420
+ `/api/sdk/checkout-checkpoints/${encodeURIComponent(checkpoint.id)}/payment-reference`,
3421
+ { paymentReference: { provider: "LINK", id: input.paymentReference.id } },
3422
+ { idempotent: true }
3423
+ ).then(
3424
+ () => false,
3425
+ async (error) => {
3426
+ if (!(error instanceof ContoError && error.status === 409)) return true;
3427
+ const latest = await this.get(checkpoint.id).catch(() => void 0);
3428
+ return latest?.paymentReference?.id !== input.paymentReference?.id;
3429
+ }
3430
+ );
3431
+ }
3432
+ const note = notRecorded ? " Conto could not record this Link request, so its hold may end when the approval lapses." : "";
3433
+ throw new ContoError(
3434
+ `The Link card covers the approved total, not this one. Do not pay.${note}`,
3435
+ {
3436
+ code: "LINK_CARD_TOTAL_CHANGED",
3437
+ status: 0,
3438
+ retryGuidance: "Call checkout.replaceLinkCard(result, { wallet, amountMinor }) for a card at the final total, then confirm the checkpoint it returns."
3439
+ }
3440
+ );
3307
3441
  }
3308
3442
  try {
3309
3443
  await this.cancel(checkpoint.id);
3310
3444
  } catch (error) {
3311
- const closed = error instanceof ContoError && error.status === 409 && ["CANCELLED", "EXPIRED", "DENIED"].includes((await this.get(checkpoint.id)).stage);
3445
+ const closed = error instanceof ContoError && error.status === 409 && CHECKPOINT_RELEASED_STAGES.includes((await this.get(checkpoint.id)).stage);
3312
3446
  if (!closed) throw error;
3313
3447
  }
3314
3448
  current = await this.begin({
@@ -3372,7 +3506,9 @@ var CheckoutClient = class {
3372
3506
  }
3373
3507
  /**
3374
3508
  * Cancel a checkpoint that was not confirmed and release its spend. A
3375
- * checkpoint waiting for the owner can be cancelled too.
3509
+ * checkpoint waiting for the owner can be cancelled too. This does not close
3510
+ * a Link card, which can still work with nothing held behind it, so cancel
3511
+ * the Link request first.
3376
3512
  */
3377
3513
  async cancel(checkpointId) {
3378
3514
  const result = await __privateGet(this, _client4).request(
@@ -3414,9 +3550,11 @@ var CheckoutClient = class {
3414
3550
  * held spend and throws a LinkCardError. When Conto cannot cancel an open Link
3415
3551
  * request, it keeps the hold and throws LINK_CARD_NOT_CANCELLED with the
3416
3552
  * 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.
3553
+ * Link request while Conto's approval is open. If its approval ended with the
3554
+ * request open, a retry throws CHECKOUT_CHECKPOINT_NOT_APPROVED. Cancel the Link
3555
+ * request, then the checkpoint. Once a confirm has started, a retry throws
3556
+ * LINK_CARD_NOT_ISSUED. Do not cancel the Link request while a payment may be in
3557
+ * flight. Follow error.retryGuidance, which depends on the stage.
3420
3558
  */
3421
3559
  async getLinkCard(input) {
3422
3560
  const {
@@ -3433,10 +3571,7 @@ var CheckoutClient = class {
3433
3571
  assertLinkApprovalCallback(onLinkApproval);
3434
3572
  let checkpoint = await this.begin({ ...beginInput, checkoutMethod: "LINK" });
3435
3573
  if (checkpoint.stage === "AWAITING_APPROVAL") {
3436
- checkpoint = await this.waitForApproval(checkpoint, {
3437
- ...ownerApprovalTimeoutMs !== void 0 ? { timeoutMs: ownerApprovalTimeoutMs } : {},
3438
- ...signal ? { signal } : {}
3439
- });
3574
+ checkpoint = await __privateMethod(this, _CheckoutClient_instances, waitForLinkOwner_fn).call(this, checkpoint, ownerApprovalTimeoutMs, signal);
3440
3575
  }
3441
3576
  return __privateMethod(this, _CheckoutClient_instances, linkCardFor_fn).call(this, checkpoint, wallet, {
3442
3577
  linkApprovalTimeoutMs,
@@ -3448,7 +3583,11 @@ var CheckoutClient = class {
3448
3583
  * The final total differs from the one the Link card covers. This cancels
3449
3584
  * the old Link request and checkpoint, begins a checkpoint at the final
3450
3585
  * 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.
3586
+ * for it. Confirm the returned checkpoint with the same total. It reads the
3587
+ * checkpoint first. At a stage where a payment may be in flight, or with a
3588
+ * different Link request on record, this throws LINK_CARD_NOT_REPLACED and
3589
+ * leaves the card and the hold alone. After the old card is closed, trying
3590
+ * again with the same input resumes the replacement.
3452
3591
  */
3453
3592
  async replaceLinkCard(previous, input) {
3454
3593
  const {
@@ -3463,23 +3602,73 @@ var CheckoutClient = class {
3463
3602
  throw checkoutError("amountMinor must be a positive integer number of cents");
3464
3603
  }
3465
3604
  assertLinkApprovalCallback(onLinkApproval);
3466
- if (amountMinor === previous.checkpoint.amountMinor) return previous;
3467
3605
  const old = previous.checkpoint;
3468
- if (!await closeLinkRequest(wallet, previous.linkRequestId)) {
3606
+ checkpointCart(amountMinor, input.lineItems ?? old.cart.lineItems, input.totals);
3607
+ const current = await this.get(old.id);
3608
+ const released = CHECKPOINT_RELEASED_STAGES.includes(current.stage);
3609
+ if (!linkCardClosable(current)) {
3610
+ throw new LinkCardError({
3611
+ ...linkCardNotReplaced(current.stage),
3612
+ code: "LINK_CARD_NOT_REPLACED",
3613
+ checkpoint: current,
3614
+ linkRequestId: previous.linkRequestId,
3615
+ holdReleased: false
3616
+ });
3617
+ }
3618
+ const recordedRequestId = current.paymentReference?.id;
3619
+ if (recordedRequestId && recordedRequestId !== previous.linkRequestId) {
3620
+ throw new LinkCardError({
3621
+ 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.",
3622
+ code: "LINK_CARD_NOT_REPLACED",
3623
+ checkpoint: current,
3624
+ linkRequestId: recordedRequestId,
3625
+ holdReleased: released,
3626
+ 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).`
3627
+ });
3628
+ }
3629
+ if (amountMinor === old.amountMinor && !released && !current.linkRequestOpen && !CLOSED_LINK_RESULTS.has(previous)) {
3630
+ return previous;
3631
+ }
3632
+ if (signal?.aborted) {
3469
3633
  throw new LinkCardError({
3470
- message: "Conto could not cancel the earlier Link card. Nothing changed. Do not pay.",
3634
+ 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.",
3635
+ code: "LINK_CARD_ABORTED",
3636
+ checkpoint: current,
3637
+ linkRequestId: previous.linkRequestId,
3638
+ holdReleased: released,
3639
+ 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)."
3640
+ });
3641
+ }
3642
+ let refusal;
3643
+ const oldCardClosed = await closeLinkRequest(wallet, previous.linkRequestId, (error) => {
3644
+ refusal = error;
3645
+ });
3646
+ if (!oldCardClosed) {
3647
+ const walletDown = errorCode(refusal) === "LINK_WALLET_UNAVAILABLE";
3648
+ throw new LinkCardError({
3649
+ 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
3650
  code: "LINK_CARD_NOT_CANCELLED",
3472
- checkpoint: old,
3651
+ checkpoint: current,
3473
3652
  linkRequestId: previous.linkRequestId,
3474
- holdReleased: false,
3475
- retryGuidance: "Try replaceLinkCard again."
3653
+ holdReleased: released,
3654
+ 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
3655
  });
3477
3656
  }
3657
+ CLOSED_LINK_RESULTS.add(previous);
3478
3658
  try {
3479
3659
  await this.cancel(old.id);
3480
3660
  } 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;
3661
+ const latest = error instanceof ContoError && error.status === 409 ? await this.get(old.id).catch(() => void 0) : void 0;
3662
+ if (!released && (!latest || !CHECKPOINT_RELEASED_STAGES.includes(latest.stage))) {
3663
+ throw new LinkCardError({
3664
+ message: "The earlier Link request is cancelled, but Conto did not release the held spend. Do not confirm or pay.",
3665
+ code: "CHECKPOINT_NOT_CANCELLED",
3666
+ checkpoint: latest ?? current,
3667
+ linkRequestId: previous.linkRequestId,
3668
+ holdReleased: false,
3669
+ retryGuidance: "Try replaceLinkCard again. If the checkpoint was confirmed, report its outcome instead."
3670
+ });
3671
+ }
3483
3672
  }
3484
3673
  let checkpoint = await this.begin({
3485
3674
  idempotencyKey: `${old.idempotencyKey.slice(0, 200)}:final-${amountMinor}`,
@@ -3496,10 +3685,7 @@ var CheckoutClient = class {
3496
3685
  totals: input.totals
3497
3686
  });
3498
3687
  if (checkpoint.stage === "AWAITING_APPROVAL") {
3499
- checkpoint = await this.waitForApproval(checkpoint, {
3500
- ...ownerApprovalTimeoutMs !== void 0 ? { timeoutMs: ownerApprovalTimeoutMs } : {},
3501
- ...signal ? { signal } : {}
3502
- });
3688
+ checkpoint = await __privateMethod(this, _CheckoutClient_instances, waitForLinkOwner_fn).call(this, checkpoint, ownerApprovalTimeoutMs, signal);
3503
3689
  }
3504
3690
  return __privateMethod(this, _CheckoutClient_instances, linkCardFor_fn).call(this, checkpoint, wallet, {
3505
3691
  linkApprovalTimeoutMs,
@@ -3528,19 +3714,56 @@ recordLinkRequest_fn = async function(checkpoint, linkRequestId) {
3528
3714
  });
3529
3715
  }
3530
3716
  };
3717
+ waitForLinkOwner_fn = async function(checkpoint, timeoutMs, signal) {
3718
+ try {
3719
+ return await this.waitForApproval(checkpoint, {
3720
+ ...timeoutMs !== void 0 ? { timeoutMs } : {},
3721
+ ...signal ? { signal } : {}
3722
+ });
3723
+ } catch (error) {
3724
+ if (!signal?.aborted || error instanceof ContoError && error !== signal.reason) throw error;
3725
+ const latest = await this.get(checkpoint.id).catch(() => void 0);
3726
+ const linkRequestId = latest?.paymentReference?.id;
3727
+ const released = Boolean(latest && CHECKPOINT_RELEASED_STAGES.includes(latest.stage));
3728
+ throw new LinkCardError({
3729
+ 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.",
3730
+ code: "LINK_CARD_ABORTED",
3731
+ checkpoint: latest ?? checkpoint,
3732
+ ...linkRequestId ? { linkRequestId } : {},
3733
+ holdReleased: released,
3734
+ ...released && !linkRequestId ? {} : {
3735
+ retryGuidance: latest && !linkRequestId && linkCardClosable(latest) ? "Retry with the same input, or call checkout.cancel(error.checkpoint.id) to release the held spend." : openLinkRequestGuidance(latest)
3736
+ }
3737
+ });
3738
+ }
3739
+ };
3531
3740
  linkCardFor_fn = async function(checkpoint, wallet, options) {
3532
3741
  const { linkApprovalTimeoutMs = 10 * 6e4, signal, onLinkApproval } = options;
3742
+ if (checkpoint.linkRequestOpen) {
3743
+ throw new LinkCardError({
3744
+ message: `Conto's approval for this checkpoint ended while its Link request may still work (stage ${checkpoint.stage}). Do not pay.`,
3745
+ code: "CHECKOUT_CHECKPOINT_NOT_APPROVED",
3746
+ checkpoint,
3747
+ ...checkpoint.paymentReference ? { linkRequestId: checkpoint.paymentReference.id } : {},
3748
+ holdReleased: false,
3749
+ retryGuidance: LINK_REQUEST_OPEN_GUIDANCE
3750
+ });
3751
+ }
3533
3752
  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
- );
3753
+ throw new LinkCardError({
3754
+ ...linkCardNotIssued(checkpoint.stage),
3755
+ code: "LINK_CARD_NOT_ISSUED",
3756
+ checkpoint,
3757
+ holdReleased: false
3758
+ });
3538
3759
  }
3539
3760
  const deadline = Date.now() + linkApprovalTimeoutMs;
3540
3761
  const call = signal ? { signal } : {};
3541
- let linkRequestId;
3762
+ const recordedRequestId = checkpoint.paymentReference?.id;
3763
+ let linkRequestId = recordedRequestId;
3542
3764
  let approvalUrl;
3543
3765
  let createStarted = false;
3766
+ let createAnswered = false;
3544
3767
  try {
3545
3768
  const cardRequest = linkCardRequest(checkpoint);
3546
3769
  createStarted = true;
@@ -3553,6 +3776,7 @@ linkCardFor_fn = async function(checkpoint, wallet, options) {
3553
3776
  });
3554
3777
  }
3555
3778
  linkRequestId = request.id;
3779
+ createAnswered = true;
3556
3780
  if (request.amountMinor !== void 0 && request.amountMinor !== checkpoint.amountMinor) {
3557
3781
  throw new LinkFailureSignal({
3558
3782
  kind: LinkCardError,
@@ -3561,6 +3785,13 @@ linkCardFor_fn = async function(checkpoint, wallet, options) {
3561
3785
  });
3562
3786
  }
3563
3787
  checkpoint = await __privateMethod(this, _CheckoutClient_instances, recordLinkRequest_fn).call(this, checkpoint, request.id);
3788
+ if (checkpoint.stage !== "APPROVED") {
3789
+ throw new LinkFailureSignal({
3790
+ kind: LinkCardError,
3791
+ code: "LINK_CARD_NOT_ISSUED",
3792
+ message: "Conto no longer has this checkpoint approved, so no Link card was returned."
3793
+ });
3794
+ }
3564
3795
  if (LINK_WAITING_STATUSES.has(request.status)) {
3565
3796
  const details = linkApprovalDetails(request);
3566
3797
  approvalUrl = details.approvalUrl;
@@ -3599,37 +3830,74 @@ linkCardFor_fn = async function(checkpoint, wallet, options) {
3599
3830
  };
3600
3831
  } catch (error) {
3601
3832
  const failure = error instanceof LinkFailureSignal ? error.failure : linkFailure(error, signal);
3602
- if (!linkRequestId && createStarted && !linkCreatedNothing(error)) {
3833
+ const latest = createStarted || linkRequestId || !linkCardClosable(checkpoint) ? await this.get(checkpoint.id).catch(() => void 0) : checkpoint;
3834
+ const onRecordId = latest?.paymentReference?.id ?? recordedRequestId;
3835
+ const anotherFlow = !recordedRequestId && onRecordId && onRecordId !== linkRequestId;
3836
+ const alreadyReleased = Boolean(latest && CHECKPOINT_RELEASED_STAGES.includes(latest.stage));
3837
+ const unread = recordedRequestId || linkRequestId && !failure.linkClosed;
3838
+ if (latest ? !linkCardClosable(latest) || anotherFlow : unread) {
3839
+ const extraRequestId = onRecordId && linkRequestId !== onRecordId ? linkRequestId : void 0;
3840
+ const extraOpen = extraRequestId && !failure.linkClosed && !await closeLinkRequest(wallet, extraRequestId);
3841
+ const keptId = onRecordId ?? linkRequestId;
3842
+ throw new LinkCardError({
3843
+ 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.`,
3844
+ code: "LINK_CARD_NOT_CANCELLED",
3845
+ checkpoint: latest ?? checkpoint,
3846
+ ...keptId ? { linkRequestId: keptId } : {},
3847
+ holdReleased: alreadyReleased,
3848
+ retryGuidance: `${extraOpen ? `Cancel ${extraRequestId} with wallet.cancel() first, because it is not the request on record. ` : ""}${openLinkRequestGuidance(latest)}`
3849
+ });
3850
+ }
3851
+ if (createStarted && !createAnswered && !linkCreatedNothing(error)) {
3852
+ const blockedBy = !recordedRequestId && !alreadyReleased && failure.retryGuidance;
3603
3853
  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.`,
3854
+ 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
3855
  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."
3856
+ checkpoint: latest ?? checkpoint,
3857
+ ...recordedRequestId ? { linkRequestId: recordedRequestId } : {},
3858
+ holdReleased: alreadyReleased,
3859
+ 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
3860
  });
3610
3861
  }
3611
- if (linkRequestId && !failure.linkClosed && !await closeLinkRequest(wallet, linkRequestId)) {
3862
+ const requestsToClose = /* @__PURE__ */ new Set();
3863
+ if (linkRequestId && !failure.linkClosed) requestsToClose.add(linkRequestId);
3864
+ if (recordedRequestId && recordedRequestId !== linkRequestId) {
3865
+ requestsToClose.add(recordedRequestId);
3866
+ }
3867
+ const stillOpen = [];
3868
+ for (const id2 of requestsToClose) {
3869
+ if (!await closeLinkRequest(wallet, id2)) stillOpen.push(id2);
3870
+ }
3871
+ if (stillOpen.length > 0) {
3872
+ const held = onRecordId ? "" : ", and may be released when the approval lapses";
3873
+ const bothOpen = stillOpen.length > 1 ? ` Link requests ${stillOpen.join(" and ")} are both open.` : "";
3612
3874
  throw new LinkCardError({
3613
- message: `${failure.message} Conto could not cancel the Link request, so the held spend stays in place. Do not pay.`,
3875
+ 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
3876
  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)."
3877
+ checkpoint: latest ?? checkpoint,
3878
+ // The one on record first, because a card may already be out on it.
3879
+ linkRequestId: onRecordId && stillOpen.includes(onRecordId) ? onRecordId : stillOpen[0],
3880
+ ...approvalUrl && latest?.stage === "APPROVED" ? { approvalUrl } : {},
3881
+ holdReleased: alreadyReleased,
3882
+ 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
3883
  });
3621
3884
  }
3622
- const holdReleased = await this.cancel(checkpoint.id).then(
3623
- () => true,
3885
+ let cancelled;
3886
+ const holdReleased = alreadyReleased || await this.cancel(checkpoint.id).then(
3887
+ (result) => {
3888
+ cancelled = result;
3889
+ return true;
3890
+ },
3624
3891
  () => false
3625
3892
  );
3626
3893
  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.`,
3894
+ 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
3895
  code: failure.code,
3629
- checkpoint,
3896
+ checkpoint: cancelled ?? latest ?? checkpoint,
3630
3897
  ...linkRequestId ? { linkRequestId } : {},
3631
3898
  ...linkRequestId && approvalUrl ? { approvalUrl } : {},
3632
- holdReleased
3899
+ holdReleased,
3900
+ ...holdReleased && failure.retryGuidance ? { retryGuidance: failure.retryGuidance } : {}
3633
3901
  });
3634
3902
  }
3635
3903
  };