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 HUNGER → RED. 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 (
#1A1A1Abackground,#D4AF37text/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)
LoyaltyLevelmodel gainsVIF = :vifconstant and the matching entries inAVAILABLE_LEVELSandTIER_IMAGESUserLoyalty::LOYALTY_SCORE[:vif] = 5(highest rank)UserLoyaltystate machine gains theplatinum: :vifupgrade transition and thevif: :platinumdowngrade transition- Dine-in multiplier is pre-seeded at 2.5x for TH/SG/MY in
LoyaltyBenefitrows - One
LoyaltyBenefitrow 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_translationsrows 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-tiercasestatement loyalty_program/qualification.rb#statusreturns:no_need_to_upgradewhen at VIFverify_level_and_upgrade!walks from the highest tier down- Feature ships behind
ff_vif_ui_components_v1andff_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_levelunchanged); VIF is reached only via the upgrade flow - Admin sets the VIF qualification thresholds before VIF goes live (mitigation for the
platinum: :vifimmediate-promotion edge case whenqualification_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::VIFconstant +AVAILABLE_LEVELS/TIER_IMAGESentries - New
UserLoyalty::LOYALTY_SCORE[:vif] = 5andplatinum ↔ vifstate-machine transitions - Data migration
db/data/20260814081059_add_vif_loyalty_level.rb— VIF row + per-countryLoyaltyBenefitrows + per-(country, locale)loyalty_benefit_translationsrows 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 onloyalty_levelsonce 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::LoyaltyLevelSerializerBigDecimal 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.rb—VIFconstant,AVAILABLE_LEVELS,TIER_IMAGESentriesapp/models/user_loyalty.rb—LOYALTY_SCORE[:vif] = 5,platinum: :vif/vif: :platinumstate-machine edgesdb/data/20260814081059_add_vif_loyalty_level.rb— idempotent data migrationapp/assets/images/vif-tier-th.png,vif-tier-en.png— VIF tier graphicapp/views/points_earned_mailer/points_earned_notification.html.erb— tier-aware banner imageapp/my_lib/loyalty_program/qualification.rb—:no_need_to_upgradeat VIFapp/my_lib/loyalty_program/template.rb— walks highest tier downapp/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
- Verify
LoyaltyLevel::VIFexists: openrails console→LoyaltyLevel::VIF→ returns:vif. - Check available levels:
LoyaltyLevel::AVAILABLE_LEVELSincludes:vif. - Confirm VIF has rank 4 (0-indexed):
LoyaltyLevel.find_by(code: 'VIF').rank→ returns4. - Confirm a VIF row exists in the database:
LoyaltyLevel.find_by(code: 'VIF'). - Confirm a VIF
LoyaltyBenefitrow exists per country:LoyaltyBenefit.where(loyalty_level_id: LoyaltyLevel.find_by(code: 'VIF').id).pluck(:country_id). - 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
- Enable Flipper
ff_vif_ui_components_v1andff_vif_onboarding_v1for the target audience. - Confirm the data migration
db/data/20260814081059_add_vif_loyalty_level.rbhas run; if not,rails db:migrate:redo VERSION=20260814081059(idempotent on re-run). - In the Admin panel (
/admin/loyalty_levels/{VIF_ID}/edit), fill in the remainingLoyaltyBenefitvalues (the dine-in multiplier is pre-seeded at 2.5x; other benefit values are intentionally blank for product/marketing to finalize). - Confirm VIF qualification thresholds (e.g.
qualification_total_reservations) — do NOT leave them at 0 or Platinum users will auto-promote. - Verify the VIF brand colors render correctly on the home page banner and onboarding carousel:
#1A1A1Abackground,#D4AF37text/icon. - Trigger a test Platinum → VIF upgrade by satisfying qualification thresholds on a test user; verify the tier banner image switches to
vif-tier-en.pngin 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 withcode = '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 gainsplatinum → vifupgrade andvif → platinumdowngrade; existing rows untouched. - Unchanged
LoyaltyBenefitmodel +find_by_keysresolver (shipped via PR #8679). - No schema migrations in this PR —
loyalty_benefitsandloyalty_benefit_translationswere added by PR #8679.
Backend Implementation
- New
LoyaltyLevel::VIF = :vifconstant plus matching entries inAVAILABLE_LEVELSandTIER_IMAGES. UserLoyalty::LOYALTY_SCORE[:vif] = 5(highest rank).UserLoyaltyAASM state machine gainsplatinum: :vifupgrade andvif: :platinumdowngrade transitions.- New tier image assets:
app/assets/images/vif-tier-th.png,vif-tier-en.png. - VIF brand colors:
#1A1A1ADeep Obsidian (primary) +#D4AF37Champagne Gold (accent); admin can refine in the form panel. - Data migration
db/data/20260814081059_add_vif_loyalty_level.rb:- Inserts the VIF
LoyaltyLevelrow (codeVIF, rank 4). - Pre-seeds one
LoyaltyBenefitrow 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
nilfor admin to fill. - Copies Platinum descriptions into per-
(country, locale)loyalty_benefit_translationsrows as a starting point. - Idempotent on re-run: row insert is a no-op, description merge does not overwrite admin changes.
- Inserts the VIF
- Tier-aware email banner:
app/views/points_earned_mailer/points_earned_notification.html.erbpicks the tier banner image fromLoyaltyLevel::TIER_IMAGES(covers RED/SILVER/GOLD/PLATINUM/VIF) — replaces the legacy inlinecasethat only knew 4 tiers. - Tier-aware qualification:
app/my_lib/loyalty_program/qualification.rbandtemplate.rb—statusreturns:no_need_to_upgradewhen at VIF;verify_level_and_upgrade!walks from the highest tier down. - Out of scope here (already shipped via PR #8679):
LoyaltyBenefitmodel +find_by_keysresolver,loyalty_benefits/loyalty_benefit_translationsmigrations,LoyaltyLevel#benefitsreading from the resolver,Api::V5::LoyaltyLevelSerializer+Api::V5::UserSerializerBigDecimal 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_v1andff_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
LoyaltyLevelserializer that ships with PR #8679).
PR:
- (none provided)
PRD & Task
- PRD: VIF Tier — https://app.clickup.com/9003122396/v/dc/8ca1fpw-7922/8ca1fpw-65716 (sub-page of the umbrella doc
8ca1fpw-7922given in the request) - Related PRD: Tier Summary — https://doc.clickup.com/d/h/8ca1fpw-65736/bd9370d13636ed7
- Task: VIF tier — https://app.clickup.com/t/9003122396/86d3n3ekd (
CU-86d3n3ekd, status: ready for release, priority: high, tags: frontend + backend, assignees: Scene + Firmansah) - Slack thread: https://hungryhubgroup.slack.com/archives/C02DA311WVD/p1783432973465039
Design
API Blueprint
| Method | Path | URL | Description | Payload |
|---|---|---|---|---|
| GET | /api/v5/loyalty_levels | https://{host}/api/v5/loyalty_levels | Loyalty 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/:id | https://{host}/api/v5/users/:id | User profile; loyalty_level now reports VIF for top-tier users. | { user: { loyalty_level: { code: "VIF", rank: 4 } } } |
| POST | /admin/loyalty_levels/:id | https://{admin-host}/admin/loyalty_levels/:id | Admin 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 usingLOYALTY_SCORE[:vif] = 5as the cap; transitionsplatinum → vifonce thresholds are satisfied. - VIF status guard:
loyalty_program/qualification.rb#statusreturns:no_need_to_upgradewhenuser_loyalty.score >= LOYALTY_SCORE[:vif]. - Banner image lookup (email):
LoyaltyLevel::TIER_IMAGES[user_loyalty.current_level_code]→ returnsvif-tier-en.png(orvif-tier-th.pngper locale).
DB Schema / Database Migration
- No schema migrations in this PR.
loyalty_benefitsandloyalty_benefit_translationswere added by PR #8679. - Data migration
db/data/20260814081059_add_vif_loyalty_level.rb:- Inserts the VIF
LoyaltyLevelrow (codeVIF, rank 4). - Pre-seeds one
LoyaltyBenefitrow 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_translationsrows. - Idempotent on re-run (no duplicate VIF row; description merge does not overwrite admin edits).
- Inserts the VIF
- Existing rows for RED / SILVER / GOLD / PLATINUM in
loyalty_levels,loyalty_benefits,loyalty_benefit_translationsare untouched. UserLoyalty#init_loyalty_levelstill defaults new users to RED; no change.
Improvement:
| Feature Name | Date | What Changed | Description |
|---|---|---|---|
| Loyalty 2.0 Refactor (PR #8679, prerequisite) | 2026-08 (already merged) | HUNGER → RED rename + LoyaltyBenefit extraction | Required 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 logic | 5th 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) | TBD | Onboarding carousel + home page banner + benefits page + CRM email templates | Owned by separate frontend PRs behind ff_vif_ui_components_v1 and ff_vif_onboarding_v1. |