Changelog

Every dated entry names the release, the new surfaces / behaviours, and any migration notes. New capabilities are additive; a partner who ignores a new field on read keeps working. When we change existing behaviour (rare) it gets a ⚠ Breaking tag with the concrete migration path.

Versioning: the URL carries a major version (/v1/qc). Everything under one major stays backward-compatible for callers that ignore unknown fields. When we need to break, a new major is introduced alongside /v1/qc for at least 6 months before the old one is retired.

2026-10-09 β€” Bookings list and revision ack fixed; documentation corrected

2026-09-02 β€” Response layer completion: envelope, names, money, PII, pagination, filters

Consolidated a week of touch-ups on the QC response contract into one changelog entry so partners see the full picture from one read. Additive across the board unless flagged; the shape rules are now consolidated in one page β€” see the new Wire conventions.

Ops changes

Behind-the-scenes

The QC calendar write path and the dashboard's own calendar write path are being merged into a single kernel service (Stage 6 on our roadmap). Step 1 β€” the interface + scaffold β€” landed with no caller migration; no partner-visible behaviour change today. Steps 2-4 (route QC through the kernel; route dashboard behind a staged rollout flag; retire the old paths) are on a separate 2-3 week track.

2026-08-31 β€” Docs: endpoint-level scoping, analytics scope, age-groups reference

Reference coverage caught up with the code that shipped this sprint. Docs-only β€” no wire change.

2026-08-31 β€” Stage 6 calendar-cores convergence plan parked

resources/docs/qc/stage-6-calendar-convergence-plan.md documents the follow-up work Gorian's 2026-08-31 summary left as "Π€ΠΈΠ½Π°Π» ΠΏΠΎΡ€Ρ‚Π° β€” Stage 6 + Phase 5". Everything shipped in this sprint (PRs #2400 – #2411) lands cleanly on the existing dual-core arrangement β€” Stage 6 is a 2-3 week refactor that consolidates the QC and dashboard calendar-write paths through a single kernel service. Migration order, invariants to preserve, and a GrowthBook rollout gate are pinned in the plan doc; the actual code work belongs to its own ticket.

2026-08-31 β€” Age-groups on a property (last pricing layer)

GET + PUT /v1/qc/properties/{ext}/age-groups. Reads / partial-updates the property's I (Infant) / C (Child) / T (Teen) brackets β€” the from_age / to_age bounds the pricing engine uses to route a guest of age N into the right slot on every quote (ChildrenGroupsResolver).

Wire cleans up the legacy '0' / '1' is_active strings to booleans and returns groups in canonical I/C/T order (matches the resolver, deterministic for UI snapshots).

PUT is idempotent + returns a preview block:

{
  "is_active": true,
  "groups": [...],
  "preview": {
    "is_active_changed": false,
    "changed_groups": ["C", "T"],
    "reprice_note": "Quote endpoints read children_settings on every request; the new brackets take effect on the very next quote. Bookings already saved with a resolved price are unaffected."
  }
}

An inverted range (from_age > to_age) is rejected with errors.qc.age_group_range_inverted before write.

Scope: properties.manage. Property must be in the token's allow-list.

2026-08-31 β€” Analytics via QC (Layer F)

Read-only proxy over the same analytics service the hotelier dashboard uses, with three deliberate departures that make it safe to hand a machine token to a PMS partner (Gorian's 2026-08-31 spec):

Six GET endpoints under /v1/qc/properties/{external_property_id}/analytics/…: overview, traffic, funnel, campaigns, bookings, pace. All require the new analytics.read scope + the property being addressed to be in the token's property allow-list.

New surface bits: QcScope::AnalyticsRead, MoneyAmount value object, PiiMasker application service.

See concepts-analytics.md for the full endpoint contract and qc-v1.yaml for the wire schemas.

2026-08-31 β€” Endpoint-level token narrowing (Layer 3b β€” catalogue + naming)

Second half of the scope Γ— endpoint Γ— property model. Layer 3a shipped the gate + storage; Layer 3b makes the vocabulary real:

Wire-level: an existing token with no endpoint_scopes is completely unaffected β€” the empty-list bypass from Layer 3a still stands.

2026-08-31 β€” Endpoint-level token narrowing (Layer 3a β€” infra)

Foundation for scope Γ— endpoint Γ— property, the third leg of the QC authz model. Every QC credential (bearer + OAuth client) can now carry an endpoint_scopes list β€” a per-route allow-list on top of the existing scope check. Empty list = no endpoint restriction (every existing token stays fully functional; the new gate is strictly opt-in at mint time).

How it composes: RequireQcScope runs scope check first (as before). On success, if the token's endpoint_scopes list is non-empty, the request's route name must match an entry β€” either exactly or under a qc.<resource>.* wildcard. Miss β†’ 403 errors.qc.endpoint_not_allowed (distinct from insufficient_scope so callers can tell "wrong scope" from "wrong endpoint under the right scope"). Miss on scope short-circuits before the endpoint check ever runs, so a mis-populated endpoint_scopes list can never widen access.

Auto-incorporation: qc.promotions.* covers every future qc.promotions.<x> endpoint we ship, so partners don't need to re-issue tokens as we grow the surface. The wildcard prefix boundary is a literal . β€” qc.rate_plans.* never leaks into qc.rate_plans_v2.*.

This is Layer 3a β€” the infra half. Layer 3b (naming all 108 existing QC routes + a GET /v1/qc/permissions catalogue + boot-time guard test + endpoint_scopes validation on mint) ships as a separate PR since it touches the whole route table. Until Layer 3b lands, endpoint_scopes populated in the DB will still work β€” the middleware reads it β€” but the token-mint UI has no vocabulary yet, so any list has to be entered raw.

2026-08-31 β€” Derived (child) rate plans can be created via API

POST /properties/{ext}/rate_plans accepts two new optional fields β€” a partner can now create a real derivative rate plan without a dashboard round-trip:

QC calls pass advancedConfigAllowed=true to CreateRatePlan β€” the customer-group advanced_rate_plan_config paywall is a hotelier-flow concern; QC clients are contracted partners.

Response echoes parent_rate_plan_id in every returned mapping so the partner can round-trip the derived-plan id it just minted. Combined with the price-write guard shipped in the same batch, the full derived-rate lifecycle is now API-round-trippable.

2026-08-31 β€” Derived rate plans refuse price writes

Both QC price-write doors now refuse to accept prices for a rate plan whose underlying legacy row has has_parent_rate = 1:

Why: UpdatePrices::executeBatch (MNG-170) strips prices on child plans at write time β€” the next chain-resolve would overwrite anything the QC surface accepted anyway. Silent accept-then-lose violates the QC "no silent drop" doctrine (same rule as Finding 1 from the 2026-08-28 e2e). Restrictions on child plans live on their own row and remain independently writable.

Wire-level: additive on the write paths β€” a partner that never pushes prices to a child sees no change.

2026-08-31 β€” Promotions request body β€” typed OpenAPI schema

POST / PATCH /properties/{ext}/promotions no longer document their body as additionalProperties: true. The PromotionInput schema in qc-v1.yaml now names every field, enum, and cross-field invariant, so a partner or a code generator reading the spec knows exactly what to send β€” no more "guess through 422".

Wire-level: nothing changes. PromotionQcRequest was already the source of truth server-side, and the schema is a faithful projection of it (including the resv_to_time > resv_from_time QC-only rule with "00:00" / "24:00" bypass and the mobile-type stay-conditions exception).

A separate follow-up will do the same treatment for payment_policies, cancellation_policies, extra_services, and content_pages.

2026-08-31 β€” Brand-new property auto-seeds default policies

POST /rate_plans no longer returns errors.qc.policies_missing when the property has no payment or cancellation policy yet. Legacy behaviour was "hotelier must click Create Policy manually" before the API can create a rate plan β€” a partner who is provisioning everything through the API can't clear that step.

The call now inserts a placeholder of each kind at request-time before creating the rate plan:

Both rows are indistinguishable from hotelier-created rows in the schema (there is no is_default marker), but the API default β€” prefix in the name makes them obvious in the dashboard so the hotelier renames them the first time they log in. A repeated POST /rate_plans against the same property does NOT re-seed β€” firstIdForProperty short-circuits before every create.

Explicit payment_policy_id / cancellation_policy_id on the request still win end-to-end; only the omitted side falls through to the seed.

⚠ Breaking (behaviour, not wire): partners who previously depended on the 422 as a signal to trigger a manual "create-policy" flow no longer see it. errors.qc.policies_missing is retired from the surface. Partners can still POST explicit ids if they need a specific policy shape.

2026-08-31 β€” Account-scoped tokens

The final gap Gorian flagged in the 2026-08-28 prod smoke β€” a credential minted before a hotel is enrolled 403-s on the new hotel β€” now has an answer that doesn't need a rotate.

Every credential (bearer qc_… and OAuth client) can be minted account_scoped: the fixed property_ids list is skipped, and AuthenticateQcRequest resolves the customer's live set from qc_property_maps Γ— qc_groups on every request. A property enrolled after the credential was minted is reachable within the next call; a disabled property or a paused group drops out at the next call too. The customer boundary is enforced in the SQL join, so nothing crosses accounts.

Explicit empty property_ids on a non-account-scoped token stays deny-all β€” the account-scoped path is opt-in, not a re-reading of the empty-list case.

See concepts-auth.md Β§ Account-scoped tokens for the decision tree and the exact wire shapes.

2026-08-28 β€” Create-time pricing model layers C + D land

Closes the epic Gorian scoped after the prod e2e review. Layers A / B / E landed in an earlier PR today; the remaining two are now on dev.

Layer C β€” optional explicit occupancies in POST /rate_plans. Wire shape (per Gorian's design):

"occupancies": [
  {"code": "A2"},
  {"code": "A1", "pricing": {"derived_from": "A2", "adjust": "-10%"}}
]

pricing.adjust is [sign][number]% for a percentage or bare number for a flat amount; missing pricing = manual. Translated to legacy rooms_rates.settings by a new QcOccupancyWireTranslator VO (7 unit tests, 7/7 pass). RoomRateRepository::create gains an optional ?array $settings param β€” matches the existing update() shape. Multi-room + occupancies returns errors.qc.occupancies_require_single_room because a hand-written list cannot fit heterogeneous rooms; the auto-derive path (Layer B) covers multi-room.

Layer D β€” PUT /rate_plans/{id}/pricing-model. Idempotent, declarative escape hatch. Body: { sell_type, occupancies?: [...] }. Walks every non-deleted rooms_rates row for the plan, reads per-row _version, calls RoomRateRepository::update with either the wire settings (single-room) or null (auto-derive per room). Mirrors sell_type onto the QC-side QcRatePlanMap when it changed. Derivative rate plans (has_parent_rate=1) carry no rooms_rates and are skipped by construction. Response echoes pairs_updated + explicit_occupancies for observability.

Docs β€” concepts-pricing gains a section per layer (create-time optional occupancies, declarative pricing-model reset). OpenAPI extended.

2026-08-28 β€” Create-time pricing model + Finding 1 error echoes vocabulary

Design agreed with Gorian after the prod e2e review: set the pricing model at rate-plan CREATE, not as a separate step. Closes Finding 2 in the natural place.

Layer A β€” sell_type accepted on POST /rate_plans. The rule was in:per_room; relaxed to in:per_room,per_person,per_occupancy. The controller now maps the wire name to legacy sell_type INT once (sellTypeToLegacy helper) and passes it into RoomRateRepository::create for every room in the request.

Layer B β€” occupancy vocabulary auto-derives from bed capacity. When sell_type is per_person / per_occupancy, RoomRateRepository::create feeds each room's acm_settings blob into buildSettings; the standard A1/A2/AEB/CRB/… vocabulary materialises then and there. No hand-list needed; heterogeneous multi-room requests get the right per-room vocabulary per row. Closes Finding 2 β€” a partner PMS + AI agent can create a real per_person pair in one declarative call without a dashboard step.

Layer E β€” unknown_acm_code rejections echo allowed_codes. Same error shape as before ({code:'unknown_acm_code', unknown_code:'A1', date:'…'}) but the row now also carries allowed_codes β€” the pair's full legal set. A partner can self-correct without a second GET /rate_plans/{id} call. 1 new unit test on top of the Finding 1 baseline (13/13 pass).

Docs cleaned up: the "per-person needs a dashboard step" warning was replaced with the new create-time flow. The migration guide and Property/getPropertySettings reference follow the same lead.

OpenAPI updated: POST /rate_plans sell_type enum now lists all three; POST .../rates unknown_acm_code error row documents allowed_codes.

Deliberately parked as the next scoped chunk of the same epic:

2026-08-28 β€” Audit follow-ups on the consolidated read

Self-audit on the same-day period-pricing read caught 5 real issues, all fixed together:

Also tightened: unknown_acm_code error message uses ASCII (rate_plan, room_type) pair (Unicode Γ— was log-unsafe); vocabularySet docblock now reflects that values are the array_flip index (unused; only key existence is checked).

5 new unit tests (14/14 pass on RatesControllerIndexTest):

2026-08-28 β€” Consolidated period-pricing read + per-person bed-layout caveat

Two follow-ups from the prod e2e review 2026-08-28.

New β€” consolidated period-pricing read. GET /rate_plans/{id}/rates?consolidated=1 returns the same per-day data collapsed into {from, to, prices} periods per (rate_plan Γ— room_type) pair. Consecutive dates carrying byte- identical prices maps merge into one row; a gap day (empty prices) closes the current period and does NOT reopen it β€” the next non-empty day starts a fresh period. A partner pulling a year gets a handful of periods instead of 365 rows. Restrictions are NOT part of the equality key in this pass; combine with the per-day read to reconcile restriction changes. 4 new unit tests cover the identical-run merge, the price-change split, the gap-and-reopen case, and the empty-calendar case.

Documented β€” per_person pairs need a dashboard bed-layout. The A1/A2/AEB/CRB/… vocabulary is computed from the room's acm_settings (bed layout). Today the API does not expose the bed-layout write, so a partner PMS can create a room + rate plan

2026-08-28 β€” Rates write validates the occupancy vocabulary

POST /rate_plans/{id}/rates used to accept any occupancy code in prices and pass it verbatim to the underlying writer β€” which silently dropped codes not on the rate_plan Γ— room_type pair's vocabulary. Response reported accepted:1, errors:[] and the values vanished. Reported by prod e2e testing 2026-08-28.

Now every code in prices is checked against the pair's vocabulary (resolved once per batch via RoomRateRepository::acmCodesByPairs). Codes not in the vocabulary land in the per-row errors[] array as {code:'unknown_acm_code', unknown_code:'A1', date:'…'} and do NOT count as accepted.

A pair with no configured vocabulary (rare β€” no rooms_rates row yet, e.g. mid-setup) skips validation so the fix isn't a regression for the parity harness or the legacy bridge that touch such pairs during setup.

RoomRateRepositoryInterface gains acmCodesByPairs so the controller depends on the Domain contract, not the Infrastructure class. See error dictionary for the full per-row error catalogue.

2026-08-28 β€” Layer 4 event emitters + observer wiring + concepts-bookings + OpenAPI

Three parallel workstreams:

Layer 4 emitters. QcEventEmitter now carries a method per QcWebhookEvent case β€” the enum is 35 events strong, 18 method stubs were missing. Every emitter method reads as one line at the call site ($this->emitter->bookingCancelled($pid, $payload)) and cannot address a non-existent event because the method name is enum-driven.

QcInboundBookingObserver now fires webhooks alongside its revisions on the four transitions that already emit revisions: booking.created on staged→promoted (existing), plus booking.cancelled on cancelled_at write, booking.no_show on no_show_at write, and booking.modified on any watched- column dirty check. One transition = one revision + one webhook, or neither if the property map has already been retired. Three new unit tests cover the added webhook paths; the three original revision-focused tests were adjusted to stub findById → null so the webhook side silently no-ops without expectations churn.

concepts-bookings enrichment. Adds full sample revision payload (with changed_fields hint, echoed partner codes), ordering guarantees (in-booking ordered by revision_number, across-booking unordered, webhooks unordered by design), reconciliation patterns (feed catch-up as default, weekly snapshot reconciliation), status catalogue (tentative / confirmed / cancelled / no_show / checked_in / checked_out), cancellation block (who cancelled, reason_code, policy_snapshot at booking time, fee_charged), the bookings/health counters (last_revision_at, pending_revisions_count, ack_lag_seconds), and the what-we-don't-do trailer.

OpenAPI qc-v1.yaml. Adds two new common responses β€” IdempotencyConflict (409) and RateLimitExceeded (429) with the full header set β€” plus a components/headers block that documents every cross-cutting response header the spec was missing: X-RateLimit-{Limit,Remaining,Reset,Bucket}, Retry-After, Idempotency-Replayed, Idempotency-Original-Timestamp, X-Qc-Request-Id, X-Qc-Support-Ticket.

2026-08-27 β€” Domain model concept enrichment

concepts-model picked up three sections it was missing:

2026-08-27 β€” Inventory concept enrichment

concepts-inventory picked up five sections that were previously scattered or missing:

2026-08-27 β€” Rate-limits + environments + recipes

Three more concept pages that were previously scattered across concepts-auth and SLA.

2026-08-27 β€” Quickstart + idempotency deep-dive

2026-08-27 β€” Migration guide PMS v1 β†’ QC

New migration-from-pms-v1 page: end-to-end PMS v1 β†’ QC map. What actually changes (auth / response shape / errors / idempotency / scoping / limits / webhooks / docs versioning), endpoint-by-endpoint mapping table per surface (availability, bookings, property/catalogue), the three-move minimum to get started (OAuth client β†’ read property once β†’ repoint one endpoint), and an FAQ addressing "can I run both at once", "will PMS v1 shut off", "does api_key keep working".

Linked from the index Where-to-start block and from the PMS v1 facade overview.

2026-08-27 β€” Dedicated webhooks page + PMS v1 byte-strict error catalogue

Two things partners kept asking for that were half-answered.

2026-08-27 β€” PMS v1 method reference β€” every method covered

Closes the PMS v1 reference set. Three more setup-time methods:

Every registered PMS v1 method now has its own reference page. The facade overview's method catalogue links them all.

2026-08-27 β€” PMS v1 method reference β€” five more

Five more dedicated reference pages under the PMS v1 (legacy compatibility) section, in the shape the getBookingOffers page set:

Only three PMS v1 methods left to document (Property/postExternalPropertyData, getRoomsDetails, getPropertySettings). The facade overview page's method catalogue now links every documented one directly.

2026-08-27 β€” SLA draft + docs typography aligned with widget

2026-08-27 β€” PMS v1 facade docs

Two new partner-facing pages:

Both are linked from the docs index, sit under a new "PMS v1 (legacy compatibility)" section in the sidebar, and are indexed by the search input.

2026-08-27 β€” getBookingOffers v2 slice + bidirectional-id closes

Property/getBookingOffers now emits real accommodation_name and link fields instead of the placeholders the first slice shipped:

The class is registered on the local PmsV1Controller β€” but only in shadow mode. Live cut-over stays gated per-api_user via qc_v1_cutover.mode='live'; today's commit does not flip anyone.

Bidirectional-id URL matching also closed at the room-type and rate-plan level β€” the URL segment under /properties/{external_property_id}/room_types/{external_room_type_id} (and the rate-plan equivalent) now accepts either the canonical external UUID or the partner_code, matching what the property level already did on 2026-08-26.

2026-08-27 β€” Bidirectional-id contract completed (URL side)

Every URL segment that accepts an external_property_id now also accepts a partner_code as a fallback. Previously the docs described a bidirectional mapping ("addressable by external_property_id or partner_code where supported"), but the routing only accepted external_id β€” a partner PMS that wanted to address our resources by its own code hit 404. ScopeQcRequest middleware now:

  1. Tries QcPropertyMapRepositoryInterface::findByExternalId(...) as before.
  2. Falls back to a new findByPartnerCode(...) on miss.

Partner codes are unique per QC group, so the lookup is unambiguous. Empty string is guarded against (would loose-compare to NULL columns in MySQL).

Room-type and rate-plan level partner-code URL resolution is on the same iteration path β€” track under DEV-262 follow-ups.

2026-08-26 β€” Configuration surfaces (v2)

Added 12 dashboard-parity CRUD surfaces so a partner PMS can onboard a hotel end-to-end without asking anyone to open the Quendoo dashboard. See configuration concepts.

Also:

2026-08-26 β€” Phase 3, per-code price writes

POST inventory/rates accepts prices maps keyed by occupancy code for every sell type, validated against the pair's occupancies; child-plan and non-manual refusals come back as row-level warnings[] (202, batch never aborts); writing a manual code seeds calendar rows for its derived codes.

2026-08-26 β€” Phase 2, the pricing self-description

occupancies (code / kind / derived_from / modifier) and prices_writable on rate plans; partner_code / partner_name readable on room types and rate plans; sell_type gains per_occupancy.

2026-08-25 β€” Docs overhaul

Concept pages, integration guide, error dictionary, Scalar reference, worked examples (Hotel Panorama) throughout; spec examples added.

2026-08-25 β€” The DEV-262 port surface

Earlier

See the git history of resources/openapi/qc-v1.yaml for the month-by-month churn that led to the current shape.