@agent-cards/checkout 0.3.0 → 0.4.0
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 +15 -0
- package/README.md +34 -5
- package/dist/cdp.js +162 -6
- package/dist/client.d.ts +60 -0
- package/dist/client.js +323 -113
- package/dist/index.d.ts +2 -2
- package/dist/index.js +1 -1
- package/dist/lifecycle.d.ts +17 -3
- package/dist/lifecycle.js +44 -2
- package/dist/preparation.d.ts +25 -0
- package/dist/preparation.js +150 -0
- package/package.json +3 -3
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) {
|
|
@@ -223,6 +233,8 @@ export class VaultClient {
|
|
|
223
233
|
pollIntervalMs;
|
|
224
234
|
unverifiableRetryDelaysMs;
|
|
225
235
|
registry;
|
|
236
|
+
preparations = new WeakSet();
|
|
237
|
+
usedPreparations = new WeakSet();
|
|
226
238
|
constructor(opts) {
|
|
227
239
|
this.opts = opts;
|
|
228
240
|
this.baseUrl = (opts.baseUrl ?? 'https://api.agentcard.sh').replace(/\/$/, '');
|
|
@@ -274,14 +286,125 @@ export class VaultClient {
|
|
|
274
286
|
cardUrlPatterns() {
|
|
275
287
|
return deriveCardUrlPatterns(this.registry);
|
|
276
288
|
}
|
|
289
|
+
/** Wait for real device approval before the caller starts native tokenization. */
|
|
290
|
+
async prepareCheckout(input) {
|
|
291
|
+
input = { ...input };
|
|
292
|
+
const fail = (reason, id = null) => new CheckoutPreparationError(id, reason);
|
|
293
|
+
if (input.psp !== 'square' || !['production', 'sandbox'].includes(input.environment))
|
|
294
|
+
throw fail('unsupported_processor');
|
|
295
|
+
if (!Number.isSafeInteger(input.amountCents) || input.amountCents <= 0 || typeof input.currency !== 'string' || !/^[a-z]{3}$/i.test(input.currency))
|
|
296
|
+
throw fail('amount_required');
|
|
297
|
+
const origin = new URL(input.merchantOrigin);
|
|
298
|
+
if (!(origin.protocol === 'https:' || (origin.protocol === 'http:' && origin.hostname === 'localhost')) || origin.origin !== input.merchantOrigin)
|
|
299
|
+
throw fail('merchant_origin_invalid');
|
|
300
|
+
if (!input.checkoutKey || !input.user || !input.merchant)
|
|
301
|
+
throw fail('checkout_context_required');
|
|
302
|
+
const timeoutMs = input.timeoutMs ?? 15 * 60_000;
|
|
303
|
+
if (!Number.isInteger(timeoutMs) || timeoutMs <= 0 || timeoutMs > 2_147_483_647)
|
|
304
|
+
throw fail('timeout_invalid');
|
|
305
|
+
const signal = input.signal ? AbortSignal.any([input.signal, AbortSignal.timeout(timeoutMs)]) : AbortSignal.timeout(timeoutMs);
|
|
306
|
+
if (signal.aborted)
|
|
307
|
+
throw fail('cancelled');
|
|
308
|
+
let id = null;
|
|
309
|
+
let ready = false;
|
|
310
|
+
try {
|
|
311
|
+
// Drain a sent creation even after caller cancellation to retire its ID.
|
|
312
|
+
// The separate stop signal prevents dispatch after a slow OAuth exchange.
|
|
313
|
+
const created = await this.post('/v2/checkout/preparations', {
|
|
314
|
+
user: input.user, merchant: input.merchant, amount_cents: input.amountCents, currency: input.currency.toLowerCase(),
|
|
315
|
+
...(input.cardId ? { card_id: input.cardId } : {}), psp: input.psp, mode: 'token',
|
|
316
|
+
environment: input.environment, checkout_key: input.checkoutKey, merchant_origin: input.merchantOrigin,
|
|
317
|
+
}, AbortSignal.timeout(30_000), signal);
|
|
318
|
+
if (!created || typeof created.id !== 'string' || !/^cprep_[A-Za-z0-9_-]{1,128}$/.test(created.id))
|
|
319
|
+
throw fail('create_unconfirmed');
|
|
320
|
+
const preparationId = created.id;
|
|
321
|
+
id = preparationId;
|
|
322
|
+
try {
|
|
323
|
+
Promise.resolve(input.onPreparationCreated?.(preparationId)).catch(() => { });
|
|
324
|
+
}
|
|
325
|
+
catch { /* observer only */ }
|
|
326
|
+
if (signal.aborted)
|
|
327
|
+
throw fail('cancelled', id);
|
|
328
|
+
if (typeof created.approvalUrl !== 'string')
|
|
329
|
+
throw fail('approval_url_missing', id);
|
|
330
|
+
try {
|
|
331
|
+
Promise.resolve(input.onApprovalUrl?.(created.approvalUrl)).catch(() => { });
|
|
332
|
+
}
|
|
333
|
+
catch { /* observer only */ }
|
|
334
|
+
while (!signal.aborted) {
|
|
335
|
+
const state = await this.get(`/v2/checkout/preparations/${id}`, signal);
|
|
336
|
+
if (signal.aborted)
|
|
337
|
+
throw fail('cancelled', id);
|
|
338
|
+
if (state?.id !== id)
|
|
339
|
+
throw fail('status_unconfirmed', id);
|
|
340
|
+
if (state.status === 'ready') {
|
|
341
|
+
const expiry = Date.parse(state.ready_expires_at);
|
|
342
|
+
if (!Number.isFinite(expiry) || expiry <= Date.now() || typeof state.card_id !== 'string' || !state.card_id
|
|
343
|
+
|| state.payment_status !== 'not_started' || state.amount_authority !== 'display_only'
|
|
344
|
+
|| state.user !== input.user || state.merchant !== input.merchant || state.merchant_origin !== input.merchantOrigin
|
|
345
|
+
|| state.amount_cents !== input.amountCents || state.currency !== input.currency.toLowerCase()
|
|
346
|
+
|| state.psp !== 'square' || state.mode !== 'token' || state.environment !== input.environment
|
|
347
|
+
|| state.checkout_key !== input.checkoutKey)
|
|
348
|
+
throw fail('ready_unconfirmed', id);
|
|
349
|
+
const prepared = Object.freeze({
|
|
350
|
+
id: preparationId, status: 'ready', psp: input.psp, environment: input.environment, expiresAt: state.ready_expires_at,
|
|
351
|
+
cardId: state.card_id, user: input.user, merchant: input.merchant, amountCents: input.amountCents,
|
|
352
|
+
currency: input.currency.toLowerCase(), merchantOrigin: input.merchantOrigin, checkoutKey: input.checkoutKey,
|
|
353
|
+
paymentStatus: 'not_started', amountAuthority: 'display_only',
|
|
354
|
+
});
|
|
355
|
+
this.preparations.add(prepared);
|
|
356
|
+
ready = true;
|
|
357
|
+
return prepared;
|
|
358
|
+
}
|
|
359
|
+
if (state.status !== 'awaiting_approval')
|
|
360
|
+
throw fail(['cancelled', 'expired', 'bound'].includes(state.status) ? state.status : 'status_unconfirmed', id);
|
|
361
|
+
await interruptibleSleep(this.pollIntervalMs, signal);
|
|
362
|
+
}
|
|
363
|
+
throw fail('cancelled', id);
|
|
364
|
+
}
|
|
365
|
+
catch (error) {
|
|
366
|
+
if (error instanceof CheckoutPreparationError)
|
|
367
|
+
throw error;
|
|
368
|
+
throw fail(signal.aborted ? 'cancelled' : 'preparation_unconfirmed', id);
|
|
369
|
+
}
|
|
370
|
+
finally {
|
|
371
|
+
if (id && !ready)
|
|
372
|
+
await this.cancelPreparation(id).catch(() => { });
|
|
373
|
+
}
|
|
374
|
+
}
|
|
375
|
+
/** Cancel only an unconsumed preparation; a bound request is reconciled separately. */
|
|
376
|
+
async cancelPreparation(id) {
|
|
377
|
+
if (!/^cprep_[A-Za-z0-9_-]{1,128}$/.test(id))
|
|
378
|
+
throw new CheckoutPreparationError(null, 'id_invalid');
|
|
379
|
+
const state = await this.post(`/v2/checkout/preparations/${id}/cancel`, {}, AbortSignal.timeout(5_000));
|
|
380
|
+
if (state?.id !== id || !['cancelled', 'expired'].includes(state.status))
|
|
381
|
+
throw new CheckoutPreparationError(id, 'cancel_unconfirmed');
|
|
382
|
+
}
|
|
277
383
|
/**
|
|
278
384
|
* Hand us a paused tokenization request. We ask the cardholder to approve,
|
|
279
385
|
* their device supplies the card and calls the merchant, and you get back the
|
|
280
386
|
* response to replay into the browser. Your process never sees a card.
|
|
281
387
|
*/
|
|
282
388
|
async authorize(input) {
|
|
389
|
+
const preparation = input.preparation;
|
|
390
|
+
if (preparation) {
|
|
391
|
+
if (!this.preparations.has(preparation) || this.usedPreparations.has(preparation))
|
|
392
|
+
throw new CheckoutPreparationError(preparation.id ?? null, 'already_used_or_foreign');
|
|
393
|
+
// Consume locally before any await, including OAuth, and never recycle it.
|
|
394
|
+
this.usedPreparations.add(preparation);
|
|
395
|
+
const url = new URL(input.request.url);
|
|
396
|
+
const host = preparation.environment === 'production' ? 'pci-connect.squareup.com' : 'pci-connect.squareupsandbox.com';
|
|
397
|
+
if (Date.parse(preparation.expiresAt) <= Date.now())
|
|
398
|
+
throw new CheckoutPreparationError(preparation.id, 'expired');
|
|
399
|
+
if (input.user !== preparation.user || input.merchant !== preparation.merchant || input.amountCents !== preparation.amountCents
|
|
400
|
+
|| input.currency?.toLowerCase() !== preparation.currency || input.cardId !== preparation.cardId
|
|
401
|
+
|| url.origin !== `https://${host}` || url.pathname !== '/v2/card-nonce' || url.username || url.password)
|
|
402
|
+
throw new CheckoutPreparationError(preparation.id, 'checkout_changed');
|
|
403
|
+
}
|
|
283
404
|
if (input.signal?.aborted)
|
|
284
405
|
throw new CheckoutCancelledError();
|
|
406
|
+
if (input.merchantSignal?.aborted)
|
|
407
|
+
throw new PaymentOutcomeUnknownError(null, 'merchant_request_aborted');
|
|
285
408
|
const rec = findRecognizer(input.request.url, this.registry);
|
|
286
409
|
if (!rec)
|
|
287
410
|
throw new Error(`not a known tokenization endpoint: ${redactUrl(input.request.url)}`);
|
|
@@ -333,6 +456,7 @@ export class VaultClient {
|
|
|
333
456
|
// the recognizer and refuses a disagreement before a row exists.
|
|
334
457
|
mode,
|
|
335
458
|
...(input.cardId ? { cardId: input.cardId } : {}),
|
|
459
|
+
...(preparation ? { preparation_id: preparation.id, checkout_key: preparation.checkoutKey, merchant_origin: preparation.merchantOrigin } : {}),
|
|
336
460
|
request: {
|
|
337
461
|
url: input.request.url,
|
|
338
462
|
method: input.request.method,
|
|
@@ -341,141 +465,218 @@ export class VaultClient {
|
|
|
341
465
|
},
|
|
342
466
|
};
|
|
343
467
|
try {
|
|
344
|
-
created = await this.createAuthorization(payload, input.currency, operationSignal);
|
|
468
|
+
created = await this.createAuthorization(payload, input.currency, AbortSignal.any([operationSignal, AbortSignal.timeout(30_000)]), input.merchantSignal);
|
|
345
469
|
}
|
|
346
470
|
catch (error) {
|
|
471
|
+
if (error instanceof PaymentOutcomeUnknownError) {
|
|
472
|
+
if (preparation && !error.authorizationId)
|
|
473
|
+
throw new PaymentOutcomeUnknownError(await this.retireUncertainPreparation(preparation, input.onAuthorizationCreated), error.reason);
|
|
474
|
+
throw error;
|
|
475
|
+
}
|
|
476
|
+
if (preparation && error instanceof CheckoutApiError && error.code === 'preparation_bound') {
|
|
477
|
+
const id = typeof error.details.authorization_id === 'string' && /^cauth_[A-Za-z0-9_-]+$/.test(error.details.authorization_id) ? error.details.authorization_id : null;
|
|
478
|
+
if (id) {
|
|
479
|
+
try {
|
|
480
|
+
Promise.resolve(input.onAuthorizationCreated?.(id)).catch(() => { });
|
|
481
|
+
}
|
|
482
|
+
catch { /* observer only */ }
|
|
483
|
+
}
|
|
484
|
+
throw new PaymentOutcomeUnknownError(id, 'preparation_already_bound');
|
|
485
|
+
}
|
|
347
486
|
// A missing answer or generic 5xx can hide a committed row and a delivered approval link.
|
|
348
487
|
// Only the documented pre-create read-back errors prove it is safe to retry.
|
|
349
488
|
const safeReadFailure = error instanceof CheckoutApiError
|
|
350
489
|
&& error.status === 502 && (error.code === 'amount_unverifiable' || error.code === 'cse_key_unavailable');
|
|
351
490
|
if ((error instanceof CheckoutApiError && error.status >= 500 && !safeReadFailure)
|
|
352
491
|
|| (!(error instanceof CheckoutApiError) && !(error instanceof ApprovalDeclinedError))) {
|
|
353
|
-
throw new PaymentOutcomeUnknownError(null, 'authorization_create_unanswered');
|
|
492
|
+
throw new PaymentOutcomeUnknownError(preparation ? await this.retireUncertainPreparation(preparation, input.onAuthorizationCreated) : null, 'authorization_create_unanswered');
|
|
354
493
|
}
|
|
355
494
|
throw error;
|
|
356
495
|
}
|
|
357
496
|
if (!created || typeof created.id !== 'string' || !created.id)
|
|
358
|
-
throw new PaymentOutcomeUnknownError(null, 'authorization_create_malformed');
|
|
497
|
+
throw new PaymentOutcomeUnknownError(preparation ? await this.retireUncertainPreparation(preparation, input.onAuthorizationCreated) : null, 'authorization_create_malformed');
|
|
359
498
|
const authorizationId = created.id;
|
|
499
|
+
let failed = false;
|
|
500
|
+
const stopSignal = input.merchantSignal
|
|
501
|
+
? AbortSignal.any([input.merchantSignal, ...(input.signal ? [input.signal] : [])]) : input.signal;
|
|
360
502
|
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
503
|
try {
|
|
378
|
-
|
|
379
|
-
}
|
|
380
|
-
catch {
|
|
381
|
-
throw new PaymentOutcomeUnknownError(authorizationId, 'authorization_poll_failed');
|
|
504
|
+
Promise.resolve(input.onAuthorizationCreated?.(authorizationId)).catch(() => { });
|
|
382
505
|
}
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
if (s.status === 'submitted_on_device') {
|
|
390
|
-
// The device attested that the processor's form left it; the stamp
|
|
391
|
-
// is the whole fact and it is NOT an approval (see HostedFormReplay).
|
|
392
|
-
// Only a hosted_form row may carry this status; a stamp without its
|
|
393
|
-
// time is not one this SDK can act on. The device may already have paid,
|
|
394
|
-
// so the adapter holds the attempt until the merchant is reconciled.
|
|
395
|
-
const mode = typeof s.mode === 'string' ? s.mode : 'token';
|
|
396
|
-
if (mode !== 'hosted_form')
|
|
397
|
-
throw new PaymentOutcomeUnknownError(authorizationId, 'submission_mode_mismatch');
|
|
398
|
-
if (typeof s.submitted_at !== 'string' || !s.submitted_at) {
|
|
399
|
-
throw new PaymentOutcomeUnknownError(authorizationId, 'submission_timestamp_missing');
|
|
506
|
+
catch { /* observer only */ }
|
|
507
|
+
if (input.merchantSignal?.aborted)
|
|
508
|
+
throw new PaymentOutcomeUnknownError(authorizationId, 'merchant_request_aborted');
|
|
509
|
+
if (!preparation) {
|
|
510
|
+
try {
|
|
511
|
+
Promise.resolve(input.onApprovalUrl?.(created.approvalUrl)).catch(() => { });
|
|
400
512
|
}
|
|
401
|
-
|
|
513
|
+
catch { /* approval delivery must not lose an existing authorization */ }
|
|
402
514
|
}
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
515
|
+
while (Date.now() < deadline) {
|
|
516
|
+
if (stopSignal?.aborted)
|
|
517
|
+
throw new PaymentOutcomeUnknownError(authorizationId, input.merchantSignal?.aborted ? 'merchant_request_aborted' : 'local_cancel');
|
|
518
|
+
await interruptibleSleep(Math.min(this.pollIntervalMs, Math.max(0, deadline - Date.now())), stopSignal);
|
|
519
|
+
if (stopSignal?.aborted)
|
|
520
|
+
throw new PaymentOutcomeUnknownError(authorizationId, input.merchantSignal?.aborted ? 'merchant_request_aborted' : 'local_cancel');
|
|
521
|
+
let s;
|
|
522
|
+
const deadlineSignal = AbortSignal.timeout(Math.max(1, deadline - Date.now()));
|
|
523
|
+
const pollSignal = stopSignal ? AbortSignal.any([stopSignal, deadlineSignal]) : deadlineSignal;
|
|
524
|
+
try {
|
|
525
|
+
s = await this.get(`/v2/checkout/authorizations/${authorizationId}`, pollSignal);
|
|
526
|
+
}
|
|
527
|
+
catch {
|
|
528
|
+
throw new PaymentOutcomeUnknownError(authorizationId, input.merchantSignal?.aborted ? 'merchant_request_aborted' : 'authorization_poll_failed');
|
|
529
|
+
}
|
|
530
|
+
if (input.merchantSignal?.aborted)
|
|
531
|
+
throw new PaymentOutcomeUnknownError(authorizationId, 'merchant_request_aborted');
|
|
532
|
+
if (!s || typeof s !== 'object' || Array.isArray(s) || typeof s.status !== 'string') {
|
|
533
|
+
throw new PaymentOutcomeUnknownError(authorizationId, 'authorization_status_malformed');
|
|
534
|
+
}
|
|
535
|
+
const amountAuthority = typeof s.amount_authority === 'string' && AMOUNT_AUTHORITIES.includes(s.amount_authority)
|
|
536
|
+
? { amountAuthority: s.amount_authority }
|
|
537
|
+
: {};
|
|
538
|
+
if (s.status === 'submitted_on_device') {
|
|
539
|
+
// The device attested that the processor's form left it; the stamp
|
|
540
|
+
// is the whole fact and it is NOT an approval (see HostedFormReplay).
|
|
541
|
+
// Only a hosted_form row may carry this status; a stamp without its
|
|
542
|
+
// time is not one this SDK can act on. The device may already have paid,
|
|
543
|
+
// so the adapter holds the attempt until the merchant is reconciled.
|
|
544
|
+
const mode = typeof s.mode === 'string' ? s.mode : 'token';
|
|
545
|
+
if (mode !== 'hosted_form')
|
|
546
|
+
throw new PaymentOutcomeUnknownError(authorizationId, 'submission_mode_mismatch');
|
|
547
|
+
if (typeof s.submitted_at !== 'string' || !s.submitted_at) {
|
|
548
|
+
throw new PaymentOutcomeUnknownError(authorizationId, 'submission_timestamp_missing');
|
|
549
|
+
}
|
|
550
|
+
return { mode: 'hosted_form', kind: 'submitted_on_device', outcome: 'unverified', authorizationId, submittedAt: s.submitted_at, ...amountAuthority };
|
|
410
551
|
}
|
|
411
|
-
if (
|
|
412
|
-
const
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
throw new PaymentOutcomeUnknownError(authorizationId, '
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
552
|
+
if (s.status === 'approved') {
|
|
553
|
+
const approvedMode = typeof s.mode === 'string' ? s.mode : 'token';
|
|
554
|
+
if (approvedMode === 'hosted_form') {
|
|
555
|
+
// Never: the API finishes a hosted form as submitted_on_device, and
|
|
556
|
+
// its database refuses `approved` on that mode. An answer that says
|
|
557
|
+
// otherwise is not one to act on as a payment.
|
|
558
|
+
throw new PaymentOutcomeUnknownError(authorizationId, 'hosted_form_approval_malformed');
|
|
559
|
+
}
|
|
560
|
+
if (approvedMode === 'cse') {
|
|
561
|
+
const sub = s.substitutions;
|
|
562
|
+
const fieldsOk = sub && typeof sub === 'object' && sub.encoding === 'json' && typeof sub.at === 'string' && sub.at
|
|
563
|
+
&& sub.fields && typeof sub.fields === 'object' && !Array.isArray(sub.fields)
|
|
564
|
+
&& Object.values(sub.fields).every((v) => typeof v === 'string' && v.length > 0);
|
|
565
|
+
if (!fieldsOk)
|
|
566
|
+
throw new PaymentOutcomeUnknownError(authorizationId, 'cse_substitutions_malformed');
|
|
567
|
+
// `remove`: sibling keys the API says to drop with the swap (Adyen's
|
|
568
|
+
// `brand`, stamped by adyen-web from the agent's dummy digits). Absent
|
|
569
|
+
// on an older API; anything but a list of names is refused, since a
|
|
570
|
+
// half-understood instruction would continue a body Adyen refuses.
|
|
571
|
+
const removeRaw = sub.remove;
|
|
572
|
+
if (removeRaw !== undefined && !(Array.isArray(removeRaw) && removeRaw.every((k) => typeof k === 'string' && k.length > 0))) {
|
|
573
|
+
throw new PaymentOutcomeUnknownError(authorizationId, 'cse_remove_malformed');
|
|
574
|
+
}
|
|
575
|
+
return {
|
|
576
|
+
mode: 'cse',
|
|
577
|
+
authorizationId,
|
|
578
|
+
substitutions: {
|
|
579
|
+
encoding: 'json',
|
|
580
|
+
at: sub.at,
|
|
581
|
+
fields: { ...sub.fields },
|
|
582
|
+
...(removeRaw ? { remove: [...removeRaw] } : {}),
|
|
583
|
+
},
|
|
584
|
+
...amountAuthority,
|
|
585
|
+
};
|
|
586
|
+
}
|
|
587
|
+
if (approvedMode !== 'token')
|
|
588
|
+
throw new PaymentOutcomeUnknownError(authorizationId, 'approved_mode_unsupported');
|
|
589
|
+
const response = s.response;
|
|
590
|
+
if (!response || !Number.isInteger(response.status) || response.status < 100 || response.status > 599
|
|
591
|
+
|| typeof response.body !== 'string' || !response.headers || typeof response.headers !== 'object' || Array.isArray(response.headers)) {
|
|
592
|
+
throw new PaymentOutcomeUnknownError(authorizationId, 'approved_response_malformed');
|
|
425
593
|
}
|
|
426
594
|
return {
|
|
427
|
-
mode: '
|
|
595
|
+
mode: 'token',
|
|
428
596
|
authorizationId,
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
},
|
|
597
|
+
...response,
|
|
598
|
+
amountVerified: typeof s.amount_verified === 'boolean' ? s.amount_verified : null,
|
|
599
|
+
chargedAmountCents: typeof s.charged_amount_cents === 'number' ? s.charged_amount_cents : null,
|
|
600
|
+
chargedCurrency: typeof s.charged_currency === 'string' ? s.charged_currency : null,
|
|
601
|
+
chargedKind: s.charged_kind === 'captured' || s.charged_kind === 'authorized' || s.charged_kind === 'none' ? s.charged_kind : null,
|
|
435
602
|
...amountAuthority,
|
|
436
603
|
};
|
|
437
604
|
}
|
|
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');
|
|
605
|
+
if (s.status === 'declined') {
|
|
606
|
+
// The pre-replay checks declined it: typed, with the numbers, so the
|
|
607
|
+
// caller can say what happened rather than "the user said no".
|
|
608
|
+
if (s.reason === 'amount_mismatch') {
|
|
609
|
+
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');
|
|
610
|
+
}
|
|
611
|
+
if (s.reason === 'intent_not_confirmable')
|
|
612
|
+
throw new IntentNotConfirmableError(String(created.id));
|
|
613
|
+
if (s.reason === 'processor_refused') {
|
|
614
|
+
throw new ProcessorRefusedError(String(created.id), typeof s.psp_error_code === 'string' ? s.psp_error_code : null);
|
|
615
|
+
}
|
|
616
|
+
throw new ApprovalDeclinedError(s.reason ?? 'no reason given');
|
|
461
617
|
}
|
|
462
|
-
if (s.
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
throw new
|
|
618
|
+
if (s.status === 'expired') {
|
|
619
|
+
if (s.replay_attempted === false)
|
|
620
|
+
throw new ApprovalTimeoutError(timeoutMs);
|
|
621
|
+
throw new PaymentOutcomeUnknownError(authorizationId, 'expired_after_possible_replay');
|
|
466
622
|
}
|
|
467
|
-
|
|
623
|
+
if (s.status !== 'awaiting_approval')
|
|
624
|
+
throw new PaymentOutcomeUnknownError(authorizationId, 'authorization_status_unrecognized');
|
|
468
625
|
}
|
|
469
|
-
|
|
470
|
-
|
|
471
|
-
|
|
472
|
-
|
|
626
|
+
// A local deadline is not server-side expiry; the person may still use the approval link.
|
|
627
|
+
throw new PaymentOutcomeUnknownError(authorizationId, 'local_approval_timeout');
|
|
628
|
+
}
|
|
629
|
+
catch (error) {
|
|
630
|
+
failed = true;
|
|
631
|
+
if (input.merchantSignal?.aborted)
|
|
632
|
+
throw new PaymentOutcomeUnknownError(authorizationId, 'merchant_request_aborted');
|
|
633
|
+
throw error;
|
|
634
|
+
}
|
|
635
|
+
finally {
|
|
636
|
+
if (input.merchantSignal?.aborted || (preparation && failed)) {
|
|
637
|
+
// Drain a create acknowledgement even after the merchant aborts so its
|
|
638
|
+
// known ID can be retired. An unacknowledged create remains unknown.
|
|
639
|
+
// A started/finalized replay or failed cleanup never becomes a claimed
|
|
640
|
+
// cancellation, and this best-effort cleanup never retries a payment.
|
|
641
|
+
await this.cancelAuthorization(authorizationId).catch(() => { });
|
|
473
642
|
}
|
|
474
|
-
if (s.status !== 'awaiting_approval')
|
|
475
|
-
throw new PaymentOutcomeUnknownError(authorizationId, 'authorization_status_unrecognized');
|
|
476
643
|
}
|
|
477
|
-
|
|
478
|
-
|
|
644
|
+
}
|
|
645
|
+
/** A lost bind acknowledgement must never resume the request. Recover metadata only for safe cleanup. */
|
|
646
|
+
async retireUncertainPreparation(preparation, onCreated) {
|
|
647
|
+
// Race cancellation atomically against a create still arriving at the API.
|
|
648
|
+
// A bound preparation refuses cancellation; resolve its ID exactly once below.
|
|
649
|
+
await this.cancelPreparation(preparation.id).catch(() => { });
|
|
650
|
+
let state;
|
|
651
|
+
try {
|
|
652
|
+
state = await this.get(`/v2/checkout/preparations/${preparation.id}`, AbortSignal.timeout(3_000));
|
|
653
|
+
}
|
|
654
|
+
catch {
|
|
655
|
+
return null;
|
|
656
|
+
}
|
|
657
|
+
if (state?.id !== preparation.id || state.status !== 'bound' || typeof state.authorization_id !== 'string'
|
|
658
|
+
|| !/^cauth_[A-Za-z0-9_-]{1,128}$/.test(state.authorization_id))
|
|
659
|
+
return null;
|
|
660
|
+
const id = state.authorization_id;
|
|
661
|
+
try {
|
|
662
|
+
Promise.resolve(onCreated?.(id)).catch(() => { });
|
|
663
|
+
}
|
|
664
|
+
catch { /* observer only */ }
|
|
665
|
+
await this.cancelAuthorization(id).catch(() => { });
|
|
666
|
+
return id;
|
|
667
|
+
}
|
|
668
|
+
/** Retire only a pre-replay authorization. A 409 or missing response remains unknown. */
|
|
669
|
+
async cancelAuthorization(authorizationId) {
|
|
670
|
+
if (!/^cauth_[A-Za-z0-9_-]{1,128}$/.test(authorizationId))
|
|
671
|
+
throw new Error('Invalid authorization ID.');
|
|
672
|
+
const result = await this.post(`/v2/checkout/authorizations/${authorizationId}/cancel`, {}, AbortSignal.timeout(5_000));
|
|
673
|
+
if (result?.id !== authorizationId || result.status !== 'declined'
|
|
674
|
+
|| result.reason !== 'merchant_request_aborted' || result.cancelled !== true
|
|
675
|
+
|| result.processor_request_started !== false) {
|
|
676
|
+
throw new PaymentOutcomeUnknownError(authorizationId, 'authorization_cancel_unconfirmed');
|
|
677
|
+
}
|
|
678
|
+
return { id: authorizationId, status: 'declined', reason: 'merchant_request_aborted',
|
|
679
|
+
cancelled: true, processor_request_started: false };
|
|
479
680
|
}
|
|
480
681
|
/**
|
|
481
682
|
* POST the create, with two typed twists: a 502 `amount_unverifiable`
|
|
@@ -484,10 +685,12 @@ export class VaultClient {
|
|
|
484
685
|
* `amount_mismatch` becomes an AmountMismatchError at stage 'create' so the
|
|
485
686
|
* adapters treat it as an answered request, not a dead page.
|
|
486
687
|
*/
|
|
487
|
-
async createAuthorization(payload, currency, signal) {
|
|
688
|
+
async createAuthorization(payload, currency, signal, stopRetries) {
|
|
488
689
|
for (let attempt = 0;; attempt++) {
|
|
690
|
+
if (stopRetries?.aborted)
|
|
691
|
+
throw new PaymentOutcomeUnknownError(null, 'merchant_request_aborted');
|
|
489
692
|
try {
|
|
490
|
-
return await this.post('/v2/checkout/authorizations', payload, signal);
|
|
693
|
+
return await this.post('/v2/checkout/authorizations', payload, signal, stopRetries);
|
|
491
694
|
}
|
|
492
695
|
catch (err) {
|
|
493
696
|
if (err instanceof CheckoutApiError && err.code === 'amount_mismatch') {
|
|
@@ -496,11 +699,11 @@ export class VaultClient {
|
|
|
496
699
|
}
|
|
497
700
|
// Two 502s the API asks to be retried: Stripe did not answer the
|
|
498
701
|
// amount read-back, or Adyen did not answer the public-key fetch.
|
|
499
|
-
const retryable = err instanceof CheckoutApiError && err.status === 502
|
|
702
|
+
const retryable = !payload.preparation_id && err instanceof CheckoutApiError && err.status === 502
|
|
500
703
|
&& (err.code === 'amount_unverifiable' || err.code === 'cse_key_unavailable');
|
|
501
704
|
if (!retryable || attempt >= this.unverifiableRetryDelaysMs.length)
|
|
502
705
|
throw err;
|
|
503
|
-
await interruptibleSleep(this.unverifiableRetryDelaysMs[attempt], signal);
|
|
706
|
+
await interruptibleSleep(this.unverifiableRetryDelaysMs[attempt], stopRetries ? AbortSignal.any([stopRetries, ...(signal ? [signal] : [])]) : signal);
|
|
504
707
|
if (signal?.aborted)
|
|
505
708
|
throw signal.reason;
|
|
506
709
|
}
|
|
@@ -545,9 +748,14 @@ export class VaultClient {
|
|
|
545
748
|
return this.inflight;
|
|
546
749
|
}
|
|
547
750
|
/** Authenticated request that retries ONCE on a 401 with a fresh token. */
|
|
548
|
-
async call(path, init = {}, retried = false) {
|
|
751
|
+
async call(path, init = {}, retried = false, stopNewRequests) {
|
|
549
752
|
const signal = init.signal ?? AbortSignal.timeout(30_000);
|
|
550
753
|
const token = await withSignal(this.accessToken(), signal);
|
|
754
|
+
// Auth can outlive the merchant request. Stop a create that has not left
|
|
755
|
+
// yet, while preserving the response of one already sent for cleanup.
|
|
756
|
+
if (stopNewRequests?.aborted)
|
|
757
|
+
throw new PaymentOutcomeUnknownError(null, 'merchant_request_aborted');
|
|
758
|
+
signal.throwIfAborted();
|
|
551
759
|
const r = await withSignal(this.fetch(`${this.baseUrl}${path}`, {
|
|
552
760
|
...init,
|
|
553
761
|
signal,
|
|
@@ -555,15 +763,17 @@ export class VaultClient {
|
|
|
555
763
|
}), signal);
|
|
556
764
|
// A token can be revoked or expire early; one forced refresh, then give up.
|
|
557
765
|
if (r.status === 401 && !retried) {
|
|
766
|
+
if (stopNewRequests?.aborted)
|
|
767
|
+
throw new PaymentOutcomeUnknownError(null, 'merchant_request_aborted');
|
|
558
768
|
await withSignal(this.accessToken(true), signal);
|
|
559
|
-
return this.call(path, { ...init, signal }, true);
|
|
769
|
+
return this.call(path, { ...init, signal }, true, stopNewRequests);
|
|
560
770
|
}
|
|
561
771
|
if (!r.ok)
|
|
562
772
|
throw new CheckoutApiError(r.status, path, await withSignal(r.text(), signal));
|
|
563
773
|
return withSignal(r.json(), signal);
|
|
564
774
|
}
|
|
565
|
-
post(path, body, signal) {
|
|
566
|
-
return this.call(path, { method: 'POST', body: JSON.stringify(body), signal });
|
|
775
|
+
post(path, body, signal, stopNewRequests) {
|
|
776
|
+
return this.call(path, { method: 'POST', body: JSON.stringify(body), signal }, false, stopNewRequests);
|
|
567
777
|
}
|
|
568
778
|
get(path, signal) {
|
|
569
779
|
return this.call(path, { signal });
|
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, } 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>>;
|
|
@@ -54,11 +57,20 @@ export declare class CheckoutLifecycle implements CheckoutController {
|
|
|
54
57
|
private held;
|
|
55
58
|
private active;
|
|
56
59
|
private cancelled;
|
|
60
|
+
private merchantAborted;
|
|
57
61
|
private unboundStripeToken;
|
|
58
62
|
private reconciliation;
|
|
63
|
+
private preparationHandler?;
|
|
64
|
+
private preparationUsed;
|
|
59
65
|
readonly abort: AbortController;
|
|
60
66
|
constructor(options: LifecycleOptions);
|
|
61
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;
|
|
62
74
|
isBlocked(): boolean;
|
|
63
75
|
isCancelled(): boolean;
|
|
64
76
|
begin(): void;
|
|
@@ -68,6 +80,8 @@ export declare class CheckoutLifecycle implements CheckoutController {
|
|
|
68
80
|
approvalUrl(approvalUrl: string): void;
|
|
69
81
|
private notify;
|
|
70
82
|
cancel(): void;
|
|
83
|
+
/** The exact browser request is gone; a late approval cannot reopen it. */
|
|
84
|
+
merchantRequestAborted(): void;
|
|
71
85
|
unsupported(): void;
|
|
72
86
|
prepareHandoff(replay: ReplayResponse, requestUrl: string): void;
|
|
73
87
|
handedOff(replay: ReplayResponse): void;
|