Offer State Machine & Pipeline
Offer State Machine & Pipeline
As of v0.1.27, offers move through a formal state machine with enforced transitions. This replaces the previous freeform status field and ensures every status change is validated, timestamped, and recorded in an immutable audit trail.
Pipeline Statuses
| Status | Label | Description |
|---|---|---|
invited | Invited | Offer created; applicant invited to proceed. |
in_progress | In Progress | Applicant is actively filling in the offer. |
with_agent | With Agent | Agent is reviewing the offer. |
awaiting_amendments | Awaiting Amendments | Agent has requested changes from the applicant. |
sent_to_landlord | Sent to Landlord | Offer forwarded to landlord for consideration. |
landlord_reviewed | Landlord Reviewed | Landlord has reviewed the offer. |
accepted | Accepted | ✅ Terminal — offer accepted. |
rejected | Rejected | ❌ Terminal — offer rejected. |
cancelled | Cancelled | ❌ Terminal — offer cancelled. |
Terminal statuses (accepted, rejected, cancelled) cannot transition to any other status.
Valid Transitions
invited → in_progress, cancelled
in_progress → with_agent, cancelled
with_agent → awaiting_amendments, sent_to_landlord, cancelled
awaiting_amendments→ in_progress, cancelled
sent_to_landlord → landlord_reviewed, cancelled
landlord_reviewed → accepted, rejected, cancelled
accepted → (terminal)
rejected → (terminal)
cancelled → (terminal)
Database Schema
offers Table
Key columns added in v0.1.27:
| Column | Type | Description |
|---|---|---|
propertyId | UUID FK | Links offer to a property. |
leadApplicantId | UUID FK | Links offer to the lead applicant. |
createdByUserId | UUID FK | User who created the offer. |
status | enum | Current pipeline status (default: invited). |
invitedAt | timestamp | Set when status enters invited. |
inProgressAt | timestamp | Set when status enters in_progress. |
withAgentAt | timestamp | Set when status enters with_agent. |
awaitingAmendmentsAt | timestamp | Set when status enters awaiting_amendments. |
sentToLandlordAt | timestamp | Set when status enters sent_to_landlord. |
landlordReviewedAt | timestamp | Set when status enters landlord_reviewed. |
acceptedAt | timestamp | Set when status enters accepted. |
rejectedAt | timestamp | Set when status enters rejected. |
cancelledAt | timestamp | Set when status enters cancelled. |
offerStatusTransitions Table (Audit Trail)
Immutable — rows are never updated or deleted.
| Column | Type | Description |
|---|---|---|
id | UUID | Primary key. |
offerId | UUID FK | The offer being transitioned. |
fromStatus | enum | Previous status (null for initial creation). |
toStatus | enum | New status. |
changedByUserId | UUID FK | User who triggered the transition. |
reason | text | Optional reason / note. |
createdAt | timestamp | When the transition occurred. |
tRPC API Reference
offer.create
Creates a new offer at invited status.
Input:
{
propertyId: string; // UUID of the property
leadApplicantId: string; // UUID of the applicant
// ...other offer fields
}
Behaviour: Sets status = "invited", records invitedAt, inserts an initial row in offerStatusTransitions, and writes an audit log entry.
offer.transitionStatus
Moves an offer to a new status. The transition is validated against the state machine before any write occurs.
Input:
{
offerId: string; // UUID of the offer
toStatus: string; // Target pipeline status
reason?: string; // Optional reason for the change
}
Response:
{
offer: Offer; // Updated offer record
validNextStatuses: string[]; // Available transitions from the new status
}
Errors: Throws if the transition is invalid (e.g. attempting to move from accepted to any status).
offer.getValidTransitions
Returns the list of statuses an offer can move to from its current state. Use this to drive UI action buttons.
Input:
{ offerId: string }
Response:
{ validNextStatuses: string[] }
offer.getTransitionHistory
Returns the full audit trail of status changes for an offer.
Input:
{ offerId: string }
Response: Array of offerStatusTransitions rows ordered by createdAt.
offer.listByProperty
Lists all offers associated with a specific property, with pagination.
Input:
{
propertyId: string;
limit?: number;
offset?: number;
}
offer.getById
Fetches a single offer. Now includes:
transitionHistory— ordered list of all status changes.validNextStatuses— statuses the offer can move to.isTerminal— boolean flag indicating no further transitions are possible.
offer.pipelineSummary
Returns a count of offers grouped by status, with human-readable labels for each group.
State Machine Helpers (src/lib/offer-state-machine.ts)
// Check whether a transition is permitted
isValidTransition(from: OfferPipelineStatus, to: OfferPipelineStatus): boolean
// Assert a transition is permitted — throws if not
assertValidTransition(from: OfferPipelineStatus, to: OfferPipelineStatus): void
// Get all statuses reachable from the current one
getValidNextStatuses(from: OfferPipelineStatus): OfferPipelineStatus[]
// Check whether a status is terminal
isTerminalStatus(status: OfferPipelineStatus): boolean
Exported constants:
OFFER_PIPELINE_STATUSES— ordered array of all 9 statuses.ACTIVE_STATUSES— statuses considered active for query filtering (excludes terminal statuses).OfferPipelineStatus— TypeScript type union.
Upgrade Notes
- The
updateStatusprocedure has been replaced bytransitionStatus. Update any client code that calledoffer.updateStatus. - The default offer status is now
"invited"(previously"draft"). Any code that created offers withstatus: "draft"should be updated. - Management metrics now use
ACTIVE_STATUSESfrom the state machine module — the stale statuses"draft","submitted","under_review", and"amended"are no longer referenced.