@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.
Files changed (45) hide show
  1. package/CHANGELOG.md +6 -0
  2. package/README.md +77 -50
  3. package/dist/cli.js +691 -571
  4. package/package.json +1 -1
  5. package/schemas/module/command.yml +1 -0
  6. package/schemas/module/model.yml +9 -0
  7. package/schemas/module/query.yml +53 -0
  8. package/src/cli.ts +6 -88
  9. package/src/commands/app/index.ts +74 -0
  10. package/src/commands/check.test.ts +2 -1
  11. package/src/commands/check.ts +1 -0
  12. package/src/commands/module/index.ts +85 -0
  13. package/src/commands/module/list.test.ts +62 -0
  14. package/src/commands/module/list.ts +64 -0
  15. package/src/commands/scaffold.test.ts +5 -0
  16. package/src/commands/scaffold.ts +2 -1
  17. package/src/commands/sync-check.test.ts +28 -0
  18. package/src/commands/sync-check.ts +6 -0
  19. package/src/integration.test.ts +6 -8
  20. package/src/module.ts +4 -3
  21. package/src/modules/primitives/docs/models/Currency.md +4 -0
  22. package/src/modules/primitives/docs/models/ExchangeRate.md +4 -1
  23. package/src/modules/primitives/docs/models/Unit.md +4 -1
  24. package/src/modules/primitives/docs/models/UoMCategory.md +2 -0
  25. package/src/modules/primitives/index.ts +2 -2
  26. package/src/modules/primitives/module.ts +5 -3
  27. package/src/modules/primitives/permissions.ts +0 -2
  28. package/src/modules/primitives/{command → query}/convertAmount.test.ts +2 -19
  29. package/src/modules/primitives/query/convertAmount.ts +122 -0
  30. package/src/modules/primitives/{command → query}/convertQuantity.test.ts +2 -13
  31. package/src/modules/primitives/{command → query}/convertQuantity.ts +4 -6
  32. package/src/modules/shared/defineQuery.test.ts +28 -0
  33. package/src/modules/shared/defineQuery.ts +16 -0
  34. package/src/modules/shared/internal.ts +2 -1
  35. package/src/modules/shared/types.ts +8 -0
  36. package/src/modules/user-management/docs/models/AuditEvent.md +2 -0
  37. package/src/modules/user-management/docs/models/Permission.md +2 -0
  38. package/src/modules/user-management/docs/models/Role.md +2 -0
  39. package/src/modules/user-management/docs/models/RolePermission.md +2 -0
  40. package/src/modules/user-management/docs/models/User.md +2 -0
  41. package/src/modules/user-management/docs/models/UserRole.md +2 -0
  42. package/src/schemas.ts +1 -0
  43. package/src/modules/primitives/command/convertAmount.ts +0 -126
  44. /package/src/modules/primitives/docs/{commands → queries}/ConvertAmount.md +0 -0
  45. /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
- - [convertAmount](../commands/ConvertAmount.md)
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
- - [convertQuantity](../commands/ConvertQuantity.md)
30
+
31
+ ### Query Definitions
32
+
33
+ - [convertQuantity](../queries/ConvertQuantity.md)
31
34
 
32
35
  ### Models
33
36
 
@@ -28,6 +28,8 @@ stateDiagram-v2
28
28
  - [deactivateCategory](../commands/DeactivateCategory.md)
29
29
  - [setReferenceUnit](../commands/SetReferenceUnit.md)
30
30
 
31
+ ### Query Definitions
32
+
31
33
  ### Models
32
34
 
33
35
  - UoMCategory
@@ -28,13 +28,13 @@ export {
28
28
  } from "./lib/errors";
29
29
 
30
30
  // input types
31
- export { type ConvertQuantityInput } from "./command/convertQuantity";
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 "./command/convertAmount";
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 "./command/convertQuantity";
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 "./command/convertAmount";
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: CommandContext = { actorId: "test-actor", permissions: ["primitives:convertAmount"] };
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: CommandContext = {
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 { defineCommand } from "../../shared/internal";
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 = defineCommand(
21
- permissions.convertQuantity,
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">;
@@ -16,6 +16,8 @@ AppendOnly
16
16
 
17
17
  - [logAuditEvent](../commands/LogAuditEvent.md)
18
18
 
19
+ ### Query Definitions
20
+
19
21
  ### Models
20
22
 
21
23
  - AuditEvent
@@ -16,6 +16,8 @@ Standard
16
16
 
17
17
  - [createPermission](../commands/CreatePermission.md)
18
18
 
19
+ ### Query Definitions
20
+
19
21
  ### Models
20
22
 
21
23
  - Permission
@@ -16,6 +16,8 @@ Standard
16
16
 
17
17
  - [createRole](../commands/CreateRole.md)
18
18
 
19
+ ### Query Definitions
20
+
19
21
  ### Models
20
22
 
21
23
  - Role
@@ -17,6 +17,8 @@ Standard
17
17
  - [assignPermissionToRole](../commands/AssignPermissionToRole.md)
18
18
  - [revokePermissionFromRole](../commands/RevokePermissionFromRole.md)
19
19
 
20
+ ### Query Definitions
21
+
20
22
  ### Models
21
23
 
22
24
  - RolePermission
@@ -29,6 +29,8 @@ stateDiagram-v2
29
29
  - [deactivateUser](../commands/DeactivateUser.md)
30
30
  - [reactivateUser](../commands/ReactivateUser.md)
31
31
 
32
+ ### Query Definitions
33
+
32
34
  ### Models
33
35
 
34
36
  - User
@@ -17,6 +17,8 @@ Standard
17
17
  - [assignRoleToUser](../commands/AssignRoleToUser.md)
18
18
  - [revokeRoleFromUser](../commands/RevokeRoleFromUser.md)
19
19
 
20
+ ### Query Definitions
21
+
20
22
  ### Models
21
23
 
22
24
  - UserRole
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
- );