@agent-cards/checkout 0.6.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 CHANGED
@@ -1,5 +1,10 @@
1
1
  # Changelog
2
2
 
3
+ ## Unreleased
4
+
5
+ - A company can put rules on the Vault purchases it chooses (a merchant list, a currency, a spend cap, a time window) by attaching a named preset to a stored card. A purchase the rules refuse now surfaces as `PresetRefusedError`, a typed decline: at stage `create` no authorization exists and nobody was asked; at stage `pre_replay` the authorization is `declined` with the rule's reason. It carries `code`, `presetName`, `rule`, `preset`, `attachment` (the card with its last four digits) and the rule's own statement; when several presets refuse the same purchase, `refusals` names every one and the other fields are the first. The adapters quiet the page's retry as for any decline, and a create-time refusal is never treated as a permanent misconfiguration.
6
+ - Requires the matching API and Vault release.
7
+
3
8
  ## 0.6.0
4
9
 
5
10
  - Recognize Paysafe Checkout 1.8's exact hosted tokenization endpoints and preserve its native credential and correlation headers. The matching API registry and Vault deployment are required.
package/README.md CHANGED
@@ -54,30 +54,28 @@ await attachToCdp(cdp, pageSessionId, {
54
54
  vault,
55
55
  user: 'usr_123', // whose card should pay
56
56
  merchant: 'vanman.shop',
57
- amountCents: 583, // what the user approves, smallest currency unit
57
+ amount: 583, // your hint, an integer in the currency's smallest unit (or a decimal string: '5.83')
58
58
  currency: 'usd', // "$5.83" is derived for the approval screen
59
59
  onApprovalUrl: (url) => sendToUser(url), // iMessage, SMS, push, your call
60
60
  });
61
61
  ```
62
62
 
63
- `amount: '$5.83'` (a display string) still works on its own. Pass
64
- `amountCents` + `currency` when you want the amount enforced: on a Stripe
65
- PaymentIntent confirm, Agentcard reads the intent back from Stripe at create
66
- and again right before the cardholder's device replays, a different amount is
67
- refused with nothing charged, and after the replay the charge is reconciled
68
- against the approval (`ReplayResponse.amountVerified`, with
69
- `chargedAmountCents` and `chargedKind`: `captured` for a succeeded intent's
70
- `amount_received`, `authorized` for a manual-capture intent's
71
- `amount_capturable`, `none` when nothing is collected yet; plus the
72
- `checkout_authorization.amount_mismatch` webhook to your server when it
73
- disagrees). Amounts are Stripe minor units up to 2^53-1. `onEvent` payloads
74
- name URLs by origin and path only, so a client secret in a paused request's
75
- query string never reaches your telemetry. When you pass the pair, any
76
- `amount` string you also pass is
77
- ignored: Agentcard derives the display string from the number, following
78
- Stripe's minor units, so the approval screen, the notifications and every
79
- read show one amount. Tokenization requests carry no amount, so there the
80
- pair is shown and reported (`amountAuthority: 'display_only'`), not enforced.
63
+ `amount` is your hint: an integer in the currency's smallest unit (583 for
64
+ $5.83), or a decimal string in normal units ('5.83'), with `currency`. The
65
+ processor's own amount is the higher authority: Agentcard reads it from the
66
+ paused request where the processor puts it there, or from the Stripe intent
67
+ the request names, right before the cardholder's device replays, and the
68
+ company's caps are judged on it. A hint lets a bad purchase be refused the
69
+ moment it opens; a hint more than one smallest unit away from the processor's
70
+ amount is refused with nothing charged (`AmountMismatchError`), and after the
71
+ replay the charge is reconciled against the approval
72
+ (`ReplayResponse.amountVerified`, `chargedAmount`, and `chargedKind`:
73
+ `captured` for a succeeded intent's `amount_received`, `authorized` for a
74
+ manual-capture intent's `amount_capturable`, `none` when nothing is collected
75
+ yet; plus the `checkout_authorization.amount_mismatch` webhook to your server
76
+ when the charge disagreed). Every result carries `amountAuthority`:
77
+ `processor`, `agent`, `page`, or `none`. A display string is never sent;
78
+ Agentcard derives it.
81
79
 
82
80
  Then let your agent click "Pay" like it always does. `attachToCdp` pauses the
83
81
  request for approval and resumes it only while the merchant request remains
@@ -129,7 +127,7 @@ Coverage is specific to the processor request format, merchant setup, browser tr
129
127
  | Processor | Status |
130
128
  |---|---|
131
129
  | Shopify | supported, verified end to end |
132
- | Stripe | tokenization replay and direct card-bearing PaymentIntent confirms are implemented; direct confirms with `amountCents` + `currency` use backend amount verification. Browser token-to-intent continuation is unsupported and held. Validate the exact merchant flow before pilot use |
130
+ | Stripe | tokenization replay and direct card-bearing PaymentIntent confirms are implemented; direct confirms read the intent's amount back from Stripe; a hint sent as `amount` + `currency` must agree with it. Browser token-to-intent continuation is unsupported and held. Validate the exact merchant flow before pilot use |
133
131
  | Braintree card tokenization | Prepared checkout supported; one live Haymarket Books ebook purchase with SDK `0.5.0` confirmed merchant fulfillment and SDK `completed` using a merchant receipt resolver. Independent processor capture/settlement, live 3DS and PayPal wallet flows remain unverified. |
134
132
  | Checkout.com | supported |
135
133
  | VGS Collect (Very Good Security; Wolt) | not supported: VGS's proxy aliases only submissions from its own iframe, so a replay from the cardholder's device is refused by the merchant (verified on Wolt, 2026-09-03). Not recognized, so the agent's browser is not paused there |
@@ -352,7 +350,7 @@ value continues to work. Choose `requireMerchantResult: true` for a pilot:
352
350
 
353
351
  ```ts
354
352
  const checkout = await attachToPlaywright(page, {
355
- vault, user, merchant, amountCents, currency,
353
+ vault, user, merchant, amount, currency,
356
354
  requireMerchantResult: true,
357
355
  onStateChange: state => recordState(state),
358
356
  onUserAction: action => deliverPrivatelyToUser(action),
@@ -395,7 +393,7 @@ Unrecognized merchant-server endpoints remain outside this guard unless listed
395
393
  in `paymentEndpoints`; this is not a guarantee against a merchant charging a
396
394
  saved token on its own server.
397
395
  A direct card-bearing PaymentIntent confirm remains supported with the backend's
398
- existing amount verification when `amountCents` and `currency` are supplied.
396
+ existing amount verification when `amount` and `currency` are supplied.
399
397
 
400
398
  Hosted-form submissions also always stay blocked because their payment outcome
401
399
  is unverified. `reconcile()` calls the resolver once, coalescing concurrent calls.
@@ -417,7 +415,7 @@ Prepare a Square, Braintree, Worldpay, Bambora or Mercado Pago checkout before t
417
415
  ```ts
418
416
  const checkout = await attachToPlaywright(page, {
419
417
  vault, user: 'your-user-id', merchant: 'Example merchant',
420
- amountCents: 100, currency: 'USD',
418
+ amount: 100, currency: 'USD',
421
419
  onApprovalUrl: deliverPrivatelyToCardholder,
422
420
  });
423
421
  const preparation = await checkout.prepare({
@@ -429,7 +427,7 @@ const preparation = await checkout.prepare({
429
427
  await page.getByRole('button', { name: 'Pay', exact: true }).click();
430
428
  ```
431
429
 
432
- `prepare()` is available on both Playwright and raw CDP controllers. It requires `amountCents` and `currency`, must precede the first recognized card request, and returns only when the cardholder's device is ready. It delivers the preparation URL through `onApprovalUrl` and `onUserAction`; binding the subsequent authorization sends no second approval link or SMS. The phone page must stay open. Its selected card, merchant origin, declared merchant, amount, currency, processor and environment bind one fresh request. The amount remains `display_only`; a card token does not enforce the merchant's eventual charge amount.
430
+ `prepare()` is available on both Playwright and raw CDP controllers. It requires `amount` and `currency`, must precede the first recognized card request, and returns only when the cardholder's device is ready. It delivers the preparation URL through `onApprovalUrl` and `onUserAction`; binding the subsequent authorization sends no second approval link or SMS. The phone page must stay open. Its selected card, merchant origin, declared merchant, amount, currency, processor and environment bind one fresh request. The amount's authority is `agent`; a card token does not enforce the merchant's eventual charge amount.
433
431
 
434
432
  | Processor | `environment` | Fresh native request |
435
433
  | --- | --- | --- |
package/dist/cdp.d.ts CHANGED
@@ -75,16 +75,28 @@ export interface AttachOptions extends LifecycleOptions {
75
75
  vault: VaultClient;
76
76
  user: string;
77
77
  merchant: string;
78
- /** Display string for the approval screen. Optional when amountCents + currency are given. */
79
- amount?: string;
80
78
  /**
81
- * The amount as a number (smallest currency unit) with its ISO 4217 code.
82
- * See AuthorizeInput.amountCents: on a Stripe PaymentIntent confirm this
83
- * binds the approval to the intent's amount, checked at create and again
84
- * right before replay, so a larger charge is refused with nothing charged.
79
+ * Your hint at the amount, an integer in the currency's smallest unit or a
80
+ * decimal string in normal units, with its ISO 4217 code. See
81
+ * AuthorizeInput.amount: the processor's own amount is the higher authority
82
+ * and is read right before the card is sent; a hint lets a bad purchase be
83
+ * refused the moment it opens, and one that disagrees with the processor is
84
+ * refused with nothing charged.
85
85
  */
86
- amountCents?: number;
86
+ amount?: number | string;
87
87
  currency?: string;
88
+ /**
89
+ * The total the checkout page shows, the lowest authority: used only when
90
+ * neither the processor's request nor your `amount` names one. Pass a reader
91
+ * that returns an integer in the currency's smallest unit with its code
92
+ * (`{ amount: 4210, currency: 'usd' }`), read off the page however your
93
+ * page spells it; the adapters call it when a card request pauses, give it
94
+ * one second, and send nothing when it yields nothing. Absent: no page total.
95
+ */
96
+ pageAmount?: () => Promise<{
97
+ amount: number;
98
+ currency: string;
99
+ } | undefined>;
88
100
  cardId?: string;
89
101
  timeoutMs?: number;
90
102
  /** Browser interception setup deadline, separate from approval. Defaults to 30000 ms; maximum 300000 ms. */
package/dist/cdp.js CHANGED
@@ -274,6 +274,59 @@ export function withCorsHeaders(headers, cors) {
274
274
  function headerEntries(headers) {
275
275
  return Object.entries(headers).map(([name, value]) => ({ name, value: String(value) }));
276
276
  }
277
+ /**
278
+ * The origin of the top-level document the payment form is on, read when a
279
+ * card request pauses: the fact that names the merchant for the company's
280
+ * presets (the API's checkout_origin). An https origin, or http://localhost
281
+ * for local checkout; anything else, or a page that cannot be read, yields
282
+ * undefined and the authorization goes out without one (a merchant, category
283
+ * or place rule then refuses it as unknown, never the request itself).
284
+ */
285
+ async function pageOriginOf(readDocumentUrl) {
286
+ try {
287
+ const url = new URL(await readDocumentUrl());
288
+ if (url.username || url.password)
289
+ return undefined;
290
+ if (url.protocol === 'https:' || (url.protocol === 'http:' && url.hostname === 'localhost'))
291
+ return url.origin;
292
+ return undefined;
293
+ }
294
+ catch {
295
+ return undefined;
296
+ }
297
+ }
298
+ /**
299
+ * The page total, the lowest amount authority, through the integrator's own
300
+ * reader (AttachOptions.pageAmount): an integer in the smallest unit with its
301
+ * currency, or nothing. A read is a courtesy, never a wait: it gets one
302
+ * second, and anything unreadable yields undefined so the authorization goes
303
+ * out without a page total.
304
+ */
305
+ const PAGE_AMOUNT_READ_MS = 1_000;
306
+ async function pageAmountOf(opts) {
307
+ if (!opts.pageAmount)
308
+ return undefined;
309
+ let timer;
310
+ try {
311
+ const raw = await Promise.race([
312
+ Promise.resolve().then(() => opts.pageAmount()).catch(() => undefined),
313
+ new Promise((resolve) => { timer = setTimeout(() => resolve(undefined), PAGE_AMOUNT_READ_MS); }),
314
+ ]);
315
+ if (!raw || typeof raw !== 'object')
316
+ return undefined;
317
+ const { amount, currency } = raw;
318
+ if (!Number.isSafeInteger(amount) || amount < 0 || typeof currency !== 'string' || !/^[A-Za-z]{3}$/.test(currency))
319
+ return undefined;
320
+ return { amount: amount, currency: currency.toLowerCase() };
321
+ }
322
+ catch {
323
+ return undefined;
324
+ }
325
+ finally {
326
+ if (timer)
327
+ clearTimeout(timer);
328
+ }
329
+ }
277
330
  /**
278
331
  * Last-resort patterns: the built-in recognizers' hosts, derived the same way
279
332
  * as everything else. Used only when the vault hands back nothing at all (a
@@ -335,13 +388,14 @@ export async function attachToCdp(cdp, pageSessionId, opts) {
335
388
  let setupFailureReported = false;
336
389
  const lifecycle = new CheckoutLifecycle(opts);
337
390
  let preparationFrameId;
338
- const preparationGate = new PreparationGate(opts, lifecycle, async () => {
391
+ const readDocumentUrl = async () => {
339
392
  const tree = await cdp.send('Page.getFrameTree', {}, pageSessionId);
340
393
  if (typeof tree?.frameTree?.frame?.url !== 'string')
341
394
  throw new Error('merchant_document_unavailable');
342
395
  preparationFrameId = tree.frameTree.frame.id;
343
396
  return tree.frameTree.frame.url;
344
- });
397
+ };
398
+ const preparationGate = new PreparationGate(opts, lifecycle, readDocumentUrl);
345
399
  const guards = paymentEndpointGuards(opts.paymentEndpoints);
346
400
  const armed = new Set();
347
401
  const arming = new Map();
@@ -533,14 +587,17 @@ export async function attachToCdp(cdp, pageSessionId, opts) {
533
587
  activeRequest = { attempt, networkId, sessionId, frameId };
534
588
  if (preparation)
535
589
  await preparationGate.assertDocument();
590
+ const pageOrigin = await pageOriginOf(readDocumentUrl);
591
+ const pageAmount = await pageAmountOf(opts);
536
592
  attempt.assertLive();
537
593
  const replay = await opts.vault.authorize({
538
594
  user: opts.user,
539
595
  merchant: opts.merchant,
540
596
  amount: opts.amount,
541
- amountCents: opts.amountCents,
542
597
  currency: opts.currency,
543
598
  cardId: preparation?.cardId ?? opts.cardId,
599
+ pageOrigin,
600
+ pageAmount,
544
601
  preparation,
545
602
  timeoutMs: opts.timeoutMs,
546
603
  signal: lifecycle.abort.signal,
@@ -675,11 +732,12 @@ export async function attachToPlaywright(page, opts) {
675
732
  throw new Error('Service workers are active; use a checkout context created with serviceWorkers: "block".');
676
733
  }
677
734
  const lifecycle = new CheckoutLifecycle(opts);
678
- const preparationGate = new PreparationGate(opts, lifecycle, async () => {
735
+ const readDocumentUrl = async () => {
679
736
  if (page.isClosed?.() || typeof page.url !== 'function')
680
737
  throw new Error('merchant_document_unavailable');
681
738
  return page.url();
682
- });
739
+ };
740
+ const preparationGate = new PreparationGate(opts, lifecycle, readDocumentUrl);
683
741
  const guards = paymentEndpointGuards(opts.paymentEndpoints);
684
742
  // Playwright's own routing, NOT a hand-rolled CDP session.
685
743
  //
@@ -800,14 +858,17 @@ export async function attachToPlaywright(page, opts) {
800
858
  activeRequest = { request, frames, attempt };
801
859
  if (preparation)
802
860
  await preparationGate.assertDocument();
861
+ const pageOrigin = await pageOriginOf(readDocumentUrl);
862
+ const pageAmount = await pageAmountOf(opts);
803
863
  assertRequestLive();
804
864
  const replay = await opts.vault.authorize({
805
865
  user: opts.user,
806
866
  merchant: opts.merchant,
807
867
  amount: opts.amount,
808
- amountCents: opts.amountCents,
809
868
  currency: opts.currency,
810
869
  cardId: preparation?.cardId ?? opts.cardId,
870
+ pageOrigin,
871
+ pageAmount,
811
872
  preparation,
812
873
  timeoutMs: opts.timeoutMs,
813
874
  signal: lifecycle.abort.signal,
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
  /**
@@ -108,7 +118,8 @@ export interface PrepareCheckoutOptions {
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;
@@ -127,12 +138,14 @@ export interface PreparedCheckout {
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
@@ -6,7 +6,13 @@ import { matchesPreparedRequest, validPreparationEnvironment } from './prepared-
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
  }
@@ -317,7 +417,7 @@ export class VaultClient {
317
417
  const fail = (reason, id = null) => new CheckoutPreparationError(id, reason);
318
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,4 +1,4 @@
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
4
  export { CheckoutAttachmentError } from './attachment.js';
package/dist/index.js CHANGED
@@ -1,4 +1,4 @@
1
- export { VaultClient, CardEncryptedError, UnsupportedModeError, ApprovalTimeoutError, ApprovalDeclinedError, AmountMismatchError, IntentNotConfirmableError, ProcessorRefusedError, CheckoutApiError, redactUrl, SUPPORTED_MODES, PaymentOutcomeUnknownError, CheckoutCancelledError, 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
3
  export { CheckoutAttachmentError } from './attachment.js';
4
4
  export { substituteEncryptedFields, SubstitutionError } from './substitute.js';
@@ -1,4 +1,5 @@
1
1
  import { CheckoutPreparationError } from './client.js';
2
+ import { validAmountInput } from './client.js';
2
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 {
@@ -39,7 +40,7 @@ export class PreparationGate {
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 => {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@agent-cards/checkout",
3
- "version": "0.6.0",
3
+ "version": "0.7.0",
4
4
  "description": "Let browser agents pay with the user's own card, without your infrastructure ever touching card data.",
5
5
  "type": "module",
6
6
  "main": "dist/index.js",