@tailor-platform/erp-kit 0.0.1 → 0.1.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +7 -0
- package/LICENSE +21 -0
- package/README.md +196 -28
- package/dist/cli.js +914 -0
- package/package.json +67 -8
- package/schemas/app-compose/actors.yml +34 -0
- package/schemas/app-compose/business-flow.yml +50 -0
- package/schemas/app-compose/requirements.yml +33 -0
- package/schemas/app-compose/resolver.yml +47 -0
- package/schemas/app-compose/screen.yml +81 -0
- package/schemas/app-compose/story.yml +67 -0
- package/schemas/module/command.yml +52 -0
- package/schemas/module/feature.yml +58 -0
- package/schemas/module/model.yml +70 -0
- package/schemas/module/module.yml +50 -0
- package/skills/1-module-docs/SKILL.md +111 -0
- package/skills/1-module-docs/references/structure.md +22 -0
- package/skills/2-module-feature-breakdown/SKILL.md +72 -0
- package/skills/2-module-feature-breakdown/references/commands.md +48 -0
- package/skills/2-module-feature-breakdown/references/models.md +29 -0
- package/skills/2-module-feature-breakdown/references/structure.md +22 -0
- package/skills/3-module-doc-review/SKILL.md +236 -0
- package/skills/3-module-doc-review/references/commands.md +54 -0
- package/skills/3-module-doc-review/references/models.md +29 -0
- package/skills/3-module-doc-review/references/testing.md +37 -0
- package/skills/4-module-tdd-implementation/SKILL.md +74 -0
- package/skills/4-module-tdd-implementation/references/commands.md +45 -0
- package/skills/4-module-tdd-implementation/references/db-relations.md +69 -0
- package/skills/4-module-tdd-implementation/references/errors.md +7 -0
- package/skills/4-module-tdd-implementation/references/exports.md +8 -0
- package/skills/4-module-tdd-implementation/references/models.md +30 -0
- package/skills/4-module-tdd-implementation/references/structure.md +22 -0
- package/skills/4-module-tdd-implementation/references/testing.md +37 -0
- package/skills/5-module-implementation-review/SKILL.md +408 -0
- package/skills/5-module-implementation-review/references/commands.md +45 -0
- package/skills/5-module-implementation-review/references/errors.md +7 -0
- package/skills/5-module-implementation-review/references/exports.md +8 -0
- package/skills/5-module-implementation-review/references/models.md +30 -0
- package/skills/5-module-implementation-review/references/testing.md +29 -0
- package/skills/app-compose-1-requirement-analysis/SKILL.md +89 -0
- package/skills/app-compose-1-requirement-analysis/references/structure.md +27 -0
- package/skills/app-compose-2-requirements-breakdown/SKILL.md +95 -0
- package/skills/app-compose-2-requirements-breakdown/references/screen-detailview.md +106 -0
- package/skills/app-compose-2-requirements-breakdown/references/screen-form.md +139 -0
- package/skills/app-compose-2-requirements-breakdown/references/screen-listview.md +153 -0
- package/skills/app-compose-2-requirements-breakdown/references/structure.md +27 -0
- package/skills/app-compose-3-doc-review/SKILL.md +116 -0
- package/skills/app-compose-3-doc-review/references/structure.md +27 -0
- package/skills/app-compose-4-design-mock/SKILL.md +256 -0
- package/skills/app-compose-4-design-mock/references/component.md +50 -0
- package/skills/app-compose-4-design-mock/references/screen-detailview.md +106 -0
- package/skills/app-compose-4-design-mock/references/screen-form.md +139 -0
- package/skills/app-compose-4-design-mock/references/screen-listview.md +153 -0
- package/skills/app-compose-4-design-mock/references/structure.md +27 -0
- package/skills/app-compose-5-design-mock-review/SKILL.md +290 -0
- package/skills/app-compose-5-design-mock-review/references/component.md +50 -0
- package/skills/app-compose-5-design-mock-review/references/screen-detailview.md +106 -0
- package/skills/app-compose-5-design-mock-review/references/screen-form.md +139 -0
- package/skills/app-compose-5-design-mock-review/references/screen-listview.md +153 -0
- package/skills/app-compose-6-implementation-spec/SKILL.md +127 -0
- package/skills/app-compose-6-implementation-spec/references/auth.md +72 -0
- package/skills/app-compose-6-implementation-spec/references/structure.md +27 -0
- package/skills/mock-scenario/SKILL.md +118 -0
- package/src/app.ts +1 -0
- package/src/cli.ts +120 -0
- package/src/commands/check.test.ts +30 -0
- package/src/commands/check.ts +66 -0
- package/src/commands/init.test.ts +88 -0
- package/src/commands/init.ts +120 -0
- package/src/commands/mock/index.ts +53 -0
- package/src/commands/mock/start.ts +179 -0
- package/src/commands/mock/validate.test.ts +185 -0
- package/src/commands/mock/validate.ts +198 -0
- package/src/commands/scaffold.test.ts +76 -0
- package/src/commands/scaffold.ts +119 -0
- package/src/commands/sync-check.test.ts +125 -0
- package/src/commands/sync-check.ts +182 -0
- package/src/integration.test.ts +63 -0
- package/src/mdschema.ts +48 -0
- package/src/mockServer.ts +55 -0
- package/src/module.ts +86 -0
- package/src/modules/accounting/.gitkeep +0 -0
- package/src/modules/coa-management/.gitkeep +0 -0
- package/src/modules/inventory/.gitkeep +0 -0
- package/src/modules/manufacturing/.gitkeep +0 -0
- package/src/modules/primitives/README.md +39 -0
- package/src/modules/primitives/command/activateCategory.test.ts +75 -0
- package/src/modules/primitives/command/activateCategory.ts +50 -0
- package/src/modules/primitives/command/activateCurrency.test.ts +70 -0
- package/src/modules/primitives/command/activateCurrency.ts +50 -0
- package/src/modules/primitives/command/activateUnit.test.ts +53 -0
- package/src/modules/primitives/command/activateUnit.ts +50 -0
- package/src/modules/primitives/command/convertAmount.test.ts +275 -0
- package/src/modules/primitives/command/convertAmount.ts +126 -0
- package/src/modules/primitives/command/convertQuantity.test.ts +219 -0
- package/src/modules/primitives/command/convertQuantity.ts +73 -0
- package/src/modules/primitives/command/createCategory.test.ts +126 -0
- package/src/modules/primitives/command/createCategory.ts +89 -0
- package/src/modules/primitives/command/createCurrency.test.ts +191 -0
- package/src/modules/primitives/command/createCurrency.ts +77 -0
- package/src/modules/primitives/command/createExchangeRate.test.ts +216 -0
- package/src/modules/primitives/command/createExchangeRate.ts +91 -0
- package/src/modules/primitives/command/createUnit.test.ts +214 -0
- package/src/modules/primitives/command/createUnit.ts +88 -0
- package/src/modules/primitives/command/deactivateCategory.test.ts +97 -0
- package/src/modules/primitives/command/deactivateCategory.ts +62 -0
- package/src/modules/primitives/command/deactivateCurrency.test.ts +85 -0
- package/src/modules/primitives/command/deactivateCurrency.ts +55 -0
- package/src/modules/primitives/command/deactivateUnit.test.ts +78 -0
- package/src/modules/primitives/command/deactivateUnit.ts +62 -0
- package/src/modules/primitives/command/setBaseCurrency.test.ts +98 -0
- package/src/modules/primitives/command/setBaseCurrency.ts +74 -0
- package/src/modules/primitives/command/setReferenceUnit.test.ts +108 -0
- package/src/modules/primitives/command/setReferenceUnit.ts +84 -0
- package/src/modules/primitives/db/currency.ts +30 -0
- package/src/modules/primitives/db/exchangeRate.ts +28 -0
- package/src/modules/primitives/db/unit.ts +32 -0
- package/src/modules/primitives/db/uomCategory.ts +32 -0
- package/src/modules/primitives/docs/commands/ActivateCategory.md +34 -0
- package/src/modules/primitives/docs/commands/ActivateCurrency.md +33 -0
- package/src/modules/primitives/docs/commands/ActivateUnit.md +34 -0
- package/src/modules/primitives/docs/commands/ConvertAmount.md +50 -0
- package/src/modules/primitives/docs/commands/ConvertQuantity.md +43 -0
- package/src/modules/primitives/docs/commands/CreateCategory.md +44 -0
- package/src/modules/primitives/docs/commands/CreateCurrency.md +47 -0
- package/src/modules/primitives/docs/commands/CreateExchangeRate.md +48 -0
- package/src/modules/primitives/docs/commands/CreateUnit.md +48 -0
- package/src/modules/primitives/docs/commands/DeactivateCategory.md +38 -0
- package/src/modules/primitives/docs/commands/DeactivateCurrency.md +38 -0
- package/src/modules/primitives/docs/commands/DeactivateUnit.md +38 -0
- package/src/modules/primitives/docs/commands/SetBaseCurrency.md +39 -0
- package/src/modules/primitives/docs/commands/SetReferenceUnit.md +43 -0
- package/src/modules/primitives/docs/features/currency-definitions.md +55 -0
- package/src/modules/primitives/docs/features/exchange-rates.md +61 -0
- package/src/modules/primitives/docs/features/unit-conversion.md +66 -0
- package/src/modules/primitives/docs/features/uom-categories.md +52 -0
- package/src/modules/primitives/docs/models/Currency.md +45 -0
- package/src/modules/primitives/docs/models/ExchangeRate.md +33 -0
- package/src/modules/primitives/docs/models/Unit.md +46 -0
- package/src/modules/primitives/docs/models/UoMCategory.md +44 -0
- package/src/modules/primitives/generated/kysely-tailordb.ts +95 -0
- package/src/modules/primitives/index.ts +40 -0
- package/src/modules/primitives/lib/errors.ts +138 -0
- package/src/modules/primitives/lib/types.ts +20 -0
- package/src/modules/primitives/module.ts +66 -0
- package/src/modules/primitives/permissions.ts +18 -0
- package/src/modules/primitives/tailor.config.ts +11 -0
- package/src/modules/primitives/testing/fixtures.ts +161 -0
- package/src/modules/product-management/.gitkeep +0 -0
- package/src/modules/purchase/.gitkeep +0 -0
- package/src/modules/sales/.gitkeep +0 -0
- package/src/modules/shared/createContext.test.ts +39 -0
- package/src/modules/shared/createContext.ts +15 -0
- package/src/modules/shared/defineCommand.test.ts +42 -0
- package/src/modules/shared/defineCommand.ts +19 -0
- package/src/modules/shared/definePermissions.test.ts +146 -0
- package/src/modules/shared/definePermissions.ts +94 -0
- package/src/modules/shared/entityTypes.ts +15 -0
- package/src/modules/shared/errors.ts +22 -0
- package/src/modules/shared/index.ts +1 -0
- package/src/modules/shared/internal.ts +13 -0
- package/src/modules/shared/requirePermission.test.ts +47 -0
- package/src/modules/shared/requirePermission.ts +8 -0
- package/src/modules/shared/types.ts +4 -0
- package/src/modules/supplier-management/.gitkeep +0 -0
- package/src/modules/supplier-portal/.gitkeep +0 -0
- package/src/modules/testing/index.ts +120 -0
- package/src/modules/user-management/README.md +38 -0
- package/src/modules/user-management/command/activateUser.test.ts +112 -0
- package/src/modules/user-management/command/activateUser.ts +67 -0
- package/src/modules/user-management/command/assignPermissionToRole.test.ts +119 -0
- package/src/modules/user-management/command/assignPermissionToRole.ts +87 -0
- package/src/modules/user-management/command/assignRoleToUser.test.ts +162 -0
- package/src/modules/user-management/command/assignRoleToUser.ts +93 -0
- package/src/modules/user-management/command/createPermission.test.ts +143 -0
- package/src/modules/user-management/command/createPermission.ts +66 -0
- package/src/modules/user-management/command/createRole.test.ts +115 -0
- package/src/modules/user-management/command/createRole.ts +52 -0
- package/src/modules/user-management/command/createUser.test.ts +198 -0
- package/src/modules/user-management/command/createUser.ts +85 -0
- package/src/modules/user-management/command/deactivateUser.test.ts +112 -0
- package/src/modules/user-management/command/deactivateUser.ts +67 -0
- package/src/modules/user-management/command/logAuditEvent.test.ts +179 -0
- package/src/modules/user-management/command/logAuditEvent.ts +59 -0
- package/src/modules/user-management/command/reactivateUser.test.ts +115 -0
- package/src/modules/user-management/command/reactivateUser.ts +67 -0
- package/src/modules/user-management/command/revokePermissionFromRole.test.ts +112 -0
- package/src/modules/user-management/command/revokePermissionFromRole.ts +81 -0
- package/src/modules/user-management/command/revokeRoleFromUser.test.ts +112 -0
- package/src/modules/user-management/command/revokeRoleFromUser.ts +81 -0
- package/src/modules/user-management/db/auditEvent.ts +47 -0
- package/src/modules/user-management/db/permission.ts +31 -0
- package/src/modules/user-management/db/role.ts +28 -0
- package/src/modules/user-management/db/rolePermission.ts +44 -0
- package/src/modules/user-management/db/user.ts +38 -0
- package/src/modules/user-management/db/userRole.ts +44 -0
- package/src/modules/user-management/docs/commands/ActivateUser.md +36 -0
- package/src/modules/user-management/docs/commands/AssignPermissionToRole.md +39 -0
- package/src/modules/user-management/docs/commands/AssignRoleToUser.md +43 -0
- package/src/modules/user-management/docs/commands/CreatePermission.md +35 -0
- package/src/modules/user-management/docs/commands/CreateRole.md +35 -0
- package/src/modules/user-management/docs/commands/CreateUser.md +41 -0
- package/src/modules/user-management/docs/commands/DeactivateUser.md +38 -0
- package/src/modules/user-management/docs/commands/LogAuditEvent.md +37 -0
- package/src/modules/user-management/docs/commands/ReactivateUser.md +37 -0
- package/src/modules/user-management/docs/commands/RevokePermissionFromRole.md +40 -0
- package/src/modules/user-management/docs/commands/RevokeRoleFromUser.md +40 -0
- package/src/modules/user-management/docs/features/audit-trail.md +80 -0
- package/src/modules/user-management/docs/features/role-based-access-control.md +76 -0
- package/src/modules/user-management/docs/features/user-account-management.md +64 -0
- package/src/modules/user-management/docs/models/AuditEvent.md +34 -0
- package/src/modules/user-management/docs/models/Permission.md +31 -0
- package/src/modules/user-management/docs/models/Role.md +31 -0
- package/src/modules/user-management/docs/models/RolePermission.md +33 -0
- package/src/modules/user-management/docs/models/User.md +47 -0
- package/src/modules/user-management/docs/models/UserRole.md +34 -0
- package/src/modules/user-management/docs/plans/2026-01-30-flattened-permissions-design.md +52 -0
- package/src/modules/user-management/executor/recomputeOnRolePermissionChange.ts +61 -0
- package/src/modules/user-management/generated/enums.ts +24 -0
- package/src/modules/user-management/generated/kysely-tailordb.ts +112 -0
- package/src/modules/user-management/index.ts +32 -0
- package/src/modules/user-management/lib/errors.ts +81 -0
- package/src/modules/user-management/lib/recomputeUserPermissions.ts +53 -0
- package/src/modules/user-management/lib/types.ts +31 -0
- package/src/modules/user-management/module.ts +77 -0
- package/src/modules/user-management/permissions.ts +15 -0
- package/src/modules/user-management/tailor.config.ts +11 -0
- package/src/modules/user-management/testing/fixtures.ts +98 -0
- package/src/schemas.ts +25 -0
- package/src/testing.ts +10 -0
- package/src/util.ts +3 -0
|
@@ -0,0 +1,275 @@
|
|
|
1
|
+
import { describe, expect, it } from "vitest";
|
|
2
|
+
import { InsufficientPermissionError, type CommandContext } from "../../shared/internal";
|
|
3
|
+
import { createMockDb } from "../../testing/index";
|
|
4
|
+
import { DB } from "../generated/kysely-tailordb";
|
|
5
|
+
import {
|
|
6
|
+
CurrencyNotFoundError,
|
|
7
|
+
ExchangeRateNotFoundError,
|
|
8
|
+
InactiveCurrencyError,
|
|
9
|
+
} from "../lib/errors";
|
|
10
|
+
import {
|
|
11
|
+
baseCurrencyEUR,
|
|
12
|
+
baseCurrencyJPY,
|
|
13
|
+
baseCurrencyUSD,
|
|
14
|
+
baseExchangeRateUSDtoEUR,
|
|
15
|
+
baseExchangeRateUSDtoJPY,
|
|
16
|
+
inactiveCurrency,
|
|
17
|
+
olderExchangeRateUSDtoEUR,
|
|
18
|
+
} from "../testing/fixtures";
|
|
19
|
+
import { convertAmount } from "./convertAmount";
|
|
20
|
+
|
|
21
|
+
describe("convertAmount", () => {
|
|
22
|
+
const ctx: CommandContext = { actorId: "test-actor", permissions: ["primitives:convertAmount"] };
|
|
23
|
+
|
|
24
|
+
// Error cases first
|
|
25
|
+
it("throws when source currency doesn't exist", async () => {
|
|
26
|
+
const { db, spies } = createMockDb<DB>();
|
|
27
|
+
spies.select.mockReturnValue(undefined);
|
|
28
|
+
|
|
29
|
+
await expect(
|
|
30
|
+
convertAmount(
|
|
31
|
+
db,
|
|
32
|
+
{
|
|
33
|
+
amount: 100,
|
|
34
|
+
sourceCurrencyCode: "XYZ",
|
|
35
|
+
targetCurrencyCode: baseCurrencyEUR.code,
|
|
36
|
+
conversionDate: "2024-01-20",
|
|
37
|
+
},
|
|
38
|
+
ctx,
|
|
39
|
+
),
|
|
40
|
+
).rejects.toBeInstanceOf(CurrencyNotFoundError);
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
it("throws when target currency doesn't exist", async () => {
|
|
44
|
+
const { db, spies } = createMockDb<DB>();
|
|
45
|
+
spies.select
|
|
46
|
+
.mockReturnValueOnce(baseCurrencyUSD) // source exists
|
|
47
|
+
.mockReturnValueOnce(undefined); // target doesn't exist
|
|
48
|
+
|
|
49
|
+
await expect(
|
|
50
|
+
convertAmount(
|
|
51
|
+
db,
|
|
52
|
+
{
|
|
53
|
+
amount: 100,
|
|
54
|
+
sourceCurrencyCode: baseCurrencyUSD.code,
|
|
55
|
+
targetCurrencyCode: "XYZ",
|
|
56
|
+
conversionDate: "2024-01-20",
|
|
57
|
+
},
|
|
58
|
+
ctx,
|
|
59
|
+
),
|
|
60
|
+
).rejects.toBeInstanceOf(CurrencyNotFoundError);
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
it("throws when source currency is inactive", async () => {
|
|
64
|
+
const { db, spies } = createMockDb<DB>();
|
|
65
|
+
spies.select
|
|
66
|
+
.mockReturnValueOnce(inactiveCurrency) // source inactive
|
|
67
|
+
.mockReturnValueOnce(baseCurrencyEUR); // target exists
|
|
68
|
+
|
|
69
|
+
await expect(
|
|
70
|
+
convertAmount(
|
|
71
|
+
db,
|
|
72
|
+
{
|
|
73
|
+
amount: 100,
|
|
74
|
+
sourceCurrencyCode: inactiveCurrency.code,
|
|
75
|
+
targetCurrencyCode: baseCurrencyEUR.code,
|
|
76
|
+
conversionDate: "2024-01-20",
|
|
77
|
+
},
|
|
78
|
+
ctx,
|
|
79
|
+
),
|
|
80
|
+
).rejects.toBeInstanceOf(InactiveCurrencyError);
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
it("throws when target currency is inactive", async () => {
|
|
84
|
+
const { db, spies } = createMockDb<DB>();
|
|
85
|
+
spies.select
|
|
86
|
+
.mockReturnValueOnce(baseCurrencyUSD) // source exists
|
|
87
|
+
.mockReturnValueOnce(inactiveCurrency); // target inactive
|
|
88
|
+
|
|
89
|
+
await expect(
|
|
90
|
+
convertAmount(
|
|
91
|
+
db,
|
|
92
|
+
{
|
|
93
|
+
amount: 100,
|
|
94
|
+
sourceCurrencyCode: baseCurrencyUSD.code,
|
|
95
|
+
targetCurrencyCode: inactiveCurrency.code,
|
|
96
|
+
conversionDate: "2024-01-20",
|
|
97
|
+
},
|
|
98
|
+
ctx,
|
|
99
|
+
),
|
|
100
|
+
).rejects.toBeInstanceOf(InactiveCurrencyError);
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
it("throws when no exchange rate exists for the currency pair", async () => {
|
|
104
|
+
const { db, spies } = createMockDb<DB>();
|
|
105
|
+
spies.select
|
|
106
|
+
.mockReturnValueOnce(baseCurrencyUSD) // source exists
|
|
107
|
+
.mockReturnValueOnce(baseCurrencyEUR) // target exists
|
|
108
|
+
.mockReturnValueOnce(undefined) // no direct rate
|
|
109
|
+
.mockReturnValueOnce(undefined); // no inverse rate
|
|
110
|
+
|
|
111
|
+
await expect(
|
|
112
|
+
convertAmount(
|
|
113
|
+
db,
|
|
114
|
+
{
|
|
115
|
+
amount: 100,
|
|
116
|
+
sourceCurrencyCode: baseCurrencyUSD.code,
|
|
117
|
+
targetCurrencyCode: baseCurrencyEUR.code,
|
|
118
|
+
conversionDate: "2024-01-20",
|
|
119
|
+
},
|
|
120
|
+
ctx,
|
|
121
|
+
),
|
|
122
|
+
).rejects.toBeInstanceOf(ExchangeRateNotFoundError);
|
|
123
|
+
});
|
|
124
|
+
|
|
125
|
+
// Success cases
|
|
126
|
+
it("returns same amount when source and target are the same currency", async () => {
|
|
127
|
+
const { db, spies } = createMockDb<DB>();
|
|
128
|
+
spies.select.mockReturnValueOnce(baseCurrencyUSD).mockReturnValueOnce(baseCurrencyUSD);
|
|
129
|
+
|
|
130
|
+
const result = await convertAmount(
|
|
131
|
+
db,
|
|
132
|
+
{
|
|
133
|
+
amount: 100,
|
|
134
|
+
sourceCurrencyCode: baseCurrencyUSD.code,
|
|
135
|
+
targetCurrencyCode: baseCurrencyUSD.code,
|
|
136
|
+
conversionDate: "2024-01-20",
|
|
137
|
+
},
|
|
138
|
+
ctx,
|
|
139
|
+
);
|
|
140
|
+
|
|
141
|
+
expect(result.convertedAmount).toBe(100);
|
|
142
|
+
expect(result.exchangeRate).toBe(1);
|
|
143
|
+
});
|
|
144
|
+
|
|
145
|
+
it("converts USD to EUR using direct rate", async () => {
|
|
146
|
+
const { db, spies } = createMockDb<DB>();
|
|
147
|
+
spies.select
|
|
148
|
+
.mockReturnValueOnce(baseCurrencyUSD) // source
|
|
149
|
+
.mockReturnValueOnce(baseCurrencyEUR) // target
|
|
150
|
+
.mockReturnValueOnce(baseExchangeRateUSDtoEUR); // rate = 0.92
|
|
151
|
+
|
|
152
|
+
// 100 USD * 0.92 = 92 EUR
|
|
153
|
+
const result = await convertAmount(
|
|
154
|
+
db,
|
|
155
|
+
{
|
|
156
|
+
amount: 100,
|
|
157
|
+
sourceCurrencyCode: baseCurrencyUSD.code,
|
|
158
|
+
targetCurrencyCode: baseCurrencyEUR.code,
|
|
159
|
+
conversionDate: "2024-01-20",
|
|
160
|
+
},
|
|
161
|
+
ctx,
|
|
162
|
+
);
|
|
163
|
+
|
|
164
|
+
expect(result.convertedAmount).toBe(92);
|
|
165
|
+
expect(result.exchangeRate).toBe(0.92);
|
|
166
|
+
});
|
|
167
|
+
|
|
168
|
+
it("uses inverse rate when no direct rate exists", async () => {
|
|
169
|
+
const { db, spies } = createMockDb<DB>();
|
|
170
|
+
spies.select
|
|
171
|
+
.mockReturnValueOnce(baseCurrencyEUR) // source
|
|
172
|
+
.mockReturnValueOnce(baseCurrencyUSD) // target
|
|
173
|
+
.mockReturnValueOnce(undefined) // no direct EUR->USD rate
|
|
174
|
+
.mockReturnValueOnce(baseExchangeRateUSDtoEUR); // inverse USD->EUR rate = 0.92
|
|
175
|
+
|
|
176
|
+
// 100 EUR * (1/0.92) = 108.70 USD (rounded to 2 decimal places)
|
|
177
|
+
const result = await convertAmount(
|
|
178
|
+
db,
|
|
179
|
+
{
|
|
180
|
+
amount: 100,
|
|
181
|
+
sourceCurrencyCode: baseCurrencyEUR.code,
|
|
182
|
+
targetCurrencyCode: baseCurrencyUSD.code,
|
|
183
|
+
conversionDate: "2024-01-20",
|
|
184
|
+
},
|
|
185
|
+
ctx,
|
|
186
|
+
);
|
|
187
|
+
|
|
188
|
+
expect(result.convertedAmount).toBe(108.7);
|
|
189
|
+
expect(result.exchangeRate).toBeCloseTo(1 / 0.92, 5);
|
|
190
|
+
});
|
|
191
|
+
|
|
192
|
+
it("uses most recent rate on or before the conversion date", async () => {
|
|
193
|
+
const { db, spies } = createMockDb<DB>();
|
|
194
|
+
spies.select
|
|
195
|
+
.mockReturnValueOnce(baseCurrencyUSD) // source
|
|
196
|
+
.mockReturnValueOnce(baseCurrencyEUR) // target
|
|
197
|
+
.mockReturnValueOnce(olderExchangeRateUSDtoEUR); // rate = 0.85 from 2024-01-01
|
|
198
|
+
|
|
199
|
+
// Querying for 2024-01-10, should use rate from 2024-01-01
|
|
200
|
+
// 100 USD * 0.85 = 85 EUR
|
|
201
|
+
const result = await convertAmount(
|
|
202
|
+
db,
|
|
203
|
+
{
|
|
204
|
+
amount: 100,
|
|
205
|
+
sourceCurrencyCode: baseCurrencyUSD.code,
|
|
206
|
+
targetCurrencyCode: baseCurrencyEUR.code,
|
|
207
|
+
conversionDate: "2024-01-10",
|
|
208
|
+
},
|
|
209
|
+
ctx,
|
|
210
|
+
);
|
|
211
|
+
|
|
212
|
+
expect(result.convertedAmount).toBe(85);
|
|
213
|
+
expect(result.exchangeRate).toBe(0.85);
|
|
214
|
+
});
|
|
215
|
+
|
|
216
|
+
it("rounds to target currency decimal places (JPY has 0)", async () => {
|
|
217
|
+
const { db, spies } = createMockDb<DB>();
|
|
218
|
+
spies.select
|
|
219
|
+
.mockReturnValueOnce(baseCurrencyUSD) // source
|
|
220
|
+
.mockReturnValueOnce(baseCurrencyJPY) // target (0 decimal places)
|
|
221
|
+
.mockReturnValueOnce(baseExchangeRateUSDtoJPY); // rate = 148.5
|
|
222
|
+
|
|
223
|
+
// 100.50 USD * 148.5 = 14924.25 -> rounded to 14924 JPY
|
|
224
|
+
const result = await convertAmount(
|
|
225
|
+
db,
|
|
226
|
+
{
|
|
227
|
+
amount: 100.5,
|
|
228
|
+
sourceCurrencyCode: baseCurrencyUSD.code,
|
|
229
|
+
targetCurrencyCode: baseCurrencyJPY.code,
|
|
230
|
+
conversionDate: "2024-01-20",
|
|
231
|
+
},
|
|
232
|
+
ctx,
|
|
233
|
+
);
|
|
234
|
+
|
|
235
|
+
expect(result.convertedAmount).toBe(14924);
|
|
236
|
+
});
|
|
237
|
+
|
|
238
|
+
it("handles zero amount correctly", async () => {
|
|
239
|
+
const { db, spies } = createMockDb<DB>();
|
|
240
|
+
spies.select
|
|
241
|
+
.mockReturnValueOnce(baseCurrencyUSD)
|
|
242
|
+
.mockReturnValueOnce(baseCurrencyEUR)
|
|
243
|
+
.mockReturnValueOnce(baseExchangeRateUSDtoEUR);
|
|
244
|
+
|
|
245
|
+
const result = await convertAmount(
|
|
246
|
+
db,
|
|
247
|
+
{
|
|
248
|
+
amount: 0,
|
|
249
|
+
sourceCurrencyCode: baseCurrencyUSD.code,
|
|
250
|
+
targetCurrencyCode: baseCurrencyEUR.code,
|
|
251
|
+
conversionDate: "2024-01-20",
|
|
252
|
+
},
|
|
253
|
+
ctx,
|
|
254
|
+
);
|
|
255
|
+
|
|
256
|
+
expect(result.convertedAmount).toBe(0);
|
|
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
|
+
});
|
|
@@ -0,0 +1,126 @@
|
|
|
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
|
+
);
|
|
@@ -0,0 +1,219 @@
|
|
|
1
|
+
import { describe, expect, it } from "vitest";
|
|
2
|
+
import { createMockDb } from "../../testing/index";
|
|
3
|
+
import { InsufficientPermissionError, type CommandContext } from "../../shared/internal";
|
|
4
|
+
import { DB } from "../generated/kysely-tailordb";
|
|
5
|
+
import { InactiveUnitError, IncompatibleUnitsError, UnitNotFoundError } from "../lib/errors";
|
|
6
|
+
import {
|
|
7
|
+
baseUnitGram,
|
|
8
|
+
baseUnitKg,
|
|
9
|
+
baseUnitLiter,
|
|
10
|
+
baseUnitPound,
|
|
11
|
+
inactiveUnit,
|
|
12
|
+
} from "../testing/fixtures";
|
|
13
|
+
import { convertQuantity } from "./convertQuantity";
|
|
14
|
+
|
|
15
|
+
describe("convertQuantity", () => {
|
|
16
|
+
const ctx: CommandContext = {
|
|
17
|
+
actorId: "test-actor",
|
|
18
|
+
permissions: ["primitives:convertQuantity"],
|
|
19
|
+
};
|
|
20
|
+
|
|
21
|
+
// Error cases first
|
|
22
|
+
it("throws when source unit doesn't exist", async () => {
|
|
23
|
+
const { db, spies } = createMockDb<DB>();
|
|
24
|
+
spies.select.mockReturnValue(undefined);
|
|
25
|
+
|
|
26
|
+
await expect(
|
|
27
|
+
convertQuantity(
|
|
28
|
+
db,
|
|
29
|
+
{
|
|
30
|
+
quantity: 10,
|
|
31
|
+
sourceUnitSymbol: "xyz",
|
|
32
|
+
targetUnitSymbol: baseUnitGram.symbol,
|
|
33
|
+
},
|
|
34
|
+
ctx,
|
|
35
|
+
),
|
|
36
|
+
).rejects.toBeInstanceOf(UnitNotFoundError);
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
it("throws when target unit doesn't exist", async () => {
|
|
40
|
+
const { db, spies } = createMockDb<DB>();
|
|
41
|
+
spies.select
|
|
42
|
+
.mockReturnValueOnce(baseUnitKg) // source unit exists
|
|
43
|
+
.mockReturnValueOnce(undefined); // target unit doesn't exist
|
|
44
|
+
|
|
45
|
+
await expect(
|
|
46
|
+
convertQuantity(
|
|
47
|
+
db,
|
|
48
|
+
{
|
|
49
|
+
quantity: 10,
|
|
50
|
+
sourceUnitSymbol: baseUnitKg.symbol,
|
|
51
|
+
targetUnitSymbol: "xyz",
|
|
52
|
+
},
|
|
53
|
+
ctx,
|
|
54
|
+
),
|
|
55
|
+
).rejects.toBeInstanceOf(UnitNotFoundError);
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
it("throws when source unit is inactive", async () => {
|
|
59
|
+
const { db, spies } = createMockDb<DB>();
|
|
60
|
+
spies.select
|
|
61
|
+
.mockReturnValueOnce(inactiveUnit) // source unit inactive
|
|
62
|
+
.mockReturnValueOnce(baseUnitGram); // target unit exists
|
|
63
|
+
|
|
64
|
+
await expect(
|
|
65
|
+
convertQuantity(
|
|
66
|
+
db,
|
|
67
|
+
{
|
|
68
|
+
quantity: 10,
|
|
69
|
+
sourceUnitSymbol: inactiveUnit.symbol,
|
|
70
|
+
targetUnitSymbol: baseUnitGram.symbol,
|
|
71
|
+
},
|
|
72
|
+
ctx,
|
|
73
|
+
),
|
|
74
|
+
).rejects.toBeInstanceOf(InactiveUnitError);
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
it("throws when target unit is inactive", async () => {
|
|
78
|
+
const { db, spies } = createMockDb<DB>();
|
|
79
|
+
spies.select
|
|
80
|
+
.mockReturnValueOnce(baseUnitKg) // source unit exists
|
|
81
|
+
.mockReturnValueOnce(inactiveUnit); // target unit inactive
|
|
82
|
+
|
|
83
|
+
await expect(
|
|
84
|
+
convertQuantity(
|
|
85
|
+
db,
|
|
86
|
+
{
|
|
87
|
+
quantity: 10,
|
|
88
|
+
sourceUnitSymbol: baseUnitKg.symbol,
|
|
89
|
+
targetUnitSymbol: inactiveUnit.symbol,
|
|
90
|
+
},
|
|
91
|
+
ctx,
|
|
92
|
+
),
|
|
93
|
+
).rejects.toBeInstanceOf(InactiveUnitError);
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
it("throws when units belong to different categories", async () => {
|
|
97
|
+
const { db, spies } = createMockDb<DB>();
|
|
98
|
+
spies.select
|
|
99
|
+
.mockReturnValueOnce(baseUnitKg) // Weight category
|
|
100
|
+
.mockReturnValueOnce(baseUnitLiter); // Volume category
|
|
101
|
+
|
|
102
|
+
await expect(
|
|
103
|
+
convertQuantity(
|
|
104
|
+
db,
|
|
105
|
+
{
|
|
106
|
+
quantity: 10,
|
|
107
|
+
sourceUnitSymbol: baseUnitKg.symbol,
|
|
108
|
+
targetUnitSymbol: baseUnitLiter.symbol,
|
|
109
|
+
},
|
|
110
|
+
ctx,
|
|
111
|
+
),
|
|
112
|
+
).rejects.toBeInstanceOf(IncompatibleUnitsError);
|
|
113
|
+
});
|
|
114
|
+
|
|
115
|
+
// Success cases
|
|
116
|
+
it("returns same quantity when source and target are the same unit", async () => {
|
|
117
|
+
const { db, spies } = createMockDb<DB>();
|
|
118
|
+
spies.select.mockReturnValueOnce(baseUnitKg).mockReturnValueOnce(baseUnitKg);
|
|
119
|
+
|
|
120
|
+
const result = await convertQuantity(
|
|
121
|
+
db,
|
|
122
|
+
{
|
|
123
|
+
quantity: 10,
|
|
124
|
+
sourceUnitSymbol: baseUnitKg.symbol,
|
|
125
|
+
targetUnitSymbol: baseUnitKg.symbol,
|
|
126
|
+
},
|
|
127
|
+
ctx,
|
|
128
|
+
);
|
|
129
|
+
|
|
130
|
+
expect(result.convertedQuantity).toBe(10);
|
|
131
|
+
expect(result.sourceUnit).toEqual(baseUnitKg);
|
|
132
|
+
expect(result.targetUnit).toEqual(baseUnitKg);
|
|
133
|
+
});
|
|
134
|
+
|
|
135
|
+
it("converts kg to grams correctly", async () => {
|
|
136
|
+
const { db, spies } = createMockDb<DB>();
|
|
137
|
+
spies.select
|
|
138
|
+
.mockReturnValueOnce(baseUnitKg) // 1 kg = 1.0 (reference)
|
|
139
|
+
.mockReturnValueOnce(baseUnitGram); // 1 g = 0.001 kg
|
|
140
|
+
|
|
141
|
+
// 5 kg * 1.0 / 0.001 = 5000 g
|
|
142
|
+
const result = await convertQuantity(
|
|
143
|
+
db,
|
|
144
|
+
{
|
|
145
|
+
quantity: 5,
|
|
146
|
+
sourceUnitSymbol: baseUnitKg.symbol,
|
|
147
|
+
targetUnitSymbol: baseUnitGram.symbol,
|
|
148
|
+
},
|
|
149
|
+
ctx,
|
|
150
|
+
);
|
|
151
|
+
|
|
152
|
+
expect(result.convertedQuantity).toBe(5000); // rounded to 0 decimal places (gram precision)
|
|
153
|
+
});
|
|
154
|
+
|
|
155
|
+
it("converts grams to kg correctly", async () => {
|
|
156
|
+
const { db, spies } = createMockDb<DB>();
|
|
157
|
+
spies.select
|
|
158
|
+
.mockReturnValueOnce(baseUnitGram) // 1 g = 0.001 kg
|
|
159
|
+
.mockReturnValueOnce(baseUnitKg); // 1 kg = 1.0 (reference)
|
|
160
|
+
|
|
161
|
+
// 2500 g * 0.001 / 1.0 = 2.5 kg
|
|
162
|
+
const result = await convertQuantity(
|
|
163
|
+
db,
|
|
164
|
+
{
|
|
165
|
+
quantity: 2500,
|
|
166
|
+
sourceUnitSymbol: baseUnitGram.symbol,
|
|
167
|
+
targetUnitSymbol: baseUnitKg.symbol,
|
|
168
|
+
},
|
|
169
|
+
ctx,
|
|
170
|
+
);
|
|
171
|
+
|
|
172
|
+
expect(result.convertedQuantity).toBe(2.5); // rounded to 2 decimal places (kg precision)
|
|
173
|
+
});
|
|
174
|
+
|
|
175
|
+
it("converts pounds to kg correctly", async () => {
|
|
176
|
+
const { db, spies } = createMockDb<DB>();
|
|
177
|
+
spies.select
|
|
178
|
+
.mockReturnValueOnce(baseUnitPound) // 1 lb = 0.453592 kg
|
|
179
|
+
.mockReturnValueOnce(baseUnitKg); // 1 kg = 1.0 (reference)
|
|
180
|
+
|
|
181
|
+
// 10 lb * 0.453592 / 1.0 = 4.53592 kg, rounded to 4.54
|
|
182
|
+
const result = await convertQuantity(
|
|
183
|
+
db,
|
|
184
|
+
{
|
|
185
|
+
quantity: 10,
|
|
186
|
+
sourceUnitSymbol: baseUnitPound.symbol,
|
|
187
|
+
targetUnitSymbol: baseUnitKg.symbol,
|
|
188
|
+
},
|
|
189
|
+
ctx,
|
|
190
|
+
);
|
|
191
|
+
|
|
192
|
+
expect(result.convertedQuantity).toBe(4.54);
|
|
193
|
+
});
|
|
194
|
+
|
|
195
|
+
it("handles zero quantity correctly", async () => {
|
|
196
|
+
const { db, spies } = createMockDb<DB>();
|
|
197
|
+
spies.select.mockReturnValueOnce(baseUnitKg).mockReturnValueOnce(baseUnitGram);
|
|
198
|
+
|
|
199
|
+
const result = await convertQuantity(
|
|
200
|
+
db,
|
|
201
|
+
{
|
|
202
|
+
quantity: 0,
|
|
203
|
+
sourceUnitSymbol: baseUnitKg.symbol,
|
|
204
|
+
targetUnitSymbol: baseUnitGram.symbol,
|
|
205
|
+
},
|
|
206
|
+
ctx,
|
|
207
|
+
);
|
|
208
|
+
|
|
209
|
+
expect(result.convertedQuantity).toBe(0);
|
|
210
|
+
});
|
|
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
|
+
});
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
import { defineCommand } from "../../shared/internal";
|
|
2
|
+
import { DB } from "../generated/kysely-tailordb";
|
|
3
|
+
import { InactiveUnitError, IncompatibleUnitsError, UnitNotFoundError } from "../lib/errors";
|
|
4
|
+
import { permissions } from "../permissions";
|
|
5
|
+
|
|
6
|
+
export interface ConvertQuantityInput {
|
|
7
|
+
quantity: number;
|
|
8
|
+
sourceUnitSymbol: string;
|
|
9
|
+
targetUnitSymbol: string;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* Function: convertQuantity
|
|
14
|
+
*
|
|
15
|
+
* Converts a quantity from one unit of measure to another within the same category.
|
|
16
|
+
* Units are identified by their symbol (e.g., "kg", "lb", "g").
|
|
17
|
+
* The conversion uses each unit's conversion factor relative to the category's reference unit.
|
|
18
|
+
* Result is rounded to the target unit's precision setting.
|
|
19
|
+
*/
|
|
20
|
+
export const convertQuantity = defineCommand(
|
|
21
|
+
permissions.convertQuantity,
|
|
22
|
+
async (db: DB, input: ConvertQuantityInput) => {
|
|
23
|
+
// 1. Validate source unit exists
|
|
24
|
+
const sourceUnit = await db
|
|
25
|
+
.selectFrom("Unit")
|
|
26
|
+
.selectAll()
|
|
27
|
+
.where("symbol", "=", input.sourceUnitSymbol)
|
|
28
|
+
.executeTakeFirst();
|
|
29
|
+
|
|
30
|
+
if (!sourceUnit) {
|
|
31
|
+
throw new UnitNotFoundError(input.sourceUnitSymbol);
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
// 2. Validate target unit exists
|
|
35
|
+
const targetUnit = await db
|
|
36
|
+
.selectFrom("Unit")
|
|
37
|
+
.selectAll()
|
|
38
|
+
.where("symbol", "=", input.targetUnitSymbol)
|
|
39
|
+
.executeTakeFirst();
|
|
40
|
+
|
|
41
|
+
if (!targetUnit) {
|
|
42
|
+
throw new UnitNotFoundError(input.targetUnitSymbol);
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
// 3. Validate both units are active
|
|
46
|
+
if (!sourceUnit.isActive) {
|
|
47
|
+
throw new InactiveUnitError(input.sourceUnitSymbol);
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
if (!targetUnit.isActive) {
|
|
51
|
+
throw new InactiveUnitError(input.targetUnitSymbol);
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
// 4. Validate units belong to the same category
|
|
55
|
+
if (sourceUnit.categoryId !== targetUnit.categoryId) {
|
|
56
|
+
throw new IncompatibleUnitsError(input.sourceUnitSymbol, input.targetUnitSymbol);
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
// 5. Perform conversion
|
|
60
|
+
// Formula: result = quantity * sourceConversionFactor / targetConversionFactor
|
|
61
|
+
const rawResult = (input.quantity * sourceUnit.conversionFactor) / targetUnit.conversionFactor;
|
|
62
|
+
|
|
63
|
+
// 6. Apply rounding to target unit's precision
|
|
64
|
+
const roundingFactor = Math.pow(10, targetUnit.roundingPrecision);
|
|
65
|
+
const convertedQuantity = Math.round(rawResult * roundingFactor) / roundingFactor;
|
|
66
|
+
|
|
67
|
+
return {
|
|
68
|
+
convertedQuantity,
|
|
69
|
+
sourceUnit,
|
|
70
|
+
targetUnit,
|
|
71
|
+
};
|
|
72
|
+
},
|
|
73
|
+
);
|