# End-to-End Workflows

Every sequence below is built directly from the service-layer code that implements it (`app/Services/**`, `app/Models/Order.php`, `app/Models/Contract.php`), not inferred from route names — each step names the exact status-guard method or DB write that makes it valid or invalid, so you can reproduce the same preconditions yourself. Field-level request/response detail for each call is in the linked role file; this document is about *order* and *why*.

## Contents
- [Order Status Machine (shared by every order-driven workflow)](#order-status-machine)
- [Workflow 1 — Individual Client: browse → cart checkout → fulfil → confirm](#workflow-1--individual-client-catalog-order)
- [Workflow 2 — Individual Client: custom-price request](#workflow-2--custom-price-request)
- [Workflow 3 — Company Onboarding](#company-onboarding)
- [Workflow 4 — Company Contract Lifecycle](#workflow-4--company-contract-lifecycle)
- [Workflow 5 — Company Job Order (cart checkout under a branch)](#workflow-5--company-job-order)
- [Workflow 6 — Technician Onboarding](#technician-onboarding)
- [Workflow 7 — Technician Assignment Lifecycle](#workflow-7--technician-assignment-lifecycle)
- [Workflow 8 — Cancellation & Refund](#workflow-8--cancellation--refund)
- [Workflow 9 — Technician Wallet Withdrawal](#workflow-9--technician-wallet-withdrawal)
- [Workflow 10 — Admin Wallet Adjustments](#workflow-10--admin-wallet-adjustments)

---

## Order Status Machine

`App\Enums\OrderStatus`: `pending`, `waiting_price`, `priced`, `accepted`, `on_the_way`, `start`, `finish`, `confirmed`, `cancelled`.

Every transition is written through `OrderTransitionService::transitionTo()`, which the `OrderObserver` uses to log a row to `order_status_histories` on every change (and on creation, `from_status: null`) — so the full history is always queryable via `GET /admin/orders/{id}` (`status_history`) regardless of which actor drove which step.

```
Catalog checkout                    Custom-price request
      │                                      │
      ▼                                      ▼
   pending                             waiting_price
      │                                      │ admin: POST /orders/{id}/quote
      │                                      ▼
      │                                   priced
      │                                      │ customer: POST /orders/{id}/accept-price (pays)
      │◄─────────────────────────────────────┘         (or reject-price → cancelled)
      │
      │ admin: POST /orders/{id}/assign-technician  (technician_id set, needs_attention=false,
      │                                               ReassignUnrespondedOrderJob armed)
      ▼
   pending  (technician_id set — "awaiting technician response")
      │
      │ technician: POST /orders/{id}/accept-assignment
      ▼
   accepted
      │ technician: POST /orders/{id}/start-heading
      ▼
   on_the_way
      │ technician: POST /orders/{id}/start-execution
      ▼
   start
      │ technician: POST /orders/{id}/finish
      ▼
   finish
      │ customer: POST /orders/{id}/confirm-completion  (optional 1-5 star rating)
      ▼
   confirmed   ◄── terminal, counts toward Order::historyStatuses()
```

`cancelled` is reachable from almost anywhere (see [Workflow 8](#workflow-8--cancellation--refund)) and is the other terminal state.

Guard rules enforced on the `Order` model (`app/Models/Order.php`) — call the wrong endpoint at the wrong status and you get `422 {"status":"fail","message":"...", "data": null}`, not a silent no-op:

| Guard | True when | Used by |
|---|---|---|
| `canBeQuoted()` | `status === waiting_price` | Admin quote |
| `awaitingCustomerResponse()` | `status === priced` | Client/Company accept-price, reject-price |
| `canAssignTechnician()` | `status` not in `finish, confirmed, cancelled` | Admin assign-technician (also usable to *re*assign mid-flow) |
| `awaitingTechnicianResponse()` | `status === pending` **and** `technician_id` already set | Technician accept/reject-assignment |
| `canReportUnreachable()` | `status === on_the_way` | Technician report-unreachable |
| `awaitingCompletionConfirmation()` | `status === finish` | Client/Company confirm-completion |
| `canBeCancelled()` | `status` not in `finish, confirmed, cancelled` | Admin cancel (widest allowance) |
| `canBeCancelledByCustomer()` | `status` in `pending, waiting_price, priced, accepted, on_the_way` | Client/Company cancel (narrower — **cannot** self-cancel once the technician has started, i.e. from `start` onward) |
| `canTransitionTo($to)` | exactly one forward step along `[pending, accepted, on_the_way, start, finish, confirmed]`, and explicitly **excludes** `waiting_price→priced` (must go through `quote()`), `priced→pending/cancelled` (must go through customer accept/reject), and `finish→confirmed` (must go through customer confirm-completion) | Admin's generic `POST /orders/{id}/status` — this is the "advance one step" escape hatch admins have alongside the technician's own lifecycle endpoints |

**Auto-reassignment on timeout**: `assignTechnician()` always arms `ReassignUnrespondedOrderJob`, delayed by `AppConfig['technician_response_timeout_minutes']` (default 15). If the order is still `pending` with the same technician still on it when the job fires, it unassigns them (`needs_attention: true`, back to `pending`) and looks for another available technician matching the order's category and the address's region — if found, auto-assigns them (re-arming the same timeout); if not, the order sits `pending`/unassigned with `needs_attention: true` for an admin to handle manually via `GET /admin/orders?needs_attention=true`.

---

## Workflow 1 — Individual Client: Catalog Order

1. `POST /client/auth/register` → OTP sent, response has `verification_token`, no user row yet.
2. `POST /client/auth/verify` (`code`, `verification_token`) → user created (`status: active`), **auto-logged-in**, response has `token`. *(Or `POST /client/auth/login` if already registered.)*
3. `GET /client/categories` → top-level service categories. `GET /client/categories/{id}/services` → bookable child services with `price`.
4. `POST /client/addresses` → create a delivery address (needed for checkout).
5. `POST /client/cart/items` (`category_id`, `quantity`) → repeat per item. `GET /client/cart` to review; `PATCH /client/cart/items/{itemId}` / `DELETE /client/cart/items/{itemId}` to adjust.
6. `POST /client/cart/checkout` (`payment_method: wallet|gateway`, `booking_type: instant|scheduled`, `address_id`, `scheduled_date` if scheduled) with header `Idempotency-Key: <client-generated-uuid>` (required by the `idempotency` middleware — retry-safe). Server computes `subtotal_amount` + VAT (`config('settings.vat_percent', 15)`) → `total_amount`, charges wallet (immediate, all-or-nothing — insufficient balance is a 422 with no order created) or gateway, then creates the `Order` (`status: pending`) and its `OrderItem`s, empties the cart. **201** on success.
7. Order now waits for an admin to assign a technician (or, if `needs_attention`, was auto-flagged after a timeout) — see [Workflow 7](#workflow-7--technician-assignment-lifecycle) for everything from here through completion.
8. `POST /client/orders/{id}/confirm-completion` (optional `stars` 1-5, `comment`) once the order reaches `finish` → `confirmed`. Rating (if given) is written to `Rating` and the technician's aggregate stats are recalculated (`Technician::recalculateStats()`).

Track progress any time with `GET /client/orders/{id}` (own orders only — scoped by `customer_id`) or `GET /client/orders?scope=active|history`.

## Workflow 2 — Custom-Price Request

For work that isn't in the fixed-price catalog:

1. `POST /client/cart/custom-request` (`description`, optional `category_id`, `request_media`, `address_id`) → creates an `Order` directly (bypasses the cart) with `status: waiting_price`, a single `OrderItem` (`pricing_type: quote`, `unit_price: 0`). **201**.
2. Admin reviews: `GET /admin/orders?status=waiting_price`, then `POST /admin/orders/{id}/quote` (`items: [{id, unit_price}]`, optional `vat_amount`/`fees_amount`) → recomputes `subtotal_amount`/`total_amount`, order moves to `priced`.
3. Client: `POST /client/orders/{id}/accept-price` (`payment_method`) — same wallet/gateway charge as catalog checkout, moves to `pending` on success. Or `POST /client/orders/{id}/reject-price` → `cancelled` (no refund needed — nothing was paid yet).
4. From here it's identical to catalog checkout: [Workflow 7](#workflow-7--technician-assignment-lifecycle).

## Company Onboarding

1. `POST /company/auth/register` (`legal_name`, `email`, `account_owner_phone`, `password`, `tax_number`, `commercial_registration_no`, device fields) → OTP sent, `verification_token` returned.
2. `POST /company/auth/verify` (`code`, `verification_token`) → `User` created (`status: pending`, `user_type: company`) + `companyProfile`. **No token returned** — the company cannot use the API yet.
3. `POST /company/auth/login` at this point → **422** `company_account_pending` (by design, per BRD Gap G-05).
4. Admin reviews the queue: `GET /admin/companies?filters[status]=pending`. Two independent approval axes:
   - Account status: `POST /admin/companies/{id}/activate` (→ `status: active`) or `POST /admin/companies/{id}/reject` (`reason` → `status: rejected`).
   - Commercial register document: `POST /admin/companies/{id}/commercial-register/approve` or `.../reject` — only actionable while `commercial_register_status === pending_review`.
5. Once `status: active`, `POST /company/auth/login` succeeds and returns `token`. Continue to [Workflow 4](#workflow-4--company-contract-lifecycle) and/or [Workflow 5](#workflow-5--company-job-order).

## Workflow 4 — Company Contract Lifecycle

Unlike orders, a contract is **created fully-formed in one call** — there's no separate draft step in the normal path:

1. `POST /company/branches` → create at least one branch (contracts reference branches by id; there's no seeded branch to point at, so this step is mandatory before contract creation, not optional).
2. `GET /company/contracts/template` → returns the current contract-template PDF (`media_id`, `url`) for the company to read, sign externally, and re-upload.
3. Upload the signed PDF via the shared media endpoint: `POST /media` (multipart, `file`) → returns a `media_id`.
4. `POST /company/contracts` (`branches: [{branch_id, service_ids: [...], visits_allowed}]`, `billing_cycle: monthly|quarterly|annual`, optional `start_date`/`end_date`/`currency`/`notes`, `signed_media_id`) — computes `total_amount` from each (branch, service) pair's `contract_price ?? price`, creates the `Contract` **directly at `status: pending_approval`** (the `Signature` row is written immediately, using the uploaded PDF as the signature artifact — there's no intermediate `pending_signature` state on this path). **201**.
5. Admin: `GET /admin/contracts?status=pending_approval`, then `POST /admin/contracts/{id}/approve` → `status: active`. Approval materializes the full visit quota as real `Order` rows — one unscheduled `Order` (`status: pending`, `order_source: contract_visit`, `booking_type: scheduled`, `contract_id` set, zero-priced `OrderItem` with `pricing_type: visit`) per `visits_allowed` slot on each (branch, service) pair in the contract. Or `POST /admin/contracts/{id}/reject` (`reason`) → `status: rejected` (only valid from `pending_signature`/`pending_approval`).
6. Once `active`, the company can place job orders against it ([Workflow 5](#workflow-5--company-job-order)); the generated visit-orders themselves flow through the normal [Order Status Machine](#order-status-machine) like any other order (admin assigns a technician, technician executes, etc.) — a contract's remaining visits (`GET /company/contracts/{id}` → `remaining_visits`) is simply a count of that contract's still-active orders, not a separate ledger balance.
7. **Renewal** (only from `active`/`expired`, and only one pending renewal at a time): `POST /company/contracts/{id}/renew` (optional `note`) → creates a `ContractRenewalRequest`, contract moves to `pending_renewal`. Admin: `POST /admin/contracts/{id}/renewal/approve` → creates a **new** `Contract` row (copying billing terms and per-branch/service visit quota, `status: active`, its own freshly-generated visit-orders), the old contract becomes `expired`; or `POST /admin/contracts/{id}/renewal/reject` → renewal request `rejected`, original contract stays as it was.
8. **Termination** (admin only, from `active`): `POST /admin/contracts/{id}/terminate` (`reason`) → `status: terminated`.
9. Billing: invoices are **system-generated monthly installments**, not created via the API (`GET /company/invoices` / `GET /admin/invoices` are read-only + admin `POST /admin/invoices/{id}/cancel`) — there is no `store` endpoint for invoices in either role.

## Workflow 5 — Company Job Order

Same cart/checkout mechanics as [Workflow 1](#workflow-1--individual-client-catalog-order), with two differences:
- `POST /company/cart/items`, `POST /company/cart/checkout`, `POST /company/cart/custom-requests` all accept an extra `branch_id` (not part of the shared `CheckoutRequest`/`CustomRequestRequest` validation — read directly off the request by the Company controller) identifying which branch the work is for.
- The resulting `Order.order_source` is set to `job_order` (individuals get `individual`) — this is how admin reporting/dashboards distinguish contract-driven company work from individual bookings, and it's how [operational report](admin.md) `order_sources[]` filtering works.
- From creation onward, the order follows the exact same [status machine](#order-status-machine) and technician-assignment flow as an individual order — `company.auth`-scoped endpoints for accept-price/reject-price/confirm-completion/cancel mirror the client ones exactly (see company.md `### Orders`).

## Technician Onboarding

1. Upload each of the 4 required documents first, **before** registering — the technician has no account yet, so use the public `POST /client/upload` endpoint (`file`), not the authenticated `POST /media` one — each call returns a `media_id`.
2. `POST /technician/auth/register` — largest payload of the four roles: personal fields, `category_id` (specialty), `region_ids` (coverage areas), `years_experience`, `daily_order_capacity`, plus the 4 media ids from step 1 (`personal_photo`, `national_id_front`, `national_id_back`, `cv_file`). → `verification_token` returned.
3. `POST /technician/auth/verify` → `User` (`status: pending`) + `technicianProfile` (`employment_type: freelance`, `availability_status: available`) created, coverage areas synced. No auto-login. `employment_type: freelance` here is deliberate, not just a default label — it's what makes the technician's wallet withdrawal-eligible from day one (`Wallet::isFreelanceTechnician()`); an admin would have to explicitly set `in_house` via `PUT /admin/technicians/{id}` to lock it.
4. `POST /technician/auth/login` while pending → rejected (403/422 depending on exact status, see AUTH.md).
5. Admin: `GET /admin/technicians?filters[status]=pending` (or `GET /admin/users?filters[user_type]=technician&filters[status]=pending`), then **generic** `POST /admin/users/{id}/activate` (there's no technician-specific activate endpoint — only Company has one, because of the extra commercial-register step) or `POST /admin/users/{id}/reject` (`reason`).
6. Once active: `POST /technician/auth/login` succeeds. Toggle field availability any time with `POST /technician/profile/toggle-availability`.
7. Continue to [Workflow 7](#workflow-7--technician-assignment-lifecycle).

## Workflow 7 — Technician Assignment Lifecycle

Picks up wherever an order reaches `pending` with no technician (fresh order) or `pending`+`needs_attention` (reassignment case):

1. Admin: `GET /admin/orders?needs_attention=true` (or plain `?status=pending`) to find work to dispatch, then `POST /admin/orders/{id}/assign-technician` (`technician_id`) — order stays `pending` but now has `technician_id` set and a 15-minute (default, `AppConfig`-driven) accept-window timer armed.
2. Technician sees it: `GET /technician/orders?scope=active` (or receives a push/in-app notification — `technician_order_assigned` scenario), then either:
   - `POST /technician/orders/{id}/accept-assignment` → `status: accepted`, or
   - `POST /technician/orders/{id}/reject-assignment` → unassigned, back to `pending` + `needs_attention: true`, admin must reassign (step 1 again).
   - **Or does nothing** — after the timeout, `ReassignUnrespondedOrderJob` auto-unassigns and tries the next available technician matching category + region automatically, re-arming the same timeout; only surfaces to the admin queue if no candidate is found.
3. `POST /technician/orders/{id}/start-heading` → `on_the_way` (customer gets an `order_en_route` notification).
4. While `on_the_way`, technician may instead call `POST /technician/orders/{id}/unreachable` (`reason`) if the customer can't be reached — unassigns, back to `pending`+`needs_attention: true` for admin re-dispatch (same as a rejected assignment).
5. `POST /technician/orders/{id}/start-execution` → `start` (`order_in_progress` notification).
6. `POST /technician/orders/{id}/finish` → `finish` (`order_work_submitted` notification to customer).
7. Customer: `POST /client|company/orders/{id}/confirm-completion` → `confirmed`, optional rating recorded against the technician and their `avg_rating`/`completed_orders_count` recalculated.

Every transition here writes an `order_status_histories` row automatically (via `OrderObserver`) — `GET /admin/orders/{id}` → `status_history` is the audit trail for "who did what when" across all of the above, regardless of whether admin, technician, or customer drove a given step.

## Workflow 8 — Cancellation & Refund

- **Admin**: `POST /admin/orders/{id}/cancel` (`reason`) — allowed from any status except `finish`/`confirmed`/`cancelled` (`canBeCancelled()`, the widest allowance of any actor).
- **Client/Company**: `POST /{role}/orders/{id}/cancel` (`reason`) — narrower: only from `pending`, `waiting_price`, `priced`, `accepted`, or `on_the_way` (`canBeCancelledByCustomer()`) — once the technician has actually started work (`start` onward), the customer can no longer self-cancel; only admin can at that point.
- Either path: status → `cancelled`, and **if `paid_amount > 0`**, `RefundService::processRefund()` runs in the same transaction:
  - Refunds only apply to **individual** orders (`order_source === individual`) with a real `customer_id` — company job orders are billed to the company/contract, not a customer wallet, so `processRefund()` throws and the transaction instead calls `blockRefund()`, which records a `$0` `reversal`-type wallet transaction with `status: failed` and the block reason, purely as an audit trail (no money moves).
  - For eligible individual orders: credits the customer's wallet with `paid_amount` as a `refund_credit` transaction, unless a completed refund for that exact order already exists (idempotent — replaying the same cancel doesn't double-refund).
- Rejecting a *quoted* price (`POST /orders/{id}/reject-price`, only valid from `priced`) also cancels the order, but never touches the wallet — nothing was ever charged for a `waiting_price`/`priced` order (`paid_amount` is `0` until `accept-price` succeeds).

## Workflow 9 — Technician Wallet Withdrawal

- **Eligibility is code-enforced, not just documentation**: `WalletValidationService::validateWithdrawal()` rejects (422) any wallet that isn't `isFreelanceTechnician()` — individuals and companies get 422 `withdrawal_not_allowed`, and **in-house** (non-freelance) technicians get 422 `wallet_locked_for_account_type`. Only freelance technicians can actually withdraw.
1. `GET /technician/wallet` → current `balance`. `GET /technician/wallet/transactions` → history.
2. `POST /technician/wallet/withdraw` (`amount`, `bank_name`, `account_number`, `iban` — all required) — **debits the wallet immediately** (not on admin approval) and creates a `WithdrawRequest` (`status: PENDING`) plus a wallet transaction of type **`reversal`** (not a dedicated "withdraw" type — `WalletTransactionType` has no such case) with `status: pending`. **201**.
3. Admin: `GET /admin/withdraw-requests`, then:
   - `POST /admin/withdraw-requests/{id}/approve` → withdrawal `status: APPROVED`, the pending wallet transaction flips to `completed` — money has genuinely left the wallet, matching a real bank payout the admin performs out-of-band.
   - `POST /admin/withdraw-requests/{id}/reject` — withdrawal `status: REJECTED`, and critically the wallet is **credited back** (`increment('balance', amount)`) and the transaction flips to `failed` — the initial debit in step 2 is reversed, so a rejected withdrawal is financially a no-op for the technician.

## Workflow 10 — Admin Wallet Adjustments

Direct admin controls over any user's wallet (`app/Services/Admin/WalletService.php`, endpoints under `/admin/wallets/{id}/...`), independent of the order/withdrawal flows above:
- `POST /admin/wallets/{id}/credit` (`amount`, `reason`) — manual credit (`manual_credit` transaction type), e.g. goodwill or an entitlement not otherwise modeled.
- `POST /admin/wallets/{id}/zero-out` (`reason`) — forces balance to 0, presumably for account closure/dispute resolution.
- `POST /admin/wallets/{id}/lock` / `.../unlock` — toggles `is_locked`; a locked wallet fails both `validateWalletUsage()` (can't pay for orders) and `validateWithdrawal()` (can't withdraw) with 422 `wallet_locked`.
- `POST /admin/wallets/{id}/block-refund` (`order_id`, `reason`) — the admin-triggered equivalent of `RefundService::blockRefund()` from [Workflow 8](#workflow-8--cancellation--refund), for cases needing manual intervention.

Field-level request/response detail for every endpoint referenced above (exact validation rules, full captured JSON) is in [client.md](client.md), [company.md](company.md), [technician.md](technician.md), and [admin.md](admin.md) under the matching `### Orders` / `### Contracts` / `### Wallet(s)` headings.
