Skip to content

Al Huda ERP State Machines

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.