# CR-9 — Cancellation Module — Requirements

Author: Fable (senior BA). Implementer: Opus agent. Reviewed against the
wireframes at `docs/wireframes-v12/` (source of truth for pixels):
`cancellation.html`, `add-cancellation.html`, `edit-cancellation.html`,
`view-cancellation.html`.

This CR extends the Bookings module (CR-8, `docs/bookings-module-requirements.md`)
and amends one CR-8 behavior (draft removal, §4). Follow
`docs/adding-a-module.md` only where §0 below does not override it. Code wins
over docs where they disagree; wireframes win over both for pixels.

---

## §0 Client-mandated conventions for this module — do NOT "fix" back

The Cancellation module is operationally part of the Bookings module and
follows the CR-8 §0 overrides, plus the client's explicit CR-9 instructions:

1. **No language files.** All user-facing text inline in controllers, views,
   mailable. (The sidebar partial is outside this override — it already uses
   `__('nav.cancellation')`; keep that, ensure the key exists.)
2. **All routes inline in `routes/web.php`**, next to the bookings block. No
   `routes/modules/cancellations.php`.
3. **ONE shared FormRequest** (`CancellationRequest`) for create and update —
   required fields are the same in both.
4. **NO policies.** Authorize with bare spatie permission gates
   (`Gate::authorize('cancellations.view')`, FormRequest `authorize()`,
   `@can(...)` in blades). "No policies" ≠ no authorization — every route,
   every button, every write is still gated.
5. **NO new test files.** Existing pinned tests (RolePermissionSeedTest,
   AppShellTest, code-format literals, anything asserting the permission list
   or sidebar) may be MINIMALLY updated so the suite stays green. Nothing else.
6. **Separate blade files** for create and edit screens (like bookings:
   `create.blade.php` / `edit.blade.php`, shared partials where sensible).
7. Client-decided removals from the wireframes (do not render): see §6 per
   screen. Removals beyond those listed are NOT authorized.

---

## §1 Scope summary

- New master table `cancellation_reasons` (seeded, dropdown source, no CRUD UI).
- New cancellation columns on `bookings` (client: "take necessary fields on
  same bookings table") including `cancelled_by`.
- New screens: Cancellation listing, Initiate Cancellation (create),
  Edit Cancellation, View Cancellation — pixel-perfect per wireframes minus
  the client's removals.
- Cancelling a booking immediately: sets status `cancelled`, frees the unit
  back to `available`, reverts the inquiry, records who cancelled, and sends a
  notification email (To = env-configured address, Cc = submitting user).
  No approval workflow.
- Bookings listing shows **only Confirmed** bookings; cancelled bookings appear
  only in the Cancellation section.
- CR-8 amendment: **Save-as-Draft is removed** — every saved booking is
  immediately Confirmed (user decision, §4).
- New permissions `cancellations.{view,create,edit}` (no delete).

Non-goals: no financial fields anywhere (unchanged CR-8 stance), no refund
tracking, no approval trail, no Print/PDF buttons, no delete of cancellations,
no CRUD UI for cancellation reasons, no new tests, no policies.

---

## §2 Schema

### 2.1 `cancellation_reasons` (new table)

Mirror the `booking_types` migration shape exactly (CR-7 precedent, including
its D2 rationale — the flag and audit columns exist now so a future CRUD CR
adds a control, not a migration):

- `id`
- `name` string, **unique (plain index, counts soft-deleted rows)**
- `is_active` boolean default true
- `created_by` / `updated_by` FK users nullable `nullOnDelete`
- `timestamps`, `softDeletes`

Model `CancellationReason`: `final`, `HasAudit`, `HasFactory`,
`$fillable = ['name']`, `is_active` boolean cast, full `@property` block
(larastan level 6). Factory required (models with HasFactory need one for the
existing suite conventions).

Seeder `DefaultCancellationReasonSeeder` — clone `DefaultBookingTypeSeeder`
(public `const REASONS`, idempotent `firstOrCreate` with
`withoutGlobalScope(SoftDeletingScope::class)`), wired into `DatabaseSeeder`
as default data. Reasons, verbatim from the wireframe select:

1. Financial Difficulty
2. Better Deal Elsewhere
3. Personal Reasons
4. Construction Delay
5. Dissatisfied with Quality
6. Change of Plans
7. Legal Issues
8. Other

### 2.2 `bookings` — new columns (one new migration; all nullable, populated only when a booking is cancelled)

- `cancellation_reason_id` FK → `cancellation_reasons`, nullable,
  `restrictOnDelete`
- `cancellation_requested_by` string nullable — enum-backed (§2.3), never SQL ENUM
- `cancellation_date` date nullable
- `cancellation_note` text nullable ("Detailed Reason" textarea)
- `cancellation_stage` string nullable — enum-backed (§2.3)
- `cancellation_verified_docs` json nullable — array of checked checklist item
  numbers for the chosen stage (e.g. `[1, 3]`); cast `array` on the model
- `cancelled_by` FK → `users`, nullable, `nullOnDelete` (client: logged-in
  user id saved here when a booking is cancelled)
- `cancelled_at` timestamp nullable — **added by the corrected D15**; written
  once by `BookingService::cancel()` from `freshTimestamp()`, never by the edit
  path, cast `datetime`, rendered on no screen
- index on `cancellation_date` (listing default sort)

`Booking` model: add relations `cancellationReason()`, `cancelledBy()`;
add casts; extend the `@property` block. None of these columns join
`$fillable` — they are server-owned, written via `forceFill()` in the service
(house rule).

### 2.3 New enums (string-backed, `label()` with no default arm, in `app/Enums`)

**`CancellationStage`**: `BeforeAgreement = 'before_agreement'` (label
"Before Registration of Agreement for Sale"), `AfterAgreement =
'after_agreement'` (label "After Registration of Agreement for Sale").
Add a `documents(): array` method returning the stage's checklist strings,
1-indexed, verbatim from the wireframe (3 items for Before, 4 for After —
copy them exactly from `add-cancellation.html` lines 173-175 / 187-190).
This is the single source for the create/edit checklists, the view screen,
validation bounds, and the email.

**`CancellationRequestedBy`**: `Customer = 'customer'`, `Builder = 'builder'`,
`Mutual = 'mutual'` (labels Customer / Builder / Mutual).

---

## §3 Behavior

### 3.1 Submitting a cancellation (POST /cancellations)

Inside ONE `DB::transaction`, honoring the module lock-order invariant
**inquiries → bookings → units** (documented in
`BookingService::lockInAscendingOrder()` — a new write path joins by taking
its locks in that order; do not reopen the cycle):

1. Lock the booking's inquiry row first
   (`lockInAscendingOrder(Inquiry::query(), [...])`).
2. Locking re-read of the booking row (`lockForUpdate`); it must still be
   `confirmed` — otherwise throw a `ValidationException` on `booking_id`
   ("This booking has already been cancelled." — this is the race-safe layer;
   the FormRequest check in §5 is the friendly first layer).
3. `forceFill` + save on the booking: `status = cancelled`, all §2.2
   cancellation columns, `cancelled_by = Auth::id()`, `updated_by = Auth::id()`.
4. Free the unit (existing `freeUnit()` — unit goes `booked → available`
   immediately; client: "Once cancellation request submited immidiatlly that
   unit status marked available").
5. Revert the inquiry (existing `revertInquiry()`).

Implementation home: **`BookingService` stays the single writer of bookings**
— extend its `cancel()` to accept the validated cancellation payload
(e.g. `cancel(Booking $booking, array $attributes): void`). The old
no-payload call sites disappear (§4.3). Keep `cancel()`'s existing
idempotence-by-refusal shape but upgrade the "already cancelled" path from
silent return to the ValidationException above (a second submitter must see
an error, not a fake success).

### 3.2 Notification email — AFTER the transaction commits

- New `app/Mail/BookingCancelledMail` (`final`), blade view under
  `resources/views/mail/` — plain inline-text HTML, no lang files, no markdown
  mailable theme dependency.
- **To:** `config('nrems.cancellation_notify_to')` — new config key reading
  `env('NREMS_CANCELLATION_NOTIFY_TO')`. Add the key to `config/nrems.php`
  following that file's comment conventions, and a commented block in
  `.env.example` (see the `NREMS_UPLOADS_DISK` exemplar). Never call `env()`
  outside the config file.
- **Cc:** the authenticated (submitting) user's email (client instruction).
- **Send synchronously** (`Mail::to(...)->cc(...)->send(...)`) — no
  `ShouldQueue`: QUEUE_CONNECTION is `database` and no worker is provisioned;
  a queued mail would silently rot in the jobs table.
- Content (inline strings): subject `Booking Cancelled - {code}`; body lists
  Booking ID (code), Customer name, Project - Unit, Booking Date,
  Cancellation Date, Reason, Requested By, Stage label, Detailed Reason (if
  any), Cancelled By (user name). Dates `d M Y`.
- **Failure containment:** if the To address is blank/unset, skip sending and
  `Log::warning` once. Wrap the send in try/catch (`Throwable`) → `report()`;
  an SMTP failure must never roll back or hide a completed cancellation. The
  success flash still shows.

### 3.3 Visibility rules

- **Bookings listing**: only `status = confirmed` rows (drop the
  filter-everything default; the DataTable base query gains
  `->where('status', BookingStatus::Confirmed)`). The `filter_status` ajax
  param and its plumbing are removed (the UI select was already commented out).
- **Cancellation listing**: only `status = cancelled` rows.
- Cancellation show/edit routes 404 (`NotFoundHttpException`) when the bound
  booking is not cancelled. Create/store only accept a confirmed booking.

### 3.4 Entry points to the create screen

- Bookings listing action column: the cancel (`fa-ban`) POST-form button is
  **replaced by a plain link** to the cancellation create screen with that
  booking preselected (`route('cancellations.create', ['booking' => $id])`),
  gated `@can('cancellations.create')`. No more confirm() dialog — the create
  screen is the confirmation step now.
- Booking show page: same replacement for its "Cancel Booking" button (same
  gate; keep `btn btn-danger btn-sm` + `fa-ban` look).
- Cancellation listing: "Initiate Cancellation" primary button (per wireframe)
  → create screen with empty selection, gated `@can('cancellations.create')`.
- The old `POST bookings/{booking}/cancel` route and
  `BookingController::cancel()` are **removed** (superseded).

### 3.5 Permissions

Add to `RolePermissionSeeder::PERMISSIONS`: `cancellations.view`,
`cancellations.create`, `cancellations.edit` (23 total). Admin gets all
automatically (role maps to PERMISSIONS). **Sales and Operations get none** —
same reasoning as CR-8 D13 ("cancelling undoes a contract; a salesperson asks
an administrator"). `bookings.delete` continues to exist: it still gates
customer-document deletion; it no longer gates any cancel action.

Sidebar: make the Operations → Cancellation item live (it exists as an inert
preview link with `fa-ban`), gated `@can('cancellations.view')`, pointing at
the cancellation index.

---

## §4 CR-8 amendment: Save-as-Draft removed (user decision 2026-09-01)

User: "all booking always confirmed booking" → confirmed choice: **remove the
draft option so every saved booking is immediately Confirmed.**

1. **Create**: the booking form no longer offers an action choice; saving a
   new booking always confirms it (books the unit, sets the inquiry to
   `booking_confirm`). Submit button label: **"Confirm Booking"**.
2. **Update**: submit button **"Update Booking"**; an update keeps the
   lifecycle untouched (the existing `save` semantics — confirmed stays
   confirmed; unit swap effects unchanged).
3. `BookingRequest`: remove the `action` input, its rules, its
   `prepareForValidation` normalization, and the action-per-state `after()`
   callback. The service no longer receives an action; `targetStatus()`
   simplifies accordingly (create → Confirmed; update → leave lifecycle
   alone). Do not remove or weaken any other `after()` callback.
4. `BookingStatus::Draft` stays as an enum case (same placeholder status as
   Registered/Transferred — a case with no writer), but nothing writes it.
5. **Data migration** (same migration as §2.2 or its own): promote existing
   `draft` rows in ascending id order — set `confirmed`, book the unit if it
   is still `available`, set the inquiry to `booking_confirm` if not already.
   If the unit is no longer available (claimed by another live booking), set
   that draft to `cancelled` instead (cancellation columns stay null). Expected
   row count in every real environment: zero; the migration exists so no
   environment can strand an invisible draft.
6. Bookings stat cards: keep as-is unless a card counts drafts; if one does,
   drop that card (report it in the completion notes).

---

## §5 Validation — `app/Http/Requests/Cancellations/CancellationRequest.php`

One `final` FormRequest for store and update. `authorize()`:
`$this->route('booking') instanceof Booking ? Gate::allows('cancellations.edit')
: Gate::allows('cancellations.create')` (same pattern as `BookingRequest`).
Inline `attributes()` / `messages()` (no lang). jsvalidation applies as on the
bookings forms.

Rules:

- `booking_id` — **create only**: `required`, integer, exists in `bookings`
  (not soft-deleted). `after()` callback: the referenced booking's status must
  be `confirmed` ("Only a confirmed booking can be cancelled."). On update the
  field is not accepted (the route binding is the booking; ignore/forbid input).
- `cancellation_reason_id` — required, integer, exists in
  `cancellation_reasons` where `is_active` = true and not soft-deleted.
- `requested_by` — required, `Rule::enum(CancellationRequestedBy::class)`.
- `cancellation_date` — required, `date_format:Y-m-d`.
- `cancellation_note` — nullable, string, max:65535.
- `stage` — required, `Rule::enum(CancellationStage::class)`. The two wireframe
  checkboxes submit this single field (§6.2).
- `verified_docs` — nullable array, each element integer distinct; `after()`
  callback: every element must be a valid 1-based index into
  `CancellationStage::from($stage)->documents()` (elements for the OTHER
  stage's list, out-of-range numbers, or non-scalars are rejected). Cap the
  array size at the stage's document count.

Tamper cases that MUST be handled (the adversarial review will probe these):
booking_id pointing at a cancelled/soft-deleted booking; inactive or trashed
reason id; stage/verified_docs mismatch; verified_docs as string or nested
array; update request smuggling a booking_id to retarget the row.

---

## §6 Screens — pixel-perfect mapping

General: `@extends('layouts.app')`, `<x-breadcrumbs>`, the bookings module's
visual idioms (`form-section`, `form-row-3` / `form-row-4`, `detail-grid`,
`d M Y` dates, em-dash `—` blank convention, no raw `{!! !!}` output, no Back
buttons beyond what the wireframe shows). Where a wireframe uses inline styles
(the Document Verified section does heavily), translate them faithfully —
reuse `nrems-wireframe.css` classes when an identical class already exists,
otherwise add module styles rather than approximating.

### 6.1 Cancellation listing — `cancellation.html` → `/cancellations`

- Page title "Cancellation Management", breadcrumb Home / Cancellation
  Management.
- **No stat cards** (user decision — the wireframe's 4-card row is removed).
- Action bar right: "Initiate Cancellation" primary button (`fa-plus`),
  `@can('cancellations.create')`.
- Filter bar: search input (placeholder adapted — no Cancel ID exists:
  `Search by Booking ID, Customer...`), "All Projects" select (real projects),
  From Date / To Date (`filter-date`) filtering on `cancellation_date`.
  **No status select** (client: "Remove All Status drop down").
- Table columns, exactly and only (client override of the wireframe's 10):
  1. Cancellation Date (`d M Y`)
  2. Reason (reason name)
  3. Booking ID (code, `fw-500`)
  4. Customer Name (main customer)
  5. Project - Unit (`{project} - {unit_number}`)
  6. Action — View eye `@can('cancellations.view')`, Edit pen
     `@can('cancellations.edit')` (`action-group` / `btn-icon` idiom).
- `CancellationsDataTable` (new, clone the bookings DataTable architecture):
  base query `Booking::query()->where('status', BookingStatus::Cancelled)`
  with eager loads; **order/search whitelist = `['cancellation_date']` only**;
  computed columns via `filterColumn`/correlated-subquery order SQL as in
  `BookingsDataTable`; default order cancellation_date desc; the JSON render
  gate is `$request->ajax() && $request->wantsJson()` (CR-7 lesson); raw
  columns limited to `actions`.

### 6.2 Initiate Cancellation — `add-cancellation.html` → `/cancellations/create`

Client removals from this screen: **Total Booking Value, Total Paid,
Outstanding Balance, IFSC Code, Expected Refund Date** (and their entire
trailing `form-row-3`).

- Page title "Initiate Cancellation"; breadcrumb Home / Cancellation
  Management / New Cancellation.
- **Booking Reference** section (`fa-file-contract`):
  - Row 1 (`form-row-3`): "Booking ID *" — a real `<select>` of **confirmed**
    bookings, option label `{code} - {main customer name}` (wireframe:
    "BK-2026-0384 - Rajesh Patel"), first option "Search and select
    booking...". Each option carries the display data via escaped data
    attributes (customer name, `{project} - {unit}` string, booking date
    `d M Y`) — the Js::from()/escaped-JSON house rule applies; no innerHTML
    for data, set `.value`/`.textContent` only.
  - "Customer" and "Project - Unit": disabled text inputs, placeholder
    "Auto-filled on booking selection", filled by JS from the selected
    option's data attributes.
  - Row 2 (`form-row-4`): "Booking Date" disabled input, placeholder
    "Auto-filled", JS-filled; the other three cells are gone with the removed
    fields — keep the row a `form-row-4` grid with the remaining cell first
    and empty `form-group` divs to preserve geometry.
  - **Preselect**: `?booking={id}` query param. Guard with the CR-7 scalar
    rule (`is_scalar` before int-cast). If it names a confirmed booking,
    preselect it and prefill the disabled fields server-side (no JS needed for
    first paint); otherwise render with empty selection. Client: "Whenever
    user come into cancellation screen Booking ID, Customer, Project - Unit
    and Booking Date will prefill from bookings."
- **Cancellation Details** section (`fa-ban`), `form-row-3`:
  - "Cancellation Reason *" select from `cancellation_reasons`
    (active, not trashed), first option "Select Reason".
  - "Requested By" select — enum options, Customer preselected by default
    (wireframe order Customer/Builder/Mutual).
  - "Cancellation Date *" — date input **prefilled with today** (client
    instruction).
  - "Detailed Reason" textarea, placeholder verbatim from wireframe.
- **Document Verified** section (`fa-list-check`), helper line "Select the
  cancellation stage and verify applicable documents.":
  - Two stage checkbox cards, labels verbatim. **Mutually exclusive and one
    required** (client: "User can only checked single option ... From this two
    option one need to checked before cancellation proceed"): checking one
    unchecks the other via JS and toggles its own sub-panel (checked → panel
    shown, unchecked → hidden, exactly like the wireframe's onchange). Both
    submit as the single `stage` field; server enforces required+enum
    regardless of JS.
  - Sub-panels: dashed-border boxes with "Verify applicable documents:" and
    the numbered document checkboxes, text verbatim from
    `CancellationStage::documents()`. Checked items post as `verified_docs[]`
    (their 1-based numbers). Optional — no requirement to tick any.
  - No inline `onchange=` attributes in our build — data-role hooks + a JS
    module with one data-attribute config root (house rule); behavior
    identical.
- Footer (`card-footer`): "Cancel" outline link → cancellation index;
  primary submit **"Submit Cancellation"** with the `fa-paper-plane` icon
  (deviation D6: wireframe says "Submit for Approval"; approval does not
  exist).
- On success → redirect to the cancellation view screen of the booking with a
  success flash ("Booking {code} cancelled."). Validation errors re-render
  with `old()` restored, including stage/checklist state.

### 6.3 Edit Cancellation — `edit-cancellation.html` → `/cancellations/{booking}/edit`

Client/decision removals: the financial trio in Booking Reference, the
IFSC/Expected Refund Date row, and the **entire Approval section** (D7 —
approval does not exist). Footer's Reject / "Approve & Process Refund"
buttons are replaced by a primary **"Update Cancellation"** submit next to the
outline "Cancel" link.

- Page title "Edit Cancellation - {code}"; breadcrumb leaf "Edit {code}".
- Booking Reference: all four fields are **disabled text inputs with real
  values** (code, customer, project - unit, booking date `d M Y`) — the
  booking of a cancellation can never change; no select here, and `PUT` input
  cannot retarget it (§5).
- Cancellation Details + Document Verified: same controls as create,
  prefilled from the row (`old()` wins over model values on validation
  errors); the saved stage's checkbox checked, its panel open, saved
  `verified_docs` ticked.
- Update path: plain attribute update via the service
  (`forceFill` on the cancellation columns + `updated_by`), inside a small
  transaction; it must **NOT** touch status, unit, inquiry, `cancelled_by`,
  and must **NOT** send another email (side effects fired once, at submit).
- Route/gate: `cancellations.edit`; 404 unless the booking is cancelled.
- On success → cancellation view screen with success flash.

### 6.4 View Cancellation — `view-cancellation.html` → `/cancellations/{booking}`

Client removals: **Refund Status card and Approval Trail card** (whole
sections). Additional authorized removals: the three financial rows in the
Booking Details card (Total Booking Value / Total Amount Paid / Outstanding
Balance — no money columns exist, unchanged CR-8 stance, D8) and the Print +
Download PDF buttons top AND bottom (user decision, D9).

- Page title / breadcrumb: "Cancellation - {code}" / leaf {code} (D10: no
  separate CAN-#### series — cancellations live on the booking row and the
  client's listing spec dropped the Cancel ID column; the booking code is the
  identifier everywhere).
- Status banner row: `badge badge-danger` "Cancelled" (with dot) sized as the
  wireframe's banner badge, plus the muted line adapted to
  `Booking ID: <strong>{code}</strong>`. Right side: **Edit** primary small
  button only (`fa-pen`), `@can('cancellations.edit')`.
- `grid-2` cards:
  - **Booking Details** (`fa-file-contract text-primary`): Booking ID,
    Customer Name, Project, Unit, Booking Date — `detail-grid` idiom.
  - **Cancellation Details** (`fa-ban text-danger`): Request Date →
    rendered as "Cancellation Date" value `d M Y`, Cancellation Reason,
    Requested By (label), full-width Detailed Reason (em-dash when blank).
    The wireframe's "Cancellation ID" item shows the booking code (D10);
    keep the item so the grid stays 2×2+full-width.
- **Document Verified** card: the chosen stage rendered as the bordered
  primary pill with `fa-check-circle`; below it every document of that stage
  listed with `fa-check-circle` in success color when its number is in
  `verified_docs`, else `fa-clock` in warning color (exactly the wireframe's
  mixed-state rendering). The wireframe's two trailing empty rows are a
  wireframe bug — do not render them.
- **Unit status info box** (amber `info-box`): text adapted to the real flow:
  `Unit <strong>{unit}, {project}</strong> has been released back to
  <strong>Available</strong> inventory.` (the wireframe's "upon completion /
  Cancellation In Progress" text describes the approval world that does not
  exist).
- Bottom action row: Edit button only (same gate) — Print/PDF dropped.
- Route/gate: `cancellations.view`; 404 unless cancelled.

---

## §7 Routes (inline in `routes/web.php`, inside the auth group, member routes before any resource per the file's ordering rule)

```
GET  /cancellations                    cancellations.index   (view)
GET  /cancellations/create            cancellations.create  (create)
POST /cancellations                   cancellations.store   (create)
GET  /cancellations/{booking}         cancellations.show    (view; 404 unless cancelled)
GET  /cancellations/{booking}/edit    cancellations.edit    (edit; 404 unless cancelled)
PUT  /cancellations/{booking}         cancellations.update  (edit; 404 unless cancelled)
```

Use `Route::resource('cancellations', ...)->except(['destroy'])` with
`->parameters(['cancellations' => 'booking'])` or explicit routes — whichever
reads cleaner next to the bookings block; document the choice inline the way
the bookings block documents its ordering. Remove
`POST bookings/{booking}/cancel`.

Controller: `final CancellationController` — index (page + DataTable JSON via
the `ajax() && wantsJson()` gate), create, store, show, edit, update, plus a
private `refuseUnlessCancelled()` guard mirroring
`BookingController::refuseWhenCancelled()`.

---

## §8 Decisions

- **D1** Cancellation data lives on `bookings` (client instruction); a
  "cancellation" is a cancelled booking. No `cancellations` table.
- **D2** `cancellation_reasons` mirrors the `booking_types` schema so a future
  CRUD CR needs no migration; seeded with the 8 wireframe reasons; no UI.
- **D3** No approval workflow (client): approval sections/statuses/buttons in
  the wireframes are not rendered; the notification email replaces approval.
- **D4** Email is synchronous, post-commit, failure-contained (§3.2); one env
  var `NREMS_CANCELLATION_NOTIFY_TO`; Cc = submitter (client).
- **D5** Permissions `cancellations.{view,create,edit}` — Admin only (Sales /
  Operations get none; CR-8 D13 reasoning). `bookings.delete` remains for
  customer-document deletion only.
- **D6** Create submit button reads "Submit Cancellation" (wireframe's
  "Submit for Approval" contradicts D3).
- **D7** Edit screen drops the Approval section; footer = Cancel + "Update
  Cancellation".
- **D8** No financial fields/rows anywhere (CR-8 "financial not required"
  stands; the client also removed them explicitly from the create screen).
- **D9** No Print / Download PDF buttons (user decision 2026-09-01).
- **D10** No separate CAN-code series; the booking code identifies a
  cancellation everywhere.
- **D11** Draft removal (§4) — user decision 2026-09-01: "Yes, always
  confirm". Bookings listing = Confirmed only; cancellation listing =
  Cancelled only.
- **D12** Edit updates attributes only; side effects (status, unit, inquiry,
  cancelled_by, email) fire exactly once, at store.
- **D13** Editing a cancellation is allowed indefinitely (user decision) —
  no time lock.
- **D14** Verified-document checklist persists as a JSON array of 1-based item
  numbers; checklist text lives once, in `CancellationStage::documents()`.
- **D15 (corrected during implementation review)** `cancelled_at` **is** added,
  and both cancellation write paths log an activity row explicitly.

  The original decision read: "`cancelled_at` is not added — `cancellation_date`
  (user-entered) is the business date; the activity log already timestamps the
  write." **Its second clause was false.** `HasAudit` configures
  spatie/laravel-activitylog with `logFillable()` + `logOnlyDirty()` +
  `dontSubmitEmptyLogs()`, and every column in §2.2 is deliberately outside
  `Booking::$fillable` (they are server-owned and written through `forceFill()`).
  The log was therefore blind to all of them: cancelling a booking wrote **no
  activity row at all**, and nothing in the database recorded when it happened.

  The corrected decision, in three parts:

  1. `bookings.cancelled_at` — `timestamp` nullable, in the §2.2 migration. Set
     from `freshTimestamp()` by `BookingService::cancel()` and by nothing else;
     `updateCancellation()` must never move it. It is the authoritative
     wall-clock record and is distinct from `cancellation_date`, which stays the
     user-entered business date (and stays editable). Cast `datetime`, in the
     `@property` block, and **rendered on no screen** — it is on no wireframe and
     is a record rather than a field.
  2. `cancel()` writes one explicit
     `activity()->performedOn()->causedBy()->withProperties()->log('Booking cancelled')`
     row after its save, carrying the six cancellation values plus `cancelled_at`
     — which is what puts the event on the booking detail screen's History tab.
  3. `updateCancellation()` writes `log('Cancellation updated')` with `old` and
     `attributes` limited to the cancellation columns that actually changed, and
     **only when something changed**. D13 allows editing a cancellation
     indefinitely, so an edit that left no trace could silently rewrite the
     record part 2 exists to protect.

  Widening `$fillable` to let `logFillable()` see these columns is **not** the
  fix and is not authorised: it would undo the mass-assignment protection that is
  the entire reason they are server-owned (§2.2). The same trade applies to any
  server-owned column a later CR adds to this table.

## §9 Deviations from the wireframe (authorized)

Everything in §6's per-screen removal lists, plus: no stat cards on the
listing (user), no status filter (client), listing columns reduced to the
client's 6, adapted search placeholder, adapted unit-status info-box text,
adapted banner/ID wording (D10), "Submit Cancellation" label (D6), no
approval artifacts anywhere (D3). Nothing else may be dropped or added.

## §10 Definition of done

- `php artisan migrate:fresh --seed` passes; re-running the seeder is
  idempotent.
- All §6 screens pixel-faithful (minus authorized removals) in the real app
  shell; jsvalidation active on create/edit; the stage-exclusivity and
  auto-fill JS work with escaped data only.
- Security: every route/button/write gated; DataTable whitelist minimal; all
  §5 tamper cases rejected; no raw output; no mass assignment of server-owned
  columns; lock order proven (state in the completion notes how each write
  path orders its locks).
- Gates with real output: `php artisan test` (existing suite green —
  1033/1029/4-skips baseline, minimally-updated pinned tests only),
  `composer analyse` (larastan level 6, 0 errors), Pint clean on the
  footprint. Use `C:\wamp64\bin\php\php8.3.28\php.exe` explicitly (PATH php
  is 8.2).
- NOTHING is committed. Ever. The user commits.

---

## CR-19 — ajax search in the Booking ID dropdown (client, 2026-09-11)

Verbatim: "When user go to the Initiate Cancellation request then provide ajax search
inside Booking ID dropdown user can search using Booking ID, Customer name, customer
number or unit number. Show format for Booking ID drop down is 'Booking Id - Customer
name - Mobile Number - Project name - Unit Number'. Remain functionality will same."
Overrides restated: no test cases, no policies. (This module is no-lang per CR-9 §0 —
strings inline.)

- **S1 — The select becomes the CR-15 ajax select2** (the pattern
  CustomerDocumentBookingSearchController established): remote search over CONFIRMED
  bookings by booking code, main-customer name, main-customer mobile, unit number;
  LIKE metacharacters escaped (CR-15 D26); ≤20 rows; empty/whitespace term → `[]`
  (no roster). Label format is the client's, identical to CR-15's — REUSE
  `CustomerDocumentBookingSearchController::label()` (public static, one format
  string app-wide) rather than a second copy; if the query shape is shared too,
  extract ONE search helper both endpoints call, with the gate staying per-endpoint.
- **S2 — Own endpoint, own gate**: `GET /api/cancellations/bookings` (invokable, e.g.
  CancellationBookingSearchController), gated `cancellations.create` — do NOT widen
  the customer-documents endpoint's gate; a cancellation clerk is not necessarily a
  documents clerk. Declared beside the cancellation routes (inline in web.php where
  CR-9 keeps this module's routes), `/api` prefix for the 401-JSON behavior.
- **S3 — "Remain functionality will same"** — everything else byte-equivalent in
  behavior: the three auto-filled disabled fields (customer / project—unit / booking
  date) still fill on selection — the data now rides the search response's rows
  (extra scalar keys per row, data-only) instead of option data attributes, or a
  cascade fetch — implementer's choice, stated; `?booking=` preselection still
  renders the chosen option server-side (CR-15's rejected-submit pattern: selected
  option + autofill values server-rendered, select2 adopts them); a rejected submit
  still restores the selection + autofill; CancellationRequest and all server rules
  UNCHANGED (its docblock notes the select was "a real <select> of confirmed
  bookings" — keep the tamper checks exactly as they are, the validator never
  trusted the option list); the create twin's other fields untouched.
- **S4 — JS**: extend the module JS this screen already loads (recon which — likely
  a cancellation form module or inline wiring) or reuse
  nrems-customer-document-form.js's select2-ajax half ONLY if it is genuinely
  host-agnostic — do not force a second host into a module whose header says
  otherwise; a small shared select2-search helper is acceptable if BOTH form modules
  adopt it, stated with reasons. No innerHTML; strings from Blade.
- **S5 — Pinned tests**: recon which tests pin the create screen's option list or
  markup (CancellationTest/CancellationsDataTableTest etc.); minimal honest re-pins
  only, quoted. No new test files.

DoD: full suite SOLO (baseline 1112/1108/0/4/4334 — deltas only from S5, quoted;
ProjectCrudTest.php untouched), phpstan 6 = 0, pint, node --check. Real-app probes:
search matches on each of the four fields with the exact label; q=% → []; q[]= →
[]; selection fills the three fields; ?booking= preselection + rejected submit both
restore; guest → 401 JSON; a customer_documents-only user → 403 on the new endpoint;
full cancel flow end-to-end unchanged. C:\wamp64\bin\php\php8.3.28\php.exe. No
commits.
