Skip to main content

EP-009 — Channel Manager & Distribution RTM

EPIC: EP-009 Channel Manager & Distribution Reconciled: 2026-05-25 (Wave 1 of per-EPIC RTM scale-out) Template: EP-002-rtm.md; pattern: PLAYBOOK.md. Source of truth for "% passing": hmp-docs/docs/platform-registry/2026-05-16-13-flow-demo-readiness.md until next re-measure.

Status legend​

StatusMeaning
BuiltCode exists + tests exist + tests pass
Built-UntestedCode exists, no automated test coverage (or test exists but is a stub)
PartialCode exists for some acceptance criteria, gaps for others
StubCode is a placeholder / TODO / returns mock data
Not StartedNo code

Catalog numbering note: 01-epic-catalog.md lists 13 stories for EP-009 (US-009-001..US-009-013); the EPIC body (epics/EP-009-channel-management.md) still uses the older US-009-01..US-009-13 numbering AND has a different story title set (catalog splits Room Type Mapping / Rate Plan Mapping into two stories; EPIC body fuses them into one US-009-02 and shifts every downstream ID by one). Same drift pattern as EP-002 D-007. This RTM follows the catalog numbering. Per-story reviews/EP-009/US-009-NNN-review.md files (11 of them) also use the catalog three-digit format.

Summary​

MetricCount
Total stories13
Catalog claims "Built"4 (US-009-001, -002, -003, -012)
Verified-Built1 (US-009-012 sync-error monitoring; the three "Built" mapping stories are actually Partial once the SME pass classifies missing-RBAC + missing-credential-validation + missing-route-coverage drifts)
Built-Untested0
Partial9 (US-009-001, -002, -003, -004, -005, -006, -009, -010, -011)
Stub1 (US-009-007 — restriction push delivered as a side-effect of pushAvailability, no dedicated endpoint, no MinLOS/MaxLOS storage)
Not Started2 (US-009-008 OTA modification/cancel webhooks, US-009-013 commission tracking)
Drift findings14 (D-001..D-014; 6 P0 / 6 P1 / 2 P2)
[SME-PROPOSED] refinements25

US-009-001 — Channel Connection Setup (Credentials & Config)​

Catalog status: Built (P0, 5 points) Verified status: Partial — CRUD shipped with AES-256-GCM encryption; missing role guards, audit trail, credential masking, and createConnection does not validate the propertyId FK against the JWT tenant (Task #11 security gap)

Code locations:

  • hmp-channel-service/src/connections/connections.service.ts:172-206 — create(tenantId, dto) validates OTA-schema credentials, encrypts via FieldEncryptionService, stamps tenantId from the caller but trusts dto.propertyId with no FK lookup
  • hmp-channel-service/src/connections/connections.controller.ts — REST surface: GET /channels, GET /channels/:id, POST /channels, PUT /channels/:id, PATCH /channels/:id/deactivate, GET /channels/:id/health, GET /channels/ota-schemas
  • hmp-channel-service/src/connections/ota-schemas.ts — Per-channel credential field requirements (Booking.com, MakeMyTrip, Expedia, Agoda, Goibibo, etc.) + validateOtaCredentials() helper
  • hmp-channel-service/src/connections/webhook-registration.service.ts — registerWebhookWithResAvenue(connectionId) (auto-fires when mode flips LIVE)
  • hmp-channel-service/src/intermediary/intermediary.controller.ts — POST /intermediary/connections (now @Roles('SUPER_ADMIN') after AP-T11-024 Phase 2.5)
  • hmp-channel-service/src/intermediary/intermediary.service.ts — credentials JSON shape (includes webhookCallbackUrl, webhookSecret, whitelistedIps, outboundIp, endpoint, providerId FK)
  • hmp-web-dashboard/src/app/(dashboard)/settings/channels/page.tsx — Channel status grid landing
  • hmp-web-dashboard/src/app/(dashboard)/settings/channels/managers/page.tsx — Intermediary manager admin

Unit + integration tests:

  • hmp-channel-service/src/connections/connections.service.spec.ts — 4 tests (create connection, room mapping, health check, not-found); does NOT cover update, deactivate, list, duplicate handling, encryption round-trip
  • hmp-channel-service/src/connections/ota-schemas.spec.ts — per-channel credential validation
  • hmp-channel-service/src/connections/dto/connection.dto.spec.ts — DTO validation
  • hmp-channel-service/src/intermediary/__tests__/connection-cross-tenant-denial.spec.ts — 11 tests including the explicit "encryption.decrypt is never called for foreign-tenant rows" assertion (Task #2 deliverable). Status: orphaned (no live consumer registers the spec into a CI gate; passes today only because IntermediaryService.findOne happens to filter by tenant)
  • hmp-channel-service/src/intermediary/__tests__/intermediary-role-gate.spec.ts — 10 cases locking the AP-T11-024 Phase 2.5 SUPER_ADMIN gate on intermediary POST/PATCH

Flow tests:

  • hmp-infrastructure/testing/flow-tests/src/flows/channel-sync.flow.spec.ts :: "channel connection bootstrap" — TBD (not in 13-flow doc) (2026-05-16-13-flow-demo-readiness.md, 2026-05-16)
  • hmp-infrastructure/testing/flow-tests/src/flows/channel-manager-multi.flow.spec.ts :: "multi-OTA seed end-to-end" — TBD (not in 13-flow doc) (2026-05-16-13-flow-demo-readiness.md, 2026-05-16)

Real-backend smokes:

  • hmp-web-dashboard/e2e/real-backend/smoke-channel-connect.real.spec.ts — Channel connect happy path

E2E (Playwright):

  • (no dedicated Playwright; covered via smoke + ARI dashboard tests)

Use cases referencing this story:

  • none

SME notes:

  • AES-256-GCM encryption on credentials JSON is the right call (FieldEncryptionService) — but the response path on GET /channels/:id DECRYPTS and returns the full secret. There is no masking. A frontend bug or a screen-share leak exposes the raw OTA API key.
  • createConnection stamps tenantId from the caller but trusts dto.propertyId as-is. There is no assertPropertyTenant(propertyId, tenantId) check — a malicious tenant could supply another tenant's propertyId and the row would be created with crossed wires (caller's tenantId + victim's propertyId). The orphan connection-cross-tenant-denial.spec.ts covers the READ side only. This is the Task #11 security gap and is P0 by SME hat — pricing data leak + cross-tenant rate-push collision.
  • Channel-specific credential validation is in ota-schemas.ts (Booking.com needs hotelId+apiKey, Agoda needs apiKey+propertyId, etc.) but testConnection() returns a hardcoded status: 'healthy' with a random latency — does NOT actually call the OTA. AC-4 in the review file calls this out.
  • ResAvenue intermediary credentials seeded in QA include the rotated 2026-05-04 user testavpms@resavenue.com (AVNPMS context) — was testpmsk4@resavenue.com / JTPMS. Cert process is in flight (FU-6 in hmp-infrastructure/compliance/follow-ups.md).
  • No AuditLog write on credential create/update/deactivate. IT Act 43A + DPDPA breach-notification both expect a tamper-evident trail of credential mutations.

Drift findings:

D-001 — [P0] [Security + missing-validation] ConnectionsService.create() stamps tenantId from req.user.tenantId but trusts dto.propertyId unchecked. No FK call to PropertyServiceClient.getProperty(propertyId) to verify the property belongs to the caller's tenant. Combined with the same propertyId-as-input pattern on the channel-mappings + sync endpoints, this is the cross-tenant pricing-leak surface the Phase 1 ServiceClient migration (cross-service-communication.md) explicitly mandates assertPropertyTenant for. The orphan connection-cross-tenant-denial.spec.ts (11 tests) covers READ-side denial only; CREATE-side is unguarded.

  • [SME-PROPOSED] Add AC: "POST /channels SHALL call PropertyServiceClient.assertPropertyTenant(dto.propertyId, req.user.tenantId) BEFORE write. On mismatch return 403 with code=CROSS_TENANT_PROPERTY_DENIED. Test asserts a foreign-tenant propertyId is rejected before any DB write."
  • [SME-PROPOSED] Promote connection-cross-tenant-denial.spec.ts from orphan status: wire into the CI suite under npm test and add the matching CREATE-side test cases (currently 11 tests for findOne/findAll/getRoomMappings/getRateMappings; need a 12th for create + 13th for update).

D-002 — [P0] [Story-incomplete + security] No credential masking in GET /channels/:id response — the decrypted secret is returned in full. No role-based guard on POST/PUT/PATCH /channels/* (the controller-level JwtAuthGuard accepts any authenticated user). Reviews confirm both (GAP-2, GAP-4). Combined surface = an authenticated frontdesk-agent (typo URL) sees and exfiltrates the property's Booking.com API key.

  • [SME-PROPOSED] Add AC: "Channel CRUD mutation endpoints (POST/PUT/PATCH /channels/*) SHALL require @Roles('MANAGER', 'REVENUE_MGR', 'ADMIN', 'SUPER_ADMIN'). GET /channels/:id SHALL mask credential fields to ***last4 for non-SUPER_ADMIN callers; SUPER_ADMIN gets the full decrypted value with an AuditLog write recording the read."
  • [SME-PROPOSED] Add AC: "Every credential mutation (create/update/deactivate) SHALL write an AuditLog row {actor, channelCode, fieldsChanged[], at} — required by IT Act 43A (reasonable security practices) and DPDPA breach-trace mandates."

US-009-002 — Room Type Mapping to OTA Channels​

Catalog status: Built (P0, 5 points) Verified status: Partial — CRUD shipped; no canonical OTA-code taxonomy, no "many-to-one PMS to OTA" guard

Code locations:

  • hmp-channel-service/src/connections/connections.service.ts — createRoomMapping(channelId, dto) writes ChannelRoomMapping
  • hmp-channel-service/src/connections/connections.controller.ts — POST /channel-mappings/rooms/:channelId, GET /channel-mappings/rooms/:channelId
  • hmp-channel-service/src/intermediary/intermediary.controller.ts — IntermediaryMapping rows (3 room types seeded in ResAvenue PMS Test Hotel)
  • hmp-channel-service/src/connections/connections.service.ts::hydrateRoomMapping (lines 38-45) — service-client lookup against property-service RoomType (replaces deleted Prisma relation)
  • hmp-web-dashboard/src/app/(dashboard)/settings/channels/mappings/page.tsx — Mappings UI

Unit + integration tests:

  • hmp-channel-service/src/connections/connections.service.spec.ts — 1 test (createRoomMapping happy path)

Flow tests:

  • hmp-infrastructure/testing/flow-tests/src/flows/channel-sync.flow.spec.ts — TBD (2026-05-16-13-flow-demo-readiness.md)
  • hmp-infrastructure/testing/flow-tests/src/flows/channel-manager-multi.flow.spec.ts — TBD (2026-05-16-13-flow-demo-readiness.md)

Real-backend smokes: none specific

Use cases referencing this story: none

SME notes:

  • OTA room-type codes are free-form String — channel-service holds whatever the operator types. The seed (seed-resavenue-firsthotel-connection.sql) hardcodes 3 ResAvenue room types but there is no per-OTA validation that the supplied code resembles their format. Booking.com room IDs are numeric; Agoda uses GUIDs; MakeMyTrip uses MMT-XXX-NNN patterns — today a typo silently maps to a non-existent OTA room.
  • "Many PMS to one OTA" + "one PMS to many OTAs" are both allowed by schema. The reverse direction (one OTA room mapped to two PMS room types) creates a fan-out ambiguity on booking pull — the BookingDownloadCron would have to pick one. No DB constraint blocks it.
  • hydrateRoomMapping swallows property-service failures and returns roomType: null. Mappings become invisibly broken when the upstream room type is deleted in property-service — story does not require an integrity check or alert.

Drift findings:

D-003 — [P1] [Story-incomplete + India-OTA-integration-risk] No per-OTA room-code format validation. Operator types MMT-DLX-O1 instead of MMT-DLX-01 (letter O vs digit zero) and the rate push silently lands on a non-existent OTA room. No bookings come back because the OTA never had inventory under that code. Symptom: "OTA is broken" — actual root cause: typo. Same class of bug as the ResAvenue <OTAName> saga (CLAUDE.md 2026-05-05).

  • [SME-PROPOSED] Add AC: "Room/rate mapping create + update SHALL validate otaRoomTypeCode / otaRateCode against a per-OTA regex (Booking.com: ^\\d+$, Agoda: 36-char GUID, MakeMyTrip: ^MMT-[A-Z]{3}-\\d{2,3}$, etc.). Unknown OTA codes warn (not block) so we can adapt to provider format changes; format violations on KNOWN OTAs return 400 with the example pattern."

D-004 — [P2] [Orphaned-code + integrity] hydrateRoomMapping silently returns roomType: null when the upstream room is gone. Mappings become "ghost mappings" pointing at deleted PMS room types — pushes continue to land at the OTA with stale codes, bookings pull back into the void.

  • [SME-PROPOSED] Add AC: "A daily integrity-check cron SHALL list mappings whose roomType/ratePlan hydration returns null and publish channel.mapping.broken for ops alerting + dashboard flag. Pause auto-sync for affected channels until reconciled."

US-009-003 — Rate Plan Mapping to OTA Channels​

Catalog status: Built (P0, 5 points) Verified status: Partial — CRUD + derived-formula engine shipped; same orphaned-code + missing-validation gaps as US-009-002; STD-CP-as-child-rate gotcha (2026-05-05) unresolved

Code locations:

  • hmp-channel-service/src/connections/connections.controller.ts — POST /channel-mappings/rates/:channelId, GET /channels/rate-mappings, PUT /channels/rate-mappings/:id, POST /channels/rate-mappings/calculate
  • hmp-channel-service/src/rate-mapping/* — ChannelRateMapping.formula engine for derived rates (e.g. parent + 5% for breakfast variant)
  • hmp-channel-service/src/connections/connections.service.ts::hydrateRateMapping (lines 51-58) — service-client lookup against property-service RatePlan
  • hmp-web-dashboard/src/app/(dashboard)/settings/channels/mappings/page.tsx

Unit + integration tests:

  • hmp-channel-service/src/rate-mapping/*.spec.ts [CITED-BUT-NOT-FOUND] — rate formula engine tests (filesystem listing did not surface per-file specs in rate-mapping dir; verify Task 6)

Flow tests: none specific (covered via channel-sync flow)

Real-backend smokes: none

Use cases referencing this story: none

SME notes:

  • ResAvenue's PMS Test Hotel has STD-CP set as a child rate of STD-EP (parent) — pushing CP returns Invalid xml request. Child rate updates are not allowed. Please try to update Parent rate(s) only. (CLAUDE.md 2026-05-05). Today we have no metadata on ChannelRateMapping indicating which mapped codes are partner-side children — every cert run requires manual investigation.
  • Derived-formula engine is the right primitive for "CP = EP + 350 INR breakfast", but no UI affordance for "this rate is locked as a derivative on partner side, do NOT push directly".
  • GST-inclusive vs GST-exclusive resolution per channel: OTAs differ (Booking.com expects inclusive in India, Expedia historically expected exclusive). Story does not state the resolution rule. Cross-references EP-002 D-008 (rate plan GST mode).

Drift findings:

D-005 — [P1] [Cross-OTA-config + cert-blocker] ChannelRateMapping carries no flag for "this OTA rate code is a child / derived rate on partner side and must NOT be pushed". Cert evidence (KNV_R_06 STD-CP) failed for this exact reason 2026-05-05.

  • [SME-PROPOSED] Add column ChannelRateMapping.partnerSideKind ENUM('PARENT', 'CHILD_DERIVED', 'INDEPENDENT') (default INDEPENDENT). SyncService.pushRates SHALL skip rows with CHILD_DERIVED and log RATE_SKIPPED_PARTNER_CHILD instead of letting them 400. UI surfaces the kind in the mapping grid.

D-006 — [P1] [Story-incomplete + India-hospitality-critical] Story is silent on GST-inclusive vs GST-exclusive resolution per channel. EP-002 D-008 calls for RatePlan.pricingMode; channel mapping needs the inverse — a ChannelRateMapping.pricePushMode ENUM('GROSS', 'NET') indicating whether the OTA wants inclusive or exclusive of GST.

  • [SME-PROPOSED] Add AC: "ChannelRateMapping.pricePushMode (GROSS includes GST; NET excludes). SyncService.pushRates SHALL apply or strip GST per the mapping value before dispatching to the adapter. Default per channel: Booking.com=GROSS, Expedia=NET, MakeMyTrip=GROSS, Agoda=GROSS, Goibibo=GROSS."

US-009-004 — Availability Push to OTAs (Real-Time)​

Catalog status: Partial (P0, 8 points) Verified status: Partial — manual single-channel push works; auto-trigger Kafka consumer + fan-out shipped (ChannelAutoPushConsumer), but retry + debounce + allocation-mode coupling partial

Code locations:

  • hmp-channel-service/src/sync/sync.controller.ts — POST /channel-sync/push-availability, POST /channel-sync/push-rates, POST /channel-sync/push-restrictions, POST /channel-sync/sync-all, GET /channel-sync/logs
  • hmp-channel-service/src/sync/sync.service.ts — pushAvailability(channelId, items); allocation-policy clamp via AllocationService.resolveCap() per AP-T11-013; demo-tenant guard short-circuit per Plan 4 (2026-05-13); idempotencyKey persistence per AP-T11-001 (2026-05-08); per-payload SHA-256 dedup
  • hmp-channel-service/src/sync/channel-auto-push.consumer.ts — Kafka consumer for reservation/folio events that fan out to all active connections
  • hmp-channel-service/src/sync/sync-retry.consumer.ts — Retry-after-failure consumer
  • hmp-channel-service/src/sync/parallel-sync.service.ts — Parallel fan-out across connections
  • hmp-channel-service/src/sync/booking-download.cron.ts — Inverse direction; cron-pulls bookings
  • hmp-channel-service/src/distribution/allocation.service.ts — resolveCap(propertyId, roomTypeId, date, channelCode) clamps push value before adapter call (AP-T11-013 POOL/RATIO/MANUAL)
  • hmp-channel-service/src/demo-tenant/demo-tenant.service.ts — Plan 4 demo-tenant guard with 5-min Redis cache
  • hmp-channel-service/src/internal/* — POST /internal/channel/distribution-fanout (AP-T11-011 distribution-fanout receiver from property-service)
  • hmp-property-service/src/distribution/distribution.service.ts — Owner of ARI cell writes; calls ChannelServiceClient.distributionFanout() after cell save
  • hmp-property-service/src/distribution/aggregator.service.ts — Reads allocations into AriCellState.allocations
  • hmp-web-dashboard/src/components/modules/ari/ari-grid.tsx + 18 sibling files — ARI grid UI (cell, channel-override-dialog, closure-dialog, dow-pattern-dialog, sync-status-strip, history-drawer)

Unit + integration tests:

  • hmp-channel-service/src/sync/sync.service.spec.ts — 14 tests including AP-T11-001 idempotencyKey 64-char hex + retry-stability + value-change-changes-key
  • hmp-channel-service/src/distribution/__tests__/allocation.service.spec.ts — AP-T11-013 POOL/RATIO/MANUAL clamp
  • hmp-channel-service/src/demo-tenant/demo-tenant.service.spec.ts — 7 specs (Plan 4 demo guard)
  • hmp-property-service/src/distribution/__tests__/distribution.service.spec.ts — ARI cell write + fanout
  • hmp-property-service/src/distribution/__tests__/aggregator.allocation.spec.ts — AP-T11-013 cell aggregation
  • hmp-property-service/src/distribution/__tests__/cell-writer.service.spec.ts — cell write idempotency

Flow tests:

  • hmp-infrastructure/testing/flow-tests/src/flows/ari-grid.flow.spec.ts :: "ARI grid push lifecycle" — TBD (not in 13-flow doc) (2026-05-16-13-flow-demo-readiness.md, 2026-05-16)
  • hmp-infrastructure/testing/flow-tests/src/flows/channel-sync.flow.spec.ts :: "push-availability lifecycle" — TBD (2026-05-16-13-flow-demo-readiness.md)
  • hmp-infrastructure/testing/flow-tests/src/flows/channel-manager-multi.flow.spec.ts :: "multi-channel fan-out" — TBD (2026-05-16-13-flow-demo-readiness.md)

Real-backend smokes:

  • hmp-web-dashboard/e2e/real-backend/smoke-channel-connect.real.spec.ts

E2E (Playwright):

  • hmp-web-dashboard/e2e/ari-grid.spec.ts
  • hmp-web-dashboard/e2e/distribution-redirects.spec.ts
  • hmp-web-dashboard/e2e/tests/channel-push-ops.spec.ts

Use cases referencing this story: none

SME notes:

  • The current implementation is MUCH closer to spec than the 2026-03-07 review file claims (review verdict was FAIL / 1-of-10). Since then: ChannelAutoPushConsumer + SyncRetryConsumer + ParallelSyncService + AP-T11-013 allocation clamping all shipped. Review file is materially stale — same drift class as EP-002 D-011.
  • AP-T11-013 allocation modes (POOL / RATIO / MANUAL) are the Indian-market answer to channel-distribution gaming. POOL = open inventory to all OTAs (last-write-wins); RATIO = "30% Booking.com, 20% MMT, 50% direct" weighted distribution; MANUAL = operator sets explicit per-channel caps. Story body says nothing about this distinction.
  • Demo-tenant guard (Plan 4) short-circuits push to live OTAs for tenants flagged isDemoTenant=true — critical because a sandbox tenant accidentally pushing junk rates to real Booking.com breaks parity for every neighbor on the channel. Story should reference this.
  • ResAvenue <OTAName> master-vs-channel-wise distinction (CLAUDE.md 2026-05-05): outbound XML omits <OTAName> by default for master-update (works); only emits per-channel when credentials.emitOtaName=true. Today this is a credential-level toggle, not surfaced anywhere in the story.

Drift findings:

D-007 — [P0] [Review-vs-code] reviews/EP-009/US-009-004-review.md verdict FAIL / 1-of-10 / "no Kafka consumer / no fan-out / no retry" is stale (2026-03-07). All three shipped via ChannelAutoPushConsumer + SyncRetryConsumer + ParallelSyncService, plus AP-T11-001/-011/-013 layered on top. Anyone reading the review alone would re-open closed work.

  • [SME-PROPOSED] Re-run the US-009-004 review; refresh verdict to PARTIAL (auto-trigger + fan-out + retry shipped; debounce window + persistent-failure NOTIFICATION_SEND publish still gaps; CTA/CTD/MinLOS surface goes through pushAvailability rather than the dedicated pushRestrictions endpoint US-009-007 expects).

D-008 — [P1] [Story-incomplete + India-distribution-critical] Story is silent on allocation modes (POOL/RATIO/MANUAL — AP-T11-013). Indian RM teams critically depend on RATIO mode during Diwali / wedding season to cap each OTA's share — without it, MakeMyTrip will pull 80% of inventory at peak and crowd out direct + Booking.com.

  • [SME-PROPOSED] Add AC: "Channel allocation policies SHALL support modes POOL (open), RATIO (weighted per channel), MANUAL (operator-set per-channel caps) at (property, roomType, date) granularity. SyncService.pushAvailability SHALL clamp the per-channel push value via AllocationService.resolveCap() before adapter dispatch. Default mode POOL is no-op; missing rows are no-op."
  • [SME-PROPOSED] Add AC: "Demo / sandbox tenants (Tenant.isDemoTenant=true) SHALL be short-circuited at push time with BadRequestException('DEMO_TENANT_OTA_BLOCKED') and a WARN log. Fail-safe: auth-service outage → guard treats tenant as NOT demo so real-tenant pushes never get blocked by transient infra."

US-009-005 — Booking Pull from OTAs (New Reservations)​

Catalog status: Partial (P0, 8 points) Verified status: Partial — webhook ingestion + canonical normalizer + duplicate detection shipped for ResAvenue; cron-based pull still mock for non-ResAvenue OTAs; modification/cancellation routing partial

Code locations:

  • hmp-channel-service/src/intermediary/providers/resavenue/resavenue-webhook.controller.ts — POST /webhooks/resavenue/booking|reservation|cancellation + HMAC /push (AP-T11-007 single inbound URL, AP-T11-007 dual-auth Classic + UserName/Password fallback)
  • hmp-channel-service/src/intermediary/providers/resavenue/resavenue-xml.parser.ts — XML parser with multi-RoomStay fix (2026-05-05); also JSON parser path
  • hmp-channel-service/src/intermediary/channel-booking-normalizer.service.ts — ChannelBookingNormalizer.normalize{Reservation,Cancellation,Modification} producing canonical OtaBookingReceivedEvent / OtaBookingCancelledEvent (@hmp/api-contracts@1.9.4)
  • hmp-channel-service/src/intermediary/abstract-intermediary-webhook.controller.ts — Base webhook controller: IP allowlist (ENFORCE/SHADOW/OFF per AP-T11-024 Phase 4E), credential extraction, raw-payload persist to WebhookEvent
  • hmp-channel-service/src/sync/booking-download.cron.ts — Cron-based pull for non-ResAvenue OTAs (mock today)
  • hmp-reservation-service/src/consumers/channel-booking-received.consumer.ts — Canonical event consumer, creates reservation
  • hmp-reservation-service/src/consumers/channel-booking-cancelled.consumer.ts — Cancellation consumer
  • hmp-channel-service/src/webhooks/webhook-management.controller.ts — POST /webhook-management/events/:id/replay (SUPER_ADMIN-only, AP-T11-007 follow-up 2026-05-05)

Unit + integration tests:

  • hmp-channel-service/src/intermediary/providers/resavenue/__tests__/resavenue-webhook-booking.spec.ts — booking webhook ingestion
  • hmp-channel-service/src/intermediary/providers/resavenue/__tests__/resavenue-webhook-hmac.spec.ts — HMAC /push signature validation
  • hmp-channel-service/src/intermediary/providers/resavenue/__tests__/resavenue-xml.parser.spec.ts — XML parser (multi-RoomStay coverage)
  • hmp-channel-service/src/intermediary/providers/resavenue/__tests__/resavenue-cert-format.spec.ts — Cert-format gates (15 cases)
  • hmp-channel-service/src/intermediary/__tests__/abstract-intermediary-webhook.spec.ts — 14 specs for dual-auth + IP allowlist branches
  • hmp-channel-service/src/intermediary/__tests__/channel-booking-normalizer.spec.ts — canonical event shape
  • hmp-channel-service/src/webhooks/__tests__/webhook-management-replay.spec.ts — Replay endpoint

Flow tests:

  • hmp-infrastructure/testing/flow-tests/src/flows/resavenue-cert-cycle.flow.spec.ts :: "RA cert cycle end-to-end" — TBD (not in 13-flow doc) (2026-05-16-13-flow-demo-readiness.md, 2026-05-16)
  • hmp-infrastructure/testing/flow-tests/src/flows/channel-manager-multi.flow.spec.ts :: "multi-channel booking-pull" — TBD (2026-05-16-13-flow-demo-readiness.md)

Real-backend smokes:

  • Implicit via smoke-channel-connect.real.spec.ts

E2E (Playwright):

  • hmp-web-dashboard/e2e/tests/channel-push-ops.spec.ts (covers reverse-direction visibility)

Use cases referencing this story: none

SME notes:

  • ResAvenue is the only OTA today with a real bidirectional webhook path. Booking.com / Expedia / MakeMyTrip / Agoda / Goibibo all flow THROUGH ResAvenue (routedVia: RESAVENUE) — they have no direct webhook handlers because we don't have certs for those.
  • IP allowlist with three modes (ENFORCE / SHADOW / OFF — AP-T11-024 Phase 4E) is a hospitality-grade safety net. SHADOW mode is the unsung hero: lets operators preview enforcement impact before flipping the gate. Story body doesn't mention modes.
  • WebhookEvent rawPayload persistence + replay endpoint (2026-05-05) closed a real prod-recovery gap from the multi-RoomStay parser bug. Story should require this.
  • Cert FU-6 (RA re-issuance against EKS-fronted ALB) is in flight; until v3 cert lands, RESAVENUE_CERT_PATH points at the v2 (2026-05-05) cert. Spec must reference this lifecycle.

Drift findings:

D-009 — [P0] [Story-incomplete + cert-lifecycle] Story has no acceptance criteria for the ResAvenue cert cycle (FU-6) or the IP allowlist enforcement modes (ENFORCE/SHADOW/OFF). Both are real production safety surfaces — losing either silently is a SEV-2 incident.

  • [SME-PROPOSED] Add AC: "ResAvenue inbound webhooks SHALL persist WebhookEvent.rawPayload for every accepted AND rejected request. SUPER_ADMIN-only POST /webhook-management/events/:id/replay SHALL re-POST the raw bytes with X-Forwarded-For set to a connection-allowed IP, persisting a NEW audit row (original event preserved)."
  • [SME-PROPOSED] Add AC: "IP allowlist enforcement SHALL support three modes (ENFORCE reject 403 + audit, SHADOW log + WARN + accept + audit, OFF skip). IntermediaryConnection.enforcementModeOverride wins over IntermediaryProviderConfig.enforcementMode. Default ENFORCE."
  • [SME-PROPOSED] Add AC: "Cert lifecycle: when RESAVENUE_CERT_PATH rotates, an automated test SHALL replay a representative cert-cycle fixture (resavenue-cert-cycle.flow.spec.ts) and gate the deploy on its success."

US-009-006 — Rate Push to OTAs​

Catalog status: Partial (P1, 8 points) Verified status: Partial — single + bulk push shipped with 3-retry backoff + idempotencyKey; ResAvenue spec v1.7 alignment shipped (<OTAName> omit-default, <Promotion>, single-pax adjustment, date-range collapse); multi-currency + GST-side resolution still gaps

Code locations:

  • hmp-channel-service/src/sync/sync.service.ts — pushRates(channelId, items), 3-attempt exponential backoff, idempotencyKey
  • hmp-channel-service/src/sync/dto/sync.dto.ts — RateItemDto with dayOfWeek, minStay, maxStay, stopSell, singlePaxAdjustment
  • hmp-channel-service/src/intermediary/providers/resavenue/resavenue-xml.builder.ts — Spec v1.7 alignment: <OTAName> omit-default (AP-T11-007), <Promotion> (AP-T11-014), single-pax BaseByGuestAmt divergence (AP-T11-015), date-range collapse (2026-05-05)
  • hmp-channel-service/src/intermediary/providers/resavenue/resavenue.adapter.ts — pushRates + pushAvailability, populates SyncResult.requestXml/responseXml
  • hmp-property-service/src/promotions/promotions.service.ts — Promotion CRUD; consumed via PromotionServiceClient.getApplicablePromotions (wiring deferred per AP-T11-014)

Unit + integration tests:

  • hmp-channel-service/src/sync/sync.service.spec.ts — 14 tests covering pushRates + idempotencyKey + retry
  • hmp-channel-service/src/intermediary/providers/resavenue/__tests__/resavenue-xml.builder.spec.ts — 71 specs incl. <Promotion> + single-pax + date-range collapse + <OTAName> omit/emit
  • hmp-channel-service/src/intermediary/providers/resavenue/__tests__/resavenue.adapter.spec.ts — adapter push/pull
  • hmp-property-service/src/promotions/__tests__/promotions.service.spec.ts

Flow tests: same as US-009-004 (channel-sync, channel-manager-multi, resavenue-cert-cycle)

Real-backend smokes: implicit via smoke-channel-connect

Use cases referencing this story: none

SME notes:

  • Catalog says P1; given this is a P0-launch channel (the cert) downgrading was a mistake — should be P0.
  • AP-T11-014 <Promotion> extension is the cleanest answer to Indian-market "Diwali special" / "wedding-season promo" push, but the wiring to actually fetch + attach promos from PromotionServiceClient is deferred — today every rate push omits promotions. Story should explicit-spec this loop closure.
  • AP-T11-015 single-pax adjustment is the Indian market gold for solo-business-traveller pricing (single-occupancy 70% of double — vs Western OTAs which want pure double-base). Spec should require the field be exposed in the rate-plan UI.
  • Multi-currency: today all push is INR. Booking.com expects per-OTA currency; story silent on multi-currency.

Drift findings:

D-010 — [P1] [Wiring-deferred + revenue-impact] AP-T11-014 <Promotion> outbound XML is shipped but the upstream wiring (call PromotionServiceClient.getApplicablePromotions(propertyId, ratePlanId, date, {channelId}) from SyncService.pushRates) is deferred. Every rate push to ResAvenue today omits the promo, so Diwali / wedding-season promos never reach Booking.com Genius / Expedia Member Deals through ResAvenue.

  • [SME-PROPOSED] Add AC: "SyncService.pushRates SHALL fetch applicable promotions via PromotionServiceClient.getApplicablePromotions(propertyId, ratePlanId, date, {channelId}) and attach to RateUpdate.promotion before adapter dispatch. Caching: 5-min Redis TTL per (propertyId, ratePlanId, date, channelId). Test asserts a promo from property-service surfaces in the outbound XML."

US-009-007 — Restriction Push (Min Stay, CTA, CTD)​

Catalog status: Not Started (P2, 5 points) Verified status: Stub — POST /channel-sync/push-restrictions endpoint exists and delegates to pushAvailability (closure flags) or pushRates (stay-only); no dedicated channel_restrictions storage table, no per-OTA support matrix, no frontend page at /channel-manager/restrictions

Code locations:

  • hmp-channel-service/src/sync/sync.controller.ts — POST /channel-sync/push-restrictions
  • hmp-channel-service/src/sync/sync.service.ts::handleRestrictionPush — splits restrictions: closure rows → inventory XML, stay-only rows → MUST go via pushRates (CLAUDE.md 2026-05-04 supersede)
  • hmp-channel-service/src/intermediary/providers/resavenue/resavenue-xml.builder.ts — emits <MinStay>/<MaxStay> inside <Rate> (Q9b); does NOT emit <LengthsOfStay> (removed 2026-05-04 per ResAvenue Manish)

Unit + integration tests:

  • hmp-channel-service/src/intermediary/providers/resavenue/__tests__/resavenue-xml.builder.spec.ts — covers <LengthsOfStay> negative assertions + MinStay/MaxStay in <Rate>

Flow tests: implicit in channel-sync flow

Real-backend smokes: none

Use cases referencing this story: none

SME notes:

  • The "Stub" classification reflects the gap between what the catalog wants (dedicated channel_restrictions table, per-OTA support matrix, frontend page) and what is shipped (a split-router method). Functionally restrictions work for ResAvenue; everywhere else is mock.
  • The 2026-05-04 ResAvenue supersede (drop <LengthsOfStay> from inventory XML, use only <MinStay>/<MaxStay> in rate XML) is an OTA-specific quirk. Story should NOT bake the protocol detail in — it should require an adapter abstraction that lets each OTA route restrictions differently.

Drift findings:

D-011 — [P2] [Stub-shipped-as-Not-Started] Catalog says Not Started. Reality is a thin alias over pushAvailability + pushRates. Functionally usable for ResAvenue, but no operator UI, no channel_restrictions storage, no per-OTA capability map.

  • [SME-PROPOSED] Bump status from Not Started → Stub and author a new story US-009-NEW-RESTRICTIONS-FIRST-CLASS "Dedicated restriction model + per-OTA capability map + management UI". Acceptance: ChannelRestriction table; POST /channel-restrictions (bulk); GET /channel-restrictions?propertyId=&from=&to=; per-OTA capability map ({BCOM: ['CTA','CTD','MIN_LOS','MAX_LOS','STOP_SELL'], EXP: ['CTA','CTD','STOP_SELL']}); unsupported restrictions return 400 with code=RESTRICTION_UNSUPPORTED_FOR_CHANNEL.

US-009-008 — Booking Modification/Cancellation from OTA​

Catalog status: Not Started (P1, 5 points) Verified status: Partial — cancellation path SHIPPED via canonical OtaBookingCancelledEvent (AP-T11-023, 2026-05-01); modification path SHIPPED via ChannelBookingReceivedConsumer.applyModification for date/pax/specialRequests; terminal-state guard logs WARN + skips; per-room rate/room-type changes out of scope for v1

Code locations:

  • hmp-channel-service/src/intermediary/channel-booking-normalizer.service.ts::normalizeCancellation — canonical event with schemaVersion 1.0
  • hmp-channel-service/src/intermediary/providers/resavenue/resavenue-webhook.controller.ts::processCancellation — publishes canonical envelope to channel.booking.cancelled
  • hmp-reservation-service/src/consumers/channel-booking-cancelled.consumer.ts — subscriberId='channel-booking-cancelled-consumer'
  • hmp-reservation-service/src/consumers/channel-booking-received.consumer.ts::applyModification — routes notificationType: 'MODIFICATION' events to ReservationsService.update(); terminal-state guard

Unit + integration tests: implicit in channel-booking-normalizer.spec.ts + the resavenue webhook specs

Flow tests: implicit in resavenue-cert-cycle.flow.spec.ts

Real-backend smokes: none

Use cases referencing this story: none

SME notes:

  • Catalog "Not Started" is stale. The cancel + modify path closed via AP-T11-023 (2026-05-01) for ResAvenue.
  • Per-room rate / room-type changes are out of scope for v1 — would need a per-room PATCH endpoint on reservation-service. Story should call this out.
  • Modifications to terminal-state reservations (CHECKED_IN/CHECKED_OUT/CANCELLED/NO_SHOW) currently log + skip. Real ops want a "manual reconciliation queue" instead of a silent log entry.

Drift findings:

D-012 — [P0] [Catalog-vs-code] Catalog says Not Started; AP-T11-023 (2026-05-01) shipped the canonical cancel/modify pipeline for ResAvenue with consumer-side handlers in reservation-service. Reading the catalog alone, a new engineer would re-implement.

  • [SME-PROPOSED] Refresh catalog status Not Started → Partial. Add AC: "OTA-originated cancellations SHALL produce a canonical OtaBookingCancelledEvent (schemaVersion 1.0) on channel.booking.cancelled. OTA-originated modifications (date/pax/specialRequests only — per-room changes out of scope for v1) SHALL route via ChannelBookingReceivedConsumer.applyModification. Terminal-state reservations SHALL go to a manual-reconciliation queue (replace the WARN + skip pattern) with a front-desk task auto-created."

US-009-009 — Rate Parity Alert Engine​

Catalog status: Partial (P1, 5 points) Verified status: Partial — ParityService + scheduled checks + Kafka alert publish + history shipped; uses simulated rate diffs (random ±2%), in-memory violation storage, no real OTA scraping, no severity classification, default 5% threshold vs spec 2%

Code locations:

  • hmp-channel-service/src/parity/parity.service.ts — Parity check engine
  • hmp-channel-service/src/parity/parity.controller.ts — GET /channel-sync/parity, /parity/history, /parity/summary, POST /parity/enforce, GET /parity/check
  • hmp-channel-service/src/parity/parity-history.service.ts — In-memory history
  • hmp-channel-service/src/parity/rate-parity-enforcement.service.ts — Best Rate Guarantee enforcement
  • hmp-channel-service/src/parity/parity-coverage.service.spec.ts — coverage spec

Unit + integration tests:

  • hmp-channel-service/src/parity/parity.service.spec.ts
  • hmp-channel-service/src/parity/parity-coverage.service.spec.ts
  • hmp-channel-service/src/parity/rate-parity-enforcement.service.spec.ts

Flow tests: none specific

Real-backend smokes: none

Use cases referencing this story: none

SME notes:

  • "Simulated rates" is the core gap. The whole point of parity monitoring is to compare PMS-pushed-rate vs OTA-published-rate (which can drift due to OTA-side promotions, GenusBOOST, MMT-couponing, etc.). Today we compare pushed-vs-pushed — that's only useful for detecting our OWN multi-channel divergence (RATE_DIFFERS_BY_CHANNEL bugs in SyncService).
  • True parity needs ONE OF: (a) OTA rate-reporting APIs (Booking.com Genius rates API exists; Agoda Partner Hub API has rate-export), or (b) a scraper sidecar. Both expensive.
  • In-memory storage means a service restart wipes violation history — DPDPA + competitive-intel both need persistence.

Drift findings:

D-013 — [P1] [Stub-data-as-Built + persistence-gap] Story marked Partial, but the deeper issue is the comparison-source: simulated random ±2% diffs, not real OTA rates. Operators get a green dashboard that means nothing.

  • [SME-PROPOSED] Add AC: "Parity engine SHALL pull live published rates from at least 2 OTA reporting endpoints (Booking.com Genius API + Agoda Partner Hub) every 4 hours. Violations SHALL persist to RateParityViolation table (NOT in-memory) with (propertyId, channelCode, roomTypeId, ratePlanId, date, pmsRate, otaRate, deltaPct, detectedAt, severity, status, exceptionApprovedBy?). Severity: INFO (delta ≤ threshold), WARN (delta > threshold ≤ 5%), CRITICAL (delta > 5%). Default threshold 2% per spec (NOT 5% as today)."

US-009-010 — Channel Performance Report​

Catalog status: Partial (P1, 3 points) Verified status: Partial — basic aggregation by source + ADR; cancellation rate per channel missing; commission data not connected; reporting feeds for analytics-service shipped (/reporting-feeds/channel/sync-log + /rate-parity)

Code locations:

  • hmp-channel-service/src/stats/* — Stats module: summary, timeline, throughput, alerts
  • hmp-channel-service/src/reporting-feeds/* — Live-proxy feeds for analytics-service (Plan 2.2): /reporting-feeds/channel/rate-parity, /reporting-feeds/channel/sync-log
  • hmp-analytics-service/src/* — LiveProxyResolver consumes channel reporting feeds via INTERNAL_SERVICE_TOKEN

Unit + integration tests: none surfaced from listing (verify Task 6)

Flow tests: none

Real-backend smokes: none

Use cases referencing this story: none

SME notes:

  • Stats module + reporting-feeds together cover ~60% of the report spec. Commission tracking is the missing 40% (carries forward to US-009-013).
  • Indian-market addition: GST-on-OTA-commission. OTAs charge commission and GST on the commission (Booking.com 18% GST on the commission invoice). Report should show GROSS, NET, and GST-side separately.
  • TdsEntry model exists in channel-service (per CLAUDE.md) for OTA-commission TDS tracking — story body silent on TDS requirement (Section 194-O TDS @ 1% on OTA gross).

Drift findings:

D-014 — [P0] [India-tax-critical + story-incomplete] Story is silent on (a) GST-on-OTA-commission (18% reverse charge on Booking.com commission invoice), (b) TDS Section 194-O @ 1% withheld on OTA payouts. Both are LIVE compliance surfaces — incorrect reporting = GSTR-2B mismatch + TDS-default penalty. The TdsEntry model already exists in channel-service schema but is not surfaced in any report.

  • [SME-PROPOSED] Add AC: "Channel performance report SHALL present per-channel rows with grossRevenue, commissionRate, commissionAmount, gstOnCommission (18% reverse-charge on commission, India), tdsWithheld (Section 194-O 1% on gross for OTA payouts), netPayout (=gross - commission - tdsWithheld + gstOnCommission). Exportable to GSTR-2B-ready CSV for the finance team."
  • [SME-PROPOSED] Add AC: "Channel report SHALL include cancellation rate per channel (cancelled bookings / total bookings) over a configurable window — required to support EP-009 KPI 'Manual intervention rate < 2%' and to evaluate per-channel quality."

US-009-011 — GDS Connectivity (Amadeus/Sabre)​

Catalog status: Partial (P2, 8 points) Verified status: Partial — adapter code exists for Amadeus + Sabre with OAuth2 + ARI push + booking pull; GdsService + GdsController exist; review file flags GdsModule is NOT imported in AppModule → endpoints unreachable dead code

Code locations:

  • hmp-channel-service/src/gds/gds.controller.ts — GET /channels/gds/providers, /gds/health, POST /gds/push-ari, GET /gds/bookings/:provider
  • hmp-channel-service/src/gds/gds.service.ts — Orchestrator
  • hmp-channel-service/src/adapters/* — Amadeus + Sabre adapter shells
  • hmp-web-dashboard/src/app/(dashboard)/settings/channels/managers/page.tsx — GDS placeholder UI

Unit + integration tests:

  • hmp-channel-service/src/gds/gds.service.spec.ts

Flow tests: none

Real-backend smokes: none

Use cases referencing this story: none

SME notes:

  • Review file 2026-03-07 flags GdsModule not imported → dead-code. Must re-verify against current app.module.ts.
  • GDS connectivity in Indian hospitality is only valuable for corporate-travel + airline-crew patterns (Infosys / TCS / Indigo crew). Tier-2/3 hotels often skip it. P2 priority is correct.
  • True GDS access requires SiteMinder / RateGain certification ($$$ + months). Mock adapters today buy nothing toward that.

Drift findings: (none net new beyond review file content)

  • [SME-PROPOSED] Add AC: "GDS module SHALL be wired into AppModule.imports and reachable through the API gateway. CI gate: a smoke test calls GET /channels/gds/providers and asserts 200 with at least Amadeus + Sabre listed."
  • [SME-PROPOSED] Add AC: "GDS adapter activation SHALL gate on GDS_MODE env var (mock | sandbox | live parallel to CHANNEL_MODE). Live requires SiteMinder or RateGain middleware credentials. Activation in QA = mock; production deferred until middleware contract signed."

US-009-012 — Sync Error Monitoring & Retry Queue​

Catalog status: Built (P1, 5 points) Verified status: Built — ChannelSyncLog persistence with status (SUCCESS/FAILED/PARTIAL) + retry count + error message + 2026-05-05 requestXml/responseXml columns; SyncRetryConsumer for retry queue; WebhookEvent audit trail; idempotencyKey (AP-T11-001)

Code locations:

  • hmp-channel-service/src/sync/sync.service.ts — populates ChannelSyncLog on every push attempt; AP-T11-001 idempotencyKey
  • hmp-channel-service/src/sync/sync-retry.consumer.ts — Retry queue consumer
  • hmp-channel-service/src/webhooks/webhook-management.service.ts — Replay endpoint
  • hmp-channel-service/src/webhooks/intermediary-audit-anomaly-detector.service.ts — AP-T11-024 Phase 4B+4C: 5 anomaly conditions (WHITELIST_BECAME_EMPTY, WHITELIST_LARGE_REMOVAL, WHITELIST_OFF_HOURS, WHITELIST_RAPID_EDITS, BULK_PROVIDER_EDITS)
  • hmp-channel-service/src/webhooks/intermediary-config-notifier.service.ts — Slack notifier (audit + anomaly webhook URLs)
  • hmp-channel-service/src/stats/* — Throughput + alerts
  • hmp-web-dashboard/src/components/modules/ari/sync-status-strip.tsx — UI

Unit + integration tests:

  • hmp-channel-service/src/sync/sync.service.spec.ts (14 tests incl. idempotencyKey shape + retry-stability)
  • hmp-channel-service/src/webhooks/__tests__/intermediary-audit-anomaly-detector.spec.ts
  • hmp-channel-service/src/webhooks/__tests__/intermediary-config-notifier.spec.ts
  • hmp-channel-service/src/webhooks/__tests__/webhook-management-replay.spec.ts

Flow tests: covered via channel-sync + resavenue-cert-cycle flows

Real-backend smokes: none specific

Use cases referencing this story: none

SME notes:

  • This is the strongest story in EP-009 — well-tested, hardened, with anomaly detection + Slack notifier + replay endpoint. Verified-Built is appropriate.
  • AP-T11-001 idempotencyKey is the unsung hero — gates duplicate pushes from upstream retries.
  • The 2026-05-05 requestXml/responseXml columns closed a real cert-debugging gap.

Drift findings: (none — implementation matches story closely)


US-009-013 — Commission Tracking by Channel​

Catalog status: Not Started (P2, 3 points) Verified status: Not Started — TdsEntry model exists in schema (per CLAUDE.md) but no commission CRUD, no per-OTA rate config, no monthly reconciliation report

Code locations:

  • hmp-channel-service/prisma/schema.prisma::TdsEntry [CITED-BUT-NOT-FOUND] (exact line not enumerated) — TDS tracking model present

Unit + integration tests: NONE FOUND

Flow tests: NONE FOUND

Real-backend smokes: NONE FOUND

Use cases referencing this story: none

SME notes:

  • The TdsEntry model is the closest thing to existing scaffolding. Commission rate config (per channel × per room type) does not exist.
  • Indian-market rates: MakeMyTrip 18-22%, Goibibo 18-22%, Booking.com 15-18%, Expedia 18%, Agoda 17-20%, OYO 22-30%. Plus 18% GST on commission (reverse charge) + 1% TDS Section 194-O.
  • This story should fold the GST + TDS surface from D-014 (US-009-010) rather than duplicate it.

Drift findings: (none — story-vs-reality matches: Not Started)

  • [SME-PROPOSED] Add AC: "ChannelCommissionConfig table: (channelCode, roomTypeId?, commissionPct, effectiveFrom, effectiveTo?). CRUD via /channels/commissions, gated MANAGER+. Monthly cron computes per-booking commission, persists to BookingCommission join table. Monthly report at GET /reports/commissions?from=&to= with GSTR-2B-ready CSV export (carries D-014)."
  • [SME-PROPOSED] Add AC: "Cancellation of an OTA booking SHALL reverse the commission entry (write a negative BookingCommission row tagged reversalOfBookingId)."