@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,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
+ }
@@ -0,0 +1,214 @@
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 {
6
+ CategoryNotActiveError,
7
+ DuplicateUnitSymbolError,
8
+ InvalidConversionFactorError,
9
+ InvalidRoundingPrecisionError,
10
+ UoMCategoryNotFoundError,
11
+ } from "../lib/errors";
12
+ import { baseUoMCategory, baseUnitKg } from "../testing/fixtures";
13
+ import { makeCreateUnit } from "./createUnit";
14
+
15
+ const createUnit = makeCreateUnit();
16
+
17
+ describe("createUnit", () => {
18
+ const ctx: CommandContext = { actorId: "test-actor", permissions: ["primitives:createUnit"] };
19
+
20
+ const inactiveCategory = {
21
+ ...baseUoMCategory,
22
+ isActive: false,
23
+ };
24
+
25
+ // Error cases
26
+ it("throws when category doesn't exist", async () => {
27
+ const { db, spies } = createMockDb<DB>();
28
+ spies.select.mockReturnValue(undefined);
29
+
30
+ await expect(
31
+ createUnit(
32
+ db,
33
+ {
34
+ categoryId: "nonexistent-category",
35
+ name: "Ounce",
36
+ symbol: "oz",
37
+ conversionFactor: 0.0283495,
38
+ roundingPrecision: 2,
39
+ },
40
+ ctx,
41
+ ),
42
+ ).rejects.toBeInstanceOf(UoMCategoryNotFoundError);
43
+ });
44
+
45
+ it("throws when category is inactive", async () => {
46
+ const { db, spies } = createMockDb<DB>();
47
+ spies.select.mockReturnValue(inactiveCategory);
48
+
49
+ await expect(
50
+ createUnit(
51
+ db,
52
+ {
53
+ categoryId: inactiveCategory.id,
54
+ name: "Ounce",
55
+ symbol: "oz",
56
+ conversionFactor: 0.0283495,
57
+ roundingPrecision: 2,
58
+ },
59
+ ctx,
60
+ ),
61
+ ).rejects.toBeInstanceOf(CategoryNotActiveError);
62
+ });
63
+
64
+ it("throws when symbol already exists in category", async () => {
65
+ const { db, spies } = createMockDb<DB>();
66
+ spies.select
67
+ .mockReturnValueOnce(baseUoMCategory) // Category lookup
68
+ .mockReturnValueOnce(baseUnitKg); // Existing unit with same symbol
69
+
70
+ await expect(
71
+ createUnit(
72
+ db,
73
+ {
74
+ categoryId: baseUoMCategory.id,
75
+ name: "Kilogram Duplicate",
76
+ symbol: "kg",
77
+ conversionFactor: 1.0,
78
+ roundingPrecision: 2,
79
+ },
80
+ ctx,
81
+ ),
82
+ ).rejects.toBeInstanceOf(DuplicateUnitSymbolError);
83
+ });
84
+
85
+ it("throws when conversion factor is not positive", async () => {
86
+ const { db, spies } = createMockDb<DB>();
87
+ spies.select
88
+ .mockReturnValueOnce(baseUoMCategory) // Category lookup
89
+ .mockReturnValueOnce(undefined); // No duplicate symbol
90
+
91
+ await expect(
92
+ createUnit(
93
+ db,
94
+ {
95
+ categoryId: baseUoMCategory.id,
96
+ name: "Invalid Unit",
97
+ symbol: "inv",
98
+ conversionFactor: 0,
99
+ roundingPrecision: 2,
100
+ },
101
+ ctx,
102
+ ),
103
+ ).rejects.toBeInstanceOf(InvalidConversionFactorError);
104
+ });
105
+
106
+ it("throws when rounding precision is negative", async () => {
107
+ const { db, spies } = createMockDb<DB>();
108
+ spies.select
109
+ .mockReturnValueOnce(baseUoMCategory) // Category lookup
110
+ .mockReturnValueOnce(undefined); // No duplicate symbol
111
+
112
+ await expect(
113
+ createUnit(
114
+ db,
115
+ {
116
+ categoryId: baseUoMCategory.id,
117
+ name: "Invalid Unit",
118
+ symbol: "inv",
119
+ conversionFactor: 1.0,
120
+ roundingPrecision: -1,
121
+ },
122
+ ctx,
123
+ ),
124
+ ).rejects.toBeInstanceOf(InvalidRoundingPrecisionError);
125
+ });
126
+
127
+ // Success cases
128
+ it("creates unit successfully", async () => {
129
+ const { db, spies } = createMockDb<DB>();
130
+ const createdUnit = {
131
+ id: "new-unit-id",
132
+ name: "Ounce",
133
+ symbol: "oz",
134
+ categoryId: baseUoMCategory.id,
135
+ conversionFactor: 0.0283495,
136
+ roundingPrecision: 2,
137
+ isActive: true,
138
+ createdAt: new Date("2024-01-15T00:00:00.000Z"),
139
+ updatedAt: null,
140
+ };
141
+
142
+ spies.select
143
+ .mockReturnValueOnce(baseUoMCategory) // Category lookup
144
+ .mockReturnValueOnce(undefined); // No duplicate symbol
145
+ spies.insert.mockReturnValue(createdUnit);
146
+
147
+ const result = await createUnit(
148
+ db,
149
+ {
150
+ categoryId: baseUoMCategory.id,
151
+ name: "Ounce",
152
+ symbol: "oz",
153
+ conversionFactor: 0.0283495,
154
+ roundingPrecision: 2,
155
+ },
156
+ ctx,
157
+ );
158
+
159
+ expect(result.unit.name).toBe("Ounce");
160
+ expect(result.unit.symbol).toBe("oz");
161
+ expect(result.unit.isActive).toBe(true);
162
+ expect(spies.insert).toHaveBeenCalled();
163
+ });
164
+
165
+ it("throws when permission is missing", async () => {
166
+ const { db } = createMockDb<DB>();
167
+ const denied: CommandContext = { actorId: "test-actor", permissions: [] };
168
+ await expect(
169
+ createUnit(
170
+ db,
171
+ {
172
+ categoryId: "cat-1",
173
+ name: "Test",
174
+ symbol: "t",
175
+ conversionFactor: 1,
176
+ roundingPrecision: 2,
177
+ },
178
+ denied,
179
+ ),
180
+ ).rejects.toBeInstanceOf(InsufficientPermissionError);
181
+ });
182
+
183
+ it("passes custom fields through to insert", async () => {
184
+ const createUnitWithFields = makeCreateUnit<{ displayOrder: number }>();
185
+ const { db, spies } = createMockDb<DB>();
186
+ const createdUnit = {
187
+ ...baseUnitKg,
188
+ id: "new-unit-id",
189
+ name: "Ounce",
190
+ symbol: "oz",
191
+ displayOrder: 5,
192
+ };
193
+
194
+ spies.select
195
+ .mockReturnValueOnce(baseUoMCategory) // Category lookup
196
+ .mockReturnValueOnce(undefined); // No duplicate symbol
197
+ spies.insert.mockReturnValue(createdUnit);
198
+
199
+ await createUnitWithFields(
200
+ db,
201
+ {
202
+ categoryId: baseUoMCategory.id,
203
+ name: "Ounce",
204
+ symbol: "oz",
205
+ conversionFactor: 0.0283495,
206
+ roundingPrecision: 2,
207
+ displayOrder: 5,
208
+ },
209
+ ctx,
210
+ );
211
+
212
+ expect(spies.values).toHaveBeenNthCalledWith(1, expect.objectContaining({ displayOrder: 5 }));
213
+ });
214
+ });
@@ -0,0 +1,88 @@
1
+ import { defineCommand } from "../../shared/internal";
2
+ import { DB } from "../generated/kysely-tailordb";
3
+ import {
4
+ CategoryNotActiveError,
5
+ DuplicateUnitSymbolError,
6
+ InvalidConversionFactorError,
7
+ InvalidRoundingPrecisionError,
8
+ UoMCategoryNotFoundError,
9
+ } from "../lib/errors";
10
+ import { permissions } from "../permissions";
11
+
12
+ interface CreateUnitInput {
13
+ categoryId: string;
14
+ name: string;
15
+ symbol: string;
16
+ conversionFactor: number;
17
+ roundingPrecision: number;
18
+ }
19
+
20
+ /**
21
+ * Function: createUnit
22
+ *
23
+ * Adds a new unit of measure to an existing category with its symbol,
24
+ * display name, and conversion factor relative to the reference unit.
25
+ */
26
+ export function makeCreateUnit<CF extends Record<string, unknown>>() {
27
+ return defineCommand(permissions.createUnit, async (db: DB, input: CreateUnitInput & CF) => {
28
+ const { categoryId, name, symbol, conversionFactor, roundingPrecision, ...customFields } =
29
+ input;
30
+
31
+ // 1. Check category exists
32
+ const category = await db
33
+ .selectFrom("UoMCategory")
34
+ .selectAll()
35
+ .where("id", "=", categoryId)
36
+ .executeTakeFirst();
37
+
38
+ if (!category) {
39
+ throw new UoMCategoryNotFoundError(categoryId);
40
+ }
41
+
42
+ // 2. Check category is active
43
+ if (!category.isActive) {
44
+ throw new CategoryNotActiveError(categoryId);
45
+ }
46
+
47
+ // 3. Check symbol uniqueness within category
48
+ const existingUnit = await db
49
+ .selectFrom("Unit")
50
+ .selectAll()
51
+ .where("categoryId", "=", categoryId)
52
+ .where("symbol", "=", symbol)
53
+ .executeTakeFirst();
54
+
55
+ if (existingUnit) {
56
+ throw new DuplicateUnitSymbolError(symbol, categoryId);
57
+ }
58
+
59
+ // 4. Validate conversion factor
60
+ if (conversionFactor <= 0) {
61
+ throw new InvalidConversionFactorError(conversionFactor);
62
+ }
63
+
64
+ // 5. Validate rounding precision
65
+ if (roundingPrecision < 0) {
66
+ throw new InvalidRoundingPrecisionError(roundingPrecision);
67
+ }
68
+
69
+ // 6. Create unit
70
+ const unit = await db
71
+ .insertInto("Unit")
72
+ .values({
73
+ ...(customFields as Record<string, unknown>),
74
+ name,
75
+ symbol,
76
+ categoryId,
77
+ conversionFactor,
78
+ roundingPrecision,
79
+ isActive: true,
80
+ createdAt: new Date(),
81
+ updatedAt: null,
82
+ })
83
+ .returningAll()
84
+ .executeTakeFirst();
85
+
86
+ return { unit: unit! };
87
+ });
88
+ }
@@ -0,0 +1,97 @@
1
+ import { describe, expect, it } from "vitest";
2
+ import { createMockDb, testNotFound, testPermissionDenied } from "../../testing/index";
3
+ import { type CommandContext } from "../../shared/internal";
4
+ import { DB } from "../generated/kysely-tailordb";
5
+ import { CategoryHasActiveUnitsError, UoMCategoryNotFoundError } from "../lib/errors";
6
+ import { baseUoMCategory, baseUnitKg } from "../testing/fixtures";
7
+ import { deactivateCategory } from "./deactivateCategory";
8
+
9
+ describe("deactivateCategory", () => {
10
+ const ctx: CommandContext = {
11
+ actorId: "test-actor",
12
+ permissions: ["primitives:deactivateCategory"],
13
+ };
14
+
15
+ const inactiveCategory = {
16
+ ...baseUoMCategory,
17
+ isActive: false,
18
+ };
19
+
20
+ it(
21
+ "throws when category doesn't exist",
22
+ testNotFound(
23
+ deactivateCategory,
24
+ { categoryId: "nonexistent-category" },
25
+ ctx,
26
+ UoMCategoryNotFoundError,
27
+ ),
28
+ );
29
+
30
+ it("throws when category has active units", async () => {
31
+ const { db, spies } = createMockDb<DB>();
32
+ spies.select
33
+ .mockReturnValueOnce(baseUoMCategory) // Category lookup
34
+ .mockReturnValueOnce([baseUnitKg]); // Active units check
35
+
36
+ await expect(
37
+ deactivateCategory(
38
+ db,
39
+ {
40
+ categoryId: baseUoMCategory.id,
41
+ },
42
+ ctx,
43
+ ),
44
+ ).rejects.toBeInstanceOf(CategoryHasActiveUnitsError);
45
+ });
46
+
47
+ // Idempotent case needs multi-mock setup, kept inline
48
+ it("returns category unchanged when already inactive", async () => {
49
+ const { db, spies } = createMockDb<DB>();
50
+ spies.select
51
+ .mockReturnValueOnce(inactiveCategory) // Category lookup
52
+ .mockReturnValueOnce([]); // No active units
53
+
54
+ const result = await deactivateCategory(
55
+ db,
56
+ {
57
+ categoryId: inactiveCategory.id,
58
+ },
59
+ ctx,
60
+ );
61
+
62
+ expect(result.category).toEqual(inactiveCategory);
63
+ expect(spies.update).not.toHaveBeenCalled();
64
+ });
65
+
66
+ // Success cases
67
+ it("deactivates active category with no active units", async () => {
68
+ const { db, spies } = createMockDb<DB>();
69
+ const deactivatedCategory = {
70
+ ...baseUoMCategory,
71
+ isActive: false,
72
+ updatedAt: new Date("2024-01-15T00:00:00.000Z"),
73
+ };
74
+
75
+ spies.select
76
+ .mockReturnValueOnce(baseUoMCategory) // Category lookup
77
+ .mockReturnValueOnce([]); // No active units
78
+ spies.update.mockReturnValue(deactivatedCategory);
79
+
80
+ const result = await deactivateCategory(
81
+ db,
82
+ {
83
+ categoryId: baseUoMCategory.id,
84
+ },
85
+ ctx,
86
+ );
87
+
88
+ expect(result.category.isActive).toBe(false);
89
+ expect(result.category.updatedAt).not.toBeNull();
90
+ expect(spies.update).toHaveBeenCalled();
91
+ });
92
+
93
+ it(
94
+ "throws when permission is missing",
95
+ testPermissionDenied(deactivateCategory, { categoryId: "cat-1" }),
96
+ );
97
+ });
@@ -0,0 +1,62 @@
1
+ import { defineCommand } from "../../shared/internal";
2
+ import { DB } from "../generated/kysely-tailordb";
3
+ import { CategoryHasActiveUnitsError, UoMCategoryNotFoundError } from "../lib/errors";
4
+ import { permissions } from "../permissions";
5
+
6
+ export interface DeactivateCategoryInput {
7
+ categoryId: string;
8
+ }
9
+
10
+ /**
11
+ * Function: deactivateCategory
12
+ *
13
+ * Disables a UoM category from being used in new product assignments
14
+ * while preserving historical data. All units must be deactivated first.
15
+ */
16
+ export const deactivateCategory = defineCommand(
17
+ permissions.deactivateCategory,
18
+ async (db: DB, input: DeactivateCategoryInput) => {
19
+ // 1. Find category by ID
20
+ const category = await db
21
+ .selectFrom("UoMCategory")
22
+ .selectAll()
23
+ .where("id", "=", input.categoryId)
24
+ .executeTakeFirst();
25
+
26
+ // 2. If not found, throw error
27
+ if (!category) {
28
+ throw new UoMCategoryNotFoundError(input.categoryId);
29
+ }
30
+
31
+ // 3. Check for active units in this category
32
+ const activeUnits = await db
33
+ .selectFrom("Unit")
34
+ .selectAll()
35
+ .where("categoryId", "=", input.categoryId)
36
+ .where("isActive", "=", true)
37
+ .execute();
38
+
39
+ if (activeUnits.length > 0) {
40
+ throw new CategoryHasActiveUnitsError(input.categoryId);
41
+ }
42
+
43
+ // 4. If already inactive, return category (idempotent)
44
+ if (!category.isActive) {
45
+ return { category };
46
+ }
47
+
48
+ // 5. Update isActive = false
49
+ const updatedCategory = await db
50
+ .updateTable("UoMCategory")
51
+ .set({
52
+ isActive: false,
53
+ updatedAt: new Date(),
54
+ })
55
+ .where("id", "=", input.categoryId)
56
+ .returningAll()
57
+ .executeTakeFirst();
58
+
59
+ // 6. Return updated category
60
+ return { category: updatedCategory! };
61
+ },
62
+ );
@@ -0,0 +1,85 @@
1
+ import { describe, expect, it } from "vitest";
2
+ import {
3
+ createMockDb,
4
+ testNotFound,
5
+ testPermissionDenied,
6
+ testIdempotent,
7
+ } from "../../testing/index";
8
+ import { type CommandContext } from "../../shared/internal";
9
+ import { DB } from "../generated/kysely-tailordb";
10
+ import { CannotDeactivateBaseCurrencyError, CurrencyNotFoundError } from "../lib/errors";
11
+ import { baseCurrencyEUR, baseCurrencyUSD, inactiveCurrency } from "../testing/fixtures";
12
+ import { deactivateCurrency } from "./deactivateCurrency";
13
+
14
+ describe("deactivateCurrency", () => {
15
+ const ctx: CommandContext = {
16
+ actorId: "test-actor",
17
+ permissions: ["primitives:deactivateCurrency"],
18
+ };
19
+
20
+ it(
21
+ "throws when currency doesn't exist",
22
+ testNotFound(
23
+ deactivateCurrency,
24
+ { currencyId: "nonexistent-currency" },
25
+ ctx,
26
+ CurrencyNotFoundError,
27
+ ),
28
+ );
29
+
30
+ it("throws when attempting to deactivate base currency", async () => {
31
+ const { db, spies } = createMockDb<DB>();
32
+ spies.select.mockReturnValue(baseCurrencyUSD); // USD is base currency
33
+
34
+ await expect(
35
+ deactivateCurrency(
36
+ db,
37
+ {
38
+ currencyId: baseCurrencyUSD.id,
39
+ },
40
+ ctx,
41
+ ),
42
+ ).rejects.toBeInstanceOf(CannotDeactivateBaseCurrencyError);
43
+ });
44
+
45
+ it(
46
+ "returns currency unchanged when already inactive",
47
+ testIdempotent(
48
+ deactivateCurrency,
49
+ { currencyId: inactiveCurrency.id },
50
+ ctx,
51
+ inactiveCurrency,
52
+ "currency",
53
+ ),
54
+ );
55
+
56
+ // Success cases
57
+ it("deactivates active non-base currency", async () => {
58
+ const { db, spies } = createMockDb<DB>();
59
+ const deactivatedCurrency = {
60
+ ...baseCurrencyEUR,
61
+ isActive: false,
62
+ updatedAt: new Date("2024-01-15T00:00:00.000Z"),
63
+ };
64
+
65
+ spies.select.mockReturnValue(baseCurrencyEUR); // EUR is not base currency
66
+ spies.update.mockReturnValue(deactivatedCurrency);
67
+
68
+ const result = await deactivateCurrency(
69
+ db,
70
+ {
71
+ currencyId: baseCurrencyEUR.id,
72
+ },
73
+ ctx,
74
+ );
75
+
76
+ expect(result.currency.isActive).toBe(false);
77
+ expect(result.currency.updatedAt).not.toBeNull();
78
+ expect(spies.update).toHaveBeenCalled();
79
+ });
80
+
81
+ it(
82
+ "throws when permission is missing",
83
+ testPermissionDenied(deactivateCurrency, { currencyId: "cur-1" }),
84
+ );
85
+ });
@@ -0,0 +1,55 @@
1
+ import { defineCommand } from "../../shared/internal";
2
+ import { DB } from "../generated/kysely-tailordb";
3
+ import { CannotDeactivateBaseCurrencyError, CurrencyNotFoundError } from "../lib/errors";
4
+ import { permissions } from "../permissions";
5
+
6
+ export interface DeactivateCurrencyInput {
7
+ currencyId: string;
8
+ }
9
+
10
+ /**
11
+ * Function: deactivateCurrency
12
+ *
13
+ * Disables a currency from being used in new transactions while preserving
14
+ * historical data. Base currency cannot be deactivated.
15
+ */
16
+ export const deactivateCurrency = defineCommand(
17
+ permissions.deactivateCurrency,
18
+ async (db: DB, input: DeactivateCurrencyInput) => {
19
+ // 1. Find currency by ID
20
+ const currency = await db
21
+ .selectFrom("Currency")
22
+ .selectAll()
23
+ .where("id", "=", input.currencyId)
24
+ .executeTakeFirst();
25
+
26
+ // 2. If not found, throw error
27
+ if (!currency) {
28
+ throw new CurrencyNotFoundError(input.currencyId);
29
+ }
30
+
31
+ // 3. Check if base currency
32
+ if (currency.isBaseCurrency) {
33
+ throw new CannotDeactivateBaseCurrencyError(input.currencyId);
34
+ }
35
+
36
+ // 4. If already inactive, return currency (idempotent)
37
+ if (!currency.isActive) {
38
+ return { currency };
39
+ }
40
+
41
+ // 5. Update isActive = false
42
+ const updatedCurrency = await db
43
+ .updateTable("Currency")
44
+ .set({
45
+ isActive: false,
46
+ updatedAt: new Date(),
47
+ })
48
+ .where("id", "=", input.currencyId)
49
+ .returningAll()
50
+ .executeTakeFirst();
51
+
52
+ // 6. Return updated currency
53
+ return { currency: updatedCurrency! };
54
+ },
55
+ );