BF-007 — OTA Channel Sync (ARI push + booking pull)
Business flow (cross-EPIC user journey). Reconciled 2026-05-25 as Wave 2 of the RTM Scale-Out, per the
PLAYBOOK.mdtemplate established by BF-001.Two directions, one flow doc:
- Outbound — operator edits a rate / inventory / restriction cell in the HMP ARI Grid; the change fans out to channel-service which transforms per-OTA and pushes to ResAvenue (XML) or a direct OTA API.
- Inbound — an OTA (Booking.com via ResAvenue, MakeMyTrip, Agoda, Expedia, Goibibo) POSTs a new / modified / cancelled booking to the ResAvenue webhook; channel-service normalises to a canonical
OtaBookingReceivedEvent/OtaBookingCancelledEvent, and reservation-service applies it.Per BF-001 step shape: Story → Code → Test → Pre/Post → Notes. Where the user-story catalog has no row for a behaviour that ships in code, the step is tagged
[SME-PROPOSED]as a NEW story to surface the gap.Drift IDs in this doc use the
BF-007 D-NNNglobal scheme — see PLAYBOOK §"What to change", proposed global namespace for cross-EPIC drift.
Personas
| Persona | Role | Source |
|---|---|---|
| Revenue Manager / RM | Sets rates + allocation per OTA on the ARI Grid | 01-epic-catalog.md EP-009 + EP-011 (RM) |
| Front Office Manager | Reviews OTA bookings after they land | 01-epic-catalog.md EP-003 |
| Channel Operations | Onboards new OTAs, monitors sync errors, replays webhooks | 01-epic-catalog.md EP-009 |
| OTA partner system (Booking.com, MMT, Agoda, Expedia, Goibibo, GoMMT) | External system pushing inbound bookings + receiving outbound ARI | inbound: ResAvenue webhook; outbound: ResAvenue / direct OTA APIs |
| Guest (indirect) | Books on OTA, never touches HMP UI; OTA owns guest comms | 01-epic-catalog.md EP-004 |
Entry points
| Entry | URL / Endpoint | Backed by |
|---|---|---|
| ARI Grid (outbound trigger) | https://dashboard.<env>.keynova.ai/distribution/ari | hmp-web-dashboard/src/components/modules/ari/ari-grid.tsx + 18 sibling files |
| Channel Connections admin | https://dashboard.<env>.keynova.ai/settings/channels | hmp-web-dashboard/src/app/(dashboard)/settings/channels/page.tsx |
| Channel Mappings admin | https://dashboard.<env>.keynova.ai/settings/channels/mappings | hmp-web-dashboard/src/app/(dashboard)/settings/channels/mappings/page.tsx |
| Sync Logs / Audit | GET /api/channel-sync/logs?propertyId=&limit= | hmp-channel-service/src/sync/sync.controller.ts |
| Webhook receiver (inbound) | POST /api/webhooks/resavenue/{booking,reservation,cancellation} + /push (HMAC) | hmp-channel-service/src/intermediary/providers/resavenue/resavenue-webhook.controller.ts |
| Webhook replay (recovery) | POST /api/webhook-management/events/:id/replay (SUPER_ADMIN) | hmp-channel-service/src/webhooks/webhook-management.controller.ts |
The 12-step journey
Step 1: Revenue Manager edits a rate / inventory cell in ARI Grid
- Story: US-009-004 — Availability Push to OTAs (Real-Time) + US-009-006 — Rate Push to OTAs. UI surface —
[SME-PROPOSED]follow-upUS-011-NEW-ARI-GRID"ARI Grid cell edit + auto-publish + per-channel override + DOW pattern + closure dialog" because the AP-T11-011 work (ari-grid.tsx+ 18 sibling components: cell, channel-override-dialog, closure-dialog, dow-pattern-dialog, sync-status-strip, history-drawer) is not catalogued anywhere. - Code:
hmp-web-dashboard/src/components/modules/ari/ari-grid.tsx(cell editor with optimistic mutation + sync-status-strip). PATCH/POST endpoints land viahmp-property-service/src/distribution/distribution.controller.ts→distribution.service.ts::upsertCells()(owner of ARI cell writes). Per-channel override dialog persists toChannelOverridecell-state metadata. - Test:
hmp-property-service/src/distribution/__tests__/distribution.service.spec.ts(ARI cell write + fanout),aggregator.allocation.spec.ts(AP-T11-013 cell aggregation),cell-writer.service.spec.ts(cell write idempotency). E2E:hmp-web-dashboard/e2e/ari-grid.spec.ts+e2e/distribution-redirects.spec.ts+e2e/tests/channel-push-ops.spec.ts. Flow:ari-grid.flow.spec.ts:: "ARI grid push lifecycle" — TBD per2026-05-16-13-flow-demo-readiness.md. - Pre-conditions: Operator authenticated as MANAGER / REVENUE_MGR / ADMIN. At least one active
ChannelConnectionexists for the property. Rate plan + room type mapped to the channel (US-009-002 / US-009-003). Allocation policy (POOL / RATIO / MANUAL per AP-T11-013) configured. - Post-conditions: Cell write committed to
AriCellState(property-service).AriCellState.allocationsreflects per-channel cap. UI optimistically reflects the new value; sync-status-strip shows pending → success/failure as the fanout completes. - Notes: The ARI Grid is the only place a RM can edit a per-OTA override without dropping into channel-service admin. The dashboard has both "publish-on-save" (auto-publish) and "stage + flush" modes — operator preference per property.
Step 2: Cell mutation persists via auto-publish or manual flush
- Story: US-009-004 + US-009-006 + US-009-007 (Restriction Push — Min Stay, CTA, CTD). Auto-publish semantics —
[SME-PROPOSED]follow-upUS-009-NEW-PUBLISH-MODES"Per-property cell publish mode (auto vs staged) + debounce window" because the dual-mode pattern is shipped but not catalogued. - Code:
hmp-property-service/src/distribution/distribution.service.ts::upsertCells()writes the cell then enqueues outbound: in auto-publish mode it immediately callsChannelServiceClient.distributionFanout(propertyId, [{date, roomTypeId, channelCode?, value, kind: AVAIL|RATE|RESTRICTION}]); in staged mode it batches intoPendingChannelPushand flushes on operator "Publish Now" or the cron timer. - Test:
hmp-property-service/src/distribution/__tests__/distribution.service.spec.ts:: auto-publish on cell write + staged flush — PASS. Idempotency assertion incell-writer.service.spec.ts. - Pre-conditions: Step 1 completed. Cell row written to
AriCellStatein same transaction. - Post-conditions: For auto-publish:
ChannelServiceClient.distributionFanout()invoked; on success, aChannelSyncLogrow appears withstatus=PENDING. For staged: aPendingChannelPushrow is enqueued. - Notes: Debounce + coalescing window (collapse 5 cell edits in 30s into 1 push) is NOT in the catalog story — flagged as drift D-007 in EP-009. Without it, a power-user keyboard-edit session can produce 100+ pushes per minute (and exhaust ResAvenue rate limit).
Step 3: Outbound fan-out — property-service → channel-service /internal/channel/distribution-fanout
- Story: US-009-004 + US-009-006. Internal-service contract surface —
[SME-PROPOSED]follow-upUS-009-NEW-FANOUT-CONTRACT"Internal distribution-fanout contract (AP-T11-011)" because the receiver endpoint + payload shape are AP-flagged but not catalogued. - Code:
hmp-channel-service/src/internal/internal.controller.ts(and/internal/channel/distribution-fanouthandler — AP-T11-011 distribution-fanout receiver). Auth: internal service-account JWT viaBaseServiceClient(@hmp/service-clients);JwtAuthGuardfast-path. Tenant context:x-tenant-id/x-property-idheaders (NEVER from client body).ParallelSyncServicefans out across all matching active connections;AllocationService.resolveCap(propertyId, roomTypeId, date, channelCode)clamps the per-channel push value before adapter dispatch. - Test:
hmp-channel-service/src/distribution/__tests__/allocation.service.spec.ts(AP-T11-013 POOL/RATIO/MANUAL clamp).parallel-sync.service.spec.ts[CITED-BUT-NOT-FOUND]— verify Task 6. - Pre-conditions: Step 2 completed; internal JWT signed by current platform RS256 keypair; receiver
assertPropertyTenant(propertyId, tenantId)passes. - Post-conditions: One
ChannelSyncLogrow per (channelId × push) withstatus=IN_PROGRESSandidempotencyKey(64-char SHA-256 hex per AP-T11-001). Demo-tenant short-circuit may have rejected withDEMO_TENANT_OTA_BLOCKED(Plan 4, 2026-05-13). - Notes:
assertPropertyTenantis non-negotiable perhmp-docs/docs/architecture/cross-service-communication.md. EP-009 D-001 flags that thecreateConnection(Step 0/setup) leg of this same controller surface lacks the check — a defense gap, but separate from the per-push receiver guard.
Step 4: Per-channel transformation (ChannelRateMapping.derivedFormula)
- Story: US-009-003 — Rate Plan Mapping (derived formula engine:
parent + 5%,parent + 350 INR breakfast, etc.). GST resolution mode —[SME-PROPOSED]per EP-009 D-006:ChannelRateMapping.pricePushMode ENUM('GROSS','NET')per OTA expectation (Booking.com=GROSS, Expedia=NET, MMT=GROSS). - Code:
hmp-channel-service/src/rate-mapping/— formula engine resolvesChannelRateMapping.formulaover the pushedparentRateto produce the per-channelderivedRate.hmp-channel-service/src/connections/connections.service.ts::hydrateRoomMapping/hydrateRateMapping(lines 38-45 / 51-58) — service-client lookup against property-serviceRoomType/RatePlan(replaces deleted Prisma relation). - Test:
hmp-channel-service/src/rate-mapping/*.spec.ts[CITED-BUT-NOT-FOUND]per EP-009 RTM.hmp-channel-service/src/connections/connections.service.spec.ts:: createRoomMapping happy path (only 1 test — coverage gap surfaced in EP-009 US-009-002). - Pre-conditions: Step 3 completed.
ChannelRoomMapping+ChannelRateMappingrows exist for (channel × pmsRoomTypeId × pmsRatePlanId). Formula validates (no divide-by-zero, no recursive parent references). - Post-conditions: Per-OTA payload built:
{otaRoomTypeCode, otaRateCode, derivedRate, gstAdjustedRate?, currency, dateRange[]}. For ResAvenue: collapsed-date-range XML per AP-T11-007. For direct OTAs: per-OTA JSON. - Notes: STD-CP-as-child-rate gotcha (2026-05-05) — ResAvenue PMS Test Hotel has STD-CP set as a child rate of STD-EP; pushing CP returns
Invalid xml request. Child rate updates are not allowed. Please try to update Parent rate(s) only.EP-009 D-005 proposes aChannelRateMapping.partnerSideKindcolumn to skip pushes for CHILD_DERIVED rows. OTA-code format validation (EP-009 D-003) — operator typesMMT-DLX-O1(letter O) vsMMT-DLX-01(digit zero) and the push silently lands at no-OTA-room. No regex guard today.
Step 5: Push to ResAvenue (XML) / direct OTA APIs
- Story: US-009-004 + US-009-006. Multi-currency —
[SME-PROPOSED]per EP-009 SME notes: today all push is INR; Booking.com expects per-OTA currency. - Code:
hmp-channel-service/src/sync/sync.service.ts::pushAvailability(channelId, items)/pushRates()/pushRestrictions()— 3-attempt exponential backoff, idempotencyKey persistence (AP-T11-001 2026-05-08 — 64-char SHA-256 keyed onpropertyId|channelId|kind|sortedItems), per-payload SHA-256 dedup.hmp-channel-service/src/intermediary/providers/resavenue/resavenue.adapter.ts::pushRates/pushAvailabilitypopulatesSyncResult.requestXml/responseXml.hmp-channel-service/src/intermediary/providers/resavenue/resavenue-xml.builder.ts— Spec v1.7 alignment:<OTAName>omit-default (AP-T11-007),<Promotion>extension (AP-T11-014), single-paxBaseByGuestAmtdivergence (AP-T11-015), date-range collapse (2026-05-05), MinStay/MaxStay inside<Rate>. RESAVENUE base URL + cert:RESAVENUE_BASE_URL,RESAVENUE_CERT_PATH(v2 today; v3 cutover tracked as FU-6). - Test:
hmp-channel-service/src/sync/sync.service.spec.ts:: 14 tests incl. AP-T11-001 idempotencyKey 64-char hex + retry-stability + value-change-changes-key — PASS.resavenue-xml.builder.spec.ts:: 71 specs covering<Promotion>, single-pax, date-range collapse,<OTAName>omit/emit — PASS.resavenue.adapter.spec.ts— adapter push/pull. Flow:resavenue-cert-cycle.flow.spec.ts:: "RA cert cycle end-to-end" +channel-sync.flow.spec.ts:: "push-availability lifecycle" +channel-manager-multi.flow.spec.ts:: "multi-channel fan-out" — TBD per 13-flow doc. - Pre-conditions: Step 4 transformation complete. ResAvenue cert v2 reachable from outbound IP. Connection in LIVE mode (not SIMULATOR / not DEMO_TENANT_BLOCKED).
- Post-conditions:
ChannelSyncLog.statusflips toSUCCESS/FAILED/PARTIAL.requestXml+responseXmlcolumns populated (2026-05-05 schema). On 4xx/5xx, retry envelope published tochannel.sync.retryforSyncRetryConsumer. - Notes:
<OTAName>master-vs-channel-wise distinction (gotcha, 2026-05-05): outbound XML OMITS<OTAName>by default for master-update (works for ResAvenue); only emits per-channel whencredentials.emitOtaName=true. ResAvenue cert FU-6 (v3 re-issuance against EKS ALB) in flight. GST + TDS reverse-charge note: outbound rate push is GROSS-or-NET per channel; the GST adjustment is a transformation concern (Step 4 D-006), not a sync concern.
Step 6: Push response logged (sync logs, audit page)
- Story: US-009-012 — Sync Error Monitoring & Retry Queue (Verified-Built per EP-009 RTM — the strongest story in EP-009). US-009-010 — Channel Performance Report.
- Code:
hmp-channel-service/src/sync/sync.service.ts(populatesChannelSyncLogwith idempotencyKey, status, error, requestXml/responseXml, retry count).hmp-channel-service/src/sync/sync-retry.consumer.ts— retry-queue consumer with exponential backoff cap.hmp-channel-service/src/stats/*— Stats module (summary, timeline, throughput, alerts).hmp-channel-service/src/webhooks/webhook-management.service.ts— replay endpoint. UI:hmp-web-dashboard/src/components/modules/ari/sync-status-strip.tsx+ sync logs page. - Test:
sync.service.spec.ts(14 tests incl. idempotency + retry-stability) — PASS.intermediary-audit-anomaly-detector.spec.ts(5 anomaly conditions: WHITELIST_BECAME_EMPTY, LARGE_REMOVAL, OFF_HOURS, RAPID_EDITS, BULK_PROVIDER_EDITS) — PASS.intermediary-config-notifier.spec.ts(Slack notifier) — PASS.webhook-management-replay.spec.ts— PASS. - Pre-conditions: Step 5 completed (success OR failure both produce a log row).
- Post-conditions:
ChannelSyncLogrow with terminal status. On persistent failure (3 retries exhausted), an alert publishes tonotification.sync.failedfor ops paging. Stats module aggregates throughput + error rate for dashboards. - Notes: AP-T11-024 Phase 4B+4C anomaly detector watches the audit log for credential-edit anomalies (whitelist suddenly empty, after-hours edits, etc.) — surfaces malicious-insider patterns. Slack notifier wires both
auditandanomalywebhook URLs.
Step 7: (Inbound) OTA booking received via webhook
- Story: US-009-005 — Booking Pull from OTAs (catalog Partial; EP-009 D-009 surfaces gaps in cert-lifecycle + IP-allowlist-modes acceptance criteria).
- Code:
hmp-channel-service/src/intermediary/providers/resavenue/resavenue-webhook.controller.ts—POST /api/webhooks/resavenue/booking|reservation|cancellation(XML + JSON parser paths) +POST /api/webhooks/resavenue/push(HMAC variant per AP-T11-007).hmp-channel-service/src/intermediary/abstract-intermediary-webhook.controller.ts— Base controller: persists raw bytes toWebhookEvent(2026-05-05 schema columns), extracts dual-auth credentials (Classic / UserName+Password per AP-T11-007), enforces IP allowlist (ENFORCE / SHADOW / OFF per AP-T11-024 Phase 4E). - Test:
resavenue-webhook-booking.spec.ts(booking webhook ingestion),resavenue-webhook-hmac.spec.ts(HMAC/pushsignature),resavenue-xml.parser.spec.ts(multi-RoomStay coverage),resavenue-cert-format.spec.ts(15 cert-format gates),abstract-intermediary-webhook.spec.ts(14 specs for dual-auth + IP allowlist branches). Flow:resavenue-cert-cycle.flow.spec.ts:: "push → modify → cancel" withX-Forwarded-For=203.109.97.241(cert sandbox IP). - Pre-conditions: ResAvenue connection seeded with
whitelistedIps=[203.109.97.241, 122.182.6.210](both required post-2026-05-05 — the 2nd was missing pre-2026-05-05 and surfaced as REJECTED_IP after Phase 4E ENFORCE default). Connection in LIVE mode. Webhook URL registered with ResAvenue viawebhook-registration.service.ts::registerWebhookWithResAvenue(auto-fires when mode flips LIVE). - Post-conditions:
WebhookEvent.rawPayloadpersisted (whether accepted OR rejected). On accept: handed to normalizer. On reject (IP/HMAC/cert): 403 + audit row; SHADOW mode logs + accepts + audits. - Notes: ResAvenue is the only OTA today with a real bidirectional webhook path. Booking.com / MMT / Agoda / Expedia / Goibibo all flow THROUGH ResAvenue (
routedVia: RESAVENUE). Webhook events from those OTAs land on the same controller withpartnerCodefield discriminating downstream.
Step 8: HMAC signature validated (where applicable) + IP allowlist enforced
- Story: US-009-005 + US-009-001 (Channel Connection — credentials carry HMAC secret). HMAC + IP-allowlist modes —
[SME-PROPOSED]per EP-009 D-009 already in flight. - Code:
hmp-channel-service/src/intermediary/providers/resavenue/resavenue-webhook.controller.ts::validateHmac()usescrypto.timingSafeEqualagainstIntermediaryConnection.credentials.webhookSecret. IP allowlist three-mode resolver:IntermediaryConnection.enforcementModeOverridewins overIntermediaryProviderConfig.enforcementMode; defaultENFORCE. - Test:
resavenue-webhook-hmac.spec.ts— covers timing-safe-equal, wrong secret, missing header, base64 vs hex.abstract-intermediary-webhook.spec.ts— 14 specs covering ENFORCE/SHADOW/OFF × allowed/denied × headerless paths. - Pre-conditions: Step 7 received raw bytes; HMAC header present (for
/push) OR IP in allowlist (for/booking|reservation|cancellation). - Post-conditions: Verified request continues to Step 9. Rejected request: 403, audit row, optional Slack notification for repeated denials.
- Notes: SHADOW mode is the unsung hero — lets operators preview enforcement impact before flipping. EP-009 D-009 proposes catalog this. Trust-boundary note (
resavenue-cert-cycle.flow.spec.tscomment):X-Forwarded-Foris only honored because the gateway is the trusted edge in cert env; in prod the gateway STRIPS client-suppliedX-Forwarded-For.
Step 9: Inbound mapping — OTA roomCode → HMP roomTypeId via IntermediaryMapping
- Story: US-009-002 (Room Type Mapping) + US-009-003 (Rate Plan Mapping). EP-009 D-002 flags missing role guards + missing FK validation on the mapping CRUD.
- Code:
hmp-channel-service/src/intermediary/channel-booking-normalizer.service.ts::normalize{Reservation,Cancellation,Modification}— readsIntermediaryMappingrows for (connectionId × otaRoomTypeCode) to resolvepmsRoomTypeId+ (otaRateCode) topmsRatePlanId. Produces canonicalOtaBookingReceivedEvent/OtaBookingCancelledEventper@hmp/api-contracts@1.9.4(schemaVersion 1.0). - Test:
channel-booking-normalizer.spec.ts— canonical event shape, multi-RoomStay (gotcha 23, 2026-05-05), cancellation, modification.intermediary/__tests__/connection-cross-tenant-denial.spec.ts— 11 tests including "encryption.decrypt is never called for foreign-tenant rows" (Task #2 deliverable; status: orphaned — not in CI gate per EP-009 D-001). - Pre-conditions: Step 8 succeeded.
IntermediaryMappingrows seeded for connection × roomCode (ResAvenue PMS Test Hotel: 3 room types). - Post-conditions: Canonical event published to
channel.booking.received/channel.booking.cancelledtopic withpmsRoomTypeId,pmsRatePlanId,tenantId,propertyId,otaBookingId,partnerCode, dates, pax, rate, GST split, guest profile. - Notes: Missing mapping → normalizer raises
OTA_MAPPING_MISSINGand publishes achannel.mapping.brokenevent for ops alerting (EP-009 D-004 proposes formalising this with a daily integrity-check cron). Today this is best-effort; bookings can land in a void.
Step 10: Reservation created in reservation-service (status=CONFIRMED, source=OTA)
- Story: US-003-001 — Create Individual Reservation (single + multi-room via
roomReservations[]) + US-003-013 — Cancel Reservation (for the cancellation arm). OTA-source-specific behaviours —[SME-PROPOSED]per EP-003 SME notes "OTA multi-RoomStay ingestion path addedevent.rooms[]consumer support"; story body silent. - Code:
hmp-reservation-service/src/consumers/channel-booking-received.consumer.ts(subscriberIdchannel-booking-received-consumer) — callsReservationsService.createFromOtaEvent()with idempotency onotaBookingId. AP-T03-004 OTA tax-inclusive reverse-derivation (isTaxInclusive=true+expectedTotalAmountreconciliation guard).ChannelBookingReceivedConsumer.applyModificationhandlesnotificationType: 'MODIFICATION'events (date/pax/specialRequests; per-room rate/room-type out of scope for v1; terminal-state reservations log WARN + skip — EP-009 D-012 proposes manual-reconciliation-queue replacement).hmp-reservation-service/src/consumers/channel-booking-cancelled.consumer.ts(subscriberIdchannel-booking-cancelled-consumer) callsReservationsService.cancel()withreason="OTA cancellation via <partnerCode>". - Test:
reservations.sprint2.spec.ts:: 13BookingSourceenum coverage +sourceReference+ Kafka event payload assertions.reservations.service.spec.ts:: AP-T03-004 OTA tax-inclusive specs.channel-booking-received.consumer.spec.ts[CITED-BUT-NOT-FOUND](verify Task 6). Flow:resavenue-cert-cycle.flow.spec.ts:: "push → modify → cancel" end-to-end. - Pre-conditions: Step 9 published canonical event. Reservation-service subscribed to topic (event-dispatcher consumer-group not skipped —
SKIP_EVENT_SUBSCRIPTIONS=false). - Post-conditions:
Reservationrow withstatus=CONFIRMED,source ∈ {BOOKING_COM, EXPEDIA, MAKEMYTRIP, AGODA, GOIBIBO, AIRBNB, GDS},sourceReference=otaBookingId,bookingChannel=RESAVENUE(or direct), tax-inclusive total reconciled.RoomReservationrows (1 or N for multi-RoomStay).ReservationHistoryrow withperformedBy='channel-service:<partnerCode>'(per EP-003 SME proposal, currentlyperformedBy=nullfor OTA-driven mods — D-driftable). - Notes: Idempotency on
otaBookingIdprevents duplicate creation when ResAvenue replays a webhook (which happens during cert cycles). EP-003 SME flagged P0 risk: OTA cancellationcancellationChargefrom the event is currently ignored; local policy applies on top → double-charge risk for OTAs that already debited via VCC (EP-003 D-007).
Step 11: Guest created (or matched) — GUEST_CREATED / matched event
- Story: US-004-001 (Guest CRUD — Create / Search / Match). Inline-guest from OTA —
[SME-PROPOSED]follow-upUS-004-NEW-OTA-INLINE-MATCH"OTA-sourced guest match-or-create with phone+email fuzzy match" because OTA bookings come with minimal guest profile (name, email, phone, country) and the merge-vs-create heuristic is not catalogued. - Code:
hmp-reservation-service/src/reservations/reservations.service.ts::create()orchestrates inline-guest creation viaGuestServiceClient.matchOrCreate(payload). Match heuristic: email exact match (+ tenant scope) → reuse; else phone match → reuse; else create newGuestrow + emitGUEST_CREATEDevent (consumed by CRM service for welcome flow, loyalty tier eval, etc.). - Test:
hmp-guest-service/src/guests/guests.service.spec.ts:: match-or-create branches. EP-003 RTM cites OTA-side spec inreservations.service.spec.ts(multi-room + OTA-source coverage). - Pre-conditions: Step 10 in flight; canonical event carries
guest: {firstName, lastName, email?, phone?, country?, ...}. - Post-conditions:
Guestrow exists (matched or created).Reservation.guestIdpopulated.GUEST_CREATEDpublished on first creation only (idempotent on email+phone+tenant). DPDPA / GDPR consent flag set conservatively (no marketing opt-in; OTA owns consent capture). - Notes: OTA-supplied phone is often the OTA's masked relay number (e.g.
+91-NN-NNNN-XXXXfor Booking.com Phone Privacy). Storing this asGuest.phoneand treating it as a real number breaks SMS dispatch. Drift candidate: store underGuest.relayPhoneseparately; expose unmask via OTA API per booking when available.
Step 12: Confirmation flow — no email from HMP (OTA owns guest communication)
- Story:
[SME-PROPOSED]new storyUS-009-NEW-OTA-COMMS-BOUNDARY"HMP suppresses guest-facing confirmation/cancellation comms for OTA-sourced reservations; ops-internal notifications only" because the policy (don't email the OTA guest) is shipped behaviour but not catalogued, and the boundary is critical to brand parity + spam-trap compliance. - Code:
hmp-notification-service/src/consumers/reservation-confirmed.consumer.ts— gated onreservation.source NOT IN (BOOKING_COM, EXPEDIA, MAKEMYTRIP, AGODA, GOIBIBO, AIRBNB, GDS). For OTA-sourced rows: emits an internalops.reservation.receivednotification (front-office Slack / dashboard toast) instead of a guest email. - Test:
reservation-confirmed.consumer.spec.ts[CITED-BUT-NOT-FOUND]— verify source-discriminator branching.hmp-notification-service/src/__tests__/ota-suppression.spec.ts[CITED-BUT-NOT-FOUND]. - Pre-conditions: Step 10 emitted
reservation.confirmed. Notification-service consumer running. - Post-conditions: NO guest-facing email/SMS sent. Internal
ops.reservation.receivednotification fires to property's front-office channel.NotificationLogrow withsuppressed: true, suppressionReason: "OTA_BOOKING_PARTNER_OWNS_COMMS". - Notes: Booking.com explicitly forbids hotels from spamming guests via Booking.com-relayed addresses; violations risk delisting. Same for MMT / Agoda. Pre-arrival upsell (3 days before check-in) MAY use the relay channel via OTA's messaging API (Booking.com Messages API, Expedia Guest Messaging) — that's a separate Day-2 follow-up, NOT part of the confirmation flow.
Failure modes worth testing
| # | Scenario | Expected behaviour | Coverage today | Owning story |
|---|---|---|---|---|
| F1 | Stale rate push (operator edits then reverts within 30s; pushes fire 2x) | Debounce / coalesce window collapses to 1 push; idempotencyKey de-dups at adapter | Partial — sync.service.spec.ts covers idempotencyKey de-dup; no debounce test | US-009-004 (EP-009 D-007) |
| F2 | Webhook IP rejected by allowlist (ENFORCE mode) | 403 + WebhookEvent.rawPayload persisted with status=REJECTED_IP + audit row + Slack alert on repeated denials | abstract-intermediary-webhook.spec.ts (14 specs) — PASS | US-009-005 (EP-009 D-009) |
| F3 | HMAC mismatch on /push | 403 + audit; timing-safe-equal prevents timing oracles | resavenue-webhook-hmac.spec.ts — PASS | US-009-005 |
| F4 | Inbound mapping missing (OTA roomCode not in IntermediaryMapping) | OTA_MAPPING_MISSING raised; channel.mapping.broken published; reservation NOT created; ops alerted | Partial — normalizer raises but no daily integrity cron; mappings can rot silently | US-009-002 (EP-009 D-004) |
| F5 | Overbooking from race (2 OTAs pull last-room simultaneously, both webhook in within 100ms) | First booking creates; second hits ROOM_TYPE_OVERSOLD safeguard; reservation-service marks status=OVERSOLD, emits ops.oversold.detected for manual reassign | Partial — reservations.service.spec.ts covers oversold detection; race-condition spec NOT in test suite | US-003-001 + US-009-005 |
| F6 | OTA outage (ResAvenue 5xx on push for 30 min) | 3-retry exponential backoff per push; persistent failure publishes notification.sync.failed; ChannelSyncLog.status=FAILED; sync-status-strip flashes red; circuit breaker opens after threshold | Partial — sync.service.spec.ts covers single-attempt retry; circuit-breaker not yet wired | US-009-004 + US-009-012 |
| F7 | Partial push success (3 of 5 channels succeed, 2 fail) | Each channel logs independently; aggregate status PARTIAL; failed channels enter retry queue; succeeded channels NOT retried (idempotencyKey blocks duplicate) | sync.service.spec.ts retry-stability — PASS | US-009-004 + US-009-012 |
| F8 | Cross-tenant push collision (malicious tenant supplies victim's propertyId in ChannelConnection.propertyId) | 403 + CROSS_TENANT_PROPERTY_DENIED before any DB write | Orphan spec only — connection-cross-tenant-denial.spec.ts covers READ-side; CREATE-side UNGUARDED | US-009-001 (EP-009 D-001 — P0) |
| F9 | Demo / sandbox tenant accidentally pushes to real OTA | 400 DEMO_TENANT_OTA_BLOCKED + WARN log; auth-service outage fail-safe = treat as NOT demo so real-tenant pushes never block | demo-tenant.service.spec.ts (7 specs) — PASS | US-009-004 (EP-009 D-008) |
| F10 | Cert v2 expires; v3 not yet provisioned | All outbound push fails with TLS handshake error; sync-status-strip flashes red on all connections; FU-6 runbook fires | Manual — covered by FU-6 runbook + monitoring/runbooks/resavenue-cert-reissuance.md | US-009-005 (EP-009 D-009) |
Day-2 channel operations (NOT in BF-007 scope)
- OTA messaging API integration — Booking.com Messages API, Expedia Guest Messaging, MMT chat. Pre-arrival upsell via OTA-owned relay channel. Strictly opt-in per OTA.
- Rate parity monitoring v2 — real OTA scraping (Genius API + Agoda Partner Hub) replacing simulated random diffs (EP-009 D-013).
- OTA promotion sync —
<Promotion>extension upstream wiring (EP-009 D-010) — fetch fromPromotionServiceClient.getApplicablePromotionsand attach before push. - Commission tracking — per-channel
commissionPctconfig +BookingCommissionper booking + monthly GSTR-2B-ready CSV (EP-009 D-014, US-009-013). - TDS Section 194-O withholding — 1% TDS deducted on OTA payout when remitting to OTA in India. Channel report split:
grossRevenue,commissionAmount,gstOnCommission(18% reverse-charge),tdsWithheld,netPayout. - VCC settlement for OTA no-shows —
channel.noshow.reportedpublish back to OTA for Booking.com / Expedia VCC settlement (EP-003 D-010 proposal under US-003-010). - GDS direct (Amadeus / Sabre) —
GdsModuleimport + SiteMinder / RateGain middleware (US-009-011 — currently flagged dead-code in review file pending re-verification). - Rate-parity enforcement automation —
rate-parity-enforcement.service.tsexists but is mostly Best-Rate-Guarantee scaffolding; auto-corrective-push on violation is a Day-2 add. - Multi-currency push — Booking.com / Agoda accept per-OTA currency; today all push INR (EP-009 D-006 only covers GST mode, not currency).
- Restriction model first-class —
ChannelRestrictiontable + per-OTA capability map (EP-009 D-011 proposes new storyUS-009-NEW-RESTRICTIONS-FIRST-CLASS).
Indian-SME compliance overlay
| Concern | Today's behaviour | Drift |
|---|---|---|
| GST on OTA commission (18% reverse charge — Booking.com / MMT / Agoda commission invoice carries IGST that the hotel must self-account) | NOT in channel performance report | EP-009 D-014 (P0) |
| TDS Section 194-O (1% TDS withheld on OTA gross for e-commerce operators) | TdsEntry model exists in schema; not surfaced in any report | EP-009 D-014 (P0) |
| GST mode per channel (Booking.com=GROSS / Expedia=NET / MMT=GROSS) | No ChannelRateMapping.pricePushMode column; push assumes GROSS | EP-009 D-006 (P1) |
| ResAvenue cert cycle (FU-6) | Cert v2 active; v3 re-issuance against EKS ALB in flight; no automated cert-replay gate | EP-009 D-009 (P0) |
| VCC settlement for OTA no-shows | processNoShow() doesn't publish back to channel; OTA never gets the "no-show" signal needed to debit VCC | EP-003 D-010 (P1, under US-003-010) |
| OTA-relay phone privacy (Booking.com Phone Privacy) | Stored as Guest.phone; SMS dispatch tries to text the relay number | NEW drift candidate (BF-007 D-009 below) |
| DPDPA consent on OTA guest profile | No marketing-consent capture; OTA owns | Acceptable per partner contract; documented |
Drift summary
The 11 BF-007-surfaced drifts. Cross-references existing per-EPIC D-NNN
IDs where the drift was already surfaced by EP-009 or EP-003 RTMs.
| ID | Type | Severity | Description | Cross-ref | Surfaced by step |
|---|---|---|---|---|---|
| BF-007 D-001 | Cross-tenant security | P0 | ChannelConnection.create() trusts dto.propertyId unchecked — cross-tenant pricing-leak surface. Orphan denial spec covers READ side only; CREATE side unguarded. | EP-009 D-001 | Step 3 / Setup |
| BF-007 D-002 | Credential exposure | P0 | GET /channels/:id returns DECRYPTED credential payload in full — no masking. Combined with missing role guard on POST/PUT/PATCH, any authenticated user can exfiltrate OTA API keys. | EP-009 D-002 | Step 5 / Setup |
| BF-007 D-003 | Cert lifecycle | P0 | No automated CI gate replays a representative cert-cycle fixture (resavenue-cert-cycle.flow.spec.ts) on RESAVENUE_CERT_PATH rotation. Cert v3 cutover (FU-6) risks silent breakage. | EP-009 D-009 | Step 5 + Step 7 |
| BF-007 D-004 | India tax compliance | P0 | Channel performance report carries no GST-on-OTA-commission (18% reverse charge) and no TDS Section 194-O (1% withholding) breakdown. TdsEntry schema exists; not surfaced. | EP-009 D-014 | Step 6 (post-push reporting) |
| BF-007 D-005 | Catalog gap / India-distribution | P1 | Allocation modes (POOL / RATIO / MANUAL — AP-T11-013) are shipped but not catalogued. RM teams critically need RATIO during Diwali / wedding season. | EP-009 D-008 | Step 3 |
| BF-007 D-006 | Story-incomplete / GST per channel | P1 | ChannelRateMapping.pricePushMode ENUM('GROSS','NET') proposed but unbuilt; today all push assumes GROSS, breaking Expedia parity. | EP-009 D-006 | Step 4 |
| BF-007 D-007 | Story-incomplete / push debounce | P1 | No debounce / coalesce window on auto-publish. A power-user editing 5 cells in 30s produces 5 distinct pushes; risks ResAvenue rate-limit + churn. | NEW (BF-007 only) | Step 2 |
| BF-007 D-008 | OTA cancel double-charge | P1 | ChannelBookingCancelledConsumer ignores event.cancellationCharge; local policy applies on top → double-charge risk for OTAs that already VCC-debited. | EP-003 D-007 | Step 10 (cancellation arm) |
| BF-007 D-009 | Privacy / OTA-relay phone | P2 | OTA-relay phone (Booking.com Phone Privacy) stored as Guest.phone; SMS dispatch attempts to text the relay number → silent failure or pollution of relay address book. | NEW (BF-007 only) | Step 11 |
| BF-007 D-010 | Catalog gap / comms boundary | P2 | The "HMP suppresses guest comms for OTA-sourced reservations" policy is shipped behaviour but uncatalogued. Risk: a future contributor adds a new consumer that doesn't gate on source, sends emails through Booking.com relay, hotel gets delisted. | NEW (BF-007 only) | Step 12 |
| BF-007 D-011 | OTA modification terminal-state | P2 | OTA-driven modifications on terminal-state reservations (CHECKED_IN / CHECKED_OUT / CANCELLED / NO_SHOW) log WARN + skip silently. Real ops want a manual-reconciliation queue with front-desk task. | EP-009 D-012 | Step 10 (modification arm) |
Cross-EPIC narrative summary
| Step | Spans | Why cross-EPIC |
|---|---|---|
| 1 → 2 | EP-011 (RM dashboard surface) ↔ EP-009 (channel-service receiver) ↔ EP-002 (property-service ARI cell owner) | ARI Grid is a three-service write — UI → property cell → channel fanout. |
| 3 → 6 | EP-009 (channel-service) + EP-015 (observability — sync logs) | Outbound push lives entirely inside channel-service but its audit log is consumed by EP-015 monitoring. |
| 7 → 8 | EP-009 (webhook receiver + HMAC + IP allowlist) | Cert-contract surface — partner-facing. |
| 9 → 10 | EP-009 (normalize) ↔ EP-003 (reservation create) | The canonical-event seam — channel-service produces, reservation-service consumes. Schema drift here = duplicate reservations or void mappings. |
| 11 | EP-003 (reservation create) ↔ EP-004 (guest match-or-create) | Inline-guest path; OTA-relay phone privacy applies. |
| 12 | EP-003 (reservation confirmed) ↔ EP-012 (notification) | Comms-suppression boundary — OTA owns guest comms; HMP only fires internal ops notifications. |
The "ARI cell write → distribution fanout → per-channel transform → ResAvenue XML → ChannelSyncLog" outbound spine and the "ResAvenue webhook → normalize → canonical event → reservation create → guest match → comms suppressed" inbound spine are the two seams BF-007 has to narrate. Both span 4+ services. Cert lifecycle (FU-6) and Indian-tax overlay (GST + TDS) are the primary BF-007 findings the SME must act on.