383 lines
16 KiB
Markdown
383 lines
16 KiB
Markdown
# 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<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 `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:
|
||
|
||
```mermaid
|
||
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:**
|
||
|
||
```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<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:
|
||
|
||
```mermaid
|
||
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
|
||
|
||
```mermaid
|
||
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')`
|
||
|
||
```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<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.
|
||
|
||
```mermaid
|
||
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
|
||
|
||
```mermaid
|
||
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.
|
||
|
||
```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<br/>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)<br/>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<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.
|