@ducatjs/money 0.0.0 → 0.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/NOTICE +51 -0
- package/README.md +1034 -3
- package/assets/CREDITS.md +12 -0
- package/data/adyen-fx.json +19 -0
- package/data/adyen.json +171 -0
- package/data/braintree.json +223 -0
- package/data/cash-rounding.json +520 -0
- package/data/checkout.json +230 -0
- package/data/cldr-cash.json +101 -0
- package/data/historic.json +78 -0
- package/data/iso4217-list-one.xml +1956 -0
- package/data/iso4217-list-three.xml +1185 -0
- package/data/klarna.json +95 -0
- package/data/paypal.json +65 -0
- package/data/stripe-fx.json +17 -0
- package/data/stripe.json +285 -0
- package/dist/adyen-fx.d.ts +25 -0
- package/dist/adyen-fx.d.ts.map +1 -0
- package/dist/adyen-fx.js +140 -0
- package/dist/adyen-fx.js.map +1 -0
- package/dist/adyen.d.ts +25 -0
- package/dist/adyen.d.ts.map +1 -0
- package/dist/adyen.js +34 -0
- package/dist/adyen.js.map +1 -0
- package/dist/allocate.d.ts +72 -0
- package/dist/allocate.d.ts.map +1 -0
- package/dist/allocate.js +314 -0
- package/dist/allocate.js.map +1 -0
- package/dist/bag.d.ts +58 -0
- package/dist/bag.d.ts.map +1 -0
- package/dist/bag.js +122 -0
- package/dist/bag.js.map +1 -0
- package/dist/boc.d.ts +31 -0
- package/dist/boc.d.ts.map +1 -0
- package/dist/boc.js +114 -0
- package/dist/boc.js.map +1 -0
- package/dist/boe.d.ts +33 -0
- package/dist/boe.d.ts.map +1 -0
- package/dist/boe.js +114 -0
- package/dist/boe.js.map +1 -0
- package/dist/braintree.d.ts +34 -0
- package/dist/braintree.d.ts.map +1 -0
- package/dist/braintree.js +45 -0
- package/dist/braintree.js.map +1 -0
- package/dist/cash.d.ts +64 -0
- package/dist/cash.d.ts.map +1 -0
- package/dist/cash.js +134 -0
- package/dist/cash.js.map +1 -0
- package/dist/checkout.d.ts +42 -0
- package/dist/checkout.d.ts.map +1 -0
- package/dist/checkout.js +52 -0
- package/dist/checkout.js.map +1 -0
- package/dist/convert.d.ts +52 -0
- package/dist/convert.d.ts.map +1 -0
- package/dist/convert.js +65 -0
- package/dist/convert.js.map +1 -0
- package/dist/currency.d.ts +22 -0
- package/dist/currency.d.ts.map +1 -0
- package/dist/currency.js +38 -0
- package/dist/currency.js.map +1 -0
- package/dist/decimal.d.ts +52 -0
- package/dist/decimal.d.ts.map +1 -0
- package/dist/decimal.js +251 -0
- package/dist/decimal.js.map +1 -0
- package/dist/drizzle/mysql.d.ts +105 -0
- package/dist/drizzle/mysql.d.ts.map +1 -0
- package/dist/drizzle/mysql.js +60 -0
- package/dist/drizzle/mysql.js.map +1 -0
- package/dist/drizzle/pg.d.ts +103 -0
- package/dist/drizzle/pg.d.ts.map +1 -0
- package/dist/drizzle/pg.js +60 -0
- package/dist/drizzle/pg.js.map +1 -0
- package/dist/drizzle/sqlite.d.ts +102 -0
- package/dist/drizzle/sqlite.d.ts.map +1 -0
- package/dist/drizzle/sqlite.js +59 -0
- package/dist/drizzle/sqlite.js.map +1 -0
- package/dist/ecb.d.ts +52 -0
- package/dist/ecb.d.ts.map +1 -0
- package/dist/ecb.js +285 -0
- package/dist/ecb.js.map +1 -0
- package/dist/exact.d.ts +62 -0
- package/dist/exact.d.ts.map +1 -0
- package/dist/exact.js +147 -0
- package/dist/exact.js.map +1 -0
- package/dist/fed.d.ts +35 -0
- package/dist/fed.d.ts.map +1 -0
- package/dist/fed.js +134 -0
- package/dist/fed.js.map +1 -0
- package/dist/fetch.d.ts +103 -0
- package/dist/fetch.d.ts.map +1 -0
- package/dist/fetch.js +222 -0
- package/dist/fetch.js.map +1 -0
- package/dist/fixed.d.ts +121 -0
- package/dist/fixed.d.ts.map +1 -0
- package/dist/fixed.js +420 -0
- package/dist/fixed.js.map +1 -0
- package/dist/fn.d.ts +34 -0
- package/dist/fn.d.ts.map +1 -0
- package/dist/fn.js +37 -0
- package/dist/fn.js.map +1 -0
- package/dist/format.d.ts +13 -0
- package/dist/format.d.ts.map +1 -0
- package/dist/format.js +90 -0
- package/dist/format.js.map +1 -0
- package/dist/generated/adyen.d.ts +3 -0
- package/dist/generated/adyen.d.ts.map +1 -0
- package/dist/generated/adyen.js +144 -0
- package/dist/generated/adyen.js.map +1 -0
- package/dist/generated/braintree.d.ts +3 -0
- package/dist/generated/braintree.d.ts.map +1 -0
- package/dist/generated/braintree.js +140 -0
- package/dist/generated/braintree.js.map +1 -0
- package/dist/generated/cash.d.ts +14 -0
- package/dist/generated/cash.d.ts.map +1 -0
- package/dist/generated/cash.js +465 -0
- package/dist/generated/cash.js.map +1 -0
- package/dist/generated/checkout.d.ts +3 -0
- package/dist/generated/checkout.d.ts.map +1 -0
- package/dist/generated/checkout.js +151 -0
- package/dist/generated/checkout.js.map +1 -0
- package/dist/generated/historic.d.ts +337 -0
- package/dist/generated/historic.d.ts.map +1 -0
- package/dist/generated/historic.js +66 -0
- package/dist/generated/historic.js.map +1 -0
- package/dist/generated/iso4217.d.ts +174 -0
- package/dist/generated/iso4217.d.ts.map +1 -0
- package/dist/generated/iso4217.js +175 -0
- package/dist/generated/iso4217.js.map +1 -0
- package/dist/generated/kernel.d.ts +17 -0
- package/dist/generated/kernel.d.ts.map +1 -0
- package/dist/generated/kernel.js +208 -0
- package/dist/generated/kernel.js.map +1 -0
- package/dist/generated/klarna.d.ts +3 -0
- package/dist/generated/klarna.d.ts.map +1 -0
- package/dist/generated/klarna.js +21 -0
- package/dist/generated/klarna.js.map +1 -0
- package/dist/generated/paypal.d.ts +3 -0
- package/dist/generated/paypal.d.ts.map +1 -0
- package/dist/generated/paypal.js +31 -0
- package/dist/generated/paypal.js.map +1 -0
- package/dist/generated/stripe.d.ts +8 -0
- package/dist/generated/stripe.d.ts.map +1 -0
- package/dist/generated/stripe.js +150 -0
- package/dist/generated/stripe.js.map +1 -0
- package/dist/historic.d.ts +40 -0
- package/dist/historic.d.ts.map +1 -0
- package/dist/historic.js +43 -0
- package/dist/historic.js.map +1 -0
- package/dist/history.d.ts +96 -0
- package/dist/history.d.ts.map +1 -0
- package/dist/history.js +213 -0
- package/dist/history.js.map +1 -0
- package/dist/hmrc.d.ts +28 -0
- package/dist/hmrc.d.ts.map +1 -0
- package/dist/hmrc.js +108 -0
- package/dist/hmrc.js.map +1 -0
- package/dist/index.d.ts +11 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +11 -0
- package/dist/index.js.map +1 -0
- package/dist/klarna.d.ts +31 -0
- package/dist/klarna.d.ts.map +1 -0
- package/dist/klarna.js +38 -0
- package/dist/klarna.js.map +1 -0
- package/dist/kysely.d.ts +120 -0
- package/dist/kysely.d.ts.map +1 -0
- package/dist/kysely.js +309 -0
- package/dist/kysely.js.map +1 -0
- package/dist/money.d.ts +302 -0
- package/dist/money.d.ts.map +1 -0
- package/dist/money.js +703 -0
- package/dist/money.js.map +1 -0
- package/dist/parse.d.ts +55 -0
- package/dist/parse.d.ts.map +1 -0
- package/dist/parse.js +419 -0
- package/dist/parse.js.map +1 -0
- package/dist/paypal.d.ts +41 -0
- package/dist/paypal.d.ts.map +1 -0
- package/dist/paypal.js +58 -0
- package/dist/paypal.js.map +1 -0
- package/dist/pg.d.ts +100 -0
- package/dist/pg.d.ts.map +1 -0
- package/dist/pg.js +343 -0
- package/dist/pg.js.map +1 -0
- package/dist/plugins/adyen.d.ts +15 -0
- package/dist/plugins/adyen.d.ts.map +1 -0
- package/dist/plugins/adyen.js +10 -0
- package/dist/plugins/adyen.js.map +1 -0
- package/dist/plugins/all.d.ts +29 -0
- package/dist/plugins/all.d.ts.map +1 -0
- package/dist/plugins/all.js +30 -0
- package/dist/plugins/all.js.map +1 -0
- package/dist/plugins/allocate.d.ts +20 -0
- package/dist/plugins/allocate.d.ts.map +1 -0
- package/dist/plugins/allocate.js +26 -0
- package/dist/plugins/allocate.js.map +1 -0
- package/dist/plugins/braintree.d.ts +12 -0
- package/dist/plugins/braintree.d.ts.map +1 -0
- package/dist/plugins/braintree.js +12 -0
- package/dist/plugins/braintree.js.map +1 -0
- package/dist/plugins/cash.d.ts +19 -0
- package/dist/plugins/cash.d.ts.map +1 -0
- package/dist/plugins/cash.js +16 -0
- package/dist/plugins/cash.js.map +1 -0
- package/dist/plugins/checkout.d.ts +21 -0
- package/dist/plugins/checkout.d.ts.map +1 -0
- package/dist/plugins/checkout.js +16 -0
- package/dist/plugins/checkout.js.map +1 -0
- package/dist/plugins/convert.d.ts +19 -0
- package/dist/plugins/convert.d.ts.map +1 -0
- package/dist/plugins/convert.js +13 -0
- package/dist/plugins/convert.js.map +1 -0
- package/dist/plugins/exact.d.ts +15 -0
- package/dist/plugins/exact.d.ts.map +1 -0
- package/dist/plugins/exact.js +13 -0
- package/dist/plugins/exact.js.map +1 -0
- package/dist/plugins/fixed.d.ts +17 -0
- package/dist/plugins/fixed.d.ts.map +1 -0
- package/dist/plugins/fixed.js +13 -0
- package/dist/plugins/fixed.js.map +1 -0
- package/dist/plugins/format.d.ts +9 -0
- package/dist/plugins/format.d.ts.map +1 -0
- package/dist/plugins/format.js +16 -0
- package/dist/plugins/format.js.map +1 -0
- package/dist/plugins/klarna.d.ts +10 -0
- package/dist/plugins/klarna.d.ts.map +1 -0
- package/dist/plugins/klarna.js +10 -0
- package/dist/plugins/klarna.js.map +1 -0
- package/dist/plugins/paypal.d.ts +14 -0
- package/dist/plugins/paypal.d.ts.map +1 -0
- package/dist/plugins/paypal.js +16 -0
- package/dist/plugins/paypal.js.map +1 -0
- package/dist/plugins/prisma.d.ts +8 -0
- package/dist/plugins/prisma.d.ts.map +1 -0
- package/dist/plugins/prisma.js +13 -0
- package/dist/plugins/prisma.js.map +1 -0
- package/dist/plugins/stripe.d.ts +14 -0
- package/dist/plugins/stripe.d.ts.map +1 -0
- package/dist/plugins/stripe.js +16 -0
- package/dist/plugins/stripe.js.map +1 -0
- package/dist/plugins/tax.d.ts +20 -0
- package/dist/plugins/tax.d.ts.map +1 -0
- package/dist/plugins/tax.js +18 -0
- package/dist/plugins/tax.js.map +1 -0
- package/dist/policy.d.ts +34 -0
- package/dist/policy.d.ts.map +1 -0
- package/dist/policy.js +90 -0
- package/dist/policy.js.map +1 -0
- package/dist/prisma.d.ts +77 -0
- package/dist/prisma.d.ts.map +1 -0
- package/dist/prisma.js +76 -0
- package/dist/prisma.js.map +1 -0
- package/dist/processor.d.ts +28 -0
- package/dist/processor.d.ts.map +1 -0
- package/dist/processor.js +118 -0
- package/dist/processor.js.map +1 -0
- package/dist/provider.d.ts +103 -0
- package/dist/provider.d.ts.map +1 -0
- package/dist/provider.js +280 -0
- package/dist/provider.js.map +1 -0
- package/dist/rate-text.d.ts +38 -0
- package/dist/rate-text.d.ts.map +1 -0
- package/dist/rate-text.js +143 -0
- package/dist/rate-text.js.map +1 -0
- package/dist/rate.d.ts +175 -0
- package/dist/rate.d.ts.map +1 -0
- package/dist/rate.js +371 -0
- package/dist/rate.js.map +1 -0
- package/dist/rounding-error.d.ts +9 -0
- package/dist/rounding-error.d.ts.map +1 -0
- package/dist/rounding-error.js +9 -0
- package/dist/rounding-error.js.map +1 -0
- package/dist/rounding.d.ts +50 -0
- package/dist/rounding.d.ts.map +1 -0
- package/dist/rounding.js +87 -0
- package/dist/rounding.js.map +1 -0
- package/dist/schema.d.ts +43 -0
- package/dist/schema.d.ts.map +1 -0
- package/dist/schema.js +77 -0
- package/dist/schema.js.map +1 -0
- package/dist/sql.d.ts +54 -0
- package/dist/sql.d.ts.map +1 -0
- package/dist/sql.js +131 -0
- package/dist/sql.js.map +1 -0
- package/dist/stripe-fx.d.ts +22 -0
- package/dist/stripe-fx.d.ts.map +1 -0
- package/dist/stripe-fx.js +30 -0
- package/dist/stripe-fx.js.map +1 -0
- package/dist/stripe.d.ts +36 -0
- package/dist/stripe.d.ts.map +1 -0
- package/dist/stripe.js +39 -0
- package/dist/stripe.js.map +1 -0
- package/dist/tax.d.ts +33 -0
- package/dist/tax.d.ts.map +1 -0
- package/dist/tax.js +55 -0
- package/dist/tax.js.map +1 -0
- package/dist/testing/arbitraries.d.ts +43 -0
- package/dist/testing/arbitraries.d.ts.map +1 -0
- package/dist/testing/arbitraries.js +98 -0
- package/dist/testing/arbitraries.js.map +1 -0
- package/dist/testing/index.d.ts +67 -0
- package/dist/testing/index.d.ts.map +1 -0
- package/dist/testing/index.js +159 -0
- package/dist/testing/index.js.map +1 -0
- package/dist/testing/jest.d.ts +2 -0
- package/dist/testing/jest.d.ts.map +1 -0
- package/dist/testing/jest.js +15 -0
- package/dist/testing/jest.js.map +1 -0
- package/dist/testing/vitest.d.ts +7 -0
- package/dist/testing/vitest.d.ts.map +1 -0
- package/dist/testing/vitest.js +12 -0
- package/dist/testing/vitest.js.map +1 -0
- package/dist/valibot.d.ts +33 -0
- package/dist/valibot.d.ts.map +1 -0
- package/dist/valibot.js +61 -0
- package/dist/valibot.js.map +1 -0
- package/dist/zod.d.ts +36 -0
- package/dist/zod.d.ts.map +1 -0
- package/dist/zod.js +58 -0
- package/dist/zod.js.map +1 -0
- package/package.json +280 -5
- package/src/adyen-fx.ts +175 -0
- package/src/adyen.ts +46 -0
- package/src/allocate.ts +393 -0
- package/src/bag.ts +170 -0
- package/src/boc.ts +136 -0
- package/src/boe.ts +131 -0
- package/src/braintree.ts +71 -0
- package/src/cash.ts +183 -0
- package/src/checkout.ts +77 -0
- package/src/convert.ts +177 -0
- package/src/currency.ts +61 -0
- package/src/decimal.ts +283 -0
- package/src/drizzle/mysql.ts +78 -0
- package/src/drizzle/pg.ts +78 -0
- package/src/drizzle/sqlite.ts +70 -0
- package/src/ecb.ts +328 -0
- package/src/exact.ts +182 -0
- package/src/fed.ts +146 -0
- package/src/fetch.ts +306 -0
- package/src/fixed.ts +558 -0
- package/src/fn.ts +94 -0
- package/src/format.ts +106 -0
- package/src/generated/adyen.ts +144 -0
- package/src/generated/braintree.ts +140 -0
- package/src/generated/cash.ts +478 -0
- package/src/generated/checkout.ts +151 -0
- package/src/generated/historic.ts +67 -0
- package/src/generated/iso4217.ts +179 -0
- package/src/generated/kernel.ts +230 -0
- package/src/generated/klarna.ts +21 -0
- package/src/generated/paypal.ts +31 -0
- package/src/generated/stripe.ts +150 -0
- package/src/historic.ts +77 -0
- package/src/history.ts +333 -0
- package/src/hmrc.ts +125 -0
- package/src/index.ts +41 -0
- package/src/klarna.ts +54 -0
- package/src/kysely.ts +588 -0
- package/src/money.ts +979 -0
- package/src/parse.ts +511 -0
- package/src/paypal.ts +93 -0
- package/src/pg.ts +410 -0
- package/src/plugins/adyen.ts +25 -0
- package/src/plugins/all.ts +57 -0
- package/src/plugins/allocate.ts +53 -0
- package/src/plugins/braintree.ts +23 -0
- package/src/plugins/cash.ts +33 -0
- package/src/plugins/checkout.ts +32 -0
- package/src/plugins/convert.ts +49 -0
- package/src/plugins/exact.ts +23 -0
- package/src/plugins/fixed.ts +28 -0
- package/src/plugins/format.ts +28 -0
- package/src/plugins/klarna.ts +21 -0
- package/src/plugins/paypal.ts +29 -0
- package/src/plugins/prisma.ts +24 -0
- package/src/plugins/stripe.ts +29 -0
- package/src/plugins/tax.ts +40 -0
- package/src/policy.ts +124 -0
- package/src/prisma.ts +199 -0
- package/src/processor.ts +181 -0
- package/src/provider.ts +389 -0
- package/src/rate-text.ts +151 -0
- package/src/rate.ts +546 -0
- package/src/rounding-error.ts +8 -0
- package/src/rounding.ts +122 -0
- package/src/schema.ts +119 -0
- package/src/sql.ts +178 -0
- package/src/stripe-fx.ts +49 -0
- package/src/stripe.ts +69 -0
- package/src/tax.ts +102 -0
- package/src/testing/arbitraries.ts +134 -0
- package/src/testing/index.ts +247 -0
- package/src/testing/jest.ts +18 -0
- package/src/testing/vitest.ts +18 -0
- package/src/valibot.ts +87 -0
- package/src/zod.ts +78 -0
package/src/kysely.ts
ADDED
|
@@ -0,0 +1,588 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `@ducatjs/money/kysely`: Money in Kysely queries, without the plugin having to understand query
|
|
3
|
+
* shapes. A money value is selected as a self-describing JSON object built in SQL (marked, with the
|
|
4
|
+
* amount as exact text and the currency), and `MoneyResultPlugin` turns exactly those objects into
|
|
5
|
+
* Money, wherever they appear: unions, CTEs, subqueries, nested JSON, every `stream()` chunk, and
|
|
6
|
+
* under `CamelCasePlugin`. A raw column is never typed as Money.
|
|
7
|
+
*
|
|
8
|
+
* const money = moneyHelpers('postgres');
|
|
9
|
+
* const db = new Kysely<Database>({ dialect, plugins: [new MoneyResultPlugin()] });
|
|
10
|
+
*
|
|
11
|
+
* await db.insertInto('payments').values({ id: 1, ...setMoney(fee, 'amount_minor', 'currency') });
|
|
12
|
+
* await db
|
|
13
|
+
* .selectFrom('payments')
|
|
14
|
+
* .select((eb) => money.selectMoney(eb, 'amount_minor', 'currency').as('fee'))
|
|
15
|
+
* .where((eb) => money.compareMoney(eb, 'amount_minor', 'currency', '>=', fee))
|
|
16
|
+
* .execute();
|
|
17
|
+
*/
|
|
18
|
+
import {
|
|
19
|
+
sql,
|
|
20
|
+
type ColumnType,
|
|
21
|
+
type Expression,
|
|
22
|
+
type ExpressionBuilder,
|
|
23
|
+
type ExtractTypeFromReferenceExpression,
|
|
24
|
+
type KyselyPlugin,
|
|
25
|
+
type PluginTransformQueryArgs,
|
|
26
|
+
type PluginTransformResultArgs,
|
|
27
|
+
type QueryResult,
|
|
28
|
+
type RawBuilder,
|
|
29
|
+
type ReferenceExpression,
|
|
30
|
+
type RootOperationNode,
|
|
31
|
+
type SqlBool,
|
|
32
|
+
type UnknownRow,
|
|
33
|
+
} from 'kysely';
|
|
34
|
+
import { currencyExponent } from './currency.js';
|
|
35
|
+
import { CurrencyMismatchError, type CurrencyInput, type Money } from './money.js';
|
|
36
|
+
import {
|
|
37
|
+
checkCurrency,
|
|
38
|
+
checkDecimalSize,
|
|
39
|
+
checkIsoScale,
|
|
40
|
+
decimalFromDriver,
|
|
41
|
+
DEFAULT_DECIMAL_SIZE,
|
|
42
|
+
type DecimalColumnSize,
|
|
43
|
+
decimalToDriver,
|
|
44
|
+
minorFromDriver,
|
|
45
|
+
minorToDriver,
|
|
46
|
+
} from './sql.js';
|
|
47
|
+
|
|
48
|
+
export { DEFAULT_DECIMAL_SIZE, type DecimalColumnSize } from './sql.js';
|
|
49
|
+
|
|
50
|
+
/** The SQL dialects the helpers can build JSON objects and exact casts for. */
|
|
51
|
+
export type MoneyDialect = 'postgres' | 'mysql' | 'sqlite';
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* A BIGINT (SQLite INTEGER) column of minor units. Drivers return it as a string, a number or a
|
|
55
|
+
* bigint; `setMoney` writes integer strings, which every driver binds exactly.
|
|
56
|
+
*/
|
|
57
|
+
export type MinorUnitsColumn = ColumnType<
|
|
58
|
+
string | number | bigint,
|
|
59
|
+
string | bigint,
|
|
60
|
+
string | bigint
|
|
61
|
+
>;
|
|
62
|
+
|
|
63
|
+
/** A NUMERIC/DECIMAL (SQLite TEXT) column holding the exact decimal as a string. */
|
|
64
|
+
export type DecimalAmountColumn = ColumnType<string, string, string>;
|
|
65
|
+
|
|
66
|
+
/** A currency that is fixed for a column instead of stored in another column: `fixed('EUR')`. */
|
|
67
|
+
export interface FixedCurrency<C extends string> {
|
|
68
|
+
readonly fixed: C;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/** Marks a column's currency as fixed: `selectMoney(eb, 'total_minor', fixed('EUR'))`. */
|
|
72
|
+
export function fixed<const C extends CurrencyInput>(currency: C): FixedCurrency<C> {
|
|
73
|
+
// an unknown code fails here, where the column is described, not on the first query
|
|
74
|
+
currencyExponent(currency);
|
|
75
|
+
return { fixed: currency };
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
function isFixed(currency: unknown): currency is FixedCurrency<string> {
|
|
79
|
+
return typeof currency === 'object' && currency !== null && 'fixed' in currency;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/** The comparison operators `compareMoney` supports. */
|
|
83
|
+
export type MoneyComparison = '=' | '<>' | '<' | '<=' | '>' | '>=';
|
|
84
|
+
|
|
85
|
+
const COMPARISONS: ReadonlySet<string> = new Set(['=', '<>', '<', '<=', '>', '>=']);
|
|
86
|
+
|
|
87
|
+
// The marker key of the JSON objects the helpers build. Its value carries a random nonce chosen
|
|
88
|
+
// when this module loads, so data that merely looks like a marker (a user's JSON column, a text
|
|
89
|
+
// value) is never read as money: forging one needs the nonce, which only appears in query text.
|
|
90
|
+
const MARKER = '$ducat';
|
|
91
|
+
const NONCE = Array.from(globalThis.crypto.getRandomValues(new Uint8Array(16)), (byte) =>
|
|
92
|
+
byte.toString(16).padStart(2, '0'),
|
|
93
|
+
).join('');
|
|
94
|
+
const MINOR_MARKER = `${NONCE}:minor`;
|
|
95
|
+
const DECIMAL_MARKER = `${NONCE}:decimal`;
|
|
96
|
+
// nested JSON deeper than this is left as it is, so a hostile document can't exhaust the stack
|
|
97
|
+
const MAX_DEPTH = 64;
|
|
98
|
+
|
|
99
|
+
type Storage = 'minor' | 'decimal';
|
|
100
|
+
|
|
101
|
+
function markerFor(storage: Storage): string {
|
|
102
|
+
return storage === 'minor' ? MINOR_MARKER : DECIMAL_MARKER;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/** `Money<C>`, or `Money<C> | null` when the amount column is nullable. */
|
|
106
|
+
type MoneyFor<Amount, C extends string> = null extends Amount ? Money<C> | null : Money<C>;
|
|
107
|
+
|
|
108
|
+
// ---------------------------------------------------------------------------------------------
|
|
109
|
+
// Writes: plain column values, so inserts, updates and upserts need no query rewriting
|
|
110
|
+
// ---------------------------------------------------------------------------------------------
|
|
111
|
+
|
|
112
|
+
/**
|
|
113
|
+
* Column values for an insert or update: the minor units as an integer string, and the currency
|
|
114
|
+
* (unless it is fixed for the column, in which case the Money must be in it).
|
|
115
|
+
*
|
|
116
|
+
* .values({ id: 1, ...setMoney(fee, 'amount_minor', 'currency') })
|
|
117
|
+
* .set(setMoney(total, 'total_minor', fixed('EUR')))
|
|
118
|
+
*/
|
|
119
|
+
export function setMoney<const A extends string, const K extends string>(
|
|
120
|
+
money: Money,
|
|
121
|
+
amount: A,
|
|
122
|
+
currency: K,
|
|
123
|
+
): { [P in A | K]: string };
|
|
124
|
+
export function setMoney<const A extends string, const C extends string>(
|
|
125
|
+
money: Money<NoInfer<C>>,
|
|
126
|
+
amount: A,
|
|
127
|
+
currency: FixedCurrency<C>,
|
|
128
|
+
): { [P in A]: string };
|
|
129
|
+
export function setMoney(
|
|
130
|
+
money: Money,
|
|
131
|
+
amount: string,
|
|
132
|
+
currency: string | FixedCurrency<string>,
|
|
133
|
+
): Record<string, string> {
|
|
134
|
+
if (isFixed(currency)) {
|
|
135
|
+
return { [amount]: minorToDriver(money, currency.fixed).toString() };
|
|
136
|
+
}
|
|
137
|
+
return { [amount]: minorToDriver(money, undefined).toString(), [currency]: money.currency };
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
/**
|
|
141
|
+
* Like `setMoney`, for a NUMERIC/DECIMAL amount column: the exact decimal. Pass the column's size
|
|
142
|
+
* (NUMERIC(19,4) by default) so that a value it would round or clamp throws instead. On SQLite,
|
|
143
|
+
* where decimals are TEXT, the size only limits what is written.
|
|
144
|
+
*/
|
|
145
|
+
export function setDecimalMoney<const A extends string, const K extends string>(
|
|
146
|
+
money: Money,
|
|
147
|
+
amount: A,
|
|
148
|
+
currency: K,
|
|
149
|
+
size?: DecimalColumnSize,
|
|
150
|
+
): { [P in A | K]: string };
|
|
151
|
+
export function setDecimalMoney<const A extends string, const C extends string>(
|
|
152
|
+
money: Money<NoInfer<C>>,
|
|
153
|
+
amount: A,
|
|
154
|
+
currency: FixedCurrency<C>,
|
|
155
|
+
size?: DecimalColumnSize,
|
|
156
|
+
): { [P in A]: string };
|
|
157
|
+
export function setDecimalMoney(
|
|
158
|
+
money: Money,
|
|
159
|
+
amount: string,
|
|
160
|
+
currency: string | FixedCurrency<string>,
|
|
161
|
+
size: DecimalColumnSize = DEFAULT_DECIMAL_SIZE,
|
|
162
|
+
): Record<string, string> {
|
|
163
|
+
const decimal = decimalForColumn(money, isFixed(currency) ? currency.fixed : undefined, size);
|
|
164
|
+
if (isFixed(currency)) {
|
|
165
|
+
return { [amount]: decimal };
|
|
166
|
+
}
|
|
167
|
+
return { [amount]: decimal, [currency]: money.currency };
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
/**
|
|
171
|
+
* The decimal for a NUMERIC(p, s) column, or a throw if the column would round or clamp it: its
|
|
172
|
+
* scale must hold the currency's exponent, and the value must fit its integer digits.
|
|
173
|
+
*/
|
|
174
|
+
function decimalForColumn(
|
|
175
|
+
money: Money,
|
|
176
|
+
currency: string | undefined,
|
|
177
|
+
size: DecimalColumnSize,
|
|
178
|
+
): string {
|
|
179
|
+
checkDecimalSize(money.currency, size);
|
|
180
|
+
return decimalToDriver(money, currency, size);
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
// ---------------------------------------------------------------------------------------------
|
|
184
|
+
// Reads, filters and sums: SQL expressions built per dialect
|
|
185
|
+
// ---------------------------------------------------------------------------------------------
|
|
186
|
+
|
|
187
|
+
/** How each dialect builds the JSON object and casts values exactly. */
|
|
188
|
+
interface DialectSql {
|
|
189
|
+
jsonObject(fields: readonly [string, Expression<unknown>][]): RawBuilder<unknown>;
|
|
190
|
+
asText(expression: Expression<unknown>): RawBuilder<string>;
|
|
191
|
+
minorParameter(minor: bigint): Expression<unknown>;
|
|
192
|
+
decimalParameter(decimal: string): Expression<unknown>;
|
|
193
|
+
/** Whether decimals compare and add as numbers (not in SQLite, where they are TEXT). */
|
|
194
|
+
readonly hasNumericDecimals: boolean;
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
function jsonObjectWith(functionName: string) {
|
|
198
|
+
return (fields: readonly [string, Expression<unknown>][]): RawBuilder<unknown> => {
|
|
199
|
+
const parts = fields.map(([key, value]) => sql`${sql.lit(key)}, ${value}`);
|
|
200
|
+
return sql`${sql.raw(functionName)}(${sql.join(parts)})`;
|
|
201
|
+
};
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
// MySQL compares a DECIMAL with a string as doubles, so parameters are cast to its widest DECIMAL
|
|
205
|
+
const MYSQL_DECIMAL_INTEGER_DIGITS = 35;
|
|
206
|
+
const MYSQL_DECIMAL_SCALE = 30;
|
|
207
|
+
|
|
208
|
+
const DIALECTS: Record<MoneyDialect, DialectSql> = {
|
|
209
|
+
postgres: {
|
|
210
|
+
// jsonb, which has equality, so UNION, DISTINCT and GROUP BY work on money values
|
|
211
|
+
jsonObject: jsonObjectWith('jsonb_build_object'),
|
|
212
|
+
asText: (expression) => sql<string>`cast(${expression} as text)`,
|
|
213
|
+
minorParameter: (minor) => sql`cast(${minor.toString()} as bigint)`,
|
|
214
|
+
decimalParameter: (decimal) => sql`cast(${decimal} as numeric)`,
|
|
215
|
+
hasNumericDecimals: true,
|
|
216
|
+
},
|
|
217
|
+
mysql: {
|
|
218
|
+
jsonObject: jsonObjectWith('json_object'),
|
|
219
|
+
asText: (expression) => sql<string>`cast(${expression} as char)`,
|
|
220
|
+
// a string compared with a BIGINT is compared as a double, which loses digits beyond 2^53
|
|
221
|
+
minorParameter: (minor) => sql`cast(${minor.toString()} as signed)`,
|
|
222
|
+
decimalParameter: (decimal) => {
|
|
223
|
+
const [integerPart, fraction = ''] = decimal.replace('-', '').split('.');
|
|
224
|
+
const fits =
|
|
225
|
+
integerPart!.length <= MYSQL_DECIMAL_INTEGER_DIGITS &&
|
|
226
|
+
fraction.length <= MYSQL_DECIMAL_SCALE;
|
|
227
|
+
if (!fits) {
|
|
228
|
+
throw new RangeError(`${decimal} is beyond MySQL's widest DECIMAL(65,30)`);
|
|
229
|
+
}
|
|
230
|
+
return sql`cast(${decimal} as decimal(65, 30))`;
|
|
231
|
+
},
|
|
232
|
+
hasNumericDecimals: true,
|
|
233
|
+
},
|
|
234
|
+
sqlite: {
|
|
235
|
+
jsonObject: jsonObjectWith('json_object'),
|
|
236
|
+
asText: (expression) => sql<string>`cast(${expression} as text)`,
|
|
237
|
+
// a bound bigint is an INTEGER, which compares as a number even against a column without
|
|
238
|
+
// affinity (a CTE or subquery column), where a string would compare as text
|
|
239
|
+
minorParameter: (minor) => sql`${minor}`,
|
|
240
|
+
decimalParameter: (decimal) => sql`${decimal}`,
|
|
241
|
+
hasNumericDecimals: false,
|
|
242
|
+
},
|
|
243
|
+
};
|
|
244
|
+
|
|
245
|
+
/** The money helpers for one dialect, from `moneyHelpers`. */
|
|
246
|
+
export interface MoneyHelpers {
|
|
247
|
+
/**
|
|
248
|
+
* The Money in an amount column of minor units, with the currency from a column or `fixed()`.
|
|
249
|
+
* Typed `Money<C>`, or `Money<C> | null` for a nullable amount column; needs `MoneyResultPlugin`.
|
|
250
|
+
*
|
|
251
|
+
* .select((eb) => money.selectMoney(eb, 'p.amount_minor', 'p.currency').as('paid'))
|
|
252
|
+
*/
|
|
253
|
+
selectMoney<DB, TB extends keyof DB, RA extends ReferenceExpression<DB, TB>>(
|
|
254
|
+
eb: ExpressionBuilder<DB, TB>,
|
|
255
|
+
amount: RA,
|
|
256
|
+
currency: ReferenceExpression<DB, TB>,
|
|
257
|
+
): RawBuilder<MoneyFor<ExtractTypeFromReferenceExpression<DB, TB, RA>, CurrencyInput>>;
|
|
258
|
+
selectMoney<DB, TB extends keyof DB, RA extends ReferenceExpression<DB, TB>, C extends string>(
|
|
259
|
+
eb: ExpressionBuilder<DB, TB>,
|
|
260
|
+
amount: RA,
|
|
261
|
+
currency: FixedCurrency<C>,
|
|
262
|
+
): RawBuilder<MoneyFor<ExtractTypeFromReferenceExpression<DB, TB, RA>, C>>;
|
|
263
|
+
|
|
264
|
+
/** Like `selectMoney`, for a NUMERIC/DECIMAL (SQLite TEXT) amount column. */
|
|
265
|
+
selectDecimalMoney<DB, TB extends keyof DB, RA extends ReferenceExpression<DB, TB>>(
|
|
266
|
+
eb: ExpressionBuilder<DB, TB>,
|
|
267
|
+
amount: RA,
|
|
268
|
+
currency: ReferenceExpression<DB, TB>,
|
|
269
|
+
): RawBuilder<MoneyFor<ExtractTypeFromReferenceExpression<DB, TB, RA>, CurrencyInput>>;
|
|
270
|
+
selectDecimalMoney<
|
|
271
|
+
DB,
|
|
272
|
+
TB extends keyof DB,
|
|
273
|
+
RA extends ReferenceExpression<DB, TB>,
|
|
274
|
+
C extends string,
|
|
275
|
+
>(
|
|
276
|
+
eb: ExpressionBuilder<DB, TB>,
|
|
277
|
+
amount: RA,
|
|
278
|
+
currency: FixedCurrency<C>,
|
|
279
|
+
): RawBuilder<MoneyFor<ExtractTypeFromReferenceExpression<DB, TB, RA>, C>>;
|
|
280
|
+
|
|
281
|
+
/**
|
|
282
|
+
* A condition comparing a stored amount with Money, currency and amount together. With a
|
|
283
|
+
* currency column, `=` and the ordering comparisons also require the same currency, and `<>`
|
|
284
|
+
* holds when either differs. With `fixed()`, Money in another currency throws.
|
|
285
|
+
*
|
|
286
|
+
* .where((eb) => money.compareMoney(eb, 'amount_minor', 'currency', '>=', limit))
|
|
287
|
+
*/
|
|
288
|
+
compareMoney<DB, TB extends keyof DB>(
|
|
289
|
+
eb: ExpressionBuilder<DB, TB>,
|
|
290
|
+
amount: ReferenceExpression<DB, TB>,
|
|
291
|
+
currency: ReferenceExpression<DB, TB> | FixedCurrency<string>,
|
|
292
|
+
operator: MoneyComparison,
|
|
293
|
+
money: Money,
|
|
294
|
+
): Expression<SqlBool>;
|
|
295
|
+
|
|
296
|
+
/**
|
|
297
|
+
* Like `compareMoney`, for a NUMERIC/DECIMAL amount column of the given size (NUMERIC(19,4) by
|
|
298
|
+
* default). SQLite allows only `=` and `<>`, which compare the text, so they match only values
|
|
299
|
+
* written with `setDecimalMoney`.
|
|
300
|
+
*/
|
|
301
|
+
compareDecimalMoney<DB, TB extends keyof DB>(
|
|
302
|
+
eb: ExpressionBuilder<DB, TB>,
|
|
303
|
+
amount: ReferenceExpression<DB, TB>,
|
|
304
|
+
currency: ReferenceExpression<DB, TB> | FixedCurrency<string>,
|
|
305
|
+
operator: MoneyComparison,
|
|
306
|
+
money: Money,
|
|
307
|
+
size?: DecimalColumnSize,
|
|
308
|
+
): Expression<SqlBool>;
|
|
309
|
+
|
|
310
|
+
/**
|
|
311
|
+
* The sum of an amount column as Money, or null over no rows. With a currency column, rows in
|
|
312
|
+
* more than one currency (or an amount without a currency) make the result throw when read, so
|
|
313
|
+
* group by the currency column to sum per currency.
|
|
314
|
+
*
|
|
315
|
+
* .select((eb) => [money.sumMoney(eb, 'amount_minor', 'currency').as('total'), 'currency'])
|
|
316
|
+
* .groupBy('currency')
|
|
317
|
+
*/
|
|
318
|
+
sumMoney<DB, TB extends keyof DB>(
|
|
319
|
+
eb: ExpressionBuilder<DB, TB>,
|
|
320
|
+
amount: ReferenceExpression<DB, TB>,
|
|
321
|
+
currency: ReferenceExpression<DB, TB>,
|
|
322
|
+
): RawBuilder<Money | null>;
|
|
323
|
+
sumMoney<DB, TB extends keyof DB, C extends string>(
|
|
324
|
+
eb: ExpressionBuilder<DB, TB>,
|
|
325
|
+
amount: ReferenceExpression<DB, TB>,
|
|
326
|
+
currency: FixedCurrency<C>,
|
|
327
|
+
): RawBuilder<Money<C> | null>;
|
|
328
|
+
|
|
329
|
+
/** Like `sumMoney`, for a NUMERIC/DECIMAL amount column. Not available on SQLite (TEXT). */
|
|
330
|
+
sumDecimalMoney<DB, TB extends keyof DB>(
|
|
331
|
+
eb: ExpressionBuilder<DB, TB>,
|
|
332
|
+
amount: ReferenceExpression<DB, TB>,
|
|
333
|
+
currency: ReferenceExpression<DB, TB>,
|
|
334
|
+
): RawBuilder<Money | null>;
|
|
335
|
+
sumDecimalMoney<DB, TB extends keyof DB, C extends string>(
|
|
336
|
+
eb: ExpressionBuilder<DB, TB>,
|
|
337
|
+
amount: ReferenceExpression<DB, TB>,
|
|
338
|
+
currency: FixedCurrency<C>,
|
|
339
|
+
): RawBuilder<Money<C> | null>;
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
// oxlint-disable-next-line typescript/no-explicit-any -- the helpers work for any database type
|
|
343
|
+
type AnyBuilder = ExpressionBuilder<any, any>;
|
|
344
|
+
// oxlint-disable-next-line typescript/no-explicit-any
|
|
345
|
+
type AnyReference = ReferenceExpression<any, any>;
|
|
346
|
+
type AnyCurrency = AnyReference | FixedCurrency<string>;
|
|
347
|
+
|
|
348
|
+
/** The read, filter and sum helpers for one SQL dialect. */
|
|
349
|
+
export function moneyHelpers(dialect: MoneyDialect): MoneyHelpers {
|
|
350
|
+
const dialectSql = DIALECTS[dialect];
|
|
351
|
+
if (dialectSql === undefined) {
|
|
352
|
+
throw new RangeError(`Unknown dialect "${String(dialect)}"; use postgres, mysql or sqlite`);
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
function currencyExpression(eb: AnyBuilder, currency: AnyCurrency): Expression<unknown> {
|
|
356
|
+
if (isFixed(currency)) {
|
|
357
|
+
return sql.lit(currency.fixed);
|
|
358
|
+
}
|
|
359
|
+
return eb.ref(currency as string);
|
|
360
|
+
}
|
|
361
|
+
|
|
362
|
+
function select(
|
|
363
|
+
storage: Storage,
|
|
364
|
+
eb: AnyBuilder,
|
|
365
|
+
amount: AnyReference,
|
|
366
|
+
currency: AnyCurrency,
|
|
367
|
+
): RawBuilder<unknown> {
|
|
368
|
+
const amountRef = eb.ref(amount as string);
|
|
369
|
+
const object = dialectSql.jsonObject([
|
|
370
|
+
[MARKER, sql.lit(markerFor(storage))],
|
|
371
|
+
['amount', dialectSql.asText(amountRef)],
|
|
372
|
+
['currency', currencyExpression(eb, currency)],
|
|
373
|
+
]);
|
|
374
|
+
// a NULL amount stays NULL rather than becoming a marker without an amount
|
|
375
|
+
return sql`case when ${amountRef} is null then null else ${object} end`;
|
|
376
|
+
}
|
|
377
|
+
|
|
378
|
+
function compare(
|
|
379
|
+
storage: Storage,
|
|
380
|
+
eb: AnyBuilder,
|
|
381
|
+
amount: AnyReference,
|
|
382
|
+
currency: AnyCurrency,
|
|
383
|
+
operator: MoneyComparison,
|
|
384
|
+
money: Money,
|
|
385
|
+
size: DecimalColumnSize = DEFAULT_DECIMAL_SIZE,
|
|
386
|
+
): Expression<SqlBool> {
|
|
387
|
+
if (!COMPARISONS.has(operator)) {
|
|
388
|
+
throw new RangeError(`Unsupported comparison "${operator}"`);
|
|
389
|
+
}
|
|
390
|
+
const isEquality = operator === '=' || operator === '<>';
|
|
391
|
+
if (storage === 'decimal' && !dialectSql.hasNumericDecimals && !isEquality) {
|
|
392
|
+
throw new RangeError(
|
|
393
|
+
'SQLite stores decimals as TEXT, which orders as text; compare minor units instead',
|
|
394
|
+
);
|
|
395
|
+
}
|
|
396
|
+
if (isFixed(currency)) {
|
|
397
|
+
checkCurrency(money, currency.fixed);
|
|
398
|
+
}
|
|
399
|
+
|
|
400
|
+
let value: Expression<unknown>;
|
|
401
|
+
if (storage === 'minor') {
|
|
402
|
+
value = dialectSql.minorParameter(minorToDriver(money, undefined));
|
|
403
|
+
} else {
|
|
404
|
+
value = dialectSql.decimalParameter(decimalForColumn(money, undefined, size));
|
|
405
|
+
}
|
|
406
|
+
const amountCondition = eb(eb.ref(amount as string), operator, value);
|
|
407
|
+
|
|
408
|
+
if (isFixed(currency)) {
|
|
409
|
+
return amountCondition;
|
|
410
|
+
}
|
|
411
|
+
const currencyRef = eb.ref(currency as string);
|
|
412
|
+
if (operator === '<>') {
|
|
413
|
+
return eb.or([eb(currencyRef, '<>', money.currency), amountCondition]);
|
|
414
|
+
}
|
|
415
|
+
return eb.and([eb(currencyRef, '=', money.currency), amountCondition]);
|
|
416
|
+
}
|
|
417
|
+
|
|
418
|
+
function sum(
|
|
419
|
+
storage: Storage,
|
|
420
|
+
eb: AnyBuilder,
|
|
421
|
+
amount: AnyReference,
|
|
422
|
+
currency: AnyCurrency,
|
|
423
|
+
): RawBuilder<unknown> {
|
|
424
|
+
if (storage === 'decimal' && !dialectSql.hasNumericDecimals) {
|
|
425
|
+
throw new RangeError("SQLite's sum() over TEXT decimals is a REAL; sum minor units instead");
|
|
426
|
+
}
|
|
427
|
+
const amountRef = eb.ref(amount as string);
|
|
428
|
+
const fields: [string, Expression<unknown>][] = [
|
|
429
|
+
[MARKER, sql.lit(markerFor(storage))],
|
|
430
|
+
['amount', dialectSql.asText(sql`sum(${amountRef})`)],
|
|
431
|
+
];
|
|
432
|
+
|
|
433
|
+
if (isFixed(currency)) {
|
|
434
|
+
fields.push(['currency', sql.lit(currency.fixed)]);
|
|
435
|
+
} else {
|
|
436
|
+
const currencyRef = eb.ref(currency as string);
|
|
437
|
+
// only rows with an amount count: a row with a NULL amount adds nothing to the sum
|
|
438
|
+
const currencyOfAmount = sql`case when ${amountRef} is not null then ${currencyRef} end`;
|
|
439
|
+
fields.push(['currency', sql`min(${currencyOfAmount})`]);
|
|
440
|
+
// more than one currency, or an amount without one: the reader throws
|
|
441
|
+
const withoutCurrency = sql`count(case when ${amountRef} is not null and ${currencyRef} is null then 1 end)`;
|
|
442
|
+
const isMixed = sql`${withoutCurrency} > 0 or count(distinct ${currencyOfAmount}) > 1`;
|
|
443
|
+
fields.push(['mixed', sql`case when ${isMixed} then 1 else 0 end`]);
|
|
444
|
+
}
|
|
445
|
+
const object = dialectSql.jsonObject(fields);
|
|
446
|
+
return sql`case when count(${amountRef}) = 0 then null else ${object} end`;
|
|
447
|
+
}
|
|
448
|
+
|
|
449
|
+
return {
|
|
450
|
+
selectMoney: ((eb: AnyBuilder, amount: AnyReference, currency: AnyCurrency) =>
|
|
451
|
+
select('minor', eb, amount, currency)) as MoneyHelpers['selectMoney'],
|
|
452
|
+
selectDecimalMoney: ((eb: AnyBuilder, amount: AnyReference, currency: AnyCurrency) =>
|
|
453
|
+
select('decimal', eb, amount, currency)) as MoneyHelpers['selectDecimalMoney'],
|
|
454
|
+
compareMoney: (eb, amount, currency, operator, money) =>
|
|
455
|
+
compare('minor', eb as AnyBuilder, amount, currency, operator, money),
|
|
456
|
+
compareDecimalMoney: (eb, amount, currency, operator, money, size) =>
|
|
457
|
+
compare('decimal', eb as AnyBuilder, amount, currency, operator, money, size),
|
|
458
|
+
sumMoney: ((eb: AnyBuilder, amount: AnyReference, currency: AnyCurrency) =>
|
|
459
|
+
sum('minor', eb, amount, currency)) as MoneyHelpers['sumMoney'],
|
|
460
|
+
sumDecimalMoney: ((eb: AnyBuilder, amount: AnyReference, currency: AnyCurrency) =>
|
|
461
|
+
sum('decimal', eb, amount, currency)) as MoneyHelpers['sumDecimalMoney'],
|
|
462
|
+
};
|
|
463
|
+
}
|
|
464
|
+
|
|
465
|
+
// ---------------------------------------------------------------------------------------------
|
|
466
|
+
// The result plugin
|
|
467
|
+
// ---------------------------------------------------------------------------------------------
|
|
468
|
+
|
|
469
|
+
const SELECT_KEYS = ['$ducat', 'amount', 'currency'];
|
|
470
|
+
const SUM_KEYS = ['$ducat', 'amount', 'currency', 'mixed'];
|
|
471
|
+
|
|
472
|
+
function hasExactly(value: Record<string, unknown>, keys: readonly string[]): boolean {
|
|
473
|
+
const own = Object.keys(value);
|
|
474
|
+
return own.length === keys.length && keys.every((key) => Object.hasOwn(value, key));
|
|
475
|
+
}
|
|
476
|
+
|
|
477
|
+
/**
|
|
478
|
+
* Whether the object is a marker built by this module: exactly the expected keys, with this
|
|
479
|
+
* process's nonce. Anything else, however similar, is ordinary data.
|
|
480
|
+
*/
|
|
481
|
+
function isMarker(value: Record<string, unknown>): boolean {
|
|
482
|
+
const marker = value[MARKER];
|
|
483
|
+
if (marker !== MINOR_MARKER && marker !== DECIMAL_MARKER) {
|
|
484
|
+
return false;
|
|
485
|
+
}
|
|
486
|
+
return hasExactly(value, SELECT_KEYS) || hasExactly(value, SUM_KEYS);
|
|
487
|
+
}
|
|
488
|
+
|
|
489
|
+
/** Money from one marker object, validating every field. */
|
|
490
|
+
function moneyFromMarker(marker: Record<string, unknown>): Money {
|
|
491
|
+
const { amount, currency, mixed } = marker;
|
|
492
|
+
|
|
493
|
+
if (mixed === 1 || mixed === true) {
|
|
494
|
+
throw new CurrencyMismatchError('one currency', 'several, or an amount without a currency');
|
|
495
|
+
}
|
|
496
|
+
if (typeof currency !== 'string') {
|
|
497
|
+
throw new TypeError(`A money value needs a currency, got ${String(currency)}`);
|
|
498
|
+
}
|
|
499
|
+
if (marker[MARKER] === MINOR_MARKER) {
|
|
500
|
+
return minorFromDriver(amount, currency);
|
|
501
|
+
}
|
|
502
|
+
const parsed = decimalFromDriver(amount, currency);
|
|
503
|
+
checkIsoScale(parsed);
|
|
504
|
+
return parsed;
|
|
505
|
+
}
|
|
506
|
+
|
|
507
|
+
function isPlainObject(value: unknown): value is Record<string, unknown> {
|
|
508
|
+
if (typeof value !== 'object' || value === null) {
|
|
509
|
+
return false;
|
|
510
|
+
}
|
|
511
|
+
const prototype = Object.getPrototypeOf(value);
|
|
512
|
+
return prototype === Object.prototype || prototype === null;
|
|
513
|
+
}
|
|
514
|
+
|
|
515
|
+
/** A marker returned as text (SQLite's json_object, MySQL's JSON as a string), or undefined. */
|
|
516
|
+
function markerFromText(text: string): Record<string, unknown> | undefined {
|
|
517
|
+
// cheap test first: only text carrying this process's nonce can be a marker
|
|
518
|
+
if (!text.includes(NONCE)) {
|
|
519
|
+
return undefined;
|
|
520
|
+
}
|
|
521
|
+
let parsed: unknown;
|
|
522
|
+
try {
|
|
523
|
+
parsed = JSON.parse(text);
|
|
524
|
+
} catch {
|
|
525
|
+
return undefined;
|
|
526
|
+
}
|
|
527
|
+
return isPlainObject(parsed) && isMarker(parsed) ? parsed : undefined;
|
|
528
|
+
}
|
|
529
|
+
|
|
530
|
+
/** The value with every money marker in it replaced by Money; other values are left as they are. */
|
|
531
|
+
function convertMarkers(value: unknown, depth: number): unknown {
|
|
532
|
+
if (depth > MAX_DEPTH) {
|
|
533
|
+
return value;
|
|
534
|
+
}
|
|
535
|
+
if (typeof value === 'string') {
|
|
536
|
+
const marker = markerFromText(value);
|
|
537
|
+
return marker === undefined ? value : moneyFromMarker(marker);
|
|
538
|
+
}
|
|
539
|
+
if (Array.isArray(value)) {
|
|
540
|
+
let changed: unknown[] | undefined;
|
|
541
|
+
for (let i = 0; i < value.length; i++) {
|
|
542
|
+
const converted = convertMarkers(value[i], depth + 1);
|
|
543
|
+
if (converted !== value[i]) {
|
|
544
|
+
changed ??= value.slice();
|
|
545
|
+
changed[i] = converted;
|
|
546
|
+
}
|
|
547
|
+
}
|
|
548
|
+
return changed ?? value;
|
|
549
|
+
}
|
|
550
|
+
if (!isPlainObject(value)) {
|
|
551
|
+
return value;
|
|
552
|
+
}
|
|
553
|
+
if (isMarker(value)) {
|
|
554
|
+
return moneyFromMarker(value);
|
|
555
|
+
}
|
|
556
|
+
let changed: Record<string, unknown> | undefined;
|
|
557
|
+
for (const key of Object.keys(value)) {
|
|
558
|
+
const converted = convertMarkers(value[key], depth + 1);
|
|
559
|
+
if (converted !== value[key]) {
|
|
560
|
+
// fromEntries defines own properties, so a "__proto__" key from JSON.parse stays a key
|
|
561
|
+
changed ??= Object.fromEntries(Object.entries(value));
|
|
562
|
+
changed[key] = converted;
|
|
563
|
+
}
|
|
564
|
+
}
|
|
565
|
+
return changed ?? value;
|
|
566
|
+
}
|
|
567
|
+
|
|
568
|
+
/**
|
|
569
|
+
* A Kysely plugin that turns the values built by `moneyHelpers` into Money, in every row and in
|
|
570
|
+
* JSON nested inside rows. It keeps no state between queries and doesn't touch the query, so it
|
|
571
|
+
* works with streams, prepared and repeated queries, and other plugins. For nested JSON that
|
|
572
|
+
* SQLite returns as text (`jsonArrayFrom`), put `ParseJSONResultsPlugin` before it.
|
|
573
|
+
*/
|
|
574
|
+
export class MoneyResultPlugin implements KyselyPlugin {
|
|
575
|
+
/** Leaves the query as it is. */
|
|
576
|
+
transformQuery(args: PluginTransformQueryArgs): RootOperationNode {
|
|
577
|
+
return args.node;
|
|
578
|
+
}
|
|
579
|
+
|
|
580
|
+
/** Converts the money values in every row. */
|
|
581
|
+
async transformResult(args: PluginTransformResultArgs): Promise<QueryResult<UnknownRow>> {
|
|
582
|
+
const rows = convertMarkers(args.result.rows, 0) as UnknownRow[];
|
|
583
|
+
if (rows === args.result.rows) {
|
|
584
|
+
return args.result;
|
|
585
|
+
}
|
|
586
|
+
return { ...args.result, rows };
|
|
587
|
+
}
|
|
588
|
+
}
|