@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.
Files changed (213) hide show
  1. package/README.md +196 -28
  2. package/dist/cli.js +894 -0
  3. package/package.json +65 -8
  4. package/rules/app-compose/backend/auth.md +78 -0
  5. package/rules/app-compose/frontend/auth.md +55 -0
  6. package/rules/app-compose/frontend/component.md +55 -0
  7. package/rules/app-compose/frontend/page.md +86 -0
  8. package/rules/app-compose/frontend/screen-detailview.md +112 -0
  9. package/rules/app-compose/frontend/screen-form.md +145 -0
  10. package/rules/app-compose/frontend/screen-listview.md +159 -0
  11. package/rules/app-compose/structure.md +32 -0
  12. package/rules/module-development/commands.md +54 -0
  13. package/rules/module-development/cross-module-type-injection.md +28 -0
  14. package/rules/module-development/dependency-modules.md +24 -0
  15. package/rules/module-development/errors.md +12 -0
  16. package/rules/module-development/executors.md +67 -0
  17. package/rules/module-development/exports.md +13 -0
  18. package/rules/module-development/models.md +34 -0
  19. package/rules/module-development/structure.md +27 -0
  20. package/rules/module-development/sync-vs-async-operations.md +83 -0
  21. package/rules/module-development/testing.md +43 -0
  22. package/rules/sdk-best-practices/db-relations.md +74 -0
  23. package/rules/sdk-best-practices/sdk-docs.md +14 -0
  24. package/schemas/app-compose/actors.yml +34 -0
  25. package/schemas/app-compose/business-flow.yml +50 -0
  26. package/schemas/app-compose/requirements.yml +33 -0
  27. package/schemas/app-compose/resolver.yml +47 -0
  28. package/schemas/app-compose/screen.yml +81 -0
  29. package/schemas/app-compose/story.yml +67 -0
  30. package/schemas/module/command.yml +52 -0
  31. package/schemas/module/feature.yml +58 -0
  32. package/schemas/module/model.yml +70 -0
  33. package/schemas/module/module.yml +50 -0
  34. package/skills/1-module-docs/SKILL.md +107 -0
  35. package/skills/2-module-feature-breakdown/SKILL.md +66 -0
  36. package/skills/3-module-doc-review/SKILL.md +230 -0
  37. package/skills/4-module-tdd-implementation/SKILL.md +56 -0
  38. package/skills/5-module-implementation-review/SKILL.md +400 -0
  39. package/skills/app-compose-1-requirement-analysis/SKILL.md +85 -0
  40. package/skills/app-compose-2-requirements-breakdown/SKILL.md +88 -0
  41. package/skills/app-compose-3-doc-review/SKILL.md +112 -0
  42. package/skills/app-compose-4-design-mock/SKILL.md +248 -0
  43. package/skills/app-compose-5-design-mock-review/SKILL.md +283 -0
  44. package/skills/app-compose-6-implementation-spec/SKILL.md +122 -0
  45. package/skills/mock-scenario/SKILL.md +118 -0
  46. package/src/app.ts +1 -0
  47. package/src/cli.ts +120 -0
  48. package/src/commands/check.test.ts +30 -0
  49. package/src/commands/check.ts +66 -0
  50. package/src/commands/init.test.ts +77 -0
  51. package/src/commands/init.ts +87 -0
  52. package/src/commands/mock/index.ts +53 -0
  53. package/src/commands/mock/start.ts +179 -0
  54. package/src/commands/mock/validate.test.ts +185 -0
  55. package/src/commands/mock/validate.ts +198 -0
  56. package/src/commands/scaffold.test.ts +76 -0
  57. package/src/commands/scaffold.ts +119 -0
  58. package/src/commands/sync-check.test.ts +125 -0
  59. package/src/commands/sync-check.ts +182 -0
  60. package/src/integration.test.ts +63 -0
  61. package/src/mdschema.ts +48 -0
  62. package/src/mockServer.ts +55 -0
  63. package/src/module.ts +86 -0
  64. package/src/modules/accounting/.gitkeep +0 -0
  65. package/src/modules/coa-management/.gitkeep +0 -0
  66. package/src/modules/inventory/.gitkeep +0 -0
  67. package/src/modules/manufacturing/.gitkeep +0 -0
  68. package/src/modules/primitives/README.md +39 -0
  69. package/src/modules/primitives/command/activateCategory.test.ts +75 -0
  70. package/src/modules/primitives/command/activateCategory.ts +50 -0
  71. package/src/modules/primitives/command/activateCurrency.test.ts +70 -0
  72. package/src/modules/primitives/command/activateCurrency.ts +50 -0
  73. package/src/modules/primitives/command/activateUnit.test.ts +53 -0
  74. package/src/modules/primitives/command/activateUnit.ts +50 -0
  75. package/src/modules/primitives/command/convertAmount.test.ts +275 -0
  76. package/src/modules/primitives/command/convertAmount.ts +126 -0
  77. package/src/modules/primitives/command/convertQuantity.test.ts +219 -0
  78. package/src/modules/primitives/command/convertQuantity.ts +73 -0
  79. package/src/modules/primitives/command/createCategory.test.ts +126 -0
  80. package/src/modules/primitives/command/createCategory.ts +89 -0
  81. package/src/modules/primitives/command/createCurrency.test.ts +191 -0
  82. package/src/modules/primitives/command/createCurrency.ts +77 -0
  83. package/src/modules/primitives/command/createExchangeRate.test.ts +216 -0
  84. package/src/modules/primitives/command/createExchangeRate.ts +91 -0
  85. package/src/modules/primitives/command/createUnit.test.ts +214 -0
  86. package/src/modules/primitives/command/createUnit.ts +88 -0
  87. package/src/modules/primitives/command/deactivateCategory.test.ts +97 -0
  88. package/src/modules/primitives/command/deactivateCategory.ts +62 -0
  89. package/src/modules/primitives/command/deactivateCurrency.test.ts +85 -0
  90. package/src/modules/primitives/command/deactivateCurrency.ts +55 -0
  91. package/src/modules/primitives/command/deactivateUnit.test.ts +78 -0
  92. package/src/modules/primitives/command/deactivateUnit.ts +62 -0
  93. package/src/modules/primitives/command/setBaseCurrency.test.ts +98 -0
  94. package/src/modules/primitives/command/setBaseCurrency.ts +74 -0
  95. package/src/modules/primitives/command/setReferenceUnit.test.ts +108 -0
  96. package/src/modules/primitives/command/setReferenceUnit.ts +84 -0
  97. package/src/modules/primitives/db/currency.ts +30 -0
  98. package/src/modules/primitives/db/exchangeRate.ts +28 -0
  99. package/src/modules/primitives/db/unit.ts +32 -0
  100. package/src/modules/primitives/db/uomCategory.ts +32 -0
  101. package/src/modules/primitives/docs/commands/ActivateCategory.md +34 -0
  102. package/src/modules/primitives/docs/commands/ActivateCurrency.md +33 -0
  103. package/src/modules/primitives/docs/commands/ActivateUnit.md +34 -0
  104. package/src/modules/primitives/docs/commands/ConvertAmount.md +50 -0
  105. package/src/modules/primitives/docs/commands/ConvertQuantity.md +43 -0
  106. package/src/modules/primitives/docs/commands/CreateCategory.md +44 -0
  107. package/src/modules/primitives/docs/commands/CreateCurrency.md +47 -0
  108. package/src/modules/primitives/docs/commands/CreateExchangeRate.md +48 -0
  109. package/src/modules/primitives/docs/commands/CreateUnit.md +48 -0
  110. package/src/modules/primitives/docs/commands/DeactivateCategory.md +38 -0
  111. package/src/modules/primitives/docs/commands/DeactivateCurrency.md +38 -0
  112. package/src/modules/primitives/docs/commands/DeactivateUnit.md +38 -0
  113. package/src/modules/primitives/docs/commands/SetBaseCurrency.md +39 -0
  114. package/src/modules/primitives/docs/commands/SetReferenceUnit.md +43 -0
  115. package/src/modules/primitives/docs/features/currency-definitions.md +55 -0
  116. package/src/modules/primitives/docs/features/exchange-rates.md +61 -0
  117. package/src/modules/primitives/docs/features/unit-conversion.md +66 -0
  118. package/src/modules/primitives/docs/features/uom-categories.md +52 -0
  119. package/src/modules/primitives/docs/models/Currency.md +45 -0
  120. package/src/modules/primitives/docs/models/ExchangeRate.md +33 -0
  121. package/src/modules/primitives/docs/models/Unit.md +46 -0
  122. package/src/modules/primitives/docs/models/UoMCategory.md +44 -0
  123. package/src/modules/primitives/generated/kysely-tailordb.ts +95 -0
  124. package/src/modules/primitives/index.ts +40 -0
  125. package/src/modules/primitives/lib/errors.ts +138 -0
  126. package/src/modules/primitives/lib/types.ts +20 -0
  127. package/src/modules/primitives/module.ts +66 -0
  128. package/src/modules/primitives/permissions.ts +18 -0
  129. package/src/modules/primitives/tailor.config.ts +11 -0
  130. package/src/modules/primitives/testing/fixtures.ts +161 -0
  131. package/src/modules/product-management/.gitkeep +0 -0
  132. package/src/modules/purchase/.gitkeep +0 -0
  133. package/src/modules/sales/.gitkeep +0 -0
  134. package/src/modules/shared/createContext.test.ts +39 -0
  135. package/src/modules/shared/createContext.ts +15 -0
  136. package/src/modules/shared/defineCommand.test.ts +42 -0
  137. package/src/modules/shared/defineCommand.ts +19 -0
  138. package/src/modules/shared/definePermissions.test.ts +146 -0
  139. package/src/modules/shared/definePermissions.ts +94 -0
  140. package/src/modules/shared/entityTypes.ts +15 -0
  141. package/src/modules/shared/errors.ts +22 -0
  142. package/src/modules/shared/index.ts +1 -0
  143. package/src/modules/shared/internal.ts +13 -0
  144. package/src/modules/shared/requirePermission.test.ts +47 -0
  145. package/src/modules/shared/requirePermission.ts +8 -0
  146. package/src/modules/shared/types.ts +4 -0
  147. package/src/modules/supplier-management/.gitkeep +0 -0
  148. package/src/modules/supplier-portal/.gitkeep +0 -0
  149. package/src/modules/testing/index.ts +120 -0
  150. package/src/modules/user-management/README.md +38 -0
  151. package/src/modules/user-management/command/activateUser.test.ts +112 -0
  152. package/src/modules/user-management/command/activateUser.ts +67 -0
  153. package/src/modules/user-management/command/assignPermissionToRole.test.ts +119 -0
  154. package/src/modules/user-management/command/assignPermissionToRole.ts +87 -0
  155. package/src/modules/user-management/command/assignRoleToUser.test.ts +162 -0
  156. package/src/modules/user-management/command/assignRoleToUser.ts +93 -0
  157. package/src/modules/user-management/command/createPermission.test.ts +143 -0
  158. package/src/modules/user-management/command/createPermission.ts +66 -0
  159. package/src/modules/user-management/command/createRole.test.ts +115 -0
  160. package/src/modules/user-management/command/createRole.ts +52 -0
  161. package/src/modules/user-management/command/createUser.test.ts +198 -0
  162. package/src/modules/user-management/command/createUser.ts +85 -0
  163. package/src/modules/user-management/command/deactivateUser.test.ts +112 -0
  164. package/src/modules/user-management/command/deactivateUser.ts +67 -0
  165. package/src/modules/user-management/command/logAuditEvent.test.ts +179 -0
  166. package/src/modules/user-management/command/logAuditEvent.ts +59 -0
  167. package/src/modules/user-management/command/reactivateUser.test.ts +115 -0
  168. package/src/modules/user-management/command/reactivateUser.ts +67 -0
  169. package/src/modules/user-management/command/revokePermissionFromRole.test.ts +112 -0
  170. package/src/modules/user-management/command/revokePermissionFromRole.ts +81 -0
  171. package/src/modules/user-management/command/revokeRoleFromUser.test.ts +112 -0
  172. package/src/modules/user-management/command/revokeRoleFromUser.ts +81 -0
  173. package/src/modules/user-management/db/auditEvent.ts +47 -0
  174. package/src/modules/user-management/db/permission.ts +31 -0
  175. package/src/modules/user-management/db/role.ts +28 -0
  176. package/src/modules/user-management/db/rolePermission.ts +44 -0
  177. package/src/modules/user-management/db/user.ts +38 -0
  178. package/src/modules/user-management/db/userRole.ts +44 -0
  179. package/src/modules/user-management/docs/commands/ActivateUser.md +36 -0
  180. package/src/modules/user-management/docs/commands/AssignPermissionToRole.md +39 -0
  181. package/src/modules/user-management/docs/commands/AssignRoleToUser.md +43 -0
  182. package/src/modules/user-management/docs/commands/CreatePermission.md +35 -0
  183. package/src/modules/user-management/docs/commands/CreateRole.md +35 -0
  184. package/src/modules/user-management/docs/commands/CreateUser.md +41 -0
  185. package/src/modules/user-management/docs/commands/DeactivateUser.md +38 -0
  186. package/src/modules/user-management/docs/commands/LogAuditEvent.md +37 -0
  187. package/src/modules/user-management/docs/commands/ReactivateUser.md +37 -0
  188. package/src/modules/user-management/docs/commands/RevokePermissionFromRole.md +40 -0
  189. package/src/modules/user-management/docs/commands/RevokeRoleFromUser.md +40 -0
  190. package/src/modules/user-management/docs/features/audit-trail.md +80 -0
  191. package/src/modules/user-management/docs/features/role-based-access-control.md +76 -0
  192. package/src/modules/user-management/docs/features/user-account-management.md +64 -0
  193. package/src/modules/user-management/docs/models/AuditEvent.md +34 -0
  194. package/src/modules/user-management/docs/models/Permission.md +31 -0
  195. package/src/modules/user-management/docs/models/Role.md +31 -0
  196. package/src/modules/user-management/docs/models/RolePermission.md +33 -0
  197. package/src/modules/user-management/docs/models/User.md +47 -0
  198. package/src/modules/user-management/docs/models/UserRole.md +34 -0
  199. package/src/modules/user-management/docs/plans/2026-01-30-flattened-permissions-design.md +52 -0
  200. package/src/modules/user-management/executor/recomputeOnRolePermissionChange.ts +61 -0
  201. package/src/modules/user-management/generated/enums.ts +24 -0
  202. package/src/modules/user-management/generated/kysely-tailordb.ts +112 -0
  203. package/src/modules/user-management/index.ts +32 -0
  204. package/src/modules/user-management/lib/errors.ts +81 -0
  205. package/src/modules/user-management/lib/recomputeUserPermissions.ts +53 -0
  206. package/src/modules/user-management/lib/types.ts +31 -0
  207. package/src/modules/user-management/module.ts +77 -0
  208. package/src/modules/user-management/permissions.ts +15 -0
  209. package/src/modules/user-management/tailor.config.ts +11 -0
  210. package/src/modules/user-management/testing/fixtures.ts +98 -0
  211. package/src/schemas.ts +25 -0
  212. package/src/testing.ts +10 -0
  213. 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
+ }