@agent-cards/checkout 0.6.0 → 0.8.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/client.d.ts CHANGED
@@ -19,12 +19,28 @@ 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;
32
+ /** How an existing authorization is being handled; omitted by older APIs. */
33
+ export interface ExecutionMetadata {
34
+ executionMode?: 'user_approval' | 'autopilot';
35
+ grantId?: string;
36
+ }
23
37
  /**
24
38
  * The token flow: the cardholder's device called the processor itself, and
25
39
  * this is the processor's answer to replay into the paused request.
26
40
  */
27
- export interface TokenReplay {
41
+ export interface TokenReplay extends ExecutionMetadata {
42
+ /** Present only after validating the explicit owned-shop purchase receipt. */
43
+ shopOrderId?: string;
28
44
  /** Absent on older API versions; always 'token' here. */
29
45
  mode?: 'token';
30
46
  /** The approved authorization (`cauth_…`). */
@@ -41,17 +57,18 @@ export interface TokenReplay {
41
57
  * moved) and Agentcard also sent your server checkout_authorization.amount_mismatch.
42
58
  */
43
59
  amountVerified?: boolean | null;
44
- chargedAmountCents?: number | null;
60
+ /** What the processor collected, an integer in the currency's smallest unit (Stripe's `amount`). */
61
+ chargedAmount?: number | null;
45
62
  chargedCurrency?: string | null;
46
63
  /**
47
- * What `chargedAmountCents` is: 'captured' (the intent succeeded; this is
64
+ * What `chargedAmount` is: 'captured' (the intent succeeded; this is
48
65
  * amount_received), 'authorized' (requires_capture; amount_capturable, the
49
66
  * merchant captures later), 'none' (a PaymentIntent was reported but
50
67
  * nothing is collected yet: processing, requires_action), or null when no
51
68
  * PaymentIntent was reported at all (a tokenization request).
52
69
  */
53
70
  chargedKind?: 'captured' | 'authorized' | 'none' | null;
54
- /** 'stripe_payment_intent' when the amount was held to a Stripe intent; 'display_only' otherwise. */
71
+ /** Who named the amount the person approved. */
55
72
  amountAuthority?: AmountAuthority;
56
73
  }
57
74
  /**
@@ -63,7 +80,7 @@ export interface TokenReplay {
63
80
  * The processor's answer then reaches the page as it normally would; the
64
81
  * merchant's order state is where the outcome shows up.
65
82
  */
66
- export interface CseReplay {
83
+ export interface CseReplay extends ExecutionMetadata {
67
84
  mode: 'cse';
68
85
  authorizationId: string;
69
86
  substitutions: Substitutions;
@@ -86,7 +103,7 @@ export interface CseReplay {
86
103
  * processor evidence on this mode and cannot obtain any. Treat it as "the
87
104
  * person paid, or tried to, on their own device" and confirm with the merchant.
88
105
  */
89
- export interface HostedFormReplay {
106
+ export interface HostedFormReplay extends ExecutionMetadata {
90
107
  mode: 'hosted_form';
91
108
  /** What this resolution is: a device-attested submission, not a processor answer. */
92
109
  kind: 'submitted_on_device';
@@ -108,7 +125,8 @@ export interface PrepareCheckoutOptions {
108
125
  export interface PrepareCheckoutInput extends PrepareCheckoutOptions {
109
126
  user: string;
110
127
  merchant: string;
111
- amountCents: number;
128
+ /** An integer in the currency's smallest unit (2306 for $23.06), or a decimal string in normal units ("23.06"). */
129
+ amount: number | string;
112
130
  currency: string;
113
131
  cardId?: string;
114
132
  merchantOrigin: string;
@@ -127,42 +145,50 @@ export interface PreparedCheckout {
127
145
  readonly cardId: string;
128
146
  readonly user: string;
129
147
  readonly merchant: string;
130
- readonly amountCents: number;
148
+ /** The approved amount, an integer in the currency's smallest unit, as the API confirmed it. */
149
+ readonly amount: number;
150
+ readonly amountDisplay: string | null;
131
151
  readonly currency: string;
132
152
  readonly merchantOrigin: string;
133
153
  readonly checkoutKey: string;
134
154
  readonly paymentStatus: 'not_started';
135
- readonly amountAuthority: 'display_only';
155
+ readonly amountAuthority: 'agent';
136
156
  }
137
157
  export declare class CheckoutPreparationError extends Error {
138
158
  preparationId: string | null;
139
159
  reason: string;
140
160
  constructor(preparationId: string | null, reason: string);
141
161
  }
142
- export interface AuthorizeInput {
162
+ export interface AuthorizeInput extends ExecutionMetadata {
163
+ /** Observed top-level HTTPS merchant origin; a routing hint, never payment authority. */
164
+ merchantOrigin?: string;
143
165
  /** Your identifier for the person whose card should pay. */
144
166
  user: string;
145
- /** Shown to the user on the approval screen. */
167
+ /** Shown to the user on the approval screen. Judged by nothing: the merchant comes from the checkout page. */
146
168
  merchant: string;
147
169
  /**
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.
170
+ * Your hint at the amount: an integer in the currency's smallest unit (2306
171
+ * for $23.06), or a decimal string in normal units ("23.06"), with its ISO
172
+ * 4217 code ("usd"). Optional: the processor's own amount is the higher
173
+ * authority, read from the paused request or from the Stripe intent it
174
+ * names right before the cardholder's device replays, and the company's
175
+ * caps are judged on it. A hint lets a bad purchase be refused the moment
176
+ * you open it, and a hint that disagrees with the processor beyond one
177
+ * smallest unit is refused with nothing charged (an AmountMismatchError;
178
+ * `stage` says which check). Agentcard derives the display string; you
179
+ * never send one. Both or neither: one without the other is refused.
151
180
  */
152
- amount?: string;
181
+ amount?: number | string;
182
+ currency?: string;
153
183
  /**
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.
184
+ * The total read off the checkout page, the lowest authority: used only
185
+ * when neither the processor's request nor your hint names an amount. The
186
+ * adapters fill it from `[data-agentcard-amount]` when a page carries one.
163
187
  */
164
- amountCents?: number;
165
- currency?: string;
188
+ pageAmount?: {
189
+ amount: number;
190
+ currency: string;
191
+ };
166
192
  /**
167
193
  * WHICH stored card should pay — a vault card id from
168
194
  * GET /api/v2/vault_cards. The approval page preselects it (the human can
@@ -171,6 +197,14 @@ export interface AuthorizeInput {
171
197
  * `card_not_found`.
172
198
  */
173
199
  cardId?: string;
200
+ /**
201
+ * The origin of the checkout page the payment form was on
202
+ * (https://shop.example.com). The adapters read it from the page; pass it
203
+ * yourself when you run your own interception. With the processor identity
204
+ * in the paused request this is what names the merchant for the company's
205
+ * presets; `merchant` above is shown to the person and judged by nothing.
206
+ */
207
+ pageOrigin?: string;
174
208
  request: PausedRequest;
175
209
  /** Abort if the user has not approved within this many ms. Default 15 min. */
176
210
  timeoutMs?: number;
@@ -300,6 +334,69 @@ export declare class ProcessorRefusedError extends ApprovalDeclinedError {
300
334
  * retrying is worth anything: a 404 `connection_not_found` will answer the same
301
335
  * way forever, while a 429 or a 502 will not.
302
336
  */
337
+ /**
338
+ * The company's preset refused the purchase. The company that runs this
339
+ * integration put rules on its users' Vault purchases (a merchant list, a
340
+ * currency, a spend cap, a time window); this purchase is outside them.
341
+ * Nothing was charged. Two stages:
342
+ * - 'create': refused before any authorization existed (`authorizationId`
343
+ * is null); nobody was asked to approve. Per purchase, not per page: the
344
+ * next request on the same page is judged afresh.
345
+ * - 'pre_replay': refused right before the cardholder's device would have
346
+ * sent the card; the authorization is `declined` with `code` as reason.
347
+ * `code` is the rule's reason (merchant_denied, currency_denied,
348
+ * spend_rate_exceeded, time_window_denied, ...), `message` the rule's own
349
+ * statement with the company's next step, `preset` the version that judged
350
+ * it. When several presets refused the same purchase, `code`, `preset`,
351
+ * `rule` and `attachment` are the first, and `refusals` names every one. A
352
+ * decline in every structural sense (the adapters quiet the page's retry as
353
+ * for a person's "no"), so it extends ApprovalDeclinedError.
354
+ */
355
+ export declare class PresetRefusedError extends ApprovalDeclinedError {
356
+ readonly authorizationId: string | null;
357
+ readonly code: string;
358
+ readonly detail: string | null;
359
+ readonly preset: {
360
+ id: string;
361
+ version: number;
362
+ name: string;
363
+ } | null;
364
+ readonly rule: string | null;
365
+ readonly stage: 'create' | 'pre_replay';
366
+ /** The stored card the preset that refused is attached to, with its last four digits. */
367
+ readonly attachment: PresetAttachment | null;
368
+ /** The preset's name. */
369
+ readonly presetName: string | null;
370
+ /** Every preset that refused, each with its attachment, rule, code and statement; one entry when one refused. */
371
+ readonly refusals: readonly PresetRefusal[];
372
+ constructor(authorizationId: string | null, code: string, detail: string | null, preset: {
373
+ id: string;
374
+ version: number;
375
+ name: string;
376
+ } | null, rule: string | null, stage: 'create' | 'pre_replay',
377
+ /** The stored card the preset that refused is attached to, with its last four digits. */
378
+ attachment?: PresetAttachment | null, refusals?: readonly PresetRefusal[]);
379
+ }
380
+ /** The stored card a preset is attached to, with its last four digits. */
381
+ export interface PresetAttachment {
382
+ kind: 'card';
383
+ targetId: string | null;
384
+ last4: string | null;
385
+ }
386
+ /** One preset's refusal of a purchase. */
387
+ export interface PresetRefusal {
388
+ preset: {
389
+ id: string;
390
+ version: number;
391
+ name: string;
392
+ };
393
+ attachment: PresetAttachment | null;
394
+ rule: string | null;
395
+ code: string;
396
+ detail: string | null;
397
+ }
398
+ /** A decline reason only the company presets stamp (see PresetRefusedError). */
399
+ export declare const PRESET_REFUSAL_REASONS: ReadonlySet<string>;
303
400
  export declare class CheckoutApiError extends Error {
304
401
  status: number;
305
402
  path: string;
package/dist/client.js CHANGED
@@ -1,12 +1,19 @@
1
1
  import { BUILTIN_REGISTRY, cardUrlPatterns as deriveCardUrlPatterns, findRecognizer, } from './registry.js';
2
2
  import { matchesPreparedRequest, validPreparationEnvironment } from './prepared-processor.js';
3
+ import { hasOwnedShopMarker, parseOwnedShopOrder, parseOwnedShopReceipt } from './owned-shop.generated.js';
3
4
  /**
4
5
  * The modes this SDK can finish. Asked for on syncRegistry (the API serves
5
6
  * only recognizers in these modes, so a request this build cannot complete
6
7
  * is never paused) and sent on every create.
7
8
  */
8
9
  export const SUPPORTED_MODES = ['token', 'cse', 'hosted_form'];
9
- const AMOUNT_AUTHORITIES = ['stripe_payment_intent', 'hosted_form_sum', 'display_only'];
10
+ /** An integer in the smallest unit, or a decimal string in normal units with a point; nothing else. */
11
+ export function validAmountInput(amount) {
12
+ if (typeof amount === 'number')
13
+ return Number.isSafeInteger(amount) && amount >= 0;
14
+ return typeof amount === 'string' && /^\d{1,15}\.\d{1,3}$/.test(amount.trim());
15
+ }
16
+ const AMOUNT_AUTHORITIES = ['processor', 'agent', 'page', 'none'];
10
17
  export class CheckoutPreparationError extends Error {
11
18
  preparationId;
12
19
  reason;
@@ -180,6 +187,94 @@ export class ProcessorRefusedError extends ApprovalDeclinedError {
180
187
  * retrying is worth anything: a 404 `connection_not_found` will answer the same
181
188
  * way forever, while a 429 or a 502 will not.
182
189
  */
190
+ /**
191
+ * The company's preset refused the purchase. The company that runs this
192
+ * integration put rules on its users' Vault purchases (a merchant list, a
193
+ * currency, a spend cap, a time window); this purchase is outside them.
194
+ * Nothing was charged. Two stages:
195
+ * - 'create': refused before any authorization existed (`authorizationId`
196
+ * is null); nobody was asked to approve. Per purchase, not per page: the
197
+ * next request on the same page is judged afresh.
198
+ * - 'pre_replay': refused right before the cardholder's device would have
199
+ * sent the card; the authorization is `declined` with `code` as reason.
200
+ * `code` is the rule's reason (merchant_denied, currency_denied,
201
+ * spend_rate_exceeded, time_window_denied, ...), `message` the rule's own
202
+ * statement with the company's next step, `preset` the version that judged
203
+ * it. When several presets refused the same purchase, `code`, `preset`,
204
+ * `rule` and `attachment` are the first, and `refusals` names every one. A
205
+ * decline in every structural sense (the adapters quiet the page's retry as
206
+ * for a person's "no"), so it extends ApprovalDeclinedError.
207
+ */
208
+ export class PresetRefusedError extends ApprovalDeclinedError {
209
+ authorizationId;
210
+ code;
211
+ detail;
212
+ preset;
213
+ rule;
214
+ stage;
215
+ attachment;
216
+ /** The preset's name. */
217
+ presetName;
218
+ /** Every preset that refused, each with its attachment, rule, code and statement; one entry when one refused. */
219
+ refusals;
220
+ constructor(authorizationId, code, detail, preset, rule, stage,
221
+ /** The stored card the preset that refused is attached to, with its last four digits. */
222
+ attachment = null, refusals = []) {
223
+ super(code);
224
+ this.authorizationId = authorizationId;
225
+ this.code = code;
226
+ this.detail = detail;
227
+ this.preset = preset;
228
+ this.rule = rule;
229
+ this.stage = stage;
230
+ this.attachment = attachment;
231
+ this.name = 'PresetRefusedError';
232
+ this.presetName = preset?.name ?? null;
233
+ this.refusals = refusals.length ? refusals : preset ? [{ preset, attachment, rule, code, detail }] : [];
234
+ const others = this.refusals.slice(1).map((r) => `"${r.preset.name}"`);
235
+ const also = others.length ? ` Also refused by ${others.length === 1 ? 'preset' : 'presets'} ${others.join(', ')}.` : '';
236
+ 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}`;
237
+ }
238
+ }
239
+ /** A decline reason only the company presets stamp (see PresetRefusedError). */
240
+ export const PRESET_REFUSAL_REASONS = new Set([
241
+ 'spend_total_exceeded', 'spend_rate_exceeded', 'spend_rate_unknown', 'amount_unknown', 'rate_unavailable',
242
+ 'category_denied', 'category_unknown', 'merchant_denied', 'merchant_unknown',
243
+ 'geo_denied', 'geo_unknown', 'currency_denied', 'currency_unknown',
244
+ 'time_window_denied', 'surface_denied', 'surface_unknown',
245
+ ]);
246
+ function presetOf(v) {
247
+ if (!v || typeof v !== 'object')
248
+ return null;
249
+ const p = v;
250
+ if (typeof p.id !== 'string' || typeof p.version !== 'number' || typeof p.name !== 'string')
251
+ return null;
252
+ return { id: p.id, version: p.version, name: p.name };
253
+ }
254
+ function attachmentOf(v) {
255
+ if (!v || typeof v !== 'object')
256
+ return null;
257
+ const a = v;
258
+ if (a.kind !== 'card')
259
+ return null;
260
+ return { kind: a.kind, targetId: typeof a.target_id === 'string' ? a.target_id : null, last4: typeof a.last4 === 'string' ? a.last4 : null };
261
+ }
262
+ /** The `refusals` list of a refusal envelope: every entry that names a preset. */
263
+ function refusalsOf(v) {
264
+ if (!Array.isArray(v))
265
+ return [];
266
+ const out = [];
267
+ for (const item of v) {
268
+ if (!item || typeof item !== 'object')
269
+ continue;
270
+ const r = item;
271
+ const preset = presetOf(r.preset);
272
+ if (!preset || typeof r.reason !== 'string')
273
+ continue;
274
+ 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 });
275
+ }
276
+ return out;
277
+ }
183
278
  export class CheckoutApiError extends Error {
184
279
  status;
185
280
  path;
@@ -218,6 +313,12 @@ export class CheckoutApiError extends Error {
218
313
  get permanent() {
219
314
  if (this.code === 'amount_mismatch' || this.code === 'duplicate_submission')
220
315
  return false;
316
+ // Nor a refusal by the company preset (a 403 carrying `preset` in the
317
+ // envelope): the company's rules refused THIS purchase, at this merchant,
318
+ // for this amount, at this hour. The next one on the same page may pass,
319
+ // so the page is quieted like a decline, never latched.
320
+ if (this.details.preset && typeof this.details.preset === 'object')
321
+ return false;
221
322
  return this.status >= 400 && this.status < 500 && this.status !== 429;
222
323
  }
223
324
  }
@@ -317,7 +418,7 @@ export class VaultClient {
317
418
  const fail = (reason, id = null) => new CheckoutPreparationError(id, reason);
318
419
  if (!validPreparationEnvironment(input.psp, input.environment))
319
420
  throw fail('unsupported_processor');
320
- if (!Number.isSafeInteger(input.amountCents) || input.amountCents <= 0 || typeof input.currency !== 'string' || !/^[a-z]{3}$/i.test(input.currency))
421
+ if (!validAmountInput(input.amount) || typeof input.currency !== 'string' || !/^[a-z]{3}$/i.test(input.currency))
321
422
  throw fail('amount_required');
322
423
  const origin = new URL(input.merchantOrigin);
323
424
  if (!(origin.protocol === 'https:' || (origin.protocol === 'http:' && origin.hostname === 'localhost')) || origin.origin !== input.merchantOrigin)
@@ -336,7 +437,7 @@ export class VaultClient {
336
437
  // Drain a sent creation even after caller cancellation to retire its ID.
337
438
  // The separate stop signal prevents dispatch after a slow OAuth exchange.
338
439
  const created = await this.post('/v2/checkout/preparations', {
339
- user: input.user, merchant: input.merchant, amount_cents: input.amountCents, currency: input.currency.toLowerCase(),
440
+ user: input.user, merchant: input.merchant, amount: input.amount, currency: input.currency.toLowerCase(),
340
441
  ...(input.cardId ? { card_id: input.cardId } : {}), psp: input.psp, mode: 'token',
341
442
  environment: input.environment, checkout_key: input.checkoutKey, merchant_origin: input.merchantOrigin,
342
443
  }, AbortSignal.timeout(30_000), signal);
@@ -365,17 +466,18 @@ export class VaultClient {
365
466
  if (state.status === 'ready') {
366
467
  const expiry = Date.parse(state.ready_expires_at);
367
468
  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'
469
+ || state.payment_status !== 'not_started' || state.amount_authority !== 'agent'
369
470
  || state.user !== input.user || state.merchant !== input.merchant || state.merchant_origin !== input.merchantOrigin
370
- || state.amount_cents !== input.amountCents || state.currency !== input.currency.toLowerCase()
471
+ || !Number.isSafeInteger(state.amount) || (typeof input.amount === 'number' && state.amount !== input.amount) || state.currency !== input.currency.toLowerCase()
371
472
  || state.psp !== input.psp || state.mode !== 'token' || state.environment !== input.environment
372
473
  || state.checkout_key !== input.checkoutKey)
373
474
  throw fail('ready_unconfirmed', id);
374
475
  const prepared = Object.freeze({
375
476
  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,
477
+ cardId: state.card_id, user: input.user, merchant: input.merchant, amount: state.amount,
478
+ amountDisplay: typeof state.amount_display === 'string' ? state.amount_display : null,
377
479
  currency: input.currency.toLowerCase(), merchantOrigin: input.merchantOrigin, checkoutKey: input.checkoutKey,
378
- paymentStatus: 'not_started', amountAuthority: 'display_only',
480
+ paymentStatus: 'not_started', amountAuthority: 'agent',
379
481
  });
380
482
  this.preparations.add(prepared);
381
483
  ready = true;
@@ -411,6 +513,27 @@ export class VaultClient {
411
513
  * response to replay into the browser. Your process never sees a card.
412
514
  */
413
515
  async authorize(input) {
516
+ const ownedShop = hasOwnedShopMarker(input.request);
517
+ const shopOrder = ownedShop ? parseOwnedShopOrder(input.request) : null;
518
+ if (ownedShop) {
519
+ if (!shopOrder || input.preparation || input.executionMode === 'user_approval' ||
520
+ !input.merchantOrigin || (input.amount != null && input.amount !== shopOrder.amount &&
521
+ !(typeof input.amount === 'string' && Number(input.amount) * 100 === shopOrder.amount)) ||
522
+ (input.currency !== undefined && input.currency.toLowerCase() !== shopOrder.currency))
523
+ throw new Error('The shop order cannot use this checkout configuration.');
524
+ input = { ...input, amount: shopOrder.amount, currency: shopOrder.currency };
525
+ }
526
+ if (input.executionMode !== undefined && !['user_approval', 'autopilot'].includes(input.executionMode))
527
+ throw new Error('Unsupported checkout execution mode.');
528
+ if (input.grantId !== undefined && !/^apg_[A-Za-z0-9_-]{1,128}$/.test(input.grantId))
529
+ throw new Error('Invalid autopilot grant ID.');
530
+ if (input.preparation && (input.executionMode === 'autopilot' || input.grantId))
531
+ throw new Error('A device preparation cannot also select autopilot.');
532
+ if (input.merchantOrigin !== undefined && !input.preparation) {
533
+ const merchantUrl = new URL(input.merchantOrigin);
534
+ if (merchantUrl.protocol !== 'https:' || merchantUrl.origin !== input.merchantOrigin)
535
+ throw new Error('merchantOrigin must be an exact HTTPS origin.');
536
+ }
414
537
  const preparation = input.preparation;
415
538
  if (preparation) {
416
539
  if (!this.preparations.has(preparation) || this.usedPreparations.has(preparation))
@@ -419,7 +542,7 @@ export class VaultClient {
419
542
  this.usedPreparations.add(preparation);
420
543
  if (Date.parse(preparation.expiresAt) <= Date.now())
421
544
  throw new CheckoutPreparationError(preparation.id, 'expired');
422
- if (input.user !== preparation.user || input.merchant !== preparation.merchant || input.amountCents !== preparation.amountCents
545
+ 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
546
  || input.currency?.toLowerCase() !== preparation.currency || input.cardId !== preparation.cardId
424
547
  || !matchesPreparedRequest(preparation.psp, preparation.environment, input.request.url, input.request.method ?? 'POST', input.request.body))
425
548
  throw new CheckoutPreparationError(preparation.id, 'checkout_changed');
@@ -452,16 +575,16 @@ export class VaultClient {
452
575
  throw new Error(`checkout authorization is POST-only; got ${method} for ${redactUrl(input.request.url)}. ` +
453
576
  'A non-POST tokenizer needs recognizer support before it can be intercepted.');
454
577
  }
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;
578
+ // The amount is a hint: a pair or nothing. Refuse half a pair here, before
579
+ // a network call: the API would too, and a retry loop cannot fix a
580
+ // missing field. The processor's own amount is read by Agentcard.
581
+ const hasAmount = input.amount != null;
459
582
  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.');
583
+ if (hasAmount !== hasCurrency) {
584
+ throw new Error('amount and currency go together: pass both or neither.');
462
585
  }
463
- if (!input.amount && !hasCents) {
464
- throw new Error('authorize needs amount (a display string), or amountCents with currency.');
586
+ if (hasAmount && !validAmountInput(input.amount)) {
587
+ 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
588
  }
466
589
  const timeoutMs = input.timeoutMs ?? 15 * 60_000;
467
590
  if (!Number.isInteger(timeoutMs) || timeoutMs <= 0 || timeoutMs > 2_147_483_647)
@@ -473,15 +596,21 @@ export class VaultClient {
473
596
  const payload = {
474
597
  user: input.user,
475
598
  merchant: input.merchant,
476
- ...(input.amount ? { amount: input.amount } : {}),
477
599
  // snake_case on the wire; camelCase is this SDK's convention.
478
- ...(hasCents ? { amount_cents: input.amountCents, currency: input.currency } : {}),
600
+ ...(hasAmount ? { amount: input.amount, currency: input.currency } : {}),
601
+ ...(input.pageAmount ? { page_amount: input.pageAmount.amount, page_currency: input.pageAmount.currency } : {}),
479
602
  psp: rec.psp,
480
603
  // The mode this request will be finished in. The API checks it against
481
604
  // the recognizer and refuses a disagreement before a row exists.
482
605
  mode,
483
606
  ...(input.cardId ? { cardId: input.cardId } : {}),
484
- ...(preparation ? { preparation_id: preparation.id, checkout_key: preparation.checkoutKey, merchant_origin: preparation.merchantOrigin } : {}),
607
+ ...(input.executionMode ? { execution_mode: input.executionMode } : {}),
608
+ ...(input.grantId ? { grant_id: input.grantId } : {}),
609
+ ...(input.merchantOrigin && !preparation ? { merchant_origin: input.merchantOrigin } : {}),
610
+ // A prepared checkout already carries the page origin as merchant_origin.
611
+ ...(preparation
612
+ ? { preparation_id: preparation.id, checkout_key: preparation.checkoutKey, merchant_origin: preparation.merchantOrigin }
613
+ : input.pageOrigin ? { checkout_origin: input.pageOrigin } : {}),
485
614
  request: {
486
615
  url: input.request.url,
487
616
  method: input.request.method,
@@ -521,6 +650,20 @@ export class VaultClient {
521
650
  if (!created || typeof created.id !== 'string' || !created.id)
522
651
  throw new PaymentOutcomeUnknownError(preparation ? await this.retireUncertainPreparation(preparation, input.onAuthorizationCreated) : null, 'authorization_create_malformed');
523
652
  const authorizationId = created.id;
653
+ let execution = {};
654
+ let approvalDelivered = false;
655
+ const deliverApproval = (state) => {
656
+ if (ownedShop || preparation || approvalDelivered || execution.executionMode === 'autopilot')
657
+ return;
658
+ const url = state.approvalUrl ?? state.approval_url;
659
+ if (typeof url !== 'string' || !url)
660
+ return;
661
+ approvalDelivered = true;
662
+ try {
663
+ Promise.resolve(input.onApprovalUrl?.(url)).catch(() => { });
664
+ }
665
+ catch { /* observer only */ }
666
+ };
524
667
  let failed = false;
525
668
  const stopSignal = input.merchantSignal
526
669
  ? AbortSignal.any([input.merchantSignal, ...(input.signal ? [input.signal] : [])]) : input.signal;
@@ -531,12 +674,8 @@ export class VaultClient {
531
674
  catch { /* observer only */ }
532
675
  if (input.merchantSignal?.aborted)
533
676
  throw new PaymentOutcomeUnknownError(authorizationId, 'merchant_request_aborted');
534
- if (!preparation) {
535
- try {
536
- Promise.resolve(input.onApprovalUrl?.(created.approvalUrl)).catch(() => { });
537
- }
538
- catch { /* approval delivery must not lose an existing authorization */ }
539
- }
677
+ execution = executionMetadata(created, authorizationId);
678
+ deliverApproval(created);
540
679
  while (Date.now() < deadline) {
541
680
  if (stopSignal?.aborted)
542
681
  throw new PaymentOutcomeUnknownError(authorizationId, input.merchantSignal?.aborted ? 'merchant_request_aborted' : 'local_cancel');
@@ -557,6 +696,11 @@ export class VaultClient {
557
696
  if (!s || typeof s !== 'object' || Array.isArray(s) || typeof s.status !== 'string') {
558
697
  throw new PaymentOutcomeUnknownError(authorizationId, 'authorization_status_malformed');
559
698
  }
699
+ execution = executionMetadata(s, authorizationId, execution);
700
+ if (shopOrder && (s.status === 'submitted_on_device' ||
701
+ (s.status === 'approved' && (s.mode !== 'token' || execution.executionMode !== 'autopilot'))))
702
+ throw new PaymentOutcomeUnknownError(authorizationId, 'shop_receipt_unconfirmed');
703
+ deliverApproval(s);
560
704
  const amountAuthority = typeof s.amount_authority === 'string' && AMOUNT_AUTHORITIES.includes(s.amount_authority)
561
705
  ? { amountAuthority: s.amount_authority }
562
706
  : {};
@@ -572,7 +716,9 @@ export class VaultClient {
572
716
  if (typeof s.submitted_at !== 'string' || !s.submitted_at) {
573
717
  throw new PaymentOutcomeUnknownError(authorizationId, 'submission_timestamp_missing');
574
718
  }
575
- return { mode: 'hosted_form', kind: 'submitted_on_device', outcome: 'unverified', authorizationId, submittedAt: s.submitted_at, ...amountAuthority };
719
+ if (execution.executionMode === 'autopilot')
720
+ throw new PaymentOutcomeUnknownError(authorizationId, 'autopilot_submission_mode_invalid');
721
+ return { mode: 'hosted_form', kind: 'submitted_on_device', outcome: 'unverified', authorizationId, submittedAt: s.submitted_at, ...amountAuthority, ...execution };
576
722
  }
577
723
  if (s.status === 'approved') {
578
724
  const approvedMode = typeof s.mode === 'string' ? s.mode : 'token';
@@ -583,6 +729,8 @@ export class VaultClient {
583
729
  throw new PaymentOutcomeUnknownError(authorizationId, 'hosted_form_approval_malformed');
584
730
  }
585
731
  if (approvedMode === 'cse') {
732
+ if (execution.executionMode === 'autopilot')
733
+ throw new PaymentOutcomeUnknownError(authorizationId, 'autopilot_submission_mode_invalid');
586
734
  const sub = s.substitutions;
587
735
  const fieldsOk = sub && typeof sub === 'object' && sub.encoding === 'json' && typeof sub.at === 'string' && sub.at
588
736
  && sub.fields && typeof sub.fields === 'object' && !Array.isArray(sub.fields)
@@ -607,6 +755,7 @@ export class VaultClient {
607
755
  ...(removeRaw ? { remove: [...removeRaw] } : {}),
608
756
  },
609
757
  ...amountAuthority,
758
+ ...execution,
610
759
  };
611
760
  }
612
761
  if (approvedMode !== 'token')
@@ -616,15 +765,29 @@ export class VaultClient {
616
765
  || typeof response.body !== 'string' || !response.headers || typeof response.headers !== 'object' || Array.isArray(response.headers)) {
617
766
  throw new PaymentOutcomeUnknownError(authorizationId, 'approved_response_malformed');
618
767
  }
768
+ if (shopOrder) {
769
+ let receipt;
770
+ try {
771
+ receipt = parseOwnedShopReceipt(JSON.parse(response.body), shopOrder);
772
+ }
773
+ catch { /* No raw error or token replay. */ }
774
+ if (execution.executionMode !== 'autopilot' || response.status !== 200 || !receipt)
775
+ throw new PaymentOutcomeUnknownError(authorizationId, 'shop_receipt_unconfirmed');
776
+ // Only canonical receipt fields and our content type enter the page.
777
+ response.body = JSON.stringify(receipt);
778
+ response.headers = { 'content-type': 'application/json' };
779
+ }
619
780
  return {
620
781
  mode: 'token',
621
782
  authorizationId,
783
+ ...(shopOrder ? { shopOrderId: shopOrder.order_id } : {}),
622
784
  ...response,
623
785
  amountVerified: typeof s.amount_verified === 'boolean' ? s.amount_verified : null,
624
- chargedAmountCents: typeof s.charged_amount_cents === 'number' ? s.charged_amount_cents : null,
786
+ chargedAmount: typeof s.charged_amount === 'number' ? s.charged_amount : null,
625
787
  chargedCurrency: typeof s.charged_currency === 'string' ? s.charged_currency : null,
626
788
  chargedKind: s.charged_kind === 'captured' || s.charged_kind === 'authorized' || s.charged_kind === 'none' ? s.charged_kind : null,
627
789
  ...amountAuthority,
790
+ ...execution,
628
791
  };
629
792
  }
630
793
  if (s.status === 'declined') {
@@ -635,6 +798,9 @@ export class VaultClient {
635
798
  }
636
799
  if (s.reason === 'intent_not_confirmable')
637
800
  throw new IntentNotConfirmableError(String(created.id));
801
+ if (typeof s.reason === 'string' && PRESET_REFUSAL_REASONS.has(s.reason)) {
802
+ 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));
803
+ }
638
804
  if (s.reason === 'processor_refused') {
639
805
  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
806
  }
@@ -718,6 +884,11 @@ export class VaultClient {
718
884
  return await this.post('/v2/checkout/authorizations', payload, signal, stopRetries);
719
885
  }
720
886
  catch (err) {
887
+ if (err instanceof CheckoutApiError && err.code && err.status === 403 && presetOf(err.details.preset)) {
888
+ // A company preset refused this purchase before a row existed.
889
+ const d = err.details;
890
+ 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));
891
+ }
721
892
  if (err instanceof CheckoutApiError && err.code === 'amount_mismatch') {
722
893
  const d = err.details;
723
894
  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');
@@ -804,6 +975,19 @@ export class VaultClient {
804
975
  return this.call(path, { signal });
805
976
  }
806
977
  }
978
+ function executionMetadata(state, authorizationId, previous = {}) {
979
+ if (state.execution_mode === undefined)
980
+ return previous;
981
+ if (state.execution_mode !== 'user_approval' && state.execution_mode !== 'autopilot')
982
+ throw new PaymentOutcomeUnknownError(authorizationId, 'execution_mode_unrecognized');
983
+ if (state.execution_mode === 'user_approval')
984
+ return { executionMode: 'user_approval' };
985
+ if (typeof state.grant_id !== 'string' || !/^apg_[A-Za-z0-9_-]{1,128}$/.test(state.grant_id))
986
+ throw new PaymentOutcomeUnknownError(authorizationId, 'autopilot_grant_unconfirmed');
987
+ if (previous.grantId && previous.grantId !== state.grant_id)
988
+ throw new PaymentOutcomeUnknownError(authorizationId, 'autopilot_grant_changed');
989
+ return { executionMode: 'autopilot', grantId: state.grant_id };
990
+ }
807
991
  /**
808
992
  * Forward only the headers the merchant needs to accept the replay. Everything
809
993
  * else (cookies, UA, tracing) is dropped so we transmit as little as possible.
package/dist/index.d.ts CHANGED
@@ -1,5 +1,5 @@
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, RazorpayProcessorError, } 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
+ export type { PausedRequest, ReplayResponse, TokenReplay, CseReplay, HostedFormReplay, AmountAuthority, AuthorizeInput, VaultClientOptions, PrepareCheckoutOptions, PrepareCheckoutInput, PreparedCheckout, RazorpayProcessorError, ExecutionMetadata, } from './client.js';
3
3
  export { attachToCdp, attachToPlaywright, corsHeadersFor, corsDecision, withCorsHeaders } from './cdp.js';
4
4
  export { CheckoutAttachmentError } from './attachment.js';
5
5
  export type { CdpLike, AttachOptions, CorsOutcome } from './cdp.js';