# 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.