Skip to content

Cruits Housing — Iteration 2: Story Specifications

GitHub: https://github.com/WAconsult/cruits-housing-ba/blob/main/01-scoping/planning-artifacts/iteration-2-story-specs.md

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

  1. Booking List section in Provider Admin
  2. Provider Admin includes an Booking List section accessible from the listing's edit view or from the main navigation.
  3. The section displays a list of all Booking records for the selected listing — both resident and provider_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.
  4. Provider block rows are visually distinguished from resident booking rows via a booking type badge.
  5. The list is listing-specific: the Provider selects which listing to manage via a listing dropdown.

  6. Creating a provider block — pool-mode listings

  7. The Provider selects a date range and specifies the number of slots to block (1 up to total_slots).
  8. The system creates that number of provider_block bookings for the listing and period (one per blocked slot).
  9. After creation, available_count for the affected period is reduced by the number of blocked slots. If available_count reaches zero, the listing is treated as fully unavailable for that period.
  10. The Provider may optionally add an internal note (reason) to the block; this note is not visible to workers.

  11. Creating a provider block — concrete-mode listings

  12. 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_block booking is created for that slot and period.
    • Marks all slots: a provider_block booking is created for each slot of the listing.
  13. Individual slot blocking allows a Provider to take one bed out of service while the others remain bookable.

  14. Conflict with existing resident bookings when creating a block

  15. If the block's date range overlaps with resident bookings in PENDING_PAYER, SIGNING, or BOOKED state for the affected slot (concrete mode) or would reduce available_count below 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.
  16. If the block's date range overlaps only with resident bookings in RESERVED or PENDING_PROVIDER state, 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.
  17. (IT-2 note: resident bookings only reach RESERVED state in this iteration, so the warning path is the only reachable scenario. The error path applies from Iteration 3 onward.)

  18. Removing a provider block

  19. 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.
  20. The cancellation is recorded in the booking's state transition history.

  21. Discovery filtering

  22. When stay dates are known — either pre-populated from permitted_period_start/end in 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 = 0 for any day within the requested period (i.e., all slots are occupied by resident bookings and/or provider blocks on at least one day).
  23. 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.

  24. Reservation gate (enforced in Story 3.2)

  25. At reservation creation time, the system verifies that sufficient capacity remains for the requested period — no active booking of any type (resident or provider_block) occupies the selected slot (concrete mode) or all pool slots (pool mode) for any day of the period.
  26. 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

  1. PKS field in Provider Admin
  2. The listing edit form in Provider Admin includes a PKS field, labelled "Housing Quality Score (PKS)".
  3. The field accepts any non-negative decimal number. No upper bound is enforced.
  4. The field is optional. A listing with no PKS value is valid and remains fully discoverable.
  5. 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.

  6. PKS on listing card

  7. When a listing has a PKS value set, the listing card displays it as "PKS: [value]".
  8. When no PKS value is set, the PKS field is absent from the listing card — no placeholder or dash is shown. [Design update required]

  9. PKS on listing detail

  10. When a listing has a PKS value set, the listing detail view displays it as "Housing Quality Score (PKS): [value]".
  11. When no PKS value is set, the field is absent from the listing detail.

  12. Indirect payment cost enforcement using PKS (Nice to have)

  13. 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_table where pks_from ≤ listing PKS < pks_to (for the open-ended last range: listing PKS ≥ pks_from). The matching range's max_daily_cost amount is the effective threshold for indirect payment eligibility checks on this listing.
    • Listing without a PKS value: the session's fallback max_daily_cost is used as the effective threshold.
  14. When no pks_cost_table is present in the session context: the session's max_daily_cost is always used, regardless of whether the listing has a PKS value.
  15. 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_table in 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

  1. Payment Terms section in Provider Admin
  2. Provider Admin includes a Payment Terms section accessible from the main navigation.
  3. The section is divided into two parts: a Default Payment Terms block at the top and a Payer Agreements table below.
  4. 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.
  5. 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).

  6. Adding an agreement

  7. The Provider clicks "+ Add agreement" and selects a Payer from a dropdown of all active Payer accounts in the system (created by Platform Admin).
  8. The Provider sets indirect payment: Enabled or Direct payment only.
  9. Only one agreement per Provider–Payer pair is allowed. Attempting to add a Payer that already has an agreement is blocked with an error.
  10. Saving creates a PayerProviderAgreement record. The change takes effect immediately for new discovery sessions and new reservations.

  11. Editing an agreement

  12. The Provider can change the indirect_payment value of an existing agreement at any time.
  13. Changes take effect immediately for new discovery sessions and new reservations. Active bookings already in progress are not affected.

  14. Removing an agreement

  15. The Provider can remove an agreement at any time.
  16. After removal, the Payer has no agreement with this Provider — equivalent to direct_payment_only for all future discovery and reservations. Active bookings already in progress are not affected.

  17. Discovery enforcement

  18. When a worker's session has indirect_payment_enabled = Yes for 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 as max_daily_cost).
    • Agreement exists with indirect_payment = direct_payment_only, or no agreement exists: the listing is shown only if direct_payment_enabled = Yes in the session context — direct payment is the only option available. If direct_payment_enabled = No, the listing is excluded from discovery entirely (the worker has no usable payment option for it).
  19. 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).

  20. Access control

  21. Only the Provider who owns the listing can manage their own Payer agreements via Provider Admin.
  22. 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:

  1. Creates or updates the Resident identity record (create-or-update behavior as defined in Story 1.3 — FR-69).
  2. Stores the session context: job location, reservation permissions, payment options, permitted period, and maximum cost constraints.
  3. 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_start and permitted_period_end comply 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 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_email as 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 PayerProviderAgreement exists for the session's Payer with indirect_payment = enabled. No covers both "no agreement" and "agreement with direct_payment_only". N/A = not evaluated because indirect_payment_enabled = No in 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

  1. Session initiation
  2. When the Cruits app opens the Housing section, it sends the context payload to the Housing Module.
  3. 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.
  4. When indirect_payment_enabled = Yes, the Housing Module additionally verifies that payer_id references an existing Payer record with account_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.)
  5. When indirect_payment_enabled = Yes, the Housing Module also validates the permitted period: permitted_period_start must be on or before permitted_period_end, and permitted_period_end must 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.
  6. 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.
  7. The Cruits app uses this session token for all subsequent Housing Module requests in this session.

  8. Session context stored and enforced

  9. The session context is stored in the Housing Module. It is the authoritative source for all constraint enforcement in this session.
  10. The worker cannot obtain permissions beyond those in the stored context by crafting requests manually — all enforcement is server-side.

  11. Browse-only enforcement

  12. 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.
  13. 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.

  14. Payment option enforcement

  15. 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.
  16. Indirect payment cost check: the effective maximum daily cost threshold for a listing is determined as follows: (a) if pks_cost_table is present in the session context AND the listing has a PKS value (Story 1.12), find the range where pks_from ≤ listing PKS < pks_to (or ≥ pks_from for the open-ended last range) and use that range's max_daily_cost as the threshold; (b) otherwise, use the session's max_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.
  17. Indirect payment period check: a reservation with dates outside permitted_period_start – permitted_period_end is rejected.
  18. Payer agreement check: indirect_payment_enabled = Yes in 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 set direct_payment_only for 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 if direct_payment_enabled = Yes.

  19. Cruits app behavior on session initiation

  20. 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.
  21. 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

  1. Proximity sort
  2. 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.
  3. When no job location is selected: listings are sorted by weekly price ascending. Buildings without a price are shown at the end.
  4. Sort order cannot be changed by the worker in other ways in this iteration.

  5. Distance display on listing cards

  6. 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).
  7. When no job location is selected: the distance field on cards is shown as "—".
  8. 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.

  9. Distance display on listing detail

  10. 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.
  11. This updates the Iteration 1 listing detail spec: distance is now a live value.

  12. Available count on pool-mode listing cards

  13. Pool-mode listing cards now display the live available count — the number of pool slots currently open for booking (total_slots minus active reservations for the listing).
  14. "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.
  15. This count is calculated at query time and is not stored persistently.
  16. 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.

  17. Payer agreement filter

  18. 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 = enabled for this session's Payer are shown; both payment options are available (subject to session-level constraints).
    • Listings from Providers with direct_payment_only or no agreement for this Payer are shown only if direct_payment_enabled = Yes in the session context. These listings are shown with the indirect payment option unavailable.
    • If direct_payment_enabled = No, listings with direct_payment_only or no agreement are excluded from discovery entirely.
  19. When the session has indirect_payment_enabled = No, this filter is not applied.
  20. The complete rules for listing visibility across all combinations of session flags, Provider contract_payers_only setting, and agreement status are described in the Payment Access Matrix in Story 2.1.

  21. Access control

  22. Same access rules as Story 2.2a: authorized Cruits integration clients with a valid session token (from Story 2.1).
  23. 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

  1. Text search and recent searches
  2. The listing list includes a search input field at the top of the screen.
  3. 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."
  4. 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.

  5. Filter panel

  6. The listing list screen includes filter controls (e.g., a filter bar or panel) accessible from the top of the list.
  7. 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 = enabled per 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, or direct_payment_only agreement) — 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.
  8. All seven filters may be combined simultaneously.

  9. Filter behaviour

  10. 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.
  11. The search field (AC 1) updates results independently as the worker types, without requiring Apply.
  12. 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).
  13. 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).
  14. 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.
  15. Active filter selections and the current search query persist when the worker navigates from the list to a listing detail and then navigates back.

  16. Stay dates — indirect payment pre-population

  17. For indirect-payment sessions (indirect_payment_enabled = Yes): the Stay dates filter is pre-populated with permitted_period_start and permitted_period_end from the session context. The worker may narrow the dates within this range but cannot select dates outside it.
  18. 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.

  19. Location filter — dropdown behaviour

  20. 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.
  21. 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.
  22. 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.
  23. Entry point without job context: the dropdown opens empty. No location is pre-selected and the distance filter starts inactive.
  24. 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.
  25. Clearing location: the worker can clear the dropdown selection. This deactivates the distance filter and reverts the sort to price ascending.

  26. Access control

  27. 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_name for pre-selecting the dropdown and permitted_period_start/end for 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:

  • RESERVED state: the initial hold window (starts the moment the Reservation is created). Enforced in this iteration.
  • PENDING_PAYER state: the payer approval window (starts when the booking enters PENDING_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

  1. Auto-release from RESERVED
  2. When a booking's hold window expires while in RESERVED state, 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).
  3. The system determines expiry by comparing the current time against the booking's hold_expires_at field. The expiry check runs automatically at regular intervals in the background; the frequency is an implementation decision.

  4. Auto-release from PENDING_PAYER (configuration defined here; enforcement validated in Iteration 3)

  5. Same mechanics: when the payer approval window expires in PENDING_PAYER, the booking transitions to RESERVATION_FAILED, the hold is released, and the Flexworker and Payer are notified.
  6. This path is not exercisable in Iteration 2 since PENDING_PAYER is introduced in Story 3.4 (Iteration 3).

  7. Provider configuration

  8. The Provider Admin Settings page includes a Hold Window section with fields for each configurable parameter.
  9. Each field shows the system default as a placeholder. A Provider may set any positive integer value; no platform-imposed bounds apply.
  10. 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.
  11. Changes take effect for new bookings under that Provider. Existing reservations keep the window that was calculated at the moment they were created.

  12. Minimum notice cap: reservation creation guard

  13. Before creating a Reservation, the system calculates the effective hold window expiry date.
  14. 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."
  15. 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_PAYER window enforcement (Iteration 3 — Story 3.4).
  • SIGNING state 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 RESERVED state 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_form value is determined by the payer-provider payment terms configured in Story 1.9 (Iteration 4). In Iteration 2, all indirect-payment reservations use payer_prepay_deduct as the default until payment terms are configured.

Acceptance Criteria

  1. Entry point and permission check
  2. A Flexworker can initiate a reservation from the listing detail screen by tapping the Reserve button.
  3. The Reserve button is visible and enabled only when reservation_permitted = Yes in the session context and the listing has at least one available slot for the requested period.
  4. 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.
  5. The Reserve button is also hidden or disabled when reservation_permitted = Yes but both direct_payment_enabled = No and indirect_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.

  6. Payment option selection

  7. If only direct_payment_enabled = Yes: the payment option step is skipped; the reservation proceeds as direct payment (worker_direct).
  8. If only indirect_payment_enabled = Yes: the payment option step is skipped; the reservation proceeds as indirect payment.
  9. If both are enabled: the worker sees a payment choice screen offering:
    • "I pay" (direct payment)
    • "[Payer name] pays" (indirect payment, using payer_name from 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_cost threshold ("Price exceeds the amount covered by [Payer name]"); (b) the Provider has set direct_payment_only for 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.
  10. If neither is enabled: the reservation cannot proceed. The server rejects any such attempt.

  11. Period selection

  12. The worker selects check-in and check-out dates.
  13. Indirect payment constraint: the selected period must fall entirely within permitted_period_start – permitted_period_end from the session context. Dates outside this range cannot be selected; an explanation is shown.
  14. 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_end must be on or after period_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_end must be on or after period_start + N × 7 days.
  15. 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.
  16. WGV indirect-payment confirmation notice (FR-82)

  17. 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.
  18. The worker must tap an explicit acknowledgement button (e.g., "I understand") before the Reservation can be submitted.
  19. If the worker does not acknowledge and navigates away, no Reservation is created.
  20. The fact that the notice was shown and acknowledged is recorded in the Booking record (wgv_notice_shown = Yes, wgv_notice_acknowledged_at timestamp).

  21. Concrete-mode: slot selection (FR-51)

  22. 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.
  23. Only slots that have no active reservation and no availability block for any day within the selected period are shown.
  24. The worker selects one slot. The selected slot is stored in the Booking record.
  25. 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."

  26. Pool-mode: slot confirmation (FR-52)

  27. 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.
  28. The worker confirms to proceed. The Booking record is created without a specific slot reference.

  29. Booking record creation

  30. On successful submission, the Housing Module creates a Booking record with:
    • status = RESERVED
    • resident_id = the Resident associated with the current session
    • payer_id = the Resident (direct payment) or the third-party Payer from the session context (indirect payment)
    • payment_form = the selected payment form
    • price_at_booking and price_period_unit_at_booking = snapshots of the listing's current price and period unit at the moment of creation
    • hold_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 BookingStateTransition entry: from_status = null, to_status = RESERVED, actor = worker
  31. 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.

  32. Concurrent reservation prevention

  33. 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."
  34. Reservations in terminal states (RESERVATION_FAILED, CANCELLED) do not count against this limit.

  35. Final availability check at submission

  36. 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_booking and price_period_unit_at_booking snapshotted; minimum stay enforced.
  • Story 3.1 (Hold Window Config) — hold window starts at Reservation creation; BookingStateTransition used for hold window tracking.

Out of Scope

  • Provider confirmation (Story 3.3 — Iteration 3). The booking remains in RESERVED state 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

  1. Push notification — RESERVED
  2. 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.
  3. Notification content: listing title and the message from the table above.
  4. The notification is delivered to all devices where the worker's Cruits app is installed.

  5. Push notification — RESERVATION_FAILED (hold window expiry)

  6. When a booking transitions to RESERVATION_FAILED as a result of the hold window expiry (Story 3.1), the Housing Module sends a push notification to the Flexworker.
  7. Notification content: listing title and the message from the table above.

  8. Notification delivery infrastructure

  9. The notification delivery channel (push) must work for iOS, Android, and PWA clients of the Cruits app.
  10. 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.
  11. Delivery is best-effort in this iteration. No retry logic or fallback to email (email fallback is post-MVP per FR-43).

  12. Notification content rules

  13. The listing title in the notification is the ListingUnit.title value at the time the notification is sent.
  14. Notifications are in English.

Dependencies

  • Story 3.2 (Reservation Creation) — RESERVED transition.
  • Story 3.1 (Hold Window Enforcement) — RESERVATION_FAILED transition.

Out of Scope

  • Notifications for PENDING_PROVIDER, PENDING_PAYER, SIGNING, BOOKED, CANCELLED states (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

  1. Entry point
  2. The Cruits app provides a "My Booking" section or tab where the Flexworker can view their active reservation or booking.
  3. If the worker has no active (non-terminal) reservation or booking, the screen shows an empty state with a prompt to browse listings.
  4. If the worker has one active reservation or booking, its full details are shown.
  5. The screen is designed to support future booking statuses beyond RESERVED and RESERVATION_FAILED (Iterations 3 and 4 will add more states) without requiring a redesign.

  6. Status display

  7. The current booking status is shown prominently:
    • RESERVED"Reservation pending confirmation"
    • RESERVATION_FAILED"Reservation expired"
  8. 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."
  9. Booking details displayed

  10. 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
  11. Concrete-mode reservations: the reserved slot (floor / room / bed) is shown.
  12. Pool-mode reservations: "Unit will be assigned by the Provider at check-in."

  13. Hold window indicator

  14. For RESERVED bookings: the screen shows the hold expiry date and time, read directly from the booking's hold_expires_at field.
  15. This gives the worker visibility into how long the unit is held before it is automatically released.

  16. RESERVATION_FAILED state

  17. When the booking has reached RESERVATION_FAILED, the expired status and reason (e.g., "Hold window expired") are shown clearly.
  18. A "Browse listings" button guides the worker back to the listing list.

  19. Access control

  20. A Flexworker can only view their own bookings. Attempting to access another worker's booking is denied.
  21. 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 BOOKED state (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

Comments