Cruits Housing — Iteration 2: Story Specifications¶
Iteration 2 goal: A Flexworker can open the Housing section in the Cruits app with a fully defined booking context, browse listings sorted by proximity to their job site, apply filters, and create a reservation for an available unit. Reserved units are held automatically, and the hold is released if the booking is not confirmed within the configured window.
Writing standard: This document is written for Manual QA and technical business analysts. Requirements and Acceptance Criteria describe observable system behavior — not implementation details. See iteration-1-story-specs-v1-2026-06-02.md for background.
Stories in This Document¶
| Story | Title |
|---|---|
| 1.7 | Booking List & Provider Blocks |
| 1.9a | Payer Agreement Management |
| 1.12 | Housing Quality Score (PKS) on Listing |
| 2.1 | Context Handshake & Booking Context Enforcement |
| 2.2b | Proximity-Based Discovery |
| 2.3 | Listing Search & Filters |
| 3.1 | Reservation Hold Window Enforcement |
| 3.2 | Reservation Creation |
| 3.5 (basic) | Booking State Transition Notifications — Basic |
| 3.6 | Booking Status View |
Story 1.7: Booking List & Provider Blocks¶
Epic: Epic 1 — Project Bootstrap & Housing Inventory Management
User Story¶
As a Provider, I want to view all bookings for each of my listings — both resident reservations and my own capacity blocks — with their current statuses, and to create and cancel capacity blocks for specific date ranges via the Booking List section in Provider Admin, So that I have full visibility over listing occupancy, can restrict capacity for unavailable periods, and workers can only attempt to reserve units that are genuinely available.
Functional Requirements Covered¶
| FR | Requirement |
|---|---|
| FR-7 | Provider configures availability calendar per ListingUnit or per pool, marking unavailable dates or periods |
| FR-29 | Provider Admin UI — Booking List section |
Concept: Provider Blocks as Bookings¶
Every listing is open for booking by default. When a Provider needs to make capacity unavailable — for renovation, maintenance, an offline booking taken outside the system, or any other reason — they create a provider block.
A provider block is a booking with booking_type = provider_block. It behaves identically to a resident booking in terms of capacity: it immediately occupies one slot for the specified period and is counted in available_count. No reservation flow, hold window, or worker notifications are involved.
This unified model has two key benefits:
1. Partial pool blocking is naturally supported. Blocking 3 out of 10 pool slots means creating 3 provider_block bookings. available_count drops from 10 to 7; the listing remains visible in discovery.
2. Single availability formula. available_count = total_slots − bookings in non-terminal states (all types). No separate tracking mechanism needed.
Provider blocks are created and managed via the Booking List section in Provider Admin.
Entity Model¶
Booking (introduced in this story; extended with resident-specific fields in Story 3.2)
| Field | Label | Type | Required | Notes |
|---|---|---|---|---|
booking_type |
Booking type | Enum | Required | resident — worker reservation; provider_block — Provider capacity block. Fields marked "resident only" are null for provider_block bookings. |
listing_unit |
Listing | Reference to ListingUnit | Required | |
slot |
Unit slot | Reference to ListingUnitSlot (nullable) | Conditional | Required for concrete-mode bookings of either type; null for pool-mode. |
period_start |
Start date | Date | Required | First day of the reserved or blocked period. |
period_end |
End date | Date | Required | Last day; must be after period_start. |
status |
Status | Enum | System | RESERVED on creation for resident type; PROVIDER_BLOCKED on creation for provider_block type. |
reason |
Note | Text | Optional | Provider-block only: internal note visible only to the Provider. Not applicable for resident bookings. |
created_at |
Created | Date and time | System | |
resident_id |
Resident | Reference to Resident | Resident only | The Flexworker making the reservation. Null for provider_block. |
payer_id |
Payer | Reference to Payer or Resident | Resident only | Same as resident_id for direct payment; third-party Payer for indirect. Null for provider_block. |
price_at_booking |
Price | Decimal number | Resident only | Snapshot of listing price at reservation creation time; immutable after creation. Null for provider_block. |
price_period_unit_at_booking |
Period unit | Enum | Resident only | Snapshot of listing period unit (day, week, or month) at creation; immutable. Null for provider_block. |
payment_form |
Payment form | Enum | Resident only | See payment form values in Story 3.2. Null for provider_block. |
wgv_notice_shown |
WGV notice shown | Yes/No | Resident only | Yes when indirect-payment confirmation notice was shown before submission. Null for provider_block. |
wgv_notice_acknowledged_at |
WGV notice acknowledged | Date and time | Resident only | Timestamp of worker acknowledgement. Null for provider_block. |
session_id |
Session | Session identifier | Resident only | Session under which the reservation was created. Null for provider_block. |
hold_expires_at |
Hold expiry | Date and time | Resident only | Calculated and stored immutably at reservation creation (Story 3.1); never recalculated. Null for provider_block. |
BookingStateTransition (one record per state transition, for all booking types)
| Field | Type | Notes |
|---|---|---|
booking |
Reference to Booking | |
from_status |
Enum (nullable) | Previous status; null for the initial entry |
to_status |
Enum | New status |
transitioned_at |
Date and time | Set automatically at the time of transition |
actor |
Text | Who or what triggered the transition: worker, provider, system, platform_admin |
notes |
Text (nullable) | Optional context, e.g., "hold window expired", "maintenance block" |
Acceptance Criteria¶
- Booking List section in Provider Admin
- Provider Admin includes an Booking List section accessible from the listing's edit view or from the main navigation.
- The section displays a list of all Booking records for the selected listing — both
residentandprovider_block— sorted by period start date descending. Each row shows: booking type, period, status, slot (concrete mode) or "Pool" (pool mode), internal note (provider blocks only), and creation date. - Provider block rows are visually distinguished from resident booking rows via a booking type badge.
-
The list is listing-specific: the Provider selects which listing to manage via a listing dropdown.
-
Creating a provider block — pool-mode listings
- The Provider selects a date range and specifies the number of slots to block (1 up to
total_slots). - The system creates that number of
provider_blockbookings for the listing and period (one per blocked slot). - After creation,
available_countfor the affected period is reduced by the number of blocked slots. Ifavailable_countreaches zero, the listing is treated as fully unavailable for that period. -
The Provider may optionally add an internal note (reason) to the block; this note is not visible to workers.
-
Creating a provider block — concrete-mode listings
- The Provider selects a date range and either:
- Marks a specific slot (selected from the listing's slots by floor / room / bed): a single
provider_blockbooking is created for that slot and period. - Marks all slots: a
provider_blockbooking is created for each slot of the listing.
- Marks a specific slot (selected from the listing's slots by floor / room / bed): a single
-
Individual slot blocking allows a Provider to take one bed out of service while the others remain bookable.
-
Conflict with existing resident bookings when creating a block
- If the block's date range overlaps with resident bookings in
PENDING_PAYER,SIGNING, orBOOKEDstate for the affected slot (concrete mode) or would reduceavailable_countbelow the number of active resident bookings for any day (pool mode), the block cannot be created. An error is shown identifying the number of conflicting bookings. The Provider must contact the affected residents and process cancellations through the standard cancellation flow before the block can be created. - If the block's date range overlaps only with resident bookings in
RESERVEDorPENDING_PROVIDERstate, the block is created. The Provider sees a warning listing the affected pending reservations and is reminded that those reservations must be handled separately (rejected or cancelled) since capacity is no longer available. -
(IT-2 note: resident bookings only reach
RESERVEDstate in this iteration, so the warning path is the only reachable scenario. The error path applies from Iteration 3 onward.) -
Removing a provider block
- The Provider can cancel any provider block from the Booking List section at any time. Cancelling a provider block immediately restores the capacity for the affected dates — no conflict check is required when removing a block.
-
The cancellation is recorded in the booking's state transition history.
-
Discovery filtering
- When stay dates are known — either pre-populated from
permitted_period_start/endin the session context (indirect payment) or entered manually via the Stay dates filter (Story 2.3) — the discovery list excludes listings that have no available capacity for the full requested period:- Concrete-mode: excluded if there is no single slot that is free for every day of the requested period — i.e., there is no slot the worker could book for the full stay.
- Pool-mode: excluded if
available_count = 0for any day within the requested period (i.e., all slots are occupied by resident bookings and/or provider blocks on at least one day).
-
When no stay dates are specified (filter is empty and no period in session context), availability filtering is not applied; all listings remain in discovery.
-
Reservation gate (enforced in Story 3.2)
- At reservation creation time, the system verifies that sufficient capacity remains for the requested period — no active booking of any type (
residentorprovider_block) occupies the selected slot (concrete mode) or all pool slots (pool mode) for any day of the period. - If no capacity is available, the reservation attempt is rejected with a clear explanation, and the worker is prompted to choose different dates.
Dependencies¶
- Story 1.6 (ListingUnit and ListingUnitSlot entities) — provider blocks reference these.
- Story 3.2 (Booking entity) — provider blocks use the Booking entity with
booking_type = provider_block. - Story 1.2 (Provider Admin frontend) — Booking List section UI is rendered in Provider Admin.
Out of Scope¶
- Calendar grid view — visual date-based display of occupancy per listing (post-MVP).
- Worker-facing availability display on the listing detail screen (post-MVP).
- Automatic availability sync from GoMeddo (Epic 8 — Iteration 5).
Story 1.12: Housing Quality Score (PKS) on Listing¶
Epic: Epic 1 — Project Bootstrap & Housing Inventory Management
User Story¶
As a Provider, I want to set an optional Housing Quality Score (PKS) on each of my listings via Provider Admin, So that the Cruits integration can use it to determine the maximum accommodation cost covered for workers under indirect payment, and workers can see the quality score of a listing before deciding to reserve.
Functional Requirements Covered¶
| FR | Requirement |
|---|---|
| FR-90 | Provider optionally sets PKS (non-negative decimal) on a ListingUnit; PKS is shown on the listing card and detail; used in indirect payment cost enforcement when session includes a PKS cost table |
Entity Model¶
The ListingUnit entity (Story 1.6) is extended with one optional field:
| Field | Label | Type | Required | Notes |
|---|---|---|---|---|
pks |
Housing Quality Score | Decimal ≥ 0 | Optional | Provider-assigned quality score. No upper bound. May be updated or cleared at any time. Updating PKS does not affect active bookings. Not included in the price change log (FR-84). |
Acceptance Criteria¶
- PKS field in Provider Admin
- The listing edit form in Provider Admin includes a PKS field, labelled "Housing Quality Score (PKS)".
- The field accepts any non-negative decimal number. No upper bound is enforced.
- The field is optional. A listing with no PKS value is valid and remains fully discoverable.
-
The Provider can update or clear the PKS value at any time. Clearing PKS does not affect any active or pending bookings on that listing.
-
PKS on listing card
- When a listing has a PKS value set, the listing card displays it as "PKS: [value]".
-
When no PKS value is set, the PKS field is absent from the listing card — no placeholder or dash is shown. [Design update required]
-
PKS on listing detail
- When a listing has a PKS value set, the listing detail view displays it as "Housing Quality Score (PKS): [value]".
-
When no PKS value is set, the field is absent from the listing detail.
-
Indirect payment cost enforcement using PKS (Nice to have)
- When the session context includes a
pks_cost_table(sent by Cruits; see Story 2.1):- Listing with a PKS value: the Housing Module finds the range in
pks_cost_tablewherepks_from ≤ listing PKS < pks_to(for the open-ended last range:listing PKS ≥ pks_from). The matching range'smax_daily_costamount is the effective threshold for indirect payment eligibility checks on this listing. - Listing without a PKS value: the session's fallback
max_daily_costis used as the effective threshold.
- Listing with a PKS value: the Housing Module finds the range in
- When no
pks_cost_tableis present in the session context: the session'smax_daily_costis always used, regardless of whether the listing has a PKS value. - This enforcement applies in both the payment option display (Story 2.1 AC 4) and the final reservation submission check (Story 3.2 AC 2).
Dependencies¶
- Story 1.6 (ListingUnit entity) — PKS is added as an optional field on ListingUnit.
- Story 2.1 (Context Handshake) —
pks_cost_tablein the session context is the source for PKS-based cost thresholds; AC 4 of this story is the Housing-side enforcement of that table.
Out of Scope¶
- PKS standardization or external validation — PKS is a freely entered number; the Housing Module applies no external standard.
- PKS as a sort or filter dimension in listing discovery (post-MVP).
- PKS change history log (post-MVP).
Story 1.9a: Payer Agreement Management¶
Epic: Epic 1 — Project Bootstrap & Housing Inventory Management
User Story¶
As a Provider, I want to configure which Payers have indirect payment access to my listings via Provider Admin, So that only Payers I have a formal agreement with can book my listings under indirect payment, and workers from other Payers can only book via direct payment.
Functional Requirements Covered¶
| FR | Requirement |
|---|---|
| FR-8 (partial) | Provider configures payer-provider agreements; indirect payment access per Payer. Full payment terms (prepay/postpay/custom) are delivered in Story 1.9, Iteration 4. |
Entity Model¶
PayerProviderAgreement
| Field | Label | Type | Required | Notes |
|---|---|---|---|---|
provider |
Provider | Reference to Provider | Required | |
payer |
Payer | Reference to Payer | Required | Combined with provider, forms a unique pair — only one agreement per Provider–Payer combination is allowed. |
indirect_payment |
Indirect payment | Enum | Required | enabled — workers with an indirect-payment session from this Payer may book this Provider's listings under indirect payment. direct_payment_only — workers from this Payer may only book via direct payment; indirect payment is unavailable for this Provider's listings. |
Provider (one field added in this story)
| Field | Label | Type | Required | Notes |
|---|---|---|---|---|
contract_payers_only |
Only payers with a special contract are allowed | Yes/No | Required | Default: No. When Yes, the Provider signals they work exclusively with third-party Payers under a formal agreement — direct worker payment is not accepted for any of their listings. The Default Payment Terms fields (payment schedule, billing period, invoice generation — configurable in Story 1.9, Iteration 4) are inactive when this setting is on, as those terms apply only to worker_direct bookings. |
Acceptance Criteria¶
- Payment Terms section in Provider Admin
- Provider Admin includes a Payment Terms section accessible from the main navigation.
- The section is divided into two parts: a Default Payment Terms block at the top and a Payer Agreements table below.
- The Default Payment Terms block includes one active control in this iteration: an Only payers with a special contract are allowed checkbox (default: unchecked). When checked, the Provider signals they work exclusively with third-party Payers who have a formal agreement in place — direct worker payment is not accepted for any of their listings. When this checkbox is on, the payment schedule, billing period, and invoice generation fields in the same block are shown as inactive — these fields are configurable in Story 1.9 (Iteration 4) and apply only to direct-payment bookings.
-
The Payer Agreements table displays all agreements configured by this Provider: Payer name, indirect payment status (Enabled / Direct payment only), payment terms (placeholder — configurable in Story 1.9), and actions (Edit, Remove).
-
Adding an agreement
- The Provider clicks "+ Add agreement" and selects a Payer from a dropdown of all active Payer accounts in the system (created by Platform Admin).
- The Provider sets indirect payment:
EnabledorDirect payment only. - Only one agreement per Provider–Payer pair is allowed. Attempting to add a Payer that already has an agreement is blocked with an error.
-
Saving creates a
PayerProviderAgreementrecord. The change takes effect immediately for new discovery sessions and new reservations. -
Editing an agreement
- The Provider can change the
indirect_paymentvalue of an existing agreement at any time. -
Changes take effect immediately for new discovery sessions and new reservations. Active bookings already in progress are not affected.
-
Removing an agreement
- The Provider can remove an agreement at any time.
-
After removal, the Payer has no agreement with this Provider — equivalent to
direct_payment_onlyfor all future discovery and reservations. Active bookings already in progress are not affected. -
Discovery enforcement
- When a worker's session has
indirect_payment_enabled = Yesfor a given Payer, listings are shown in discovery according to the following rules:- Agreement exists with
indirect_payment = enabled: listing is shown; both direct and indirect payment options are available (subject to the usual session-level constraints such asmax_daily_cost). - Agreement exists with
indirect_payment = direct_payment_only, or no agreement exists: the listing is shown only ifdirect_payment_enabled = Yesin the session context — direct payment is the only option available. Ifdirect_payment_enabled = No, the listing is excluded from discovery entirely (the worker has no usable payment option for it).
- Agreement exists with
-
When a worker's session has
indirect_payment_enabled = No, agreement status has no effect on discovery — all listings from all Providers are shown (subject to other active filters). -
Access control
- Only the Provider who owns the listing can manage their own Payer agreements via Provider Admin.
- Platform Admin can view and manage all agreements via Django Admin.
Dependencies¶
- Story 1.3 (Provider and Payer identity accounts) — Payer dropdown references active Payer records.
- Story 1.2 (Provider Admin frontend) — Payment Terms section added to the Provider Admin navigation.
- Story 2.1 (Context Handshake) — Payer identity from the session context is used to look up the agreement at discovery and reservation time.
- Story 2.2b (Proximity-Based Discovery) — discovery query applies the agreement filter.
Out of Scope¶
- Payment terms configuration (prepay/postpay/custom schedule) — Story 1.9, Iteration 4.
- Default payment terms configuration — Story 1.9, Iteration 4.
- Payer-initiated agreement management — Payers cannot manage agreements; Provider Admin only.
Story 2.1: Context Handshake & Booking Context Enforcement¶
Epic: Epic 2 — Worker Housing Discovery & Context Handshake
User Story¶
As a Flexworker, I want the Cruits app to pass my worker identity, job location, and booking context to the Housing Module when I open the Housing section, So that listings are proximity-sorted to my job site, available payment options are known, and the system enforces my booking constraints server-side from the start of my session.
Functional Requirements Covered¶
| FR | Requirement |
|---|---|
| FR-44 | Cruits passes worker_id, job_location, and booking_context on each session launch |
| FR-45 | booking_context specifies payment options: browse-only, direct, indirect (with permitted period, max daily cost, billing period unit) |
| FR-46 | Housing Module enforces all booking context constraints as server-side invariants |
Concept: Worker Session¶
Each time a Flexworker opens the Housing section, the Cruits app initiates a worker session by sending a context payload to the Housing Module. The Housing Module:
- Creates or updates the Resident identity record (create-or-update behavior as defined in Story 1.3 — FR-69).
- Stores the session context: job location, reservation permissions, payment options, permitted period, and maximum cost constraints.
- Returns a session token. The Cruits app attaches this token to all subsequent Housing Module requests in this session.
All booking-related requests made during the session are validated against the stored context at the Housing Module level. The Cruits app cannot grant itself permissions that are not in the stored context.
Booking Context Payload¶
Fields sent by the Cruits app on session initiation:
| Field | Type | Required | Notes |
|---|---|---|---|
worker_id |
Text | Required | Cruits worker identifier |
source_client |
Text | Required | Always cruits for the Cruits integration |
full_name |
Text | Required | Worker's full name; used for Resident identity upsert (Story 1.3) |
email |
Email address | Optional | Worker's email; used for identity upsert |
phone |
Phone number | Optional | Worker's phone in E.164 format; used for identity upsert |
job_location_lat |
Decimal number | Conditional | Latitude of the job site. Omitted when entry point has no job context. |
job_location_lng |
Decimal number | Conditional | Longitude of the job site. Omitted when entry point has no job context. |
job_location_display_name |
Text | Conditional | Display name for the pre-selected item in the job location dropdown. Format: "Employer name, Location display name" (e.g., "Intertoys, Amsterdam"). Omitted when entry point has no job context. |
reservation_permitted |
Yes/No | Required | No = browse-only session; reservation attempts are blocked |
direct_payment_enabled |
Yes/No | Required | Whether the worker may pay directly for housing |
indirect_payment_enabled |
Yes/No | Required | Whether a third-party payer is available for this session |
permitted_period_start |
Date | Conditional | Required when indirect_payment_enabled = Yes; first date the payer covers |
permitted_period_end |
Date | Conditional | Required when indirect_payment_enabled = Yes; last date the payer covers |
max_daily_cost |
Decimal number | Conditional | Required when indirect_payment_enabled = Yes; maximum daily accommodation cost covered by the payer |
billing_period_unit |
Enum | Conditional | Required when indirect_payment_enabled = Yes; day, week, or month |
payer_id |
Text | Conditional | Required when indirect_payment_enabled = Yes; the Housing Module identifier for the Payer covering this session |
payer_name |
Text | Conditional | Required when indirect_payment_enabled = Yes; displayed in the WGV confirmation notice (Story 3.2) |
pks_cost_table |
Array | Conditional | Optional — present when indirect_payment_enabled = Yes AND a PKS cost table is configured in Cruits global settings. Each entry: {pks_from, pks_to, max_daily_cost} where amounts are pre-calculated in Euro by Cruits. pks_to is absent for the open-ended last range. Ranges are non-overlapping, contiguous, starting at 0. When present, the Housing Module uses a listing's PKS value (Story 1.12) to look up the effective max_daily_cost threshold for that listing. |
Both direct_payment_enabled and indirect_payment_enabled may be Yes at the same time — the worker then chooses their payment option at reservation. Any combination is valid: both enabled, one enabled, or neither (browse-only).
Note on SNF / WGV responsibility: The Cruits integration is solely responsible for ensuring that
permitted_period_startandpermitted_period_endcomply with SNF norm 5.2, applicable ABU/NBBU CAO provisions, and WGV Art. 2 lid 3. The Housing Module enforces the values as received without independent compliance checks on the dates.
Payer Entity Model¶
The payer_id in the session payload references a Payer record stored in the Housing Module. The Payer entity represents the organisation responsible for covering the worker's accommodation costs under indirect payment. Payer records are created by Platform Admin.
Payer
| Field | Label | Type | Required | Notes |
|---|---|---|---|---|
name |
Name | Text | Required | Display name of the Payer organisation. Shown in the WGV confirmation notice (Story 3.2) and in Payer Admin. |
contact_email |
Contact email | Required | Organisation-level contact; used by Platform Admin. Individual user emails are on Payer User accounts (see note below). | |
account_status |
Status | Enum | Required | Active or Inactive. An Inactive Payer is rejected at session validation (AC 1). Existing bookings referencing an Inactive Payer are not affected. |
cancellation_window_days |
Cancellation notice | Number | Optional | Minimum number of days' notice required for a Payer-initiated cancellation of a BOOKED booking (FR-76). If blank, no minimum is enforced. |
auto_approve |
Auto-approve reservations | Yes/No | Required | When Yes, bookings that reach PENDING_PAYER for this Payer are automatically approved by the Housing Module without manual action. Defaults to No. When No, the Payer must confirm or reject manually via Payer Admin or API (FR-15). |
Note — Payer User accounts: Individual login accounts for Payer Admin users are not modelled in this iteration. The Payer User entity is required before Story 3.4 (Payer Confirmation) and payer-targeted notifications in Story 3.5 can be implemented. Defining Payer Users is in scope for Story 6.1 (Payer Admin). Until then, Payer-directed notifications reference the Payer's
contact_emailas a placeholder.
Payment Access Matrix¶
All meaningful combinations of Provider configuration and session context flags, mapped to listing visibility and reservation availability. Use this as the single reference for discovery and reservation enforcement logic across Stories 2.2b, 2.3, 3.2, and 1.9a. The authoritative source is the Payment Access Matrix spreadsheet, column "Reservation & Filter on listing". The table below mirrors that column and is kept in sync with it.
Column notes:
- contract_payers_only — Provider-level flag (Story 1.9a). When
Yes, the Provider does not accept direct worker payment; only contracted 3rd-party Payers may book. - Contract with Payer — whether a
PayerProviderAgreementexists for the session's Payer withindirect_payment = enabled.Nocovers both "no agreement" and "agreement withdirect_payment_only".N/A= not evaluated becauseindirect_payment_enabled = Noin session (Story 2.2b AC 5: agreement filter is not applied when indirect is not in session). - Resident Has Payer — whether the Resident has a 3rd party payer link in the system, independent of session flags. In Cruits MVP this is always
Yes(all workers come through Cruits with a payer). Relevant post-MVP for a standalone housing app where workers may register without any payer. - reservation_permitted = No (browse-only) always produces "Not available" for reservation; the listing remains visible in discovery.
- Reservation column shows
—when the listing is not shown in discovery (reservation question does not arise).
| # | Use Case Title | contract_payers_only | Contract with Payer | Resident Has Payer | reservation_permitted | direct_payment_enabled | indirect_payment_enabled | Show in Discovery | Reservation & Payment | Comment |
|---|---|---|---|---|---|---|---|---|---|---|
| UC-01 | Standard direct pay | No | N/A | No | Yes | Yes | No | Yes | Direct Only | Post-MVP only (standalone app). In Cruits MVP all residents have a payer link. |
| UC-02 | Standard direct pay — browse-only | No | N/A | No | No | Yes | No | Yes | Not available | Browse-only version of UC-01. |
| UC-03 | No payment option — misconfigured session | No | N/A | No | Yes | No | No | Yes | Not available | Both payment flags off — invalid session. Must not occur in valid Cruits sessions. Listing shown but no reservation possible. |
| UC-04 | Contract-only provider, no payer in session | Yes | N/A | No | Yes | Yes | No | No | — | Provider is contract-only; no payer in session → no usable payment option → listing hidden from discovery. Not applicable in Cruits MVP. |
| UC-05 | Contract-only, browse-only, no payer | Yes | N/A | No | No | Yes | No | No | — | Same as UC-04 — hidden regardless of browse-only flag. |
| UC-06 | Resident has payer link, but direct-pay session | No | N/A | Yes | Yes | Yes | No | Yes | Direct Only | Payer link exists in system but this session does not enable indirect payment. Agreement filter not applied. Uncommon in Cruits MVP. |
| UC-07 | Resident has payer link, direct-pay session, browse-only | No | N/A | Yes | No | Yes | No | Yes | Not available | Browse-only version of UC-06. |
| UC-08 | Contract-only, payer link exists, indirect not in session | Yes | N/A | Yes | Yes | Yes | No | No | — | Contract-only + indirect not enabled in session → no usable payment → listing hidden. |
| UC-09 | No contract with payer, both payment options in session | No | No | Yes | Yes | Yes | Yes | Yes | Direct Only | No indirect contract → indirect blocked for this provider. Listing shown because direct_payment_enabled = Yes (Story 2.2b AC 5). Worker may only pay directly. |
| UC-10 | No contract with payer, both payment, browse-only | No | No | Yes | No | Yes | Yes | Yes | Not available | Browse-only version of UC-09. |
| UC-11 | No contract with payer, indirect-only session | No | No | Yes | Yes | No | Yes | No | — | No contract + no direct → listing excluded from discovery (Story 2.2b AC 5). |
| UC-12 | Contract-only, no contract with payer, both payment in session | Yes | No | Yes | Yes | Yes | Yes | No | — | Provider rejects direct (contract-only) and has no indirect contract → no usable option → listing hidden. |
| UC-13 | Contract-only, no contract with payer, indirect-only session | Yes | No | Yes | Yes | No | Yes | No | — | Hidden per Story 2.2b AC 5 (no direct + no contract). Also consistent with contract-only rule. |
| UC-14 | Contract with payer exists, both payment options in session | No | Yes | Yes | Yes | Yes | Yes | Yes | Both | Primary Cruits MVP scenario. Worker chooses: pay directly or have payer cover. |
| UC-15 | Contract exists, both payment, browse-only | No | Yes | Yes | No | Yes | Yes | Yes | Not available | Browse-only version of UC-14. |
| UC-16 | Contract exists, indirect-only session | No | Yes | Yes | Yes | No | Yes | Yes | Indirect Only | Direct not enabled in session. Contract with payer → indirect payment available. |
| UC-17 | Contract exists, indirect-only session, browse-only | No | Yes | Yes | No | No | Yes | Yes | Not available | Browse-only version of UC-16. |
| UC-18 | Contract-only provider, contract with payer, both payment in session | Yes | Yes | Yes | Yes | Yes | Yes | Yes | Indirect Only | Provider contract-only overrides direct payment from session. Direct rejected by provider; indirect available via contract. Most common MVP case for contract-only providers. |
| UC-19 | Contract-only, contract with payer, browse-only | Yes | Yes | Yes | No | Yes | Yes | Yes | Not available | Browse-only version of UC-18. |
| UC-20 | Contract-only, contract with payer, indirect-only session | Yes | Yes | Yes | Yes | No | Yes | Yes | Indirect Only | Both provider and session are indirect-only; contract exists → clean indirect-only result. |
| UC-21 | Contract-only, contract with payer, indirect-only, browse-only | Yes | Yes | Yes | No | No | Yes | Yes | Not available | Browse-only version of UC-20. |
Acceptance Criteria¶
- Session initiation
- When the Cruits app opens the Housing section, it sends the context payload to the Housing Module.
- The Housing Module validates the payload: all required fields must be present and structurally valid. Conditional fields must be present when their condition applies. If validation fails, the session is rejected and the Cruits app receives a descriptive error.
- When
indirect_payment_enabled = Yes, the Housing Module additionally verifies thatpayer_idreferences an existing Payer record withaccount_status = Active. If the Payer is unknown or Inactive, the session is rejected with a descriptive error. (This check is possible because the Payer entity is defined in this story.) - When
indirect_payment_enabled = Yes, the Housing Module also validates the permitted period:permitted_period_startmust be on or beforepermitted_period_end, andpermitted_period_endmust be on or after today's date. If either condition fails, the session is rejected with a descriptive error that the Cruits app can surface to the worker. - On success, the Housing Module:
- Applies the Resident create-or-update (Story 1.3 behavior) for the passed
worker_id. - Stores the session context.
- Returns a session token.
- Applies the Resident create-or-update (Story 1.3 behavior) for the passed
-
The Cruits app uses this session token for all subsequent Housing Module requests in this session.
-
Session context stored and enforced
- The session context is stored in the Housing Module. It is the authoritative source for all constraint enforcement in this session.
-
The worker cannot obtain permissions beyond those in the stored context by crafting requests manually — all enforcement is server-side.
-
Browse-only enforcement
- When
reservation_permitted = No, any reservation creation attempt in this session is rejected by the Housing Module, regardless of the UI state in the Cruits app. -
The Cruits app should also hide or disable the Reserve button for browse-only sessions (defensive UX), but server-side enforcement is the definitive gate.
-
Payment option enforcement
- A reservation using a payment form not enabled in the session context (e.g., indirect payment when
indirect_payment_enabled = No) is rejected by the Housing Module. - Indirect payment cost check: the effective maximum daily cost threshold for a listing is determined as follows: (a) if
pks_cost_tableis present in the session context AND the listing has a PKS value (Story 1.12), find the range wherepks_from ≤ listing PKS < pks_to(or≥ pks_fromfor the open-ended last range) and use that range'smax_daily_costas the threshold; (b) otherwise, use the session'smax_daily_cost. If the listing's normalized daily price exceeds the effective threshold, the indirect payment option is rejected. The same price normalization used for weekly display (Story 1.8) is applied to derive the daily equivalent. - Indirect payment period check: a reservation with dates outside
permitted_period_start – permitted_period_endis rejected. -
Payer agreement check:
indirect_payment_enabled = Yesin the session context is a necessary but not sufficient condition for indirect payment on a specific listing. The Provider–Payer agreement (Story 1.9a) is an additional gate checked at discovery and reservation time. If the Provider has setdirect_payment_onlyfor this session's Payer — or no agreement exists — indirect payment is unavailable for that Provider's listings regardless of the session context. This is not a session-level rejection; the session remains valid and the worker may still book those listings via direct payment ifdirect_payment_enabled = Yes. -
Cruits app behavior on session initiation
- The Cruits app always sends a fresh context payload on each Housing section open. A cached context from a previous session must not be reused.
- If the session initiation call fails (network error, validation error), the Cruits app shows an error message and does not open the listing list.
Dependencies¶
- Story 1.3 (Resident create-or-update behavior) — triggered by session initiation.
- Story 1.4 (access control) — integration client credentials validated on session initiation.
Out of Scope¶
- Worker-facing display of "this session is browse-only" — the Cruits app handles this based on the session response.
- GDPR consent capture on session initiation (Story 1.5 — deferred from Iteration 1).
- Session expiry and re-initiation mechanics — implementation decision; the session is valid for the duration of the worker's Housing section visit.
Story 2.2b: Proximity-Based Discovery¶
Epic: Epic 2 — Worker Housing Discovery & Context Handshake
User Story¶
As a Flexworker, I want to see available housing listings sorted by distance to my job site when I open the Housing section in the Cruits app, So that I can immediately find the most conveniently located accommodation without manual location entry.
Functional Requirements Covered¶
| FR | Requirement |
|---|---|
| FR-47 | Flexworker browses listings pre-filtered by proximity to job_location |
| FR-10 | Listing card displays distance to reference point |
Acceptance Criteria¶
- Proximity sort
- When a job location is selected in the location filter (Story 2.3): the listing list is sorted by straight-line distance from the selected location coordinates (closest listing first). Distance is calculated from the Building's geocoded coordinates (
latitude/longitude) to the selected reference point. Buildings without geocoded coordinates appear at the end of the list. - When no job location is selected: listings are sorted by weekly price ascending. Buildings without a price are shown at the end.
-
Sort order cannot be changed by the worker in other ways in this iteration.
-
Distance display on listing cards
- When a job location is selected in the location filter: each listing card displays the distance from the selected location.
- 1 km or more: displayed in kilometres, rounded to one decimal place (e.g., "2.3 km").
- Under 1 km: displayed in metres, rounded to the nearest 10 m (e.g., "850 m").
- No Building coordinates: displayed as "—" (dash).
- When no job location is selected: the distance field on cards is shown as "—".
-
This updates the Iteration 1 listing card spec: distance is now shown as a live value rather than blank. The Cruits app team must remove the "distance unknown" placeholder from Iteration 1 designs and replace it with the live value.
-
Distance display on listing detail
- The listing detail view (Story 2.4) shows the distance from the selected job location in the same format, or "—" when no location is selected.
-
This updates the Iteration 1 listing detail spec: distance is now a live value.
-
Available count on pool-mode listing cards
- Pool-mode listing cards now display the live available count — the number of pool slots currently open for booking (
total_slotsminus active reservations for the listing). - "Active reservations" means bookings in non-terminal states only:
RESERVED,PENDING_PROVIDER,PENDING_PAYER,SIGNING,BOOKED,PROVIDER_BLOCKED. Bookings in terminal states (RESERVATION_FAILED,CANCELLED,ENDED) are excluded from the subtraction. - This count is calculated at query time and is not stored persistently.
-
This updates the Iteration 1 listing card spec: in Iteration 1 the available count was omitted because no reservations existed. With Reservation Creation (Story 3.2) in scope for this iteration, the count is now meaningful and must be shown.
-
Payer agreement filter
- When the session has
indirect_payment_enabled = Yes, the discovery query applies the Provider–Payer agreement check (Story 1.9a) per listing:- Listings from Providers with
indirect_payment = enabledfor this session's Payer are shown; both payment options are available (subject to session-level constraints). - Listings from Providers with
direct_payment_onlyor no agreement for this Payer are shown only ifdirect_payment_enabled = Yesin the session context. These listings are shown with the indirect payment option unavailable. - If
direct_payment_enabled = No, listings withdirect_payment_onlyor no agreement are excluded from discovery entirely.
- Listings from Providers with
- When the session has
indirect_payment_enabled = No, this filter is not applied. -
The complete rules for listing visibility across all combinations of session flags, Provider
contract_payers_onlysetting, and agreement status are described in the Payment Access Matrix in Story 2.1. -
Access control
- Same access rules as Story 2.2a: authorized Cruits integration clients with a valid session token (from Story 2.1).
- Unauthenticated access is denied.
Dependencies¶
- Story 2.3 (Listing Search & Filters) — the reference point for distance sort and display is the location selected in the location filter, not a fixed session-context value.
- Story 2.1 (Context Handshake) — session context provides the pre-selected location (if any) passed from the Cruits entry point.
- Story 1.6 (Building geocoded coordinates) — Buildings need coordinates for distance to be calculated.
- Story 1.9a (Payer Agreement Management) — agreement status per Provider–Payer pair is required for the discovery filter.
- Story 2.2a (Listing Discovery, Iteration 1) — this story enhances the listing list from Story 2.2a; it does not replace it.
Out of Scope¶
- Maximum distance filter (Story 2.3 — delivered in the same iteration as a separate story).
- Interactive map view of listings (Story 2.7 — post-MVP).
Story 2.3: Listing Search & Filters¶
Epic: Epic 2 — Worker Housing Discovery & Context Handshake
User Story¶
As a Flexworker, I want to select a job location, search listings by name or address, and filter available listings by room type, maximum distance from the selected location, maximum price per period, payment type, and my desired stay dates, So that I can quickly narrow down options to those that match my preferences, preferred payment method, stay within my budget, and are actually available for the dates I need.
Functional Requirements Covered¶
| FR | Requirement |
|---|---|
| FR-48 | Flexworker filters by room type, maximum distance, maximum price per period, and stay period; and searches listings by name, city, or address (text search; recent search history — Nice to Have) |
Acceptance Criteria¶
- Text search and recent searches
- The listing list includes a search input field at the top of the screen.
- Search behaviour: When the worker types in the search field, the listing list filters to show only listings whose title, city, or street address contains the entered text (case-insensitive). Search is applied server-side and updates results as the worker types (debounced). Search and filter controls are active simultaneously — the result set is the intersection of the text match and any active filters. When the search field is cleared, the full listing list (subject to any active filters) is restored. When no listings match, an empty state is shown: "No listings found."
-
Recent searches (Nice to Have): When the worker focuses the search field and it is empty, a list of their most recent search queries is shown (up to 5 entries, most recent first). Tapping a recent search populates the field and triggers the search immediately. Recent searches are stored per worker and persist across sessions. The worker can clear an individual entry or clear all recent searches. Recent searches are stored only for queries that returned at least one result — empty-result queries are not saved.
-
Filter panel
- The listing list screen includes filter controls (e.g., a filter bar or panel) accessible from the top of the list.
- Seven filter dimensions are available:
- Job location: a dropdown listing all active job locations on the platform (see AC 5). Selecting a location sets the reference point for distance sort and the distance filter.
- Room type: All / Shared Room / Single Room.
- Maximum distance: a distance threshold in kilometres (e.g., 1 km / 2 km / 5 km / 10 km / All distances). Active only when a job location is selected (see AC 5). Listings without Building coordinates are excluded when this filter is active.
- Weekly price range: a min and max value in Euro (€), selected via a dropdown. Listings whose normalized weekly price falls outside the selected range are excluded. The same price normalization as Story 1.8 applies. (Nice to have: a price histogram with a range slider as an alternative input within the dropdown.)
- Amenities: a multi-select list using the fixed Amenity Reference (Story 1.2). The worker selects one or more amenities; only listings that have all selected amenities are shown (AND logic). No selection = no amenity filtering applied.
- Stay dates: check-in and check-out date picker. When filled in, listings that are fully unavailable for the entire selected period are excluded (same logic as Story 1.7 AC 6). Leave blank to show all listings regardless of availability.
- Payment type: All / Payer-covered / Direct only. Narrows results by which payment option is available for each listing. Each listing in the discovery result set independently carries a payment availability status determined by the Provider–Payer agreement (Story 1.9a): indirect payment is either available or not for that specific Provider. Filter visibility is determined by the content of the full (unfiltered) discovery result set on page load and does not change as other filters narrow results: the filter is shown only when the full result set contains at least one listing where indirect payment is available and at least one listing where only direct payment is available — i.e., when there is genuine heterogeneity across Providers. When all visible listings share the same payment availability, the filter is hidden (there is nothing meaningful to filter on). The filter is also always hidden in browse-only sessions (
reservation_permitted = No). When set to Payer-covered: shows only listings for which the session's Payer has an active indirect-payment contract with the Provider (indirect_payment = enabledper the Provider–Payer agreement). This includes listings where both direct and indirect are available. When set to Direct only: shows only listings where indirect payment is not available for this session (no contract, ordirect_payment_onlyagreement) — listings where both are available are excluded. Defaults to All. The full listing visibility logic per payment type is defined by the Payment Access Matrix (column "Reservation & Filter on listing") and mirrored in the Payment Access Matrix table in Story 2.1.
-
All seven filters may be combined simultaneously.
-
Filter behaviour
- Filters are applied server-side. The filter panel includes an Apply button; results update when the worker taps Apply. A Reset button clears all active filters and returns the full listing list.
- The search field (AC 1) updates results independently as the worker types, without requiring Apply.
- When no location is selected and no other filters are active: the full listing list is shown sorted by price ascending (Story 2.2b AC 1).
- When a location is selected and no other filters are active: the full listing list is shown sorted by distance from the selected location (Story 2.2b AC 1).
- The maximum distance filter is disabled and visually inactive on the UI when no job location is selected. It becomes active as soon as a location is selected.
-
Active filter selections and the current search query persist when the worker navigates from the list to a listing detail and then navigates back.
-
Stay dates — indirect payment pre-population
- For indirect-payment sessions (
indirect_payment_enabled = Yes): the Stay dates filter is pre-populated withpermitted_period_startandpermitted_period_endfrom the session context. The worker may narrow the dates within this range but cannot select dates outside it. -
For direct-payment-only sessions: the Stay dates filter is empty by default. The worker enters dates freely with no constraint from the session context.
-
Location filter — dropdown behaviour
- When the listing screen opens, the Housing Module FE calls the Cruits API to load all active job locations on the platform. Each entry contains: employer name, location display name, latitude, and longitude.
- Locations are shown in the dropdown in the format: "Employer name, Location display name" (e.g., "Intertoys, Amsterdam"). Entries are sorted alphabetically by employer name, then by location display name.
- Pre-selection from session context: when the session payload contains
job_location_display_name, the matching dropdown item is pre-selected on screen load. If no match is found in the loaded list (e.g., the job is no longer active), the dropdown opens empty. - Entry point without job context: the dropdown opens empty. No location is pre-selected and the distance filter starts inactive.
- Changing location: when the worker selects a different item from the dropdown, the Housing Module updates the distance reference point and re-sorts and re-filters the listing list accordingly. No new session initiation is triggered.
-
Clearing location: the worker can clear the dropdown selection. This deactivates the distance filter and reverts the sort to price ascending.
-
Access control
- Same access rules as Story 2.2b: authorized Cruits integration clients with a valid session token.
Dependencies¶
- Story 2.2b (Proximity-Based Discovery) — distance sort and distance display use the reference point from the location filter selected here.
- Story 2.1 (Context Handshake) — session context provides
job_location_display_namefor pre-selecting the dropdown andpermitted_period_start/endfor Stay dates pre-population. - Cruits Jobs API — source of all active job locations loaded into the dropdown on screen open.
- Story 1.7 (Booking List & Provider Blocks) — Stay dates filter uses the same availability exclusion logic as Story 1.7 AC 5 and 6.
Out of Scope¶
- Sorting options other than distance (when location selected) or price ascending (when no location).
Story 3.1: Reservation Hold Window Enforcement¶
Epic: Epic 3 — Reservation & Booking Flow
User Story¶
As a Provider, I want to configure how long a reservation hold stays open before the system releases it automatically, So that I control how quickly stalled bookings free up capacity, and units are never locked indefinitely.
Functional Requirements Covered¶
| FR | Requirement |
|---|---|
| FR-13 | Hold window configurable per Provider (Provider Admin Settings); system defaults apply when no value set |
Hold Window Concept¶
When a Reservation is created, a hold window starts. If the booking does not advance past the hold state within the configured window, the system automatically releases the hold and transitions the booking to RESERVATION_FAILED.
Two hold states are governed by this mechanism:
RESERVEDstate: the initial hold window (starts the moment the Reservation is created). Enforced in this iteration.PENDING_PAYERstate: the payer approval window (starts when the booking entersPENDING_PAYER). Configured in this story; enforcement is validated in Iteration 3 when Story 3.4 is in scope.
The effective hold window duration for a booking is determined by three rules applied in order:
| Rule | Description |
|---|---|
| Standard window | The configured number of days from the hold start date. Applies when the booking's period_start is far from the reservation creation date. |
| Short-notice window | A shorter window (in hours). Applies instead of the standard window when period_start falls within the short-notice threshold (configured number of days) of the reservation creation date. |
| Minimum notice cap | The hold window must end no later than the configured number of days before period_start. If the calculated expiry falls after this cap, it is pulled forward to the cap deadline. |
A Provider who has not configured any values uses the system defaults.
Provider Configuration (Provider Admin Settings)¶
The Provider Admin Settings page (Story 1.10) includes a Hold Window section. Each Provider configures their own hold window parameters; system defaults apply when no value is set. There are no platform-imposed caps — Providers set values freely.
| Setting | System default |
|---|---|
| Standard hold window | 7 days |
| Short-notice hold window | 24 hours |
| Short-notice threshold | 3 days |
| Minimum notice cap | 2 days before period_start |
| Payer approval window | 7 days |
A Provider who has not configured any values uses the system defaults for all bookings under their listings.
Acceptance Criteria¶
- Auto-release from
RESERVED - When a booking's hold window expires while in
RESERVEDstate, the system:- Transitions the booking to
RESERVATION_FAILED. - Releases the unit hold: the slot (concrete mode) or the pool slot (pool mode) becomes available again for new reservations.
- Sends the Flexworker a push notification (Story 3.5 basic).
- Transitions the booking to
-
The system determines expiry by comparing the current time against the booking's
hold_expires_atfield. The expiry check runs automatically at regular intervals in the background; the frequency is an implementation decision. -
Auto-release from
PENDING_PAYER(configuration defined here; enforcement validated in Iteration 3) - Same mechanics: when the payer approval window expires in
PENDING_PAYER, the booking transitions toRESERVATION_FAILED, the hold is released, and the Flexworker and Payer are notified. -
This path is not exercisable in Iteration 2 since
PENDING_PAYERis introduced in Story 3.4 (Iteration 3). -
Provider configuration
- The Provider Admin Settings page includes a Hold Window section with fields for each configurable parameter.
- Each field shows the system default as a placeholder. A Provider may set any positive integer value; no platform-imposed bounds apply.
- A Provider who leaves all fields blank (or resets them) uses the system defaults. Saving with all fields blank restores system defaults for that Provider.
-
Changes take effect for new bookings under that Provider. Existing reservations keep the window that was calculated at the moment they were created.
-
Minimum notice cap: reservation creation guard
- Before creating a Reservation, the system calculates the effective hold window expiry date.
- If the calculated expiry falls in the past — meaning the check-in date is so close that no valid hold window can exist — the Reservation creation is rejected with a clear error: "The check-in date is too close to allow a new reservation. Please contact the housing manager."
- Example: minimum notice cap = 2 days (system default); a worker attempts to reserve a unit with
period_start= today. The cap requires the hold to expire before today − 2 days, which is in the past. The reservation is rejected.
Dependencies¶
- Story 1.1 (Housing backend service with background task scheduling) — periodic expiry checks require background processing infrastructure.
- Story 1.10 (Provider Settings screen) — extended with Hold Window section.
Out of Scope¶
PENDING_PAYERwindow enforcement (Iteration 3 — Story 3.4).SIGNINGstate envelope expiry (FR-74 — Iteration 4, Story 4.2).
Story 3.2: Reservation Creation¶
Epic: Epic 3 — Reservation & Booking Flow
User Story¶
As a Flexworker, I want to create a reservation for an available listing from the Cruits app — selecting a specific unit for concrete-mode listings or confirming a pool slot for pool-mode listings — and, when reserving under indirect payment, to see and acknowledge a confirmation notice detailing the payer-covered period, the payer's name, my rights to independent booking, and the 28-day period-end reminder, So that the unit is held for me, the booking record is created with my resident and payer identities, WGV notice obligations are met, and the booking process begins.
Functional Requirements Covered¶
| FR | Requirement |
|---|---|
| FR-11 | Flexworker creates Reservation; unit held; booking status → RESERVED |
| FR-19 | Booking carries independent resident_id and payer_id |
| FR-20 | Supported payment_form values |
| FR-50 | Flexworker creates Reservation from Listing detail when session context permits |
| FR-51 | Concrete-mode: worker selects a specific unit slot at reservation |
| FR-52 | Pool-mode: worker confirms a pool slot; specific assignment communicated at check-in |
| FR-82 | Indirect-payment confirmation notice: worker must acknowledge before Reservation is submitted |
FR-69 (Resident identity create-or-update) is triggered on session initiation (Story 2.1), not at reservation creation. The contact history, conflict detection, and signing guard defined in Story 1.3 apply whenever the session creates or updates a Resident record.
Wireframes / UI Design¶
Figma designs for the Reservation flow: [TBD — to be linked when available from the Cruits design team.]
Booking Flow — Iteration 2 Scope¶
Worker taps Reserve on listing detail
↓
[Permission check: reservation_permitted = Yes?]
↓
[Payment option screen]
Direct / Indirect / both offered
↓
[Period selection]
Check-in and check-out dates
↓
[Availability check]
Provider blocks + active reservations
↓
[If indirect payment selected]
WGV confirmation notice → worker acknowledges
↓
[If concrete-mode listing]
Slot selection: floor / room / bed
↓
[If pool-mode listing]
Pool slot confirmation screen
↓
[Final server-side validation]
↓
RESERVED
(unit hold placed; booking record created)
Iteration 2 scope note: The booking remains in
RESERVEDstate after creation in this iteration. The path forward — provider confirmation, payer approval, signing, and BOOKED — is implemented in Iterations 3 and 4 (Stories 3.3, 3.4, and Epic 4).
Entity Model¶
The Booking and BookingStateTransition entities are defined in Story 1.7. This story creates Booking records with booking_type = resident. All resident-specific fields are populated at reservation creation as described in AC 7 below. The reason field (provider-block only) is not used for resident bookings.
payment_form values:
| Value | Label | Payer = Resident? |
|---|---|---|
worker_direct |
Worker pays directly | Yes |
payer_prepay_deduct |
Payer prepays (deducted from worker) | No |
payer_postpay_deduct |
Payer postpays (deducted from worker) | No |
payer_subsidy_full |
Full payer subsidy | No |
payer_subsidy_partial |
Partial payer subsidy | No |
Iteration 2 note on indirect payment form: The specific indirect
payment_formvalue is determined by the payer-provider payment terms configured in Story 1.9 (Iteration 4). In Iteration 2, all indirect-payment reservations usepayer_prepay_deductas the default until payment terms are configured.
Acceptance Criteria¶
- Entry point and permission check
- A Flexworker can initiate a reservation from the listing detail screen by tapping the Reserve button.
- The Reserve button is visible and enabled only when
reservation_permitted = Yesin the session context and the listing has at least one available slot for the requested period. - If
reservation_permitted = No, the Reserve button is hidden or shown as disabled. A server-side check enforces the browse-only constraint independently of the UI state. -
The Reserve button is also hidden or disabled when
reservation_permitted = Yesbut bothdirect_payment_enabled = Noandindirect_payment_enabled = No. This is a misconfigured session context — it should never occur in normal Cruits app flow. The server rejects any reservation attempt in this state regardless of UI. -
Payment option selection
- If only
direct_payment_enabled = Yes: the payment option step is skipped; the reservation proceeds as direct payment (worker_direct). - If only
indirect_payment_enabled = Yes: the payment option step is skipped; the reservation proceeds as indirect payment. - If both are enabled: the worker sees a payment choice screen offering:
- "I pay" (direct payment)
- "[Payer name] pays" (indirect payment, using
payer_namefrom the session context) - The indirect payment option is shown as disabled with an explanation in either of these cases: (a) the listing's normalized daily cost exceeds the effective
max_daily_costthreshold ("Price exceeds the amount covered by [Payer name]"); (b) the Provider has setdirect_payment_onlyfor this session's Payer or no agreement exists ("This provider does not accept indirect payment for [Payer name]"). Both checks are applied; if either condition is true, indirect payment is unavailable for that listing.
-
If neither is enabled: the reservation cannot proceed. The server rejects any such attempt.
-
Period selection
- The worker selects check-in and check-out dates.
- Indirect payment constraint: the selected period must fall entirely within
permitted_period_start – permitted_period_endfrom the session context. Dates outside this range cannot be selected; an explanation is shown. - Minimum stay: the selected period must be at least as long as the listing's
minimum_stay. If shorter, an error is shown and the worker cannot proceed. Comparison uses calendar arithmetic: for month-based minimum stay,period_endmust be on or afterperiod_start + N months(e.g., minimum 1 month starting Feb 28 → end date must be ≥ Mar 28; starting Mar 31 → end date must be ≥ Apr 30). For week-based minimum stay,period_endmust be on or afterperiod_start + N × 7 days. -
Availability pre-check: after date selection, the system checks whether the requested period is available:
- Concrete mode: indicates how many slots are available for the selected period.
- Pool mode: indicates whether any pool slots remain available for the entire period.
- If no slots are available, the worker sees "No availability for the selected dates" and must choose different dates or a different listing.
-
WGV indirect-payment confirmation notice (FR-82)
- When indirect payment is selected, before the Reservation is submitted, the Cruits app displays a confirmation notice containing:
- (a) The period covered by the payer (
permitted_period_start – permitted_period_end) and the payer's name. - (b) That the worker may independently book housing for any period outside the payer-covered window, including via direct payment if that option is available to them.
- © That the worker will receive a reminder notification 28 days before the booked period ends; for stays shorter than 28 days, this information will be included in the booking confirmation message.
- (a) The period covered by the payer (
- The worker must tap an explicit acknowledgement button (e.g., "I understand") before the Reservation can be submitted.
- If the worker does not acknowledge and navigates away, no Reservation is created.
-
The fact that the notice was shown and acknowledged is recorded in the Booking record (
wgv_notice_shown = Yes,wgv_notice_acknowledged_attimestamp). -
Concrete-mode: slot selection (FR-51)
- For concrete-mode listings, after period selection, the worker sees a list of available slots for the selected period. Each slot is identified by floor, room, and bed.
- Only slots that have no active reservation and no availability block for any day within the selected period are shown.
- The worker selects one slot. The selected slot is stored in the Booking record.
-
If no slots are available for the selected period, the Reserve button is disabled and the worker is shown "No units available for these dates."
-
Pool-mode: slot confirmation (FR-52)
- For pool-mode listings, after period selection, the worker sees a confirmation screen stating that a pool slot will be held and the specific unit will be assigned by the Provider at check-in.
-
The worker confirms to proceed. The Booking record is created without a specific slot reference.
-
Booking record creation
- On successful submission, the Housing Module creates a Booking record with:
status = RESERVEDresident_id= the Resident associated with the current sessionpayer_id= the Resident (direct payment) or the third-party Payer from the session context (indirect payment)payment_form= the selected payment formprice_at_bookingandprice_period_unit_at_booking= snapshots of the listing's current price and period unit at the moment of creationhold_expires_at= the calculated hold window expiry date and time, determined at creation from the applicable platform and Provider hold window configuration (Story 3.1); stored immutably and never recalculated- A
BookingStateTransitionentry:from_status = null,to_status = RESERVED,actor = worker
-
The unit hold is placed immediately: the slot (concrete mode) or one pool slot (pool mode) is counted as occupied for the reserved period and is excluded from new reservation availability.
-
Concurrent reservation prevention
- A Flexworker may not hold more than one active (non-terminal) reservation for the same ListingUnit at the same time. If an active reservation already exists for this worker on this listing, the new reservation attempt is rejected with an error: "You already have an active reservation for this listing."
-
Reservations in terminal states (
RESERVATION_FAILED,CANCELLED) do not count against this limit. -
Final availability check at submission
- The availability check is repeated at the moment of submission (in addition to the earlier pre-check shown during date selection). If the slot has become unavailable between the worker's selection and submission (e.g., another worker reserved it first), the submission is rejected with a clear error instructing the worker to select a different unit or dates.
Dependencies¶
- Story 2.1 (Context Handshake) — session context required for payment option enforcement, period constraints, and max daily cost check.
- Story 1.6 (ListingUnit and ListingUnitSlot entities).
- Story 1.7 (Booking List & Provider Blocks) — availability blocks checked at reservation time.
- Story 1.8 (Pricing) —
price_at_bookingandprice_period_unit_at_bookingsnapshotted; minimum stay enforced. - Story 3.1 (Hold Window Config) — hold window starts at Reservation creation;
BookingStateTransitionused for hold window tracking.
Out of Scope¶
- Provider confirmation (Story 3.3 — Iteration 3). The booking remains in
RESERVEDstate in Iteration 2. - Payer confirmation flow (Story 3.4 — Iteration 3).
- DocuSign signing step (Story 4.2 — Iteration 4).
- Pre-BOOKED cancellation from the reservation flow (Story 3.7 — Iteration 3).
- Payment terms application on booking confirmation (Story 1.9 — Iteration 4).
- WGV period-end notification (FR-83 — triggered at BOOKED state, Iteration 3).
Story 3.5 (basic): Booking State Transition Notifications — Basic¶
Epic: Epic 3 — Reservation & Booking Flow
User Story¶
As a Flexworker, I want to receive an in-app push notification when my reservation is successfully created and when it expires automatically, So that I know the current status of my reservation without having to open the app to check.
Functional Requirements Covered¶
| FR | Requirement |
|---|---|
| FR-18 | Flexworker receives a notification at every state transition relevant to them (partial: RESERVED and RESERVATION_FAILED only in this iteration) |
| FR-43 | Notification delivery: Flexworker receives in-app push notification via the Cruits mobile app |
Scope Note¶
This story delivers basic notification coverage — the two state transitions that exist in Iteration 2:
- RESERVED (reservation created successfully)
- RESERVATION_FAILED (hold window expired via Story 3.1)
The full notification scope — covering all remaining transitions (PENDING_PROVIDER, PENDING_PAYER, SIGNING, BOOKED, CANCELLED, and the Provider / Payer desktop SSE channel) — is delivered in Story 3.5 full (Iteration 3).
Notification Triggers¶
| Trigger | Recipient | Message |
|---|---|---|
Booking enters RESERVED |
Flexworker | "Your reservation for [Listing Title] has been created. We are confirming availability." |
Booking transitions to RESERVATION_FAILED (hold window expired) |
Flexworker | "Your reservation for [Listing Title] has expired. The unit hold has been released. You may create a new reservation if the listing is still available." |
Acceptance Criteria¶
- Push notification — RESERVED
- When a Flexworker's Reservation is successfully created (Booking status =
RESERVED), the Housing Module sends a push notification to the worker via the Cruits mobile app. - Notification content: listing title and the message from the table above.
-
The notification is delivered to all devices where the worker's Cruits app is installed.
-
Push notification — RESERVATION_FAILED (hold window expiry)
- When a booking transitions to
RESERVATION_FAILEDas a result of the hold window expiry (Story 3.1), the Housing Module sends a push notification to the Flexworker. -
Notification content: listing title and the message from the table above.
-
Notification delivery infrastructure
- The notification delivery channel (push) must work for iOS, Android, and PWA clients of the Cruits app.
- Push notifications are delivered via the existing Cruits push notification mechanism — the Housing Module calls the Cruits API to trigger delivery; the Cruits app handles the actual push to the device. No new notification infrastructure is required.
-
Delivery is best-effort in this iteration. No retry logic or fallback to email (email fallback is post-MVP per FR-43).
-
Notification content rules
- The listing title in the notification is the
ListingUnit.titlevalue at the time the notification is sent. - Notifications are in English.
Dependencies¶
- Story 3.2 (Reservation Creation) —
RESERVEDtransition. - Story 3.1 (Hold Window Enforcement) —
RESERVATION_FAILEDtransition.
Out of Scope¶
- Notifications for
PENDING_PROVIDER,PENDING_PAYER,SIGNING,BOOKED,CANCELLEDstates (Story 3.5 full — Iteration 3). - Provider desktop (SSE) notifications (Story 3.5 full — Iteration 3).
- Payer notifications (Story 3.5 full — Iteration 3).
- Email fallback (post-MVP).
Story 3.6: Booking Status View¶
Epic: Epic 3 — Reservation & Booking Flow
User Story¶
As a Flexworker, I want to view the current status and full details of my active reservation or booking from the Cruits app, So that I can track where my booking stands and access the details at any time.
Functional Requirements Covered¶
| FR | Requirement |
|---|---|
| FR-53 | Flexworker views current status and details of their active Reservation or Booking |
Wireframes / UI Design¶
Figma designs for the Booking Status screen: [TBD — to be linked when available from the Cruits design team.]
Acceptance Criteria¶
- Entry point
- The Cruits app provides a "My Booking" section or tab where the Flexworker can view their active reservation or booking.
- If the worker has no active (non-terminal) reservation or booking, the screen shows an empty state with a prompt to browse listings.
- If the worker has one active reservation or booking, its full details are shown.
-
The screen is designed to support future booking statuses beyond
RESERVEDandRESERVATION_FAILED(Iterations 3 and 4 will add more states) without requiring a redesign. -
Status display
- The current booking status is shown prominently:
RESERVED→ "Reservation pending confirmation"RESERVATION_FAILED→ "Reservation expired"
-
A brief human-readable explanation is shown below the status label:
RESERVED: "Your unit is held while we confirm availability. This hold expires on [expiry date and time]."RESERVATION_FAILED: "Your unit hold was released because the reservation was not confirmed in time. You may search for another listing."
-
Booking details displayed
- The following information is shown:
- Listing title and room type label (Shared Room / Single Room)
- Building address (street, city)
- Listing hero photo
- Check-in and check-out dates
- Price at booking (snapshotted at reservation creation, with period unit)
- Payment form label:
worker_direct→ "You pay"- Any indirect form → "[Payer name] pays"
- Reservation created date and time
- Concrete-mode reservations: the reserved slot (floor / room / bed) is shown.
-
Pool-mode reservations: "Unit will be assigned by the Provider at check-in."
-
Hold window indicator
- For
RESERVEDbookings: the screen shows the hold expiry date and time, read directly from the booking'shold_expires_atfield. -
This gives the worker visibility into how long the unit is held before it is automatically released.
-
RESERVATION_FAILED state
- When the booking has reached
RESERVATION_FAILED, the expired status and reason (e.g., "Hold window expired") are shown clearly. -
A "Browse listings" button guides the worker back to the listing list.
-
Access control
- A Flexworker can only view their own bookings. Attempting to access another worker's booking is denied.
- The session token from Story 2.1 is required.
Status Display Reference¶
Complete reference for how each booking state appears in the Booking Status View. This table is the single source of truth for UI labels and body text across all iterations.
| State | Variant | UI Label | Body text | Buttons |
|---|---|---|---|---|
RESERVED |
— | "Reservation pending confirmation" | "Your unit is held while we confirm availability. This hold expires on [expiry date and time]." | Cancel reservation |
PENDING_PROVIDER |
— | "Confirming availability" | "We are confirming your unit with the housing provider." | Cancel reservation |
PENDING_PAYER |
— | "Awaiting payer approval" | "Your reservation is waiting for approval from [Payer name]. You will be notified when a decision is made." | Cancel reservation |
BOOKED |
— | "Housing confirmed" | "Your unit at [confirmed address] is confirmed. Check-in: [date]. Check-out: [date]." | Cancel booking (before period_start, within window) · Request cancellation (before period_start, outside window) · No button after period_start |
CHECKED_IN |
— | "Checked in" | "You are checked in at [Listing Title]. Check-out: [date]." | Check out |
RESERVATION_FAILED |
Hold window expired | "Reservation expired" | "Your unit hold was released because the reservation was not confirmed in time. You may search for another listing." | Browse listings |
RESERVATION_FAILED |
Provider rejected | TBD | TBD | Browse listings |
RESERVATION_FAILED |
Payer rejected | "Reservation rejected" | "Your reservation for [Listing Title] was rejected by [Payer name]. You may search for another listing." | Browse listings |
RESERVATION_FAILED |
Payer timeout | "Reservation expired" | "Approval from [Payer name] was not received in time. The unit hold has been released." | Browse listings |
CANCELLED |
Pre-BOOKED | "Reservation cancelled" | "You have cancelled your reservation for [Listing Title]. You may search for another listing." | Browse listings |
CANCELLED |
BOOKED | "Booking cancelled" | "Your booking for [Listing Title] has been cancelled. Reason: [reason]." | Browse listings |
ENDED |
— | TBD | TBD | — |
SIGNING |
— | TBD | TBD | — |
Notes:
- PENDING_PROVIDER and PENDING_PAYER are two distinct statuses for the Resident. Both are "waiting" states but with different explanations — the Resident can see who is holding up their booking. In admin panels they route to different actors (Provider Admin / Payer Admin).
- RESERVATION_FAILED has multiple variants with different labels and body text depending on the cause. The system must pass the failure reason to the UI.
- Cancel reservation button on RESERVED, PENDING_PROVIDER, and PENDING_PAYER is added in Story 3.7 (Iteration 3).
- RESERVATION_FAILED — Provider rejected: screen label and body text not yet defined. To be specified.
- ENDED screen content is not yet defined. The "My Booking" section may show an empty state or a dedicated completion screen — to be specified.
- SIGNING is deferred to Iteration 4 (Story 4.2). Screen content is not yet defined.
Dependencies¶
- Story 3.2 (Reservation Creation) — Booking entity must exist.
- Story 3.1 (Hold Window Enforcement) — hold expiry date shown in the status view.
- Story 2.1 (Context Handshake) — session token for access control.
Out of Scope¶
- Cancellation action from the status view (Story 3.7 — Iteration 3).
- In-app messaging from the booking status view (Story 9.2 — Iteration 6).
- Confirmed booking address and contract display — available after
BOOKEDstate (Iteration 3). - Multiple simultaneous bookings view — one active booking per worker is the expected state; displaying multiple is deferred.
Summary Table¶
| Story | Epic | FRs | Deliverable |
|---|---|---|---|
| 1.7 | Epic 1 | FR-7, FR-29 | Booking List & Provider Blocks — Provider views all bookings per listing; creates and cancels capacity blocks; enforced at reservation and in discovery |
| 1.9a | Epic 1 | FR-8 (partial) | Payer Agreement Management — Provider configures indirect payment access per Payer; discovery and reservation enforce agreement check |
| 1.12 | Epic 1 | FR-90 | Housing Quality Score (PKS) — Provider sets optional PKS on listing; shown on card and detail; used in PKS-based indirect payment cost enforcement |
| 2.1 | Epic 2 | FR-44, FR-45, FR-46 | Context Handshake — Worker session initiated with job location and booking context; all constraints enforced server-side |
| 2.2b | Epic 2 | FR-47, FR-10 (distance) | Proximity Discovery — Listing list sorted by distance when location selected, by price when not; live distance shown on cards and detail |
| 2.3 | Epic 2 | FR-48 | Listing Search & Filters — Job location dropdown (pre-selected from entry point); room type, max distance (inactive without location), max weekly price, amenities (multi-select AND), stay dates; text search; recent search history (Nice to Have) |
| 3.1 | Epic 3 | FR-13 | Hold Window Config — Platform and Provider-level hold window configuration; auto-release from RESERVED to RESERVATION_FAILED |
| 3.2 | Epic 3 | FR-11, FR-19, FR-20, FR-50, FR-51, FR-52, FR-82 | Reservation Creation — Worker reserves a listing from Cruits app; Booking created in RESERVED state; WGV notice shown and acknowledged for indirect payment |
| 3.5 (basic) | Epic 3 | FR-18, FR-43 (partial) | Basic Notifications — Push notifications to Flexworker for RESERVED and RESERVATION_FAILED transitions |
| 3.6 | Epic 3 | FR-53 | Booking Status View — Worker views active reservation status, details, and hold expiry in the Cruits app |
Deferred from Iteration 2:
| Story | Title | Deferred to |
|---|---|---|
| 3.3 | Provider Confirmation & Direct Booking Path | Iteration 3 |
| 3.4 | Payer Confirmation Flow | Iteration 3 |
| 3.5 full | Booking State Transition Notifications — all remaining states + Provider/Payer channels | Iteration 3 |
| 3.7 | Pre-BOOKED Cancellation | Iteration 3 |