Keyboard shortcuts

Press or to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

VIF Tier

Description / Background

VIF Tier (Very Important Foodie) is the 5th and highest loyalty tier in the Loyalty 2.0 hierarchy (rank 4, 0-indexed), sitting on top of the existing 4-tier progression (RED → SILVER → GOLD → PLATINUM). The feature ships as a follow-up to the already-merged Loyalty 2.0 refactor (PR #8679) which extracted per-country benefit values into the LoyaltyBenefit table and renamed HUNGERRED. The VIF tier introduces a new visual identity (Deep Obsidian #1A1A1A + Champagne Gold #D4AF37), refreshes onboarding, home page tier banners, the benefits page, and CRM email templates so users understand that VIF is the highest tier and Platinum is no longer the endpoint.

Problem: the current UI, onboarding screens, and CRM emails explicitly state that Platinum is the highest tier. Launching the backend tier logic without UI/email updates would create onboarding drift, conflicting Platinum-tier messaging, and missing visual recognition for VIF users.

Solution: add the VIF tier (5th rank, 2.5x dine-in multiplier for TH/SG/MY pre-seeded) via a single idempotent data migration, expose tier-aware UI strings that read from LoyaltyLevel::TIER_IMAGES, and update the qualification state machine so VIF is reachable via the existing upgrade flow.

Objectives

  • Users see a complete 5-tier journey (1st RED → 2nd SILVER → 3rd GOLD → 4th PLATINUM → 5th VIF) on the mobile onboarding carousel
  • Users see the headline “Journey from RED to VIF” on the onboarding Tier Screen
  • Users see VIF crown icons in the onboarding background illustration
  • Users see a new VIF banner state on the home page below the search bar (Standard: “You are VIF tier & have X points >”; Expiry Warning: “Book by [Date] to keep X points & VIF tier >”)
  • Users see VIF brand colors (#1A1A1A background, #D4AF37 text/icon) on the banner, maintaining accessibility contrast
  • Users see the full 5-tier hierarchy on the Benefits page
  • Users see VIF-specific benefits (2.5x base earn, up to 5x on Mondays, 50% off cancellation insurance, Walk-in + (-2h Omakase) advance booking edge)
  • Users see the Platinum column visually marked as no longer the final tier
  • Users receive accurate VIF-expiry emails with copy “YOU ARE A VIF.! YOU’VE REACHED THE ULTIMATE TIER. ENJOY YOUR EXCLUSIVE BENEFITS.”
  • Users on lower tiers (RED/SILVER/GOLD/PLATINUM) receive expiry emails with copy “TO UNLOCK [next tier] TIER” replacing the legacy “YOU’VE REACHED HIGHEST TIER” hero text
  • Users downgraded from VIF to Platinum receive an accurate transition notification
  • Users opening the app for the first time on a smaller screen (e.g. iPhone SE) see the 5-tier horizontal graphic scale responsively without text truncation
  • Users holding VIF status with 0 points still see VIF displayed (VIF-only state)
  • Restaurants benefit from increased engagement as users aspirationally target the new top tier
  • The Platinum → VIF conversion rate reaches 5% within 6 months of UI/email changes
  • Customer support tickets regarding “tier confusion” or “missing highest tier status” stay under 10 per week post-launch
  • The onboarding 5-tier graphic must scale to small screens without truncation (Responsive guarantee)
  • LoyaltyLevel model gains VIF = :vif constant and the matching entries in AVAILABLE_LEVELS and TIER_IMAGES
  • UserLoyalty::LOYALTY_SCORE[:vif] = 5 (highest rank)
  • UserLoyalty state machine gains the platinum: :vif upgrade transition and the vif: :platinum downgrade transition
  • Dine-in multiplier is pre-seeded at 2.5x for TH/SG/MY in LoyaltyBenefit rows
  • One LoyaltyBenefit row per (country, benefit_type) exists for TH/SG/MY so the admin form’s country panels have rows to render
  • Per-(country, locale) loyalty_benefit_translations rows exist for VIF, seeded from Platinum descriptions as a starting point
  • Tier banner image in the points-earned email is selected from LoyaltyLevel::TIER_IMAGES (covers RED/SILVER/GOLD/PLATINUM/VIF) — no inline 4-tier case statement
  • loyalty_program/qualification.rb#status returns :no_need_to_upgrade when at VIF
  • verify_level_and_upgrade! walks from the highest tier down
  • Feature ships behind ff_vif_ui_components_v1 and ff_vif_onboarding_v1
  • The data migration is idempotent — re-running does not duplicate the VIF row, and merges Platinum → VIF descriptions without overwriting admin edits
  • New users still default to RED (UserLoyalty#init_loyalty_level unchanged); VIF is reached only via the upgrade flow
  • Admin sets the VIF qualification thresholds before VIF goes live (mitigation for the platinum: :vif immediate-promotion edge case when qualification_total_reservations: 0)
  • 5-tier responsive graphic is rendered without text truncation on iPhone SE and equivalent small screens

Terms & Conditions

  • VIF qualification thresholds are admin-configurable; default values from the migration must be reviewed before production rollout.
  • Tier ranking is positional: VIF > PLATINUM > GOLD > SILVER > RED.
  • Downgrades follow the existing qualification-maintenance logic (e.g. inactivity-based demotion from PLATINUM → GOLD).
  • Tier downgrade from VIF to Platinum triggers a transition notification email.

Scope

  • New LoyaltyLevel::VIF constant + AVAILABLE_LEVELS / TIER_IMAGES entries
  • New UserLoyalty::LOYALTY_SCORE[:vif] = 5 and platinum ↔ vif state-machine transitions
  • Data migration db/data/20260814081059_add_vif_loyalty_level.rb — VIF row + per-country LoyaltyBenefit rows + per-(country, locale) loyalty_benefit_translations rows seeded from Platinum descriptions
  • Tier image assets vif-tier-th.png, vif-tier-en.png
  • Tier-aware email banner (points_earned_mailer/points_earned_notification.html.erb)
  • Tier-aware qualification logic (loyalty_program/qualification.rb, template.rb)
  • Tests: spec/models/loyalty_level_spec.rb (+VIF tier tests), spec/models/user_loyalty_spec.rb (new), spec/my_lib/loyalty_program/qualification_spec.rb (new), spec/support/loyalty_levels.rb (modified for 5 tiers)
  • Tier representation across onboarding, home page banner, benefits page, and CRM emails (UI owned by separate frontend PRs)

Upcoming

  • Drop legacy benefits_* columns on loyalty_levels once admin has fully migrated to the new table
  • VIF-specific email template variants (e.g. dedicated VIF-only expiry copy variants)
  • Mobile-app tier progression graphic (5 tiers) — frontend PR not in scope of this backend PR

Out of Scope

  • New backend tier logic beyond adding the VIF tier — refactor of RED/SILVER/GOLD/PLATINUM logic is already shipped via PR #8679
  • Admin form / show view rewrite (per-country panels) — already shipped via PR #8679
  • HUNGER → RED rename (data + constants + state machine) — already shipped via PR #8679
  • Api::V5::LoyaltyLevelSerializer BigDecimal coercion — already shipped via PR #8679
  • Frontend UI components (onboarding carousel, home page banner, benefits page) — owned by separate frontend PRs behind ff_vif_ui_components_v1 / ff_vif_onboarding_v1

Location

Backend (hh-server)

  • app/models/loyalty_level.rbVIF constant, AVAILABLE_LEVELS, TIER_IMAGES entries
  • app/models/user_loyalty.rbLOYALTY_SCORE[:vif] = 5, platinum: :vif / vif: :platinum state-machine edges
  • db/data/20260814081059_add_vif_loyalty_level.rb — idempotent data migration
  • app/assets/images/vif-tier-th.png, vif-tier-en.png — VIF tier graphic
  • app/views/points_earned_mailer/points_earned_notification.html.erb — tier-aware banner image
  • app/my_lib/loyalty_program/qualification.rb:no_need_to_upgrade at VIF
  • app/my_lib/loyalty_program/template.rb — walks highest tier down
  • app/admin/loyalty_levels/*.rb — admin form (already shipped per-country panels from PR #8679)
  • spec/models/loyalty_level_spec.rb, spec/models/user_loyalty_spec.rb, spec/my_lib/loyalty_program/qualification_spec.rb, spec/support/loyalty_levels.rb — test coverage

Frontend (separate PRs)

  • Mobile App onboarding carousel “Tier Screen”
  • Home Page tier banner (Standard + Expiry Warning states)
  • Benefits page tier progression graphic + VIF column
  • CRM email templates (Platinum expiry + new VIF expiry)

How to find VIF Tier

  1. Verify LoyaltyLevel::VIF exists: open rails consoleLoyaltyLevel::VIF → returns :vif.
  2. Check available levels: LoyaltyLevel::AVAILABLE_LEVELS includes :vif.
  3. Confirm VIF has rank 4 (0-indexed): LoyaltyLevel.find_by(code: 'VIF').rank → returns 4.
  4. Confirm a VIF row exists in the database: LoyaltyLevel.find_by(code: 'VIF').
  5. Confirm a VIF LoyaltyBenefit row exists per country: LoyaltyBenefit.where(loyalty_level_id: LoyaltyLevel.find_by(code: 'VIF').id).pluck(:country_id).
  6. For diners, look for the VIF banner on the home page or the VIF tier in the benefits page (gated on ff_vif_ui_components_v1).

How to set VIF Tier

  1. Enable Flipper ff_vif_ui_components_v1 and ff_vif_onboarding_v1 for the target audience.
  2. Confirm the data migration db/data/20260814081059_add_vif_loyalty_level.rb has run; if not, rails db:migrate:redo VERSION=20260814081059 (idempotent on re-run).
  3. In the Admin panel (/admin/loyalty_levels/{VIF_ID}/edit), fill in the remaining LoyaltyBenefit values (the dine-in multiplier is pre-seeded at 2.5x; other benefit values are intentionally blank for product/marketing to finalize).
  4. Confirm VIF qualification thresholds (e.g. qualification_total_reservations) — do NOT leave them at 0 or Platinum users will auto-promote.
  5. Verify the VIF brand colors render correctly on the home page banner and onboarding carousel: #1A1A1A background, #D4AF37 text/icon.
  6. Trigger a test Platinum → VIF upgrade by satisfying qualification thresholds on a test user; verify the tier banner image switches to vif-tier-en.png in the points-earned email.

Sequence Diagram / Flow

VIF promotion flow

sequenceDiagram
  participant U as User
  participant Q as Qualification Service
  participant UL as UserLoyalty
  participant LL as LoyaltyLevel
  participant LB as LoyaltyBenefit
  participant E as Points Earned Email

  U->>Q: Booking / spend triggers verify_level_and_upgrade!
  Q->>UL: read current level + score
  Q->>LB: fetch tier thresholds per (country, benefit_type)
  Q->>LL: walk highest tier down, match score
  Q->>UL: state machine transition (platinum: :vif)
  UL->>LB: snapshot new VIF benefits
  UL->>E: enqueue points_earned_notification
  E->>E: pick banner image from LoyaltyLevel::TIER_IMAGES
  E-->>U: email with VIF banner + 2.5x multiplier

Admin configuration flow

sequenceDiagram
  participant A as Admin
  participant Portal as /admin/loyalty_levels/{VIF_ID}/edit
  participant LL as LoyaltyLevel
  participant LB as LoyaltyBenefit
  participant LBT as LoyaltyBenefitTranslation

  A->>Portal: Open VIF row editor
  Portal->>LL: load VIF + children
  Portal->>LB: load per-(country, benefit_type) rows
  Portal->>LBT: load per-(country, locale) translations
  A->>Portal: Edit benefits + brand colors
  Portal->>LB: persist benefit values (e.g. 2.5x dine-in)
  Portal->>LBT: persist translation overrides
  Portal-->>A: 200 OK

ERD

  • Modified table loyalty_levels: new row inserted by data migration with code = 'VIF', rank = 4.
  • Modified table loyalty_benefits: one new row per (country, benefit_type) for TH/SG/MY attached to VIF — fields per the PR #8679 schema (e.g. loyalty_level_id, country_id, benefit_type, value).
  • Modified table loyalty_benefit_translations: per-(country, locale) rows seeded from Platinum descriptions as a starting point.
  • Unchanged user_loyalties: state-machine gains platinum → vif upgrade and vif → platinum downgrade; existing rows untouched.
  • Unchanged LoyaltyBenefit model + find_by_keys resolver (shipped via PR #8679).
  • No schema migrations in this PR — loyalty_benefits and loyalty_benefit_translations were added by PR #8679.

Backend Implementation

  • New LoyaltyLevel::VIF = :vif constant plus matching entries in AVAILABLE_LEVELS and TIER_IMAGES.
  • UserLoyalty::LOYALTY_SCORE[:vif] = 5 (highest rank).
  • UserLoyalty AASM state machine gains platinum: :vif upgrade and vif: :platinum downgrade transitions.
  • New tier image assets: app/assets/images/vif-tier-th.png, vif-tier-en.png.
  • VIF brand colors: #1A1A1A Deep Obsidian (primary) + #D4AF37 Champagne Gold (accent); admin can refine in the form panel.
  • Data migration db/data/20260814081059_add_vif_loyalty_level.rb:
    • Inserts the VIF LoyaltyLevel row (code VIF, rank 4).
    • Pre-seeds one LoyaltyBenefit row per (country, benefit_type) for TH/SG/MY so the admin form’s country panels have rows to render.
    • Sets the dine-in multiplier at 2.5x for TH/SG/MY (per product brief); remaining benefit values left nil for admin to fill.
    • Copies Platinum descriptions into per-(country, locale) loyalty_benefit_translations rows as a starting point.
    • Idempotent on re-run: row insert is a no-op, description merge does not overwrite admin changes.
  • Tier-aware email banner: app/views/points_earned_mailer/points_earned_notification.html.erb picks the tier banner image from LoyaltyLevel::TIER_IMAGES (covers RED/SILVER/GOLD/PLATINUM/VIF) — replaces the legacy inline case that only knew 4 tiers.
  • Tier-aware qualification: app/my_lib/loyalty_program/qualification.rb and template.rbstatus returns :no_need_to_upgrade when at VIF; verify_level_and_upgrade! walks from the highest tier down.
  • Out of scope here (already shipped via PR #8679): LoyaltyBenefit model + find_by_keys resolver, loyalty_benefits / loyalty_benefit_translations migrations, LoyaltyLevel#benefits reading from the resolver, Api::V5::LoyaltyLevelSerializer + Api::V5::UserSerializer BigDecimal coercion, HUNGER → RED rename (data + constants + state machine), Admin form / show view rewrite (per-country panels).
  • Tests: spec/models/loyalty_level_spec.rb (+VIF tier tests), spec/models/user_loyalty_spec.rb (new, 30 examples: associations, scope, state machine, instance methods), spec/my_lib/loyalty_program/qualification_spec.rb (new, 40 examples: VIF status, upgrade flow, daily limit, maintenance), spec/support/loyalty_levels.rb (modified for 5 tiers). See PR #8656 for test results.
  • Rollout risk mitigation: VIF promotion requires admin to set qualification_total_reservations (or equivalent) above 0 before VIF goes live; otherwise any current Platinum user qualifies because the empty threshold defaults to 0.

PR:

https://github.com/hungryhub-team/hh-server/pull/8656

Hybrid Implementation

  • No hybrid (hh-pegasus / hh-felidae) PRs were provided for this feature. Per the PRD, frontend UI components (onboarding carousel, home page banner, benefits page, CRM email templates) are tracked separately behind ff_vif_ui_components_v1 and ff_vif_onboarding_v1.
  • When the hybrid PRs land, this section will list the pegasus components touched (onboarding Tier Screen graphic, home page banner states, benefits page tier progression) and any GraphQL/API changes on felidae (likely none — VIF tier is consumed via the existing LoyaltyLevel serializer that ships with PR #8679).

PR:

  • (none provided)

PRD & Task

Design

API Blueprint

MethodPathURLDescriptionPayload
GET/api/v5/loyalty_levelshttps://{host}/api/v5/loyalty_levelsLoyalty level list; now includes the VIF tier (code VIF, rank 4) — serializer BigDecimal coercion shipped via PR #8679.{ loyalty_levels: [ { code, rank, name, brand_colors, benefits } ] }
GET/api/v5/users/:idhttps://{host}/api/v5/users/:idUser profile; loyalty_level now reports VIF for top-tier users.{ user: { loyalty_level: { code: "VIF", rank: 4 } } }
POST/admin/loyalty_levels/:idhttps://{admin-host}/admin/loyalty_levels/:idAdmin update for VIF benefit values; per-country panels (already shipped via PR #8679).{ loyalty_level: { benefit_values: [...] } }

New Query

  • VIF lookup: LoyaltyLevel.find_by(code: 'VIF') → single row.
  • VIF benefit enumeration: LoyaltyBenefit.where(loyalty_level_id: LoyaltyLevel.find_by(code: 'VIF').id).pluck(:country_id, :benefit_type, :value) — returns one row per (country, benefit_type) for TH/SG/MY.
  • VIF promotion (qualification): UserLoyalty#verify_level_and_upgrade! walks highest tier down using LOYALTY_SCORE[:vif] = 5 as the cap; transitions platinum → vif once thresholds are satisfied.
  • VIF status guard: loyalty_program/qualification.rb#status returns :no_need_to_upgrade when user_loyalty.score >= LOYALTY_SCORE[:vif].
  • Banner image lookup (email): LoyaltyLevel::TIER_IMAGES[user_loyalty.current_level_code] → returns vif-tier-en.png (or vif-tier-th.png per locale).

DB Schema / Database Migration

  • No schema migrations in this PR. loyalty_benefits and loyalty_benefit_translations were added by PR #8679.
  • Data migration db/data/20260814081059_add_vif_loyalty_level.rb:
    • Inserts the VIF LoyaltyLevel row (code VIF, rank 4).
    • Pre-seeds one LoyaltyBenefit row per (country, benefit_type) for TH/SG/MY.
    • Sets the dine-in multiplier at 2.5x for TH/SG/MY.
    • Copies Platinum descriptions into per-(country, locale) loyalty_benefit_translations rows.
    • Idempotent on re-run (no duplicate VIF row; description merge does not overwrite admin edits).
  • Existing rows for RED / SILVER / GOLD / PLATINUM in loyalty_levels, loyalty_benefits, loyalty_benefit_translations are untouched.
  • UserLoyalty#init_loyalty_level still defaults new users to RED; no change.

Improvement:

Feature NameDateWhat ChangedDescription
Loyalty 2.0 Refactor (PR #8679, prerequisite)2026-08 (already merged)HUNGER → RED rename + LoyaltyBenefit extractionRequired infrastructure for VIF: loyalty_benefits / loyalty_benefit_translations tables, resolver, serializer BigDecimal coercion, admin per-country panels.
VIF Tier Backend (PR #8656)2026-08-24 (open, not merged yet — mergeable_state: blocked)New LoyaltyLevel::VIF tier + state-machine transitions + idempotent data migration + tier-aware email/qualification logic5th loyalty tier (rank 4, score 5), 2.5x dine-in multiplier pre-seeded for TH/SG/MY, brand colors #1A1A1A / #D4AF37. See PR for test results.
VIF Tier Frontend (not yet provided)TBDOnboarding carousel + home page banner + benefits page + CRM email templatesOwned by separate frontend PRs behind ff_vif_ui_components_v1 and ff_vif_onboarding_v1.