From e0f8ede514a9d020aaeedd902cb52637fb7cb32f Mon Sep 17 00:00:00 2001 From: Baba-Yoga Date: Wed, 24 Jun 2026 22:47:41 +0000 Subject: [PATCH] feat: add contract renewal automation and upsell recommendation engine (#560 #561) Issue 560 - Contract renewal automation: - Add renewal types (RenewalMilestone, RenewalStatus, ApprovalWorkflow, etc.) - Add RenewalService with quote generation, negotiation workspace, approval chain, e-signature integration, win/loss tracking, auto vs opt-in renewal, and mid-negotiation freeze support - Add RenewalMilestoneChecker cron for 90/60/30-day milestone alerts - Add RenewalWorkspaceScreen with full negotiation UI Issue 561 - Upsell recommendation engine: - Add upsell types (RecommendationTrigger, ConversionFunnel, etc.) - Add RecommendationService with collaborative filtering, A/B test assignment, merchant trigger config, and conversion funnel tracking - Add UpsellWidget and RecommendationCard embeddable components - Handle edge cases: max-tier subscriber, control A/B variant Misc: - Register RenewalWorkspace screen in navigation - Update .gitignore to exclude test snapshots and stray working files --- .gitignore | 34 ++ app/screens/RenewalWorkspaceScreen.tsx | 494 ++++++++++++++++++ .../renewal/renewalMilestoneChecker.ts | 69 +++ backend/services/renewal/renewalService.ts | 278 ++++++++++ .../services/upsell/recommendationService.ts | 173 ++++++ src/components/upsell/RecommendationCard.tsx | 99 ++++ src/components/upsell/UpsellWidget.tsx | 160 ++++++ src/components/upsell/index.ts | 3 + src/navigation/AppNavigator.tsx | 8 + src/navigation/types.ts | 1 + src/types/renewal.ts | 103 ++++ src/types/upsell.ts | 64 +++ 12 files changed, 1486 insertions(+) create mode 100644 app/screens/RenewalWorkspaceScreen.tsx create mode 100644 backend/services/renewal/renewalMilestoneChecker.ts create mode 100644 backend/services/renewal/renewalService.ts create mode 100644 backend/services/upsell/recommendationService.ts create mode 100644 src/components/upsell/RecommendationCard.tsx create mode 100644 src/components/upsell/UpsellWidget.tsx create mode 100644 src/components/upsell/index.ts create mode 100644 src/types/renewal.ts create mode 100644 src/types/upsell.ts diff --git a/.gitignore b/.gitignore index fc097f2f..d5d62ee1 100644 --- a/.gitignore +++ b/.gitignore @@ -89,3 +89,37 @@ contracts/migrations/history/* !contracts/migrations/history/.gitkeep contracts/migrations/snapshots/* !contracts/migrations/snapshots/.gitkeep + +# Test snapshots (generated — do not commit) +**/__snapshots__/ +**/test_snapshots/**/*.json +contracts/test_snapshots/ +contracts/proxy/test_snapshots/ +contracts/sla/test_snapshots/ +contracts/invoice/test_snapshots/ + +# Stray working files committed by mistake +*.tmp +*.bak +SubTrackr +package-fixed.json +package.json.backup +tsc_output*.txt +lint_output*.txt +lint_final_error.txt +final_lint_check.txt +test_output.txt +RACE_CONDITION_FIX.md +JS_BUNDLE_FIX.md +BUILD_FIX_GUIDE.md +BUNDLE_AUDIT.md +COMPLETION_SUMMARY.md +DESIGN_SYSTEM_IMPLEMENTATION.md +DESIGN_SYSTEM_INTEGRATION.md +DESIGN_SYSTEM_SETUP.md +FORMATTING.md +QUICK_START.md +PR_BODY_SUBSCRIPTION_ADVANCED_SEARCH.md +PR_CI_Optimizations.md +WCAG_COMPLIANCE.md +issue*.json diff --git a/app/screens/RenewalWorkspaceScreen.tsx b/app/screens/RenewalWorkspaceScreen.tsx new file mode 100644 index 00000000..8438822e --- /dev/null +++ b/app/screens/RenewalWorkspaceScreen.tsx @@ -0,0 +1,494 @@ +// Issue 560: Renewal Workspace Screen + +import React, { useState, useCallback } from 'react'; +import { + View, + Text, + StyleSheet, + ScrollView, + TouchableOpacity, + TextInput, + Alert, + ActivityIndicator, +} from 'react-native'; +import { colors, spacing } from '../../src/utils/constants'; +import { Card } from '../../src/components/common/Card'; +import { renewalService } from '../../backend/services/renewal/renewalService'; +import type { + RenewalRecord, + RenewalStatus, + WinLossReason, +} from '../../src/types/renewal'; + +const STATUS_LABELS: Record = { + pending: 'Pending', + negotiating: 'Negotiating', + awaiting_approval: 'Awaiting Approval', + awaiting_signature: 'Awaiting Signature', + signed: 'Signed', + auto_renewed: 'Auto-Renewed', + won: 'Won', + lost: 'Lost', + frozen: 'Frozen', +}; + +const STATUS_COLORS: Record = { + pending: '#F29900', + negotiating: '#1A73E8', + awaiting_approval: '#E37400', + awaiting_signature: '#9334E6', + signed: '#1E8E3E', + auto_renewed: '#1E8E3E', + won: '#1E8E3E', + lost: '#D93025', + frozen: '#5F6368', +}; + +const WIN_LOSS_REASONS: WinLossReason[] = [ + 'accepted_offer', + 'custom_terms_agreed', + 'price_too_high', + 'competitor', + 'budget_cut', + 'scope_change', + 'other', +]; + +interface RenewalWorkspaceScreenProps { + renewalId?: string; +} + +const RenewalWorkspaceScreen: React.FC = ({ renewalId }) => { + const [loading, setLoading] = useState(false); + const [renewal, setRenewal] = useState( + renewalId ? (() => { + try { return renewalService.getRenewal(renewalId); } catch { return null; } + })() : null + ); + const [demoMode, setDemoMode] = useState(!renewalId); + const [negotiationText, setNegotiationText] = useState(''); + const [discountText, setDiscountText] = useState('0'); + + const createDemo = useCallback(() => { + setLoading(true); + try { + // Seed a demo approval chain + renewalService.configureApprovalChain('demo_merchant', [ + 'sales_manager', + 'finance', + 'legal', + ]); + const endDate = Date.now() + 25 * 86_400_000; // 25 days from now + const r = renewalService.createRenewal( + 'sub_demo_001', + 'subscriber_001', + 'demo_merchant', + endDate, + 'opt_in' + ); + renewalService.generateQuote(r.id, 1200, 5, 0); + setRenewal(renewalService.getRenewal(r.id)); + setDemoMode(false); + } catch (e) { + Alert.alert('Error', String(e)); + } finally { + setLoading(false); + } + }, []); + + const refresh = useCallback(() => { + if (!renewal) return; + try { + setRenewal(renewalService.getRenewal(renewal.id)); + } catch { /* ignore */ } + }, [renewal]); + + const handleOpenNegotiation = () => { + if (!renewal) return; + try { + renewalService.openNegotiation(renewal.id, 'Standard SaaS Terms v2', 'Starting negotiation'); + refresh(); + } catch (e) { + Alert.alert('Error', String(e)); + } + }; + + const handleUpdateNegotiation = () => { + if (!renewal) return; + try { + renewalService.updateNegotiation(renewal.id, { + counterTerms: negotiationText || undefined, + agreedDiscount: parseFloat(discountText) || 0, + }); + refresh(); + Alert.alert('Updated', 'Negotiation terms updated'); + } catch (e) { + Alert.alert('Error', String(e)); + } + }; + + const handleFreeze = () => { + if (!renewal) return; + Alert.alert('Freeze Contract', 'Freeze mid-negotiation?', [ + { text: 'Cancel', style: 'cancel' }, + { + text: 'Freeze', + style: 'destructive', + onPress: () => { + try { + renewalService.freezeNegotiation(renewal.id); + refresh(); + } catch (e) { + Alert.alert('Error', String(e)); + } + }, + }, + ]); + }; + + const handleApprove = () => { + if (!renewal) return; + try { + renewalService.approveStep(renewal.id, 'current_user', 'Approved via mobile'); + refresh(); + } catch (e) { + Alert.alert('Error', String(e)); + } + }; + + const handleRequestSignature = () => { + if (!renewal) return; + try { + renewalService.requestESignature( + renewal.id, + 'docusign', + 'https://app.docusign.com/contracts/' + renewal.id + ); + refresh(); + } catch (e) { + Alert.alert('Error', String(e)); + } + }; + + const handleOutcome = (outcome: 'won' | 'lost') => { + if (!renewal) return; + const reason: WinLossReason = outcome === 'won' ? 'accepted_offer' : 'price_too_high'; + try { + renewalService.recordOutcome(renewal.id, outcome, reason); + refresh(); + } catch (e) { + Alert.alert('Error', String(e)); + } + }; + + if (loading) { + return ( + + + + ); + } + + if (demoMode || !renewal) { + return ( + + Renewal Workspace + + No active renewal selected. Create a demo to explore the workspace. + + + Create Demo Renewal + + + ); + } + + const statusColor = STATUS_COLORS[renewal.status]; + const daysLeft = Math.ceil((renewal.contractEndDate - Date.now()) / 86_400_000); + const approvalProgress = renewal.approval + ? `${renewal.approval.currentStep}/${renewal.approval.chain.length}` + : 'N/A'; + + return ( + + + Renewal Workspace + Contract ID: {renewal.subscriptionId} + + + {/* Status card */} + + + Status + + {STATUS_LABELS[renewal.status]} + + + + Type + {renewal.renewalType === 'auto' ? 'Auto-Renewal' : 'Opt-In'} + + + Days Until Expiry + + {daysLeft > 0 ? `${daysLeft} days` : 'Expired'} + + + + + {/* Milestones */} + {renewal.milestones.length > 0 && ( + + Milestones Triggered + {renewal.milestones.map((m, i) => ( + + {m.milestone.replace('_', '-')} + + {m.notificationSent ? '✓ Notified' : '⏳ Pending'} + + + ))} + + )} + + {/* Quote */} + {renewal.quote && ( + + Renewal Quote + + Base Price + ${renewal.quote.basePlanPrice.toFixed(2)} + + + Escalator + {renewal.quote.escalatorPercent}% + + + Discount + {renewal.quote.discount}% + + + Final Price + ${renewal.quote.finalPrice.toFixed(2)} + + + )} + + {/* Negotiation Workspace */} + + Negotiation Workspace + {!renewal.negotiation ? ( + + Open Negotiation + + ) : ( + <> + Proposed Terms + {renewal.negotiation.proposedTerms} + + {renewal.negotiation.frozenAt ? ( + + 🔒 Contract Frozen + + ) : ( + <> + Counter Terms + + Agreed Discount (%) + + + + Update Terms + + + Freeze + + + + )} + + )} + + + {/* Approval Workflow */} + {renewal.approval && ( + + Approval Chain + Progress: {approvalProgress} + {renewal.approval.steps.map((step, i) => ( + + + {i + 1}. {step.role.replace('_', ' ').replace(/\b\w/g, (c) => c.toUpperCase())} + + + {step.approvedAt ? '✅ Approved' : step.rejected ? '❌ Rejected' : '⏳ Pending'} + + + ))} + {renewal.status === 'negotiating' || renewal.status === 'pending' ? ( + + Approve Current Step + + ) : null} + + )} + + {/* E-Signature */} + {renewal.status === 'awaiting_signature' && !renewal.eSignature && ( + + E-Signature + + Request DocuSign + + + )} + {renewal.eSignature && ( + + E-Signature + + Provider + {renewal.eSignature.provider} + + + Status + + {renewal.eSignature.signedAt ? '✅ Signed' : '⏳ Awaiting'} + + + + )} + + {/* Win/Loss Tracking */} + {!['won', 'lost', 'auto_renewed'].includes(renewal.status) && ( + + Record Outcome + + handleOutcome('won')} + > + Mark Won + + handleOutcome('lost')} + > + Mark Lost + + + + )} + + {(renewal.status === 'won' || renewal.status === 'lost') && renewal.winLossReason && ( + + Outcome + + Result + + {STATUS_LABELS[renewal.status]} + + + + Reason + + {renewal.winLossReason.replace(/_/g, ' ').replace(/\b\w/g, (c) => c.toUpperCase())} + + + + )} + + + + ); +}; + +const styles = StyleSheet.create({ + container: { flex: 1, backgroundColor: colors.background }, + loadingContainer: { flex: 1, justifyContent: 'center', alignItems: 'center' }, + emptyContainer: { flex: 1, justifyContent: 'center', alignItems: 'center', padding: spacing.xl }, + emptyTitle: { fontSize: 24, fontWeight: 'bold', color: colors.text, marginBottom: spacing.sm }, + emptySubtitle: { fontSize: 16, color: colors.textSecondary, textAlign: 'center', marginBottom: spacing.xl }, + header: { padding: spacing.xl, paddingTop: 60, backgroundColor: colors.surface }, + title: { fontSize: 28, fontWeight: 'bold', color: colors.text }, + subtitle: { fontSize: 14, color: colors.textSecondary, marginTop: 4 }, + card: { margin: spacing.lg, padding: spacing.lg }, + row: { flexDirection: 'row', justifyContent: 'space-between', alignItems: 'center', marginBottom: spacing.sm }, + label: { fontSize: 14, color: colors.textSecondary, fontWeight: '500' }, + value: { fontSize: 14, color: colors.text, fontWeight: '600' }, + badge: { paddingHorizontal: spacing.sm, paddingVertical: 3, borderRadius: 12 }, + badgeText: { color: '#fff', fontSize: 12, fontWeight: '700' }, + sectionTitle: { fontSize: 16, fontWeight: 'bold', color: colors.text, marginBottom: spacing.md }, + milestoneRow: { flexDirection: 'row', justifyContent: 'space-between', marginBottom: spacing.xs }, + milestoneLabel: { fontSize: 14, color: colors.text, textTransform: 'capitalize' }, + milestoneStatus: { fontSize: 13, color: colors.textSecondary }, + finalPriceRow: { borderTopWidth: 1, borderTopColor: colors.border, paddingTop: spacing.sm, marginTop: spacing.sm }, + finalPriceLabel: { fontSize: 16, fontWeight: 'bold', color: colors.text }, + finalPrice: { fontSize: 20, fontWeight: 'bold', color: colors.primary }, + termsText: { fontSize: 14, color: colors.text, lineHeight: 20, marginBottom: spacing.sm }, + input: { + borderWidth: 1, + borderColor: colors.border, + borderRadius: 8, + padding: spacing.sm, + fontSize: 14, + color: colors.text, + marginBottom: spacing.sm, + minHeight: 40, + }, + buttonRow: { flexDirection: 'row', gap: spacing.sm, marginTop: spacing.sm }, + primaryButton: { + backgroundColor: colors.primary, + padding: spacing.md, + borderRadius: 8, + alignItems: 'center', + marginTop: spacing.sm, + }, + primaryButtonText: { color: '#fff', fontWeight: '700', fontSize: 14 }, + secondaryButton: { + borderWidth: 1, + borderColor: colors.primary, + padding: spacing.md, + borderRadius: 8, + alignItems: 'center', + marginTop: spacing.sm, + }, + secondaryButtonText: { color: colors.primary, fontWeight: '700', fontSize: 14 }, + dangerButton: { + backgroundColor: '#D93025', + padding: spacing.md, + borderRadius: 8, + alignItems: 'center', + marginTop: spacing.sm, + }, + dangerButtonText: { color: '#fff', fontWeight: '700', fontSize: 14 }, + frozenBanner: { + backgroundColor: '#F1F3F4', + padding: spacing.md, + borderRadius: 8, + alignItems: 'center', + marginTop: spacing.sm, + }, + frozenText: { fontSize: 16, fontWeight: 'bold', color: '#5F6368' }, + approvalStep: { flexDirection: 'row', justifyContent: 'space-between', paddingVertical: spacing.xs }, + approvalRole: { fontSize: 14, color: colors.text }, + approvalStatus: { fontSize: 13, color: colors.textSecondary }, +}); + +export default RenewalWorkspaceScreen; diff --git a/backend/services/renewal/renewalMilestoneChecker.ts b/backend/services/renewal/renewalMilestoneChecker.ts new file mode 100644 index 00000000..1d0bf669 --- /dev/null +++ b/backend/services/renewal/renewalMilestoneChecker.ts @@ -0,0 +1,69 @@ +// Issue 560: Renewal milestone checker cron job + +import type { RenewalMilestone, RenewalRecord } from '../../../src/types/renewal'; +import { renewalService } from './renewalService'; + +export interface MilestoneCheckResult { + renewalId: string; + subscriptionId: string; + milestones: RenewalMilestone[]; + autoRenewed: boolean; +} + +/** + * Checks all active renewals for pending milestones and auto-renewals. + * Intended to run on a periodic cron schedule (e.g. daily). + */ +export class RenewalMilestoneChecker { + private notifyCallback?: (renewal: RenewalRecord, milestone: RenewalMilestone) => Promise; + + onMilestoneTriggered( + cb: (renewal: RenewalRecord, milestone: RenewalMilestone) => Promise + ): void { + this.notifyCallback = cb; + } + + async run(): Promise { + const results: MilestoneCheckResult[] = []; + + // Process auto-renewals first + const autoRenewed = renewalService.processAutoRenewals(); + const autoRenewedIds = new Set(autoRenewed.map((r) => r.id)); + + // Check milestones for all active renewals + const allRenewals = renewalService.listRenewals(); + for (const renewal of allRenewals) { + if (['won', 'lost', 'auto_renewed', 'signed'].includes(renewal.status)) continue; + + const pending = renewalService.getPendingMilestones(renewal.id); + const triggered: RenewalMilestone[] = []; + + for (const milestone of pending) { + renewalService.recordMilestone(renewal.id, milestone); + triggered.push(milestone); + + if (this.notifyCallback) { + try { + await this.notifyCallback(renewal, milestone); + renewalService.markMilestoneNotified(renewal.id, milestone); + } catch (err) { + console.error(`Failed to notify milestone ${milestone} for renewal ${renewal.id}:`, err); + } + } + } + + if (triggered.length > 0 || autoRenewedIds.has(renewal.id)) { + results.push({ + renewalId: renewal.id, + subscriptionId: renewal.subscriptionId, + milestones: triggered, + autoRenewed: autoRenewedIds.has(renewal.id), + }); + } + } + + return results; + } +} + +export const renewalMilestoneChecker = new RenewalMilestoneChecker(); diff --git a/backend/services/renewal/renewalService.ts b/backend/services/renewal/renewalService.ts new file mode 100644 index 00000000..f60d5487 --- /dev/null +++ b/backend/services/renewal/renewalService.ts @@ -0,0 +1,278 @@ +// Issue 560: Contract renewal automation service + +import type { + ApprovalRole, + ApprovalWorkflow, + ESignatureRequest, + NegotiationWorkspace, + RenewalApprovalChainConfig, + RenewalMilestone, + RenewalMilestoneEvent, + RenewalQuote, + RenewalRecord, + RenewalStatus, + RenewalType, + WinLossReason, +} from '../../../src/types/renewal'; + +const now = (): number => Date.now(); + +const createId = (prefix: string): string => + `${prefix}_${now().toString(36)}_${Math.random().toString(36).slice(2, 8)}`; + +const MS_PER_DAY = 86_400_000; + +const MILESTONE_DAYS: Record = { + '90_day': 90, + '60_day': 60, + '30_day': 30, + expired: 0, +}; + +export class RenewalService { + private renewals = new Map(); + private approvalChains = new Map(); + + // Configure approval chain per merchant + configureApprovalChain(merchantId: string, chain: ApprovalRole[]): void { + this.approvalChains.set(merchantId, { merchantId, chain }); + } + + // Create a renewal record for a subscription + createRenewal( + subscriptionId: string, + subscriberId: string, + merchantId: string, + contractEndDate: number, + renewalType: RenewalType = 'auto' + ): RenewalRecord { + const id = createId('renewal'); + const chainConfig = this.approvalChains.get(merchantId); + const record: RenewalRecord = { + id, + subscriptionId, + subscriberId, + merchantId, + renewalType, + status: 'pending', + milestones: [], + contractStartDate: now(), + contractEndDate, + createdAt: now(), + updatedAt: now(), + }; + if (chainConfig) { + record.approval = { + chain: chainConfig.chain, + steps: chainConfig.chain.map((role) => ({ role })), + currentStep: 0, + }; + } + this.renewals.set(id, record); + return record; + } + + // Generate a renewal quote based on current plan + escalator + generateQuote( + renewalId: string, + basePlanPrice: number, + escalatorPercent: number, + discount = 0, + customPrice?: number + ): RenewalQuote { + const renewal = this.getRenewal(renewalId); + const escalated = basePlanPrice * (1 + escalatorPercent / 100); + const finalPrice = customPrice ?? escalated * (1 - discount / 100); + const quote: RenewalQuote = { + id: createId('quote'), + renewalId, + basePlanPrice, + escalatorPercent, + discount, + customPrice, + finalPrice: Math.max(0, finalPrice), + currency: 'USD', + generatedAt: now(), + }; + renewal.quote = quote; + renewal.updatedAt = now(); + return quote; + } + + // Open negotiation workspace + openNegotiation( + renewalId: string, + proposedTerms: string, + notes = '' + ): NegotiationWorkspace { + const renewal = this.getRenewal(renewalId); + const workspace: NegotiationWorkspace = { + proposedTerms, + agreedDiscount: 0, + notes, + }; + renewal.negotiation = workspace; + renewal.status = 'negotiating'; + renewal.updatedAt = now(); + return workspace; + } + + // Freeze contract mid-negotiation + freezeNegotiation(renewalId: string): void { + const renewal = this.getRenewal(renewalId); + if (!renewal.negotiation) throw new Error('No active negotiation'); + renewal.negotiation.frozenAt = now(); + renewal.status = 'frozen'; + renewal.updatedAt = now(); + } + + // Update negotiation terms + updateNegotiation( + renewalId: string, + updates: Partial + ): NegotiationWorkspace { + const renewal = this.getRenewal(renewalId); + if (!renewal.negotiation) throw new Error('No active negotiation'); + if (renewal.negotiation.frozenAt) throw new Error('Negotiation is frozen'); + Object.assign(renewal.negotiation, updates); + renewal.updatedAt = now(); + return renewal.negotiation; + } + + // Advance approval workflow one step + approveStep(renewalId: string, approvedBy: string, notes?: string): ApprovalWorkflow { + const renewal = this.getRenewal(renewalId); + if (!renewal.approval) throw new Error('No approval workflow configured'); + const { steps, currentStep } = renewal.approval; + if (currentStep >= steps.length) throw new Error('Approval already complete'); + + steps[currentStep].approvedBy = approvedBy; + steps[currentStep].approvedAt = now(); + steps[currentStep].notes = notes; + + const nextStep = currentStep + 1; + renewal.approval.currentStep = nextStep; + + if (nextStep >= steps.length) { + renewal.approval.completedAt = now(); + renewal.status = 'awaiting_signature'; + } else { + renewal.status = 'awaiting_approval'; + } + renewal.updatedAt = now(); + return renewal.approval; + } + + // Reject at any approval step + rejectStep(renewalId: string, rejectedBy: string, notes?: string): void { + const renewal = this.getRenewal(renewalId); + if (!renewal.approval) throw new Error('No approval workflow configured'); + const { steps, currentStep } = renewal.approval; + steps[currentStep].rejected = true; + steps[currentStep].approvedBy = rejectedBy; + steps[currentStep].notes = notes; + renewal.status = 'lost'; + renewal.updatedAt = now(); + } + + // Initiate e-signature + requestESignature( + renewalId: string, + provider: 'docusign' | 'hellosign', + documentUrl: string + ): ESignatureRequest { + const renewal = this.getRenewal(renewalId); + const sig: ESignatureRequest = { + provider, + documentUrl, + requestedAt: now(), + }; + renewal.eSignature = sig; + renewal.status = 'awaiting_signature'; + renewal.updatedAt = now(); + return sig; + } + + // Record signature completion + recordSignature(renewalId: string, signatureId: string): void { + const renewal = this.getRenewal(renewalId); + if (!renewal.eSignature) throw new Error('No e-signature request'); + renewal.eSignature.signedAt = now(); + renewal.eSignature.signatureId = signatureId; + renewal.status = 'signed'; + renewal.updatedAt = now(); + } + + // Record win/loss outcome + recordOutcome( + renewalId: string, + outcome: 'won' | 'lost', + reason: WinLossReason, + notes?: string + ): void { + const renewal = this.getRenewal(renewalId); + renewal.status = outcome; + renewal.winLossReason = reason; + renewal.winLossNotes = notes; + renewal.updatedAt = now(); + } + + // Record a milestone event + recordMilestone(renewalId: string, milestone: RenewalMilestone): RenewalMilestoneEvent { + const renewal = this.getRenewal(renewalId); + const event: RenewalMilestoneEvent = { + milestone, + triggeredAt: now(), + notificationSent: false, + }; + renewal.milestones.push(event); + renewal.updatedAt = now(); + return event; + } + + markMilestoneNotified(renewalId: string, milestone: RenewalMilestone): void { + const renewal = this.getRenewal(renewalId); + const event = renewal.milestones.find((m) => m.milestone === milestone); + if (event) event.notificationSent = true; + } + + // Get upcoming milestones for a renewal + getPendingMilestones(renewalId: string): RenewalMilestone[] { + const renewal = this.getRenewal(renewalId); + const daysUntilExpiry = (renewal.contractEndDate - now()) / MS_PER_DAY; + const triggered = new Set(renewal.milestones.map((m) => m.milestone)); + return (Object.keys(MILESTONE_DAYS) as RenewalMilestone[]).filter((m) => { + const days = MILESTONE_DAYS[m]; + return daysUntilExpiry <= days && !triggered.has(m); + }); + } + + getRenewal(renewalId: string): RenewalRecord { + const r = this.renewals.get(renewalId); + if (!r) throw new Error(`Renewal ${renewalId} not found`); + return r; + } + + listRenewals(merchantId?: string): RenewalRecord[] { + const all = Array.from(this.renewals.values()); + return merchantId ? all.filter((r) => r.merchantId === merchantId) : all; + } + + // Check which auto-renewals should fire + processAutoRenewals(): RenewalRecord[] { + const processed: RenewalRecord[] = []; + for (const renewal of this.renewals.values()) { + if (renewal.renewalType === 'auto' && renewal.status === 'pending') { + const daysLeft = (renewal.contractEndDate - now()) / MS_PER_DAY; + if (daysLeft <= 0) { + renewal.status = 'auto_renewed'; + renewal.updatedAt = now(); + processed.push(renewal); + } + } + } + return processed; + } +} + +export const renewalService = new RenewalService(); diff --git a/backend/services/upsell/recommendationService.ts b/backend/services/upsell/recommendationService.ts new file mode 100644 index 00000000..be7b173d --- /dev/null +++ b/backend/services/upsell/recommendationService.ts @@ -0,0 +1,173 @@ +// Issue 561: Upsell recommendation engine service + +import type { + ABTestVariant, + CollaborativeFilteringInput, + ConversionEvent, + ConversionFunnel, + MerchantUpsellConfig, + RecommendationItem, + RecommendationTrigger, + RecommendationType, + UpsellRecommendation, +} from '../../../src/types/upsell'; + +const now = (): number => Date.now(); +const createId = (p: string) => `${p}_${now().toString(36)}_${Math.random().toString(36).slice(2, 8)}`; + +/** Simulated plan catalogue: maps planId -> { type, price, name } */ +interface PlanMeta { + id: string; + name: string; + type: RecommendationType; + price: number; + tierRank: number; // higher = more premium +} + +export class RecommendationService { + private configs = new Map(); + private plans = new Map(); + private recommendations = new Map(); + private events: ConversionEvent[] = []; + + // ── Plan catalogue management ───────────────────────────────────────────── + + registerPlan(meta: PlanMeta): void { + this.plans.set(meta.id, meta); + } + + // ── Merchant config ─────────────────────────────────────────────────────── + + configureMerchant(config: MerchantUpsellConfig): void { + this.configs.set(config.merchantId, config); + } + + getMerchantConfig(merchantId: string): MerchantUpsellConfig | undefined { + return this.configs.get(merchantId); + } + + // ── Collaborative filtering model ───────────────────────────────────────── + + /** + * Simple collaborative filtering: score each candidate plan by how many + * similar subscribers are on it, weighted by usage proximity. + */ + private collaborativeFilteringScore( + input: CollaborativeFilteringInput, + candidatePlanId: string + ): number { + const matchCount = input.similarSubscriberPlanIds.filter((p) => p === candidatePlanId).length; + const similarityWeight = Math.min(matchCount / Math.max(input.similarSubscriberPlanIds.length, 1), 1); + // Blend with usage score: high-usage subscribers should see upgrade suggestions more strongly + return similarityWeight * 0.7 + input.usageScore * 0.3; + } + + // ── Recommendation generation ───────────────────────────────────────────── + + recommend( + subscriberId: string, + merchantId: string, + trigger: RecommendationTrigger, + input: CollaborativeFilteringInput, + currentPlanTierRank: number, + abVariant: ABTestVariant = 'recommendation' + ): UpsellRecommendation | null { + // Control variant: no recommendation + if (abVariant === 'control') { + return null; + } + + const config = this.configs.get(merchantId); + if (config && !config.enabledTriggers.includes(trigger)) { + return null; + } + + const maxItems = config?.maxItemsPerRecommendation ?? 3; + const allPlans = Array.from(this.plans.values()); + + // Edge case: subscriber already at max tier – no upgrade recommendations + const maxTier = Math.max(...allPlans.map((p) => p.tierRank)); + const candidates = allPlans.filter((plan) => { + if (plan.id === input.currentPlanId) return false; + // For upgrade_tier: only suggest strictly higher tiers + if (plan.type === 'upgrade_tier' && plan.tierRank <= currentPlanTierRank) return false; + return true; + }); + + if (candidates.length === 0) return null; + + const items: RecommendationItem[] = candidates + .map((plan) => ({ + id: createId('item'), + type: plan.type, + planId: plan.id, + planName: plan.name, + description: `Upgrade to ${plan.name}`, + price: plan.price, + currency: 'USD', + commission: config?.commissionPercent, + score: this.collaborativeFilteringScore(input, plan.id), + })) + .sort((a, b) => b.score - a.score) + .slice(0, maxItems); + + const recommendation: UpsellRecommendation = { + id: createId('rec'), + subscriberId, + merchantId, + trigger, + items, + abVariant, + createdAt: now(), + expiresAt: now() + 7 * 86_400_000, // 7-day TTL + }; + + this.recommendations.set(recommendation.id, recommendation); + return recommendation; + } + + getRecommendation(id: string): UpsellRecommendation | undefined { + return this.recommendations.get(id); + } + + // ── A/B test ────────────────────────────────────────────────────────────── + + /** Deterministically assign variant from subscriberId */ + assignABVariant(subscriberId: string): ABTestVariant { + const hash = subscriberId.split('').reduce((acc, c) => acc + c.charCodeAt(0), 0); + return hash % 2 === 0 ? 'recommendation' : 'control'; + } + + // ── Conversion tracking ─────────────────────────────────────────────────── + + trackEvent( + recommendationId: string, + itemId: string, + eventType: ConversionEvent['eventType'], + revenue?: number + ): ConversionEvent { + const event: ConversionEvent = { recommendationId, itemId, eventType, revenue, occurredAt: now() }; + this.events.push(event); + return event; + } + + getConversionFunnel(recommendationId: string): ConversionFunnel { + const evts = this.events.filter((e) => e.recommendationId === recommendationId); + return { + recommendationId, + impressions: evts.filter((e) => e.eventType === 'impression').length, + clicks: evts.filter((e) => e.eventType === 'click').length, + conversions: evts.filter((e) => e.eventType === 'conversion').length, + totalRevenue: evts + .filter((e) => e.eventType === 'conversion') + .reduce((sum, e) => sum + (e.revenue ?? 0), 0), + }; + } + + getAllFunnels(): ConversionFunnel[] { + const recIds = new Set(this.events.map((e) => e.recommendationId)); + return Array.from(recIds).map((id) => this.getConversionFunnel(id)); + } +} + +export const recommendationService = new RecommendationService(); diff --git a/src/components/upsell/RecommendationCard.tsx b/src/components/upsell/RecommendationCard.tsx new file mode 100644 index 00000000..595e76d1 --- /dev/null +++ b/src/components/upsell/RecommendationCard.tsx @@ -0,0 +1,99 @@ +// Issue 561: RecommendationCard component + +import React from 'react'; +import { View, Text, TouchableOpacity, StyleSheet } from 'react-native'; +import { colors, spacing } from '../../utils/constants'; +import type { RecommendationItem } from '../../types/upsell'; + +const TYPE_LABELS = { + upgrade_tier: '⬆ Upgrade', + add_on: '➕ Add-On', + complementary_plan: '🔗 Bundle', +}; + +interface RecommendationCardProps { + item: RecommendationItem; + onPress?: (item: RecommendationItem) => void; +} + +export const RecommendationCard: React.FC = ({ item, onPress }) => { + return ( + onPress?.(item)} + accessibilityRole="button" + accessibilityLabel={`Recommendation: ${item.planName}`}> + + {TYPE_LABELS[item.type]} + {Math.round(item.score * 100)}% match + + {item.planName} + {item.description} + + + {item.currency} {item.price.toFixed(2)}/mo + + {item.commission !== undefined && ( + {item.commission}% commission + )} + + + ); +}; + +const styles = StyleSheet.create({ + card: { + backgroundColor: colors.surface, + borderRadius: 12, + padding: spacing.md, + marginBottom: spacing.sm, + borderWidth: 1, + borderColor: colors.border, + shadowColor: '#000', + shadowOffset: { width: 0, height: 1 }, + shadowOpacity: 0.06, + shadowRadius: 3, + elevation: 2, + }, + header: { + flexDirection: 'row', + justifyContent: 'space-between', + marginBottom: spacing.xs, + }, + typeLabel: { + fontSize: 12, + fontWeight: '700', + color: colors.primary, + textTransform: 'uppercase', + }, + score: { + fontSize: 12, + color: colors.textSecondary, + }, + planName: { + fontSize: 16, + fontWeight: '700', + color: colors.text, + marginBottom: 2, + }, + description: { + fontSize: 13, + color: colors.textSecondary, + marginBottom: spacing.sm, + }, + footer: { + flexDirection: 'row', + justifyContent: 'space-between', + alignItems: 'center', + }, + price: { + fontSize: 15, + fontWeight: '700', + color: colors.text, + }, + commission: { + fontSize: 12, + color: '#1E8E3E', + fontWeight: '600', + }, +}); diff --git a/src/components/upsell/UpsellWidget.tsx b/src/components/upsell/UpsellWidget.tsx new file mode 100644 index 00000000..0529b00a --- /dev/null +++ b/src/components/upsell/UpsellWidget.tsx @@ -0,0 +1,160 @@ +// Issue 561: UpsellWidget – embeddable recommendation widget for mobile and web + +import React, { useCallback, useState } from 'react'; +import { View, Text, StyleSheet, TouchableOpacity, ActivityIndicator } from 'react-native'; +import { colors, spacing } from '../../utils/constants'; +import { RecommendationCard } from './RecommendationCard'; +import { recommendationService } from '../../../backend/services/upsell/recommendationService'; +import type { + CollaborativeFilteringInput, + RecommendationItem, + RecommendationTrigger, + UpsellRecommendation, +} from '../../types/upsell'; + +export interface UpsellWidgetProps { + subscriberId: string; + merchantId: string; + trigger: RecommendationTrigger; + currentPlanId: string; + currentPlanTierRank?: number; + similarSubscriberPlanIds?: string[]; + usageScore?: number; + onConvert?: (item: RecommendationItem) => void; +} + +export const UpsellWidget: React.FC = ({ + subscriberId, + merchantId, + trigger, + currentPlanId, + currentPlanTierRank = 1, + similarSubscriberPlanIds = [], + usageScore = 0.5, + onConvert, +}) => { + const [recommendation, setRecommendation] = useState(null); + const [loaded, setLoaded] = useState(false); + const [loading, setLoading] = useState(false); + + const load = useCallback(() => { + setLoading(true); + try { + const variant = recommendationService.assignABVariant(subscriberId); + const input: CollaborativeFilteringInput = { + subscriberId, + currentPlanId, + usageScore, + similarSubscriberPlanIds, + }; + const rec = recommendationService.recommend( + subscriberId, + merchantId, + trigger, + input, + currentPlanTierRank, + variant + ); + setRecommendation(rec); + if (rec) { + // Track impression for each item + rec.items.forEach((item) => + recommendationService.trackEvent(rec.id, item.id, 'impression') + ); + } + } finally { + setLoaded(true); + setLoading(false); + } + }, [ + subscriberId, + merchantId, + trigger, + currentPlanId, + currentPlanTierRank, + similarSubscriberPlanIds, + usageScore, + ]); + + const handleCardPress = (item: RecommendationItem) => { + if (!recommendation) return; + recommendationService.trackEvent(recommendation.id, item.id, 'click'); + onConvert?.(item); + recommendationService.trackEvent(recommendation.id, item.id, 'conversion', item.price); + }; + + if (!loaded) { + return ( + + See Recommended Upgrades + + ); + } + + if (loading) { + return ( + + + + ); + } + + if (!recommendation || recommendation.items.length === 0) { + return null; // No recommendations (control variant or max tier) + } + + return ( + + Recommended for You + {TRIGGER_LABELS[trigger]} + {recommendation.items.map((item) => ( + + ))} + + ); +}; + +const TRIGGER_LABELS: Record = { + checkout: 'Complete your setup with these add-ons', + usage_threshold: "You're growing fast — consider upgrading", + renewal_window: 'Maximize value at renewal time', + support_request: 'These plans could prevent future issues', +}; + +const styles = StyleSheet.create({ + container: { + paddingVertical: spacing.md, + }, + heading: { + fontSize: 18, + fontWeight: '700', + color: colors.text, + marginBottom: 2, + }, + subheading: { + fontSize: 13, + color: colors.textSecondary, + marginBottom: spacing.md, + }, + loadTrigger: { + backgroundColor: colors.surface, + borderWidth: 1, + borderColor: colors.primary, + borderRadius: 8, + padding: spacing.md, + alignItems: 'center', + marginVertical: spacing.sm, + }, + loadTriggerText: { + color: colors.primary, + fontWeight: '700', + fontSize: 14, + }, +}); diff --git a/src/components/upsell/index.ts b/src/components/upsell/index.ts new file mode 100644 index 00000000..94120aaa --- /dev/null +++ b/src/components/upsell/index.ts @@ -0,0 +1,3 @@ +export { RecommendationCard } from './RecommendationCard'; +export { UpsellWidget } from './UpsellWidget'; +export type { UpsellWidgetProps } from './UpsellWidget'; diff --git a/src/navigation/AppNavigator.tsx b/src/navigation/AppNavigator.tsx index 0f32b2ee..42d96431 100644 --- a/src/navigation/AppNavigator.tsx +++ b/src/navigation/AppNavigator.tsx @@ -77,6 +77,9 @@ const PaymentMethodsScreen = lazyScreen(() => })) ); const AnalyticsDashboard = lazyScreen(() => import('../../app/screens/AnalyticsDashboard')); +const RenewalWorkspaceScreen = lazyScreen(() => + import('../../app/screens/RenewalWorkspaceScreen').then((m) => ({ default: m.default })) +); const Tab = createBottomTabNavigator(); const Stack = createNativeStackNavigator(); @@ -199,6 +202,11 @@ const HomeStack = () => ( component={IntegrationGuidesScreen} options={{ headerShown: false }} /> + ); diff --git a/src/navigation/types.ts b/src/navigation/types.ts index 10bf40b0..4bbdeced 100644 --- a/src/navigation/types.ts +++ b/src/navigation/types.ts @@ -48,6 +48,7 @@ export type RootStackParamList = { ChangePlan: { subscriptionId: string }; PaymentMethods: undefined; AnalyticsDashboard: undefined; + RenewalWorkspace: { renewalId?: string } | undefined; NotFound: { reason?: string }; }; diff --git a/src/types/renewal.ts b/src/types/renewal.ts new file mode 100644 index 00000000..a168e616 --- /dev/null +++ b/src/types/renewal.ts @@ -0,0 +1,103 @@ +// Issue 560: Contract renewal automation types + +export type RenewalMilestone = '90_day' | '60_day' | '30_day' | 'expired'; + +export type RenewalStatus = + | 'pending' + | 'negotiating' + | 'awaiting_approval' + | 'awaiting_signature' + | 'signed' + | 'auto_renewed' + | 'won' + | 'lost' + | 'frozen'; + +export type ApprovalRole = 'sales_manager' | 'finance' | 'legal'; + +export type RenewalType = 'auto' | 'opt_in'; + +export type WinLossReason = + | 'price_too_high' + | 'competitor' + | 'budget_cut' + | 'scope_change' + | 'accepted_offer' + | 'custom_terms_agreed' + | 'other'; + +export interface RenewalMilestoneEvent { + milestone: RenewalMilestone; + triggeredAt: number; + notificationSent: boolean; +} + +export interface RenewalQuote { + id: string; + renewalId: string; + basePlanPrice: number; + escalatorPercent: number; + discount: number; + customPrice?: number; + finalPrice: number; + currency: string; + generatedAt: number; + terms?: string; +} + +export interface ApprovalStep { + role: ApprovalRole; + approvedBy?: string; + approvedAt?: number; + rejected?: boolean; + notes?: string; +} + +export interface ApprovalWorkflow { + chain: ApprovalRole[]; + steps: ApprovalStep[]; + currentStep: number; + completedAt?: number; +} + +export interface ESignatureRequest { + provider: 'docusign' | 'hellosign'; + documentUrl: string; + requestedAt: number; + signedAt?: number; + signatureId?: string; +} + +export interface NegotiationWorkspace { + proposedTerms: string; + counterTerms?: string; + agreedDiscount: number; + customPricing?: number; + frozenAt?: number; // mid-negotiation contract freeze + notes: string; +} + +export interface RenewalRecord { + id: string; + subscriptionId: string; + subscriberId: string; + merchantId: string; + renewalType: RenewalType; + status: RenewalStatus; + milestones: RenewalMilestoneEvent[]; + quote?: RenewalQuote; + negotiation?: NegotiationWorkspace; + approval?: ApprovalWorkflow; + eSignature?: ESignatureRequest; + winLossReason?: WinLossReason; + winLossNotes?: string; + contractStartDate: number; + contractEndDate: number; + createdAt: number; + updatedAt: number; +} + +export interface RenewalApprovalChainConfig { + merchantId: string; + chain: ApprovalRole[]; +} diff --git a/src/types/upsell.ts b/src/types/upsell.ts new file mode 100644 index 00000000..1083bbb1 --- /dev/null +++ b/src/types/upsell.ts @@ -0,0 +1,64 @@ +// Issue 561: Upsell/recommendation engine types + +export type RecommendationTrigger = + | 'checkout' + | 'usage_threshold' + | 'renewal_window' + | 'support_request'; + +export type RecommendationType = 'upgrade_tier' | 'add_on' | 'complementary_plan'; + +export type ABTestVariant = 'recommendation' | 'control'; + +export interface RecommendationItem { + id: string; + type: RecommendationType; + planId: string; + planName: string; + description: string; + price: number; + currency: string; + commission?: number; // merchant-set commission % + score: number; // 0–1 relevance score from model +} + +export interface UpsellRecommendation { + id: string; + subscriberId: string; + merchantId: string; + trigger: RecommendationTrigger; + items: RecommendationItem[]; + abVariant: ABTestVariant; + createdAt: number; + expiresAt?: number; +} + +export interface ConversionEvent { + recommendationId: string; + itemId: string; + eventType: 'impression' | 'click' | 'conversion'; + revenue?: number; + occurredAt: number; +} + +export interface ConversionFunnel { + recommendationId: string; + impressions: number; + clicks: number; + conversions: number; + totalRevenue: number; +} + +export interface MerchantUpsellConfig { + merchantId: string; + enabledTriggers: RecommendationTrigger[]; + commissionPercent: number; + maxItemsPerRecommendation: number; +} + +export interface CollaborativeFilteringInput { + subscriberId: string; + currentPlanId: string; + usageScore: number; // 0–1 normalised usage level + similarSubscriberPlanIds: string[]; // plans bought by similar users +}