Skip to content

Powered by Grav CMS + Helios Theme

Rentals for developers

This reference describes the rental model and its integration with Bookings and KahunaCart.

The core classes use constructor injection and do not depend on Grav. kahunacart-rentals.php registers event handlers; classes/KahunaCartRentals.php connects Grav configuration and events to the core.

Before you begin

  • An API key or signed-in admin session with api.access and the required route permission. See REST API.
  • Shell access for the CLI, and the worker running for anything on the queue.

The model

A nightly property is a KahunaCart product whose Bookings service rule uses mode nightly. Rentals does not maintain a separate property table.

The thing Where it lives
The name, the description, the pictures, the price Core's catalogue
Which bookable it occupies, how many of it there are, its hours, its days off, its outside calendars Bookings
How many people it sleeps (party_size) Bookings' service row
Check-in and check-out, the shortest and longest stay, arrival and departure days, the fees, the deposit terms, the cancellation policy kahunacart_rental_rules
The rate card kahunacart_rental_rates

Variants represent price-changing choices, such as room only or room with breakfast. Service resolution uses variant over product: an option with its own rule has its own rate card; an option inheriting the product rule shares its card. RateRepository does not resolve the service again.

Bookings integration

Rentals registers a booking mode through onKahunaCartBookingsRegisterModes.

Mode\NightlyMode implements BookingMode as a stateless adapter. It returns an Offer without accessing Bookings tables directly. Bookings reserves the offer through its normal locking, expired-hold sweep, availability recheck and unique-key enforcement.

It also implements two optional things Bookings offers:

  • Mode\PaymentPolicy provides refundOnCancel() and balanceOverdueSays(). These return refund guidance and overdue-payment text without moving money.
  • Mode\WholeDays is a marker interface enabling the Stays view and front desk arrival and departure lists.

A mode never reads Bookings’ tables directly. ModeServices provides eligible properties, capacities, schedules, time off, bookings, holds and external blocks. tests/Unit/PluginWiringTest.php checks that this add-on does not query Bookings tables.

The engine also resolves a policy through ServiceRepository::policyFor($productId, $variantId). This supports callers such as the CLI that begin with a product ID rather than ModeServices.

The engine

Engine\NightlyScheduler provides two query methods and one rescheduling rule:

PHP
/** @return list<Night> */
public function nights(int $productId, ?int $variantId, string $fromDate, string $toDate, ?int $excludeBookingId = null): array;
public function nightsFor(array $policy, string $fromDate, string $toDate, ?string $excludeHoldUid = null, ?int $excludeBookingId = null): array;

public function quote(int $productId, ?int $variantId, string $arrivalDate, string $departureDate, int $guests = 1, ?int $excludeBookingId = null): Quote;
public function quoteFor(array $policy, string $arrivalDate, string $departureDate, int $guests = 1, ?string $excludeHoldUid = null, ?int $excludeBookingId = null): Quote;

/** @return array<string, mixed> — night by night, for Troubleshoot */
public function explainFor(array $policy, string $fromDate, string $toDate, ?string $excludeHoldUid = null, ?int $excludeBookingId = null): array;

public function rulesFor(array $policy): StayRules;
public function clockFor(array $policy): NightClock;
public function policyFor(int $productId, ?int $variantId = null): ?array;
public function now(): int;

Engine\NightClock calculates nights by their start date in the property’s timezone. Each night runs from check-in to check-out the next morning. With the default times, this is eighteen hours, leaving six hours for same-day changeovers.

Engine\RateCard calculates prices from supplied bands without database access. The band with the highest position wins for each night. Engine\Occupancy distinguishes single-unit claims from outside-calendar blocks covering all units.

Validation order

  1. guests
  2. min_nights — the band's minimum, else the property's
  3. max_nights, with 0 meaning no limit
  4. arrival_day, departure_day
  5. Per night: past, notice, window, time-off, blackout, external, session, hold, booked, plus the composite need:<label>:<reason>
  6. gap

The first failing check supplies the refusal reason.

The reason codes

Rentals registers seven codes with Bookings’ Engine\Reason, used by Troubleshoot, the CLI and the picker. Other codes are inherited from Bookings. The nightly engine does not emit Bookings’ capacity code (Full).

Code What it says Whose
min_nights Shorter than the shortest stay here Rentals
max_nights Longer than the longest stay here Rentals
arrival_day Stays do not start on that day Rentals
departure_day Stays do not end on that day Rentals
guests More guests than this sleeps Rentals
gap That would leave nights nobody could book Rentals
past That date has been and gone Rentals
notice Too soon Bookings
window Too far ahead Bookings
time-off Time off Bookings
blackout Closed Bookings
external Busy on another calendar — external:Airbnb says which Bookings
session Teaching a class Bookings
hold Held by someone else Bookings
booked Booked Bookings

Engine\RentalReason::ORDER defines reason priority. For gap, Quote::$stranded identifies the unbookable night included in the message. past replaces the corresponding Bookings reason.

Multi-bookable requirements prefix the reason with their label: need:Sea-view rooms:booked displays “Sea-view rooms: booked”. Single-bookable requirements return the unprefixed reason.

The five night states

State The night before This night What a guest may do with the date
free free free arrive or leave
arrival_only taken free arrive
departure_only free taken leave
taken taken taken No arrival or departure available
blocked taken taken nothing — the property is shut

blocked represents a taken night caused by blackout or time-off, and applies only when the previous night is also taken. The first closure date therefore returns departure_only with reason blackout.

Quote response

Public availability includes Engine\Quote::toArray() under mode.quote. The picker uses this response for its summary. Amounts are in minor units of currency.

Key What it is
ok Whether these nights can be had at all; everything below is only worth reading when it is true
reason, says, stranded Why not, the sentence for it, and the night the gap rule named
arrival, departure, night_count, guests What was asked for
nights One entry per night: its date, what it costs, which band it fell in, whether it is a weekend
nights_minor, extra_guest_minor, cleaning_minor The three parts
total_minor What the stay comes to, which is the three parts added up
rows The same, as the breakdown the receipt prints: code, label, amount_minor, discountable
due_today_minor What the guest is asked for now — the deposit where there is one, the total where there is not
deposit_minor, balance_due_at The deposit, and the epoch the rest falls due at (local midnight where the property is)
due_in_full_today True when the property asks for a deposit but the guest is arriving too soon for one
deposit_says One sentence about the two payments, or about why there is only one
damage_deposit_minor A stated amount, never charged here
cancellation The policy as days and percentages
cancellation_says The same policy as sentences a guest reads
timezone The zone every date above is in
label "Sat 9 Aug to Sat 16 Aug, 7 nights"

rows always sums to total_minor, and the cleaning row is the one marked discountable: false.

NightlyMode::offer() returns a Mode\Offer containing the interval and resource selections, plus:

  • price: unit_price_minor and the quote rows.
  • deposit: amount_minor, balance_due_at, remind_at and says, or null. The amount is a monetary value, not a percentage.
  • label: the booking description.
  • facts: unit (always night), nights, guests, arrival, departure, check_in, check_out, their spoken forms, timezone, damage_deposit_minor, due_today_minor and deposit_says.
  • allDay: always true.

Admin routes

Admin routes use /kahunacart/rentals for the rule editor and Rates tab. Guest operations use Bookings’ storefront routes, with rental data under mode.

Rentals reuses Bookings’ view and manage permissions. It declares no additional permissions.

See REST API → Rentals for route parameters and responses. Each route has an MCP tool prefixed kahunacart_rentals_. tests/Unit/McpManifestTest.php checks this correspondence.

Monetary fields use minor units and names ending in *_minor. Routes verify that a service exists and uses mode nightly; otherwise they return 404. Repository validation failures return 422 with an explanation.

An unavailable quote returns 200 with ok: false and a reason. This is a valid query result, not a validation error. A product without a nightly rule returns 404.

rates is ordered by ascending position; later bands take priority. base_minor is the base amount used by percentage bands.

For availability, holds and booking creation, use Bookings’ existing tools. Rentals tools manage rules and rates. See Run the store from an AI assistant.

Database tables

kahunacart_rental_rules

One rule row exists per nightly service, keyed by service_id. reset --orders retains this setup data.

Column Type Means
service_id BIGINT Bookings' service id. Unique per store
check_in_time VARCHAR(5) Wall time in the property's zone, default 16:00
check_out_time VARCHAR(5) Wall time in the property's zone, default 10:00
min_nights INTEGER Default 1
max_nights INTEGER Default 28. Zero is no limit
arrival_days VARCHAR(20) Comma-separated weekday numbers, 0 Sunday to 6 Saturday. Null is any day
departure_days VARCHAR(20) The same
guests_included INTEGER How many the nightly rate covers. Default 2
cleaning_fee_minor BIGINT Once per stay
extra_guest_minor BIGINT Per guest per night over guests_included
damage_deposit_minor BIGINT Stated only — the owner collects it
avoid_gaps INTEGER The orphan-night rule. On by default
deposit_percent INTEGER Null takes the whole amount at once
balance_days_before INTEGER Days before arrival the rest is due
cancellation_json TEXT [{days, refund_percent}, …], longest notice first
pre_arrival_text TEXT Private instructions included only in the arrival email.

Maximum guest capacity is stored in Bookings’ service party_size, not in the rental rules table.

pre_arrival_text is the one rule field that never leaves the admin. NightlyMode::load() takes it straight back out before Bookings ever sees it, so it is never in StayRules, never in effective, never in a quote, never in an offer's facts, never on a receipt and never in an earlier message. See The two messages.

kahunacart_rental_rates

Rate bands are setup data indexed by (service_id, starts_on, ends_on).

Column Type Means
service_id BIGINT Which property
starts_on, ends_on CHAR(10) ISO dates, both ends inclusive, in the property's zone
label VARCHAR(190) What a merchant calls it
nightly_minor BIGINT A price a night — or percent, never both
percent INTEGER Percentage of the base nightly price, from 1 to 1000. 130 means a 30% increase; 80 means a 20% reduction.
weekend_minor BIGINT What a weekend night costs inside this band
weekend_days VARCHAR(20) Which nights count as the weekend. Null is read as Friday and Saturday, never as "no weekend"
min_nights INTEGER Minimum for stays arriving within the band. Bands cannot override maximum stays or departure weekdays.
arrival_days VARCHAR(20) The same
position INTEGER Higher wins where two bands cover one night

These tables store configured prices and fees used to calculate quotes. The agreed payment amounts for a purchased stay remain on its order.

Scheduled jobs

Type What it does
rentals.tick Finds upcoming and recently ended stays, then queues the relevant message jobs. Deduplicated for five minutes.
rentals.send_pre_arrival The message that carries the owner's note. Claims the send before it makes it, so two workers cannot both send it
rentals.send_thank_you Sends the post-stay thank-you email.

Both messages are registered with core notifications and have independent switches. Cancelled, rejected, expired and no-show stays are excluded.

Event handlers

Rentals defines no events of its own. It handles eleven integration events and two autoloader registration events.

Event What it does
onKahunaCartRegisterMigrations Appends this plugin's migrations/ directory
onKahunaCartBookingsRegisterModes Registers nightly, with Mode\PaymentPolicy and the Mode\WholeDays marker
onKahunaCartBookingsRegisterPickerFaces Hands Bookings assets/js/rentals.js, its stylesheet and this add-on's translated words, so the range picker reaches the page
onKahunaCartBookingsRegisterAdminSlots The two screens this add-on draws inside the Bookings section
onApiRegisterRoutes The nine admin routes above, behind Bookings' own permissions
onKahunaCartRegisterNotifications The two messages a stay sends, as rows a merchant can switch off
onKahunaCartRegisterJobHandlers The three job types above
onKahunaCartWorkerTick Queues the tick
onTwigTemplatePaths This plugin's templates/, so a theme can override either message by copying one file
onShortcodeHandlers [kahunacart-availability] and its underscore spelling
onTwigExtensions kahunacart_availability()
onPluginsInitialized / onCliInitialize The autoloader, at priority 100001

Integration handlers check the plugin’s enabled setting. Autoloader registration always runs so existing class references remain resolvable.

Core pricing and deposit events

Rentals does not subscribe to these core events directly. Bookings handles them using the price and deposit returned by the mode. Use these events when debugging pricing or payment schedules:

Event What travels on it
onKahunaCartCartLinePrice Supplies Quote::rows() and Quote::$totalMinor from the hold as included adjustments. The cleaning row is discountable: false.
onKahunaCartOrderPaymentSchedule Supplies the deposit amount and balance deadline. Using an amount avoids percentage rounding across the whole order.
onKahunaCartOrderPartPaid Confirms the booking after a deposit is received.
onKahunaCartOrderBalanceOverdue Triggers the owner alert and prevents arrival-note delivery while payment remains overdue.

See Events for the contracts themselves.

The shortcode and the Twig function

TXT
[kahunacart-availability product="beach-house" months="2" /]
[kahunacart_availability product="beach-house" /]
Attribute What it is
product The product's slug. Required; anything else answers an empty string rather than an error
service Accepted as a spelling of product, for anyone who thinks of it as the service
months Maximum displayed months, from 1 to 12, default 2. Out-of-range values are clamped. Fewer months appear in narrow containers.
TWIG
{{ kahunacart_availability('beach-house') }}
{{ kahunacart_availability('beach-house', { months: 3 }) }}

The second argument overrides defaults from the first. A non-string product or rendering exception returns an empty string. Both shortcode spellings require Shortcode Core. The mount includes the script and stylesheet so assets remain available when processed page content is cached.

The calendar uses the picker’s availability endpoint. See Shortcodes and Twig functions.

Admin extension slots

Admin\SlotHooks mounts rental components into two existing Bookings slots. Rentals does not add a separate admin section.

Slot What goes in it
bookings.service.fields admin-next/slots/service-fields.js — the stay rules, drawn inside the service editor the moment the way of booking is set to nightly
bookings.tabs admin-next/slots/rates.js — the Rates tab: a year of the property's prices with the bands on it to scale

Each mount specifies a script and the custom element it defines. Bookings serves it through an authenticated route and passes ctx, containing escaping helpers, pills, dialogs, stylesheets, currency and the API client. Service fields also receive the service, existing rule and onChange. A script-load failure displays an add-on error.

The rules panel passes its complete flat object through onChange. Bookings’ Save action calls NightlyMode::sanitize() and save() within the service transaction, storing both sets of rules together.

The scripts have no imports or build step. tests/js/slots-render-check.mjs mounts both components against a stub host using route response data. tests/Unit/SlotsRenderTest.php runs that check.

Template overrides

Override the four email files in templates/emails/ by copying matching filenames into user/themes/<your-theme>/templates/emails/:

rental-pre-arrival.html.twig, rental-pre-arrival.txt.twig, rental-thank-you.html.twig, rental-thank-you.txt.twig.

HTML emails inherit the core layout’s colours, logo and footer.

The command line

BASH
bin/plugin kahunacart-rentals nights <product> [--variant=] [--from=] [--to=] [--guests=1] [--quote=A..B] [--explain]
bin/plugin kahunacart-rentals seed [rental|bnb|all] [--timezone=]

product accepts an ID or slug. --from defaults to today in the property’s timezone; --to defaults to one month later. Supply --quote as 2026-08-09..2026-08-16. Add --explain to show each blocked night and its reason.

BASH
bin/plugin kahunacart-rentals nights beach-house --quote 2026-10-08..2026-10-14 --guests 6

The command prints rate groups, extras, total, payment due today and the balance deadline. nights uses the picker’s engine; CommandsTest checks that the CLI does not calculate prices independently.

seed defaults to the rental recipe and lists valid choices for an unknown name. Rentals uses its own Seed\RecipeRegistry because the Bookings registry is a final class with a fixed const array.

Recipe What it writes
rental A beach house: sleeps eight, $249 a night, a summer rate and three stays in the book. See For holiday lets
bnb Four identical doubles as one record, two nights minimum, breakfast as an option. See For B&Bs
all Both

Seed\Recipes\RentalRecipe extends Bookings’ Recipe for shared schedule, location, resource and service setup. Recipes are idempotent and use the store timezone. Seasonal bands use the current year unless that season has passed, then use the next year. Arrival instructions are left empty.

Model limitations

Written up rather than faked:

  • Recurring weekend-only minimums. Minimums apply to a property or dated rate band. Supporting recurring weekday-specific minimums would require an additional field and a quoteFor() check.
  • Imported calendars block every unit on the property record. Separately listed units need separate records and feeds.
  • Mixed-timezone pools use the first bookable’s timezone, determined before the property is selected.

Tests

BASH
../grav-plugin-kahunacart/vendor/bin/phpunit -c phpunit.xml

tests/Support/RentalsFixture.php builds the core, Bookings and Rentals schemas on one in-memory database, using BookingsFixture. MigrationTest and EngineMatrixTest always run on SQLite. Set the same environment variables as core’s matrix tests to include MariaDB and PostgreSQL. The matrix verifies matching results across engines, including CHAR(10) date comparisons with >=.