@agentsbloom/sdk 0.4.0 → 0.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/lib/money.js ADDED
@@ -0,0 +1,283 @@
1
+ /**
2
+ * Exact money handling for the AgentsBloom SDK.
3
+ *
4
+ * Why this file exists
5
+ * -------------------
6
+ * Every monetary value in the previous implementation was an IEEE-754
7
+ * double, and the conversion to minor units was `Math.round(Number(x) * 100)`.
8
+ * That is wrong in a way that costs real money:
9
+ *
10
+ * 1.005 * 100 === 100.49999999999999 -> Math.round -> 100 ($1.00)
11
+ * 1.015 * 100 === 101.49999999999999 -> Math.round -> 101 ($1.01)
12
+ * 2.675 * 100 === 267.49999999999994 -> Math.round -> 267 ($2.67)
13
+ *
14
+ * A price that a human wrote as $1.005 is silently charged as $1.00, and the
15
+ * same wrong number is what gets hashed into a Cart Mandate. Budget
16
+ * comparisons had the same problem from the other direction: `orderTotal >
17
+ * maxBudget` on doubles can accept a total that is a fraction of a cent over
18
+ * the authorized cap, and accumulating `sum += q * u` across a large cart can
19
+ * exceed 2^53 and lose precision entirely.
20
+ *
21
+ * The fix is to never multiply a float. Values arrive as decimal strings or
22
+ * numbers, are converted to their exact shortest decimal representation, and
23
+ * are then shifted by the required number of places using integer (BigInt)
24
+ * arithmetic with explicit half-up rounding.
25
+ *
26
+ * Everything here is total: nothing throws for bad input, it returns `null`
27
+ * so callers make a deliberate fail-closed decision.
28
+ */
29
+
30
+ /**
31
+ * ISO 4217 currencies whose minor unit is NOT two decimal places. Anything
32
+ * absent from this table is treated as exponent 2, which is correct for the
33
+ * overwhelming majority of codes.
34
+ */
35
+ const CURRENCY_EXPONENTS = new Map(Object.entries({
36
+ // Zero-decimal currencies.
37
+ BIF: 0, CLP: 0, DJF: 0, GNF: 0, ISK: 0, JPY: 0, KMF: 0, KRW: 0, PYG: 0,
38
+ RWF: 0, UGX: 0, UYI: 0, VND: 0, VUV: 0, XAF: 0, XOF: 0, XPF: 0,
39
+ // Three-decimal currencies.
40
+ BHD: 3, IQD: 3, JOD: 3, KWD: 3, LYD: 3, OMR: 3, TND: 3,
41
+ // Four-decimal currencies.
42
+ CLF: 4, UYW: 4,
43
+ }));
44
+
45
+ /**
46
+ * Internal comparison scale. Six places is more than any real currency needs
47
+ * (the deepest ISO 4217 minor unit is four), so scaling both sides of a
48
+ * budget comparison to micros is exact for every currency without needing to
49
+ * know which currency is in play.
50
+ */
51
+ export const COMPARISON_SCALE = 6;
52
+
53
+ /** Longest accepted numeric input; guards BigInt work on hostile input. */
54
+ const MAX_NUMERIC_LENGTH = 32;
55
+
56
+ /** Hard ceiling on a single monetary amount (1 trillion major units). */
57
+ const MAX_MAJOR_UNITS = 1_000_000_000_000n;
58
+
59
+ /**
60
+ * Returns the number of minor-unit decimal places for a currency code.
61
+ *
62
+ * @param {unknown} currency
63
+ * @returns {number}
64
+ */
65
+ export function currencyExponent(currency) {
66
+ const code = normalizeCurrencyCode(currency);
67
+ if (!code) return 2;
68
+ return CURRENCY_EXPONENTS.has(code) ? CURRENCY_EXPONENTS.get(code) : 2;
69
+ }
70
+
71
+ /**
72
+ * Normalizes a currency code to uppercase, or returns null when it is not a
73
+ * syntactically valid ISO 4217 alphabetic code.
74
+ *
75
+ * @param {unknown} currency
76
+ * @returns {string|null}
77
+ */
78
+ export function normalizeCurrencyCode(currency) {
79
+ if (typeof currency !== 'string') return null;
80
+ const code = currency.trim().toUpperCase();
81
+ return /^[A-Z]{3}$/.test(code) ? code : null;
82
+ }
83
+
84
+ /**
85
+ * Converts a number or numeric string to its exact decimal representation,
86
+ * expanding exponent notation. Returns null for anything non-finite or
87
+ * malformed.
88
+ *
89
+ * `String(n)` gives JavaScript's shortest round-trip decimal, which is the
90
+ * value the developer actually wrote for any literal they could have typed —
91
+ * so this is a faithful, not lossy, starting point.
92
+ *
93
+ * @param {unknown} value
94
+ * @returns {string|null} e.g. '-12.345'
95
+ */
96
+ export function toDecimalString(value) {
97
+ let text;
98
+ if (typeof value === 'number') {
99
+ if (!Number.isFinite(value)) return null;
100
+ text = String(value);
101
+ } else if (typeof value === 'bigint') {
102
+ text = value.toString();
103
+ } else if (typeof value === 'string') {
104
+ text = value.trim();
105
+ } else {
106
+ return null;
107
+ }
108
+
109
+ if (text === '' || text.length > MAX_NUMERIC_LENGTH) return null;
110
+
111
+ const match = /^([+-]?)(\d*)(?:\.(\d*))?(?:[eE]([+-]?\d{1,3}))?$/.exec(text);
112
+ if (!match) return null;
113
+ const [, rawSign, rawInt = '', rawFrac = '', rawExp] = match;
114
+ if (rawInt === '' && rawFrac === '') return null;
115
+
116
+ const sign = rawSign === '-' ? '-' : '';
117
+ let digits = `${rawInt}${rawFrac}`;
118
+ // Decimal point position measured from the left of `digits`.
119
+ let pointIndex = rawInt.length;
120
+
121
+ if (rawExp !== undefined) {
122
+ pointIndex += Number(rawExp);
123
+ }
124
+
125
+ if (pointIndex <= 0) {
126
+ digits = '0'.repeat(1 - pointIndex) + digits;
127
+ pointIndex = 1;
128
+ }
129
+ if (pointIndex > digits.length) {
130
+ digits += '0'.repeat(pointIndex - digits.length);
131
+ }
132
+
133
+ const intPart = digits.slice(0, pointIndex).replace(/^0+(?=\d)/, '');
134
+ const fracPart = digits.slice(pointIndex).replace(/0+$/, '');
135
+ return fracPart ? `${sign}${intPart}.${fracPart}` : `${sign}${intPart}`;
136
+ }
137
+
138
+ /**
139
+ * Converts a monetary value to an exact integer scaled by 10^scale, rounding
140
+ * half away from zero. Returns null when the value is not a finite number,
141
+ * is malformed, or exceeds the sanity ceiling.
142
+ *
143
+ * @param {unknown} value
144
+ * @param {number} scale - number of decimal places to keep
145
+ * @returns {bigint|null}
146
+ */
147
+ export function toScaledBigInt(value, scale) {
148
+ if (!Number.isInteger(scale) || scale < 0 || scale > 12) return null;
149
+ const decimal = toDecimalString(value);
150
+ if (decimal === null) return null;
151
+
152
+ const negative = decimal.startsWith('-');
153
+ const unsigned = negative ? decimal.slice(1) : decimal;
154
+ const [intPart, fracPart = ''] = unsigned.split('.');
155
+
156
+ if (intPart.length > 16) return null;
157
+
158
+ const kept = fracPart.slice(0, scale).padEnd(scale, '0');
159
+ const dropped = fracPart.slice(scale);
160
+
161
+ let scaled = BigInt(`${intPart}${kept}` || '0');
162
+ // Half-up rounding on the discarded remainder: '5...' rounds away from zero.
163
+ if (dropped.length > 0 && dropped.charCodeAt(0) >= 0x35) {
164
+ scaled += 1n;
165
+ }
166
+
167
+ if (scaled > MAX_MAJOR_UNITS * 10n ** BigInt(scale)) return null;
168
+ return negative ? -scaled : scaled;
169
+ }
170
+
171
+ /**
172
+ * Converts a monetary value to the exact minor units of a currency
173
+ * (cents for USD, yen for JPY, fils for KWD).
174
+ *
175
+ * @param {unknown} value
176
+ * @param {unknown} currency
177
+ * @returns {bigint|null}
178
+ */
179
+ export function toMinorUnits(value, currency) {
180
+ return toScaledBigInt(value, currencyExponent(currency));
181
+ }
182
+
183
+ /**
184
+ * Converts a monetary value to hundredths of the major unit.
185
+ *
186
+ * Used by `canonicalCartHash`, whose wire format (`ac1_`, field `u`) is
187
+ * defined as hundredths regardless of the currency's real minor unit. That
188
+ * definition is fixed for cross-SDK byte compatibility — the agent SDK, the
189
+ * PHP core, and every merchant must agree — so this deliberately does NOT
190
+ * consult `currencyExponent`. Only the ROUNDING is corrected relative to the
191
+ * old `Math.round(x * 100)`.
192
+ *
193
+ * @param {unknown} value
194
+ * @returns {number|null} an exact integer, or null when unrepresentable
195
+ */
196
+ export function toHundredths(value) {
197
+ const scaled = toScaledBigInt(value, 2);
198
+ if (scaled === null) return null;
199
+ if (scaled > BigInt(Number.MAX_SAFE_INTEGER) || scaled < -BigInt(Number.MAX_SAFE_INTEGER)) {
200
+ return null;
201
+ }
202
+ return Number(scaled);
203
+ }
204
+
205
+ /**
206
+ * Compares two monetary amounts exactly.
207
+ *
208
+ * Both sides are converted to micros with half-up rounding, so this is exact
209
+ * for every ISO 4217 currency and immune to the float artifacts that made
210
+ * `Number(orderTotal) > Number(maxBudget)` unreliable.
211
+ *
212
+ * @param {unknown} left
213
+ * @param {unknown} right
214
+ * @returns {-1|0|1|null} null when either side is not a valid amount
215
+ */
216
+ export function compareAmounts(left, right) {
217
+ const a = toScaledBigInt(left, COMPARISON_SCALE);
218
+ const b = toScaledBigInt(right, COMPARISON_SCALE);
219
+ if (a === null || b === null) return null;
220
+ if (a < b) return -1;
221
+ if (a > b) return 1;
222
+ return 0;
223
+ }
224
+
225
+ /**
226
+ * Strict parse of a client- or handler-supplied monetary amount.
227
+ *
228
+ * This is the gate that closes the budget-enforcement bypass: the old code
229
+ * did `Number(orderTotal) > Number(maxBudget)`, and `Number('abc')` is `NaN`,
230
+ * for which every comparison is false. A malformed total therefore passed the
231
+ * budget check instead of failing it. Callers must treat `null` as "reject".
232
+ *
233
+ * @param {unknown} value
234
+ * @param {object} [options]
235
+ * @param {boolean} [options.allowZero=true]
236
+ * @param {boolean} [options.allowNegative=false]
237
+ * @returns {{ decimal: string, micros: bigint }|null}
238
+ */
239
+ export function parseAmount(value, options = {}) {
240
+ const { allowZero = true, allowNegative = false } = options;
241
+ // Reject booleans and empty-ish values that Number() would happily coerce.
242
+ if (typeof value === 'boolean' || value === null || value === undefined) return null;
243
+ if (typeof value === 'string' && value.trim() === '') return null;
244
+ if (typeof value !== 'number' && typeof value !== 'string' && typeof value !== 'bigint') return null;
245
+
246
+ const decimal = toDecimalString(value);
247
+ if (decimal === null) return null;
248
+ const micros = toScaledBigInt(decimal, COMPARISON_SCALE);
249
+ if (micros === null) return null;
250
+ if (!allowNegative && micros < 0n) return null;
251
+ if (!allowZero && micros === 0n) return null;
252
+ return { decimal, micros };
253
+ }
254
+
255
+ /**
256
+ * Formats a scaled integer back to a decimal string.
257
+ *
258
+ * @param {bigint} scaled
259
+ * @param {number} scale
260
+ * @returns {string}
261
+ */
262
+ export function formatScaled(scaled, scale) {
263
+ const negative = scaled < 0n;
264
+ const digits = (negative ? -scaled : scaled).toString().padStart(scale + 1, '0');
265
+ const intPart = digits.slice(0, digits.length - scale);
266
+ const fracPart = scale > 0 ? digits.slice(digits.length - scale).replace(/0+$/, '') : '';
267
+ return `${negative ? '-' : ''}${intPart}${fracPart ? `.${fracPart}` : ''}`;
268
+ }
269
+
270
+ /**
271
+ * Sums a list of `{ quantity, unitHundredths }` line items exactly.
272
+ *
273
+ * @param {Array<{ quantity: number, unitHundredths: number }>} items
274
+ * @returns {number|null} total in hundredths, or null on overflow
275
+ */
276
+ export function sumLineItems(items) {
277
+ let total = 0n;
278
+ for (const item of items) {
279
+ total += BigInt(item.quantity) * BigInt(item.unitHundredths);
280
+ if (total > BigInt(Number.MAX_SAFE_INTEGER)) return null;
281
+ }
282
+ return Number(total);
283
+ }
package/lib/outcomes.js CHANGED
@@ -1,108 +1,108 @@
1
- /**
2
- * Payment-outcome reporting (migration 011 / POST /api/outcomes).
3
- *
4
- * AgentsBloom is the middleman: the merchant's gateway keys stay on THEIR
5
- * servers, inside THEIR webhook handlers. That also means payment outcomes
6
- * (paid / declined / chargeback) happen where we cannot see them - so this
7
- * module lets the merchant's existing webhook handler report the outcome
8
- * with one line. Those reports are what turn risk-core's decline-rate rules
9
- * from theory into data, and what powers the dashboard's outcome analytics.
10
- *
11
- * Keys never leave the merchant: they only ever send order references,
12
- * statuses, and optional amounts.
13
- */
14
-
15
- const DEFAULT_STATUSES = ['paid', 'declined', 'refunded', 'chargeback', 'disputed', 'canceled'];
16
-
17
- /**
18
- * Maps a Stripe webhook event to an outcome report body. Returns null for
19
- * events that carry no outcome signal, so handlers can do:
20
- *
21
- * const report = stripeEventToOutcome(event);
22
- * if (report) await reportPaymentOutcome(report);
23
- */
24
- export function stripeEventToOutcome(event) {
25
- const type = event?.type;
26
- const wrap = (status, extra = {}) => {
27
- const obj = event?.data?.object ?? {};
28
- return {
29
- orderRef:
30
- obj.metadata?.orderRef ??
31
- obj.client_reference_id ??
32
- obj.id,
33
- gateway: 'stripe',
34
- status,
35
- amount: typeof obj.amount_total === 'number'
36
- ? obj.amount_total / 100
37
- : typeof obj.amount === 'number'
38
- ? obj.amount / 100
39
- : undefined,
40
- currency: typeof obj.currency === 'string' ? obj.currency : undefined,
41
- reason: extra.reason ?? obj.failure_message ?? null,
42
- occurredAt: event?.created ? new Date(event.created * 1000).toISOString() : undefined,
43
- };
44
- };
45
- switch (type) {
46
- case 'checkout.session.completed':
47
- return wrap('paid');
48
- case 'invoice.payment_failed':
49
- case 'charge.failed': {
50
- const rep = wrap('declined');
51
- rep.reason = event?.data?.object?.failure_message ?? event?.data?.object?.failure_code ?? null;
52
- return rep;
53
- }
54
- case 'charge.refunded':
55
- return wrap('refunded');
56
- case 'charge.dispute.created':
57
- return wrap('chargeback');
58
- default:
59
- return null;
60
- }
61
- }
62
-
63
- /**
64
- * Creates a reporter bound to your AgentsBloom account key.
65
- *
66
- * @param {object} options
67
- * @param {string} options.apiKey - partner API key (ag_live_... / ag_test_...)
68
- * @param {string} [options.collectorUrl] - backend base URL
69
- * (default https://api.agentsbloom.com)
70
- * @param {(url:string, init:object)=>Promise<Response>} [options.fetchImpl]
71
- */
72
- export function createOutcomeReporter({ apiKey, collectorUrl, fetchImpl } = {}) {
73
- const base = String(collectorUrl || process.env.AGENTSBLOOM_API_URL || 'https://api.agentsbloom.com').replace(/\/$/, '');
74
- const doFetch = fetchImpl ?? fetch;
75
-
76
- async function report(body) {
77
- if (!apiKey) throw new Error('createOutcomeReporter requires your AgentsBloom apiKey');
78
- if (!body || !body.orderRef || !DEFAULT_STATUSES.includes(body.status)) {
79
- throw new Error('reportPaymentOutcome requires orderRef and status (paid|declined|refunded|chargeback|disputed|canceled)');
80
- }
81
- const res = await doFetch(`${base}/api/outcomes`, {
82
- method: 'POST',
83
- headers: {
84
- 'content-type': 'application/json',
85
- authorization: `Bearer ${apiKey}`,
86
- },
87
- body: JSON.stringify({
88
- gateway: 'stripe',
89
- ...body,
90
- }),
91
- signal: AbortSignal.timeout(10_000),
92
- });
93
- if (!res.ok) {
94
- const text = await res.text().catch(() => '');
95
- throw new Error(`outcome report failed: HTTP ${res.status} ${text.slice(0, 200)}`);
96
- }
97
- return res.json();
98
- }
99
-
100
- /** Reports a raw Stripe webhook event (no-op for non-outcome events). */
101
- async function captureStripeOutcome(stripeEvent) {
102
- const body = stripeEventToOutcome(stripeEvent);
103
- if (!body) return { skipped: true };
104
- return report(body);
105
- }
106
-
107
- return { report, captureStripeOutcome };
108
- }
1
+ /**
2
+ * Payment-outcome reporting (migration 011 / POST /api/outcomes).
3
+ *
4
+ * AgentsBloom is the middleman: the merchant's gateway keys stay on THEIR
5
+ * servers, inside THEIR webhook handlers. That also means payment outcomes
6
+ * (paid / declined / chargeback) happen where we cannot see them - so this
7
+ * module lets the merchant's existing webhook handler report the outcome
8
+ * with one line. Those reports are what turn risk-core's decline-rate rules
9
+ * from theory into data, and what powers the dashboard's outcome analytics.
10
+ *
11
+ * Keys never leave the merchant: they only ever send order references,
12
+ * statuses, and optional amounts.
13
+ */
14
+
15
+ const DEFAULT_STATUSES = ['paid', 'declined', 'refunded', 'chargeback', 'disputed', 'canceled'];
16
+
17
+ /**
18
+ * Maps a Stripe webhook event to an outcome report body. Returns null for
19
+ * events that carry no outcome signal, so handlers can do:
20
+ *
21
+ * const report = stripeEventToOutcome(event);
22
+ * if (report) await reportPaymentOutcome(report);
23
+ */
24
+ export function stripeEventToOutcome(event) {
25
+ const type = event?.type;
26
+ const wrap = (status, extra = {}) => {
27
+ const obj = event?.data?.object ?? {};
28
+ return {
29
+ orderRef:
30
+ obj.metadata?.orderRef ??
31
+ obj.client_reference_id ??
32
+ obj.id,
33
+ gateway: 'stripe',
34
+ status,
35
+ amount: typeof obj.amount_total === 'number'
36
+ ? obj.amount_total / 100
37
+ : typeof obj.amount === 'number'
38
+ ? obj.amount / 100
39
+ : undefined,
40
+ currency: typeof obj.currency === 'string' ? obj.currency : undefined,
41
+ reason: extra.reason ?? obj.failure_message ?? null,
42
+ occurredAt: event?.created ? new Date(event.created * 1000).toISOString() : undefined,
43
+ };
44
+ };
45
+ switch (type) {
46
+ case 'checkout.session.completed':
47
+ return wrap('paid');
48
+ case 'invoice.payment_failed':
49
+ case 'charge.failed': {
50
+ const rep = wrap('declined');
51
+ rep.reason = event?.data?.object?.failure_message ?? event?.data?.object?.failure_code ?? null;
52
+ return rep;
53
+ }
54
+ case 'charge.refunded':
55
+ return wrap('refunded');
56
+ case 'charge.dispute.created':
57
+ return wrap('chargeback');
58
+ default:
59
+ return null;
60
+ }
61
+ }
62
+
63
+ /**
64
+ * Creates a reporter bound to your AgentsBloom account key.
65
+ *
66
+ * @param {object} options
67
+ * @param {string} options.apiKey - partner API key (ag_live_... / ag_test_...)
68
+ * @param {string} [options.collectorUrl] - backend base URL
69
+ * (default https://api.agentsbloom.com)
70
+ * @param {(url:string, init:object)=>Promise<Response>} [options.fetchImpl]
71
+ */
72
+ export function createOutcomeReporter({ apiKey, collectorUrl, fetchImpl } = {}) {
73
+ const base = String(collectorUrl || process.env.AGENTSBLOOM_API_URL || 'https://api.agentsbloom.com').replace(/\/$/, '');
74
+ const doFetch = fetchImpl ?? fetch;
75
+
76
+ async function report(body) {
77
+ if (!apiKey) throw new Error('createOutcomeReporter requires your AgentsBloom apiKey');
78
+ if (!body || !body.orderRef || !DEFAULT_STATUSES.includes(body.status)) {
79
+ throw new Error('reportPaymentOutcome requires orderRef and status (paid|declined|refunded|chargeback|disputed|canceled)');
80
+ }
81
+ const res = await doFetch(`${base}/api/outcomes`, {
82
+ method: 'POST',
83
+ headers: {
84
+ 'content-type': 'application/json',
85
+ authorization: `Bearer ${apiKey}`,
86
+ },
87
+ body: JSON.stringify({
88
+ gateway: 'stripe',
89
+ ...body,
90
+ }),
91
+ signal: AbortSignal.timeout(10_000),
92
+ });
93
+ if (!res.ok) {
94
+ const text = await res.text().catch(() => '');
95
+ throw new Error(`outcome report failed: HTTP ${res.status} ${text.slice(0, 200)}`);
96
+ }
97
+ return res.json();
98
+ }
99
+
100
+ /** Reports a raw Stripe webhook event (no-op for non-outcome events). */
101
+ async function captureStripeOutcome(stripeEvent) {
102
+ const body = stripeEventToOutcome(stripeEvent);
103
+ if (!body) return { skipped: true };
104
+ return report(body);
105
+ }
106
+
107
+ return { report, captureStripeOutcome };
108
+ }