@tailor-platform/erp-kit 0.0.1 → 0.1.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/README.md +196 -28
- package/dist/cli.js +894 -0
- package/package.json +65 -8
- package/rules/app-compose/backend/auth.md +78 -0
- package/rules/app-compose/frontend/auth.md +55 -0
- package/rules/app-compose/frontend/component.md +55 -0
- package/rules/app-compose/frontend/page.md +86 -0
- package/rules/app-compose/frontend/screen-detailview.md +112 -0
- package/rules/app-compose/frontend/screen-form.md +145 -0
- package/rules/app-compose/frontend/screen-listview.md +159 -0
- package/rules/app-compose/structure.md +32 -0
- package/rules/module-development/commands.md +54 -0
- package/rules/module-development/cross-module-type-injection.md +28 -0
- package/rules/module-development/dependency-modules.md +24 -0
- package/rules/module-development/errors.md +12 -0
- package/rules/module-development/executors.md +67 -0
- package/rules/module-development/exports.md +13 -0
- package/rules/module-development/models.md +34 -0
- package/rules/module-development/structure.md +27 -0
- package/rules/module-development/sync-vs-async-operations.md +83 -0
- package/rules/module-development/testing.md +43 -0
- package/rules/sdk-best-practices/db-relations.md +74 -0
- package/rules/sdk-best-practices/sdk-docs.md +14 -0
- 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 +107 -0
- package/skills/2-module-feature-breakdown/SKILL.md +66 -0
- package/skills/3-module-doc-review/SKILL.md +230 -0
- package/skills/4-module-tdd-implementation/SKILL.md +56 -0
- package/skills/5-module-implementation-review/SKILL.md +400 -0
- package/skills/app-compose-1-requirement-analysis/SKILL.md +85 -0
- package/skills/app-compose-2-requirements-breakdown/SKILL.md +88 -0
- package/skills/app-compose-3-doc-review/SKILL.md +112 -0
- package/skills/app-compose-4-design-mock/SKILL.md +248 -0
- package/skills/app-compose-5-design-mock-review/SKILL.md +283 -0
- package/skills/app-compose-6-implementation-spec/SKILL.md +122 -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 +77 -0
- package/src/commands/init.ts +87 -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,191 @@
|
|
|
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
|
+
DuplicateCurrencyCodeError,
|
|
7
|
+
InvalidDecimalPlacesError,
|
|
8
|
+
InvalidISOCodeError,
|
|
9
|
+
} from "../lib/errors";
|
|
10
|
+
import { baseCurrencyUSD } from "../testing/fixtures";
|
|
11
|
+
import { makeCreateCurrency } from "./createCurrency";
|
|
12
|
+
|
|
13
|
+
const createCurrency = makeCreateCurrency();
|
|
14
|
+
|
|
15
|
+
describe("createCurrency", () => {
|
|
16
|
+
const ctx: CommandContext = { actorId: "test-actor", permissions: ["primitives:createCurrency"] };
|
|
17
|
+
|
|
18
|
+
// Error cases
|
|
19
|
+
it("throws when ISO code is invalid format", async () => {
|
|
20
|
+
const { db } = createMockDb<DB>();
|
|
21
|
+
|
|
22
|
+
await expect(
|
|
23
|
+
createCurrency(
|
|
24
|
+
db,
|
|
25
|
+
{
|
|
26
|
+
code: "us", // Should be 3 uppercase letters
|
|
27
|
+
name: "US Dollar",
|
|
28
|
+
symbol: "$",
|
|
29
|
+
decimalPlaces: 2,
|
|
30
|
+
},
|
|
31
|
+
ctx,
|
|
32
|
+
),
|
|
33
|
+
).rejects.toBeInstanceOf(InvalidISOCodeError);
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
it("throws when ISO code already exists", async () => {
|
|
37
|
+
const { db, spies } = createMockDb<DB>();
|
|
38
|
+
spies.select.mockReturnValue(baseCurrencyUSD);
|
|
39
|
+
|
|
40
|
+
await expect(
|
|
41
|
+
createCurrency(
|
|
42
|
+
db,
|
|
43
|
+
{
|
|
44
|
+
code: "USD",
|
|
45
|
+
name: "US Dollar",
|
|
46
|
+
symbol: "$",
|
|
47
|
+
decimalPlaces: 2,
|
|
48
|
+
},
|
|
49
|
+
ctx,
|
|
50
|
+
),
|
|
51
|
+
).rejects.toBeInstanceOf(DuplicateCurrencyCodeError);
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
it("throws when decimal places is negative", async () => {
|
|
55
|
+
const { db, spies } = createMockDb<DB>();
|
|
56
|
+
spies.select.mockReturnValue(undefined); // No existing currency
|
|
57
|
+
|
|
58
|
+
await expect(
|
|
59
|
+
createCurrency(
|
|
60
|
+
db,
|
|
61
|
+
{
|
|
62
|
+
code: "XXX",
|
|
63
|
+
name: "Test Currency",
|
|
64
|
+
symbol: "X",
|
|
65
|
+
decimalPlaces: -1,
|
|
66
|
+
},
|
|
67
|
+
ctx,
|
|
68
|
+
),
|
|
69
|
+
).rejects.toBeInstanceOf(InvalidDecimalPlacesError);
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
it("throws when decimal places exceeds maximum", async () => {
|
|
73
|
+
const { db, spies } = createMockDb<DB>();
|
|
74
|
+
spies.select.mockReturnValue(undefined); // No existing currency
|
|
75
|
+
|
|
76
|
+
await expect(
|
|
77
|
+
createCurrency(
|
|
78
|
+
db,
|
|
79
|
+
{
|
|
80
|
+
code: "XXX",
|
|
81
|
+
name: "Test Currency",
|
|
82
|
+
symbol: "X",
|
|
83
|
+
decimalPlaces: 5,
|
|
84
|
+
},
|
|
85
|
+
ctx,
|
|
86
|
+
),
|
|
87
|
+
).rejects.toBeInstanceOf(InvalidDecimalPlacesError);
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
// Success cases
|
|
91
|
+
it("creates first currency as base currency", async () => {
|
|
92
|
+
const { db, spies } = createMockDb<DB>();
|
|
93
|
+
const createdCurrency = {
|
|
94
|
+
id: "new-currency-id",
|
|
95
|
+
code: "USD",
|
|
96
|
+
name: "US Dollar",
|
|
97
|
+
symbol: "$",
|
|
98
|
+
decimalPlaces: 2,
|
|
99
|
+
isBaseCurrency: true,
|
|
100
|
+
isActive: true,
|
|
101
|
+
createdAt: new Date("2024-01-15T00:00:00.000Z"),
|
|
102
|
+
updatedAt: null,
|
|
103
|
+
};
|
|
104
|
+
|
|
105
|
+
spies.select
|
|
106
|
+
.mockReturnValueOnce(undefined) // No existing with same code
|
|
107
|
+
.mockReturnValueOnce(undefined); // No existing currencies (first currency)
|
|
108
|
+
spies.insert.mockReturnValue(createdCurrency);
|
|
109
|
+
|
|
110
|
+
const result = await createCurrency(
|
|
111
|
+
db,
|
|
112
|
+
{
|
|
113
|
+
code: "USD",
|
|
114
|
+
name: "US Dollar",
|
|
115
|
+
symbol: "$",
|
|
116
|
+
decimalPlaces: 2,
|
|
117
|
+
},
|
|
118
|
+
ctx,
|
|
119
|
+
);
|
|
120
|
+
|
|
121
|
+
expect(result.currency.code).toBe("USD");
|
|
122
|
+
expect(result.currency.isBaseCurrency).toBe(true);
|
|
123
|
+
expect(result.currency.isActive).toBe(true);
|
|
124
|
+
});
|
|
125
|
+
|
|
126
|
+
it("creates subsequent currency as non-base", async () => {
|
|
127
|
+
const { db, spies } = createMockDb<DB>();
|
|
128
|
+
const createdCurrency = {
|
|
129
|
+
id: "new-currency-id",
|
|
130
|
+
code: "EUR",
|
|
131
|
+
name: "Euro",
|
|
132
|
+
symbol: "€",
|
|
133
|
+
decimalPlaces: 2,
|
|
134
|
+
isBaseCurrency: false,
|
|
135
|
+
isActive: true,
|
|
136
|
+
createdAt: new Date("2024-01-15T00:00:00.000Z"),
|
|
137
|
+
updatedAt: null,
|
|
138
|
+
};
|
|
139
|
+
|
|
140
|
+
spies.select
|
|
141
|
+
.mockReturnValueOnce(undefined) // No existing with same code
|
|
142
|
+
.mockReturnValueOnce(baseCurrencyUSD); // Existing currency exists
|
|
143
|
+
spies.insert.mockReturnValue(createdCurrency);
|
|
144
|
+
|
|
145
|
+
const result = await createCurrency(
|
|
146
|
+
db,
|
|
147
|
+
{
|
|
148
|
+
code: "EUR",
|
|
149
|
+
name: "Euro",
|
|
150
|
+
symbol: "€",
|
|
151
|
+
decimalPlaces: 2,
|
|
152
|
+
},
|
|
153
|
+
ctx,
|
|
154
|
+
);
|
|
155
|
+
|
|
156
|
+
expect(result.currency.code).toBe("EUR");
|
|
157
|
+
expect(result.currency.isBaseCurrency).toBe(false);
|
|
158
|
+
});
|
|
159
|
+
|
|
160
|
+
it("throws when permission is missing", async () => {
|
|
161
|
+
const { db } = createMockDb<DB>();
|
|
162
|
+
const denied: CommandContext = { actorId: "test-actor", permissions: [] };
|
|
163
|
+
await expect(
|
|
164
|
+
createCurrency(db, { code: "USD", name: "US Dollar", symbol: "$", decimalPlaces: 2 }, denied),
|
|
165
|
+
).rejects.toBeInstanceOf(InsufficientPermissionError);
|
|
166
|
+
});
|
|
167
|
+
|
|
168
|
+
it("passes custom fields through to insert", async () => {
|
|
169
|
+
const createCurrencyWithFields = makeCreateCurrency<{ region: string }>();
|
|
170
|
+
const { db, spies } = createMockDb<DB>();
|
|
171
|
+
const createdCurrency = {
|
|
172
|
+
...baseCurrencyUSD,
|
|
173
|
+
id: "new-currency-id",
|
|
174
|
+
code: "GBP",
|
|
175
|
+
region: "Europe",
|
|
176
|
+
};
|
|
177
|
+
|
|
178
|
+
spies.select
|
|
179
|
+
.mockReturnValueOnce(undefined) // No existing with same code
|
|
180
|
+
.mockReturnValueOnce(undefined); // No existing currencies
|
|
181
|
+
spies.insert.mockReturnValue(createdCurrency);
|
|
182
|
+
|
|
183
|
+
await createCurrencyWithFields(
|
|
184
|
+
db,
|
|
185
|
+
{ code: "GBP", name: "British Pound", symbol: "£", decimalPlaces: 2, region: "Europe" },
|
|
186
|
+
ctx,
|
|
187
|
+
);
|
|
188
|
+
|
|
189
|
+
expect(spies.values).toHaveBeenNthCalledWith(1, expect.objectContaining({ region: "Europe" }));
|
|
190
|
+
});
|
|
191
|
+
});
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
import { defineCommand } from "../../shared/internal";
|
|
2
|
+
import { DB } from "../generated/kysely-tailordb";
|
|
3
|
+
import {
|
|
4
|
+
DuplicateCurrencyCodeError,
|
|
5
|
+
InvalidDecimalPlacesError,
|
|
6
|
+
InvalidISOCodeError,
|
|
7
|
+
} from "../lib/errors";
|
|
8
|
+
import { permissions } from "../permissions";
|
|
9
|
+
|
|
10
|
+
interface CreateCurrencyInput {
|
|
11
|
+
code: string;
|
|
12
|
+
name: string;
|
|
13
|
+
symbol: string;
|
|
14
|
+
decimalPlaces: number;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
const ISO_CODE_PATTERN = /^[A-Z]{3}$/;
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* Function: createCurrency
|
|
21
|
+
*
|
|
22
|
+
* Establishes a new monetary unit with its ISO 4217 code, display symbol,
|
|
23
|
+
* name, and decimal precision. The first currency becomes the base currency.
|
|
24
|
+
*/
|
|
25
|
+
export function makeCreateCurrency<CF extends Record<string, unknown>>() {
|
|
26
|
+
return defineCommand(
|
|
27
|
+
permissions.createCurrency,
|
|
28
|
+
async (db: DB, input: CreateCurrencyInput & CF) => {
|
|
29
|
+
const { code, name, symbol, decimalPlaces, ...customFields } = input;
|
|
30
|
+
|
|
31
|
+
// 1. Validate ISO code format
|
|
32
|
+
if (!ISO_CODE_PATTERN.test(code)) {
|
|
33
|
+
throw new InvalidISOCodeError(code);
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
// 2. Check code uniqueness
|
|
37
|
+
const existingCurrency = await db
|
|
38
|
+
.selectFrom("Currency")
|
|
39
|
+
.selectAll()
|
|
40
|
+
.where("code", "=", code)
|
|
41
|
+
.executeTakeFirst();
|
|
42
|
+
|
|
43
|
+
if (existingCurrency) {
|
|
44
|
+
throw new DuplicateCurrencyCodeError(code);
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
// 3. Validate decimal places
|
|
48
|
+
if (decimalPlaces < 0 || decimalPlaces > 4) {
|
|
49
|
+
throw new InvalidDecimalPlacesError(decimalPlaces);
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
// 4. Check if this is the first currency
|
|
53
|
+
const anyCurrency = await db.selectFrom("Currency").selectAll().executeTakeFirst();
|
|
54
|
+
|
|
55
|
+
const isBaseCurrency = !anyCurrency;
|
|
56
|
+
|
|
57
|
+
// 5. Create currency
|
|
58
|
+
const currency = await db
|
|
59
|
+
.insertInto("Currency")
|
|
60
|
+
.values({
|
|
61
|
+
...(customFields as Record<string, unknown>),
|
|
62
|
+
code,
|
|
63
|
+
name,
|
|
64
|
+
symbol,
|
|
65
|
+
decimalPlaces,
|
|
66
|
+
isBaseCurrency,
|
|
67
|
+
isActive: true,
|
|
68
|
+
createdAt: new Date(),
|
|
69
|
+
updatedAt: null,
|
|
70
|
+
})
|
|
71
|
+
.returningAll()
|
|
72
|
+
.executeTakeFirst();
|
|
73
|
+
|
|
74
|
+
return { currency: currency! };
|
|
75
|
+
},
|
|
76
|
+
);
|
|
77
|
+
}
|
|
@@ -0,0 +1,216 @@
|
|
|
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
|
+
InactiveCurrencyError,
|
|
8
|
+
InvalidExchangeRateError,
|
|
9
|
+
SameCurrencyPairError,
|
|
10
|
+
} from "../lib/errors";
|
|
11
|
+
import {
|
|
12
|
+
baseCurrencyEUR,
|
|
13
|
+
baseCurrencyUSD,
|
|
14
|
+
baseExchangeRateUSDtoEUR,
|
|
15
|
+
inactiveCurrency,
|
|
16
|
+
} from "../testing/fixtures";
|
|
17
|
+
import { makeCreateExchangeRate } from "./createExchangeRate";
|
|
18
|
+
|
|
19
|
+
const createExchangeRate = makeCreateExchangeRate();
|
|
20
|
+
|
|
21
|
+
describe("createExchangeRate", () => {
|
|
22
|
+
const ctx: CommandContext = {
|
|
23
|
+
actorId: "test-actor",
|
|
24
|
+
permissions: ["primitives:createExchangeRate"],
|
|
25
|
+
};
|
|
26
|
+
|
|
27
|
+
// Error cases
|
|
28
|
+
it("throws when source currency doesn't exist", async () => {
|
|
29
|
+
const { db, spies } = createMockDb<DB>();
|
|
30
|
+
spies.select.mockReturnValue(undefined);
|
|
31
|
+
|
|
32
|
+
await expect(
|
|
33
|
+
createExchangeRate(
|
|
34
|
+
db,
|
|
35
|
+
{
|
|
36
|
+
sourceCurrencyId: "nonexistent-currency",
|
|
37
|
+
targetCurrencyId: baseCurrencyEUR.id,
|
|
38
|
+
rate: 0.92,
|
|
39
|
+
effectiveDate: new Date("2024-01-15"),
|
|
40
|
+
},
|
|
41
|
+
ctx,
|
|
42
|
+
),
|
|
43
|
+
).rejects.toBeInstanceOf(CurrencyNotFoundError);
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
it("throws when source currency is inactive", async () => {
|
|
47
|
+
const { db, spies } = createMockDb<DB>();
|
|
48
|
+
spies.select.mockReturnValue(inactiveCurrency);
|
|
49
|
+
|
|
50
|
+
await expect(
|
|
51
|
+
createExchangeRate(
|
|
52
|
+
db,
|
|
53
|
+
{
|
|
54
|
+
sourceCurrencyId: inactiveCurrency.id,
|
|
55
|
+
targetCurrencyId: baseCurrencyEUR.id,
|
|
56
|
+
rate: 0.92,
|
|
57
|
+
effectiveDate: new Date("2024-01-15"),
|
|
58
|
+
},
|
|
59
|
+
ctx,
|
|
60
|
+
),
|
|
61
|
+
).rejects.toBeInstanceOf(InactiveCurrencyError);
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
it("throws when target currency doesn't exist", async () => {
|
|
65
|
+
const { db, spies } = createMockDb<DB>();
|
|
66
|
+
spies.select
|
|
67
|
+
.mockReturnValueOnce(baseCurrencyUSD) // Source exists
|
|
68
|
+
.mockReturnValueOnce(undefined); // Target doesn't exist
|
|
69
|
+
|
|
70
|
+
await expect(
|
|
71
|
+
createExchangeRate(
|
|
72
|
+
db,
|
|
73
|
+
{
|
|
74
|
+
sourceCurrencyId: baseCurrencyUSD.id,
|
|
75
|
+
targetCurrencyId: "nonexistent-currency",
|
|
76
|
+
rate: 0.92,
|
|
77
|
+
effectiveDate: new Date("2024-01-15"),
|
|
78
|
+
},
|
|
79
|
+
ctx,
|
|
80
|
+
),
|
|
81
|
+
).rejects.toBeInstanceOf(CurrencyNotFoundError);
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
it("throws when target currency is inactive", async () => {
|
|
85
|
+
const { db, spies } = createMockDb<DB>();
|
|
86
|
+
spies.select
|
|
87
|
+
.mockReturnValueOnce(baseCurrencyUSD) // Source exists and active
|
|
88
|
+
.mockReturnValueOnce(inactiveCurrency); // Target is inactive
|
|
89
|
+
|
|
90
|
+
await expect(
|
|
91
|
+
createExchangeRate(
|
|
92
|
+
db,
|
|
93
|
+
{
|
|
94
|
+
sourceCurrencyId: baseCurrencyUSD.id,
|
|
95
|
+
targetCurrencyId: inactiveCurrency.id,
|
|
96
|
+
rate: 0.92,
|
|
97
|
+
effectiveDate: new Date("2024-01-15"),
|
|
98
|
+
},
|
|
99
|
+
ctx,
|
|
100
|
+
),
|
|
101
|
+
).rejects.toBeInstanceOf(InactiveCurrencyError);
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
it("throws when source and target are the same", async () => {
|
|
105
|
+
const { db, spies } = createMockDb<DB>();
|
|
106
|
+
spies.select.mockReturnValue(baseCurrencyUSD);
|
|
107
|
+
|
|
108
|
+
await expect(
|
|
109
|
+
createExchangeRate(
|
|
110
|
+
db,
|
|
111
|
+
{
|
|
112
|
+
sourceCurrencyId: baseCurrencyUSD.id,
|
|
113
|
+
targetCurrencyId: baseCurrencyUSD.id,
|
|
114
|
+
rate: 1.0,
|
|
115
|
+
effectiveDate: new Date("2024-01-15"),
|
|
116
|
+
},
|
|
117
|
+
ctx,
|
|
118
|
+
),
|
|
119
|
+
).rejects.toBeInstanceOf(SameCurrencyPairError);
|
|
120
|
+
});
|
|
121
|
+
|
|
122
|
+
it("throws when rate is not positive", async () => {
|
|
123
|
+
const { db, spies } = createMockDb<DB>();
|
|
124
|
+
spies.select.mockReturnValueOnce(baseCurrencyUSD).mockReturnValueOnce(baseCurrencyEUR);
|
|
125
|
+
|
|
126
|
+
await expect(
|
|
127
|
+
createExchangeRate(
|
|
128
|
+
db,
|
|
129
|
+
{
|
|
130
|
+
sourceCurrencyId: baseCurrencyUSD.id,
|
|
131
|
+
targetCurrencyId: baseCurrencyEUR.id,
|
|
132
|
+
rate: 0,
|
|
133
|
+
effectiveDate: new Date("2024-01-15"),
|
|
134
|
+
},
|
|
135
|
+
ctx,
|
|
136
|
+
),
|
|
137
|
+
).rejects.toBeInstanceOf(InvalidExchangeRateError);
|
|
138
|
+
});
|
|
139
|
+
|
|
140
|
+
// Success cases
|
|
141
|
+
it("creates exchange rate successfully", async () => {
|
|
142
|
+
const { db, spies } = createMockDb<DB>();
|
|
143
|
+
const createdRate = {
|
|
144
|
+
id: "new-rate-id",
|
|
145
|
+
sourceCurrencyId: baseCurrencyUSD.id,
|
|
146
|
+
targetCurrencyId: baseCurrencyEUR.id,
|
|
147
|
+
rate: 0.92,
|
|
148
|
+
effectiveDate: new Date("2024-01-15T00:00:00.000Z"),
|
|
149
|
+
createdAt: new Date("2024-01-15T00:00:00.000Z"),
|
|
150
|
+
updatedAt: null,
|
|
151
|
+
};
|
|
152
|
+
|
|
153
|
+
spies.select.mockReturnValueOnce(baseCurrencyUSD).mockReturnValueOnce(baseCurrencyEUR);
|
|
154
|
+
spies.insert.mockReturnValue(createdRate);
|
|
155
|
+
|
|
156
|
+
const result = await createExchangeRate(
|
|
157
|
+
db,
|
|
158
|
+
{
|
|
159
|
+
sourceCurrencyId: baseCurrencyUSD.id,
|
|
160
|
+
targetCurrencyId: baseCurrencyEUR.id,
|
|
161
|
+
rate: 0.92,
|
|
162
|
+
effectiveDate: new Date("2024-01-15"),
|
|
163
|
+
},
|
|
164
|
+
ctx,
|
|
165
|
+
);
|
|
166
|
+
|
|
167
|
+
expect(result.exchangeRate.rate).toBe(0.92);
|
|
168
|
+
expect(result.exchangeRate.sourceCurrencyId).toBe(baseCurrencyUSD.id);
|
|
169
|
+
expect(result.exchangeRate.targetCurrencyId).toBe(baseCurrencyEUR.id);
|
|
170
|
+
expect(spies.insert).toHaveBeenCalled();
|
|
171
|
+
});
|
|
172
|
+
|
|
173
|
+
it("throws when permission is missing", async () => {
|
|
174
|
+
const { db } = createMockDb<DB>();
|
|
175
|
+
const denied: CommandContext = { actorId: "test-actor", permissions: [] };
|
|
176
|
+
await expect(
|
|
177
|
+
createExchangeRate(
|
|
178
|
+
db,
|
|
179
|
+
{
|
|
180
|
+
sourceCurrencyId: "cur-1",
|
|
181
|
+
targetCurrencyId: "cur-2",
|
|
182
|
+
rate: 1.5,
|
|
183
|
+
effectiveDate: new Date("2024-01-15"),
|
|
184
|
+
},
|
|
185
|
+
denied,
|
|
186
|
+
),
|
|
187
|
+
).rejects.toBeInstanceOf(InsufficientPermissionError);
|
|
188
|
+
});
|
|
189
|
+
|
|
190
|
+
it("passes custom fields through to insert", async () => {
|
|
191
|
+
const createExchangeRateWithFields = makeCreateExchangeRate<{ source: string }>();
|
|
192
|
+
const { db, spies } = createMockDb<DB>();
|
|
193
|
+
const createdRate = {
|
|
194
|
+
...baseExchangeRateUSDtoEUR,
|
|
195
|
+
id: "new-rate-id",
|
|
196
|
+
source: "ECB",
|
|
197
|
+
};
|
|
198
|
+
|
|
199
|
+
spies.select.mockReturnValueOnce(baseCurrencyUSD).mockReturnValueOnce(baseCurrencyEUR);
|
|
200
|
+
spies.insert.mockReturnValue(createdRate);
|
|
201
|
+
|
|
202
|
+
await createExchangeRateWithFields(
|
|
203
|
+
db,
|
|
204
|
+
{
|
|
205
|
+
sourceCurrencyId: baseCurrencyUSD.id,
|
|
206
|
+
targetCurrencyId: baseCurrencyEUR.id,
|
|
207
|
+
rate: 0.92,
|
|
208
|
+
effectiveDate: new Date("2024-01-15"),
|
|
209
|
+
source: "ECB",
|
|
210
|
+
},
|
|
211
|
+
ctx,
|
|
212
|
+
);
|
|
213
|
+
|
|
214
|
+
expect(spies.values).toHaveBeenNthCalledWith(1, expect.objectContaining({ source: "ECB" }));
|
|
215
|
+
});
|
|
216
|
+
});
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
import { defineCommand } from "../../shared/internal";
|
|
2
|
+
import { DB } from "../generated/kysely-tailordb";
|
|
3
|
+
import {
|
|
4
|
+
CurrencyNotFoundError,
|
|
5
|
+
InactiveCurrencyError,
|
|
6
|
+
InvalidExchangeRateError,
|
|
7
|
+
SameCurrencyPairError,
|
|
8
|
+
} from "../lib/errors";
|
|
9
|
+
import { permissions } from "../permissions";
|
|
10
|
+
|
|
11
|
+
interface CreateExchangeRateInput {
|
|
12
|
+
sourceCurrencyId: string;
|
|
13
|
+
targetCurrencyId: string;
|
|
14
|
+
rate: number;
|
|
15
|
+
effectiveDate: Date;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* Function: createExchangeRate
|
|
20
|
+
*
|
|
21
|
+
* Establishes a new conversion ratio between a currency pair with a specific
|
|
22
|
+
* effective date. The rate specifies how many units of the target currency
|
|
23
|
+
* equal one unit of the source currency.
|
|
24
|
+
*/
|
|
25
|
+
export function makeCreateExchangeRate<CF extends Record<string, unknown>>() {
|
|
26
|
+
return defineCommand(
|
|
27
|
+
permissions.createExchangeRate,
|
|
28
|
+
async (db: DB, input: CreateExchangeRateInput & CF) => {
|
|
29
|
+
const { sourceCurrencyId, targetCurrencyId, rate, effectiveDate, ...customFields } = input;
|
|
30
|
+
|
|
31
|
+
// 1. Check source currency exists
|
|
32
|
+
const sourceCurrency = await db
|
|
33
|
+
.selectFrom("Currency")
|
|
34
|
+
.selectAll()
|
|
35
|
+
.where("id", "=", sourceCurrencyId)
|
|
36
|
+
.executeTakeFirst();
|
|
37
|
+
|
|
38
|
+
if (!sourceCurrency) {
|
|
39
|
+
throw new CurrencyNotFoundError(sourceCurrencyId);
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
// 2. Check source currency is active
|
|
43
|
+
if (!sourceCurrency.isActive) {
|
|
44
|
+
throw new InactiveCurrencyError(sourceCurrency.code);
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
// 3. Check target currency exists
|
|
48
|
+
const targetCurrency = await db
|
|
49
|
+
.selectFrom("Currency")
|
|
50
|
+
.selectAll()
|
|
51
|
+
.where("id", "=", targetCurrencyId)
|
|
52
|
+
.executeTakeFirst();
|
|
53
|
+
|
|
54
|
+
if (!targetCurrency) {
|
|
55
|
+
throw new CurrencyNotFoundError(targetCurrencyId);
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
// 4. Check target currency is active
|
|
59
|
+
if (!targetCurrency.isActive) {
|
|
60
|
+
throw new InactiveCurrencyError(targetCurrency.code);
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
// 5. Check source and target are different
|
|
64
|
+
if (sourceCurrencyId === targetCurrencyId) {
|
|
65
|
+
throw new SameCurrencyPairError(sourceCurrencyId);
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
// 6. Validate rate is positive
|
|
69
|
+
if (rate <= 0) {
|
|
70
|
+
throw new InvalidExchangeRateError(rate);
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
// 7. Create exchange rate
|
|
74
|
+
const exchangeRate = await db
|
|
75
|
+
.insertInto("ExchangeRate")
|
|
76
|
+
.values({
|
|
77
|
+
...(customFields as Record<string, unknown>),
|
|
78
|
+
sourceCurrencyId,
|
|
79
|
+
targetCurrencyId,
|
|
80
|
+
rate,
|
|
81
|
+
effectiveDate,
|
|
82
|
+
createdAt: new Date(),
|
|
83
|
+
updatedAt: null,
|
|
84
|
+
})
|
|
85
|
+
.returningAll()
|
|
86
|
+
.executeTakeFirst();
|
|
87
|
+
|
|
88
|
+
return { exchangeRate: exchangeRate! };
|
|
89
|
+
},
|
|
90
|
+
);
|
|
91
|
+
}
|