16 KiB
Promo Display Logic — services Screen
Component: src/app/profile/manage-services/manage-services.component.ts
Route: /profile/services
Last Updated: March 18, 2026
Table of Contents
- Overview
- Data Flow
- Key State
- activePromos Map Construction
- Button Labels and confirmServices Flow
- getPromoForLookupKey the Core Gate
- isAllPackagesPromo Package-Wide Banner Logic
- Template Rendering Logic
- Promo Price Calculation
- ESS_1 Legacy Special Handling
- Promo Display Components
- Quick Reference Table
Overview
The /services screen ("Choose Your Plan") shows packages and addons with promotional pricing when applicable. Promo data comes from the authenticated GET /api/activePromos endpoint (v3.0+), which already filters by customer eligibility server-side. The client only needs to apply display-mode gating (available vs. subscribed) on top.
Data Flow
flowchart TD
A([ngOnInit]) --> B[dispatch FetchSubPlans]
A --> C[loadActivePromos]
A --> D[loadPromoMode]
B --> E["populates essPkgs, addons via Redux store"]
C --> F["GET /api/activePromos<br/>Auth required - v3.0"]
F --> G["Server filters by customer eligibility<br/>eligibility: all / new_only / renew_only"]
G --> H["Returns only eligible promos"]
H --> I[buildActivePromosMap]
I --> J[(activePromos Map)]
D --> F2["getCurrentMode()"]
F2 --> K["promoMode: enabled or disabled"]
J --> L[Template rendering]
K --> L
E --> L
v3.0 (Jan 2026):
/api/activePromosrequires authentication and returns only the promos the current customer is eligible for. The client does not need to re-check eligibility — the returned list is already filtered.
Key State
| Property | Source | Purpose |
|---|---|---|
activePromos |
GET /api/activePromos |
Map of eligible promos for current user |
promoMode |
currentMode.mode from same response |
Global kill switch ('enabled' or 'disabled') |
subs |
Redux getSubscriptions |
Current Stripe subscriptions for this user |
isTrial |
Redux getSubIntentState — subIntent.mode === Mode.TRIALING |
Whether checkout intent is a trial sign-up |
activePromos Map Construction
loadActivePromos() builds a flat Map<string, ActivePromo> using three key patterns based on what fields each promo has:
flowchart TD
A([Promo from /api/activePromos]) --> B{Has priceKey?}
B -->|Yes| C["activePromos.set(priceKey, promo)<br/>e.g. 'ess_1_1' -> promo"]
B -->|No| D{Has type?}
D -->|Yes - package or addon| E["activePromos.set('package_all' or 'addon_all', promo)"]
D -->|No - universal promo| F["activePromos.set('package_all', promo)<br/>activePromos.set('addon_all', promo)"]
Lookup at render time:
flowchart LR
A["getPromoForLookupKey('ess_1_1', 'package')"]
A --> B["activePromos.get('ess_1_1')"]
B -->|found| C([return exact promo])
B -->|not found| D["activePromos.get('package_all')"]
D -->|found| E([return type-wide promo])
D -->|not found| F([return null])
Button Labels and confirmServices Flow
The confirm button in #btnSection uses a computed confirmLabel getter:
isNewSub = !originalSel.selPkg && !(originalSel.selAddons.length > 0)
confirmLabel =
isNewSub && isTrial → "Create Trial Subscription Plan"
isNewSub && !isTrial → "Create New Subscription Plan"
otherwise → "Confirm"
isNewSub is true when the user has no existing package and no existing addon subscriptions — i.e. they are subscribing for the first time. isTrial comes from Redux subIntent.mode === Mode.TRIALING.
Create New Subscription Plan
Triggered when isNewSub=true and isTrial=false. Full flow from button click through promo display in checkout:
flowchart TD
BTN([Click Create New Subscription Plan]) --> CS[confirmServices]
CS --> ISNEW{isNewSub?}
ISNEW -->|Yes| REGDIRECT[dispatchStartBillingInfo<br/>mode = Mode.REGULAR]
ISNEW -->|No - existing sub change| CONFIRM[Confirm dialog<br/>then dispatchStartBillingInfo<br/>mode = Mode.REGULAR]
REGDIRECT --> SBI[StartBillingInfo dispatched<br/>prorateTS = DateUtils.currUTC]
CONFIRM --> SBI
SBI --> NAV([Navigate to /checkout])
NAV --> INIT[initPage]
INIT --> ISTRIALCK{isTrial?}
ISTRIALCK -->|No - regular| INVOICES[Fetch upcoming invoices<br/>calcChkoutPayment]
INVOICES --> CAP[checkApplicablePromos]
CAP --> GATE["getPromoForLookupKey<br/>hasAnyPackageSub? NO<br/>exact or type-wide match?"]
GATE -->|promo found| PROMODISPLAY([Show promo badge + discounted price])
GATE -->|no match| NORMALPRICE([Regular price])
NAV --> LAP[loadActivePromos async]
LAP --> CAP2[checkApplicablePromos again<br/>with real activePromos]
CAP2 --> PROMODISPLAY2([Promo display updated if match])
Create Trial Subscription Plan
Triggered when isNewSub=true and isTrial=true. The trial flow adds trialEnd timestamps to the selected package and addons, then navigates to checkout:
flowchart TD
BTN([Click Create Trial Subscription Plan]) --> CS[confirmServices]
CS --> TRIALS[Read membership.trials<br/>Calculate trialEndDate]
TRIALS --> PKGTRIALEND["selPkg = { ...currSel.selPkg,<br/>trialEnd: trialEndDate }"]
PKGTRIALEND --> ADDONTRIALEND["selAddons = addons.map<br/>addon.trialEnd = trialEndDate"]
ADDONTRIALEND --> ISNEW{isNewSub?}
ISNEW -->|Yes| TRIALDIRECT[dispatchStartBillingInfo<br/>mode = Mode.TRIALING<br/>prorateTS = null]
ISNEW -->|No - existing trial change| TRIALCONFIRM[Confirm dialog<br/>then dispatchStartBillingInfo<br/>mode = Mode.TRIALING]
TRIALDIRECT --> SBI[StartBillingInfo dispatched]
TRIALCONFIRM --> SBI
SBI --> NAV([Navigate to /checkout])
NAV --> INIT[initPage]
INIT --> ISTRIALCK{isTrial = true}
ISTRIALCK --> TRIALITEMS["createTrialItems<br/>from selPkg + selAddons"]
TRIALITEMS --> CTIP1["checkTrialItemPromos<br/>activePromos EMPTY at this point<br/>totalPromoSavings = 0"]
CTIP1 --> AMOUNT1["amount.total = grossTotal - 0<br/>STALE - full price"]
NAV --> LAP[loadActivePromos async]
LAP --> PROMOMAP[activePromos Map built<br/>from /api/activePromos response]
PROMOMAP --> CTIP2["checkTrialItemPromos<br/>activePromos NOW loaded"]
CTIP2 --> PROMOFOUND{promo in activePromos<br/>for this lookupKey?}
PROMOFOUND -->|Yes - e.g. ess_1_1 eligibility=all| SAVINGS["totalPromoSavings recalculated<br/>paymentPromos populated"]
SAVINGS --> AMOUNTFIX["amount.total = grossTotal - totalPromoSavings<br/>UpdateAmount dispatched"]
AMOUNTFIX --> PROMODISPLAY([Show promo badge + discounted price])
PROMOFOUND -->|No match| NORMALPRICE([Full trial price - no promo])
Checkout Promo Display after Each Flow
flowchart LR
REG([Regular flow<br/>Mode.REGULAR]) --> REGPATH["checkApplicablePromos<br/>uses chkoutPmt.lineItems<br/>gate: hasAnyPackageSub"]
REGPATH --> REGPROMO([paymentPromos map<br/>promo badges + savings])
TRIAL([Trial flow<br/>Mode.TRIALING]) --> TRIALPATH["checkTrialItemPromos<br/>uses trialItems<br/>no subscription gate<br/>looks up activePromos directly"]
TRIALPATH --> TRIALPROMO(["paymentPromos map<br/>promo badges + discounted trial total<br/>eligibility=all promos shown"])
Key difference: checkApplicablePromos gates on hasAnyPackageSubscription because it works with real invoice line items that could include existing subscriptions. checkTrialItemPromos skips that gate — the items are the trial package/addons only, and the server already applied eligibility filtering to /activePromos.
getPromoForLookupKey the Core Gate
This is the single method called by the template for every package and addon row. It returns an ActivePromo to display or null to show nothing.
Signature: getPromoForLookupKey(lookupKey, type, mode = 'available')
flowchart TD
START([getPromoForLookupKey]) --> A{promoMode === disabled?}
A -->|Yes| NULL1([return null - global kill switch])
A -->|No| B[getUserSubscriptionForLookupKey]
B --> C{User has sub for this item<br/>AND status === trialing?}
C -->|Yes| NULL2([return null - trial IS the promo])
C -->|No| D{mode === available<br/>AND userHasThis?}
D -->|Yes| NULL3([return null - item already subscribed])
D -->|No| E{mode === subscribed<br/>AND NOT userHasThis?}
E -->|Yes| NULL4([return null - item not subscribed])
E -->|No| F{mode === subscribed?}
F -->|Yes| G{promoDetails.hasPromo === true?}
G -->|Yes| CONV([return convertPromoDetailsToActivePromo])
G -->|No| NULL5([return null - no fallback for subscribed mode])
F -->|No - mode is available| H["activePromos.get(lookupKey)"]
H -->|found| EXACT([return exact-match promo])
H -->|not found| I["activePromos.get(type_all)"]
I -->|found| TYPE([return type-wide promo])
I -->|not found| NULL6([return null])
Why isTrial does NOT suppress promos
Prior to v3.0, the flag isTrial (set when checkout intent mode is TRIALING) blocked all available promos. This was removed because:
- Since v3.0, the server already evaluates
eligibilitybefore returning promos. If a promo witheligibility: 'all'is returned (e.g.ess_1_1with a$400 OFFoffer), it means the server has confirmed this user qualifies. - A trial user looking at
/servicesto decide whether to subscribe with auto-renewal should see that promo — it is the incentive to convert. - The existing guard
status === 'trialing'(step above) still correctly hides promos on any subscription row where the user is actively in a trial.
isAllPackagesPromo Package-Wide Banner Logic
Controls whether the green promo banner above the packages table is shown.
flowchart TD
START([isAllPackagesPromo]) --> A{essPkgs empty?}
A -->|Yes| NULL1([return null])
A -->|No| B{User has ANY existing<br/>package subscription?}
B -->|Yes| NULL2([return null - banner only for new subscribers])
B -->|No| C["activePromos.get('package_all')"]
C -->|found| RET1([return type-wide promo])
C -->|not found| D[Map each pkg to its activePromo]
D --> E{All packages have<br/>an individual promo?}
E -->|No| NULL3([return null])
E -->|Yes| F{All promos share same<br/>discountType + discountValue?}
F -->|No| NULL4([return null])
F -->|Yes| RET2([return the shared promo])
The banner is intentionally shown only to brand-new subscribers (no existing package subscription). Returning subscribers see promo state per-row instead.
Template Rendering Logic
Package Row Structure
flowchart TD
ROW([Package row rendered]) --> LEGACY{isLegacyEss1?}
LEGACY -->|Yes| LEGACYLABEL[Show legacy notice label<br/>Promo display suppressed]
LEGACY -->|No| SUB{isUserSubscribed?}
SUB -->|Yes - subscribed| ACTIVE["getPromoForLookupKey(key, package, subscribed)"]
ACTIVE -->|promo found| ACTIVELABEL["agm-active-promo-label<br/>Active Promo: DISCOUNT"]
ACTIVE -->|null| NOTHING1[No promo label]
SUB -->|No - not subscribed| AVAIL["getPromoForLookupKey(key, package, available)"]
AVAIL -->|promo found| AVAILABLELABEL["Promo name text<br/>e.g. AgMission Essentials 1 Plus"]
AVAIL -->|null| NOTHING2[No promo label]
ROW --> PRICECOL[Price column]
PRICECOL --> PROMOCHECK["getPromoForLookupKey(key, package)<br/>mode defaults to available"]
PROMOCHECK -->|promo found| CROSSEDPRICE["original-price crossed out<br/>promo-price shown<br/>Valid until date below"]
PROMOCHECK -->|null| REGULARPRICE[Regular price only]
Addon Row Structure
Same dual-mode structure as packages, with no banner at the top (per P2-D wireframe). Both Unit Price and Total Price columns independently call getPromoForLookupKey in available mode.
flowchart TD
ADDONROW([Addon row rendered]) --> SUBSCHECK{isUserSubscribed?}
SUBSCHECK -->|Yes| A2["getPromoForLookupKey(key, addon, subscribed)"]
A2 -->|promo found| A2L[agm-active-promo-label]
A2 -->|null| A2N[No promo label]
SUBSCHECK -->|No| A3["getPromoForLookupKey(key, addon, available)"]
A3 -->|promo found| A3L[Promo name text below addon name]
A3 -->|null| A3N[No promo label]
ADDONROW --> UNITPRICE[Unit Price column]
UNITPRICE --> UP["getPromoForLookupKey(key, addon)"]
UP -->|promo| UPPROMO[crossed price + promo price]
UP -->|null| UPREGULAR[Regular unit price]
ADDONROW --> TOTALPRICE[Total Price column]
TOTALPRICE --> TP["getPromoForLookupKey(key, addon)"]
TP -->|promo| TPPROMO["crossed total + promo total<br/>Valid until date below"]
TP -->|null| TPREGULAR[Regular total price]
Promo Price Calculation
All math is delegated to SubscriptionService.calculateDiscountedAmount(originalCents, promo):
flowchart TD
CALC([calculateDiscountedAmount]) --> A{discountType}
A -->|free OR discountValue === 100| ZERO([return 0])
A -->|percent| PCT["return round(original x 1 - value/100)"]
A -->|fixed| FIXED["return max(0, original - value)<br/>value is already in cents"]
For addons: calculatePromoTotal(addon, promo) = calculatePromoPrice(addon.price, promo) × quantity.
ESS_1 Legacy Special Handling
flowchart TD
PKG([Package item]) --> SHOW{shouldShowPackage}
SHOW --> ESS1{lookupKey === ess_1?}
ESS1 -->|Yes| HASLEGACY{hasLegacyEss1Subscription?}
HASLEGACY -->|Yes - user has active or trialing ESS_1| VISIBLE([Show row])
HASLEGACY -->|No| HIDDEN([Hide row])
ESS1 -->|No - ess_1_1 or others| VISIBLE2([Always show row])
VISIBLE --> PROMOCHECK{isLegacyEss1?}
PROMOCHECK -->|Yes - ess_1 row| LEGACYNOTICE[Show legacy notice<br/>Promo display suppressed]
PROMOCHECK -->|No| NORMALPROMO[Normal dual-mode promo display]
isLegacyEss1(lookupKey): returns true only when lookupKey === 'ess_1' AND the user has a legacy ESS_1 subscription. Promo display is suppressed on ESS_1 rows even if a matching global promo exists.
Promo Display Components
| Component | Selector | Used for | Shows |
|---|---|---|---|
ActivePromoLabelComponent |
agm-active-promo-label |
Subscribed users with promoDetails.hasPromo |
Active Promo: DISCOUNT |
ConstraintMessageComponent |
agm-constraint-message severity="promo" |
Package-wide banner via isAllPackagesPromo() |
Green info box with promo message and valid-until date |
| Raw template | div.available-promo |
Available promo name below package or addon name | Promo name text |
| Raw template | div.price-with-promo |
Price column when promo available | Crossed-out price, promo price, valid-until date |
Quick Reference Table
| User type | Item state | mode arg | Result |
|---|---|---|---|
Any, PROMO_MODE=disabled |
any | any | No promo shown |
| Any | status=trialing subscription |
any | No promo shown |
| Not subscribed | available item | available |
Promo shown if in activePromos |
| Already subscribed | own subscription | subscribed |
Promo shown if promoDetails.hasPromo |
Trial user (isTrial=true) |
not yet subscribed | available |
Promo shown if in activePromos |
| Legacy ESS_1 subscriber | ess_1 row |
any | Promo suppressed, legacy notice shown |
| User with existing package sub | package-wide banner | isAllPackagesPromo |
Banner hidden |
Trial users CAN see available promos. Server-side eligibility filtering (v3.0) ensures only qualifying promos are returned. The
isTrialflag from Redux intent no longer suppresses promo display — only activestatus='trialing'subscriptions suppress promos on their own row.