@ducatjs/money 0.0.0 → 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 -3
  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 +169 -5
  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
@@ -0,0 +1,393 @@
1
+ import { parseDecimal, pow10n, normalizeMinor } from './decimal.js';
2
+ import {
3
+ CurrencyMismatchError,
4
+ compare,
5
+ make,
6
+ negate,
7
+ scaleUp,
8
+ subtract,
9
+ toScale,
10
+ type Money,
11
+ } from './money.js';
12
+
13
+ const MAX_SAFE_MINOR = Number.MAX_SAFE_INTEGER;
14
+
15
+ /**
16
+ * Where the indivisible remainder (always fewer minor units than there are non-zero buckets for
17
+ * the spreading strategies) goes:
18
+ * - 'largest' largest-remainder (Hamilton) method: units go to the buckets with the biggest
19
+ * fractional part, ties to the earlier bucket. The default; minimises error per bucket.
20
+ * - 'first' one unit each to the first buckets (with non-zero weight)
21
+ * - 'last' one unit each to the last buckets
22
+ * - 'toFirst' the whole remainder to the first bucket
23
+ * - 'toLast' the whole remainder to the last bucket
24
+ */
25
+ export type Remainder = 'largest' | 'first' | 'last' | 'toFirst' | 'toLast';
26
+
27
+ /** Options for `allocate` and `split`: where the indivisible remainder goes. */
28
+ export interface AllocateOptions {
29
+ readonly remainder?: Remainder;
30
+ }
31
+
32
+ /** A ratio weight: a non-negative integer, bigint or decimal string ('33.3'). */
33
+ export type Weight = number | bigint | string;
34
+
35
+ function weightsToBig(ws: readonly Weight[]): bigint[] {
36
+ return weightsScaled(ws).ws;
37
+ }
38
+
39
+ function weightsScaled(ws: readonly Weight[]): { ws: bigint[]; scale: number } {
40
+ let scale = 0;
41
+ const parsed = ws.map((weight) => {
42
+ if (typeof weight === 'bigint') {
43
+ return { digits: weight, scale: 0 };
44
+ }
45
+ if (typeof weight === 'number' && Number.isSafeInteger(weight)) {
46
+ return { digits: BigInt(weight), scale: 0 };
47
+ }
48
+ const item = parseDecimal(String(weight));
49
+ return item;
50
+ });
51
+ for (const item of parsed) {
52
+ if (item.digits < 0n) {
53
+ throw new RangeError('Allocation weights must not be negative');
54
+ }
55
+ if (item.scale > scale) {
56
+ scale = item.scale;
57
+ }
58
+ }
59
+ return { ws: parsed.map((item) => item.digits * pow10n(scale - item.scale)), scale };
60
+ }
61
+
62
+ /**
63
+ * Distributes `rem` extra units among the buckets; `frac` are the per-bucket remainders (for
64
+ * 'largest').
65
+ */
66
+ function distribute<T extends number | bigint>(
67
+ shares: T[],
68
+ rem: number,
69
+ weights: readonly (number | bigint)[],
70
+ frac: readonly (number | bigint)[] | null,
71
+ mode: Remainder,
72
+ one: T,
73
+ ): void {
74
+ if (rem === 0) {
75
+ return;
76
+ }
77
+ const count = shares.length;
78
+ const eligible: number[] = [];
79
+ for (let i = 0; i < count; i++) {
80
+ if (weights[i]! > 0) {
81
+ eligible.push(i);
82
+ }
83
+ }
84
+ const plus = (i: number, k: number) => {
85
+ shares[i] = (
86
+ typeof one === 'number' ? (shares[i] as number) + k : (shares[i] as bigint) + BigInt(k)
87
+ ) as T;
88
+ };
89
+ switch (mode) {
90
+ case 'toFirst':
91
+ return plus(eligible[0]!, rem);
92
+ case 'toLast':
93
+ return plus(eligible[eligible.length - 1]!, rem);
94
+ case 'first':
95
+ for (let k = 0; k < rem; k++) {
96
+ plus(eligible[k]!, 1);
97
+ }
98
+ return;
99
+ case 'last':
100
+ for (let k = 0; k < rem; k++) {
101
+ plus(eligible[eligible.length - 1 - k]!, 1);
102
+ }
103
+ return;
104
+ case 'largest': {
105
+ const remainders = frac!;
106
+ // Largest remainder first; equal remainders keep bucket order.
107
+ eligible.sort((left, right) => {
108
+ if (remainders[left]! !== remainders[right]!) {
109
+ return remainders[left]! > remainders[right]! ? -1 : 1;
110
+ }
111
+ return left - right;
112
+ });
113
+ for (let k = 0; k < rem; k++) {
114
+ plus(eligible[k]!, 1);
115
+ }
116
+ return;
117
+ }
118
+ }
119
+ throw new RangeError(`Unknown remainder strategy "${String(mode)}"`);
120
+ }
121
+
122
+ /**
123
+ * Splits `m` into parts proportional to `weights`. Exact: the parts always sum to `m`, and zero
124
+ * weights get zero. With 'largest' each part is less than one minor unit from its exact share; with
125
+ * 'first' and 'last', at most one unit. Negative amounts are allocated as the negation of the
126
+ * positive allocation. These properties are proved for a Lean model of this function
127
+ * (proofs/Ducat/Allocate.lean), and test/allocate.oracle.test.ts checks this code against that
128
+ * model.
129
+ *
130
+ * allocate(money('100', 'EUR'), [1, 1, 1]) // 33.34, 33.33, 33.33
131
+ * allocate(money('0.05', 'EUR'), ['70', '30']) // 0.04, 0.01
132
+ */
133
+ export function allocate<C extends string>(
134
+ money: Money<C>,
135
+ weights: readonly Weight[],
136
+ options?: AllocateOptions,
137
+ ): Money<C>[] {
138
+ const count = weights.length;
139
+ if (count === 0) {
140
+ throw new RangeError('allocate() needs at least one weight');
141
+ }
142
+ const mode = options?.remainder ?? 'largest';
143
+ if (money.minor < 0) {
144
+ return allocate(negate(money), weights, options).map(negate);
145
+ }
146
+ const minor = money.minor;
147
+
148
+ // fast path: integer weights whose products with the amount fit exactly in a double
149
+ if (typeof minor === 'number') {
150
+ let total = 0;
151
+ let maxWeight = 0;
152
+ let ok = true;
153
+ for (let i = 0; i < count; i++) {
154
+ const weight = weights[i];
155
+ if (typeof weight !== 'number' || !(weight >= 0) || !Number.isSafeInteger(weight)) {
156
+ ok = false;
157
+ break;
158
+ }
159
+ total += weight;
160
+ if (weight > maxWeight) {
161
+ maxWeight = weight;
162
+ }
163
+ }
164
+ if (ok && total > 0 && total <= MAX_SAFE_MINOR && minor * maxWeight <= MAX_SAFE_MINOR - total) {
165
+ const ws = weights as readonly number[];
166
+ const shares = new Array<number>(count);
167
+ const frac = new Array<number>(count);
168
+ let rem = minor;
169
+ for (let i = 0; i < count; i++) {
170
+ const product = minor * ws[i]!;
171
+ let quotient = Math.floor(product / total);
172
+ let remainder = product - quotient * total;
173
+ if (remainder < 0) {
174
+ quotient -= 1;
175
+ remainder += total;
176
+ } else if (remainder >= total) {
177
+ quotient += 1;
178
+ remainder -= total;
179
+ } // float-quotient fix-up
180
+ shares[i] = quotient;
181
+ frac[i] = remainder;
182
+ rem -= quotient;
183
+ }
184
+ if (rem > 0) {
185
+ if (mode === 'largest' && count <= 32) {
186
+ // selection instead of sort: rem < number of non-zero weights, n small
187
+ for (; rem > 0; rem--) {
188
+ let best = -1;
189
+ for (let i = 0; i < count; i++) {
190
+ if (ws[i]! > 0 && frac[i]! >= 0 && (best < 0 || frac[i]! > frac[best]!)) {
191
+ best = i;
192
+ }
193
+ }
194
+ shares[best]! += 1;
195
+ frac[best] = -1;
196
+ }
197
+ } else {
198
+ distribute(shares, rem, ws, frac, mode, 1);
199
+ }
200
+ }
201
+ const out = new Array<Money<C>>(count);
202
+ for (let i = 0; i < count; i++) {
203
+ out[i] = make(money.currency, shares[i]!, money.scale);
204
+ }
205
+ return out;
206
+ }
207
+ }
208
+
209
+ const ws = weightsToBig(weights);
210
+ let total = 0n;
211
+ for (const weight of ws) {
212
+ total += weight;
213
+ }
214
+ if (total === 0n) {
215
+ throw new RangeError('Allocation weights must not all be zero');
216
+ }
217
+ const xb = BigInt(minor);
218
+ const shares: bigint[] = new Array(count);
219
+ const frac: bigint[] = new Array(count);
220
+ let assigned = 0n;
221
+ for (let i = 0; i < count; i++) {
222
+ const product = xb * ws[i]!;
223
+ shares[i] = product / total;
224
+ frac[i] = product % total;
225
+ assigned += shares[i]!;
226
+ }
227
+ distribute(shares, Number(xb - assigned), ws, frac, mode, 1n);
228
+ return shares.map((share) => make(money.currency, share, money.scale));
229
+ }
230
+
231
+ /** Splits into `count` equal parts; the remainder goes per `remainder` (default 'first'). */
232
+ export function split<C extends string>(
233
+ money: Money<C>,
234
+ count: number,
235
+ options?: AllocateOptions,
236
+ ): Money<C>[] {
237
+ if (!Number.isSafeInteger(count) || count < 1) {
238
+ throw new RangeError(`Invalid count ${count}`);
239
+ }
240
+ const weights = new Array<number>(count).fill(1);
241
+ return allocate(money, weights, { remainder: options?.remainder ?? 'first' });
242
+ }
243
+
244
+ /**
245
+ * Options for `instalments`: where the remainder goes, and an optional unit every regular
246
+ * instalment is a multiple of.
247
+ */
248
+ export interface InstalmentOptions<C extends string> {
249
+ /** Where the remainder goes: default 'toFirst' (first instalment absorbs it). */
250
+ readonly remainder?: Exclude<Remainder, 'largest'>;
251
+ /** Make every regular instalment a multiple of this (e.g. money('1', 'EUR') for whole euros). */
252
+ readonly unit?: Money<C>;
253
+ }
254
+
255
+ /**
256
+ * Instalment plan: `count` equal payments, one of which absorbs the remainder.
257
+ *
258
+ * instalments(money('100', 'EUR'), 3) // 33.34, 33.33, 33.33
259
+ * instalments(money('100', 'EUR'), 3, { remainder: 'toLast' }) // 33.33, 33.33, 33.34
260
+ * instalments(money('100', 'EUR'), 3, { unit: money('1', 'EUR') }) // 34.00, 33.00, 33.00
261
+ */
262
+ export function instalments<C extends string>(
263
+ total: Money<C>,
264
+ count: number,
265
+ options?: InstalmentOptions<NoInfer<C>>,
266
+ ): Money<C>[] {
267
+ if (!Number.isSafeInteger(count) || count < 1) {
268
+ throw new RangeError(`Invalid count ${count}`);
269
+ }
270
+ const mode = options?.remainder ?? 'toFirst';
271
+ const unit = options?.unit;
272
+ if (!unit) {
273
+ return allocate(total, new Array<number>(count).fill(1), { remainder: mode });
274
+ }
275
+ if (unit.currency !== total.currency) {
276
+ throw new CurrencyMismatchError(total.currency, unit.currency);
277
+ }
278
+ if (!(unit.minor > 0)) {
279
+ throw new RangeError('unit must be positive');
280
+ }
281
+ const scale = Math.max(total.scale, unit.scale);
282
+ const scaledTotal = toScale(total, scale);
283
+ const unitMinor = BigInt(scaleUp(unit.minor, scale - unit.scale));
284
+ const neg = scaledTotal.minor < 0;
285
+ const tv = BigInt(scaledTotal.minor) * (neg ? -1n : 1n);
286
+ const per = (tv / (unitMinor * BigInt(count))) * unitMinor;
287
+ const parts = new Array<bigint>(count).fill(per);
288
+ let left = tv - per * BigInt(count);
289
+ // spread whole units first (for 'first'/'last'), then the sub-unit leftover
290
+ if (mode === 'first' || mode === 'last') {
291
+ for (let k = 0; left >= unitMinor; k++, left -= unitMinor) {
292
+ parts[mode === 'first' ? k : count - 1 - k]! += unitMinor;
293
+ }
294
+ }
295
+ parts[mode === 'toLast' || mode === 'last' ? count - 1 : 0]! += left;
296
+ return parts.map((part) => make(total.currency, neg ? -part : part, scale));
297
+ }
298
+
299
+ /** One tranche of a `schedule`: a fixed amount, a (capped) percentage, or the rest. */
300
+ export type Tranche<C extends string> =
301
+ | { readonly amount: Money<C> }
302
+ | { readonly percent: Weight; readonly cap?: Money<C> }
303
+ | { readonly rest: true };
304
+
305
+ /**
306
+ * A payment schedule mixing fixed amounts, percentages of the total (optionally capped) and one
307
+ * `rest` tranche that receives whatever is left. Percent tranches are computed together with
308
+ * largest-remainder allocation, so no rounding mode is needed and the schedule is exact.
309
+ * Throws if the tranches cannot add up to exactly `total`.
310
+ *
311
+ * schedule(money('1000', 'EUR'), [{ percent: 30 }, { amount: money('200', 'EUR') }, { rest: true }])
312
+ * // 300.00, 200.00, 500.00
313
+ */
314
+ export function schedule<C extends string>(
315
+ total: Money<C>,
316
+ tranches: readonly Tranche<NoInfer<C>>[],
317
+ ): Money<C>[] {
318
+ const pctIdx: number[] = [];
319
+ const pcts: Weight[] = [];
320
+ let restIdx = -1;
321
+ tranches.forEach((tranche, i) => {
322
+ if ('percent' in tranche) {
323
+ pctIdx.push(i);
324
+ pcts.push(tranche.percent);
325
+ } else if ('rest' in tranche) {
326
+ if (restIdx >= 0) {
327
+ throw new RangeError('schedule() allows only one rest tranche');
328
+ }
329
+ restIdx = i;
330
+ }
331
+ });
332
+ const out: (Money<C> | undefined)[] = new Array(tranches.length);
333
+ let left = total;
334
+ if (pcts.length) {
335
+ const { ws, scale } = weightsScaled(pcts);
336
+ let sum = 0n;
337
+ for (const weight of ws) {
338
+ sum += weight;
339
+ }
340
+ const hundred = 100n * pow10n(scale);
341
+ if (sum > hundred) {
342
+ throw new RangeError('schedule() percentages exceed 100');
343
+ }
344
+ const parts = allocate(
345
+ total,
346
+ [...ws, hundred - sum].map((weight) => normalizeMinor(weight)),
347
+ );
348
+ pctIdx.forEach((trancheIndex, k) => {
349
+ let part = parts[k]!;
350
+ const cap = (tranches[trancheIndex] as { cap?: Money<C> }).cap;
351
+ if (cap && compare(part, cap) > 0) {
352
+ part = cap;
353
+ }
354
+ out[trancheIndex] = part;
355
+ left = subtract(left, part);
356
+ });
357
+ }
358
+ tranches.forEach((tranche, i) => {
359
+ if ('amount' in tranche) {
360
+ out[i] = tranche.amount;
361
+ left = subtract(left, tranche.amount);
362
+ }
363
+ });
364
+ if (left.minor < 0) {
365
+ throw new RangeError(`schedule() tranches exceed the total by ${negate(left)}`);
366
+ }
367
+ if (restIdx >= 0) {
368
+ out[restIdx] = left;
369
+ } else if (left.minor != 0) {
370
+ throw new RangeError(`schedule() leaves ${left} unassigned; add { rest: true }`);
371
+ }
372
+ return out as Money<C>[];
373
+ }
374
+
375
+ /** Splits into tranches of at most `max` each: chunk(EUR 1000, EUR 300) -> 300, 300, 300, 100. */
376
+ export function chunk<C extends string>(total: Money<C>, max: Money<NoInfer<C>>): Money<C>[] {
377
+ if (!(max.minor > 0)) {
378
+ throw new RangeError('chunk() max must be positive');
379
+ }
380
+ if (total.minor < 0) {
381
+ throw new RangeError('chunk() total must not be negative');
382
+ }
383
+ const out: Money<C>[] = [];
384
+ let left = total;
385
+ while (compare(left, max) > 0) {
386
+ out.push(max as Money<C>);
387
+ left = subtract(left, max);
388
+ }
389
+ if (left.minor > 0 || out.length === 0) {
390
+ out.push(left);
391
+ }
392
+ return out;
393
+ }
package/src/bag.ts ADDED
@@ -0,0 +1,170 @@
1
+ // Mixed-currency amounts: a bag of per-currency totals, converted and rounded once when a single
2
+ // total is needed, and a cross-currency comparator through one base currency (a total preorder, so
3
+ // sorting is well defined).
4
+ import { exact, sumExact, type ExactMoney } from './exact.js';
5
+ import { add, type Money } from './money.js';
6
+ import { rateOf, type RateProvider, type RateQuery } from './provider.js';
7
+ import type { Rate } from './rate.js';
8
+ import type { RoundingMode } from './rounding.js';
9
+
10
+ /** Options for MoneyBag#total. */
11
+ export interface TotalOptions {
12
+ readonly asOf?: string | Date;
13
+ /** Result scale (default: the target currency's exponent). */
14
+ readonly scale?: number;
15
+ }
16
+
17
+ function queryFor(asOf: string | Date | undefined): RateQuery | undefined {
18
+ if (asOf === undefined) {
19
+ return undefined;
20
+ }
21
+ return { asOf };
22
+ }
23
+
24
+ /** Per-currency totals of `items`, in first-seen order. */
25
+ export function sumByCurrency(items: Iterable<Money>): Map<string, Money> {
26
+ const totals = new Map<string, Money>();
27
+ for (const money of items) {
28
+ const previous = totals.get(money.currency);
29
+ if (previous === undefined) {
30
+ totals.set(money.currency, money);
31
+ } else {
32
+ totals.set(money.currency, add(previous, money));
33
+ }
34
+ }
35
+ return totals;
36
+ }
37
+
38
+ /** An immutable set of per-currency totals: `bag(eur10, usd5, eur2).get('EUR')` is EUR 12. */
39
+ export class MoneyBag {
40
+ readonly #totals: ReadonlyMap<string, Money>;
41
+
42
+ /** @internal use bag() */
43
+ constructor(totals: ReadonlyMap<string, Money>) {
44
+ // Copied, so a caller who keeps the Map can't change an immutable bag.
45
+ this.#totals = new Map(totals);
46
+ }
47
+
48
+ /** A new bag with `items` added. */
49
+ add(...items: Money[]): MoneyBag {
50
+ return new MoneyBag(sumByCurrency([...this.#totals.values(), ...items]));
51
+ }
52
+
53
+ /** The total in `currency`, or undefined when the bag has none. */
54
+ get<C extends string>(currency: C): Money<C> | undefined {
55
+ return this.#totals.get(currency) as Money<C> | undefined;
56
+ }
57
+
58
+ /** The currencies held, in first-seen order. */
59
+ currencies(): string[] {
60
+ return [...this.#totals.keys()];
61
+ }
62
+
63
+ /** The per-currency totals, in first-seen order. */
64
+ values(): Money[] {
65
+ return [...this.#totals.values()];
66
+ }
67
+
68
+ /** Every entry converted exactly into `to` and summed. Nothing is rounded. */
69
+ exactTotal<To extends string>(
70
+ to: To,
71
+ provider: RateProvider,
72
+ options?: Pick<TotalOptions, 'asOf'>,
73
+ ): ExactMoney<To> {
74
+ const query = queryFor(options?.asOf);
75
+ const parts: ExactMoney<To>[] = [];
76
+ for (const money of this.#totals.values()) {
77
+ if (money.currency === to) {
78
+ parts.push(exact(money as Money<To>));
79
+ } else {
80
+ const rate = rateOf(provider, money.currency, to, query);
81
+ parts.push(exact(money).convert(rate));
82
+ }
83
+ }
84
+ return sumExact(parts, to);
85
+ }
86
+
87
+ /** The total in `to`, rounded once: `bag.total('EUR', fx, 'halfEven')`. */
88
+ total<To extends string>(
89
+ to: To,
90
+ provider: RateProvider,
91
+ rounding: RoundingMode,
92
+ options?: TotalOptions,
93
+ ): Money<To> {
94
+ return this.exactTotal(to, provider, options).round(rounding, options?.scale);
95
+ }
96
+
97
+ /** The totals as a list of Money JSON values. */
98
+ toJSON(): unknown[] {
99
+ return this.values().map((money) => money.toJSON());
100
+ }
101
+ }
102
+
103
+ /** A bag of the per-currency totals of `items`. */
104
+ export function bag(...items: Money[]): MoneyBag {
105
+ return new MoneyBag(sumByCurrency(items));
106
+ }
107
+
108
+ /** Options for comparator(). */
109
+ export interface ComparatorOptions {
110
+ /** Every amount is compared as its exact value in this currency. */
111
+ readonly base: string;
112
+ readonly asOf?: string | Date;
113
+ }
114
+
115
+ /** Converts amounts exactly into `options.base`, looking each currency's rate up once. */
116
+ function baseConverter(
117
+ provider: RateProvider,
118
+ options: ComparatorOptions,
119
+ ): (money: Money) => ExactMoney {
120
+ const base = options.base;
121
+ const query = queryFor(options.asOf);
122
+ const ratesToBase = new Map<string, Rate>();
123
+
124
+ return (money) => {
125
+ if (money.currency === base) {
126
+ return exact(money);
127
+ }
128
+ let rate = ratesToBase.get(money.currency);
129
+ if (rate === undefined) {
130
+ rate = rateOf(provider, money.currency, base, query);
131
+ ratesToBase.set(money.currency, rate);
132
+ }
133
+ return exact(money).convert(rate);
134
+ };
135
+ }
136
+
137
+ /**
138
+ * Compares amounts in any currencies through one base currency, exactly (no rounding, so rounding
139
+ * makes no ties). One rate per currency makes it a total preorder, so `list.sort(comparator(fx, {
140
+ * base: 'USD' }))` is consistent. Pairwise conversion with bid/ask or slightly inconsistent mids is
141
+ * not, and Array#sort's result then differs between engines. Rates are looked up once per currency,
142
+ * lazily: a missing rate throws from inside `sort`, after the list may already be partly reordered.
143
+ * `sortByValue` looks every rate up first.
144
+ */
145
+ export function comparator(
146
+ provider: RateProvider,
147
+ options: ComparatorOptions,
148
+ ): (money: Money, other: Money) => -1 | 0 | 1 {
149
+ const inBase = baseConverter(provider, options);
150
+ return (money, other) => inBase(money).compare(inBase(other));
151
+ }
152
+
153
+ /**
154
+ * A new array of `items` in ascending order of their value in `options.base`, as `comparator`
155
+ * orders them. Every amount is converted before sorting, so a missing rate throws
156
+ * RateUnavailableError before anything is reordered, and each amount is converted only once.
157
+ *
158
+ * sortByValue([jpy100, gbp1, usd1], fx, { base: 'EUR' }) // [jpy100, usd1, gbp1]
159
+ */
160
+ export function sortByValue<Item extends Money>(
161
+ items: Iterable<Item>,
162
+ provider: RateProvider,
163
+ options: ComparatorOptions,
164
+ ): Item[] {
165
+ const inBase = baseConverter(provider, options);
166
+ const keyed = Array.from(items, (item) => ({ item, value: inBase(item) }));
167
+ // Array#sort is stable, so equal values keep their input order.
168
+ keyed.sort((first, second) => first.value.compare(second.value));
169
+ return keyed.map((entry) => entry.item);
170
+ }
@@ -0,0 +1,71 @@
1
+ /**
2
+ * Braintree's amount view (https://developer.paypal.com/braintree/docs/reference/general/currencies,
3
+ * encoded in data/braintree.json). `amount` is a decimal string in major units, "formatted like
4
+ * '10' or '10.00'":
5
+ * - zero-decimal currencies can't include decimals: BIF CLP DJF GNF ISK JPY KMF KRW LAK PYG RWF UGX
6
+ * VND VUV XAF XOF XPF (LAK has 2 in ISO 4217);
7
+ * - the others take their ISO decimals ("10.00"). No three-decimal currency is listed.
8
+ * Amounts must be greater than zero, refunds and credits included.
9
+ */
10
+ import { BRAINTREE_EXPONENTS } from './generated/braintree.js';
11
+ import { type CurrencyInput, type Money } from './money.js';
12
+ import { fromProcessorDecimal, toProcessorDecimal } from './processor.js';
13
+ import type { RoundingMode } from './rounding.js';
14
+
15
+ export { BRAINTREE_EXPONENTS };
16
+
17
+ /** "Can only contain numbers and one decimal point (e.g. x.xx)". */
18
+ const AMOUNT_PATTERN = /^[0-9]+(\.[0-9]+)?$/;
19
+
20
+ /** The number of decimals Braintree takes for a currency (JPY 0, LAK 0, USD 2). */
21
+ export function braintreeExponent(currency: string): number {
22
+ const exponent = BRAINTREE_EXPONENTS[currency.toUpperCase()];
23
+ if (exponent === undefined) {
24
+ throw new RangeError(`${currency} is not a Braintree currency`);
25
+ }
26
+ return exponent;
27
+ }
28
+
29
+ /** Whether Braintree lists the currency (case-insensitive). */
30
+ export function isBraintreeCurrency(currency: string): boolean {
31
+ return BRAINTREE_EXPONENTS[currency.toUpperCase()] !== undefined;
32
+ }
33
+
34
+ /** Options for the Braintree amount: how to round when Braintree needs a coarser amount. */
35
+ export interface BraintreeAmountOptions {
36
+ /**
37
+ * How to round when Braintree needs fewer decimals than the value has (default 'unnecessary':
38
+ * throw).
39
+ */
40
+ readonly rounding?: RoundingMode;
41
+ }
42
+
43
+ /**
44
+ * The exact `amount` string: toBraintree(money('10', 'USD')) === '10.00', JPY 100 -> '100'.
45
+ * Braintree has no currency field on a transaction: the merchant account sets it.
46
+ * Throws a RangeError for zero or negative amounts.
47
+ */
48
+ export function toBraintree(money: Money, options?: BraintreeAmountOptions): string {
49
+ const rounding = options?.rounding ?? 'unnecessary';
50
+
51
+ // 81531 "Amount must be greater than zero"; 81501: credits are positive too ("x.xx, not -x.xx").
52
+ return toProcessorDecimal(
53
+ 'Braintree',
54
+ money,
55
+ braintreeExponent(money.currency),
56
+ rounding,
57
+ 'positive',
58
+ );
59
+ }
60
+
61
+ /** Reads a Braintree amount string exactly: fromBraintree('10.00', 'USD'). */
62
+ export function fromBraintree<C extends CurrencyInput>(
63
+ amount: string,
64
+ currency: C,
65
+ ): Money<Uppercase<C>> {
66
+ const code = currency.toUpperCase() as Uppercase<C>;
67
+
68
+ // Validates the currency before parsing, so an unlisted currency is the error reported.
69
+ braintreeExponent(code);
70
+ return fromProcessorDecimal('Braintree', amount, code, AMOUNT_PATTERN);
71
+ }