# 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](#overview)
- [Data Flow](#data-flow)
- [Key State](#key-state)
- [activePromos Map Construction](#activepromos-map-construction)
- [Button Labels and confirmServices Flow](#button-labels-and-confirmservices-flow)
- [Create New Subscription Plan](#create-new-subscription-plan)
- [Create Trial Subscription Plan](#create-trial-subscription-plan)
- [Checkout Promo Display after Each Flow](#checkout-promo-display-after-each-flow)
- [getPromoForLookupKey the Core Gate](#getpromoforlookupkey-the-core-gate)
- [isAllPackagesPromo Package-Wide Banner Logic](#isallpackagespromo-package-wide-banner-logic)
- [Template Rendering Logic](#template-rendering-logic)
- [Promo Price Calculation](#promo-price-calculation)
- [ESS_1 Legacy Special Handling](#ess_1-legacy-special-handling)
- [Promo Display Components](#promo-display-components)
- [Quick Reference Table](#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
```mermaid
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
Auth required - v3.0"]
F --> G["Server filters by customer eligibility
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/activePromos` requires 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` using three key patterns based on what fields each promo has:
```mermaid
flowchart TD
A([Promo from /api/activePromos]) --> B{Has priceKey?}
B -->|Yes| C["activePromos.set(priceKey, promo)
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)
activePromos.set('addon_all', promo)"]
```
**Lookup at render time:**
```mermaid
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:
```mermaid
flowchart TD
BTN([Click Create New Subscription Plan]) --> CS[confirmServices]
CS --> ISNEW{isNewSub?}
ISNEW -->|Yes| REGDIRECT[dispatchStartBillingInfo
mode = Mode.REGULAR]
ISNEW -->|No - existing sub change| CONFIRM[Confirm dialog
then dispatchStartBillingInfo
mode = Mode.REGULAR]
REGDIRECT --> SBI[StartBillingInfo dispatched
prorateTS = DateUtils.currUTC]
CONFIRM --> SBI
SBI --> NAV([Navigate to /checkout])
NAV --> INIT[initPage]
INIT --> ISTRIALCK{isTrial?}
ISTRIALCK -->|No - regular| INVOICES[Fetch upcoming invoices
calcChkoutPayment]
INVOICES --> CAP[checkApplicablePromos]
CAP --> GATE["getPromoForLookupKey
hasAnyPackageSub? NO
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
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:
```mermaid
flowchart TD
BTN([Click Create Trial Subscription Plan]) --> CS[confirmServices]
CS --> TRIALS[Read membership.trials
Calculate trialEndDate]
TRIALS --> PKGTRIALEND["selPkg = { ...currSel.selPkg,
trialEnd: trialEndDate }"]
PKGTRIALEND --> ADDONTRIALEND["selAddons = addons.map
addon.trialEnd = trialEndDate"]
ADDONTRIALEND --> ISNEW{isNewSub?}
ISNEW -->|Yes| TRIALDIRECT[dispatchStartBillingInfo
mode = Mode.TRIALING
prorateTS = null]
ISNEW -->|No - existing trial change| TRIALCONFIRM[Confirm dialog
then dispatchStartBillingInfo
mode = Mode.TRIALING]
TRIALDIRECT --> SBI[StartBillingInfo dispatched]
TRIALCONFIRM --> SBI
SBI --> NAV([Navigate to /checkout])
NAV --> INIT[initPage]
INIT --> ISTRIALCK{isTrial = true}
ISTRIALCK --> TRIALITEMS["createTrialItems
from selPkg + selAddons"]
TRIALITEMS --> CTIP1["checkTrialItemPromos
activePromos EMPTY at this point
totalPromoSavings = 0"]
CTIP1 --> AMOUNT1["amount.total = grossTotal - 0
STALE - full price"]
NAV --> LAP[loadActivePromos async]
LAP --> PROMOMAP[activePromos Map built
from /api/activePromos response]
PROMOMAP --> CTIP2["checkTrialItemPromos
activePromos NOW loaded"]
CTIP2 --> PROMOFOUND{promo in activePromos
for this lookupKey?}
PROMOFOUND -->|Yes - e.g. ess_1_1 eligibility=all| SAVINGS["totalPromoSavings recalculated
paymentPromos populated"]
SAVINGS --> AMOUNTFIX["amount.total = grossTotal - totalPromoSavings
UpdateAmount dispatched"]
AMOUNTFIX --> PROMODISPLAY([Show promo badge + discounted price])
PROMOFOUND -->|No match| NORMALPRICE([Full trial price - no promo])
```
### Checkout Promo Display after Each Flow
```mermaid
flowchart LR
REG([Regular flow
Mode.REGULAR]) --> REGPATH["checkApplicablePromos
uses chkoutPmt.lineItems
gate: hasAnyPackageSub"]
REGPATH --> REGPROMO([paymentPromos map
promo badges + savings])
TRIAL([Trial flow
Mode.TRIALING]) --> TRIALPATH["checkTrialItemPromos
uses trialItems
no subscription gate
looks up activePromos directly"]
TRIALPATH --> TRIALPROMO(["paymentPromos map
promo badges + discounted trial total
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')`
```mermaid
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
AND status === trialing?}
C -->|Yes| NULL2([return null - trial IS the promo])
C -->|No| D{mode === available
AND userHasThis?}
D -->|Yes| NULL3([return null - item already subscribed])
D -->|No| E{mode === subscribed
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 `eligibility` before returning promos. If a promo with `eligibility: 'all'` is returned (e.g. `ess_1_1` with a `$400 OFF` offer), it means the server has confirmed this user qualifies.
- A trial user looking at `/services` to 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.
```mermaid
flowchart TD
START([isAllPackagesPromo]) --> A{essPkgs empty?}
A -->|Yes| NULL1([return null])
A -->|No| B{User has ANY existing
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
an individual promo?}
E -->|No| NULL3([return null])
E -->|Yes| F{All promos share same
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
```mermaid
flowchart TD
ROW([Package row rendered]) --> LEGACY{isLegacyEss1?}
LEGACY -->|Yes| LEGACYLABEL[Show legacy notice label
Promo display suppressed]
LEGACY -->|No| SUB{isUserSubscribed?}
SUB -->|Yes - subscribed| ACTIVE["getPromoForLookupKey(key, package, subscribed)"]
ACTIVE -->|promo found| ACTIVELABEL["agm-active-promo-label
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
e.g. AgMission Essentials 1 Plus"]
AVAIL -->|null| NOTHING2[No promo label]
ROW --> PRICECOL[Price column]
PRICECOL --> PROMOCHECK["getPromoForLookupKey(key, package)
mode defaults to available"]
PROMOCHECK -->|promo found| CROSSEDPRICE["original-price crossed out
promo-price shown
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.
```mermaid
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
Valid until date below"]
TP -->|null| TPREGULAR[Regular total price]
```
---
## Promo Price Calculation
All math is delegated to `SubscriptionService.calculateDiscountedAmount(originalCents, promo)`:
```mermaid
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)
value is already in cents"]
```
For addons: `calculatePromoTotal(addon, promo)` = `calculatePromoPrice(addon.price, promo) × quantity`.
---
## ESS_1 Legacy Special Handling
```mermaid
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
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 `isTrial` flag from Redux intent no longer suppresses promo display — only active `status='trialing'` subscriptions suppress promos on their own row.