# 0012. Status vocabularies are classes, not tables

- **Status:** Accepted
- **Date:** 2025-07-20

## Context

Rentals, payments and proposal requests each move through a small, fixed set of states. Those states are
business logic — which transitions are legal, what icon and colour each carries, which ones count as
"finished" — not data the back office should be able to edit.

## Decision

Each vocabulary is a **plain PHP class of constants and static helpers**, with no table:

```php
class RentStatus {
    const string NEW = 'NEW', ACTIVE = 'ACTIVE', CLOSED = 'CLOSED', COMPLETED = 'COMPLETED';
    public static function all() : array;
    public static function icon( ?string $status ) : string;      // FontAwesome class + colour
    public static function next( ?string $current, bool $withCurrent = true ) : array;
    public static function asOptions( ?array $statuses = null ) : array;   // bt( 'rentStatuses_…' )
    public static function asNextOptions( ?string $current, bool $withCurrent = true ) : array;
    public static function endingStatuses() : array;
}
```

The value lives in the owner's `status` column. `PaymentStatus` and `PaymentMethod` follow the same shape.

`next()` encodes the state machine, and `asNextOptions()` feeds the admin dropdown — so **the form can only
offer legal transitions**.

## Alternatives

- **Lookup tables** (`rent_status`, `payment_status`). Editable by the back office, which is exactly the
  problem: a status is not data, and adding one without code changes would produce a state nothing handles.
  It would also mean a join or a cached read on every render.
- **PHP 8.1 enums.** The right modern answer. Would require the stored values to be enum-backed everywhere,
  including in `ActiveRecord` hydration and the admin field system, and the codebase predates the decision to
  use them. Worth revisiting if a fourth vocabulary appears.
- **Bare strings with no class.** No transition validation, no shared icon logic, no single place to read the
  vocabulary.

## Consequences

- **The state machine is in one file** and is enforced at the point where a human picks the next status.
- **There is no `rent_status` table**, which surprises everyone reading the schema for the first time. The
  class *is* the schema.
- Labels are translation keys (`bt( 'rentStatuses_ACTIVE' )`), so the vocabulary is Hungarian-only, like the
  rest of the admin.
- `endingStatuses()` is what occupancy and reporting queries exclude. Note that `PaymentStatus::CLOSED` is
  **not** an ending status — it is the "never bill this" escape hatch, and filters using `endingStatuses()`
  will still pick closed payments up.
- Proposal requests deliberately have **no** `next()` machine: `ProposalRequest` exposes `getStatuses()` and
  allows any transition, because the sales workflow moves backwards in practice. Its constants carry a
  numeric prefix (`10_New` … `50_Declined`) so the column sorts into workflow order — and never compare
  against the bare word.
