Salta ai contenuti

0027. Rates, restrictions and reservation quotes

Questi contenuti non sono ancora disponibili nella tua lingua.

  • Status: Accepted
  • Date: 2026-10-27

Administrators create, edit, derive, order and archive room-type rate plans in property settings. Revenue managers and administrators edit nightly prices and all supported restrictions in Rates and availability, singly or over a weekday-filtered range. Reload shows persisted base and computed derived values alongside ledger availability. Every write commits audit, event and an ARI change together. Other roles can inspect; another workspace cannot read or reference the records. Invalid and stale edits preserve drafts. A receptionist requests a quote, chooses a bookable plan, previews and saves a reservation with exact frozen nightly prices. Changed prices, restrictions and availability are checked again on submission. A permitted explicit price override remains visible after reload. Quotes never hold inventory. These criteria include production browser checks. The explicit implementation brief substitutes for a separate feature issue.

Rates belong to the inventory/reservations bounded context. Plans and sparse base-plan night rows use composite workspace/property/type references and forced RLS. Currency is always the property’s, and becomes immutable while plans exist. Per-person sale basis, occupancy curves, age bands and packages followed on 2026-10-31 (see below). Derived plans inherit every restriction, recursively; only price changes. Parents must be active, in the same room type; cycles are refused. A parent with active descendants cannot be archived. No database rows are materialized for derived days.

Derivation percentages use integer basis points; fixed changes use integer minor units on the wire. The UI accepts percentages and amounts in the property’s currency and converts them exactly at the boundary. The final amount is rounded once to a positive integer increment of minor units using the explicit rounding mode. Exact bigint rational arithmetic precedes rounding. Negative or unsafe or non-positive derived amounts become missing prices, never silently clamped. Base nightly prices must be positive too. The same pure calculation serves the calendar, quotes and future ARI publisher.

Calendar windows and bulk ranges are half-open, at most 366 nights, at most 20 selected base plans. A property calendar revision guards writes and Undo with strong If-Match. Undo restores exact prior sparse cells and is refused after intervening rate changes. Plan versions separately guard setup edits. Dates use civil years 0001–9999. The restore endpoint allows 4 MiB for a complete 7,320-cell Undo, an explicit exception to ADR 0016’s 1 MiB default; other routes retain that default. Both are bounded before parsing, and an installation’s explicit maxBodyBytes overrides both. This preserves the supported bulk window without partial Undo writes.

Quotes return every active plan with nights, total and explicit refusal reasons. CTA is checked on arrival; CTD on the departure date (requiring one extra read day); stop sell and minimum stay through apply to every occupied night. Minimum and maximum stay on arrival are checked on the arrival date. Channex’s ARI API defines max_stay as a dated restriction, and its Airbnb mapping guide describes a nightly maximum sent to the channel. Neither public page specifies how a multi-night booking is evaluated against changing nightly maxima; using the arrival date here is an inference, to be checked against Channex certification before ARI push. Occupancy uses room-type capacities. Missing data cannot sell. Submission compares expected quoted nightly amounts to current values and returns 412 if they changed, then freezes the current nights. Only admin/revenue_manager may explicitly override prices; restrictions and availability remain mandatory. An existing room’s sold nights are excluded from its own edit quote. In-house edits preserve the original nightly prices and assignment, and quote only added nights against current prices and restrictions. Legacy manual bookings remain supported; the migration marks them as manually priced, consistently with newly created manual rooms.

ARI outbox rows carry workspace, property, room type, optional plan, half-open range, kind, changed fields and creation time. Bulk writes enqueue affected base and descendant plans. Reservation and inventory writes enqueue room-type availability rows in the same transaction. Setup changes enqueue a 500-night horizon from the property’s local today; the future publisher must also add the newly entering night on its daily tick. Rows have pending, claimed, completed and superseded states, claim generation/owner/time, attempt count and a safe error code. The pending count includes pending and claimed rows, never a sync success signal. A future consumer will coalesce overlapping ranges, claim exact committed row IDs, publish absolute effective values and complete only the rows it read. No consumer, Channex mapping or outbound push is implemented here.

Occupancy pricing, children and packages (2026-10-31)

Section titled “Occupancy pricing, children and packages (2026-10-31)”

Acceptance: an administrator chooses a plan’s sale basis (per room or per person), and for a per-person plan its base and maximum adult occupancy and an occupancy curve; adds an infant and a child age band with nightly fees; and lists the catalog extras the price includes. Derived plans follow their root. A setting Channex or the channels cannot represent is refused at save time with a field-linked message. Revenue managers see each occupancy’s price in the rates calendar. A receptionist quotes and books (several rooms included) with adults, children and each child’s age; the frozen nights are those of that occupancy; confirmation rechecks the quoted amounts. Channex receives per-occupancy prices through the existing publisher. A published plan’s sale basis and the property’s currency cannot change.

  • Sale basis and curve. Channex sells a per-person plan with one occupancy option per adult count (rate plans), at most 18 (size limits), and receives one price per option in the ARI rates array (ARI). A per-person plan therefore sells every adult count from 1 to its maximum (at most 18 and the room type’s adult limit). The calendar keeps one price per night, the price of the base occupancy; the curve turns it into the other counts: per adult (an amount added per adult above the base, an amount taken off per adult below it) or explicit (a signed amount per count, 0 for the base). Explicit amounts are plan settings, not a second per-date calendar: every occupancy follows the base price’s edits, bulk edits, Undo and restore stay unchanged, and Channex still receives absolute prices. A count whose result is not a positive safe amount has no price, never a clamped one, and is unsellable on that night; the publisher then stop-sells the whole night of that plan rather than send a partial rates list. Amounts on the wire are integer minor units, as derivation.amount already is.
  • Derived plans store no curve or bands of their own: they price with their chain’s root and must keep its sale basis. Each occupancy of a derived plan is its derivation of the parent’s price for that occupancy (Channex’s “cascade” behaviour, computed by GuestCraft as for every plan). Packages are per plan: “bed and breakfast” may derive from a room-only plan.
  • Children. Channex prices children only through two per-plan fees, children_fee and infant_fee, “added to normal price … based at Hotel Age Policy”; its per-person options count adults. GuestCraft keeps the age bands: at most one infant band, from age 0, and one child band above it, up to 17, each with a nightly fee. More bands, or a child band below an infant band, are refused as rate_plan.not_distributable. A child older than the last band is refused in quotes (child_age_unpriced): book them as an adult. With bands, each child needs an age (child_ages_required); without bands children are free and ages optional. Channex’s ages are the property’s hotel policy (Properties.Property.hotel_policy_id, then GET /hotel_policies/{id}: infant_max_age and children_max_age, “maximum age at which a guest counts as an infant / a child”): a plan sold through Channex is distributable only when every age from 0 to 21 has the same category under its bands as under that policy; a policy GuestCraft has not read, or that the property lacks, is not exact. Mapping and every publication run also compare the Channex plan’s fees with the bands’. Charging each child’s fee per night is GuestCraft’s declared rule: Channex does not say whether its fees are per night, so this is not a verified equivalence, and no channel is documented to carry the fees (see below).
  • Channels. Channex’s public API lists no channel or channel mapping, so each plan declares where it is sold (distribution): direct only, never published to Channex; or through Channex on declared channels (Booking.com, Expedia, Agoda, Airbnb, Hostelworld). A table of what each channel represents, with the Channex guide it comes from, decides what is distributable: Booking.com (occupancy-based pricing), Expedia and Agoda take every adult count; Airbnb takes one price up to the base and one constant amount per extra adult; Hostelworld one price per room; none is documented to charge Channex’s child fees. A plan whose channels are not declared is held to what every channel with per-guest pricing represents (Hostelworld sells one price per room and must be declared): one constant amount per extra adult, no discount below the base, no percentage derivation (rounding would break the constant amount) and no child fee. One assessment (distributionProblem) runs at save, at mapping and on every publication run, and refuses with the field (occupancy/curve, derivation, distribution, ageBands); at mapping, rate_plan.not_distributable also names the plan. A plan sold direct only may use any local pricing.
  • A plan issue withholds only its plan. A problem found on a publication run in one mapping (one plan on one room type), whether hotel policy ages, a policy not read or missing, channels or fees, Channex’s occupancy options, or a Channex plan that changed, withholds that mapping alone. It is stop-sold without prices; a Channex or local plan that no longer exists sells nothing and is sent nothing; a Channex plan that inherits values GuestCraft does not send is sent nothing and closes its room type’s availability, since its stop sell cannot be trusted. The property’s other plans and room types keep publishing availability, rates and restrictions, and the drift check goes on. A room type otherwise closes only when no healthy plan remains on it. Closing the whole property, as the first version did, would leave every valid plan’s availability stale on the OTAs, an overbooking risk, so only property-wide failures (currency, credentials, degraded intake, internal failure) still close it. Health and the plans_withheld alert name each withheld mapping with its field.
  • Packages are in ADR 0029: the price includes them; they are frozen per night and split on the folio.
  • Quotes and booking. Quotes take adults, children and ages; each night is the occupancy’s price plus the children’s fees. Frozen nights are those of the booked occupancy: editing the occupants of an expected stay on a plan priced by occupancy needs a new quote, and submission compares the quoted nightly amounts as before (412 quote.changed, never a silent change). The quote also answers a fingerprint of the whole offer (plan and terms version, occupancy and ages, nightly prices and the included services as they would be frozen); confirming with it refuses an offer that changed at the same amounts. A validate_only move or resize that re-quotes a room answers the same fingerprint on the previewed room (quoted_fingerprint), and its confirmation sends it back. The fingerprint is required whenever the offer involves a package, that is when the quoted nights or the room’s sold nights include services: the amounts alone cannot show that the services are those shown, so a confirmation without it answers 412 quote.changed, as one without quoted amounts does. It stays optional where no package is involved, so that callers of earlier revisions, which never saw packages, keep working. A manual price is taken as entered, but the services it includes are the offer’s, so it confirms the offer it was entered on the same way; a move or resize keeping the sold prices, a split and an undo confirm no new offer: they keep the nights and services as sold. An in-house stay keeps its frozen nights on any edit, as before; a bulk re-rate reprices its remaining nights at its occupancy.
  • Storage. Migration 0054 keeps the curve and the two bands as CHECKed columns of rate_plans rather than child rows: the bands are at most two by rule, and a constraint can then refuse a non-distributable shape (more than 18 levels, an infant band above the child band, a derived plan with settings of its own) even from another writer. Package items are rows referencing the property’s extras; children’s ages are an array on the reservation room. It is a contract migration: the earlier runtime would read per-person plans as per room.
  • Fixed once published. A Channex plan holds the sale basis (sell_mode, fixed by Channex at creation), the occupancy options (one per adult count) and the child and infant fees. Changing any of them here on a plan mapped to Channex, or on the root of a mapped derived plan, would stop that plan’s publication (the publisher verifies them before every run and withholds the plan on a mismatch), so the save is refused at once: 409 rate_plan.channex_locked with the field (sellMode, occupancy/max or ageBands; any change of the bands, ages included, since Channex’s hotel policy holds the ages), and a published plan cannot become direct-only (distribution). A new plan and a new mapping carry the new setting. The curve’s amounts stay editable: Channex holds none. A plan without bands prices no child, so its Channex plan’s fees are not compared and an existing per-room mapping keeps publishing after the upgrade. A published derived plan cannot be moved to a parent whose root holds other settings (derivation/parentId). Changing the sale basis of a plan with active derived plans returns 422 rates.invalid on sellMode, since they sell on its basis. Moving a per-stay service when a stay is resized keeps its sold value. The property currency already cannot change while plans exist.
  • Updates keep what they leave out. occupancy, age_bands, package_items and distribution are optional on update without a default: an omitted member keeps the stored value, merged inside the property’s write transaction after If-Match and before validation and the publication lock; an explicit null or [] clears it, under the same rules. The contract stays additive (6.7.0).

Acceptance: a revenue manager sets, on a base plan’s nights, the most rooms the plan may sell through every channel together, in the calendar and its bulk edit, with Undo; the calendar shows each plan’s sold rooms against the cap and the OTA rooms that are attributed to no plan. A quote names the reached cap; a direct booking beyond it is refused with the plan and nights, unless an administrator overrides it with a reason, which is audited. Channex receives the plan’s remaining count, reads it back and repairs drift. Channex’s channel availability rules are shown read-only.

  • A seventh restriction. maxSell (0–9999, null for no cap) is a nullable column of rate_days (migration 0071) and a member of Restrictions, so the calendar, bulk changes, Undo, restore and the effective-value evaluator carry it like the other six. A derived plan inherits the value, as every restriction (see above), and counts only its own sales against it; a cap per plan family would need derived rows, which the calendar does not store. 0 and null differ everywhere: 0 sells nothing.
  • The sold count is read, not kept. The deferral above asked for a per-plan sales ledger. GuestCraft counts a plan’s sales on read from the stored reservations instead (RateRepository.sales), with the room-type ledger’s sale definition (inventoryDeltas: not cancelled, nights before the release), keyed by the room’s stored rate_plan_id. A second ledger maintained by deltas would have to be kept exact by every writer (bookings, edits, moves, splits, resizes, cancellations, no-shows, undos, reinstatements, OTA revisions), and could drift; a read of the same rows inside the write transaction is exact by construction. The same delta function (planSaleChanges) decides what a change adds and what is republished.
  • Attribution. An OTA room carries the local plan its Channex rate plan maps to through the ARI mapping, set at import (ADR 0029’s package split already resolves it). An OTA room whose Channex plan is unmapped or absent is unattributed: it counts against no plan’s cap (a product decision: the operator maps the plan rather than GuestCraft guessing it), is listed in the calendar per room type and night, and counted in Channels health (unattributed_nights), so that the operator maps the plan. Enforcement covers attributed sales only, and the screens say so.
  • Enforcement. Quotes add the reason max_sell on a night the room would add to a plan that sold its cap; nights a room already sold on the plan keep selling, as with stop sell. Saving any reservation change checks the plan-nights it adds, all its rooms together, inside the property’s write transaction: every write path takes the property row lock first (the property writer gate), so the check and the write are one serialized decision and two concurrent bookings cannot both take the last room (proved on PostgreSQL). Beyond the cap it answers 409 rate_plan.max_sell_reached with the nights. OTA bookings are facts and are never refused.
  • Override. max_sell_override_reason on a room sells it beyond the cap with the new permission reservations.override_sales_cap (built-in: administrators only). The reason needs the permission whenever it is sent, also for rooms that exceed the cap only together; the audit entry reservation.max_sell_overridden (room, plan, nights, reason) is written only when a cap was exceeded, and the reason is never stored on the room. An overridden sale counts in the plan’s sales like any other, so the cap stays exceeded, never negative.
  • Publication is described in ADR 0034’s “Sales caps” section: Channex receives the remaining count, null removes the limit, a plan with nothing left is also stop-sold.

Formal cancellation and guarantee policies, taxes and fees, quote expiry/holds, and Channex ARI publication (since implemented: policies in ADR 0032, ARI publication in ADR 0034; occupancy prices, age bands and packages above). Cancellation is currently a named flexible/non-refundable/custom-text preset. The existing ARI publication, including certification of maximum-stay semantics, belongs to the sync round.

The property screen uses the repository’s existing MIT TanStack Table v9 cell-selection feature and TanStack Virtual. It has an APG grid, arrow/Home/End navigation, Shift range selection, mouse range selection, F2/double-click price editing, TSV clipboard, fill and Undo. Read-only derived/availability rows are excluded from a writable rectangle. The viewport shows 7/14/31 dates; the public API supports 366-night windows. Instructions are expandable so the operating grid stays visible on shorter screens. No new dependency versions or services were introduced.

At the first-round head, the full quality gate passed with 2,819 tests and the eight existing optional upstream drift tests not run. Global coverage is 96.35% statements, 92.22% branches, 94.93% functions and 97.28% lines; every unchanged package threshold passes. No source exclusions, test skips or retry settings were added for this branch. The shared host uses VITEST_MAX_WORKERS=1. Setting it to two overrides this Vitest version’s project-level serialization and raced the existing HTTP fixtures’ cluster-wide ALTER ROLE calls; the full gate was rerun with one worker. A new component test’s long text entry was reduced to a direct field change, keeping its behavior assertions and the original five-second deadline.

The complete database suite passes 266 tests against PostgreSQL 17 and PgBouncer, including the new tables’ RLS matrix, composite references, sparse Undo, rollback on audit failure, version conflicts and 7,320 nightly values in a constant statement budget. The composed HTTP suite also verifies canonical three-decimal prices, quotes, frozen booking provenance and restriction refusal. The production Playwright front-desk workflow now includes plan creation, range pricing, reload, quoted booking, stop-sell refusal and viewer UI denial. It passes without retries. REUSE, OpenAPI/SDK drift checks, the web production build and Docker image build pass. The complete 7,320-cell Undo payload passes both standalone and mounted API transports; the regression fails with 413 under the former 1 MiB limit. Oversized restores, ordinary limits and explicit installation limits are also tested. The built Docker app verifies those limits at its real /api mount.

Sixteen screenshots and real-browser axe audits cover Rates and New reservation with a selected quote, English/Italian, 1440×900 and 390×844, light/dark. The audit report is /tmp/guestcraft-rates/screenshot-audits.json; screenshots are beside it under screens/. All report zero axe violations, page errors and viewport overflow. The native collaborative preview was used for inspection, then the repository’s Playwright screenshot workflow for exact output dimensions, because native preview saves resized images. Visual review moved quote selection before guest entry, shortened the calendar’s vertical controls and removed the duplicate create action in the new settings empty state. Automated checks do not replace human screen-reader certification.

Local verification uses only ports 3960–3965 and the gc-rates Compose prefix. The initial shared test pooler used trust authentication and too few job-worker connections: password and pooler failure tests correctly failed. It was corrected to SCRAM and per-user pool limits before the passing full gate; no application/test expectations were weakened. The first migration conformance run also caught the new policy name and appended-column order, both corrected in the unpublished migration/schema before the passing gate.

One later full run on the shared host (load around 57 on 32 CPUs) hit seven existing five-second UI timeouts and one follow-on field lookup failure. The same 60 tests passed in isolation, then the final full gate passed with unchanged tests and deadlines. This timing sensitivity remains a verification risk; it was not hidden by retries or skips.

Before first-round delivery, origin/main was fetched and merged at 1ed268f, including the new product status documentation, whose rates row now distinguishes implemented per-room behavior from planned advanced pricing. The parallel front-desk review branch was not available on main. Its separate fixes are outside this branch’s acceptance claim.

Round-two review merged main at f61fee9, including the front-desk review, and rebuilt the OpenAPI/SDK and i18n artifacts. Reports then merged at cf7d338; the combined public API revision is 1.6.0. At the front-desk merge head, pnpm quality passed 2,862 tests with the same eight optional upstream drift tests not run; coverage is 96.4% statements, 92.12% branches, 95.02% functions and 97.32% lines. PostgreSQL/PgBouncer integration includes sold-out edit quotes, frozen in-house nights and transactional availability outbox rows. The built Playwright front-desk workflow passes without retries after the merge. REUSE passes on 766 files, and code generation drift checks, the production web build and the Docker image build pass. Twenty-four round-two screenshots cover Rates, the range editor and the reservation quote panel in English/Italian, light/dark and desktop/mobile; the browser axe, page-error and horizontal-overflow audits are all zero. The report is /tmp/guestcraft-rates/screenshot-audits-r2.json, with images under screens-r2/.