@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/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
- export type AmountAuthority = 'stripe_payment_intent' | 'hosted_form_sum' | 'display_only';
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
- chargedAmountCents?: number | null;
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 `chargedAmountCents` is: 'captured' (the intent succeeded; this is
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
- /** 'stripe_payment_intent' when the amount was held to a Stripe intent; 'display_only' otherwise. */
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
- amountCents: number;
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
- readonly amountCents: number;
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: 'display_only';
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
- * The display string the approval screen shows ("$23.06"). Optional when
149
- * amountCents + currency are given, and IGNORED then: Agentcard derives the
150
- * string from the number so every surface shows one amount.
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 amount as a number in the smallest currency unit (2306 for $23.06)
155
- * with its ISO 4217 code ("usd"). On a Stripe PaymentIntent confirm this
156
- * pair is BOUND to the intent: Agentcard reads the intent back from Stripe
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
- amountCents?: number;
165
- currency?: string;
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
- const AMOUNT_AUTHORITIES = ['stripe_payment_intent', 'hosted_form_sum', 'display_only'];
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 (!['square', 'braintree'].includes(input.psp) || !['production', 'sandbox'].includes(input.environment))
418
+ if (!validPreparationEnvironment(input.psp, input.environment))
319
419
  throw fail('unsupported_processor');
320
- if (!Number.isSafeInteger(input.amountCents) || input.amountCents <= 0 || typeof input.currency !== 'string' || !/^[a-z]{3}$/i.test(input.currency))
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, amount_cents: input.amountCents, currency: input.currency.toLowerCase(),
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 !== 'display_only'
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.amount_cents !== input.amountCents || state.currency !== input.currency.toLowerCase()
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, amountCents: input.amountCents,
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: 'display_only',
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.amountCents !== preparation.amountCents
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 travels as a display string, a number with its currency, or
456
- // both. Refuse half a pair here, before a network call: the API would too,
457
- // and a retry loop cannot fix a missing field.
458
- const hasCents = input.amountCents != null;
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 (hasCents !== hasCurrency) {
461
- throw new Error('amountCents and currency go together: pass both or neither.');
561
+ if (hasAmount !== hasCurrency) {
562
+ throw new Error('amount and currency go together: pass both or neither.');
462
563
  }
463
- if (!input.amount && !hasCents) {
464
- throw new Error('authorize needs amount (a display string), or amountCents with currency.');
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
- ...(hasCents ? { amount_cents: input.amountCents, currency: input.currency } : {}),
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
- ...(preparation ? { preparation_id: preparation.id, checkout_key: preparation.checkoutKey, merchant_origin: preparation.merchantOrigin } : {}),
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
- chargedAmountCents: typeof s.charged_amount_cents === 'number' ? s.charged_amount_cents : null,
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';
@@ -1,5 +1,6 @@
1
1
  import { CheckoutPreparationError } from './client.js';
2
- import { matchesPreparedRequest, preparationEndpoint } from './prepared-processor.js';
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 || !['square', 'braintree'].includes(options.psp) || !['production', 'sandbox'].includes(options.environment))
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 (!Number.isSafeInteger(this.opts.amountCents) || (this.opts.amountCents ?? 0) <= 0 || !/^[a-z]{3}$/i.test(this.opts.currency ?? ''))
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
- amountCents: this.opts.amountCents, currency: this.opts.currency, cardId: this.opts.cardId,
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
- return request.origin === endpoint.origin && request.pathname === endpoint.pathname && !request.username && !request.password;
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;