Alhuda ERP State Machines
Superseded — kept for history only
This page was written in January 2026 and is not a description of how the system works today. It is kept so the reasoning behind early decisions stays readable. Current behaviour is described in Customer lifecycle (LC), Visa (VISA) and Bookings. Do not build from this page.
Booking State Machine
States: - Draft - PendingOpsReview - PendingFinanceApproval - Approved - OnHold - Cancelled
Rules: - Ops approval requires finance policy satisfied.
TypeScript:
export enum BookingState {
Draft = 'DRAFT',
PendingOpsReview = 'PENDING_OPS_REVIEW',
PendingFinanceApproval = 'PENDING_FINANCE_APPROVAL',
Approved = 'APPROVED',
OnHold = 'ON_HOLD',
Cancelled = 'CANCELLED'
}
export function canTransitionBooking(from: BookingState, to: BookingState): boolean {
const allowed: Record<BookingState, BookingState[]> = {
[BookingState.Draft]: [BookingState.PendingOpsReview, BookingState.Cancelled],
[BookingState.PendingOpsReview]: [BookingState.PendingFinanceApproval, BookingState.OnHold, BookingState.Cancelled],
[BookingState.PendingFinanceApproval]: [BookingState.Approved, BookingState.OnHold, BookingState.Cancelled],
[BookingState.Approved]: [BookingState.OnHold, BookingState.Cancelled],
[BookingState.OnHold]: [BookingState.PendingOpsReview, BookingState.Cancelled],
[BookingState.Cancelled]: []
};
return allowed[from].includes(to);
}
Visa State Machine
States: - NotStarted - Applied - UnderProcess - Issued - Rejected - SentToEmbassy
TypeScript:
export enum VisaState {
NotStarted = 'NOT_STARTED',
Applied = 'APPLIED',
UnderProcess = 'UNDER_PROCESS',
Issued = 'ISSUED',
Rejected = 'REJECTED',
SentToEmbassy = 'SENT_TO_EMBASSY'
}
export function canTransitionVisa(from: VisaState, to: VisaState): boolean {
const allowed: Record<VisaState, VisaState[]> = {
[VisaState.NotStarted]: [VisaState.Applied],
[VisaState.Applied]: [VisaState.UnderProcess, VisaState.SentToEmbassy, VisaState.Rejected],
[VisaState.UnderProcess]: [VisaState.Issued, VisaState.Rejected],
[VisaState.Issued]: [],
[VisaState.Rejected]: [],
[VisaState.SentToEmbassy]: [VisaState.UnderProcess]
};
return allowed[from].includes(to);
}
Ticket State Machine
States: - NotReady - NameUpdatePending - OnHold - FinanceCleared - Issued
Rules: - Issued only if finance cleared OR GM exception proof exists. - Name update must be done within 2 days of departure.
TypeScript:
export enum TicketState {
NotReady = 'NOT_READY',
NameUpdatePending = 'NAME_UPDATE_PENDING',
OnHold = 'ON_HOLD',
FinanceCleared = 'FINANCE_CLEARED',
Issued = 'ISSUED'
}
export function canTransitionTicket(from: TicketState, to: TicketState): boolean {
const allowed: Record<TicketState, TicketState[]> = {
[TicketState.NotReady]: [TicketState.NameUpdatePending],
[TicketState.NameUpdatePending]: [TicketState.OnHold, TicketState.FinanceCleared],
[TicketState.OnHold]: [TicketState.FinanceCleared],
[TicketState.FinanceCleared]: [TicketState.Issued],
[TicketState.Issued]: []
};
return allowed[from].includes(to);
}
Server-Side Validators
export function requireFinancePolicyMet(paid: number, total: number, policy: 'advance' | 'partial' | 'full') {
if (policy === 'full' && paid < total) throw new Error('Policy requires full payment');
if (policy === 'partial' && paid <= 0) throw new Error('Policy requires partial payment');
}
export function requireTicketClearance(isCleared: boolean, exceptionProof?: string) {
if (!isCleared && !exceptionProof) throw new Error('Ticket issuance requires clearance or GM exception proof');
}
Example Unit Tests
import { BookingState, canTransitionBooking } from './states';
test('cannot approve booking from Draft', () => {
expect(canTransitionBooking(BookingState.Draft, BookingState.Approved)).toBe(false);
});
test('can move Draft -> PendingOpsReview', () => {
expect(canTransitionBooking(BookingState.Draft, BookingState.PendingOpsReview)).toBe(true);
});
Audit Log Entries
- On each transition, write: actorId, entity, fromState, toState, reason.