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
- Fixed:
GET /properties/{ext}/bookingsandPOST /booking_revisions/{id}/ackanswered500for any property holding a booking made in Quendoo itself (source_channelQDO,QDB,QDA,GHA). An un-acked revision stays in the feed, so a correct integration kept receiving the same revisions. Ack them again after this release; acking an already-acked revision is a no-op. - Docs: the quickstart and onboarding write bodies were wrong. They
showed
room_id/date/qty; the availability write takesroom_type_id/date_from/date_to/availability, as the reference always said. - Docs: reads take
from/to, notdate_from/date_to(availability, rates). There is no separate/ratesor/restrictionsread at property level β restrictions come withrate_plans/{id}/rates. - Docs: a write to a
shadowproperty reports0in every counter. The examples showed the counts aliveproperty would report. - Docs: the booking-revision example now shows the real payload β
currency_code,qty, theexternal_*ids to match on, and thesource_channelvalues that occur. - β Analytics answer in the QC envelope.
analytics/overview,bookings,traffic,funnelandcampaignsreturned the bare report body (and[]with no data); they now return{status, message, data}like every other endpoint.analytics/pacereturns its days underdata.pace_daysinstead of a nested{status, data}. - Docs: the extra-service example sent a
client_select_dates_settings.typethat does not exist (422); the values are"0","1","2". - Docs: the revision example on the bookings page showed a shape the
feed never returned (
booking,revision_number,changed_fields); replaced with the real one.
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.
- Envelope flattened everywhere. Every list response now has
shape
{data: {<resource_plural>: [...], meta: {...}}}and every single-item response has{data: {...}}flat. Pre-sweep responses were{data: {data: X}}(double-wrap). Migrated callers should readdata.<plural>(e.g.data.properties,data.bookings,data.rate_plans,data.webhook_deliveries) instead ofdata.data. name+name_tron every named entity. Property, room type, rate plan, extra, cancellation policy, payment policy, promotion, special offer β each response now carries the hotelier-setnameplus aname_trtranslation map keyed by IETF locale tag. Entities without translation columns on the legacy side (payment policy, promotion) emit a one-locale projection{"en-GB": "<name>"}for uniform iteration.external_*_idcanonical field alongsideid. Every externally-addressable resource emits both β path parameters use the same name as the canonical field (external_property_id,external_room_type_id,external_rate_plan_id).idstays as a back-compat alias.- Money wrapped as
{amount, currency}. Every monetary field (bookingtotal_price,rooms[].price,extras[].price, and every analytics field likerevenue,adr,revpar,total_paid) now ships as an object with amount and currency together. Booking rows keep a top-levelcurrencyconvenience key; individual price lines carry their own currency so a partner storing lines in their own DB doesn't have to look outside the row. - PII masking on guest fields.
guest_name,first_name,last_name,email,phoneonBookingResource,ReviewResource, and every analyticscampaigns/pacepassthrough now go through the shared masker:Ivan PetrovβIvan P.,ivan@β¦βi***@***,+359881234567β last two digits kept. Aggregates and monetary fields pass through unchanged. - Pagination on the endpoints that grow.
GET /properties,GET /bookings,GET /subscriptions/{id}/deliveries,GET /auto_email_templatesgained?page/?limit+meta: {total, page, per_page, last_page}. Small per-property lists (room types, rate plans, policies, extras, gallery items, special offers, content pages, amenities, property types) stay unpaginated by design. The booking revisions feed keeps cursor pagination. - Rich filters on bookings.
GET /bookingsacceptsbooking_status(single value OR array),source_channel,guest_name(case-insensitive substring),booking_id(partial match), plus the six pre-existing date filters. Malformed values are dropped, not 422 β matches Channex parity. - Rich filters on deliveries.
GET /subscriptions/{id}/deliveriesgainedcreated_from/created_toon top ofstatus. - Two orthogonal booking status axes.
Booking.statusnow carries QC pipeline lifecycle (staged/promoted/rejected/duplicate), aliased to canonicalprocessing_state. Newbooking_statusfield carries the BUSINESS state (created/confirmed/cancelled/no_show) derived from terminal timestamps β this is what integrators want to render.statusstays deprecated-alias toprocessing_state.
Ops changes
- Admin-side revoke for
qc_api_tokensβ support flow when a hotelier can't reach their own dashboard. - Admin-side OAuth clients CRUD with in-place secret rotation
(
qc_oauth_clientsFilament resource). Every lifecycle event (create / edit / delete / rotate) writes an audit line toapplication_events. - Smoke commands on the artisan side β
qc:smoke:oauth,qc:smoke:rate-limit,qc:smoke:webhookβ for on-demand pre-launch handshakes with new partners. See the new partner onboarding runbook (internal).
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.
- Endpoint-level scoping
is now written up on the auth page: how
endpoint_scopesnarrows a token below its scopes (the two layers are ANDed, it only ever narrows), theqc.<resource>.*wildcard and its dot boundary, minting a narrowed token, and theGET /v1/qc/permissionsroute catalogue that entries are validated against.errors.qc.endpoint_not_allowedwas already in the dictionary. analytics.readnow appears in the scopes table, and the Analytics page is now linked from the docs index (the six reports themselves shipped earlier this sprint).- Age groups β the
GET/PUT /age-groupschild-band surface (I/C/T) is now documented as a first-class configuration surface, witherrors.qc.age_group_range_invertedadded to the dictionary.
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):
widget_codesare never accepted on the wire. Resolved server-side from the token's active property set Γ the widget ownership table.- PII on customer-level payloads is masked on the
bookingsendpoint.name β "Ivan P.",email β "i***@***",phone β last 2 digits. Aggregates pass through unchanged. - Money is native + code. Every monetary value ships as
{amount, currency_code}β the upstream does NOT converttotal_paid(each row in its property's own currency), so a bare number was a data-integrity trap.
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:
- Every authenticated QC route is named
qc.<resource>.<action>β 98 of them across the three middleware groups (reads, config-writes, inventory-writes). The name is what a token'sendpoint_scopesentry matches against. GET /v1/qc/permissionsβ new catalogue endpoint. Any authenticated token can read the live list of route names, HTTP methods, URIs, and the scope each route requires. Introspected fromRoute::getRoutes(), so the catalogue never drifts from the code. A UI reading this powers the token-mint form's endpoint-scope checkboxes.- Boot-time guard test β
QcRouteNamingGuardTestiterates the route table on every CI run and fails loudly if anyqc.auth-gated route lacks aqc.*name or aqc.needs:<scope>middleware (with a small allowlist for discovery reads:property-types,amenities, the catalogue itself, andproperties.{index,show}where the concept doc pre-committed to "no scope needed"). A new endpoint that forgets either lands as a red PR before it ships β no more silent holes. - Mint-time validation β
POST /api/v1/pem-systems/qc/api-tokensnow checks everyendpoint_scopesentry against the live catalogue. An unknown name / wildcard prefix returnscustomer.qc_api_tokens.errors.unknown_endpoint_scope(same principle asscopes.*validation β a typo yields a token that admits nothing, which is the worst UX to debug).
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:
parent_rate_plan_idβ the QC external id of the parent plan. Resolved server-side to the underlying legacyrate_idand validated against the same-property invariant (a cross-property parent returnserrors.qc.parent_rate_plan_not_on_property).derivationβ the adjustment applied to the parent's calendar to compute the child's prices. Wire shape:
Each period expands into one{ "type": "dec_per|dec_abs|inc_per|inc_abs", "amount": 15, "periods": [{ "from": "2027-05-01", "to": "2027-09-30" }] }active_periods[]row forCreateRatePlanβ every downstream guard (dec_per β₯ 100refused,dec_abs β₯ effective baserefused, cycle-depth refused) still runs. Aderivationwithoutparent_rate_plan_idis refused witherrors.qc.derivation_requires_parent.
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:
POST /properties/{ext}/rate_plans/{id}/ratesβ a row that carriespricesfor a derived plan is refused per-row witherrors.qc.price_on_derived_rate; the row echoes theexternal_rate_plan_id+ theattempted_codesmap so the partner can self-correct. Restrictions in the same row are refused with the row; send them in a separate row that carries nopricesto write restrictions on a child.POST /properties/{ext}/inventory/ratesβ the same guard, as awarnings[]row (batch does not abort). When the child's parent IS mapped to QC, the pre-existingprices_belong_to_parentwarning still fires with the redirectparent_rate_plan_id; the newprice_on_derived_ratecovers the case where the parent is unmapped (no id to redirect to).
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:
- Payment β
name = "API default β pay on arrival", schedule{pay_type: on_arr, amount_type: tp}. The full trip is paid on arrival β no prepayment is captured, so nothing needs refunding if the guest cancels. - Cancellation β
name = "API default β non-refundable", empty schedule. Legacy semantics treat an empty schedule as fully non-refundable β the strictest and safest default for the hotel.
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.
- Bearer tokens β
POST /api/v1/pem-systems/qc/api-tokensaccepts a newaccount_scoped: true.property_idsin the same body is deliberately ignored (a static list would drift the moment a new hotel enrols). The store() response and the show() read carryaccount_scoped: truealongside the emptyproperty_ids, so the dashboard can render the mode without guessing. - OAuth clients β
qc_oauth_clients.account_scopedis stamped at client creation.POST /v1/qc/oauth/tokenresponses on such clients returnproperty_ids: nullandaccount_scoped: true; the JWT itself is unchanged. The middleware re-checks the flag each request β a client is free to be flipped account-scoped on its very next issued JWT without a partner-side rotate. - Fixed-scope tokens are unchanged. Legacy callers who never pass the flag still see the exact behaviour they had yesterday; the middleware skips the group resolver entirely when the flag is false, so there is no per-request cost.
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:
- Layer C β optional explicit
occupanciesonPOST /rate_plansfor the single-room / custom-derived case. - Layer D β
PUT /rate_plans/{id}/pricing-modelidempotent escape hatch for the ~10% of cases where the auto-derived vocabulary needs a per-pair override (heterogeneous multi-room, custom fees).
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:
- Range cap now lifts to 730 days for
?consolidated=1β previously inherited the per-day 90-day cap, contradicting the "pull a year" use case the endpoint was designed for. The wire volume is bounded by distinct period count, not day count, so 730 days per call is safe. - Price equality is normalised β
ksort+ numeric-cast before comparing. Two consecutive days carrying the same vocabulary in different key order OR with int-vs-float representations of the same value no longer split into separate periods. - Restriction changes do NOT split periods β design intent documented + tested (was implicit before).
- OpenAPI updated β the
consolidatedquery param, the two-shape response, and theunknown_acm_codeper-row error code (with theunknown_codefield) are now on the spec. - Docs cross-linked β the per-person bed-layout caveat now
gets a call-out on both the migration guide and the
Property/getPropertySettingsreference so partners moving from PMS v1 see it.
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):
- key-order insensitivity
- int-vs-float insensitivity
- restrictions ignored on grouping
- 730-day range allowed on consolidated
- ranges beyond 730 still rejected
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
- flip
sell_typethrough the API alone but the pair won't have a real per-person vocabulary until someone opens the Quendoo dashboard and configures the bed layout. See the new per-person pairs need a bed layout section for the current end-to-end sequence.
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:
- Bidirectional-ids β dedicated section anchoring the URL contract that used to be a passing mention: canonical UUID first, then partner_code fallback, with practical examples for property / room_type / rate_plan.
- Mode lifecycle β full table for legacy / shadow / live /
disabled with the practical transitions (
legacy β shadow β liveforward;disabledfreeze from any state;live β shadowpost-incident-audit path). - Occupancy taxonomy β default vs Qc-Rich-Occupancy β the
simple
{adults, children, infants}default vs the rich header- opt-in taxonomy for PMSs that model bed layouts in detail.
2026-08-27 β Inventory concept enrichment
concepts-inventory picked up
five sections that were previously scattered or missing:
- Restriction fields β what each one gates, with a per-field
table clarifying the check-in-date anchor for
min_los/max_losand the "stop_sell beats everything" precedence. - Rates go with occupancy codes, pointing to the pricing concepts page and noting rate-plan Γ occupancy-key validation.
- Reading what you pushed back, with the three mirror-read endpoints and the "free capacity vs raw qty_offered" semantic.
- Racing writers β last write wins, with the natural-key idempotence rationale and the pattern for a partner that MUST guarantee ordering.
- The parity harness β how shadow writes are verified, with
the
qc_parity_observationpointer.
2026-08-27 β Rate-limits + environments + recipes
Three more concept pages that were previously scattered across concepts-auth and SLA.
- Rate limits β full behaviour: the
four buckets (reads / config-writes / inventory-writes /
webhook-fanout), what every
X-RateLimit-*header means, what counts (every 4xx you triggered, retries after 5xx, replays within idempotency), what does not (401/403 auth failures, OPTIONS preflights, health endpoints), 429 vs SLA availability clarification, three pacing patterns (low-watermark, aligned- burst, exponential + jitter retry), and what we deliberately don't do (no credit rollover, no half-writes, no per-property limits below account). - Environments β production vs staging, disposable-property reset endpoint, what's simulated in staging (payments, emails, webhook allow-list), isolation guarantees (auth, subscriptions, data, rate-limit buckets), and the differences that will bite (latency, data volume, staging tier fixed at Free trial).
- Recipes β copy-pasteable workflows for what a running integration does every day: nightly inventory sync, promo creation, poll-and-ack loop with a crash-safe idempotency key shape, room assignment, catalogue onboarding, webhook verification pointer, webhook secret rotation with 24h overlap, PMS v1 shadow-diff debug pointer, and how to correlate our request ids with your PMS logs.
2026-08-27 β Quickstart + idempotency deep-dive
- Quickstart β five-minute cheat sheet under Getting started (before the full integration guide): token β property β inventory push. Aimed at "does this work at all" first-run, with a "common first mistakes" trailer.
- Idempotency β dedicated deep-dive
under Concepts. Full contract with the three outcomes on retry
(replay / 409 conflict / fresh op), what "same response" means
(down to
Idempotency-Replayed/-Original-Timestampheaders), the 24 h hard TTL, the Redis-outage failure mode we picked and why, how to CHOOSE a key (per-logical-op, not per-HTTP-call), concurrent-retry lock semantics, and a curl cheat sheet showing replay + 409 conflict end to end.
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.
- Webhooks β its own concept page. The
four-step verification recipe (freshness β HMAC β idempotency β
scope) explained inline, and full verification snippets in
Node.js, Python, PHP and curl. Delivery shape with every
X-Qc-*header documented, retry ladder + auto-disable rule (72 h consecutive non-2xx), event-catalogue highlights, health endpoint, and what we deliberately don't do (no signed URL params, no IP allow-list, no mTLS out). - PMS v1 byte-strict error catalogue merged into the
error dictionary. Every legacy error
string we're byte-preserving now lives in one grep-able table
per method plus the general v1 wrapper (
Wrong api key,Missing endpoint class/method, the 404-with-throwable-dump wrapper). Log-scrapers on the PMS side can anchor on the exact literal, not the sentence-case English we happen to render on the reference page.
2026-08-27 β PMS v1 method reference β every method covered
Closes the PMS v1 reference set. Three more setup-time methods:
Property/postExternalPropertyDataβ full replacement snapshot of the PMS's own catalogue (rooms / rates / services / meals / beds) with{id, name}per entry; onboarding + on-catalogue-change.Property/getPropertySettingsβ one-shot read of Quendoo's whole property with anamesfilter for the ten top-level keys.Property/getRoomsDetailsβ canonical room catalogue with layout counts and image URLs.
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:
Availability/updateAvailabilityβ full payload, bounds,add_qty_bookedsemantics, every byte- strict error string legacy returns.Availability/getAvailabilityβsysres=qdo|extkey semantics, silent-omission rule for unmapped rooms.Booking/getBookingsβ the revisions feed, ack filter, the two revisioning eras (id β₯ 161300new vs. old MD5 hashing), polling cadence advice.Booking/ackBookingβ revision matching, per-item ack shape, the "first PMS to touch wins" rule, why re-acking errors.Booking/postRoomAssignmentβ room number + self check-in code, thecustomers_events_logside effect.
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
- New SLA page as a draft for product sign-off. Fills in every number a partner asks for before signing: uptime target (99.9% monthly with per-bucket measurement), response-time p50/p95/p99 per surface, credit schedule (10/25/ 50/100% by uptime band), tier-scoped rate limits (Free trial / Standard / Growth / Enterprise), maintenance windows (Sun 04:00β 05:00 UTC), incident cadence (ack β€ 15 min, updates every 30 min, post-mortem in 5 business days), support severity ladder (P0 ack 15 min 24Γ7 β P3 2 business days), retention (13 months bookings feed / 90 days webhook deliveries), and sunset (12 mo minimum per major, 6 mo overlap).
- Docs typography now shares the booking widget's own type
language: Open Sans body (Google Fonts,
display=swapfor no FOIT), and the 28 / 22 / 18 / 16 / 14 / 11 six-step ramp that matches the widget's--tpl3-font-size-{xl,l,m,def,s,xs}. Docs and the surface partners' guests actually see now feel like one product even in a side-by-side tab.
2026-08-27 β PMS v1 facade docs
Two new partner-facing pages:
- PMS v1 facade β how the legacy
apc-integrator.phpsurface is being taken over method-by-method under a byte-parity guarantee, and how the shadow/live cutover works per api_user. Includes the full method catalogue. - Property/getBookingOffers
β first dedicated method reference: query params, error strings,
response shape and per-field semantics,
accommodation_namecomposition rules,linkbuilder behaviour.
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:
accommodation_nameports the legacy composition (translations/languages_booking/.default.json+ thenumAdults / onBed{RB,EB,NB} / onAgeRange / maxRoomOccupancy, accommodationPerRoomphrasing). English-only for now; other locales come with the shadow-diff pass.linkmirrors the legacygetCustomUrlLinkURL builder:custom_domains WHERE service='bk' AND url_key=?for a partner custom domain, otherwise a relative/{url_key}/search/β¦.
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:
- Tries
QcPropertyMapRepositoryInterface::findByExternalId(...)as before. - 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.
- Extra services β
/properties/{ext}/extra_services.nameOR at least one non-emptyname_trlocale required on POST. - Promotions β
/properties/{ext}/promotions. Cross-field rules reused with the dashboard;resv_from_time < resv_to_timewith00:00/24:00midnight sentinels as a QC-only tightening. - Payment policies β
/properties/{ext}/payment_policies. Conditional required bypay_type(af_res/be_arrneednum_days;be_datneedsdate). DELETE returns 409errors.qc.payment_policy_in_usewhen still bound to a rate plan. - Cancellation policies β
/properties/{ext}/cancellation_policies.data[]items enum-whitelisted (c_typeΓp_type), visibility-driven required forp_days/p_date/p_value, cap 50 rules. - Amenity catalogue β
GET /amenities. Read-only reference. - Auto-email templates β
/auto_email_templates(owner-scoped).property_ids[]must be a subset of the OAuth client's granted set; a cross-tenant id returns 422errors.qc.property_id_not_granted_to_client. - Gallery β
/gallery. Upload / register / retag / delete. DELETE returns 409 with ausages[]list;?force=1severs usages and deletes anyway. - Reviews β read + reply only (
/reviews,/reviews/{id}/reply). Reviews come from the connected OTA channels; create / delete are not partner concerns. - Content pages, guest feedback β
/properties/{ext}/content_pages,/properties/{ext}/guest_feedback. Content-page DELETE also strips the deleted page's reference from the property'sguest_guide.data.content_items[](DEV-234) so the editor doesn't error on zombies. - Booking buttons discovery β
GET /properties/{ext}/booking_buttons. Lightweight options list; cache it once at onboarding to know which pbb_ids you have. - Special offers β
/booking_buttons/{pbb_id}/special_offers(nested for list/create),/special_offers/{id}(flat read/update/delete).active_from_date+active_to_daterequired (an open-ended offer would keep firing after the campaign ended);rate_plans_ids[]non-empty;sfp.min_nights β€ sfp.max_nights.
Also:
- Idempotency β every write on the surfaces above honours
Idempotency-Keywith 24h memory. Replay with the same key + body returns the cached response plusIdempotency-Replayed: true; same key + different body returns 409errors.qc.idempotency_conflict. - New error keys β see the error dictionary.
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
POST room_types/rate_plans(create) plusPUT/GETcatalogue.POST inventory/*(mode gate,dryflag, add-qty-booked).- Partner fields on
PATCH. - Bookings collection filters.
/healthcalendar / bookings / v1_facade blocks.
Earlier
See the git history of resources/openapi/qc-v1.yaml for the
month-by-month churn that led to the current shape.