Unit Transaction Service
The Unit Transaction Service provides centralized, read-only access to unit purchase and surrender transactions from both fund and investor perspectives. It ensures proper authorization and provides efficient querying with filtering and pagination capabilities.
Overview
The Unit Transaction Service acts as a query layer that consolidates transaction data that was previously split between FundService and InvestorService. It provides secure, authorized access to transaction history with organization-level isolation for multi-tenant security.
Key Responsibilities
- Fund-Scoped Queries: Retrieve all transactions for a specific fund
- Investor-Scoped Queries: Retrieve all transactions for a specific investor
- Authorization Enforcement: Verify fund/investor access belongs to expected organization
- Pagination Support: Efficient handling of large transaction datasets
- Rich Filtering: Filter by transaction type, status, dates, and search terms
Important Note
This service is read-only. Transaction creation is handled by the InvestorService through the InvestorHoldingAggregate, which maintains data consistency and business rule enforcement.
Core Features
Fund Transaction Queries
Retrieve transactions for a specific fund with investor details:
const result = await unitTransactionService.getFundTransactions(
{
fundId: "fund-123",
transactionType: "PURCHASE",
status: "COMPLETED",
dateFrom: "2025-01-01",
dateTo: "2025-01-31",
page: 1,
limit: 50,
},
{
expectedOrganizationId: "org-456", // Multi-tenant security
},
);
Investor Transaction Queries
Retrieve transactions for a specific investor with fund details:
const result = await unitTransactionService.getInvestorTransactions(
{
investorId: "investor-789",
fundId: "fund-123", // Optional filter
transactionType: "PURCHASE",
dateFrom: "2025-01-01",
dateTo: "2025-01-31",
page: 1,
limit: 50,
},
{
expectedOrganizationId: "org-456", // Multi-tenant security
},
);
API Reference
getFundTransactions()
Retrieves paginated unit transactions for a specific fund with investor details enriched.
Parameters
interface GetFundTransactionsRequest {
fundId: string;
investorId?: string; // Filter by specific investor
transactionType?: TransactionType;
status?: TransactionStatus;
dateFrom?: string; // ISO 8601 date
dateTo?: string; // ISO 8601 date
search?: string; // Search in description/references
page?: number; // Default: 1
limit?: number; // Default: 50, Max: 100
}
Returns
interface TransactionListResponse<T> {
transactions: T[];
pagination: {
page: number;
limit: number;
total: number;
totalPages: number;
};
}
Response Types
interface FundTransactionRecord {
id: string;
fundId: string;
investorId: string;
investorName: string; // Enriched from investor service
investorEmail: string; // Enriched from investor service
transactionType: TransactionType;
status: TransactionStatus;
units: number;
unitPrice: number;
totalAmount: number;
currency: string;
transactionDate: string;
settlementDate?: string;
reference?: string;
description?: string;
createdAt: string;
updatedAt: string;
}
getInvestorTransactions()
Retrieves paginated unit transactions for a specific investor with fund details enriched.
Parameters
interface GetInvestorTransactionsRequest {
investorId: string;
fundId?: string; // Filter by specific fund
transactionType?: TransactionType;
status?: TransactionStatus;
dateFrom?: string; // ISO 8601 date
dateTo?: string; // ISO 8601 date
page?: number; // Default: 1
limit?: number; // Default: 50, Max: 100
}
Response Types
interface InvestorTransactionRecord {
id: string;
fundId: string;
fundName: string; // Enriched from fund service
fundCode: string; // Enriched from fund service
investorId: string;
transactionType: TransactionType;
status: TransactionStatus;
units: number;
unitPrice: number;
totalAmount: number;
currency: string;
transactionDate: string;
settlementDate?: string;
reference?: string;
description?: string;
createdAt: string;
updatedAt: string;
}
Enums and Types
TransactionType
enum TransactionType {
PURCHASE = "PURCHASE",
SURRENDER = "SURRENDER",
}
TransactionStatus
enum TransactionStatus {
PENDING = "PENDING",
PROCESSING = "PROCESSING",
COMPLETED = "COMPLETED",
FAILED = "FAILED",
CANCELLED = "CANCELLED",
}
Security and Authorization
Multi-Tenant Isolation
The service enforces organization-based access control:
// Fund-scoped queries verify fund ownership
const fundResult = await this.fundService.getFundById(fundId);
if (
options.expectedOrganizationId &&
fundResult.value.organizationId !== options.expectedOrganizationId
) {
return err(createNotFoundError("Fund", fundId)); // Security: return not found
}
// Investor-scoped queries verify investor ownership
const investorResult = await this.investorService.getInvestorById(investorId);
if (
options.expectedOrganizationId &&
investorResult.value.organizationId !== options.expectedOrganizationId
) {
return err(createNotFoundError("Investor", investorId)); // Security: return not found
}
Access Pattern
- Always pass
expectedOrganizationIdin multi-tenant environments - Service returns "not found" instead of "access denied" for security
- Prevents information disclosure attacks
- Ensures proper data isolation between organizations
Error Handling
Common Errors
| Error Type | Description | Resolution |
|---|---|---|
NotFoundError | Fund or investor not found | Verify IDs exist and user has access |
ValidationError | Invalid pagination or filter parameters | Check parameter ranges and formats |
BusinessLogicError | Authorization failed | Verify organization access permissions |
Error Response Example
// Access denied scenario
{
type: "NotFoundError",
entity: "Fund",
id: "fund-123"
}
// Invalid pagination
{
type: "ValidationError",
field: "limit",
message: "Limit must be between 1 and 100"
}
Usage Examples
Frontend Integration
// Fund transaction history page
export function FundTransactionHistory() {
const { fundId, orgSlug } = useParams();
const { data, error, isLoading } = trpc.org.fund.unitTransactions.useQuery({
fundId,
page: 1,
limit: 25,
transactionType: "PURCHASE"
});
if (isLoading) return <Loading />;
if (error) return <ErrorMessage error={error} />;
return (
<div>
<h2>Unit Transactions</h2>
{data?.transactions.map(tx => (
<TransactionCard key={tx.id} transaction={tx} />
))}
<Pagination pagination={data.pagination} />
</div>
);
}
Backend Service Integration
// Use case that needs transaction history
export class InvestorDashboardUseCase {
constructor(
private unitTransactionService: UnitTransactionService,
// ... other services
) {}
async getRecentActivity(
investorId: string,
organizationId: string,
): Promise<Result<TransactionRecord[], DomainError>> {
const result = await this.unitTransactionService.getInvestorTransactions(
{
investorId,
dateFrom: this.getThirtyDaysAgo(),
dateTo: new Date().toISOString(),
limit: 10,
page: 1,
},
{
expectedOrganizationId: organizationId,
},
);
return result.map((response) => response.transactions);
}
}
Transaction Reconciliation
// Daily reconciliation process
export class DailyReconciliationUseCase {
async reconcileFundTransactions(
fundId: string,
businessDate: string,
organizationId: string,
): Promise<Result<ReconciliationReport, DomainError>> {
// Get all transactions for the day
const transactionsResult =
await this.unitTransactionService.getFundTransactions(
{
fundId,
dateFrom: businessDate,
dateTo: businessDate,
limit: 1000, // Get all transactions
page: 1,
},
{
expectedOrganizationId: organizationId,
},
);
if (transactionsResult.isErr()) {
return transactionsResult;
}
const transactions = transactionsResult.value.transactions;
// Perform reconciliation logic...
return this.performReconciliation(transactions, businessDate);
}
}
Performance Considerations
Database Queries
- Uses optimized repository queries with proper indexing
- Implements server-side pagination to limit memory usage
- Supports efficient filtering by date ranges and status
Caching Strategy
Consider caching frequently accessed data:
// Cache recent transactions for dashboard
const cacheKey = `transactions:${fundId}:recent`;
const cachedTransactions = await cache.get(cacheKey);
if (cachedTransactions) {
return ok(cachedTransactions);
}
const result = await this.unitTransactionService.getFundTransactions({
fundId,
dateFrom: this.getSevenDaysAgo(),
limit: 10,
});
// Cache for 5 minutes
await cache.set(cacheKey, result.value, { ttl: 300 });
Pagination Best Practices
- Use appropriate page sizes (10-100) based on UI requirements
- Implement infinite scroll for better UX on large datasets
- Cache pagination metadata separately from transaction data
Testing
Service Testing
describe("UnitTransactionService", () => {
let service: UnitTransactionService;
let mockRepository: jest.Mocked<UnitTransactionRepository>;
let mockFundService: jest.Mocked<FundService>;
beforeEach(() => {
mockRepository = createMockRepository();
mockFundService = createMockFundService();
service = new UnitTransactionService(
mockRepository,
mockFundService,
mockInvestorService,
);
});
it("should return fund transactions with proper authorization", async () => {
// Arrange
const fundId = "fund-123";
const orgId = "org-456";
mockFundService.getFundById.mockResolvedValue(
ok({
id: fundId,
organizationId: orgId,
} as Fund),
);
mockRepository.listTransactionsByFund.mockResolvedValue({
items: [mockTransaction],
total: 1,
});
// Act
const result = await service.getFundTransactions(
{
fundId,
page: 1,
limit: 50,
},
{
expectedOrganizationId: orgId,
},
);
// Assert
expect(result.isOk()).toBe(true);
expect(result.value.transactions).toHaveLength(1);
expect(mockFundService.getFundById).toHaveBeenCalledWith(fundId);
});
it("should reject access for wrong organization", async () => {
// Arrange
const fundId = "fund-123";
const wrongOrgId = "org-wrong";
mockFundService.getFundById.mockResolvedValue(
ok({
id: fundId,
organizationId: "correct-org-id",
} as Fund),
);
// Act
const result = await service.getFundTransactions(
{
fundId,
page: 1,
limit: 50,
},
{
expectedOrganizationId: wrongOrgId,
},
);
// Assert
expect(result.isErr()).toBe(true);
expect(result.error.type).toBe("NotFoundError");
});
});
Integration Testing
describe("UnitTransaction Integration", () => {
it("should handle complete transaction query workflow", async () => {
const ctx = await createTestContext();
const caller = createCaller(ctx);
// Create test data
const org = await caller.org.create({ name: "Test Org", slug: "test" });
const fund = await caller.org.funds.create({
orgSlug: org.slug,
fund: { name: "Test Fund", code: "TEST" },
});
const investor = await caller.org.investors.create({
orgSlug: org.slug,
investor: { name: "Test Investor", email: "[email protected]" },
});
// Create unit transaction (through investor service)
await caller.org.fund.investors.purchaseUnits({
orgSlug: org.slug,
fundCode: fund.code,
investorId: investor.id,
units: 100,
});
// Query transactions
const fundTransactions =
await caller.org.fund.unitTransactions.getTransactions({
fundId: fund.id,
page: 1,
limit: 10,
});
expect(fundTransactions.transactions).toHaveLength(1);
expect(fundTransactions.transactions[0].units).toBe(100);
});
});
Integration with tRPC Router
The Unit Transaction Service integrates with the tRPC router through dedicated endpoints:
// worker/trpc/routers/org/fund/unit-transactions.ts
export const unitTransactionsRouter = router({
getTransactions: publicProcedure
.input(
z.object({
fundId: z.string(),
transactionType: z.enum(["PURCHASE", "SURRENDER"]).optional(),
status: z
.enum(["PENDING", "PROCESSING", "COMPLETED", "FAILED"])
.optional(),
dateFrom: z.string().optional(),
dateTo: z.string().optional(),
page: z.number().int().positive().default(1),
limit: z.number().int().min(1).max(100).default(50),
}),
)
.query(async ({ input, ctx }) => {
// Verify fund access
const fundResult = await verifyFundAccess(ctx, input.fundId);
if (fundResult.isErr()) {
throw new TRPCError({
code: "NOT_FOUND",
message: "Fund not found",
});
}
const result =
await ctx.domainServices.unitTransaction.getFundTransactions(input, {
expectedOrganizationId: ctx.user?.orgId,
});
if (result.isErr()) {
throw new TRPCError({
code: "INTERNAL_SERVER_ERROR",
message: result.error.message,
});
}
return result.value;
}),
});
Best Practices
1. Always Use Organization Context
// ✅ Good: Pass organization context
await unitTransactionService.getFundTransactions(params, {
expectedOrganizationId: user.orgId,
});
// ❌ Bad: No organization context
await unitTransactionService.getFundTransactions(params);
2. Implement Proper Pagination
// ✅ Good: Use pagination parameters
const result = await service.getFundTransactions({
fundId,
page: currentPage,
limit: 25,
});
// ❌ Bad: No pagination (may return thousands of records)
const result = await service.getFundTransactions({
fundId,
limit: 10000, // Don't do this
});
3. Handle Errors Gracefully
const result = await service.getFundTransactions(params, { expectedOrganizationId });
if (result.isErr()) {
if (result.error.type === "NotFoundError") {
// Show user-friendly message
return <div>Fund not found</div>;
}
// Log technical errors
logger.error("Transaction query failed", { error: result.error });
return <div>Unable to load transactions</div>;
}
4. Cache Appropriately
// Cache recent transaction data
const cacheKey = `transactions:${fundId}:${JSON.stringify(filters)}`;
const cached = await cache.get(cacheKey);
if (cached) {
return ok(cached);
}
const result = await service.getFundTransactions(params);
if (result.isOk()) {
// Cache for 5 minutes
await cache.set(cacheKey, result.value, { ttl: 300 });
}
return result;
Service Status: ✅ Fully migrated to neverthrow patterns with comprehensive Result<T, DomainError> error handling.
Last Updated: 2025-01-15 - Reflects current implementation with multi-tenant security and pagination support.