# Termination Requests

A termination request is an explicit, reviewable record that a booking which has already started should be ended
early. Instead of curtailing the booking immediately, a caller with the right permission opens a request against the
booking; another caller (or the same one, if they hold both permissions) then approves, declines, or withdraws it.
Approving curtails the booking to the requested final date in the same transaction; declining and withdrawing leave
the booking confirmed.

Termination is the inverse of a cancelation request. A cancelation applies to a booking whose stay is still in the
future, whereas a termination ends a booking whose contract has already started. The two flows are mutually
exclusive: a booking cannot have a pending termination request and a pending cancelation request at the same time.

Termination requests are a per-workspace opt-in. When the workspace setting is off, all the operations described on
this page short-circuit with `termination_approvals_disabled`, and the read field returns an empty list.

## Key Concepts

### Lifecycle

Every termination request follows the same four-state lifecycle:

```text
       submit             approve
[None] -------> [Pending] ---------> [Approved]  (booking curtailed; termination scheduled)
                     |
                     |--- decline ---> [Declined]   (booking stays confirmed)
                     |
                     |--- withdraw --> [Withdrawn] (booking stays confirmed)
```

There is at most one **pending** request per booking at any time. Once a request is in a terminal state (approved,
declined, or withdrawn), a new pending request can be opened.

### The termination date

Unlike a cancelation request, a termination request carries a requested final date, the `terminationDate`. This is
the date the started booking should be cut short to, and it must fall within the booking's contract window (between
its check-in and check-out, inclusive). The requested date is captured when the request is submitted and travels with
the request through its lifecycle. An approver may supply an amended final date at approval time; if they do, that
amended date is the one the booking is curtailed to.

### Eligibility

A booking is eligible for a termination request when **all** of the following hold:

- The workspace setting for termination requests is enabled
- The booking is a student Product Booking
- The booking status is `confirmed`
- The booking contract has started (its check-in is on or before today)
- The booking does not already have a pending cancelation request
- The booking does not already have a pending termination request
- The requested `terminationDate` falls within the contract window

If a booking is not a student Product Booking, is not `confirmed`, or has not yet started, `submitTerminationRequest`
returns `booking_not_eligible_for_termination_approval`. A requested date outside the contract window returns
`termination_date_out_of_bounds`, and a competing pending cancelation request returns
`booking_has_pending_cancelation_request`. Eligibility is only checked on submit; transitions on an already-pending
request are not re-gated by eligibility, though an amended date supplied at approval time is bounds-checked again.

### Permissions

Authorization is checked per operation, against the booking resource:

| Operation | Required permission on the booking |
| ----------- | ------------------------------------ |
| Submit a termination request | `request_termination` |
| Approve a pending request | `approve_termination` |
| Decline a pending request | `decline_termination` |
| Withdraw a pending request | `withdraw_termination` |

A caller who lacks the matching permission gets back `unauthorized`, regardless of the state of the booking or any
pending request.

### Side effects of approval

When a pending request is approved, the booking is curtailed and the termination is **scheduled** inside the same
database transaction as the request status flip. The booking is **not** flipped to `terminated` at approval time —
it stays `confirmed` until the scheduled termination is applied. Concretely, on `APPROVE`:

- The request moves from `Pending` to `Approved`
- The booking is curtailed to the final termination date (the amended date if the approver supplied one, otherwise the
  date captured at submit time), and a flip to `terminated` is scheduled for when the curtailed stay ends
- The `approved` webhook event for the request fires in this transaction

The booking's status flips to `terminated` — and the `v1.booking.terminated` event fires — only when that scheduled
termination is applied: effectively immediately if the final date is in the past or today, or deferred until that date
if it is in the future. So approval does not always terminate the booking immediately.

> **Note:** When the approved termination date is in the **future**, the booking remains `confirmed` right up to that
> date and keeps behaving as a normal confirmed booking (it still reports `status: CONFIRMED`, stays bookable for its
> curtailed stay, etc.). It only transitions to `terminated` when the scheduled date arrives. A past or same-day
> approved date applies effectively immediately.

If the booking is already `terminated` when the approval lands, the request still moves to `Approved`, but the
curtailment/scheduling side effect is skipped, so re-approving is idempotent.

## GraphQL API

### Types

#### `TerminationRequest` (interface)

The polymorphic interface every request implements. Reads return this interface; clients use inline fragments to pull
state-specific timestamps.

| Field | Type | Description |
| ------- | ------ | ------------- |
| `status` | `ApprovalRequestStatusEnum!` | Current lifecycle state of the request |
| `requestedAt` | `ISO8601DateTime!` | When the request was first submitted |
| `terminationDate` | `ISO8601Date!` | The requested final date for the booking |
| `terminationReason` | `BookingTerminationReasonEnum` | Optional reason the caller attached at submit time |

The owning workspace is reachable via the booking itself (`booking.workspace { id }`); a termination request always
belongs to the same workspace as its booking.

The interface has four concrete implementations, one per lifecycle state. Each adds a single state-specific timestamp:

| Concrete type | Extra field |
| --------------- | ------------- |
| `PendingTerminationRequest` | (interface fields only) |
| `ApprovedTerminationRequest` | `approvedAt: ISO8601DateTime!` |
| `DeclinedTerminationRequest` | `declinedAt: ISO8601DateTime!` |
| `WithdrawnTerminationRequest` | `withdrawnAt: ISO8601DateTime!` |

Decision timestamps are state-specific by design: a `PendingTerminationRequest` will not carry an `approvedAt`, and an
`ApprovedTerminationRequest` will not carry a `declinedAt`. Use inline fragments to pull whichever timestamp matches
the request's current state (see the query examples below).

#### Enums

**`ApprovalRequestStatusEnum`** - lifecycle state of a termination request:

| Value | Description |
| ------- | ------------- |
| `PENDING` | The request has been submitted and is awaiting a decision |
| `APPROVED` | The request has been approved |
| `DECLINED` | The request has been declined |
| `WITHDRAWN` | The request was withdrawn before a decision was made |

**`ApprovalRequestTransitionEnum`** - the event applied to a pending request:

| Value | Description |
| ------- | ------------- |
| `APPROVE` | Approve the pending request (also terminates the booking) |
| `DECLINE` | Decline the pending request |
| `WITHDRAW` | Withdraw the pending request |

**`BookingTerminationReasonEnum`** - the reason a caller may attach when submitting a request. Values are:

`ABSCONDER`, `ACADEMIC`, `CAMPUS_CHANGE`, `COMPASSIONATE`, `COMPLAINT`, `EVICTION`, `FINANCIAL`, `HOUSEKEEPING_ISSUE`,
`INCIDENT`, `LOCATION`, `MAINTENANCE_ISSUE`, `MEDICAL`, `NO_SHOW`, `NOISE_ISSUE`, `OTHER`,
`PROCESSING_ERROR`, `PROPERTY_SOLD`, `PROPERTY_TRANSFER`, `SOCIAL`, `WELLBEING`.

> **Note:** The termination reason set is distinct from the cancelation reason set. The two workflows track different
> operational reasons, so they do not share an enum.

### Reading the pending request on a booking

Read the pending request from `terminationRequests(status: [PENDING])`. Because a booking can have **at most one
pending termination request** at a time, filtering the connection to `PENDING` and reading the first node gives you
the current open request, or no node when none exists.

```graphql
query BookingPendingTerminationRequest($id: ID!) {
  bookingAvailability {
    booking(id: $id) {
      id
      terminationRequests(status: [PENDING]) {
        nodes {
          __typename
          status
          requestedAt
          terminationDate
          terminationReason
        }
      }
    }
  }
}
```

```json
{
  "id": "booking-id"
}
```

`nodes` will contain a single `PendingTerminationRequest` when a pending request exists, and be empty when one does not.

### Reading the full request history on a booking

`Booking.terminationRequests(status: [...])` returns the full list of requests for the booking, newest first. It
follows the standard [pagination pattern](/concepts/api-paradigms/connections/) used across the API, so
the connection exposes `nodes`, `edges`, `pageInfo`, and `totalCount`. Pass `status:` to narrow the result to one or
more lifecycle states; omit it to get everything.

Because the connection's `nodes` are typed as the `TerminationRequest` interface, you use inline fragments to pull the
state-specific decision timestamp.

```graphql
query BookingTerminationRequests($id: ID!) {
  bookingAvailability {
    booking(id: $id) {
      id
      terminationRequests {
        totalCount
        nodes {
          __typename
          status
          requestedAt
          terminationDate
          terminationReason
          ... on ApprovedTerminationRequest {
            approvedAt
          }
          ... on DeclinedTerminationRequest {
            declinedAt
          }
          ... on WithdrawnTerminationRequest {
            withdrawnAt
          }
        }
        pageInfo {
          hasNextPage
          endCursor
        }
      }
    }
  }
}
```

```json
{
  "id": "booking-id"
}
```

To filter to a single state, supply `status`:

```graphql
query BookingApprovedTerminationRequests($id: ID!, $status: [ApprovalRequestStatusEnum!]) {
  bookingAvailability {
    booking(id: $id) {
      terminationRequests(status: $status) {
        nodes {
          __typename
          terminationDate
          ... on ApprovedTerminationRequest {
            approvedAt
          }
        }
      }
    }
  }
}
```

```json
{
  "id": "booking-id",
  "status": ["APPROVED"]
}
```

> **Note:** When the workspace setting for termination requests is off, `terminationRequests` returns as if no
> requests exist, regardless of any rows that may have been written before the setting was toggled.

### Submitting a termination request

Use the `submitTerminationRequest` mutation under the `bookings` namespace. The request is keyed by `bookingId`; at
most one pending request can exist per booking, so you do not (and cannot) supply a request id. Supply the requested
final date in `terminationDate`.

```graphql
mutation SubmitTerminationRequest($input: SubmitTerminationRequestInput!) {
  bookingAvailability {
    bookings {
      submitTerminationRequest(submitTerminationRequest: $input) {
        booking {
          id
          status
          terminationRequests(status: [PENDING]) {
            nodes {
              __typename
              status
              requestedAt
              terminationDate
              terminationReason
            }
          }
        }
      }
    }
  }
}
```

```json
{
  "input": {
    "bookingId": "booking-id",
    "terminationDate": "2026-09-30",
    "terminationReason": "ABSCONDER"
  }
}
```

**Input fields:**

| Field | Type | Description |
| ------- | ------ | ------------- |
| `bookingId` | `ID!` | The booking to open a termination request against |
| `terminationDate` | `ISO8601Date!` | The requested final date for the booking. Must fall within the contract window |
| `terminationReason` | `BookingTerminationReasonEnum` | Optional reason, surfaced on the request |

**Error codes:**

| Code | When |
| ------ | ------ |
| `validation_error` | The input failed structural validation (for example, a missing or malformed `terminationDate`) |
| `termination_approvals_disabled` | The workspace setting is off |
| `booking_not_found` | No booking matches `bookingId` |
| `unauthorized` | The caller lacks `request_termination` on the booking |
| `booking_not_eligible_for_termination_approval` | The booking fails one of the eligibility checks (see [Eligibility](#eligibility)) |
| `termination_date_out_of_bounds` | The requested `terminationDate` is outside the booking contract window |
| `booking_has_pending_cancelation_request` | The booking already has a pending cancelation request |
| `pending_cancelation_request_check_failed` | The check for a competing pending cancelation request could not be completed |
| `termination_request_already_pending` | A pending termination request for this booking already exists |

### Transitioning a pending request

Use the `transitionTerminationRequest` mutation to approve, decline, or withdraw the pending request on a booking. The
request is again keyed by `bookingId`, since at most one pending request can exist per booking. On `APPROVE` only, you
may supply an amended `terminationDate`; supplying it with `DECLINE` or `WITHDRAW` is rejected because those
transitions apply no date.

```graphql
mutation TransitionTerminationRequest($input: TransitionTerminationRequestInput!) {
  bookingAvailability {
    bookings {
      transitionTerminationRequest(transitionTerminationRequest: $input) {
        booking {
          id
          status
          terminationRequests(status: [APPROVED, DECLINED, WITHDRAWN]) {
            nodes {
              __typename
              terminationDate
              ... on ApprovedTerminationRequest { approvedAt }
              ... on DeclinedTerminationRequest { declinedAt }
              ... on WithdrawnTerminationRequest { withdrawnAt }
            }
          }
        }
      }
    }
  }
}
```

Approve, accepting the date captured at submit time:

```json
{
  "input": {
    "bookingId": "booking-id",
    "transition": "APPROVE"
  }
}
```

Approve with an amended final date:

```json
{
  "input": {
    "bookingId": "booking-id",
    "transition": "APPROVE",
    "terminationDate": "2026-10-15"
  }
}
```

Decline:

```json
{
  "input": {
    "bookingId": "booking-id",
    "transition": "DECLINE"
  }
}
```

Withdraw:

```json
{
  "input": {
    "bookingId": "booking-id",
    "transition": "WITHDRAW"
  }
}
```

**Input fields:**

| Field | Type | Description |
| ------- | ------ | ------------- |
| `bookingId` | `ID!` | The booking whose pending request is being transitioned |
| `transition` | `ApprovalRequestTransitionEnum!` | One of `APPROVE`, `DECLINE`, `WITHDRAW` |
| `terminationDate` | `ISO8601Date` | Optional amended final date, only valid when approving. Defaults to the date captured at submit time |

**Error codes:**

| Code | When |
| ------ | ------ |
| `termination_approvals_disabled` | The workspace setting is off |
| `booking_not_found` | No booking matches `bookingId` |
| `unauthorized` | The caller lacks the per-transition permission (`approve_termination`, `decline_termination`, or `withdraw_termination`) on the booking |
| `termination_request_not_pending` | No pending request exists for this booking (it may have been transitioned already, or it may never have been opened) |
| `termination_date_missing` | A final termination date is required to approve but none is available |
| `termination_date_invalid` | The supplied amended date could not be parsed |
| `termination_date_out_of_bounds` | The supplied amended date is outside the booking contract window |

> **Note:** On `APPROVE`, if the booking is already `terminated`, the request still transitions to `Approved` but the
> booking is not terminated again. No duplicate booking-terminated webhook event is sent.

## Webhooks

Each lifecycle change emits a webhook event. Approving emits the `approved` event for the request in the approval
transaction; the `v1.booking.terminated` event is emitted separately when the scheduled termination is applied, which
may be later than the approval (see [Side effects of approval](#side-effects-of-approval)). Declining and withdrawing
emit only their own event; the booking stays confirmed.

| Event type | When it fires |
| ------------ | --------------- |
| `v1.booking.termination_request.requested` | A new pending request is opened via `submitTerminationRequest` |
| `v1.booking.termination_request.approved` | A pending request is approved via `transitionTerminationRequest` (`v1.booking.terminated` follows when the scheduled termination is applied, unless the booking was already terminated) |
| `v1.booking.termination_request.declined` | A pending request is declined via `transitionTerminationRequest` |
| `v1.booking.termination_request.withdrawn` | A pending request is withdrawn via `transitionTerminationRequest` |
| `v1.booking.terminated` | A booking's scheduled termination is applied and its status flips to `terminated` (immediately for a past/today final date, later for a future one) |

The four `termination_request.*` events share the same payload shape:

```json
{
  "type": "v1.booking.termination_request.approved",
  "id": "evt_2x...",
  "timestamp": "2026-05-22T13:00:00Z",
  "booking": { "id": "booking-id" },
  "workspace": { "id": "workspace-id" }
}
```

Payload fields:

| Field | Type | Description |
| ------- | ------ | ------------- |
| `type` | `string` | The event type (one of the four `termination_request.*` values above) |
| `id` | `string` | Stable, unique identifier for this event delivery |
| `timestamp` | `string` (ISO 8601) | When the underlying lifecycle change happened |
| `booking.id` | `string` | The booking the request targets |
| `workspace.id` | `string` | The workspace that owns the booking |

The `v1.booking.terminated` event carries the time the booking was terminated in addition to the booking and
workspace identifiers:

```json
{
  "type": "v1.booking.terminated",
  "id": "evt_2x...",
  "timestamp": "2026-05-22T13:00:00Z",
  "booking": { "id": "booking-id", "terminatedAt": "2026-05-22T13:00:00Z" },
  "workspace": { "id": "workspace-id" }
}
```

To resolve any further detail about the request or the booking after receiving an event, re-query the booking via the
GraphQL API using the `booking.id` from the payload. The read field documented above is the canonical source of truth.

See [Webhooks](/concepts/webhooks/) for the general webhook delivery model and signature
verification.

## Relationships

Termination requests sit alongside bookings in the inventory hierarchy:

- A **booking** can have many termination requests over time, but at most one in the `Pending` state
- A termination request always belongs to exactly one **workspace** (the booking's workspace) and exactly one
  **booking**

See [Structure & Terms](/concepts/structure-and-terms/) for more on the booking model.
