@agent-cards/checkout 0.5.0 → 0.7.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 +17 -0
- package/README.md +49 -31
- package/dist/attachment.d.ts +11 -0
- package/dist/attachment.js +50 -0
- package/dist/braintree.d.ts +2 -0
- package/dist/braintree.js +12 -0
- package/dist/cdp.d.ts +21 -7
- package/dist/cdp.js +313 -159
- package/dist/client.d.ts +113 -25
- package/dist/client.js +134 -22
- package/dist/index.d.ts +2 -1
- package/dist/index.js +2 -1
- package/dist/preparation.js +5 -4
- package/dist/prepared-processor.d.ts +4 -2
- package/dist/prepared-processor.js +75 -7
- package/dist/registry.js +10 -6
- package/package.json +2 -2
package/dist/client.d.ts
CHANGED
|
@@ -19,7 +19,16 @@ export declare const SUPPORTED_MODES: readonly CheckoutMode[];
|
|
|
19
19
|
* (hosted_form: the bytes the device submits name the amount), or a display
|
|
20
20
|
* fact on a template that carries no amount.
|
|
21
21
|
*/
|
|
22
|
-
|
|
22
|
+
/**
|
|
23
|
+
* Who named the amount an authorization carries: `processor` (the paused
|
|
24
|
+
* request's own bytes, or the Stripe intent it names, read back by Agentcard),
|
|
25
|
+
* `agent` (the `amount` you passed), `page` (the total read off the checkout
|
|
26
|
+
* page), or `none` (nobody yet; the processor is read right before the card
|
|
27
|
+
* is sent).
|
|
28
|
+
*/
|
|
29
|
+
export type AmountAuthority = 'processor' | 'agent' | 'page' | 'none';
|
|
30
|
+
/** An integer in the smallest unit, or a decimal string in normal units with a point; nothing else. */
|
|
31
|
+
export declare function validAmountInput(amount: unknown): amount is number | string;
|
|
23
32
|
/**
|
|
24
33
|
* The token flow: the cardholder's device called the processor itself, and
|
|
25
34
|
* this is the processor's answer to replay into the paused request.
|
|
@@ -41,17 +50,18 @@ export interface TokenReplay {
|
|
|
41
50
|
* moved) and Agentcard also sent your server checkout_authorization.amount_mismatch.
|
|
42
51
|
*/
|
|
43
52
|
amountVerified?: boolean | null;
|
|
44
|
-
|
|
53
|
+
/** What the processor collected, an integer in the currency's smallest unit (Stripe's `amount`). */
|
|
54
|
+
chargedAmount?: number | null;
|
|
45
55
|
chargedCurrency?: string | null;
|
|
46
56
|
/**
|
|
47
|
-
* What `
|
|
57
|
+
* What `chargedAmount` is: 'captured' (the intent succeeded; this is
|
|
48
58
|
* amount_received), 'authorized' (requires_capture; amount_capturable, the
|
|
49
59
|
* merchant captures later), 'none' (a PaymentIntent was reported but
|
|
50
60
|
* nothing is collected yet: processing, requires_action), or null when no
|
|
51
61
|
* PaymentIntent was reported at all (a tokenization request).
|
|
52
62
|
*/
|
|
53
63
|
chargedKind?: 'captured' | 'authorized' | 'none' | null;
|
|
54
|
-
/**
|
|
64
|
+
/** Who named the amount the person approved. */
|
|
55
65
|
amountAuthority?: AmountAuthority;
|
|
56
66
|
}
|
|
57
67
|
/**
|
|
@@ -102,13 +112,14 @@ export type ReplayResponse = TokenReplay | CseReplay | HostedFormReplay;
|
|
|
102
112
|
export interface PrepareCheckoutOptions {
|
|
103
113
|
psp: PreparationProcessor;
|
|
104
114
|
/** The processor environment, independent of your Agentcard client's mode. */
|
|
105
|
-
environment: 'production' | 'sandbox';
|
|
115
|
+
environment: 'production' | 'sandbox' | 'shared';
|
|
106
116
|
signal?: AbortSignal;
|
|
107
117
|
}
|
|
108
118
|
export interface PrepareCheckoutInput extends PrepareCheckoutOptions {
|
|
109
119
|
user: string;
|
|
110
120
|
merchant: string;
|
|
111
|
-
|
|
121
|
+
/** An integer in the currency's smallest unit (2306 for $23.06), or a decimal string in normal units ("23.06"). */
|
|
122
|
+
amount: number | string;
|
|
112
123
|
currency: string;
|
|
113
124
|
cardId?: string;
|
|
114
125
|
merchantOrigin: string;
|
|
@@ -122,17 +133,19 @@ export interface PreparedCheckout {
|
|
|
122
133
|
readonly id: string;
|
|
123
134
|
readonly status: 'ready';
|
|
124
135
|
readonly psp: PreparationProcessor;
|
|
125
|
-
readonly environment: 'production' | 'sandbox';
|
|
136
|
+
readonly environment: 'production' | 'sandbox' | 'shared';
|
|
126
137
|
readonly expiresAt: string;
|
|
127
138
|
readonly cardId: string;
|
|
128
139
|
readonly user: string;
|
|
129
140
|
readonly merchant: string;
|
|
130
|
-
|
|
141
|
+
/** The approved amount, an integer in the currency's smallest unit, as the API confirmed it. */
|
|
142
|
+
readonly amount: number;
|
|
143
|
+
readonly amountDisplay: string | null;
|
|
131
144
|
readonly currency: string;
|
|
132
145
|
readonly merchantOrigin: string;
|
|
133
146
|
readonly checkoutKey: string;
|
|
134
147
|
readonly paymentStatus: 'not_started';
|
|
135
|
-
readonly amountAuthority: '
|
|
148
|
+
readonly amountAuthority: 'agent';
|
|
136
149
|
}
|
|
137
150
|
export declare class CheckoutPreparationError extends Error {
|
|
138
151
|
preparationId: string | null;
|
|
@@ -142,27 +155,31 @@ export declare class CheckoutPreparationError extends Error {
|
|
|
142
155
|
export interface AuthorizeInput {
|
|
143
156
|
/** Your identifier for the person whose card should pay. */
|
|
144
157
|
user: string;
|
|
145
|
-
/** Shown to the user on the approval screen. */
|
|
158
|
+
/** Shown to the user on the approval screen. Judged by nothing: the merchant comes from the checkout page. */
|
|
146
159
|
merchant: string;
|
|
147
160
|
/**
|
|
148
|
-
*
|
|
149
|
-
*
|
|
150
|
-
*
|
|
161
|
+
* Your hint at the amount: an integer in the currency's smallest unit (2306
|
|
162
|
+
* for $23.06), or a decimal string in normal units ("23.06"), with its ISO
|
|
163
|
+
* 4217 code ("usd"). Optional: the processor's own amount is the higher
|
|
164
|
+
* authority, read from the paused request or from the Stripe intent it
|
|
165
|
+
* names right before the cardholder's device replays, and the company's
|
|
166
|
+
* caps are judged on it. A hint lets a bad purchase be refused the moment
|
|
167
|
+
* you open it, and a hint that disagrees with the processor beyond one
|
|
168
|
+
* smallest unit is refused with nothing charged (an AmountMismatchError;
|
|
169
|
+
* `stage` says which check). Agentcard derives the display string; you
|
|
170
|
+
* never send one. Both or neither: one without the other is refused.
|
|
151
171
|
*/
|
|
152
|
-
amount?: string;
|
|
172
|
+
amount?: number | string;
|
|
173
|
+
currency?: string;
|
|
153
174
|
/**
|
|
154
|
-
* The
|
|
155
|
-
*
|
|
156
|
-
*
|
|
157
|
-
* at create and again right before the cardholder's device replays, and a
|
|
158
|
-
* different amount is refused with nothing charged (an AmountMismatchError
|
|
159
|
-
* either way: `stage` says which check). After the replay the charge is
|
|
160
|
-
* reconciled against the approval (see ReplayResponse.amountVerified).
|
|
161
|
-
* Tokenization requests carry no amount, so there it is display-only.
|
|
162
|
-
* Both or neither: one without the other is refused.
|
|
175
|
+
* The total read off the checkout page, the lowest authority: used only
|
|
176
|
+
* when neither the processor's request nor your hint names an amount. The
|
|
177
|
+
* adapters fill it from `[data-agentcard-amount]` when a page carries one.
|
|
163
178
|
*/
|
|
164
|
-
|
|
165
|
-
|
|
179
|
+
pageAmount?: {
|
|
180
|
+
amount: number;
|
|
181
|
+
currency: string;
|
|
182
|
+
};
|
|
166
183
|
/**
|
|
167
184
|
* WHICH stored card should pay — a vault card id from
|
|
168
185
|
* GET /api/v2/vault_cards. The approval page preselects it (the human can
|
|
@@ -171,6 +188,14 @@ export interface AuthorizeInput {
|
|
|
171
188
|
* `card_not_found`.
|
|
172
189
|
*/
|
|
173
190
|
cardId?: string;
|
|
191
|
+
/**
|
|
192
|
+
* The origin of the checkout page the payment form was on
|
|
193
|
+
* (https://shop.example.com). The adapters read it from the page; pass it
|
|
194
|
+
* yourself when you run your own interception. With the processor identity
|
|
195
|
+
* in the paused request this is what names the merchant for the company's
|
|
196
|
+
* presets; `merchant` above is shown to the person and judged by nothing.
|
|
197
|
+
*/
|
|
198
|
+
pageOrigin?: string;
|
|
174
199
|
request: PausedRequest;
|
|
175
200
|
/** Abort if the user has not approved within this many ms. Default 15 min. */
|
|
176
201
|
timeoutMs?: number;
|
|
@@ -300,6 +325,69 @@ export declare class ProcessorRefusedError extends ApprovalDeclinedError {
|
|
|
300
325
|
* retrying is worth anything: a 404 `connection_not_found` will answer the same
|
|
301
326
|
* way forever, while a 429 or a 502 will not.
|
|
302
327
|
*/
|
|
328
|
+
/**
|
|
329
|
+
* The company's preset refused the purchase. The company that runs this
|
|
330
|
+
* integration put rules on its users' Vault purchases (a merchant list, a
|
|
331
|
+
* currency, a spend cap, a time window); this purchase is outside them.
|
|
332
|
+
* Nothing was charged. Two stages:
|
|
333
|
+
* - 'create': refused before any authorization existed (`authorizationId`
|
|
334
|
+
* is null); nobody was asked to approve. Per purchase, not per page: the
|
|
335
|
+
* next request on the same page is judged afresh.
|
|
336
|
+
* - 'pre_replay': refused right before the cardholder's device would have
|
|
337
|
+
* sent the card; the authorization is `declined` with `code` as reason.
|
|
338
|
+
* `code` is the rule's reason (merchant_denied, currency_denied,
|
|
339
|
+
* spend_rate_exceeded, time_window_denied, ...), `message` the rule's own
|
|
340
|
+
* statement with the company's next step, `preset` the version that judged
|
|
341
|
+
* it. When several presets refused the same purchase, `code`, `preset`,
|
|
342
|
+
* `rule` and `attachment` are the first, and `refusals` names every one. A
|
|
343
|
+
* decline in every structural sense (the adapters quiet the page's retry as
|
|
344
|
+
* for a person's "no"), so it extends ApprovalDeclinedError.
|
|
345
|
+
*/
|
|
346
|
+
export declare class PresetRefusedError extends ApprovalDeclinedError {
|
|
347
|
+
readonly authorizationId: string | null;
|
|
348
|
+
readonly code: string;
|
|
349
|
+
readonly detail: string | null;
|
|
350
|
+
readonly preset: {
|
|
351
|
+
id: string;
|
|
352
|
+
version: number;
|
|
353
|
+
name: string;
|
|
354
|
+
} | null;
|
|
355
|
+
readonly rule: string | null;
|
|
356
|
+
readonly stage: 'create' | 'pre_replay';
|
|
357
|
+
/** The stored card the preset that refused is attached to, with its last four digits. */
|
|
358
|
+
readonly attachment: PresetAttachment | null;
|
|
359
|
+
/** The preset's name. */
|
|
360
|
+
readonly presetName: string | null;
|
|
361
|
+
/** Every preset that refused, each with its attachment, rule, code and statement; one entry when one refused. */
|
|
362
|
+
readonly refusals: readonly PresetRefusal[];
|
|
363
|
+
constructor(authorizationId: string | null, code: string, detail: string | null, preset: {
|
|
364
|
+
id: string;
|
|
365
|
+
version: number;
|
|
366
|
+
name: string;
|
|
367
|
+
} | null, rule: string | null, stage: 'create' | 'pre_replay',
|
|
368
|
+
/** The stored card the preset that refused is attached to, with its last four digits. */
|
|
369
|
+
attachment?: PresetAttachment | null, refusals?: readonly PresetRefusal[]);
|
|
370
|
+
}
|
|
371
|
+
/** The stored card a preset is attached to, with its last four digits. */
|
|
372
|
+
export interface PresetAttachment {
|
|
373
|
+
kind: 'card';
|
|
374
|
+
targetId: string | null;
|
|
375
|
+
last4: string | null;
|
|
376
|
+
}
|
|
377
|
+
/** One preset's refusal of a purchase. */
|
|
378
|
+
export interface PresetRefusal {
|
|
379
|
+
preset: {
|
|
380
|
+
id: string;
|
|
381
|
+
version: number;
|
|
382
|
+
name: string;
|
|
383
|
+
};
|
|
384
|
+
attachment: PresetAttachment | null;
|
|
385
|
+
rule: string | null;
|
|
386
|
+
code: string;
|
|
387
|
+
detail: string | null;
|
|
388
|
+
}
|
|
389
|
+
/** A decline reason only the company presets stamp (see PresetRefusedError). */
|
|
390
|
+
export declare const PRESET_REFUSAL_REASONS: ReadonlySet<string>;
|
|
303
391
|
export declare class CheckoutApiError extends Error {
|
|
304
392
|
status: number;
|
|
305
393
|
path: string;
|
package/dist/client.js
CHANGED
|
@@ -1,12 +1,18 @@
|
|
|
1
1
|
import { BUILTIN_REGISTRY, cardUrlPatterns as deriveCardUrlPatterns, findRecognizer, } from './registry.js';
|
|
2
|
-
import { matchesPreparedRequest } from './prepared-processor.js';
|
|
2
|
+
import { matchesPreparedRequest, validPreparationEnvironment } from './prepared-processor.js';
|
|
3
3
|
/**
|
|
4
4
|
* The modes this SDK can finish. Asked for on syncRegistry (the API serves
|
|
5
5
|
* only recognizers in these modes, so a request this build cannot complete
|
|
6
6
|
* is never paused) and sent on every create.
|
|
7
7
|
*/
|
|
8
8
|
export const SUPPORTED_MODES = ['token', 'cse', 'hosted_form'];
|
|
9
|
-
|
|
9
|
+
/** An integer in the smallest unit, or a decimal string in normal units with a point; nothing else. */
|
|
10
|
+
export function validAmountInput(amount) {
|
|
11
|
+
if (typeof amount === 'number')
|
|
12
|
+
return Number.isSafeInteger(amount) && amount >= 0;
|
|
13
|
+
return typeof amount === 'string' && /^\d{1,15}\.\d{1,3}$/.test(amount.trim());
|
|
14
|
+
}
|
|
15
|
+
const AMOUNT_AUTHORITIES = ['processor', 'agent', 'page', 'none'];
|
|
10
16
|
export class CheckoutPreparationError extends Error {
|
|
11
17
|
preparationId;
|
|
12
18
|
reason;
|
|
@@ -180,6 +186,94 @@ export class ProcessorRefusedError extends ApprovalDeclinedError {
|
|
|
180
186
|
* retrying is worth anything: a 404 `connection_not_found` will answer the same
|
|
181
187
|
* way forever, while a 429 or a 502 will not.
|
|
182
188
|
*/
|
|
189
|
+
/**
|
|
190
|
+
* The company's preset refused the purchase. The company that runs this
|
|
191
|
+
* integration put rules on its users' Vault purchases (a merchant list, a
|
|
192
|
+
* currency, a spend cap, a time window); this purchase is outside them.
|
|
193
|
+
* Nothing was charged. Two stages:
|
|
194
|
+
* - 'create': refused before any authorization existed (`authorizationId`
|
|
195
|
+
* is null); nobody was asked to approve. Per purchase, not per page: the
|
|
196
|
+
* next request on the same page is judged afresh.
|
|
197
|
+
* - 'pre_replay': refused right before the cardholder's device would have
|
|
198
|
+
* sent the card; the authorization is `declined` with `code` as reason.
|
|
199
|
+
* `code` is the rule's reason (merchant_denied, currency_denied,
|
|
200
|
+
* spend_rate_exceeded, time_window_denied, ...), `message` the rule's own
|
|
201
|
+
* statement with the company's next step, `preset` the version that judged
|
|
202
|
+
* it. When several presets refused the same purchase, `code`, `preset`,
|
|
203
|
+
* `rule` and `attachment` are the first, and `refusals` names every one. A
|
|
204
|
+
* decline in every structural sense (the adapters quiet the page's retry as
|
|
205
|
+
* for a person's "no"), so it extends ApprovalDeclinedError.
|
|
206
|
+
*/
|
|
207
|
+
export class PresetRefusedError extends ApprovalDeclinedError {
|
|
208
|
+
authorizationId;
|
|
209
|
+
code;
|
|
210
|
+
detail;
|
|
211
|
+
preset;
|
|
212
|
+
rule;
|
|
213
|
+
stage;
|
|
214
|
+
attachment;
|
|
215
|
+
/** The preset's name. */
|
|
216
|
+
presetName;
|
|
217
|
+
/** Every preset that refused, each with its attachment, rule, code and statement; one entry when one refused. */
|
|
218
|
+
refusals;
|
|
219
|
+
constructor(authorizationId, code, detail, preset, rule, stage,
|
|
220
|
+
/** The stored card the preset that refused is attached to, with its last four digits. */
|
|
221
|
+
attachment = null, refusals = []) {
|
|
222
|
+
super(code);
|
|
223
|
+
this.authorizationId = authorizationId;
|
|
224
|
+
this.code = code;
|
|
225
|
+
this.detail = detail;
|
|
226
|
+
this.preset = preset;
|
|
227
|
+
this.rule = rule;
|
|
228
|
+
this.stage = stage;
|
|
229
|
+
this.attachment = attachment;
|
|
230
|
+
this.name = 'PresetRefusedError';
|
|
231
|
+
this.presetName = preset?.name ?? null;
|
|
232
|
+
this.refusals = refusals.length ? refusals : preset ? [{ preset, attachment, rule, code, detail }] : [];
|
|
233
|
+
const others = this.refusals.slice(1).map((r) => `"${r.preset.name}"`);
|
|
234
|
+
const also = others.length ? ` Also refused by ${others.length === 1 ? 'preset' : 'presets'} ${others.join(', ')}.` : '';
|
|
235
|
+
this.message = `refused by the company preset${preset ? ` "${preset.name}"` : ''} (${code}${stage === 'create' ? ', before any authorization was created' : ''}): ${detail ?? 'this purchase is outside the rules the company set'}${also}`;
|
|
236
|
+
}
|
|
237
|
+
}
|
|
238
|
+
/** A decline reason only the company presets stamp (see PresetRefusedError). */
|
|
239
|
+
export const PRESET_REFUSAL_REASONS = new Set([
|
|
240
|
+
'spend_total_exceeded', 'spend_rate_exceeded', 'spend_rate_unknown',
|
|
241
|
+
'category_denied', 'category_unknown', 'merchant_denied', 'merchant_unknown',
|
|
242
|
+
'geo_denied', 'geo_unknown', 'currency_denied', 'currency_unknown',
|
|
243
|
+
'time_window_denied', 'surface_denied', 'surface_unknown',
|
|
244
|
+
]);
|
|
245
|
+
function presetOf(v) {
|
|
246
|
+
if (!v || typeof v !== 'object')
|
|
247
|
+
return null;
|
|
248
|
+
const p = v;
|
|
249
|
+
if (typeof p.id !== 'string' || typeof p.version !== 'number' || typeof p.name !== 'string')
|
|
250
|
+
return null;
|
|
251
|
+
return { id: p.id, version: p.version, name: p.name };
|
|
252
|
+
}
|
|
253
|
+
function attachmentOf(v) {
|
|
254
|
+
if (!v || typeof v !== 'object')
|
|
255
|
+
return null;
|
|
256
|
+
const a = v;
|
|
257
|
+
if (a.kind !== 'card')
|
|
258
|
+
return null;
|
|
259
|
+
return { kind: a.kind, targetId: typeof a.target_id === 'string' ? a.target_id : null, last4: typeof a.last4 === 'string' ? a.last4 : null };
|
|
260
|
+
}
|
|
261
|
+
/** The `refusals` list of a refusal envelope: every entry that names a preset. */
|
|
262
|
+
function refusalsOf(v) {
|
|
263
|
+
if (!Array.isArray(v))
|
|
264
|
+
return [];
|
|
265
|
+
const out = [];
|
|
266
|
+
for (const item of v) {
|
|
267
|
+
if (!item || typeof item !== 'object')
|
|
268
|
+
continue;
|
|
269
|
+
const r = item;
|
|
270
|
+
const preset = presetOf(r.preset);
|
|
271
|
+
if (!preset || typeof r.reason !== 'string')
|
|
272
|
+
continue;
|
|
273
|
+
out.push({ preset, attachment: attachmentOf(r.attachment), rule: typeof r.rule === 'string' ? r.rule : null, code: r.reason, detail: typeof r.message === 'string' ? r.message : null });
|
|
274
|
+
}
|
|
275
|
+
return out;
|
|
276
|
+
}
|
|
183
277
|
export class CheckoutApiError extends Error {
|
|
184
278
|
status;
|
|
185
279
|
path;
|
|
@@ -218,6 +312,12 @@ export class CheckoutApiError extends Error {
|
|
|
218
312
|
get permanent() {
|
|
219
313
|
if (this.code === 'amount_mismatch' || this.code === 'duplicate_submission')
|
|
220
314
|
return false;
|
|
315
|
+
// Nor a refusal by the company preset (a 403 carrying `preset` in the
|
|
316
|
+
// envelope): the company's rules refused THIS purchase, at this merchant,
|
|
317
|
+
// for this amount, at this hour. The next one on the same page may pass,
|
|
318
|
+
// so the page is quieted like a decline, never latched.
|
|
319
|
+
if (this.details.preset && typeof this.details.preset === 'object')
|
|
320
|
+
return false;
|
|
221
321
|
return this.status >= 400 && this.status < 500 && this.status !== 429;
|
|
222
322
|
}
|
|
223
323
|
}
|
|
@@ -315,9 +415,9 @@ export class VaultClient {
|
|
|
315
415
|
async prepareCheckout(input) {
|
|
316
416
|
input = { ...input };
|
|
317
417
|
const fail = (reason, id = null) => new CheckoutPreparationError(id, reason);
|
|
318
|
-
if (!
|
|
418
|
+
if (!validPreparationEnvironment(input.psp, input.environment))
|
|
319
419
|
throw fail('unsupported_processor');
|
|
320
|
-
if (!
|
|
420
|
+
if (!validAmountInput(input.amount) || typeof input.currency !== 'string' || !/^[a-z]{3}$/i.test(input.currency))
|
|
321
421
|
throw fail('amount_required');
|
|
322
422
|
const origin = new URL(input.merchantOrigin);
|
|
323
423
|
if (!(origin.protocol === 'https:' || (origin.protocol === 'http:' && origin.hostname === 'localhost')) || origin.origin !== input.merchantOrigin)
|
|
@@ -336,7 +436,7 @@ export class VaultClient {
|
|
|
336
436
|
// Drain a sent creation even after caller cancellation to retire its ID.
|
|
337
437
|
// The separate stop signal prevents dispatch after a slow OAuth exchange.
|
|
338
438
|
const created = await this.post('/v2/checkout/preparations', {
|
|
339
|
-
user: input.user, merchant: input.merchant,
|
|
439
|
+
user: input.user, merchant: input.merchant, amount: input.amount, currency: input.currency.toLowerCase(),
|
|
340
440
|
...(input.cardId ? { card_id: input.cardId } : {}), psp: input.psp, mode: 'token',
|
|
341
441
|
environment: input.environment, checkout_key: input.checkoutKey, merchant_origin: input.merchantOrigin,
|
|
342
442
|
}, AbortSignal.timeout(30_000), signal);
|
|
@@ -365,17 +465,18 @@ export class VaultClient {
|
|
|
365
465
|
if (state.status === 'ready') {
|
|
366
466
|
const expiry = Date.parse(state.ready_expires_at);
|
|
367
467
|
if (!Number.isFinite(expiry) || expiry <= Date.now() || typeof state.card_id !== 'string' || !state.card_id
|
|
368
|
-
|| state.payment_status !== 'not_started' || state.amount_authority !== '
|
|
468
|
+
|| state.payment_status !== 'not_started' || state.amount_authority !== 'agent'
|
|
369
469
|
|| state.user !== input.user || state.merchant !== input.merchant || state.merchant_origin !== input.merchantOrigin
|
|
370
|
-
|| state.
|
|
470
|
+
|| !Number.isSafeInteger(state.amount) || (typeof input.amount === 'number' && state.amount !== input.amount) || state.currency !== input.currency.toLowerCase()
|
|
371
471
|
|| state.psp !== input.psp || state.mode !== 'token' || state.environment !== input.environment
|
|
372
472
|
|| state.checkout_key !== input.checkoutKey)
|
|
373
473
|
throw fail('ready_unconfirmed', id);
|
|
374
474
|
const prepared = Object.freeze({
|
|
375
475
|
id: preparationId, status: 'ready', psp: input.psp, environment: input.environment, expiresAt: state.ready_expires_at,
|
|
376
|
-
cardId: state.card_id, user: input.user, merchant: input.merchant,
|
|
476
|
+
cardId: state.card_id, user: input.user, merchant: input.merchant, amount: state.amount,
|
|
477
|
+
amountDisplay: typeof state.amount_display === 'string' ? state.amount_display : null,
|
|
377
478
|
currency: input.currency.toLowerCase(), merchantOrigin: input.merchantOrigin, checkoutKey: input.checkoutKey,
|
|
378
|
-
paymentStatus: 'not_started', amountAuthority: '
|
|
479
|
+
paymentStatus: 'not_started', amountAuthority: 'agent',
|
|
379
480
|
});
|
|
380
481
|
this.preparations.add(prepared);
|
|
381
482
|
ready = true;
|
|
@@ -419,7 +520,7 @@ export class VaultClient {
|
|
|
419
520
|
this.usedPreparations.add(preparation);
|
|
420
521
|
if (Date.parse(preparation.expiresAt) <= Date.now())
|
|
421
522
|
throw new CheckoutPreparationError(preparation.id, 'expired');
|
|
422
|
-
if (input.user !== preparation.user || input.merchant !== preparation.merchant || input.
|
|
523
|
+
if (input.user !== preparation.user || input.merchant !== preparation.merchant || (typeof input.amount === 'number' && input.amount !== preparation.amount) || (typeof input.amount === 'string' && !validAmountInput(input.amount))
|
|
423
524
|
|| input.currency?.toLowerCase() !== preparation.currency || input.cardId !== preparation.cardId
|
|
424
525
|
|| !matchesPreparedRequest(preparation.psp, preparation.environment, input.request.url, input.request.method ?? 'POST', input.request.body))
|
|
425
526
|
throw new CheckoutPreparationError(preparation.id, 'checkout_changed');
|
|
@@ -452,16 +553,16 @@ export class VaultClient {
|
|
|
452
553
|
throw new Error(`checkout authorization is POST-only; got ${method} for ${redactUrl(input.request.url)}. ` +
|
|
453
554
|
'A non-POST tokenizer needs recognizer support before it can be intercepted.');
|
|
454
555
|
}
|
|
455
|
-
// The amount
|
|
456
|
-
//
|
|
457
|
-
//
|
|
458
|
-
const
|
|
556
|
+
// The amount is a hint: a pair or nothing. Refuse half a pair here, before
|
|
557
|
+
// a network call: the API would too, and a retry loop cannot fix a
|
|
558
|
+
// missing field. The processor's own amount is read by Agentcard.
|
|
559
|
+
const hasAmount = input.amount != null;
|
|
459
560
|
const hasCurrency = typeof input.currency === 'string' && input.currency.length > 0;
|
|
460
|
-
if (
|
|
461
|
-
throw new Error('
|
|
561
|
+
if (hasAmount !== hasCurrency) {
|
|
562
|
+
throw new Error('amount and currency go together: pass both or neither.');
|
|
462
563
|
}
|
|
463
|
-
if (!input.amount
|
|
464
|
-
throw new Error('
|
|
564
|
+
if (hasAmount && !validAmountInput(input.amount)) {
|
|
565
|
+
throw new Error('amount is an integer in the currency\'s smallest unit (2306 for $23.06), or a decimal string in normal units ("23.06").');
|
|
465
566
|
}
|
|
466
567
|
const timeoutMs = input.timeoutMs ?? 15 * 60_000;
|
|
467
568
|
if (!Number.isInteger(timeoutMs) || timeoutMs <= 0 || timeoutMs > 2_147_483_647)
|
|
@@ -473,15 +574,18 @@ export class VaultClient {
|
|
|
473
574
|
const payload = {
|
|
474
575
|
user: input.user,
|
|
475
576
|
merchant: input.merchant,
|
|
476
|
-
...(input.amount ? { amount: input.amount } : {}),
|
|
477
577
|
// snake_case on the wire; camelCase is this SDK's convention.
|
|
478
|
-
...(
|
|
578
|
+
...(hasAmount ? { amount: input.amount, currency: input.currency } : {}),
|
|
579
|
+
...(input.pageAmount ? { page_amount: input.pageAmount.amount, page_currency: input.pageAmount.currency } : {}),
|
|
479
580
|
psp: rec.psp,
|
|
480
581
|
// The mode this request will be finished in. The API checks it against
|
|
481
582
|
// the recognizer and refuses a disagreement before a row exists.
|
|
482
583
|
mode,
|
|
483
584
|
...(input.cardId ? { cardId: input.cardId } : {}),
|
|
484
|
-
|
|
585
|
+
// A prepared checkout already carries the page origin as merchant_origin.
|
|
586
|
+
...(preparation
|
|
587
|
+
? { preparation_id: preparation.id, checkout_key: preparation.checkoutKey, merchant_origin: preparation.merchantOrigin }
|
|
588
|
+
: input.pageOrigin ? { checkout_origin: input.pageOrigin } : {}),
|
|
485
589
|
request: {
|
|
486
590
|
url: input.request.url,
|
|
487
591
|
method: input.request.method,
|
|
@@ -621,7 +725,7 @@ export class VaultClient {
|
|
|
621
725
|
authorizationId,
|
|
622
726
|
...response,
|
|
623
727
|
amountVerified: typeof s.amount_verified === 'boolean' ? s.amount_verified : null,
|
|
624
|
-
|
|
728
|
+
chargedAmount: typeof s.charged_amount === 'number' ? s.charged_amount : null,
|
|
625
729
|
chargedCurrency: typeof s.charged_currency === 'string' ? s.charged_currency : null,
|
|
626
730
|
chargedKind: s.charged_kind === 'captured' || s.charged_kind === 'authorized' || s.charged_kind === 'none' ? s.charged_kind : null,
|
|
627
731
|
...amountAuthority,
|
|
@@ -635,6 +739,9 @@ export class VaultClient {
|
|
|
635
739
|
}
|
|
636
740
|
if (s.reason === 'intent_not_confirmable')
|
|
637
741
|
throw new IntentNotConfirmableError(String(created.id));
|
|
742
|
+
if (typeof s.reason === 'string' && PRESET_REFUSAL_REASONS.has(s.reason)) {
|
|
743
|
+
throw new PresetRefusedError(String(created.id), s.reason, typeof s.message === 'string' ? s.message : null, presetOf(s.preset), typeof s.rule === 'string' ? s.rule : null, 'pre_replay', attachmentOf(s.attachment), refusalsOf(s.refusals));
|
|
744
|
+
}
|
|
638
745
|
if (s.reason === 'processor_refused') {
|
|
639
746
|
throw new ProcessorRefusedError(String(created.id), typeof s.psp_error_code === 'string' ? s.psp_error_code : null, s.psp === 'razorpay' ? parseProcessorError(s.processor_error) : null);
|
|
640
747
|
}
|
|
@@ -718,6 +825,11 @@ export class VaultClient {
|
|
|
718
825
|
return await this.post('/v2/checkout/authorizations', payload, signal, stopRetries);
|
|
719
826
|
}
|
|
720
827
|
catch (err) {
|
|
828
|
+
if (err instanceof CheckoutApiError && err.code && err.status === 403 && presetOf(err.details.preset)) {
|
|
829
|
+
// A company preset refused this purchase before a row existed.
|
|
830
|
+
const d = err.details;
|
|
831
|
+
throw new PresetRefusedError(null, err.code, typeof d.message === 'string' ? d.message : null, presetOf(d.preset), typeof d.rule === 'string' ? d.rule : null, 'create', attachmentOf(d.attachment), refusalsOf(d.refusals));
|
|
832
|
+
}
|
|
721
833
|
if (err instanceof CheckoutApiError && err.code === 'amount_mismatch') {
|
|
722
834
|
const d = err.details;
|
|
723
835
|
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');
|
package/dist/index.d.ts
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
|
-
export { VaultClient, CardEncryptedError, UnsupportedModeError, ApprovalTimeoutError, ApprovalDeclinedError, AmountMismatchError, IntentNotConfirmableError, ProcessorRefusedError, CheckoutApiError, redactUrl, SUPPORTED_MODES, PaymentOutcomeUnknownError, CheckoutCancelledError, CheckoutPreparationError, } from './client.js';
|
|
1
|
+
export { VaultClient, CardEncryptedError, UnsupportedModeError, ApprovalTimeoutError, ApprovalDeclinedError, AmountMismatchError, PresetRefusedError, PRESET_REFUSAL_REASONS, IntentNotConfirmableError, ProcessorRefusedError, CheckoutApiError, redactUrl, SUPPORTED_MODES, PaymentOutcomeUnknownError, CheckoutCancelledError, CheckoutPreparationError, } from './client.js';
|
|
2
2
|
export type { PausedRequest, ReplayResponse, TokenReplay, CseReplay, HostedFormReplay, AmountAuthority, AuthorizeInput, VaultClientOptions, PrepareCheckoutOptions, PrepareCheckoutInput, PreparedCheckout, RazorpayProcessorError, } from './client.js';
|
|
3
3
|
export { attachToCdp, attachToPlaywright, corsHeadersFor, corsDecision, withCorsHeaders } from './cdp.js';
|
|
4
|
+
export { CheckoutAttachmentError } from './attachment.js';
|
|
4
5
|
export type { CdpLike, AttachOptions, CorsOutcome } from './cdp.js';
|
|
5
6
|
export { substituteEncryptedFields, SubstitutionError } from './substitute.js';
|
|
6
7
|
export type { Substitutions } from './substitute.js';
|
package/dist/index.js
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
|
-
export { VaultClient, CardEncryptedError, UnsupportedModeError, ApprovalTimeoutError, ApprovalDeclinedError, AmountMismatchError, IntentNotConfirmableError, ProcessorRefusedError, CheckoutApiError, redactUrl, SUPPORTED_MODES, PaymentOutcomeUnknownError, CheckoutCancelledError, CheckoutPreparationError, } from './client.js';
|
|
1
|
+
export { VaultClient, CardEncryptedError, UnsupportedModeError, ApprovalTimeoutError, ApprovalDeclinedError, AmountMismatchError, PresetRefusedError, PRESET_REFUSAL_REASONS, IntentNotConfirmableError, ProcessorRefusedError, CheckoutApiError, redactUrl, SUPPORTED_MODES, PaymentOutcomeUnknownError, CheckoutCancelledError, CheckoutPreparationError, } from './client.js';
|
|
2
2
|
export { attachToCdp, attachToPlaywright, corsHeadersFor, corsDecision, withCorsHeaders } from './cdp.js';
|
|
3
|
+
export { CheckoutAttachmentError } from './attachment.js';
|
|
3
4
|
export { substituteEncryptedFields, SubstitutionError } from './substitute.js';
|
|
4
5
|
export { hostedFormSubmittedPage, HOSTED_FORM_SUBMITTED_OUTCOME } from './hosted-form.js';
|
|
5
6
|
export { BUILTIN_REGISTRY, cardUrlPatterns, findRecognizer } from './registry.js';
|
package/dist/preparation.js
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { CheckoutPreparationError } from './client.js';
|
|
2
|
-
import {
|
|
2
|
+
import { validAmountInput } from './client.js';
|
|
3
|
+
import { matchesPreparedRequest, validPreparationEnvironment, preparationEndpoint } from './prepared-processor.js';
|
|
3
4
|
/** A local, one-use rendezvous. It never starts or retries a merchant request. */
|
|
4
5
|
export class PreparationGate {
|
|
5
6
|
opts;
|
|
@@ -34,12 +35,12 @@ export class PreparationGate {
|
|
|
34
35
|
const onAbort = () => this.invalidate('cancelled');
|
|
35
36
|
signal.addEventListener('abort', onAbort, { once: true });
|
|
36
37
|
try {
|
|
37
|
-
if (!options || !
|
|
38
|
+
if (!options || !validPreparationEnvironment(options.psp, options.environment))
|
|
38
39
|
throw new CheckoutPreparationError(null, 'unsupported_processor');
|
|
39
40
|
const tokenizer = preparationEndpoint(options.psp, options.environment);
|
|
40
41
|
if (!this.opts.vault.isCardRequest(tokenizer, 'POST'))
|
|
41
42
|
throw new CheckoutPreparationError(null, 'processor_interception_unavailable');
|
|
42
|
-
if (!
|
|
43
|
+
if (!validAmountInput(this.opts.amount) || this.opts.amount === 0 || !/^[a-z]{3}$/i.test(this.opts.currency ?? ''))
|
|
43
44
|
throw new CheckoutPreparationError(null, 'amount_required');
|
|
44
45
|
if (signal.aborted)
|
|
45
46
|
throw new CheckoutPreparationError(null, 'cancelled');
|
|
@@ -49,7 +50,7 @@ export class PreparationGate {
|
|
|
49
50
|
throw new CheckoutPreparationError(null, 'merchant_origin_invalid');
|
|
50
51
|
const prepared = await this.opts.vault.prepareCheckout({
|
|
51
52
|
...options, user: this.opts.user, merchant: this.opts.merchant,
|
|
52
|
-
|
|
53
|
+
amount: this.opts.amount, currency: this.opts.currency, cardId: this.opts.cardId,
|
|
53
54
|
merchantOrigin: page.origin, checkoutKey: crypto.randomUUID(), timeoutMs: this.opts.timeoutMs, signal,
|
|
54
55
|
onPreparationCreated: id => this.lifecycle.preparationCreated(id),
|
|
55
56
|
onApprovalUrl: url => {
|
|
@@ -1,5 +1,7 @@
|
|
|
1
|
-
export type PreparationProcessor = 'square' | 'braintree';
|
|
2
|
-
export type PreparationEnvironment = 'production' | 'sandbox';
|
|
1
|
+
export type PreparationProcessor = 'square' | 'braintree' | 'worldpay' | 'bambora' | 'mercado_pago';
|
|
2
|
+
export type PreparationEnvironment = 'production' | 'sandbox' | 'shared';
|
|
3
|
+
/** Shared endpoint processors cannot attest test/live mode from their URL or key prefix. */
|
|
4
|
+
export declare function validPreparationEnvironment(psp: string, environment: string): boolean;
|
|
3
5
|
export declare function preparationEndpoint(psp: PreparationProcessor, environment: PreparationEnvironment): string;
|
|
4
6
|
/** Processor identity and environment are part of the device's prior consent. */
|
|
5
7
|
export declare function matchesPreparedRequest(psp: PreparationProcessor, environment: PreparationEnvironment, requestUrl: string, method: string, body?: string | null): boolean;
|
|
@@ -1,23 +1,91 @@
|
|
|
1
|
-
import { braintreeEnvironment, isPreparedBraintreeRequest } from './braintree.js';
|
|
1
|
+
import { braintreeEnvironment, isPreparedBraintreeRequest, readTokenizationJson } from './braintree.js';
|
|
2
|
+
/** Shared endpoint processors cannot attest test/live mode from their URL or key prefix. */
|
|
3
|
+
export function validPreparationEnvironment(psp, environment) {
|
|
4
|
+
if (psp === 'bambora' || psp === 'mercado_pago')
|
|
5
|
+
return environment === 'shared';
|
|
6
|
+
return ['square', 'braintree', 'worldpay'].includes(psp) && ['production', 'sandbox'].includes(environment);
|
|
7
|
+
}
|
|
2
8
|
export function preparationEndpoint(psp, environment) {
|
|
9
|
+
if (!validPreparationEnvironment(psp, environment))
|
|
10
|
+
throw new Error('unsupported_preparation_processor');
|
|
11
|
+
if (psp === 'bambora')
|
|
12
|
+
return 'https://api.bam.shift4api.net/scripts/tokenization/tokens';
|
|
13
|
+
if (psp === 'mercado_pago')
|
|
14
|
+
return 'https://api.mercadopago.com/v1/card_tokens';
|
|
15
|
+
if (psp === 'worldpay')
|
|
16
|
+
return environment === 'production'
|
|
17
|
+
? 'https://access.worldpay.com/sessions/card' : 'https://try.access.worldpay.com/sessions/card';
|
|
3
18
|
if (psp === 'braintree')
|
|
4
19
|
return environment === 'production'
|
|
5
20
|
? 'https://payments.braintree-api.com/graphql' : 'https://payments.sandbox.braintree-api.com/graphql';
|
|
6
21
|
return environment === 'production'
|
|
7
22
|
? 'https://pci-connect.squareup.com/v2/card-nonce' : 'https://pci-connect.squareupsandbox.com/v2/card-nonce';
|
|
8
23
|
}
|
|
24
|
+
function record(value) {
|
|
25
|
+
return value !== null && typeof value === 'object' && !Array.isArray(value);
|
|
26
|
+
}
|
|
27
|
+
const keys = (value, allowed) => Object.keys(value).every(key => allowed.includes(key));
|
|
28
|
+
const string = (value, max = 1024) => typeof value === 'string' && value.length > 0 && value.length <= max && !/[\u0000-\u001f\u007f]/.test(value);
|
|
29
|
+
const digits = (value, min, max) => typeof value === 'string' && value.length >= min && value.length <= max && !/[^0-9]/.test(value);
|
|
30
|
+
const month = (value) => (typeof value === 'number' && Number.isInteger(value) || digits(value, 1, 2)) && Number(value) >= 1 && Number(value) <= 12;
|
|
31
|
+
const year = (value) => (typeof value === 'number' && Number.isInteger(value) || digits(value, 2, 4)) && (Number(value) >= 0 && Number(value) <= 99 || Number(value) >= 2000 && Number(value) <= 9999);
|
|
32
|
+
/** Native fresh-card shapes only. Saved-card, charge and recurring siblings never consume consent. */
|
|
33
|
+
function freshCardBody(psp, body) {
|
|
34
|
+
const value = readTokenizationJson(body);
|
|
35
|
+
if (!value)
|
|
36
|
+
return false;
|
|
37
|
+
if (psp === 'worldpay')
|
|
38
|
+
return keys(value, ['identity', 'cardNumber', 'cardExpiryDate', 'cvc'])
|
|
39
|
+
&& string(value.identity) && digits(value.cardNumber, 12, 19)
|
|
40
|
+
&& record(value.cardExpiryDate) && keys(value.cardExpiryDate, ['month', 'year'])
|
|
41
|
+
&& month(value.cardExpiryDate.month) && year(value.cardExpiryDate.year)
|
|
42
|
+
&& (value.cvc === undefined || digits(value.cvc, 3, 4));
|
|
43
|
+
if (psp === 'bambora')
|
|
44
|
+
return keys(value, ['number', 'expiry_month', 'expiry_year', 'cvd'])
|
|
45
|
+
&& digits(value.number, 12, 19) && month(value.expiry_month) && year(value.expiry_year)
|
|
46
|
+
&& (value.cvd === undefined || digits(value.cvd, 3, 4));
|
|
47
|
+
if (psp !== 'mercado_pago' || !keys(value, ['card_number', 'expiration_month', 'expiration_year', 'security_code', 'cardholder', 'device'])
|
|
48
|
+
|| !digits(value.card_number, 12, 19) || !month(value.expiration_month) || !year(value.expiration_year)
|
|
49
|
+
|| (value.security_code !== undefined && value.security_code !== '' && !digits(value.security_code, 3, 4)) || !record(value.cardholder)
|
|
50
|
+
|| !keys(value.cardholder, ['name', 'identification']))
|
|
51
|
+
return false;
|
|
52
|
+
const holder = value.cardholder;
|
|
53
|
+
if (holder.name !== undefined && holder.name !== '' && !string(holder.name))
|
|
54
|
+
return false;
|
|
55
|
+
if (holder.identification !== undefined && (!record(holder.identification) || !keys(holder.identification, ['type', 'number'])
|
|
56
|
+
|| Object.values(holder.identification).some(v => v !== '' && !string(v))))
|
|
57
|
+
return false;
|
|
58
|
+
if (value.device !== undefined && (!record(value.device) || !keys(value.device, ['meli'])
|
|
59
|
+
|| !record(value.device.meli) || !keys(value.device.meli, ['session_id']) || !string(value.device.meli.session_id, 4096)))
|
|
60
|
+
return false;
|
|
61
|
+
return true;
|
|
62
|
+
}
|
|
9
63
|
/** Processor identity and environment are part of the device's prior consent. */
|
|
10
64
|
export function matchesPreparedRequest(psp, environment, requestUrl, method, body) {
|
|
11
|
-
if (method.toUpperCase() !== 'POST')
|
|
65
|
+
if (method.toUpperCase() !== 'POST' || !validPreparationEnvironment(psp, environment))
|
|
12
66
|
return false;
|
|
13
|
-
if (psp === 'braintree')
|
|
67
|
+
if (psp === 'braintree')
|
|
14
68
|
return braintreeEnvironment(requestUrl) === environment && isPreparedBraintreeRequest(body ?? null);
|
|
15
|
-
}
|
|
16
|
-
if (psp !== 'square')
|
|
17
|
-
return false;
|
|
18
69
|
try {
|
|
19
70
|
const request = new URL(requestUrl), endpoint = new URL(preparationEndpoint(psp, environment));
|
|
20
|
-
|
|
71
|
+
if (request.username || request.password || request.hash)
|
|
72
|
+
return false;
|
|
73
|
+
if (psp === 'bambora') {
|
|
74
|
+
if (![endpoint.href, 'https://api.na.bambora.com/scripts/tokenization/tokens'].includes(requestUrl))
|
|
75
|
+
return false;
|
|
76
|
+
}
|
|
77
|
+
else if (psp === 'worldpay') {
|
|
78
|
+
if (requestUrl !== endpoint.href)
|
|
79
|
+
return false;
|
|
80
|
+
}
|
|
81
|
+
else if (request.origin !== endpoint.origin || request.pathname !== endpoint.pathname)
|
|
82
|
+
return false;
|
|
83
|
+
if (psp === 'mercado_pago') {
|
|
84
|
+
const names = [...request.searchParams.keys()];
|
|
85
|
+
if (new Set(names).size !== names.length || names.some(name => !['public_key', 'locale', 'js_version', 'referer'].includes(name)))
|
|
86
|
+
return false;
|
|
87
|
+
}
|
|
88
|
+
return psp === 'square' || freshCardBody(psp, body ?? null);
|
|
21
89
|
}
|
|
22
90
|
catch {
|
|
23
91
|
return false;
|