Skip to main content

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 expectedOrganizationId in 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 TypeDescriptionResolution
NotFoundErrorFund or investor not foundVerify IDs exist and user has access
ValidationErrorInvalid pagination or filter parametersCheck parameter ranges and formats
BusinessLogicErrorAuthorization failedVerify 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.