Dynamic Prepayment Package
Authored 2026-09-01; feature shipped 2026-07-29. The Improvement table below captures incremental changes made during development.
Description / Background
Dynamic Prepayment Package introduces a rules-based prepayment engine for HH package templates (AYCE, Set Menu, Hungry Set, Buffet, Bites, etc.). Instead of relying solely on the static require_cc flag on each package, restaurant owners and managers can attach reusable prepayment rules scoped by date/time and/or party size. Rules are evaluated at booking time to decide whether a package requires prepayment and whether pay-now is allowed.
The system replaces the binary require_cc model with a hybrid model:
- Static fallback: existing packages without dynamic rules behave exactly as before (
require_cctrue/false). - Dynamic rules: package admins attach one or more reusable rules (
by_date_time,by_party_size) that override the static flag for matching bookings.
Gated behind the Flipper feature flag ff_dynamic_prepayment_v1 in both the backend reservation engine and the admin UI.
Current state (after roll-out):
- New
hh_package_prepayment_rulestable; polymorphic association to all 8 HH package template types. - All package template tables gain
is_dynamic_prepayment,rules_type,date_type. - Admin Vue panel
PrepaymentRulesInput.vueallows rule authoring, with field-edit gating when the flag is off. - Diner checkout calls
GET /api/v5/restaurant_packages/prepayment_evaluatefor rule evaluation. - Booking package store (
bookingPackageStore) tracksdynamicPrepaymentsnapshot,isPackagesRequirePrepayment, andisPackagesPayNowAllowed. - Hybrid (Pegasus) wires the evaluation result into pay-now toggle, payment-method reset, on-hold copy, and badge labels.
Objectives
- Allow admins to define dynamic prepayment rules per package template.
- Evaluate prepayment requirement at booking time using selected date, start time, and party size.
- Combine dynamic rules with static
require_ccfallback so existing packages remain unaffected. - Override dynamic rules with promo codes and gift cards (always force prepayment).
- Show appropriate UI copy per charge type:
May Require PrepaymentvsMay Require Pre-authorization. - Keep edits to dynamic fields disabled when the flipper flag is off (admin UI gate).
- Provide an admin audit trail via
BUSINESS_LOGGER.warnon edits to dynamic-prepayment fields. - Support batch evaluation of up to 50 restaurant-package IDs in a single API call.
- Roll out progressively: Phase 1 (5 alpha partners, TH) → Phase 2 (20% TH) → Phase 3 GA (TH, SG, MY).
Scope
In scope
- Admin authoring of dynamic prepayment rules on HH package templates.
- Six
date_typemodes:everyday,specific_date,specific_time,specific_date_time,day_of_week,day_time_of_week. - Two
rules_typemodes:by_date_timeandby_party_size. - Snapshot of
prepayment_rule_appliedperrestaurant_packageduring reservation init. - Booking-time evaluation via
PackagePrepaymentChecker. - Hybrid checkout wiring: pay-now toggle, prepayment labels, badge labels, on-hold copy.
- Localized strings across 15 languages (de, en, es, fr, id, ja, km, ko, lo, ms, ru, th, vi, zh_Hans, zh_Hant).
- Flipper flag
ff_dynamic_prepayment_v1controlling backend engine and admin UI. - Seeder: sample rules on
Seed AYCE Package (THA)template.
Out of scope
- Bulk rule import/export.
- Per-restaurant (not per-template) rule scoping.
- Rule templates / library sharing across restaurants beyond template-level polymorphism.
- AI-assisted rule suggestions.
- Customer-facing prepayment explanation beyond the package detail body copy.
Location
Admin
- Package form:
admin.packagesform, packages section. - Sub-component:
PrepaymentRulesInput.vuerendered insidePackageForm.vue. - Endpoint:
GET /admin/packages/.../edit.
Hybrid (Pegasus) — Diner
- Storefront package cards:
FeaturedPackage.vue,PackageCardDesktop.vue,PackageCardMobile.vue. - Package detail:
PackageDetailBody.vue,OnHoldDescription.vue,PackageBadge.vue. - Checkout:
CheckOutPage.vue,BookingPayment.vue,PrepaymentLabel.vue,PaynowToggleButton.vue. - API client:
src/api/book/evaluatePrepayment.ts.
Backend
- New table:
hh_package_prepayment_rules. - New model:
HhPackage::PrepaymentRule. - New service:
PackagePrepaymentChecker. - New controller:
Api::V5::PrepaymentRulesController. - Wired into:
ReservationService::Init::HungryHub,ReservationService::Form::HungryHub,lib/model_ext/reservations/credit_card.rb.
How to find Dynamic Prepayment
For admins
- Open Admin Dashboard.
- Open a package template (AYCE / Set Menu / Hungry Set / Buffet / Bites / etc.).
- Locate the Dynamic Prepayment panel in the package form.
- Confirm the flipper flag
ff_dynamic_prepayment_v1is enabled (otherwise the panel is read-only with a rollout-off banner).
For diners
- Open a participating restaurant package on web or app.
- Select date, time, and party size.
- Prepayment badges update live:
May Require PrepaymentorMay Require Pre-authorization. - Checkout enforces pay-now or on-hold based on evaluated rule.
How to set Dynamic Prepayment rules
Admin steps
- Open a package template form.
- In the Dynamic Prepayment panel, toggle Enable dynamic rules.
- Pick
rules_type:by_date_time— date/time window + optional day-of-week.by_party_size—min_party_size/max_party_size.
- Pick
date_type:everydayspecific_date— single date.specific_time— daily time window.specific_date_time— exact date + time window.day_of_week— recurring day(s) of week.day_time_of_week— recurring day(s) of week + time window.
- Fill in the matching fields (date, time, days, party-size bounds).
- Save — service normalizes, validates, persists via
apply_prepayment_rules!. - Audit log entry written to
BUSINESS_LOGGERwhenever dynamic fields are edited.
Rule precedence
- Multiple rules on the same package are OR-combined: any match triggers prepayment.
- Promo code or gift card applied at checkout bypasses dynamic rules and force prepayment (constraint override).
- On-hold charge type uses
May Require Pre-authorization; otherwiseMay Require Prepayment.
Flow
flowchart LR
A[Selected packages / date / time / vouchers] -- trigger evaluation --> B[Dynamic prepayment API]
B -- aggregate result --> C[Booking package store]
C -- require / pay-now flags --> D[Checkout payment UI]
D -- labels and validation --> E[Booking submission]
ERD
erDiagram
HH_PACKAGE_TEMPLATE ||--o{ HH_PACKAGE_PREPAYMENT_RULES : has
HH_PACKAGE_PREPAYMENT_RULES {
bigint id
bigint package_id
string package_type
string rules_type
int date_type
date start_date
date end_date
time start_time
time end_time
string days_of_week
int min_party_size
int max_party_size
boolean is_active
datetime created_at
datetime updated_at
}
Backend Implementation
PR: https://github.com/hungryhub-team/hh-server/pull/8276
Database
- New table
hh_package_prepayment_rules— polymorphic (package_id+package_type) withrules_type(by_date_time/by_party_size),date_type(int → 6 modes), date/time bounds, days-of-week, party-size bounds,is_active. - Indexes on
(package_id, package_type),is_active,date_type,rules_type. - Adds
is_dynamic_prepayment(bool),rules_type(string),date_type(int) to all 8 HH package template tables viapt_add_columns.
Models
- New
HhPackage::PrepaymentRule— polymorphic, IdentityCache + audited.- Scopes:
active,for_package,by_rules_type. - Validations: date/time range order, day-of-week format, party-size bounds, required-fields-per-
date_type.
- Scopes:
HhPackage::Package::Basehas_many :prepayment_rules, as: :package, dependent: :destroyenum date_type- Helpers:
can_add_dynamic_rules?,active_dynamic_rules,dynamic_prepayment_active?.
HhPackage::Package—PACKAGE_DATE_TYPESconstants.
Services
- New
PackagePrepaymentChecker:require_prepayment?,pay_now_allowed?,matched_rule,matched_rules.- Falls back to
package.require_ccwhen dynamic mode is off. - Wrapped with
ElasticAPM.span_method, logs toBUSINESS_LOGGER.
ReservationService::Init::HungryHubdecorate_package_params_with_prepaymentwritesprepayment_rule_appliedsnapshot perrestaurant_package.
ReservationService::Form::HungryHub- Replaces direct
require_cc?checks withpackage_accepts_prepayment?/prepayment_required_for_pack?.
- Replaces direct
lib/model_ext/reservations/credit_card.rbdynamic_or_static_require_prepayment?reads snapshot, verifies rule still exists/active, falls back torequire_cc?.
API
- New
GET /api/v5/restaurant_packages/prepayment_evaluateApi::V5::PrepaymentRulesController#evaluate.- Batch-evaluates up to 50 restaurant-package IDs against
date,start_time,party_size. - Returns per-package
require_prepayment/pay_now_allowed+ aggregate flags.
Api::V5::RestaurantPackageSerializer- New fields:
is_dynamic_prepayment,has_dynamic_rules,dynamic_rules_count,rules_type(gated on flipper flag).
- New fields:
Admin
Admin::Packages::BaseController- Strips dynamic-prepayment keys from
permit!. - Applies nested
prepayment_rules_attributesaftersaveviaapply_prepayment_rules!. - Gates edits of dynamic fields on the flipper flag with
BUSINESS_LOGGER.warnaudit logging.
- Strips dynamic-prepayment keys from
- New Vue component
PrepaymentRulesInput.vue- Toggle, 6
date_typeselectors, rule list with add/remove, party-size + date/time editors, rollout-off banner.
- Toggle, 6
PackageForm.vue— embeds the new panel, keepsprepayment_rules_attributesnested-attribute payload intact.
Seeder
db/seeds/hh_package_prepayment_rule.rb- Sample rules on
Seed AYCE Package (THA)template. - Modes:
by_date_time(Fri/Sat/Sun 18:00–22:00) orby_party_size(party size 1–4; 3 sample rules of this type). - Wired into
db/seeds/load_all.rb.
- Sample rules on
Tests
spec/services/package_prepayment_checker_spec.rb— exhaustive rule-matching coverage (largest spec).spec/requests/api/v5/prepayment_rules_controller_spec.rb— batch evaluator validation, limits, missing packages, aggregates.spec/requests/admin/packages/ayces_controller_spec.rb— create/update, permit-stripping, flag-gating, rule application.spec/serializers/api/v5/restaurant_package_serializer_spec.rb— new serializer attributes incl. flag gating.spec/services/reservation_service/init/hungry_hub_spec.rb—decorate_package_params_with_prepayment.spec/my_lib/hh_package/reservation_packages/metadata_prepayment_spec.rb— snapshot injection.
Feature flag
ff_dynamic_prepayment_v1 — single primary flag controls both reservation-flow evaluation and admin UI/server-side gating.
Hybrid Implementation
PR: https://github.com/hungryhub-team/hh-pegasus/pull/3071
New API client
src/api/book/evaluatePrepayment.ts- Valibot payload schema:
lang,packageIds[],date(YYYY-MM-DD),startTime(HH:mm),adult, optionalvoucherCode. - Calls
GET /api/v5/restaurant_packages/prepayment_evaluate.json. - Returns
{ bookingContext, results[], aggregate }typed viaInferOutput.
- Valibot payload schema:
Booking package store
src/stores/booking/bookingPackage.ts- Adds
dynamicPrepaymentsnapshot:requirePrepayment,payNowAllowed,isPrepaymentLoading,hasDynamicPrepayment. - Replaces
isPackagesSupportPayNowwithisPackagesPayNowAllowed. - Replaces
isPackagesRequireCCwithisPackagesRequirePrepayment. - Action:
evaluateDynamicPrepayment(lang)triggered on selection change.
- Adds
Checkout flow
CheckOutPage.vue- Watches
[selectedPackages, selectedDate, selectedTime, adult, selectedVouchers]. - Calls
evaluateDynamicPrepaymenton change. - Uses
bookingPackageStore.isPackagesPayNowAllowedto drive:support-pay-now. - Uses
bookingPackageStore.isPackagesRequirePrepaymentfor payment requirement. - Resets
creditCardStoreon back navigation. - Guards Omise token submission when absent (
createBooking.ts).
- Watches
BookingPayment.vue- Reads
isPackagesPayNowAllowedfor Pay Now label + toggle visibility.
- Reads
PaynowToggleButton.vue- Resets
bookingStore.paymentMethodwhen disabling pay-now.
- Resets
PrepaymentLabel.vue- Reads
isPackagesRequirePrepaymentfor label selection.
- Reads
On-hold charge
OnHoldDescription.vue- New prop
isDynamicPrepayment. - Title switches
requiredPreAuth→requirePreAuthorization. - Renders
preAuthorizationMayBeRequiredcopy when dynamic.
- New prop
Package card + detail
PackageDetailBody.vue- New prop
isDynamicPrepayment. - When dynamic and not on-hold: shows
requirePrePayment+prepaymentMayBeRequired. - Passes
exclude-payment-badgestoPackageBadgeso badges aren’t duplicated.
- New prop
PackageBadge.vue- New prop
excludePaymentBadges.
- New prop
FeaturedPackage.vue,PackageCardDesktop.vue,PackageCardMobile.vue- Pass dynamic-prepayment flag to
getPaymentBadgeLabel.
- Pass dynamic-prepayment flag to
Pricing helpers
helpers/pack.tsformatPackageCardpropagatesisDynamicPrepayment.getPaymentBadgeLabelreturnsmayRequirePreAuthorization/mayRequirePrepaymentfor dynamic packages.
services/booking/onHoldPrice.ts- Includes
isDynamicPrepaymenton package payload. - Sends
reservationDate+reservationTimewhenhasDynamicPrepaymentORhasPricingRule.
- Includes
Hybrid event support
services/booking/checkOutEvents.ts- Preserves
isDynamicPrepaymentinrebuildPackagesDatafor hybrid packages.
- Preserves
services/booking/createBooking.ts- Guards Omise token submission when
bookingStore.creditCardProvider === "omise" && bookingStore.omiseToken.
- Guards Omise token submission when
helpers/pack.ts(Pack.ts type) —isDynamicPrepaymentadded toBooking.tsandPack.tstypes.
Localization
Adds 6 new keys in 15 locales (de, en, es, fr, id, ja, km, ko, lo, ms, ru, th, vi, zh_Hans, zh_Hant):
requirePrePaymentprepaymentMayBeRequiredmayRequirePrepaymentrequirePreAuthorizationmayRequirePreAuthorizationpreAuthorizationMayBeRequired
Frontend Implementation
See Hybrid Implementation section above — frontend lives in Pegasus.
Mobile Implementation
N/A — Pegasus (hybrid web) is the primary surface; mobile apps consume the same backend API via the web hybrid layer.
PRD & Task
Design
- Figma: Package Setting
API Blueprint
| Method | Path | URL | Description | Payload |
|---|---|---|---|---|
| GET | /api/v5/restaurant_packages/prepayment_evaluate | Hybrid base URL | Batch-evaluate up to 50 restaurant-package IDs against a booking context. Returns per-package require_prepayment / pay_now_allowed and aggregate flags. | ids (comma-separated, ≤ 50), date (YYYY-MM-DD), startTime (HH:mm), partySize (int), optional voucherCode. |
Note: The client (Valibot schema) uses
lang,packageIds[], andadult; the API Blueprint documentsidsandpartySize. The Pegasus client translatespackageIds[]→ids(comma-separated) andadult→partySizebefore sending the request. Thelangparameter is used locally to determine which localized prepayment labels to display (e.g., “May Require Prepayment” vs localized variant) but is not sent to the server — the server returns rawrequire_prepayment/pay_now_allowedbooleans.
New Query
Evaluate dynamic prepayment (server-side)
PackagePrepaymentChecker.new(package, date: date, start_time: start_time, party_size: party_size)
.require_prepayment? # => true | false
.pay_now_allowed? # => true | false
.matched_rule # => HhPackage::PrepaymentRule | nil
Snapshot decoration on reservation init
ReservationService::Init::HungryHub.new(restaurant, params)
.decorate_package_params_with_prepayment # injects prepayment_rule_applied per restaurant_package
Static fallback
package.dynamic_prepayment_active? ? evaluated : package.require_cc
DB Schema / Database Migration
hh_package_prepayment_rules
| Column | Type | Description |
|---|---|---|
id | bigint | Primary key. |
package_id | bigint | Polymorphic package ID. |
package_type | varchar | Polymorphic package type (AYCE, SetMenu, etc.). |
rules_type | varchar | by_date_time or by_party_size. |
date_type | int | 0 everyday, 1 specific_date, 2 specific_time, 3 specific_date_time, 4 day_of_week, 5 day_time_of_week. |
start_date | date | Inclusive start date (for date-bounded types). |
end_date | date | Inclusive end date (for date-bounded types). |
start_time | time | Inclusive start time (for time-bounded types). |
end_time | time | Inclusive end time (for time-bounded types). |
days_of_week | varchar | Comma-separated mon,tue,wed,thu,fri,sat,sun. |
min_party_size | int | Inclusive lower bound for by_party_size. |
max_party_size | int | Inclusive upper bound for by_party_size. |
is_active | boolean | Soft activation flag. |
created_at | datetime | Creation timestamp. |
updated_at | datetime | Update timestamp. |
Indexes: (package_id, package_type), is_active, date_type, rules_type.
Per-package-template columns (all 8 HH template tables)
| Column | Type | Description |
|---|---|---|
is_dynamic_prepayment | boolean | True when ≥ 1 active dynamic rule exists. |
rules_type | varchar | Current rules_type for serializer. |
date_type | int | Current date_type for serializer. |
Added via pt_add_columns.
Improvement:
| Feature Name | Date | What Changed | Description |
|---|---|---|---|
| Dynamic Prepayment Engine | 2026-07-29 | Added PackagePrepaymentChecker and snapshot decoration | Backend reservation flow evaluates per-package rules at init time and decorates restaurant_package params with prepayment_rule_applied. |
| Admin Rule Authoring | 2026-07-29 | Added PrepaymentRulesInput.vue and apply_prepayment_rules! | Admin panel supports 6 date_type and 2 rules_type modes; edits gated on flipper flag with BUSINESS_LOGGER audit. |
| Batch Evaluation API | 2026-07-29 | Added GET /restaurant_packages/prepayment_evaluate | Hybrid checkout can batch-evaluate up to 50 packages per call. |
| Hybrid Checkout Wiring | 2026-07-27 | Booking package store + checkout integration | evaluatePrepayment.ts + bookingPackageStore snapshot drive pay-now toggle, prepayment label, on-hold copy, and badge labels. |
| On-Hold Copy Variant | 2026-07-27 | OnHoldDescription switches to preauthorization copy | When isDynamicPrepayment && chargeType === 'on_hold', UI uses May Require Pre-authorization instead of May Require Prepayment. |
| Localized Copy (15 locales) | 2026-07-27 | Added 6 keys × 15 languages | requirePrePayment, prepaymentMayBeRequired, mayRequirePrepayment, requirePreAuthorization, mayRequirePreAuthorization, preAuthorizationMayBeRequired. |
Flipper Flag ff_dynamic_prepayment_v1 | 2026-07-29 | Single-flag rollout control | Gates reservation engine and admin UI; server-side audit on attempted edits when off. |
| Sample Seeder | 2026-07-29 | db/seeds/hh_package_prepayment_rule.rb | Provides demo rules on Seed AYCE Package (THA) template for QA and demo environments. |