@agent-cards/checkout 0.3.0 → 0.3.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 +7 -0
- package/README.md +8 -3
- package/dist/cdp.js +93 -4
- package/dist/client.d.ts +10 -0
- package/dist/client.js +158 -110
- package/dist/lifecycle.d.ts +3 -0
- package/dist/lifecycle.js +14 -0
- package/package.json +2 -2
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,12 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 0.3.1
|
|
4
|
+
|
|
5
|
+
- 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.
|
|
6
|
+
- Retire pending authorizations through the org-scoped cancellation endpoint when the merchant request disappears. Cancellation can win only before the processor-send boundary; started or completed replay remains unknown to this operation. A late creation response is drained so its authorization ID can be cancelled without exposing another approval link.
|
|
7
|
+
- Square token mode uses the vault's verified browser TLS relay. The relay keeps TLS termination and card plaintext on the cardholder device and Square. SDK 0.2.1 can complete a prompt approval but does not contain the native-timeout lifecycle fixes; upgrade to 0.3.1 for Square checkout.
|
|
8
|
+
- Square's native tokenization request still expires after about 10 seconds, including approval and token handoff. Delayed approval cannot complete that checkout; a later SCA challenge has its own lifetime after handoff. This release does not extend the deadline or retry a payment after timeout. Reconcile the merchant outcome and explicitly begin another checkout when required.
|
|
9
|
+
|
|
3
10
|
## 0.3.0
|
|
4
11
|
|
|
5
12
|
### Browser checkout lifecycle
|
package/README.md
CHANGED
|
@@ -30,7 +30,7 @@ your agent ──drives──> merchant checkout
|
|
|
30
30
|
npm i @agent-cards/checkout
|
|
31
31
|
```
|
|
32
32
|
|
|
33
|
-
Upgrading from 0.2.x? Read the [
|
|
33
|
+
Upgrading from 0.2.x? Read the [migration notes](./CHANGELOG.md), especially
|
|
34
34
|
the unknown-outcome, cancellation and browser-context requirements.
|
|
35
35
|
|
|
36
36
|
## Use it
|
|
@@ -79,8 +79,11 @@ Stripe's minor units, so the approval screen, the notifications and every
|
|
|
79
79
|
read show one amount. Tokenization requests carry no amount, so there the
|
|
80
80
|
pair is shown and reported (`amountAuthority: 'display_only'`), not enforced.
|
|
81
81
|
|
|
82
|
-
Then let your agent click "Pay" like it always does. `attachToCdp`
|
|
83
|
-
request
|
|
82
|
+
Then let your agent click "Pay" like it always does. `attachToCdp` pauses the
|
|
83
|
+
request for approval and resumes it only while the merchant request remains
|
|
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.
|
|
84
87
|
|
|
85
88
|
Playwright:
|
|
86
89
|
|
|
@@ -389,6 +392,8 @@ do not reuse that token in a new attachment as a workaround. Configuration and
|
|
|
389
392
|
unsupported-mode failures require fixing the integration. Bank flows requiring
|
|
390
393
|
another confirmation and other stored-token chains remain unverified.
|
|
391
394
|
|
|
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.
|
|
396
|
+
|
|
392
397
|
Lost authorization polling, local approval timeouts, or interrupted browser
|
|
393
398
|
handoffs produce `outcome_unknown` and block automatic retry. The thrown
|
|
394
399
|
`PaymentOutcomeUnknownError` carries `authorizationId` when creation was
|
package/dist/cdp.js
CHANGED
|
@@ -284,6 +284,22 @@ function failureSummary(error) {
|
|
|
284
284
|
return `${error.name}: ${error.code ?? `http_${error.status}`}`;
|
|
285
285
|
return error instanceof Error ? error.name : 'CheckoutError';
|
|
286
286
|
}
|
|
287
|
+
/** Separate from explicit cancellation: the client can drain a late create ID. */
|
|
288
|
+
function merchantAttempt(lifecycle) {
|
|
289
|
+
const controller = new AbortController();
|
|
290
|
+
const error = () => new PaymentOutcomeUnknownError(lifecycle.getState().authorizationId, 'merchant_request_aborted');
|
|
291
|
+
return {
|
|
292
|
+
signal: controller.signal,
|
|
293
|
+
stop() {
|
|
294
|
+
if (controller.signal.aborted)
|
|
295
|
+
return;
|
|
296
|
+
lifecycle.merchantRequestAborted();
|
|
297
|
+
controller.abort(error());
|
|
298
|
+
},
|
|
299
|
+
assertLive() { if (controller.signal.aborted)
|
|
300
|
+
throw error(); },
|
|
301
|
+
};
|
|
302
|
+
}
|
|
287
303
|
/**
|
|
288
304
|
* Take over card tokenization for a page.
|
|
289
305
|
*
|
|
@@ -313,6 +329,7 @@ export async function attachToCdp(cdp, pageSessionId, opts) {
|
|
|
313
329
|
let quietUntil = 0;
|
|
314
330
|
// One outstanding approval at a time; see the note above isApprovalOutcome.
|
|
315
331
|
let awaitingApproval = false;
|
|
332
|
+
let activeRequest = null;
|
|
316
333
|
// The hosted form the cardholder already submitted; see HOSTED_FORM_REPEAT_QUIET_MS.
|
|
317
334
|
const repeatQuietMs = opts.hostedFormRepeatQuietMs ?? HOSTED_FORM_REPEAT_QUIET_MS;
|
|
318
335
|
let lastSubmitted = null;
|
|
@@ -323,6 +340,10 @@ export async function attachToCdp(cdp, pageSessionId, opts) {
|
|
|
323
340
|
const key = sessionId ?? '__root__';
|
|
324
341
|
if (armed.has(key))
|
|
325
342
|
return;
|
|
343
|
+
// Fetch's interception ID differs from Network's ID. Enable failure events
|
|
344
|
+
// before intercepting and bind each attempt to both its ID and CDP session.
|
|
345
|
+
await cdp.send('Network.enable', {}, sessionId);
|
|
346
|
+
await cdp.send('Page.enable', {}, sessionId).catch(() => { });
|
|
326
347
|
await cdp.send('Fetch.enable', {
|
|
327
348
|
patterns: urlPatterns.map((urlPattern) => ({ urlPattern, requestStage: 'Request' })),
|
|
328
349
|
}, sessionId);
|
|
@@ -333,6 +354,18 @@ export async function attachToCdp(cdp, pageSessionId, opts) {
|
|
|
333
354
|
armed.add(key);
|
|
334
355
|
};
|
|
335
356
|
cdp.on(async (method, params, sessionId) => {
|
|
357
|
+
if (method === 'Network.loadingFailed') {
|
|
358
|
+
if (activeRequest && activeRequest.networkId === params.requestId && activeRequest.sessionId === sessionId)
|
|
359
|
+
activeRequest.attempt.stop();
|
|
360
|
+
return;
|
|
361
|
+
}
|
|
362
|
+
if (method === 'Target.detachedFromTarget' || method === 'Inspector.detached' || method === 'Page.frameDetached') {
|
|
363
|
+
if (activeRequest && (method === 'Target.detachedFromTarget' ? activeRequest.sessionId === params.sessionId
|
|
364
|
+
: method === 'Inspector.detached' ? activeRequest.sessionId === sessionId
|
|
365
|
+
: activeRequest.sessionId === sessionId && activeRequest.frameId === params.frameId))
|
|
366
|
+
activeRequest.attempt.stop();
|
|
367
|
+
return;
|
|
368
|
+
}
|
|
336
369
|
if (method === 'Target.attachedToTarget') {
|
|
337
370
|
const child = params.sessionId;
|
|
338
371
|
try {
|
|
@@ -351,7 +384,7 @@ export async function attachToCdp(cdp, pageSessionId, opts) {
|
|
|
351
384
|
}
|
|
352
385
|
if (method !== 'Fetch.requestPaused')
|
|
353
386
|
return;
|
|
354
|
-
const { requestId, request, resourceType } = params;
|
|
387
|
+
const { requestId, request, resourceType, networkId, frameId } = params;
|
|
355
388
|
if (!opts.vault.isCardRequest(request.url, request.method)) {
|
|
356
389
|
if (guards.matches(request.url, request.method)) {
|
|
357
390
|
lifecycle.unsupported();
|
|
@@ -374,6 +407,7 @@ export async function attachToCdp(cdp, pageSessionId, opts) {
|
|
|
374
407
|
// requests can never both clear the check above and raise two prompts for
|
|
375
408
|
// one checkout.
|
|
376
409
|
awaitingApproval = true;
|
|
410
|
+
const attempt = merchantAttempt(lifecycle);
|
|
377
411
|
let handoffStarted = false;
|
|
378
412
|
try {
|
|
379
413
|
const body = pausedBody(request);
|
|
@@ -395,6 +429,11 @@ export async function attachToCdp(cdp, pageSessionId, opts) {
|
|
|
395
429
|
}
|
|
396
430
|
opts.onEvent?.({ type: 'card_request_paused', detail: { url: redactUrl(request.url), ...(resourceType ? { resourceType } : {}) } });
|
|
397
431
|
lifecycle.begin();
|
|
432
|
+
if (typeof networkId !== 'string' || !networkId) {
|
|
433
|
+
attempt.stop();
|
|
434
|
+
attempt.assertLive();
|
|
435
|
+
}
|
|
436
|
+
activeRequest = { attempt, networkId, sessionId, frameId };
|
|
398
437
|
const replay = await opts.vault.authorize({
|
|
399
438
|
user: opts.user,
|
|
400
439
|
merchant: opts.merchant,
|
|
@@ -404,10 +443,15 @@ export async function attachToCdp(cdp, pageSessionId, opts) {
|
|
|
404
443
|
cardId: opts.cardId,
|
|
405
444
|
timeoutMs: opts.timeoutMs,
|
|
406
445
|
signal: lifecycle.abort.signal,
|
|
446
|
+
merchantSignal: attempt.signal,
|
|
407
447
|
onAuthorizationCreated: (id) => lifecycle.approvalCreated(id),
|
|
408
|
-
onApprovalUrl: (url) => {
|
|
448
|
+
onApprovalUrl: (url) => { if (!attempt.signal.aborted) {
|
|
449
|
+
lifecycle.approvalUrl(url);
|
|
450
|
+
return opts.onApprovalUrl?.(url);
|
|
451
|
+
} },
|
|
409
452
|
request: { url: request.url, method: request.method, headers: request.headers, body },
|
|
410
453
|
});
|
|
454
|
+
attempt.assertLive();
|
|
411
455
|
if (lifecycle.isCancelled())
|
|
412
456
|
throw new Error('checkout cancelled locally after approval');
|
|
413
457
|
lifecycle.prepareHandoff(replay, request.url);
|
|
@@ -427,6 +471,7 @@ export async function attachToCdp(cdp, pageSessionId, opts) {
|
|
|
427
471
|
responseHeaders: headerEntries(withCorsHeaders(page.headers, corsHeadersFor(request.url, request.headers))),
|
|
428
472
|
body: Buffer.from(page.body).toString('base64'),
|
|
429
473
|
}, sessionId);
|
|
474
|
+
attempt.assertLive();
|
|
430
475
|
lastSubmitted = { url: request.url, body, at: Date.now() };
|
|
431
476
|
// Named for what it is: a device-attested submission with no
|
|
432
477
|
// processor evidence, never an `authorized` event.
|
|
@@ -444,6 +489,7 @@ export async function attachToCdp(cdp, pageSessionId, opts) {
|
|
|
444
489
|
requestId,
|
|
445
490
|
postData,
|
|
446
491
|
}, sessionId);
|
|
492
|
+
attempt.assertLive();
|
|
447
493
|
opts.onEvent?.({ type: 'authorized', detail: { mode: 'cse', authorizationId: replay.authorizationId, fields: Object.keys(replay.substitutions.fields) } });
|
|
448
494
|
}
|
|
449
495
|
else {
|
|
@@ -460,11 +506,15 @@ export async function attachToCdp(cdp, pageSessionId, opts) {
|
|
|
460
506
|
responseHeaders: headerEntries(withCorsHeaders(replay.headers, cors.headers)),
|
|
461
507
|
body: Buffer.from(replay.body).toString('base64'),
|
|
462
508
|
}, sessionId);
|
|
509
|
+
attempt.assertLive();
|
|
463
510
|
opts.onEvent?.({ type: 'authorized', detail: { mode: 'token', authorizationId: replay.authorizationId, amountVerified: replay.amountVerified ?? null, cors: cors.outcome } });
|
|
464
511
|
}
|
|
465
512
|
lifecycle.handedOff(replay);
|
|
466
513
|
}
|
|
467
514
|
catch (err) {
|
|
515
|
+
// Ignore the failure event caused by our own safe decline/error abort.
|
|
516
|
+
if (activeRequest?.attempt === attempt)
|
|
517
|
+
activeRequest = null;
|
|
468
518
|
lifecycle.failed(err, handoffStarted);
|
|
469
519
|
if (isTerminal(err))
|
|
470
520
|
terminal = err;
|
|
@@ -474,6 +524,8 @@ export async function attachToCdp(cdp, pageSessionId, opts) {
|
|
|
474
524
|
await cdp.send('Fetch.failRequest', { requestId, errorReason: 'Aborted' }, sessionId).catch(() => { });
|
|
475
525
|
}
|
|
476
526
|
finally {
|
|
527
|
+
if (activeRequest?.attempt === attempt)
|
|
528
|
+
activeRequest = null;
|
|
477
529
|
awaitingApproval = false;
|
|
478
530
|
lifecycle.end();
|
|
479
531
|
}
|
|
@@ -520,6 +572,17 @@ export async function attachToPlaywright(page, opts) {
|
|
|
520
572
|
let quietUntil = 0;
|
|
521
573
|
// One outstanding approval at a time; see the note above isApprovalOutcome.
|
|
522
574
|
let awaitingApproval = false;
|
|
575
|
+
let activeRequest = null;
|
|
576
|
+
page.on?.('requestfailed', (request) => {
|
|
577
|
+
if (activeRequest && activeRequest.request === request)
|
|
578
|
+
activeRequest.attempt.stop();
|
|
579
|
+
});
|
|
580
|
+
page.on?.('close', () => activeRequest?.attempt.stop());
|
|
581
|
+
page.on?.('crash', () => activeRequest?.attempt.stop());
|
|
582
|
+
page.on?.('framedetached', (frame) => {
|
|
583
|
+
if (activeRequest?.frames.includes(frame))
|
|
584
|
+
activeRequest.attempt.stop();
|
|
585
|
+
});
|
|
523
586
|
// The hosted form the cardholder already submitted; see HOSTED_FORM_REPEAT_QUIET_MS.
|
|
524
587
|
const repeatQuietMs = opts.hostedFormRepeatQuietMs ?? HOSTED_FORM_REPEAT_QUIET_MS;
|
|
525
588
|
let lastSubmitted = null;
|
|
@@ -547,6 +610,18 @@ export async function attachToPlaywright(page, opts) {
|
|
|
547
610
|
}
|
|
548
611
|
// Reserved before anything that could yield, matching attachToCdp.
|
|
549
612
|
awaitingApproval = true;
|
|
613
|
+
const attempt = merchantAttempt(lifecycle);
|
|
614
|
+
const frames = [];
|
|
615
|
+
try {
|
|
616
|
+
for (let frame = request.frame?.(); frame; frame = frame.parentFrame?.())
|
|
617
|
+
frames.push(frame);
|
|
618
|
+
}
|
|
619
|
+
catch { /* requestfailed/page close still cover unavailable frame metadata */ }
|
|
620
|
+
const assertRequestLive = () => {
|
|
621
|
+
if (request.failure?.() || page.isClosed?.() || frames.some(frame => frame.isDetached?.()))
|
|
622
|
+
attempt.stop();
|
|
623
|
+
attempt.assertLive();
|
|
624
|
+
};
|
|
550
625
|
let handoffStarted = false;
|
|
551
626
|
try {
|
|
552
627
|
const body = request.postData() ?? '';
|
|
@@ -556,6 +631,8 @@ export async function attachToPlaywright(page, opts) {
|
|
|
556
631
|
}
|
|
557
632
|
opts.onEvent?.({ type: 'card_request_paused', detail: { url: redactUrl(request.url()) } });
|
|
558
633
|
lifecycle.begin();
|
|
634
|
+
activeRequest = { request, frames, attempt };
|
|
635
|
+
assertRequestLive();
|
|
559
636
|
const replay = await opts.vault.authorize({
|
|
560
637
|
user: opts.user,
|
|
561
638
|
merchant: opts.merchant,
|
|
@@ -565,10 +642,15 @@ export async function attachToPlaywright(page, opts) {
|
|
|
565
642
|
cardId: opts.cardId,
|
|
566
643
|
timeoutMs: opts.timeoutMs,
|
|
567
644
|
signal: lifecycle.abort.signal,
|
|
645
|
+
merchantSignal: attempt.signal,
|
|
568
646
|
onAuthorizationCreated: (id) => lifecycle.approvalCreated(id),
|
|
569
|
-
onApprovalUrl: (url) => {
|
|
647
|
+
onApprovalUrl: (url) => { if (!attempt.signal.aborted) {
|
|
648
|
+
lifecycle.approvalUrl(url);
|
|
649
|
+
return opts.onApprovalUrl?.(url);
|
|
650
|
+
} },
|
|
570
651
|
request: { url: request.url(), method: request.method(), headers: request.headers(), body },
|
|
571
652
|
});
|
|
653
|
+
assertRequestLive();
|
|
572
654
|
if (lifecycle.isCancelled())
|
|
573
655
|
throw new Error('checkout cancelled locally after approval');
|
|
574
656
|
lifecycle.prepareHandoff(replay, request.url());
|
|
@@ -579,6 +661,7 @@ export async function attachToPlaywright(page, opts) {
|
|
|
579
661
|
const synthetic = hostedFormSubmittedPage({ authorizationId: replay.authorizationId, merchant: opts.merchant, submittedAt: replay.submittedAt });
|
|
580
662
|
// Inert on a navigation (never CORS-checked); one path for every fulfill.
|
|
581
663
|
await route.fulfill({ status: synthetic.status, headers: withCorsHeaders(synthetic.headers, corsHeadersFor(request.url(), request.headers())), body: synthetic.body });
|
|
664
|
+
assertRequestLive();
|
|
582
665
|
lastSubmitted = { url: request.url(), body, at: Date.now() };
|
|
583
666
|
opts.onEvent?.({ type: 'submitted_on_device', detail: { authorizationId: replay.authorizationId, submittedAt: replay.submittedAt, outcome: replay.outcome } });
|
|
584
667
|
}
|
|
@@ -589,6 +672,7 @@ export async function attachToPlaywright(page, opts) {
|
|
|
589
672
|
const postData = cseBody(body, replay);
|
|
590
673
|
handoffStarted = true;
|
|
591
674
|
await route.continue({ postData });
|
|
675
|
+
assertRequestLive();
|
|
592
676
|
opts.onEvent?.({ type: 'authorized', detail: { mode: 'cse', authorizationId: replay.authorizationId, fields: Object.keys(replay.substitutions.fields) } });
|
|
593
677
|
}
|
|
594
678
|
else {
|
|
@@ -597,20 +681,25 @@ export async function attachToPlaywright(page, opts) {
|
|
|
597
681
|
// cross-origin answer is the same whichever adapter ran.
|
|
598
682
|
const cors = corsDecision(request.url(), request.headers());
|
|
599
683
|
await route.fulfill({ status: replay.status, headers: withCorsHeaders(replay.headers, cors.headers), body: replay.body });
|
|
684
|
+
assertRequestLive();
|
|
600
685
|
opts.onEvent?.({ type: 'authorized', detail: { mode: 'token', authorizationId: replay.authorizationId, amountVerified: replay.amountVerified ?? null, cors: cors.outcome } });
|
|
601
686
|
}
|
|
602
687
|
lifecycle.handedOff(replay);
|
|
603
688
|
}
|
|
604
689
|
catch (err) {
|
|
690
|
+
if (activeRequest?.attempt === attempt)
|
|
691
|
+
activeRequest = null;
|
|
605
692
|
lifecycle.failed(err, handoffStarted);
|
|
606
693
|
if (isTerminal(err))
|
|
607
694
|
terminal = err;
|
|
608
695
|
else if (isApprovalOutcome(err))
|
|
609
696
|
quietUntil = Date.now() + cooldownMs;
|
|
610
697
|
opts.onEvent?.({ type: 'failed', detail: failureSummary(err) });
|
|
611
|
-
await route.abort('aborted');
|
|
698
|
+
await route.abort('aborted').catch(() => { });
|
|
612
699
|
}
|
|
613
700
|
finally {
|
|
701
|
+
if (activeRequest?.attempt === attempt)
|
|
702
|
+
activeRequest = null;
|
|
614
703
|
awaitingApproval = false;
|
|
615
704
|
lifecycle.end();
|
|
616
705
|
}
|
package/dist/client.d.ts
CHANGED
|
@@ -135,6 +135,8 @@ export interface AuthorizeInput {
|
|
|
135
135
|
timeoutMs?: number;
|
|
136
136
|
/** Stops local polling; it does not revoke a pending approval or undo a payment. */
|
|
137
137
|
signal?: AbortSignal;
|
|
138
|
+
/** Adapter-owned merchant request lifetime. A failed request retires a pending approval before replay when possible. */
|
|
139
|
+
merchantSignal?: AbortSignal;
|
|
138
140
|
/** Called before onApprovalUrl; lets a runtime reconcile an interrupted authorization. */
|
|
139
141
|
onAuthorizationCreated?: (authorizationId: string) => void;
|
|
140
142
|
/** Called once with the URL to surface to the user, if you deliver it yourself. */
|
|
@@ -336,6 +338,14 @@ export declare class VaultClient {
|
|
|
336
338
|
* response to replay into the browser. Your process never sees a card.
|
|
337
339
|
*/
|
|
338
340
|
authorize(input: AuthorizeInput): Promise<ReplayResponse>;
|
|
341
|
+
/** Retire only a pre-replay authorization. A 409 or missing response remains unknown. */
|
|
342
|
+
cancelAuthorization(authorizationId: string): Promise<{
|
|
343
|
+
id: string;
|
|
344
|
+
status: 'declined';
|
|
345
|
+
reason: 'merchant_request_aborted';
|
|
346
|
+
cancelled: true;
|
|
347
|
+
processor_request_started: false;
|
|
348
|
+
}>;
|
|
339
349
|
/**
|
|
340
350
|
* POST the create, with two typed twists: a 502 `amount_unverifiable`
|
|
341
351
|
* (Stripe did not answer the read-back) is retried on a short backoff
|
package/dist/client.js
CHANGED
|
@@ -282,6 +282,8 @@ export class VaultClient {
|
|
|
282
282
|
async authorize(input) {
|
|
283
283
|
if (input.signal?.aborted)
|
|
284
284
|
throw new CheckoutCancelledError();
|
|
285
|
+
if (input.merchantSignal?.aborted)
|
|
286
|
+
throw new PaymentOutcomeUnknownError(null, 'merchant_request_aborted');
|
|
285
287
|
const rec = findRecognizer(input.request.url, this.registry);
|
|
286
288
|
if (!rec)
|
|
287
289
|
throw new Error(`not a known tokenization endpoint: ${redactUrl(input.request.url)}`);
|
|
@@ -341,9 +343,11 @@ export class VaultClient {
|
|
|
341
343
|
},
|
|
342
344
|
};
|
|
343
345
|
try {
|
|
344
|
-
created = await this.createAuthorization(payload, input.currency, operationSignal);
|
|
346
|
+
created = await this.createAuthorization(payload, input.currency, AbortSignal.any([operationSignal, AbortSignal.timeout(30_000)]), input.merchantSignal);
|
|
345
347
|
}
|
|
346
348
|
catch (error) {
|
|
349
|
+
if (error instanceof PaymentOutcomeUnknownError)
|
|
350
|
+
throw error;
|
|
347
351
|
// A missing answer or generic 5xx can hide a committed row and a delivered approval link.
|
|
348
352
|
// Only the documented pre-create read-back errors prove it is safe to retry.
|
|
349
353
|
const safeReadFailure = error instanceof CheckoutApiError
|
|
@@ -357,125 +361,160 @@ export class VaultClient {
|
|
|
357
361
|
if (!created || typeof created.id !== 'string' || !created.id)
|
|
358
362
|
throw new PaymentOutcomeUnknownError(null, 'authorization_create_malformed');
|
|
359
363
|
const authorizationId = created.id;
|
|
364
|
+
const stopSignal = input.merchantSignal
|
|
365
|
+
? AbortSignal.any([input.merchantSignal, ...(input.signal ? [input.signal] : [])]) : input.signal;
|
|
360
366
|
try {
|
|
361
|
-
Promise.resolve(input.onAuthorizationCreated?.(authorizationId)).catch(() => { });
|
|
362
|
-
}
|
|
363
|
-
catch { /* observer only */ }
|
|
364
|
-
try {
|
|
365
|
-
Promise.resolve(input.onApprovalUrl?.(created.approvalUrl)).catch(() => { });
|
|
366
|
-
}
|
|
367
|
-
catch { /* approval delivery must not lose an existing authorization */ }
|
|
368
|
-
while (Date.now() < deadline) {
|
|
369
|
-
if (input.signal?.aborted)
|
|
370
|
-
throw new PaymentOutcomeUnknownError(authorizationId, 'local_cancel');
|
|
371
|
-
await interruptibleSleep(Math.min(this.pollIntervalMs, Math.max(0, deadline - Date.now())), input.signal);
|
|
372
|
-
if (input.signal?.aborted)
|
|
373
|
-
throw new PaymentOutcomeUnknownError(authorizationId, 'local_cancel');
|
|
374
|
-
let s;
|
|
375
|
-
const deadlineSignal = AbortSignal.timeout(Math.max(1, deadline - Date.now()));
|
|
376
|
-
const pollSignal = input.signal ? AbortSignal.any([input.signal, deadlineSignal]) : deadlineSignal;
|
|
377
367
|
try {
|
|
378
|
-
|
|
368
|
+
Promise.resolve(input.onAuthorizationCreated?.(authorizationId)).catch(() => { });
|
|
379
369
|
}
|
|
380
|
-
catch {
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
370
|
+
catch { /* observer only */ }
|
|
371
|
+
if (input.merchantSignal?.aborted)
|
|
372
|
+
throw new PaymentOutcomeUnknownError(authorizationId, 'merchant_request_aborted');
|
|
373
|
+
try {
|
|
374
|
+
Promise.resolve(input.onApprovalUrl?.(created.approvalUrl)).catch(() => { });
|
|
385
375
|
}
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
const
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
if (typeof s.submitted_at !== 'string' || !s.submitted_at) {
|
|
399
|
-
throw new PaymentOutcomeUnknownError(authorizationId, 'submission_timestamp_missing');
|
|
376
|
+
catch { /* approval delivery must not lose an existing authorization */ }
|
|
377
|
+
while (Date.now() < deadline) {
|
|
378
|
+
if (stopSignal?.aborted)
|
|
379
|
+
throw new PaymentOutcomeUnknownError(authorizationId, input.merchantSignal?.aborted ? 'merchant_request_aborted' : 'local_cancel');
|
|
380
|
+
await interruptibleSleep(Math.min(this.pollIntervalMs, Math.max(0, deadline - Date.now())), stopSignal);
|
|
381
|
+
if (stopSignal?.aborted)
|
|
382
|
+
throw new PaymentOutcomeUnknownError(authorizationId, input.merchantSignal?.aborted ? 'merchant_request_aborted' : 'local_cancel');
|
|
383
|
+
let s;
|
|
384
|
+
const deadlineSignal = AbortSignal.timeout(Math.max(1, deadline - Date.now()));
|
|
385
|
+
const pollSignal = stopSignal ? AbortSignal.any([stopSignal, deadlineSignal]) : deadlineSignal;
|
|
386
|
+
try {
|
|
387
|
+
s = await this.get(`/v2/checkout/authorizations/${authorizationId}`, pollSignal);
|
|
400
388
|
}
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
if (s.status === 'approved') {
|
|
404
|
-
const approvedMode = typeof s.mode === 'string' ? s.mode : 'token';
|
|
405
|
-
if (approvedMode === 'hosted_form') {
|
|
406
|
-
// Never: the API finishes a hosted form as submitted_on_device, and
|
|
407
|
-
// its database refuses `approved` on that mode. An answer that says
|
|
408
|
-
// otherwise is not one to act on as a payment.
|
|
409
|
-
throw new PaymentOutcomeUnknownError(authorizationId, 'hosted_form_approval_malformed');
|
|
389
|
+
catch {
|
|
390
|
+
throw new PaymentOutcomeUnknownError(authorizationId, input.merchantSignal?.aborted ? 'merchant_request_aborted' : 'authorization_poll_failed');
|
|
410
391
|
}
|
|
411
|
-
if (
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
//
|
|
421
|
-
//
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
392
|
+
if (input.merchantSignal?.aborted)
|
|
393
|
+
throw new PaymentOutcomeUnknownError(authorizationId, 'merchant_request_aborted');
|
|
394
|
+
if (!s || typeof s !== 'object' || Array.isArray(s) || typeof s.status !== 'string') {
|
|
395
|
+
throw new PaymentOutcomeUnknownError(authorizationId, 'authorization_status_malformed');
|
|
396
|
+
}
|
|
397
|
+
const amountAuthority = typeof s.amount_authority === 'string' && AMOUNT_AUTHORITIES.includes(s.amount_authority)
|
|
398
|
+
? { amountAuthority: s.amount_authority }
|
|
399
|
+
: {};
|
|
400
|
+
if (s.status === 'submitted_on_device') {
|
|
401
|
+
// The device attested that the processor's form left it; the stamp
|
|
402
|
+
// is the whole fact and it is NOT an approval (see HostedFormReplay).
|
|
403
|
+
// Only a hosted_form row may carry this status; a stamp without its
|
|
404
|
+
// time is not one this SDK can act on. The device may already have paid,
|
|
405
|
+
// so the adapter holds the attempt until the merchant is reconciled.
|
|
406
|
+
const mode = typeof s.mode === 'string' ? s.mode : 'token';
|
|
407
|
+
if (mode !== 'hosted_form')
|
|
408
|
+
throw new PaymentOutcomeUnknownError(authorizationId, 'submission_mode_mismatch');
|
|
409
|
+
if (typeof s.submitted_at !== 'string' || !s.submitted_at) {
|
|
410
|
+
throw new PaymentOutcomeUnknownError(authorizationId, 'submission_timestamp_missing');
|
|
411
|
+
}
|
|
412
|
+
return { mode: 'hosted_form', kind: 'submitted_on_device', outcome: 'unverified', authorizationId, submittedAt: s.submitted_at, ...amountAuthority };
|
|
413
|
+
}
|
|
414
|
+
if (s.status === 'approved') {
|
|
415
|
+
const approvedMode = typeof s.mode === 'string' ? s.mode : 'token';
|
|
416
|
+
if (approvedMode === 'hosted_form') {
|
|
417
|
+
// Never: the API finishes a hosted form as submitted_on_device, and
|
|
418
|
+
// its database refuses `approved` on that mode. An answer that says
|
|
419
|
+
// otherwise is not one to act on as a payment.
|
|
420
|
+
throw new PaymentOutcomeUnknownError(authorizationId, 'hosted_form_approval_malformed');
|
|
421
|
+
}
|
|
422
|
+
if (approvedMode === 'cse') {
|
|
423
|
+
const sub = s.substitutions;
|
|
424
|
+
const fieldsOk = sub && typeof sub === 'object' && sub.encoding === 'json' && typeof sub.at === 'string' && sub.at
|
|
425
|
+
&& sub.fields && typeof sub.fields === 'object' && !Array.isArray(sub.fields)
|
|
426
|
+
&& Object.values(sub.fields).every((v) => typeof v === 'string' && v.length > 0);
|
|
427
|
+
if (!fieldsOk)
|
|
428
|
+
throw new PaymentOutcomeUnknownError(authorizationId, 'cse_substitutions_malformed');
|
|
429
|
+
// `remove`: sibling keys the API says to drop with the swap (Adyen's
|
|
430
|
+
// `brand`, stamped by adyen-web from the agent's dummy digits). Absent
|
|
431
|
+
// on an older API; anything but a list of names is refused, since a
|
|
432
|
+
// half-understood instruction would continue a body Adyen refuses.
|
|
433
|
+
const removeRaw = sub.remove;
|
|
434
|
+
if (removeRaw !== undefined && !(Array.isArray(removeRaw) && removeRaw.every((k) => typeof k === 'string' && k.length > 0))) {
|
|
435
|
+
throw new PaymentOutcomeUnknownError(authorizationId, 'cse_remove_malformed');
|
|
436
|
+
}
|
|
437
|
+
return {
|
|
438
|
+
mode: 'cse',
|
|
439
|
+
authorizationId,
|
|
440
|
+
substitutions: {
|
|
441
|
+
encoding: 'json',
|
|
442
|
+
at: sub.at,
|
|
443
|
+
fields: { ...sub.fields },
|
|
444
|
+
...(removeRaw ? { remove: [...removeRaw] } : {}),
|
|
445
|
+
},
|
|
446
|
+
...amountAuthority,
|
|
447
|
+
};
|
|
448
|
+
}
|
|
449
|
+
if (approvedMode !== 'token')
|
|
450
|
+
throw new PaymentOutcomeUnknownError(authorizationId, 'approved_mode_unsupported');
|
|
451
|
+
const response = s.response;
|
|
452
|
+
if (!response || !Number.isInteger(response.status) || response.status < 100 || response.status > 599
|
|
453
|
+
|| typeof response.body !== 'string' || !response.headers || typeof response.headers !== 'object' || Array.isArray(response.headers)) {
|
|
454
|
+
throw new PaymentOutcomeUnknownError(authorizationId, 'approved_response_malformed');
|
|
425
455
|
}
|
|
426
456
|
return {
|
|
427
|
-
mode: '
|
|
457
|
+
mode: 'token',
|
|
428
458
|
authorizationId,
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
},
|
|
459
|
+
...response,
|
|
460
|
+
amountVerified: typeof s.amount_verified === 'boolean' ? s.amount_verified : null,
|
|
461
|
+
chargedAmountCents: typeof s.charged_amount_cents === 'number' ? s.charged_amount_cents : null,
|
|
462
|
+
chargedCurrency: typeof s.charged_currency === 'string' ? s.charged_currency : null,
|
|
463
|
+
chargedKind: s.charged_kind === 'captured' || s.charged_kind === 'authorized' || s.charged_kind === 'none' ? s.charged_kind : null,
|
|
435
464
|
...amountAuthority,
|
|
436
465
|
};
|
|
437
466
|
}
|
|
438
|
-
if (
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
chargedAmountCents: typeof s.charged_amount_cents === 'number' ? s.charged_amount_cents : null,
|
|
451
|
-
chargedCurrency: typeof s.charged_currency === 'string' ? s.charged_currency : null,
|
|
452
|
-
chargedKind: s.charged_kind === 'captured' || s.charged_kind === 'authorized' || s.charged_kind === 'none' ? s.charged_kind : null,
|
|
453
|
-
...amountAuthority,
|
|
454
|
-
};
|
|
455
|
-
}
|
|
456
|
-
if (s.status === 'declined') {
|
|
457
|
-
// The pre-replay checks declined it: typed, with the numbers, so the
|
|
458
|
-
// caller can say what happened rather than "the user said no".
|
|
459
|
-
if (s.reason === 'amount_mismatch') {
|
|
460
|
-
throw new AmountMismatchError(String(created.id), Number(s.expected_cents), Number(s.actual_cents), String(s.currency ?? input.currency ?? ''), s.actual_currency != null ? String(s.actual_currency) : undefined, 'pre_replay');
|
|
467
|
+
if (s.status === 'declined') {
|
|
468
|
+
// The pre-replay checks declined it: typed, with the numbers, so the
|
|
469
|
+
// caller can say what happened rather than "the user said no".
|
|
470
|
+
if (s.reason === 'amount_mismatch') {
|
|
471
|
+
throw new AmountMismatchError(String(created.id), Number(s.expected_cents), Number(s.actual_cents), String(s.currency ?? input.currency ?? ''), s.actual_currency != null ? String(s.actual_currency) : undefined, 'pre_replay');
|
|
472
|
+
}
|
|
473
|
+
if (s.reason === 'intent_not_confirmable')
|
|
474
|
+
throw new IntentNotConfirmableError(String(created.id));
|
|
475
|
+
if (s.reason === 'processor_refused') {
|
|
476
|
+
throw new ProcessorRefusedError(String(created.id), typeof s.psp_error_code === 'string' ? s.psp_error_code : null);
|
|
477
|
+
}
|
|
478
|
+
throw new ApprovalDeclinedError(s.reason ?? 'no reason given');
|
|
461
479
|
}
|
|
462
|
-
if (s.
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
throw new
|
|
480
|
+
if (s.status === 'expired') {
|
|
481
|
+
if (s.replay_attempted === false)
|
|
482
|
+
throw new ApprovalTimeoutError(timeoutMs);
|
|
483
|
+
throw new PaymentOutcomeUnknownError(authorizationId, 'expired_after_possible_replay');
|
|
466
484
|
}
|
|
467
|
-
|
|
485
|
+
if (s.status !== 'awaiting_approval')
|
|
486
|
+
throw new PaymentOutcomeUnknownError(authorizationId, 'authorization_status_unrecognized');
|
|
468
487
|
}
|
|
469
|
-
|
|
470
|
-
|
|
471
|
-
|
|
472
|
-
|
|
488
|
+
// A local deadline is not server-side expiry; the person may still use the approval link.
|
|
489
|
+
throw new PaymentOutcomeUnknownError(authorizationId, 'local_approval_timeout');
|
|
490
|
+
}
|
|
491
|
+
catch (error) {
|
|
492
|
+
if (input.merchantSignal?.aborted)
|
|
493
|
+
throw new PaymentOutcomeUnknownError(authorizationId, 'merchant_request_aborted');
|
|
494
|
+
throw error;
|
|
495
|
+
}
|
|
496
|
+
finally {
|
|
497
|
+
if (input.merchantSignal?.aborted) {
|
|
498
|
+
// Drain a create acknowledgement even after the merchant aborts so its
|
|
499
|
+
// known ID can be retired. An unacknowledged create remains unknown.
|
|
500
|
+
// A started/finalized replay or failed cleanup never becomes a claimed
|
|
501
|
+
// cancellation, and this best-effort cleanup never retries a payment.
|
|
502
|
+
await this.cancelAuthorization(authorizationId).catch(() => { });
|
|
473
503
|
}
|
|
474
|
-
if (s.status !== 'awaiting_approval')
|
|
475
|
-
throw new PaymentOutcomeUnknownError(authorizationId, 'authorization_status_unrecognized');
|
|
476
504
|
}
|
|
477
|
-
|
|
478
|
-
|
|
505
|
+
}
|
|
506
|
+
/** Retire only a pre-replay authorization. A 409 or missing response remains unknown. */
|
|
507
|
+
async cancelAuthorization(authorizationId) {
|
|
508
|
+
if (!/^cauth_[A-Za-z0-9_-]{1,128}$/.test(authorizationId))
|
|
509
|
+
throw new Error('Invalid authorization ID.');
|
|
510
|
+
const result = await this.post(`/v2/checkout/authorizations/${authorizationId}/cancel`, {}, AbortSignal.timeout(5_000));
|
|
511
|
+
if (result?.id !== authorizationId || result.status !== 'declined'
|
|
512
|
+
|| result.reason !== 'merchant_request_aborted' || result.cancelled !== true
|
|
513
|
+
|| result.processor_request_started !== false) {
|
|
514
|
+
throw new PaymentOutcomeUnknownError(authorizationId, 'authorization_cancel_unconfirmed');
|
|
515
|
+
}
|
|
516
|
+
return { id: authorizationId, status: 'declined', reason: 'merchant_request_aborted',
|
|
517
|
+
cancelled: true, processor_request_started: false };
|
|
479
518
|
}
|
|
480
519
|
/**
|
|
481
520
|
* POST the create, with two typed twists: a 502 `amount_unverifiable`
|
|
@@ -484,10 +523,12 @@ export class VaultClient {
|
|
|
484
523
|
* `amount_mismatch` becomes an AmountMismatchError at stage 'create' so the
|
|
485
524
|
* adapters treat it as an answered request, not a dead page.
|
|
486
525
|
*/
|
|
487
|
-
async createAuthorization(payload, currency, signal) {
|
|
526
|
+
async createAuthorization(payload, currency, signal, stopRetries) {
|
|
488
527
|
for (let attempt = 0;; attempt++) {
|
|
528
|
+
if (stopRetries?.aborted)
|
|
529
|
+
throw new PaymentOutcomeUnknownError(null, 'merchant_request_aborted');
|
|
489
530
|
try {
|
|
490
|
-
return await this.post('/v2/checkout/authorizations', payload, signal);
|
|
531
|
+
return await this.post('/v2/checkout/authorizations', payload, signal, stopRetries);
|
|
491
532
|
}
|
|
492
533
|
catch (err) {
|
|
493
534
|
if (err instanceof CheckoutApiError && err.code === 'amount_mismatch') {
|
|
@@ -500,7 +541,7 @@ export class VaultClient {
|
|
|
500
541
|
&& (err.code === 'amount_unverifiable' || err.code === 'cse_key_unavailable');
|
|
501
542
|
if (!retryable || attempt >= this.unverifiableRetryDelaysMs.length)
|
|
502
543
|
throw err;
|
|
503
|
-
await interruptibleSleep(this.unverifiableRetryDelaysMs[attempt], signal);
|
|
544
|
+
await interruptibleSleep(this.unverifiableRetryDelaysMs[attempt], stopRetries ? AbortSignal.any([stopRetries, ...(signal ? [signal] : [])]) : signal);
|
|
504
545
|
if (signal?.aborted)
|
|
505
546
|
throw signal.reason;
|
|
506
547
|
}
|
|
@@ -545,9 +586,14 @@ export class VaultClient {
|
|
|
545
586
|
return this.inflight;
|
|
546
587
|
}
|
|
547
588
|
/** Authenticated request that retries ONCE on a 401 with a fresh token. */
|
|
548
|
-
async call(path, init = {}, retried = false) {
|
|
589
|
+
async call(path, init = {}, retried = false, stopNewRequests) {
|
|
549
590
|
const signal = init.signal ?? AbortSignal.timeout(30_000);
|
|
550
591
|
const token = await withSignal(this.accessToken(), signal);
|
|
592
|
+
// Auth can outlive the merchant request. Stop a create that has not left
|
|
593
|
+
// yet, while preserving the response of one already sent for cleanup.
|
|
594
|
+
if (stopNewRequests?.aborted)
|
|
595
|
+
throw new PaymentOutcomeUnknownError(null, 'merchant_request_aborted');
|
|
596
|
+
signal.throwIfAborted();
|
|
551
597
|
const r = await withSignal(this.fetch(`${this.baseUrl}${path}`, {
|
|
552
598
|
...init,
|
|
553
599
|
signal,
|
|
@@ -555,15 +601,17 @@ export class VaultClient {
|
|
|
555
601
|
}), signal);
|
|
556
602
|
// A token can be revoked or expire early; one forced refresh, then give up.
|
|
557
603
|
if (r.status === 401 && !retried) {
|
|
604
|
+
if (stopNewRequests?.aborted)
|
|
605
|
+
throw new PaymentOutcomeUnknownError(null, 'merchant_request_aborted');
|
|
558
606
|
await withSignal(this.accessToken(true), signal);
|
|
559
|
-
return this.call(path, { ...init, signal }, true);
|
|
607
|
+
return this.call(path, { ...init, signal }, true, stopNewRequests);
|
|
560
608
|
}
|
|
561
609
|
if (!r.ok)
|
|
562
610
|
throw new CheckoutApiError(r.status, path, await withSignal(r.text(), signal));
|
|
563
611
|
return withSignal(r.json(), signal);
|
|
564
612
|
}
|
|
565
|
-
post(path, body, signal) {
|
|
566
|
-
return this.call(path, { method: 'POST', body: JSON.stringify(body), signal });
|
|
613
|
+
post(path, body, signal, stopNewRequests) {
|
|
614
|
+
return this.call(path, { method: 'POST', body: JSON.stringify(body), signal }, false, stopNewRequests);
|
|
567
615
|
}
|
|
568
616
|
get(path, signal) {
|
|
569
617
|
return this.call(path, { signal });
|
package/dist/lifecycle.d.ts
CHANGED
|
@@ -54,6 +54,7 @@ export declare class CheckoutLifecycle implements CheckoutController {
|
|
|
54
54
|
private held;
|
|
55
55
|
private active;
|
|
56
56
|
private cancelled;
|
|
57
|
+
private merchantAborted;
|
|
57
58
|
private unboundStripeToken;
|
|
58
59
|
private reconciliation;
|
|
59
60
|
readonly abort: AbortController;
|
|
@@ -68,6 +69,8 @@ export declare class CheckoutLifecycle implements CheckoutController {
|
|
|
68
69
|
approvalUrl(approvalUrl: string): void;
|
|
69
70
|
private notify;
|
|
70
71
|
cancel(): void;
|
|
72
|
+
/** The exact browser request is gone; a late approval cannot reopen it. */
|
|
73
|
+
merchantRequestAborted(): void;
|
|
71
74
|
unsupported(): void;
|
|
72
75
|
prepareHandoff(replay: ReplayResponse, requestUrl: string): void;
|
|
73
76
|
handedOff(replay: ReplayResponse): void;
|
package/dist/lifecycle.js
CHANGED
|
@@ -6,6 +6,7 @@ export class CheckoutLifecycle {
|
|
|
6
6
|
held = false;
|
|
7
7
|
active = false;
|
|
8
8
|
cancelled = false;
|
|
9
|
+
merchantAborted = false;
|
|
9
10
|
unboundStripeToken = false;
|
|
10
11
|
reconciliation = null;
|
|
11
12
|
abort = new AbortController();
|
|
@@ -46,6 +47,12 @@ export class CheckoutLifecycle {
|
|
|
46
47
|
this.abort.abort();
|
|
47
48
|
this.set({ ...this.state, status: this.active || this.state.authorizationId ? 'outcome_unknown' : 'cancelled', reason: 'local_cancel' });
|
|
48
49
|
}
|
|
50
|
+
/** The exact browser request is gone; a late approval cannot reopen it. */
|
|
51
|
+
merchantRequestAborted() {
|
|
52
|
+
this.merchantAborted = true;
|
|
53
|
+
this.held = true;
|
|
54
|
+
this.set({ ...this.state, status: 'outcome_unknown', reason: 'merchant_request_aborted' });
|
|
55
|
+
}
|
|
49
56
|
unsupported() {
|
|
50
57
|
this.held = true;
|
|
51
58
|
if (this.state.authorizationId)
|
|
@@ -71,6 +78,8 @@ export class CheckoutLifecycle {
|
|
|
71
78
|
}
|
|
72
79
|
}
|
|
73
80
|
handedOff(replay) {
|
|
81
|
+
if (this.merchantAborted)
|
|
82
|
+
return;
|
|
74
83
|
const mismatch = (!replay.mode || replay.mode === 'token') && replay.amountVerified === false;
|
|
75
84
|
this.held = !!this.options.requireMerchantResult || replay.mode === 'hosted_form' || mismatch || this.unboundStripeToken;
|
|
76
85
|
this.set({ status: 'awaiting_merchant', authorizationId: replay.authorizationId, mode: replay.mode ?? 'token',
|
|
@@ -79,6 +88,10 @@ export class CheckoutLifecycle {
|
|
|
79
88
|
failed(error, handoffStarted = false) {
|
|
80
89
|
if (this.cancelled)
|
|
81
90
|
return;
|
|
91
|
+
if (this.merchantAborted) {
|
|
92
|
+
this.merchantRequestAborted();
|
|
93
|
+
return;
|
|
94
|
+
}
|
|
82
95
|
const authorizationId = error instanceof PaymentOutcomeUnknownError ? error.authorizationId : this.state.authorizationId;
|
|
83
96
|
if (handoffStarted || error instanceof PaymentOutcomeUnknownError || error instanceof IntentNotConfirmableError) {
|
|
84
97
|
this.held = true;
|
|
@@ -160,6 +173,7 @@ export class CheckoutLifecycle {
|
|
|
160
173
|
if (this.unboundStripeToken)
|
|
161
174
|
throw new Error('This attachment delivered an unbound Stripe token; reconcile the merchant order and use a separately validated checkout flow.');
|
|
162
175
|
this.held = false;
|
|
176
|
+
this.merchantAborted = false;
|
|
163
177
|
this.set({ status: 'idle', authorizationId: null });
|
|
164
178
|
}
|
|
165
179
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@agent-cards/checkout",
|
|
3
|
-
"version": "0.3.
|
|
3
|
+
"version": "0.3.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,7 +23,7 @@
|
|
|
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",
|
|
26
|
+
"test": "node test.mjs && node --test lifecycle.test.mjs merchant-abort.test.mjs",
|
|
27
27
|
"test:browser": "node browser.test.mjs && node stripe-browser.test.mjs"
|
|
28
28
|
},
|
|
29
29
|
"keywords": [
|