@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,39 @@
1
+ # README
2
+
3
+ ## Overview
4
+
5
+ The Primitives module provides foundational reference data that other ERP modules depend on. It includes unit of measure (UoM) definitions for quantity handling and currency definitions for multi-currency financial operations. These are stable, rarely-changing entities that form the measurement and monetary foundation of the system.
6
+
7
+ This module combines related configuration primitives to simplify dependency management while keeping tax-related functionality separate due to its regulatory complexity.
8
+
9
+ ## Key Features
10
+
11
+ - **UoM Categories**: Group related units (e.g., Unit, Weight, Volume, Length, Time) with a designated reference unit for each category
12
+ - **Unit Definitions**: Define individual units with symbols, names, and conversion factors relative to the reference unit
13
+ - **Quantity Conversion**: Convert quantities between any two units within the same category using automatic factor calculation
14
+ - **Rounding Precision**: Configure decimal precision per unit to ensure appropriate rounding for business operations
15
+ - **Currency Definitions**: Define currencies with ISO 4217 codes, symbols, and decimal precision
16
+ - **Base Currency**: Designate a company base currency for reporting and consolidation
17
+ - **Exchange Rates**: Maintain date-based exchange rates between currency pairs
18
+ - **Amount Conversion**: Convert monetary amounts between currencies using applicable rates
19
+
20
+ ## Module Scope
21
+
22
+ ### In Scope
23
+
24
+ - UoM category and unit management with conversion factors
25
+ - Currency definitions with ISO 4217 codes and symbols
26
+ - Exchange rate storage with effective dates
27
+ - Quantity conversion between compatible units
28
+ - Amount conversion between currencies
29
+
30
+ ### Out of Scope
31
+
32
+ - Tax configuration and fiscal positions (separate tax-configuration module)
33
+ - Product definitions (product-management module)
34
+ - Transaction recording (sales, purchase, accounting modules)
35
+ - Automatic exchange rate fetching from external APIs
36
+
37
+ ## Module Dependencies
38
+
39
+ - None (this is a foundational module)
@@ -0,0 +1,75 @@
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 { UoMCategoryNotFoundError } from "../lib/errors";
11
+ import { baseUoMCategory } from "../testing/fixtures";
12
+ import { activateCategory } from "./activateCategory";
13
+
14
+ describe("activateCategory", () => {
15
+ const ctx: CommandContext = {
16
+ actorId: "test-actor",
17
+ permissions: ["primitives:activateCategory"],
18
+ };
19
+
20
+ const inactiveCategory = {
21
+ ...baseUoMCategory,
22
+ isActive: false,
23
+ };
24
+
25
+ it(
26
+ "throws when category doesn't exist",
27
+ testNotFound(
28
+ activateCategory,
29
+ { categoryId: "nonexistent-category" },
30
+ ctx,
31
+ UoMCategoryNotFoundError,
32
+ ),
33
+ );
34
+
35
+ it(
36
+ "returns category unchanged when already active",
37
+ testIdempotent(
38
+ activateCategory,
39
+ { categoryId: baseUoMCategory.id },
40
+ ctx,
41
+ baseUoMCategory,
42
+ "category",
43
+ ),
44
+ );
45
+
46
+ // Success cases
47
+ it("activates inactive category", async () => {
48
+ const { db, spies } = createMockDb<DB>();
49
+ const activatedCategory = {
50
+ ...inactiveCategory,
51
+ isActive: true,
52
+ updatedAt: new Date("2024-01-15T00:00:00.000Z"),
53
+ };
54
+
55
+ spies.select.mockReturnValue(inactiveCategory);
56
+ spies.update.mockReturnValue(activatedCategory);
57
+
58
+ const result = await activateCategory(
59
+ db,
60
+ {
61
+ categoryId: inactiveCategory.id,
62
+ },
63
+ ctx,
64
+ );
65
+
66
+ expect(result.category.isActive).toBe(true);
67
+ expect(result.category.updatedAt).not.toBeNull();
68
+ expect(spies.update).toHaveBeenCalled();
69
+ });
70
+
71
+ it(
72
+ "throws when permission is missing",
73
+ testPermissionDenied(activateCategory, { categoryId: "cat-1" }),
74
+ );
75
+ });
@@ -0,0 +1,50 @@
1
+ import { defineCommand } from "../../shared/internal";
2
+ import { DB } from "../generated/kysely-tailordb";
3
+ import { UoMCategoryNotFoundError } from "../lib/errors";
4
+ import { permissions } from "../permissions";
5
+
6
+ export interface ActivateCategoryInput {
7
+ categoryId: string;
8
+ }
9
+
10
+ /**
11
+ * Function: activateCategory
12
+ *
13
+ * Re-enables a previously deactivated UoM category, making it and its units
14
+ * available for new product assignments and transactions.
15
+ */
16
+ export const activateCategory = defineCommand(
17
+ permissions.activateCategory,
18
+ async (db: DB, input: ActivateCategoryInput) => {
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. If already active, return category (idempotent)
32
+ if (category.isActive) {
33
+ return { category };
34
+ }
35
+
36
+ // 4. Update isActive = true
37
+ const updatedCategory = await db
38
+ .updateTable("UoMCategory")
39
+ .set({
40
+ isActive: true,
41
+ updatedAt: new Date(),
42
+ })
43
+ .where("id", "=", input.categoryId)
44
+ .returningAll()
45
+ .executeTakeFirst();
46
+
47
+ // 5. Return updated category
48
+ return { category: updatedCategory! };
49
+ },
50
+ );
@@ -0,0 +1,70 @@
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 { CurrencyNotFoundError } from "../lib/errors";
11
+ import { baseCurrencyUSD, inactiveCurrency } from "../testing/fixtures";
12
+ import { activateCurrency } from "./activateCurrency";
13
+
14
+ describe("activateCurrency", () => {
15
+ const ctx: CommandContext = {
16
+ actorId: "test-actor",
17
+ permissions: ["primitives:activateCurrency"],
18
+ };
19
+
20
+ it(
21
+ "throws when currency doesn't exist",
22
+ testNotFound(
23
+ activateCurrency,
24
+ { currencyId: "nonexistent-currency" },
25
+ ctx,
26
+ CurrencyNotFoundError,
27
+ ),
28
+ );
29
+
30
+ it(
31
+ "returns currency unchanged when already active",
32
+ testIdempotent(
33
+ activateCurrency,
34
+ { currencyId: baseCurrencyUSD.id },
35
+ ctx,
36
+ baseCurrencyUSD,
37
+ "currency",
38
+ ),
39
+ );
40
+
41
+ // Success cases
42
+ it("activates inactive currency", async () => {
43
+ const { db, spies } = createMockDb<DB>();
44
+ const activatedCurrency = {
45
+ ...inactiveCurrency,
46
+ isActive: true,
47
+ updatedAt: new Date("2024-01-15T00:00:00.000Z"),
48
+ };
49
+
50
+ spies.select.mockReturnValue(inactiveCurrency);
51
+ spies.update.mockReturnValue(activatedCurrency);
52
+
53
+ const result = await activateCurrency(
54
+ db,
55
+ {
56
+ currencyId: inactiveCurrency.id,
57
+ },
58
+ ctx,
59
+ );
60
+
61
+ expect(result.currency.isActive).toBe(true);
62
+ expect(result.currency.updatedAt).not.toBeNull();
63
+ expect(spies.update).toHaveBeenCalled();
64
+ });
65
+
66
+ it(
67
+ "throws when permission is missing",
68
+ testPermissionDenied(activateCurrency, { currencyId: "cur-1" }),
69
+ );
70
+ });
@@ -0,0 +1,50 @@
1
+ import { defineCommand } from "../../shared/internal";
2
+ import { DB } from "../generated/kysely-tailordb";
3
+ import { CurrencyNotFoundError } from "../lib/errors";
4
+ import { permissions } from "../permissions";
5
+
6
+ export interface ActivateCurrencyInput {
7
+ currencyId: string;
8
+ }
9
+
10
+ /**
11
+ * Function: activateCurrency
12
+ *
13
+ * Re-enables a previously deactivated currency, making it available
14
+ * for new transactions.
15
+ */
16
+ export const activateCurrency = defineCommand(
17
+ permissions.activateCurrency,
18
+ async (db: DB, input: ActivateCurrencyInput) => {
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. If already active, return currency (idempotent)
32
+ if (currency.isActive) {
33
+ return { currency };
34
+ }
35
+
36
+ // 4. Update isActive = true
37
+ const updatedCurrency = await db
38
+ .updateTable("Currency")
39
+ .set({
40
+ isActive: true,
41
+ updatedAt: new Date(),
42
+ })
43
+ .where("id", "=", input.currencyId)
44
+ .returningAll()
45
+ .executeTakeFirst();
46
+
47
+ // 5. Return updated currency
48
+ return { currency: updatedCurrency! };
49
+ },
50
+ );
@@ -0,0 +1,53 @@
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 { UnitNotFoundError } from "../lib/errors";
11
+ import { baseUnitKg, inactiveUnit } from "../testing/fixtures";
12
+ import { activateUnit } from "./activateUnit";
13
+
14
+ describe("activateUnit", () => {
15
+ const ctx: CommandContext = { actorId: "test-actor", permissions: ["primitives:activateUnit"] };
16
+
17
+ it(
18
+ "throws when unit doesn't exist",
19
+ testNotFound(activateUnit, { unitId: "nonexistent-unit" }, ctx, UnitNotFoundError),
20
+ );
21
+
22
+ it(
23
+ "returns unit unchanged when already active",
24
+ testIdempotent(activateUnit, { unitId: baseUnitKg.id }, ctx, baseUnitKg, "unit"),
25
+ );
26
+
27
+ // Success cases
28
+ it("activates inactive unit", async () => {
29
+ const { db, spies } = createMockDb<DB>();
30
+ const activatedUnit = {
31
+ ...inactiveUnit,
32
+ isActive: true,
33
+ updatedAt: new Date("2024-01-15T00:00:00.000Z"),
34
+ };
35
+
36
+ spies.select.mockReturnValue(inactiveUnit);
37
+ spies.update.mockReturnValue(activatedUnit);
38
+
39
+ const result = await activateUnit(
40
+ db,
41
+ {
42
+ unitId: inactiveUnit.id,
43
+ },
44
+ ctx,
45
+ );
46
+
47
+ expect(result.unit.isActive).toBe(true);
48
+ expect(result.unit.updatedAt).not.toBeNull();
49
+ expect(spies.update).toHaveBeenCalled();
50
+ });
51
+
52
+ it("throws when permission is missing", testPermissionDenied(activateUnit, { unitId: "unit-1" }));
53
+ });
@@ -0,0 +1,50 @@
1
+ import { defineCommand } from "../../shared/internal";
2
+ import { DB } from "../generated/kysely-tailordb";
3
+ import { UnitNotFoundError } from "../lib/errors";
4
+ import { permissions } from "../permissions";
5
+
6
+ export interface ActivateUnitInput {
7
+ unitId: string;
8
+ }
9
+
10
+ /**
11
+ * Function: activateUnit
12
+ *
13
+ * Re-enables a previously deactivated unit of measure, making it available
14
+ * for new product assignments and quantity conversions.
15
+ */
16
+ export const activateUnit = defineCommand(
17
+ permissions.activateUnit,
18
+ async (db: DB, input: ActivateUnitInput) => {
19
+ // 1. Find unit by ID
20
+ const unit = await db
21
+ .selectFrom("Unit")
22
+ .selectAll()
23
+ .where("id", "=", input.unitId)
24
+ .executeTakeFirst();
25
+
26
+ // 2. If not found, throw error
27
+ if (!unit) {
28
+ throw new UnitNotFoundError(input.unitId);
29
+ }
30
+
31
+ // 3. If already active, return unit (idempotent)
32
+ if (unit.isActive) {
33
+ return { unit };
34
+ }
35
+
36
+ // 4. Update isActive = true
37
+ const updatedUnit = await db
38
+ .updateTable("Unit")
39
+ .set({
40
+ isActive: true,
41
+ updatedAt: new Date(),
42
+ })
43
+ .where("id", "=", input.unitId)
44
+ .returningAll()
45
+ .executeTakeFirst();
46
+
47
+ // 5. Return updated unit
48
+ return { unit: updatedUnit! };
49
+ },
50
+ );
@@ -0,0 +1,275 @@
1
+ import { describe, expect, it } from "vitest";
2
+ import { InsufficientPermissionError, type CommandContext } from "../../shared/internal";
3
+ import { createMockDb } from "../../testing/index";
4
+ import { DB } from "../generated/kysely-tailordb";
5
+ import {
6
+ CurrencyNotFoundError,
7
+ ExchangeRateNotFoundError,
8
+ InactiveCurrencyError,
9
+ } from "../lib/errors";
10
+ import {
11
+ baseCurrencyEUR,
12
+ baseCurrencyJPY,
13
+ baseCurrencyUSD,
14
+ baseExchangeRateUSDtoEUR,
15
+ baseExchangeRateUSDtoJPY,
16
+ inactiveCurrency,
17
+ olderExchangeRateUSDtoEUR,
18
+ } from "../testing/fixtures";
19
+ import { convertAmount } from "./convertAmount";
20
+
21
+ describe("convertAmount", () => {
22
+ const ctx: CommandContext = { actorId: "test-actor", permissions: ["primitives:convertAmount"] };
23
+
24
+ // Error cases first
25
+ it("throws when source currency doesn't exist", async () => {
26
+ const { db, spies } = createMockDb<DB>();
27
+ spies.select.mockReturnValue(undefined);
28
+
29
+ await expect(
30
+ convertAmount(
31
+ db,
32
+ {
33
+ amount: 100,
34
+ sourceCurrencyCode: "XYZ",
35
+ targetCurrencyCode: baseCurrencyEUR.code,
36
+ conversionDate: "2024-01-20",
37
+ },
38
+ ctx,
39
+ ),
40
+ ).rejects.toBeInstanceOf(CurrencyNotFoundError);
41
+ });
42
+
43
+ it("throws when target currency doesn't exist", async () => {
44
+ const { db, spies } = createMockDb<DB>();
45
+ spies.select
46
+ .mockReturnValueOnce(baseCurrencyUSD) // source exists
47
+ .mockReturnValueOnce(undefined); // target doesn't exist
48
+
49
+ await expect(
50
+ convertAmount(
51
+ db,
52
+ {
53
+ amount: 100,
54
+ sourceCurrencyCode: baseCurrencyUSD.code,
55
+ targetCurrencyCode: "XYZ",
56
+ conversionDate: "2024-01-20",
57
+ },
58
+ ctx,
59
+ ),
60
+ ).rejects.toBeInstanceOf(CurrencyNotFoundError);
61
+ });
62
+
63
+ it("throws when source currency is inactive", async () => {
64
+ const { db, spies } = createMockDb<DB>();
65
+ spies.select
66
+ .mockReturnValueOnce(inactiveCurrency) // source inactive
67
+ .mockReturnValueOnce(baseCurrencyEUR); // target exists
68
+
69
+ await expect(
70
+ convertAmount(
71
+ db,
72
+ {
73
+ amount: 100,
74
+ sourceCurrencyCode: inactiveCurrency.code,
75
+ targetCurrencyCode: baseCurrencyEUR.code,
76
+ conversionDate: "2024-01-20",
77
+ },
78
+ ctx,
79
+ ),
80
+ ).rejects.toBeInstanceOf(InactiveCurrencyError);
81
+ });
82
+
83
+ it("throws when target currency is inactive", async () => {
84
+ const { db, spies } = createMockDb<DB>();
85
+ spies.select
86
+ .mockReturnValueOnce(baseCurrencyUSD) // source exists
87
+ .mockReturnValueOnce(inactiveCurrency); // target inactive
88
+
89
+ await expect(
90
+ convertAmount(
91
+ db,
92
+ {
93
+ amount: 100,
94
+ sourceCurrencyCode: baseCurrencyUSD.code,
95
+ targetCurrencyCode: inactiveCurrency.code,
96
+ conversionDate: "2024-01-20",
97
+ },
98
+ ctx,
99
+ ),
100
+ ).rejects.toBeInstanceOf(InactiveCurrencyError);
101
+ });
102
+
103
+ it("throws when no exchange rate exists for the currency pair", async () => {
104
+ const { db, spies } = createMockDb<DB>();
105
+ spies.select
106
+ .mockReturnValueOnce(baseCurrencyUSD) // source exists
107
+ .mockReturnValueOnce(baseCurrencyEUR) // target exists
108
+ .mockReturnValueOnce(undefined) // no direct rate
109
+ .mockReturnValueOnce(undefined); // no inverse rate
110
+
111
+ await expect(
112
+ convertAmount(
113
+ db,
114
+ {
115
+ amount: 100,
116
+ sourceCurrencyCode: baseCurrencyUSD.code,
117
+ targetCurrencyCode: baseCurrencyEUR.code,
118
+ conversionDate: "2024-01-20",
119
+ },
120
+ ctx,
121
+ ),
122
+ ).rejects.toBeInstanceOf(ExchangeRateNotFoundError);
123
+ });
124
+
125
+ // Success cases
126
+ it("returns same amount when source and target are the same currency", async () => {
127
+ const { db, spies } = createMockDb<DB>();
128
+ spies.select.mockReturnValueOnce(baseCurrencyUSD).mockReturnValueOnce(baseCurrencyUSD);
129
+
130
+ const result = await convertAmount(
131
+ db,
132
+ {
133
+ amount: 100,
134
+ sourceCurrencyCode: baseCurrencyUSD.code,
135
+ targetCurrencyCode: baseCurrencyUSD.code,
136
+ conversionDate: "2024-01-20",
137
+ },
138
+ ctx,
139
+ );
140
+
141
+ expect(result.convertedAmount).toBe(100);
142
+ expect(result.exchangeRate).toBe(1);
143
+ });
144
+
145
+ it("converts USD to EUR using direct rate", async () => {
146
+ const { db, spies } = createMockDb<DB>();
147
+ spies.select
148
+ .mockReturnValueOnce(baseCurrencyUSD) // source
149
+ .mockReturnValueOnce(baseCurrencyEUR) // target
150
+ .mockReturnValueOnce(baseExchangeRateUSDtoEUR); // rate = 0.92
151
+
152
+ // 100 USD * 0.92 = 92 EUR
153
+ const result = await convertAmount(
154
+ db,
155
+ {
156
+ amount: 100,
157
+ sourceCurrencyCode: baseCurrencyUSD.code,
158
+ targetCurrencyCode: baseCurrencyEUR.code,
159
+ conversionDate: "2024-01-20",
160
+ },
161
+ ctx,
162
+ );
163
+
164
+ expect(result.convertedAmount).toBe(92);
165
+ expect(result.exchangeRate).toBe(0.92);
166
+ });
167
+
168
+ it("uses inverse rate when no direct rate exists", async () => {
169
+ const { db, spies } = createMockDb<DB>();
170
+ spies.select
171
+ .mockReturnValueOnce(baseCurrencyEUR) // source
172
+ .mockReturnValueOnce(baseCurrencyUSD) // target
173
+ .mockReturnValueOnce(undefined) // no direct EUR->USD rate
174
+ .mockReturnValueOnce(baseExchangeRateUSDtoEUR); // inverse USD->EUR rate = 0.92
175
+
176
+ // 100 EUR * (1/0.92) = 108.70 USD (rounded to 2 decimal places)
177
+ const result = await convertAmount(
178
+ db,
179
+ {
180
+ amount: 100,
181
+ sourceCurrencyCode: baseCurrencyEUR.code,
182
+ targetCurrencyCode: baseCurrencyUSD.code,
183
+ conversionDate: "2024-01-20",
184
+ },
185
+ ctx,
186
+ );
187
+
188
+ expect(result.convertedAmount).toBe(108.7);
189
+ expect(result.exchangeRate).toBeCloseTo(1 / 0.92, 5);
190
+ });
191
+
192
+ it("uses most recent rate on or before the conversion date", async () => {
193
+ const { db, spies } = createMockDb<DB>();
194
+ spies.select
195
+ .mockReturnValueOnce(baseCurrencyUSD) // source
196
+ .mockReturnValueOnce(baseCurrencyEUR) // target
197
+ .mockReturnValueOnce(olderExchangeRateUSDtoEUR); // rate = 0.85 from 2024-01-01
198
+
199
+ // Querying for 2024-01-10, should use rate from 2024-01-01
200
+ // 100 USD * 0.85 = 85 EUR
201
+ const result = await convertAmount(
202
+ db,
203
+ {
204
+ amount: 100,
205
+ sourceCurrencyCode: baseCurrencyUSD.code,
206
+ targetCurrencyCode: baseCurrencyEUR.code,
207
+ conversionDate: "2024-01-10",
208
+ },
209
+ ctx,
210
+ );
211
+
212
+ expect(result.convertedAmount).toBe(85);
213
+ expect(result.exchangeRate).toBe(0.85);
214
+ });
215
+
216
+ it("rounds to target currency decimal places (JPY has 0)", async () => {
217
+ const { db, spies } = createMockDb<DB>();
218
+ spies.select
219
+ .mockReturnValueOnce(baseCurrencyUSD) // source
220
+ .mockReturnValueOnce(baseCurrencyJPY) // target (0 decimal places)
221
+ .mockReturnValueOnce(baseExchangeRateUSDtoJPY); // rate = 148.5
222
+
223
+ // 100.50 USD * 148.5 = 14924.25 -> rounded to 14924 JPY
224
+ const result = await convertAmount(
225
+ db,
226
+ {
227
+ amount: 100.5,
228
+ sourceCurrencyCode: baseCurrencyUSD.code,
229
+ targetCurrencyCode: baseCurrencyJPY.code,
230
+ conversionDate: "2024-01-20",
231
+ },
232
+ ctx,
233
+ );
234
+
235
+ expect(result.convertedAmount).toBe(14924);
236
+ });
237
+
238
+ it("handles zero amount correctly", async () => {
239
+ const { db, spies } = createMockDb<DB>();
240
+ spies.select
241
+ .mockReturnValueOnce(baseCurrencyUSD)
242
+ .mockReturnValueOnce(baseCurrencyEUR)
243
+ .mockReturnValueOnce(baseExchangeRateUSDtoEUR);
244
+
245
+ const result = await convertAmount(
246
+ db,
247
+ {
248
+ amount: 0,
249
+ sourceCurrencyCode: baseCurrencyUSD.code,
250
+ targetCurrencyCode: baseCurrencyEUR.code,
251
+ conversionDate: "2024-01-20",
252
+ },
253
+ ctx,
254
+ );
255
+
256
+ expect(result.convertedAmount).toBe(0);
257
+ });
258
+
259
+ it("throws when permission is missing", async () => {
260
+ const { db } = createMockDb<DB>();
261
+ const denied: CommandContext = { actorId: "test-actor", permissions: [] };
262
+ await expect(
263
+ convertAmount(
264
+ db,
265
+ {
266
+ amount: 100,
267
+ sourceCurrencyCode: "USD",
268
+ targetCurrencyCode: "EUR",
269
+ conversionDate: "2024-01-15",
270
+ },
271
+ denied,
272
+ ),
273
+ ).rejects.toBeInstanceOf(InsufficientPermissionError);
274
+ });
275
+ });