Skip to main content

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:

  1. Creates or retrieves the investor's holding for this fund
  2. Creates a transaction record
  3. 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> to Promise<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() to execute() for consistency

Benefits of Migration

  1. Explicit error handling - All error paths are visible and handled
  2. Type safety - Error types are discriminated unions with full type coverage
  3. Better testing - Error scenarios can be tested deterministically
  4. Improved debugging - Rich error context with business meaning
  5. Compliance - Audit-friendly error handling with full traceability

Best Practices

  1. Validate Early: Check all inputs before making any changes
  2. Idempotency: Consider using transaction IDs to prevent duplicate purchases
  3. Error Recovery: Don't fail the entire purchase if accounting fails
  4. Audit Trail: Ensure transaction records are created
  5. 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