@ducatjs/money 0.1.0 → 0.2.1

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 (253) hide show
  1. package/NOTICE +73 -0
  2. package/README.md +479 -62
  3. package/data/cash-rounding.json +123 -2
  4. package/data/cldr-cash.json +101 -0
  5. package/data/historic.json +78 -0
  6. package/data/iso4217-list-three.xml +1185 -0
  7. package/dist/adyen-fx.d.ts.map +1 -1
  8. package/dist/adyen-fx.js +22 -4
  9. package/dist/adyen-fx.js.map +1 -1
  10. package/dist/boc.d.ts +31 -0
  11. package/dist/boc.d.ts.map +1 -0
  12. package/dist/boc.js +114 -0
  13. package/dist/boc.js.map +1 -0
  14. package/dist/boe.d.ts +33 -0
  15. package/dist/boe.d.ts.map +1 -0
  16. package/dist/boe.js +114 -0
  17. package/dist/boe.js.map +1 -0
  18. package/dist/cash.d.ts +30 -1
  19. package/dist/cash.d.ts.map +1 -1
  20. package/dist/cash.js +50 -2
  21. package/dist/cash.js.map +1 -1
  22. package/dist/convert.d.ts +10 -9
  23. package/dist/convert.d.ts.map +1 -1
  24. package/dist/convert.js +13 -3
  25. package/dist/convert.js.map +1 -1
  26. package/dist/drizzle/mysql.d.ts +105 -0
  27. package/dist/drizzle/mysql.d.ts.map +1 -0
  28. package/dist/drizzle/mysql.js +60 -0
  29. package/dist/drizzle/mysql.js.map +1 -0
  30. package/dist/drizzle/pg.d.ts +103 -0
  31. package/dist/drizzle/pg.d.ts.map +1 -0
  32. package/dist/drizzle/pg.js +60 -0
  33. package/dist/drizzle/pg.js.map +1 -0
  34. package/dist/drizzle/sqlite.d.ts +102 -0
  35. package/dist/drizzle/sqlite.d.ts.map +1 -0
  36. package/dist/drizzle/sqlite.js +59 -0
  37. package/dist/drizzle/sqlite.js.map +1 -0
  38. package/dist/ecb.d.ts +37 -2
  39. package/dist/ecb.d.ts.map +1 -1
  40. package/dist/ecb.js +148 -10
  41. package/dist/ecb.js.map +1 -1
  42. package/dist/fed.d.ts +35 -0
  43. package/dist/fed.d.ts.map +1 -0
  44. package/dist/fed.js +134 -0
  45. package/dist/fed.js.map +1 -0
  46. package/dist/fetch.d.ts +45 -0
  47. package/dist/fetch.d.ts.map +1 -1
  48. package/dist/fetch.js +167 -24
  49. package/dist/fetch.js.map +1 -1
  50. package/dist/fn.d.ts +8 -1
  51. package/dist/fn.d.ts.map +1 -1
  52. package/dist/fn.js +8 -1
  53. package/dist/fn.js.map +1 -1
  54. package/dist/generated/cash.d.ts +2 -0
  55. package/dist/generated/cash.d.ts.map +1 -1
  56. package/dist/generated/cash.js +101 -0
  57. package/dist/generated/cash.js.map +1 -1
  58. package/dist/generated/historic.d.ts +337 -0
  59. package/dist/generated/historic.d.ts.map +1 -0
  60. package/dist/generated/historic.js +66 -0
  61. package/dist/generated/historic.js.map +1 -0
  62. package/dist/historic.d.ts +40 -0
  63. package/dist/historic.d.ts.map +1 -0
  64. package/dist/historic.js +43 -0
  65. package/dist/historic.js.map +1 -0
  66. package/dist/history.d.ts +96 -0
  67. package/dist/history.d.ts.map +1 -0
  68. package/dist/history.js +213 -0
  69. package/dist/history.js.map +1 -0
  70. package/dist/hmrc.d.ts +28 -0
  71. package/dist/hmrc.d.ts.map +1 -0
  72. package/dist/hmrc.js +108 -0
  73. package/dist/hmrc.js.map +1 -0
  74. package/dist/index.d.ts +1 -1
  75. package/dist/index.d.ts.map +1 -1
  76. package/dist/index.js +1 -1
  77. package/dist/index.js.map +1 -1
  78. package/dist/kysely.d.ts +120 -0
  79. package/dist/kysely.d.ts.map +1 -0
  80. package/dist/kysely.js +309 -0
  81. package/dist/kysely.js.map +1 -0
  82. package/dist/money.d.ts +93 -9
  83. package/dist/money.d.ts.map +1 -1
  84. package/dist/money.js +135 -9
  85. package/dist/money.js.map +1 -1
  86. package/dist/parse.d.ts +45 -1
  87. package/dist/parse.d.ts.map +1 -1
  88. package/dist/parse.js +290 -3
  89. package/dist/parse.js.map +1 -1
  90. package/dist/pg.d.ts +100 -0
  91. package/dist/pg.d.ts.map +1 -0
  92. package/dist/pg.js +343 -0
  93. package/dist/pg.js.map +1 -0
  94. package/dist/plugins/adyen.d.ts +1 -1
  95. package/dist/plugins/adyen.d.ts.map +1 -1
  96. package/dist/plugins/adyen.js.map +1 -1
  97. package/dist/plugins/all.d.ts +8 -1
  98. package/dist/plugins/all.d.ts.map +1 -1
  99. package/dist/plugins/all.js +8 -1
  100. package/dist/plugins/all.js.map +1 -1
  101. package/dist/plugins/allocate.d.ts +1 -1
  102. package/dist/plugins/allocate.d.ts.map +1 -1
  103. package/dist/plugins/allocate.js.map +1 -1
  104. package/dist/plugins/braintree.d.ts +1 -1
  105. package/dist/plugins/braintree.d.ts.map +1 -1
  106. package/dist/plugins/braintree.js.map +1 -1
  107. package/dist/plugins/cash.d.ts +1 -1
  108. package/dist/plugins/cash.d.ts.map +1 -1
  109. package/dist/plugins/cash.js.map +1 -1
  110. package/dist/plugins/checkout.d.ts +1 -1
  111. package/dist/plugins/checkout.d.ts.map +1 -1
  112. package/dist/plugins/checkout.js.map +1 -1
  113. package/dist/plugins/convert.d.ts +5 -5
  114. package/dist/plugins/convert.d.ts.map +1 -1
  115. package/dist/plugins/convert.js.map +1 -1
  116. package/dist/plugins/exact.d.ts +1 -1
  117. package/dist/plugins/exact.d.ts.map +1 -1
  118. package/dist/plugins/exact.js.map +1 -1
  119. package/dist/plugins/fixed.d.ts +1 -1
  120. package/dist/plugins/fixed.d.ts.map +1 -1
  121. package/dist/plugins/fixed.js.map +1 -1
  122. package/dist/plugins/format.d.ts +1 -1
  123. package/dist/plugins/format.d.ts.map +1 -1
  124. package/dist/plugins/format.js.map +1 -1
  125. package/dist/plugins/klarna.d.ts +1 -1
  126. package/dist/plugins/klarna.d.ts.map +1 -1
  127. package/dist/plugins/klarna.js.map +1 -1
  128. package/dist/plugins/paypal.d.ts +1 -1
  129. package/dist/plugins/paypal.d.ts.map +1 -1
  130. package/dist/plugins/paypal.js +0 -4
  131. package/dist/plugins/paypal.js.map +1 -1
  132. package/dist/plugins/prisma.d.ts +1 -1
  133. package/dist/plugins/prisma.d.ts.map +1 -1
  134. package/dist/plugins/prisma.js +0 -4
  135. package/dist/plugins/prisma.js.map +1 -1
  136. package/dist/plugins/stripe.d.ts +1 -1
  137. package/dist/plugins/stripe.d.ts.map +1 -1
  138. package/dist/plugins/stripe.js +0 -4
  139. package/dist/plugins/stripe.js.map +1 -1
  140. package/dist/plugins/tax.d.ts +6 -6
  141. package/dist/plugins/tax.d.ts.map +1 -1
  142. package/dist/plugins/tax.js.map +1 -1
  143. package/dist/policy.d.ts +34 -0
  144. package/dist/policy.d.ts.map +1 -0
  145. package/dist/policy.js +90 -0
  146. package/dist/policy.js.map +1 -0
  147. package/dist/prisma.d.ts +25 -14
  148. package/dist/prisma.d.ts.map +1 -1
  149. package/dist/prisma.js +29 -19
  150. package/dist/prisma.js.map +1 -1
  151. package/dist/provider.d.ts +21 -7
  152. package/dist/provider.d.ts.map +1 -1
  153. package/dist/provider.js +51 -11
  154. package/dist/provider.js.map +1 -1
  155. package/dist/rate-text.d.ts +38 -0
  156. package/dist/rate-text.d.ts.map +1 -0
  157. package/dist/rate-text.js +143 -0
  158. package/dist/rate-text.js.map +1 -0
  159. package/dist/rate.d.ts +61 -5
  160. package/dist/rate.d.ts.map +1 -1
  161. package/dist/rate.js +133 -33
  162. package/dist/rate.js.map +1 -1
  163. package/dist/rounding.js +1 -1
  164. package/dist/rounding.js.map +1 -1
  165. package/dist/schema.d.ts +43 -0
  166. package/dist/schema.d.ts.map +1 -0
  167. package/dist/schema.js +77 -0
  168. package/dist/schema.js.map +1 -0
  169. package/dist/sql.d.ts +54 -0
  170. package/dist/sql.d.ts.map +1 -0
  171. package/dist/sql.js +131 -0
  172. package/dist/sql.js.map +1 -0
  173. package/dist/tax.d.ts +13 -9
  174. package/dist/tax.d.ts.map +1 -1
  175. package/dist/tax.js +20 -4
  176. package/dist/tax.js.map +1 -1
  177. package/dist/testing/arbitraries.d.ts +43 -0
  178. package/dist/testing/arbitraries.d.ts.map +1 -0
  179. package/dist/testing/arbitraries.js +98 -0
  180. package/dist/testing/arbitraries.js.map +1 -0
  181. package/dist/testing/index.d.ts +67 -0
  182. package/dist/testing/index.d.ts.map +1 -0
  183. package/dist/testing/index.js +159 -0
  184. package/dist/testing/index.js.map +1 -0
  185. package/dist/testing/jest.d.ts +2 -0
  186. package/dist/testing/jest.d.ts.map +1 -0
  187. package/dist/testing/jest.js +15 -0
  188. package/dist/testing/jest.js.map +1 -0
  189. package/dist/testing/vitest.d.ts +7 -0
  190. package/dist/testing/vitest.d.ts.map +1 -0
  191. package/dist/testing/vitest.js +12 -0
  192. package/dist/testing/vitest.js.map +1 -0
  193. package/dist/valibot.d.ts +33 -0
  194. package/dist/valibot.d.ts.map +1 -0
  195. package/dist/valibot.js +61 -0
  196. package/dist/valibot.js.map +1 -0
  197. package/dist/zod.d.ts +36 -0
  198. package/dist/zod.d.ts.map +1 -0
  199. package/dist/zod.js +58 -0
  200. package/dist/zod.js.map +1 -0
  201. package/package.json +116 -5
  202. package/src/adyen-fx.ts +25 -4
  203. package/src/boc.ts +136 -0
  204. package/src/boe.ts +131 -0
  205. package/src/cash.ts +70 -2
  206. package/src/convert.ts +33 -15
  207. package/src/drizzle/mysql.ts +78 -0
  208. package/src/drizzle/pg.ts +78 -0
  209. package/src/drizzle/sqlite.ts +70 -0
  210. package/src/ecb.ts +179 -13
  211. package/src/fed.ts +146 -0
  212. package/src/fetch.ts +207 -28
  213. package/src/fn.ts +14 -0
  214. package/src/generated/cash.ts +103 -0
  215. package/src/generated/historic.ts +67 -0
  216. package/src/historic.ts +77 -0
  217. package/src/history.ts +333 -0
  218. package/src/hmrc.ts +125 -0
  219. package/src/index.ts +4 -0
  220. package/src/kysely.ts +588 -0
  221. package/src/money.ts +210 -12
  222. package/src/parse.ts +371 -4
  223. package/src/pg.ts +410 -0
  224. package/src/plugins/adyen.ts +3 -2
  225. package/src/plugins/all.ts +10 -0
  226. package/src/plugins/allocate.ts +3 -2
  227. package/src/plugins/braintree.ts +3 -2
  228. package/src/plugins/cash.ts +3 -2
  229. package/src/plugins/checkout.ts +3 -2
  230. package/src/plugins/convert.ts +6 -6
  231. package/src/plugins/exact.ts +3 -2
  232. package/src/plugins/fixed.ts +3 -2
  233. package/src/plugins/format.ts +3 -2
  234. package/src/plugins/klarna.ts +3 -2
  235. package/src/plugins/paypal.ts +3 -2
  236. package/src/plugins/prisma.ts +3 -2
  237. package/src/plugins/stripe.ts +3 -2
  238. package/src/plugins/tax.ts +11 -11
  239. package/src/policy.ts +124 -0
  240. package/src/prisma.ts +91 -10
  241. package/src/provider.ts +81 -16
  242. package/src/rate-text.ts +151 -0
  243. package/src/rate.ts +205 -36
  244. package/src/rounding.ts +1 -1
  245. package/src/schema.ts +119 -0
  246. package/src/sql.ts +178 -0
  247. package/src/tax.ts +40 -9
  248. package/src/testing/arbitraries.ts +134 -0
  249. package/src/testing/index.ts +247 -0
  250. package/src/testing/jest.ts +18 -0
  251. package/src/testing/vitest.ts +18 -0
  252. package/src/valibot.ts +87 -0
  253. package/src/zod.ts +78 -0
@@ -2,12 +2,13 @@
2
2
  * `@ducatjs/money/stripe`: Stripe's minor-unit view. Importing this module adds `toStripe` and
3
3
  * `toStripeAmount`.
4
4
  */
5
- import { extendMoney, type Money } from '../money.js';
5
+ import type { Money } from '../index.js';
6
+ import { extendMoney } from '../money.js';
6
7
  import { toStripe, toStripeAmount, type StripeAmountOptions } from '../stripe.js';
7
8
 
8
9
  export * from '../stripe.js';
9
10
 
10
- declare module '../money.js' {
11
+ declare module '../index.js' {
11
12
  interface Money<C extends string> {
12
13
  /** The integer `amount` to send to Stripe: `money('5', 'ISK').toStripeAmount()` is 500. */
13
14
  toStripeAmount(options?: StripeAmountOptions): number;
@@ -3,37 +3,37 @@
3
3
  * methods below.
4
4
  */
5
5
  import type { Factor } from '../decimal.js';
6
- import { extendMoney, type Money } from '../money.js';
7
- import type { RoundingMode } from '../rounding.js';
6
+ import type { Money } from '../index.js';
7
+ import { extendMoney, type RoundingInput } from '../money.js';
8
8
  import { addTax, discount, extractTax, percentage, type TaxBreakdown } from '../tax.js';
9
9
 
10
10
  export * from '../tax.js';
11
11
 
12
- declare module '../money.js' {
12
+ declare module '../index.js' {
13
13
  interface Money<C extends string> {
14
14
  /** `percent`% of this, e.g. `price.percentage(15, 'halfEven')` */
15
- percentage(percent: Factor, rounding?: RoundingMode): Money<C>;
15
+ percentage(percent: Factor, rounding?: RoundingInput): Money<C>;
16
16
  /** this minus `percent`% */
17
- discount(percent: Factor, rounding?: RoundingMode): Money<C>;
17
+ discount(percent: Factor, rounding?: RoundingInput): Money<C>;
18
18
  /** Treats this as net: `{ net, tax, gross }`, net + tax = gross exactly. */
19
- addTax(percent: Factor, rounding?: RoundingMode): TaxBreakdown<C>;
19
+ addTax(percent: Factor, rounding?: RoundingInput): TaxBreakdown<C>;
20
20
  /** Treats this as gross: `{ net, tax, gross }`, net + tax = gross exactly. */
21
- extractTax(percent: Factor, rounding?: RoundingMode): TaxBreakdown<C>;
21
+ extractTax(percent: Factor, rounding?: RoundingInput): TaxBreakdown<C>;
22
22
  }
23
23
  }
24
24
 
25
25
  extendMoney(
26
26
  {
27
- percentage(this: Money, percent: Factor, rounding?: RoundingMode) {
27
+ percentage(this: Money, percent: Factor, rounding?: RoundingInput) {
28
28
  return percentage(this, percent, rounding);
29
29
  },
30
- discount(this: Money, percent: Factor, rounding?: RoundingMode) {
30
+ discount(this: Money, percent: Factor, rounding?: RoundingInput) {
31
31
  return discount(this, percent, rounding);
32
32
  },
33
- addTax(this: Money, percent: Factor, rounding?: RoundingMode) {
33
+ addTax(this: Money, percent: Factor, rounding?: RoundingInput) {
34
34
  return addTax(this, percent, rounding);
35
35
  },
36
- extractTax(this: Money, percent: Factor, rounding?: RoundingMode) {
36
+ extractTax(this: Money, percent: Factor, rounding?: RoundingInput) {
37
37
  return extractTax(this, percent, rounding);
38
38
  },
39
39
  },
package/src/policy.ts ADDED
@@ -0,0 +1,124 @@
1
+ /**
2
+ * `@ducatjs/money/policy`: rounding policies, reusable values for a scale, an increment and a
3
+ * rounding mode ("CHF cash", "4 decimals, half even"), accepted wherever a rounding mode is. The
4
+ * core only hands the work to the policy, so none of this code is in a bundle that never imports
5
+ * this entry (or `cashPolicy` from `@ducatjs/money/cash`).
6
+ */
7
+ import { currencyExponent, isCurrency } from './currency.js';
8
+ import { MAX_DECIMAL_SCALE, type Ratio } from './decimal.js';
9
+ import {
10
+ CurrencyMismatchError,
11
+ multiply,
12
+ multiplyByRatio,
13
+ ratio,
14
+ ROUND_PRODUCT,
15
+ type Money,
16
+ type RoundingPolicy,
17
+ } from './money.js';
18
+ import { DEFAULT_ROUNDING, Rounding, type RoundingMode } from './rounding.js';
19
+
20
+ export type { RoundingInput, RoundingPolicy } from './money.js';
21
+
22
+ /** Options for `roundingPolicy`. Every field is optional. */
23
+ export interface RoundingPolicyOptions {
24
+ /** Decimal places of the result (default: the currency's ISO exponent). */
25
+ readonly scale?: number;
26
+ /** A positive safe integer count of 10^-scale units to round to (default 1). */
27
+ readonly increment?: number;
28
+ /** The rounding mode (default 'halfExpand'). */
29
+ readonly rounding?: RoundingMode;
30
+ /** Restrict the policy to one currency. */
31
+ readonly currency?: string;
32
+ }
33
+
34
+ const ONE = ratio(1);
35
+ const MODES = new Set<unknown>(Object.values(Rounding));
36
+
37
+ /**
38
+ * Builds a reusable rounding policy (see `RoundingPolicy`). The options are checked here, once, so
39
+ * applying the policy needs no checks.
40
+ *
41
+ * const unitPrice = roundingPolicy({ scale: 4, rounding: 'halfEven' })
42
+ * const fiveCents = roundingPolicy({ increment: 5 }) // 0.05 in a 2-decimal currency
43
+ */
44
+ export function roundingPolicy(options: RoundingPolicyOptions = {}): RoundingPolicy {
45
+ const scale = options.scale;
46
+ const increment = options.increment ?? 1;
47
+ const rounding = options.rounding ?? DEFAULT_ROUNDING;
48
+ const currency = options.currency;
49
+
50
+ if (
51
+ scale !== undefined &&
52
+ !(Number.isInteger(scale) && scale >= 0 && scale <= MAX_DECIMAL_SCALE)
53
+ ) {
54
+ throw new RangeError(`Invalid scale ${scale}`);
55
+ }
56
+ if (!(Number.isSafeInteger(increment) && increment >= 1)) {
57
+ throw new RangeError(`Invalid increment ${increment}; it must be a positive integer`);
58
+ }
59
+ if (!MODES.has(rounding)) {
60
+ throw new RangeError(`Invalid rounding mode ${String(rounding)}`);
61
+ }
62
+ // A typo such as 'chf' would otherwise make a policy that rejects every amount.
63
+ if (currency !== undefined && !isCurrency(currency)) {
64
+ throw new RangeError(`Unknown currency ${currency}`);
65
+ }
66
+
67
+ const incrementAsBigint = BigInt(increment);
68
+
69
+ // money × factor rounded once to the policy: with an increment, count whole increments
70
+ // (money × factor / increment, rounded at the policy's scale) and multiply back, which is
71
+ // rounding to the nearest multiple of the increment. The count's ratio is left unreduced: the
72
+ // division rounds the same either way, and a gcd per call would cost more than it saves.
73
+ const roundProduct = <C extends string>(money: Money<C>, factor: Ratio): Money<C> => {
74
+ if (currency !== undefined && currency !== money.currency) {
75
+ throw new CurrencyMismatchError(currency, money.currency);
76
+ }
77
+ const resultScale = scale ?? currencyExponent(money.currency);
78
+ if (increment === 1) {
79
+ return multiplyByRatio(money, factor, rounding, resultScale);
80
+ }
81
+ const stepDenominator = factor.d * increment;
82
+ const perStep: Ratio = {
83
+ num: factor.num,
84
+ den: factor.den * incrementAsBigint,
85
+ n: factor.n,
86
+ // NaN (not a safe integer) sends multiplyByRatio down its bigint path
87
+ d: Number.isSafeInteger(stepDenominator) ? stepDenominator : NaN,
88
+ };
89
+ return multiply(multiplyByRatio(money, perStep, rounding, resultScale), increment);
90
+ };
91
+
92
+ const policy = <C extends string>(money: Money<C>): Money<C> => roundProduct(money, ONE);
93
+ return Object.freeze(
94
+ Object.assign(policy, { scale, increment, rounding, currency, [ROUND_PRODUCT]: roundProduct }),
95
+ );
96
+ }
97
+
98
+ /**
99
+ * Rounding to the currency's ISO exponent, half up: the policy most amounts should end with.
100
+ * `assertScale(m, isoPolicy)` checks that an amount is already there.
101
+ */
102
+ export const isoPolicy: RoundingPolicy = /* @__PURE__ */ roundingPolicy();
103
+
104
+ /**
105
+ * Throws a RangeError unless `money` is exactly what `policy` produces: at the policy's scale and a
106
+ * multiple of its increment (and in its currency, if it has one). Use it where an amount must
107
+ * already be rounded, such as before booking it or sending it to a processor.
108
+ */
109
+ export function assertScale(money: Money, policy: RoundingPolicy): void {
110
+ if (policy.currency !== undefined && policy.currency !== money.currency) {
111
+ throw new CurrencyMismatchError(policy.currency, money.currency);
112
+ }
113
+ const scale = policy.scale ?? currencyExponent(money.currency);
114
+ if (money.scale !== scale) {
115
+ throw new RangeError(`${money} has scale ${money.scale}, expected ${scale}`);
116
+ }
117
+ const minor = money.minor;
118
+ const increment = policy.increment;
119
+ const isMultiple =
120
+ typeof minor === 'number' ? minor % increment === 0 : minor % BigInt(increment) === 0n;
121
+ if (!isMultiple) {
122
+ throw new RangeError(`${money} is not a multiple of ${increment} at scale ${scale}`);
123
+ }
124
+ }
package/src/prisma.ts CHANGED
@@ -8,6 +8,7 @@
8
8
  * 2. a DECIMAL/NUMERIC column (+ a currency column or constant)
9
9
  * -> decimalMoneyField / toDecimalValue
10
10
  */
11
+ import { MAX_DECIMAL_SCALE } from './decimal.js';
11
12
  import {
12
13
  fromMinor,
13
14
  money,
@@ -42,23 +43,84 @@ export function toDecimalValue<D>(money: Money, Decimal: new (value: string) =>
42
43
 
43
44
  type Row<A extends string, V> = { [P in A]: V };
44
45
 
46
+ /** Options for `minorMoneyField` and `toMinorColumns`: a column that records each amount's scale. */
47
+ export interface ScaleColumnOption<S extends string> {
48
+ /**
49
+ * The integer column holding the number of decimals the minor units are counted in (2 for
50
+ * cents). With it, any scale round-trips, and stored amounts keep their meaning even if a
51
+ * currency's exponent changes.
52
+ */
53
+ readonly scale: S;
54
+ }
55
+
56
+ /** A scale read from a row: a safe, non-negative integer (a SMALLINT or INT column). */
57
+ function readScale(value: unknown, column: string): number {
58
+ const scale = typeof value === 'bigint' ? Number(value) : value;
59
+ // capped like parsed decimals: a corrupt row must not make toDecimal build a huge power of ten
60
+ const isValid =
61
+ typeof scale === 'number' &&
62
+ Number.isInteger(scale) &&
63
+ scale >= 0 &&
64
+ scale <= MAX_DECIMAL_SCALE;
65
+ if (!isValid) {
66
+ throw new RangeError(
67
+ `Expected an integer scale from 0 to ${MAX_DECIMAL_SCALE} in "${column}", got ${String(value)}`,
68
+ );
69
+ }
70
+ return scale;
71
+ }
72
+
45
73
  /**
46
74
  * A Prisma result-extension field that exposes a Money built from `amount_minor` + `currency`
47
- * columns:
75
+ * columns, and optionally a scale column:
48
76
  *
49
77
  * const db = new PrismaClient().$extends({
50
78
  * result: { order: { total: minorMoneyField('totalMinor', 'currency') } },
51
79
  * });
52
80
  * (await db.order.findFirstOrThrow()).total // Money
81
+ *
82
+ * Without a scale column the minor units are read at the currency's current exponent.
53
83
  */
54
84
  export function minorMoneyField<const A extends string, const K extends string>(
55
85
  amount: A,
56
86
  currency: K,
57
- ) {
87
+ ): {
88
+ needs: { [P in A | K]: true };
89
+ compute: (row: Row<A, bigint | number> & Row<K, string>) => Money;
90
+ };
91
+ export function minorMoneyField<
92
+ const A extends string,
93
+ const K extends string,
94
+ const S extends string,
95
+ >(
96
+ amount: A,
97
+ currency: K,
98
+ options: ScaleColumnOption<S>,
99
+ ): {
100
+ needs: { [P in A | K | S]: true };
101
+ compute: (row: Row<A, bigint | number> & Row<K, string> & Row<S, bigint | number>) => Money;
102
+ };
103
+ export function minorMoneyField(
104
+ amount: string,
105
+ currency: string,
106
+ options?: ScaleColumnOption<string>,
107
+ ): { needs: Record<string, true>; compute: (row: Record<string, unknown>) => Money } {
108
+ if (options === undefined) {
109
+ return {
110
+ needs: { [amount]: true, [currency]: true },
111
+ compute: (row) => fromMinor(row[amount] as bigint | number, row[currency] as string),
112
+ };
113
+ }
114
+
115
+ const scaleColumn = options.scale;
58
116
  return {
59
- needs: { [amount]: true, [currency]: true } as { [P in A | K]: true },
60
- compute: (row: Row<A, bigint | number> & Row<K, string>): Money =>
61
- fromMinor(row[amount], row[currency]),
117
+ needs: { [amount]: true, [currency]: true, [scaleColumn]: true },
118
+ compute: (row) =>
119
+ fromMinor(
120
+ row[amount] as bigint | number,
121
+ row[currency] as string,
122
+ readScale(row[scaleColumn], scaleColumn),
123
+ ),
62
124
  };
63
125
  }
64
126
 
@@ -95,16 +157,35 @@ export function decimalMoneyField(
95
157
 
96
158
  /**
97
159
  * Column values for writes: `data: { ...toMinorColumns(total, 'totalMinor', 'currency') }`.
98
- * Minor units are a bigint (Prisma BigInt). Throws if the value has a non-standard scale, because
99
- * the column cannot record it.
160
+ * Minor units are a bigint (Prisma BigInt). Without a scale column, throws if the value has a
161
+ * non-standard scale, because the columns cannot record it. With `{ scale: 'amountScale' }`, any
162
+ * scale is stored, next to the minor units it counts.
100
163
  */
101
164
  export function toMinorColumns<const A extends string, const K extends string>(
102
165
  money: Money,
103
166
  amount: A,
104
167
  currency: K,
105
- ): Row<A, bigint> & Row<K, string> {
106
- return { [amount]: toMinorAtIsoScale(money), [currency]: money.currency } as Row<A, bigint> &
107
- Row<K, string>;
168
+ ): Row<A, bigint> & Row<K, string>;
169
+ export function toMinorColumns<
170
+ const A extends string,
171
+ const K extends string,
172
+ const S extends string,
173
+ >(
174
+ money: Money,
175
+ amount: A,
176
+ currency: K,
177
+ options: ScaleColumnOption<S>,
178
+ ): Row<A, bigint> & Row<K, string> & Row<S, number>;
179
+ export function toMinorColumns(
180
+ money: Money,
181
+ amount: string,
182
+ currency: string,
183
+ options?: ScaleColumnOption<string>,
184
+ ): Record<string, unknown> {
185
+ if (options === undefined) {
186
+ return { [amount]: toMinorAtIsoScale(money), [currency]: money.currency };
187
+ }
188
+ return { [amount]: toMinor(money), [currency]: money.currency, [options.scale]: money.scale };
108
189
  }
109
190
 
110
191
  function toMinorAtIsoScale(money: Money): bigint {
package/src/provider.ts CHANGED
@@ -3,7 +3,15 @@
3
3
  // async boundary.
4
4
  import type { Factor } from './decimal.js';
5
5
  import { CurrencyMismatchError } from './money.js';
6
- import { exchangeRate, isRate, toIsoString, type Rate, type RateInfo } from './rate.js';
6
+ import {
7
+ exchangeRate,
8
+ hasEnded,
9
+ hasStarted,
10
+ isRate,
11
+ toIsoString,
12
+ type Rate,
13
+ type RateInfo,
14
+ } from './rate.js';
7
15
 
8
16
  const DAY_MS = 86_400_000;
9
17
 
@@ -29,6 +37,12 @@ export interface RateTable extends RateProvider {
29
37
  readonly base?: string;
30
38
  /** The table's date, when it has one. */
31
39
  readonly asOf?: string;
40
+ /** When the table was published, when that is known. */
41
+ readonly availableAt?: string;
42
+ /** The last day (or instant) the table applies to, for a table valid for a period. */
43
+ readonly validUntil?: string;
44
+ /** Currencies the source listed without a value (holidays, `ND`, `N/A`). */
45
+ readonly missing?: readonly string[];
32
46
  }
33
47
 
34
48
  /** Thrown when no provider has a rate for the pair. */
@@ -95,33 +109,68 @@ export function identity(): RateProvider {
95
109
  };
96
110
  }
97
111
 
98
- /** A one-base table: `rates.USD` is the number of USD per 1 `base`. */
99
- export interface RateTableSpec extends RateInfo {
112
+ /**
113
+ * A one-base table: `rates.USD` is the number of USD per 1 `base` (or a base → USD Rate), or null
114
+ * when the source has no value for the day. For rates published the other way round or per 100
115
+ * units, see `quotedTable` in `@ducatjs/money/convert/history`.
116
+ */
117
+ export interface RateTableSpec extends Omit<RateInfo, 'quote'> {
100
118
  readonly base: string;
101
- readonly rates: Readonly<Record<string, Factor>>;
119
+ readonly rates: Readonly<Record<string, Factor | Rate | null>>;
102
120
  }
103
121
 
104
122
  /**
105
123
  * A fixed table of rates: one base currency against many (ECB style), or a list of Rates. Only the
106
124
  * pairs given are answered; wrap it in `reversed()` or `via()` for the others.
107
125
  *
108
- * Lookups are by whole UTC day: a query for a day before a rate's asOf gets nothing, and a query at
109
- * any time on the rate's own day gets the rate, even before its publication time (the ECB publishes
110
- * at about 15:00 UTC). If that matters, query with the previous day until the table is published.
126
+ * A query with an `asOf` gets nothing from a rate that doesn't apply then: one dated after it, one
127
+ * published (`availableAt`) after it, or one whose `validUntil` has passed. Days are UTC days, and
128
+ * a date query covers the whole day: without an `availableAt`, a query at any time on the rate's
129
+ * own day gets the rate, even before the source published it (the ECB publishes at about 15:00
130
+ * UTC). Give `availableAt` to rule that out.
111
131
  *
112
132
  * const ecb = rateTable({ base: 'EUR', source: 'ECB', asOf: '2026-11-06', rates: { USD: '1.1165' } });
113
133
  */
114
134
  export function rateTable(spec: RateTableSpec | readonly Rate[]): RateTable {
115
135
  let rates: Rate[];
116
- const header: { base?: string; asOf?: string } = {};
136
+ const header: {
137
+ base?: string;
138
+ asOf?: string;
139
+ availableAt?: string;
140
+ validUntil?: string;
141
+ missing?: string[];
142
+ } = {};
117
143
  if (isRateList(spec)) {
118
144
  rates = [...spec];
119
145
  } else {
120
- rates = Object.entries(spec.rates).map(([to, rate]) => exchangeRate(spec.base, to, rate, spec));
146
+ rates = [];
147
+ const missing: string[] = [];
148
+ for (const [currency, entry] of Object.entries(spec.rates)) {
149
+ if (entry === null) {
150
+ missing.push(currency);
151
+ } else if (isRate(entry)) {
152
+ // A ready-made Rate is looked up under its own pair, so it must be the one its key says.
153
+ if (entry.from !== spec.base || entry.to !== currency) {
154
+ throw new CurrencyMismatchError(`${spec.base}→${currency}`, `${entry.from}→${entry.to}`);
155
+ }
156
+ rates.push(entry);
157
+ } else {
158
+ rates.push(exchangeRate(spec.base, currency, entry, spec));
159
+ }
160
+ }
121
161
  header.base = spec.base;
122
162
  if (spec.asOf !== undefined) {
123
163
  header.asOf = toIsoString(spec.asOf);
124
164
  }
165
+ if (spec.availableAt !== undefined) {
166
+ header.availableAt = toIsoString(spec.availableAt);
167
+ }
168
+ if (spec.validUntil !== undefined) {
169
+ header.validUntil = toIsoString(spec.validUntil);
170
+ }
171
+ if (missing.length > 0) {
172
+ header.missing = missing;
173
+ }
125
174
  }
126
175
 
127
176
  const byPair = new Map<string, Rate>();
@@ -137,11 +186,9 @@ export function rateTable(spec: RateTableSpec | readonly Rate[]): RateTable {
137
186
  if (rate === undefined) {
138
187
  return undefined;
139
188
  }
140
- const askedBeforeRateExisted =
141
- query?.asOf !== undefined &&
142
- rate.asOf !== undefined &&
143
- dayOf(query.asOf) < dayOf(rate.asOf);
144
- if (askedBeforeRateExisted) {
189
+ // The table's own dates count too: a ready-made Rate entry doesn't carry them.
190
+ const asOf = query?.asOf;
191
+ if (asOf !== undefined && (!appliesAt(rate, asOf) || !appliesAt(header, asOf))) {
145
192
  return undefined;
146
193
  }
147
194
  // The key matched, so the rate's currencies are exactly `from` and `to`.
@@ -150,6 +197,20 @@ export function rateTable(spec: RateTableSpec | readonly Rate[]): RateTable {
150
197
  };
151
198
  }
152
199
 
200
+ /** Whether a rate (or a table) applies at `at`: dated, published and still valid then. */
201
+ function appliesAt(
202
+ rate: { readonly asOf?: string; readonly availableAt?: string; readonly validUntil?: string },
203
+ at: string | Date,
204
+ ): boolean {
205
+ if (rate.asOf !== undefined && dayOf(at) < dayOf(rate.asOf)) {
206
+ return false;
207
+ }
208
+ if (rate.availableAt !== undefined && !hasStarted(rate.availableAt, at)) {
209
+ return false;
210
+ }
211
+ return rate.validUntil === undefined || !hasEnded(rate.validUntil, at);
212
+ }
213
+
153
214
  function isRateList(spec: RateTableSpec | readonly Rate[]): spec is readonly Rate[] {
154
215
  return Array.isArray(spec) && spec.every(isRate);
155
216
  }
@@ -235,7 +296,9 @@ export interface Snapshots {
235
296
  * undefined when there is none. Days are UTC days. Loaded tables are cached by day. Misses and
236
297
  * failures are not cached, so a table that isn't published yet is tried again next time. A loader
237
298
  * that throws fails the lookup rather than falling back to an older day, so an outage doesn't
238
- * silently become a stale rate. The rates carry the day they're from.
299
+ * silently become a stale rate. The rates carry the day they're from. A table with an
300
+ * `availableAt` after an instant asked for is passed over, so a lookup at 09:00 on a publication
301
+ * day gets the day before.
239
302
  *
240
303
  * const history = snapshots(async (day) => parseEcbXml(await readFile(`ecb/${day}.xml`, 'utf8'))[0]);
241
304
  * const fx = await history.at('2026-11-08'); // a Sunday: Friday 2026-11-06's table
@@ -298,7 +361,9 @@ export function snapshots(
298
361
  // Sequential on purpose: stop at the first day that has a table, without loading older
299
362
  // days. oxlint-disable-next-line no-await-in-loop
300
363
  const table = await loadDay(day);
301
- if (table !== undefined) {
364
+ // A table not yet published at the instant asked for is passed over like a missing one.
365
+ const availableAt = (table as Partial<RateTable> | undefined)?.availableAt;
366
+ if (table !== undefined && (availableAt === undefined || hasStarted(availableAt, asOf))) {
302
367
  return table;
303
368
  }
304
369
  }
@@ -0,0 +1,151 @@
1
+ // Reading rate cells from published files, shared by the source parsers so that every source
2
+ // handles missing values and stripped digits the same way.
3
+
4
+ // What sources write for "no value this day": blank (ECB CSV, BoE), ND (Fed H.10), N/A (ECB CSV),
5
+ // NA (IMF), and dashes and NaN in other files. Compared upper-cased.
6
+ const MISSING_MARKERS = new Set(['', 'ND', 'N/A', 'NA', 'N.A.', '-', '–', '—', 'NAN', 'NULL']);
7
+ const PLAIN_DECIMAL = /^\d+(?:\.\d+)?$/;
8
+
9
+ /**
10
+ * @internal
11
+ * A published rate cell: the decimal text, or null when the source marks the value as missing
12
+ * (holidays, `ND`, `N/A`). Anything else throws SyntaxError: a cell that can't be read must not
13
+ * look like "not quoted".
14
+ */
15
+ export function readRateCell(cell: string, where: string): string | null {
16
+ const text = cell.trim();
17
+ if (MISSING_MARKERS.has(text.toUpperCase())) {
18
+ return null;
19
+ }
20
+ if (!PLAIN_DECIMAL.test(text)) {
21
+ throw new SyntaxError(`Not a rate: ${JSON.stringify(cell)} (${where})`);
22
+ }
23
+ return text;
24
+ }
25
+
26
+ /**
27
+ * @internal
28
+ * The published digits of a rate from a feed that strips trailing zeros, given the number of
29
+ * decimals the publisher states: '1.175' with 4 decimals is '1.1750'. Text with more decimals than
30
+ * stated is kept as it is, since dropping digits would change the rate.
31
+ */
32
+ export function withDecimals(text: string, decimals: number): string {
33
+ if (!Number.isSafeInteger(decimals) || decimals < 0 || decimals > 30) {
34
+ throw new RangeError(`decimals must be an integer from 0 to 30, got ${decimals}`);
35
+ }
36
+ const point = text.indexOf('.');
37
+ const present = point === -1 ? 0 : text.length - point - 1;
38
+ if (present >= decimals) {
39
+ return text;
40
+ }
41
+ const integerPart = point === -1 ? text : text.slice(0, point);
42
+ const fraction = point === -1 ? '' : text.slice(point + 1);
43
+ return `${integerPart}.${fraction.padEnd(decimals, '0')}`;
44
+ }
45
+
46
+ /**
47
+ * @internal
48
+ * The records of a CSV file, RFC 4180 style: a cell may be quoted ("a, b", with "" for a quote),
49
+ * and a quote anywhere else, or one left open, is a SyntaxError, so a stray quote can't swallow
50
+ * the lines after it. Blank lines are skipped.
51
+ */
52
+ export function csvRows(text: string): string[][] {
53
+ const rows: string[][] = [];
54
+ let row: string[] = [];
55
+ let cell = '';
56
+ // 'start' of a cell, inside an unquoted one, inside quotes, or just after the closing quote
57
+ let state: 'start' | 'plain' | 'quoted' | 'closed' = 'start';
58
+ const endRow = () => {
59
+ row.push(cell);
60
+ if (row.length > 1 || row[0]!.trim() !== '') {
61
+ rows.push(row);
62
+ }
63
+ row = [];
64
+ cell = '';
65
+ state = 'start';
66
+ };
67
+
68
+ for (let i = 0; i < text.length; i++) {
69
+ const char = text[i]!;
70
+ if (state === 'quoted') {
71
+ if (char !== '"') {
72
+ cell += char;
73
+ } else if (text[i + 1] === '"') {
74
+ cell += '"';
75
+ i++;
76
+ } else {
77
+ state = 'closed';
78
+ }
79
+ continue;
80
+ }
81
+ if (char === ',') {
82
+ row.push(cell);
83
+ cell = '';
84
+ state = 'start';
85
+ } else if (char === '\n') {
86
+ endRow();
87
+ } else if (char === '\r' && text[i + 1] === '\n') {
88
+ continue;
89
+ } else if (char === '"' && state === 'start') {
90
+ state = 'quoted';
91
+ } else if (char === '"' || state === 'closed') {
92
+ throw new SyntaxError(
93
+ `Malformed CSV: unexpected ${JSON.stringify(char)} on line ${rows.length + 1}`,
94
+ );
95
+ } else {
96
+ cell += char;
97
+ state = 'plain';
98
+ }
99
+ }
100
+ if (state === 'quoted') {
101
+ throw new SyntaxError('Malformed CSV: a quoted cell is never closed');
102
+ }
103
+ if (cell !== '' || row.length > 0) {
104
+ endRow();
105
+ }
106
+ return rows;
107
+ }
108
+
109
+ const ISO_DAY = /^(\d{4})-(\d\d)-(\d\d)$/;
110
+
111
+ /**
112
+ * @internal
113
+ * Whether `text` is a real calendar day in ISO form. '2026-02-30' is not, although Date.parse rolls
114
+ * it over to 2 March.
115
+ */
116
+ export function isCalendarDay(text: string): boolean {
117
+ const match = ISO_DAY.exec(text);
118
+ if (match === null) {
119
+ return false;
120
+ }
121
+ const date = new Date(Date.UTC(Number(match[1]), Number(match[2]) - 1, Number(match[3])));
122
+ return date.toISOString().slice(0, 10) === text;
123
+ }
124
+
125
+ /**
126
+ * @internal
127
+ * Records a currency's rate (or null, missing) for a day. The same currency twice on one day is
128
+ * either a repeat, which is fine, or a contradiction, which throws: a parser never picks one.
129
+ */
130
+ export function addDayRate(
131
+ days: Map<string, Record<string, string | null>>,
132
+ day: string,
133
+ currency: string,
134
+ rate: string | null,
135
+ ): void {
136
+ let rates = days.get(day);
137
+ if (rates === undefined) {
138
+ rates = {};
139
+ days.set(day, rates);
140
+ }
141
+ const earlier = rates[currency];
142
+ if (earlier !== undefined && earlier !== rate) {
143
+ throw new SyntaxError(`Two different values for ${currency} on ${day}: ${earlier} and ${rate}`);
144
+ }
145
+ rates[currency] = rate;
146
+ }
147
+
148
+ /** @internal Dated tables, newest first (ISO dates sort as strings). */
149
+ export function newestFirst<T extends { readonly asOf?: string }>(tables: T[]): T[] {
150
+ return tables.sort((table, other) => other.asOf!.localeCompare(table.asOf!));
151
+ }