Portfolio Reporting — Single-Owner Scope
Portfolio Reporting — Single-Owner Scope
Compliance rule: Portfolio-level reporting always aggregates data within a single owner's blocks only. Financial data across different owners' management accounts must never be merged or combined. Different owners' funds are legally distinct client money.
Overview
The portfolio reporting system enforces a strict ownership boundary on all financial reports. When an agent manages blocks on behalf of multiple owners, each owner's financial data is isolated — there is no combined P&L or cross-owner ledger view.
Agents can see a structural overview of their entire portfolio from the agent dashboard, but detailed financial data is always accessed owner-by-owner.
Endpoints
All endpoints are available under the portfolioReporting tRPC router and require the caller to be authenticated and org-scoped.
portfolioReporting.ownerPortfolio
Returns a full portfolio summary for a single owner.
Permission required: financials.read
Input:
{ ownerId: string }
Returns:
{
owner: {
id: string;
name: string;
ownerType: string;
email: string | null;
phone: string | null;
};
blocks: OwnerScopedBlock[]; // All blocks belonging to this owner
totals: {
blockCount: number;
totalUnits: number;
blocksWithManagementAccounts: number;
blocksWithClientMoneyAccounts: number;
};
}
This endpoint logs a portfolio.owner_summary_viewed audit event on each call.
portfolioReporting.validateReportScope
Validates that a set of block IDs all belong to a single owner. Use this before generating any report that takes multiple block IDs as input.
Permission required: financials.read
Input:
{
blockIds: string[]; // 1–500 block IDs
reportType?: string; // Optional label for audit logging
}
Returns:
{
valid: boolean;
ownerId: string | null;
ownerName: string | null;
blockCount: number;
distinctOwnerIds: string[]; // Populated when valid=false and blocks span multiple owners
rejectionReason: string | null;
}
When valid is false and the blocks span multiple owners, the attempt is written to the audit trail as portfolio.cross_owner_report_rejected.
portfolioReporting.agentOverview
Agent-level dashboard showing per-owner summaries across the entire portfolio.
Permission required: financials.read
Input: None
Returns:
{
agentTotals: {
ownerCount: number;
totalBlocks: number;
totalUnits: number;
totalActiveClientMoneyAccounts: number;
totalActiveManagementSources: number;
unassignedBlocks: number;
};
owners: Array<{
owner: { id, name, ownerType, email, createdAt };
blockCount: number;
totalUnits: number;
activeClientMoneyAccounts: number;
activeManagementAccountSources: number;
// No financial totals — intentionally omitted
}>;
_enforcement: {
rule: string;
message: string;
};
}
Important: The agentTotals object contains only structural counts (blocks, units). Financial totals such as balances and P&L figures are never aggregated at this level. To view financial data, call ownerPortfolio for a specific owner.
portfolioReporting.ownerBlocksSummary
Lightweight block list for a single owner. Designed for populating block selectors in report-building UIs.
Permission required: blocks.read
Input:
{ ownerId: string }
Returns:
{
ownerId: string;
blocks: Array<{
id: string;
name: string;
addressLine1: string | null;
city: string | null;
postcode: string | null;
totalUnits: number | null;
}>;
blockCount: number;
}
Utility Functions
The enforcement logic lives in src/lib/portfolio-scope.ts and should be used directly by any future financial reporting procedure.
requireSingleOwnerScope
The primary guard for financial report procedures. Throws TRPCError(BAD_REQUEST) if the provided blocks span more than one owner.
import { requireSingleOwnerScope } from "@/lib/portfolio-scope";
// At the top of any financial reporting procedure:
const { ownerId } = await requireSingleOwnerScope(
blockIds,
ctx.orgId,
{ logAttempt: true, userId: ctx.userId, reportType: "P&L" }
);
// If blockIds span multiple owners, execution stops here with TRPCError(BAD_REQUEST)
| Option | Type | Description |
|---|---|---|
logAttempt | boolean | Write rejected cross-owner attempts to the audit trail |
userId | string | User ID for the audit log entry |
reportType | string | Human-readable label for the report type being attempted |
validateSingleOwnerScope
Same validation logic as requireSingleOwnerScope, but returns a result object instead of throwing. Use when you need to show the user a detailed error.
import { validateSingleOwnerScope } from "@/lib/portfolio-scope";
const result = await validateSingleOwnerScope(blockIds, ctx.orgId);
if (!result.valid) {
// result.rejectionReason explains why
// result.distinctOwnerIds lists the owner IDs found
}
getOwnerScopedBlocks
Fetch all blocks for a single owner. All returned blocks are guaranteed to belong to exactly one owner.
import { getOwnerScopedBlocks } from "@/lib/portfolio-scope";
const blocks = await getOwnerScopedBlocks(ownerId, ctx.orgId);
buildOwnerPortfolioSummary
Builds a complete OwnerPortfolioSummary for a single owner. Includes owner details, block list, unit totals, and account status counts.
import { buildOwnerPortfolioSummary } from "@/lib/portfolio-scope";
const summary = await buildOwnerPortfolioSummary(ownerId, ctx.orgId);
Audit Trail
The following audit actions are emitted by this feature:
| Action | Trigger |
|---|---|
portfolio.owner_summary_viewed | ownerPortfolio endpoint called successfully |
portfolio.cross_owner_report_rejected | validateReportScope or requireSingleOwnerScope detects blocks spanning multiple owners |
Building Future Financial Features
When implementing P&L, ledger, or service charge reporting, always begin the procedure with requireSingleOwnerScope:
import { requireSingleOwnerScope } from "@/lib/portfolio-scope";
export const myFinancialReportProcedure = orgProcedure
.input(z.object({ blockIds: z.array(z.string()) }))
.query(async ({ ctx, input }) => {
requirePermission(ctx.platformRole as PlatformRole, "financials.read");
// Enforce single-owner scope before any financial aggregation
const { ownerId } = await requireSingleOwnerScope(
input.blockIds,
ctx.orgId,
{ logAttempt: true, userId: ctx.userId, reportType: "MyReport" }
);
// All further queries are now safely scoped to a single owner
// ...
});
Financial totals (balances, P&L) must never be aggregated across owner boundaries, even at the agent dashboard level. Structural counts (number of blocks, number of units) are safe to aggregate.