@agent-cards/checkout 0.3.1 → 0.4.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/CHANGELOG.md CHANGED
@@ -1,5 +1,19 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.4.1
4
+
5
+ - Stop describing every processor request rejection as a card decline or proof that nothing was charged. `ProcessorRefusedError` keeps its existing class and code, with neutral wording and an optional `processorError` containing bounded Razorpay reason, source, step and payment/order identifiers from the matching API/Vault release. Older failure records cannot recover details that were not retained.
6
+ - Hold further card requests within the attachment after a processor refusal, including when the merchant retries after the approval cooldown. Reconcile the merchant payment; only an explicit `retryAfterMerchantFailure({ status: 'failed' })` releases this hold and permits an immediate new attempt. This also applies to clear card declines. Ordinary user declines keep their approval cooldown. Do not automatically replace the attachment to bypass reconciliation.
7
+ - Add deterministic refusal, diagnostic sanitization and retry-guard regressions. These tests do not establish a successful Razorpay purchase or processor capture.
8
+
9
+ ## 0.4.0
10
+
11
+ - Add `controller.prepare({ psp: 'square', environment: 'production' | 'sandbox' })` to both browser adapters. The caller awaits cardholder consent and unlock before starting its first native Pay action. This requires the matching preparation API and Vault deployment.
12
+ - Bind one fresh request to the selected card, declared merchant, merchant origin, amount, currency and Square environment. Readiness expires after at most 30 seconds; preparation failure, expiry, cancellation, navigation and reuse fail closed. Binding creates no second approval link or SMS. Square token amounts remain display-only.
13
+ - Preserve native request deadlines and merchant-abort protections. The SDK never clicks Pay, changes Square timers or retries an abandoned prepared checkout. The post-submit relay and token handoff must still fit Square's native deadline; a disconnected or slow cardholder device can miss it.
14
+ - Recover a lost bind acknowledgement through preparation metadata for cancellation and reconciliation only. Started replay or unconfirmed cleanup remains unknown. A parent CDP page disconnect now also stops a pending request in a child iframe.
15
+ - Add preparation lifecycle tests and an isolated Chromium fixture that waits more than ten seconds before any card request, then permits one fresh request. These fixtures use no real processor and do not establish native Square or production checkout acceptance.
16
+
3
17
  ## 0.3.1
4
18
 
5
19
  - Detect a merchant request abort or owning frame/page closure while approval is pending. The attachment holds an unknown outcome and blocks automatic retries; an expired request is never reported as an authorized handoff.
package/README.md CHANGED
@@ -82,8 +82,8 @@ pair is shown and reported (`amountAuthority: 'display_only'`), not enforced.
82
82
  Then let your agent click "Pay" like it always does. `attachToCdp` pauses the
83
83
  request for approval and resumes it only while the merchant request remains
84
84
  live. Merchant timeouts still apply: Square's observed tokenization deadline
85
- is about 10 seconds for approval and token handoff, so delayed approval cannot
86
- complete that checkout.
85
+ is about 10 seconds for approval and token handoff. For human approval, use
86
+ `controller.prepare()` before the first Pay action as shown below.
87
87
 
88
88
  Playwright:
89
89
 
@@ -248,11 +248,19 @@ one. `amountAuthority` on every replay is `stripe_payment_intent`,
248
248
  refused to replay a confirm at it. An `ApprovalDeclinedError` with
249
249
  `code: 'intent_not_confirmable'`. Deliberately not "nothing was charged":
250
250
  check the intent at Stripe before retrying.
251
- - `ProcessorRefusedError`: the cardholder's device sent the card and the
252
- processor refused it outright (`pspErrorCode` is the processor's own code,
253
- such as Stripe's `card_declined`). Nothing was charged. An
254
- `ApprovalDeclinedError` with `code: 'processor_refused'`; the page's next
255
- attempt raises a fresh approval where the person can pick another card.
251
+ - `ProcessorRefusedError`: the cardholder's device reported a processor
252
+ request rejection. `pspErrorCode` carries the processor's code; optional
253
+ `processorError` carries bounded Razorpay reason, source, step and payment/order
254
+ identifiers when the API has them. A generic code such as `BAD_REQUEST_ERROR`
255
+ does not establish an issuer decline or prove no money moved. Reconcile the
256
+ merchant payment before retrying. This remains an `ApprovalDeclinedError`
257
+ with `code: 'processor_refused'` for compatibility.
258
+ The attachment records `status: 'declined', reason: 'processor_refused'`
259
+ and holds further card requests. After confirming merchant failure, call
260
+ `retryAfterMerchantFailure({ status: 'failed' })` to permit a deliberate new
261
+ attempt immediately, without waiting for the user-decline cooldown. Do not
262
+ automatically create a new attachment after this error;
263
+ the guard applies only within the existing attachment.
256
264
  - `CheckoutApiError` with `code === 'amount_unverifiable'`: Stripe could not
257
265
  be asked (502; the SDK retries twice, 500ms then 1500ms, before throwing)
258
266
  or the paused request lacked its client secret or publishable key (400).
@@ -392,7 +400,28 @@ do not reuse that token in a new attachment as a workaround. Configuration and
392
400
  unsupported-mode failures require fixing the integration. Bank flows requiring
393
401
  another confirmation and other stored-token chains remain unverified.
394
402
 
395
- Square saved-card checkout requires SDK 0.3.1 for merchant-request lifetime handling. Its observed native tokenization request expires after about 10 seconds, including approval-page loading, unlocking, approval, relay and token handoff. An approval that exceeds this window cannot finish that checkout. A subsequent SCA challenge has its own lifetime after the token handoff. This SDK does not pause Square's timers or automatically retry an expired checkout. If the merchant request aborts or its frame closes, the attachment blocks further card requests and tries to retire a pre-replay approval. A started replay or unconfirmed cancellation remains unknown. General delayed human approval is not supported by this Square flow.
403
+ Square's observed native tokenization request expires after about 10 seconds. SDK 0.4.0 adds approval before submission, requiring the matching preparation API and Vault deployment. Start human approval before the caller's first Pay action:
404
+
405
+ ```ts
406
+ const checkout = await attachToPlaywright(page, {
407
+ vault, user: 'your-user-id', merchant: 'Example merchant',
408
+ amountCents: 100, currency: 'USD',
409
+ onApprovalUrl: deliverPrivatelyToCardholder,
410
+ });
411
+ const preparation = await checkout.prepare({
412
+ psp: 'square',
413
+ environment: 'production', // explicit; use 'sandbox' for Square Sandbox
414
+ });
415
+ // The cardholder has consented and unlocked the same approval document.
416
+ // No processor request or payment has started.
417
+ await page.getByRole('button', { name: 'Pay', exact: true }).click();
418
+ ```
419
+
420
+ `prepare()` is available on both Playwright and raw CDP controllers. It requires `amountCents` and `currency`, must precede the first recognized card request, and returns only when the cardholder's device is ready. It delivers the preparation URL through `onApprovalUrl` and `onUserAction`; binding the subsequent authorization sends no second approval link or SMS. The phone page must stay open. Its selected card, merchant origin, declared merchant, amount, currency and Square environment bind one fresh request. The amount remains `display_only`; a Square token does not enforce the merchant's eventual charge amount.
421
+
422
+ Readiness lasts up to 30 seconds (`preparation.expiresAt`) and appears as `ready_to_submit`, with `paymentStatus: 'not_started'`. Trigger the caller-owned Pay action immediately after the promise resolves. Expiry, navigation, cancellation, an early request or a changed checkout fails closed. A preparation and its attachment are single use; reconcile any bound authorization before creating a new attachment. The SDK never clicks Pay, reuses a stale request, pauses Square timers, or automatically retries a failed prepared checkout.
423
+
424
+ After Pay, Square's native deadline still covers fresh authorization binding, device replay, relay and token handoff. A disconnected/backgrounded phone or slow transport can still miss it. A subsequent SCA challenge has its own lifetime after token handoff. If the merchant request aborts or its frame closes, the attachment blocks further requests and tries to retire the pre-replay authorization; a started replay or unconfirmed cancellation remains unknown. Without `prepare()`, approval loading and human interaction still share the native deadline, so delayed approval cannot finish that request.
396
425
 
397
426
  Lost authorization polling, local approval timeouts, or interrupted browser
398
427
  handoffs produce `outcome_unknown` and block automatic retry. The thrown
@@ -439,8 +468,11 @@ pnpm test:browser
439
468
  The browser fixtures never contact a payment service. The general suite uses
440
469
  `psp.invalid`; the Stripe continuation suite forces `api.stripe.com` through an
441
470
  allowlisted loopback proxy and a temporary self-signed TLS stub (requires the
442
- `openssl` CLI). All other proxy destinations are rejected. Both suites use an
443
- in-process Agentcard API fixture and loopback merchant pages. It proves nested-frame pause/resume, agent control during approval,
471
+ `openssl` CLI). All other proxy destinations are rejected. These suites use an
472
+ in-process Agentcard API fixture and loopback merchant pages. The preparation
473
+ fixture denies all external traffic, waits more than ten seconds before any
474
+ card request, and then checks one fresh request with an unchanged ten-second
475
+ fixture abort timer. It exercises SDK ordering, not the native Square SDK. It proves nested-frame pause/resume, agent control during approval,
444
476
  post-payment tasks in the same page, decline/expiry/cancel, unknown-outcome retry
445
477
  blocking, explicit unsupported endpoint behavior, and blocking an immediate real-browser
446
478
  Stripe token-to-intent fetch chain, including unrelated first intents, changed
package/dist/cdp.js CHANGED
@@ -1,5 +1,6 @@
1
1
  import { BUILTIN_REGISTRY, cardUrlPatterns } from './registry.js';
2
- import { ApprovalDeclinedError, ApprovalTimeoutError, CardEncryptedError, CheckoutApiError, PaymentOutcomeUnknownError, UnsupportedModeError, redactUrl, } from './client.js';
2
+ import { ApprovalDeclinedError, ApprovalTimeoutError, CardEncryptedError, CheckoutApiError, PaymentOutcomeUnknownError, ProcessorRefusedError, UnsupportedModeError, redactUrl, } from './client.js';
3
+ import { PreparationGate } from './preparation.js';
3
4
  import { substituteEncryptedFields } from './substitute.js';
4
5
  import { hostedFormSubmittedPage } from './hosted-form.js';
5
6
  import { CheckoutLifecycle, paymentEndpointGuards } from './lifecycle.js';
@@ -79,6 +80,11 @@ const APPROVAL_COOLDOWN_MS = 5_000;
79
80
  * quiet window absorbs the burst; the next request past it is judged afresh.
80
81
  */
81
82
  function isApprovalOutcome(err) {
83
+ // Processor refusals are held by the lifecycle until explicit merchant
84
+ // reconciliation and retry. A second timer would outlive that release and
85
+ // reject the application's deliberate retry even after confirmed failure.
86
+ if (err instanceof ProcessorRefusedError)
87
+ return false;
82
88
  if (err instanceof CheckoutApiError)
83
89
  return err.code === 'duplicate_submission';
84
90
  return err instanceof ApprovalDeclinedError || err instanceof ApprovalTimeoutError;
@@ -97,7 +103,9 @@ function isApprovalOutcome(err) {
97
103
  * a misconfiguration or an unsupported PSP. Nothing a person does changes
98
104
  * those, so asking again is pure waste.
99
105
  *
100
- * Everything else stays retryable. A 5xx or a 429 clears on its own, and a
106
+ * Other errors may be retryable unless the lifecycle holds the attachment
107
+ * for merchant reconciliation, as it does after a processor refusal.
108
+ * A 5xx or a 429 clears on its own, and a
101
109
  * decline or a timeout is answered by the cooldown above rather than by
102
110
  * killing the page: the person said no to one authorization, not to every
103
111
  * checkout they will ever make in this session. A 409 duplicate_submission
@@ -320,6 +328,14 @@ function merchantAttempt(lifecycle) {
320
328
  export async function attachToCdp(cdp, pageSessionId, opts) {
321
329
  opts = safeOptions(opts);
322
330
  const lifecycle = new CheckoutLifecycle(opts);
331
+ let preparationFrameId;
332
+ const preparationGate = new PreparationGate(opts, lifecycle, async () => {
333
+ const tree = await cdp.send('Page.getFrameTree', {}, pageSessionId);
334
+ if (typeof tree?.frameTree?.frame?.url !== 'string')
335
+ throw new Error('merchant_document_unavailable');
336
+ preparationFrameId = tree.frameTree.frame.id;
337
+ return tree.frameTree.frame.url;
338
+ });
323
339
  const guards = paymentEndpointGuards(opts.paymentEndpoints);
324
340
  const armed = new Set();
325
341
  // Set once a failure proves that retrying cannot help; see isTerminal.
@@ -354,6 +370,18 @@ export async function attachToCdp(cdp, pageSessionId, opts) {
354
370
  armed.add(key);
355
371
  };
356
372
  cdp.on(async (method, params, sessionId) => {
373
+ if (((method === 'Page.frameNavigated' && !params.frame?.parentId) || (method === 'Page.navigatedWithinDocument' && preparationFrameId && params.frameId === preparationFrameId)) && sessionId === pageSessionId) {
374
+ preparationGate.invalidate('merchant_document_changed');
375
+ if (preparationGate.isEngaged())
376
+ activeRequest?.attempt.stop();
377
+ return;
378
+ }
379
+ if ((method === 'Inspector.detached' && sessionId === pageSessionId)
380
+ || (method === 'Target.detachedFromTarget' && params.sessionId === pageSessionId)) {
381
+ preparationGate.invalidate('merchant_document_closed');
382
+ // The root page owns every attached OOPIF; its loss ends child requests too.
383
+ activeRequest?.attempt.stop();
384
+ }
357
385
  if (method === 'Network.loadingFailed') {
358
386
  if (activeRequest && activeRequest.networkId === params.requestId && activeRequest.sessionId === sessionId)
359
387
  activeRequest.attempt.stop();
@@ -387,6 +415,7 @@ export async function attachToCdp(cdp, pageSessionId, opts) {
387
415
  const { requestId, request, resourceType, networkId, frameId } = params;
388
416
  if (!opts.vault.isCardRequest(request.url, request.method)) {
389
417
  if (guards.matches(request.url, request.method)) {
418
+ preparationGate.invalidate('unsupported_checkout');
390
419
  lifecycle.unsupported();
391
420
  opts.onEvent?.({ type: 'unsupported_checkout', detail: { url: redactUrl(request.url), method: request.method } });
392
421
  await cdp.send('Fetch.failRequest', { requestId, errorReason: 'Aborted' }, sessionId).catch(() => { });
@@ -395,11 +424,22 @@ export async function attachToCdp(cdp, pageSessionId, opts) {
395
424
  await cdp.send('Fetch.continueRequest', { requestId }, sessionId).catch(() => { });
396
425
  return;
397
426
  }
427
+ let preparation;
428
+ try {
429
+ preparation = preparationGate.claim(request.url);
430
+ }
431
+ catch (error) {
432
+ opts.onEvent?.({ type: 'blocked', detail: failureSummary(error) });
433
+ await cdp.send('Fetch.failRequest', { requestId, errorReason: 'Aborted' }, sessionId).catch(() => { });
434
+ return;
435
+ }
398
436
  // Same stop condition as the Playwright adapter: once a failure proves
399
437
  // retrying is pointless, fail the request without calling the API again.
400
438
  if (terminal || lifecycle.isBlocked() || awaitingApproval || Date.now() < quietUntil) {
401
439
  const why = terminal ?? (lifecycle.isBlocked() ? lifecycle.getState().status : awaitingApproval ? 'an approval is already outstanding' : 'awaiting approval cooldown');
402
440
  opts.onEvent?.({ type: 'blocked', detail: why instanceof Error ? failureSummary(why) : String(why) });
441
+ if (preparation)
442
+ preparationGate.retireUnboundClaim();
403
443
  await cdp.send('Fetch.failRequest', { requestId, errorReason: 'Aborted' }, sessionId).catch(() => { });
404
444
  return;
405
445
  }
@@ -434,18 +474,22 @@ export async function attachToCdp(cdp, pageSessionId, opts) {
434
474
  attempt.assertLive();
435
475
  }
436
476
  activeRequest = { attempt, networkId, sessionId, frameId };
477
+ if (preparation)
478
+ await preparationGate.assertDocument();
479
+ attempt.assertLive();
437
480
  const replay = await opts.vault.authorize({
438
481
  user: opts.user,
439
482
  merchant: opts.merchant,
440
483
  amount: opts.amount,
441
484
  amountCents: opts.amountCents,
442
485
  currency: opts.currency,
443
- cardId: opts.cardId,
486
+ cardId: preparation?.cardId ?? opts.cardId,
487
+ preparation,
444
488
  timeoutMs: opts.timeoutMs,
445
489
  signal: lifecycle.abort.signal,
446
490
  merchantSignal: attempt.signal,
447
491
  onAuthorizationCreated: (id) => lifecycle.approvalCreated(id),
448
- onApprovalUrl: (url) => { if (!attempt.signal.aborted) {
492
+ onApprovalUrl: (url) => { if (!preparation && !attempt.signal.aborted) {
449
493
  lifecycle.approvalUrl(url);
450
494
  return opts.onApprovalUrl?.(url);
451
495
  } },
@@ -528,6 +572,8 @@ export async function attachToCdp(cdp, pageSessionId, opts) {
528
572
  activeRequest = null;
529
573
  awaitingApproval = false;
530
574
  lifecycle.end();
575
+ if (preparation)
576
+ preparationGate.retireUnboundClaim();
531
577
  }
532
578
  });
533
579
  await arm(pageSessionId);
@@ -552,6 +598,11 @@ export async function attachToPlaywright(page, opts) {
552
598
  throw new Error('Service workers are active; use a checkout context created with serviceWorkers: "block".');
553
599
  }
554
600
  const lifecycle = new CheckoutLifecycle(opts);
601
+ const preparationGate = new PreparationGate(opts, lifecycle, async () => {
602
+ if (page.isClosed?.() || typeof page.url !== 'function')
603
+ throw new Error('merchant_document_unavailable');
604
+ return page.url();
605
+ });
555
606
  const guards = paymentEndpointGuards(opts.paymentEndpoints);
556
607
  // Playwright's own routing, NOT a hand-rolled CDP session.
557
608
  //
@@ -577,8 +628,15 @@ export async function attachToPlaywright(page, opts) {
577
628
  if (activeRequest && activeRequest.request === request)
578
629
  activeRequest.attempt.stop();
579
630
  });
580
- page.on?.('close', () => activeRequest?.attempt.stop());
581
- page.on?.('crash', () => activeRequest?.attempt.stop());
631
+ page.on?.('close', () => { preparationGate.invalidate('merchant_document_closed'); activeRequest?.attempt.stop(); });
632
+ page.on?.('crash', () => { preparationGate.invalidate('merchant_document_closed'); activeRequest?.attempt.stop(); });
633
+ page.on?.('framenavigated', (frame) => {
634
+ if (frame === page.mainFrame?.()) {
635
+ preparationGate.invalidate('merchant_document_changed');
636
+ if (preparationGate.isEngaged())
637
+ activeRequest?.attempt.stop();
638
+ }
639
+ });
582
640
  page.on?.('framedetached', (frame) => {
583
641
  if (activeRequest?.frames.includes(frame))
584
642
  activeRequest.attempt.stop();
@@ -592,12 +650,21 @@ export async function attachToPlaywright(page, opts) {
592
650
  // untouched or the browser's CORS check fails on our synthetic answer.
593
651
  if (!opts.vault.isCardRequest(request.url(), request.method())) {
594
652
  if (guards.matches(request.url(), request.method())) {
653
+ preparationGate.invalidate('unsupported_checkout');
595
654
  lifecycle.unsupported();
596
655
  opts.onEvent?.({ type: 'unsupported_checkout', detail: { url: redactUrl(request.url()), method: request.method() } });
597
656
  return route.abort('aborted');
598
657
  }
599
658
  return route.fallback();
600
659
  }
660
+ let preparation;
661
+ try {
662
+ preparation = preparationGate.claim(request.url());
663
+ }
664
+ catch (error) {
665
+ opts.onEvent?.({ type: 'blocked', detail: failureSummary(error) });
666
+ return route.abort('aborted');
667
+ }
601
668
  // Fail closed and stay quiet: no card may reach the PSP, but neither may
602
669
  // the page's retry loop turn into a stream of doomed API calls. Every
603
670
  // abort in this adapter is 'aborted' (ERR_ABORTED), the same code the
@@ -606,6 +673,8 @@ export async function attachToPlaywright(page, opts) {
606
673
  if (terminal || lifecycle.isBlocked() || awaitingApproval || Date.now() < quietUntil) {
607
674
  const why = terminal ?? (lifecycle.isBlocked() ? lifecycle.getState().status : awaitingApproval ? 'an approval is already outstanding' : 'awaiting approval cooldown');
608
675
  opts.onEvent?.({ type: 'blocked', detail: why instanceof Error ? failureSummary(why) : String(why) });
676
+ if (preparation)
677
+ preparationGate.retireUnboundClaim();
609
678
  return route.abort('aborted');
610
679
  }
611
680
  // Reserved before anything that could yield, matching attachToCdp.
@@ -632,6 +701,8 @@ export async function attachToPlaywright(page, opts) {
632
701
  opts.onEvent?.({ type: 'card_request_paused', detail: { url: redactUrl(request.url()) } });
633
702
  lifecycle.begin();
634
703
  activeRequest = { request, frames, attempt };
704
+ if (preparation)
705
+ await preparationGate.assertDocument();
635
706
  assertRequestLive();
636
707
  const replay = await opts.vault.authorize({
637
708
  user: opts.user,
@@ -639,12 +710,13 @@ export async function attachToPlaywright(page, opts) {
639
710
  amount: opts.amount,
640
711
  amountCents: opts.amountCents,
641
712
  currency: opts.currency,
642
- cardId: opts.cardId,
713
+ cardId: preparation?.cardId ?? opts.cardId,
714
+ preparation,
643
715
  timeoutMs: opts.timeoutMs,
644
716
  signal: lifecycle.abort.signal,
645
717
  merchantSignal: attempt.signal,
646
718
  onAuthorizationCreated: (id) => lifecycle.approvalCreated(id),
647
- onApprovalUrl: (url) => { if (!attempt.signal.aborted) {
719
+ onApprovalUrl: (url) => { if (!preparation && !attempt.signal.aborted) {
648
720
  lifecycle.approvalUrl(url);
649
721
  return opts.onApprovalUrl?.(url);
650
722
  } },
@@ -702,6 +774,8 @@ export async function attachToPlaywright(page, opts) {
702
774
  activeRequest = null;
703
775
  awaitingApproval = false;
704
776
  lifecycle.end();
777
+ if (preparation)
778
+ preparationGate.retireUnboundClaim();
705
779
  }
706
780
  });
707
781
  return lifecycle;
package/dist/client.d.ts CHANGED
@@ -98,6 +98,46 @@ export interface HostedFormReplay {
98
98
  }
99
99
  /** What authorize() resolves with; branch on `mode` (absent means token). */
100
100
  export type ReplayResponse = TokenReplay | CseReplay | HostedFormReplay;
101
+ export interface PrepareCheckoutOptions {
102
+ psp: 'square';
103
+ /** The processor environment, independent of your Agentcard client's mode. */
104
+ environment: 'production' | 'sandbox';
105
+ signal?: AbortSignal;
106
+ }
107
+ export interface PrepareCheckoutInput extends PrepareCheckoutOptions {
108
+ user: string;
109
+ merchant: string;
110
+ amountCents: number;
111
+ currency: string;
112
+ cardId?: string;
113
+ merchantOrigin: string;
114
+ checkoutKey: string;
115
+ timeoutMs?: number;
116
+ onPreparationCreated?: (id: string) => void;
117
+ onApprovalUrl?: (url: string) => void;
118
+ }
119
+ /** Real user consent and an unlocked device; no processor request or payment yet. */
120
+ export interface PreparedCheckout {
121
+ readonly id: string;
122
+ readonly status: 'ready';
123
+ readonly psp: 'square';
124
+ readonly environment: 'production' | 'sandbox';
125
+ readonly expiresAt: string;
126
+ readonly cardId: string;
127
+ readonly user: string;
128
+ readonly merchant: string;
129
+ readonly amountCents: number;
130
+ readonly currency: string;
131
+ readonly merchantOrigin: string;
132
+ readonly checkoutKey: string;
133
+ readonly paymentStatus: 'not_started';
134
+ readonly amountAuthority: 'display_only';
135
+ }
136
+ export declare class CheckoutPreparationError extends Error {
137
+ preparationId: string | null;
138
+ reason: string;
139
+ constructor(preparationId: string | null, reason: string);
140
+ }
101
141
  export interface AuthorizeInput {
102
142
  /** Your identifier for the person whose card should pay. */
103
143
  user: string;
@@ -141,6 +181,8 @@ export interface AuthorizeInput {
141
181
  onAuthorizationCreated?: (authorizationId: string) => void;
142
182
  /** Called once with the URL to surface to the user, if you deliver it yourself. */
143
183
  onApprovalUrl?: (url: string) => void;
184
+ /** One-use preparation returned by this client. Never resumes an older request. */
185
+ preparation?: PreparedCheckout;
144
186
  }
145
187
  export declare class CardEncryptedError extends Error {
146
188
  psp: string;
@@ -229,20 +271,27 @@ export declare class IntentNotConfirmableError extends ApprovalDeclinedError {
229
271
  readonly code: "intent_not_confirmable";
230
272
  constructor(authorizationId: string);
231
273
  }
274
+ /** Bounded processor identifiers, never a raw response or free-form description. */
275
+ export interface RazorpayProcessorError {
276
+ reason?: string;
277
+ source?: string;
278
+ step?: string;
279
+ payment_id?: string;
280
+ order_id?: string;
281
+ }
232
282
  /**
233
- * The cardholder's device sent the card and the processor refused it
234
- * outright (a card decline, a bad CVC, an invalid request). Nothing was
235
- * charged; the authorization is `declined` with reason `processor_refused`
236
- * and the processor's own code in `pspErrorCode` (Stripe's `card_declined`,
237
- * `incorrect_cvc`, …). A decline like any other for the adapters: the paused
238
- * request is aborted and the page's retry gets a fresh approval, where the
239
- * person can pick another card.
283
+ * The device reported a processor request rejection. A generic processor
284
+ * code does not establish an issuer decline or prove no money moved. Read
285
+ * the bounded processor evidence and reconcile the merchant before retrying.
286
+ * The authorization remains `declined` with reason `processor_refused` for
287
+ * compatibility; no successful processor response is handed to the merchant.
240
288
  */
241
289
  export declare class ProcessorRefusedError extends ApprovalDeclinedError {
242
290
  authorizationId: string;
243
291
  pspErrorCode: string | null;
244
292
  readonly code: "processor_refused";
245
- constructor(authorizationId: string, pspErrorCode: string | null);
293
+ readonly processorError: RazorpayProcessorError | null;
294
+ constructor(authorizationId: string, pspErrorCode: string | null, processorError?: RazorpayProcessorError | null);
246
295
  }
247
296
  /**
248
297
  * A non-2xx from the Agentcard API, carrying the status so callers can tell a
@@ -315,6 +364,8 @@ export declare class VaultClient {
315
364
  private readonly pollIntervalMs;
316
365
  private readonly unverifiableRetryDelaysMs;
317
366
  private registry;
367
+ private readonly preparations;
368
+ private readonly usedPreparations;
318
369
  constructor(opts: VaultClientOptions);
319
370
  /** Refresh recognizers from the API so new PSPs work without a redeploy. */
320
371
  syncRegistry(): Promise<void>;
@@ -332,12 +383,18 @@ export declare class VaultClient {
332
383
  * these patterns pause.
333
384
  */
334
385
  cardUrlPatterns(): string[];
386
+ /** Wait for real device approval before the caller starts native tokenization. */
387
+ prepareCheckout(input: PrepareCheckoutInput): Promise<PreparedCheckout>;
388
+ /** Cancel only an unconsumed preparation; a bound request is reconciled separately. */
389
+ cancelPreparation(id: string): Promise<void>;
335
390
  /**
336
391
  * Hand us a paused tokenization request. We ask the cardholder to approve,
337
392
  * their device supplies the card and calls the merchant, and you get back the
338
393
  * response to replay into the browser. Your process never sees a card.
339
394
  */
340
395
  authorize(input: AuthorizeInput): Promise<ReplayResponse>;
396
+ /** A lost bind acknowledgement must never resume the request. Recover metadata only for safe cleanup. */
397
+ private retireUncertainPreparation;
341
398
  /** Retire only a pre-replay authorization. A 409 or missing response remains unknown. */
342
399
  cancelAuthorization(authorizationId: string): Promise<{
343
400
  id: string;
package/dist/client.js CHANGED
@@ -6,6 +6,16 @@ import { BUILTIN_REGISTRY, cardUrlPatterns as deriveCardUrlPatterns, findRecogni
6
6
  */
7
7
  export const SUPPORTED_MODES = ['token', 'cse', 'hosted_form'];
8
8
  const AMOUNT_AUTHORITIES = ['stripe_payment_intent', 'hosted_form_sum', 'display_only'];
9
+ export class CheckoutPreparationError extends Error {
10
+ preparationId;
11
+ reason;
12
+ constructor(preparationId, reason) {
13
+ super(`Checkout preparation unavailable: ${reason}`);
14
+ this.preparationId = preparationId;
15
+ this.reason = reason;
16
+ this.name = 'CheckoutPreparationError';
17
+ }
18
+ }
9
19
  export class CardEncryptedError extends Error {
10
20
  psp;
11
21
  constructor(psp) {
@@ -118,25 +128,49 @@ export class IntentNotConfirmableError extends ApprovalDeclinedError {
118
128
  + 'it cannot be confirmed again. Check the intent at Stripe before retrying.';
119
129
  }
120
130
  }
131
+ function parseProcessorError(value) {
132
+ if (!value || typeof value !== 'object' || Array.isArray(value))
133
+ return null;
134
+ const fields = value;
135
+ const result = {};
136
+ const exact = (pattern, field) => pattern.exec(field)?.[0] === field;
137
+ for (const [key, field] of Object.entries(fields)) {
138
+ if (typeof field !== 'string')
139
+ return null;
140
+ if (key === 'reason' || key === 'source' || key === 'step') {
141
+ if (!exact(/^[a-z][a-z_]{0,63}$/, field))
142
+ return null;
143
+ result[key] = field;
144
+ }
145
+ else if (key === 'payment_id' || key === 'order_id') {
146
+ if (!exact(key === 'payment_id' ? /^pay_[A-Za-z0-9]{1,128}$/ : /^order_[A-Za-z0-9]{1,128}$/, field))
147
+ return null;
148
+ result[key] = field;
149
+ }
150
+ else
151
+ return null;
152
+ }
153
+ return Object.keys(result).length ? result : null;
154
+ }
121
155
  /**
122
- * The cardholder's device sent the card and the processor refused it
123
- * outright (a card decline, a bad CVC, an invalid request). Nothing was
124
- * charged; the authorization is `declined` with reason `processor_refused`
125
- * and the processor's own code in `pspErrorCode` (Stripe's `card_declined`,
126
- * `incorrect_cvc`, …). A decline like any other for the adapters: the paused
127
- * request is aborted and the page's retry gets a fresh approval, where the
128
- * person can pick another card.
156
+ * The device reported a processor request rejection. A generic processor
157
+ * code does not establish an issuer decline or prove no money moved. Read
158
+ * the bounded processor evidence and reconcile the merchant before retrying.
159
+ * The authorization remains `declined` with reason `processor_refused` for
160
+ * compatibility; no successful processor response is handed to the merchant.
129
161
  */
130
162
  export class ProcessorRefusedError extends ApprovalDeclinedError {
131
163
  authorizationId;
132
164
  pspErrorCode;
133
165
  code = 'processor_refused';
134
- constructor(authorizationId, pspErrorCode) {
166
+ processorError;
167
+ constructor(authorizationId, pspErrorCode, processorError = null) {
135
168
  super('processor_refused');
136
169
  this.authorizationId = authorizationId;
137
170
  this.pspErrorCode = pspErrorCode;
138
171
  this.name = 'ProcessorRefusedError';
139
- this.message = `the processor refused the card on ${authorizationId}${pspErrorCode ? ` (${pspErrorCode})` : ''}. Nothing was charged.`;
172
+ this.processorError = parseProcessorError(processorError);
173
+ this.message = `the processor rejected the payment request on ${authorizationId}${pspErrorCode ? ` (${pspErrorCode})` : ''}. Check the merchant payment status before retrying.`;
140
174
  }
141
175
  }
142
176
  /**
@@ -223,6 +257,8 @@ export class VaultClient {
223
257
  pollIntervalMs;
224
258
  unverifiableRetryDelaysMs;
225
259
  registry;
260
+ preparations = new WeakSet();
261
+ usedPreparations = new WeakSet();
226
262
  constructor(opts) {
227
263
  this.opts = opts;
228
264
  this.baseUrl = (opts.baseUrl ?? 'https://api.agentcard.sh').replace(/\/$/, '');
@@ -274,12 +310,121 @@ export class VaultClient {
274
310
  cardUrlPatterns() {
275
311
  return deriveCardUrlPatterns(this.registry);
276
312
  }
313
+ /** Wait for real device approval before the caller starts native tokenization. */
314
+ async prepareCheckout(input) {
315
+ input = { ...input };
316
+ const fail = (reason, id = null) => new CheckoutPreparationError(id, reason);
317
+ if (input.psp !== 'square' || !['production', 'sandbox'].includes(input.environment))
318
+ throw fail('unsupported_processor');
319
+ if (!Number.isSafeInteger(input.amountCents) || input.amountCents <= 0 || typeof input.currency !== 'string' || !/^[a-z]{3}$/i.test(input.currency))
320
+ throw fail('amount_required');
321
+ const origin = new URL(input.merchantOrigin);
322
+ if (!(origin.protocol === 'https:' || (origin.protocol === 'http:' && origin.hostname === 'localhost')) || origin.origin !== input.merchantOrigin)
323
+ throw fail('merchant_origin_invalid');
324
+ if (!input.checkoutKey || !input.user || !input.merchant)
325
+ throw fail('checkout_context_required');
326
+ const timeoutMs = input.timeoutMs ?? 15 * 60_000;
327
+ if (!Number.isInteger(timeoutMs) || timeoutMs <= 0 || timeoutMs > 2_147_483_647)
328
+ throw fail('timeout_invalid');
329
+ const signal = input.signal ? AbortSignal.any([input.signal, AbortSignal.timeout(timeoutMs)]) : AbortSignal.timeout(timeoutMs);
330
+ if (signal.aborted)
331
+ throw fail('cancelled');
332
+ let id = null;
333
+ let ready = false;
334
+ try {
335
+ // Drain a sent creation even after caller cancellation to retire its ID.
336
+ // The separate stop signal prevents dispatch after a slow OAuth exchange.
337
+ const created = await this.post('/v2/checkout/preparations', {
338
+ user: input.user, merchant: input.merchant, amount_cents: input.amountCents, currency: input.currency.toLowerCase(),
339
+ ...(input.cardId ? { card_id: input.cardId } : {}), psp: input.psp, mode: 'token',
340
+ environment: input.environment, checkout_key: input.checkoutKey, merchant_origin: input.merchantOrigin,
341
+ }, AbortSignal.timeout(30_000), signal);
342
+ if (!created || typeof created.id !== 'string' || !/^cprep_[A-Za-z0-9_-]{1,128}$/.test(created.id))
343
+ throw fail('create_unconfirmed');
344
+ const preparationId = created.id;
345
+ id = preparationId;
346
+ try {
347
+ Promise.resolve(input.onPreparationCreated?.(preparationId)).catch(() => { });
348
+ }
349
+ catch { /* observer only */ }
350
+ if (signal.aborted)
351
+ throw fail('cancelled', id);
352
+ if (typeof created.approvalUrl !== 'string')
353
+ throw fail('approval_url_missing', id);
354
+ try {
355
+ Promise.resolve(input.onApprovalUrl?.(created.approvalUrl)).catch(() => { });
356
+ }
357
+ catch { /* observer only */ }
358
+ while (!signal.aborted) {
359
+ const state = await this.get(`/v2/checkout/preparations/${id}`, signal);
360
+ if (signal.aborted)
361
+ throw fail('cancelled', id);
362
+ if (state?.id !== id)
363
+ throw fail('status_unconfirmed', id);
364
+ if (state.status === 'ready') {
365
+ const expiry = Date.parse(state.ready_expires_at);
366
+ if (!Number.isFinite(expiry) || expiry <= Date.now() || typeof state.card_id !== 'string' || !state.card_id
367
+ || state.payment_status !== 'not_started' || state.amount_authority !== 'display_only'
368
+ || state.user !== input.user || state.merchant !== input.merchant || state.merchant_origin !== input.merchantOrigin
369
+ || state.amount_cents !== input.amountCents || state.currency !== input.currency.toLowerCase()
370
+ || state.psp !== 'square' || state.mode !== 'token' || state.environment !== input.environment
371
+ || state.checkout_key !== input.checkoutKey)
372
+ throw fail('ready_unconfirmed', id);
373
+ const prepared = Object.freeze({
374
+ id: preparationId, status: 'ready', psp: input.psp, environment: input.environment, expiresAt: state.ready_expires_at,
375
+ cardId: state.card_id, user: input.user, merchant: input.merchant, amountCents: input.amountCents,
376
+ currency: input.currency.toLowerCase(), merchantOrigin: input.merchantOrigin, checkoutKey: input.checkoutKey,
377
+ paymentStatus: 'not_started', amountAuthority: 'display_only',
378
+ });
379
+ this.preparations.add(prepared);
380
+ ready = true;
381
+ return prepared;
382
+ }
383
+ if (state.status !== 'awaiting_approval')
384
+ throw fail(['cancelled', 'expired', 'bound'].includes(state.status) ? state.status : 'status_unconfirmed', id);
385
+ await interruptibleSleep(this.pollIntervalMs, signal);
386
+ }
387
+ throw fail('cancelled', id);
388
+ }
389
+ catch (error) {
390
+ if (error instanceof CheckoutPreparationError)
391
+ throw error;
392
+ throw fail(signal.aborted ? 'cancelled' : 'preparation_unconfirmed', id);
393
+ }
394
+ finally {
395
+ if (id && !ready)
396
+ await this.cancelPreparation(id).catch(() => { });
397
+ }
398
+ }
399
+ /** Cancel only an unconsumed preparation; a bound request is reconciled separately. */
400
+ async cancelPreparation(id) {
401
+ if (!/^cprep_[A-Za-z0-9_-]{1,128}$/.test(id))
402
+ throw new CheckoutPreparationError(null, 'id_invalid');
403
+ const state = await this.post(`/v2/checkout/preparations/${id}/cancel`, {}, AbortSignal.timeout(5_000));
404
+ if (state?.id !== id || !['cancelled', 'expired'].includes(state.status))
405
+ throw new CheckoutPreparationError(id, 'cancel_unconfirmed');
406
+ }
277
407
  /**
278
408
  * Hand us a paused tokenization request. We ask the cardholder to approve,
279
409
  * their device supplies the card and calls the merchant, and you get back the
280
410
  * response to replay into the browser. Your process never sees a card.
281
411
  */
282
412
  async authorize(input) {
413
+ const preparation = input.preparation;
414
+ if (preparation) {
415
+ if (!this.preparations.has(preparation) || this.usedPreparations.has(preparation))
416
+ throw new CheckoutPreparationError(preparation.id ?? null, 'already_used_or_foreign');
417
+ // Consume locally before any await, including OAuth, and never recycle it.
418
+ this.usedPreparations.add(preparation);
419
+ const url = new URL(input.request.url);
420
+ const host = preparation.environment === 'production' ? 'pci-connect.squareup.com' : 'pci-connect.squareupsandbox.com';
421
+ if (Date.parse(preparation.expiresAt) <= Date.now())
422
+ throw new CheckoutPreparationError(preparation.id, 'expired');
423
+ if (input.user !== preparation.user || input.merchant !== preparation.merchant || input.amountCents !== preparation.amountCents
424
+ || input.currency?.toLowerCase() !== preparation.currency || input.cardId !== preparation.cardId
425
+ || url.origin !== `https://${host}` || url.pathname !== '/v2/card-nonce' || url.username || url.password)
426
+ throw new CheckoutPreparationError(preparation.id, 'checkout_changed');
427
+ }
283
428
  if (input.signal?.aborted)
284
429
  throw new CheckoutCancelledError();
285
430
  if (input.merchantSignal?.aborted)
@@ -335,6 +480,7 @@ export class VaultClient {
335
480
  // the recognizer and refuses a disagreement before a row exists.
336
481
  mode,
337
482
  ...(input.cardId ? { cardId: input.cardId } : {}),
483
+ ...(preparation ? { preparation_id: preparation.id, checkout_key: preparation.checkoutKey, merchant_origin: preparation.merchantOrigin } : {}),
338
484
  request: {
339
485
  url: input.request.url,
340
486
  method: input.request.method,
@@ -346,21 +492,35 @@ export class VaultClient {
346
492
  created = await this.createAuthorization(payload, input.currency, AbortSignal.any([operationSignal, AbortSignal.timeout(30_000)]), input.merchantSignal);
347
493
  }
348
494
  catch (error) {
349
- if (error instanceof PaymentOutcomeUnknownError)
495
+ if (error instanceof PaymentOutcomeUnknownError) {
496
+ if (preparation && !error.authorizationId)
497
+ throw new PaymentOutcomeUnknownError(await this.retireUncertainPreparation(preparation, input.onAuthorizationCreated), error.reason);
350
498
  throw error;
499
+ }
500
+ if (preparation && error instanceof CheckoutApiError && error.code === 'preparation_bound') {
501
+ const id = typeof error.details.authorization_id === 'string' && /^cauth_[A-Za-z0-9_-]+$/.test(error.details.authorization_id) ? error.details.authorization_id : null;
502
+ if (id) {
503
+ try {
504
+ Promise.resolve(input.onAuthorizationCreated?.(id)).catch(() => { });
505
+ }
506
+ catch { /* observer only */ }
507
+ }
508
+ throw new PaymentOutcomeUnknownError(id, 'preparation_already_bound');
509
+ }
351
510
  // A missing answer or generic 5xx can hide a committed row and a delivered approval link.
352
511
  // Only the documented pre-create read-back errors prove it is safe to retry.
353
512
  const safeReadFailure = error instanceof CheckoutApiError
354
513
  && error.status === 502 && (error.code === 'amount_unverifiable' || error.code === 'cse_key_unavailable');
355
514
  if ((error instanceof CheckoutApiError && error.status >= 500 && !safeReadFailure)
356
515
  || (!(error instanceof CheckoutApiError) && !(error instanceof ApprovalDeclinedError))) {
357
- throw new PaymentOutcomeUnknownError(null, 'authorization_create_unanswered');
516
+ throw new PaymentOutcomeUnknownError(preparation ? await this.retireUncertainPreparation(preparation, input.onAuthorizationCreated) : null, 'authorization_create_unanswered');
358
517
  }
359
518
  throw error;
360
519
  }
361
520
  if (!created || typeof created.id !== 'string' || !created.id)
362
- throw new PaymentOutcomeUnknownError(null, 'authorization_create_malformed');
521
+ throw new PaymentOutcomeUnknownError(preparation ? await this.retireUncertainPreparation(preparation, input.onAuthorizationCreated) : null, 'authorization_create_malformed');
363
522
  const authorizationId = created.id;
523
+ let failed = false;
364
524
  const stopSignal = input.merchantSignal
365
525
  ? AbortSignal.any([input.merchantSignal, ...(input.signal ? [input.signal] : [])]) : input.signal;
366
526
  try {
@@ -370,10 +530,12 @@ export class VaultClient {
370
530
  catch { /* observer only */ }
371
531
  if (input.merchantSignal?.aborted)
372
532
  throw new PaymentOutcomeUnknownError(authorizationId, 'merchant_request_aborted');
373
- try {
374
- Promise.resolve(input.onApprovalUrl?.(created.approvalUrl)).catch(() => { });
533
+ if (!preparation) {
534
+ try {
535
+ Promise.resolve(input.onApprovalUrl?.(created.approvalUrl)).catch(() => { });
536
+ }
537
+ catch { /* approval delivery must not lose an existing authorization */ }
375
538
  }
376
- catch { /* approval delivery must not lose an existing authorization */ }
377
539
  while (Date.now() < deadline) {
378
540
  if (stopSignal?.aborted)
379
541
  throw new PaymentOutcomeUnknownError(authorizationId, input.merchantSignal?.aborted ? 'merchant_request_aborted' : 'local_cancel');
@@ -473,7 +635,7 @@ export class VaultClient {
473
635
  if (s.reason === 'intent_not_confirmable')
474
636
  throw new IntentNotConfirmableError(String(created.id));
475
637
  if (s.reason === 'processor_refused') {
476
- throw new ProcessorRefusedError(String(created.id), typeof s.psp_error_code === 'string' ? s.psp_error_code : null);
638
+ throw new ProcessorRefusedError(String(created.id), typeof s.psp_error_code === 'string' ? s.psp_error_code : null, s.psp === 'razorpay' ? parseProcessorError(s.processor_error) : null);
477
639
  }
478
640
  throw new ApprovalDeclinedError(s.reason ?? 'no reason given');
479
641
  }
@@ -489,12 +651,13 @@ export class VaultClient {
489
651
  throw new PaymentOutcomeUnknownError(authorizationId, 'local_approval_timeout');
490
652
  }
491
653
  catch (error) {
654
+ failed = true;
492
655
  if (input.merchantSignal?.aborted)
493
656
  throw new PaymentOutcomeUnknownError(authorizationId, 'merchant_request_aborted');
494
657
  throw error;
495
658
  }
496
659
  finally {
497
- if (input.merchantSignal?.aborted) {
660
+ if (input.merchantSignal?.aborted || (preparation && failed)) {
498
661
  // Drain a create acknowledgement even after the merchant aborts so its
499
662
  // known ID can be retired. An unacknowledged create remains unknown.
500
663
  // A started/finalized replay or failed cleanup never becomes a claimed
@@ -503,6 +666,29 @@ export class VaultClient {
503
666
  }
504
667
  }
505
668
  }
669
+ /** A lost bind acknowledgement must never resume the request. Recover metadata only for safe cleanup. */
670
+ async retireUncertainPreparation(preparation, onCreated) {
671
+ // Race cancellation atomically against a create still arriving at the API.
672
+ // A bound preparation refuses cancellation; resolve its ID exactly once below.
673
+ await this.cancelPreparation(preparation.id).catch(() => { });
674
+ let state;
675
+ try {
676
+ state = await this.get(`/v2/checkout/preparations/${preparation.id}`, AbortSignal.timeout(3_000));
677
+ }
678
+ catch {
679
+ return null;
680
+ }
681
+ if (state?.id !== preparation.id || state.status !== 'bound' || typeof state.authorization_id !== 'string'
682
+ || !/^cauth_[A-Za-z0-9_-]{1,128}$/.test(state.authorization_id))
683
+ return null;
684
+ const id = state.authorization_id;
685
+ try {
686
+ Promise.resolve(onCreated?.(id)).catch(() => { });
687
+ }
688
+ catch { /* observer only */ }
689
+ await this.cancelAuthorization(id).catch(() => { });
690
+ return id;
691
+ }
506
692
  /** Retire only a pre-replay authorization. A 409 or missing response remains unknown. */
507
693
  async cancelAuthorization(authorizationId) {
508
694
  if (!/^cauth_[A-Za-z0-9_-]{1,128}$/.test(authorizationId))
@@ -537,7 +723,7 @@ export class VaultClient {
537
723
  }
538
724
  // Two 502s the API asks to be retried: Stripe did not answer the
539
725
  // amount read-back, or Adyen did not answer the public-key fetch.
540
- const retryable = err instanceof CheckoutApiError && err.status === 502
726
+ const retryable = !payload.preparation_id && err instanceof CheckoutApiError && err.status === 502
541
727
  && (err.code === 'amount_unverifiable' || err.code === 'cse_key_unavailable');
542
728
  if (!retryable || attempt >= this.unverifiableRetryDelaysMs.length)
543
729
  throw err;
package/dist/index.d.ts CHANGED
@@ -1,5 +1,5 @@
1
- export { VaultClient, CardEncryptedError, UnsupportedModeError, ApprovalTimeoutError, ApprovalDeclinedError, AmountMismatchError, IntentNotConfirmableError, ProcessorRefusedError, CheckoutApiError, redactUrl, SUPPORTED_MODES, PaymentOutcomeUnknownError, CheckoutCancelledError, } from './client.js';
2
- export type { PausedRequest, ReplayResponse, TokenReplay, CseReplay, HostedFormReplay, AmountAuthority, AuthorizeInput, VaultClientOptions, } from './client.js';
1
+ export { VaultClient, CardEncryptedError, UnsupportedModeError, ApprovalTimeoutError, ApprovalDeclinedError, AmountMismatchError, IntentNotConfirmableError, ProcessorRefusedError, CheckoutApiError, redactUrl, SUPPORTED_MODES, PaymentOutcomeUnknownError, CheckoutCancelledError, CheckoutPreparationError, } from './client.js';
2
+ export type { PausedRequest, ReplayResponse, TokenReplay, CseReplay, HostedFormReplay, AmountAuthority, AuthorizeInput, VaultClientOptions, PrepareCheckoutOptions, PrepareCheckoutInput, PreparedCheckout, RazorpayProcessorError, } from './client.js';
3
3
  export { attachToCdp, attachToPlaywright, corsHeadersFor, corsDecision, withCorsHeaders } from './cdp.js';
4
4
  export type { CdpLike, AttachOptions, CorsOutcome } from './cdp.js';
5
5
  export { substituteEncryptedFields, SubstitutionError } from './substitute.js';
package/dist/index.js CHANGED
@@ -1,4 +1,4 @@
1
- export { VaultClient, CardEncryptedError, UnsupportedModeError, ApprovalTimeoutError, ApprovalDeclinedError, AmountMismatchError, IntentNotConfirmableError, ProcessorRefusedError, CheckoutApiError, redactUrl, SUPPORTED_MODES, PaymentOutcomeUnknownError, CheckoutCancelledError, } from './client.js';
1
+ export { VaultClient, CardEncryptedError, UnsupportedModeError, ApprovalTimeoutError, ApprovalDeclinedError, AmountMismatchError, IntentNotConfirmableError, ProcessorRefusedError, CheckoutApiError, redactUrl, SUPPORTED_MODES, PaymentOutcomeUnknownError, CheckoutCancelledError, CheckoutPreparationError, } from './client.js';
2
2
  export { attachToCdp, attachToPlaywright, corsHeadersFor, corsDecision, withCorsHeaders } from './cdp.js';
3
3
  export { substituteEncryptedFields, SubstitutionError } from './substitute.js';
4
4
  export { hostedFormSubmittedPage, HOSTED_FORM_SUBMITTED_OUTCOME } from './hosted-form.js';
@@ -1,4 +1,4 @@
1
- import { type ReplayResponse } from './client.js';
1
+ import { CheckoutPreparationError, type PrepareCheckoutOptions, type PreparedCheckout, type ReplayResponse } from './client.js';
2
2
  import type { CheckoutMode } from './registry.js';
3
3
  /** A processor approval is not an order. Only the merchant can confirm this result. */
4
4
  export type MerchantResult = {
@@ -13,8 +13,9 @@ export type MerchantResult = {
13
13
  reason: '3ds' | 'redirect' | 'other';
14
14
  };
15
15
  export interface CheckoutState {
16
- status: 'idle' | 'awaiting_approval' | 'awaiting_merchant' | 'requires_user_action' | 'completed' | 'declined' | 'timed_out' | 'cancelled' | 'unsupported' | 'outcome_unknown' | 'failed';
16
+ status: 'idle' | 'awaiting_approval' | 'ready_to_submit' | 'awaiting_merchant' | 'requires_user_action' | 'completed' | 'declined' | 'timed_out' | 'cancelled' | 'unsupported' | 'outcome_unknown' | 'failed';
17
17
  authorizationId: string | null;
18
+ preparationId?: string;
18
19
  mode?: CheckoutMode;
19
20
  orderId?: string;
20
21
  /** Stable SDK category; never includes a request body, processor response, or approval link. */
@@ -36,7 +37,9 @@ export interface LifecycleOptions {
36
37
  }
37
38
  export interface CheckoutController {
38
39
  getState(): Readonly<CheckoutState>;
39
- /** Stop this attachment locally. Does not revoke an approval link or cancel a processor payment. */
40
+ /** Await device consent before the caller starts the first native Pay action. One use per attachment. */
41
+ prepare(options: PrepareCheckoutOptions): Promise<PreparedCheckout>;
42
+ /** Stop locally and best-effort retire an unbound preparation. Does not cancel a processor payment. */
40
43
  cancel(): void;
41
44
  /** Ask the application's merchant resolver. A rejection records unknown; never automatically retries payment. */
42
45
  reconcile(): Promise<Readonly<CheckoutState>>;
@@ -57,9 +60,17 @@ export declare class CheckoutLifecycle implements CheckoutController {
57
60
  private merchantAborted;
58
61
  private unboundStripeToken;
59
62
  private reconciliation;
63
+ private preparationHandler?;
64
+ private preparationUsed;
60
65
  readonly abort: AbortController;
61
66
  constructor(options: LifecycleOptions);
62
67
  getState(): Readonly<CheckoutState>;
68
+ setPreparationHandler(handler: (options: PrepareCheckoutOptions) => Promise<PreparedCheckout>): void;
69
+ prepare(options: PrepareCheckoutOptions): Promise<PreparedCheckout>;
70
+ preparing(): void;
71
+ preparationCreated(preparationId: string): void;
72
+ prepared(preparation: PreparedCheckout): void;
73
+ preparationFailed(error: CheckoutPreparationError): void;
63
74
  isBlocked(): boolean;
64
75
  isCancelled(): boolean;
65
76
  begin(): void;
package/dist/lifecycle.js CHANGED
@@ -1,4 +1,4 @@
1
- import { ApprovalDeclinedError, ApprovalTimeoutError, CheckoutCancelledError, IntentNotConfirmableError, PaymentOutcomeUnknownError } from './client.js';
1
+ import { ApprovalDeclinedError, ApprovalTimeoutError, CheckoutCancelledError, CheckoutPreparationError, IntentNotConfirmableError, PaymentOutcomeUnknownError, ProcessorRefusedError } from './client.js';
2
2
  /** Shared by the raw CDP and Playwright transports. No browser ownership or payment execution lives here. */
3
3
  export class CheckoutLifecycle {
4
4
  options;
@@ -9,16 +9,38 @@ export class CheckoutLifecycle {
9
9
  merchantAborted = false;
10
10
  unboundStripeToken = false;
11
11
  reconciliation = null;
12
+ preparationHandler;
13
+ preparationUsed = false;
12
14
  abort = new AbortController();
13
15
  constructor(options) {
14
16
  this.options = options;
15
17
  }
16
18
  getState() { return { ...this.state }; }
19
+ setPreparationHandler(handler) { this.preparationHandler = handler; }
20
+ prepare(options) {
21
+ if (!this.preparationHandler)
22
+ return Promise.reject(new CheckoutPreparationError(null, 'transport_unavailable'));
23
+ return this.preparationHandler(options);
24
+ }
25
+ preparing() {
26
+ this.preparationUsed = true;
27
+ this.set({ status: 'awaiting_approval', authorizationId: null });
28
+ }
29
+ preparationCreated(preparationId) { this.set({ ...this.state, preparationId }); }
30
+ prepared(preparation) {
31
+ this.set({ status: 'ready_to_submit', authorizationId: null, preparationId: preparation.id });
32
+ }
33
+ preparationFailed(error) {
34
+ this.held = true;
35
+ if (this.cancelled)
36
+ return;
37
+ this.set({ ...this.state, status: error.reason === 'expired' ? 'timed_out' : error.reason === 'cancelled' ? 'cancelled' : 'failed', reason: error.reason });
38
+ }
17
39
  isBlocked() { return this.held || this.cancelled; }
18
40
  isCancelled() { return this.cancelled; }
19
41
  begin() {
20
42
  this.active = true;
21
- this.set({ status: 'awaiting_approval', authorizationId: null });
43
+ this.set({ status: 'awaiting_approval', authorizationId: null, ...(this.state.preparationId ? { preparationId: this.state.preparationId } : {}) });
22
44
  }
23
45
  end() { this.active = false; }
24
46
  set(state) {
@@ -83,6 +105,7 @@ export class CheckoutLifecycle {
83
105
  const mismatch = (!replay.mode || replay.mode === 'token') && replay.amountVerified === false;
84
106
  this.held = !!this.options.requireMerchantResult || replay.mode === 'hosted_form' || mismatch || this.unboundStripeToken;
85
107
  this.set({ status: 'awaiting_merchant', authorizationId: replay.authorizationId, mode: replay.mode ?? 'token',
108
+ ...(this.state.preparationId ? { preparationId: this.state.preparationId } : {}),
86
109
  ...(mismatch ? { reason: 'charged_amount_mismatch' } : this.unboundStripeToken ? { reason: 'stripe_tokenization_unbound' } : {}) });
87
110
  }
88
111
  failed(error, handoffStarted = false) {
@@ -92,7 +115,7 @@ export class CheckoutLifecycle {
92
115
  this.merchantRequestAborted();
93
116
  return;
94
117
  }
95
- const authorizationId = error instanceof PaymentOutcomeUnknownError ? error.authorizationId : this.state.authorizationId;
118
+ const authorizationId = error instanceof PaymentOutcomeUnknownError || error instanceof ProcessorRefusedError ? error.authorizationId : this.state.authorizationId;
96
119
  if (handoffStarted || error instanceof PaymentOutcomeUnknownError || error instanceof IntentNotConfirmableError) {
97
120
  this.held = true;
98
121
  this.set({ ...this.state, authorizationId, status: 'outcome_unknown', reason: error instanceof PaymentOutcomeUnknownError ? error.reason : error instanceof IntentNotConfirmableError ? 'intent_not_confirmable' : 'browser_handoff_failed' });
@@ -100,9 +123,19 @@ export class CheckoutLifecycle {
100
123
  else if (error instanceof CheckoutCancelledError) {
101
124
  this.cancel();
102
125
  }
126
+ else if (error instanceof CheckoutPreparationError) {
127
+ this.preparationFailed(error);
128
+ }
103
129
  else if (error instanceof ApprovalTimeoutError) {
104
130
  this.set({ ...this.state, status: 'timed_out', reason: 'approval_expired' });
105
131
  }
132
+ else if (error instanceof ProcessorRefusedError) {
133
+ // A rejected processor request is not proof that the merchant cannot
134
+ // collect this payment. Keep native retries held until the application
135
+ // checks the merchant and explicitly starts another attempt.
136
+ this.held = true;
137
+ this.set({ ...this.state, authorizationId, status: 'declined', reason: 'processor_refused' });
138
+ }
106
139
  else if (error instanceof ApprovalDeclinedError) {
107
140
  this.set({ ...this.state, status: 'declined', reason: 'approval_declined' });
108
141
  }
@@ -162,6 +195,8 @@ export class CheckoutLifecycle {
162
195
  return this.getState();
163
196
  }
164
197
  retryAfterMerchantFailure(result) {
198
+ if (this.preparationUsed)
199
+ throw new Error('Prepared checkouts are single use. Reconcile this attempt and create a new attachment.');
165
200
  if (this.active || this.reconciliation)
166
201
  throw new Error('Cannot start another attempt while payment or reconciliation is in progress.');
167
202
  if (this.cancelled)
@@ -0,0 +1,25 @@
1
+ import { type PreparedCheckout } from './client.js';
2
+ import type { AttachOptions } from './cdp.js';
3
+ import type { CheckoutLifecycle } from './lifecycle.js';
4
+ /** A local, one-use rendezvous. It never starts or retries a merchant request. */
5
+ export declare class PreparationGate {
6
+ private readonly opts;
7
+ private readonly lifecycle;
8
+ private readonly readDocumentUrl;
9
+ private state;
10
+ private observedRequest;
11
+ private documentUrl;
12
+ private prepared?;
13
+ private stop;
14
+ private expiryTimer?;
15
+ constructor(opts: AttachOptions, lifecycle: CheckoutLifecycle, readDocumentUrl: () => Promise<string>);
16
+ private prepare;
17
+ /** Called for every recognized card mutation, before any await or local retry guard. */
18
+ claim(requestUrl: string): PreparedCheckout | undefined;
19
+ assertDocument(): Promise<void>;
20
+ private readDocument;
21
+ isEngaged(): boolean;
22
+ retireUnboundClaim(): void;
23
+ /** A bound native request has its own cancellation/unknown-outcome machinery. */
24
+ invalidate(reason: string): void;
25
+ }
@@ -0,0 +1,150 @@
1
+ import { CheckoutPreparationError } from './client.js';
2
+ /** A local, one-use rendezvous. It never starts or retries a merchant request. */
3
+ export class PreparationGate {
4
+ opts;
5
+ lifecycle;
6
+ readDocumentUrl;
7
+ state = 'unused';
8
+ observedRequest = false;
9
+ documentUrl = '';
10
+ prepared;
11
+ stop = new AbortController();
12
+ expiryTimer;
13
+ constructor(opts, lifecycle, readDocumentUrl) {
14
+ this.opts = opts;
15
+ this.lifecycle = lifecycle;
16
+ this.readDocumentUrl = readDocumentUrl;
17
+ lifecycle.setPreparationHandler(options => this.prepare(options));
18
+ lifecycle.abort.signal.addEventListener('abort', () => this.invalidate('cancelled'), { once: true });
19
+ }
20
+ async prepare(options) {
21
+ options = { ...options };
22
+ if (this.state !== 'unused' || this.observedRequest || this.lifecycle.getState().status !== 'idle' || this.lifecycle.isBlocked()) {
23
+ // A late opt-in cannot turn the next native retry into ordinary approval.
24
+ // Preserve any existing authorization's state for reconciliation.
25
+ if (this.state === 'unused')
26
+ this.state = 'failed';
27
+ throw new CheckoutPreparationError(this.prepared?.id ?? null, 'must_prepare_before_first_request');
28
+ }
29
+ // Reserve synchronously, including while origin discovery/OAuth is pending.
30
+ this.state = 'preparing';
31
+ this.lifecycle.preparing();
32
+ const signal = AbortSignal.any([this.stop.signal, this.lifecycle.abort.signal, ...(options?.signal ? [options.signal] : [])]);
33
+ const onAbort = () => this.invalidate('cancelled');
34
+ signal.addEventListener('abort', onAbort, { once: true });
35
+ try {
36
+ if (!options || options.psp !== 'square' || !['production', 'sandbox'].includes(options.environment))
37
+ throw new CheckoutPreparationError(null, 'unsupported_processor');
38
+ const tokenizer = options.environment === 'production' ? 'https://pci-connect.squareup.com/v2/card-nonce' : 'https://pci-connect.squareupsandbox.com/v2/card-nonce';
39
+ if (!this.opts.vault.isCardRequest(tokenizer, 'POST'))
40
+ throw new CheckoutPreparationError(null, 'processor_interception_unavailable');
41
+ if (!Number.isSafeInteger(this.opts.amountCents) || (this.opts.amountCents ?? 0) <= 0 || !/^[a-z]{3}$/i.test(this.opts.currency ?? ''))
42
+ throw new CheckoutPreparationError(null, 'amount_required');
43
+ if (signal.aborted)
44
+ throw new CheckoutPreparationError(null, 'cancelled');
45
+ this.documentUrl = await this.readDocument();
46
+ const page = new URL(this.documentUrl);
47
+ if (!(page.protocol === 'https:' || (page.protocol === 'http:' && page.hostname === 'localhost')) || page.username || page.password)
48
+ throw new CheckoutPreparationError(null, 'merchant_origin_invalid');
49
+ const prepared = await this.opts.vault.prepareCheckout({
50
+ ...options, user: this.opts.user, merchant: this.opts.merchant,
51
+ amountCents: this.opts.amountCents, currency: this.opts.currency, cardId: this.opts.cardId,
52
+ merchantOrigin: page.origin, checkoutKey: crypto.randomUUID(), timeoutMs: this.opts.timeoutMs, signal,
53
+ onPreparationCreated: id => this.lifecycle.preparationCreated(id),
54
+ onApprovalUrl: url => {
55
+ if (!signal.aborted) {
56
+ this.lifecycle.approvalUrl(url);
57
+ try {
58
+ Promise.resolve(this.opts.onApprovalUrl?.(url)).catch(() => { });
59
+ }
60
+ catch { /* observer only */ }
61
+ }
62
+ },
63
+ });
64
+ this.prepared = prepared;
65
+ if (signal.aborted || this.state !== 'preparing') {
66
+ void this.opts.vault.cancelPreparation(prepared.id).catch(() => { });
67
+ throw new CheckoutPreparationError(prepared.id, 'cancelled');
68
+ }
69
+ await this.assertDocument();
70
+ if (signal.aborted || this.state !== 'preparing')
71
+ throw new CheckoutPreparationError(prepared.id, 'cancelled');
72
+ const remaining = Date.parse(prepared.expiresAt) - Date.now();
73
+ if (!(remaining > 0))
74
+ throw new CheckoutPreparationError(prepared.id, 'expired');
75
+ this.state = 'ready';
76
+ this.expiryTimer = setTimeout(() => this.invalidate('expired'), remaining);
77
+ this.expiryTimer.unref?.();
78
+ this.lifecycle.prepared(prepared);
79
+ return prepared;
80
+ }
81
+ catch (error) {
82
+ const failure = error instanceof CheckoutPreparationError ? error : new CheckoutPreparationError(this.prepared?.id ?? null, 'preparation_unconfirmed');
83
+ this.invalidate(failure.reason);
84
+ throw failure;
85
+ }
86
+ // Keep the signal listener after ready: caller cancellation retires the handle too.
87
+ }
88
+ /** Called for every recognized card mutation, before any await or local retry guard. */
89
+ claim(requestUrl) {
90
+ this.observedRequest = true;
91
+ if (this.state === 'unused')
92
+ return undefined;
93
+ if (this.state !== 'ready' || !this.prepared) {
94
+ this.invalidate(this.state === 'preparing' ? 'submitted_before_ready' : 'already_used_or_unavailable');
95
+ throw new CheckoutPreparationError(this.prepared?.id ?? null, 'already_used_or_unavailable');
96
+ }
97
+ const prepared = this.prepared;
98
+ const request = new URL(requestUrl);
99
+ const origin = prepared.environment === 'production' ? 'https://pci-connect.squareup.com' : 'https://pci-connect.squareupsandbox.com';
100
+ if (Date.parse(prepared.expiresAt) <= Date.now() || request.origin !== origin || request.pathname !== '/v2/card-nonce' || request.username || request.password) {
101
+ const reason = Date.parse(prepared.expiresAt) <= Date.now() ? 'expired' : 'checkout_changed';
102
+ this.invalidate(reason);
103
+ throw new CheckoutPreparationError(prepared.id, reason);
104
+ }
105
+ this.state = 'consumed';
106
+ clearTimeout(this.expiryTimer);
107
+ return prepared;
108
+ }
109
+ async assertDocument() {
110
+ if (this.documentUrl && await this.readDocument() !== this.documentUrl)
111
+ throw new CheckoutPreparationError(this.prepared?.id ?? null, 'merchant_document_changed');
112
+ }
113
+ async readDocument() {
114
+ const signal = AbortSignal.any([this.stop.signal, this.lifecycle.abort.signal, AbortSignal.timeout(5_000)]);
115
+ const failure = () => new CheckoutPreparationError(this.prepared?.id ?? null, 'merchant_document_unavailable');
116
+ if (signal.aborted)
117
+ throw failure();
118
+ let aborted;
119
+ const stopped = new Promise((_, reject) => {
120
+ aborted = () => reject(failure());
121
+ signal.addEventListener('abort', aborted, { once: true });
122
+ });
123
+ try {
124
+ return await Promise.race([this.readDocumentUrl(), stopped]);
125
+ }
126
+ finally {
127
+ signal.removeEventListener('abort', aborted);
128
+ }
129
+ }
130
+ isEngaged() { return this.state !== 'unused'; }
131
+ retireUnboundClaim() {
132
+ if (this.state !== 'consumed' || !this.prepared || this.lifecycle.getState().authorizationId)
133
+ return;
134
+ void this.opts.vault.cancelPreparation(this.prepared.id).catch(() => { });
135
+ // No bound ID means the adapter must not leave a spent handle appearing ready.
136
+ if (this.lifecycle.getState().status === 'ready_to_submit')
137
+ this.lifecycle.preparationFailed(new CheckoutPreparationError(this.prepared.id, 'request_not_bound'));
138
+ }
139
+ /** A bound native request has its own cancellation/unknown-outcome machinery. */
140
+ invalidate(reason) {
141
+ if (this.state === 'unused' || this.state === 'consumed' || this.state === 'failed')
142
+ return;
143
+ this.state = 'failed';
144
+ clearTimeout(this.expiryTimer);
145
+ this.stop.abort();
146
+ if (this.prepared)
147
+ void this.opts.vault.cancelPreparation(this.prepared.id).catch(() => { });
148
+ this.lifecycle.preparationFailed(new CheckoutPreparationError(this.prepared?.id ?? null, reason));
149
+ }
150
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@agent-cards/checkout",
3
- "version": "0.3.1",
3
+ "version": "0.4.1",
4
4
  "description": "Let browser agents pay with the user's own card, without your infrastructure ever touching card data.",
5
5
  "type": "module",
6
6
  "main": "dist/index.js",
@@ -23,8 +23,8 @@
23
23
  "_comment_build": "TypeScript is fetched rather than declared as a devDependency ON PURPOSE. This package ships zero dependencies, which is why pnpm writes no importer for it in the workspace lockfile; adding any dep here creates one, and an importer the lockfile has not been regenerated for fails every Vercel build with ERR_PNPM_OUTDATED_LOCKFILE. Pinned so the published output is reproducible.",
24
24
  "build": "npx -y -p typescript@5.9.3 tsc",
25
25
  "prepublishOnly": "pnpm build",
26
- "test": "node test.mjs && node --test lifecycle.test.mjs merchant-abort.test.mjs",
27
- "test:browser": "node browser.test.mjs && node stripe-browser.test.mjs"
26
+ "test": "node test.mjs && node --test lifecycle.test.mjs merchant-abort.test.mjs preparation.test.mjs",
27
+ "test:browser": "node browser.test.mjs && node stripe-browser.test.mjs && node preparation-browser.test.mjs"
28
28
  },
29
29
  "keywords": [
30
30
  "payments",