@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 +14 -0
- package/README.md +42 -10
- package/dist/cdp.js +82 -8
- package/dist/client.d.ts +65 -8
- package/dist/client.js +204 -18
- package/dist/index.d.ts +2 -2
- package/dist/index.js +1 -1
- package/dist/lifecycle.d.ts +14 -3
- package/dist/lifecycle.js +38 -3
- package/dist/preparation.d.ts +25 -0
- package/dist/preparation.js +150 -0
- package/package.json +3 -3
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
|
|
86
|
-
|
|
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
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
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
|
|
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.
|
|
443
|
-
in-process Agentcard API fixture and loopback merchant pages.
|
|
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
|
-
*
|
|
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
|
|
234
|
-
*
|
|
235
|
-
*
|
|
236
|
-
*
|
|
237
|
-
*
|
|
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
|
-
|
|
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
|
|
123
|
-
*
|
|
124
|
-
*
|
|
125
|
-
*
|
|
126
|
-
*
|
|
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
|
-
|
|
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.
|
|
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
|
-
|
|
374
|
-
|
|
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';
|
package/dist/lifecycle.d.ts
CHANGED
|
@@ -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
|
-
/**
|
|
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
|
+
"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",
|