agmission/Development/client/docs/MANAGE_SERVICES_PROMO_DISPLAY.md

16 KiB
Raw Blame History

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

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/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 getSubIntentStatesubIntent.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 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.

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 isTrial flag from Redux intent no longer suppresses promo display — only active status='trialing' subscriptions suppress promos on their own row.