@ducatjs/money 0.0.0 → 0.2.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 (398) hide show
  1. package/LICENSE +21 -0
  2. package/NOTICE +51 -0
  3. package/README.md +1034 -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 +520 -0
  9. package/data/checkout.json +230 -0
  10. package/data/cldr-cash.json +101 -0
  11. package/data/historic.json +78 -0
  12. package/data/iso4217-list-one.xml +1956 -0
  13. package/data/iso4217-list-three.xml +1185 -0
  14. package/data/klarna.json +95 -0
  15. package/data/paypal.json +65 -0
  16. package/data/stripe-fx.json +17 -0
  17. package/data/stripe.json +285 -0
  18. package/dist/adyen-fx.d.ts +25 -0
  19. package/dist/adyen-fx.d.ts.map +1 -0
  20. package/dist/adyen-fx.js +140 -0
  21. package/dist/adyen-fx.js.map +1 -0
  22. package/dist/adyen.d.ts +25 -0
  23. package/dist/adyen.d.ts.map +1 -0
  24. package/dist/adyen.js +34 -0
  25. package/dist/adyen.js.map +1 -0
  26. package/dist/allocate.d.ts +72 -0
  27. package/dist/allocate.d.ts.map +1 -0
  28. package/dist/allocate.js +314 -0
  29. package/dist/allocate.js.map +1 -0
  30. package/dist/bag.d.ts +58 -0
  31. package/dist/bag.d.ts.map +1 -0
  32. package/dist/bag.js +122 -0
  33. package/dist/bag.js.map +1 -0
  34. package/dist/boc.d.ts +31 -0
  35. package/dist/boc.d.ts.map +1 -0
  36. package/dist/boc.js +114 -0
  37. package/dist/boc.js.map +1 -0
  38. package/dist/boe.d.ts +33 -0
  39. package/dist/boe.d.ts.map +1 -0
  40. package/dist/boe.js +114 -0
  41. package/dist/boe.js.map +1 -0
  42. package/dist/braintree.d.ts +34 -0
  43. package/dist/braintree.d.ts.map +1 -0
  44. package/dist/braintree.js +45 -0
  45. package/dist/braintree.js.map +1 -0
  46. package/dist/cash.d.ts +64 -0
  47. package/dist/cash.d.ts.map +1 -0
  48. package/dist/cash.js +134 -0
  49. package/dist/cash.js.map +1 -0
  50. package/dist/checkout.d.ts +42 -0
  51. package/dist/checkout.d.ts.map +1 -0
  52. package/dist/checkout.js +52 -0
  53. package/dist/checkout.js.map +1 -0
  54. package/dist/convert.d.ts +52 -0
  55. package/dist/convert.d.ts.map +1 -0
  56. package/dist/convert.js +65 -0
  57. package/dist/convert.js.map +1 -0
  58. package/dist/currency.d.ts +22 -0
  59. package/dist/currency.d.ts.map +1 -0
  60. package/dist/currency.js +38 -0
  61. package/dist/currency.js.map +1 -0
  62. package/dist/decimal.d.ts +52 -0
  63. package/dist/decimal.d.ts.map +1 -0
  64. package/dist/decimal.js +251 -0
  65. package/dist/decimal.js.map +1 -0
  66. package/dist/drizzle/mysql.d.ts +105 -0
  67. package/dist/drizzle/mysql.d.ts.map +1 -0
  68. package/dist/drizzle/mysql.js +60 -0
  69. package/dist/drizzle/mysql.js.map +1 -0
  70. package/dist/drizzle/pg.d.ts +103 -0
  71. package/dist/drizzle/pg.d.ts.map +1 -0
  72. package/dist/drizzle/pg.js +60 -0
  73. package/dist/drizzle/pg.js.map +1 -0
  74. package/dist/drizzle/sqlite.d.ts +102 -0
  75. package/dist/drizzle/sqlite.d.ts.map +1 -0
  76. package/dist/drizzle/sqlite.js +59 -0
  77. package/dist/drizzle/sqlite.js.map +1 -0
  78. package/dist/ecb.d.ts +52 -0
  79. package/dist/ecb.d.ts.map +1 -0
  80. package/dist/ecb.js +285 -0
  81. package/dist/ecb.js.map +1 -0
  82. package/dist/exact.d.ts +62 -0
  83. package/dist/exact.d.ts.map +1 -0
  84. package/dist/exact.js +147 -0
  85. package/dist/exact.js.map +1 -0
  86. package/dist/fed.d.ts +35 -0
  87. package/dist/fed.d.ts.map +1 -0
  88. package/dist/fed.js +134 -0
  89. package/dist/fed.js.map +1 -0
  90. package/dist/fetch.d.ts +103 -0
  91. package/dist/fetch.d.ts.map +1 -0
  92. package/dist/fetch.js +222 -0
  93. package/dist/fetch.js.map +1 -0
  94. package/dist/fixed.d.ts +121 -0
  95. package/dist/fixed.d.ts.map +1 -0
  96. package/dist/fixed.js +420 -0
  97. package/dist/fixed.js.map +1 -0
  98. package/dist/fn.d.ts +34 -0
  99. package/dist/fn.d.ts.map +1 -0
  100. package/dist/fn.js +37 -0
  101. package/dist/fn.js.map +1 -0
  102. package/dist/format.d.ts +13 -0
  103. package/dist/format.d.ts.map +1 -0
  104. package/dist/format.js +90 -0
  105. package/dist/format.js.map +1 -0
  106. package/dist/generated/adyen.d.ts +3 -0
  107. package/dist/generated/adyen.d.ts.map +1 -0
  108. package/dist/generated/adyen.js +144 -0
  109. package/dist/generated/adyen.js.map +1 -0
  110. package/dist/generated/braintree.d.ts +3 -0
  111. package/dist/generated/braintree.d.ts.map +1 -0
  112. package/dist/generated/braintree.js +140 -0
  113. package/dist/generated/braintree.js.map +1 -0
  114. package/dist/generated/cash.d.ts +14 -0
  115. package/dist/generated/cash.d.ts.map +1 -0
  116. package/dist/generated/cash.js +465 -0
  117. package/dist/generated/cash.js.map +1 -0
  118. package/dist/generated/checkout.d.ts +3 -0
  119. package/dist/generated/checkout.d.ts.map +1 -0
  120. package/dist/generated/checkout.js +151 -0
  121. package/dist/generated/checkout.js.map +1 -0
  122. package/dist/generated/historic.d.ts +337 -0
  123. package/dist/generated/historic.d.ts.map +1 -0
  124. package/dist/generated/historic.js +66 -0
  125. package/dist/generated/historic.js.map +1 -0
  126. package/dist/generated/iso4217.d.ts +174 -0
  127. package/dist/generated/iso4217.d.ts.map +1 -0
  128. package/dist/generated/iso4217.js +175 -0
  129. package/dist/generated/iso4217.js.map +1 -0
  130. package/dist/generated/kernel.d.ts +17 -0
  131. package/dist/generated/kernel.d.ts.map +1 -0
  132. package/dist/generated/kernel.js +208 -0
  133. package/dist/generated/kernel.js.map +1 -0
  134. package/dist/generated/klarna.d.ts +3 -0
  135. package/dist/generated/klarna.d.ts.map +1 -0
  136. package/dist/generated/klarna.js +21 -0
  137. package/dist/generated/klarna.js.map +1 -0
  138. package/dist/generated/paypal.d.ts +3 -0
  139. package/dist/generated/paypal.d.ts.map +1 -0
  140. package/dist/generated/paypal.js +31 -0
  141. package/dist/generated/paypal.js.map +1 -0
  142. package/dist/generated/stripe.d.ts +8 -0
  143. package/dist/generated/stripe.d.ts.map +1 -0
  144. package/dist/generated/stripe.js +150 -0
  145. package/dist/generated/stripe.js.map +1 -0
  146. package/dist/historic.d.ts +40 -0
  147. package/dist/historic.d.ts.map +1 -0
  148. package/dist/historic.js +43 -0
  149. package/dist/historic.js.map +1 -0
  150. package/dist/history.d.ts +96 -0
  151. package/dist/history.d.ts.map +1 -0
  152. package/dist/history.js +213 -0
  153. package/dist/history.js.map +1 -0
  154. package/dist/hmrc.d.ts +28 -0
  155. package/dist/hmrc.d.ts.map +1 -0
  156. package/dist/hmrc.js +108 -0
  157. package/dist/hmrc.js.map +1 -0
  158. package/dist/index.d.ts +11 -0
  159. package/dist/index.d.ts.map +1 -0
  160. package/dist/index.js +11 -0
  161. package/dist/index.js.map +1 -0
  162. package/dist/klarna.d.ts +31 -0
  163. package/dist/klarna.d.ts.map +1 -0
  164. package/dist/klarna.js +38 -0
  165. package/dist/klarna.js.map +1 -0
  166. package/dist/kysely.d.ts +120 -0
  167. package/dist/kysely.d.ts.map +1 -0
  168. package/dist/kysely.js +309 -0
  169. package/dist/kysely.js.map +1 -0
  170. package/dist/money.d.ts +302 -0
  171. package/dist/money.d.ts.map +1 -0
  172. package/dist/money.js +703 -0
  173. package/dist/money.js.map +1 -0
  174. package/dist/parse.d.ts +55 -0
  175. package/dist/parse.d.ts.map +1 -0
  176. package/dist/parse.js +419 -0
  177. package/dist/parse.js.map +1 -0
  178. package/dist/paypal.d.ts +41 -0
  179. package/dist/paypal.d.ts.map +1 -0
  180. package/dist/paypal.js +58 -0
  181. package/dist/paypal.js.map +1 -0
  182. package/dist/pg.d.ts +100 -0
  183. package/dist/pg.d.ts.map +1 -0
  184. package/dist/pg.js +343 -0
  185. package/dist/pg.js.map +1 -0
  186. package/dist/plugins/adyen.d.ts +15 -0
  187. package/dist/plugins/adyen.d.ts.map +1 -0
  188. package/dist/plugins/adyen.js +10 -0
  189. package/dist/plugins/adyen.js.map +1 -0
  190. package/dist/plugins/all.d.ts +29 -0
  191. package/dist/plugins/all.d.ts.map +1 -0
  192. package/dist/plugins/all.js +30 -0
  193. package/dist/plugins/all.js.map +1 -0
  194. package/dist/plugins/allocate.d.ts +20 -0
  195. package/dist/plugins/allocate.d.ts.map +1 -0
  196. package/dist/plugins/allocate.js +26 -0
  197. package/dist/plugins/allocate.js.map +1 -0
  198. package/dist/plugins/braintree.d.ts +12 -0
  199. package/dist/plugins/braintree.d.ts.map +1 -0
  200. package/dist/plugins/braintree.js +12 -0
  201. package/dist/plugins/braintree.js.map +1 -0
  202. package/dist/plugins/cash.d.ts +19 -0
  203. package/dist/plugins/cash.d.ts.map +1 -0
  204. package/dist/plugins/cash.js +16 -0
  205. package/dist/plugins/cash.js.map +1 -0
  206. package/dist/plugins/checkout.d.ts +21 -0
  207. package/dist/plugins/checkout.d.ts.map +1 -0
  208. package/dist/plugins/checkout.js +16 -0
  209. package/dist/plugins/checkout.js.map +1 -0
  210. package/dist/plugins/convert.d.ts +19 -0
  211. package/dist/plugins/convert.d.ts.map +1 -0
  212. package/dist/plugins/convert.js +13 -0
  213. package/dist/plugins/convert.js.map +1 -0
  214. package/dist/plugins/exact.d.ts +15 -0
  215. package/dist/plugins/exact.d.ts.map +1 -0
  216. package/dist/plugins/exact.js +13 -0
  217. package/dist/plugins/exact.js.map +1 -0
  218. package/dist/plugins/fixed.d.ts +17 -0
  219. package/dist/plugins/fixed.d.ts.map +1 -0
  220. package/dist/plugins/fixed.js +13 -0
  221. package/dist/plugins/fixed.js.map +1 -0
  222. package/dist/plugins/format.d.ts +9 -0
  223. package/dist/plugins/format.d.ts.map +1 -0
  224. package/dist/plugins/format.js +16 -0
  225. package/dist/plugins/format.js.map +1 -0
  226. package/dist/plugins/klarna.d.ts +10 -0
  227. package/dist/plugins/klarna.d.ts.map +1 -0
  228. package/dist/plugins/klarna.js +10 -0
  229. package/dist/plugins/klarna.js.map +1 -0
  230. package/dist/plugins/paypal.d.ts +14 -0
  231. package/dist/plugins/paypal.d.ts.map +1 -0
  232. package/dist/plugins/paypal.js +16 -0
  233. package/dist/plugins/paypal.js.map +1 -0
  234. package/dist/plugins/prisma.d.ts +8 -0
  235. package/dist/plugins/prisma.d.ts.map +1 -0
  236. package/dist/plugins/prisma.js +13 -0
  237. package/dist/plugins/prisma.js.map +1 -0
  238. package/dist/plugins/stripe.d.ts +14 -0
  239. package/dist/plugins/stripe.d.ts.map +1 -0
  240. package/dist/plugins/stripe.js +16 -0
  241. package/dist/plugins/stripe.js.map +1 -0
  242. package/dist/plugins/tax.d.ts +20 -0
  243. package/dist/plugins/tax.d.ts.map +1 -0
  244. package/dist/plugins/tax.js +18 -0
  245. package/dist/plugins/tax.js.map +1 -0
  246. package/dist/policy.d.ts +34 -0
  247. package/dist/policy.d.ts.map +1 -0
  248. package/dist/policy.js +90 -0
  249. package/dist/policy.js.map +1 -0
  250. package/dist/prisma.d.ts +77 -0
  251. package/dist/prisma.d.ts.map +1 -0
  252. package/dist/prisma.js +76 -0
  253. package/dist/prisma.js.map +1 -0
  254. package/dist/processor.d.ts +28 -0
  255. package/dist/processor.d.ts.map +1 -0
  256. package/dist/processor.js +118 -0
  257. package/dist/processor.js.map +1 -0
  258. package/dist/provider.d.ts +103 -0
  259. package/dist/provider.d.ts.map +1 -0
  260. package/dist/provider.js +280 -0
  261. package/dist/provider.js.map +1 -0
  262. package/dist/rate-text.d.ts +38 -0
  263. package/dist/rate-text.d.ts.map +1 -0
  264. package/dist/rate-text.js +143 -0
  265. package/dist/rate-text.js.map +1 -0
  266. package/dist/rate.d.ts +175 -0
  267. package/dist/rate.d.ts.map +1 -0
  268. package/dist/rate.js +371 -0
  269. package/dist/rate.js.map +1 -0
  270. package/dist/rounding-error.d.ts +9 -0
  271. package/dist/rounding-error.d.ts.map +1 -0
  272. package/dist/rounding-error.js +9 -0
  273. package/dist/rounding-error.js.map +1 -0
  274. package/dist/rounding.d.ts +50 -0
  275. package/dist/rounding.d.ts.map +1 -0
  276. package/dist/rounding.js +87 -0
  277. package/dist/rounding.js.map +1 -0
  278. package/dist/schema.d.ts +43 -0
  279. package/dist/schema.d.ts.map +1 -0
  280. package/dist/schema.js +77 -0
  281. package/dist/schema.js.map +1 -0
  282. package/dist/sql.d.ts +54 -0
  283. package/dist/sql.d.ts.map +1 -0
  284. package/dist/sql.js +131 -0
  285. package/dist/sql.js.map +1 -0
  286. package/dist/stripe-fx.d.ts +22 -0
  287. package/dist/stripe-fx.d.ts.map +1 -0
  288. package/dist/stripe-fx.js +30 -0
  289. package/dist/stripe-fx.js.map +1 -0
  290. package/dist/stripe.d.ts +36 -0
  291. package/dist/stripe.d.ts.map +1 -0
  292. package/dist/stripe.js +39 -0
  293. package/dist/stripe.js.map +1 -0
  294. package/dist/tax.d.ts +33 -0
  295. package/dist/tax.d.ts.map +1 -0
  296. package/dist/tax.js +55 -0
  297. package/dist/tax.js.map +1 -0
  298. package/dist/testing/arbitraries.d.ts +43 -0
  299. package/dist/testing/arbitraries.d.ts.map +1 -0
  300. package/dist/testing/arbitraries.js +98 -0
  301. package/dist/testing/arbitraries.js.map +1 -0
  302. package/dist/testing/index.d.ts +67 -0
  303. package/dist/testing/index.d.ts.map +1 -0
  304. package/dist/testing/index.js +159 -0
  305. package/dist/testing/index.js.map +1 -0
  306. package/dist/testing/jest.d.ts +2 -0
  307. package/dist/testing/jest.d.ts.map +1 -0
  308. package/dist/testing/jest.js +15 -0
  309. package/dist/testing/jest.js.map +1 -0
  310. package/dist/testing/vitest.d.ts +7 -0
  311. package/dist/testing/vitest.d.ts.map +1 -0
  312. package/dist/testing/vitest.js +12 -0
  313. package/dist/testing/vitest.js.map +1 -0
  314. package/dist/valibot.d.ts +33 -0
  315. package/dist/valibot.d.ts.map +1 -0
  316. package/dist/valibot.js +61 -0
  317. package/dist/valibot.js.map +1 -0
  318. package/dist/zod.d.ts +36 -0
  319. package/dist/zod.d.ts.map +1 -0
  320. package/dist/zod.js +58 -0
  321. package/dist/zod.js.map +1 -0
  322. package/package.json +280 -5
  323. package/src/adyen-fx.ts +175 -0
  324. package/src/adyen.ts +46 -0
  325. package/src/allocate.ts +393 -0
  326. package/src/bag.ts +170 -0
  327. package/src/boc.ts +136 -0
  328. package/src/boe.ts +131 -0
  329. package/src/braintree.ts +71 -0
  330. package/src/cash.ts +183 -0
  331. package/src/checkout.ts +77 -0
  332. package/src/convert.ts +177 -0
  333. package/src/currency.ts +61 -0
  334. package/src/decimal.ts +283 -0
  335. package/src/drizzle/mysql.ts +78 -0
  336. package/src/drizzle/pg.ts +78 -0
  337. package/src/drizzle/sqlite.ts +70 -0
  338. package/src/ecb.ts +328 -0
  339. package/src/exact.ts +182 -0
  340. package/src/fed.ts +146 -0
  341. package/src/fetch.ts +306 -0
  342. package/src/fixed.ts +558 -0
  343. package/src/fn.ts +94 -0
  344. package/src/format.ts +106 -0
  345. package/src/generated/adyen.ts +144 -0
  346. package/src/generated/braintree.ts +140 -0
  347. package/src/generated/cash.ts +478 -0
  348. package/src/generated/checkout.ts +151 -0
  349. package/src/generated/historic.ts +67 -0
  350. package/src/generated/iso4217.ts +179 -0
  351. package/src/generated/kernel.ts +230 -0
  352. package/src/generated/klarna.ts +21 -0
  353. package/src/generated/paypal.ts +31 -0
  354. package/src/generated/stripe.ts +150 -0
  355. package/src/historic.ts +77 -0
  356. package/src/history.ts +333 -0
  357. package/src/hmrc.ts +125 -0
  358. package/src/index.ts +41 -0
  359. package/src/klarna.ts +54 -0
  360. package/src/kysely.ts +588 -0
  361. package/src/money.ts +979 -0
  362. package/src/parse.ts +511 -0
  363. package/src/paypal.ts +93 -0
  364. package/src/pg.ts +410 -0
  365. package/src/plugins/adyen.ts +25 -0
  366. package/src/plugins/all.ts +57 -0
  367. package/src/plugins/allocate.ts +53 -0
  368. package/src/plugins/braintree.ts +23 -0
  369. package/src/plugins/cash.ts +33 -0
  370. package/src/plugins/checkout.ts +32 -0
  371. package/src/plugins/convert.ts +49 -0
  372. package/src/plugins/exact.ts +23 -0
  373. package/src/plugins/fixed.ts +28 -0
  374. package/src/plugins/format.ts +28 -0
  375. package/src/plugins/klarna.ts +21 -0
  376. package/src/plugins/paypal.ts +29 -0
  377. package/src/plugins/prisma.ts +24 -0
  378. package/src/plugins/stripe.ts +29 -0
  379. package/src/plugins/tax.ts +40 -0
  380. package/src/policy.ts +124 -0
  381. package/src/prisma.ts +199 -0
  382. package/src/processor.ts +181 -0
  383. package/src/provider.ts +389 -0
  384. package/src/rate-text.ts +151 -0
  385. package/src/rate.ts +546 -0
  386. package/src/rounding-error.ts +8 -0
  387. package/src/rounding.ts +122 -0
  388. package/src/schema.ts +119 -0
  389. package/src/sql.ts +178 -0
  390. package/src/stripe-fx.ts +49 -0
  391. package/src/stripe.ts +69 -0
  392. package/src/tax.ts +102 -0
  393. package/src/testing/arbitraries.ts +134 -0
  394. package/src/testing/index.ts +247 -0
  395. package/src/testing/jest.ts +18 -0
  396. package/src/testing/vitest.ts +18 -0
  397. package/src/valibot.ts +87 -0
  398. package/src/zod.ts +78 -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
+ }
package/src/boc.ts ADDED
@@ -0,0 +1,136 @@
1
+ // `@ducatjs/money/convert/boc`: an offline parser for the Bank of Canada's daily exchange rates
2
+ // (the Valet API). No I/O: fetch the JSON yourself, or use fetchBankOfCanada from
3
+ // @ducatjs/money/convert/fetch. Rates are kept exactly as published.
4
+ import { quotedTable } from './history.js';
5
+ import type { RateTable } from './provider.js';
6
+ import { addDayRate, isCalendarDay, newestFirst, readRateCell } from './rate-text.js';
7
+
8
+ const SERIES = /^FX([A-Z]{3})CAD$/;
9
+ const ZERO = /^0+(?:\.0+)?$/;
10
+
11
+ // The Bank publishes "once each business day by 16:30 (ET)". One formatter for the zone, built on
12
+ // first use, gives the Toronto offset on any day, summer time included.
13
+ let torontoFormatter: Intl.DateTimeFormat | undefined;
14
+
15
+ /** 16:30 in Toronto on `day`, as a UTC instant. */
16
+ function publishedBy(day: string): string {
17
+ torontoFormatter ??= new Intl.DateTimeFormat('en-US', {
18
+ timeZone: 'America/Toronto',
19
+ timeZoneName: 'shortOffset',
20
+ });
21
+ // Noon UTC is 07:00 or 08:00 in Toronto, after any clock change that day (they happen at 02:00).
22
+ const noon = new Date(`${day}T12:00:00Z`);
23
+ const zone = torontoFormatter.formatToParts(noon).find((part) => part.type === 'timeZoneName');
24
+ const offset = /^GMT([+-]\d+)$/.exec(zone?.value ?? '');
25
+ if (offset === null) {
26
+ throw new RangeError(`Can't tell Toronto's UTC offset on ${day} (${zone?.value})`);
27
+ }
28
+ const utcMs = Date.parse(`${day}T16:30:00Z`) - Number(offset[1]) * 3_600_000;
29
+ return new Date(utcMs).toISOString();
30
+ }
31
+
32
+ function notValet(why: string): never {
33
+ throw new SyntaxError(`Not a Bank of Canada Valet daily FX file: ${why}`);
34
+ }
35
+
36
+ /** Options for parseBankOfCanadaJson. */
37
+ export interface BankOfCanadaOptions {
38
+ /**
39
+ * When each day's rates were published, or false for none. Default: 16:30 Toronto time that day,
40
+ * when the Bank says it publishes them.
41
+ */
42
+ readonly availableAt?: false | ((day: string) => string | Date);
43
+ }
44
+
45
+ /**
46
+ * Parses the Bank of Canada's daily exchange rates from the Valet API's JSON (the
47
+ * `FX_RATES_DAILY` group, or single `FX…CAD` series) into one CAD-based table per business day,
48
+ * newest first. The Bank quotes CAD per unit of each currency (FXUSDCAD 1.3748), so each table
49
+ * holds the exact inverse, CAD → currency, and keeps the published quote: `rate.inverse()` prints
50
+ * '1.3748' again. Rates have `source: 'BoC'`, `type: 'average'` (an average of a day's quotes, not
51
+ * a market rate) and `availableAt` 16:30 Toronto time, when the Bank says it publishes them (a late
52
+ * rate can come later; pass `availableAt` to change it). Valet leaves a series out of a day
53
+ * without a value, so a series listed in `seriesDetail` but absent on a day, an empty value or a
54
+ * null one, is in that day's `missing`. Anything but daily `FX…CAD` series (monthly or annual
55
+ * groups, other data) throws SyntaxError, as do two different values for one series on one day.
56
+ *
57
+ * Precision: the Bank quotes four decimals, up to six for small rates, so a small rate has only
58
+ * about four significant digits (JPY 0.008760, KRW 0.000929, and IDR 0.000082 with two). Its
59
+ * inverse is exact, but only as precise as that. For such currencies, prefer a source that quotes
60
+ * them the other way round.
61
+ *
62
+ * const tables = parseBankOfCanadaJson(await (await fetch(url)).text());
63
+ * tables[0].rate('CAD', 'USD')?.inverse().toString(); // '1.3748'
64
+ */
65
+ export function parseBankOfCanadaJson(json: string, options?: BankOfCanadaOptions): RateTable[] {
66
+ const parsed = JSON.parse(json) as {
67
+ observations?: unknown;
68
+ seriesDetail?: Record<string, unknown>;
69
+ } | null;
70
+ const observations = parsed?.observations;
71
+ if (!Array.isArray(observations)) {
72
+ notValet('no observations');
73
+ }
74
+
75
+ // The series the file says it has; each must be a daily FX…CAD series.
76
+ const expected = new Set<string>();
77
+ for (const series of Object.keys(parsed?.seriesDetail ?? {})) {
78
+ if (!SERIES.test(series)) {
79
+ notValet(`series ${series} isn't a daily FX…CAD rate`);
80
+ }
81
+ expected.add(series);
82
+ }
83
+
84
+ const days = new Map<string, Record<string, string | null>>();
85
+ for (const observation of observations as readonly Record<string, unknown>[]) {
86
+ const day = observation?.['d'];
87
+ if (typeof day !== 'string' || !isCalendarDay(day)) {
88
+ notValet(`bad date ${JSON.stringify(day)}`);
89
+ }
90
+
91
+ for (const [series, cell] of Object.entries(observation)) {
92
+ if (series === 'd') {
93
+ continue;
94
+ }
95
+ const currency = SERIES.exec(series)?.[1];
96
+ if (currency === undefined) {
97
+ notValet(`series ${series} isn't a daily FX…CAD rate`);
98
+ }
99
+ const where = `${series} on ${day}`;
100
+ const value = (cell as { v?: unknown } | null)?.v;
101
+ if (value !== null && typeof value !== 'string') {
102
+ throw new SyntaxError(`Malformed Valet value for ${where}`);
103
+ }
104
+ const rate = value === null ? null : readRateCell(value, where);
105
+ if (rate !== null && ZERO.test(rate)) {
106
+ throw new SyntaxError(`A zero rate for ${where}`);
107
+ }
108
+ addDayRate(days, day, currency, rate);
109
+ }
110
+ // Valet leaves out a series without a value that day.
111
+ for (const series of expected) {
112
+ const currency = SERIES.exec(series)![1]!;
113
+ if (days.get(day)?.[currency] === undefined) {
114
+ addDayRate(days, day, currency, null);
115
+ }
116
+ }
117
+ }
118
+
119
+ const tables: RateTable[] = [];
120
+ for (const [day, rates] of days) {
121
+ if (!Object.values(rates).some((rate) => rate !== null)) {
122
+ continue;
123
+ }
124
+ let availableAt: string | Date | undefined;
125
+ if (options?.availableAt === undefined) {
126
+ availableAt = publishedBy(day);
127
+ } else if (options.availableAt !== false) {
128
+ availableAt = options.availableAt(day);
129
+ }
130
+ const info = { base: 'CAD', inverted: true, source: 'BoC', type: 'average', asOf: day };
131
+ tables.push(
132
+ quotedTable({ ...info, ...(availableAt === undefined ? {} : { availableAt }), rates }),
133
+ );
134
+ }
135
+ return newestFirst(tables);
136
+ }