Skip to main content

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:

  1. Sellers upload loan tapes to PSX
  2. CRA eligibility is determined via third-party (Incenter)
  3. WTPO evaluates loans against PSX buyer rate sheets
  4. WTPO purchases profitable loans (trade executed on PSX)
  5. Loans must be tracked through delivery to the buyer ← Pipeline Management
  6. 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 ComponentRelevance to This Spec
loan tableActive pipeline loans — field names inform PipelineLoan entity design
loan_shipped tableSettled/delivered loans — settlement concept maps to our settled status
loan_history tableFallout/historical loans — our rejected/returned statuses serve a similar archival purpose
pscat_pipeline_loan_status_codesStatus code catalog — inspires our configurable state machine
Activity logAudit 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 PipelineTrade record grouping all loans in the trade
  • Create individual PipelineLoan records for each loan in the trade, initialized to purchased status
  • Record the initial status change in PipelineStatusHistory with changed_by: "system/exchange-webhook"
  • Return the created pipeline_trade_id and loan count in the webhook response
  • Support idempotent webhook processing — redelivery of the same exchange_trade_id returns the existing trade without creating duplicates
  • Authenticate webhook requests via a shared secret (API key in X-Exchange-Api-Key header)

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 PipelineStatusHistory with 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 settled upon 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

  1. The default loan lifecycle follows a linear progression: purchasedin_transitdeliveredsettled
  2. Three exception states branch off the main path: rejected (buyer rejects the loan), returned (delivery issue requiring resubmission), disputed (settlement disagreement)
  3. Valid transitions are defined in the state machine diagram below — any transition not explicitly shown is rejected by the API
  4. A loan in settled status cannot transition to any other status — settlement is final
  5. Loans in rejected or returned status can transition back to in_transit for resubmission
  6. Loans in disputed status can transition to settled (dispute resolved) or returned (dispute results in return)

State Machine Diagram

Configurable Status Workflows

  1. Tenants may customize their status workflow via PipelineStatusConfig — adding, renaming, or removing optional states
  2. The four core states (purchased, in_transit, delivered, settled) cannot be removed — they are required for the fundamental lifecycle
  3. Custom states can be added between core states (e.g., docs_review between in_transit and delivered)
  4. 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

  1. Every status change creates a PipelineStatusHistory record — no exceptions
  2. The changed_by field records either a user identity (from JWT) or a system identifier (e.g., system/exchange-webhook, system/bulk-settle)
  3. The notes field is required for manual status changes and optional for system-initiated changes
  4. Status history records are append-only — they are never updated or deleted
  5. Status history is returned chronologically (oldest first) when querying loan detail

Aging and SLA Tracking

  1. Each status has a configurable aging threshold (in business days) — loans exceeding this threshold are flagged in dashboard views
  2. Default aging thresholds: purchased = 2 days, in_transit = 5 days, delivered = 10 days
  3. Aging is calculated from the status_date (when the loan entered its current status) to the current date, excluding weekends

Financial Precision

  1. All monetary values (loan_amount, purchase_price, sale_price, settlement_amount, profit_spread) use decimal — never float or double
  2. Purchase price, sale price, profit spread, and settlement amount are stored with 2 decimal places of precision
  3. Summary aggregations (total dollar volume) preserve full precision during calculation and round to 2 decimal places only on output

Trade-Level Operations

  1. 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 state
    • settled — all loans have reached settled status
    • partial — some loans are settled and others are in exception states (rejected, returned, disputed)
  2. Trade-level settlement (POST /trades/{tradeId}/settle) settles all loans in delivered status within the trade — loans in other statuses are skipped with a warning in the response
  3. Trade dollar totals (total_amount) are the sum of individual loan purchase_price values

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

MethodPathDescriptionAuth
POST/api/pipeline/tradesReceive PSX trade webhookPSX API key
GET/api/pipeline/tradesList trades with filters, paginationTenant user
GET/api/pipeline/trades/{id}Trade detail with associated loansTenant user
POST/api/pipeline/trades/{tradeId}/settleSettle all delivered loans in a tradeTenant admin
GET/api/pipeline/loansList loans with filters, pagination, sortingTenant user
GET/api/pipeline/loans/{id}Loan detail with full status historyTenant user
GET/api/pipeline/loans/summaryDashboard summary (counts, volumes, aging)Tenant user
PUT/api/pipeline/loans/{id}/statusUpdate single loan statusTenant user
POST/api/pipeline/loans/bulk-statusBulk status update (max 500 loans)Tenant user
POST/api/pipeline/loans/{id}/settleRecord settlement for a single loanTenant 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:

CodeHTTP StatusMeaning
DUPLICATE_TRADE409 ConflictPSX trade ID already exists (idempotent — returns existing trade)
INVALID_TRANSITION422 Unprocessable EntityStatus transition violates the state machine
LOAN_NOT_FOUND404 Not FoundPipeline loan ID does not exist in this tenant
TRADE_NOT_FOUND404 Not FoundPipeline trade ID does not exist in this tenant
INVALID_SETTLEMENT422 Unprocessable EntitySettlement data validation failed (negative amount, empty reference)
PAYLOAD_VALIDATION400 Bad RequestWebhook 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:

  • PipelineTrade and PipelineLoan are both aggregate roots — trades group loans but loans are independently queryable and updatable
  • ExchangeTradeId is denormalized on PipelineLoan to enable efficient filtering without joins
  • Trade Status is derived (computed from loan statuses) rather than stored — or stored as a cached value updated on each loan status change for query performance
  • PipelineStatusConfig stores allowed transitions as a JSON array to avoid a separate junction table for the simple state machine
  • All monetary fields use decimal with explicit precision — enforced at the EF Core model level
  • DataSource tracks provenance — which system originated the loan record (PSX webhook, LOS import, manual entry, file upload)
  • ProfitSpread is computed (SalePrice - PurchasePrice) and stored for query performance; recalculated whenever SalePrice or PurchasePrice changes
  • PipelineStatusHistory is 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_codedisplay_nameallowed_transitionsis_terminalsequence_orderaging_threshold_days
purchasedPurchased["in_transit"]false12
in_transitIn Transit["delivered", "rejected", "returned"]false25
deliveredDelivered["settled", "disputed"]false310
settledSettled[]true4
rejectedRejected["in_transit"]false55
returnedReturned["in_transit"]false65
disputedDisputed["settled", "returned"]false73

UX Notes

Modern Mode (Default — New Customers)

The modern Pipeline Management experience is a dashboard-first design with progressive disclosure:

  1. 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.

  2. 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.

  3. 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).

  4. Quick Actions — Contextual action buttons based on current status:

    • purchased → "Mark In Transit" button
    • in_transit → "Mark Delivered" / "Mark Rejected" / "Mark Returned" buttons
    • delivered → "Record Settlement" button (opens settlement form) / "Mark Disputed" button
    • Each action opens a confirmation dialog with a required notes field
  5. 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).

  6. 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.

  7. 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:

  1. 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.

  2. 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.

  3. Bulk Selection — Checkbox column for multi-select. Toolbar above the grid shows "Update Status" and "Settle" bulk actions for the selection.

  4. Trade Grouping — Row grouping by exchange_trade_id to see loans organized by trade within the grid.

  5. 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.

  6. Column Filters — Every column supports filter dropdowns. Status column has a multi-select filter showing all active statuses with counts.

  7. 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

  1. A valid PSX trade webhook creates one PipelineTrade and N PipelineLoan records, all in purchased status
  2. Each loan's initial status change is recorded in PipelineStatusHistory with changed_by: "system/exchange-webhook"
  3. Redelivery of the same exchange_trade_id returns the existing trade (HTTP 409 with the existing pipelineTradeId) without creating duplicates
  4. A webhook with an invalid API key returns 401 Unauthorized
  5. A webhook with missing required fields returns 400 Bad Request with field-level validation errors

Status Management

  1. Updating a loan from purchased to in_transit succeeds and creates a status history record
  2. Attempting to update a loan from settled to any other status returns 422 Unprocessable Entity with error code INVALID_TRANSITION
  3. Attempting to update a loan from purchased to delivered (skipping in_transit) returns 422 Unprocessable Entity
  4. Every successful status change is recorded in PipelineStatusHistory with the correct previous_status, new_status, changed_at, changed_by, and notes
  5. Bulk status update with 100+ loans completes within 5 seconds and returns individual success/failure results for each loan

Queries and Dashboard

  1. GET /loans returns paginated results with correct totalRows count
  2. Filtering by currentStatus=in_transit returns only loans in that status
  3. Filtering by purchaseDate date range returns only loans purchased in that window
  4. Sorting by loanAmount descending returns loans in correct order
  5. GET /loans/{id} returns the full loan detail including chronologically ordered status history
  6. GET /loans/summary returns accurate counts and volumes per status, plus aging alerts for loans exceeding thresholds

Settlement

  1. POST /loans/{id}/settle with valid settlement data transitions the loan to settled and records settlement amount, date, and reference
  2. Attempting to settle a loan that is not in delivered status returns 422 Unprocessable Entity
  3. Settling a loan with a negative settlementAmount returns 422 Unprocessable Entity
  4. POST /trades/{tradeId}/settle settles all delivered loans in the trade and returns a count of settled vs. skipped loans with reasons
  5. Profit spread is correctly computed as sale_price - purchase_price for loans where both values are present; null when either value is missing

Multi-Tenant Isolation

  1. 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 (not 403, to avoid information leakage)
  2. The PSX webhook for Tenant A's principal creates records in Tenant A's database only
  3. Dashboard summary aggregations only include data from the authenticated tenant's database

Performance

  1. Loan list query with filters returns within 500ms for a tenant with 10,000 pipeline loans
  2. Dashboard summary query returns within 1 second for a tenant with 10,000 pipeline loans
  3. Bulk status update for 500 loans completes within 10 seconds

Out of Scope (V1)

The following are explicitly not covered by this specification:

TopicReasonFuture Spec
Underwriting stagesPSX-purchased loans are already closed — no underwriting neededFull Pipeline spec (when agency delivery starts)
Fallout processingNo fallout concept for purchased loans — they were already boughtFull Pipeline spec
Reservations and master agreementsAgency delivery feature for MBS/mandatory commitmentsAgency Delivery spec
Best efforts commitmentsIndividual loan-level investor locks — not applicable to PSX flowFull Pipeline spec
Integration with BestEx resultsFuture capability when principals start agency delivery alongside PSXBestEx Integration spec
Automated status transitionsV1 is manual status updates + PSX webhook; rule-based automation comes laterPipeline Automation spec
Payment processingPSSaaS tracks settlement data; Odoo handles actual invoicing and paymentOdoo Integration spec
Document trackingTracking required loan documents (note, deed, title) for deliveryDocument Tracking spec
Advanced reporting and analyticsHistorical trend reporting, trade performance analytics, counterparty analysis — note: basic profit tracking (spread calculation) IS in scope as a core secondary marketing functionPipeline Reporting spec
LOS integrationDesigned for but not implemented in V1 — PSX webhook is the primary import source; LOS data import and profit/settlement feedback are future phasesLOS Integration spec
Status workflow admin UICRUD interface for managing PipelineStatusConfig — V1 uses seed data + direct DB configPipeline Admin spec
NotificationsEmail/SMS alerts for aging loans, status changes, settlement confirmationsNotifications spec
PSX API (outbound)PSSaaS calling back to PSX to confirm delivery or update statusPSX Integration spec