Offer Status State Machine
Offer Status State Machine
The offer pipeline is governed by an explicit state machine that enforces which status transitions are valid, records an immutable audit trail of every change, and exposes per-status timestamps for reporting.
Status Pipeline
Offers move through 9 statuses:
invited
└─► in_progress
└─► with_agent
└─► awaiting_amendments ──► with_agent (loop)
└─► sent_to_landlord
└─► landlord_reviewed
├─► accepted (terminal)
└─► rejected (terminal)
(cancelled is reachable from any non-terminal status)
| Status | Label | Terminal? |
|---|---|---|
invited | Invited | No |
in_progress | In Progress | No |
with_agent | With Agent | No |
awaiting_amendments | Awaiting Amendments | No |
sent_to_landlord | Sent to Landlord | No |
landlord_reviewed | Landlord Reviewed | No |
accepted | Accepted | Yes |
rejected | Rejected | Yes |
cancelled | Cancelled | Yes |
Terminal statuses (accepted, rejected, cancelled) cannot be transitioned further.
Valid Transitions
| From | Allowed Next Statuses |
|---|---|
invited | in_progress, cancelled |
in_progress | with_agent, cancelled |
with_agent | awaiting_amendments, sent_to_landlord, cancelled |
awaiting_amendments | with_agent, cancelled |
sent_to_landlord | landlord_reviewed, cancelled |
landlord_reviewed | accepted, rejected, cancelled |
accepted | (none) |
rejected | (none) |
cancelled | (none) |
State Machine Helper Functions
Imported from src/lib/offer-state-machine.ts:
import {
isValidTransition,
assertValidTransition,
getValidNextStatuses,
isTerminalStatus,
ACTIVE_STATUSES,
OFFER_PIPELINE_STATUSES,
} from "@/lib/offer-state-machine";
isValidTransition(from, to)
Returns true if the transition is permitted.
isValidTransition("invited", "in_progress"); // true
isValidTransition("accepted", "in_progress"); // false
assertValidTransition(from, to)
Throws an error if the transition is not permitted. Used internally by transitionStatus.
assertValidTransition("invited", "cancelled"); // ok
assertValidTransition("accepted", "rejected"); // throws
getValidNextStatuses(status)
Returns an array of statuses the offer can move to next.
getValidNextStatuses("landlord_reviewed");
// => ["accepted", "rejected", "cancelled"]
isTerminalStatus(status)
Returns true for accepted, rejected, or cancelled.
isTerminalStatus("accepted"); // true
isTerminalStatus("in_progress"); // false
Database Schema
offers table
Key fields added in this release:
propertyId: uuid (FK → properties)
leadApplicantId: uuid (FK → applicants)
createdByUserId: uuid (FK → users)
status: text default "invited"
// Per-status timestamps (set once, immutable)
invitedAt: timestamp
inProgressAt: timestamp
withAgentAt: timestamp
awaitingAmendmentsAt: timestamp
sentToLandlordAt: timestamp
landlordReviewedAt: timestamp
acceptedAt: timestamp
rejectedAt: timestamp
cancelledAt: timestamp
offerStatusTransitions table
Every call to transitionStatus appends a row here. Rows are never updated or deleted.
offerId: uuid (FK → offers)
fromStatus: text
toStatus: text
changedByUserId: uuid (FK → users)
reason: text | null
createdAt: timestamp
tRPC API
offer.create
Creates a new offer with status invited.
Input:
{
propertyId: string;
leadApplicantId: string;
// ...other offer fields
}
Behaviour:
- Sets
status = "invited"andinvitedAt = now(). - Inserts the initial row into
offerStatusTransitions. - Writes an audit log entry.
offer.transitionStatus
Moves an offer from its current status to a new one. Replaces the former updateStatus procedure.
Input:
{
offerId: string;
toStatus: OfferPipelineStatus;
reason?: string;
}
Behaviour:
- Calls
assertValidTransition(currentStatus, toStatus)— throwsTRPCError(BAD_REQUEST) if invalid. - Updates
statusand the corresponding*Attimestamp. - Appends a row to
offerStatusTransitions. - Writes an audit log entry.
Returns:
{
offer: Offer;
validNextStatuses: OfferPipelineStatus[];
}
offer.getById
Fetches a single offer with enriched transition data.
Returns:
{
offer: Offer;
transitionHistory: OfferStatusTransition[];
validNextStatuses: OfferPipelineStatus[];
isTerminal: boolean;
}
offer.getValidTransitions
Returns allowable next statuses for an offer — use this to drive UI action menus without a full fetch.
Input:
{ offerId: string }
Returns:
{ validNextStatuses: OfferPipelineStatus[] }
offer.getTransitionHistory
Fetches the full ordered audit trail for an offer.
Input:
{ offerId: string }
Returns: OfferStatusTransition[] ordered by createdAt ascending.
offer.listByProperty
Lists all offers for a given property with pagination.
Input:
{
propertyId: string;
limit?: number;
cursor?: string;
}
offer.pipelineSummary
Returns offer counts per status, now including human-readable labels.
Returns:
{
status: OfferPipelineStatus;
label: string; // e.g. "Awaiting Amendments"
count: number;
}[]
Migration Notes
- Run schema migration to create the
offerStatusTransitionstable and add the new columns tooffersbefore deploying. - Default status changed — new offers default to
"invited"instead of the former"draft". - Replace
updateStatuscalls withtransitionStatus. The new procedure is not a drop-in replacement: it enforces the transition map and will reject illegal state changes. - The management router now uses
ACTIVE_STATUSESfrom the state machine module. No manual status list maintenance is required.