@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
package/src/rate.ts CHANGED
@@ -12,7 +12,15 @@ import {
12
12
  type Factor,
13
13
  type Ratio,
14
14
  } from './decimal.js';
15
- import { CurrencyMismatchError, make, type CurrencyInput, type Money } from './money.js';
15
+ import {
16
+ CurrencyMismatchError,
17
+ make,
18
+ roundWithPolicy,
19
+ type CurrencyInput,
20
+ type Money,
21
+ type RoundingInput,
22
+ type RoundingPolicy,
23
+ } from './money.js';
16
24
  import {
17
25
  DEFAULT_ROUNDING,
18
26
  divideAndRound,
@@ -26,13 +34,39 @@ const MAX_SAFE_MINOR = Number.MAX_SAFE_INTEGER;
26
34
  export type RateType = 'mid' | 'bid' | 'ask' | (string & {});
27
35
 
28
36
  /**
29
- * Where a rate came from. `asOf` and `validUntil` are ISO-8601 dates ('2026-11-06') or instants.
37
+ * How a source published a rate, kept with the Rate for audit and display. The published direction
38
+ * is `from` → `to`, or `to` → `from` when `inverted`.
39
+ */
40
+ export interface RateQuote {
41
+ /** The number exactly as published, trailing zeros included ('136.80'). */
42
+ readonly rate: string;
43
+ /**
44
+ * How many units of the published direction's first currency the number is for: 100 for "NOK
45
+ * per 100 JPY". Omitted for 1.
46
+ */
47
+ readonly per?: number;
48
+ /** The source quoted `to` → `from`; this Rate is its exact inverse. */
49
+ readonly inverted?: boolean;
50
+ }
51
+
52
+ /**
53
+ * Where a rate came from. `asOf`, `availableAt` and `validUntil` are ISO-8601 dates ('2026-11-06')
54
+ * or instants.
30
55
  */
31
56
  export interface RateInfo {
32
57
  readonly source?: string;
58
+ /** The date or time the rate applies to. */
33
59
  readonly asOf?: string | Date;
60
+ /**
61
+ * When the rate was published. A lookup for an earlier time doesn't get it, so a historical
62
+ * lookup sees only what was known then. A date means from the start of that UTC day.
63
+ */
64
+ readonly availableAt?: string | Date;
65
+ /** The last date or time the rate applies to. A date is valid all day (UTC). */
34
66
  readonly validUntil?: string | Date;
35
67
  readonly type?: RateType;
68
+ /** The rate as the source published it. */
69
+ readonly quote?: RateQuote;
36
70
  }
37
71
 
38
72
  /** Options for converting an amount. */
@@ -64,12 +98,22 @@ export interface RateJSON {
64
98
  rate: string;
65
99
  source?: string;
66
100
  asOf?: string;
101
+ availableAt?: string;
67
102
  validUntil?: string;
68
103
  type?: string;
104
+ /** Present only when it says more than `rate` does: a `per` or an inverted quote. */
105
+ quote?: RateQuote;
69
106
  derivedFrom?: RateJSON[];
70
107
  }
71
108
 
72
- type MutableInfo = { source?: string; asOf?: string; validUntil?: string; type?: RateType };
109
+ type MutableInfo = {
110
+ source?: string;
111
+ asOf?: string;
112
+ availableAt?: string;
113
+ validUntil?: string;
114
+ type?: RateType;
115
+ quote?: RateQuote;
116
+ };
73
117
  /** RateInfo that may also hold explicit undefineds (as optional parameters do). */
74
118
  type LooseInfo = { readonly [Key in keyof RateInfo]?: RateInfo[Key] | undefined };
75
119
 
@@ -93,18 +137,52 @@ export function toIsoString(time: string | Date): string {
93
137
  return date.toISOString();
94
138
  }
95
139
 
140
+ /** A date as the first instant of its UTC day; an instant as it is. */
141
+ function asInstant(time: string): string {
142
+ return ISO_DATE.test(time) ? `${time}T00:00:00.000Z` : time;
143
+ }
144
+
96
145
  /**
97
- * The earlier of two ISO-8601 strings (one format compares lexicographically); undefined = no
98
- * limit.
146
+ * @internal
147
+ * Whether `time` has come by `at`. A date `at` means the whole of that UTC day, so anything on or
148
+ * before that day counts; a date `time` means the start of its day.
149
+ */
150
+ export function hasStarted(time: string, at: string | Date): boolean {
151
+ const instant = toIsoString(at);
152
+ if (ISO_DATE.test(instant)) {
153
+ return time.slice(0, 10) <= instant;
154
+ }
155
+ return asInstant(time) <= instant;
156
+ }
157
+
158
+ /** @internal Whether `validUntil` (a date: valid all day, UTC) has passed at `at`. */
159
+ export function hasEnded(validUntil: string, at: string | Date): boolean {
160
+ const instant = asInstant(toIsoString(at));
161
+ if (ISO_DATE.test(validUntil)) {
162
+ return instant.slice(0, 10) > validUntil;
163
+ }
164
+ return instant > validUntil;
165
+ }
166
+
167
+ /**
168
+ * The earlier of two ISO-8601 strings (one format compares lexicographically), or the later one
169
+ * with `later`; undefined = no limit.
99
170
  */
100
- function earlier(first: string | undefined, second: string | undefined): string | undefined {
171
+ function earlier(
172
+ first: string | undefined,
173
+ second: string | undefined,
174
+ later?: boolean,
175
+ ): string | undefined {
101
176
  if (first === undefined) {
102
177
  return second;
103
178
  }
104
- if (second === undefined || first <= second) {
179
+ if (second === undefined) {
105
180
  return first;
106
181
  }
107
- return second;
182
+ if (later === true) {
183
+ return first >= second ? first : second;
184
+ }
185
+ return first <= second ? first : second;
108
186
  }
109
187
 
110
188
  /**
@@ -163,8 +241,10 @@ export class Rate<From extends string = string, To extends string = string> {
163
241
  // field semantics would otherwise define them all as undefined.
164
242
  declare readonly source?: string;
165
243
  declare readonly asOf?: string;
244
+ declare readonly availableAt?: string;
166
245
  declare readonly validUntil?: string;
167
246
  declare readonly type?: RateType;
247
+ declare readonly quote?: RateQuote;
168
248
  declare readonly derivedFrom?: readonly Rate[];
169
249
 
170
250
  /** @internal use exchangeRate() */
@@ -181,17 +261,19 @@ export class Rate<From extends string = string, To extends string = string> {
181
261
 
182
262
  // Only defined fields are assigned, so rates compare and serialise without undefined keys.
183
263
  const writable = this as { -readonly [Key in keyof Rate]?: unknown };
184
- if (info?.source !== undefined) {
185
- writable.source = info.source;
186
- }
187
- if (info?.asOf !== undefined) {
188
- writable.asOf = toIsoString(info.asOf);
189
- }
190
- if (info?.validUntil !== undefined) {
191
- writable.validUntil = toIsoString(info.validUntil);
264
+ const quote = info?.quote;
265
+ if (quote !== undefined) {
266
+ // toString prints a one-for-one quote, so it must be this rate. (rateFromQuote derives a
267
+ // scaled or inverted rate from its quote, so that one can't disagree.)
268
+ const direct = quote.per === undefined && quote.inverted !== true;
269
+ // ratio() caches strings, so for a rate built from the same text this is two lookups.
270
+ const quoted = direct ? ratio(quote.rate) : normalized;
271
+ if (quoted.num !== normalized.num || quoted.den !== normalized.den) {
272
+ throw new RangeError(`Quote ${quote.rate} isn't the rate ${from}→${to}`);
273
+ }
192
274
  }
193
- if (info?.type !== undefined) {
194
- writable.type = info.type;
275
+ if (info !== undefined) {
276
+ Object.assign(this, infoOf(info));
195
277
  }
196
278
  if (derivedFrom !== undefined) {
197
279
  writable.derivedFrom = derivedFrom;
@@ -205,7 +287,15 @@ export class Rate<From extends string = string, To extends string = string> {
205
287
  type = OPPOSITE_SIDE[type] ?? type;
206
288
  }
207
289
  const reciprocal = ratio(this.ratio.den, this.ratio.num);
208
- return new Rate(this.to, this.from, reciprocal, timingOf(this, type), [this]);
290
+ let info = timingOf(this, type);
291
+ // The published number reads the other way round now, so the inverse of a quoted rate keeps
292
+ // its digits too: the inverse of CAD→USD quoted as 1.3650 inverted prints '1.3650'.
293
+ const quote = this.quote;
294
+ if (quote !== undefined) {
295
+ const { inverted, ...rest } = quote;
296
+ info = { ...info, quote: inverted === true ? rest : { ...rest, inverted: true } };
297
+ }
298
+ return new Rate(this.to, this.from, reciprocal, info, [this]);
209
299
  }
210
300
 
211
301
  /**
@@ -223,9 +313,12 @@ export class Rate<From extends string = string, To extends string = string> {
223
313
  if (this.type === next.type) {
224
314
  type = this.type;
225
315
  }
316
+ // A composite is known only once both legs are, and is valid only while both are.
226
317
  const asOf = earlier(this.asOf, next.asOf);
318
+ const availableAt = earlier(this.availableAt, next.availableAt, true);
227
319
  const validUntil = earlier(this.validUntil, next.validUntil);
228
- return new Rate(this.from, next.to, product, timing(asOf, validUntil, type), [this, next]);
320
+ const info = timing(asOf, availableAt, validUntil, type);
321
+ return new Rate(this.from, next.to, product, info, [this, next]);
229
322
  }
230
323
 
231
324
  /** This rate × (1 + margin), exactly: `withMargin('-0.02')` gives 2% less `to` per `from`. */
@@ -251,14 +344,26 @@ export class Rate<From extends string = string, To extends string = string> {
251
344
  return formatScaled(scaled, places);
252
345
  }
253
346
 
254
- /** The exact rate: '1.1165', or '2000/2233' when its decimal expansion doesn't terminate. */
347
+ /**
348
+ * The exact rate: '1.1165', or '2000/2233' when its decimal expansion doesn't terminate. A rate
349
+ * published one unit for one unit keeps the published digits ('136.80').
350
+ */
255
351
  toString(): string {
352
+ const quote = this.quote;
353
+ if (quote !== undefined && quote.per === undefined && quote.inverted !== true) {
354
+ return quote.rate;
355
+ }
256
356
  return ratioToDecimal(this.ratio) ?? `${this.ratio.num}/${this.ratio.den}`;
257
357
  }
258
358
 
259
359
  /** The rate, its metadata and its audit chain as JSON. `rateFromJSON` reverses it. */
260
360
  toJSON(): RateJSON {
261
- const json: RateJSON = { from: this.from, to: this.to, rate: this.toString(), ...infoOf(this) };
361
+ const { quote, ...info } = infoOf(this);
362
+ const json: RateJSON = { from: this.from, to: this.to, rate: this.toString(), ...info };
363
+ // A direct quote is the `rate` string itself, so only a scaled or inverted one is written.
364
+ if (quote !== undefined && (quote.per !== undefined || quote.inverted === true)) {
365
+ json.quote = quote;
366
+ }
262
367
  if (this.derivedFrom !== undefined) {
263
368
  json.derivedFrom = this.derivedFrom.map((input) => input.toJSON());
264
369
  }
@@ -273,20 +378,27 @@ export class Rate<From extends string = string, To extends string = string> {
273
378
  if (this.validUntil === undefined) {
274
379
  return false;
275
380
  }
276
- let instant = toIsoString(now);
277
- if (ISO_DATE.test(instant)) {
278
- instant = `${instant}T00:00:00.000Z`;
279
- }
280
- if (ISO_DATE.test(this.validUntil)) {
281
- return instant.slice(0, 10) > this.validUntil;
381
+ return hasEnded(this.validUntil, now);
382
+ }
383
+
384
+ /**
385
+ * Whether the rate had been published by `at`: always, without an `availableAt`. A date `at`
386
+ * means by the end of that UTC day.
387
+ */
388
+ isAvailable(at: string | Date): boolean {
389
+ if (this.availableAt === undefined) {
390
+ return true;
282
391
  }
283
- return instant > this.validUntil;
392
+ return hasStarted(this.availableAt, at);
284
393
  }
285
394
 
286
- /** Converts `money`, rounding once to the target currency's exponent (or `options.scale`). */
395
+ /**
396
+ * Converts `money`, rounding once: with a mode, to the target currency's exponent (or
397
+ * `options.scale`); with a rounding policy, to the policy's scale and increment.
398
+ */
287
399
  convert(
288
400
  money: Money<From>,
289
- rounding: RoundingMode = DEFAULT_ROUNDING,
401
+ rounding: RoundingInput = DEFAULT_ROUNDING,
290
402
  options?: ConvertOptions,
291
403
  ): Money<To> {
292
404
  if (money.currency !== this.from) {
@@ -295,12 +407,35 @@ export class Rate<From extends string = string, To extends string = string> {
295
407
  if (options?.strictValidity === true && this.validUntil !== undefined && this.isExpired()) {
296
408
  throw new RateExpiredError(this.from, this.to, this.validUntil);
297
409
  }
410
+ if (typeof rounding === 'function') {
411
+ return convertWithPolicy(money, this.to, this.ratio, rounding, options);
412
+ }
298
413
  const scale = options?.scale ?? currencyExponent(this.to);
299
414
  const minor = multiplyByRatioAtScale(money.minor, this.ratio, scale - money.scale, rounding);
300
415
  return make(this.to, minor, scale);
301
416
  }
302
417
  }
303
418
 
419
+ /**
420
+ * @internal
421
+ * money × rate in `to`, rounded once by a policy. The amount is relabelled `to` at its own scale,
422
+ * so the policy rounds the product exactly as it would a multiplication in `to`: to its scale
423
+ * (default `to`'s exponent) and increment, checking its currency against `to`.
424
+ */
425
+ export function convertWithPolicy<To extends string>(
426
+ money: Money,
427
+ to: To,
428
+ rate: Ratio,
429
+ policy: RoundingPolicy,
430
+ options?: ConvertOptions,
431
+ ): Money<To> {
432
+ // Two scales would leave it unclear which one the result has.
433
+ if (options?.scale !== undefined) {
434
+ throw new TypeError('options.scale with a rounding policy');
435
+ }
436
+ return roundWithPolicy(make(to, money.minor, money.scale), rate, policy);
437
+ }
438
+
304
439
  /** The metadata fields of `info` that are defined (no undefined keys). */
305
440
  function infoOf(info: LooseInfo): MutableInfo {
306
441
  const defined: MutableInfo = {};
@@ -310,24 +445,57 @@ function infoOf(info: LooseInfo): MutableInfo {
310
445
  if (info.asOf !== undefined) {
311
446
  defined.asOf = toIsoString(info.asOf);
312
447
  }
448
+ if (info.availableAt !== undefined) {
449
+ defined.availableAt = toIsoString(info.availableAt);
450
+ }
313
451
  if (info.validUntil !== undefined) {
314
452
  defined.validUntil = toIsoString(info.validUntil);
315
453
  }
316
454
  if (info.type !== undefined) {
317
455
  defined.type = info.type;
318
456
  }
457
+ if (info.quote !== undefined) {
458
+ defined.quote = info.quote;
459
+ }
319
460
  return defined;
320
461
  }
321
462
 
322
- function timing(asOf?: string, validUntil?: string, type?: RateType): RateInfo {
323
- return infoOf({ asOf, validUntil, type });
463
+ function timing(
464
+ asOf?: string,
465
+ availableAt?: string,
466
+ validUntil?: string,
467
+ type?: RateType,
468
+ ): RateInfo {
469
+ return infoOf({ asOf, availableAt, validUntil, type });
324
470
  }
325
471
 
326
472
  /**
327
473
  * A derived rate keeps the timing of its input; the input's source stays with it, in derivedFrom.
328
474
  */
329
475
  function timingOf(input: Rate, type?: RateType): RateInfo {
330
- return timing(input.asOf, input.validUntil, type);
476
+ return timing(input.asOf, input.availableAt, input.validUntil, type);
477
+ }
478
+
479
+ // A published decimal whose canonical form drops digits: trailing fraction zeros only. Leading
480
+ // zeros ('01.50') aren't published digits, so those are left to the canonical form.
481
+ const PUBLISHED_DECIMAL = /^(?:0|[1-9]\d*)\.\d*0$/;
482
+
483
+ /**
484
+ * Keeps the published digits of `text` on a Rate just built from it, when its canonical form would
485
+ * drop some ('1.1650' prints as '1.165'). Assigned directly: the quote is the rate's own text, so
486
+ * there is nothing to check, and building a rate inline stays cheap.
487
+ */
488
+ function keepDigits<R extends Rate>(rate: R, text: Factor): R {
489
+ // Only a last character of '0' can be dropped, so most rates are settled by one comparison.
490
+ const dropsDigits =
491
+ typeof text === 'string' &&
492
+ text.charCodeAt(text.length - 1) === 48 &&
493
+ rate.quote === undefined &&
494
+ PUBLISHED_DECIMAL.test(text);
495
+ if (dropsDigits) {
496
+ (rate as { quote?: RateQuote }).quote = { rate: text };
497
+ }
498
+ return rate;
331
499
  }
332
500
 
333
501
  /**
@@ -345,13 +513,14 @@ export function exchangeRate<From extends CurrencyInput, To extends CurrencyInpu
345
513
  rate: Factor,
346
514
  info?: RateInfo,
347
515
  ): Rate<From, To> {
348
- return new Rate(from, to, ratio(rate), info);
516
+ return keepDigits(new Rate(from, to, ratio(rate), info), rate);
349
517
  }
350
518
 
351
519
  /** Rebuilds a Rate, with its audit chain, from `rate.toJSON()`. */
352
520
  export function rateFromJSON(json: RateJSON): Rate {
353
521
  const derivedFrom = json.derivedFrom?.map(rateFromJSON);
354
- return new Rate(json.from, json.to, ratio(json.rate), infoOf(json), derivedFrom);
522
+ const rate = new Rate(json.from, json.to, ratio(json.rate), infoOf(json), derivedFrom);
523
+ return keepDigits(rate, json.rate);
355
524
  }
356
525
 
357
526
  /** Whether `value` is a Rate. */
package/src/rounding.ts CHANGED
@@ -65,7 +65,7 @@ const MAX_SAFE_MINOR = Number.MAX_SAFE_INTEGER;
65
65
  */
66
66
  export function divideAndRound(dividend: number, divisor: number, mode: RoundingMode): number {
67
67
  if (divisor === 1) {
68
- return dividend;
68
+ return dividend + 0; // + 0 normalises -0 (0 * -1 from a caller)
69
69
  }
70
70
  // Beyond MAX_SAFE_MINOR the product quotient * divisor below could round, so take the bigint
71
71
  // path.
package/src/schema.ts ADDED
@@ -0,0 +1,119 @@
1
+ import { isCurrency } from './currency.js';
2
+ import { MAX_DECIMAL_SCALE } from './decimal.js';
3
+ import { money, type Money, type MoneyJSON } from './money.js';
4
+ import { Rounding, type RoundingMode } from './rounding.js';
5
+
6
+ /** Options for the Zod and Valibot money schemas. */
7
+ export interface MoneySchemaOptions<C extends string = string> {
8
+ /**
9
+ * The currency, or the currencies, to accept. Without it, any ISO 4217 code or code registered
10
+ * with `defineCurrency` is accepted, and nothing else.
11
+ */
12
+ readonly currency?: C | readonly C[];
13
+ /** Decimal places to keep (default: the currency's exponent), as in `money()`. */
14
+ readonly scale?: number;
15
+ /**
16
+ * How to round an amount with more decimals than the scale. The default, 'unnecessary', rejects
17
+ * such amounts, as `fromJSON` does.
18
+ */
19
+ readonly rounding?: RoundingMode;
20
+ /**
21
+ * The most digits the amount may have, not counting the sign and the decimal point (default
22
+ * 50). It keeps an oversized request body from building a huge bigint.
23
+ */
24
+ readonly maxDigits?: number;
25
+ }
26
+
27
+ /** The default `maxDigits`: far beyond any real amount, small enough to be cheap to reject. */
28
+ export const DEFAULT_MAX_DIGITS = 50;
29
+
30
+ const MODES = new Set<unknown>(Object.values(Rounding));
31
+
32
+ /**
33
+ * @internal Checks the options when the schema is built, so a mistake there throws at once
34
+ * instead of turning every input into a validation error.
35
+ */
36
+ export function checkSchemaOptions(options: MoneySchemaOptions | undefined): void {
37
+ if (options === undefined) {
38
+ return;
39
+ }
40
+ const allowed = options.currency;
41
+ const codes = typeof allowed === 'string' || allowed === undefined ? [allowed] : allowed;
42
+ for (const code of codes) {
43
+ if (code !== undefined && !isCurrency(code)) {
44
+ throw new RangeError(`Unknown currency ${String(code)}`);
45
+ }
46
+ }
47
+ const scale = options.scale;
48
+ if (
49
+ scale !== undefined &&
50
+ !(Number.isInteger(scale) && scale >= 0 && scale <= MAX_DECIMAL_SCALE)
51
+ ) {
52
+ throw new RangeError(`Invalid scale ${scale}`);
53
+ }
54
+ if (options.rounding !== undefined && !MODES.has(options.rounding)) {
55
+ throw new RangeError(`Invalid rounding mode ${String(options.rounding)}`);
56
+ }
57
+ const maxDigits = options.maxDigits;
58
+ if (maxDigits !== undefined && !(Number.isSafeInteger(maxDigits) && maxDigits >= 1)) {
59
+ throw new RangeError(`Invalid maxDigits ${maxDigits}`);
60
+ }
61
+ }
62
+
63
+ /** The Money type a schema produces: one Money per allowed currency, not a mixed-currency Money. */
64
+ export type MoneyFor<C extends string> = C extends string ? Money<C> : never;
65
+
66
+ /** @internal A problem with one field of the input, for the schema libraries' issue paths. */
67
+ export interface MoneyIssue {
68
+ readonly field: 'amount' | 'currency';
69
+ readonly message: string;
70
+ }
71
+
72
+ // The exact decimal strings `toJSON` writes: an optional minus, digits, and an optional fraction.
73
+ // No exponent, no plus sign, no whitespace and no grouping, so the wire format stays unambiguous.
74
+ const DECIMAL = /^-?\d+(?:\.\d+)?$/;
75
+
76
+ /**
77
+ * @internal Reads `{ amount, currency }` (already known to have two string fields) into Money, or
78
+ * says what is wrong with it. Shared by the Zod and Valibot entries, so both accept exactly the
79
+ * same inputs.
80
+ */
81
+ export function readMoneyJSON<C extends string>(
82
+ json: MoneyJSON,
83
+ options: MoneySchemaOptions<C> | undefined,
84
+ ): Money | MoneyIssue {
85
+ const currency = json.currency;
86
+ const allowed = options?.currency;
87
+
88
+ if (allowed === undefined) {
89
+ if (!isCurrency(currency)) {
90
+ return { field: 'currency', message: `Unknown currency "${currency}"` };
91
+ }
92
+ } else if (
93
+ typeof allowed === 'string' ? currency !== allowed : !allowed.includes(currency as C)
94
+ ) {
95
+ const expected = typeof allowed === 'string' ? allowed : allowed.join(', ');
96
+ return { field: 'currency', message: `Expected currency ${expected}, got "${currency}"` };
97
+ }
98
+
99
+ const amount = json.amount;
100
+ // Checked before the pattern, so a huge string is rejected without scanning it.
101
+ const maxLength = (options?.maxDigits ?? DEFAULT_MAX_DIGITS) + 2;
102
+ if (amount.length > maxLength || !DECIMAL.test(amount)) {
103
+ return { field: 'amount', message: 'Expected a decimal string such as "12.34"' };
104
+ }
105
+ const digitCount = amount.length - (amount[0] === '-' ? 1 : 0) - (amount.includes('.') ? 1 : 0);
106
+ if (digitCount > (options?.maxDigits ?? DEFAULT_MAX_DIGITS)) {
107
+ return {
108
+ field: 'amount',
109
+ message: `More than ${options?.maxDigits ?? DEFAULT_MAX_DIGITS} digits`,
110
+ };
111
+ }
112
+
113
+ try {
114
+ return money(amount, currency, options);
115
+ } catch (error) {
116
+ // the options are checked up front, so this is too many decimals for the scale
117
+ return { field: 'amount', message: (error as Error).message };
118
+ }
119
+ }