@agent-cards/checkout 0.4.0 → 0.5.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,18 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.5.0
4
+
5
+ - Prepare Braintree card checkout with `controller.prepare({ psp: 'braintree', environment: 'production' | 'sandbox' })` before the merchant's first Pay action. The cardholder approves and unlocks first; one fresh native request then uses that approval without another notification. The matching API and Vault release is required.
6
+ - Let native Braintree client-configuration queries pass without consuming consent. Prepared checkout accepts one card-tokenization mutation for the approved processor and environment. Other operations, ambiguous GraphQL payloads and legacy REST fallback cannot use the preparation.
7
+ - Keep the native request timeout and one-use, card, merchant, amount, currency and document bindings. Approval readiness lasts at most 30 seconds. Tokenization amounts remain display-only; a token does not establish a paid order.
8
+ - Add a real Chromium fixture with approval delayed 61 seconds before a fresh 60-second XHR, plus cancellation, request classification and binding regressions. The isolated fixture contacts no payment processor and is not live purchase proof.
9
+
10
+ ## 0.4.1
11
+
12
+ - Stop describing every processor request rejection as a card decline or proof that nothing was charged. `ProcessorRefusedError` keeps its existing class and code, with neutral wording and an optional `processorError` containing bounded Razorpay reason, source, step and payment/order identifiers from the matching API/Vault release. Older failure records cannot recover details that were not retained.
13
+ - Hold further card requests within the attachment after a processor refusal, including when the merchant retries after the approval cooldown. Reconcile the merchant payment; only an explicit `retryAfterMerchantFailure({ status: 'failed' })` releases this hold and permits an immediate new attempt. This also applies to clear card declines. Ordinary user declines keep their approval cooldown. Do not automatically replace the attachment to bypass reconciliation.
14
+ - Add deterministic refusal, diagnostic sanitization and retry-guard regressions. These tests do not establish a successful Razorpay purchase or processor capture.
15
+
3
16
  ## 0.4.0
4
17
 
5
18
  - Add `controller.prepare({ psp: 'square', environment: 'production' | 'sandbox' })` to both browser adapters. The caller awaits cardholder consent and unlock before starting its first native Pay action. This requires the matching preparation API and Vault deployment.
package/README.md CHANGED
@@ -82,7 +82,7 @@ pair is shown and reported (`amountAuthority: 'display_only'`), not enforced.
82
82
  Then let your agent click "Pay" like it always does. `attachToCdp` pauses the
83
83
  request for approval and resumes it only while the merchant request remains
84
84
  live. Merchant timeouts still apply: Square's observed tokenization deadline
85
- is about 10 seconds for approval and token handoff. For human approval, use
85
+ is about 10 seconds, and Braintree's native request timeout is 60 seconds, including approval and token handoff. For human approval, use
86
86
  `controller.prepare()` before the first Pay action as shown below.
87
87
 
88
88
  Playwright:
@@ -130,7 +130,7 @@ Coverage is specific to the processor request format, merchant setup, browser tr
130
130
  |---|---|
131
131
  | Shopify | supported, verified end to end |
132
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 |
133
- | Braintree / PayPal | supported, verified end to end |
133
+ | Braintree card tokenization | prepared checkout supported; production paid-order validation pending |
134
134
  | Checkout.com | supported |
135
135
  | 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 |
136
136
  | Adyen | supported (mode `cse`): the vault encrypts the card for Adyen on the cardholder's device and your browser sends it |
@@ -248,11 +248,19 @@ one. `amountAuthority` on every replay is `stripe_payment_intent`,
248
248
  refused to replay a confirm at it. An `ApprovalDeclinedError` with
249
249
  `code: 'intent_not_confirmable'`. Deliberately not "nothing was charged":
250
250
  check the intent at Stripe before retrying.
251
- - `ProcessorRefusedError`: the cardholder's device sent the card and the
252
- processor refused it outright (`pspErrorCode` is the processor's own code,
253
- such as Stripe's `card_declined`). Nothing was charged. An
254
- `ApprovalDeclinedError` with `code: 'processor_refused'`; the page's next
255
- attempt raises a fresh approval where the person can pick another card.
251
+ - `ProcessorRefusedError`: the cardholder's device reported a processor
252
+ request rejection. `pspErrorCode` carries the processor's code; optional
253
+ `processorError` carries bounded Razorpay reason, source, step and payment/order
254
+ identifiers when the API has them. A generic code such as `BAD_REQUEST_ERROR`
255
+ does not establish an issuer decline or prove no money moved. Reconcile the
256
+ merchant payment before retrying. This remains an `ApprovalDeclinedError`
257
+ with `code: 'processor_refused'` for compatibility.
258
+ The attachment records `status: 'declined', reason: 'processor_refused'`
259
+ and holds further card requests. After confirming merchant failure, call
260
+ `retryAfterMerchantFailure({ status: 'failed' })` to permit a deliberate new
261
+ attempt immediately, without waiting for the user-decline cooldown. Do not
262
+ automatically create a new attachment after this error;
263
+ the guard applies only within the existing attachment.
256
264
  - `CheckoutApiError` with `code === 'amount_unverifiable'`: Stripe could not
257
265
  be asked (502; the SDK retries twice, 500ms then 1500ms, before throwing)
258
266
  or the paused request lacked its client secret or publishable key (400).
@@ -392,7 +400,7 @@ do not reuse that token in a new attachment as a workaround. Configuration and
392
400
  unsupported-mode failures require fixing the integration. Bank flows requiring
393
401
  another confirmation and other stored-token chains remain unverified.
394
402
 
395
- Square's observed native tokenization request expires after about 10 seconds. SDK 0.4.0 adds approval before submission, requiring the matching preparation API and Vault deployment. Start human approval before the caller's first Pay action:
403
+ Prepare a Square or Braintree checkout before the first Pay action so the cardholder can approve before the native card request starts. The API and Vault deployments must support the selected processor:
396
404
 
397
405
  ```ts
398
406
  const checkout = await attachToPlaywright(page, {
@@ -401,19 +409,21 @@ const checkout = await attachToPlaywright(page, {
401
409
  onApprovalUrl: deliverPrivatelyToCardholder,
402
410
  });
403
411
  const preparation = await checkout.prepare({
404
- psp: 'square',
405
- environment: 'production', // explicit; use 'sandbox' for Square Sandbox
412
+ psp: 'braintree', // use 'square' for Square
413
+ environment: 'production', // explicit; use 'sandbox' for the processor's sandbox
406
414
  });
407
415
  // The cardholder has consented and unlocked the same approval document.
408
416
  // No processor request or payment has started.
409
417
  await page.getByRole('button', { name: 'Pay', exact: true }).click();
410
418
  ```
411
419
 
412
- `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 and Square environment bind one fresh request. The amount remains `display_only`; a Square token does not enforce the merchant's eventual charge amount.
420
+ `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.
413
421
 
414
- Readiness lasts up to 30 seconds (`preparation.expiresAt`) and appears as `ready_to_submit`, with `paymentStatus: 'not_started'`. Trigger the caller-owned Pay action immediately after the promise resolves. Expiry, navigation, cancellation, an early request or a changed checkout fails closed. A preparation and its attachment are single use; reconcile any bound authorization before creating a new attachment. The SDK never clicks Pay, reuses a stale request, pauses Square timers, or automatically retries a failed prepared checkout.
422
+ Braintree's native `ClientConfiguration` GraphQL query can run before, during or after preparation without using the approval. Only a single `TokenizeCreditCard` mutation can consume the prepared Braintree checkout. Prepared requests require guest card tokenization with explicit `options.validate: false`; omitted validation options, saved-card fields and `validate: true` are refused. Other GraphQL operations, batches and compound mutations are blocked. Braintree's legacy REST fallback cannot consume a prepared authorization.
415
423
 
416
- After Pay, Square's native deadline still covers fresh authorization binding, device replay, relay and token handoff. A disconnected/backgrounded phone or slow transport can still miss it. A subsequent SCA challenge has its own lifetime after token handoff. If the merchant request aborts or its frame closes, the attachment blocks further requests and tries to retire the pre-replay authorization; a started replay or unconfirmed cancellation remains unknown. Without `prepare()`, approval loading and human interaction still share the native deadline, so delayed approval cannot finish that request.
424
+ Readiness lasts up to 30 seconds (`preparation.expiresAt`) and appears as `ready_to_submit`, with `paymentStatus: 'not_started'`. Trigger the caller-owned Pay action immediately after the promise resolves. Expiry, navigation, cancellation, an early request or a changed checkout fails closed. A preparation and its attachment are single use; reconcile any bound authorization before creating a new attachment. The SDK never clicks Pay, reuses a stale request, changes native request deadlines, or automatically retries a failed prepared checkout.
425
+
426
+ After Pay, the processor's native deadline still covers fresh authorization binding, device replay and token handoff. A disconnected/backgrounded phone or slow transport can still miss it. A subsequent SCA challenge has its own lifetime after token handoff. If the merchant request aborts or its frame closes, the attachment blocks further requests and tries to retire the pre-replay authorization; a started replay or unconfirmed cancellation remains unknown. Without `prepare()`, approval loading and human interaction still share the native deadline, so delayed approval cannot finish that request.
417
427
 
418
428
  Lost authorization polling, local approval timeouts, or interrupted browser
419
429
  handoffs produce `outcome_unknown` and block automatic retry. The thrown
@@ -0,0 +1,8 @@
1
+ /** Classify native Braintree GraphQL requests without changing their bytes. */
2
+ export type BraintreeEnvironment = 'production' | 'sandbox';
3
+ export type BraintreeRequestKind = 'configuration' | 'tokenization' | 'invalid';
4
+ export declare function braintreeEnvironment(url: string): BraintreeEnvironment | undefined;
5
+ /** Undefined belongs to another processor; invalid Braintree requests stay blocked. */
6
+ export declare function classifyBraintreeRequest(url: string, method: string, body: string | null): BraintreeRequestKind | undefined;
7
+ /** Preparation accepts guest tokenization only; ordinary interception stays broader. */
8
+ export declare function isPreparedBraintreeRequest(body: string | null): boolean;
@@ -0,0 +1,290 @@
1
+ const PRODUCTION = 'https://payments.braintree-api.com/graphql';
2
+ const SANDBOX = 'https://payments.sandbox.braintree-api.com/graphql';
3
+ const MAX_BODY_LENGTH = 128 * 1024;
4
+ const MAX_DEPTH = 32;
5
+ const MAX_NODES = 10_000;
6
+ export function braintreeEnvironment(url) {
7
+ return url === PRODUCTION ? 'production' : url === SANDBOX ? 'sandbox' : undefined;
8
+ }
9
+ function invalid() { throw new Error('invalid_braintree_request'); }
10
+ function record(value) {
11
+ return value !== null && typeof value === 'object' && !Array.isArray(value);
12
+ }
13
+ /** JSON.parse discards duplicate keys, so validate keys before using any value. */
14
+ class JsonReader {
15
+ text;
16
+ at = 0;
17
+ nodes = 0;
18
+ constructor(text) {
19
+ this.text = text;
20
+ }
21
+ read() {
22
+ const value = this.value(0);
23
+ this.space();
24
+ if (this.at !== this.text.length)
25
+ invalid();
26
+ return value;
27
+ }
28
+ space() {
29
+ while (' \t\r\n'.includes(this.text[this.at] ?? '\0'))
30
+ this.at++;
31
+ }
32
+ take(expected) {
33
+ this.space();
34
+ if (this.text[this.at++] !== expected)
35
+ invalid();
36
+ }
37
+ string() {
38
+ this.space();
39
+ const start = this.at;
40
+ if (this.text[this.at++] !== '"')
41
+ invalid();
42
+ while (this.at < this.text.length) {
43
+ const char = this.text[this.at++];
44
+ if (char === '\\') {
45
+ this.at++;
46
+ continue;
47
+ }
48
+ if (char === '"')
49
+ return JSON.parse(this.text.slice(start, this.at));
50
+ }
51
+ return invalid();
52
+ }
53
+ value(depth) {
54
+ if (depth > MAX_DEPTH || ++this.nodes > MAX_NODES)
55
+ invalid();
56
+ this.space();
57
+ const char = this.text[this.at];
58
+ if (char === '"')
59
+ return this.string();
60
+ if (char === '{') {
61
+ this.at++;
62
+ const result = Object.create(null);
63
+ const keys = new Set();
64
+ this.space();
65
+ if (this.text[this.at] === '}') {
66
+ this.at++;
67
+ return result;
68
+ }
69
+ while (true) {
70
+ const key = this.string();
71
+ if (keys.has(key))
72
+ invalid();
73
+ keys.add(key);
74
+ this.take(':');
75
+ result[key] = this.value(depth + 1);
76
+ this.space();
77
+ const next = this.text[this.at++];
78
+ if (next === '}')
79
+ return result;
80
+ if (next !== ',')
81
+ invalid();
82
+ }
83
+ }
84
+ if (char === '[') {
85
+ this.at++;
86
+ const result = [];
87
+ this.space();
88
+ if (this.text[this.at] === ']') {
89
+ this.at++;
90
+ return result;
91
+ }
92
+ while (true) {
93
+ result.push(this.value(depth + 1));
94
+ this.space();
95
+ const next = this.text[this.at++];
96
+ if (next === ']')
97
+ return result;
98
+ if (next !== ',')
99
+ invalid();
100
+ }
101
+ }
102
+ for (const [literal, value] of [['true', true], ['false', false], ['null', null]]) {
103
+ if (this.text.startsWith(literal, this.at)) {
104
+ this.at += literal.length;
105
+ return value;
106
+ }
107
+ }
108
+ const number = /^-?(?:0|[1-9][0-9]*)(?:\.[0-9]+)?(?:[eE][+-]?[0-9]+)?/.exec(this.text.slice(this.at));
109
+ if (!number)
110
+ invalid();
111
+ this.at += number[0].length;
112
+ const value = Number(number[0]);
113
+ if (!Number.isFinite(value))
114
+ invalid();
115
+ return value;
116
+ }
117
+ }
118
+ /** Native operations need names, punctuation and variables, never literals. */
119
+ function lex(query) {
120
+ const tokens = [];
121
+ let at = 0;
122
+ while (at < query.length) {
123
+ const char = query[at];
124
+ if (' \t\r\n,'.includes(char)) {
125
+ at++;
126
+ continue;
127
+ }
128
+ if ('!$():{}'.includes(char)) {
129
+ tokens.push(char);
130
+ at++;
131
+ }
132
+ else if (/[A-Za-z_]/.test(char)) {
133
+ const start = at++;
134
+ while (at < query.length && /[A-Za-z_0-9]/.test(query[at]))
135
+ at++;
136
+ tokens.push(query.slice(start, at));
137
+ }
138
+ else
139
+ invalid(); // Includes comments, strings, fragments and directives.
140
+ if (tokens.length > MAX_NODES)
141
+ invalid();
142
+ }
143
+ return tokens;
144
+ }
145
+ class GraphqlReader {
146
+ tokens;
147
+ at = 0;
148
+ constructor(tokens) {
149
+ this.tokens = tokens;
150
+ }
151
+ peek() { return this.tokens[this.at]; }
152
+ take(expected) {
153
+ if (this.tokens[this.at++] !== expected)
154
+ invalid();
155
+ }
156
+ name() {
157
+ const value = this.tokens[this.at++];
158
+ if (!value || !/^[A-Za-z_][A-Za-z_0-9]*$/.test(value))
159
+ invalid();
160
+ return value;
161
+ }
162
+ read() {
163
+ const kind = this.name();
164
+ const name = this.name();
165
+ let inputDefinition = false;
166
+ if (this.peek() === '(') {
167
+ // No defaults, alternate types, unused variables or duplicate definitions.
168
+ for (const token of ['(', '$', 'input', ':', 'TokenizeCreditCardInput', '!', ')'])
169
+ this.take(token);
170
+ inputDefinition = true;
171
+ }
172
+ const fields = this.selection(0);
173
+ if (this.at !== this.tokens.length)
174
+ invalid();
175
+ return { kind, name, inputDefinition, fields };
176
+ }
177
+ selection(depth) {
178
+ if (depth > MAX_DEPTH)
179
+ invalid();
180
+ this.take('{');
181
+ const fields = [];
182
+ const names = new Set();
183
+ while (this.peek() !== '}') {
184
+ const name = this.name();
185
+ if (names.has(name))
186
+ invalid();
187
+ names.add(name);
188
+ const args = [];
189
+ if (this.peek() === '(') {
190
+ if (depth !== 0)
191
+ invalid();
192
+ this.take('(');
193
+ const argument = this.name();
194
+ this.take(':');
195
+ this.take('$');
196
+ args.push({ name: argument, variable: this.name() });
197
+ this.take(')');
198
+ }
199
+ const children = this.peek() === '{' ? this.selection(depth + 1) : [];
200
+ fields.push({ name, arguments: args, fields: children });
201
+ // Aliases, directives and other punctuation cannot begin the next field.
202
+ }
203
+ this.take('}');
204
+ if (fields.length === 0)
205
+ invalid();
206
+ return fields;
207
+ }
208
+ }
209
+ /** Undefined belongs to another processor; invalid Braintree requests stay blocked. */
210
+ export function classifyBraintreeRequest(url, method, body) {
211
+ let hostname;
212
+ try {
213
+ hostname = new URL(url).hostname;
214
+ }
215
+ catch {
216
+ return undefined;
217
+ }
218
+ if (hostname !== 'payments.braintree-api.com' && hostname !== 'payments.sandbox.braintree-api.com')
219
+ return undefined;
220
+ if (!braintreeEnvironment(url) || method !== 'POST' || typeof body !== 'string' || body.length === 0 || body.length > MAX_BODY_LENGTH)
221
+ return 'invalid';
222
+ try {
223
+ const envelope = new JsonReader(body).read();
224
+ if (!record(envelope) || typeof envelope.query !== 'string' || typeof envelope.operationName !== 'string')
225
+ return 'invalid';
226
+ if (Object.keys(envelope).some(key => !['query', 'operationName', 'variables', 'clientSdkMetadata'].includes(key)))
227
+ return 'invalid';
228
+ if (envelope.clientSdkMetadata !== undefined && !record(envelope.clientSdkMetadata))
229
+ return 'invalid';
230
+ const operation = new GraphqlReader(lex(envelope.query)).read();
231
+ if (operation.name !== envelope.operationName || operation.fields.length !== 1)
232
+ return 'invalid';
233
+ const root = operation.fields[0];
234
+ if (root.fields.length === 0)
235
+ return 'invalid';
236
+ const variables = envelope.variables === undefined ? Object.create(null) : envelope.variables;
237
+ if (!record(variables))
238
+ return 'invalid';
239
+ if (operation.kind === 'query' && ['ClientConfiguration', 'ClientConfigurationQuery'].includes(operation.name)
240
+ && !operation.inputDefinition && root.name === 'clientConfiguration' && root.arguments.length === 0
241
+ && Object.keys(variables).length === 0)
242
+ return 'configuration';
243
+ if (operation.kind !== 'mutation' || operation.name !== 'TokenizeCreditCard' || !operation.inputDefinition
244
+ || root.name !== 'tokenizeCreditCard' || root.arguments.length !== 1
245
+ || root.arguments[0].name !== 'input' || root.arguments[0].variable !== 'input'
246
+ || Object.keys(variables).length !== 1 || !record(variables.input) || !record(variables.input.creditCard))
247
+ return 'invalid';
248
+ const card = variables.input.creditCard;
249
+ if (!['number', 'expirationMonth', 'expirationYear'].every(key => typeof card[key] === 'string' && card[key].trim().length > 0))
250
+ return 'invalid';
251
+ if (card.cvv !== undefined && (typeof card.cvv !== 'string' || card.cvv.trim().length === 0))
252
+ return 'invalid';
253
+ return 'tokenization';
254
+ }
255
+ catch {
256
+ return 'invalid';
257
+ }
258
+ }
259
+ /** Preparation accepts guest tokenization only; ordinary interception stays broader. */
260
+ export function isPreparedBraintreeRequest(body) {
261
+ if (classifyBraintreeRequest(PRODUCTION, 'POST', body) !== 'tokenization')
262
+ return false;
263
+ try {
264
+ const envelope = new JsonReader(body).read();
265
+ const variables = envelope.variables;
266
+ const input = variables.input;
267
+ if (Object.keys(input).some(key => key !== 'creditCard' && key !== 'options'))
268
+ return false;
269
+ const card = input.creditCard;
270
+ if (Object.keys(card).some(key => !['number', 'expirationMonth', 'expirationYear', 'cvv', 'cardholderName', 'billingAddress'].includes(key)))
271
+ return false;
272
+ if (Object.entries(card).some(([key, value]) => key !== 'billingAddress' && typeof value !== 'string'))
273
+ return false;
274
+ if (card.billingAddress !== undefined) {
275
+ if (!record(card.billingAddress))
276
+ return false;
277
+ const addressKeys = ['postalCode', 'firstName', 'lastName', 'company', 'streetAddress', 'extendedAddress',
278
+ 'locality', 'region', 'countryCodeNumeric', 'countryCodeAlpha2', 'countryCodeAlpha3', 'countryName'];
279
+ if (Object.entries(card.billingAddress).some(([key, value]) => !addressKeys.includes(key) || typeof value !== 'string'))
280
+ return false;
281
+ }
282
+ // Fingerprint authorization defaults validation on when the option is absent.
283
+ // Preparation permits only an explicit request for a transient card token.
284
+ return record(input.options) && Object.keys(input.options).every(key => key === 'validate')
285
+ && input.options.validate === false;
286
+ }
287
+ catch {
288
+ return false;
289
+ }
290
+ }
package/dist/cdp.js CHANGED
@@ -1,6 +1,7 @@
1
1
  import { BUILTIN_REGISTRY, cardUrlPatterns } from './registry.js';
2
- import { ApprovalDeclinedError, ApprovalTimeoutError, CardEncryptedError, CheckoutApiError, PaymentOutcomeUnknownError, UnsupportedModeError, redactUrl, } from './client.js';
2
+ import { ApprovalDeclinedError, ApprovalTimeoutError, CardEncryptedError, CheckoutApiError, PaymentOutcomeUnknownError, ProcessorRefusedError, UnsupportedModeError, redactUrl, } from './client.js';
3
3
  import { PreparationGate } from './preparation.js';
4
+ import { classifyBraintreeRequest } from './braintree.js';
4
5
  import { substituteEncryptedFields } from './substitute.js';
5
6
  import { hostedFormSubmittedPage } from './hosted-form.js';
6
7
  import { CheckoutLifecycle, paymentEndpointGuards } from './lifecycle.js';
@@ -80,6 +81,11 @@ const APPROVAL_COOLDOWN_MS = 5_000;
80
81
  * quiet window absorbs the burst; the next request past it is judged afresh.
81
82
  */
82
83
  function isApprovalOutcome(err) {
84
+ // Processor refusals are held by the lifecycle until explicit merchant
85
+ // reconciliation and retry. A second timer would outlive that release and
86
+ // reject the application's deliberate retry even after confirmed failure.
87
+ if (err instanceof ProcessorRefusedError)
88
+ return false;
83
89
  if (err instanceof CheckoutApiError)
84
90
  return err.code === 'duplicate_submission';
85
91
  return err instanceof ApprovalDeclinedError || err instanceof ApprovalTimeoutError;
@@ -98,7 +104,9 @@ function isApprovalOutcome(err) {
98
104
  * a misconfiguration or an unsupported PSP. Nothing a person does changes
99
105
  * those, so asking again is pure waste.
100
106
  *
101
- * Everything else stays retryable. A 5xx or a 429 clears on its own, and a
107
+ * Other errors may be retryable unless the lifecycle holds the attachment
108
+ * for merchant reconciliation, as it does after a processor refusal.
109
+ * A 5xx or a 429 clears on its own, and a
102
110
  * decline or a timeout is answered by the cooldown above rather than by
103
111
  * killing the page: the person said no to one authorization, not to every
104
112
  * checkout they will ever make in this session. A 409 duplicate_submission
@@ -406,6 +414,19 @@ export async function attachToCdp(cdp, pageSessionId, opts) {
406
414
  if (method !== 'Fetch.requestPaused')
407
415
  return;
408
416
  const { requestId, request, resourceType, networkId, frameId } = params;
417
+ // Braintree shares /graphql between configuration and card mutations.
418
+ // Classify before URL-only recognition or claiming a prepared request.
419
+ const braintree = request.method.toUpperCase() === 'POST'
420
+ ? classifyBraintreeRequest(request.url, request.method, pausedBody(request)) : undefined;
421
+ if (braintree === 'configuration') {
422
+ await cdp.send('Fetch.continueRequest', { requestId }, sessionId).catch(() => { });
423
+ return;
424
+ }
425
+ if (braintree === 'invalid') {
426
+ opts.onEvent?.({ type: 'blocked', detail: 'unsupported_braintree_graphql_operation' });
427
+ await cdp.send('Fetch.failRequest', { requestId, errorReason: 'Aborted' }, sessionId).catch(() => { });
428
+ return;
429
+ }
409
430
  if (!opts.vault.isCardRequest(request.url, request.method)) {
410
431
  if (guards.matches(request.url, request.method)) {
411
432
  preparationGate.invalidate('unsupported_checkout');
@@ -419,7 +440,7 @@ export async function attachToCdp(cdp, pageSessionId, opts) {
419
440
  }
420
441
  let preparation;
421
442
  try {
422
- preparation = preparationGate.claim(request.url);
443
+ preparation = preparationGate.claim(request.url, braintree === 'tokenization' ? pausedBody(request) : undefined);
423
444
  }
424
445
  catch (error) {
425
446
  opts.onEvent?.({ type: 'blocked', detail: failureSummary(error) });
@@ -639,6 +660,14 @@ export async function attachToPlaywright(page, opts) {
639
660
  let lastSubmitted = null;
640
661
  await page.route((url) => opts.vault.isCardRequest(url.toString()) || guards.matches(url.toString()), async (route) => {
641
662
  const request = route.request();
663
+ const braintree = request.method().toUpperCase() === 'POST'
664
+ ? classifyBraintreeRequest(request.url(), request.method(), request.postData() ?? '') : undefined;
665
+ if (braintree === 'configuration')
666
+ return route.fallback();
667
+ if (braintree === 'invalid') {
668
+ opts.onEvent?.({ type: 'blocked', detail: 'unsupported_braintree_graphql_operation' });
669
+ return route.abort('aborted');
670
+ }
642
671
  // The matcher only sees the URL; a preflight or a GET must pass through
643
672
  // untouched or the browser's CORS check fails on our synthetic answer.
644
673
  if (!opts.vault.isCardRequest(request.url(), request.method())) {
@@ -652,7 +681,7 @@ export async function attachToPlaywright(page, opts) {
652
681
  }
653
682
  let preparation;
654
683
  try {
655
- preparation = preparationGate.claim(request.url());
684
+ preparation = preparationGate.claim(request.url(), braintree === 'tokenization' ? request.postData() ?? '' : undefined);
656
685
  }
657
686
  catch (error) {
658
687
  opts.onEvent?.({ type: 'blocked', detail: failureSummary(error) });
package/dist/client.d.ts CHANGED
@@ -1,5 +1,6 @@
1
1
  import { type CheckoutMode, type Recognizer } from './registry.js';
2
2
  import type { Substitutions } from './substitute.js';
3
+ import { type PreparationProcessor } from './prepared-processor.js';
3
4
  export interface PausedRequest {
4
5
  url: string;
5
6
  method: string;
@@ -99,7 +100,7 @@ export interface HostedFormReplay {
99
100
  /** What authorize() resolves with; branch on `mode` (absent means token). */
100
101
  export type ReplayResponse = TokenReplay | CseReplay | HostedFormReplay;
101
102
  export interface PrepareCheckoutOptions {
102
- psp: 'square';
103
+ psp: PreparationProcessor;
103
104
  /** The processor environment, independent of your Agentcard client's mode. */
104
105
  environment: 'production' | 'sandbox';
105
106
  signal?: AbortSignal;
@@ -120,7 +121,7 @@ export interface PrepareCheckoutInput extends PrepareCheckoutOptions {
120
121
  export interface PreparedCheckout {
121
122
  readonly id: string;
122
123
  readonly status: 'ready';
123
- readonly psp: 'square';
124
+ readonly psp: PreparationProcessor;
124
125
  readonly environment: 'production' | 'sandbox';
125
126
  readonly expiresAt: string;
126
127
  readonly cardId: string;
@@ -271,20 +272,27 @@ export declare class IntentNotConfirmableError extends ApprovalDeclinedError {
271
272
  readonly code: "intent_not_confirmable";
272
273
  constructor(authorizationId: string);
273
274
  }
275
+ /** Bounded processor identifiers, never a raw response or free-form description. */
276
+ export interface RazorpayProcessorError {
277
+ reason?: string;
278
+ source?: string;
279
+ step?: string;
280
+ payment_id?: string;
281
+ order_id?: string;
282
+ }
274
283
  /**
275
- * The cardholder's device sent the card and the processor refused it
276
- * outright (a card decline, a bad CVC, an invalid request). Nothing was
277
- * charged; the authorization is `declined` with reason `processor_refused`
278
- * and the processor's own code in `pspErrorCode` (Stripe's `card_declined`,
279
- * `incorrect_cvc`, …). A decline like any other for the adapters: the paused
280
- * request is aborted and the page's retry gets a fresh approval, where the
281
- * person can pick another card.
284
+ * The device reported a processor request rejection. A generic processor
285
+ * code does not establish an issuer decline or prove no money moved. Read
286
+ * the bounded processor evidence and reconcile the merchant before retrying.
287
+ * The authorization remains `declined` with reason `processor_refused` for
288
+ * compatibility; no successful processor response is handed to the merchant.
282
289
  */
283
290
  export declare class ProcessorRefusedError extends ApprovalDeclinedError {
284
291
  authorizationId: string;
285
292
  pspErrorCode: string | null;
286
293
  readonly code: "processor_refused";
287
- constructor(authorizationId: string, pspErrorCode: string | null);
294
+ readonly processorError: RazorpayProcessorError | null;
295
+ constructor(authorizationId: string, pspErrorCode: string | null, processorError?: RazorpayProcessorError | null);
288
296
  }
289
297
  /**
290
298
  * A non-2xx from the Agentcard API, carrying the status so callers can tell a
package/dist/client.js CHANGED
@@ -1,4 +1,5 @@
1
1
  import { BUILTIN_REGISTRY, cardUrlPatterns as deriveCardUrlPatterns, findRecognizer, } from './registry.js';
2
+ import { matchesPreparedRequest } from './prepared-processor.js';
2
3
  /**
3
4
  * The modes this SDK can finish. Asked for on syncRegistry (the API serves
4
5
  * only recognizers in these modes, so a request this build cannot complete
@@ -128,25 +129,49 @@ export class IntentNotConfirmableError extends ApprovalDeclinedError {
128
129
  + 'it cannot be confirmed again. Check the intent at Stripe before retrying.';
129
130
  }
130
131
  }
132
+ function parseProcessorError(value) {
133
+ if (!value || typeof value !== 'object' || Array.isArray(value))
134
+ return null;
135
+ const fields = value;
136
+ const result = {};
137
+ const exact = (pattern, field) => pattern.exec(field)?.[0] === field;
138
+ for (const [key, field] of Object.entries(fields)) {
139
+ if (typeof field !== 'string')
140
+ return null;
141
+ if (key === 'reason' || key === 'source' || key === 'step') {
142
+ if (!exact(/^[a-z][a-z_]{0,63}$/, field))
143
+ return null;
144
+ result[key] = field;
145
+ }
146
+ else if (key === 'payment_id' || key === 'order_id') {
147
+ if (!exact(key === 'payment_id' ? /^pay_[A-Za-z0-9]{1,128}$/ : /^order_[A-Za-z0-9]{1,128}$/, field))
148
+ return null;
149
+ result[key] = field;
150
+ }
151
+ else
152
+ return null;
153
+ }
154
+ return Object.keys(result).length ? result : null;
155
+ }
131
156
  /**
132
- * The cardholder's device sent the card and the processor refused it
133
- * outright (a card decline, a bad CVC, an invalid request). Nothing was
134
- * charged; the authorization is `declined` with reason `processor_refused`
135
- * and the processor's own code in `pspErrorCode` (Stripe's `card_declined`,
136
- * `incorrect_cvc`, …). A decline like any other for the adapters: the paused
137
- * request is aborted and the page's retry gets a fresh approval, where the
138
- * person can pick another card.
157
+ * The device reported a processor request rejection. A generic processor
158
+ * code does not establish an issuer decline or prove no money moved. Read
159
+ * the bounded processor evidence and reconcile the merchant before retrying.
160
+ * The authorization remains `declined` with reason `processor_refused` for
161
+ * compatibility; no successful processor response is handed to the merchant.
139
162
  */
140
163
  export class ProcessorRefusedError extends ApprovalDeclinedError {
141
164
  authorizationId;
142
165
  pspErrorCode;
143
166
  code = 'processor_refused';
144
- constructor(authorizationId, pspErrorCode) {
167
+ processorError;
168
+ constructor(authorizationId, pspErrorCode, processorError = null) {
145
169
  super('processor_refused');
146
170
  this.authorizationId = authorizationId;
147
171
  this.pspErrorCode = pspErrorCode;
148
172
  this.name = 'ProcessorRefusedError';
149
- this.message = `the processor refused the card on ${authorizationId}${pspErrorCode ? ` (${pspErrorCode})` : ''}. Nothing was charged.`;
173
+ this.processorError = parseProcessorError(processorError);
174
+ this.message = `the processor rejected the payment request on ${authorizationId}${pspErrorCode ? ` (${pspErrorCode})` : ''}. Check the merchant payment status before retrying.`;
150
175
  }
151
176
  }
152
177
  /**
@@ -290,7 +315,7 @@ export class VaultClient {
290
315
  async prepareCheckout(input) {
291
316
  input = { ...input };
292
317
  const fail = (reason, id = null) => new CheckoutPreparationError(id, reason);
293
- if (input.psp !== 'square' || !['production', 'sandbox'].includes(input.environment))
318
+ if (!['square', 'braintree'].includes(input.psp) || !['production', 'sandbox'].includes(input.environment))
294
319
  throw fail('unsupported_processor');
295
320
  if (!Number.isSafeInteger(input.amountCents) || input.amountCents <= 0 || typeof input.currency !== 'string' || !/^[a-z]{3}$/i.test(input.currency))
296
321
  throw fail('amount_required');
@@ -343,7 +368,7 @@ export class VaultClient {
343
368
  || state.payment_status !== 'not_started' || state.amount_authority !== 'display_only'
344
369
  || state.user !== input.user || state.merchant !== input.merchant || state.merchant_origin !== input.merchantOrigin
345
370
  || state.amount_cents !== input.amountCents || state.currency !== input.currency.toLowerCase()
346
- || state.psp !== 'square' || state.mode !== 'token' || state.environment !== input.environment
371
+ || state.psp !== input.psp || state.mode !== 'token' || state.environment !== input.environment
347
372
  || state.checkout_key !== input.checkoutKey)
348
373
  throw fail('ready_unconfirmed', id);
349
374
  const prepared = Object.freeze({
@@ -392,13 +417,11 @@ export class VaultClient {
392
417
  throw new CheckoutPreparationError(preparation.id ?? null, 'already_used_or_foreign');
393
418
  // Consume locally before any await, including OAuth, and never recycle it.
394
419
  this.usedPreparations.add(preparation);
395
- const url = new URL(input.request.url);
396
- const host = preparation.environment === 'production' ? 'pci-connect.squareup.com' : 'pci-connect.squareupsandbox.com';
397
420
  if (Date.parse(preparation.expiresAt) <= Date.now())
398
421
  throw new CheckoutPreparationError(preparation.id, 'expired');
399
422
  if (input.user !== preparation.user || input.merchant !== preparation.merchant || input.amountCents !== preparation.amountCents
400
423
  || input.currency?.toLowerCase() !== preparation.currency || input.cardId !== preparation.cardId
401
- || url.origin !== `https://${host}` || url.pathname !== '/v2/card-nonce' || url.username || url.password)
424
+ || !matchesPreparedRequest(preparation.psp, preparation.environment, input.request.url, input.request.method ?? 'POST', input.request.body))
402
425
  throw new CheckoutPreparationError(preparation.id, 'checkout_changed');
403
426
  }
404
427
  if (input.signal?.aborted)
@@ -413,6 +436,8 @@ export class VaultClient {
413
436
  // clientSideEncrypted entry (an older registry, a hand-built one) still
414
437
  // refuses here, exactly as before.
415
438
  const mode = rec.mode ?? 'token';
439
+ if (preparation && (rec.psp !== preparation.psp || mode !== 'token'))
440
+ throw new CheckoutPreparationError(preparation.id, 'checkout_changed');
416
441
  if (rec.clientSideEncrypted && mode !== 'cse')
417
442
  throw new CardEncryptedError(rec.psp);
418
443
  if (!SUPPORTED_MODES.includes(mode))
@@ -611,7 +636,7 @@ export class VaultClient {
611
636
  if (s.reason === 'intent_not_confirmable')
612
637
  throw new IntentNotConfirmableError(String(created.id));
613
638
  if (s.reason === 'processor_refused') {
614
- throw new ProcessorRefusedError(String(created.id), typeof s.psp_error_code === 'string' ? s.psp_error_code : null);
639
+ 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);
615
640
  }
616
641
  throw new ApprovalDeclinedError(s.reason ?? 'no reason given');
617
642
  }
package/dist/index.d.ts CHANGED
@@ -1,5 +1,5 @@
1
1
  export { VaultClient, CardEncryptedError, UnsupportedModeError, ApprovalTimeoutError, ApprovalDeclinedError, AmountMismatchError, IntentNotConfirmableError, ProcessorRefusedError, CheckoutApiError, redactUrl, SUPPORTED_MODES, PaymentOutcomeUnknownError, CheckoutCancelledError, CheckoutPreparationError, } from './client.js';
2
- export type { PausedRequest, ReplayResponse, TokenReplay, CseReplay, HostedFormReplay, AmountAuthority, AuthorizeInput, VaultClientOptions, PrepareCheckoutOptions, PrepareCheckoutInput, PreparedCheckout, } from './client.js';
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 type { CdpLike, AttachOptions, CorsOutcome } from './cdp.js';
5
5
  export { substituteEncryptedFields, SubstitutionError } from './substitute.js';
package/dist/lifecycle.js CHANGED
@@ -1,4 +1,4 @@
1
- import { ApprovalDeclinedError, ApprovalTimeoutError, CheckoutCancelledError, CheckoutPreparationError, IntentNotConfirmableError, PaymentOutcomeUnknownError } from './client.js';
1
+ import { ApprovalDeclinedError, ApprovalTimeoutError, CheckoutCancelledError, CheckoutPreparationError, IntentNotConfirmableError, PaymentOutcomeUnknownError, ProcessorRefusedError } from './client.js';
2
2
  /** Shared by the raw CDP and Playwright transports. No browser ownership or payment execution lives here. */
3
3
  export class CheckoutLifecycle {
4
4
  options;
@@ -115,7 +115,7 @@ export class CheckoutLifecycle {
115
115
  this.merchantRequestAborted();
116
116
  return;
117
117
  }
118
- const authorizationId = error instanceof PaymentOutcomeUnknownError ? error.authorizationId : this.state.authorizationId;
118
+ const authorizationId = error instanceof PaymentOutcomeUnknownError || error instanceof ProcessorRefusedError ? error.authorizationId : this.state.authorizationId;
119
119
  if (handoffStarted || error instanceof PaymentOutcomeUnknownError || error instanceof IntentNotConfirmableError) {
120
120
  this.held = true;
121
121
  this.set({ ...this.state, authorizationId, status: 'outcome_unknown', reason: error instanceof PaymentOutcomeUnknownError ? error.reason : error instanceof IntentNotConfirmableError ? 'intent_not_confirmable' : 'browser_handoff_failed' });
@@ -129,6 +129,13 @@ export class CheckoutLifecycle {
129
129
  else if (error instanceof ApprovalTimeoutError) {
130
130
  this.set({ ...this.state, status: 'timed_out', reason: 'approval_expired' });
131
131
  }
132
+ else if (error instanceof ProcessorRefusedError) {
133
+ // A rejected processor request is not proof that the merchant cannot
134
+ // collect this payment. Keep native retries held until the application
135
+ // checks the merchant and explicitly starts another attempt.
136
+ this.held = true;
137
+ this.set({ ...this.state, authorizationId, status: 'declined', reason: 'processor_refused' });
138
+ }
132
139
  else if (error instanceof ApprovalDeclinedError) {
133
140
  this.set({ ...this.state, status: 'declined', reason: 'approval_declined' });
134
141
  }
@@ -15,7 +15,7 @@ export declare class PreparationGate {
15
15
  constructor(opts: AttachOptions, lifecycle: CheckoutLifecycle, readDocumentUrl: () => Promise<string>);
16
16
  private prepare;
17
17
  /** Called for every recognized card mutation, before any await or local retry guard. */
18
- claim(requestUrl: string): PreparedCheckout | undefined;
18
+ claim(requestUrl: string, requestBody?: string | null): PreparedCheckout | undefined;
19
19
  assertDocument(): Promise<void>;
20
20
  private readDocument;
21
21
  isEngaged(): boolean;
@@ -1,4 +1,5 @@
1
1
  import { CheckoutPreparationError } from './client.js';
2
+ import { matchesPreparedRequest, preparationEndpoint } from './prepared-processor.js';
2
3
  /** A local, one-use rendezvous. It never starts or retries a merchant request. */
3
4
  export class PreparationGate {
4
5
  opts;
@@ -33,9 +34,9 @@ export class PreparationGate {
33
34
  const onAbort = () => this.invalidate('cancelled');
34
35
  signal.addEventListener('abort', onAbort, { once: true });
35
36
  try {
36
- if (!options || options.psp !== 'square' || !['production', 'sandbox'].includes(options.environment))
37
+ if (!options || !['square', 'braintree'].includes(options.psp) || !['production', 'sandbox'].includes(options.environment))
37
38
  throw new CheckoutPreparationError(null, 'unsupported_processor');
38
- const tokenizer = options.environment === 'production' ? 'https://pci-connect.squareup.com/v2/card-nonce' : 'https://pci-connect.squareupsandbox.com/v2/card-nonce';
39
+ const tokenizer = preparationEndpoint(options.psp, options.environment);
39
40
  if (!this.opts.vault.isCardRequest(tokenizer, 'POST'))
40
41
  throw new CheckoutPreparationError(null, 'processor_interception_unavailable');
41
42
  if (!Number.isSafeInteger(this.opts.amountCents) || (this.opts.amountCents ?? 0) <= 0 || !/^[a-z]{3}$/i.test(this.opts.currency ?? ''))
@@ -62,6 +63,8 @@ export class PreparationGate {
62
63
  },
63
64
  });
64
65
  this.prepared = prepared;
66
+ if (prepared.psp !== options.psp || prepared.environment !== options.environment)
67
+ throw new CheckoutPreparationError(prepared.id, 'checkout_changed');
65
68
  if (signal.aborted || this.state !== 'preparing') {
66
69
  void this.opts.vault.cancelPreparation(prepared.id).catch(() => { });
67
70
  throw new CheckoutPreparationError(prepared.id, 'cancelled');
@@ -86,7 +89,7 @@ export class PreparationGate {
86
89
  // Keep the signal listener after ready: caller cancellation retires the handle too.
87
90
  }
88
91
  /** Called for every recognized card mutation, before any await or local retry guard. */
89
- claim(requestUrl) {
92
+ claim(requestUrl, requestBody) {
90
93
  this.observedRequest = true;
91
94
  if (this.state === 'unused')
92
95
  return undefined;
@@ -95,9 +98,7 @@ export class PreparationGate {
95
98
  throw new CheckoutPreparationError(this.prepared?.id ?? null, 'already_used_or_unavailable');
96
99
  }
97
100
  const prepared = this.prepared;
98
- const request = new URL(requestUrl);
99
- const origin = prepared.environment === 'production' ? 'https://pci-connect.squareup.com' : 'https://pci-connect.squareupsandbox.com';
100
- if (Date.parse(prepared.expiresAt) <= Date.now() || request.origin !== origin || request.pathname !== '/v2/card-nonce' || request.username || request.password) {
101
+ if (Date.parse(prepared.expiresAt) <= Date.now() || !matchesPreparedRequest(prepared.psp, prepared.environment, requestUrl, 'POST', requestBody)) {
101
102
  const reason = Date.parse(prepared.expiresAt) <= Date.now() ? 'expired' : 'checkout_changed';
102
103
  this.invalidate(reason);
103
104
  throw new CheckoutPreparationError(prepared.id, reason);
@@ -0,0 +1,5 @@
1
+ export type PreparationProcessor = 'square' | 'braintree';
2
+ export type PreparationEnvironment = 'production' | 'sandbox';
3
+ export declare function preparationEndpoint(psp: PreparationProcessor, environment: PreparationEnvironment): string;
4
+ /** Processor identity and environment are part of the device's prior consent. */
5
+ export declare function matchesPreparedRequest(psp: PreparationProcessor, environment: PreparationEnvironment, requestUrl: string, method: string, body?: string | null): boolean;
@@ -0,0 +1,25 @@
1
+ import { braintreeEnvironment, isPreparedBraintreeRequest } from './braintree.js';
2
+ export function preparationEndpoint(psp, environment) {
3
+ if (psp === 'braintree')
4
+ return environment === 'production'
5
+ ? 'https://payments.braintree-api.com/graphql' : 'https://payments.sandbox.braintree-api.com/graphql';
6
+ return environment === 'production'
7
+ ? 'https://pci-connect.squareup.com/v2/card-nonce' : 'https://pci-connect.squareupsandbox.com/v2/card-nonce';
8
+ }
9
+ /** Processor identity and environment are part of the device's prior consent. */
10
+ export function matchesPreparedRequest(psp, environment, requestUrl, method, body) {
11
+ if (method.toUpperCase() !== 'POST')
12
+ return false;
13
+ if (psp === 'braintree') {
14
+ return braintreeEnvironment(requestUrl) === environment && isPreparedBraintreeRequest(body ?? null);
15
+ }
16
+ if (psp !== 'square')
17
+ return false;
18
+ try {
19
+ 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;
21
+ }
22
+ catch {
23
+ return false;
24
+ }
25
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@agent-cards/checkout",
3
- "version": "0.4.0",
3
+ "version": "0.5.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",
@@ -23,7 +23,7 @@
23
23
  "_comment_build": "TypeScript is fetched rather than declared as a devDependency ON PURPOSE. This package ships zero dependencies, which is why pnpm writes no importer for it in the workspace lockfile; adding any dep here creates one, and an importer the lockfile has not been regenerated for fails every Vercel build with ERR_PNPM_OUTDATED_LOCKFILE. Pinned so the published output is reproducible.",
24
24
  "build": "npx -y -p typescript@5.9.3 tsc",
25
25
  "prepublishOnly": "pnpm build",
26
- "test": "node test.mjs && node --test lifecycle.test.mjs merchant-abort.test.mjs preparation.test.mjs",
26
+ "test": "node test.mjs && node --test lifecycle.test.mjs merchant-abort.test.mjs preparation.test.mjs braintree.test.mjs minimum-delay.test.mjs",
27
27
  "test:browser": "node browser.test.mjs && node stripe-browser.test.mjs && node preparation-browser.test.mjs"
28
28
  },
29
29
  "keywords": [