@agent-cards/checkout 0.17.0 → 0.19.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.
Files changed (49) hide show
  1. package/CHANGELOG.md +16 -0
  2. package/PREFLIGHT.md +4 -0
  3. package/README.md +106 -14
  4. package/dist/adyen-merchant-hosted.generated.d.ts +277 -0
  5. package/dist/adyen-merchant-hosted.generated.js +1902 -0
  6. package/dist/builtin-registry.generated.js +1 -1
  7. package/dist/card-fields.generated.d.ts +3 -0
  8. package/dist/card-fields.generated.js +46 -0
  9. package/dist/cdp.d.ts +6 -1
  10. package/dist/cdp.js +574 -220
  11. package/dist/client.d.ts +285 -5
  12. package/dist/client.js +590 -12
  13. package/dist/cse-body.d.ts +25 -0
  14. package/dist/cse-body.js +41 -0
  15. package/dist/fiserv.d.ts +65 -0
  16. package/dist/fiserv.generated.d.ts +73 -0
  17. package/dist/fiserv.generated.js +830 -0
  18. package/dist/fiserv.js +104 -0
  19. package/dist/index.d.ts +7 -2
  20. package/dist/index.js +5 -1
  21. package/dist/lifecycle.d.ts +38 -1
  22. package/dist/lifecycle.js +77 -5
  23. package/dist/merchant-handoff.d.ts +54 -0
  24. package/dist/merchant-handoff.js +100 -0
  25. package/dist/merchant-hosted.d.ts +140 -0
  26. package/dist/merchant-hosted.js +170 -0
  27. package/dist/merchant-total-watch.d.ts +115 -0
  28. package/dist/merchant-total-watch.js +268 -0
  29. package/dist/merchant-total.d.ts +257 -0
  30. package/dist/merchant-total.js +383 -0
  31. package/dist/pre-claim.d.ts +123 -0
  32. package/dist/pre-claim.js +386 -0
  33. package/dist/preflight-capabilities.generated.d.ts +1 -1
  34. package/dist/preflight-capabilities.generated.js +1 -1
  35. package/dist/preflight-catalog.json +133 -1
  36. package/dist/preflight-schemas.json +14 -2
  37. package/dist/preflight.generated.d.ts +1 -1
  38. package/dist/preflight.generated.js +15 -1
  39. package/dist/preparation.d.ts +13 -0
  40. package/dist/preparation.js +46 -9
  41. package/dist/prepared-processor.d.ts +36 -3
  42. package/dist/prepared-processor.js +53 -3
  43. package/dist/registry.d.ts +60 -0
  44. package/dist/registry.js +14 -0
  45. package/dist/stripe-checkout.generated.js +140 -20
  46. package/dist/substitutions.generated.d.ts +2 -1
  47. package/dist/substitutions.generated.js +758 -6
  48. package/examples/preflight/kernel-native/inventory.json +1 -1
  49. package/package.json +3 -3
@@ -0,0 +1,170 @@
1
+ /**
2
+ * Adyen merchant-hosted checkouts, as the agent's browser sees them.
3
+ *
4
+ * Some Adyen merchants (Dunelm, Cinemark) have adyen-web encrypt the card into
5
+ * four fields and post them to THEIR OWN server, which then charges the card
6
+ * through Adyen. No processor host is involved, so the processor registry can
7
+ * never recognize that request. Agentcard reviews each such merchant instead and
8
+ * pins its endpoint, body rules and Adyen key in a profile (payment-core
9
+ * merchant-profiles.js). This build carries those profiles as a generated
10
+ * projection (adyen-merchant-hosted.generated.ts) and arms every one of them from
11
+ * the start, in 'observe' until the API says this client may pay it
12
+ * (VaultClient.syncRegistry asks with merchant_profiles=1). A sync that has not run
13
+ * or failed, or an API that serves no profile, leaves each one in 'observe'.
14
+ *
15
+ * A profile's `status` keeps it dark: 'observe' pauses its card request, reports
16
+ * the request's shape (a hash of its key paths, never a value) and aborts it, so
17
+ * the agent's dummy card never reaches the merchant and nothing is charged;
18
+ * 'enabled' lets a prepared checkout pay it. The API can hold a profile in
19
+ * 'observe' for a client but never enable it past this build: a profile is
20
+ * 'enabled' only when both say so. Every profile ships in 'observe' today.
21
+ */
22
+ import { MERCHANT_PROFILE_RECOGNIZERS as SHARED_PROFILES, classifyDeclaredMerchantRequest as sharedClassifyDeclared, declaredTemplatedUrl as sharedDeclaredTemplatedUrl, declaredUrlGlobs as sharedDeclaredUrlGlobs, declaredUrlRelation as sharedDeclaredUrlRelation, declaredEndpointRefusal as sharedDeclaredEndpointRefusal, classifyMerchantRequest as sharedClassify, merchantBodyKeyPathSha256 as sharedKeyPathSha256, merchantProfileEnvironment as sharedEnvironment, merchantProfileRulesMatch as sharedRulesMatch, merchantProfileTemplatedUrl as sharedTemplatedUrl, merchantProfileUrlGlobs as sharedUrlGlobs, merchantProfileUrlRelation as sharedUrlRelation, sha256Hex as sharedSha256Hex, } from './adyen-merchant-hosted.generated.js';
23
+ import { SubstitutionError, substituteMerchantHosted as sharedSubstitute } from './substitutions.generated.js';
24
+ import { BUILTIN_REGISTRY } from './registry.js';
25
+ /**
26
+ * The profile's verdict on a paused request: pause (its reviewed card request),
27
+ * abort (encrypted card data it cannot finish, with the reason) or pass (no
28
+ * encrypted card data, or not its endpoint or a sibling of it). Reads only;
29
+ * throws for a profile id this build did not review.
30
+ */
31
+ export const classifyMerchantRequest = sharedClassify;
32
+ /** The endpoint origin, the path TEMPLATE and the profile's own query: what an event may name. */
33
+ export const merchantProfileTemplatedUrl = sharedTemplatedUrl;
34
+ /** SHA-256 of a JSON body's sorted key paths, no value in it; null for a body that is not JSON. */
35
+ export const merchantBodyKeyPathSha256 = sharedKeyPathSha256;
36
+ /** 'sandbox' for a profile on Adyen's TEST platform, 'production' for every live environment. */
37
+ export const merchantProfileEnvironment = sharedEnvironment;
38
+ const urlRelation = sharedUrlRelation;
39
+ const urlGlobs = sharedUrlGlobs;
40
+ const rulesMatch = sharedRulesMatch;
41
+ const sha256Hex = sharedSha256Hex;
42
+ const substitute = sharedSubstitute;
43
+ /** classifyMerchantRequest for a sandbox declaration: the profile's body rules at the declared endpoint instead of the profile's. */
44
+ export const classifyDeclaredMerchantRequest = sharedClassifyDeclared;
45
+ const declaredRelation = sharedDeclaredUrlRelation;
46
+ const declaredTemplated = sharedDeclaredTemplatedUrl;
47
+ const declaredGlobs = sharedDeclaredUrlGlobs;
48
+ /** payment-core's endpoint rule for a declaration, the part that needs no processor catalog: why an endpoint cannot be declared, or null. */
49
+ const declaredEndpointRefusal = sharedDeclaredEndpointRefusal;
50
+ const BUILT = SHARED_PROFILES;
51
+ /**
52
+ * The profiles this client arms: every profile this build reviewed, once each, in
53
+ * this build's order. A served entry is `{ merchant_profile: <the profile's wire
54
+ * projection> }`, and the first one that names a profile with exactly this build's
55
+ * rules (endpoint, body rules, holder, fields, key: everything but `status`) and a
56
+ * status this build knows decides it: 'enabled' only when both that entry and this
57
+ * build say so. Every other profile stays in 'observe': one the API did not serve (no
58
+ * sync yet, a failed sync, an API that predates profiles) and one the API reviewed
59
+ * again after this build was cut, which this build cannot pay. In 'observe' its card
60
+ * request is still paused and aborted, so the agent's dummy card never reaches a
61
+ * reviewed merchant, whatever the API answered.
62
+ */
63
+ export function armServedProfiles(entries = []) {
64
+ const decided = new Map();
65
+ for (const entry of entries) {
66
+ if (!entry || typeof entry !== 'object' || Array.isArray(entry))
67
+ continue;
68
+ const served = entry.merchant_profile;
69
+ if (!served || typeof served !== 'object' || Array.isArray(served))
70
+ continue;
71
+ const id = served.id;
72
+ const status = served.status;
73
+ const built = typeof id === 'string' ? BUILT.find((profile) => profile.id === id) : undefined;
74
+ if (!built || decided.has(built.id) || (status !== 'observe' && status !== 'enabled') || !rulesMatch(built.id, served))
75
+ continue;
76
+ decided.set(built.id, status === 'enabled' && built.status === 'enabled' ? 'enabled' : 'observe');
77
+ }
78
+ return BUILT.map((built) => Object.freeze({ id: built.id, merchant: built.merchant, status: decided.get(built.id) ?? 'observe' }));
79
+ }
80
+ const TEST_CLIENT_KEY = /^test_[A-Za-z0-9]{32}$/;
81
+ /**
82
+ * Arms a sandbox declaration: the reviewed profile's card request at your own
83
+ * endpoint, under your own Adyen TEST key. Throws a TypeError for a profile this
84
+ * build did not review, an endpoint the API would refuse, or a key that is not a
85
+ * test_ key. The endpoint rule is the API's own (payment-core sandboxDeclarationRefusal):
86
+ * an HTTPS URL in the exact form the browser writes it (no port, credentials or
87
+ * fragment), on a public domain name outside Adyen's domains, Agentcard's and every
88
+ * reviewed merchant's domain (declaredEndpointRefusal), and on no host this build's
89
+ * processor registry names. The armed entry names its host as the merchant, and is
90
+ * 'enabled' whatever the reviewed profile's status: it never pays the reviewed
91
+ * merchant, the API refuses it from a live client and from an org it has not turned
92
+ * declarations on for, and the Vault encrypts only Adyen's documented test cards
93
+ * under a test_ key.
94
+ */
95
+ export function declareSandboxMerchant(input) {
96
+ const built = input && typeof input.profile === 'string' ? BUILT.find((profile) => profile.id === input.profile) : undefined;
97
+ if (!built)
98
+ throw new TypeError('sandboxMerchants: profile is not a merchant profile this SDK build reviewed.');
99
+ const refusal = declaredEndpointRefusal(input.endpoint);
100
+ if (refusal)
101
+ throw new TypeError(`sandboxMerchants: ${refusal}.`);
102
+ const url = new URL(input.endpoint);
103
+ const host = url.hostname.replace(/\.$/, '');
104
+ if (BUILTIN_REGISTRY.some((rec) => (rec.hosts ?? []).some((source) => new RegExp(source, 'i').test(host)))) {
105
+ throw new TypeError(`sandboxMerchants: ${url.hostname} is a payment processor's host, not a merchant's own endpoint.`);
106
+ }
107
+ if (typeof input.clientKey !== 'string' || !TEST_CLIENT_KEY.test(input.clientKey)) {
108
+ throw new TypeError('sandboxMerchants: clientKey must be an Adyen TEST client key (test_ followed by 32 letters and digits).');
109
+ }
110
+ return Object.freeze({ id: built.id, merchant: url.hostname, status: 'enabled',
111
+ sandboxDeclaration: Object.freeze({ endpoint: input.endpoint, clientKey: input.clientKey }) });
112
+ }
113
+ /** How a URL relates to an armed profile: its declared endpoint when it has one, else the reviewed endpoint. */
114
+ function relationTo(profile, url) {
115
+ return profile.sandboxDeclaration ? declaredRelation(profile.sandboxDeclaration.endpoint, url) : urlRelation(profile.id, url);
116
+ }
117
+ /** The armed profile whose endpoint, or a sibling of it (same origin and path shape), a URL is; null for any other URL. */
118
+ export function merchantProfileFor(profiles, url) {
119
+ if (typeof url !== 'string')
120
+ return null;
121
+ return profiles.find((profile) => relationTo(profile, url) !== null) ?? null;
122
+ }
123
+ /** The Fetch.enable globs that pause every armed profile's endpoint and its siblings, a declared endpoint included. */
124
+ export function merchantProfileUrlPatterns(profiles) {
125
+ return [...new Set(profiles.flatMap((profile) => (profile.sandboxDeclaration
126
+ ? declaredGlobs(profile.sandboxDeclaration.endpoint) : urlGlobs(profile.id))))];
127
+ }
128
+ /** classifyMerchantRequest for an armed profile: at its declared endpoint when it has one. */
129
+ export function classifyProfileRequest(profile, url, method, body) {
130
+ return profile.sandboxDeclaration
131
+ ? classifyDeclaredMerchantRequest(profile.id, profile.sandboxDeclaration.endpoint, url, method, body)
132
+ : classifyMerchantRequest(profile.id, url, method, body);
133
+ }
134
+ /** merchantProfileTemplatedUrl for an armed profile: a declared endpoint's origin and path, never its query. */
135
+ export function profileTemplatedUrl(profile, url) {
136
+ return profile.sandboxDeclaration ? declaredTemplated(profile.sandboxDeclaration.endpoint) : merchantProfileTemplatedUrl(profile.id, url);
137
+ }
138
+ /**
139
+ * The Fetch.enable globs for every profile this build reviewed. Every one of them is
140
+ * armed from the start (armServedProfiles), so attachToCdp arms all of them at attach
141
+ * and a syncRegistry() before or after it changes only a profile's status: its card
142
+ * request is paused and judged under CDP exactly as attachToPlaywright's per-request
143
+ * route judges it.
144
+ */
145
+ export function reviewedMerchantProfileUrlPatterns() {
146
+ return [...new Set(BUILT.flatMap((profile) => urlGlobs(profile.id)))];
147
+ }
148
+ /**
149
+ * The continuation of a merchant-hosted approval: the live paused body with the
150
+ * Vault's ciphertext in place of the dummy fields. The API never sees the body the
151
+ * browser continues, so the live body must first hash to the one the API checked
152
+ * when the authorization was created (`bodySha256`); a page that rewrote its body
153
+ * while the cardholder decided is refused rather than paid. payment-core's
154
+ * substituteMerchantHosted then reads the profile's body rules again on the live
155
+ * body, writes only the profile's four fields, and refuses a profile that is not
156
+ * enabled, unless this client armed the endpoint from its own sandbox declaration
157
+ * (the replay carries `sandboxDeclaration`), where every body rule still applies.
158
+ * Throws SubstitutionError; nothing leaves the browser when it does.
159
+ */
160
+ export function substituteMerchantHostedBody(body, replay) {
161
+ if (typeof body !== 'string' || sha256Hex(body) !== replay.bodySha256) {
162
+ throw new SubstitutionError('the paused body is not the one Agentcard checked when this checkout was created');
163
+ }
164
+ const { set, ...substitution } = replay.substitutions;
165
+ return substitute(body, set && set.length ? { ...substitution, set } : substitution, { sandboxDeclared: replay.sandboxDeclaration !== undefined });
166
+ }
167
+ /** The URL an event names for a merchant-hosted replay's request: its declared endpoint's origin and path, or the profile's template. */
168
+ export function replayTemplatedUrl(replay, url) {
169
+ return replay.sandboxDeclaration ? declaredTemplated(replay.sandboxDeclaration.endpoint) : merchantProfileTemplatedUrl(replay.profile, url);
170
+ }
@@ -0,0 +1,115 @@
1
+ import { type MerchantHostedReplay } from './client.js';
2
+ import { MerchantExchangeLog, type MerchantRequestSeen, type MerchantTotalCapture } from './merchant-total.js';
3
+ /** onEvent is ordinary integrator telemetry; the shape matches AttachOptions.onEvent. */
4
+ type TotalEvent = {
5
+ type: string;
6
+ detail?: unknown;
7
+ };
8
+ type ChargeReporter = {
9
+ reportMerchantCharge?: (authorizationId: string, report: any) => Promise<{
10
+ verdict: string;
11
+ alerted: boolean;
12
+ }>;
13
+ };
14
+ type Canceller = {
15
+ cancelAuthorization?: (authorizationId: string, reason: 'merchant_never_retried') => Promise<unknown>;
16
+ };
17
+ /** What the watch reads from the adapter's VaultClient: the profile a URL pays, and this client's own declarations. */
18
+ type ProfileSource = {
19
+ merchantProfile?: (id: string) => {
20
+ id: string;
21
+ sandboxDeclaration?: {
22
+ endpoint: string;
23
+ };
24
+ } | null;
25
+ merchantProfileOf?: (url: string) => {
26
+ id: string;
27
+ } | null;
28
+ };
29
+ /**
30
+ * How long a paused card request's page is waited for. Chromium can deliver a request's
31
+ * Network event (which names its document) after the Fetch pause; it follows within
32
+ * milliseconds, so this bound only matters when it never comes, and the read then has no page.
33
+ */
34
+ export declare const MERCHANT_PAGE_WAIT_MS = 1000;
35
+ /**
36
+ * The pauses before the after-payment report is tried again when it could not go out (no
37
+ * confirmation read yet, or the report request failed): a confirmation the page asks for
38
+ * after the card request's answer (Dick's order submit) still gets reported.
39
+ */
40
+ export declare const MERCHANT_CHARGE_RETRY_DELAYS_MS: readonly number[];
41
+ /** How long after the merchant answered a confirmation that arrives late is still reported. */
42
+ export declare const MERCHANT_CHARGE_REPORT_WINDOW_MS: number;
43
+ /**
44
+ * The merchant's own total around one attached page, shared by the CDP and Playwright
45
+ * adapters so both read it the same way (merchant-total.ts says what it proves). From
46
+ * attach on, the adapter tells it about every request that leaves, every answer and every
47
+ * failure; it keeps, read-only, the ones a priced profile's rules read at the origins they
48
+ * read (a client's sandbox declaration moves a profile's to its declared endpoint), and
49
+ * the page each request came from. At a pause it hands the client a capture; at release it
50
+ * reads the total again and holds the card request unless it is still the approved amount;
51
+ * once the merchant answered the continued request it reports what the merchant says it
52
+ * charged.
53
+ */
54
+ export declare class MerchantTotalWatch {
55
+ private readonly onEvent?;
56
+ private readonly vault?;
57
+ private readonly waitMs;
58
+ private readonly settleMs;
59
+ private readonly retryDelaysMs;
60
+ readonly log: MerchantExchangeLog;
61
+ private readonly pages;
62
+ private readonly pageWaiters;
63
+ /** Approvals whose charge report the API took (the newest REPORTS_MAX). */
64
+ private readonly reported;
65
+ /** Approvals a report attempt is running or scheduled for. */
66
+ private readonly reporting;
67
+ /**
68
+ * Approvals still waiting for their report (the newest REPORTS_MAX), each with until when a
69
+ * confirmation that comes late reports it. One per approval, so a second payment on the
70
+ * same page never takes the first one's place.
71
+ */
72
+ private readonly awaiting;
73
+ constructor(onEvent?: ((event: TotalEvent) => void) | undefined, vault?: ProfileSource | undefined, waitMs?: number, settleMs?: number, retryDelaysMs?: readonly number[]);
74
+ /** A request left: remember its page, and record it when a priced profile's rules read it. */
75
+ sent(key: string, request: MerchantRequestSeen): void;
76
+ /** The page a request came from, when the adapter named one. */
77
+ pageOf(key: string): string | undefined;
78
+ /**
79
+ * The page a request came from, waiting up to `timeoutMs` for its own event to arrive when
80
+ * the adapter has not seen it yet; undefined when that event names none or never comes.
81
+ */
82
+ pageFor(key: string, timeoutMs: number, signal?: AbortSignal): Promise<string | undefined>;
83
+ /** Whether a paused request is the card request of an armed profile the merchant's own total prices. */
84
+ priced(url: string): boolean;
85
+ /** The capture the client reads for a card request that paused at `pausedAt`. */
86
+ capture(pausedAt: number, page: string | undefined): MerchantTotalCapture;
87
+ /**
88
+ * The read at release. Resolves when the card request may go out; otherwise retires the
89
+ * approval (the card never left this browser, so merchant_never_retried, which stops the
90
+ * API serving its ciphertext) and throws. The retirement is awaited: once the API confirms
91
+ * it, a MerchantTotalError at stage 'release'; when it does not (or this vault cannot
92
+ * retire), the approved row may still serve its ciphertext, so a PaymentOutcomeUnknownError
93
+ * ('authorization_cancel_unconfirmed') the application must reconcile, as every retirement
94
+ * the API did not confirm ends. `merchant_total_held` says which (`retired`).
95
+ */
96
+ release(replay: MerchantHostedReplay, vault: Canceller | undefined, signal?: AbortSignal): Promise<void>;
97
+ /**
98
+ * The merchant answered the continued card request (both adapters call this when its
99
+ * final answer arrives, whatever the profile's after-payment rules read): after a short
100
+ * settle for the rest of its confirmation, report what it says it charged, once per
101
+ * approval. The approval counts as reported only once the API took the report. One that
102
+ * could not go out (no confirmation read yet, or the report request failed) is tried again
103
+ * after each of MERCHANT_CHARGE_RETRY_DELAYS_MS; after the last, a confirmation the log reads
104
+ * within MERCHANT_CHARGE_REPORT_WINDOW_MS of the answer is still reported (lateConfirmation).
105
+ * The first attempt keeps a Node process alive, since the confirmation may already be read;
106
+ * later ones do not. Never throws; each outcome is an event, and `merchant_charge_unreported`
107
+ * says whether another attempt follows (`retrying`).
108
+ */
109
+ answered(replay: MerchantHostedReplay, vault: ChargeReporter | undefined): void;
110
+ /** A newly read answer: each approval's confirmation that came after its last attempt is reported now, under that approval. */
111
+ private lateConfirmation;
112
+ /** One report attempt, `tries` into its retries; never rejects. */
113
+ private attempt;
114
+ }
115
+ export {};
@@ -0,0 +1,268 @@
1
+ import { MerchantTotalError, PaymentOutcomeUnknownError } from './client.js';
2
+ import { MERCHANT_CHARGE_SETTLE_MS, MERCHANT_LOG_TEXT_MAX, MERCHANT_TOTAL_WAIT_MS, MerchantExchangeLog, merchantChargeReport, merchantProfilePricedBySource, merchantTotalAtRelease, } from './merchant-total.js';
3
+ import { MERCHANT_PROFILE_RECOGNIZERS } from './adyen-merchant-hosted.generated.js';
4
+ /** Every profile this build reviewed that the merchant's own responses price. */
5
+ const PRICED_IDS = MERCHANT_PROFILE_RECOGNIZERS
6
+ .map((profile) => profile.id).filter((id) => merchantProfilePricedBySource(id));
7
+ /** How many request pages are remembered, oldest dropped first. */
8
+ const PAGES_MAX = 512;
9
+ /**
10
+ * How long a paused card request's page is waited for. Chromium can deliver a request's
11
+ * Network event (which names its document) after the Fetch pause; it follows within
12
+ * milliseconds, so this bound only matters when it never comes, and the read then has no page.
13
+ */
14
+ export const MERCHANT_PAGE_WAIT_MS = 1_000;
15
+ /**
16
+ * The pauses before the after-payment report is tried again when it could not go out (no
17
+ * confirmation read yet, or the report request failed): a confirmation the page asks for
18
+ * after the card request's answer (Dick's order submit) still gets reported.
19
+ */
20
+ export const MERCHANT_CHARGE_RETRY_DELAYS_MS = [1_000, 3_000, 10_000];
21
+ /** How long after the merchant answered a confirmation that arrives late is still reported. */
22
+ export const MERCHANT_CHARGE_REPORT_WINDOW_MS = 10 * 60_000;
23
+ /** How many approvals the watch remembers as reported, or as waiting for a late confirmation. */
24
+ const REPORTS_MAX = 16;
25
+ /**
26
+ * The merchant's own total around one attached page, shared by the CDP and Playwright
27
+ * adapters so both read it the same way (merchant-total.ts says what it proves). From
28
+ * attach on, the adapter tells it about every request that leaves, every answer and every
29
+ * failure; it keeps, read-only, the ones a priced profile's rules read at the origins they
30
+ * read (a client's sandbox declaration moves a profile's to its declared endpoint), and
31
+ * the page each request came from. At a pause it hands the client a capture; at release it
32
+ * reads the total again and holds the card request unless it is still the approved amount;
33
+ * once the merchant answered the continued request it reports what the merchant says it
34
+ * charged.
35
+ */
36
+ export class MerchantTotalWatch {
37
+ onEvent;
38
+ vault;
39
+ waitMs;
40
+ settleMs;
41
+ retryDelaysMs;
42
+ log;
43
+ pages = new Map();
44
+ pageWaiters = new Map();
45
+ /** Approvals whose charge report the API took (the newest REPORTS_MAX). */
46
+ reported = new Set();
47
+ /** Approvals a report attempt is running or scheduled for. */
48
+ reporting = new Set();
49
+ /**
50
+ * Approvals still waiting for their report (the newest REPORTS_MAX), each with until when a
51
+ * confirmation that comes late reports it. One per approval, so a second payment on the
52
+ * same page never takes the first one's place.
53
+ */
54
+ awaiting = new Map();
55
+ constructor(onEvent, vault, waitMs = MERCHANT_TOTAL_WAIT_MS, settleMs = MERCHANT_CHARGE_SETTLE_MS, retryDelaysMs = MERCHANT_CHARGE_RETRY_DELAYS_MS) {
56
+ this.onEvent = onEvent;
57
+ this.vault = vault;
58
+ this.waitMs = waitMs;
59
+ this.settleMs = settleMs;
60
+ this.retryDelaysMs = retryDelaysMs;
61
+ // A client's declarations are fixed when it is built (VaultClientOptions.sandboxMerchants),
62
+ // so where each priced profile is read is settled once, here.
63
+ const scopes = PRICED_IDS.map((id) => {
64
+ let endpoint;
65
+ try {
66
+ endpoint = typeof vault?.merchantProfile === 'function' ? vault.merchantProfile(id)?.sandboxDeclaration?.endpoint : undefined;
67
+ }
68
+ catch {
69
+ endpoint = undefined;
70
+ }
71
+ return typeof endpoint === 'string' ? { id, declaredEndpoint: endpoint } : id;
72
+ });
73
+ // Every answer the log reads can be a confirmation that came after the report's last attempt.
74
+ this.log = new MerchantExchangeLog(() => scopes, Date.now, MERCHANT_LOG_TEXT_MAX, () => this.lateConfirmation());
75
+ }
76
+ /** A request left: remember its page, and record it when a priced profile's rules read it. */
77
+ sent(key, request) {
78
+ if (request.page) {
79
+ this.pages.delete(key);
80
+ this.pages.set(key, request.page);
81
+ while (this.pages.size > PAGES_MAX)
82
+ this.pages.delete(this.pages.keys().next().value);
83
+ }
84
+ this.log.sent(key, request);
85
+ // A pause waiting for this request's page learns it now (or that its event names none).
86
+ for (const waiter of [...(this.pageWaiters.get(key) ?? [])])
87
+ waiter();
88
+ }
89
+ /** The page a request came from, when the adapter named one. */
90
+ pageOf(key) {
91
+ return this.pages.get(key);
92
+ }
93
+ /**
94
+ * The page a request came from, waiting up to `timeoutMs` for its own event to arrive when
95
+ * the adapter has not seen it yet; undefined when that event names none or never comes.
96
+ */
97
+ async pageFor(key, timeoutMs, signal) {
98
+ const known = this.pages.get(key);
99
+ if (known !== undefined || timeoutMs <= 0 || signal?.aborted)
100
+ return known;
101
+ await new Promise((resolve) => {
102
+ const waiters = this.pageWaiters.get(key) ?? new Set();
103
+ const done = () => {
104
+ clearTimeout(timer);
105
+ waiters.delete(done);
106
+ if (waiters.size === 0 && this.pageWaiters.get(key) === waiters)
107
+ this.pageWaiters.delete(key);
108
+ signal?.removeEventListener('abort', done);
109
+ resolve();
110
+ };
111
+ const timer = setTimeout(done, timeoutMs);
112
+ waiters.add(done);
113
+ this.pageWaiters.set(key, waiters);
114
+ signal?.addEventListener('abort', done, { once: true });
115
+ });
116
+ return this.pages.get(key);
117
+ }
118
+ /** Whether a paused request is the card request of an armed profile the merchant's own total prices. */
119
+ priced(url) {
120
+ try {
121
+ const profile = typeof this.vault?.merchantProfileOf === 'function' ? this.vault.merchantProfileOf(url) : null;
122
+ return !!profile && merchantProfilePricedBySource(profile.id);
123
+ }
124
+ catch {
125
+ return false;
126
+ }
127
+ }
128
+ /** The capture the client reads for a card request that paused at `pausedAt`. */
129
+ capture(pausedAt, page) {
130
+ return this.log.capture(pausedAt, page, this.waitMs);
131
+ }
132
+ /**
133
+ * The read at release. Resolves when the card request may go out; otherwise retires the
134
+ * approval (the card never left this browser, so merchant_never_retried, which stops the
135
+ * API serving its ciphertext) and throws. The retirement is awaited: once the API confirms
136
+ * it, a MerchantTotalError at stage 'release'; when it does not (or this vault cannot
137
+ * retire), the approved row may still serve its ciphertext, so a PaymentOutcomeUnknownError
138
+ * ('authorization_cancel_unconfirmed') the application must reconcile, as every retirement
139
+ * the API did not confirm ends. `merchant_total_held` says which (`retired`).
140
+ */
141
+ async release(replay, vault, signal) {
142
+ if (!replay.merchantTotal)
143
+ return;
144
+ const refusal = await merchantTotalAtRelease(this.log, replay.profile, replay.merchantTotal, Date.now(), this.waitMs, signal);
145
+ if (!refusal)
146
+ return;
147
+ let retired = false;
148
+ if (typeof vault?.cancelAuthorization === 'function') {
149
+ try {
150
+ await vault.cancelAuthorization(replay.authorizationId, 'merchant_never_retried');
151
+ retired = true;
152
+ }
153
+ catch {
154
+ retired = false;
155
+ }
156
+ }
157
+ this.onEvent?.({ type: 'merchant_total_held', detail: { authorizationId: replay.authorizationId, profile: replay.profile, reason: refusal.code, retired } });
158
+ if (!retired)
159
+ throw new PaymentOutcomeUnknownError(replay.authorizationId, 'authorization_cancel_unconfirmed');
160
+ throw new MerchantTotalError(replay.authorizationId, 'merchant_total_changed', 'release', refusal.code, `The card request was held: ${refusal.reason}`);
161
+ }
162
+ /**
163
+ * The merchant answered the continued card request (both adapters call this when its
164
+ * final answer arrives, whatever the profile's after-payment rules read): after a short
165
+ * settle for the rest of its confirmation, report what it says it charged, once per
166
+ * approval. The approval counts as reported only once the API took the report. One that
167
+ * could not go out (no confirmation read yet, or the report request failed) is tried again
168
+ * after each of MERCHANT_CHARGE_RETRY_DELAYS_MS; after the last, a confirmation the log reads
169
+ * within MERCHANT_CHARGE_REPORT_WINDOW_MS of the answer is still reported (lateConfirmation).
170
+ * The first attempt keeps a Node process alive, since the confirmation may already be read;
171
+ * later ones do not. Never throws; each outcome is an event, and `merchant_charge_unreported`
172
+ * says whether another attempt follows (`retrying`).
173
+ */
174
+ answered(replay, vault) {
175
+ const id = replay.authorizationId;
176
+ if (!replay.merchantTotal || this.reported.has(id) || this.reporting.has(id))
177
+ return;
178
+ this.awaiting.delete(id);
179
+ this.awaiting.set(id, { replay, vault, until: Date.now() + MERCHANT_CHARGE_REPORT_WINDOW_MS });
180
+ while (this.awaiting.size > REPORTS_MAX)
181
+ this.awaiting.delete(this.awaiting.keys().next().value);
182
+ this.reporting.add(id);
183
+ setTimeout(() => { void this.attempt(replay, vault, 0); }, this.settleMs);
184
+ }
185
+ /** A newly read answer: each approval's confirmation that came after its last attempt is reported now, under that approval. */
186
+ lateConfirmation() {
187
+ const now = Date.now();
188
+ for (const [id, awaiting] of [...this.awaiting]) {
189
+ if (this.reported.has(id) || now > awaiting.until || !awaiting.replay.merchantTotal) {
190
+ this.awaiting.delete(id);
191
+ continue;
192
+ }
193
+ if (this.reporting.has(id))
194
+ continue;
195
+ let readable = false;
196
+ try {
197
+ readable = merchantChargeReport(this.log, awaiting.replay.profile, awaiting.replay.merchantTotal) !== null;
198
+ }
199
+ catch {
200
+ readable = false;
201
+ }
202
+ if (!readable)
203
+ continue;
204
+ this.reporting.add(id);
205
+ void this.attempt(awaiting.replay, awaiting.vault, this.retryDelaysMs.length);
206
+ }
207
+ }
208
+ /** One report attempt, `tries` into its retries; never rejects. */
209
+ async attempt(replay, vault, tries) {
210
+ const id = replay.authorizationId;
211
+ const approved = replay.merchantTotal;
212
+ const detail = { authorizationId: id, profile: replay.profile };
213
+ let retrying = false;
214
+ try {
215
+ if (!approved)
216
+ return;
217
+ // The confirmation's text is read after it finished: wait (bounded) for every answer
218
+ // the after-payment rules read, then report what arrived.
219
+ await this.log.settle(replay.profile, 'charged', approved.card, Date.now(), this.waitMs).catch(() => { });
220
+ const report = merchantChargeReport(this.log, replay.profile, approved);
221
+ let reason;
222
+ if (!report)
223
+ reason = 'no_confirmation_read';
224
+ else if (typeof vault?.reportMerchantCharge !== 'function')
225
+ reason = 'no_reporter';
226
+ else {
227
+ let result = null;
228
+ try {
229
+ result = await vault.reportMerchantCharge(id, report);
230
+ }
231
+ catch {
232
+ result = null;
233
+ }
234
+ if (result) {
235
+ this.reported.add(id);
236
+ while (this.reported.size > REPORTS_MAX)
237
+ this.reported.delete(this.reported.values().next().value);
238
+ this.awaiting.delete(id);
239
+ this.onEvent?.({ type: 'merchant_charge_reported', detail: { ...detail, verdict: result.verdict, alerted: result.alerted } });
240
+ return;
241
+ }
242
+ reason = 'report_failed';
243
+ }
244
+ // Only a confirmation still to come can change a missing one; a failed report or a
245
+ // missing reporter is not tried again by a later answer.
246
+ if (reason !== 'no_confirmation_read')
247
+ this.awaiting.delete(id);
248
+ // No reporter never changes, so it is not tried again at all.
249
+ retrying = reason !== 'no_reporter' && tries < this.retryDelaysMs.length;
250
+ this.onEvent?.({ type: 'merchant_charge_unreported', detail: { ...detail, reason, retrying } });
251
+ if (retrying)
252
+ later(() => { void this.attempt(replay, vault, tries + 1); }, this.retryDelaysMs[tries]);
253
+ }
254
+ catch {
255
+ // A report never breaks the page: an attempt that threw is the last one.
256
+ retrying = false;
257
+ }
258
+ finally {
259
+ if (!retrying)
260
+ this.reporting.delete(id);
261
+ }
262
+ }
263
+ }
264
+ /** A timer that never keeps a Node process alive on its own: a retry of the report is best effort. */
265
+ function later(run, ms) {
266
+ const timer = setTimeout(run, ms);
267
+ timer.unref?.();
268
+ }