@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.
Files changed (231) hide show
  1. package/CHANGELOG.md +7 -0
  2. package/LICENSE +21 -0
  3. package/README.md +196 -28
  4. package/dist/cli.js +914 -0
  5. package/package.json +67 -8
  6. package/schemas/app-compose/actors.yml +34 -0
  7. package/schemas/app-compose/business-flow.yml +50 -0
  8. package/schemas/app-compose/requirements.yml +33 -0
  9. package/schemas/app-compose/resolver.yml +47 -0
  10. package/schemas/app-compose/screen.yml +81 -0
  11. package/schemas/app-compose/story.yml +67 -0
  12. package/schemas/module/command.yml +52 -0
  13. package/schemas/module/feature.yml +58 -0
  14. package/schemas/module/model.yml +70 -0
  15. package/schemas/module/module.yml +50 -0
  16. package/skills/1-module-docs/SKILL.md +111 -0
  17. package/skills/1-module-docs/references/structure.md +22 -0
  18. package/skills/2-module-feature-breakdown/SKILL.md +72 -0
  19. package/skills/2-module-feature-breakdown/references/commands.md +48 -0
  20. package/skills/2-module-feature-breakdown/references/models.md +29 -0
  21. package/skills/2-module-feature-breakdown/references/structure.md +22 -0
  22. package/skills/3-module-doc-review/SKILL.md +236 -0
  23. package/skills/3-module-doc-review/references/commands.md +54 -0
  24. package/skills/3-module-doc-review/references/models.md +29 -0
  25. package/skills/3-module-doc-review/references/testing.md +37 -0
  26. package/skills/4-module-tdd-implementation/SKILL.md +74 -0
  27. package/skills/4-module-tdd-implementation/references/commands.md +45 -0
  28. package/skills/4-module-tdd-implementation/references/db-relations.md +69 -0
  29. package/skills/4-module-tdd-implementation/references/errors.md +7 -0
  30. package/skills/4-module-tdd-implementation/references/exports.md +8 -0
  31. package/skills/4-module-tdd-implementation/references/models.md +30 -0
  32. package/skills/4-module-tdd-implementation/references/structure.md +22 -0
  33. package/skills/4-module-tdd-implementation/references/testing.md +37 -0
  34. package/skills/5-module-implementation-review/SKILL.md +408 -0
  35. package/skills/5-module-implementation-review/references/commands.md +45 -0
  36. package/skills/5-module-implementation-review/references/errors.md +7 -0
  37. package/skills/5-module-implementation-review/references/exports.md +8 -0
  38. package/skills/5-module-implementation-review/references/models.md +30 -0
  39. package/skills/5-module-implementation-review/references/testing.md +29 -0
  40. package/skills/app-compose-1-requirement-analysis/SKILL.md +89 -0
  41. package/skills/app-compose-1-requirement-analysis/references/structure.md +27 -0
  42. package/skills/app-compose-2-requirements-breakdown/SKILL.md +95 -0
  43. package/skills/app-compose-2-requirements-breakdown/references/screen-detailview.md +106 -0
  44. package/skills/app-compose-2-requirements-breakdown/references/screen-form.md +139 -0
  45. package/skills/app-compose-2-requirements-breakdown/references/screen-listview.md +153 -0
  46. package/skills/app-compose-2-requirements-breakdown/references/structure.md +27 -0
  47. package/skills/app-compose-3-doc-review/SKILL.md +116 -0
  48. package/skills/app-compose-3-doc-review/references/structure.md +27 -0
  49. package/skills/app-compose-4-design-mock/SKILL.md +256 -0
  50. package/skills/app-compose-4-design-mock/references/component.md +50 -0
  51. package/skills/app-compose-4-design-mock/references/screen-detailview.md +106 -0
  52. package/skills/app-compose-4-design-mock/references/screen-form.md +139 -0
  53. package/skills/app-compose-4-design-mock/references/screen-listview.md +153 -0
  54. package/skills/app-compose-4-design-mock/references/structure.md +27 -0
  55. package/skills/app-compose-5-design-mock-review/SKILL.md +290 -0
  56. package/skills/app-compose-5-design-mock-review/references/component.md +50 -0
  57. package/skills/app-compose-5-design-mock-review/references/screen-detailview.md +106 -0
  58. package/skills/app-compose-5-design-mock-review/references/screen-form.md +139 -0
  59. package/skills/app-compose-5-design-mock-review/references/screen-listview.md +153 -0
  60. package/skills/app-compose-6-implementation-spec/SKILL.md +127 -0
  61. package/skills/app-compose-6-implementation-spec/references/auth.md +72 -0
  62. package/skills/app-compose-6-implementation-spec/references/structure.md +27 -0
  63. package/skills/mock-scenario/SKILL.md +118 -0
  64. package/src/app.ts +1 -0
  65. package/src/cli.ts +120 -0
  66. package/src/commands/check.test.ts +30 -0
  67. package/src/commands/check.ts +66 -0
  68. package/src/commands/init.test.ts +88 -0
  69. package/src/commands/init.ts +120 -0
  70. package/src/commands/mock/index.ts +53 -0
  71. package/src/commands/mock/start.ts +179 -0
  72. package/src/commands/mock/validate.test.ts +185 -0
  73. package/src/commands/mock/validate.ts +198 -0
  74. package/src/commands/scaffold.test.ts +76 -0
  75. package/src/commands/scaffold.ts +119 -0
  76. package/src/commands/sync-check.test.ts +125 -0
  77. package/src/commands/sync-check.ts +182 -0
  78. package/src/integration.test.ts +63 -0
  79. package/src/mdschema.ts +48 -0
  80. package/src/mockServer.ts +55 -0
  81. package/src/module.ts +86 -0
  82. package/src/modules/accounting/.gitkeep +0 -0
  83. package/src/modules/coa-management/.gitkeep +0 -0
  84. package/src/modules/inventory/.gitkeep +0 -0
  85. package/src/modules/manufacturing/.gitkeep +0 -0
  86. package/src/modules/primitives/README.md +39 -0
  87. package/src/modules/primitives/command/activateCategory.test.ts +75 -0
  88. package/src/modules/primitives/command/activateCategory.ts +50 -0
  89. package/src/modules/primitives/command/activateCurrency.test.ts +70 -0
  90. package/src/modules/primitives/command/activateCurrency.ts +50 -0
  91. package/src/modules/primitives/command/activateUnit.test.ts +53 -0
  92. package/src/modules/primitives/command/activateUnit.ts +50 -0
  93. package/src/modules/primitives/command/convertAmount.test.ts +275 -0
  94. package/src/modules/primitives/command/convertAmount.ts +126 -0
  95. package/src/modules/primitives/command/convertQuantity.test.ts +219 -0
  96. package/src/modules/primitives/command/convertQuantity.ts +73 -0
  97. package/src/modules/primitives/command/createCategory.test.ts +126 -0
  98. package/src/modules/primitives/command/createCategory.ts +89 -0
  99. package/src/modules/primitives/command/createCurrency.test.ts +191 -0
  100. package/src/modules/primitives/command/createCurrency.ts +77 -0
  101. package/src/modules/primitives/command/createExchangeRate.test.ts +216 -0
  102. package/src/modules/primitives/command/createExchangeRate.ts +91 -0
  103. package/src/modules/primitives/command/createUnit.test.ts +214 -0
  104. package/src/modules/primitives/command/createUnit.ts +88 -0
  105. package/src/modules/primitives/command/deactivateCategory.test.ts +97 -0
  106. package/src/modules/primitives/command/deactivateCategory.ts +62 -0
  107. package/src/modules/primitives/command/deactivateCurrency.test.ts +85 -0
  108. package/src/modules/primitives/command/deactivateCurrency.ts +55 -0
  109. package/src/modules/primitives/command/deactivateUnit.test.ts +78 -0
  110. package/src/modules/primitives/command/deactivateUnit.ts +62 -0
  111. package/src/modules/primitives/command/setBaseCurrency.test.ts +98 -0
  112. package/src/modules/primitives/command/setBaseCurrency.ts +74 -0
  113. package/src/modules/primitives/command/setReferenceUnit.test.ts +108 -0
  114. package/src/modules/primitives/command/setReferenceUnit.ts +84 -0
  115. package/src/modules/primitives/db/currency.ts +30 -0
  116. package/src/modules/primitives/db/exchangeRate.ts +28 -0
  117. package/src/modules/primitives/db/unit.ts +32 -0
  118. package/src/modules/primitives/db/uomCategory.ts +32 -0
  119. package/src/modules/primitives/docs/commands/ActivateCategory.md +34 -0
  120. package/src/modules/primitives/docs/commands/ActivateCurrency.md +33 -0
  121. package/src/modules/primitives/docs/commands/ActivateUnit.md +34 -0
  122. package/src/modules/primitives/docs/commands/ConvertAmount.md +50 -0
  123. package/src/modules/primitives/docs/commands/ConvertQuantity.md +43 -0
  124. package/src/modules/primitives/docs/commands/CreateCategory.md +44 -0
  125. package/src/modules/primitives/docs/commands/CreateCurrency.md +47 -0
  126. package/src/modules/primitives/docs/commands/CreateExchangeRate.md +48 -0
  127. package/src/modules/primitives/docs/commands/CreateUnit.md +48 -0
  128. package/src/modules/primitives/docs/commands/DeactivateCategory.md +38 -0
  129. package/src/modules/primitives/docs/commands/DeactivateCurrency.md +38 -0
  130. package/src/modules/primitives/docs/commands/DeactivateUnit.md +38 -0
  131. package/src/modules/primitives/docs/commands/SetBaseCurrency.md +39 -0
  132. package/src/modules/primitives/docs/commands/SetReferenceUnit.md +43 -0
  133. package/src/modules/primitives/docs/features/currency-definitions.md +55 -0
  134. package/src/modules/primitives/docs/features/exchange-rates.md +61 -0
  135. package/src/modules/primitives/docs/features/unit-conversion.md +66 -0
  136. package/src/modules/primitives/docs/features/uom-categories.md +52 -0
  137. package/src/modules/primitives/docs/models/Currency.md +45 -0
  138. package/src/modules/primitives/docs/models/ExchangeRate.md +33 -0
  139. package/src/modules/primitives/docs/models/Unit.md +46 -0
  140. package/src/modules/primitives/docs/models/UoMCategory.md +44 -0
  141. package/src/modules/primitives/generated/kysely-tailordb.ts +95 -0
  142. package/src/modules/primitives/index.ts +40 -0
  143. package/src/modules/primitives/lib/errors.ts +138 -0
  144. package/src/modules/primitives/lib/types.ts +20 -0
  145. package/src/modules/primitives/module.ts +66 -0
  146. package/src/modules/primitives/permissions.ts +18 -0
  147. package/src/modules/primitives/tailor.config.ts +11 -0
  148. package/src/modules/primitives/testing/fixtures.ts +161 -0
  149. package/src/modules/product-management/.gitkeep +0 -0
  150. package/src/modules/purchase/.gitkeep +0 -0
  151. package/src/modules/sales/.gitkeep +0 -0
  152. package/src/modules/shared/createContext.test.ts +39 -0
  153. package/src/modules/shared/createContext.ts +15 -0
  154. package/src/modules/shared/defineCommand.test.ts +42 -0
  155. package/src/modules/shared/defineCommand.ts +19 -0
  156. package/src/modules/shared/definePermissions.test.ts +146 -0
  157. package/src/modules/shared/definePermissions.ts +94 -0
  158. package/src/modules/shared/entityTypes.ts +15 -0
  159. package/src/modules/shared/errors.ts +22 -0
  160. package/src/modules/shared/index.ts +1 -0
  161. package/src/modules/shared/internal.ts +13 -0
  162. package/src/modules/shared/requirePermission.test.ts +47 -0
  163. package/src/modules/shared/requirePermission.ts +8 -0
  164. package/src/modules/shared/types.ts +4 -0
  165. package/src/modules/supplier-management/.gitkeep +0 -0
  166. package/src/modules/supplier-portal/.gitkeep +0 -0
  167. package/src/modules/testing/index.ts +120 -0
  168. package/src/modules/user-management/README.md +38 -0
  169. package/src/modules/user-management/command/activateUser.test.ts +112 -0
  170. package/src/modules/user-management/command/activateUser.ts +67 -0
  171. package/src/modules/user-management/command/assignPermissionToRole.test.ts +119 -0
  172. package/src/modules/user-management/command/assignPermissionToRole.ts +87 -0
  173. package/src/modules/user-management/command/assignRoleToUser.test.ts +162 -0
  174. package/src/modules/user-management/command/assignRoleToUser.ts +93 -0
  175. package/src/modules/user-management/command/createPermission.test.ts +143 -0
  176. package/src/modules/user-management/command/createPermission.ts +66 -0
  177. package/src/modules/user-management/command/createRole.test.ts +115 -0
  178. package/src/modules/user-management/command/createRole.ts +52 -0
  179. package/src/modules/user-management/command/createUser.test.ts +198 -0
  180. package/src/modules/user-management/command/createUser.ts +85 -0
  181. package/src/modules/user-management/command/deactivateUser.test.ts +112 -0
  182. package/src/modules/user-management/command/deactivateUser.ts +67 -0
  183. package/src/modules/user-management/command/logAuditEvent.test.ts +179 -0
  184. package/src/modules/user-management/command/logAuditEvent.ts +59 -0
  185. package/src/modules/user-management/command/reactivateUser.test.ts +115 -0
  186. package/src/modules/user-management/command/reactivateUser.ts +67 -0
  187. package/src/modules/user-management/command/revokePermissionFromRole.test.ts +112 -0
  188. package/src/modules/user-management/command/revokePermissionFromRole.ts +81 -0
  189. package/src/modules/user-management/command/revokeRoleFromUser.test.ts +112 -0
  190. package/src/modules/user-management/command/revokeRoleFromUser.ts +81 -0
  191. package/src/modules/user-management/db/auditEvent.ts +47 -0
  192. package/src/modules/user-management/db/permission.ts +31 -0
  193. package/src/modules/user-management/db/role.ts +28 -0
  194. package/src/modules/user-management/db/rolePermission.ts +44 -0
  195. package/src/modules/user-management/db/user.ts +38 -0
  196. package/src/modules/user-management/db/userRole.ts +44 -0
  197. package/src/modules/user-management/docs/commands/ActivateUser.md +36 -0
  198. package/src/modules/user-management/docs/commands/AssignPermissionToRole.md +39 -0
  199. package/src/modules/user-management/docs/commands/AssignRoleToUser.md +43 -0
  200. package/src/modules/user-management/docs/commands/CreatePermission.md +35 -0
  201. package/src/modules/user-management/docs/commands/CreateRole.md +35 -0
  202. package/src/modules/user-management/docs/commands/CreateUser.md +41 -0
  203. package/src/modules/user-management/docs/commands/DeactivateUser.md +38 -0
  204. package/src/modules/user-management/docs/commands/LogAuditEvent.md +37 -0
  205. package/src/modules/user-management/docs/commands/ReactivateUser.md +37 -0
  206. package/src/modules/user-management/docs/commands/RevokePermissionFromRole.md +40 -0
  207. package/src/modules/user-management/docs/commands/RevokeRoleFromUser.md +40 -0
  208. package/src/modules/user-management/docs/features/audit-trail.md +80 -0
  209. package/src/modules/user-management/docs/features/role-based-access-control.md +76 -0
  210. package/src/modules/user-management/docs/features/user-account-management.md +64 -0
  211. package/src/modules/user-management/docs/models/AuditEvent.md +34 -0
  212. package/src/modules/user-management/docs/models/Permission.md +31 -0
  213. package/src/modules/user-management/docs/models/Role.md +31 -0
  214. package/src/modules/user-management/docs/models/RolePermission.md +33 -0
  215. package/src/modules/user-management/docs/models/User.md +47 -0
  216. package/src/modules/user-management/docs/models/UserRole.md +34 -0
  217. package/src/modules/user-management/docs/plans/2026-01-30-flattened-permissions-design.md +52 -0
  218. package/src/modules/user-management/executor/recomputeOnRolePermissionChange.ts +61 -0
  219. package/src/modules/user-management/generated/enums.ts +24 -0
  220. package/src/modules/user-management/generated/kysely-tailordb.ts +112 -0
  221. package/src/modules/user-management/index.ts +32 -0
  222. package/src/modules/user-management/lib/errors.ts +81 -0
  223. package/src/modules/user-management/lib/recomputeUserPermissions.ts +53 -0
  224. package/src/modules/user-management/lib/types.ts +31 -0
  225. package/src/modules/user-management/module.ts +77 -0
  226. package/src/modules/user-management/permissions.ts +15 -0
  227. package/src/modules/user-management/tailor.config.ts +11 -0
  228. package/src/modules/user-management/testing/fixtures.ts +98 -0
  229. package/src/schemas.ts +25 -0
  230. package/src/testing.ts +10 -0
  231. package/src/util.ts +3 -0
@@ -0,0 +1,126 @@
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 { DuplicateCategoryNameError } from "../lib/errors";
6
+ import { baseUoMCategory, baseUnitKg } from "../testing/fixtures";
7
+ import { makeCreateCategory } from "./createCategory";
8
+
9
+ const createCategory = makeCreateCategory();
10
+
11
+ describe("createCategory", () => {
12
+ const ctx: CommandContext = { actorId: "test-actor", permissions: ["primitives:createCategory"] };
13
+
14
+ // Error cases
15
+ it("throws when category name already exists", async () => {
16
+ const { db, spies } = createMockDb<DB>();
17
+ spies.select.mockReturnValue(baseUoMCategory);
18
+
19
+ await expect(
20
+ createCategory(
21
+ db,
22
+ {
23
+ name: baseUoMCategory.name,
24
+ referenceUnit: {
25
+ name: "Kilogram",
26
+ symbol: "kg",
27
+ roundingPrecision: 2,
28
+ },
29
+ },
30
+ ctx,
31
+ ),
32
+ ).rejects.toBeInstanceOf(DuplicateCategoryNameError);
33
+ });
34
+
35
+ // Success cases
36
+ it("creates category with reference unit", async () => {
37
+ const { db, spies } = createMockDb<DB>();
38
+ const createdCategory = {
39
+ ...baseUoMCategory,
40
+ id: "new-category-id",
41
+ referenceUnitId: "new-unit-id",
42
+ };
43
+ const createdUnit = {
44
+ ...baseUnitKg,
45
+ id: "new-unit-id",
46
+ categoryId: "new-category-id",
47
+ };
48
+
49
+ spies.select.mockReturnValue(undefined); // No existing category
50
+ spies.insert.mockReturnValueOnce(createdCategory).mockReturnValueOnce(createdUnit);
51
+ spies.update.mockReturnValue({ ...createdCategory, referenceUnitId: "new-unit-id" });
52
+
53
+ const result = await createCategory(
54
+ db,
55
+ {
56
+ name: "Weight",
57
+ description: "Weight units",
58
+ referenceUnit: {
59
+ name: "Kilogram",
60
+ symbol: "kg",
61
+ roundingPrecision: 2,
62
+ },
63
+ },
64
+ ctx,
65
+ );
66
+
67
+ expect(result.category.name).toBe("Weight");
68
+ expect(result.category.isActive).toBe(true);
69
+ expect(result.referenceUnit.conversionFactor).toBe(1.0);
70
+ expect(spies.insert).toHaveBeenCalled();
71
+ });
72
+
73
+ it("throws when permission is missing", async () => {
74
+ const { db } = createMockDb<DB>();
75
+ const denied: CommandContext = { actorId: "test-actor", permissions: [] };
76
+ await expect(
77
+ createCategory(
78
+ db,
79
+ { name: "Test", referenceUnit: { name: "Unit", symbol: "u", roundingPrecision: 2 } },
80
+ denied,
81
+ ),
82
+ ).rejects.toBeInstanceOf(InsufficientPermissionError);
83
+ });
84
+
85
+ it("passes custom fields through to insert", async () => {
86
+ const createCategoryWithFields = makeCreateCategory<
87
+ { priority: number },
88
+ { tolerance: number }
89
+ >();
90
+ const { db, spies } = createMockDb<DB>();
91
+ const createdCategory = {
92
+ ...baseUoMCategory,
93
+ id: "new-category-id",
94
+ referenceUnitId: "new-unit-id",
95
+ };
96
+ const createdUnit = {
97
+ ...baseUnitKg,
98
+ id: "new-unit-id",
99
+ categoryId: "new-category-id",
100
+ };
101
+
102
+ spies.select.mockReturnValue(undefined);
103
+ spies.insert.mockReturnValueOnce(createdCategory).mockReturnValueOnce(createdUnit);
104
+ spies.update.mockReturnValue({ ...createdCategory, referenceUnitId: "new-unit-id" });
105
+
106
+ await createCategoryWithFields(
107
+ db,
108
+ {
109
+ name: "Weight",
110
+ priority: 1,
111
+ referenceUnit: {
112
+ name: "Kilogram",
113
+ symbol: "kg",
114
+ roundingPrecision: 2,
115
+ tolerance: 0.01,
116
+ },
117
+ },
118
+ ctx,
119
+ );
120
+
121
+ // Category insert includes category custom field
122
+ expect(spies.values).toHaveBeenNthCalledWith(1, expect.objectContaining({ priority: 1 }));
123
+ // Unit insert includes unit custom field
124
+ expect(spies.values).toHaveBeenNthCalledWith(2, expect.objectContaining({ tolerance: 0.01 }));
125
+ });
126
+ });
@@ -0,0 +1,89 @@
1
+ import { defineCommand } from "../../shared/internal";
2
+ import { DB } from "../generated/kysely-tailordb";
3
+ import { DuplicateCategoryNameError } from "../lib/errors";
4
+ import { permissions } from "../permissions";
5
+
6
+ interface CreateCategoryInput<UnitCF extends Record<string, unknown>> {
7
+ name: string;
8
+ description?: string;
9
+ referenceUnit: {
10
+ name: string;
11
+ symbol: string;
12
+ roundingPrecision: number;
13
+ } & UnitCF;
14
+ }
15
+
16
+ /**
17
+ * Function: createCategory
18
+ *
19
+ * Establishes a new unit of measure category that groups related units.
20
+ * Creates the category with a reference unit that has conversion factor 1.0.
21
+ */
22
+ export function makeCreateCategory<
23
+ CatCF extends Record<string, unknown>,
24
+ UnitCF extends Record<string, unknown>,
25
+ >() {
26
+ return defineCommand(
27
+ permissions.createCategory,
28
+ async (db: DB, input: CreateCategoryInput<UnitCF> & CatCF) => {
29
+ const { name, description, referenceUnit, ...categoryCustomFields } = input;
30
+ const { name: unitName, symbol, roundingPrecision, ...unitCustomFields } = referenceUnit;
31
+
32
+ // 1. Check if category name already exists
33
+ const existingCategory = await db
34
+ .selectFrom("UoMCategory")
35
+ .selectAll()
36
+ .where("name", "=", name)
37
+ .executeTakeFirst();
38
+
39
+ if (existingCategory) {
40
+ throw new DuplicateCategoryNameError(name);
41
+ }
42
+
43
+ // 2. Create category without reference unit first
44
+ const category = await db
45
+ .insertInto("UoMCategory")
46
+ .values({
47
+ ...(categoryCustomFields as Record<string, unknown>),
48
+ name,
49
+ description: description ?? null,
50
+ referenceUnitId: null,
51
+ isActive: true,
52
+ createdAt: new Date(),
53
+ updatedAt: null,
54
+ })
55
+ .returningAll()
56
+ .executeTakeFirst();
57
+
58
+ // 3. Create reference unit with conversion factor 1.0
59
+ const createdReferenceUnit = await db
60
+ .insertInto("Unit")
61
+ .values({
62
+ ...(unitCustomFields as Record<string, unknown>),
63
+ name: unitName,
64
+ symbol,
65
+ categoryId: category!.id,
66
+ conversionFactor: 1.0,
67
+ roundingPrecision,
68
+ isActive: true,
69
+ createdAt: new Date(),
70
+ updatedAt: null,
71
+ })
72
+ .returningAll()
73
+ .executeTakeFirst();
74
+
75
+ // 4. Update category with reference unit ID
76
+ const updatedCategory = await db
77
+ .updateTable("UoMCategory")
78
+ .set({
79
+ referenceUnitId: createdReferenceUnit!.id,
80
+ updatedAt: new Date(),
81
+ })
82
+ .where("id", "=", category!.id)
83
+ .returningAll()
84
+ .executeTakeFirst();
85
+
86
+ return { category: updatedCategory!, referenceUnit: createdReferenceUnit! };
87
+ },
88
+ );
89
+ }
@@ -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
+ });