From 4ee9fe654ed06edb0a3a14dc4f91b0bfe11b0dec Mon Sep 17 00:00:00 2001 From: susanyusuf Date: Tue, 28 Jul 2026 16:27:04 +0100 Subject: [PATCH 1/7] feat(plans): add plan comparison and recommendation engine Closes 776 --- .../controller/planComparisonController.ts | 154 +++++ backend/subscription/router/index.ts | 1 + .../router/planComparisonRouter.ts | 76 +++ docs/PLAN_COMPARISON.md | 138 +++++ src/navigation/types.ts | 1 + src/screens/PlanComparisonScreen.tsx | 348 +++++++++++ .../__tests__/planComparisonEngine.test.ts | 340 +++++++++++ src/services/planComparisonEngine.ts | 570 ++++++++++++++++++ src/store/index.ts | 1 + src/store/planComparisonStore.ts | 197 ++++++ src/types/planComparison.ts | 160 +++++ 11 files changed, 1986 insertions(+) create mode 100644 backend/subscription/controller/planComparisonController.ts create mode 100644 backend/subscription/router/planComparisonRouter.ts create mode 100644 docs/PLAN_COMPARISON.md create mode 100644 src/screens/PlanComparisonScreen.tsx create mode 100644 src/services/__tests__/planComparisonEngine.test.ts create mode 100644 src/services/planComparisonEngine.ts create mode 100644 src/store/planComparisonStore.ts create mode 100644 src/types/planComparison.ts diff --git a/backend/subscription/controller/planComparisonController.ts b/backend/subscription/controller/planComparisonController.ts new file mode 100644 index 00000000..53fcd012 --- /dev/null +++ b/backend/subscription/controller/planComparisonController.ts @@ -0,0 +1,154 @@ +/** + * Issue #776 – Plan comparison / recommendation API controller. + */ + +import type { Request, Response } from 'express'; +import { ok, fail } from '../../services/shared/apiResponse'; +import { extractRequestId } from './index'; +import type { + ComparablePlan, + CompareOptions, + PreferenceProfile, + RecommendationTrackingEvent, +} from '../../../src/types/planComparison'; +import { + PlanRecommendationTracker, + compareAndTrack, + recommendAndTrack, + createComparisonShare, + resolveComparisonShare, + trackRecommendationEvent, + getComparisonAnalytics, +} from '../../../src/services/planComparisonEngine'; + +/** Process-local tracker shared by all plan-comparison endpoints. */ +export const planComparisonTracker = new PlanRecommendationTracker(); + +function requestId(req: Request): string | undefined { + return extractRequestId(req); +} + +export function comparePlansHandler(req: Request, res: Response): void { + const plans = req.body?.plans as ComparablePlan[] | undefined; + const options = req.body?.options as CompareOptions | undefined; + + if (!Array.isArray(plans) || plans.length < 2) { + res + .status(400) + .json(fail('BAD_REQUEST', 'Body must include at least two plans', requestId(req))); + return; + } + + try { + const result = compareAndTrack(plans, options, planComparisonTracker); + res.status(200).json(ok(result, requestId(req))); + } catch (err) { + const message = err instanceof Error ? err.message : 'Comparison failed'; + res.status(400).json(fail('BAD_REQUEST', message, requestId(req))); + } +} + +export function recommendPlansHandler(req: Request, res: Response): void { + const plans = req.body?.plans as ComparablePlan[] | undefined; + const profile = (req.body?.profile ?? {}) as PreferenceProfile; + + if (!Array.isArray(plans) || plans.length === 0) { + res + .status(400) + .json(fail('BAD_REQUEST', 'Body must include at least one plan', requestId(req))); + return; + } + + try { + const recommendations = recommendAndTrack(plans, profile, planComparisonTracker); + res.status(200).json(ok({ recommendations }, requestId(req))); + } catch (err) { + const message = err instanceof Error ? err.message : 'Recommendation failed'; + res.status(400).json(fail('BAD_REQUEST', message, requestId(req))); + } +} + +export function trackRecommendationHandler(req: Request, res: Response): void { + const body = req.body as Partial | undefined; + + if (!body?.recommendationId || !body?.planId || !body?.eventType) { + res + .status(400) + .json( + fail( + 'BAD_REQUEST', + 'Body must include recommendationId, planId, and eventType', + requestId(req) + ) + ); + return; + } + + const event = trackRecommendationEvent( + { + recommendationId: body.recommendationId, + planId: body.planId, + eventType: body.eventType, + userId: body.userId, + comparisonId: body.comparisonId, + metadata: body.metadata, + occurredAt: body.occurredAt, + }, + planComparisonTracker + ); + + res.status(200).json(ok(event, requestId(req))); +} + +export function getAnalyticsHandler(req: Request, res: Response): void { + const analytics = getComparisonAnalytics(planComparisonTracker); + res.status(200).json(ok(analytics, requestId(req))); +} + +export function shareComparisonHandler(req: Request, res: Response): void { + const comparisonId = req.body?.comparisonId as string | undefined; + const planIds = req.body?.planIds as string[] | undefined; + const ttlMs = req.body?.ttlMs as number | undefined; + const payload = req.body?.payload; + + if (!comparisonId || !Array.isArray(planIds) || planIds.length < 2) { + res + .status(400) + .json( + fail( + 'BAD_REQUEST', + 'Body must include comparisonId and at least two planIds', + requestId(req) + ) + ); + return; + } + + const share = createComparisonShare( + comparisonId, + planIds, + payload, + ttlMs, + planComparisonTracker + ); + + res.status(201).json(ok(share, requestId(req))); +} + +export function resolveShareHandler(req: Request, res: Response): void { + const token = req.params.token; + if (!token) { + res.status(400).json(fail('BAD_REQUEST', 'Share token is required', requestId(req))); + return; + } + + const share = resolveComparisonShare(token, planComparisonTracker); + if (!share) { + res + .status(404) + .json(fail('NOT_FOUND', `Share token "${token}" not found or expired`, requestId(req))); + return; + } + + res.status(200).json(ok(share, requestId(req))); +} diff --git a/backend/subscription/router/index.ts b/backend/subscription/router/index.ts index 18354a53..642dc11e 100644 --- a/backend/subscription/router/index.ts +++ b/backend/subscription/router/index.ts @@ -1,2 +1,3 @@ export { createPublicApiRouter } from './publicApiRouter'; export { createThemeRouter } from './themeRouter'; +export { createPlanComparisonRouter } from './planComparisonRouter'; diff --git a/backend/subscription/router/planComparisonRouter.ts b/backend/subscription/router/planComparisonRouter.ts new file mode 100644 index 00000000..3b1672cc --- /dev/null +++ b/backend/subscription/router/planComparisonRouter.ts @@ -0,0 +1,76 @@ +/** + * Issue #776 – Plan comparison / recommendation routes. + * + * POST /plans/compare + * POST /plans/recommend + * POST /plans/recommendations/track + * GET /plans/comparisons/analytics + * POST /plans/comparisons/share + * GET /plans/comparisons/share/:token + */ + +import { Router, type Request, type Response, type NextFunction } from 'express'; +import { + comparePlansHandler, + recommendPlansHandler, + trackRecommendationHandler, + getAnalyticsHandler, + shareComparisonHandler, + resolveShareHandler, +} from '../controller/planComparisonController'; + +type AsyncHandler = (req: Request, res: Response, next: NextFunction) => Promise | void; + +function asyncHandler(fn: AsyncHandler) { + return (req: Request, res: Response, next: NextFunction): void => { + Promise.resolve(fn(req, res, next)).catch(next); + }; +} + +export function createPlanComparisonRouter(): Router { + const router = Router(); + + router.post( + '/plans/compare', + asyncHandler((req, res) => { + comparePlansHandler(req, res); + }) + ); + + router.post( + '/plans/recommend', + asyncHandler((req, res) => { + recommendPlansHandler(req, res); + }) + ); + + router.post( + '/plans/recommendations/track', + asyncHandler((req, res) => { + trackRecommendationHandler(req, res); + }) + ); + + router.get( + '/plans/comparisons/analytics', + asyncHandler((req, res) => { + getAnalyticsHandler(req, res); + }) + ); + + router.post( + '/plans/comparisons/share', + asyncHandler((req, res) => { + shareComparisonHandler(req, res); + }) + ); + + router.get( + '/plans/comparisons/share/:token', + asyncHandler((req, res) => { + resolveShareHandler(req, res); + }) + ); + + return router; +} diff --git a/docs/PLAN_COMPARISON.md b/docs/PLAN_COMPARISON.md new file mode 100644 index 00000000..3154f8c2 --- /dev/null +++ b/docs/PLAN_COMPARISON.md @@ -0,0 +1,138 @@ +# Plan Comparison & Recommendation Engine + +Issue #776 – dedicated plan comparison and recommendation feature (separate from the upsell engine in `backend/services/upsell/recommendationService.ts`). + +## Architecture + +``` +src/types/planComparison.ts Shared domain types +src/services/planComparisonEngine.ts Pure TS engine (no RN deps) +src/store/planComparisonStore.ts Zustand UI wrapper +src/screens/PlanComparisonScreen.tsx Side-by-side comparison UI +backend/subscription/controller/planComparisonController.ts +backend/subscription/router/planComparisonRouter.ts +``` + +The engine is pure TypeScript so it can run in Jest, Node API handlers, and the React Native app without platform imports. + +### Core flows + +1. **Compare** – `comparePlans(plans, options?)` builds a feature matrix, normalized price diffs, and winners (cheapest / most features / best value / per-category). +2. **Recommend** – `recommendPlan(plans, profile)` scores each plan against a `PreferenceProfile` and returns a ranked list with reasons. +3. **Track** – `PlanRecommendationTracker` stores comparison + recommendation events in memory for analytics. +4. **Share** – `createComparisonShare` / `resolveComparisonShare` mint and resolve opaque tokens. + +## Recommendation scoring + +Each candidate plan receives a composite score in `[0, 1]`: + +| Factor | Default weight | Value-priority weight | Notes | +|--------|----------------|----------------------|-------| +| Budget fit | 0.30 | 0.20 | Prefers using budget without exceeding it | +| Feature match | 0.35 | 0.25 | Required features are a hard filter; preferred features raise score | +| Usage fit | 0.20 | 0.15 | Matches `tierRank` to `usageLevel` | +| Value score | 0.15 | 0.40 | Features per monthly dollar | + +Plans missing any `requiredFeatures` are excluded. The current plan (`currentPlanId`) is skipped. + +Monthly price normalization: + +| Billing cycle | Multiplier to monthly | +|---------------|----------------------| +| daily | ×30 | +| weekly | ×(52/12) | +| monthly | ×1 | +| yearly | ×(1/12) | + +## API + +Mount with `createPlanComparisonRouter()` from `backend/subscription/router`. + +| Method | Path | Body / params | Response `data` | +|--------|------|---------------|-----------------| +| POST | `/plans/compare` | `{ plans, options? }` | `PlanComparisonResult` | +| POST | `/plans/recommend` | `{ plans, profile? }` | `{ recommendations }` | +| POST | `/plans/recommendations/track` | `{ recommendationId, planId, eventType, ... }` | `RecommendationTrackingEvent` | +| GET | `/plans/comparisons/analytics` | — | `ComparisonAnalytics` | +| POST | `/plans/comparisons/share` | `{ comparisonId, planIds, payload?, ttlMs? }` | `ComparisonShare` | +| GET | `/plans/comparisons/share/:token` | `:token` | `ComparisonShare` | + +All responses use the standard `ok` / `fail` envelope from `backend/services/shared/apiResponse.ts`. + +### Example: compare + +```http +POST /plans/compare +Content-Type: application/json + +{ + "plans": [ + { + "id": "basic", + "name": "Basic", + "price": 9.99, + "currency": "USD", + "billingCycle": "monthly", + "features": [ + { "id": "api", "name": "API Access", "category": "integrations", "value": false } + ] + }, + { + "id": "pro", + "name": "Pro", + "price": 29.99, + "currency": "USD", + "billingCycle": "monthly", + "features": [ + { "id": "api", "name": "API Access", "category": "integrations", "value": true } + ] + } + ], + "options": { "normalizeBillingCycle": "monthly" } +} +``` + +### Example: recommend + +```http +POST /plans/recommend +Content-Type: application/json + +{ + "plans": [ /* ComparablePlan[] */ ], + "profile": { + "budget": 50, + "requiredFeatures": ["api"], + "preferredFeatures": ["support"], + "usageLevel": "moderate", + "prioritizeValue": true, + "maxResults": 3 + } +} +``` + +## Analytics + +`getComparisonAnalytics()` / `GET /plans/comparisons/analytics` returns: + +- `totalComparisons`, `totalRecommendations` +- `impressions`, `clicks`, `accepts`, `dismissals` +- `conversionRate` = accepts / impressions +- `clickThroughRate` = clicks / impressions +- `mostComparedPairs` – top plan-id pairs by compare count +- `topRecommended` – plans most often returned by the recommender + +## Client store + +```ts +import { usePlanComparisonStore } from '../store/planComparisonStore'; + +const { setSelectedPlans, runComparison, runRecommendation, shareComparison } = + usePlanComparisonStore(); +``` + +Screen route: `PlanComparison` (see `RootStackParamList`). + +## Relation to upsell recommendations + +The upsell service (`RecommendationService`) targets checkout / renewal upsells with collaborative filtering. This engine compares arbitrary catalog plans against explicit preferences. Do not merge the two stores or event streams. diff --git a/src/navigation/types.ts b/src/navigation/types.ts index 0cd7c908..ce07ce41 100644 --- a/src/navigation/types.ts +++ b/src/navigation/types.ts @@ -68,6 +68,7 @@ export type RootStackParamList = { BillingSettings: undefined; BillingAlignment: undefined; ChangePlan: { subscriptionId: string }; + PlanComparison: undefined; PauseResume: { id: string }; PaymentMethods: undefined; AnalyticsDashboard: undefined; diff --git a/src/screens/PlanComparisonScreen.tsx b/src/screens/PlanComparisonScreen.tsx new file mode 100644 index 00000000..aed56dd9 --- /dev/null +++ b/src/screens/PlanComparisonScreen.tsx @@ -0,0 +1,348 @@ +/** + * Issue #776 – Side-by-side plan comparison with recommendation CTA. + */ + +import React, { useMemo, useCallback } from 'react'; +import { View, Text, StyleSheet, SafeAreaView, ScrollView, Alert } from 'react-native'; +import { NativeStackScreenProps } from '@react-navigation/native-stack'; +import { RootStackParamList } from '../navigation/types'; +import { usePlanComparisonStore } from '../store/planComparisonStore'; +import { Card } from '../components/common/Card'; +import { Button } from '../components/common/Button'; +import { useThemeColors } from '../hooks/useThemeColors'; +import { spacing, typography, borderRadius } from '../utils/constants'; +import { formatCurrency } from '../utils/formatting'; +import type { ComparablePlan } from '../types/planComparison'; + +type Props = NativeStackScreenProps; + +const DEMO_PLANS: ComparablePlan[] = [ + { + id: 'basic', + name: 'Basic', + price: 9.99, + currency: 'USD', + billingCycle: 'monthly', + tierRank: 1, + features: [ + { id: 'users', name: 'Users', category: 'limits', value: 3 }, + { id: 'storage', name: 'Storage GB', category: 'limits', value: 10 }, + { id: 'api', name: 'API Access', category: 'integrations', value: false }, + { id: 'support', name: 'Priority Support', category: 'support', value: false }, + ], + }, + { + id: 'pro', + name: 'Pro', + price: 29.99, + currency: 'USD', + billingCycle: 'monthly', + tierRank: 2, + popular: true, + features: [ + { id: 'users', name: 'Users', category: 'limits', value: 25 }, + { id: 'storage', name: 'Storage GB', category: 'limits', value: 100 }, + { id: 'api', name: 'API Access', category: 'integrations', value: true }, + { id: 'support', name: 'Priority Support', category: 'support', value: true }, + ], + }, + { + id: 'enterprise', + name: 'Enterprise', + price: 99.99, + currency: 'USD', + billingCycle: 'monthly', + tierRank: 4, + features: [ + { id: 'users', name: 'Users', category: 'limits', value: 500 }, + { id: 'storage', name: 'Storage GB', category: 'limits', value: 1000 }, + { id: 'api', name: 'API Access', category: 'integrations', value: true }, + { id: 'support', name: 'Priority Support', category: 'support', value: true }, + ], + }, +]; + +const PlanComparisonScreen: React.FC = () => { + const colors = useThemeColors(); + const styles = useMemo(() => createStyles(colors), [colors]); + + const { + selectedPlans, + comparison, + recommendations, + lastShare, + error, + setSelectedPlans, + runComparison, + runRecommendation, + shareComparison, + trackEvent, + } = usePlanComparisonStore(); + + const plans = selectedPlans.length >= 2 ? selectedPlans : DEMO_PLANS; + + const handleCompare = useCallback(() => { + setSelectedPlans(plans); + const result = runComparison(); + if (result) { + runRecommendation({ + budget: 50, + requiredFeatures: ['api'], + usageLevel: 'moderate', + prioritizeValue: true, + }); + } + }, [plans, setSelectedPlans, runComparison, runRecommendation]); + + const topRec = recommendations[0]; + + const handleAcceptRecommendation = useCallback(() => { + if (!topRec) return; + trackEvent({ + recommendationId: comparison?.id ?? 'rec', + planId: topRec.planId, + eventType: 'accept', + comparisonId: comparison?.id, + }); + Alert.alert('Plan selected', `${topRec.planName} marked as accepted.`); + }, [topRec, comparison, trackEvent]); + + const handleShare = useCallback(() => { + if (!comparison) { + Alert.alert('Compare first', 'Run a comparison before sharing.'); + return; + } + const share = shareComparison(7 * 24 * 60 * 60 * 1000); + if (share) { + Alert.alert('Share link', `Token: ${share.token}`); + } + }, [comparison, shareComparison]); + + const featureIds = useMemo(() => { + const ids = new Map(); + for (const plan of plans) { + for (const f of plan.features) { + if (!ids.has(f.id)) ids.set(f.id, f.name); + } + } + return [...ids.entries()]; + }, [plans]); + + const formatFeatureValue = (value: boolean | string | number | null | undefined) => { + if (value === null || value === undefined) return '—'; + if (typeof value === 'boolean') return value ? 'Yes' : 'No'; + return String(value); + }; + + return ( + + + Plan Comparison + + Compare features and pricing side-by-side, then get a recommendation. + + + {error ? {error} : null} + + + + + + Plan + + {plans.map((plan) => ( + + {plan.name} + + {formatCurrency(plan.price, plan.currency)} + + /{plan.billingCycle === 'yearly' ? 'yr' : 'mo'} + + + + ))} + + + {featureIds.map(([id, name]) => ( + + + {name} + + {plans.map((plan) => { + const feature = plan.features.find((f) => f.id === id); + const winner = + comparison?.featureMatrix.find((d) => d.featureId === id)?.winnerPlanId === + plan.id; + return ( + + + {formatFeatureValue(feature?.value)} + + + ); + })} + + ))} + + + + +