@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,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
+ });
@@ -0,0 +1,126 @@
1
+ import { defineCommand } from "../../shared/internal";
2
+ import { DB } from "../generated/kysely-tailordb";
3
+ import {
4
+ CurrencyNotFoundError,
5
+ ExchangeRateNotFoundError,
6
+ InactiveCurrencyError,
7
+ } from "../lib/errors";
8
+ import { permissions } from "../permissions";
9
+
10
+ export interface ConvertAmountInput {
11
+ amount: number;
12
+ sourceCurrencyCode: string;
13
+ targetCurrencyCode: string;
14
+ conversionDate: string;
15
+ }
16
+
17
+ /**
18
+ * Function: convertAmount
19
+ *
20
+ * Converts a monetary amount from one currency to another using the applicable
21
+ * exchange rate for a given date. Currencies are identified by their ISO 4217
22
+ * code (e.g., "USD", "EUR", "JPY"). The function looks up the most recent exchange
23
+ * rate on or before the specified date. If no direct rate exists, it calculates
24
+ * the inverse rate. Result is rounded to the target currency's decimal precision.
25
+ */
26
+ export const convertAmount = defineCommand(
27
+ permissions.convertAmount,
28
+ async (db: DB, input: ConvertAmountInput) => {
29
+ // 1. Validate source currency exists
30
+ const sourceCurrency = await db
31
+ .selectFrom("Currency")
32
+ .selectAll()
33
+ .where("code", "=", input.sourceCurrencyCode)
34
+ .executeTakeFirst();
35
+
36
+ if (!sourceCurrency) {
37
+ throw new CurrencyNotFoundError(input.sourceCurrencyCode);
38
+ }
39
+
40
+ // 2. Validate target currency exists
41
+ const targetCurrency = await db
42
+ .selectFrom("Currency")
43
+ .selectAll()
44
+ .where("code", "=", input.targetCurrencyCode)
45
+ .executeTakeFirst();
46
+
47
+ if (!targetCurrency) {
48
+ throw new CurrencyNotFoundError(input.targetCurrencyCode);
49
+ }
50
+
51
+ // 3. Validate both currencies are active
52
+ if (!sourceCurrency.isActive) {
53
+ throw new InactiveCurrencyError(input.sourceCurrencyCode);
54
+ }
55
+
56
+ if (!targetCurrency.isActive) {
57
+ throw new InactiveCurrencyError(input.targetCurrencyCode);
58
+ }
59
+
60
+ // 4. Same currency - return original amount
61
+ if (sourceCurrency.id === targetCurrency.id) {
62
+ return {
63
+ convertedAmount: input.amount,
64
+ exchangeRate: 1,
65
+ sourceCurrency,
66
+ targetCurrency,
67
+ exchangeRateRecord: null,
68
+ };
69
+ }
70
+
71
+ // 5. Find direct exchange rate (most recent on or before conversion date)
72
+ const conversionDate = new Date(input.conversionDate);
73
+ const directRate = await db
74
+ .selectFrom("ExchangeRate")
75
+ .selectAll()
76
+ .where("sourceCurrencyId", "=", sourceCurrency.id)
77
+ .where("targetCurrencyId", "=", targetCurrency.id)
78
+ .where("effectiveDate", "<=", conversionDate)
79
+ .orderBy("effectiveDate", "desc")
80
+ .executeTakeFirst();
81
+
82
+ let exchangeRate: number;
83
+ let exchangeRateRecord = null;
84
+
85
+ if (directRate) {
86
+ exchangeRate = directRate.rate;
87
+ exchangeRateRecord = directRate;
88
+ } else {
89
+ // 6. Try inverse rate
90
+ const inverseRate = await db
91
+ .selectFrom("ExchangeRate")
92
+ .selectAll()
93
+ .where("sourceCurrencyId", "=", targetCurrency.id)
94
+ .where("targetCurrencyId", "=", sourceCurrency.id)
95
+ .where("effectiveDate", "<=", conversionDate)
96
+ .orderBy("effectiveDate", "desc")
97
+ .executeTakeFirst();
98
+
99
+ if (!inverseRate) {
100
+ throw new ExchangeRateNotFoundError(
101
+ input.sourceCurrencyCode,
102
+ input.targetCurrencyCode,
103
+ input.conversionDate,
104
+ );
105
+ }
106
+
107
+ exchangeRate = 1 / inverseRate.rate;
108
+ exchangeRateRecord = inverseRate;
109
+ }
110
+
111
+ // 7. Calculate converted amount
112
+ const rawResult = input.amount * exchangeRate;
113
+
114
+ // 8. Round to target currency's decimal places
115
+ const roundingFactor = Math.pow(10, targetCurrency.decimalPlaces);
116
+ const convertedAmount = Math.round(rawResult * roundingFactor) / roundingFactor;
117
+
118
+ return {
119
+ convertedAmount,
120
+ exchangeRate,
121
+ sourceCurrency,
122
+ targetCurrency,
123
+ exchangeRateRecord,
124
+ };
125
+ },
126
+ );
@@ -0,0 +1,219 @@
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 { InactiveUnitError, IncompatibleUnitsError, UnitNotFoundError } from "../lib/errors";
6
+ import {
7
+ baseUnitGram,
8
+ baseUnitKg,
9
+ baseUnitLiter,
10
+ baseUnitPound,
11
+ inactiveUnit,
12
+ } from "../testing/fixtures";
13
+ import { convertQuantity } from "./convertQuantity";
14
+
15
+ describe("convertQuantity", () => {
16
+ const ctx: CommandContext = {
17
+ actorId: "test-actor",
18
+ permissions: ["primitives:convertQuantity"],
19
+ };
20
+
21
+ // Error cases first
22
+ it("throws when source unit doesn't exist", async () => {
23
+ const { db, spies } = createMockDb<DB>();
24
+ spies.select.mockReturnValue(undefined);
25
+
26
+ await expect(
27
+ convertQuantity(
28
+ db,
29
+ {
30
+ quantity: 10,
31
+ sourceUnitSymbol: "xyz",
32
+ targetUnitSymbol: baseUnitGram.symbol,
33
+ },
34
+ ctx,
35
+ ),
36
+ ).rejects.toBeInstanceOf(UnitNotFoundError);
37
+ });
38
+
39
+ it("throws when target unit doesn't exist", async () => {
40
+ const { db, spies } = createMockDb<DB>();
41
+ spies.select
42
+ .mockReturnValueOnce(baseUnitKg) // source unit exists
43
+ .mockReturnValueOnce(undefined); // target unit doesn't exist
44
+
45
+ await expect(
46
+ convertQuantity(
47
+ db,
48
+ {
49
+ quantity: 10,
50
+ sourceUnitSymbol: baseUnitKg.symbol,
51
+ targetUnitSymbol: "xyz",
52
+ },
53
+ ctx,
54
+ ),
55
+ ).rejects.toBeInstanceOf(UnitNotFoundError);
56
+ });
57
+
58
+ it("throws when source unit is inactive", async () => {
59
+ const { db, spies } = createMockDb<DB>();
60
+ spies.select
61
+ .mockReturnValueOnce(inactiveUnit) // source unit inactive
62
+ .mockReturnValueOnce(baseUnitGram); // target unit exists
63
+
64
+ await expect(
65
+ convertQuantity(
66
+ db,
67
+ {
68
+ quantity: 10,
69
+ sourceUnitSymbol: inactiveUnit.symbol,
70
+ targetUnitSymbol: baseUnitGram.symbol,
71
+ },
72
+ ctx,
73
+ ),
74
+ ).rejects.toBeInstanceOf(InactiveUnitError);
75
+ });
76
+
77
+ it("throws when target unit is inactive", async () => {
78
+ const { db, spies } = createMockDb<DB>();
79
+ spies.select
80
+ .mockReturnValueOnce(baseUnitKg) // source unit exists
81
+ .mockReturnValueOnce(inactiveUnit); // target unit inactive
82
+
83
+ await expect(
84
+ convertQuantity(
85
+ db,
86
+ {
87
+ quantity: 10,
88
+ sourceUnitSymbol: baseUnitKg.symbol,
89
+ targetUnitSymbol: inactiveUnit.symbol,
90
+ },
91
+ ctx,
92
+ ),
93
+ ).rejects.toBeInstanceOf(InactiveUnitError);
94
+ });
95
+
96
+ it("throws when units belong to different categories", async () => {
97
+ const { db, spies } = createMockDb<DB>();
98
+ spies.select
99
+ .mockReturnValueOnce(baseUnitKg) // Weight category
100
+ .mockReturnValueOnce(baseUnitLiter); // Volume category
101
+
102
+ await expect(
103
+ convertQuantity(
104
+ db,
105
+ {
106
+ quantity: 10,
107
+ sourceUnitSymbol: baseUnitKg.symbol,
108
+ targetUnitSymbol: baseUnitLiter.symbol,
109
+ },
110
+ ctx,
111
+ ),
112
+ ).rejects.toBeInstanceOf(IncompatibleUnitsError);
113
+ });
114
+
115
+ // Success cases
116
+ it("returns same quantity when source and target are the same unit", async () => {
117
+ const { db, spies } = createMockDb<DB>();
118
+ spies.select.mockReturnValueOnce(baseUnitKg).mockReturnValueOnce(baseUnitKg);
119
+
120
+ const result = await convertQuantity(
121
+ db,
122
+ {
123
+ quantity: 10,
124
+ sourceUnitSymbol: baseUnitKg.symbol,
125
+ targetUnitSymbol: baseUnitKg.symbol,
126
+ },
127
+ ctx,
128
+ );
129
+
130
+ expect(result.convertedQuantity).toBe(10);
131
+ expect(result.sourceUnit).toEqual(baseUnitKg);
132
+ expect(result.targetUnit).toEqual(baseUnitKg);
133
+ });
134
+
135
+ it("converts kg to grams correctly", async () => {
136
+ const { db, spies } = createMockDb<DB>();
137
+ spies.select
138
+ .mockReturnValueOnce(baseUnitKg) // 1 kg = 1.0 (reference)
139
+ .mockReturnValueOnce(baseUnitGram); // 1 g = 0.001 kg
140
+
141
+ // 5 kg * 1.0 / 0.001 = 5000 g
142
+ const result = await convertQuantity(
143
+ db,
144
+ {
145
+ quantity: 5,
146
+ sourceUnitSymbol: baseUnitKg.symbol,
147
+ targetUnitSymbol: baseUnitGram.symbol,
148
+ },
149
+ ctx,
150
+ );
151
+
152
+ expect(result.convertedQuantity).toBe(5000); // rounded to 0 decimal places (gram precision)
153
+ });
154
+
155
+ it("converts grams to kg correctly", async () => {
156
+ const { db, spies } = createMockDb<DB>();
157
+ spies.select
158
+ .mockReturnValueOnce(baseUnitGram) // 1 g = 0.001 kg
159
+ .mockReturnValueOnce(baseUnitKg); // 1 kg = 1.0 (reference)
160
+
161
+ // 2500 g * 0.001 / 1.0 = 2.5 kg
162
+ const result = await convertQuantity(
163
+ db,
164
+ {
165
+ quantity: 2500,
166
+ sourceUnitSymbol: baseUnitGram.symbol,
167
+ targetUnitSymbol: baseUnitKg.symbol,
168
+ },
169
+ ctx,
170
+ );
171
+
172
+ expect(result.convertedQuantity).toBe(2.5); // rounded to 2 decimal places (kg precision)
173
+ });
174
+
175
+ it("converts pounds to kg correctly", async () => {
176
+ const { db, spies } = createMockDb<DB>();
177
+ spies.select
178
+ .mockReturnValueOnce(baseUnitPound) // 1 lb = 0.453592 kg
179
+ .mockReturnValueOnce(baseUnitKg); // 1 kg = 1.0 (reference)
180
+
181
+ // 10 lb * 0.453592 / 1.0 = 4.53592 kg, rounded to 4.54
182
+ const result = await convertQuantity(
183
+ db,
184
+ {
185
+ quantity: 10,
186
+ sourceUnitSymbol: baseUnitPound.symbol,
187
+ targetUnitSymbol: baseUnitKg.symbol,
188
+ },
189
+ ctx,
190
+ );
191
+
192
+ expect(result.convertedQuantity).toBe(4.54);
193
+ });
194
+
195
+ it("handles zero quantity correctly", async () => {
196
+ const { db, spies } = createMockDb<DB>();
197
+ spies.select.mockReturnValueOnce(baseUnitKg).mockReturnValueOnce(baseUnitGram);
198
+
199
+ const result = await convertQuantity(
200
+ db,
201
+ {
202
+ quantity: 0,
203
+ sourceUnitSymbol: baseUnitKg.symbol,
204
+ targetUnitSymbol: baseUnitGram.symbol,
205
+ },
206
+ ctx,
207
+ );
208
+
209
+ expect(result.convertedQuantity).toBe(0);
210
+ });
211
+
212
+ it("throws when permission is missing", async () => {
213
+ const { db } = createMockDb<DB>();
214
+ const denied: CommandContext = { actorId: "test-actor", permissions: [] };
215
+ await expect(
216
+ convertQuantity(db, { quantity: 1, sourceUnitSymbol: "kg", targetUnitSymbol: "g" }, denied),
217
+ ).rejects.toBeInstanceOf(InsufficientPermissionError);
218
+ });
219
+ });
@@ -0,0 +1,73 @@
1
+ import { defineCommand } from "../../shared/internal";
2
+ import { DB } from "../generated/kysely-tailordb";
3
+ import { InactiveUnitError, IncompatibleUnitsError, UnitNotFoundError } from "../lib/errors";
4
+ import { permissions } from "../permissions";
5
+
6
+ export interface ConvertQuantityInput {
7
+ quantity: number;
8
+ sourceUnitSymbol: string;
9
+ targetUnitSymbol: string;
10
+ }
11
+
12
+ /**
13
+ * Function: convertQuantity
14
+ *
15
+ * Converts a quantity from one unit of measure to another within the same category.
16
+ * Units are identified by their symbol (e.g., "kg", "lb", "g").
17
+ * The conversion uses each unit's conversion factor relative to the category's reference unit.
18
+ * Result is rounded to the target unit's precision setting.
19
+ */
20
+ export const convertQuantity = defineCommand(
21
+ permissions.convertQuantity,
22
+ async (db: DB, input: ConvertQuantityInput) => {
23
+ // 1. Validate source unit exists
24
+ const sourceUnit = await db
25
+ .selectFrom("Unit")
26
+ .selectAll()
27
+ .where("symbol", "=", input.sourceUnitSymbol)
28
+ .executeTakeFirst();
29
+
30
+ if (!sourceUnit) {
31
+ throw new UnitNotFoundError(input.sourceUnitSymbol);
32
+ }
33
+
34
+ // 2. Validate target unit exists
35
+ const targetUnit = await db
36
+ .selectFrom("Unit")
37
+ .selectAll()
38
+ .where("symbol", "=", input.targetUnitSymbol)
39
+ .executeTakeFirst();
40
+
41
+ if (!targetUnit) {
42
+ throw new UnitNotFoundError(input.targetUnitSymbol);
43
+ }
44
+
45
+ // 3. Validate both units are active
46
+ if (!sourceUnit.isActive) {
47
+ throw new InactiveUnitError(input.sourceUnitSymbol);
48
+ }
49
+
50
+ if (!targetUnit.isActive) {
51
+ throw new InactiveUnitError(input.targetUnitSymbol);
52
+ }
53
+
54
+ // 4. Validate units belong to the same category
55
+ if (sourceUnit.categoryId !== targetUnit.categoryId) {
56
+ throw new IncompatibleUnitsError(input.sourceUnitSymbol, input.targetUnitSymbol);
57
+ }
58
+
59
+ // 5. Perform conversion
60
+ // Formula: result = quantity * sourceConversionFactor / targetConversionFactor
61
+ const rawResult = (input.quantity * sourceUnit.conversionFactor) / targetUnit.conversionFactor;
62
+
63
+ // 6. Apply rounding to target unit's precision
64
+ const roundingFactor = Math.pow(10, targetUnit.roundingPrecision);
65
+ const convertedQuantity = Math.round(rawResult * roundingFactor) / roundingFactor;
66
+
67
+ return {
68
+ convertedQuantity,
69
+ sourceUnit,
70
+ targetUnit,
71
+ };
72
+ },
73
+ );