Skip to main content
All Docs
FeaturesPurple PepperUpdated April 6, 2026

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)
StatusLabelTerminal?
invitedInvitedNo
in_progressIn ProgressNo
with_agentWith AgentNo
awaiting_amendmentsAwaiting AmendmentsNo
sent_to_landlordSent to LandlordNo
landlord_reviewedLandlord ReviewedNo
acceptedAcceptedYes
rejectedRejectedYes
cancelledCancelledYes

Terminal statuses (accepted, rejected, cancelled) cannot be transitioned further.


Valid Transitions

FromAllowed Next Statuses
invitedin_progress, cancelled
in_progresswith_agent, cancelled
with_agentawaiting_amendments, sent_to_landlord, cancelled
awaiting_amendmentswith_agent, cancelled
sent_to_landlordlandlord_reviewed, cancelled
landlord_reviewedaccepted, 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" and invitedAt = 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) — throws TRPCError (BAD_REQUEST) if invalid.
  • Updates status and the corresponding *At timestamp.
  • 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

  1. Run schema migration to create the offerStatusTransitions table and add the new columns to offers before deploying.
  2. Default status changed — new offers default to "invited" instead of the former "draft".
  3. Replace updateStatus calls with transitionStatus. The new procedure is not a drop-in replacement: it enforces the transition map and will reject illegal state changes.
  4. The management router now uses ACTIVE_STATUSES from the state machine module. No manual status list maintenance is required.