@agent-cards/checkout 0.18.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 (44) hide show
  1. package/CHANGELOG.md +8 -0
  2. package/PREFLIGHT.md +4 -0
  3. package/README.md +91 -7
  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/cdp.d.ts +4 -1
  8. package/dist/cdp.js +416 -217
  9. package/dist/client.d.ts +236 -5
  10. package/dist/client.js +514 -11
  11. package/dist/cse-body.d.ts +25 -0
  12. package/dist/cse-body.js +41 -0
  13. package/dist/fiserv.d.ts +65 -0
  14. package/dist/fiserv.generated.d.ts +73 -0
  15. package/dist/fiserv.generated.js +830 -0
  16. package/dist/fiserv.js +104 -0
  17. package/dist/index.d.ts +7 -2
  18. package/dist/index.js +5 -1
  19. package/dist/lifecycle.d.ts +15 -1
  20. package/dist/lifecycle.js +28 -3
  21. package/dist/merchant-handoff.d.ts +54 -0
  22. package/dist/merchant-handoff.js +100 -0
  23. package/dist/merchant-hosted.d.ts +140 -0
  24. package/dist/merchant-hosted.js +170 -0
  25. package/dist/merchant-total-watch.d.ts +115 -0
  26. package/dist/merchant-total-watch.js +268 -0
  27. package/dist/merchant-total.d.ts +257 -0
  28. package/dist/merchant-total.js +383 -0
  29. package/dist/pre-claim.d.ts +123 -0
  30. package/dist/pre-claim.js +386 -0
  31. package/dist/preflight-catalog.json +132 -0
  32. package/dist/preflight-schemas.json +14 -2
  33. package/dist/preflight.generated.js +15 -1
  34. package/dist/preparation.d.ts +7 -0
  35. package/dist/preparation.js +33 -6
  36. package/dist/prepared-processor.d.ts +36 -3
  37. package/dist/prepared-processor.js +53 -3
  38. package/dist/registry.d.ts +45 -0
  39. package/dist/registry.js +14 -0
  40. package/dist/stripe-checkout.generated.js +96 -7
  41. package/dist/substitutions.generated.d.ts +2 -1
  42. package/dist/substitutions.generated.js +758 -6
  43. package/examples/preflight/kernel-native/inventory.json +1 -1
  44. package/package.json +3 -3
@@ -0,0 +1,257 @@
1
+ /** One merchant request this browser saw answered, as payment-core reads it. */
2
+ export interface MerchantExchange {
3
+ method: string;
4
+ /** The request's full URL, serialized. */
5
+ url: string;
6
+ /** The response's HTTP status. */
7
+ status: number;
8
+ /** When the response finished: ms since the epoch, this browser's clock. */
9
+ completedAt: number;
10
+ /** When the request left, same clock. */
11
+ sentAt?: number;
12
+ /** An opaque id of the tab and document the request came from. */
13
+ page?: string;
14
+ /** The request's JSON text. */
15
+ requestBody?: string;
16
+ /** The response text. */
17
+ body: string;
18
+ }
19
+ /** The paused card request the total is read for (payment-core MerchantAmountCard). */
20
+ export interface MerchantAmountCard {
21
+ url: string;
22
+ method: string;
23
+ body: string;
24
+ pausedAt: number;
25
+ checkoutOrigin?: string;
26
+ page?: string;
27
+ /** A sandbox declaration's endpoint: its own responses price it. */
28
+ declaredEndpoint?: string;
29
+ }
30
+ export type MerchantAmountStage = 'source' | 'charged';
31
+ type Refusal = {
32
+ ok: false;
33
+ code: string;
34
+ reason: string;
35
+ };
36
+ type Reading = {
37
+ ok: true;
38
+ amount: {
39
+ amount: number;
40
+ currency: string;
41
+ };
42
+ source: Record<string, unknown>;
43
+ } | Refusal;
44
+ /**
45
+ * A priced profile whose requests the log records: its id alone reads them at the profile's
46
+ * reviewed origins; with `declaredEndpoint` (this client's sandbox declaration for the
47
+ * profile, VaultClientOptions.sandboxMerchants) at that endpoint's origin instead.
48
+ */
49
+ export type MerchantTotalScope = string | {
50
+ id: string;
51
+ declaredEndpoint?: string;
52
+ };
53
+ /** How many exchanges one read takes: the API's limit on merchant_total and merchant_charge. */
54
+ export declare const MERCHANT_EXCHANGES_MAX: number;
55
+ /** The exchanges one read of a stage sends: its shapes at its origin, projected, the newest MERCHANT_EXCHANGES_MAX. */
56
+ export declare const merchantAmountExchanges: (profileId: string, stage: MerchantAmountStage, input: {
57
+ card: MerchantAmountCard;
58
+ responses: MerchantExchange[];
59
+ }) => {
60
+ ok: true;
61
+ responses: MerchantExchange[];
62
+ } | Refusal;
63
+ /** The reading the API makes of the merchant's total for a paused card request, its total as `{amount, currency}`. */
64
+ export declare const merchantAmountFromResponses: (profileId: string, input: {
65
+ card: MerchantAmountCard;
66
+ responses: MerchantExchange[];
67
+ }) => Reading;
68
+ /** Whether a reviewed profile is priced by the merchant's own responses (it names an amount source). */
69
+ export declare function merchantProfilePricedBySource(profileId: string): boolean;
70
+ /** What the adapter knows of a request when it leaves. */
71
+ export interface MerchantRequestSeen {
72
+ method: string;
73
+ url: string;
74
+ /** The request's JSON text, when it has one. */
75
+ requestBody?: string;
76
+ /** An opaque id of the tab and document the request came from. */
77
+ page?: string;
78
+ }
79
+ /** A response body past this many characters is recorded as unreadable (''). */
80
+ export declare const MERCHANT_BODY_READ_MAX: number;
81
+ /**
82
+ * The most request and response text the log holds at once, in characters: room for the
83
+ * MERCHANT_EXCHANGES_MAX newest exchanges one read sends at MERCHANT_BODY_READ_MAX each.
84
+ * Past it the oldest answered exchanges go first; a request still waiting for its answer
85
+ * goes only when waiting ones alone fill it, and reads refuse as stale until it ends.
86
+ */
87
+ export declare const MERCHANT_LOG_TEXT_MAX: number;
88
+ /**
89
+ * Every request this browser sent that a reviewed profile's amount rules could read, and
90
+ * its answer. The adapters feed it from their network events: sent() when a request
91
+ * leaves (recorded only when some profile's amount stage reads its shape at an origin that
92
+ * stage reads, payment-core merchantAmountRequestStages, so another shop's /checkout is
93
+ * never read or held), answered() with the status and the time the response finished,
94
+ * then read() with the response text (null when it could not be read), or failed(). It
95
+ * holds at most LOG_MAX requests and MERCHANT_LOG_TEXT_MAX characters of their text.
96
+ * Read-only: nothing here pauses, changes or answers a request.
97
+ */
98
+ export declare class MerchantExchangeLog {
99
+ /** The reviewed profiles whose amount rules decide what is recorded, and where (MerchantTotalScope). */
100
+ private readonly profiles;
101
+ private readonly now;
102
+ private readonly textMax;
103
+ /** Told after each answer the log reads (MerchantTotalWatch reports a confirmation that came late). */
104
+ private readonly onRead?;
105
+ private readonly entries;
106
+ private readonly waiters;
107
+ private text;
108
+ /**
109
+ * The requests the log dropped while they still waited for their answer, by key, with when
110
+ * they left and the stages they are read for (the newest LOST_MAX). Each one can still move
111
+ * what its stage reads, so every wait for that stage counts it as unanswered until the
112
+ * adapter reports it failed or answered.
113
+ */
114
+ private readonly lostWaiting;
115
+ /** More requests were lost at once than lostWaiting holds: no read of a total is trusted again. */
116
+ private lostOverflow;
117
+ /**
118
+ * When the newest answer came to a dropped request that is read for the total (a 'source'
119
+ * stage request), or null. The log never read that answer, which could carry another total,
120
+ * so every read of the total refuses as stale until a total request the page sent after it
121
+ * answers: the merchant's later word on its total, read in full. An answer of any other
122
+ * stage (a payment's) says nothing about the total and never clears it.
123
+ */
124
+ private unreadSourceAnswerAt;
125
+ constructor(
126
+ /** The reviewed profiles whose amount rules decide what is recorded, and where (MerchantTotalScope). */
127
+ profiles: () => readonly MerchantTotalScope[], now?: () => number, textMax?: number,
128
+ /** Told after each answer the log reads (MerchantTotalWatch reports a confirmation that came late). */
129
+ onRead?: (() => void) | undefined);
130
+ /** A request left; true when it is recorded. */
131
+ sent(key: string, request: MerchantRequestSeen, sentAt?: number): boolean;
132
+ /** Forgets one request and its text. */
133
+ private drop;
134
+ /**
135
+ * Past LOG_MAX requests or MERCHANT_LOG_TEXT_MAX characters, drops the oldest answered
136
+ * exchanges first. Only when the requests still waiting fill the log on their own do the
137
+ * oldest of them go too: the log keeps only their keys (lostWaiting), so it stays bounded
138
+ * while every read still waits for them and refuses the total as unknown.
139
+ */
140
+ private trim;
141
+ /** Whether a request is recorded (its answer is then worth reading). */
142
+ tracks(key: string): boolean;
143
+ /** The response's status arrived. */
144
+ answered(key: string, status: number): void;
145
+ /** The page a request's answer belongs to, when only its answer says (a document the frame then shows). */
146
+ repage(key: string, page: string): void;
147
+ /**
148
+ * The response finished at `completedAt` (taken when it finished, before its text was
149
+ * read), with this text, or null when it could not be read (it then reads as empty).
150
+ */
151
+ read(key: string, body: string | null, completedAt?: number): void;
152
+ /** The request failed or was cancelled: it has no answer to read. */
153
+ failed(key: string): void;
154
+ /** Every answered exchange, in the order the requests left. */
155
+ exchanges(): MerchantExchange[];
156
+ /** The unanswered requests, sent at or before `before`, that can change what a stage reads for this card request. */
157
+ pending(profileId: string, stage: MerchantAmountStage, card: MerchantAmountCard, before: number): number;
158
+ /**
159
+ * Resolves once no request sent at or before `before` that can change what the stage
160
+ * reads is unanswered, with every answered exchange; rejects with a MerchantTotalWait
161
+ * when one is still unanswered after `timeoutMs`, or when `signal` aborts first.
162
+ */
163
+ settle(profileId: string, stage: MerchantAmountStage, card: MerchantAmountCard, before: number, timeoutMs: number, signal?: AbortSignal): Promise<MerchantExchange[]>;
164
+ /** The capture the client reads for a card request that paused now, on `page`. */
165
+ capture(pausedAt: number, page: string | undefined, waitMs?: number): MerchantTotalCapture;
166
+ private changed;
167
+ }
168
+ /** How long the SDK waits for the merchant's own requests to answer before it reads the total. */
169
+ export declare const MERCHANT_TOTAL_WAIT_MS = 5000;
170
+ /** A request that can change the total was still unanswered when the SDK had to read it. */
171
+ export declare class MerchantTotalWait extends Error {
172
+ readonly open: number;
173
+ constructor(open: number);
174
+ }
175
+ /**
176
+ * What an adapter hands the client for a card request priced by the merchant's total:
177
+ * when it paused, on which page, and the exchanges it recorded once the requests that can
178
+ * change the total have answered (bounded; see MerchantExchangeLog.settle). A raw CDP
179
+ * runtime that calls authorize() itself builds one from its own network events.
180
+ */
181
+ export interface MerchantTotalCapture {
182
+ /** When the card request paused: ms since the epoch, this browser's clock. */
183
+ pausedAt: number;
184
+ /** An opaque id of the tab and document the card request came from. */
185
+ page?: string;
186
+ exchanges(profileId: string, stage: MerchantAmountStage, card: MerchantAmountCard, signal?: AbortSignal): Promise<MerchantExchange[]>;
187
+ }
188
+ /** The merchant's total a merchant-hosted approval was created at, for the check at release. */
189
+ export interface MerchantTotalApproved {
190
+ card: MerchantAmountCard;
191
+ /** The approved total: `amount` in the currency's smallest unit. */
192
+ amount: {
193
+ amount: number;
194
+ currency: string;
195
+ };
196
+ }
197
+ /** merchant_total on the create's wire: snake_case, as the API takes it. */
198
+ export declare function merchantTotalWire(pausedAt: number, page: string | undefined, responses: readonly MerchantExchange[]): {
199
+ responses: {
200
+ body: string;
201
+ request_body?: string | undefined;
202
+ page?: string | undefined;
203
+ sent_at?: number | undefined;
204
+ method: string;
205
+ url: string;
206
+ status: number;
207
+ completed_at: number;
208
+ }[];
209
+ page?: string | undefined;
210
+ paused_at: number;
211
+ };
212
+ /** One exchange on the wire. */
213
+ export declare function exchangeWire(exchange: MerchantExchange): {
214
+ body: string;
215
+ request_body?: string | undefined;
216
+ page?: string | undefined;
217
+ sent_at?: number | undefined;
218
+ method: string;
219
+ url: string;
220
+ status: number;
221
+ completed_at: number;
222
+ };
223
+ /**
224
+ * The read at release: the same reading as at the create, with the release moment as the
225
+ * pause, over what the log holds once the requests that can move the total have answered.
226
+ * null when the card request may go out; otherwise why it is held: a request still
227
+ * unanswered ('stale'), a reading that fails, or a total that is not the approved amount
228
+ * ('changed'). The API bounds the total's age when it serves the card, but never sees the
229
+ * responses between the pause and the release: this read does.
230
+ */
231
+ export declare function merchantTotalAtRelease(log: MerchantExchangeLog, profileId: string, approved: MerchantTotalApproved, releasedAt: number, waitMs?: number, signal?: AbortSignal): Promise<Refusal | null>;
232
+ /** How long after the merchant answers the card request the SDK waits for the rest of its confirmation. */
233
+ export declare const MERCHANT_CHARGE_SETTLE_MS = 500;
234
+ /**
235
+ * The after-payment report (POST /v2/checkout/authorizations/:id/merchant_charge): the
236
+ * paused card request the create read and the merchant's answers after it that the
237
+ * profile's after-payment rules read, projected; null when the profile reads none, none
238
+ * came, or none the API's reading ties to this card request.
239
+ */
240
+ export declare function merchantChargeReport(log: MerchantExchangeLog, profileId: string, approved: MerchantTotalApproved): {
241
+ request: {
242
+ url: string;
243
+ method: string;
244
+ body: string;
245
+ };
246
+ responses: {
247
+ body: string;
248
+ request_body?: string | undefined;
249
+ page?: string | undefined;
250
+ sent_at?: number | undefined;
251
+ method: string;
252
+ url: string;
253
+ status: number;
254
+ completed_at: number;
255
+ }[];
256
+ } | null;
257
+ export {};
@@ -0,0 +1,383 @@
1
+ /**
2
+ * The merchant's own total, as this browser saw it (payment-core merchant-amount.js).
3
+ *
4
+ * A reviewed Adyen merchant-hosted profile's card request goes to the merchant's own
5
+ * server, which picks what it charges, and several merchants' card bodies name no amount
6
+ * at all. So a profile can name an amount source: the merchant's own checkout responses
7
+ * that carry the grand total. The adapters record those responses read-only from the
8
+ * moment they attach (they never pause, change or answer one), the client sends the ones
9
+ * the profile's rules read with the create, and the API refuses a payment whose agent
10
+ * amount is not that total. After payment the adapters report what the merchant's
11
+ * confirmation says it charged, and the API alerts on a higher charge.
12
+ *
13
+ * What this proves: the total is what the agent's browser session saw. It catches a
14
+ * mistyped amount and a cart that changed under the agent; it cannot tell a merchant's
15
+ * response from one the agent wrote, so every profile priced this way stays in observe.
16
+ *
17
+ * What the SDK owes payment-core (the header of merchant-amount.js): every exchange of
18
+ * the profile's shapes, projected, with the time it left; a wait for any such request
19
+ * still unanswered before it reads the total; the newest MERCHANT_EXCHANGES_MAX; and at
20
+ * release the same read with the release moment as the pause, the card request held
21
+ * unless the total is still the approved amount.
22
+ */
23
+ import { MERCHANT_EXCHANGES_MAX as SHARED_MAX, MERCHANT_PROFILE_RECOGNIZERS as SHARED_PROFILES, merchantAmountExchanges as sharedExchanges, merchantChargedFromResponses as sharedCharged, merchantTotalFromResponses as sharedRead, merchantAmountPending as sharedPending, merchantAmountStagesOf as sharedStages, } from './adyen-merchant-hosted.generated.js';
24
+ /** How many exchanges one read takes: the API's limit on merchant_total and merchant_charge. */
25
+ export const MERCHANT_EXCHANGES_MAX = SHARED_MAX;
26
+ const stagesOf = sharedStages;
27
+ const pendingFor = sharedPending;
28
+ /** The exchanges one read of a stage sends: its shapes at its origin, projected, the newest MERCHANT_EXCHANGES_MAX. */
29
+ export const merchantAmountExchanges = sharedExchanges;
30
+ /** The reading the API makes of the merchant's total for a paused card request, its total as `{amount, currency}`. */
31
+ export const merchantAmountFromResponses = sharedRead;
32
+ /** Whether the API's own after-payment reading reads a charge from these exchanges for this card request. */
33
+ const chargeReads = (profileId, input) => sharedCharged(profileId, input).ok === true;
34
+ const PRICED = new Set(SHARED_PROFILES
35
+ .filter((profile) => profile.amountSource !== undefined && profile.amountSource !== null).map((profile) => profile.id));
36
+ /** Whether a reviewed profile is priced by the merchant's own responses (it names an amount source). */
37
+ export function merchantProfilePricedBySource(profileId) {
38
+ return PRICED.has(profileId);
39
+ }
40
+ /** How many dropped, still unanswered requests the log keeps the keys of; past it, every read refuses. */
41
+ const LOST_MAX = 4096;
42
+ /**
43
+ * The most requests the log keeps, the oldest answered one dropped first: far more than any
44
+ * profile's rules read. A request still waiting for its answer goes only when waiting ones
45
+ * alone fill the log, and the log then counts it as unanswered until it ends (see trim).
46
+ */
47
+ const LOG_MAX = 256;
48
+ /** A response body past this many characters is recorded as unreadable (''). */
49
+ export const MERCHANT_BODY_READ_MAX = 1024 * 1024;
50
+ /**
51
+ * The most request and response text the log holds at once, in characters: room for the
52
+ * MERCHANT_EXCHANGES_MAX newest exchanges one read sends at MERCHANT_BODY_READ_MAX each.
53
+ * Past it the oldest answered exchanges go first; a request still waiting for its answer
54
+ * goes only when waiting ones alone fill it, and reads refuse as stale until it ends.
55
+ */
56
+ export const MERCHANT_LOG_TEXT_MAX = MERCHANT_EXCHANGES_MAX * MERCHANT_BODY_READ_MAX;
57
+ const scopeId = (scope) => (typeof scope === 'string' ? scope : scope.id);
58
+ const entryText = (entry) => (entry.body?.length ?? 0) + (entry.requestBody?.length ?? 0);
59
+ /**
60
+ * Every request this browser sent that a reviewed profile's amount rules could read, and
61
+ * its answer. The adapters feed it from their network events: sent() when a request
62
+ * leaves (recorded only when some profile's amount stage reads its shape at an origin that
63
+ * stage reads, payment-core merchantAmountRequestStages, so another shop's /checkout is
64
+ * never read or held), answered() with the status and the time the response finished,
65
+ * then read() with the response text (null when it could not be read), or failed(). It
66
+ * holds at most LOG_MAX requests and MERCHANT_LOG_TEXT_MAX characters of their text.
67
+ * Read-only: nothing here pauses, changes or answers a request.
68
+ */
69
+ export class MerchantExchangeLog {
70
+ profiles;
71
+ now;
72
+ textMax;
73
+ onRead;
74
+ entries = new Map();
75
+ waiters = new Set();
76
+ text = 0;
77
+ /**
78
+ * The requests the log dropped while they still waited for their answer, by key, with when
79
+ * they left and the stages they are read for (the newest LOST_MAX). Each one can still move
80
+ * what its stage reads, so every wait for that stage counts it as unanswered until the
81
+ * adapter reports it failed or answered.
82
+ */
83
+ lostWaiting = new Map();
84
+ /** More requests were lost at once than lostWaiting holds: no read of a total is trusted again. */
85
+ lostOverflow = false;
86
+ /**
87
+ * When the newest answer came to a dropped request that is read for the total (a 'source'
88
+ * stage request), or null. The log never read that answer, which could carry another total,
89
+ * so every read of the total refuses as stale until a total request the page sent after it
90
+ * answers: the merchant's later word on its total, read in full. An answer of any other
91
+ * stage (a payment's) says nothing about the total and never clears it.
92
+ */
93
+ unreadSourceAnswerAt = null;
94
+ constructor(
95
+ /** The reviewed profiles whose amount rules decide what is recorded, and where (MerchantTotalScope). */
96
+ profiles, now = Date.now, textMax = MERCHANT_LOG_TEXT_MAX,
97
+ /** Told after each answer the log reads (MerchantTotalWatch reports a confirmation that came late). */
98
+ onRead) {
99
+ this.profiles = profiles;
100
+ this.now = now;
101
+ this.textMax = textMax;
102
+ this.onRead = onRead;
103
+ }
104
+ /** A request left; true when it is recorded. */
105
+ sent(key, request, sentAt = this.now()) {
106
+ if (typeof request?.url !== 'string' || typeof request.method !== 'string')
107
+ return false;
108
+ const probe = { method: request.method.toUpperCase(), url: request.url,
109
+ ...(typeof request.requestBody === 'string' ? { requestBody: request.requestBody } : {}) };
110
+ const stages = new Set();
111
+ for (const scope of this.profiles()) {
112
+ try {
113
+ for (const stage of stagesOf(scopeId(scope), probe, typeof scope === 'string' || scope.declaredEndpoint === undefined
114
+ ? undefined : { declaredEndpoint: scope.declaredEndpoint }))
115
+ stages.add(stage);
116
+ }
117
+ catch { /* not a request this profile reads */ }
118
+ }
119
+ if (stages.size === 0)
120
+ return false;
121
+ this.drop(key);
122
+ const entry = { ...probe, ...(request.page ? { page: request.page } : {}), sentAt, stages: [...stages], settled: false, failed: false };
123
+ this.entries.set(key, entry);
124
+ this.text += entryText(entry);
125
+ this.trim();
126
+ this.changed();
127
+ return true;
128
+ }
129
+ /** Forgets one request and its text. */
130
+ drop(key) {
131
+ const entry = this.entries.get(key);
132
+ if (!entry)
133
+ return;
134
+ this.text -= entryText(entry);
135
+ this.entries.delete(key);
136
+ }
137
+ /**
138
+ * Past LOG_MAX requests or MERCHANT_LOG_TEXT_MAX characters, drops the oldest answered
139
+ * exchanges first. Only when the requests still waiting fill the log on their own do the
140
+ * oldest of them go too: the log keeps only their keys (lostWaiting), so it stays bounded
141
+ * while every read still waits for them and refuses the total as unknown.
142
+ */
143
+ trim() {
144
+ const over = () => this.entries.size > LOG_MAX || this.text > this.textMax;
145
+ if (!over())
146
+ return;
147
+ for (const [key, entry] of this.entries) {
148
+ if (!over())
149
+ return;
150
+ if (entry.settled)
151
+ this.drop(key);
152
+ }
153
+ for (const [key, entry] of this.entries) {
154
+ if (!over())
155
+ return;
156
+ this.lostWaiting.set(key, { sentAt: entry.sentAt, stages: entry.stages });
157
+ if (this.lostWaiting.size > LOST_MAX) {
158
+ this.lostWaiting.delete(this.lostWaiting.keys().next().value);
159
+ this.lostOverflow = true;
160
+ }
161
+ this.drop(key);
162
+ }
163
+ }
164
+ /** Whether a request is recorded (its answer is then worth reading). */
165
+ tracks(key) {
166
+ return this.entries.has(key);
167
+ }
168
+ /** The response's status arrived. */
169
+ answered(key, status) {
170
+ const entry = this.entries.get(key);
171
+ if (!entry) {
172
+ // A request the log dropped got its answer, which the log will never read.
173
+ const lost = this.lostWaiting.get(key);
174
+ if (lost) {
175
+ this.lostWaiting.delete(key);
176
+ if (lost.stages.includes('source'))
177
+ this.unreadSourceAnswerAt = Math.max(this.unreadSourceAnswerAt ?? 0, this.now());
178
+ this.changed();
179
+ }
180
+ return;
181
+ }
182
+ if (entry.settled || !Number.isInteger(status))
183
+ return;
184
+ entry.status = status;
185
+ }
186
+ /** The page a request's answer belongs to, when only its answer says (a document the frame then shows). */
187
+ repage(key, page) {
188
+ const entry = this.entries.get(key);
189
+ if (entry && !entry.settled)
190
+ entry.page = page;
191
+ }
192
+ /**
193
+ * The response finished at `completedAt` (taken when it finished, before its text was
194
+ * read), with this text, or null when it could not be read (it then reads as empty).
195
+ */
196
+ read(key, body, completedAt = this.now()) {
197
+ const entry = this.entries.get(key);
198
+ if (!entry || entry.settled)
199
+ return;
200
+ entry.completedAt = completedAt;
201
+ entry.body = typeof body === 'string' && body.length <= MERCHANT_BODY_READ_MAX ? body : '';
202
+ this.text += entry.body.length;
203
+ entry.settled = true;
204
+ if (this.unreadSourceAnswerAt !== null && entry.stages.includes('source') && entry.sentAt > this.unreadSourceAnswerAt) {
205
+ this.unreadSourceAnswerAt = null;
206
+ }
207
+ this.trim();
208
+ this.changed();
209
+ try {
210
+ this.onRead?.();
211
+ }
212
+ catch { /* a listener never breaks the log */ }
213
+ }
214
+ /** The request failed or was cancelled: it has no answer to read. */
215
+ failed(key) {
216
+ const entry = this.entries.get(key);
217
+ if (!entry) {
218
+ // A request the log dropped failed: no answer came, so it moved nothing.
219
+ if (this.lostWaiting.delete(key))
220
+ this.changed();
221
+ return;
222
+ }
223
+ if (entry.settled)
224
+ return;
225
+ entry.settled = true;
226
+ entry.failed = true;
227
+ this.trim();
228
+ this.changed();
229
+ }
230
+ /** Every answered exchange, in the order the requests left. */
231
+ exchanges() {
232
+ const out = [];
233
+ for (const entry of this.entries.values()) {
234
+ if (!entry.settled || entry.failed || entry.status === undefined || entry.completedAt === undefined)
235
+ continue;
236
+ out.push({ method: entry.method, url: entry.url, status: entry.status, completedAt: entry.completedAt, sentAt: entry.sentAt,
237
+ ...(entry.page ? { page: entry.page } : {}), ...(entry.requestBody !== undefined ? { requestBody: entry.requestBody } : {}),
238
+ body: entry.body ?? '' });
239
+ }
240
+ return out;
241
+ }
242
+ /** The unanswered requests, sent at or before `before`, that can change what a stage reads for this card request. */
243
+ pending(profileId, stage, card, before) {
244
+ // What the log dropped still counts: a request of this stage still waiting, and a total it never read.
245
+ let count = this.lostOverflow ? 1 : 0;
246
+ for (const lost of this.lostWaiting.values())
247
+ if (lost.sentAt <= before && lost.stages.includes(stage))
248
+ count += 1;
249
+ if (stage === 'source' && this.unreadSourceAnswerAt !== null && this.unreadSourceAnswerAt <= before)
250
+ count += 1;
251
+ for (const entry of this.entries.values()) {
252
+ if (entry.settled || entry.sentAt > before)
253
+ continue;
254
+ try {
255
+ if (pendingFor(profileId, stage, card, entry))
256
+ count += 1;
257
+ }
258
+ catch {
259
+ count += 1;
260
+ }
261
+ }
262
+ return count;
263
+ }
264
+ /**
265
+ * Resolves once no request sent at or before `before` that can change what the stage
266
+ * reads is unanswered, with every answered exchange; rejects with a MerchantTotalWait
267
+ * when one is still unanswered after `timeoutMs`, or when `signal` aborts first.
268
+ */
269
+ async settle(profileId, stage, card, before, timeoutMs, signal) {
270
+ const deadline = this.now() + timeoutMs;
271
+ for (;;) {
272
+ if (signal?.aborted)
273
+ throw signal.reason ?? new Error('aborted');
274
+ const open = this.pending(profileId, stage, card, before);
275
+ if (open === 0)
276
+ return this.exchanges();
277
+ const left = deadline - this.now();
278
+ if (left <= 0)
279
+ throw new MerchantTotalWait(open);
280
+ await new Promise((resolve) => {
281
+ const done = () => { clearTimeout(timer); this.waiters.delete(done); signal?.removeEventListener('abort', done); resolve(); };
282
+ const timer = setTimeout(done, Math.min(left, 250));
283
+ this.waiters.add(done);
284
+ signal?.addEventListener('abort', done, { once: true });
285
+ });
286
+ }
287
+ }
288
+ /** The capture the client reads for a card request that paused now, on `page`. */
289
+ capture(pausedAt, page, waitMs = MERCHANT_TOTAL_WAIT_MS) {
290
+ return {
291
+ pausedAt,
292
+ ...(page ? { page } : {}),
293
+ exchanges: (profileId, stage, card, signal) => this.settle(profileId, stage, card, card.pausedAt, waitMs, signal),
294
+ };
295
+ }
296
+ changed() {
297
+ for (const waiter of [...this.waiters])
298
+ waiter();
299
+ }
300
+ }
301
+ /** How long the SDK waits for the merchant's own requests to answer before it reads the total. */
302
+ export const MERCHANT_TOTAL_WAIT_MS = 5_000;
303
+ /** A request that can change the total was still unanswered when the SDK had to read it. */
304
+ export class MerchantTotalWait extends Error {
305
+ open;
306
+ constructor(open) {
307
+ super(`${open} of the merchant's own checkout requests had not answered, so its total was not known`);
308
+ this.open = open;
309
+ this.name = 'MerchantTotalWait';
310
+ }
311
+ }
312
+ /** merchant_total on the create's wire: snake_case, as the API takes it. */
313
+ export function merchantTotalWire(pausedAt, page, responses) {
314
+ return {
315
+ paused_at: pausedAt,
316
+ ...(page ? { page } : {}),
317
+ responses: responses.map(exchangeWire),
318
+ };
319
+ }
320
+ /** One exchange on the wire. */
321
+ export function exchangeWire(exchange) {
322
+ return {
323
+ method: exchange.method, url: exchange.url, status: exchange.status, completed_at: exchange.completedAt,
324
+ ...(exchange.sentAt !== undefined ? { sent_at: exchange.sentAt } : {}),
325
+ ...(exchange.page !== undefined ? { page: exchange.page } : {}),
326
+ ...(exchange.requestBody !== undefined ? { request_body: exchange.requestBody } : {}),
327
+ body: exchange.body,
328
+ };
329
+ }
330
+ /**
331
+ * The read at release: the same reading as at the create, with the release moment as the
332
+ * pause, over what the log holds once the requests that can move the total have answered.
333
+ * null when the card request may go out; otherwise why it is held: a request still
334
+ * unanswered ('stale'), a reading that fails, or a total that is not the approved amount
335
+ * ('changed'). The API bounds the total's age when it serves the card, but never sees the
336
+ * responses between the pause and the release: this read does.
337
+ */
338
+ export async function merchantTotalAtRelease(log, profileId, approved, releasedAt, waitMs = MERCHANT_TOTAL_WAIT_MS, signal) {
339
+ const card = { ...approved.card, pausedAt: releasedAt };
340
+ let seen;
341
+ try {
342
+ seen = await log.settle(profileId, 'source', card, releasedAt, waitMs, signal);
343
+ }
344
+ catch (error) {
345
+ if (error instanceof MerchantTotalWait)
346
+ return { ok: false, code: 'stale', reason: error.message };
347
+ throw error;
348
+ }
349
+ const sent = merchantAmountExchanges(profileId, 'source', { card, responses: seen });
350
+ if (!sent.ok)
351
+ return sent;
352
+ const read = merchantAmountFromResponses(profileId, { card, responses: sent.responses });
353
+ if (!read.ok)
354
+ return read;
355
+ const want = approved.amount;
356
+ if (read.amount.amount !== want.amount || read.amount.currency.toLowerCase() !== want.currency.toLowerCase()) {
357
+ return { ok: false, code: 'changed',
358
+ reason: `the merchant's total is now ${read.amount.amount} ${read.amount.currency}, not the ${want.amount} ${want.currency.toLowerCase()} the cardholder approved` };
359
+ }
360
+ return null;
361
+ }
362
+ /** How long after the merchant answers the card request the SDK waits for the rest of its confirmation. */
363
+ export const MERCHANT_CHARGE_SETTLE_MS = 500;
364
+ /**
365
+ * The after-payment report (POST /v2/checkout/authorizations/:id/merchant_charge): the
366
+ * paused card request the create read and the merchant's answers after it that the
367
+ * profile's after-payment rules read, projected; null when the profile reads none, none
368
+ * came, or none the API's reading ties to this card request.
369
+ */
370
+ export function merchantChargeReport(log, profileId, approved) {
371
+ const after = log.exchanges().filter((exchange) => exchange.completedAt >= approved.card.pausedAt);
372
+ const sent = merchantAmountExchanges(profileId, 'charged', { card: approved.card, responses: after });
373
+ if (!sent.ok || sent.responses.length === 0)
374
+ return null;
375
+ // Only a confirmation the API's own reading ties to this card request counts: another
376
+ // payment's on the same page (another order) is never sent under this approval.
377
+ if (!chargeReads(profileId, { card: approved.card, responses: sent.responses }))
378
+ return null;
379
+ return {
380
+ request: { url: approved.card.url, method: approved.card.method, body: approved.card.body },
381
+ responses: sent.responses.map(exchangeWire),
382
+ };
383
+ }