Skip to main content

Documentation

No results found.
Features Members

Venue & Facility Rental

Venue & Facility Rental turns a hall, pavilion, meeting room, or set of grounds into something visitors can check availability on and book — by the hour, the day, or the weekend. Spaces nest, so renting the whole property closes the roo...

Venue & Facility Rental turns a hall, pavilion, meeting room, or set of grounds into something visitors can check availability on and book — by the hour, the day, or the weekend. Spaces nest, so renting the whole property closes the rooms inside it; the rate card varies by day of week; add-ons like a cleaning fee or bartenders price themselves; and each space either books instantly with a deposit or comes in as an inquiry staff quote by hand.


The problem

Renting out a hall is run on a paper calendar and a phone. Two people ask about the same Saturday and whoever calls back first gets it — or worse, nobody notices the double-booking until both parties show up. The rate card lives in a PDF that is out of date. And the appointment-booking tools that a service business uses don't fit at all: they book one person into a fixed-length slot at a fixed price, where a venue rents a variable interval, at a price that depends on the day of the week, with extras, to a group — and the reception room can't be rented while the whole facility is.

The fix

A self-contained feature module under app/Features/VenueRental/ with its own tables, dashboard, and public flow. It is deliberately not an extension of Online Booking: that module is an appointment engine (fixed duration, one price, one person per slot) and this is a resource engine (variable interval, a day-of-week rate card, priced extras, combinable spaces). The two coexist and never share tables — a site can run both.

Like every addon it is a feature module gated by feature:venue_rental middleware. The service provider boots unconditionally; routes 404 and the sidebar entry hides when the feature is off. Its migrations run on every install regardless of the toggle, so nothing else in the CMS breaks when it is disabled.

Spaces, and how they block each other

The core idea. A venue holds spaces, and spaces nest: a Whole Facility space with the Main Hall, Reception Room, Stone Pavilion, and Grounds underneath it.

  • A booking on a space blocks every space it conflicts with — all its ancestors and all its descendants. Booking the Main Hall makes the Whole Facility unavailable; booking the Whole Facility closes every room inside it.
  • Siblings do not block each other. The Reception Room stays rentable while the Main Hall is booked.
  • Some overlaps aren't a tree — the Pavilion sits on the Grounds without either containing the other. Those are added as manual cross-links, and they survive every rebuild of the derived graph.

Conflicts are stored as an explicit table (venue_space_conflicts) rather than walked at request time, because the availability engine reads them on every quote and every calendar day. Derived rows are rebuilt from ancestry whenever a space's parent changes; hand-added rows are never touched by that rebuild. VenueSpace::blockingSpaceIds() is the single accessor everything else uses — it returns the space's own id plus every conflicting id, so callers union in one query.

Opening hours and availability

Each space carries weekly hours — as many windows per weekday as it needs. A window whose end time is at or before its start runs past midnight, which is the ordinary case for an event space open 18:00 to 01:00.

Date overrides handle the exceptions, and they replace that date's weekly hours rather than merging with them. A closed override blacks the date out entirely — the post's own Friday dance night, a holiday, a private event.

From those, the availability engine answers three questions:

  • Is this exact range free? The requested interval has to sit inside the open hours and clear of everything blocking it. When it doesn't, the engine names why — closed, outside hours, already busy, inside the notice window, past the horizon, under the minimum, over the maximum, or multi-day on a space that doesn't allow it — so the booking form can say something useful instead of just refusing.
  • How booked is this day? open, partly booked, booked, or closed, for the month calendar. A day with only an hour left on a three-hour-minimum space reads as booked, because nobody could actually book it.
  • What's busy? Every blocking booking on the space and every space it conflicts with, merged into intervals.

Buffers belong to the space. A booking is padded by the setup/teardown buffers of the space it occupies, and a requested range is padded by the buffers of the space being asked about — so two spaces that each want 30 minutes leave an hour between them.

Multi-day rentals block their entire span, including days with no booking of their own. Their open-hours check is looser by design: the start and the end each have to fall inside a window on their own date, and no date in between may be closed — the renter effectively holds the keys overnight.

"Multi-day" counts rental days, not calendar days. An 18:00–01:00 evening crosses midnight but is one Saturday under the cutover hour (see Rates and pricing below), so it is allowed on a space that doesn't permit multi-day bookings — otherwise the single most common hall booking there is would need the multi-day flag, which would then also permit genuine weekend-long rentals. It still has to sit inside a window, so a hall wanting past-midnight evenings needs hours that say so.

Rates and pricing

A space's rate card is a set of rows, each covering a set of weekdays: a base price covering a minimum number of hours, a per-extra-hour rate, and an optional full-day price. Rows can carry a date window (holidays, wedding season) which beats the plain weekday rows for the dates it covers; the highest priority wins between two rows that both apply.

Multi-day rentals resolve the rate per day and sum, so a Friday-setup / Saturday-event weekend prices off both rows rather than charging one day's rate for both. A day the rental occupies end to end takes the full-day price automatically when the row has one.

Rental days run cutover-to-cutover, not midnight-to-midnight. A hall's Saturday-night party running 18:00 to 01:00 is one Saturday — billing it as a Saturday plus a fresh Sunday base price would be wrong. The cutover hour is a venue setting (default 4am); set it to 0 for plain calendar-date billing.

Part hours round up to the space's booking increment, and hours are real elapsed hours measured between UTC instants — so a rental spanning a spring-forward night bills the hours the venue is actually occupied, not the wall-clock difference.

Extras are priced add-ons — flat, per hour, per unit, or per guest — offered venue-wide or on one space. They can be marked required (the cleaning fee everyone pays, billed whether or not it's chosen) or optional with a quantity and a maximum (bartenders).

The engine returns a line-item breakdown, not just a total: the public form renders it live and the same figures snapshot onto the booking. A space with no rate row covering a date is quote-only — the breakdown comes back unpriced, and the form says the space is quoted by conversation instead of showing a number.

Worked example (the acceptance test): a Saturday, 5 hours, bar with 2 bartenders, on a card of $750 base / 3 hours / $110 per extra hour, a required $150 cleaning fee and a $150-per-unit bartender = 750 + (2 × 110) + 150 + (2 × 150) = $1,420.

Booking modes

Every space carries a booking mode, chosen per space:

  • Instant — the visitor picks dates, sees a live quote, pays a deposit, and the booking is confirmed. Suits a hall with a published rate.
  • Inquiry — the visitor sends a request; staff build a quote in the dashboard and send it; the renter accepts; then the contract and/or deposit follow. Suits outdoor space and anything priced by conversation.

Both flows run through the same wizard and land in the same bookings list.

Holds

Whether a booking blocks its dates is the pair (status, hold policy):

  • tentative, contract_sent, pending_payment, and confirmed always hold their dates.
  • inquiry and quoted hold only when Inquiries hold the date is switched on (off by default, so an unanswered inquiry can't cost the venue a real booking).
  • cancelled and declined never hold.

The effective policy is snapshotted onto the booking when it comes in, so changing the setting later never rewrites what an existing booking does.

Holds in the pipeline (inquiry, quoted, tentative, contract_sent) expire after Hold duration (hours) — 72 by default; a checkout hold uses the shorter minutes-scale setting instead. A background sweep (venue:expire-holds, every 5 minutes) does three things:

  • Abandoned checkouts are deleted. A pending_payment row past its hold is a browser tab someone closed, and this is the same disposal the expired-session webhook performs — so a missed webhook can't strand a Saturday.
  • A lapsed pipeline hold stops blocking but is not cancelled. The deal stays in the list, flagged Hold lapsed, and the dates go back on sale. Moving it forward re-checks that they are still free.
  • A finished rental moves to completed twelve hours after it ends, which is what puts it in the Past tab.

The status machine

VenueBookingWorkflow owns every transition; nothing else writes a booking's status. The graph:

From Can move to
inquiry quoted, tentative, contract_sent, pending_payment, confirmed, declined, cancelled
quoted quoted (a revised re-send), tentative, contract_sent, pending_payment, confirmed, declined, cancelled
tentative contract_sent, pending_payment, confirmed, declined, cancelled
contract_sent pending_payment, confirmed, declined, cancelled
pending_payment confirmed, cancelled
confirmed completed, cancelled
completed confirmed (staff undo), cancelled
cancelled / declined terminal

Any move that starts holding dates re-validates availability inside a transaction with the conflicting space rows locked — because an unheld inquiry's dates can be sold while staff work up a quote. A refusal is surfaced as "those dates were just taken", not as an error page.

What visitors see

/venues — an editable page listing every published space as a card: photo, capacity, a "from" price (or Priced by conversation), and a link through to the space. Enabling the feature materializes this page from the active theme into the normal page tree, so it is editable in the page builder like any other page. It is never overwritten afterwards.

/venues/{space} — the space's own page: photos, seated and standing capacity, square footage, the write-up, the amenity list, the gallery, and three live panels.

  • The rate card — the space's rate rows and add-ons with their prices, read at render time.
  • The month calendar shows each day as open, partly booked, booked, or closed — read from the same engine the dashboard preview uses.
  • The booking wizard runs in three steps: dates and times → add-ons → your details, with a live line-item quote visible from the moment a valid range is picked. Intake questions for the space are asked in the last step, and the rental terms are shown above the submit button.

All three are lazy islands. The space page itself is response-cached (it is identical for every guest), so anything that varies per visitor — the calendar, the wizard, the quote — hydrates client-side after the cached body is served and never bakes into shared HTML. The rate card is an island for a related but distinct reason: it varies over time rather than per visitor, and a price baked into the shared body would keep quoting the old rate after staff changed it (see Why prices are never stale).

Instant-mode spaces with a price go to a Stripe deposit checkout; the dates are held while the visitor pays and released automatically if checkout is abandoned. Instant-mode spaces with nothing payable confirm on the spot.

Inquiry-mode spaces run the same wizard with a different tail. The steps read Dates → Options → Your event, no price is shown anywhere (not even when the space happens to carry rate rows for staff to quote against — the mode, not the absence of a card, is what suppresses it), and submitting sends a request rather than taking money. Two further differences: the notice period is not enforced ("can you do this Saturday?" is exactly the conversation the mode exists to start) and, when inquiries don't hold dates, two people can ask about the same date. The conflict check still applies to both modes, so nobody can inquire about a date that is genuinely sold.

The renter gets a price-free acknowledgement email and lands on their rental page with a "nothing is booked or charged yet" banner.

The quote

When staff send a quote, the renter gets an email with the line-item breakdown and a link to /venues/quote/{token} — the snapshot they were sent, and one Accept button. Accepting moves the booking to tentative with a hold, then hands off to a deposit checkout when money is due.

Two things that page gets right: accepting is idempotent (email clients prefetch links, so a double-click or a prefetch cannot transition twice or mint two holds), and the dates may be gone — a quote can sit for two weeks, so acceptance re-validates and says so plainly rather than optimistically confirming. A quote's validity window is snapshotted when it is sent, so shortening the setting later can't lapse one already sitting in someone's inbox.

A deposit paid against an accepted quote does not move the booking through pending_payment. That status means "a row minted solely to carry a checkout", and both the sweep and the expired-session webhook delete such rows; an accepted quote is a real deal that has to survive an abandoned payment, so it keeps its own hold and simply gains a Stripe session.

After booking, the renter lands on a token-addressed page showing the rental, its line items, what's paid and what's owed, an Add to calendar .ics download, and — inside the cancellation window — a self-serve cancel. A confirmation email goes out with the same .ics attached, and the venue's notification address gets a copy.

The wizard's availability verdict is advisory. The authoritative check runs inside the write transaction, with the conflicting space rows locked first, so two visitors racing the same Saturday can never both win — the loser is told the dates were just taken.

Payments

The venue module shares the site-wide Stripe keys (Settings → API Keys) and adds its own webhook endpoint at venues/stripe/webhook with its own signing secret (Venue → Settings), so it can be wired up in Stripe without disturbing the shop, donations, or booking endpoints. A paid deposit is confirmed by whichever of the webhook or the Stripe return page lands first — an atomic claim on the booking makes the other a no-op, so the confirmation email is sent exactly once.

The public booking form keeps normal CSRF protection even on the cached page; only the provider-posted webhook is exempt.

What the dashboard gets

Under Dashboard → Venue Rental:

  • Rentals — the list, in four tabs: Upcoming (what is actually sold), Inquiries (what needs a quote, badged with a count), Past and Cancelled, with a search across name / email / organization and a space filter. A Month view switches the same data to a calendar across every space at once — a multi-day rental appears on each of its days — because "is that weekend free?" is the question staff get asked on the phone. New booking takes a rental on the renter's behalf: notice periods are skipped (the phone rings about tonight) but the conflict check is not, so staff cannot double-book any more than a visitor can. An end time earlier than the start means the evening runs past midnight.
  • A rental's own page — the quote builder for anything still being negotiated: adjust the hours, add-on quantities and a discount, watch the breakdown recompute, then Save without sending or Send quote. The draft only touches the booking when you save, so a half-typed discount never becomes what the renter owes. Beside it: the renter and event details, the intake answers, internal notes, the renter-facing links, and the status actions the workflow currently permits (accepted-by-phone, confirm, mark completed, decline, cancel). Once a rental is past the quoting stage the builder is replaced by the agreed breakdown.
  • Venues & Spaces — the properties and the nested spaces inside them, shown as an indented tree. Per space: the write-up, the amenity list (one per line), a main photo and gallery from the media library, capacities, square footage, booking mode, minimum/increment/maximum hours, multi-day permission, setup and teardown buffers, notice required, booking horizon, deposit override, whether it needs a signed lease, and the hand-added cross-links. The parent picker refuses to nest a space inside its own sub-space, and deleting a space promotes its children to the top level rather than wiping a subtree.
  • Availability — weekly hours and date overrides per space, beside a month preview rendered from the same engine the public calendar reads, so what staff see is what a visitor would.
  • Rate Card — the rate rows for the selected space, the venue's extras, and the space's intake questions, with a live worked example showing what the saved card actually charges. Extras are updated in place rather than deleted and recreated, so an existing booking's line item keeps pointing at the thing it was sold.
  • Settings (admin only) — currency, the default deposit (none / fixed / percentage), hold duration, whether inquiries hold the date, how long quotes stay valid, the cancellation window, the rental-day cutover hour, reminder timing, the transactional sender and owner notification address, the Stripe webhook signing secret (write-only), the rental terms, and the lease template.

Plus a Rentals card on the main dashboard: how many rentals are still ahead of you, with the number of inquiries and quotes waiting on a human as the sub-line — an unanswered inquiry is the one thing here that costs money when nobody notices it. It appears automatically while the feature is on and can be switched off from Dashboard → Customize.

Page-builder rows

Enabling the feature adds a Venue category to the design library — the row picker, the browse page, and the editor's add-row drawer — and hides it again when the feature is off, so an install that doesn't rent anything never sees it.

Row What it does
Spaces Grid Every published space as a card: photo, capacity, a "from" price (or Priced by conversation), and a link through to its own page. This is the row the /venues page ships with.
Availability Calendar The live month calendar — open / partly booked / booked / closed — read from the real bookings.
Booking / Inquiry Form The rental wizard, following the space's own mode: a deposit checkout on an instant space, a request on an inquiry space.
Rate Card Your rate rows and add-ons, read live from Dashboard → Venue Rental — what each day costs, what it covers, the price of each extra hour, the optional full-day price, and any holiday/season date window. Edit the rate card in the dashboard and the page updates itself.
Space Detail A photo beside the write-up for one space: capacity, square footage, the amenity checklist, and a button through to check dates. For featuring your main hall on a homepage.
Photo Gallery Photos of the room set up for real events, click-to-enlarge.
Check Your Date Band A compact full-width band asking the one thing every renter wants to know, linking to the rental page.

All four live rows (spaces, rate card, calendar, wizard) are lazy islands, so they work on a response-cached page. Dropped on a page that isn't a space page they have no space in scope, so the calendar and the wizard fall back to your first published space — the right answer for the single-hall installs that are most of them, and a multi-space venue puts those two on each space's own page instead. The rate card is the exception: with no space in scope it shows every published space, each under its own name, which is what a "What it costs" page on a multi-space venue actually wants. A space with no rate rows reads Priced by conversation rather than an empty table.

Why prices are never stale

A published price that keeps showing after staff change it is worse than no published price, and the public pages are response-cached — one body served to every guest. Two mechanisms keep the rate card honest, and they cover different things:

  • The rate card is an island, so it hydrates after the cached body and reads the rate rows at that moment. This is what makes the row correct even on a page nobody thought to invalidate — an editor's own landing page, say.
  • Saving venue inventory forgets the cached pages. Changing a rate, an add-on, a space, or the venue itself forgets /venues and the affected /venues/{slug} (and their /{lang} variants). That is what keeps the rest of a space page current — the name, the write-up, capacity, amenities, photos — none of which is an island. Renaming a space also forgets its old URL, so the previous slug can't keep serving the page it no longer is.

Bookings deliberately do not invalidate anything: nothing about a booking renders into cached HTML (the availability calendar is an island for exactly this reason), and clearing on every booking would throw the site's cache away several times a day.

Renter reminders

Confirmed rentals get one reminder email, N hours before the event (Venue → Settings, 72 by default). It repeats the details, attaches the calendar invite again, and — when there is one — leads with the balance still to settle, because the worst time to discover it is on the day.

The sweep (venue:send-reminders, every 5 minutes) claims reminder_sent_at atomically before sending, so an interrupted pass, two workers, or a re-run can never double-send; a failed send releases the claim so the next pass retries. On an install with no email transport configured it does nothing at all rather than claiming, failing, and filling the log.

Staff calendar feed

Each venue has a read-only ICS subscribe URL on the Venues & Spaces page — copy it into Google Calendar, Outlook, or Apple Calendar and the venue's booked dates show up beside everything else staff already keep there. Replace the link rotates the token, which immediately breaks every URL already in someone's calendar app.

The feed carries every rental that holds its dates, not only the confirmed ones — a staff calendar exists to answer "is that Saturday spoken for?", and a tentative hold is exactly the thing that otherwise gets sold twice. Held-but-unconfirmed rentals are marked TENTATIVE so a calendar app can show them differently.

It carries no contact details. The event title is the space plus the renter's first name, and there is no description — the URL lives in a third-party calendar account, so emails, phone numbers, layout notes, and prices never enter it.

CRM

With the CRM enabled, every renter lands there automatically: a contact matched on email (created once, updated never-destructively) and a timeline entry for each moment that matters — the inquiry, the booking, the cancellation. A repeat renter gets one record with their whole history, and booking again re-fires the source-scoped automation rules that only run on a contact's creation.

With CRM disabled nothing is created and no CRM class is constructed — the same rule the Contracts and Client Billing integrations follow.

Leases

A space can be marked Requires a signed lease. When it is, the rental gains a lease step between the agreement and the date being locked in.

With Contracts and Client Billing enabled and a lease template chosen (Venue → Settings), the module generates the lease and hands it to Contracts for e-signature:

  • The renter is matched onto a Client Billing client by email — one record and one history for a repeat renter — created as a Prospect. Signing is what makes them Active, the same rule appointments follow.
  • The template body is merged in two passes: the venue tokens resolve in this module first, then Contracts resolves its own client/business tokens. Neither pass has to know about the other, because ContractMerge leaves tokens it doesn't recognise exactly as it found them. The only change to Contracts is that the template editor now lists the venue tokens too.
  • The lease is emailed to the renter and the rental moves to Lease sent. It moves even if the mailer isn't configured, so staff can share the contract link by hand rather than having the rental hide behind a failed send.
  • Issuing is idempotent — a rental already carrying a lease gets that lease back rather than a second one.

When an inquiry-mode renter accepts a quote for a lease-requiring space, the lease is issued automatically at that moment; the accept page then offers Review and sign the agreement instead of the deposit button.

Merge tokens

Available in a contract template alongside the standard Contracts ones:

Token Resolves to
{{venue_name}} The venue's name
{{venue_space}} The space being rented
{{event_date}} Rental date, e.g. Saturday, September 5, 2026
{{event_time}} Start and end time in the site timezone
{{event_hours}} Billable hours, e.g. 5 hours
{{event_type}} Type of event as the renter gave it
{{expected_guests}} Expected guest count
{{rental_total}} Rental total
{{deposit_amount}} Deposit amount
{{balance_due}} Balance still owed
{{rental_terms}} The rental terms text from Venue → Settings

They only appear in the template editor's hint list when Venue Rental is enabled.

Signing advances the rental

Signing is pushed, not polled. Contracts dispatches a ContractSigned event from inside the atomic claim that records the signature, and Venue Rental listens for it. Where the rental lands depends on the money:

  • Nothing outstanding → Confirmed, with the usual confirmation email and .ics.
  • A deposit still owed, and Stripe configured → the rental stays at Lease sent on its hold, and the renter's page shows a Pay the deposit button. Confirming an unpaid rental would give the date away for free.
  • A deposit still owed but no card processing configured → Confirmed anyway. A venue that takes a cheque has no checkout to send anyone to, and the balance goes out as an invoice instead.

The renter's rental page carries the link into checkout by token, so it works from an email days later in a browser with no session.

Without Contracts / Client Billing

The module degrades rather than disappearing. With either feature off — or on before a lease template has been picked — the rental's Lease card shows a Mark lease signed action with a date, and the flow proceeds exactly as it does after an e-signature: the same code decides what a signature means, so the two paths can never drift. Staff can also clear a mis-ticked signature, which never moves the status.

Venue Rental is fully usable with no other addon enabled: with Contracts off, no contract or client row is ever created and no Contracts class is constructed.

Billing the balance

The deposit is what Stripe takes up front. The rest — the extra hours, the bartenders, the damage found on Monday — is billed afterwards, which is how halls actually get paid.

Bill the balance on a confirmed or completed rental raises a draft invoice in Client Billing (draft, not sent, because staff routinely add a line before it goes out). Unlike the equivalent action on an appointment it is itemized: the rental itself, each add-on as it was sold, any discount, and a Less deposit already paid credit line. Those lines sum to the balance due — the credit is what stops the deposit either vanishing or being charged twice.

It is idempotent via the invoice id stored on the rental: a double-clicked button returns the invoice that already exists, and the action disappears once one is raised. With Client Billing off, the whole Billing card is absent.

Refunds

Cancelling a paid rental from the dashboard offers Refund … to the renter. The refund runs through Stripe before the cancellation, so a declined refund leaves the rental untouched and staff can retry — a cancelled-but-unrefunded rental is the state nobody notices until the renter calls. Refunds arriving from Stripe's own side (charge.refunded) are recorded monotonically, so a stale webhook can never lower the recorded total.

Timezones and money

  • starts_at / ends_at are stored as UTC instants and displayed in the site timezone (DateValue::siteTimezone(), Settings → Business). Opening hours and date overrides are wall-clock strings in that same zone, so an event doesn't shift an hour across a DST boundary.
  • Every amount is an integer *_cents column. The quote is snapshotted onto the booking, so editing the rate card later never changes what someone already agreed to pay.
  • The booking horizon is per space and uncapped — deliberately unlike Online Booking's 365-day ceiling, because a wedding is routinely booked more than a year out.

What it's not (yet)

  • Two-way calendar sync (Google / Outlook OAuth) is out of scope for v1. The read-only ICS subscribe feed above is one-directional: what staff block out in Google does not come back and close dates here.
  • Recurring rentals (a club that meets the second Tuesday of every month) are not modelled; each date is booked individually.
  • One reminder, email only. There is no second nudge, no SMS reminder (unlike appointments), and no per-space override of the timing.
  • The Rate Card publishes the rate rows verbatim. There is no way to show a simplified "from £X" summary while the engine charges something more detailed — if you want the published card to read differently from what the quote engine does, the rate rows themselves are the only lever.
  • The availability calendar and booking wizard dropped outside a space page show the first published space. There is no per-row space picker. (The rate card handles this differently — it shows them all.)
  • Per-space damage deposits held and released separately from the rental deposit are not modelled; charge damages as a balance invoice.
  • A declined lease doesn't move the rental. Contracts emails the owner that it was declined; staff decide whether to cancel or re-issue.
  • Partial refunds aren't offered from the dashboard — the Refund action returns everything paid. Refund a smaller amount in Stripe and the charge.refunded webhook records it.