@tailor-platform/erp-kit 0.1.1 → 0.1.2
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/CHANGELOG.md +6 -0
- package/README.md +77 -50
- package/dist/cli.js +691 -571
- package/package.json +1 -1
- package/schemas/module/command.yml +1 -0
- package/schemas/module/model.yml +9 -0
- package/schemas/module/query.yml +53 -0
- package/src/cli.ts +6 -88
- package/src/commands/app/index.ts +74 -0
- package/src/commands/check.test.ts +2 -1
- package/src/commands/check.ts +1 -0
- package/src/commands/module/index.ts +85 -0
- package/src/commands/module/list.test.ts +62 -0
- package/src/commands/module/list.ts +64 -0
- package/src/commands/scaffold.test.ts +5 -0
- package/src/commands/scaffold.ts +2 -1
- package/src/commands/sync-check.test.ts +28 -0
- package/src/commands/sync-check.ts +6 -0
- package/src/integration.test.ts +6 -8
- package/src/module.ts +4 -3
- package/src/modules/primitives/docs/models/Currency.md +4 -0
- package/src/modules/primitives/docs/models/ExchangeRate.md +4 -1
- package/src/modules/primitives/docs/models/Unit.md +4 -1
- package/src/modules/primitives/docs/models/UoMCategory.md +2 -0
- package/src/modules/primitives/index.ts +2 -2
- package/src/modules/primitives/module.ts +5 -3
- package/src/modules/primitives/permissions.ts +0 -2
- package/src/modules/primitives/{command → query}/convertAmount.test.ts +2 -19
- package/src/modules/primitives/query/convertAmount.ts +122 -0
- package/src/modules/primitives/{command → query}/convertQuantity.test.ts +2 -13
- package/src/modules/primitives/{command → query}/convertQuantity.ts +4 -6
- package/src/modules/shared/defineQuery.test.ts +28 -0
- package/src/modules/shared/defineQuery.ts +16 -0
- package/src/modules/shared/internal.ts +2 -1
- package/src/modules/shared/types.ts +8 -0
- package/src/modules/user-management/docs/models/AuditEvent.md +2 -0
- package/src/modules/user-management/docs/models/Permission.md +2 -0
- package/src/modules/user-management/docs/models/Role.md +2 -0
- package/src/modules/user-management/docs/models/RolePermission.md +2 -0
- package/src/modules/user-management/docs/models/User.md +2 -0
- package/src/modules/user-management/docs/models/UserRole.md +2 -0
- package/src/schemas.ts +1 -0
- package/src/modules/primitives/command/convertAmount.ts +0 -126
- /package/src/modules/primitives/docs/{commands → queries}/ConvertAmount.md +0 -0
- /package/src/modules/primitives/docs/{commands → queries}/ConvertQuantity.md +0 -0
|
@@ -15,7 +15,10 @@ AppendOnly
|
|
|
15
15
|
### Command Definitions
|
|
16
16
|
|
|
17
17
|
- [createExchangeRate](../commands/CreateExchangeRate.md)
|
|
18
|
-
|
|
18
|
+
|
|
19
|
+
### Query Definitions
|
|
20
|
+
|
|
21
|
+
- [convertAmount](../queries/ConvertAmount.md)
|
|
19
22
|
|
|
20
23
|
### Models
|
|
21
24
|
|
|
@@ -27,7 +27,10 @@ stateDiagram-v2
|
|
|
27
27
|
- [createUnit](../commands/CreateUnit.md)
|
|
28
28
|
- [activateUnit](../commands/ActivateUnit.md)
|
|
29
29
|
- [deactivateUnit](../commands/DeactivateUnit.md)
|
|
30
|
-
|
|
30
|
+
|
|
31
|
+
### Query Definitions
|
|
32
|
+
|
|
33
|
+
- [convertQuantity](../queries/ConvertQuantity.md)
|
|
31
34
|
|
|
32
35
|
### Models
|
|
33
36
|
|
|
@@ -28,13 +28,13 @@ export {
|
|
|
28
28
|
} from "./lib/errors";
|
|
29
29
|
|
|
30
30
|
// input types
|
|
31
|
-
export { type ConvertQuantityInput } from "./
|
|
31
|
+
export { type ConvertQuantityInput } from "./query/convertQuantity";
|
|
32
32
|
export { type ActivateCategoryInput } from "./command/activateCategory";
|
|
33
33
|
export { type DeactivateCategoryInput } from "./command/deactivateCategory";
|
|
34
34
|
export { type SetReferenceUnitInput } from "./command/setReferenceUnit";
|
|
35
35
|
export { type ActivateUnitInput } from "./command/activateUnit";
|
|
36
36
|
export { type DeactivateUnitInput } from "./command/deactivateUnit";
|
|
37
|
-
export { type ConvertAmountInput } from "./
|
|
37
|
+
export { type ConvertAmountInput } from "./query/convertAmount";
|
|
38
38
|
export { type ActivateCurrencyInput } from "./command/activateCurrency";
|
|
39
39
|
export { type DeactivateCurrencyInput } from "./command/deactivateCurrency";
|
|
40
40
|
export { type SetBaseCurrencyInput } from "./command/setBaseCurrency";
|
|
@@ -9,11 +9,11 @@ import { deactivateCategory } from "./command/deactivateCategory";
|
|
|
9
9
|
import { activateUnit } from "./command/activateUnit";
|
|
10
10
|
import { deactivateUnit } from "./command/deactivateUnit";
|
|
11
11
|
import { setReferenceUnit } from "./command/setReferenceUnit";
|
|
12
|
-
import { convertQuantity } from "./
|
|
12
|
+
import { convertQuantity } from "./query/convertQuantity";
|
|
13
13
|
import { activateCurrency } from "./command/activateCurrency";
|
|
14
14
|
import { deactivateCurrency } from "./command/deactivateCurrency";
|
|
15
15
|
import { setBaseCurrency } from "./command/setBaseCurrency";
|
|
16
|
-
import { convertAmount } from "./
|
|
16
|
+
import { convertAmount } from "./query/convertAmount";
|
|
17
17
|
import { createUoMCategoryType, CreateUoMCategoryTypeParams } from "./db/uomCategory";
|
|
18
18
|
import { createUnitType, CreateUnitTypeParams } from "./db/unit";
|
|
19
19
|
import { createCurrencyType, CreateCurrencyTypeParams } from "./db/currency";
|
|
@@ -56,10 +56,12 @@ export const defineModule = <
|
|
|
56
56
|
activateUnit,
|
|
57
57
|
deactivateUnit,
|
|
58
58
|
setReferenceUnit,
|
|
59
|
-
convertQuantity,
|
|
60
59
|
activateCurrency,
|
|
61
60
|
deactivateCurrency,
|
|
62
61
|
setBaseCurrency,
|
|
62
|
+
},
|
|
63
|
+
queries: {
|
|
64
|
+
convertQuantity,
|
|
63
65
|
convertAmount,
|
|
64
66
|
},
|
|
65
67
|
};
|
|
@@ -1,7 +1,6 @@
|
|
|
1
1
|
import { definePermissions } from "../shared/internal";
|
|
2
2
|
|
|
3
3
|
export const { permissions, own, all } = definePermissions("primitives", [
|
|
4
|
-
"convertQuantity",
|
|
5
4
|
"createCategory",
|
|
6
5
|
"activateCategory",
|
|
7
6
|
"deactivateCategory",
|
|
@@ -9,7 +8,6 @@ export const { permissions, own, all } = definePermissions("primitives", [
|
|
|
9
8
|
"createUnit",
|
|
10
9
|
"activateUnit",
|
|
11
10
|
"deactivateUnit",
|
|
12
|
-
"convertAmount",
|
|
13
11
|
"createCurrency",
|
|
14
12
|
"activateCurrency",
|
|
15
13
|
"deactivateCurrency",
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { describe, expect, it } from "vitest";
|
|
2
|
-
import { InsufficientPermissionError, type CommandContext } from "../../shared/internal";
|
|
3
2
|
import { createMockDb } from "../../testing/index";
|
|
4
3
|
import { DB } from "../generated/kysely-tailordb";
|
|
4
|
+
import type { QueryContext } from "../../shared/internal";
|
|
5
5
|
import {
|
|
6
6
|
CurrencyNotFoundError,
|
|
7
7
|
ExchangeRateNotFoundError,
|
|
@@ -19,7 +19,7 @@ import {
|
|
|
19
19
|
import { convertAmount } from "./convertAmount";
|
|
20
20
|
|
|
21
21
|
describe("convertAmount", () => {
|
|
22
|
-
const ctx:
|
|
22
|
+
const ctx: QueryContext = { actorId: "test-actor" };
|
|
23
23
|
|
|
24
24
|
// Error cases first
|
|
25
25
|
it("throws when source currency doesn't exist", async () => {
|
|
@@ -255,21 +255,4 @@ describe("convertAmount", () => {
|
|
|
255
255
|
|
|
256
256
|
expect(result.convertedAmount).toBe(0);
|
|
257
257
|
});
|
|
258
|
-
|
|
259
|
-
it("throws when permission is missing", async () => {
|
|
260
|
-
const { db } = createMockDb<DB>();
|
|
261
|
-
const denied: CommandContext = { actorId: "test-actor", permissions: [] };
|
|
262
|
-
await expect(
|
|
263
|
-
convertAmount(
|
|
264
|
-
db,
|
|
265
|
-
{
|
|
266
|
-
amount: 100,
|
|
267
|
-
sourceCurrencyCode: "USD",
|
|
268
|
-
targetCurrencyCode: "EUR",
|
|
269
|
-
conversionDate: "2024-01-15",
|
|
270
|
-
},
|
|
271
|
-
denied,
|
|
272
|
-
),
|
|
273
|
-
).rejects.toBeInstanceOf(InsufficientPermissionError);
|
|
274
|
-
});
|
|
275
258
|
});
|
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
import { defineQuery, type ReadonlyDB } from "../../shared/internal";
|
|
2
|
+
import type { DB } from "../generated/kysely-tailordb";
|
|
3
|
+
import {
|
|
4
|
+
CurrencyNotFoundError,
|
|
5
|
+
ExchangeRateNotFoundError,
|
|
6
|
+
InactiveCurrencyError,
|
|
7
|
+
} from "../lib/errors";
|
|
8
|
+
|
|
9
|
+
export interface ConvertAmountInput {
|
|
10
|
+
amount: number;
|
|
11
|
+
sourceCurrencyCode: string;
|
|
12
|
+
targetCurrencyCode: string;
|
|
13
|
+
conversionDate: string;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* Function: convertAmount
|
|
18
|
+
*
|
|
19
|
+
* Converts a monetary amount from one currency to another using the applicable
|
|
20
|
+
* exchange rate for a given date. Currencies are identified by their ISO 4217
|
|
21
|
+
* code (e.g., "USD", "EUR", "JPY"). The function looks up the most recent exchange
|
|
22
|
+
* rate on or before the specified date. If no direct rate exists, it calculates
|
|
23
|
+
* the inverse rate. Result is rounded to the target currency's decimal precision.
|
|
24
|
+
*/
|
|
25
|
+
export const convertAmount = defineQuery(async (db: ReadonlyDB<DB>, input: ConvertAmountInput) => {
|
|
26
|
+
// 1. Validate source currency exists
|
|
27
|
+
const sourceCurrency = await db
|
|
28
|
+
.selectFrom("Currency")
|
|
29
|
+
.selectAll()
|
|
30
|
+
.where("code", "=", input.sourceCurrencyCode)
|
|
31
|
+
.executeTakeFirst();
|
|
32
|
+
|
|
33
|
+
if (!sourceCurrency) {
|
|
34
|
+
throw new CurrencyNotFoundError(input.sourceCurrencyCode);
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
// 2. Validate target currency exists
|
|
38
|
+
const targetCurrency = await db
|
|
39
|
+
.selectFrom("Currency")
|
|
40
|
+
.selectAll()
|
|
41
|
+
.where("code", "=", input.targetCurrencyCode)
|
|
42
|
+
.executeTakeFirst();
|
|
43
|
+
|
|
44
|
+
if (!targetCurrency) {
|
|
45
|
+
throw new CurrencyNotFoundError(input.targetCurrencyCode);
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
// 3. Validate both currencies are active
|
|
49
|
+
if (!sourceCurrency.isActive) {
|
|
50
|
+
throw new InactiveCurrencyError(input.sourceCurrencyCode);
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
if (!targetCurrency.isActive) {
|
|
54
|
+
throw new InactiveCurrencyError(input.targetCurrencyCode);
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
// 4. Same currency - return original amount
|
|
58
|
+
if (sourceCurrency.id === targetCurrency.id) {
|
|
59
|
+
return {
|
|
60
|
+
convertedAmount: input.amount,
|
|
61
|
+
exchangeRate: 1,
|
|
62
|
+
sourceCurrency,
|
|
63
|
+
targetCurrency,
|
|
64
|
+
exchangeRateRecord: null,
|
|
65
|
+
};
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
// 5. Find direct exchange rate (most recent on or before conversion date)
|
|
69
|
+
const conversionDate = new Date(input.conversionDate);
|
|
70
|
+
const directRate = await db
|
|
71
|
+
.selectFrom("ExchangeRate")
|
|
72
|
+
.selectAll()
|
|
73
|
+
.where("sourceCurrencyId", "=", sourceCurrency.id)
|
|
74
|
+
.where("targetCurrencyId", "=", targetCurrency.id)
|
|
75
|
+
.where("effectiveDate", "<=", conversionDate)
|
|
76
|
+
.orderBy("effectiveDate", "desc")
|
|
77
|
+
.executeTakeFirst();
|
|
78
|
+
|
|
79
|
+
let exchangeRate: number;
|
|
80
|
+
let exchangeRateRecord = null;
|
|
81
|
+
|
|
82
|
+
if (directRate) {
|
|
83
|
+
exchangeRate = directRate.rate;
|
|
84
|
+
exchangeRateRecord = directRate;
|
|
85
|
+
} else {
|
|
86
|
+
// 6. Try inverse rate
|
|
87
|
+
const inverseRate = await db
|
|
88
|
+
.selectFrom("ExchangeRate")
|
|
89
|
+
.selectAll()
|
|
90
|
+
.where("sourceCurrencyId", "=", targetCurrency.id)
|
|
91
|
+
.where("targetCurrencyId", "=", sourceCurrency.id)
|
|
92
|
+
.where("effectiveDate", "<=", conversionDate)
|
|
93
|
+
.orderBy("effectiveDate", "desc")
|
|
94
|
+
.executeTakeFirst();
|
|
95
|
+
|
|
96
|
+
if (!inverseRate) {
|
|
97
|
+
throw new ExchangeRateNotFoundError(
|
|
98
|
+
input.sourceCurrencyCode,
|
|
99
|
+
input.targetCurrencyCode,
|
|
100
|
+
input.conversionDate,
|
|
101
|
+
);
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
exchangeRate = 1 / inverseRate.rate;
|
|
105
|
+
exchangeRateRecord = inverseRate;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
// 7. Calculate converted amount
|
|
109
|
+
const rawResult = input.amount * exchangeRate;
|
|
110
|
+
|
|
111
|
+
// 8. Round to target currency's decimal places
|
|
112
|
+
const roundingFactor = Math.pow(10, targetCurrency.decimalPlaces);
|
|
113
|
+
const convertedAmount = Math.round(rawResult * roundingFactor) / roundingFactor;
|
|
114
|
+
|
|
115
|
+
return {
|
|
116
|
+
convertedAmount,
|
|
117
|
+
exchangeRate,
|
|
118
|
+
sourceCurrency,
|
|
119
|
+
targetCurrency,
|
|
120
|
+
exchangeRateRecord,
|
|
121
|
+
};
|
|
122
|
+
});
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { describe, expect, it } from "vitest";
|
|
2
2
|
import { createMockDb } from "../../testing/index";
|
|
3
|
-
import { InsufficientPermissionError, type CommandContext } from "../../shared/internal";
|
|
4
3
|
import { DB } from "../generated/kysely-tailordb";
|
|
4
|
+
import type { QueryContext } from "../../shared/internal";
|
|
5
5
|
import { InactiveUnitError, IncompatibleUnitsError, UnitNotFoundError } from "../lib/errors";
|
|
6
6
|
import {
|
|
7
7
|
baseUnitGram,
|
|
@@ -13,10 +13,7 @@ import {
|
|
|
13
13
|
import { convertQuantity } from "./convertQuantity";
|
|
14
14
|
|
|
15
15
|
describe("convertQuantity", () => {
|
|
16
|
-
const ctx:
|
|
17
|
-
actorId: "test-actor",
|
|
18
|
-
permissions: ["primitives:convertQuantity"],
|
|
19
|
-
};
|
|
16
|
+
const ctx: QueryContext = { actorId: "test-actor" };
|
|
20
17
|
|
|
21
18
|
// Error cases first
|
|
22
19
|
it("throws when source unit doesn't exist", async () => {
|
|
@@ -208,12 +205,4 @@ describe("convertQuantity", () => {
|
|
|
208
205
|
|
|
209
206
|
expect(result.convertedQuantity).toBe(0);
|
|
210
207
|
});
|
|
211
|
-
|
|
212
|
-
it("throws when permission is missing", async () => {
|
|
213
|
-
const { db } = createMockDb<DB>();
|
|
214
|
-
const denied: CommandContext = { actorId: "test-actor", permissions: [] };
|
|
215
|
-
await expect(
|
|
216
|
-
convertQuantity(db, { quantity: 1, sourceUnitSymbol: "kg", targetUnitSymbol: "g" }, denied),
|
|
217
|
-
).rejects.toBeInstanceOf(InsufficientPermissionError);
|
|
218
|
-
});
|
|
219
208
|
});
|
|
@@ -1,7 +1,6 @@
|
|
|
1
|
-
import {
|
|
2
|
-
import { DB } from "../generated/kysely-tailordb";
|
|
1
|
+
import { defineQuery, type ReadonlyDB } from "../../shared/internal";
|
|
2
|
+
import type { DB } from "../generated/kysely-tailordb";
|
|
3
3
|
import { InactiveUnitError, IncompatibleUnitsError, UnitNotFoundError } from "../lib/errors";
|
|
4
|
-
import { permissions } from "../permissions";
|
|
5
4
|
|
|
6
5
|
export interface ConvertQuantityInput {
|
|
7
6
|
quantity: number;
|
|
@@ -17,9 +16,8 @@ export interface ConvertQuantityInput {
|
|
|
17
16
|
* The conversion uses each unit's conversion factor relative to the category's reference unit.
|
|
18
17
|
* Result is rounded to the target unit's precision setting.
|
|
19
18
|
*/
|
|
20
|
-
export const convertQuantity =
|
|
21
|
-
|
|
22
|
-
async (db: DB, input: ConvertQuantityInput) => {
|
|
19
|
+
export const convertQuantity = defineQuery(
|
|
20
|
+
async (db: ReadonlyDB<DB>, input: ConvertQuantityInput) => {
|
|
23
21
|
// 1. Validate source unit exists
|
|
24
22
|
const sourceUnit = await db
|
|
25
23
|
.selectFrom("Unit")
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
import { describe, expect, it, vi } from "vitest";
|
|
2
|
+
import { defineQuery } from "./defineQuery";
|
|
3
|
+
import type { QueryContext } from "./types";
|
|
4
|
+
|
|
5
|
+
describe("defineQuery", () => {
|
|
6
|
+
const ctx: QueryContext = { actorId: "user-1" };
|
|
7
|
+
|
|
8
|
+
it("calls impl with db, input, and ctx", async () => {
|
|
9
|
+
const impl = vi.fn().mockResolvedValue({ result: "ok" });
|
|
10
|
+
const query = defineQuery(impl);
|
|
11
|
+
|
|
12
|
+
const result = await query("fake-db", { foo: "bar" }, ctx);
|
|
13
|
+
|
|
14
|
+
expect(result).toEqual({ result: "ok" });
|
|
15
|
+
expect(impl).toHaveBeenCalledWith("fake-db", { foo: "bar" }, ctx);
|
|
16
|
+
});
|
|
17
|
+
|
|
18
|
+
it("passes ctx with actorId to impl", async () => {
|
|
19
|
+
const impl = vi.fn().mockResolvedValue({});
|
|
20
|
+
const query = defineQuery(impl);
|
|
21
|
+
|
|
22
|
+
await query("fake-db", { x: 1 }, ctx);
|
|
23
|
+
|
|
24
|
+
expect(impl).toHaveBeenCalledTimes(1);
|
|
25
|
+
expect(impl.mock.calls[0]).toHaveLength(3);
|
|
26
|
+
expect(impl.mock.calls[0][2]).toEqual({ actorId: "user-1" });
|
|
27
|
+
});
|
|
28
|
+
});
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
import type { QueryContext } from "./types";
|
|
2
|
+
|
|
3
|
+
export type Query<TInput, TResult> = (
|
|
4
|
+
db: unknown,
|
|
5
|
+
input: TInput,
|
|
6
|
+
ctx: QueryContext,
|
|
7
|
+
) => Promise<TResult>;
|
|
8
|
+
|
|
9
|
+
export function defineQuery<TInput, TResult>(
|
|
10
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
11
|
+
impl: (db: any, input: TInput, ctx: QueryContext) => Promise<TResult>,
|
|
12
|
+
): Query<TInput, TResult> {
|
|
13
|
+
return async (db, input, ctx) => {
|
|
14
|
+
return impl(db, input, ctx);
|
|
15
|
+
};
|
|
16
|
+
}
|
|
@@ -1,8 +1,9 @@
|
|
|
1
1
|
export { defineCommand, type Command } from "./defineCommand";
|
|
2
|
+
export { defineQuery, type Query } from "./defineQuery";
|
|
2
3
|
export { definePermissions } from "./definePermissions";
|
|
3
4
|
export { requirePermission } from "./requirePermission";
|
|
4
5
|
export { createDomainError, InsufficientPermissionError } from "./errors";
|
|
5
|
-
export type { CommandContext } from "./types";
|
|
6
|
+
export type { CommandContext, QueryContext, ReadonlyDB } from "./types";
|
|
6
7
|
export type {
|
|
7
8
|
InferSchema,
|
|
8
9
|
Selectable,
|
|
@@ -1,4 +1,12 @@
|
|
|
1
|
+
export interface QueryContext {
|
|
2
|
+
actorId: string;
|
|
3
|
+
}
|
|
4
|
+
|
|
1
5
|
export interface CommandContext {
|
|
2
6
|
actorId: string;
|
|
3
7
|
permissions: readonly string[];
|
|
4
8
|
}
|
|
9
|
+
|
|
10
|
+
// Strips write methods (updateTable, insertInto, deleteFrom) from a Kysely DB instance.
|
|
11
|
+
// Query handlers use this instead of the full DB type to get compile-time write prevention.
|
|
12
|
+
export type ReadonlyDB<T extends { selectFrom: unknown }> = Pick<T, "selectFrom">;
|
package/src/schemas.ts
CHANGED
|
@@ -8,6 +8,7 @@ export const MODULE_SCHEMAS = {
|
|
|
8
8
|
command: path.join(SCHEMAS_ROOT, "module", "command.yml"),
|
|
9
9
|
model: path.join(SCHEMAS_ROOT, "module", "model.yml"),
|
|
10
10
|
feature: path.join(SCHEMAS_ROOT, "module", "feature.yml"),
|
|
11
|
+
query: path.join(SCHEMAS_ROOT, "module", "query.yml"),
|
|
11
12
|
} as const;
|
|
12
13
|
|
|
13
14
|
export const APP_COMPOSE_SCHEMAS = {
|
|
@@ -1,126 +0,0 @@
|
|
|
1
|
-
import { defineCommand } from "../../shared/internal";
|
|
2
|
-
import { DB } from "../generated/kysely-tailordb";
|
|
3
|
-
import {
|
|
4
|
-
CurrencyNotFoundError,
|
|
5
|
-
ExchangeRateNotFoundError,
|
|
6
|
-
InactiveCurrencyError,
|
|
7
|
-
} from "../lib/errors";
|
|
8
|
-
import { permissions } from "../permissions";
|
|
9
|
-
|
|
10
|
-
export interface ConvertAmountInput {
|
|
11
|
-
amount: number;
|
|
12
|
-
sourceCurrencyCode: string;
|
|
13
|
-
targetCurrencyCode: string;
|
|
14
|
-
conversionDate: string;
|
|
15
|
-
}
|
|
16
|
-
|
|
17
|
-
/**
|
|
18
|
-
* Function: convertAmount
|
|
19
|
-
*
|
|
20
|
-
* Converts a monetary amount from one currency to another using the applicable
|
|
21
|
-
* exchange rate for a given date. Currencies are identified by their ISO 4217
|
|
22
|
-
* code (e.g., "USD", "EUR", "JPY"). The function looks up the most recent exchange
|
|
23
|
-
* rate on or before the specified date. If no direct rate exists, it calculates
|
|
24
|
-
* the inverse rate. Result is rounded to the target currency's decimal precision.
|
|
25
|
-
*/
|
|
26
|
-
export const convertAmount = defineCommand(
|
|
27
|
-
permissions.convertAmount,
|
|
28
|
-
async (db: DB, input: ConvertAmountInput) => {
|
|
29
|
-
// 1. Validate source currency exists
|
|
30
|
-
const sourceCurrency = await db
|
|
31
|
-
.selectFrom("Currency")
|
|
32
|
-
.selectAll()
|
|
33
|
-
.where("code", "=", input.sourceCurrencyCode)
|
|
34
|
-
.executeTakeFirst();
|
|
35
|
-
|
|
36
|
-
if (!sourceCurrency) {
|
|
37
|
-
throw new CurrencyNotFoundError(input.sourceCurrencyCode);
|
|
38
|
-
}
|
|
39
|
-
|
|
40
|
-
// 2. Validate target currency exists
|
|
41
|
-
const targetCurrency = await db
|
|
42
|
-
.selectFrom("Currency")
|
|
43
|
-
.selectAll()
|
|
44
|
-
.where("code", "=", input.targetCurrencyCode)
|
|
45
|
-
.executeTakeFirst();
|
|
46
|
-
|
|
47
|
-
if (!targetCurrency) {
|
|
48
|
-
throw new CurrencyNotFoundError(input.targetCurrencyCode);
|
|
49
|
-
}
|
|
50
|
-
|
|
51
|
-
// 3. Validate both currencies are active
|
|
52
|
-
if (!sourceCurrency.isActive) {
|
|
53
|
-
throw new InactiveCurrencyError(input.sourceCurrencyCode);
|
|
54
|
-
}
|
|
55
|
-
|
|
56
|
-
if (!targetCurrency.isActive) {
|
|
57
|
-
throw new InactiveCurrencyError(input.targetCurrencyCode);
|
|
58
|
-
}
|
|
59
|
-
|
|
60
|
-
// 4. Same currency - return original amount
|
|
61
|
-
if (sourceCurrency.id === targetCurrency.id) {
|
|
62
|
-
return {
|
|
63
|
-
convertedAmount: input.amount,
|
|
64
|
-
exchangeRate: 1,
|
|
65
|
-
sourceCurrency,
|
|
66
|
-
targetCurrency,
|
|
67
|
-
exchangeRateRecord: null,
|
|
68
|
-
};
|
|
69
|
-
}
|
|
70
|
-
|
|
71
|
-
// 5. Find direct exchange rate (most recent on or before conversion date)
|
|
72
|
-
const conversionDate = new Date(input.conversionDate);
|
|
73
|
-
const directRate = await db
|
|
74
|
-
.selectFrom("ExchangeRate")
|
|
75
|
-
.selectAll()
|
|
76
|
-
.where("sourceCurrencyId", "=", sourceCurrency.id)
|
|
77
|
-
.where("targetCurrencyId", "=", targetCurrency.id)
|
|
78
|
-
.where("effectiveDate", "<=", conversionDate)
|
|
79
|
-
.orderBy("effectiveDate", "desc")
|
|
80
|
-
.executeTakeFirst();
|
|
81
|
-
|
|
82
|
-
let exchangeRate: number;
|
|
83
|
-
let exchangeRateRecord = null;
|
|
84
|
-
|
|
85
|
-
if (directRate) {
|
|
86
|
-
exchangeRate = directRate.rate;
|
|
87
|
-
exchangeRateRecord = directRate;
|
|
88
|
-
} else {
|
|
89
|
-
// 6. Try inverse rate
|
|
90
|
-
const inverseRate = await db
|
|
91
|
-
.selectFrom("ExchangeRate")
|
|
92
|
-
.selectAll()
|
|
93
|
-
.where("sourceCurrencyId", "=", targetCurrency.id)
|
|
94
|
-
.where("targetCurrencyId", "=", sourceCurrency.id)
|
|
95
|
-
.where("effectiveDate", "<=", conversionDate)
|
|
96
|
-
.orderBy("effectiveDate", "desc")
|
|
97
|
-
.executeTakeFirst();
|
|
98
|
-
|
|
99
|
-
if (!inverseRate) {
|
|
100
|
-
throw new ExchangeRateNotFoundError(
|
|
101
|
-
input.sourceCurrencyCode,
|
|
102
|
-
input.targetCurrencyCode,
|
|
103
|
-
input.conversionDate,
|
|
104
|
-
);
|
|
105
|
-
}
|
|
106
|
-
|
|
107
|
-
exchangeRate = 1 / inverseRate.rate;
|
|
108
|
-
exchangeRateRecord = inverseRate;
|
|
109
|
-
}
|
|
110
|
-
|
|
111
|
-
// 7. Calculate converted amount
|
|
112
|
-
const rawResult = input.amount * exchangeRate;
|
|
113
|
-
|
|
114
|
-
// 8. Round to target currency's decimal places
|
|
115
|
-
const roundingFactor = Math.pow(10, targetCurrency.decimalPlaces);
|
|
116
|
-
const convertedAmount = Math.round(rawResult * roundingFactor) / roundingFactor;
|
|
117
|
-
|
|
118
|
-
return {
|
|
119
|
-
convertedAmount,
|
|
120
|
-
exchangeRate,
|
|
121
|
-
sourceCurrency,
|
|
122
|
-
targetCurrency,
|
|
123
|
-
exchangeRateRecord,
|
|
124
|
-
};
|
|
125
|
-
},
|
|
126
|
-
);
|
|
File without changes
|
|
File without changes
|