Unit Purchase Use Case
Orchestrates the complete workflow of an investor purchasing fund units, including validation, holding updates, and accounting journal entries. This use case has been fully migrated to neverthrow Result patterns with custom error types.
Status: ✅ Fully migrated to neverthrow - Uses Result<T, PurchaseError> patterns with comprehensive error handling
Migration: Complete with custom error types and structured error propagation
Location: worker/use-cases/investor/purchase-units.use-case.ts
Dependencies
export interface PurchaseUnitsDependencies {
investorService: InvestorService;
accountingService?: IAccountingService;
bankAccountService?: BankAccountService;
fundService?: FundService;
}
Workflow Diagram
Custom Error Types
The use case implements comprehensive error handling with discriminated unions:
PurchaseError Types
export type PurchaseError =
| ValidationError // Input validation failures
| BusinessLogicError // Business rule violations
| SystemError // System-level failures
| NotFoundError; // Resource not found errors
ValidationError
interface ValidationError {
type: "ValidationError";
field: string; // Field that failed validation
message: string; // Error message
value?: unknown; // Invalid value
}
// Usage examples
ValidationError.positiveUnits(); // Units must be positive
ValidationError.positiveNav(); // NAV must be positive
ValidationError.custom("field", "Custom error message", value);
BusinessLogicError
interface BusinessLogicError {
type: "BusinessLogicError";
code: string; // Error code for routing
message: string; // Error description
details?: Record<string, unknown>;
}
// Usage examples
BusinessLogicError.accessDenied("Investor not authorized for fund");
BusinessLogicError.insufficientFunds("Insufficient cash balance");
SystemError & NotFoundError
interface SystemError {
type: "SystemError";
code: string;
message: string;
originalError?: Error;
details?: Record<string, unknown>;
}
interface NotFoundError {
type: "NotFoundError";
resource: string; // Resource type
id: string; // Resource ID
message: string;
}
Request & Response
PurchaseUnitsRequest
export interface PurchaseUnitsRequest {
investorId: string | number;
fundId: string;
units: number;
navPerUnit: { amount: number; currency: string };
purchaseDate?: string;
/** User ID who is creating this purchase entry */
createdBy?: string;
}
PurchaseUnitsResult
export interface PurchaseUnitsResult {
transactionId: string;
holding: InvestorHolding;
journalEntryId?: string;
bankAccountMovement?: {
amount: number;
type: string;
bankAccountId: number;
};
}
Process Steps
1. Input Validation
async execute(request: PurchaseUnitsRequest): Promise<Result<PurchaseUnitsResult, PurchaseError>> {
// Validate positive values with Result-based errors
if (request.units <= 0) {
return err(ValidationError.positiveUnits());
}
if (request.navPerUnit.amount <= 0) {
return err(ValidationError.positiveNav());
}
// Normalize investor ID to string
const investorId = typeof request.investorId === "number"
? String(request.investorId)
: request.investorId;
// Use Result-based service calls
const investorResult = await this.svc.investorService.getInvestorById(investorId);
if (investorResult.isErr()) {
return err(NotFoundError.investor(investorId));
}
const investor = investorResult.value;
// Continue processing...
return ok(result);
}
2. Fund and Organization Validation
if (this.svc.fundService) {
const fund = await this.svc.fundService.getFundById(request.fundId);
if (!fund) throw new Error("Fund not found");
// Verify fund belongs to same organization as investor
if (fund.organizationId !== investor.organizationId) {
throw new Error("Fund not found or access denied");
}
// Verify at least one bank account exists
if (this.svc.bankAccountService) {
const bankAccounts =
await this.svc.bankAccountService.getBankAccountsByFund(request.fundId);
if (bankAccounts.length === 0) {
throw new Error("No bank account found for fund");
}
}
}
3. Process Unit Purchase
The use case delegates to InvestorService which:
- Creates or retrieves the investor's holding for this fund
- Creates a transaction record
- Updates the holding with weighted average cost
const { transactionId } = await this.svc.investorService.purchaseUnits({
investorId,
fundId: request.fundId,
units: request.units,
navPerUnit: request.navPerUnit,
});
Weighted Average Cost Calculation:
New Total Units = Existing Units + Purchased Units
New Average Cost = (Existing Units × Existing Cost + Purchased Units × Purchase Price) / New Total Units
Example:
- Existing: 100 units @ BDT 10.50 = BDT 1,050
- Purchase: 50 units @ BDT 11.00 = BDT 550
- New: 150 units @ BDT 10.67 = BDT 1,600
4. Retrieve Updated Holding
const holdings = await this.svc.investorService.getInvestorHoldings(investorId);
const holding = holdings.find((h) => h.fundId === request.fundId);
if (!holding) throw new Error("Holding not found after purchase");
5. Create Journal Entry (Optional)
If accounting service is available, create journal entry:
private async createJournalEntry(request: UnitPurchaseRequest): Promise<string> {
const totalAmount = request.units * request.navPerUnit.amount;
const faceValue = 10; // BDT 10 per unit face value
const faceValueTotal = request.units * faceValue;
const premiumDiscount = totalAmount - faceValueTotal;
// Get accounts
const accounts = await this.svc.accountingService!.getAccountsByFundId(request.fundId);
const bankAccount = accounts.find((a) => a.name.includes("Bank Account"));
const unitCapital = accounts.find((a) => a.name === "Unit Capital");
const unitPremium = accounts.find((a) => a.name === "Unit Premium");
const unitDiscount = accounts.find((a) => a.name === "Unit Discount");
if (!bankAccount || !unitCapital) {
throw new Error("Required accounts not found");
}
// Build journal entry lines
const lines = [
{ accountId: bankAccount.id, amount: totalAmount }, // Debit
{ accountId: unitCapital.id, amount: -faceValueTotal }, // Credit
];
// Handle premium or discount
if (premiumDiscount > 0 && unitPremium) {
lines.push({ accountId: unitPremium.id, amount: -premiumDiscount }); // Credit
} else if (premiumDiscount < 0 && unitDiscount) {
lines.push({ accountId: unitDiscount.id, amount: Math.abs(premiumDiscount) }); // Debit
}
const entry = await this.svc.accountingService!.createJournalEntry({
fundId: request.fundId,
description: `Unit purchase - ${request.units} units @ ${request.navPerUnit.amount}`,
createdBy: "test-user-id-1",
lines,
});
return entry.id;
}
Journal Entry Example:
Purchase: 100 units @ BDT 11.00 per unit = BDT 1,100
- Face value: 100 × BDT 10.00 = BDT 1,000
- Premium: BDT 1,100 - BDT 1,000 = BDT 100
Dr. Bank Account 1,100
Cr. Unit Capital 1,000
Cr. Unit Premium 100
Usage Examples
Basic Unit Purchase with Result Handling
const result = await purchaseUnitsUseCase.execute({
investorId: "investor-123",
fundId: "fund-456",
units: 100,
navPerUnit: { amount: 1100, currency: "BDT" }, // 11.00 BDT
createdBy: "user-789",
});
if (result.isOk()) {
const purchase = result.value;
console.log(`Transaction ID: ${purchase.transactionId}`);
console.log(`New total units: ${purchase.holding.totalUnits}`);
console.log(`Average cost: ${purchase.holding.averageCost}`);
if (purchase.journalEntryId) {
console.log(`Journal entry: ${purchase.journalEntryId}`);
}
} else {
const error = result.error;
console.error("Unit purchase failed:", error.message);
// Handle specific error types
switch (error.type) {
case "ValidationError":
console.error(`Validation failed for ${error.field}: ${error.message}`);
break;
case "BusinessLogicError":
console.error(`Business logic error (${error.code}): ${error.message}`);
break;
case "NotFoundError":
console.error(`Resource not found: ${error.resource} ${error.id}`);
break;
case "SystemError":
console.error(`System error (${error.code}): ${error.message}`);
break;
}
}
Error Pattern Matching
const result = await purchaseUnitsUseCase.execute(request);
result.match(
(purchase) => {
// Success: process the purchase result
logger.info("Unit purchase completed", {
transactionId: purchase.transactionId,
units: purchase.holding.totalUnits,
});
},
(error) => {
// Error: handle specific error types
switch (error.type) {
case "ValidationError":
showUserError(`Invalid ${error.field}: ${error.message}`);
break;
case "BusinessLogicError":
showUserError(error.message);
break;
case "NotFoundError":
showUserError(`${error.resource} not found`);
break;
case "SystemError":
showSystemError(error.message);
break;
}
},
);
Premium vs Discount
At Premium (NAV > Face Value)
When NAV per unit > BDT 10.00:
Purchase: 100 units @ BDT 11.50 = BDT 1,150
Face value: 100 × 10 = BDT 1,000
Premium: BDT 150
Dr. Bank Account 1,150
Cr. Unit Capital 1,000
Cr. Unit Premium 150
At Discount (NAV < Face Value)
When NAV per unit < BDT 10.00:
Purchase: 100 units @ BDT 9.50 = BDT 950
Face value: 100 × 10 = BDT 1,000
Discount: BDT 50
Dr. Bank Account 950
Dr. Unit Discount 50
Cr. Unit Capital 1,000
At Par (NAV = Face Value)
When NAV per unit = BDT 10.00:
Purchase: 100 units @ BDT 10.00 = BDT 1,000
Face value: 100 × 10 = BDT 1,000
Premium/Discount: BDT 0
Dr. Bank Account 1,000
Cr. Unit Capital 1,000
Error Handling Patterns
Result-Based Error Propagation
async execute(request: PurchaseUnitsRequest): Promise<Result<PurchaseUnitsResult, PurchaseError>> {
// Validation returns specific errors
if (request.units <= 0) {
return err(ValidationError.positiveUnits());
}
if (request.navPerUnit.amount <= 0) {
return err(ValidationError.positiveNav());
}
// Service calls with Result handling
const investorResult = await this.svc.investorService.getInvestorById(investorId);
if (investorResult.isErr()) {
return err(NotFoundError.investor(investorId));
}
const investor = investorResult.value;
// Authorization check with business logic error
if (fund.organizationId !== investor.organizationId) {
return err(BusinessLogicError.accessDenied("Investor not authorized for this fund"));
}
// Transaction processing with Result handling
const purchaseResult = await this.svc.investorService.purchaseUnits({...});
if (purchaseResult.isErr()) {
return err(purchaseResult.error); // Propagate domain service error
}
const result: PurchaseUnitsResult = {
transactionId: purchaseResult.value.transactionId,
holding: purchaseResult.value.holding
};
// Optional journal entry with graceful degradation
if (this.svc.accountingService) {
const journalResult = await this.createJournalEntrySafe(request);
if (journalResult.isOk()) {
result.journalEntryId = journalResult.value;
} else {
logger.warn("Failed to create journal entry:", journalResult.error);
// Don't fail purchase - journal entry is optional
}
}
return ok(result);
}
Safe Journal Entry Creation
private async createJournalEntrySafe(request: PurchaseUnitsRequest): Promise<Result<string, SystemError>> {
try {
const journalEntryId = await this.createJournalEntry(request);
return ok(journalEntryId);
} catch (error) {
return err(SystemError.custom(
"JOURNAL_ENTRY_FAILED",
"Failed to create journal entry for unit purchase",
error instanceof Error ? error : undefined
));
}
}
Testing
Unit Test with Result Patterns
describe("PurchaseUnitsUseCase", () => {
it("processes unit purchase with accounting", async () => {
const mockInvestorService = {
getInvestorById: vi.fn().mockResolvedValue(
ok({
id: "1",
organizationId: "org-1",
}),
),
purchaseUnits: vi.fn().mockResolvedValue(ok({ transactionId: "tx-123" })),
getInvestorHoldings: vi
.fn()
.mockResolvedValue([{ fundId: 1, totalUnits: 100, averageCost: 1050 }]),
};
const mockAccountingService = {
getAccountsByFundId: vi.fn().mockResolvedValue([
{ id: "1", name: "Bank Account - 123" },
{ id: "2", name: "Unit Capital" },
{ id: "3", name: "Unit Premium" },
]),
createJournalEntry: vi.fn().mockResolvedValue({ id: "je-123" }),
};
const useCase = new PurchaseUnitsUseCase({
investorService: mockInvestorService as any,
accountingService: mockAccountingService as any,
});
const result = await useCase.execute({
investorId: "1",
fundId: "FUND-1",
units: 100,
navPerUnit: { amount: 1100, currency: "BDT" },
});
expect(result.isOk()).toBe(true);
if (result.isOk()) {
expect(result.value.transactionId).toBe("tx-123");
expect(result.value.journalEntryId).toBe("je-123");
}
expect(mockInvestorService.purchaseUnits).toHaveBeenCalled();
});
it("handles validation errors", async () => {
const useCase = new PurchaseUnitsUseCase({
investorService: {} as any,
});
const result = await useCase.execute({
investorId: "1",
fundId: "FUND-1",
units: -10, // Invalid units
navPerUnit: { amount: 1100, currency: "BDT" },
});
expect(result.isErr()).toBe(true);
if (result.isErr()) {
expect(result.error.type).toBe("ValidationError");
expect(result.error.field).toBe("units");
}
});
it("handles not found errors", async () => {
const mockInvestorService = {
getInvestorById: vi
.fn()
.mockResolvedValue(err(notFoundError("Investor", "1"))),
};
const useCase = new PurchaseUnitsUseCase({
investorService: mockInvestorService as any,
});
const result = await useCase.execute({
investorId: "1",
fundId: "FUND-1",
units: 100,
navPerUnit: { amount: 1100, currency: "BDT" },
});
expect(result.isErr()).toBe(true);
if (result.isErr()) {
expect(result.error.type).toBe("NotFoundError");
expect(result.error.resource).toBe("Investor");
}
});
});
Integration Test with Result Handling
describe("PurchaseUnitsUseCase Integration", () => {
it("handles complete unit purchase workflow", async () => {
const services = createDomainServices({
db: await getTestDB(),
env: {} as Env,
});
const useCases = createApplicationServices(services);
// Setup fund with accounts
const org = await services.organization.createOrganization({
name: "Test",
slug: "test",
});
const fundSetupResult = await useCases.fundSetup.execute({
organizationId: org.id,
fund: { name: "Test Fund", code: "TEST01" },
bankAccounts: [
{
bank: "Test",
accountNumber: "123",
routingNumber: "ROUTE-123",
accountName: "Operating Account",
},
],
});
expect(fundSetupResult.isOk()).toBe(true);
if (!fundSetupResult.isOk()) return;
const fundSetup = fundSetupResult.value;
// Register investor
const investorResult = await services.investor.registerInvestor(
{
fullName: "John Doe",
email: "[email protected]",
mobilePhone: "123",
investorType: "individual",
},
org.id,
);
expect(investorResult.isOk()).toBe(true);
if (!investorResult.isOk()) return;
const { investorId } = investorResult.value;
// Purchase units
const result = await useCases.unitPurchase.execute({
investorId,
fundId: fundSetup.fund.id,
units: 100,
navPerUnit: { amount: 1100, currency: "BDT" },
});
expect(result.isOk()).toBe(true);
if (result.isOk()) {
expect(result.value.transactionId).toBeDefined();
expect(result.value.holding.totalUnits).toBe(100);
expect(result.value.holding.averageCost).toBe(1100);
expect(result.value.journalEntryId).toBeDefined();
}
});
});
Migration Notes
Pre-Migration (Exception-Based)
// Old pattern - throws exceptions
async purchaseUnits(request: UnitPurchaseRequest): Promise<UnitPurchaseResult> {
if (request.units <= 0) {
throw new Error("Units must be positive");
}
// ... processing logic with try/catch blocks
}
Post-Migration (Result-Based)
// New pattern - returns Results
async execute(request: PurchaseUnitsRequest): Promise<Result<PurchaseUnitsResult, PurchaseError>> {
if (request.units <= 0) {
return err(ValidationError.positiveUnits());
}
// ... processing logic with Result handling
return ok(result);
}
Breaking Changes
- Method signature: Changed from
Promise<UnitPurchaseResult>toPromise<Result<PurchaseUnitsResult, PurchaseError>> - Error handling: Exception throwing replaced with Result-based error returns
- Caller responsibilities: Must handle
.isOk()/.isErr()patterns - Error objects: Structured error data instead of plain error messages
- Method name: Changed from
purchaseUnits()toexecute()for consistency
Benefits of Migration
- Explicit error handling - All error paths are visible and handled
- Type safety - Error types are discriminated unions with full type coverage
- Better testing - Error scenarios can be tested deterministically
- Improved debugging - Rich error context with business meaning
- Compliance - Audit-friendly error handling with full traceability
Best Practices
- Validate Early: Check all inputs before making any changes
- Idempotency: Consider using transaction IDs to prevent duplicate purchases
- Error Recovery: Don't fail the entire purchase if accounting fails
- Audit Trail: Ensure transaction records are created
- Authorization: Always verify organization membership
Common Issues
Issue: "Holding not found after purchase"
Cause: Race condition or investor service failure
Solution: Ensure investor service completes before querying holdings
Issue: "Required accounts not found"
Cause: Chart of accounts not properly initialized
Solution: Run fund setup use case to create complete chart
Issue: Premium/discount calculation wrong
Cause: Face value not matching actual convention
Solution: Verify face value constant matches fund's unit structure
See Also
- Investor Service API - Unit purchase logic
- Accounting Service API - Journal entries
- [Fund Setup UseCase](/use cases/fund-setup) - Creates chart of accounts