@ducatjs/money 0.0.0-stage → 0.1.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 (285) hide show
  1. package/LICENSE +21 -0
  2. package/NOTICE +51 -0
  3. package/README.md +617 -2
  4. package/assets/CREDITS.md +12 -0
  5. package/data/adyen-fx.json +19 -0
  6. package/data/adyen.json +171 -0
  7. package/data/braintree.json +223 -0
  8. package/data/cash-rounding.json +399 -0
  9. package/data/checkout.json +230 -0
  10. package/data/iso4217-list-one.xml +1956 -0
  11. package/data/klarna.json +95 -0
  12. package/data/paypal.json +65 -0
  13. package/data/stripe-fx.json +17 -0
  14. package/data/stripe.json +285 -0
  15. package/dist/adyen-fx.d.ts +25 -0
  16. package/dist/adyen-fx.d.ts.map +1 -0
  17. package/dist/adyen-fx.js +122 -0
  18. package/dist/adyen-fx.js.map +1 -0
  19. package/dist/adyen.d.ts +25 -0
  20. package/dist/adyen.d.ts.map +1 -0
  21. package/dist/adyen.js +34 -0
  22. package/dist/adyen.js.map +1 -0
  23. package/dist/allocate.d.ts +72 -0
  24. package/dist/allocate.d.ts.map +1 -0
  25. package/dist/allocate.js +314 -0
  26. package/dist/allocate.js.map +1 -0
  27. package/dist/bag.d.ts +58 -0
  28. package/dist/bag.d.ts.map +1 -0
  29. package/dist/bag.js +122 -0
  30. package/dist/bag.js.map +1 -0
  31. package/dist/braintree.d.ts +34 -0
  32. package/dist/braintree.d.ts.map +1 -0
  33. package/dist/braintree.js +45 -0
  34. package/dist/braintree.js.map +1 -0
  35. package/dist/cash.d.ts +35 -0
  36. package/dist/cash.d.ts.map +1 -0
  37. package/dist/cash.js +86 -0
  38. package/dist/cash.js.map +1 -0
  39. package/dist/checkout.d.ts +42 -0
  40. package/dist/checkout.d.ts.map +1 -0
  41. package/dist/checkout.js +52 -0
  42. package/dist/checkout.js.map +1 -0
  43. package/dist/convert.d.ts +51 -0
  44. package/dist/convert.d.ts.map +1 -0
  45. package/dist/convert.js +55 -0
  46. package/dist/convert.js.map +1 -0
  47. package/dist/currency.d.ts +22 -0
  48. package/dist/currency.d.ts.map +1 -0
  49. package/dist/currency.js +38 -0
  50. package/dist/currency.js.map +1 -0
  51. package/dist/decimal.d.ts +52 -0
  52. package/dist/decimal.d.ts.map +1 -0
  53. package/dist/decimal.js +251 -0
  54. package/dist/decimal.js.map +1 -0
  55. package/dist/ecb.d.ts +17 -0
  56. package/dist/ecb.d.ts.map +1 -0
  57. package/dist/ecb.js +147 -0
  58. package/dist/ecb.js.map +1 -0
  59. package/dist/exact.d.ts +62 -0
  60. package/dist/exact.d.ts.map +1 -0
  61. package/dist/exact.js +147 -0
  62. package/dist/exact.js.map +1 -0
  63. package/dist/fetch.d.ts +58 -0
  64. package/dist/fetch.d.ts.map +1 -0
  65. package/dist/fetch.js +79 -0
  66. package/dist/fetch.js.map +1 -0
  67. package/dist/fixed.d.ts +121 -0
  68. package/dist/fixed.d.ts.map +1 -0
  69. package/dist/fixed.js +420 -0
  70. package/dist/fixed.js.map +1 -0
  71. package/dist/fn.d.ts +27 -0
  72. package/dist/fn.d.ts.map +1 -0
  73. package/dist/fn.js +30 -0
  74. package/dist/fn.js.map +1 -0
  75. package/dist/format.d.ts +13 -0
  76. package/dist/format.d.ts.map +1 -0
  77. package/dist/format.js +90 -0
  78. package/dist/format.js.map +1 -0
  79. package/dist/generated/adyen.d.ts +3 -0
  80. package/dist/generated/adyen.d.ts.map +1 -0
  81. package/dist/generated/adyen.js +144 -0
  82. package/dist/generated/adyen.js.map +1 -0
  83. package/dist/generated/braintree.d.ts +3 -0
  84. package/dist/generated/braintree.d.ts.map +1 -0
  85. package/dist/generated/braintree.js +140 -0
  86. package/dist/generated/braintree.js.map +1 -0
  87. package/dist/generated/cash.d.ts +12 -0
  88. package/dist/generated/cash.d.ts.map +1 -0
  89. package/dist/generated/cash.js +364 -0
  90. package/dist/generated/cash.js.map +1 -0
  91. package/dist/generated/checkout.d.ts +3 -0
  92. package/dist/generated/checkout.d.ts.map +1 -0
  93. package/dist/generated/checkout.js +151 -0
  94. package/dist/generated/checkout.js.map +1 -0
  95. package/dist/generated/iso4217.d.ts +174 -0
  96. package/dist/generated/iso4217.d.ts.map +1 -0
  97. package/dist/generated/iso4217.js +175 -0
  98. package/dist/generated/iso4217.js.map +1 -0
  99. package/dist/generated/kernel.d.ts +17 -0
  100. package/dist/generated/kernel.d.ts.map +1 -0
  101. package/dist/generated/kernel.js +208 -0
  102. package/dist/generated/kernel.js.map +1 -0
  103. package/dist/generated/klarna.d.ts +3 -0
  104. package/dist/generated/klarna.d.ts.map +1 -0
  105. package/dist/generated/klarna.js +21 -0
  106. package/dist/generated/klarna.js.map +1 -0
  107. package/dist/generated/paypal.d.ts +3 -0
  108. package/dist/generated/paypal.d.ts.map +1 -0
  109. package/dist/generated/paypal.js +31 -0
  110. package/dist/generated/paypal.js.map +1 -0
  111. package/dist/generated/stripe.d.ts +8 -0
  112. package/dist/generated/stripe.d.ts.map +1 -0
  113. package/dist/generated/stripe.js +150 -0
  114. package/dist/generated/stripe.js.map +1 -0
  115. package/dist/index.d.ts +11 -0
  116. package/dist/index.d.ts.map +1 -0
  117. package/dist/index.js +11 -0
  118. package/dist/index.js.map +1 -0
  119. package/dist/klarna.d.ts +31 -0
  120. package/dist/klarna.d.ts.map +1 -0
  121. package/dist/klarna.js +38 -0
  122. package/dist/klarna.js.map +1 -0
  123. package/dist/money.d.ts +218 -0
  124. package/dist/money.d.ts.map +1 -0
  125. package/dist/money.js +577 -0
  126. package/dist/money.js.map +1 -0
  127. package/dist/parse.d.ts +11 -0
  128. package/dist/parse.d.ts.map +1 -0
  129. package/dist/parse.js +132 -0
  130. package/dist/parse.js.map +1 -0
  131. package/dist/paypal.d.ts +41 -0
  132. package/dist/paypal.d.ts.map +1 -0
  133. package/dist/paypal.js +58 -0
  134. package/dist/paypal.js.map +1 -0
  135. package/dist/plugins/adyen.d.ts +15 -0
  136. package/dist/plugins/adyen.d.ts.map +1 -0
  137. package/dist/plugins/adyen.js +10 -0
  138. package/dist/plugins/adyen.js.map +1 -0
  139. package/dist/plugins/all.d.ts +22 -0
  140. package/dist/plugins/all.d.ts.map +1 -0
  141. package/dist/plugins/all.js +23 -0
  142. package/dist/plugins/all.js.map +1 -0
  143. package/dist/plugins/allocate.d.ts +20 -0
  144. package/dist/plugins/allocate.d.ts.map +1 -0
  145. package/dist/plugins/allocate.js +26 -0
  146. package/dist/plugins/allocate.js.map +1 -0
  147. package/dist/plugins/braintree.d.ts +12 -0
  148. package/dist/plugins/braintree.d.ts.map +1 -0
  149. package/dist/plugins/braintree.js +12 -0
  150. package/dist/plugins/braintree.js.map +1 -0
  151. package/dist/plugins/cash.d.ts +19 -0
  152. package/dist/plugins/cash.d.ts.map +1 -0
  153. package/dist/plugins/cash.js +16 -0
  154. package/dist/plugins/cash.js.map +1 -0
  155. package/dist/plugins/checkout.d.ts +21 -0
  156. package/dist/plugins/checkout.d.ts.map +1 -0
  157. package/dist/plugins/checkout.js +16 -0
  158. package/dist/plugins/checkout.js.map +1 -0
  159. package/dist/plugins/convert.d.ts +20 -0
  160. package/dist/plugins/convert.d.ts.map +1 -0
  161. package/dist/plugins/convert.js +13 -0
  162. package/dist/plugins/convert.js.map +1 -0
  163. package/dist/plugins/exact.d.ts +15 -0
  164. package/dist/plugins/exact.d.ts.map +1 -0
  165. package/dist/plugins/exact.js +13 -0
  166. package/dist/plugins/exact.js.map +1 -0
  167. package/dist/plugins/fixed.d.ts +17 -0
  168. package/dist/plugins/fixed.d.ts.map +1 -0
  169. package/dist/plugins/fixed.js +13 -0
  170. package/dist/plugins/fixed.js.map +1 -0
  171. package/dist/plugins/format.d.ts +9 -0
  172. package/dist/plugins/format.d.ts.map +1 -0
  173. package/dist/plugins/format.js +16 -0
  174. package/dist/plugins/format.js.map +1 -0
  175. package/dist/plugins/klarna.d.ts +10 -0
  176. package/dist/plugins/klarna.d.ts.map +1 -0
  177. package/dist/plugins/klarna.js +10 -0
  178. package/dist/plugins/klarna.js.map +1 -0
  179. package/dist/plugins/paypal.d.ts +14 -0
  180. package/dist/plugins/paypal.d.ts.map +1 -0
  181. package/dist/plugins/paypal.js +16 -0
  182. package/dist/plugins/paypal.js.map +1 -0
  183. package/dist/plugins/prisma.d.ts +8 -0
  184. package/dist/plugins/prisma.d.ts.map +1 -0
  185. package/dist/plugins/prisma.js +13 -0
  186. package/dist/plugins/prisma.js.map +1 -0
  187. package/dist/plugins/stripe.d.ts +14 -0
  188. package/dist/plugins/stripe.d.ts.map +1 -0
  189. package/dist/plugins/stripe.js +16 -0
  190. package/dist/plugins/stripe.js.map +1 -0
  191. package/dist/plugins/tax.d.ts +21 -0
  192. package/dist/plugins/tax.d.ts.map +1 -0
  193. package/dist/plugins/tax.js +18 -0
  194. package/dist/plugins/tax.js.map +1 -0
  195. package/dist/prisma.d.ts +66 -0
  196. package/dist/prisma.d.ts.map +1 -0
  197. package/dist/prisma.js +66 -0
  198. package/dist/prisma.js.map +1 -0
  199. package/dist/processor.d.ts +28 -0
  200. package/dist/processor.d.ts.map +1 -0
  201. package/dist/processor.js +118 -0
  202. package/dist/processor.js.map +1 -0
  203. package/dist/provider.d.ts +89 -0
  204. package/dist/provider.d.ts.map +1 -0
  205. package/dist/provider.js +240 -0
  206. package/dist/provider.js.map +1 -0
  207. package/dist/rate.d.ts +119 -0
  208. package/dist/rate.d.ts.map +1 -0
  209. package/dist/rate.js +271 -0
  210. package/dist/rate.js.map +1 -0
  211. package/dist/rounding-error.d.ts +9 -0
  212. package/dist/rounding-error.d.ts.map +1 -0
  213. package/dist/rounding-error.js +9 -0
  214. package/dist/rounding-error.js.map +1 -0
  215. package/dist/rounding.d.ts +50 -0
  216. package/dist/rounding.d.ts.map +1 -0
  217. package/dist/rounding.js +87 -0
  218. package/dist/rounding.js.map +1 -0
  219. package/dist/stripe-fx.d.ts +22 -0
  220. package/dist/stripe-fx.d.ts.map +1 -0
  221. package/dist/stripe-fx.js +30 -0
  222. package/dist/stripe-fx.js.map +1 -0
  223. package/dist/stripe.d.ts +36 -0
  224. package/dist/stripe.d.ts.map +1 -0
  225. package/dist/stripe.js +39 -0
  226. package/dist/stripe.js.map +1 -0
  227. package/dist/tax.d.ts +29 -0
  228. package/dist/tax.d.ts.map +1 -0
  229. package/dist/tax.js +39 -0
  230. package/dist/tax.js.map +1 -0
  231. package/package.json +171 -4
  232. package/src/adyen-fx.ts +154 -0
  233. package/src/adyen.ts +46 -0
  234. package/src/allocate.ts +393 -0
  235. package/src/bag.ts +170 -0
  236. package/src/braintree.ts +71 -0
  237. package/src/cash.ts +115 -0
  238. package/src/checkout.ts +77 -0
  239. package/src/convert.ts +159 -0
  240. package/src/currency.ts +61 -0
  241. package/src/decimal.ts +283 -0
  242. package/src/ecb.ts +162 -0
  243. package/src/exact.ts +182 -0
  244. package/src/fetch.ts +127 -0
  245. package/src/fixed.ts +558 -0
  246. package/src/fn.ts +80 -0
  247. package/src/format.ts +106 -0
  248. package/src/generated/adyen.ts +144 -0
  249. package/src/generated/braintree.ts +140 -0
  250. package/src/generated/cash.ts +375 -0
  251. package/src/generated/checkout.ts +151 -0
  252. package/src/generated/iso4217.ts +179 -0
  253. package/src/generated/kernel.ts +230 -0
  254. package/src/generated/klarna.ts +21 -0
  255. package/src/generated/paypal.ts +31 -0
  256. package/src/generated/stripe.ts +150 -0
  257. package/src/index.ts +37 -0
  258. package/src/klarna.ts +54 -0
  259. package/src/money.ts +781 -0
  260. package/src/parse.ts +144 -0
  261. package/src/paypal.ts +93 -0
  262. package/src/plugins/adyen.ts +25 -0
  263. package/src/plugins/all.ts +47 -0
  264. package/src/plugins/allocate.ts +53 -0
  265. package/src/plugins/braintree.ts +23 -0
  266. package/src/plugins/cash.ts +33 -0
  267. package/src/plugins/checkout.ts +32 -0
  268. package/src/plugins/convert.ts +50 -0
  269. package/src/plugins/exact.ts +23 -0
  270. package/src/plugins/fixed.ts +28 -0
  271. package/src/plugins/format.ts +28 -0
  272. package/src/plugins/klarna.ts +21 -0
  273. package/src/plugins/paypal.ts +29 -0
  274. package/src/plugins/prisma.ts +24 -0
  275. package/src/plugins/stripe.ts +29 -0
  276. package/src/plugins/tax.ts +41 -0
  277. package/src/prisma.ts +118 -0
  278. package/src/processor.ts +181 -0
  279. package/src/provider.ts +324 -0
  280. package/src/rate.ts +377 -0
  281. package/src/rounding-error.ts +8 -0
  282. package/src/rounding.ts +122 -0
  283. package/src/stripe-fx.ts +49 -0
  284. package/src/stripe.ts +69 -0
  285. package/src/tax.ts +71 -0
package/src/rate.ts ADDED
@@ -0,0 +1,377 @@
1
+ // Exchange rates as exact rationals with metadata. A Rate never loses digits: inverses,
2
+ // compositions and margins are exact products of ratios, and rounding happens once, when an amount
3
+ // is converted.
4
+ import { currencyExponent } from './currency.js';
5
+ import {
6
+ formatScaled,
7
+ POW10,
8
+ pow10n,
9
+ ratio,
10
+ ratioToDecimal,
11
+ normalizeMinor,
12
+ type Factor,
13
+ type Ratio,
14
+ } from './decimal.js';
15
+ import { CurrencyMismatchError, make, type CurrencyInput, type Money } from './money.js';
16
+ import {
17
+ DEFAULT_ROUNDING,
18
+ divideAndRound,
19
+ divideAndRoundBigint,
20
+ type RoundingMode,
21
+ } from './rounding.js';
22
+
23
+ const MAX_SAFE_MINOR = Number.MAX_SAFE_INTEGER;
24
+
25
+ /** How a rate was quoted. `inverse()` swaps 'bid' and 'ask'. */
26
+ export type RateType = 'mid' | 'bid' | 'ask' | (string & {});
27
+
28
+ /**
29
+ * Where a rate came from. `asOf` and `validUntil` are ISO-8601 dates ('2026-11-06') or instants.
30
+ */
31
+ export interface RateInfo {
32
+ readonly source?: string;
33
+ readonly asOf?: string | Date;
34
+ readonly validUntil?: string | Date;
35
+ readonly type?: RateType;
36
+ }
37
+
38
+ /** Options for converting an amount. */
39
+ export interface ConvertOptions {
40
+ /** Result scale (default: the target currency's exponent). */
41
+ readonly scale?: number;
42
+ /**
43
+ * Throw RateExpiredError when the rate has a `validUntil` that has passed (by the system clock).
44
+ * Off by default: ducat reads no clock unless asked to.
45
+ */
46
+ readonly strictValidity?: boolean;
47
+ }
48
+
49
+ /** Thrown by a strict-validity conversion with a rate past its `validUntil`. */
50
+ export class RateExpiredError extends RangeError {
51
+ readonly validUntil: string;
52
+
53
+ constructor(from: string, to: string, validUntil: string) {
54
+ super(`Exchange rate ${from}→${to} expired at ${validUntil}`);
55
+ this.name = 'RateExpiredError';
56
+ this.validUntil = validUntil;
57
+ }
58
+ }
59
+
60
+ /** The JSON form of a Rate: the exact decimal string, or "num/den" when it doesn't terminate. */
61
+ export interface RateJSON {
62
+ from: string;
63
+ to: string;
64
+ rate: string;
65
+ source?: string;
66
+ asOf?: string;
67
+ validUntil?: string;
68
+ type?: string;
69
+ derivedFrom?: RateJSON[];
70
+ }
71
+
72
+ type MutableInfo = { source?: string; asOf?: string; validUntil?: string; type?: RateType };
73
+ /** RateInfo that may also hold explicit undefineds (as optional parameters do). */
74
+ type LooseInfo = { readonly [Key in keyof RateInfo]?: RateInfo[Key] | undefined };
75
+
76
+ const OPPOSITE_SIDE: Record<string, RateType> = { bid: 'ask', ask: 'bid' };
77
+
78
+ const ISO_DATE = /^\d{4}-\d\d-\d\d$/;
79
+
80
+ /**
81
+ * @internal
82
+ * A date stays as given ('2026-11-06'); an instant becomes UTC ISO ('2026-11-06T15:00:00.000Z'), so
83
+ * that times with different offsets compare correctly as strings.
84
+ */
85
+ export function toIsoString(time: string | Date): string {
86
+ if (typeof time === 'string' && ISO_DATE.test(time)) {
87
+ return time;
88
+ }
89
+ const date = time instanceof Date ? time : new Date(time);
90
+ if (Number.isNaN(date.getTime())) {
91
+ throw new RangeError(`Invalid date or time ${String(time)}`);
92
+ }
93
+ return date.toISOString();
94
+ }
95
+
96
+ /**
97
+ * The earlier of two ISO-8601 strings (one format compares lexicographically); undefined = no
98
+ * limit.
99
+ */
100
+ function earlier(first: string | undefined, second: string | undefined): string | undefined {
101
+ if (first === undefined) {
102
+ return second;
103
+ }
104
+ if (second === undefined || first <= second) {
105
+ return first;
106
+ }
107
+ return second;
108
+ }
109
+
110
+ /**
111
+ * minor × rate × 10^scaleShift, rounded once with `rounding`. This is the kernel of every
112
+ * conversion: `scaleShift` is the result scale minus the input scale. Pure, and `rate.den > 0` by
113
+ * construction.
114
+ */
115
+ export function multiplyByRatioAtScale(
116
+ minor: number | bigint,
117
+ rate: Ratio,
118
+ scaleShift: number,
119
+ rounding: RoundingMode,
120
+ ): number | bigint {
121
+ // Fast path: all factors are safe integers, so a product that stays in the safe range is exact.
122
+ // `rate.n === rate.n` is false only for NaN, which marks a numerator that isn't a safe integer.
123
+ const rateIsSafe = rate.n === rate.n && rate.d === rate.d;
124
+ if (typeof minor === 'number' && rateIsSafe && scaleShift >= -15 && scaleShift <= 15) {
125
+ let numerator = rate.n;
126
+ let denominator = rate.d;
127
+ if (scaleShift > 0) {
128
+ numerator *= POW10[scaleShift]!;
129
+ } else if (scaleShift < 0) {
130
+ denominator *= POW10[-scaleShift]!;
131
+ }
132
+
133
+ if (numerator <= MAX_SAFE_MINOR && denominator <= MAX_SAFE_MINOR) {
134
+ const product = minor * numerator;
135
+ if (product <= MAX_SAFE_MINOR && product >= -MAX_SAFE_MINOR) {
136
+ return divideAndRound(product, denominator, rounding);
137
+ }
138
+ }
139
+ }
140
+
141
+ // Slow path: the same single division, in bigint.
142
+ let numerator = BigInt(minor) * rate.num;
143
+ let denominator = rate.den;
144
+ if (scaleShift > 0) {
145
+ numerator *= pow10n(scaleShift);
146
+ } else if (scaleShift < 0) {
147
+ denominator *= pow10n(-scaleShift);
148
+ }
149
+ return normalizeMinor(divideAndRoundBigint(numerator, denominator, rounding));
150
+ }
151
+
152
+ /**
153
+ * An exact exchange rate: one unit of `from` is worth `ratio` units of `to`. Build it with
154
+ * `exchangeRate()`. Derived rates (`inverse`, `andThen`, `withMargin`, `round`) keep their inputs
155
+ * in `derivedFrom`, the audit chain. Their `asOf` is the oldest input's, so staleness can't hide,
156
+ * and their `validUntil` is the earliest.
157
+ */
158
+ export class Rate<From extends string = string, To extends string = string> {
159
+ readonly from: From;
160
+ readonly to: To;
161
+ readonly ratio: Ratio;
162
+ // The optional fields are `declare`d, so only the ones that are set exist on an instance: class
163
+ // field semantics would otherwise define them all as undefined.
164
+ declare readonly source?: string;
165
+ declare readonly asOf?: string;
166
+ declare readonly validUntil?: string;
167
+ declare readonly type?: RateType;
168
+ declare readonly derivedFrom?: readonly Rate[];
169
+
170
+ /** @internal use exchangeRate() */
171
+ constructor(from: From, to: To, rate: Ratio, info?: RateInfo, derivedFrom?: readonly Rate[]) {
172
+ // Rebuilt so that a hand-made Ratio object is reduced, has a positive denominator, and has
173
+ // number fields that match its bigints (the fast path relies on them).
174
+ const normalized = ratio(rate.num, rate.den);
175
+ if (normalized.num <= 0n) {
176
+ throw new RangeError('Exchange rate must be positive');
177
+ }
178
+ this.from = from;
179
+ this.to = to;
180
+ this.ratio = normalized;
181
+
182
+ // Only defined fields are assigned, so rates compare and serialise without undefined keys.
183
+ const writable = this as { -readonly [Key in keyof Rate]?: unknown };
184
+ if (info?.source !== undefined) {
185
+ writable.source = info.source;
186
+ }
187
+ if (info?.asOf !== undefined) {
188
+ writable.asOf = toIsoString(info.asOf);
189
+ }
190
+ if (info?.validUntil !== undefined) {
191
+ writable.validUntil = toIsoString(info.validUntil);
192
+ }
193
+ if (info?.type !== undefined) {
194
+ writable.type = info.type;
195
+ }
196
+ if (derivedFrom !== undefined) {
197
+ writable.derivedFrom = derivedFrom;
198
+ }
199
+ }
200
+
201
+ /** The exact reciprocal, `to` → `from`. A bid becomes an ask and vice versa. */
202
+ inverse(): Rate<To, From> {
203
+ let type = this.type;
204
+ if (type !== undefined) {
205
+ type = OPPOSITE_SIDE[type] ?? type;
206
+ }
207
+ const reciprocal = ratio(this.ratio.den, this.ratio.num);
208
+ return new Rate(this.to, this.from, reciprocal, timingOf(this, type), [this]);
209
+ }
210
+
211
+ /**
212
+ * The exact composite `from` → `next.to`. Nothing is rounded: converting with it rounds once.
213
+ * (Named `andThen`, not `then`, so that a Rate is never mistaken for a promise.)
214
+ */
215
+ andThen<Next extends string>(next: Rate<To, Next>): Rate<From, Next> {
216
+ if (next.from !== this.to) {
217
+ throw new CurrencyMismatchError(this.to, next.from);
218
+ }
219
+ const product = ratio(this.ratio.num * next.ratio.num, this.ratio.den * next.ratio.den);
220
+
221
+ // A composite keeps a type only when both legs agree, so it can't be mislabelled.
222
+ let type: RateType | undefined;
223
+ if (this.type === next.type) {
224
+ type = this.type;
225
+ }
226
+ const asOf = earlier(this.asOf, next.asOf);
227
+ const validUntil = earlier(this.validUntil, next.validUntil);
228
+ return new Rate(this.from, next.to, product, timing(asOf, validUntil, type), [this, next]);
229
+ }
230
+
231
+ /** This rate × (1 + margin), exactly: `withMargin('-0.02')` gives 2% less `to` per `from`. */
232
+ withMargin(margin: Factor): Rate<From, To> {
233
+ const marginRatio = ratio(margin);
234
+ const numerator = this.ratio.num * (marginRatio.den + marginRatio.num);
235
+ const denominator = this.ratio.den * marginRatio.den;
236
+ return new Rate(this.from, this.to, ratio(numerator, denominator), timingOf(this), [this]);
237
+ }
238
+
239
+ /**
240
+ * A new Rate rounded to `places` decimals, for businesses that quote rounded (e.g. inverse)
241
+ * rates.
242
+ */
243
+ round(places: number, rounding: RoundingMode = 'halfEven'): Rate<From, To> {
244
+ const rounded = ratio(this.toDecimal(places, rounding));
245
+ return new Rate(this.from, this.to, rounded, timingOf(this, this.type), [this]);
246
+ }
247
+
248
+ /** The rate with exactly `places` decimals, for display. The Rate itself keeps every digit. */
249
+ toDecimal(places: number, rounding: RoundingMode = 'halfEven'): string {
250
+ const scaled = divideAndRoundBigint(this.ratio.num * pow10n(places), this.ratio.den, rounding);
251
+ return formatScaled(scaled, places);
252
+ }
253
+
254
+ /** The exact rate: '1.1165', or '2000/2233' when its decimal expansion doesn't terminate. */
255
+ toString(): string {
256
+ return ratioToDecimal(this.ratio) ?? `${this.ratio.num}/${this.ratio.den}`;
257
+ }
258
+
259
+ /** The rate, its metadata and its audit chain as JSON. `rateFromJSON` reverses it. */
260
+ toJSON(): RateJSON {
261
+ const json: RateJSON = { from: this.from, to: this.to, rate: this.toString(), ...infoOf(this) };
262
+ if (this.derivedFrom !== undefined) {
263
+ json.derivedFrom = this.derivedFrom.map((input) => input.toJSON());
264
+ }
265
+ return json;
266
+ }
267
+
268
+ /**
269
+ * Whether `validUntil` has passed at `now` (default: the system clock). A date is valid all day
270
+ * (UTC).
271
+ */
272
+ isExpired(now: string | Date = new Date()): boolean {
273
+ if (this.validUntil === undefined) {
274
+ return false;
275
+ }
276
+ let instant = toIsoString(now);
277
+ if (ISO_DATE.test(instant)) {
278
+ instant = `${instant}T00:00:00.000Z`;
279
+ }
280
+ if (ISO_DATE.test(this.validUntil)) {
281
+ return instant.slice(0, 10) > this.validUntil;
282
+ }
283
+ return instant > this.validUntil;
284
+ }
285
+
286
+ /** Converts `money`, rounding once to the target currency's exponent (or `options.scale`). */
287
+ convert(
288
+ money: Money<From>,
289
+ rounding: RoundingMode = DEFAULT_ROUNDING,
290
+ options?: ConvertOptions,
291
+ ): Money<To> {
292
+ if (money.currency !== this.from) {
293
+ throw new CurrencyMismatchError(this.from, money.currency);
294
+ }
295
+ if (options?.strictValidity === true && this.validUntil !== undefined && this.isExpired()) {
296
+ throw new RateExpiredError(this.from, this.to, this.validUntil);
297
+ }
298
+ const scale = options?.scale ?? currencyExponent(this.to);
299
+ const minor = multiplyByRatioAtScale(money.minor, this.ratio, scale - money.scale, rounding);
300
+ return make(this.to, minor, scale);
301
+ }
302
+ }
303
+
304
+ /** The metadata fields of `info` that are defined (no undefined keys). */
305
+ function infoOf(info: LooseInfo): MutableInfo {
306
+ const defined: MutableInfo = {};
307
+ if (info.source !== undefined) {
308
+ defined.source = info.source;
309
+ }
310
+ if (info.asOf !== undefined) {
311
+ defined.asOf = toIsoString(info.asOf);
312
+ }
313
+ if (info.validUntil !== undefined) {
314
+ defined.validUntil = toIsoString(info.validUntil);
315
+ }
316
+ if (info.type !== undefined) {
317
+ defined.type = info.type;
318
+ }
319
+ return defined;
320
+ }
321
+
322
+ function timing(asOf?: string, validUntil?: string, type?: RateType): RateInfo {
323
+ return infoOf({ asOf, validUntil, type });
324
+ }
325
+
326
+ /**
327
+ * A derived rate keeps the timing of its input; the input's source stays with it, in derivedFrom.
328
+ */
329
+ function timingOf(input: Rate, type?: RateType): RateInfo {
330
+ return timing(input.asOf, input.validUntil, type);
331
+ }
332
+
333
+ /**
334
+ * An exact rate pinned to a currency pair, so converting the wrong currency is a type error. Give
335
+ * the rate as a decimal string ('1.1165', every digit kept), a "num/den" string, a bigint or a
336
+ * Ratio. Numbers are read at their shortest decimal form (1.234 is exactly 1234/1000).
337
+ *
338
+ * const eurUsd = exchangeRate('EUR', 'USD', '1.1165', { source: 'ECB', asOf: '2026-11-06' });
339
+ * eurUsd.convert(money('100', 'EUR'), 'halfEven'); // 111.65 USD
340
+ * eurUsd.inverse().ratio // 2000/2233, exactly
341
+ */
342
+ export function exchangeRate<From extends CurrencyInput, To extends CurrencyInput>(
343
+ from: From,
344
+ to: To,
345
+ rate: Factor,
346
+ info?: RateInfo,
347
+ ): Rate<From, To> {
348
+ return new Rate(from, to, ratio(rate), info);
349
+ }
350
+
351
+ /** Rebuilds a Rate, with its audit chain, from `rate.toJSON()`. */
352
+ export function rateFromJSON(json: RateJSON): Rate {
353
+ const derivedFrom = json.derivedFrom?.map(rateFromJSON);
354
+ return new Rate(json.from, json.to, ratio(json.rate), infoOf(json), derivedFrom);
355
+ }
356
+
357
+ /** Whether `value` is a Rate. */
358
+ export function isRate(value: unknown): value is Rate {
359
+ return value instanceof Rate;
360
+ }
361
+
362
+ /**
363
+ * The exact rate a processor actually applied: settled ÷ presented. Use it to audit a quoted rate,
364
+ * which after the processor's own rounding often doesn't reproduce the settled amount.
365
+ *
366
+ * impliedRate(money('10.00', 'EUR'), money('12.34', 'USD')) // EUR→USD 1.234
367
+ */
368
+ export function impliedRate<From extends string, To extends string>(
369
+ presented: Money<From>,
370
+ settled: Money<To>,
371
+ info?: RateInfo,
372
+ ): Rate<From, To> {
373
+ // (settled.minor / 10^settled.scale) ÷ (presented.minor / 10^presented.scale)
374
+ const numerator = BigInt(settled.minor) * pow10n(presented.scale);
375
+ const denominator = BigInt(presented.minor) * pow10n(settled.scale);
376
+ return new Rate(presented.currency, settled.currency, ratio(numerator, denominator), info);
377
+ }
@@ -0,0 +1,8 @@
1
+ /**
2
+ * Thrown when an operation would have to round but the rounding mode is `'unnecessary'`. It lives
3
+ * in its own module so the generated kernels can throw it without importing `rounding.ts`, which
4
+ * imports them.
5
+ */
6
+ export class RoundingError extends RangeError {
7
+ override name = 'RoundingError';
8
+ }
@@ -0,0 +1,122 @@
1
+ import {
2
+ divideAndRoundBigint as provedDivideAndRoundBigint,
3
+ roundTruncatedQuotient,
4
+ } from './generated/kernel.js';
5
+ import { RoundingError } from './rounding-error.js';
6
+
7
+ /**
8
+ * Rounding modes. The string values are the ones `Intl.NumberFormat` uses for its `roundingMode`
9
+ * option (ES2023), so a mode can be passed straight through to formatting. `Rounding` gives the
10
+ * familiar names (HalfUp, HalfEven, ...) for the same strings.
11
+ *
12
+ * An invalid mode (possible only from untyped code) is reported, with a RangeError, only when
13
+ * rounding is actually needed: an exact result is returned without looking at the mode, which keeps
14
+ * the exact path as fast as it can be.
15
+ */
16
+ export type RoundingMode =
17
+ // ties to even ("banker's rounding"; IEEE 754 default, EU VAT practice in many states)
18
+ | 'halfEven'
19
+ | 'halfExpand' // ties away from zero ("half up" in commercial usage)
20
+ | 'halfTrunc' // ties towards zero ("half down")
21
+ | 'halfCeil' // ties towards +infinity
22
+ | 'halfFloor' // ties towards -infinity
23
+ | 'expand' // away from zero ("up")
24
+ | 'trunc' // towards zero ("down")
25
+ | 'ceil' // towards +infinity
26
+ | 'floor' // towards -infinity
27
+ // throw RoundingError if the result is inexact (the default for parsing and processor amounts)
28
+ | 'unnecessary';
29
+
30
+ /**
31
+ * Familiar names for the rounding modes (HalfUp, HalfEven, Bankers, ...), mapped to the mode
32
+ * strings.
33
+ */
34
+ export const Rounding = {
35
+ HalfEven: 'halfEven',
36
+ Bankers: 'halfEven',
37
+ HalfUp: 'halfExpand',
38
+ HalfDown: 'halfTrunc',
39
+ HalfCeil: 'halfCeil',
40
+ HalfFloor: 'halfFloor',
41
+ Up: 'expand',
42
+ Down: 'trunc',
43
+ Ceil: 'ceil',
44
+ Floor: 'floor',
45
+ Unnecessary: 'unnecessary',
46
+ } as const satisfies Record<string, RoundingMode>;
47
+
48
+ /**
49
+ * The rounding mode arithmetic uses when none is given: ties away from zero ('half up'). It is the
50
+ * default of decimal.js, big.js, bignumber.js, Prisma.Decimal and Intl.NumberFormat, and what
51
+ * Math.round-based money libraries (currency.js, js-money, ts-money) intend. Parsing
52
+ * (`money('1.005', 'EUR')`, `fromDecimal`) and processor amounts (`toStripeAmount`, `toAdyen`) stay
53
+ * strict: they throw unless a mode is passed.
54
+ */
55
+ export const DEFAULT_ROUNDING: RoundingMode = 'halfExpand';
56
+
57
+ export { RoundingError };
58
+
59
+ const MAX_SAFE_MINOR = Number.MAX_SAFE_INTEGER;
60
+
61
+ /**
62
+ * dividend / divisor rounded with `mode`, for safe-integer dividends and divisor > 0. Exact: the
63
+ * float quotient is only a first guess, corrected until the remainder has the dividend's sign and
64
+ * is smaller than the divisor.
65
+ */
66
+ export function divideAndRound(dividend: number, divisor: number, mode: RoundingMode): number {
67
+ if (divisor === 1) {
68
+ return dividend;
69
+ }
70
+ // Beyond MAX_SAFE_MINOR the product quotient * divisor below could round, so take the bigint
71
+ // path.
72
+ if (!(Math.abs(dividend) + divisor <= MAX_SAFE_MINOR)) {
73
+ return Number(divideAndRoundBigint(BigInt(dividend), BigInt(divisor), mode));
74
+ }
75
+
76
+ let quotient = Math.trunc(dividend / divisor);
77
+ // exact: |quotient * divisor| <= |dividend| + divisor <= MAX_SAFE_MINOR
78
+ let remainder = dividend - quotient * divisor;
79
+
80
+ // The float quotient can be off by one ulp; fix it so the remainder has the dividend's sign and
81
+ // is smaller than the divisor in magnitude.
82
+ if (dividend >= 0) {
83
+ if (remainder < 0) {
84
+ quotient -= 1;
85
+ remainder += divisor;
86
+ } else if (remainder >= divisor) {
87
+ quotient += 1;
88
+ remainder -= divisor;
89
+ }
90
+ } else if (remainder > 0) {
91
+ quotient += 1;
92
+ remainder -= divisor;
93
+ } else if (remainder <= -divisor) {
94
+ quotient -= 1;
95
+ remainder += divisor;
96
+ }
97
+
98
+ // The generated switch handles a zero remainder too; returning here saves the call on the hottest
99
+ // path.
100
+ if (remainder === 0) {
101
+ return quotient + 0; // + 0 normalises -0
102
+ }
103
+ // quotient and remainder are now the exact truncated quotient and remainder, as the generated
104
+ // switch expects
105
+ return roundTruncatedQuotient(dividend, divisor, quotient, remainder, mode) + 0;
106
+ }
107
+
108
+ /**
109
+ * dividend / divisor rounded with `mode`, for divisor > 0n. The bigint kernel is generated from the
110
+ * Lean proofs in proofs/Ducat/Rounding.lean (`src/generated/kernel.ts`), and so is the mode switch
111
+ * `divideAndRound` uses.
112
+ */
113
+ export function divideAndRoundBigint(
114
+ dividend: bigint,
115
+ divisor: bigint,
116
+ mode: RoundingMode,
117
+ ): bigint {
118
+ if (divisor === 1n) {
119
+ return dividend;
120
+ }
121
+ return provedDivideAndRoundBigint(dividend, divisor, mode);
122
+ }
@@ -0,0 +1,49 @@
1
+ // Stripe exchange rates as exact Rates, for converting and reconciling balance transactions.
2
+ import type { Money } from './money.js';
3
+ import { exchangeRate, impliedRate, type Rate, type RateInfo } from './rate.js';
4
+ import { fromStripe } from './stripe.js';
5
+
6
+ /** The fields of a Stripe BalanceTransaction used here. */
7
+ export interface StripeBalanceTransaction {
8
+ readonly amount: number;
9
+ readonly currency: string;
10
+ readonly exchange_rate?: number | null;
11
+ /** Seconds since the Unix epoch. */
12
+ readonly created?: number;
13
+ }
14
+
15
+ function stripeInfo(transaction: StripeBalanceTransaction): RateInfo {
16
+ if (transaction.created === undefined) {
17
+ return { source: 'stripe' };
18
+ }
19
+ return { source: 'stripe', asOf: new Date(transaction.created * 1000) };
20
+ }
21
+
22
+ /**
23
+ * The rate Stripe quotes on a balance transaction, from the charge currency `from` to the balance
24
+ * currency: "the amount in currency A, multiplied by the exchange_rate, equals the amount in
25
+ * currency B". The JSON number is read at its shortest decimal form, so 1.234 is exactly 1234/1000.
26
+ * `asOf` is the balance transaction's `created` time, the closest timestamp Stripe gives for the
27
+ * conversion; it is not the charge's creation time, and Stripe doesn't say when the rate was
28
+ * quoted. `undefined` when nothing was converted.
29
+ */
30
+ export function stripeRate<From extends string>(
31
+ from: From,
32
+ transaction: StripeBalanceTransaction,
33
+ ): Rate<Uppercase<From>, Uppercase<string>> | undefined {
34
+ if (transaction.exchange_rate === undefined || transaction.exchange_rate === null) {
35
+ return undefined;
36
+ }
37
+ const charged = from.toUpperCase() as Uppercase<From>;
38
+ const balance = transaction.currency.toUpperCase() as Uppercase<string>;
39
+ return exchangeRate(charged, balance, transaction.exchange_rate, stripeInfo(transaction));
40
+ }
41
+
42
+ /** The exact rate Stripe applied: the balance transaction's gross amount ÷ the charged amount. */
43
+ export function stripeImpliedRate<From extends string>(
44
+ charged: Money<From>,
45
+ transaction: StripeBalanceTransaction,
46
+ ): Rate<From, Uppercase<string>> {
47
+ const settled = fromStripe(transaction.amount, transaction.currency.toUpperCase());
48
+ return impliedRate(charged, settled, stripeInfo(transaction));
49
+ }
package/src/stripe.ts ADDED
@@ -0,0 +1,69 @@
1
+ /**
2
+ * Stripe's minor-unit view. Stripe uses ISO exponents except where documented otherwise
3
+ * (https://docs.stripe.com/currencies, encoded in data/stripe.json):
4
+ * - zero-decimal: BIF CLP DJF GNF JPY KMF KRW MGA PYG RWF VND VUV XAF XOF XPF (note MGA: ISO says
5
+ * 2)
6
+ * - ISK, UGX: sent as two-decimal values whose last two digits are 00 (5 ISK -> 500)
7
+ * - HUF, TWD: two decimals for charges, but payouts must be whole units (multiple of 100)
8
+ * - BHD JOD KWD OMR TND: three decimals, last digit must be 0 (UAE accounts only)
9
+ */
10
+ import { STRIPE_RULES } from './generated/stripe.js';
11
+ import { type CurrencyInput, type Money } from './money.js';
12
+ import { fromProcessorAmount, toProcessorAmount } from './processor.js';
13
+ import type { RoundingMode } from './rounding.js';
14
+
15
+ export { STRIPE_RULES };
16
+
17
+ function rule(currency: string) {
18
+ const found = STRIPE_RULES[currency.toUpperCase()];
19
+ if (!found) {
20
+ throw new RangeError(`${currency} is not a Stripe presentment currency`);
21
+ }
22
+ return found;
23
+ }
24
+
25
+ /** Whether Stripe supports the currency as a presentment currency. */
26
+ export const isStripeCurrency = (currency: string): boolean =>
27
+ STRIPE_RULES[currency.toUpperCase()] !== undefined;
28
+
29
+ /** The exponent Stripe's API uses for a currency (ISK 2, JPY 0, KWD 3, MGA 0). */
30
+ export const stripeExponent = (currency: string): number => rule(currency)[0];
31
+
32
+ /**
33
+ * Options for the Stripe amount: payout rules and how to round when Stripe needs a coarser amount.
34
+ */
35
+ export interface StripeAmountOptions {
36
+ /** Payout amounts have stricter rules (HUF, TWD: whole units). */
37
+ readonly payout?: boolean;
38
+ /** How to round when Stripe needs a coarser amount (default 'unnecessary': throw). */
39
+ readonly rounding?: RoundingMode;
40
+ }
41
+
42
+ /** The integer `amount` to send to Stripe, e.g. toStripeAmount(money('5', 'ISK')) === 500. */
43
+ export function toStripeAmount(money: Money, options?: StripeAmountOptions): number {
44
+ const [exp, charge, payout] = rule(money.currency);
45
+ return toProcessorAmount(
46
+ 'Stripe',
47
+ money,
48
+ exp,
49
+ options?.payout ? payout : charge,
50
+ options?.rounding ?? 'unnecessary',
51
+ );
52
+ }
53
+
54
+ /** `{ amount, currency }` with Stripe's lowercase currency code, ready for a PaymentIntent. */
55
+ export function toStripe(
56
+ money: Money,
57
+ options?: StripeAmountOptions,
58
+ ): { amount: number; currency: string } {
59
+ return { amount: toStripeAmount(money, options), currency: money.currency.toLowerCase() };
60
+ }
61
+
62
+ /** Reads a Stripe amount (e.g. from a webhook): fromStripe(500, 'isk') is 5 ISK. */
63
+ export function fromStripe<C extends CurrencyInput>(
64
+ amount: number | bigint,
65
+ currency: C,
66
+ ): Money<Uppercase<C>> {
67
+ const code = currency.toUpperCase() as Uppercase<C>;
68
+ return fromProcessorAmount('Stripe', amount, code, rule(code)[0]);
69
+ }