@agent-cards/checkout 0.1.0 → 0.2.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/README.md +160 -6
- package/dist/cdp.d.ts +24 -2
- package/dist/cdp.js +309 -26
- package/dist/client.d.ts +268 -3
- package/dist/client.js +360 -12
- package/dist/hosted-form.d.ts +44 -0
- package/dist/hosted-form.js +78 -0
- package/dist/index.d.ts +8 -4
- package/dist/index.js +4 -2
- package/dist/registry.d.ts +50 -1
- package/dist/registry.js +436 -4
- package/dist/substitute.d.ts +42 -0
- package/dist/substitute.js +87 -0
- package/package.json +1 -1
package/dist/client.js
CHANGED
|
@@ -1,4 +1,11 @@
|
|
|
1
|
-
import { BUILTIN_REGISTRY, findRecognizer } from './registry.js';
|
|
1
|
+
import { BUILTIN_REGISTRY, cardUrlPatterns as deriveCardUrlPatterns, findRecognizer, } from './registry.js';
|
|
2
|
+
/**
|
|
3
|
+
* The modes this SDK can finish. Asked for on syncRegistry (the API serves
|
|
4
|
+
* only recognizers in these modes, so a request this build cannot complete
|
|
5
|
+
* is never paused) and sent on every create.
|
|
6
|
+
*/
|
|
7
|
+
export const SUPPORTED_MODES = ['token', 'cse', 'hosted_form'];
|
|
8
|
+
const AMOUNT_AUTHORITIES = ['stripe_payment_intent', 'hosted_form_sum', 'display_only'];
|
|
2
9
|
export class CardEncryptedError extends Error {
|
|
3
10
|
psp;
|
|
4
11
|
constructor(psp) {
|
|
@@ -8,17 +15,200 @@ export class CardEncryptedError extends Error {
|
|
|
8
15
|
this.name = 'CardEncryptedError';
|
|
9
16
|
}
|
|
10
17
|
}
|
|
18
|
+
/**
|
|
19
|
+
* The API approved an authorization in a mode this SDK build cannot finish.
|
|
20
|
+
* Unreachable by construction (syncRegistry asks only for SUPPORTED_MODES and
|
|
21
|
+
* every create names its mode), so it is treated as terminal: retrying the
|
|
22
|
+
* same page could only raise more prompts for the same dead end.
|
|
23
|
+
*/
|
|
24
|
+
export class UnsupportedModeError extends Error {
|
|
25
|
+
mode;
|
|
26
|
+
constructor(mode) {
|
|
27
|
+
super(`the authorization was approved in mode "${mode}", which this version of @agent-cards/checkout cannot complete; upgrade the SDK.`);
|
|
28
|
+
this.mode = mode;
|
|
29
|
+
this.name = 'UnsupportedModeError';
|
|
30
|
+
}
|
|
31
|
+
}
|
|
11
32
|
export class ApprovalTimeoutError extends Error {
|
|
12
33
|
constructor(ms) { super(`user did not approve within ${ms}ms`); this.name = 'ApprovalTimeoutError'; }
|
|
13
34
|
}
|
|
14
35
|
export class ApprovalDeclinedError extends Error {
|
|
15
36
|
constructor(reason) { super(`user declined: ${reason}`); this.name = 'ApprovalDeclinedError'; }
|
|
16
37
|
}
|
|
38
|
+
/**
|
|
39
|
+
* Agentcard refused the payment because the processor's amount did not match
|
|
40
|
+
* the amount the user was (or would have been) asked to approve. Nothing was
|
|
41
|
+
* charged. Two stages:
|
|
42
|
+
* - 'create': the intent already disagreed when the request was parked. No
|
|
43
|
+
* authorization exists (`authorizationId` is null). Per request, not per
|
|
44
|
+
* page: the merchant can still update the intent before confirmation, so
|
|
45
|
+
* the adapters keep intercepting and the next attempt is judged afresh.
|
|
46
|
+
* - 'pre_replay': the intent moved between create and the moment the
|
|
47
|
+
* cardholder's device would have sent the card. The authorization is
|
|
48
|
+
* `declined` with reason `amount_mismatch`.
|
|
49
|
+
*
|
|
50
|
+
* A decline in every structural sense (the adapters abort the paused request
|
|
51
|
+
* and quiet the page's retry exactly as for a person's "no"), so it extends
|
|
52
|
+
* ApprovalDeclinedError: code that already handles declines keeps working,
|
|
53
|
+
* and code that wants the numbers reads them here or branches on `code`.
|
|
54
|
+
*/
|
|
55
|
+
export class AmountMismatchError extends ApprovalDeclinedError {
|
|
56
|
+
authorizationId;
|
|
57
|
+
expectedCents;
|
|
58
|
+
actualCents;
|
|
59
|
+
currency;
|
|
60
|
+
actualCurrency;
|
|
61
|
+
stage;
|
|
62
|
+
code = 'amount_mismatch';
|
|
63
|
+
constructor(
|
|
64
|
+
/** The declined authorization, or null for a create-time refusal (no row exists). */
|
|
65
|
+
authorizationId,
|
|
66
|
+
/** What the user was asked to approve, smallest currency unit. */
|
|
67
|
+
expectedCents,
|
|
68
|
+
/** What the processor reported at the last check. */
|
|
69
|
+
actualCents,
|
|
70
|
+
/** ISO 4217 of the approved amount. */
|
|
71
|
+
currency,
|
|
72
|
+
/** ISO 4217 the processor reported (differs only on a currency change). */
|
|
73
|
+
actualCurrency = currency,
|
|
74
|
+
/** Which check refused it. */
|
|
75
|
+
stage = 'pre_replay') {
|
|
76
|
+
super('amount_mismatch');
|
|
77
|
+
this.authorizationId = authorizationId;
|
|
78
|
+
this.expectedCents = expectedCents;
|
|
79
|
+
this.actualCents = actualCents;
|
|
80
|
+
this.currency = currency;
|
|
81
|
+
this.actualCurrency = actualCurrency;
|
|
82
|
+
this.stage = stage;
|
|
83
|
+
this.name = 'AmountMismatchError';
|
|
84
|
+
this.message = `amount mismatch${authorizationId ? ` on ${authorizationId}` : ' at create'}: `
|
|
85
|
+
+ `the user was asked to approve ${expectedCents} ${currency}, the processor reports ${actualCents} ${actualCurrency}. Nothing was charged.`;
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
/**
|
|
89
|
+
* The PaymentIntent behind this checkout can no longer be confirmed: it was
|
|
90
|
+
* already charged (succeeded), is being charged (processing), is authorized
|
|
91
|
+
* and on hold for the merchant to capture (requires_capture), or was
|
|
92
|
+
* canceled. Agentcard refused rather than replay a confirm at it; the
|
|
93
|
+
* authorization is `declined` with reason `intent_not_confirmable`.
|
|
94
|
+
* Deliberately NOT "nothing was charged": for three of those four, money has
|
|
95
|
+
* moved or is moving. Check the intent at Stripe before retrying.
|
|
96
|
+
*/
|
|
97
|
+
export class IntentNotConfirmableError extends ApprovalDeclinedError {
|
|
98
|
+
authorizationId;
|
|
99
|
+
code = 'intent_not_confirmable';
|
|
100
|
+
constructor(authorizationId) {
|
|
101
|
+
super('intent_not_confirmable');
|
|
102
|
+
this.authorizationId = authorizationId;
|
|
103
|
+
this.name = 'IntentNotConfirmableError';
|
|
104
|
+
this.message = `the PaymentIntent behind ${authorizationId} was already charged, is processing, or is on hold; `
|
|
105
|
+
+ 'it cannot be confirmed again. Check the intent at Stripe before retrying.';
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
/**
|
|
109
|
+
* The cardholder's device sent the card and the processor refused it
|
|
110
|
+
* outright (a card decline, a bad CVC, an invalid request). Nothing was
|
|
111
|
+
* charged; the authorization is `declined` with reason `processor_refused`
|
|
112
|
+
* and the processor's own code in `pspErrorCode` (Stripe's `card_declined`,
|
|
113
|
+
* `incorrect_cvc`, …). A decline like any other for the adapters: the paused
|
|
114
|
+
* request is aborted and the page's retry gets a fresh approval, where the
|
|
115
|
+
* person can pick another card.
|
|
116
|
+
*/
|
|
117
|
+
export class ProcessorRefusedError extends ApprovalDeclinedError {
|
|
118
|
+
authorizationId;
|
|
119
|
+
pspErrorCode;
|
|
120
|
+
code = 'processor_refused';
|
|
121
|
+
constructor(authorizationId, pspErrorCode) {
|
|
122
|
+
super('processor_refused');
|
|
123
|
+
this.authorizationId = authorizationId;
|
|
124
|
+
this.pspErrorCode = pspErrorCode;
|
|
125
|
+
this.name = 'ProcessorRefusedError';
|
|
126
|
+
this.message = `the processor refused the card on ${authorizationId}${pspErrorCode ? ` (${pspErrorCode})` : ''}. Nothing was charged.`;
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
/**
|
|
130
|
+
* A non-2xx from the Agentcard API, carrying the status so callers can tell a
|
|
131
|
+
* misconfiguration from a blip. The adapters use this to decide whether
|
|
132
|
+
* retrying is worth anything: a 404 `connection_not_found` will answer the same
|
|
133
|
+
* way forever, while a 429 or a 502 will not.
|
|
134
|
+
*/
|
|
135
|
+
export class CheckoutApiError extends Error {
|
|
136
|
+
status;
|
|
137
|
+
path;
|
|
138
|
+
bodyText;
|
|
139
|
+
/** The API's stable error code (`{ error: { code } }`), or null when the body carried none. */
|
|
140
|
+
code;
|
|
141
|
+
/**
|
|
142
|
+
* The rest of the error envelope. A 409 `amount_mismatch` from create
|
|
143
|
+
* carries `expected_cents`, `actual_cents`, `currency`, `actual_currency`;
|
|
144
|
+
* an `amount_unverifiable` carries `reason`; an `intent_not_confirmable`
|
|
145
|
+
* carries `intent_status`.
|
|
146
|
+
*/
|
|
147
|
+
details;
|
|
148
|
+
constructor(status, path, bodyText) {
|
|
149
|
+
super(`agentcard ${path} -> ${status} ${bodyText}`);
|
|
150
|
+
this.status = status;
|
|
151
|
+
this.path = path;
|
|
152
|
+
this.bodyText = bodyText;
|
|
153
|
+
this.name = 'CheckoutApiError';
|
|
154
|
+
const envelope = parseErrorEnvelope(bodyText);
|
|
155
|
+
this.code = envelope.code;
|
|
156
|
+
this.details = envelope.details;
|
|
157
|
+
}
|
|
158
|
+
/**
|
|
159
|
+
* True when repeating this exact call cannot succeed: a misconfiguration
|
|
160
|
+
* (4xx other than 429). NOT a 409 `amount_mismatch`: Stripe lets a merchant
|
|
161
|
+
* update an intent's amount until it is confirmed, so the next request on
|
|
162
|
+
* the same page may well agree. That one is a per-request failure, and
|
|
163
|
+
* authorize() surfaces it as AmountMismatchError before an adapter ever
|
|
164
|
+
* sees it here. NOT a 409 `duplicate_submission` either: the household
|
|
165
|
+
* already has, or already answered, the prompt for this submission, and
|
|
166
|
+
* once that prior authorization is declined or expired the same form is a
|
|
167
|
+
* new question. The adapters quiet the page's re-post the way they quiet a
|
|
168
|
+
* decline instead of latching the attachment.
|
|
169
|
+
*/
|
|
170
|
+
get permanent() {
|
|
171
|
+
if (this.code === 'amount_mismatch' || this.code === 'duplicate_submission')
|
|
172
|
+
return false;
|
|
173
|
+
return this.status >= 400 && this.status < 500 && this.status !== 429;
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
function parseErrorEnvelope(bodyText) {
|
|
177
|
+
try {
|
|
178
|
+
const parsed = JSON.parse(bodyText);
|
|
179
|
+
const err = parsed && typeof parsed === 'object' ? parsed.error : null;
|
|
180
|
+
if (err && typeof err === 'object') {
|
|
181
|
+
const { code, ...details } = err;
|
|
182
|
+
return { code: typeof code === 'string' ? code : null, details };
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
catch {
|
|
186
|
+
// Not a JSON envelope (a proxy page, an empty body): no code to carry.
|
|
187
|
+
}
|
|
188
|
+
return { code: null, details: {} };
|
|
189
|
+
}
|
|
190
|
+
/**
|
|
191
|
+
* A URL as it may appear in an error message: origin + path only. A paused
|
|
192
|
+
* request's URL can carry a client secret in its query string, and error
|
|
193
|
+
* messages travel further than anyone intends (onEvent, logs, crash reports).
|
|
194
|
+
*/
|
|
195
|
+
export function redactUrl(raw) {
|
|
196
|
+
try {
|
|
197
|
+
const u = new URL(raw);
|
|
198
|
+
return `${u.origin}${u.pathname}`;
|
|
199
|
+
}
|
|
200
|
+
catch {
|
|
201
|
+
return raw.split(/[?#]/)[0];
|
|
202
|
+
}
|
|
203
|
+
}
|
|
204
|
+
/** Default backoff for a 502 amount_unverifiable at create: two retries, then give up. */
|
|
205
|
+
const UNVERIFIABLE_RETRY_DELAYS_MS = [500, 1500];
|
|
17
206
|
export class VaultClient {
|
|
18
207
|
opts;
|
|
19
208
|
baseUrl;
|
|
20
209
|
fetch;
|
|
21
210
|
pollIntervalMs;
|
|
211
|
+
unverifiableRetryDelaysMs;
|
|
22
212
|
registry;
|
|
23
213
|
constructor(opts) {
|
|
24
214
|
this.opts = opts;
|
|
@@ -26,14 +216,21 @@ export class VaultClient {
|
|
|
26
216
|
this.fetch = opts.fetchImpl ?? globalThis.fetch;
|
|
27
217
|
this.registry = opts.registry ?? BUILTIN_REGISTRY;
|
|
28
218
|
this.pollIntervalMs = opts.pollIntervalMs ?? 2000;
|
|
219
|
+
this.unverifiableRetryDelaysMs = opts.unverifiableRetryDelaysMs ?? UNVERIFIABLE_RETRY_DELAYS_MS;
|
|
29
220
|
}
|
|
30
221
|
/** Refresh recognizers from the API so new PSPs work without a redeploy. */
|
|
31
222
|
async syncRegistry() {
|
|
32
223
|
// Never break checkout over a registry fetch — an auth blip or a bad
|
|
33
224
|
// response leaves the built-in recognizers in place.
|
|
225
|
+
//
|
|
226
|
+
// ?modes= is capability negotiation: the API serves only recognizers in
|
|
227
|
+
// the modes this build can finish, so a processor whose flow this SDK
|
|
228
|
+
// does not speak is never armed (a pause it cannot complete becomes an
|
|
229
|
+
// abort, which would dead-end the checkout). `mode` rides through the
|
|
230
|
+
// spread verbatim.
|
|
34
231
|
let raw;
|
|
35
232
|
try {
|
|
36
|
-
raw = await this.get(
|
|
233
|
+
raw = await this.get(`/v2/checkout/recognizers?modes=${SUPPORTED_MODES.join(',')}`);
|
|
37
234
|
}
|
|
38
235
|
catch {
|
|
39
236
|
return;
|
|
@@ -50,6 +247,20 @@ export class VaultClient {
|
|
|
50
247
|
isCardRequest(url, method = 'POST') {
|
|
51
248
|
return method.toUpperCase() === 'POST' && findRecognizer(url, this.registry) !== null;
|
|
52
249
|
}
|
|
250
|
+
/**
|
|
251
|
+
* Glob url patterns covering every host the CURRENT registry can send a card
|
|
252
|
+
* to — what a raw CDP connection has to hand `Fetch.enable` before any card
|
|
253
|
+
* request can be paused. attachToCdp calls this for you.
|
|
254
|
+
*
|
|
255
|
+
* Call syncRegistry() FIRST: without it these cover only the built-in PSPs,
|
|
256
|
+
* and a request the API knows about is never paused at all. Deliberately
|
|
257
|
+
* WIDER than the recognizers themselves (a glob cannot express an anchored
|
|
258
|
+
* host regex); isCardRequest is the exact check and runs on every request
|
|
259
|
+
* these patterns pause.
|
|
260
|
+
*/
|
|
261
|
+
cardUrlPatterns() {
|
|
262
|
+
return deriveCardUrlPatterns(this.registry);
|
|
263
|
+
}
|
|
53
264
|
/**
|
|
54
265
|
* Hand us a paused tokenization request. We ask the cardholder to approve,
|
|
55
266
|
* their device supplies the card and calls the merchant, and you get back the
|
|
@@ -58,34 +269,171 @@ export class VaultClient {
|
|
|
58
269
|
async authorize(input) {
|
|
59
270
|
const rec = findRecognizer(input.request.url, this.registry);
|
|
60
271
|
if (!rec)
|
|
61
|
-
throw new Error(`not a known tokenization endpoint: ${input.request.url}`);
|
|
62
|
-
|
|
272
|
+
throw new Error(`not a known tokenization endpoint: ${redactUrl(input.request.url)}`);
|
|
273
|
+
// A client-side-encrypted processor is only takeable when its entry says
|
|
274
|
+
// the vault produces that ciphertext itself (mode cse); a bare
|
|
275
|
+
// clientSideEncrypted entry (an older registry, a hand-built one) still
|
|
276
|
+
// refuses here, exactly as before.
|
|
277
|
+
const mode = rec.mode ?? 'token';
|
|
278
|
+
if (rec.clientSideEncrypted && mode !== 'cse')
|
|
63
279
|
throw new CardEncryptedError(rec.psp);
|
|
280
|
+
if (!SUPPORTED_MODES.includes(mode))
|
|
281
|
+
throw new UnsupportedModeError(mode);
|
|
282
|
+
// Say no rather than coerce. isCardRequest only ever pauses a POST, and the
|
|
283
|
+
// service stores the replay template with method 'POST' hardcoded — so a
|
|
284
|
+
// caller handing in a PUT or GET would get it silently rewritten and the
|
|
285
|
+
// cardholder's device would replay something the caller never asked for.
|
|
286
|
+
// A direct caller deserves to be told, not surprised.
|
|
287
|
+
const method = (input.request.method ?? 'POST').toUpperCase();
|
|
288
|
+
if (method !== 'POST') {
|
|
289
|
+
throw new Error(`checkout authorization is POST-only; got ${method} for ${redactUrl(input.request.url)}. ` +
|
|
290
|
+
'A non-POST tokenizer needs recognizer support before it can be intercepted.');
|
|
291
|
+
}
|
|
292
|
+
// The amount travels as a display string, a number with its currency, or
|
|
293
|
+
// both. Refuse half a pair here, before a network call: the API would too,
|
|
294
|
+
// and a retry loop cannot fix a missing field.
|
|
295
|
+
const hasCents = input.amountCents != null;
|
|
296
|
+
const hasCurrency = typeof input.currency === 'string' && input.currency.length > 0;
|
|
297
|
+
if (hasCents !== hasCurrency) {
|
|
298
|
+
throw new Error('amountCents and currency go together: pass both or neither.');
|
|
299
|
+
}
|
|
300
|
+
if (!input.amount && !hasCents) {
|
|
301
|
+
throw new Error('authorize needs amount (a display string), or amountCents with currency.');
|
|
302
|
+
}
|
|
64
303
|
const timeoutMs = input.timeoutMs ?? 15 * 60_000;
|
|
65
|
-
const created = await this.
|
|
304
|
+
const created = await this.createAuthorization({
|
|
66
305
|
user: input.user,
|
|
67
306
|
merchant: input.merchant,
|
|
68
|
-
amount: input.amount,
|
|
307
|
+
...(input.amount ? { amount: input.amount } : {}),
|
|
308
|
+
// snake_case on the wire; camelCase is this SDK's convention.
|
|
309
|
+
...(hasCents ? { amount_cents: input.amountCents, currency: input.currency } : {}),
|
|
69
310
|
psp: rec.psp,
|
|
311
|
+
// The mode this request will be finished in. The API checks it against
|
|
312
|
+
// the recognizer and refuses a disagreement before a row exists.
|
|
313
|
+
mode,
|
|
314
|
+
...(input.cardId ? { cardId: input.cardId } : {}),
|
|
70
315
|
request: {
|
|
71
316
|
url: input.request.url,
|
|
72
317
|
method: input.request.method,
|
|
73
318
|
headers: pickHeaders(input.request.headers, rec.passthroughHeaders),
|
|
74
319
|
body: input.request.body,
|
|
75
320
|
},
|
|
76
|
-
});
|
|
321
|
+
}, input.currency);
|
|
77
322
|
input.onApprovalUrl?.(created.approvalUrl);
|
|
323
|
+
const authorizationId = String(created.id);
|
|
78
324
|
const deadline = Date.now() + timeoutMs;
|
|
79
325
|
while (Date.now() < deadline) {
|
|
80
326
|
await sleep(this.pollIntervalMs);
|
|
81
|
-
const s = await this.get(`/v2/checkout/authorizations/${
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
327
|
+
const s = await this.get(`/v2/checkout/authorizations/${authorizationId}`);
|
|
328
|
+
const amountAuthority = typeof s.amount_authority === 'string' && AMOUNT_AUTHORITIES.includes(s.amount_authority)
|
|
329
|
+
? { amountAuthority: s.amount_authority }
|
|
330
|
+
: {};
|
|
331
|
+
if (s.status === 'submitted_on_device') {
|
|
332
|
+
// The device attested that the processor's form left it; the stamp
|
|
333
|
+
// is the whole fact and it is NOT an approval (see HostedFormReplay).
|
|
334
|
+
// Only a hosted_form row may carry this status; a stamp without its
|
|
335
|
+
// time is not one this SDK can act on (not permanent: the next poll,
|
|
336
|
+
// or request, may carry it).
|
|
337
|
+
const mode = typeof s.mode === 'string' ? s.mode : 'token';
|
|
338
|
+
if (mode !== 'hosted_form')
|
|
339
|
+
throw new Error(`malformed authorization ${authorizationId}: submitted_on_device on mode ${mode}`);
|
|
340
|
+
if (typeof s.submitted_at !== 'string' || !s.submitted_at) {
|
|
341
|
+
throw new Error(`malformed hosted_form submission on authorization ${authorizationId}: no submitted_at`);
|
|
342
|
+
}
|
|
343
|
+
return { mode: 'hosted_form', kind: 'submitted_on_device', outcome: 'unverified', authorizationId, submittedAt: s.submitted_at, ...amountAuthority };
|
|
344
|
+
}
|
|
345
|
+
if (s.status === 'approved') {
|
|
346
|
+
const approvedMode = typeof s.mode === 'string' ? s.mode : 'token';
|
|
347
|
+
if (approvedMode === 'hosted_form') {
|
|
348
|
+
// Never: the API finishes a hosted form as submitted_on_device, and
|
|
349
|
+
// its database refuses `approved` on that mode. An answer that says
|
|
350
|
+
// otherwise is not one to act on as a payment.
|
|
351
|
+
throw new Error(`malformed authorization ${authorizationId}: a hosted_form authorization finishes as submitted_on_device, never approved`);
|
|
352
|
+
}
|
|
353
|
+
if (approvedMode === 'cse') {
|
|
354
|
+
const sub = s.substitutions;
|
|
355
|
+
const fieldsOk = sub && typeof sub === 'object' && sub.encoding === 'json' && typeof sub.at === 'string' && sub.at
|
|
356
|
+
&& sub.fields && typeof sub.fields === 'object' && !Array.isArray(sub.fields)
|
|
357
|
+
&& Object.values(sub.fields).every((v) => typeof v === 'string' && v.length > 0);
|
|
358
|
+
if (!fieldsOk)
|
|
359
|
+
throw new Error(`malformed substitutions on approved authorization ${authorizationId}`);
|
|
360
|
+
// `remove`: sibling keys the API says to drop with the swap (Adyen's
|
|
361
|
+
// `brand`, stamped by adyen-web from the agent's dummy digits). Absent
|
|
362
|
+
// on an older API; anything but a list of names is refused, since a
|
|
363
|
+
// half-understood instruction would continue a body Adyen refuses.
|
|
364
|
+
const removeRaw = sub.remove;
|
|
365
|
+
if (removeRaw !== undefined && !(Array.isArray(removeRaw) && removeRaw.every((k) => typeof k === 'string' && k.length > 0))) {
|
|
366
|
+
throw new Error(`malformed substitutions.remove on approved authorization ${authorizationId}`);
|
|
367
|
+
}
|
|
368
|
+
return {
|
|
369
|
+
mode: 'cse',
|
|
370
|
+
authorizationId,
|
|
371
|
+
substitutions: {
|
|
372
|
+
encoding: 'json',
|
|
373
|
+
at: sub.at,
|
|
374
|
+
fields: { ...sub.fields },
|
|
375
|
+
...(removeRaw ? { remove: [...removeRaw] } : {}),
|
|
376
|
+
},
|
|
377
|
+
...amountAuthority,
|
|
378
|
+
};
|
|
379
|
+
}
|
|
380
|
+
if (approvedMode !== 'token')
|
|
381
|
+
throw new UnsupportedModeError(approvedMode);
|
|
382
|
+
const response = s.response;
|
|
383
|
+
return {
|
|
384
|
+
mode: 'token',
|
|
385
|
+
authorizationId,
|
|
386
|
+
...response,
|
|
387
|
+
amountVerified: typeof s.amount_verified === 'boolean' ? s.amount_verified : null,
|
|
388
|
+
chargedAmountCents: typeof s.charged_amount_cents === 'number' ? s.charged_amount_cents : null,
|
|
389
|
+
chargedCurrency: typeof s.charged_currency === 'string' ? s.charged_currency : null,
|
|
390
|
+
chargedKind: s.charged_kind === 'captured' || s.charged_kind === 'authorized' || s.charged_kind === 'none' ? s.charged_kind : null,
|
|
391
|
+
...amountAuthority,
|
|
392
|
+
};
|
|
393
|
+
}
|
|
394
|
+
if (s.status === 'declined') {
|
|
395
|
+
// The pre-replay checks declined it: typed, with the numbers, so the
|
|
396
|
+
// caller can say what happened rather than "the user said no".
|
|
397
|
+
if (s.reason === 'amount_mismatch') {
|
|
398
|
+
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');
|
|
399
|
+
}
|
|
400
|
+
if (s.reason === 'intent_not_confirmable')
|
|
401
|
+
throw new IntentNotConfirmableError(String(created.id));
|
|
402
|
+
if (s.reason === 'processor_refused') {
|
|
403
|
+
throw new ProcessorRefusedError(String(created.id), typeof s.psp_error_code === 'string' ? s.psp_error_code : null);
|
|
404
|
+
}
|
|
85
405
|
throw new ApprovalDeclinedError(s.reason ?? 'no reason given');
|
|
406
|
+
}
|
|
86
407
|
}
|
|
87
408
|
throw new ApprovalTimeoutError(timeoutMs);
|
|
88
409
|
}
|
|
410
|
+
/**
|
|
411
|
+
* POST the create, with two typed twists: a 502 `amount_unverifiable`
|
|
412
|
+
* (Stripe did not answer the read-back) is retried on a short backoff
|
|
413
|
+
* instead of being left to the page's own retry loop, and a 409
|
|
414
|
+
* `amount_mismatch` becomes an AmountMismatchError at stage 'create' so the
|
|
415
|
+
* adapters treat it as an answered request, not a dead page.
|
|
416
|
+
*/
|
|
417
|
+
async createAuthorization(payload, currency) {
|
|
418
|
+
for (let attempt = 0;; attempt++) {
|
|
419
|
+
try {
|
|
420
|
+
return await this.post('/v2/checkout/authorizations', payload);
|
|
421
|
+
}
|
|
422
|
+
catch (err) {
|
|
423
|
+
if (err instanceof CheckoutApiError && err.code === 'amount_mismatch') {
|
|
424
|
+
const d = err.details;
|
|
425
|
+
throw new AmountMismatchError(null, Number(d.expected_cents), Number(d.actual_cents), String(d.currency ?? currency ?? ''), d.actual_currency != null ? String(d.actual_currency) : undefined, 'create');
|
|
426
|
+
}
|
|
427
|
+
// Two 502s the API asks to be retried: Stripe did not answer the
|
|
428
|
+
// amount read-back, or Adyen did not answer the public-key fetch.
|
|
429
|
+
const retryable = err instanceof CheckoutApiError && err.status === 502
|
|
430
|
+
&& (err.code === 'amount_unverifiable' || err.code === 'cse_key_unavailable');
|
|
431
|
+
if (!retryable || attempt >= this.unverifiableRetryDelaysMs.length)
|
|
432
|
+
throw err;
|
|
433
|
+
await sleep(this.unverifiableRetryDelaysMs[attempt]);
|
|
434
|
+
}
|
|
435
|
+
}
|
|
436
|
+
}
|
|
89
437
|
// --- auth: client_credentials, cached until just before it expires --------
|
|
90
438
|
token = null;
|
|
91
439
|
inflight = null;
|
|
@@ -136,7 +484,7 @@ export class VaultClient {
|
|
|
136
484
|
return this.call(path, init, true);
|
|
137
485
|
}
|
|
138
486
|
if (!r.ok)
|
|
139
|
-
throw new
|
|
487
|
+
throw new CheckoutApiError(r.status, path, await r.text());
|
|
140
488
|
return r.json();
|
|
141
489
|
}
|
|
142
490
|
post(path, body) {
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The hosted_form half of a replay: what the paused navigation is resolved
|
|
3
|
+
* with once the cardholder's device has submitted the processor's own form.
|
|
4
|
+
*
|
|
5
|
+
* On a hosted-form processor (Tranzila) the paused request is a top-level
|
|
6
|
+
* form navigation inside the processor's iframe, not an XHR. The cardholder's
|
|
7
|
+
* device rebuilds that form with the real card and submits it itself, so the
|
|
8
|
+
* processor's answer (approved, declined) is shown on the device and reaches
|
|
9
|
+
* the merchant from the processor. The agent's browser never gets it.
|
|
10
|
+
*
|
|
11
|
+
* The paused navigation still has to resolve to SOMETHING. Aborting it is the
|
|
12
|
+
* wrong something: an aborted navigation renders no error page, the iframe
|
|
13
|
+
* silently stays on the dummy-card form, and the agent's natural next move is
|
|
14
|
+
* to click Pay again. A fake copy of the processor's result page is wrong too:
|
|
15
|
+
* this SDK does not know the outcome or the merchant's contract, so it must
|
|
16
|
+
* not claim one. What it CAN say is exactly what happened: the cardholder
|
|
17
|
+
* completed this payment on their own device, do not resubmit, confirm the
|
|
18
|
+
* order with the merchant. This page says that, for a person and for an
|
|
19
|
+
* agent, and nothing else.
|
|
20
|
+
*/
|
|
21
|
+
export interface HostedFormSubmittedPageInput {
|
|
22
|
+
/** The approved authorization (`cauth_…`). */
|
|
23
|
+
authorizationId: string;
|
|
24
|
+
/** The merchant name the authorization was created with. */
|
|
25
|
+
merchant: string;
|
|
26
|
+
/** When the device reported the form left it (ISO 8601). */
|
|
27
|
+
submittedAt: string;
|
|
28
|
+
}
|
|
29
|
+
export interface SyntheticPage {
|
|
30
|
+
status: 200;
|
|
31
|
+
headers: Record<string, string>;
|
|
32
|
+
body: string;
|
|
33
|
+
}
|
|
34
|
+
/** The outcome word on the page, the header and the JSON: one spelling everywhere. */
|
|
35
|
+
export declare const HOSTED_FORM_SUBMITTED_OUTCOME = "submitted_on_device";
|
|
36
|
+
/**
|
|
37
|
+
* A self-contained HTML document: no external resources, no script that runs
|
|
38
|
+
* (the one `<script>` is `application/json`, inert by type), machine-readable
|
|
39
|
+
* through the meta tag, the header and the JSON block, and human-readable in
|
|
40
|
+
* one paragraph. `merchant` is integrator text and lands escaped; inside the
|
|
41
|
+
* JSON every `<`, `>` and `&` is a \u escape (still valid JSON), so a merchant
|
|
42
|
+
* name can never close the script block early or open a comment inside it.
|
|
43
|
+
*/
|
|
44
|
+
export declare function hostedFormSubmittedPage(input: HostedFormSubmittedPageInput): SyntheticPage;
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The hosted_form half of a replay: what the paused navigation is resolved
|
|
3
|
+
* with once the cardholder's device has submitted the processor's own form.
|
|
4
|
+
*
|
|
5
|
+
* On a hosted-form processor (Tranzila) the paused request is a top-level
|
|
6
|
+
* form navigation inside the processor's iframe, not an XHR. The cardholder's
|
|
7
|
+
* device rebuilds that form with the real card and submits it itself, so the
|
|
8
|
+
* processor's answer (approved, declined) is shown on the device and reaches
|
|
9
|
+
* the merchant from the processor. The agent's browser never gets it.
|
|
10
|
+
*
|
|
11
|
+
* The paused navigation still has to resolve to SOMETHING. Aborting it is the
|
|
12
|
+
* wrong something: an aborted navigation renders no error page, the iframe
|
|
13
|
+
* silently stays on the dummy-card form, and the agent's natural next move is
|
|
14
|
+
* to click Pay again. A fake copy of the processor's result page is wrong too:
|
|
15
|
+
* this SDK does not know the outcome or the merchant's contract, so it must
|
|
16
|
+
* not claim one. What it CAN say is exactly what happened: the cardholder
|
|
17
|
+
* completed this payment on their own device, do not resubmit, confirm the
|
|
18
|
+
* order with the merchant. This page says that, for a person and for an
|
|
19
|
+
* agent, and nothing else.
|
|
20
|
+
*/
|
|
21
|
+
/** The outcome word on the page, the header and the JSON: one spelling everywhere. */
|
|
22
|
+
export const HOSTED_FORM_SUBMITTED_OUTCOME = 'submitted_on_device';
|
|
23
|
+
function escapeHtml(s) {
|
|
24
|
+
return String(s)
|
|
25
|
+
.replace(/&/g, '&')
|
|
26
|
+
.replace(/</g, '<')
|
|
27
|
+
.replace(/>/g, '>')
|
|
28
|
+
.replace(/"/g, '"')
|
|
29
|
+
.replace(/'/g, ''');
|
|
30
|
+
}
|
|
31
|
+
/**
|
|
32
|
+
* A self-contained HTML document: no external resources, no script that runs
|
|
33
|
+
* (the one `<script>` is `application/json`, inert by type), machine-readable
|
|
34
|
+
* through the meta tag, the header and the JSON block, and human-readable in
|
|
35
|
+
* one paragraph. `merchant` is integrator text and lands escaped; inside the
|
|
36
|
+
* JSON every `<`, `>` and `&` is a \u escape (still valid JSON), so a merchant
|
|
37
|
+
* name can never close the script block early or open a comment inside it.
|
|
38
|
+
*/
|
|
39
|
+
export function hostedFormSubmittedPage(input) {
|
|
40
|
+
const merchant = String(input.merchant ?? '');
|
|
41
|
+
const record = {
|
|
42
|
+
outcome: HOSTED_FORM_SUBMITTED_OUTCOME,
|
|
43
|
+
// In words, so an agent reading this block cannot take it for a receipt:
|
|
44
|
+
// the cardholder's device attested the form left it, nothing more.
|
|
45
|
+
payment: 'unverified',
|
|
46
|
+
attested_by: 'cardholder_device',
|
|
47
|
+
authorization_id: String(input.authorizationId),
|
|
48
|
+
merchant,
|
|
49
|
+
submitted_at: String(input.submittedAt),
|
|
50
|
+
next: "poll the merchant's order state; never resubmit",
|
|
51
|
+
};
|
|
52
|
+
const json = JSON.stringify(record).replace(/</g, '\\u003c').replace(/>/g, '\\u003e').replace(/&/g, '\\u0026');
|
|
53
|
+
const body = [
|
|
54
|
+
'<!doctype html>',
|
|
55
|
+
'<html lang="en">',
|
|
56
|
+
'<head>',
|
|
57
|
+
'<meta charset="utf-8">',
|
|
58
|
+
`<meta name="agentcard-checkout" content="${HOSTED_FORM_SUBMITTED_OUTCOME}">`,
|
|
59
|
+
'<meta name="robots" content="noindex">',
|
|
60
|
+
'<title>Submitted on the cardholder\'s device</title>',
|
|
61
|
+
'</head>',
|
|
62
|
+
'<body>',
|
|
63
|
+
`<p>The cardholder submitted this payment from their own device. Agentcard holds no evidence of the processor's answer: this is not a receipt. Do not resubmit this form. Confirm the order with the merchant${merchant ? ` (${escapeHtml(merchant)})` : ''}.</p>`,
|
|
64
|
+
`<script type="application/json" id="agentcard-checkout">${json}</script>`,
|
|
65
|
+
'</body>',
|
|
66
|
+
'</html>',
|
|
67
|
+
'',
|
|
68
|
+
].join('\n');
|
|
69
|
+
return {
|
|
70
|
+
status: 200,
|
|
71
|
+
headers: {
|
|
72
|
+
'content-type': 'text/html; charset=utf-8',
|
|
73
|
+
'cache-control': 'no-store',
|
|
74
|
+
'x-agentcard-checkout': HOSTED_FORM_SUBMITTED_OUTCOME,
|
|
75
|
+
},
|
|
76
|
+
body,
|
|
77
|
+
};
|
|
78
|
+
}
|
package/dist/index.d.ts
CHANGED
|
@@ -1,6 +1,10 @@
|
|
|
1
|
-
export { VaultClient, CardEncryptedError, ApprovalTimeoutError, ApprovalDeclinedError } from './client.js';
|
|
2
|
-
export type { PausedRequest, ReplayResponse, AuthorizeInput, VaultClientOptions } from './client.js';
|
|
1
|
+
export { VaultClient, CardEncryptedError, UnsupportedModeError, ApprovalTimeoutError, ApprovalDeclinedError, AmountMismatchError, IntentNotConfirmableError, ProcessorRefusedError, CheckoutApiError, redactUrl, SUPPORTED_MODES, } from './client.js';
|
|
2
|
+
export type { PausedRequest, ReplayResponse, TokenReplay, CseReplay, HostedFormReplay, AmountAuthority, AuthorizeInput, VaultClientOptions, } from './client.js';
|
|
3
3
|
export { attachToCdp, attachToPlaywright } from './cdp.js';
|
|
4
4
|
export type { CdpLike, AttachOptions } from './cdp.js';
|
|
5
|
-
export {
|
|
6
|
-
export type {
|
|
5
|
+
export { substituteEncryptedFields, SubstitutionError } from './substitute.js';
|
|
6
|
+
export type { Substitutions } from './substitute.js';
|
|
7
|
+
export { hostedFormSubmittedPage, HOSTED_FORM_SUBMITTED_OUTCOME } from './hosted-form.js';
|
|
8
|
+
export type { HostedFormSubmittedPageInput, SyntheticPage } from './hosted-form.js';
|
|
9
|
+
export { BUILTIN_REGISTRY, cardUrlPatterns, findRecognizer } from './registry.js';
|
|
10
|
+
export type { Recognizer, CheckoutMode } from './registry.js';
|
package/dist/index.js
CHANGED
|
@@ -1,3 +1,5 @@
|
|
|
1
|
-
export { VaultClient, CardEncryptedError, ApprovalTimeoutError, ApprovalDeclinedError } from './client.js';
|
|
1
|
+
export { VaultClient, CardEncryptedError, UnsupportedModeError, ApprovalTimeoutError, ApprovalDeclinedError, AmountMismatchError, IntentNotConfirmableError, ProcessorRefusedError, CheckoutApiError, redactUrl, SUPPORTED_MODES, } from './client.js';
|
|
2
2
|
export { attachToCdp, attachToPlaywright } from './cdp.js';
|
|
3
|
-
export {
|
|
3
|
+
export { substituteEncryptedFields, SubstitutionError } from './substitute.js';
|
|
4
|
+
export { hostedFormSubmittedPage, HOSTED_FORM_SUBMITTED_OUTCOME } from './hosted-form.js';
|
|
5
|
+
export { BUILTIN_REGISTRY, cardUrlPatterns, findRecognizer } from './registry.js';
|