@ducatjs/money 0.1.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 (201) hide show
  1. package/README.md +479 -62
  2. package/data/cash-rounding.json +123 -2
  3. package/data/cldr-cash.json +101 -0
  4. package/data/historic.json +78 -0
  5. package/data/iso4217-list-three.xml +1185 -0
  6. package/dist/adyen-fx.d.ts.map +1 -1
  7. package/dist/adyen-fx.js +22 -4
  8. package/dist/adyen-fx.js.map +1 -1
  9. package/dist/boc.d.ts +31 -0
  10. package/dist/boc.d.ts.map +1 -0
  11. package/dist/boc.js +114 -0
  12. package/dist/boc.js.map +1 -0
  13. package/dist/boe.d.ts +33 -0
  14. package/dist/boe.d.ts.map +1 -0
  15. package/dist/boe.js +114 -0
  16. package/dist/boe.js.map +1 -0
  17. package/dist/cash.d.ts +30 -1
  18. package/dist/cash.d.ts.map +1 -1
  19. package/dist/cash.js +50 -2
  20. package/dist/cash.js.map +1 -1
  21. package/dist/convert.d.ts +10 -9
  22. package/dist/convert.d.ts.map +1 -1
  23. package/dist/convert.js +13 -3
  24. package/dist/convert.js.map +1 -1
  25. package/dist/drizzle/mysql.d.ts +105 -0
  26. package/dist/drizzle/mysql.d.ts.map +1 -0
  27. package/dist/drizzle/mysql.js +60 -0
  28. package/dist/drizzle/mysql.js.map +1 -0
  29. package/dist/drizzle/pg.d.ts +103 -0
  30. package/dist/drizzle/pg.d.ts.map +1 -0
  31. package/dist/drizzle/pg.js +60 -0
  32. package/dist/drizzle/pg.js.map +1 -0
  33. package/dist/drizzle/sqlite.d.ts +102 -0
  34. package/dist/drizzle/sqlite.d.ts.map +1 -0
  35. package/dist/drizzle/sqlite.js +59 -0
  36. package/dist/drizzle/sqlite.js.map +1 -0
  37. package/dist/ecb.d.ts +37 -2
  38. package/dist/ecb.d.ts.map +1 -1
  39. package/dist/ecb.js +148 -10
  40. package/dist/ecb.js.map +1 -1
  41. package/dist/fed.d.ts +35 -0
  42. package/dist/fed.d.ts.map +1 -0
  43. package/dist/fed.js +134 -0
  44. package/dist/fed.js.map +1 -0
  45. package/dist/fetch.d.ts +45 -0
  46. package/dist/fetch.d.ts.map +1 -1
  47. package/dist/fetch.js +167 -24
  48. package/dist/fetch.js.map +1 -1
  49. package/dist/fn.d.ts +8 -1
  50. package/dist/fn.d.ts.map +1 -1
  51. package/dist/fn.js +8 -1
  52. package/dist/fn.js.map +1 -1
  53. package/dist/generated/cash.d.ts +2 -0
  54. package/dist/generated/cash.d.ts.map +1 -1
  55. package/dist/generated/cash.js +101 -0
  56. package/dist/generated/cash.js.map +1 -1
  57. package/dist/generated/historic.d.ts +337 -0
  58. package/dist/generated/historic.d.ts.map +1 -0
  59. package/dist/generated/historic.js +66 -0
  60. package/dist/generated/historic.js.map +1 -0
  61. package/dist/historic.d.ts +40 -0
  62. package/dist/historic.d.ts.map +1 -0
  63. package/dist/historic.js +43 -0
  64. package/dist/historic.js.map +1 -0
  65. package/dist/history.d.ts +96 -0
  66. package/dist/history.d.ts.map +1 -0
  67. package/dist/history.js +213 -0
  68. package/dist/history.js.map +1 -0
  69. package/dist/hmrc.d.ts +28 -0
  70. package/dist/hmrc.d.ts.map +1 -0
  71. package/dist/hmrc.js +108 -0
  72. package/dist/hmrc.js.map +1 -0
  73. package/dist/index.d.ts +1 -1
  74. package/dist/index.d.ts.map +1 -1
  75. package/dist/index.js +1 -1
  76. package/dist/index.js.map +1 -1
  77. package/dist/kysely.d.ts +120 -0
  78. package/dist/kysely.d.ts.map +1 -0
  79. package/dist/kysely.js +309 -0
  80. package/dist/kysely.js.map +1 -0
  81. package/dist/money.d.ts +93 -9
  82. package/dist/money.d.ts.map +1 -1
  83. package/dist/money.js +135 -9
  84. package/dist/money.js.map +1 -1
  85. package/dist/parse.d.ts +45 -1
  86. package/dist/parse.d.ts.map +1 -1
  87. package/dist/parse.js +290 -3
  88. package/dist/parse.js.map +1 -1
  89. package/dist/pg.d.ts +100 -0
  90. package/dist/pg.d.ts.map +1 -0
  91. package/dist/pg.js +343 -0
  92. package/dist/pg.js.map +1 -0
  93. package/dist/plugins/all.d.ts +8 -1
  94. package/dist/plugins/all.d.ts.map +1 -1
  95. package/dist/plugins/all.js +8 -1
  96. package/dist/plugins/all.js.map +1 -1
  97. package/dist/plugins/convert.d.ts +3 -4
  98. package/dist/plugins/convert.d.ts.map +1 -1
  99. package/dist/plugins/convert.js.map +1 -1
  100. package/dist/plugins/tax.d.ts +4 -5
  101. package/dist/plugins/tax.d.ts.map +1 -1
  102. package/dist/plugins/tax.js.map +1 -1
  103. package/dist/policy.d.ts +34 -0
  104. package/dist/policy.d.ts.map +1 -0
  105. package/dist/policy.js +90 -0
  106. package/dist/policy.js.map +1 -0
  107. package/dist/prisma.d.ts +25 -14
  108. package/dist/prisma.d.ts.map +1 -1
  109. package/dist/prisma.js +29 -19
  110. package/dist/prisma.js.map +1 -1
  111. package/dist/provider.d.ts +21 -7
  112. package/dist/provider.d.ts.map +1 -1
  113. package/dist/provider.js +51 -11
  114. package/dist/provider.js.map +1 -1
  115. package/dist/rate-text.d.ts +38 -0
  116. package/dist/rate-text.d.ts.map +1 -0
  117. package/dist/rate-text.js +143 -0
  118. package/dist/rate-text.js.map +1 -0
  119. package/dist/rate.d.ts +61 -5
  120. package/dist/rate.d.ts.map +1 -1
  121. package/dist/rate.js +133 -33
  122. package/dist/rate.js.map +1 -1
  123. package/dist/rounding.js +1 -1
  124. package/dist/rounding.js.map +1 -1
  125. package/dist/schema.d.ts +43 -0
  126. package/dist/schema.d.ts.map +1 -0
  127. package/dist/schema.js +77 -0
  128. package/dist/schema.js.map +1 -0
  129. package/dist/sql.d.ts +54 -0
  130. package/dist/sql.d.ts.map +1 -0
  131. package/dist/sql.js +131 -0
  132. package/dist/sql.js.map +1 -0
  133. package/dist/tax.d.ts +13 -9
  134. package/dist/tax.d.ts.map +1 -1
  135. package/dist/tax.js +20 -4
  136. package/dist/tax.js.map +1 -1
  137. package/dist/testing/arbitraries.d.ts +43 -0
  138. package/dist/testing/arbitraries.d.ts.map +1 -0
  139. package/dist/testing/arbitraries.js +98 -0
  140. package/dist/testing/arbitraries.js.map +1 -0
  141. package/dist/testing/index.d.ts +67 -0
  142. package/dist/testing/index.d.ts.map +1 -0
  143. package/dist/testing/index.js +159 -0
  144. package/dist/testing/index.js.map +1 -0
  145. package/dist/testing/jest.d.ts +2 -0
  146. package/dist/testing/jest.d.ts.map +1 -0
  147. package/dist/testing/jest.js +15 -0
  148. package/dist/testing/jest.js.map +1 -0
  149. package/dist/testing/vitest.d.ts +7 -0
  150. package/dist/testing/vitest.d.ts.map +1 -0
  151. package/dist/testing/vitest.js +12 -0
  152. package/dist/testing/vitest.js.map +1 -0
  153. package/dist/valibot.d.ts +33 -0
  154. package/dist/valibot.d.ts.map +1 -0
  155. package/dist/valibot.js +61 -0
  156. package/dist/valibot.js.map +1 -0
  157. package/dist/zod.d.ts +36 -0
  158. package/dist/zod.d.ts.map +1 -0
  159. package/dist/zod.js +58 -0
  160. package/dist/zod.js.map +1 -0
  161. package/package.json +116 -5
  162. package/src/adyen-fx.ts +25 -4
  163. package/src/boc.ts +136 -0
  164. package/src/boe.ts +131 -0
  165. package/src/cash.ts +70 -2
  166. package/src/convert.ts +33 -15
  167. package/src/drizzle/mysql.ts +78 -0
  168. package/src/drizzle/pg.ts +78 -0
  169. package/src/drizzle/sqlite.ts +70 -0
  170. package/src/ecb.ts +179 -13
  171. package/src/fed.ts +146 -0
  172. package/src/fetch.ts +207 -28
  173. package/src/fn.ts +14 -0
  174. package/src/generated/cash.ts +103 -0
  175. package/src/generated/historic.ts +67 -0
  176. package/src/historic.ts +77 -0
  177. package/src/history.ts +333 -0
  178. package/src/hmrc.ts +125 -0
  179. package/src/index.ts +4 -0
  180. package/src/kysely.ts +588 -0
  181. package/src/money.ts +210 -12
  182. package/src/parse.ts +371 -4
  183. package/src/pg.ts +410 -0
  184. package/src/plugins/all.ts +10 -0
  185. package/src/plugins/convert.ts +4 -5
  186. package/src/plugins/tax.ts +9 -10
  187. package/src/policy.ts +124 -0
  188. package/src/prisma.ts +91 -10
  189. package/src/provider.ts +81 -16
  190. package/src/rate-text.ts +151 -0
  191. package/src/rate.ts +205 -36
  192. package/src/rounding.ts +1 -1
  193. package/src/schema.ts +119 -0
  194. package/src/sql.ts +178 -0
  195. package/src/tax.ts +40 -9
  196. package/src/testing/arbitraries.ts +134 -0
  197. package/src/testing/index.ts +247 -0
  198. package/src/testing/jest.ts +18 -0
  199. package/src/testing/vitest.ts +18 -0
  200. package/src/valibot.ts +87 -0
  201. package/src/zod.ts +78 -0
package/src/pg.ts ADDED
@@ -0,0 +1,410 @@
1
+ /**
2
+ * `@ducatjs/money/pg`: node-postgres type parsers that keep NUMERIC and BIGINT exact, a Postgres
3
+ * composite type for Money with a `sum` aggregate, and a guard for NUMERIC(p, s) columns.
4
+ *
5
+ * import pg from 'pg';
6
+ * import { exactTypes } from '@ducatjs/money/pg';
7
+ *
8
+ * const pool = new pg.Pool({ types: exactTypes(pg.types) });
9
+ *
10
+ * It imports nothing from pg at run time: pass `pg.types` in.
11
+ */
12
+ import { currencyExponent } from './currency.js';
13
+ import { money as parseMoney, toDecimal, type CurrencyInput, type Money } from './money.js';
14
+ import { checkDecimalSize, checkIntegerDigits, type DecimalColumnSize } from './sql.js';
15
+
16
+ export { DEFAULT_DECIMAL_SIZE, type DecimalColumnSize } from './sql.js';
17
+
18
+ /** A text-format value parser, as node-postgres calls it. */
19
+ export type TypeParser = (text: string) => unknown;
20
+
21
+ /** The part of `pg.types` (or a client's `types` option) these parsers build on. */
22
+ export interface TypeParserSource {
23
+ getTypeParser(oid: number, format?: 'text' | 'binary'): TypeParser;
24
+ }
25
+
26
+ /** The OIDs of a composite money type and its array type, from `moneyTypeOids`. */
27
+ export interface MoneyTypeOids {
28
+ readonly oid: number;
29
+ readonly arrayOid: number;
30
+ }
31
+
32
+ /** Options for `exactTypes`. */
33
+ export interface ExactTypesOptions {
34
+ /** Also parse this composite money type (and its array) into Money. */
35
+ readonly money?: MoneyTypeOids;
36
+ /**
37
+ * How BIGINT (and BIGINT[]) values come back: `'bigint'` (the default) or `'string'`, pg's own
38
+ * exact format. A bigint is what `fromMinor` takes, but it applies to every BIGINT on the client,
39
+ * `count(*)` and bigserial ids included, and `JSON.stringify` throws on it.
40
+ */
41
+ readonly int8?: 'bigint' | 'string';
42
+ }
43
+
44
+ const INT8_OID = 20;
45
+ const NUMERIC_OID = 1700;
46
+ const INT8_ARRAY_OID = 1016;
47
+ const NUMERIC_ARRAY_OID = 1231;
48
+
49
+ const INTEGER_TEXT = /^-?\d+$/;
50
+ const NUMERIC_TEXT = /^-?\d+(?:\.\d+)?$/;
51
+
52
+ /**
53
+ * BIGINT text as a bigint. node-postgres returns it as a string by default, which is exact but not
54
+ * a number type the Money constructors accept.
55
+ */
56
+ export function parseBigInt(text: string): bigint {
57
+ if (!INTEGER_TEXT.test(text)) {
58
+ throw new SyntaxError(`Expected BIGINT text, got "${text}"`);
59
+ }
60
+ return BigInt(text);
61
+ }
62
+
63
+ /** BIGINT text, checked and kept as the string. */
64
+ function checkBigIntText(text: string): string {
65
+ parseBigInt(text);
66
+ return text;
67
+ }
68
+
69
+ /**
70
+ * NUMERIC text, checked and returned as the exact decimal string. Postgres NUMERIC can hold `NaN`
71
+ * and `±Infinity`, which are not amounts, so they throw.
72
+ */
73
+ export function parseNumeric(text: string): string {
74
+ if (!NUMERIC_TEXT.test(text)) {
75
+ throw new SyntaxError(`Expected a finite NUMERIC, got "${text}"`);
76
+ }
77
+ return text;
78
+ }
79
+
80
+ /**
81
+ * Splits Postgres array text (`{1,2,NULL}`, `{{1,2},{3,4}}`, `{"(1.00,EUR)"}`) into nested arrays of
82
+ * element text, with `null` for NULL. Quoted elements are unescaped.
83
+ */
84
+ export function parseArray(text: string): unknown[] {
85
+ let position = 0;
86
+
87
+ // an array may start with explicit bounds, `[1:2]={...}`
88
+ if (text.startsWith('[')) {
89
+ position = text.indexOf('=') + 1;
90
+ }
91
+
92
+ function parseLevel(): unknown[] {
93
+ if (text[position] !== '{') {
94
+ throw new SyntaxError(`Malformed array text "${text}"`);
95
+ }
96
+ position++;
97
+ const items: unknown[] = [];
98
+ if (text[position] === '}') {
99
+ position++;
100
+ return items;
101
+ }
102
+
103
+ for (;;) {
104
+ if (text[position] === '{') {
105
+ items.push(parseLevel());
106
+ } else if (text[position] === '"') {
107
+ position++;
108
+ let value = '';
109
+ while (text[position] !== '"') {
110
+ if (position >= text.length) {
111
+ throw new SyntaxError(`Unterminated quote in array text "${text}"`);
112
+ }
113
+ if (text[position] === '\\') {
114
+ position++;
115
+ }
116
+ value += text[position];
117
+ position++;
118
+ }
119
+ position++;
120
+ items.push(value);
121
+ } else {
122
+ const start = position;
123
+ while (position < text.length && text[position] !== ',' && text[position] !== '}') {
124
+ position++;
125
+ }
126
+ const value = text.slice(start, position);
127
+ items.push(value === 'NULL' ? null : value);
128
+ }
129
+
130
+ if (text[position] === ',') {
131
+ position++;
132
+ continue;
133
+ }
134
+ if (text[position] === '}') {
135
+ position++;
136
+ return items;
137
+ }
138
+ throw new SyntaxError(`Malformed array text "${text}"`);
139
+ }
140
+ }
141
+
142
+ const result = parseLevel();
143
+ if (position !== text.length) {
144
+ throw new SyntaxError(`Malformed array text "${text}"`);
145
+ }
146
+ return result;
147
+ }
148
+
149
+ function mapArray(items: unknown[], parseElement: (text: string) => unknown): unknown[] {
150
+ return items.map((item) => {
151
+ if (item === null) {
152
+ return null;
153
+ }
154
+ if (Array.isArray(item)) {
155
+ return mapArray(item, parseElement);
156
+ }
157
+ return parseElement(item as string);
158
+ });
159
+ }
160
+
161
+ /**
162
+ * Splits record text, `(12.34,EUR)` or `(12.34,"A\\B")`, into its fields, with `null` for an empty
163
+ * unquoted field. Quoted fields are unescaped: Postgres doubles `"` and `\` inside them on output,
164
+ * and accepts a backslash before any character on input.
165
+ */
166
+ function parseRecord(text: string): (string | null)[] {
167
+ if (!text.startsWith('(') || !text.endsWith(')')) {
168
+ throw new SyntaxError(`Expected record text in parentheses, got "${text}"`);
169
+ }
170
+ const fields: (string | null)[] = [];
171
+ let position = 1;
172
+ const end = text.length - 1;
173
+
174
+ for (;;) {
175
+ let field = '';
176
+ let isQuotedOrNonEmpty = false;
177
+ while (position < end && text[position] !== ',') {
178
+ const character = text[position]!;
179
+ if (character === '"') {
180
+ isQuotedOrNonEmpty = true;
181
+ position++;
182
+ for (;;) {
183
+ if (position >= end) {
184
+ throw new SyntaxError(`Unbalanced quote in record text "${text}"`);
185
+ }
186
+ const quoted = text[position]!;
187
+ if (quoted === '\\') {
188
+ field += text[position + 1] ?? '';
189
+ position += 2;
190
+ } else if (quoted === '"' && text[position + 1] === '"') {
191
+ field += '"';
192
+ position += 2;
193
+ } else if (quoted === '"') {
194
+ position++;
195
+ break;
196
+ } else {
197
+ field += quoted;
198
+ position++;
199
+ }
200
+ }
201
+ continue;
202
+ }
203
+ if (character === '\\') {
204
+ field += text[position + 1] ?? '';
205
+ position += 2;
206
+ } else {
207
+ field += character;
208
+ position++;
209
+ }
210
+ isQuotedOrNonEmpty = true;
211
+ }
212
+ fields.push(isQuotedOrNonEmpty ? field : null);
213
+ if (position >= end) {
214
+ return fields;
215
+ }
216
+ position++;
217
+ }
218
+ }
219
+
220
+ /**
221
+ * Money from the text of the composite type, `(12.34,EUR)`. The amount keeps the scale it was
222
+ * stored with, and never less than the currency's exponent. NULL fields, `NaN`, `Infinity` and
223
+ * malformed text throw.
224
+ */
225
+ export function parseMoneyComposite(text: string): Money {
226
+ const fields = parseRecord(text);
227
+ if (fields.length !== 2) {
228
+ throw new SyntaxError(`Expected (amount,currency) composite text, got "${text}"`);
229
+ }
230
+ const [amount, currency] = fields;
231
+ if (amount === null || amount === undefined || currency === null || currency === undefined) {
232
+ throw new TypeError(`A money composite needs both an amount and a currency, got "${text}"`);
233
+ }
234
+ parseNumeric(amount);
235
+
236
+ const fractionDigits = amount.includes('.') ? amount.length - amount.indexOf('.') - 1 : 0;
237
+ const scale = Math.max(fractionDigits, currencyExponent(currency));
238
+ return parseMoney(amount, currency, { scale });
239
+ }
240
+
241
+ /**
242
+ * The composite type's text for a query parameter: `(12.34,"EUR")`, at the Money's own scale. The
243
+ * currency is always quoted and escaped, so custom codes containing `\`, `"`, `,` or `)` survive.
244
+ * Cast it in SQL (`$1::ducat_money`) where Postgres can't infer the type.
245
+ */
246
+ export function toMoneyComposite(money: Money): string {
247
+ const currency = money.currency.replace(/["\\]/g, (character) => `\\${character}`);
248
+ return `(${toDecimal(money)},"${currency}")`;
249
+ }
250
+
251
+ /**
252
+ * Type parsers for node-postgres that never turn an amount into a float: BIGINT as bigint, NUMERIC
253
+ * as an exact string (rejecting `NaN` and `Infinity`), their arrays the same way (pg's default
254
+ * parses NUMERIC arrays as floats), and optionally the composite money type as Money. Every other
255
+ * type uses `base`.
256
+ *
257
+ * new pg.Pool({ types: exactTypes(pg.types, { money: await moneyTypeOids(client) }) })
258
+ */
259
+ export function exactTypes(base: TypeParserSource, options?: ExactTypesOptions): TypeParserSource {
260
+ const moneyOids = options?.money;
261
+ const parseInt8 = options?.int8 === 'string' ? checkBigIntText : parseBigInt;
262
+
263
+ return {
264
+ getTypeParser(oid: number, format?: 'text' | 'binary'): TypeParser {
265
+ // the binary format is only used when a query asks for it; leave it to pg
266
+ if (format === 'binary') {
267
+ return base.getTypeParser(oid, format);
268
+ }
269
+ if (oid === INT8_OID) {
270
+ return parseInt8;
271
+ }
272
+ if (oid === NUMERIC_OID) {
273
+ return parseNumeric;
274
+ }
275
+ if (oid === INT8_ARRAY_OID) {
276
+ return (text) => mapArray(parseArray(text), parseInt8);
277
+ }
278
+ if (oid === NUMERIC_ARRAY_OID) {
279
+ return (text) => mapArray(parseArray(text), parseNumeric);
280
+ }
281
+ if (moneyOids !== undefined && oid === moneyOids.oid) {
282
+ return parseMoneyComposite;
283
+ }
284
+ if (moneyOids !== undefined && oid === moneyOids.arrayOid) {
285
+ return (text) => mapArray(parseArray(text), parseMoneyComposite);
286
+ }
287
+ return base.getTypeParser(oid, format);
288
+ },
289
+ };
290
+ }
291
+
292
+ /** The default name of the composite money type. */
293
+ export const MONEY_TYPE_NAME = 'ducat_money';
294
+
295
+ const IDENTIFIER = /^[a-z_][a-z0-9_]*(?:\.[a-z_][a-z0-9_]*)?$/;
296
+ // Postgres truncates longer names, which would make `name` and `name_add` collide with others
297
+ const MAX_IDENTIFIER_LENGTH = 63;
298
+
299
+ /**
300
+ * The DDL for a composite money type, `(amount numeric, currency text)`, and a `sum` aggregate over
301
+ * it that throws on mixed currencies, and on a value with a NULL amount or currency. The amount is
302
+ * an unconstrained NUMERIC, so it keeps every digit and its scale. A schema-qualified name puts the
303
+ * type, its add function and the aggregate in that schema.
304
+ *
305
+ * Run the statements once, in order, in a migration: they are not idempotent, and a second run
306
+ * fails with `duplicate_object`.
307
+ *
308
+ * for (const statement of moneyTypeSql()) await client.query(statement);
309
+ * SELECT sum(price) FROM items; -- (37.50,EUR)
310
+ */
311
+ export function moneyTypeSql(name: string = MONEY_TYPE_NAME): string[] {
312
+ if (!IDENTIFIER.test(name)) {
313
+ throw new RangeError(`"${name}" is not a plain lower-case SQL name (optionally schema.name)`);
314
+ }
315
+ const dot = name.indexOf('.');
316
+ const schemaPrefix = dot < 0 ? '' : name.slice(0, dot + 1);
317
+ const typeName = name.slice(dot + 1);
318
+ if (`${typeName}_add`.length > MAX_IDENTIFIER_LENGTH) {
319
+ throw new RangeError(`"${typeName}" is too long for a Postgres name with an "_add" suffix`);
320
+ }
321
+ const add = `${name}_add`;
322
+
323
+ return [
324
+ `CREATE TYPE ${name} AS (amount numeric, currency text)`,
325
+ // search_path FROM CURRENT: the cast below resolves the same way whatever the caller's path
326
+ `CREATE FUNCTION ${add}(total ${name}, item ${name}) RETURNS ${name}
327
+ LANGUAGE plpgsql IMMUTABLE PARALLEL SAFE SET search_path FROM CURRENT AS $$
328
+ BEGIN
329
+ IF item IS NULL THEN
330
+ RETURN total;
331
+ END IF;
332
+ IF item.amount IS NULL OR item.currency IS NULL THEN
333
+ RAISE EXCEPTION 'a money value needs both an amount and a currency'
334
+ USING ERRCODE = 'null_value_not_allowed';
335
+ END IF;
336
+ IF total IS NULL THEN
337
+ RETURN item;
338
+ END IF;
339
+ IF total.currency IS DISTINCT FROM item.currency THEN
340
+ RAISE EXCEPTION 'cannot add % and % amounts', total.currency, item.currency
341
+ USING ERRCODE = 'data_exception';
342
+ END IF;
343
+ RETURN ROW(total.amount + item.amount, total.currency)::${name};
344
+ END
345
+ $$`,
346
+ `CREATE AGGREGATE ${schemaPrefix}sum(${name}) ` +
347
+ `(SFUNC = ${add}, STYPE = ${name}, COMBINEFUNC = ${add}, PARALLEL = SAFE)`,
348
+ ];
349
+ }
350
+
351
+ /** The part of a pg Client or Pool that `moneyTypeOids` uses. */
352
+ export interface Queryable {
353
+ query(
354
+ sql: string,
355
+ values: unknown[],
356
+ ): Promise<{ rows: { oid: number | string; typarray: number | string }[] }>;
357
+ }
358
+
359
+ /**
360
+ * Looks up the OIDs of the composite money type, for `exactTypes`. Run it once at start-up on a
361
+ * client without the money parser. OIDs differ between databases, and change if the type is
362
+ * dropped and recreated; values of a type whose OID `exactTypes` wasn't given come back as pg's
363
+ * raw text. With a type per schema, look up and pass each one's OIDs.
364
+ */
365
+ export async function moneyTypeOids(
366
+ client: Queryable,
367
+ name: string = MONEY_TYPE_NAME,
368
+ ): Promise<MoneyTypeOids> {
369
+ const result = await client.query(
370
+ 'SELECT oid, typarray FROM pg_type WHERE oid = to_regtype($1)',
371
+ [name],
372
+ );
373
+ const row = result.rows[0];
374
+ if (row === undefined) {
375
+ throw new RangeError(`No type "${name}" in this database; run moneyTypeSql() first`);
376
+ }
377
+ return { oid: Number(row.oid), arrayOid: Number(row.typarray) };
378
+ }
379
+
380
+ /**
381
+ * The exact decimal string for a NUMERIC(precision, scale) column, or a throw if Postgres would
382
+ * change it: it rounds extra decimals silently on insert, and rejects too many integer digits.
383
+ * Unlike the column types, any scale up to the column's is accepted, and trailing zeros beyond it
384
+ * are dropped since they don't change the value.
385
+ */
386
+ export function toNumeric<C extends CurrencyInput>(
387
+ money: Money<C>,
388
+ size: DecimalColumnSize,
389
+ ): string {
390
+ checkDecimalSize(money.currency, size);
391
+ const decimal = toDecimal(money);
392
+ const [integerPart, fraction = ''] = decimal.replace('-', '').split('.');
393
+
394
+ const significantFraction = fraction.replace(/0+$/, '');
395
+ if (significantFraction.length > size.scale) {
396
+ throw new RangeError(
397
+ `${money} has ${significantFraction.length} decimals; NUMERIC(${size.precision},${size.scale}) ` +
398
+ `would round it to ${size.scale}`,
399
+ );
400
+ }
401
+
402
+ checkIntegerDigits(money, decimal, size);
403
+
404
+ if (fraction.length <= size.scale) {
405
+ return decimal;
406
+ }
407
+ const sign = decimal.startsWith('-') ? '-' : '';
408
+ const kept = fraction.slice(0, size.scale);
409
+ return kept === '' ? `${sign}${integerPart}` : `${sign}${integerPart}.${kept}`;
410
+ }
@@ -12,6 +12,7 @@ export {
12
12
  toScale,
13
13
  compare,
14
14
  equals,
15
+ identical,
15
16
  lessThan,
16
17
  lessThanOrEqual,
17
18
  greaterThan,
@@ -23,18 +24,27 @@ export {
23
24
  min,
24
25
  max,
25
26
  clamp,
27
+ majorPart,
28
+ minorPart,
26
29
  toDecimal,
27
30
  toMinor,
28
31
  toMinorNumber,
29
32
  } from '../money.js';
33
+ export { roundingPolicy, isoPolicy, assertScale, type RoundingPolicyOptions } from '../policy.js';
30
34
  export * from './allocate.js';
31
35
  export * from './tax.js';
32
36
  export * from './convert.js';
33
37
  export * from './exact.js';
34
38
  export * from '../ecb.js';
39
+ export * from '../boc.js';
40
+ export * from '../boe.js';
41
+ export * from '../hmrc.js';
42
+ export * from '../fed.js';
43
+ export * from '../history.js';
35
44
  export * from '../stripe-fx.js';
36
45
  export * from '../adyen-fx.js';
37
46
  export * from './cash.js';
47
+ export * from '../historic.js';
38
48
  export * from './format.js';
39
49
  export * from '../parse.js';
40
50
  export * from './stripe.js';
@@ -4,11 +4,10 @@
4
4
  */
5
5
  import { convert } from '../convert.js';
6
6
  import type { Factor } from '../decimal.js';
7
- import { extendMoney, type CurrencyInput, type Money } from '../money.js';
7
+ import { extendMoney, type CurrencyInput, type Money, type RoundingInput } from '../money.js';
8
8
  import type { ProviderConvertOptions } from '../convert.js';
9
9
  import type { RateProvider } from '../provider.js';
10
10
  import type { ConvertOptions, Rate } from '../rate.js';
11
- import type { RoundingMode } from '../rounding.js';
12
11
 
13
12
  export * from '../convert.js';
14
13
 
@@ -17,14 +16,14 @@ declare module '../money.js' {
17
16
  /** Converts at a Rate, rounding once: `eur.convert(eurUsd, 'halfEven')`. */
18
17
  convert<To extends string>(
19
18
  rate: Rate<NoInfer<C>, To>,
20
- rounding?: RoundingMode,
19
+ rounding?: RoundingInput,
21
20
  options?: ConvertOptions,
22
21
  ): Money<To>;
23
22
  /** Converts at an exact decimal-string rate: `eur.convert('USD', '1.0832', 'halfEven')`. */
24
23
  convert<To extends CurrencyInput>(
25
24
  to: To,
26
25
  rate: Factor,
27
- rounding?: RoundingMode,
26
+ rounding?: RoundingInput,
28
27
  options?: ConvertOptions,
29
28
  ): Money<To>;
30
29
  /**
@@ -34,7 +33,7 @@ declare module '../money.js' {
34
33
  convert<To extends CurrencyInput>(
35
34
  to: To,
36
35
  provider: RateProvider,
37
- rounding?: RoundingMode,
36
+ rounding?: RoundingInput,
38
37
  options?: ProviderConvertOptions,
39
38
  ): Money<To>;
40
39
  }
@@ -3,8 +3,7 @@
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 { extendMoney, type Money, type RoundingInput } from '../money.js';
8
7
  import { addTax, discount, extractTax, percentage, type TaxBreakdown } from '../tax.js';
9
8
 
10
9
  export * from '../tax.js';
@@ -12,28 +11,28 @@ export * from '../tax.js';
12
11
  declare module '../money.js' {
13
12
  interface Money<C extends string> {
14
13
  /** `percent`% of this, e.g. `price.percentage(15, 'halfEven')` */
15
- percentage(percent: Factor, rounding?: RoundingMode): Money<C>;
14
+ percentage(percent: Factor, rounding?: RoundingInput): Money<C>;
16
15
  /** this minus `percent`% */
17
- discount(percent: Factor, rounding?: RoundingMode): Money<C>;
16
+ discount(percent: Factor, rounding?: RoundingInput): Money<C>;
18
17
  /** Treats this as net: `{ net, tax, gross }`, net + tax = gross exactly. */
19
- addTax(percent: Factor, rounding?: RoundingMode): TaxBreakdown<C>;
18
+ addTax(percent: Factor, rounding?: RoundingInput): TaxBreakdown<C>;
20
19
  /** Treats this as gross: `{ net, tax, gross }`, net + tax = gross exactly. */
21
- extractTax(percent: Factor, rounding?: RoundingMode): TaxBreakdown<C>;
20
+ extractTax(percent: Factor, rounding?: RoundingInput): TaxBreakdown<C>;
22
21
  }
23
22
  }
24
23
 
25
24
  extendMoney(
26
25
  {
27
- percentage(this: Money, percent: Factor, rounding?: RoundingMode) {
26
+ percentage(this: Money, percent: Factor, rounding?: RoundingInput) {
28
27
  return percentage(this, percent, rounding);
29
28
  },
30
- discount(this: Money, percent: Factor, rounding?: RoundingMode) {
29
+ discount(this: Money, percent: Factor, rounding?: RoundingInput) {
31
30
  return discount(this, percent, rounding);
32
31
  },
33
- addTax(this: Money, percent: Factor, rounding?: RoundingMode) {
32
+ addTax(this: Money, percent: Factor, rounding?: RoundingInput) {
34
33
  return addTax(this, percent, rounding);
35
34
  },
36
- extractTax(this: Money, percent: Factor, rounding?: RoundingMode) {
35
+ extractTax(this: Money, percent: Factor, rounding?: RoundingInput) {
37
36
  return extractTax(this, percent, rounding);
38
37
  },
39
38
  },
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
+ }