agmission/Development/client/docs/MANAGE_SERVICES_PROMO_DISPLAY.md

383 lines
16 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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