Skip to main content

EP-010 — CRM, Loyalty & Campaigns RTM

EPIC: EP-010 CRM, Loyalty & Campaigns Reconciled: 2026-05-25 Pattern: see ./PLAYBOOK.md. Wave 1 of the 21-EPIC fan-out; follows the ./EP-002-rtm.md pilot template. Source of truth for "% passing": flow-tests hmp-infrastructure/testing/flow-tests/src/flows/loyalty-program.flow.spec.ts, staff-inbox.flow.spec.ts, whatsapp-feedback.flow.spec.ts.

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

Summary​

MetricCount
Total stories19
Catalog claims "Built"9
Catalog claims "Partial"4
Catalog claims "Not Started"6
Verified-Built2 (US-010-01, US-010-13)
Built-Untested0
Partial13 (US-010-02, -03, -04, -06, -07, -08, -09, -10, -11, -15, -17, -18, +US-010-19 scaffold)
Stub0
Not Started4 (US-010-05, -12, -14, -16)
Drift findings15 (D-001..D-015; 7 P0 / 7 P1 / 1 P2)
[SME-PROPOSED] refinements34

ID convention drift (D-001 below): catalog uses US-010-NN (2-digit). The review files under reviews/EP-010/ use US-010-NNN (3-digit). This RTM follows the catalog; review-file references use the existing 3-digit names (-001..-009 reviews map to catalog -01..-09).


US-010-01 — Define Loyalty Tiers​

Catalog status: Built (P0, 5 points) Verified status: Built (with significant validation gaps per review)

Code locations:

  • hmp-crm-service/src/loyalty/loyalty.service.ts — Programs + tiers domain service (createProgram, createTier, updateProgram, getProgram with tiers: true, rewards: true)
  • hmp-crm-service/src/loyalty/loyalty.controller.ts:32-110 — @Roles('SUPER_ADMIN','ADMIN','MANAGER'), @RequiresFeature('crm.loyalty'), GET/POST /loyalty/programs, GET/PATCH /loyalty/programs/:id, GET /loyalty/tiers, GET /loyalty/tiers/:programId, POST /loyalty/tiers
  • hmp-crm-service/src/loyalty/dto/loyalty.dto.ts — CreateProgramDto, CreateTierDto
  • hmp-crm-service/src/loyalty/tier-review.service.ts — Annual tier-review cron (IST timezone)
  • hmp-web-dashboard/src/app/(dashboard)/crm/loyalty/page.tsx — Tiers tab with TierCard grid

Unit + integration tests:

  • hmp-crm-service/src/loyalty/loyalty.service.spec.ts — 4 tests (program create, earn+balance, auto-upgrade, redeem rejection)
  • hmp-crm-service/src/loyalty/tier-review.service.spec.ts — Annual tier-review cron tests

Flow tests:

  • hmp-infrastructure/testing/flow-tests/src/flows/loyalty-program.flow.spec.ts :: "Scenario 1 — enroll new loyalty member in seeded program + Silver tier" — exercises program/tier seed indirectly (TBD — feature-flag gated, soft-skip if crm.loyalty off)

Use cases referencing this story: none

SME notes:

  • Catalog title says 4 tiers (Silver/Gold/Platinum/Diamond); seed ships only Silver/Gold/Platinum per loyalty-program.flow.spec.ts header. EPIC body §3.1 says "Multi-tier loyalty program (Silver, Gold, Platinum, Diamond)" — Diamond is missing from the seed. Story Gherkin example uses generic "Gold" only.
  • Review file US-010-001-review.md flags 12 gaps including missing @@unique([programId, name]), no overlap validation, no PATCH/DELETE tier endpoints, no tenantId scoping on queries (P0 security), no Kafka events.
  • LoyaltyTier.minPoints/maxPoints is a Number range — there is no DB CHECK constraint preventing overlapping ranges (Silver 0-999 + Bronze 500-1499). The earnPoints tier-resolver picks orderBy minPoints desc which is non-deterministic under overlap.

Drift findings:

D-002 — [P0] [Missing-validation + tenant-isolation] LoyaltyService.createTier does not enforce per-program tier name uniqueness (no @@unique([programId, name]) in Prisma; no service-side check), nor overlapping point-range rejection, nor tenant scoping on loyalty/programs/:id GET (the controller passes tenantId but the service does not consistently filter by it — see review GAP-5). Silent overlap → non-deterministic tier upgrade.

  • [SME-PROPOSED] Add AC: "Creating two tiers with the same name within one programId SHALL return 409 with code=LOYALTY_TIER_DUPLICATE_NAME. Two tiers with overlapping [minPoints, maxPoints] ranges SHALL return 409 with code=LOYALTY_TIER_RANGE_OVERLAP. Both rules enforced by service-layer check + Prisma @@unique([programId, name]) constraint."
  • [SME-PROPOSED] Add AC: "Every loyalty read/write endpoint SHALL scope by tenantId from JWT. A request with a forged programId belonging to tenant-B from tenant-A's JWT SHALL return 404 (not 403 — no row-existence signal)."

US-010-02 — Member Enrollment​

Catalog status: Built (P0, 5 points) Verified status: Partial — review verdict: 3/10 ACs pass

Code locations:

  • hmp-crm-service/src/loyalty/loyalty.service.ts — enrollMember(tenantId, dto), earnPoints(memberId, dto) (tier auto-upgrade with Kafka loyalty.tier.changed)
  • hmp-crm-service/src/loyalty/loyalty.controller.ts — POST /loyalty/members, GET /loyalty/members, GET /loyalty/members/:id, POST /loyalty/members/:id/earn
  • hmp-crm-service/src/loyalty/portal/loyalty-portal.controller.ts — booking-engine self-enrollment surface (stub per review)
  • hmp-crm-service/src/loyalty/dto/loyalty.dto.ts — EnrollMemberDto, EarnPointsDto
  • hmp-web-dashboard/src/app/(dashboard)/crm/loyalty/page.tsx — Members tab (search, tier/status filter, paginated table)

Unit + integration tests:

  • hmp-crm-service/src/loyalty/loyalty.service.spec.ts — 2 relevant tests (earn+balance, auto-upgrade)
  • hmp-crm-service/src/loyalty/portal/loyalty-portal.service.spec.ts — 5 portal tests

Flow tests:

  • hmp-infrastructure/testing/flow-tests/src/flows/loyalty-program.flow.spec.ts :: "Scenario 1 — enroll a new loyalty member" — TBD (crm.loyalty feature-flag gated)

Use cases referencing this story: none

SME notes (review file US-010-002-review.md):

  • Membership number format is LM-{timestamp}-{random}; story specifies HMP-LY-XXXXX / HMP-XXXXXXXX. Review GAP-1 (MEDIUM).
  • EnrollMemberDto lacks guestId → Guest.loyaltyMemberId never linked; profile→member resolution broken in dashboard.
  • No duplicate-enrollment check, no inactive-program check, no Kafka loyalty.member.enrolled event.
  • Indian-compliance gap: AC-6 ("GST excluded from points calculation") is NOT_IMPL — earnPoints accepts a pre-calculated points value. Story Gherkin Scenario 3 explicitly requires "10000 INR room + 1800 INR GST (18%) → points on 10000 only".
  • No Kafka consumer for folio.settled / billing.invoice.finalized (AC-10 NOT_IMPL) → stay-based points never auto-credit. This is the core "earn-from-stay" loop being absent.

Drift findings:

D-003 — [P0] [Cross-service-handoff-missing + India-compliance] No FolioSettledConsumer exists in crm-service; the entire stay-based earn loop relies on staff manually POSTing to /loyalty/members/:id/earn. AC-10 + AC-4 + AC-6 (GST exclusion) all hinge on this consumer. Story is marked "Built"; reality is a controller endpoint with no event wiring + no GST math.

  • [SME-PROPOSED] Add AC: "crm-service SHALL register a FolioSettledConsumer subscribing to folio.settled (or canonical FOLIO_POSTED/FOLIO_PAYMENT_POSTED) from frontdesk-service. The consumer SHALL: (a) compute qualifyingAmount = totalAmount - (cgst + sgst + igst + serviceCharge); (b) apply tier multiplier; (c) credit points with expiryDate = now + earningRules.pointsExpiryMonths (default 24); (d) publish loyalty.points.earned with dedup key folio:{folioId}:points-earn."
  • [SME-PROPOSED] Add AC: "Guest.loyaltyMemberId SHALL be updated atomically with LoyaltyMember create in a single $transaction. EnrollMemberDto SHALL accept guestId (UUID, required for staff enrolment) or phone+firstName+lastName (for self-enrolment without prior guest profile)."

US-010-03 — Points Earn Rules​

Catalog status: Built (P0, 8 points) Verified status: Partial — caller-supplied points, no GST exclusion, no bonus rules

Code locations:

  • hmp-crm-service/src/loyalty/loyalty.service.ts — earnPoints, tier multiplier path (basic), loyalty.tier.changed Kafka emit
  • hmp-crm-service/src/loyalty/dto/loyalty.dto.ts — EarnPointsDto

Unit + integration tests:

  • hmp-crm-service/src/loyalty/loyalty.service.spec.ts — earn+balance, auto-upgrade

Flow tests:

  • loyalty-program.flow.spec.ts :: "Scenario 2 — earn points (500 from a Room Stay)" — TBD

Use cases referencing this story: none

SME notes:

  • earningRules is a JSON blob on LoyaltyProgram — no typed schema. Tenants can submit any shape; service consumers must parse defensively.
  • No direct-booking bonus, no birthday bonus, no first-stay bonus (all promised by EPIC §6 KPIs and story Scenarios 2-3).
  • No idempotency on POST /loyalty/members/:id/earn — replay risk via OTA retry.

Drift findings:

D-004 — [P1] [Story-incomplete + India-tax-critical] earningRules is an untyped JSON object on the program. The story specifies stay-based, spend-based, activity-based earn types and a Gold 1.5× multiplier, but the DB shape allows ANY structure. India-tax concern: there is no enforced field for excludeGstFromQualifyingSpend: boolean, even though the canonical Indian-hospitality model is points on pre-GST tariff only.

  • [SME-PROPOSED] Add AC: "earningRules SHALL be a Zod-validated typed object with the discriminated-union shape { kind: 'STAY' | 'SPEND' | 'ACTIVITY', ...details } and a top-level qualifyingSpend: { includeGst: boolean (default false), includeServiceCharge: boolean (default false), excludedChargeCodes: string[] }. Validation runs at POST /loyalty/programs time."
  • [SME-PROPOSED] Add AC: "POST /loyalty/members/:id/earn SHALL accept referenceId (folio ID, reservation ID, or external receipt). The (memberId, referenceId) pair SHALL be unique; replays return the original PointsTransaction row (200, idempotent)."

US-010-04 — Points Redemption​

Catalog status: Built (P0, 8 points) Verified status: Partial — review verdict: 2/10 ACs pass

Code locations:

  • hmp-crm-service/src/loyalty/loyalty.service.ts — redeemPoints (balance check + deduction; BadRequestException on insufficient points)
  • hmp-crm-service/src/loyalty/portal/loyalty-portal.service.ts — guest-facing redeemReward, getAvailableRewards with affordable flag
  • hmp-crm-service/src/loyalty/portal/loyalty-portal.controller.ts — POST /loyalty/portal/redeem, GET /loyalty/portal/rewards
  • hmp-crm-service/src/loyalty/dto/loyalty.dto.ts — RedeemPointsDto

Unit + integration tests:

  • loyalty.service.spec.ts — 1 redemption-rejection test (insufficient points)
  • loyalty-portal.service.spec.ts — portal redeem coverage

Flow tests:

  • loyalty-program.flow.spec.ts :: "Scenario 3 — redeem 200 points" — TBD

Use cases referencing this story: none

SME notes (review file US-010-003-review.md):

  • No maxRedemptionPct cap (story AC-2 NOT_IMPL) — member can redeem 100% of folio.
  • No minBalance cap (AC-3 NOT_IMPL) — member can drain to zero.
  • No optimistic locking on pointsBalance (AC-10 NOT_IMPL) — two concurrent POST /redeem calls can double-deduct (P0 financial race).
  • No reverse-redemption on reservation.cancelled (AC-7 NOT_IMPL) — booking cancellation leaves points spent.
  • No voucher generation (story Scenario 3 last-line says "voucher code generated + sent via SMS"); reward redemption returns a Prisma row with no voucher entity.

Drift findings:

D-005 — [P0] [Financial-correctness + concurrency] LoyaltyService.redeemPoints performs findUnique → decrement without optimistic locking, version column, or transactional balance assertion. Two concurrent redeem calls deduct twice. For an end-of-stay flow with parallel booking-engine + front-desk surfaces, this is a realistic race.

  • [SME-PROPOSED] Add AC: "redeemPoints SHALL execute in a Prisma $transaction using update where: { id, pointsBalance: { gte: dto.points } } and assert count === 1, returning 409 with code=LOYALTY_INSUFFICIENT_OR_RACE otherwise. Spec covers two concurrent redemptions of 600 points each against a 1,000-balance member — exactly one MUST succeed."
  • [SME-PROPOSED] Add AC: "Cancellation reversal — a ReservationCancelledConsumer SHALL credit back any REDEEM rows tagged with the cancelled reservation's referenceId and publish loyalty.points.refunded (idempotent via referenceId)."

US-010-05 — Points Expiry Processing​

Catalog status: Not Started (P0, 5 points) Verified status: Partial — service skeleton exists but EARN rows lack expiryDate

Code locations:

  • hmp-crm-service/src/loyalty/points-expiry.service.ts — PointsExpiryService (cron skeleton + DPDPA reference)
  • hmp-crm-service/src/loyalty/points-expiry.service.spec.ts — spec exists

Unit + integration tests: spec file present; coverage of nightly expiry path is the operative gap.

Flow tests: none.

Use cases referencing this story: none

SME notes:

  • Service file exists per Bash ls but LoyaltyService.earnPoints does not set expiryDate on the PointsTransaction EARN row (per US-010-002 review GAP-7). Result: the expiry cron has nothing to expire.
  • Pre-expiry reminder (story AC-3, 30 days before) requires a separate scheduled scan + notification dispatch — not implemented.

Drift findings:

D-006 — [P1] [Catalog-vs-code-drift + half-built] Catalog says "Not Started" but points-expiry.service.ts + spec EXIST. However, the upstream earnPoints write path doesn't populate expiryDate, so the cron is decorative. Either declare the story Partial or remove the skeleton.

  • [SME-PROPOSED] Promote catalog status from Not Started → Partial. Add AC: "Every EARN PointsTransaction row SHALL carry expiryDate = transactionDate + program.earningRules.pointsExpiryMonths (default 24). Nightly cron at 02:30 IST scans EARN rows where expiryDate < now AND no offsetting EXPIRE row, writes an EXPIRE transaction, deducts the member balance, and publishes loyalty.points.expired."
  • [SME-PROPOSED] Add AC: "30-day pre-expiry reminder cron at 09:00 IST scans EARN rows with expiryDate between now+29d and now+31d, dispatches one SMS/email per member via notification-service with {{points}} {{expiryDate}} template. Throttle: 1 reminder per member per 30 days regardless of multiple expiring batches."

US-010-06 — Points Transfer Between Members​

Catalog status: Partial (P2, 5 points) Verified status: Partial — endpoint exists; fees, limits, audit, UI all missing per EPIC §10 story body

Code locations:

  • hmp-crm-service/src/loyalty/loyalty.controller.ts — controller surface (transfer endpoint per story body)
  • hmp-crm-service/src/loyalty/loyalty.service.ts — transfer service path (validation: same tenant only)

Unit + integration tests: no dedicated transfer spec found in loyalty.service.spec.ts.

Flow tests: none.

Use cases referencing this story: none

SME notes:

  • Story Gherkin Scenario 1 specifies a 10% transfer fee; no code enforces it. Scenario 2 specifies a monthly 5,000-point limit; no rate-limit table exists. Scenario 3 specifies OTP verification of recipient; auth-service has no OTP endpoint wired into the CRM transfer path.
  • Tax exposure: in India, loyalty point transfers across non-related parties can trigger GST under Schedule II (transfer of right in goods). The story doesn't address this.

Drift findings: (no new D; documented in US-010-04 D-005 family — concurrency applies here too)

  • [SME-PROPOSED] Add AC: "Transfers SHALL be limited to verified family members (recipient on the same Guest.familyGroupId) OR require recipient OTP verification. Tax disclaimer in transfer UI: 'Point transfers may have GST implications; consult your tax advisor.'"

US-010-07 — Reward Catalog Management​

Catalog status: Built (P1, 5 points) Verified status: Partial — list/create work; PATCH missing; inventory/availability gaps

Code locations:

  • hmp-crm-service/src/loyalty/loyalty.controller.ts — GET /loyalty/rewards, POST /loyalty/rewards
  • hmp-crm-service/src/loyalty/loyalty.service.ts — reward CRUD (no PATCH per review GAP-9)
  • hmp-crm-service/src/loyalty/portal/loyalty-portal.service.ts — getAvailableRewards with affordable boolean

Unit + integration tests: covered indirectly via portal tests.

Flow tests: none.

Use cases referencing this story: none

SME notes:

  • No availabilityWindow enforcement (story Scenario 2 — "Paused for dates 2026-04-01 to 2026-04-15") — isActive boolean only.
  • No daily-cap inventory (story Scenario 3 — "max 2 redemptions per day") — no redemptionsToday counter.

Drift findings: (none new — feeds D-005 redemption-control family)

  • [SME-PROPOSED] Add AC: "LoyaltyReward SHALL gain pausedFrom/pausedUntil date fields and maxRedemptionsPerDay. Redemption attempt outside availability or over cap returns 422 with code=REWARD_UNAVAILABLE / REWARD_DAILY_CAP_REACHED plus next-available-date."

US-010-08 — Campaign Creation (6 Types)​

Catalog status: Built (P0, 8 points) Verified status: Partial — review verdict: 2/8 ACs pass

Code locations:

  • hmp-crm-service/src/campaigns/campaigns.controller.ts:34-161 — @Roles('SUPER_ADMIN','ADMIN','MANAGER'), @RequiresFeature('crm.campaigns'), POST/GET/GET-by-id/DELETE/POST-pause/POST-launch/POST-execute/GET-recipients/GET-metrics. Tenant-id is read from x-tenant-id header (forbidden if missing).
  • hmp-crm-service/src/campaigns/campaigns.service.ts — create, findOne, findAll, launch, execute, segmentRecipients, pause, remove, getMetrics
  • hmp-crm-service/src/campaigns/dto/campaign.dto.ts — CreateCampaignDto (type+channel as plain strings, no enum validation per review GAP-7)
  • hmp-crm-service/src/campaigns/trai-dnd.service.ts — TRAI DND registry check service
  • hmp-web-dashboard/src/app/(dashboard)/crm/campaigns/page.tsx — list page with launch/pause/delete dropdown

Unit + integration tests:

  • hmp-crm-service/src/campaigns/campaigns.service.spec.ts — 15+ tests (segmentation, batch, status transitions)
  • hmp-crm-service/src/campaigns/__tests__/campaign-cross-tenant-denial.spec.ts — 14 tests (Pick D Gold-Standard, including segmentRecipients PII-leak guard)

Flow tests:

  • hmp-infrastructure/testing/flow-tests/src/flows/staff-inbox.flow.spec.ts — campaign-adjacent (staff communication inbox flow)

Use cases referencing this story: none

SME notes (review file US-010-004-review.md):

  • No type-specific config validation (BIRTHDAY.daysBefore, FEEDBACK.daysAfterCheckout, TRANSACTIONAL.triggerEvent, NEWSLETTER.recurrencePattern, ANNIVERSARY.anniversaryType). Story body shows all five — code accepts any string in segmentRules JSON.
  • No PATCH endpoint, no clone endpoint.
  • Security gap (Task #11): per the cross-tenant-denial spec header, CampaignsService.create writes the row using caller-supplied tenantId but does NOT verify that the DTO's propertyId or templateId belongs to that tenant. A SUPER_ADMIN with a forged propertyId writes a Campaign attached to another tenant's property. Same gap applies to templateId (template-channel mismatch also unchecked per review GAP-5).
  • Security gap (Task #11, findOne info-leaky): findOne rejects cross-tenant reads with ForbiddenException rather than NotFoundException, leaking row existence. The pattern in hmp-pos-service orders cross-tenant denial returns 404 — CRM should match (per the spec's own header comment, "this is information-leaky vs. the canonical pattern").

Drift findings:

D-007 — [P0] [Security + cross-service-validation] CampaignsService.create does not call PropertyServiceClient.assertPropertyTenant(propertyId, tenantId) nor verify templateId ownership. The cross-tenant-denial spec documents this as a known gap. Together with findOne returning 403 instead of 404, an attacker with a tenant-A JWT can probe foreign campaign IDs (existence-leak) and write rows pointing at foreign properties (data-pollution).

  • [SME-PROPOSED] Add AC: "POST /api/campaigns SHALL call PropertyServiceClient.assertPropertyTenant(dto.propertyId, tenantId) before insert. SHALL look up dto.templateId and assert template.tenantId === tenantId AND template.channel === dto.channel. Either check failing returns 404 (not 403, not 422 — no existence/ownership signal)."
  • [SME-PROPOSED] Add AC: "CampaignsService.findOne cross-tenant denial SHALL return NotFoundException (404), matching the hmp-pos-service/src/orders canonical pattern. The existing 403 path is a known regression tracked since the campaign-cross-tenant-denial.spec.ts was authored."

D-008 — [P1] [Indian-compliance + story-incomplete] trai-dnd.service.ts exists but the EPIC §10 story body for US-010-08 (and -10) does not specify the DND-check contract. TRAI's TCCCPR 2018 requires consent-based dispatch for promotional SMS to non-business numbers + DLT sender-ID validation. Missing from ACs.

  • [SME-PROPOSED] Add AC: "All PROMOTIONAL and NEWSLETTER SMS campaigns SHALL be filtered through TraiDndService.filterRecipients(numbers, headerCategory) before dispatch. Excluded numbers are recorded as skipped: 'DND_REGISTRY' in campaign metrics. DLT sender ID + template ID validation is enforced at the notification-service boundary (cross-EPIC: EP-014)."

US-010-09 — Communication Template Engine​

Catalog status: Built (P0, 8 points) Verified status: Partial — review verdict: 2/8 ACs pass

Code locations:

  • Templates engine lives in hmp-notification-service (per US-010-005-review.md GAP-9 — path is /api/v1/notification/templates not /api/v1/crm/templates)
  • Shared package: @hmp/template-engine (block-tree → MJML → HTML; 11 typed block types; Handlebars helpers for currency/IST date/pluralize per platform CLAUDE)
  • Shared package: @hmp/notification-contracts (Zod schemas per event, 5-channel __fallback strings)

Unit + integration tests: per review file, no templates.service.spec.ts exists in notification-service (review GAP — 0 tests for template logic).

Flow tests: none.

Use cases referencing this story: none

SME notes (review file US-010-005-review.md):

  • No auto-extraction of {{variable}} patterns from body/subject (story AC-1, AC-2 NOT_IMPL).
  • No language field in CommunicationTemplate schema (story AC-3 + AC-8 NOT_IMPL — Hindi/multi-language fallback impossible).
  • No SMS character-limit validation (story AC-4 NOT_IMPL) — 480-char threshold absent.
  • No version column → updates overwrite; running campaigns reference mutable templates (AC-7 NOT_IMPL).
  • No dashboard template management UI (review GAP-13).
  • Path mismatch story-vs-code: story says /api/v1/crm/templates; code lives at /api/v1/notification/templates. Gateway-routing decision.

Drift findings:

D-009 — [P1] [Multi-language + Indian-language-critical] No language column on CommunicationTemplate. Story Scenario 3 explicitly requires Hindi + English templates with fallback. India's 8th-schedule language coverage (HI/TA/TE/BN/MR/GU + EN) is a baseline requirement for Tier-2/3 hotel guest comms — currently impossible.

  • [SME-PROPOSED] Add AC: "CommunicationTemplate SHALL gain language String @default('en') + version Int @default(1) columns. @@unique([propertyId, name, channel, language]) constraint. Update path SHALL create a new version row (immutable history); campaign.templateVersion pins the version snapshot. Dispatch path resolves guest's preferred language with fallback chain (preferred → en)."
  • [SME-PROPOSED] Add AC: "Body SHALL be parsed with regex /\\{\\{(\\w+)\\}\\}/g; extracted variables checked against KNOWN_VARIABLES registry; unknown variables return non-blocking warnings[] in the create/update response."

US-010-10 — Multi-Channel Campaign Dispatch​

Catalog status: Partial (P0, 13 points) Verified status: Partial — review verdict: 2/9 ACs pass

Code locations:

  • hmp-crm-service/src/campaigns/campaigns.service.ts — execute() (sync), executeBatch() (batch size 100), publishes notification.send + campaign.batch.send Kafka events
  • hmp-crm-service/src/campaigns/trai-dnd.service.ts — DND check
  • hmp-notification-service — Kafka NOTIFICATION_SEND consumer; 4 channel adapters (Email/SMS/WhatsApp/Push) per platform CLAUDE

Unit + integration tests:

  • campaigns.service.spec.ts — covers batch execute path

Flow tests:

  • hmp-infrastructure/testing/flow-tests/src/flows/whatsapp-feedback.flow.spec.ts — adjacent WhatsApp dispatch flow

Use cases referencing this story: none

SME notes (review file US-010-006-review.md):

  • executeBatch() sends campaign.name as message content, NOT a rendered template body (review GAP-1 HIGH).
  • No pause/resume mid-execution (execute is synchronous in a single request).
  • No SendGrid/Twilio/Meta webhook consumers → delivered/opened/clicked/converted fields never updated post-dispatch.
  • No auto-pause on >50% batch failure rate.
  • No TRAI DND enforcement on PROMOTIONAL SMS at dispatch time despite trai-dnd.service.ts existing.

Drift findings:

D-010 — [P0] [Critical-implementation-gap] executeBatch sends campaign.name as the literal message body. The campaign-level templateId is recorded but never resolved/rendered. Every campaign that "succeeds" today is delivering the campaign name string to guests — a real ops escape.

  • [SME-PROPOSED] Add AC: "executeBatch SHALL resolve campaign.templateId (with version pin if D-009 lands), call templateEngine.render(template, { guest, booking, hotel, ...vars }), and publish the rendered body in the Kafka NOTIFICATION_SEND payload. Spec asserts the payload content field is the rendered output, not the campaign name."
  • [SME-PROPOSED] Add AC: "Dispatch SHALL invoke TraiDndService.filterRecipients BEFORE batching for any campaign of type PROMOTIONAL/NEWSLETTER on channel SMS. Filtered count surfaces as metrics.skipped.dnd. Spec asserts a DND-registered number is excluded from the Kafka publish."

US-010-11 — Drip Campaign Visual Builder​

Catalog status: Built (P1, 13 points) Verified status: Partial — linear step engine exists, NOT the React Flow visual builder

Code locations:

  • hmp-crm-service/src/campaigns/drip-engine.service.ts — linear step-based drip engine
  • hmp-crm-service/src/campaigns/drip-engine.service.spec.ts — drip engine tests
  • BullMQ queue crm-drip-campaigns (per crm CLAUDE Gotchas — F-023 — replaced in-memory scheduledSteps[])
  • hmp-web-dashboard/src/app/(dashboard)/crm/campaigns/... — Drip Builder link (timeline UI, not React Flow per review)

Unit + integration tests: 8 tests in campaigns.service.spec.ts + drip-engine.service.spec.ts.

Flow tests: none.

Use cases referencing this story: none

SME notes (review file US-010-007-review.md):

  • Verdict FAIL: linear campaign_steps table + cron scheduler is FUNDAMENTALLY DIFFERENT from the React Flow node graph specified in the story.
  • No DripCampaign / DripCampaignVersion Prisma model with workflow_json.
  • No node-types (Trigger/Delay/Condition/Action/Split) — just a vertical timeline.
  • BullMQ wiring IS correct (no longer in-memory) — that's the one improvement on the story body.

Drift findings:

D-011 — [P1] [Catalog-overestimates] Catalog says Built; review verdict is FAIL. The shipped linear builder satisfies a narrow read of "drip" but not the visual workflow described in story Scenario 1 ("Trigger → Delay → Action → Condition → Yes/No branches"). Demoting to Partial is generous.

  • [SME-PROPOSED] Demote catalog → Partial. Author follow-up story US-010-NEW-DRIP-WORKFLOW-JSON for the actual React Flow + workflow-JSON + per-node config-panel scope. Existing linear engine becomes the v0 internal implementation behind it.

US-010-12 — Drip Campaign Execution Engine​

Catalog status: Not Started (P0, 13 points) Verified status: Not Started — agrees with US-010-11 review

Code locations: none for the workflow-graph executor; linear drip-engine.service.ts covers a narrow subset.

Unit + integration tests: none.

Flow tests: none.

Use cases referencing this story: none

SME notes:

  • Honest "Not Started" — agrees with the story body (no Kafka consumer that traverses a workflow graph; no scheduler that respects delay nodes; no condition-node evaluator).
  • Hard dependency on US-010-09 (rendered template) and US-010-10 (dispatch wiring). Sequencing matters in plan.

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

  • [SME-PROPOSED] Add AC: "Execution-engine entry contract: DripWorkflowExecutor.start({ tenantId, workflowId, triggerEvent, contextVars }). Engine state persisted as DripInstance(workflowId, contextSnapshot, currentNodeId, nextFireAt, status). Cancellation hook: ReservationCancelledConsumer terminates any DripInstance referencing the cancelled booking. Required before any production drip rollout."

US-010-13 — Guest Segmentation (Manual)​

Catalog status: Built (P1, 5 points) Verified status: Built — manual filter segmentation works (within campaigns); review verdict for the broader segmentation engine (US-010-008 review) is FAIL

Code locations:

  • hmp-crm-service/src/campaigns/campaigns.service.ts — segmentRecipients() filters guests by vipLevel, minStays, lastStayBefore/After, hasEmail, hasPhone, tags
  • Hard cap: 500 guests per segmentation pass (review GAP-7)

Unit + integration tests:

  • campaigns.service.spec.ts — 7 tests in segmentRecipients describe block

Flow tests: none.

Use cases referencing this story: none

SME notes:

  • tags filter is text contains on the Guest.notes field — fragile. Should be a Guest.tags String[] array.
  • 500-cap means a large property with >500 segmented guests gets silently truncated.
  • No dedicated GuestSegment entity → segments are NOT reusable across campaigns; every campaign re-encodes its rules.

Drift findings: (none net new — see D-012 below for RFM scope)

  • [SME-PROPOSED] Replace tags text-search with Guest.tags String[] array + GIN index. Add Guest.tagsHistory Json for audit. Cursor-based pagination instead of take: 500.

US-010-14 — RFM-Based Guest Segmentation​

Catalog status: Not Started (P1, 13 points) Verified status: Not Started — no guest_rfm_scores table, no nightly cron

Code locations: none.

Unit + integration tests: none.

Flow tests: none.

Use cases referencing this story: none

SME notes (review file US-010-008-review.md):

  • Story specifies a nightly 02:00 IST batch + Champions/Loyal/At-Risk/Lost named segments + per-tenant configurable thresholds. None exists.
  • Honest "Not Started".

Drift findings:

D-012 — [P2] [Story-incomplete + India-INR-thresholds] Story Scenario 3 specifies per-tenant monetary thresholds in INR. The story does not specify the source-of-truth for "monetary" (gross? net of GST? folio total vs realized payment?). Resolution affects RFM math.

  • [SME-PROPOSED] Add AC: "RFM monetary score SHALL use sum(folio.realizedPayment - folio.refund) over the rolling 12-month window (NOT gross folio amount, NOT including GST). Currency = INR; multi-currency properties (out of scope for v1) re-quantise to INR at booking date FX rate."

US-010-15 — Feedback Survey Builder​

Catalog status: Built (P1, 8 points) Verified status: Partial — survey CRUD + response submission work; review verdict PASS_WITH_NOTES

Code locations:

  • hmp-crm-service/src/feedback/feedback.controller.ts:28-101 — @Roles('SUPER_ADMIN','ADMIN','MANAGER'), @RequiresFeature('crm.basic'), GET/POST /feedback/surveys, GET /feedback/responses, etc.
  • hmp-crm-service/src/feedback/feedback.service.ts — survey CRUD, sentiment analysis, auto-assign-to-manager for negative feedback
  • hmp-crm-service/src/feedback/feedback-trigger.service.ts — BullMQ-backed checkout-trigger (per crm CLAUDE Gotchas — F-024)
  • hmp-crm-service/src/feedback/dto/feedback.dto.ts — CreateSurveyDto, SubmitResponseDto
  • hmp-web-dashboard/src/app/(dashboard)/crm/feedback/page.tsx — list page with ratings, sentiment, respond dialog

Unit + integration tests:

  • feedback.service.spec.ts — 2 tests (sentiment, auto-assign)
  • feedback-trigger.service.spec.ts — 5 tests (checkout subscribe, queue, SMS fallback)

Flow tests:

  • hmp-infrastructure/testing/flow-tests/src/flows/whatsapp-feedback.flow.spec.ts — WhatsApp feedback request scheduled at checkout

Use cases referencing this story: none

SME notes (review file US-010-009-review.md):

  • Survey builder backend works; no survey-builder UI in dashboard (review GAP-6).
  • No department field on survey questions (story spec extension).
  • feedback.controller.ts and service use @ts-nocheck per review GAP-9 — type safety bypassed.

Drift findings: (none new — covered by D-013 below)

  • [SME-PROPOSED] Add AC: "Remove @ts-nocheck from feedback.controller.ts and feedback.service.ts. Document any remaining type errors in a TODO(D-013) follow-up before merge."

US-010-16 — Automated NPS Calculation​

Catalog status: Not Started (P1, 5 points) Verified status: Not Started — npsScore stored but no aggregation

Code locations:

  • GET /feedback/nps controller route exists in feedback.controller.ts:45-59 — wraps feedbackService.getNpsSummary. Per review file US-010-009-review.md, the underlying getSummary returns hardcoded placeholder data — the NPS aggregation path appears to be a stub. Verify.

Unit + integration tests: none for NPS aggregation.

Flow tests: none.

Use cases referencing this story: none

SME notes (review file US-010-009-review.md):

  • npsScore field exists on FeedbackResponse; values are stored but no Promoter/Passive/Detractor aggregation computed.
  • getSummary() returns hardcoded averages (review GAP-3 HIGH).
  • No NPS dashboard card.
  • No alerting when NPS drops past threshold (story Scenario 2).
  • No NpsSnapshot model for persistence across periods.

Drift findings: (none new — but escalate getSummary's hardcoded data to P0 per review)

  • [SME-PROPOSED] Add AC: "getSummary SHALL aggregate from actual FeedbackResponse rows (no hardcoded placeholders). NpsSnapshot(tenantId, propertyId, periodStart, periodEnd, promoters, passives, detractors, nps, sampleSize) persisted nightly at 02:30 IST. Dashboard renders 30/60/90-day rolling NPS + tier-segmented NPS + alert when drop > 15 pts."

US-010-17 — A/B Testing for Campaigns​

Catalog status: Partial (P1, 8 points) Verified status: Partial — UI variants exist; backend split logic does not (per EPIC §5 Gap #2 — all recipients get variant A today)

Code locations:

  • Dashboard CRM module — A/B variant UI (up to 3 variants + control)
  • No backend split logic in campaigns.service.ts (per EPIC §5)

Unit + integration tests: none for split logic.

Flow tests: none.

Use cases referencing this story: none

SME notes:

  • Story Scenario 3 specifies statistical-significance check (p > 0.05). The platform has no stats library wired; would need simple-statistics or equivalent.

Drift findings: (none new — EPIC §5 Gap #2 already P1)

  • [SME-PROPOSED] Add AC: "Backend executeBatch SHALL randomly split the test-size cohort (20% default) per configured ratio (50/50/...) using a per-recipient deterministic hash (recipientId+campaignId). Holdout (80%) waits in a separate Kafka topic; auto-winner cron at testEnd fires the holdout with the winning variant. Significance check uses chi-squared on open-rate (single-sided p<0.05) before declaring a winner."

US-010-18 — Campaign Analytics Dashboard​

Catalog status: Partial (P1, 8 points) Verified status: Partial — basic metrics; webhook consumers missing (cross-EPIC EP-014)

Code locations:

  • campaigns.service.ts::getMetrics returns Campaign row's tracking fields
  • Campaign columns: delivered/opened/clicked/converted (per review GAP-7 — never updated post-dispatch)
  • SendGrid webhook → EMAIL delivered/opened partial via notification-service

Unit + integration tests: none for end-to-end webhook → metric increment.

Flow tests: none.

Use cases referencing this story: none

SME notes:

  • Conversion attribution (story Scenario 1 — "booked within 7 days of click with promo code SUMMER26") requires booking-engine to surface the promo code on reservation create + a Kafka reservation.promo.matched consumer in crm-service. Not wired.

Drift findings: (none new)

  • [SME-PROPOSED] Add AC: "Conversion attribution SHALL be event-driven: a ReservationConfirmedConsumer matches reservation.promoCode to recent Campaign.recipients rows, increments converted, and writes attributedRevenue = reservation.totalAmount - taxes. Window: 7 days from Recipient.clickedAt."

US-010-19 — WhatsApp Template Approval Workflow​

Catalog status: Not Started (P1, 8 points) Verified status: Not Started — no Meta integration

Code locations: none in crm-service for template-approval workflow. WhatsApp dispatcher exists in notification-service (per EPIC §4 — scaffolded, blocked on Meta Business verification).

Unit + integration tests: none.

Flow tests:

  • whatsapp-feedback.flow.spec.ts — exercises WhatsApp dispatch but not template approval

Use cases referencing this story: none

SME notes:

  • WhatsApp Business API requires pre-approved templates AND the quality_rating of the sending number drops on user-reported spam. Story does not address rating governance.
  • Indian context: Meta requires Indian phone numbers under WhatsApp Business to use a verified business name displayed to recipients. Template approval is per-sender-number, not per-tenant.

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

  • [SME-PROPOSED] Add AC: "WhatsAppTemplate Prisma model SHALL track metaTemplateId, metaStatus (PENDING/APPROVED/REJECTED/PAUSED), metaQualityRating (GREEN/YELLOW/RED), rejectionReason. A webhook consumer at notification-service /webhooks/meta/template-status updates metaStatus and emits whatsapp.template.statusChanged for crm-service to invalidate its template selectors."

Cross-EPIC drift findings (DSAR / DPDPA / Guardian fields)​

D-001 — ID convention drift catalog vs reviews (P0)​

Catalog uses US-010-NN (2-digit, 19 stories). Review files under reviews/EP-010/ use US-010-NNN (3-digit; only 9 review files exist — covering -001..-009 which correspond to catalog -01..-09). Reviews -010 through -019 are missing entirely. Cross-references in this RTM and downstream business-flow docs WILL diverge.

  • [SME-PROPOSED] Adopt the catalog (2-digit, US-010-NN) as canonical for the EPIC body and per-EPIC RTM. Rename existing review files US-010-NNN-review.md → US-010-NN-review.md AND backfill the missing 10 review files (US-010-10..US-010-19).

EP-010 stories repeatedly reference SMS/WhatsApp/email dispatch to guests (US-010-02 enrollment SMS, US-010-08 birthday auto-target, US-010-10 multi-channel dispatch, US-010-15 post-stay feedback, US-010-19 WhatsApp). None of the stories address minors. Under India's DPDPA 2023 §9, processing personal data of children (under 18) requires verifiable parental/guardian consent, and behavioural tracking + targeted ads to children are prohibited. A 16-year-old loyalty member opted-in by a parent at check-in receives birthday SMS at age 17 with no guardian re-consent trail.

  • [SME-PROPOSED] Add AC (cross-EPIC, primary owner EP-004 guest-service, consumer EP-010): "Guest.dateOfBirth SHALL be required for loyalty enrolment. If age < 18 at any campaign dispatch time, dispatch SHALL be blocked unless Guest.guardianConsent.recordedAt is non-null AND the guardian's phoneNumberHash is on file. Behavioural tracking + targeted promotional campaigns SHALL be entirely disabled for minor members."
  • [SME-PROPOSED] Add AC: "Loyalty enrolment for minors SHALL collect guardianName, guardianPhone, guardianRelation (parent / legal guardian), guardianConsent: { recordedAt, recordingMethod (VERBAL / SIGNATURE / DIGITAL_OTP) }. UI presents the consent screen separately from the guest-data screen."

D-014 — GDPR/DPDPA right-to-erasure cascade (cross-EPIC, P1)​

hmp-crm-service SHIPS a GuestErasedConsumer (per crm CLAUDE — AP-T08-005) that nulls PII on Guest read-model + GuestCommunication.content. But loyalty data is NOT cascaded: LoyaltyMember.memberNumber, full PointsTransaction history, FeedbackResponse.comments (free-text PII risk), and Campaign.recipients (per-guest engagement log) remain intact. Under DPDPA §12 + GDPR Art. 17 the erasure cascade must reach every identifiable record.

  • [SME-PROPOSED] Add AC: "On guest.erased, GuestErasedConsumer SHALL ALSO: (a) anonymise LoyaltyMember by setting memberNumber='[erased]', firstName='[erased]', lastName='[erased]', email/phone null; (b) preserve pointsBalance aggregate for financial-liability accounting but null PointsTransaction.metadata.guestNotes; (c) replace FeedbackResponse.comments + answers (free-text fields) with '[erased]'; (d) hard-delete Campaign.recipients rows for the guest (engagement log is not financial); (e) emit loyalty.guest.erased.ack."

D-015 — Loyalty point GST treatment (cross-EPIC, P1)​

EPIC §8 AC-1 says "INR-based spend thresholds and points-per-rupee earn rates" but never addresses the GST liability when points are redeemed. Indian GST law treats loyalty points redeemed against room/F&B as discount IF declared on the original tax invoice, OR as separate taxable supply if added at redemption time. Today: redeem path deducts INR off folio with no e-invoice (EP-005B/EP-012C) re-mapping.

  • [SME-PROPOSED] Add AC: "Loyalty redemption against a folio SHALL appear as a folio line LOYALTY_REDEMPTION flagged pricingMode='DISCOUNT' (default — reduces taxable base) OR pricingMode='TAXABLE_SUPPLY' per property setting. The line carries pointsBurned, inrValue, and a GST treatment tag for the IRN payload (cross-EPIC: EP-005B folio + EP-012C e-invoice)."

Cross-references: drift findings D-013/D-014/D-015 span EP-004 (guest-service), EP-014 (notification-service), EP-005B (folios/billing), EP-012C (e-invoice/IRN). Recorded here because EP-010 is the dispatch point that surfaces the gaps. Per the playbook §"Lift D-NNN ids to a global namespace" recommendation, these cross-EPIC findings are candidates for the future BF-NNN-D-XXX global scheme.