@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.
- package/README.md +196 -28
- package/dist/cli.js +894 -0
- package/package.json +65 -8
- package/rules/app-compose/backend/auth.md +78 -0
- package/rules/app-compose/frontend/auth.md +55 -0
- package/rules/app-compose/frontend/component.md +55 -0
- package/rules/app-compose/frontend/page.md +86 -0
- package/rules/app-compose/frontend/screen-detailview.md +112 -0
- package/rules/app-compose/frontend/screen-form.md +145 -0
- package/rules/app-compose/frontend/screen-listview.md +159 -0
- package/rules/app-compose/structure.md +32 -0
- package/rules/module-development/commands.md +54 -0
- package/rules/module-development/cross-module-type-injection.md +28 -0
- package/rules/module-development/dependency-modules.md +24 -0
- package/rules/module-development/errors.md +12 -0
- package/rules/module-development/executors.md +67 -0
- package/rules/module-development/exports.md +13 -0
- package/rules/module-development/models.md +34 -0
- package/rules/module-development/structure.md +27 -0
- package/rules/module-development/sync-vs-async-operations.md +83 -0
- package/rules/module-development/testing.md +43 -0
- package/rules/sdk-best-practices/db-relations.md +74 -0
- package/rules/sdk-best-practices/sdk-docs.md +14 -0
- package/schemas/app-compose/actors.yml +34 -0
- package/schemas/app-compose/business-flow.yml +50 -0
- package/schemas/app-compose/requirements.yml +33 -0
- package/schemas/app-compose/resolver.yml +47 -0
- package/schemas/app-compose/screen.yml +81 -0
- package/schemas/app-compose/story.yml +67 -0
- package/schemas/module/command.yml +52 -0
- package/schemas/module/feature.yml +58 -0
- package/schemas/module/model.yml +70 -0
- package/schemas/module/module.yml +50 -0
- package/skills/1-module-docs/SKILL.md +107 -0
- package/skills/2-module-feature-breakdown/SKILL.md +66 -0
- package/skills/3-module-doc-review/SKILL.md +230 -0
- package/skills/4-module-tdd-implementation/SKILL.md +56 -0
- package/skills/5-module-implementation-review/SKILL.md +400 -0
- package/skills/app-compose-1-requirement-analysis/SKILL.md +85 -0
- package/skills/app-compose-2-requirements-breakdown/SKILL.md +88 -0
- package/skills/app-compose-3-doc-review/SKILL.md +112 -0
- package/skills/app-compose-4-design-mock/SKILL.md +248 -0
- package/skills/app-compose-5-design-mock-review/SKILL.md +283 -0
- package/skills/app-compose-6-implementation-spec/SKILL.md +122 -0
- package/skills/mock-scenario/SKILL.md +118 -0
- package/src/app.ts +1 -0
- package/src/cli.ts +120 -0
- package/src/commands/check.test.ts +30 -0
- package/src/commands/check.ts +66 -0
- package/src/commands/init.test.ts +77 -0
- package/src/commands/init.ts +87 -0
- package/src/commands/mock/index.ts +53 -0
- package/src/commands/mock/start.ts +179 -0
- package/src/commands/mock/validate.test.ts +185 -0
- package/src/commands/mock/validate.ts +198 -0
- package/src/commands/scaffold.test.ts +76 -0
- package/src/commands/scaffold.ts +119 -0
- package/src/commands/sync-check.test.ts +125 -0
- package/src/commands/sync-check.ts +182 -0
- package/src/integration.test.ts +63 -0
- package/src/mdschema.ts +48 -0
- package/src/mockServer.ts +55 -0
- package/src/module.ts +86 -0
- package/src/modules/accounting/.gitkeep +0 -0
- package/src/modules/coa-management/.gitkeep +0 -0
- package/src/modules/inventory/.gitkeep +0 -0
- package/src/modules/manufacturing/.gitkeep +0 -0
- package/src/modules/primitives/README.md +39 -0
- package/src/modules/primitives/command/activateCategory.test.ts +75 -0
- package/src/modules/primitives/command/activateCategory.ts +50 -0
- package/src/modules/primitives/command/activateCurrency.test.ts +70 -0
- package/src/modules/primitives/command/activateCurrency.ts +50 -0
- package/src/modules/primitives/command/activateUnit.test.ts +53 -0
- package/src/modules/primitives/command/activateUnit.ts +50 -0
- package/src/modules/primitives/command/convertAmount.test.ts +275 -0
- package/src/modules/primitives/command/convertAmount.ts +126 -0
- package/src/modules/primitives/command/convertQuantity.test.ts +219 -0
- package/src/modules/primitives/command/convertQuantity.ts +73 -0
- package/src/modules/primitives/command/createCategory.test.ts +126 -0
- package/src/modules/primitives/command/createCategory.ts +89 -0
- package/src/modules/primitives/command/createCurrency.test.ts +191 -0
- package/src/modules/primitives/command/createCurrency.ts +77 -0
- package/src/modules/primitives/command/createExchangeRate.test.ts +216 -0
- package/src/modules/primitives/command/createExchangeRate.ts +91 -0
- package/src/modules/primitives/command/createUnit.test.ts +214 -0
- package/src/modules/primitives/command/createUnit.ts +88 -0
- package/src/modules/primitives/command/deactivateCategory.test.ts +97 -0
- package/src/modules/primitives/command/deactivateCategory.ts +62 -0
- package/src/modules/primitives/command/deactivateCurrency.test.ts +85 -0
- package/src/modules/primitives/command/deactivateCurrency.ts +55 -0
- package/src/modules/primitives/command/deactivateUnit.test.ts +78 -0
- package/src/modules/primitives/command/deactivateUnit.ts +62 -0
- package/src/modules/primitives/command/setBaseCurrency.test.ts +98 -0
- package/src/modules/primitives/command/setBaseCurrency.ts +74 -0
- package/src/modules/primitives/command/setReferenceUnit.test.ts +108 -0
- package/src/modules/primitives/command/setReferenceUnit.ts +84 -0
- package/src/modules/primitives/db/currency.ts +30 -0
- package/src/modules/primitives/db/exchangeRate.ts +28 -0
- package/src/modules/primitives/db/unit.ts +32 -0
- package/src/modules/primitives/db/uomCategory.ts +32 -0
- package/src/modules/primitives/docs/commands/ActivateCategory.md +34 -0
- package/src/modules/primitives/docs/commands/ActivateCurrency.md +33 -0
- package/src/modules/primitives/docs/commands/ActivateUnit.md +34 -0
- package/src/modules/primitives/docs/commands/ConvertAmount.md +50 -0
- package/src/modules/primitives/docs/commands/ConvertQuantity.md +43 -0
- package/src/modules/primitives/docs/commands/CreateCategory.md +44 -0
- package/src/modules/primitives/docs/commands/CreateCurrency.md +47 -0
- package/src/modules/primitives/docs/commands/CreateExchangeRate.md +48 -0
- package/src/modules/primitives/docs/commands/CreateUnit.md +48 -0
- package/src/modules/primitives/docs/commands/DeactivateCategory.md +38 -0
- package/src/modules/primitives/docs/commands/DeactivateCurrency.md +38 -0
- package/src/modules/primitives/docs/commands/DeactivateUnit.md +38 -0
- package/src/modules/primitives/docs/commands/SetBaseCurrency.md +39 -0
- package/src/modules/primitives/docs/commands/SetReferenceUnit.md +43 -0
- package/src/modules/primitives/docs/features/currency-definitions.md +55 -0
- package/src/modules/primitives/docs/features/exchange-rates.md +61 -0
- package/src/modules/primitives/docs/features/unit-conversion.md +66 -0
- package/src/modules/primitives/docs/features/uom-categories.md +52 -0
- package/src/modules/primitives/docs/models/Currency.md +45 -0
- package/src/modules/primitives/docs/models/ExchangeRate.md +33 -0
- package/src/modules/primitives/docs/models/Unit.md +46 -0
- package/src/modules/primitives/docs/models/UoMCategory.md +44 -0
- package/src/modules/primitives/generated/kysely-tailordb.ts +95 -0
- package/src/modules/primitives/index.ts +40 -0
- package/src/modules/primitives/lib/errors.ts +138 -0
- package/src/modules/primitives/lib/types.ts +20 -0
- package/src/modules/primitives/module.ts +66 -0
- package/src/modules/primitives/permissions.ts +18 -0
- package/src/modules/primitives/tailor.config.ts +11 -0
- package/src/modules/primitives/testing/fixtures.ts +161 -0
- package/src/modules/product-management/.gitkeep +0 -0
- package/src/modules/purchase/.gitkeep +0 -0
- package/src/modules/sales/.gitkeep +0 -0
- package/src/modules/shared/createContext.test.ts +39 -0
- package/src/modules/shared/createContext.ts +15 -0
- package/src/modules/shared/defineCommand.test.ts +42 -0
- package/src/modules/shared/defineCommand.ts +19 -0
- package/src/modules/shared/definePermissions.test.ts +146 -0
- package/src/modules/shared/definePermissions.ts +94 -0
- package/src/modules/shared/entityTypes.ts +15 -0
- package/src/modules/shared/errors.ts +22 -0
- package/src/modules/shared/index.ts +1 -0
- package/src/modules/shared/internal.ts +13 -0
- package/src/modules/shared/requirePermission.test.ts +47 -0
- package/src/modules/shared/requirePermission.ts +8 -0
- package/src/modules/shared/types.ts +4 -0
- package/src/modules/supplier-management/.gitkeep +0 -0
- package/src/modules/supplier-portal/.gitkeep +0 -0
- package/src/modules/testing/index.ts +120 -0
- package/src/modules/user-management/README.md +38 -0
- package/src/modules/user-management/command/activateUser.test.ts +112 -0
- package/src/modules/user-management/command/activateUser.ts +67 -0
- package/src/modules/user-management/command/assignPermissionToRole.test.ts +119 -0
- package/src/modules/user-management/command/assignPermissionToRole.ts +87 -0
- package/src/modules/user-management/command/assignRoleToUser.test.ts +162 -0
- package/src/modules/user-management/command/assignRoleToUser.ts +93 -0
- package/src/modules/user-management/command/createPermission.test.ts +143 -0
- package/src/modules/user-management/command/createPermission.ts +66 -0
- package/src/modules/user-management/command/createRole.test.ts +115 -0
- package/src/modules/user-management/command/createRole.ts +52 -0
- package/src/modules/user-management/command/createUser.test.ts +198 -0
- package/src/modules/user-management/command/createUser.ts +85 -0
- package/src/modules/user-management/command/deactivateUser.test.ts +112 -0
- package/src/modules/user-management/command/deactivateUser.ts +67 -0
- package/src/modules/user-management/command/logAuditEvent.test.ts +179 -0
- package/src/modules/user-management/command/logAuditEvent.ts +59 -0
- package/src/modules/user-management/command/reactivateUser.test.ts +115 -0
- package/src/modules/user-management/command/reactivateUser.ts +67 -0
- package/src/modules/user-management/command/revokePermissionFromRole.test.ts +112 -0
- package/src/modules/user-management/command/revokePermissionFromRole.ts +81 -0
- package/src/modules/user-management/command/revokeRoleFromUser.test.ts +112 -0
- package/src/modules/user-management/command/revokeRoleFromUser.ts +81 -0
- package/src/modules/user-management/db/auditEvent.ts +47 -0
- package/src/modules/user-management/db/permission.ts +31 -0
- package/src/modules/user-management/db/role.ts +28 -0
- package/src/modules/user-management/db/rolePermission.ts +44 -0
- package/src/modules/user-management/db/user.ts +38 -0
- package/src/modules/user-management/db/userRole.ts +44 -0
- package/src/modules/user-management/docs/commands/ActivateUser.md +36 -0
- package/src/modules/user-management/docs/commands/AssignPermissionToRole.md +39 -0
- package/src/modules/user-management/docs/commands/AssignRoleToUser.md +43 -0
- package/src/modules/user-management/docs/commands/CreatePermission.md +35 -0
- package/src/modules/user-management/docs/commands/CreateRole.md +35 -0
- package/src/modules/user-management/docs/commands/CreateUser.md +41 -0
- package/src/modules/user-management/docs/commands/DeactivateUser.md +38 -0
- package/src/modules/user-management/docs/commands/LogAuditEvent.md +37 -0
- package/src/modules/user-management/docs/commands/ReactivateUser.md +37 -0
- package/src/modules/user-management/docs/commands/RevokePermissionFromRole.md +40 -0
- package/src/modules/user-management/docs/commands/RevokeRoleFromUser.md +40 -0
- package/src/modules/user-management/docs/features/audit-trail.md +80 -0
- package/src/modules/user-management/docs/features/role-based-access-control.md +76 -0
- package/src/modules/user-management/docs/features/user-account-management.md +64 -0
- package/src/modules/user-management/docs/models/AuditEvent.md +34 -0
- package/src/modules/user-management/docs/models/Permission.md +31 -0
- package/src/modules/user-management/docs/models/Role.md +31 -0
- package/src/modules/user-management/docs/models/RolePermission.md +33 -0
- package/src/modules/user-management/docs/models/User.md +47 -0
- package/src/modules/user-management/docs/models/UserRole.md +34 -0
- package/src/modules/user-management/docs/plans/2026-01-30-flattened-permissions-design.md +52 -0
- package/src/modules/user-management/executor/recomputeOnRolePermissionChange.ts +61 -0
- package/src/modules/user-management/generated/enums.ts +24 -0
- package/src/modules/user-management/generated/kysely-tailordb.ts +112 -0
- package/src/modules/user-management/index.ts +32 -0
- package/src/modules/user-management/lib/errors.ts +81 -0
- package/src/modules/user-management/lib/recomputeUserPermissions.ts +53 -0
- package/src/modules/user-management/lib/types.ts +31 -0
- package/src/modules/user-management/module.ts +77 -0
- package/src/modules/user-management/permissions.ts +15 -0
- package/src/modules/user-management/tailor.config.ts +11 -0
- package/src/modules/user-management/testing/fixtures.ts +98 -0
- package/src/schemas.ts +25 -0
- package/src/testing.ts +10 -0
- 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
|
+
});
|