Spec: Pipeline Management
Status: Draft Author: Kevin Sawyer (Product Owner) + AI Architect Date: 2026-03-17 (revised)
Overview
Pipeline Management is the secondary marketing operations layer of PSSaaS. It tracks purchased loans through the investor/buyer delivery and settlement lifecycle — managing which buyer each loan goes to, at what price, the profit spread, delivery timing, and settlement confirmation. It does NOT replace the principal's Loan Origination System (LOS), which remains the system of record for loan data. Instead, Pipeline imports loan data from multiple sources (PSX webhooks, LOS integrations, future: file uploads) and overlays secondary marketing intelligence that the LOS doesn't manage. PSSaaS creates pipeline records and provides a configurable state machine, audit trail, dashboard views, profit tracking, and settlement tracking so that PSX principals can manage their entire post-purchase secondary marketing lifecycle in one place.
Business Context
Watermark TPO (MWFI Holdings) is the first principal on PowerSeller X. Their current workflow:
- Sellers upload loan tapes to PSX
- CRA eligibility is determined via third-party (Incenter)
- WTPO evaluates loans against PSX buyer rate sheets
- WTPO purchases profitable loans (trade executed on PSX)
- Loans must be tracked through delivery to the buyer ← Pipeline Management
- Settlement and payment must be confirmed ← Pipeline Management
Steps 1–4 are handled entirely by PSX. PSX does not track post-execution operations. PSSaaS Pipeline Management picks up at step 5, providing the operational backbone for everything that happens after a loan is purchased until settlement is complete.
A second principal (CDFI-focused) is expected later this year with a similar buy-side profile. The pipeline design must be configurable enough to accommodate different status workflows without code changes.
Relationship to the LOS
When WTPO purchases a loan, that loan enters WTPO's Loan Origination System as the system of record. The LOS manages operational concerns: document custody, compliance, servicing setup, accounting entries.
PSSaaS Pipeline manages secondary marketing concerns that the LOS does not:
- Which buyer/investor is this loan going to, and at what price?
- What is the profit spread between purchase price and sale price?
- When must it be delivered, and has delivery been confirmed?
- What settlement is expected vs. received?
- Across all loans from all sources, what is the aggregate position?
Data flows bidirectionally:
- LOS → PSSaaS: Loan data imports (via LOS integration or file upload, supplementing PSX webhook data)
- PSSaaS → LOS: Profit/loss data, delivery confirmations, settlement records (for accounting and regulatory audit)
This is the same model the legacy PowerSeller Desktop App used — it always sat alongside a lender's LOS, never replaced it.
Why Not the Full Legacy Pipeline?
The legacy PowerBuilder pipeline module manages the entire loan lifecycle — from origination through underwriting, pooling, trading, and settlement. That scope is far broader than what PSX principals need. PSX-purchased loans skip origination, underwriting, and fallout entirely — they arrive as completed purchases that need delivery and settlement tracking. This spec targets that narrow but critical gap. The legacy Desktop App always coexisted with lenders' LOSs — importing loan data from them and providing secondary marketing operations that LOSs don't handle. PSSaaS continues this pattern.
PowerBuilder Reference
The legacy pipeline implementation lives in the pwrpipe module folder. Relevant structures:
| Legacy Component | Relevance to This Spec |
|---|---|
loan table | Active pipeline loans — field names inform PipelineLoan entity design |
loan_shipped table | Settled/delivered loans — settlement concept maps to our settled status |
loan_history table | Fallout/historical loans — our rejected/returned statuses serve a similar archival purpose |
pscat_pipeline_loan_status_codes | Status code catalog — inspires our configurable state machine |
| Activity log | Audit trail pattern — our PipelineStatusHistory serves the same purpose |
The legacy module's underwriting stages, fallout processing, master agreements, reservations, and best efforts workflows are not ported in this spec. See Out of Scope.
Requirements
LOS Integration (Future — Designed For)
- Support configurable LOS data import via REST API (pull or push)
- Map LOS loan fields to PSSaaS Pipeline loan fields (configurable field mapping per LOS)
- Support incremental sync (import only new or changed loans since last sync)
- Feed settlement and profit data back to the LOS via API callback
- Track data provenance (which fields came from PSX, which from LOS, which computed by PSSaaS)
- LOS integration is NOT required for V1 — PSX webhook is the primary import source initially
Note: V1 import sources are PSX webhook and future manual/file upload. LOS integration is designed for but implemented in a later phase.
Trade Ingestion
- Receive trade execution notifications from PSX via webhook (
POST /api/pipeline/trades) - Validate incoming trade payload: required fields, data types, no duplicate
exchange_trade_id - Create a
PipelineTraderecord grouping all loans in the trade - Create individual
PipelineLoanrecords for each loan in the trade, initialized topurchasedstatus - Record the initial status change in
PipelineStatusHistorywithchanged_by: "system/exchange-webhook" - Return the created
pipeline_trade_idand loan count in the webhook response - Support idempotent webhook processing — redelivery of the same
exchange_trade_idreturns the existing trade without creating duplicates - Authenticate webhook requests via a shared secret (API key in
X-Exchange-Api-Keyheader)
Loan Status Management
- Update individual loan status via API with required notes
- Bulk-update status for multiple loans in a single request (up to 500 loans per call)
- Enforce state machine transitions — reject invalid transitions with a clear error message
- Record every status change in
PipelineStatusHistorywith timestamp, user/system identity, and notes - Support configurable status workflows per tenant (stored in
PipelineStatusConfig) - Provide sensible default status configuration on tenant provisioning
Loan Queries
- List pipeline loans with filtering by: status, buyer, seller, date range (purchase date, expected delivery date), loan amount range, property state
- Support pagination (page/pageSize) with a maximum page size of 500
- Support sorting by any column (ascending/descending)
- Return single loan detail including full status history
- Provide dashboard summary: loan counts by status, total dollar volume by status, aging metrics (average days in current status, loans exceeding configurable aging thresholds)
Trade Queries
- List trades with filtering by: status, buyer, seller, date range
- Support pagination and sorting
- Return trade detail with all associated loan summaries
Settlement
- Record settlement for individual loans: settlement amount, settlement date, settlement reference
- Bulk-settle all loans in a trade in a single operation
- Transition loan status to
settledupon settlement recording (enforcing state machine) - Validate settlement amount is a positive decimal value
- Settlement reference must be non-empty (wire reference, ACH confirmation, etc.)
Multi-Tenant Isolation
- All pipeline data is scoped to the authenticated tenant's database (per ADR-005)
- No API endpoint can return or modify data belonging to another tenant
- Tenant context is resolved from JWT claims and mapped to a tenant-specific database connection
Business Rules
State Machine
- The default loan lifecycle follows a linear progression:
purchased→in_transit→delivered→settled - Three exception states branch off the main path:
rejected(buyer rejects the loan),returned(delivery issue requiring resubmission),disputed(settlement disagreement) - Valid transitions are defined in the state machine diagram below — any transition not explicitly shown is rejected by the API
- A loan in
settledstatus cannot transition to any other status — settlement is final - Loans in
rejectedorreturnedstatus can transition back toin_transitfor resubmission - Loans in
disputedstatus can transition tosettled(dispute resolved) orreturned(dispute results in return)
State Machine Diagram
Configurable Status Workflows
- Tenants may customize their status workflow via
PipelineStatusConfig— adding, renaming, or removing optional states - The four core states (
purchased,in_transit,delivered,settled) cannot be removed — they are required for the fundamental lifecycle - Custom states can be added between core states (e.g.,
docs_reviewbetweenin_transitanddelivered) - Each status configuration entry defines:
status_code,display_name,allowed_transitions(list of valid next statuses),is_terminal(boolean),sequence_order(for display sorting)
Audit Trail
- Every status change creates a
PipelineStatusHistoryrecord — no exceptions - The
changed_byfield records either a user identity (from JWT) or a system identifier (e.g.,system/exchange-webhook,system/bulk-settle) - The
notesfield is required for manual status changes and optional for system-initiated changes - Status history records are append-only — they are never updated or deleted
- Status history is returned chronologically (oldest first) when querying loan detail
Aging and SLA Tracking
- Each status has a configurable aging threshold (in business days) — loans exceeding this threshold are flagged in dashboard views
- Default aging thresholds:
purchased= 2 days,in_transit= 5 days,delivered= 10 days - Aging is calculated from the
status_date(when the loan entered its current status) to the current date, excluding weekends
Financial Precision
- All monetary values (
loan_amount,purchase_price,sale_price,settlement_amount,profit_spread) usedecimal— neverfloatordouble - Purchase price, sale price, profit spread, and settlement amount are stored with 2 decimal places of precision
- Summary aggregations (total dollar volume) preserve full precision during calculation and round to 2 decimal places only on output
Trade-Level Operations
- A trade's status is derived from the aggregate status of its loans:
active— at least one loan is not yet settled or in a terminal exception statesettled— all loans have reachedsettledstatuspartial— some loans are settled and others are in exception states (rejected,returned,disputed)
- Trade-level settlement (
POST /trades/{tradeId}/settle) settles all loans indeliveredstatus within the trade — loans in other statuses are skipped with a warning in the response - Trade dollar totals (
total_amount) are the sum of individual loanpurchase_pricevalues
API Design
Endpoint Map
Request / Response Shapes
// --- Trade Ingestion (Webhook from PSX) ---
interface PsxTradeWebhook {
exchangeTradeId: string;
tradeDate: string; // ISO 8601
principalId: string;
buyerId: string;
buyerName: string;
sellerId: string;
sellerName: string;
loans: PsxLoanPayload[];
}
interface PsxLoanPayload {
exchangeLoanId: string;
loanId: string; // from the original loan tape
loanAmount: number; // decimal
noteRate: number; // decimal
borrowerName: string;
propertyAddress: string;
propertyCity: string;
propertyState: string; // 2-letter state code
propertyZip: string;
creditScore: number;
ltv: number; // decimal, e.g. 80.00
cltv: number; // decimal
purposeCode: string; // P = purchase, R = refinance, C = cash-out
occupancyCode: string; // O = owner, S = second, I = investment
propertyType: string; // SF = single family, CO = condo, etc.
productCode: string; // e.g. "FHA30", "CONV15"
purchasePrice: number; // decimal — price paid by principal
}
interface TradeCreatedResponse {
pipelineTradeId: number;
exchangeTradeId: string;
loanCount: number;
totalAmount: number; // sum of purchasePrice values
status: "active";
createdAt: string; // ISO 8601
}
// --- Loan Queries ---
interface PipelineLoanListRequest {
page: number; // 1-based
pageSize: number; // default 25, max 500
sortBy: string; // column name
sortDirection: "asc" | "desc";
filters?: LoanFilter[];
}
interface LoanFilter {
field: string; // e.g. "currentStatus", "buyerId", "propertyState"
operator: "eq" | "neq" | "gt" | "gte" | "lt" | "lte" | "in" | "contains";
value: string | number | string[] | number[];
}
interface PipelineLoanSummary {
pipelineLoanId: number;
exchangeLoanId: string;
loanId: string;
dataSource: string; // "exchange", "los_import", "manual", "file_upload"
borrowerName: string;
loanAmount: number;
noteRate: number;
propertyState: string;
productCode: string;
currentStatus: string;
statusDate: string; // ISO 8601
purchasePrice: number;
salePrice: number | null; // price sold to buyer/investor
profitSpread: number | null; // salePrice - purchasePrice
purchaseDate: string; // ISO 8601
buyerName: string;
sellerName: string;
expectedDeliveryDate: string | null;
daysInCurrentStatus: number;
agingAlert: boolean; // true if exceeds threshold
}
interface PipelineLoanListResponse {
totalRows: number;
page: number;
pageSize: number;
loans: PipelineLoanSummary[];
}
interface PipelineLoanDetail {
pipelineLoanId: number;
exchangeLoanId: string;
exchangeTradeId: string;
loanId: string;
dataSource: string; // "exchange", "los_import", "manual", "file_upload"
// Loan data
loanAmount: number;
noteRate: number;
borrowerName: string;
propertyAddress: string;
propertyCity: string;
propertyState: string;
propertyZip: string;
creditScore: number;
ltv: number;
cltv: number;
purposeCode: string;
occupancyCode: string;
propertyType: string;
productCode: string;
// Trade context
buyerId: string;
buyerName: string;
sellerId: string;
sellerName: string;
principalId: string;
purchasePrice: number;
salePrice: number | null; // price sold to buyer/investor
profitSpread: number | null; // salePrice - purchasePrice
purchaseDate: string;
// Status
currentStatus: string;
statusDate: string;
// Delivery
expectedDeliveryDate: string | null;
actualDeliveryDate: string | null;
deliveryMethod: string | null;
// Settlement
settlementDate: string | null;
settlementAmount: number | null;
settlementReference: string | null;
// Metadata
createdAt: string;
updatedAt: string;
// Audit trail
statusHistory: StatusHistoryEntry[];
}
interface StatusHistoryEntry {
previousStatus: string | null; // null for initial creation
newStatus: string;
changedAt: string; // ISO 8601
changedBy: string;
notes: string | null;
}
// --- Dashboard Summary ---
interface PipelineSummaryResponse {
statusBreakdown: StatusSummary[];
totalLoans: number;
totalVolume: number; // sum of all purchasePrice
agingAlerts: AgingAlert[];
}
interface StatusSummary {
status: string;
displayName: string;
loanCount: number;
totalVolume: number; // sum of purchasePrice for loans in this status
averageDaysInStatus: number;
}
interface AgingAlert {
status: string;
thresholdDays: number;
loansExceedingThreshold: number;
oldestDays: number; // days in status for the oldest loan
}
// --- Status Management ---
interface UpdateStatusRequest {
newStatus: string;
notes: string; // required for manual changes
}
interface UpdateStatusResponse {
pipelineLoanId: number;
previousStatus: string;
newStatus: string;
changedAt: string;
changedBy: string;
}
interface BulkStatusUpdateRequest {
loanIds: number[]; // max 500
newStatus: string;
notes: string;
}
interface BulkStatusUpdateResponse {
totalRequested: number;
successful: number;
failed: number;
results: BulkStatusResult[];
}
interface BulkStatusResult {
pipelineLoanId: number;
success: boolean;
previousStatus: string | null;
newStatus: string | null;
error: string | null; // e.g. "Invalid transition from 'settled' to 'in_transit'"
}
// --- Settlement ---
interface SettleLoanRequest {
settlementAmount: number; // positive decimal
settlementDate: string; // ISO 8601
settlementReference: string; // wire ref, ACH confirmation, etc.
}
interface SettleLoanResponse {
pipelineLoanId: number;
previousStatus: string;
newStatus: "settled";
settlementAmount: number;
settlementDate: string;
settlementReference: string;
changedAt: string;
}
interface SettleTradeRequest {
settlementDate: string; // ISO 8601
settlementReference: string;
}
interface SettleTradeResponse {
pipelineTradeId: number;
totalLoans: number;
settled: number;
skipped: number;
skippedDetails: SkippedLoan[];
}
interface SkippedLoan {
pipelineLoanId: number;
currentStatus: string;
reason: string; // e.g. "Loan is not in 'delivered' status"
}
// --- Trade Queries ---
interface PipelineTradeListRequest {
page: number;
pageSize: number; // default 25, max 100
sortBy: string;
sortDirection: "asc" | "desc";
filters?: TradeFilter[];
}
interface TradeFilter {
field: string;
operator: "eq" | "neq" | "gt" | "gte" | "lt" | "lte" | "in";
value: string | number | string[] | number[];
}
interface PipelineTradeSummary {
pipelineTradeId: number;
exchangeTradeId: string;
buyerName: string;
sellerName: string;
tradeDate: string;
totalLoanCount: number;
totalAmount: number;
status: "active" | "settled" | "partial";
createdAt: string;
}
interface PipelineTradeListResponse {
totalRows: number;
page: number;
pageSize: number;
trades: PipelineTradeSummary[];
}
interface PipelineTradeDetail {
pipelineTradeId: number;
exchangeTradeId: string;
principalId: string;
buyerId: string;
buyerName: string;
sellerId: string;
sellerName: string;
tradeDate: string;
totalLoanCount: number;
totalAmount: number;
status: "active" | "settled" | "partial";
statusBreakdown: StatusSummary[];
loans: PipelineLoanSummary[];
createdAt: string;
updatedAt: string;
}
Endpoint Details
| Method | Path | Description | Auth |
|---|---|---|---|
POST | /api/pipeline/trades | Receive PSX trade webhook | PSX API key |
GET | /api/pipeline/trades | List trades with filters, pagination | Tenant user |
GET | /api/pipeline/trades/{id} | Trade detail with associated loans | Tenant user |
POST | /api/pipeline/trades/{tradeId}/settle | Settle all delivered loans in a trade | Tenant admin |
GET | /api/pipeline/loans | List loans with filters, pagination, sorting | Tenant user |
GET | /api/pipeline/loans/{id} | Loan detail with full status history | Tenant user |
GET | /api/pipeline/loans/summary | Dashboard summary (counts, volumes, aging) | Tenant user |
PUT | /api/pipeline/loans/{id}/status | Update single loan status | Tenant user |
POST | /api/pipeline/loans/bulk-status | Bulk status update (max 500 loans) | Tenant user |
POST | /api/pipeline/loans/{id}/settle | Record settlement for a single loan | Tenant admin |
Webhook Authentication
PSX authenticates to PSSaaS using a pre-shared API key:
- Header:
X-Exchange-Api-Key: <key> - The key is stored in PSSaaS configuration per tenant (each principal has their own key)
- Invalid or missing keys return
401 Unauthorized - The key is rotatable without downtime via configuration update
Error Responses
All endpoints return structured error responses:
interface ApiError {
code: string; // machine-readable error code
message: string; // human-readable message
details: Record<string, string>[]; // field-level details (optional)
}
Common error codes:
| Code | HTTP Status | Meaning |
|---|---|---|
DUPLICATE_TRADE | 409 Conflict | PSX trade ID already exists (idempotent — returns existing trade) |
INVALID_TRANSITION | 422 Unprocessable Entity | Status transition violates the state machine |
LOAN_NOT_FOUND | 404 Not Found | Pipeline loan ID does not exist in this tenant |
TRADE_NOT_FOUND | 404 Not Found | Pipeline trade ID does not exist in this tenant |
INVALID_SETTLEMENT | 422 Unprocessable Entity | Settlement data validation failed (negative amount, empty reference) |
PAYLOAD_VALIDATION | 400 Bad Request | Webhook payload missing required fields or invalid data types |
Data Model
Data Flow
Entity Relationship Diagram
EF Core Entity Design
// Expressed as C#-style pseudocode for spec purposes
// Aggregate root: PipelineTrade
class PipelineTrade {
PipelineTradeId: int // PK, auto-increment
ExchangeTradeId: string // unique index
PrincipalId: string
BuyerId: string
BuyerName: string
SellerId: string
SellerName: string
TradeDate: DateOnly
TotalLoanCount: int
TotalAmount: decimal // HasPrecision(18, 2)
Status: string // computed from loan statuses
CreatedAt: DateTime
UpdatedAt: DateTime
// Navigation
Loans: PipelineLoan[] // 1:N
}
// Aggregate root: PipelineLoan
class PipelineLoan {
PipelineLoanId: int // PK, auto-increment
PipelineTradeId: int // FK → PipelineTrade
ExchangeLoanId: string
ExchangeTradeId: string // denormalized for query convenience
LoanId: string // from original loan tape
DataSource: string // "exchange", "los_import", "manual", "file_upload"
// Loan data
LoanAmount: decimal // HasPrecision(18, 2)
NoteRate: decimal // HasPrecision(7, 4)
BorrowerName: string
PropertyAddress: string
PropertyCity: string
PropertyState: string // 2 chars
PropertyZip: string // 5 or 9 chars
CreditScore: int
Ltv: decimal // HasPrecision(7, 3)
Cltv: decimal // HasPrecision(7, 3)
PurposeCode: string
OccupancyCode: string
PropertyType: string
ProductCode: string
// Trade context
BuyerId: string
BuyerName: string
SellerId: string
SellerName: string
PrincipalId: string
PurchasePrice: decimal // HasPrecision(18, 2)
SalePrice: decimal? // HasPrecision(18, 2) — price sold to buyer
ProfitSpread: decimal? // HasPrecision(18, 2) — computed: SalePrice - PurchasePrice
PurchaseDate: DateOnly
// Status
CurrentStatus: string // indexed
StatusDate: DateTime
// Delivery
ExpectedDeliveryDate: DateOnly?
ActualDeliveryDate: DateOnly?
DeliveryMethod: string? // e.g. "electronic", "physical", "eVault"
// Settlement
SettlementDate: DateOnly?
SettlementAmount: decimal? // HasPrecision(18, 2)
SettlementReference: string?
// Metadata
CreatedAt: DateTime
UpdatedAt: DateTime
// Navigation
Trade: PipelineTrade // N:1
StatusHistory: PipelineStatusHistory[] // 1:N
}
// Entity: PipelineStatusHistory
class PipelineStatusHistory {
PipelineStatusHistoryId: int // PK, auto-increment
PipelineLoanId: int // FK → PipelineLoan, indexed
PreviousStatus: string? // null on initial creation
NewStatus: string
ChangedAt: DateTime // indexed for chronological queries
ChangedBy: string // user identity or system identifier
Notes: string?
}
// Entity: PipelineStatusConfig (tenant-level configuration)
class PipelineStatusConfig {
PipelineStatusConfigId: int // PK, auto-increment
StatusCode: string // unique index
DisplayName: string
AllowedTransitions: string // JSON array of status codes, e.g. '["in_transit","rejected"]'
IsTerminal: bool // true = no further transitions allowed
SequenceOrder: int // display ordering
AgingThresholdDays: int // business days before aging alert
CreatedAt: DateTime
UpdatedAt: DateTime
}
Key design decisions:
PipelineTradeandPipelineLoanare both aggregate roots — trades group loans but loans are independently queryable and updatableExchangeTradeIdis denormalized onPipelineLoanto enable efficient filtering without joins- Trade
Statusis derived (computed from loan statuses) rather than stored — or stored as a cached value updated on each loan status change for query performance PipelineStatusConfigstores allowed transitions as a JSON array to avoid a separate junction table for the simple state machine- All monetary fields use
decimalwith explicit precision — enforced at the EF Core model level DataSourcetracks provenance — which system originated the loan record (PSX webhook, LOS import, manual entry, file upload)ProfitSpreadis computed (SalePrice - PurchasePrice) and stored for query performance; recalculated wheneverSalePriceorPurchasePricechangesPipelineStatusHistoryis append-only; the entity has no update or delete operations- Indexes:
PipelineLoan.CurrentStatus,PipelineLoan.PurchaseDate,PipelineLoan.BuyerId,PipelineLoan.DataSource,PipelineTrade.ExchangeTradeId(unique),PipelineStatusHistory.PipelineLoanId + ChangedAt
Default Status Configuration Seed Data
On tenant provisioning, the following default PipelineStatusConfig records are created:
| status_code | display_name | allowed_transitions | is_terminal | sequence_order | aging_threshold_days |
|---|---|---|---|---|---|
purchased | Purchased | ["in_transit"] | false | 1 | 2 |
in_transit | In Transit | ["delivered", "rejected", "returned"] | false | 2 | 5 |
delivered | Delivered | ["settled", "disputed"] | false | 3 | 10 |
settled | Settled | [] | true | 4 | — |
rejected | Rejected | ["in_transit"] | false | 5 | 5 |
returned | Returned | ["in_transit"] | false | 6 | 5 |
disputed | Disputed | ["settled", "returned"] | false | 7 | 3 |
UX Notes
Modern Mode (Default — New Customers)
The modern Pipeline Management experience is a dashboard-first design with progressive disclosure:
-
Pipeline Dashboard — Top-level view showing status cards arranged in the lifecycle flow. Each card displays the status name, loan count, and total dollar volume. Cards for statuses with aging alerts show a warning indicator with the count of overdue loans. The cards are connected by directional arrows showing the flow.
-
Status Card Drill-Down — Clicking a status card filters the loan list to that status. The loan list shows key fields (borrower, loan amount, days in status, buyer, property state) in a clean table with sort and filter controls.
-
Loan Detail Panel — Clicking a loan opens a slide-over panel with full loan details, trade context, and a visual timeline showing the status history (each entry shows the status, date, who changed it, and notes).
-
Quick Actions — Contextual action buttons based on current status:
purchased→ "Mark In Transit" buttonin_transit→ "Mark Delivered" / "Mark Rejected" / "Mark Returned" buttonsdelivered→ "Record Settlement" button (opens settlement form) / "Mark Disputed" button- Each action opens a confirmation dialog with a required notes field
-
Bulk Operations — Multi-select checkboxes on the loan list enable a "Bulk Update Status" toolbar action. The toolbar shows the count of selected loans and the available status transitions (only transitions valid for ALL selected loans are shown).
-
Trade View — An alternative grouping that shows loans organized by trade. Each trade is an expandable card showing trade-level summary and a nested list of its loans.
-
Aging Alerts — A notification area (or badge on the dashboard) highlights loans that have exceeded their aging threshold. Clicking navigates to a pre-filtered list of overdue loans.
Power Mode (Opt-In — Migrating Customers)
The power mode Pipeline Management experience provides a dense, full-control interface:
-
Pipeline Grid — AG Grid showing all pipeline loans with every field as a column. Supports column pinning, reordering, resizing, grouping, and filtering. Status column is color-coded.
-
Inline Status Update — Right-click context menu on any loan shows valid status transitions. Selecting a transition opens a minimal inline dialog for notes, then updates immediately.
-
Bulk Selection — Checkbox column for multi-select. Toolbar above the grid shows "Update Status" and "Settle" bulk actions for the selection.
-
Trade Grouping — Row grouping by
exchange_trade_idto see loans organized by trade within the grid. -
Summary Bar — A persistent summary bar above the grid showing: total loans, total volume, counts per status (as compact chips). Updates in real-time as filters change.
-
Column Filters — Every column supports filter dropdowns. Status column has a multi-select filter showing all active statuses with counts.
-
Keyboard Navigation — Full keyboard support: arrow keys to navigate, Enter to open detail, Space to toggle selection, Ctrl+Shift+S for bulk status update.
Shared Interactions
Both modes support:
- Export — Export filtered loan list to CSV with column selection
- Search — Global search across borrower name, loan ID, PSX loan ID, and property address
- Settlement form — A modal form for recording settlement with amount, date, and reference fields — amount is pre-populated with the purchase price as a starting value
- Responsive — Dashboard works on tablet screens; power mode requires desktop
Acceptance Criteria
Trade Ingestion
- A valid PSX trade webhook creates one
PipelineTradeand NPipelineLoanrecords, all inpurchasedstatus - Each loan's initial status change is recorded in
PipelineStatusHistorywithchanged_by: "system/exchange-webhook" - Redelivery of the same
exchange_trade_idreturns the existing trade (HTTP 409 with the existingpipelineTradeId) without creating duplicates - A webhook with an invalid API key returns
401 Unauthorized - A webhook with missing required fields returns
400 Bad Requestwith field-level validation errors
Status Management
- Updating a loan from
purchasedtoin_transitsucceeds and creates a status history record - Attempting to update a loan from
settledto any other status returns422 Unprocessable Entitywith error codeINVALID_TRANSITION - Attempting to update a loan from
purchasedtodelivered(skippingin_transit) returns422 Unprocessable Entity - Every successful status change is recorded in
PipelineStatusHistorywith the correctprevious_status,new_status,changed_at,changed_by, andnotes - Bulk status update with 100+ loans completes within 5 seconds and returns individual success/failure results for each loan
Queries and Dashboard
GET /loansreturns paginated results with correcttotalRowscount- Filtering by
currentStatus=in_transitreturns only loans in that status - Filtering by
purchaseDatedate range returns only loans purchased in that window - Sorting by
loanAmountdescending returns loans in correct order GET /loans/{id}returns the full loan detail including chronologically ordered status historyGET /loans/summaryreturns accurate counts and volumes per status, plus aging alerts for loans exceeding thresholds
Settlement
POST /loans/{id}/settlewith valid settlement data transitions the loan tosettledand records settlement amount, date, and reference- Attempting to settle a loan that is not in
deliveredstatus returns422 Unprocessable Entity - Settling a loan with a negative
settlementAmountreturns422 Unprocessable Entity POST /trades/{tradeId}/settlesettles alldeliveredloans in the trade and returns a count of settled vs. skipped loans with reasons- Profit spread is correctly computed as
sale_price - purchase_pricefor loans where both values are present;nullwhen either value is missing
Multi-Tenant Isolation
- API requests with Tenant A's JWT cannot access or modify Tenant B's pipeline data — verified by attempting cross-tenant access and confirming
404 Not Found(not403, to avoid information leakage) - The PSX webhook for Tenant A's principal creates records in Tenant A's database only
- Dashboard summary aggregations only include data from the authenticated tenant's database
Performance
- Loan list query with filters returns within 500ms for a tenant with 10,000 pipeline loans
- Dashboard summary query returns within 1 second for a tenant with 10,000 pipeline loans
- Bulk status update for 500 loans completes within 10 seconds
Out of Scope (V1)
The following are explicitly not covered by this specification:
| Topic | Reason | Future Spec |
|---|---|---|
| Underwriting stages | PSX-purchased loans are already closed — no underwriting needed | Full Pipeline spec (when agency delivery starts) |
| Fallout processing | No fallout concept for purchased loans — they were already bought | Full Pipeline spec |
| Reservations and master agreements | Agency delivery feature for MBS/mandatory commitments | Agency Delivery spec |
| Best efforts commitments | Individual loan-level investor locks — not applicable to PSX flow | Full Pipeline spec |
| Integration with BestEx results | Future capability when principals start agency delivery alongside PSX | BestEx Integration spec |
| Automated status transitions | V1 is manual status updates + PSX webhook; rule-based automation comes later | Pipeline Automation spec |
| Payment processing | PSSaaS tracks settlement data; Odoo handles actual invoicing and payment | Odoo Integration spec |
| Document tracking | Tracking required loan documents (note, deed, title) for delivery | Document Tracking spec |
| Advanced reporting and analytics | Historical trend reporting, trade performance analytics, counterparty analysis — note: basic profit tracking (spread calculation) IS in scope as a core secondary marketing function | Pipeline Reporting spec |
| LOS integration | Designed for but not implemented in V1 — PSX webhook is the primary import source; LOS data import and profit/settlement feedback are future phases | LOS Integration spec |
| Status workflow admin UI | CRUD interface for managing PipelineStatusConfig — V1 uses seed data + direct DB config | Pipeline Admin spec |
| Notifications | Email/SMS alerts for aging loans, status changes, settlement confirmations | Notifications spec |
| PSX API (outbound) | PSSaaS calling back to PSX to confirm delivery or update status | PSX Integration spec |