From b5aed31ff1ccbd8d2748cf55f966188b9af7a05c Mon Sep 17 00:00:00 2001 From: kingsrichie79-cmyk Date: Tue, 20 Oct 2026 23:46:29 +0100 Subject: [PATCH] feat: implement flexible recurring payment plans with schedule engine and analytics --- .../RecurringPaymentPlanManager.tsx | 827 ++++++++++++++++++ src/hooks/useRecurringPaymentPlans.ts | 257 ++++++ .../__tests__/amountEngine.test.ts | 275 ++++++ .../__tests__/analyticsEngine.test.ts | 211 +++++ .../__tests__/integration.test.ts | 157 ++++ .../__tests__/modificationEngine.test.ts | 181 ++++ .../__tests__/pauseResumeEngine.test.ts | 173 ++++ .../__tests__/scheduleEngine.test.ts | 288 ++++++ src/lib/recurringPaymentPlan/amountEngine.ts | 244 ++++++ .../recurringPaymentPlan/analyticsEngine.ts | 228 +++++ src/lib/recurringPaymentPlan/index.ts | 6 + .../modificationEngine.ts | 219 +++++ .../recurringPaymentPlan/pauseResumeEngine.ts | 175 ++++ .../recurringPaymentPlan/scheduleEngine.ts | 360 ++++++++ src/types/recurringPaymentPlan.ts | 206 +++++ 15 files changed, 3807 insertions(+) create mode 100644 src/components/RecurringPaymentPlanManager.tsx create mode 100644 src/hooks/useRecurringPaymentPlans.ts create mode 100644 src/lib/recurringPaymentPlan/__tests__/amountEngine.test.ts create mode 100644 src/lib/recurringPaymentPlan/__tests__/analyticsEngine.test.ts create mode 100644 src/lib/recurringPaymentPlan/__tests__/integration.test.ts create mode 100644 src/lib/recurringPaymentPlan/__tests__/modificationEngine.test.ts create mode 100644 src/lib/recurringPaymentPlan/__tests__/pauseResumeEngine.test.ts create mode 100644 src/lib/recurringPaymentPlan/__tests__/scheduleEngine.test.ts create mode 100644 src/lib/recurringPaymentPlan/amountEngine.ts create mode 100644 src/lib/recurringPaymentPlan/analyticsEngine.ts create mode 100644 src/lib/recurringPaymentPlan/index.ts create mode 100644 src/lib/recurringPaymentPlan/modificationEngine.ts create mode 100644 src/lib/recurringPaymentPlan/pauseResumeEngine.ts create mode 100644 src/lib/recurringPaymentPlan/scheduleEngine.ts create mode 100644 src/types/recurringPaymentPlan.ts diff --git a/src/components/RecurringPaymentPlanManager.tsx b/src/components/RecurringPaymentPlanManager.tsx new file mode 100644 index 00000000..2a76cfad --- /dev/null +++ b/src/components/RecurringPaymentPlanManager.tsx @@ -0,0 +1,827 @@ +'use client'; + +import React, { useState } from 'react'; +import type { + RecurringPaymentPlan, + PlanFrequency, + AmountVariationType, + ResumeSchedulePolicy, + PlanModificationInput, +} from '@/types/recurringPaymentPlan'; +import { useRecurringPaymentPlans } from '@/hooks/useRecurringPaymentPlans'; +import { generateUpcomingSchedule, roundAmount } from '@/lib/recurringPaymentPlan'; + +export default function RecurringPaymentPlanManager() { + const { + plans, + isLoaded, + createPlan, + modifyPlan, + pausePlan, + resumePlan, + cancelPlan, + executePlanPayment, + catchUpPlan, + getPlanAnalytics, + portfolioAnalytics, + } = useRecurringPaymentPlans(); + + const [selectedPlanId, setSelectedPlanId] = useState(null); + const [filterStatus, setFilterStatus] = useState('all'); + const [showCreateModal, setShowCreateModal] = useState(false); + const [showModifyModal, setShowModifyModal] = useState(false); + const [resumePolicy, setResumePolicy] = useState('catch_up'); + + // Form states for Create Plan + const [newTitle, setNewTitle] = useState(''); + const [newRecipient, setNewRecipient] = useState(''); + const [newPayer, setNewPayer] = useState(''); + const [newFrequency, setNewFrequency] = useState('monthly'); + const [newCustomDays, setNewCustomDays] = useState(30); + const [newIntervalCount, setNewIntervalCount] = useState(1); + const [newAmountType, setNewAmountType] = useState('fixed'); + const [newBaseAmount, setNewBaseAmount] = useState(100); + const [newCurrency, setNewCurrency] = useState('USDC'); + const [newMaxCycles, setNewMaxCycles] = useState(undefined); + const [newPercentageChange, setNewPercentageChange] = useState(5); + const [newStepFrequency, setNewStepFrequency] = useState(2); + + // Form states for Modify Plan + const [modTitle, setModTitle] = useState(''); + const [modBaseAmount, setModBaseAmount] = useState(100); + const [modMaxCycles, setModMaxCycles] = useState(undefined); + const [modReason, setModReason] = useState(''); + + const selectedPlan = plans.find((p) => p.id === selectedPlanId); + const selectedPlanAnalytics = selectedPlan ? getPlanAnalytics(selectedPlan.id) : null; + const upcomingSchedule = selectedPlan ? generateUpcomingSchedule(selectedPlan, 5) : []; + + const filteredPlans = plans.filter((p) => { + if (filterStatus === 'all') return true; + return p.status === filterStatus; + }); + + const handleCreate = (e: React.FormEvent) => { + e.preventDefault(); + if (!newTitle || !newRecipient) return; + + let amountConfig: any = { + type: newAmountType, + baseAmount: Number(newBaseAmount), + currency: newCurrency, + }; + + if (newAmountType === 'percentage_step') { + amountConfig.percentageStep = { + percentageChange: Number(newPercentageChange), + stepFrequencyCycles: Number(newStepFrequency), + compound: true, + }; + } else if (newAmountType === 'tiered') { + amountConfig.tieredAmounts = [ + { upToCycle: 2, amount: Number(newBaseAmount) }, + { upToCycle: 5, amount: roundAmount(Number(newBaseAmount) * 1.2) }, + { upToCycle: 10, amount: roundAmount(Number(newBaseAmount) * 1.5) }, + ]; + } else if (newAmountType === 'scheduled') { + amountConfig.scheduledAmounts = [ + { cycle: 1, amount: Number(newBaseAmount) }, + { cycle: 2, amount: roundAmount(Number(newBaseAmount) * 1.1) }, + { cycle: 3, amount: roundAmount(Number(newBaseAmount) * 1.25) }, + ]; + } + + createPlan({ + title: newTitle, + recipientAddress: newRecipient, + payerAddress: newPayer || 'G...PayerSelf', + startDate: Date.now(), + maxCycles: newMaxCycles ? Number(newMaxCycles) : null, + intervalConfig: { + frequency: newFrequency, + intervalCount: Number(newIntervalCount), + customIntervalDays: newFrequency === 'custom' ? Number(newCustomDays) : undefined, + }, + amountConfig, + }); + + setShowCreateModal(false); + // Reset inputs + setNewTitle(''); + setNewRecipient(''); + }; + + const handleOpenModify = (plan: RecurringPaymentPlan) => { + setModTitle(plan.title); + setModBaseAmount(plan.amountConfig.baseAmount); + setModMaxCycles(plan.maxCycles ?? undefined); + setModReason(''); + setShowModifyModal(true); + }; + + const handleApplyModify = (e: React.FormEvent) => { + e.preventDefault(); + if (!selectedPlan) return; + + const updates: PlanModificationInput = { + title: modTitle, + amountConfig: { + ...selectedPlan.amountConfig, + baseAmount: Number(modBaseAmount), + }, + maxCycles: modMaxCycles ? Number(modMaxCycles) : null, + }; + + modifyPlan(selectedPlan.id, updates, modReason || 'User plan adjustment'); + setShowModifyModal(false); + }; + + if (!isLoaded) { + return ( +
+ Loading recurring payment plans... +
+ ); + } + + return ( +
+ {/* Header */} +
+
+

+ Flexible Recurring Payment Plans +

+

+ Create and manage recurring plans with customizable intervals, amounts, and pause controls. +

+
+ +
+ + {/* Portfolio Analytics Summary Cards */} +
+
+ + Active Plans + +
+ + {portfolioAnalytics.activePlans} + + + of {portfolioAnalytics.totalPlans} total + +
+
+ +
+ + Total Collected + +
+ + {portfolioAnalytics.totalVolumeCollected.toLocaleString()} + + + +{portfolioAnalytics.totalProjectedVolume.toLocaleString()} proj. + +
+
+ +
+ + On-Time Rate + +
+ + {portfolioAnalytics.overallOnTimeRate}% + + + {portfolioAnalytics.successfulExecutionsCount} execs + +
+
+ +
+ + Missed Payments + +
+ 0 + ? 'text-rose-400' + : 'text-gray-300' + }`} + > + {portfolioAnalytics.totalMissedPayments} + + + {portfolioAnalytics.totalMissedPayments === 0 ? 'All caught up' : 'Needs attention'} + +
+
+
+ + {/* Main Grid: Plans List + Selected Plan Detail */} +
+ {/* Left Column: Plan List */} +
+
+

Your Plans

+ +
+ +
+ {filteredPlans.length === 0 ? ( +

+ No plans found matching filter. +

+ ) : ( + filteredPlans.map((plan) => ( +
setSelectedPlanId(plan.id)} + className={`p-3 rounded-lg border cursor-pointer transition ${ + selectedPlanId === plan.id + ? 'bg-gray-800 border-indigo-500' + : 'bg-gray-800/40 border-gray-800 hover:border-gray-700' + }`} + > +
+ + {plan.title} + + + {plan.status} + +
+ +
+ + {plan.intervalConfig.frequency}{' '} + {plan.intervalConfig.intervalCount > 1 + ? `(every ${plan.intervalConfig.intervalCount})` + : ''} + + + {plan.amountConfig.baseAmount} {plan.amountConfig.currency} ( + {plan.amountConfig.type}) + +
+ +
+ Cycle: {plan.currentCycle} + + {plan.executions.length} payments recorded + +
+
+ )) + )} +
+
+ + {/* Right Column: Plan Detail */} +
+ {!selectedPlan ? ( +
+ Select a payment plan on the left to view details, execution schedule, pause controls, and analytics. +
+ ) : ( +
+ {/* Plan Title & Actions Bar */} +
+
+
+

+ {selectedPlan.title} +

+ + {selectedPlan.status} + +
+

+ Recipient: {selectedPlan.recipientAddress} +

+
+ + {/* Action Buttons */} +
+ {selectedPlan.status === 'active' && ( + + )} + + {selectedPlan.status === 'paused' && ( +
+ + +
+ )} + + {selectedPlan.status !== 'completed' && selectedPlan.status !== 'cancelled' && ( + <> + + + + )} +
+
+ + {/* Execution / Schedule Action Controls */} + {selectedPlan.status === 'active' && ( +
+
+ Next Scheduled Execution: +

+ {selectedPlan.nextExecutionDate + ? new Date(selectedPlan.nextExecutionDate).toLocaleDateString() + ' ' + new Date(selectedPlan.nextExecutionDate).toLocaleTimeString() + : 'None / Completed'} +

+
+
+ + +
+
+ )} + + {/* Single Plan Analytics Cards */} + {selectedPlanAnalytics && ( +
+
+ Paid Volume +

+ {selectedPlanAnalytics.totalAmountPaid} {selectedPlan.amountConfig.currency} +

+
+
+ Avg Amount +

+ {selectedPlanAnalytics.averagePaymentAmount} {selectedPlan.amountConfig.currency} +

+
+
+ Cycles +

+ {selectedPlanAnalytics.completedCycles} + {selectedPlanAnalytics.totalCyclesPlanned ? ` / ${selectedPlanAnalytics.totalCyclesPlanned}` : ''} +

+
+
+ Times Paused +

+ {selectedPlanAnalytics.timesPaused} +

+
+
+ )} + + {/* Upcoming Payment Schedule */} +
+

+ Upcoming Scheduled Payments (with Amount Variations) +

+ {upcomingSchedule.length === 0 ? ( +

No upcoming payments scheduled.

+ ) : ( +
+ {upcomingSchedule.map((item) => ( +
+
+ + Cycle {item.cycle} + + + {new Date(item.scheduledDate).toLocaleDateString()} + +
+
+ + {item.amount} {item.currency} + + + {item.status} + +
+
+ ))} +
+ )} +
+ + {/* Execution History */} +
+

+ Execution History ({selectedPlan.executions.length}) +

+ {selectedPlan.executions.length === 0 ? ( +

No executions recorded yet.

+ ) : ( +
+ {selectedPlan.executions.map((e) => ( +
+
+ Cycle {e.cycle} + + {new Date(e.executedDate).toLocaleString()} + +
+
+ + {e.amount} {e.currency} + + + {e.status} + +
+
+ ))} +
+ )} +
+ + {/* Modifications Audit Trail */} + {selectedPlan.modifications.length > 0 && ( +
+

+ Audit Trail ({selectedPlan.modifications.length} modifications) +

+
+ {selectedPlan.modifications.map((m) => ( +
+
+ {m.field} + {new Date(m.timestamp).toLocaleTimeString()} +
+

{m.reason}

+
+ ))} +
+
+ )} +
+ )} +
+
+ + {/* Create Plan Modal */} + {showCreateModal && ( +
+
+
+

Create Recurring Payment Plan

+ +
+ +
+
+ + setNewTitle(e.target.value)} + placeholder="e.g. Creator Retainer, Monthly Subscription" + className="w-full bg-gray-800 border border-gray-700 rounded-lg px-3 py-2 text-sm text-white" + /> +
+ +
+ + setNewRecipient(e.target.value)} + placeholder="G..." + className="w-full bg-gray-800 border border-gray-700 rounded-lg px-3 py-2 text-sm text-white font-mono" + /> +
+ +
+
+ + +
+ + {newFrequency === 'custom' ? ( +
+ + setNewCustomDays(Number(e.target.value))} + className="w-full bg-gray-800 border border-gray-700 rounded-lg px-3 py-2 text-sm text-white" + /> +
+ ) : ( +
+ + setNewIntervalCount(Number(e.target.value))} + className="w-full bg-gray-800 border border-gray-700 rounded-lg px-3 py-2 text-sm text-white" + /> +
+ )} +
+ +
+
+ + +
+ +
+ + setNewBaseAmount(Number(e.target.value))} + className="w-full bg-gray-800 border border-gray-700 rounded-lg px-3 py-2 text-sm text-white" + /> +
+
+ + {newAmountType === 'percentage_step' && ( +
+
+ + setNewPercentageChange(Number(e.target.value))} + className="w-full bg-gray-800 border border-gray-700 rounded px-2 py-1 text-sm text-white" + /> +
+
+ + setNewStepFrequency(Number(e.target.value))} + className="w-full bg-gray-800 border border-gray-700 rounded px-2 py-1 text-sm text-white" + /> +
+
+ )} + +
+ + setNewMaxCycles(e.target.value ? Number(e.target.value) : undefined)} + placeholder="Infinite if left blank" + className="w-full bg-gray-800 border border-gray-700 rounded-lg px-3 py-2 text-sm text-white" + /> +
+ +
+ + +
+
+
+
+ )} + + {/* Modify Plan Modal */} + {showModifyModal && ( +
+
+
+

Modify Plan Safely

+ +
+ +
+
+ + setModTitle(e.target.value)} + className="w-full bg-gray-800 border border-gray-700 rounded-lg px-3 py-2 text-sm text-white" + /> +
+ +
+ + setModBaseAmount(Number(e.target.value))} + className="w-full bg-gray-800 border border-gray-700 rounded-lg px-3 py-2 text-sm text-white" + /> +
+ +
+ + setModMaxCycles(e.target.value ? Number(e.target.value) : undefined)} + className="w-full bg-gray-800 border border-gray-700 rounded-lg px-3 py-2 text-sm text-white" + /> +
+ +
+ + setModReason(e.target.value)} + placeholder="e.g. Rate adjustment agreed with recipient" + className="w-full bg-gray-800 border border-gray-700 rounded-lg px-3 py-2 text-sm text-white" + /> +
+ +
+ + +
+
+
+
+ )} +
+ ); +} diff --git a/src/hooks/useRecurringPaymentPlans.ts b/src/hooks/useRecurringPaymentPlans.ts new file mode 100644 index 00000000..41fbf2c5 --- /dev/null +++ b/src/hooks/useRecurringPaymentPlans.ts @@ -0,0 +1,257 @@ +'use client'; + +import { useState, useEffect, useCallback, useMemo } from 'react'; +import type { + RecurringPaymentPlan, + PlanModificationInput, + ResumeSchedulePolicy, + SinglePlanAnalytics, + PortfolioAnalytics, +} from '@/types/recurringPaymentPlan'; +import { + calculateCycleAmount, + executePayment, + catchUpDuePayments, + getDueExecutions, + modifyPlan as modifyPlanLogic, + pausePlan as pausePlanLogic, + resumePlan as resumePlanLogic, + calculatePlanAnalytics, + calculatePortfolioAnalytics, +} from '@/lib/recurringPaymentPlan'; + +const STORAGE_KEY = 'stellar_split_recurring_payment_plans'; + +export function useRecurringPaymentPlans() { + const [plans, setPlans] = useState([]); + const [isLoaded, setIsLoaded] = useState(false); + + // Load from localStorage on mount + useEffect(() => { + try { + const stored = localStorage.getItem(STORAGE_KEY); + if (stored) { + setPlans(JSON.parse(stored)); + } + } catch (err) { + console.error('Failed to load recurring payment plans from localStorage', err); + } finally { + setIsLoaded(true); + } + }, []); + + // Save to localStorage when plans change + const savePlans = useCallback((updated: RecurringPaymentPlan[]) => { + setPlans(updated); + try { + localStorage.setItem(STORAGE_KEY, JSON.stringify(updated)); + } catch (err) { + console.error('Failed to persist recurring payment plans', err); + } + }, []); + + const createPlan = useCallback( + ( + newPlanData: Omit< + RecurringPaymentPlan, + | 'id' + | 'currentCycle' + | 'status' + | 'executions' + | 'modifications' + | 'pauseHistory' + | 'nextExecutionDate' + | 'lastExecutionDate' + | 'createdAt' + | 'updatedAt' + > + ): RecurringPaymentPlan => { + const now = Date.now(); + const plan: RecurringPaymentPlan = { + ...newPlanData, + id: `plan_${now}_${Math.random().toString(16).substring(2, 8)}`, + status: 'active', + currentCycle: 0, + nextExecutionDate: newPlanData.startDate, + lastExecutionDate: null, + executions: [], + modifications: [], + pauseHistory: [], + createdAt: now, + updatedAt: now, + }; + + const updated = [plan, ...plans]; + savePlans(updated); + return plan; + }, + [plans, savePlans] + ); + + const modifyPlan = useCallback( + (planId: string, updates: PlanModificationInput, reason?: string) => { + const index = plans.findIndex((p) => p.id === planId); + if (index === -1) { + return { success: false, error: 'Plan not found' }; + } + + const result = modifyPlanLogic(plans[index], updates, { reason }); + if (result.success) { + const nextPlans = [...plans]; + nextPlans[index] = result.plan; + savePlans(nextPlans); + } + return result; + }, + [plans, savePlans] + ); + + const pausePlan = useCallback( + (planId: string, reason?: string) => { + const index = plans.findIndex((p) => p.id === planId); + if (index === -1) { + return { success: false, error: 'Plan not found' }; + } + + const result = pausePlanLogic(plans[index], Date.now(), reason); + if (result.success) { + const nextPlans = [...plans]; + nextPlans[index] = result.plan; + savePlans(nextPlans); + } + return result; + }, + [plans, savePlans] + ); + + const resumePlan = useCallback( + (planId: string, policy: ResumeSchedulePolicy = 'catch_up') => { + const index = plans.findIndex((p) => p.id === planId); + if (index === -1) { + return { success: false, error: 'Plan not found' }; + } + + const result = resumePlanLogic(plans[index], Date.now(), policy); + if (result.success) { + const nextPlans = [...plans]; + nextPlans[index] = result.plan; + savePlans(nextPlans); + } + return result; + }, + [plans, savePlans] + ); + + const cancelPlan = useCallback( + (planId: string, reason: string = 'User cancelled plan') => { + const index = plans.findIndex((p) => p.id === planId); + if (index === -1) { + return { success: false, error: 'Plan not found' }; + } + + const plan = plans[index]; + if (plan.status === 'completed' || plan.status === 'cancelled') { + return { success: false, error: `Cannot cancel a plan that is '${plan.status}'` }; + } + + const now = Date.now(); + const cancelledPlan: RecurringPaymentPlan = { + ...plan, + status: 'cancelled', + nextExecutionDate: null, + updatedAt: now, + modifications: [ + ...plan.modifications, + { + id: `mod_cancel_${now}`, + timestamp: now, + field: 'status', + oldValue: plan.status, + newValue: 'cancelled', + effectiveFromCycle: plan.currentCycle + 1, + reason, + }, + ], + }; + + const nextPlans = [...plans]; + nextPlans[index] = cancelledPlan; + savePlans(nextPlans); + return { success: true, plan: cancelledPlan }; + }, + [plans, savePlans] + ); + + const executePlanPayment = useCallback( + (planId: string, txDetails?: { txHash?: string; note?: string }) => { + const index = plans.findIndex((p) => p.id === planId); + if (index === -1) { + return { success: false, error: 'Plan not found' }; + } + + const result = executePayment(plans[index], Date.now(), txDetails); + if (result.success) { + const nextPlans = [...plans]; + nextPlans[index] = result.plan; + savePlans(nextPlans); + } + return result; + }, + [plans, savePlans] + ); + + const catchUpPlan = useCallback( + async (planId: string) => { + const index = plans.findIndex((p) => p.id === planId); + if (index === -1) { + return { success: false, error: 'Plan not found' }; + } + + const result = await catchUpDuePayments(plans[index], Date.now()); + if (result.executedCount > 0) { + const nextPlans = [...plans]; + nextPlans[index] = result.plan; + savePlans(nextPlans); + } + return result; + }, + [plans, savePlans] + ); + + const getPlanDueExecutions = useCallback( + (planId: string) => { + const plan = plans.find((p) => p.id === planId); + if (!plan) return []; + return getDueExecutions(plan, Date.now()); + }, + [plans] + ); + + const getPlanAnalytics = useCallback( + (planId: string): SinglePlanAnalytics | null => { + const plan = plans.find((p) => p.id === planId); + if (!plan) return null; + return calculatePlanAnalytics(plan, Date.now()); + }, + [plans] + ); + + const portfolioAnalytics: PortfolioAnalytics = useMemo(() => { + return calculatePortfolioAnalytics(plans, { currentTime: Date.now() }); + }, [plans]); + + return { + plans, + isLoaded, + createPlan, + modifyPlan, + pausePlan, + resumePlan, + cancelPlan, + executePlanPayment, + catchUpPlan, + getPlanDueExecutions, + getPlanAnalytics, + portfolioAnalytics, + }; +} diff --git a/src/lib/recurringPaymentPlan/__tests__/amountEngine.test.ts b/src/lib/recurringPaymentPlan/__tests__/amountEngine.test.ts new file mode 100644 index 00000000..c57f8ce1 --- /dev/null +++ b/src/lib/recurringPaymentPlan/__tests__/amountEngine.test.ts @@ -0,0 +1,275 @@ +import { describe, it, expect } from 'vitest'; +import { + calculateCycleAmount, + roundAmount, + validateAmountConfig, + projectTotalAmount, +} from '../amountEngine'; +import type { PlanAmountConfig } from '@/types/recurringPaymentPlan'; + +describe('Amount Variation Engine', () => { + describe('roundAmount', () => { + it('correctly rounds to specified decimal places', () => { + expect(roundAmount(10.123456789, 7)).toBe(10.1234568); + expect(roundAmount(10.555, 2)).toBe(10.56); + expect(roundAmount(NaN)).toBe(0); + expect(roundAmount(Infinity)).toBe(0); + }); + }); + + describe('validateAmountConfig', () => { + it('validates fixed amount configuration', () => { + const config: PlanAmountConfig = { + type: 'fixed', + baseAmount: 100, + currency: 'USDC', + }; + const res = validateAmountConfig(config); + expect(res.valid).toBe(true); + expect(res.errors).toHaveLength(0); + }); + + it('rejects negative baseAmount and invalid currency', () => { + const config: PlanAmountConfig = { + type: 'fixed', + baseAmount: -50, + currency: '', + }; + const res = validateAmountConfig(config); + expect(res.valid).toBe(false); + expect(res.errors).toContain('baseAmount must be a non-negative number'); + expect(res.errors).toContain('currency is required'); + }); + + it('rejects minimumAmount exceeding maximumAmount', () => { + const config: PlanAmountConfig = { + type: 'fixed', + baseAmount: 100, + currency: 'USDC', + minimumAmount: 200, + maximumAmount: 150, + }; + const res = validateAmountConfig(config); + expect(res.valid).toBe(false); + expect(res.errors).toContain('minimumAmount cannot exceed maximumAmount'); + }); + + it('validates scheduled amount variations', () => { + const config: PlanAmountConfig = { + type: 'scheduled', + baseAmount: 100, + currency: 'USDC', + scheduledAmounts: [ + { cycle: 1, amount: 100 }, + { cycle: 2, amount: 150 }, + ], + }; + expect(validateAmountConfig(config).valid).toBe(true); + + const invalid: PlanAmountConfig = { + type: 'scheduled', + baseAmount: 100, + currency: 'USDC', + scheduledAmounts: [{ cycle: 0, amount: -10 }], + }; + const invRes = validateAmountConfig(invalid); + expect(invRes.valid).toBe(false); + expect(invRes.errors).toContain('Cycle must be >= 1 (got 0)'); + expect(invRes.errors).toContain('Amount for cycle 0 cannot be negative'); + }); + + it('validates tiered amount variations', () => { + const config: PlanAmountConfig = { + type: 'tiered', + baseAmount: 100, + currency: 'USDC', + tieredAmounts: [ + { upToCycle: 3, amount: 80 }, + { upToCycle: 6, amount: 120 }, + ], + }; + expect(validateAmountConfig(config).valid).toBe(true); + }); + + it('validates percentage_step variations', () => { + const config: PlanAmountConfig = { + type: 'percentage_step', + baseAmount: 100, + currency: 'USDC', + percentageStep: { + percentageChange: 10, + stepFrequencyCycles: 2, + }, + }; + expect(validateAmountConfig(config).valid).toBe(true); + + const invalid: PlanAmountConfig = { + type: 'percentage_step', + baseAmount: 100, + currency: 'USDC', + percentageStep: { + percentageChange: 10, + stepFrequencyCycles: 0, + }, + }; + expect(validateAmountConfig(invalid).valid).toBe(false); + }); + }); + + describe('calculateCycleAmount', () => { + it('throws error for cycle index < 1', () => { + const config: PlanAmountConfig = { type: 'fixed', baseAmount: 100, currency: 'USDC' }; + expect(() => calculateCycleAmount(config, 0)).toThrow('Cycle index must be positive'); + }); + + it('returns fixed baseAmount for all cycles in fixed mode', () => { + const config: PlanAmountConfig = { type: 'fixed', baseAmount: 125.5, currency: 'USDC' }; + expect(calculateCycleAmount(config, 1)).toBe(125.5); + expect(calculateCycleAmount(config, 5)).toBe(125.5); + expect(calculateCycleAmount(config, 20)).toBe(125.5); + }); + + it('returns exact scheduled amounts and extrapolates beyond highest cycle', () => { + const config: PlanAmountConfig = { + type: 'scheduled', + baseAmount: 50, + currency: 'USDC', + scheduledAmounts: [ + { cycle: 1, amount: 100 }, + { cycle: 2, amount: 150 }, + { cycle: 4, amount: 250 }, + ], + }; + expect(calculateCycleAmount(config, 1)).toBe(100); + expect(calculateCycleAmount(config, 2)).toBe(150); + // For cycle 3 (gap between 2 and 4), falls back to cycle 2 amount + expect(calculateCycleAmount(config, 3)).toBe(150); + expect(calculateCycleAmount(config, 4)).toBe(250); + // Beyond cycle 4, uses last defined schedule amount (250) + expect(calculateCycleAmount(config, 10)).toBe(250); + }); + + it('applies tiered amounts according to upToCycle thresholds', () => { + const config: PlanAmountConfig = { + type: 'tiered', + baseAmount: 50, + currency: 'USDC', + tieredAmounts: [ + { upToCycle: 2, amount: 60 }, + { upToCycle: 5, amount: 90 }, + { upToCycle: 8, amount: 120 }, + ], + }; + expect(calculateCycleAmount(config, 1)).toBe(60); + expect(calculateCycleAmount(config, 2)).toBe(60); + expect(calculateCycleAmount(config, 3)).toBe(90); + expect(calculateCycleAmount(config, 5)).toBe(90); + expect(calculateCycleAmount(config, 6)).toBe(120); + expect(calculateCycleAmount(config, 8)).toBe(120); + // Beyond highest tier, uses final tier amount + expect(calculateCycleAmount(config, 12)).toBe(120); + }); + + it('calculates compound percentage step increases accurately', () => { + const config: PlanAmountConfig = { + type: 'percentage_step', + baseAmount: 100, + currency: 'USDC', + percentageStep: { + percentageChange: 10, // +10% + stepFrequencyCycles: 2, // every 2 cycles (steps on cycle 3, 5, 7...) + compound: true, + }, + }; + // Cycle 1: step 0 -> 100 + expect(calculateCycleAmount(config, 1)).toBe(100); + // Cycle 2: step 0 -> 100 + expect(calculateCycleAmount(config, 2)).toBe(100); + // Cycle 3: step 1 -> 100 * 1.1 = 110 + expect(calculateCycleAmount(config, 3)).toBe(110); + // Cycle 4: step 1 -> 110 + expect(calculateCycleAmount(config, 4)).toBe(110); + // Cycle 5: step 2 -> 100 * 1.1^2 = 121 + expect(calculateCycleAmount(config, 5)).toBe(121); + }); + + it('calculates linear percentage step increases accurately', () => { + const config: PlanAmountConfig = { + type: 'percentage_step', + baseAmount: 100, + currency: 'USDC', + percentageStep: { + percentageChange: 10, + stepFrequencyCycles: 1, // every single cycle + compound: false, + }, + }; + expect(calculateCycleAmount(config, 1)).toBe(100); + expect(calculateCycleAmount(config, 2)).toBe(110); + expect(calculateCycleAmount(config, 3)).toBe(120); + expect(calculateCycleAmount(config, 4)).toBe(130); + }); + + it('respects minAmount and maxAmount bounds on percentage step and global', () => { + const config: PlanAmountConfig = { + type: 'percentage_step', + baseAmount: 100, + currency: 'USDC', + percentageStep: { + percentageChange: 50, + stepFrequencyCycles: 1, + compound: true, + maxAmount: 200, + }, + maximumAmount: 180, // stricter global cap + }; + expect(calculateCycleAmount(config, 1)).toBe(100); + expect(calculateCycleAmount(config, 2)).toBe(150); + // Cycle 3 would be 225, capped at 180 + expect(calculateCycleAmount(config, 3)).toBe(180); + }); + + it('handles milestone amounts on specific cycles', () => { + const config: PlanAmountConfig = { + type: 'milestone', + baseAmount: 100, + currency: 'USDC', + milestoneAmounts: [ + { cycle: 3, amount: 250 }, + { cycle: 6, amount: 500 }, + ], + }; + expect(calculateCycleAmount(config, 1)).toBe(100); + expect(calculateCycleAmount(config, 2)).toBe(100); + expect(calculateCycleAmount(config, 3)).toBe(250); + expect(calculateCycleAmount(config, 4)).toBe(100); + expect(calculateCycleAmount(config, 6)).toBe(500); + }); + }); + + describe('projectTotalAmount', () => { + it('accurately projects total sum over cycle ranges', () => { + const config: PlanAmountConfig = { + type: 'fixed', + baseAmount: 100, + currency: 'USDC', + }; + expect(projectTotalAmount(config, 1, 5)).toBe(500); + expect(projectTotalAmount(config, 3, 2)).toBe(0); + }); + + it('projects varying amounts accurately across cycles', () => { + const config: PlanAmountConfig = { + type: 'tiered', + baseAmount: 50, + currency: 'USDC', + tieredAmounts: [ + { upToCycle: 2, amount: 50 }, + { upToCycle: 4, amount: 100 }, + ], + }; + // Cycle 1: 50, Cycle 2: 50, Cycle 3: 100, Cycle 4: 100 -> Sum = 300 + expect(projectTotalAmount(config, 1, 4)).toBe(300); + }); + }); +}); diff --git a/src/lib/recurringPaymentPlan/__tests__/analyticsEngine.test.ts b/src/lib/recurringPaymentPlan/__tests__/analyticsEngine.test.ts new file mode 100644 index 00000000..0549880e --- /dev/null +++ b/src/lib/recurringPaymentPlan/__tests__/analyticsEngine.test.ts @@ -0,0 +1,211 @@ +import { describe, it, expect } from 'vitest'; +import { + calculatePlanAnalytics, + calculatePortfolioAnalytics, +} from '../analyticsEngine'; +import { executePayment } from '../scheduleEngine'; +import { pausePlan, resumePlan } from '../pauseResumeEngine'; +import type { RecurringPaymentPlan } from '@/types/recurringPaymentPlan'; + +const MS_PER_DAY = 24 * 60 * 60 * 1000; + +function createBasePlan(overrides: Partial = {}): RecurringPaymentPlan { + const now = new Date('2025-01-01T00:00:00Z').getTime(); + return { + id: 'test_plan_analytics', + title: 'Analytics Plan', + payerAddress: 'G...Payer', + recipientAddress: 'G...Recipient', + status: 'active', + startDate: now, + currentCycle: 0, + nextExecutionDate: now, + lastExecutionDate: null, + maxCycles: 5, + intervalConfig: { + frequency: 'monthly', + intervalCount: 1, + }, + amountConfig: { + type: 'fixed', + baseAmount: 100, + currency: 'USDC', + }, + executions: [], + modifications: [], + pauseHistory: [], + createdAt: now, + updatedAt: now, + ...overrides, + }; +} + +describe('Payment Plan Analytics Engine', () => { + describe('calculatePlanAnalytics', () => { + it('computes accurate analytics for newly created plan with 0 executions', () => { + const plan = createBasePlan({ maxCycles: 4, amountConfig: { type: 'fixed', baseAmount: 150, currency: 'USDC' } }); + const stats = calculatePlanAnalytics(plan, plan.startDate); + + expect(stats.totalCyclesPlanned).toBe(4); + expect(stats.completedCycles).toBe(0); + expect(stats.remainingCycles).toBe(4); + expect(stats.totalAmountPaid).toBe(0); + expect(stats.remainingProjectedAmount).toBe(600); // 4 * 150 + expect(stats.averagePaymentAmount).toBe(150); + expect(stats.onTimeExecutionRate).toBe(100); + expect(stats.completionPercentage).toBe(0); + }); + + it('computes accurate payment statistics across varying amounts', () => { + let plan = createBasePlan({ + amountConfig: { + type: 'tiered', + baseAmount: 100, + currency: 'USDC', + tieredAmounts: [ + { upToCycle: 1, amount: 100 }, + { upToCycle: 2, amount: 200 }, + { upToCycle: 3, amount: 300 }, + ], + }, + }); + + // Execute 3 payments + plan = executePayment(plan, plan.startDate).plan; + plan = executePayment(plan, plan.nextExecutionDate!).plan; + plan = executePayment(plan, plan.nextExecutionDate!).plan; + + const stats = calculatePlanAnalytics(plan); + expect(stats.completedCycles).toBe(3); + expect(stats.totalAmountPaid).toBe(600); // 100 + 200 + 300 + expect(stats.averagePaymentAmount).toBe(200); + expect(stats.minPaymentAmount).toBe(100); + expect(stats.maxPaymentAmount).toBe(300); + expect(stats.amountVolatility).toBeGreaterThan(0); + }); + + it('computes accurate onTimeExecutionRate when payments are delayed', () => { + const plan = createBasePlan(); + const sched1 = plan.startDate; + const sched2 = plan.startDate + 30 * MS_PER_DAY; + + plan.executions = [ + // On time (same day) + { + id: '1', + cycle: 1, + scheduledDate: sched1, + executedDate: sched1, + amount: 100, + currency: 'USDC', + status: 'completed', + }, + // Late by 5 days (> 24h tolerance) + { + id: '2', + cycle: 2, + scheduledDate: sched2, + executedDate: sched2 + 5 * MS_PER_DAY, + amount: 100, + currency: 'USDC', + status: 'completed', + }, + ]; + + const stats = calculatePlanAnalytics(plan, sched2 + 10 * MS_PER_DAY); + expect(stats.onTimeExecutionRate).toBe(50); // 1 out of 2 on time + }); + + it('calculates missed payments count accurately', () => { + const plan = createBasePlan({ + intervalConfig: { frequency: 'daily', intervalCount: 1 }, + }); + // 5 days have passed with 0 executions + const now = plan.startDate + 5 * MS_PER_DAY; + const stats = calculatePlanAnalytics(plan, now); + + expect(stats.missedPaymentsCount).toBeGreaterThan(0); + }); + + it('tracks pause analytics accurately', () => { + let plan = createBasePlan(); + plan = pausePlan(plan, plan.startDate + 1000).plan; + plan = resumePlan(plan, plan.startDate + 4000).plan; // 3000 ms + + const stats = calculatePlanAnalytics(plan, plan.startDate + 5000); + expect(stats.timesPaused).toBe(1); + expect(stats.totalPausedDurationMs).toBe(3000); + }); + }); + + describe('calculatePortfolioAnalytics', () => { + it('handles empty plans array safely without division by zero', () => { + const portfolio = calculatePortfolioAnalytics([]); + expect(portfolio.totalPlans).toBe(0); + expect(portfolio.activePlans).toBe(0); + expect(portfolio.totalVolumeCollected).toBe(0); + expect(portfolio.totalProjectedVolume).toBe(0); + expect(portfolio.averagePlanSize).toBe(0); + expect(portfolio.overallOnTimeRate).toBe(100); + expect(portfolio.pauseRate).toBe(0); + }); + + it('aggregates metrics across active, paused, and completed plans accurately', () => { + const plan1 = createBasePlan({ id: 'p1', title: 'Plan 1', status: 'active' }); + const plan2 = createBasePlan({ id: 'p2', title: 'Plan 2', status: 'paused' }); + const plan3 = createBasePlan({ id: 'p3', title: 'Plan 3', status: 'completed' }); + const plan4 = createBasePlan({ id: 'p4', title: 'Plan 4', status: 'cancelled' }); + + // Add execution to plan 3 + plan3.executions = [ + { + id: 'e1', + cycle: 1, + scheduledDate: plan3.startDate, + executedDate: plan3.startDate, + amount: 500, + currency: 'USDC', + status: 'completed', + }, + ]; + + const portfolio = calculatePortfolioAnalytics([plan1, plan2, plan3, plan4]); + + expect(portfolio.totalPlans).toBe(4); + expect(portfolio.activePlans).toBe(1); + expect(portfolio.pausedPlans).toBe(1); + expect(portfolio.completedPlans).toBe(1); + expect(portfolio.cancelledPlans).toBe(1); + expect(portfolio.totalVolumeCollected).toBe(500); + expect(portfolio.volumeByCurrency['USDC'].collected).toBe(500); + expect(portfolio.pauseRate).toBe(25); + expect(portfolio.completionRate).toBe(25); + expect(portfolio.churnRate).toBe(25); + }); + + it('forecasts upcoming cashflow chronologically', () => { + const now = Date.now(); + const planA = createBasePlan({ + id: 'pA', + title: 'Plan A', + startDate: now + 5 * MS_PER_DAY, + intervalConfig: { frequency: 'daily', intervalCount: 1 }, + }); + const planB = createBasePlan({ + id: 'pB', + title: 'Plan B', + startDate: now + 2 * MS_PER_DAY, + intervalConfig: { frequency: 'daily', intervalCount: 1 }, + }); + + const portfolio = calculatePortfolioAnalytics([planA, planB], { currentTime: now }); + expect(portfolio.upcomingCashflow.length).toBeGreaterThan(0); + // Verifies chronological ordering + for (let i = 0; i < portfolio.upcomingCashflow.length - 1; i++) { + expect(portfolio.upcomingCashflow[i].date).toBeLessThanOrEqual( + portfolio.upcomingCashflow[i + 1].date + ); + } + }); + }); +}); diff --git a/src/lib/recurringPaymentPlan/__tests__/integration.test.ts b/src/lib/recurringPaymentPlan/__tests__/integration.test.ts new file mode 100644 index 00000000..25f068a0 --- /dev/null +++ b/src/lib/recurringPaymentPlan/__tests__/integration.test.ts @@ -0,0 +1,157 @@ +import { describe, it, expect } from 'vitest'; +import { + executePayment, + getDueExecutions, + catchUpDuePayments, +} from '../scheduleEngine'; +import { modifyPlan } from '../modificationEngine'; +import { pausePlan, resumePlan } from '../pauseResumeEngine'; +import { calculatePlanAnalytics, calculatePortfolioAnalytics } from '../analyticsEngine'; +import type { RecurringPaymentPlan } from '@/types/recurringPaymentPlan'; + +const MS_PER_DAY = 24 * 60 * 60 * 1000; + +describe('Recurring Payment Plan End-to-End Lifecycle', () => { + it('fulfills all acceptance criteria across the complete lifecycle', async () => { + const start = new Date('2025-01-01T00:00:00Z').getTime(); + + // 1. Requirement & Criterion: Design payment plan structure & Plans execute on schedule + let plan: RecurringPaymentPlan = { + id: 'plan_e2e_101', + title: 'Tiered Creator Contract', + payerAddress: 'G...PayerAddress', + recipientAddress: 'G...RecipientAddress', + status: 'active', + startDate: start, + currentCycle: 0, + nextExecutionDate: start, + lastExecutionDate: null, + maxCycles: 6, + intervalConfig: { + frequency: 'weekly', + intervalCount: 1, + }, + // 2. Requirement & Criterion: Add amount variation support & Amounts vary correctly + amountConfig: { + type: 'percentage_step', + baseAmount: 100, + currency: 'USDC', + percentageStep: { + percentageChange: 10, // +10% + stepFrequencyCycles: 2, // every 2 cycles (steps on cycle 3, 5) + compound: true, + }, + }, + executions: [], + modifications: [], + pauseHistory: [], + createdAt: start, + updatedAt: start, + }; + + // --- Execution 1: Cycle 1 on schedule --- + let execResult = executePayment(plan, start); + expect(execResult.success).toBe(true); + expect(execResult.execution?.cycle).toBe(1); + expect(execResult.execution?.amount).toBe(100); + plan = execResult.plan; + expect(plan.currentCycle).toBe(1); + expect(plan.nextExecutionDate).toBe(start + 7 * MS_PER_DAY); + + // --- Execution 2: Cycle 2 on schedule --- + execResult = executePayment(plan, plan.nextExecutionDate!); + expect(execResult.success).toBe(true); + expect(execResult.execution?.cycle).toBe(2); + expect(execResult.execution?.amount).toBe(100); + plan = execResult.plan; + expect(plan.currentCycle).toBe(2); + expect(plan.nextExecutionDate).toBe(start + 14 * MS_PER_DAY); + + // --- Execution 3: Cycle 3 (+10% variation applied correctly) --- + execResult = executePayment(plan, plan.nextExecutionDate!); + expect(execResult.success).toBe(true); + expect(execResult.execution?.cycle).toBe(3); + expect(execResult.execution?.amount).toBe(110); // 100 * 1.1 + plan = execResult.plan; + + // 3. Requirement & Criterion: Implement pause/resume functionality & Pause/resume works + const pauseTime = start + 16 * MS_PER_DAY; // 2 days after cycle 3 + const pauseResult = pausePlan(plan, pauseTime, 'Temporary hold for contract review'); + expect(pauseResult.success).toBe(true); + plan = pauseResult.plan; + expect(plan.status).toBe('paused'); + + // While paused, no payments should be executed or due + expect(executePayment(plan, start + 21 * MS_PER_DAY).success).toBe(false); + expect(getDueExecutions(plan, start + 21 * MS_PER_DAY)).toHaveLength(0); + + // Resume after 2 weeks have elapsed (Jan 30) + const resumeTime = start + 30 * MS_PER_DAY; + const resumeResult = resumePlan(plan, resumeTime, 'catch_up'); + expect(resumeResult.success).toBe(true); + plan = resumeResult.plan; + expect(plan.status).toBe('active'); + + // 4. Requirement & Criterion: No missed payments (catch-up due payments) + const dueDuringPause = getDueExecutions(plan, resumeTime); + expect(dueDuringPause.length).toBeGreaterThanOrEqual(1); + + const catchUpResult = await catchUpDuePayments(plan, resumeTime); + expect(catchUpResult.executedCount).toBe(dueDuringPause.length); + expect(catchUpResult.errors).toHaveLength(0); + plan = catchUpResult.plan; + + // Verify all missed payments were caught up + const remainingDue = getDueExecutions(plan, resumeTime); + expect(remainingDue).toHaveLength(0); + + // 5. Requirement & Criterion: Create plan modification logic & Modifications apply safely + const modResult = modifyPlan( + plan, + { + title: 'Tiered Creator Contract (Amended)', + amountConfig: { + ...plan.amountConfig, + baseAmount: 150, // Amended base amount + }, + }, + { reason: 'Approved rate revision' } + ); + expect(modResult.success).toBe(true); + plan = modResult.plan; + + // Verify past executions are immutable + expect(plan.executions[0].amount).toBe(100); + expect(plan.executions[1].amount).toBe(100); + expect(plan.executions[2].amount).toBe(110); + expect(plan.modifications.length).toBeGreaterThan(0); + + // 6. Complete remaining cycles until maxCycles + while (plan.status === 'active') { + const nextDate = plan.nextExecutionDate!; + const r = executePayment(plan, nextDate); + if (!r.success) break; + plan = r.plan; + } + + expect(plan.status).toBe('completed'); + expect(plan.currentCycle).toBe(6); + expect(plan.nextExecutionDate).toBeNull(); + + // 7. Requirement & Criterion: Add payment plan analytics & Analytics accurate + const stats = calculatePlanAnalytics(plan); + expect(stats.completedCycles).toBe(6); + expect(stats.remainingCycles).toBe(0); + expect(stats.completionPercentage).toBe(100); + expect(stats.missedPaymentsCount).toBe(0); + expect(stats.timesPaused).toBe(1); + expect(stats.totalPausedDurationMs).toBe(resumeTime - pauseTime); + expect(stats.totalAmountPaid).toBeGreaterThan(0); + + const portfolio = calculatePortfolioAnalytics([plan]); + expect(portfolio.totalPlans).toBe(1); + expect(portfolio.completedPlans).toBe(1); + expect(portfolio.completionRate).toBe(100); + expect(portfolio.totalMissedPayments).toBe(0); + }); +}); diff --git a/src/lib/recurringPaymentPlan/__tests__/modificationEngine.test.ts b/src/lib/recurringPaymentPlan/__tests__/modificationEngine.test.ts new file mode 100644 index 00000000..2db2f1ca --- /dev/null +++ b/src/lib/recurringPaymentPlan/__tests__/modificationEngine.test.ts @@ -0,0 +1,181 @@ +import { describe, it, expect } from 'vitest'; +import { modifyPlan, validateIntervalConfig } from '../modificationEngine'; +import { executePayment } from '../scheduleEngine'; +import type { RecurringPaymentPlan } from '@/types/recurringPaymentPlan'; + +const MS_PER_DAY = 24 * 60 * 60 * 1000; + +function createBasePlan(): RecurringPaymentPlan { + const now = new Date('2025-01-01T00:00:00Z').getTime(); + return { + id: 'test_plan_mod', + title: 'Original Title', + payerAddress: 'G...Payer', + recipientAddress: 'G...Recipient', + status: 'active', + startDate: now, + currentCycle: 0, + nextExecutionDate: now, + lastExecutionDate: null, + intervalConfig: { + frequency: 'monthly', + intervalCount: 1, + dayOfMonth: 1, + }, + amountConfig: { + type: 'fixed', + baseAmount: 100, + currency: 'USDC', + }, + executions: [], + modifications: [], + pauseHistory: [], + createdAt: now, + updatedAt: now, + }; +} + +describe('Plan Modification Engine', () => { + describe('validateIntervalConfig', () => { + it('accepts valid intervals', () => { + expect(validateIntervalConfig({ intervalCount: 2, dayOfMonth: 15 }).valid).toBe(true); + }); + + it('rejects invalid interval counts or days', () => { + const res = validateIntervalConfig({ intervalCount: 0, dayOfMonth: 32 }); + expect(res.valid).toBe(false); + expect(res.errors).toContain('intervalCount must be at least 1'); + expect(res.errors).toContain('dayOfMonth must be between 1 and 31'); + }); + }); + + describe('modifyPlan', () => { + it('prevents modification of completed or cancelled plans', () => { + const plan = createBasePlan(); + plan.status = 'completed'; + + const res = modifyPlan(plan, { title: 'New Title' }); + expect(res.success).toBe(false); + expect(res.error).toContain("Cannot modify a plan that is 'completed'"); + + plan.status = 'cancelled'; + const res2 = modifyPlan(plan, { title: 'New Title' }); + expect(res2.success).toBe(false); + expect(res2.error).toContain("Cannot modify a plan that is 'cancelled'"); + }); + + it('prevents setting maxCycles lower than already executed cycles', () => { + let plan = createBasePlan(); + // Execute 3 cycles + plan = executePayment(plan, plan.startDate).plan; + plan = executePayment(plan, plan.nextExecutionDate!).plan; + plan = executePayment(plan, plan.nextExecutionDate!).plan; + expect(plan.currentCycle).toBe(3); + + const res = modifyPlan(plan, { maxCycles: 2 }); + expect(res.success).toBe(false); + expect(res.error).toContain('Cannot reduce maxCycles (2) below already completed cycles (3)'); + + const validRes = modifyPlan(plan, { maxCycles: 5 }); + expect(validRes.success).toBe(true); + expect(validRes.plan.maxCycles).toBe(5); + }); + + it('prevents setting endDate prior to last executed payment date', () => { + let plan = createBasePlan(); + const lastExec = plan.startDate + 10 * MS_PER_DAY; + plan = executePayment(plan, lastExec).plan; + + const res = modifyPlan(plan, { endDate: plan.startDate + 5 * MS_PER_DAY }); + expect(res.success).toBe(false); + expect(res.error).toContain('Cannot set endDate before the last executed payment date'); + }); + + it('safely modifies intervalConfig and recalculates upcoming schedule date', () => { + const plan = createBasePlan(); + const res = modifyPlan(plan, { + intervalConfig: { + frequency: 'weekly', + intervalCount: 2, + }, + }); + + expect(res.success).toBe(true); + expect(res.plan.intervalConfig.frequency).toBe('weekly'); + expect(res.plan.intervalConfig.intervalCount).toBe(2); + // Next cycle is 1 (currentCycle 0 + 1 = 1) at startDate + expect(res.plan.nextExecutionDate).toBe(plan.startDate); + }); + + it('safely modifies amountConfig without altering past executions', () => { + let plan = createBasePlan(); + // Execute cycle 1 with 100 USDC + plan = executePayment(plan, plan.startDate).plan; + expect(plan.executions[0].amount).toBe(100); + + // Modify amountConfig to 200 USDC + const res = modifyPlan( + plan, + { + amountConfig: { + ...plan.amountConfig, + baseAmount: 200, + }, + }, + { reason: 'Price tier upgrade' } + ); + + expect(res.success).toBe(true); + // Past execution remains strictly immutable + expect(res.plan.executions[0].amount).toBe(100); + expect(res.plan.amountConfig.baseAmount).toBe(200); + + // Execute next payment (cycle 2) and verify it receives the new 200 amount + const exec2 = executePayment(res.plan, res.plan.nextExecutionDate!); + expect(exec2.success).toBe(true); + expect(exec2.execution?.cycle).toBe(2); + expect(exec2.execution?.amount).toBe(200); + }); + + it('validates recipient split percentages total 100%', () => { + const plan = createBasePlan(); + const invalidRes = modifyPlan(plan, { + recipients: [ + { address: 'G...A', sharePercentage: 60 }, + { address: 'G...B', sharePercentage: 30 }, + ], + }); + expect(invalidRes.success).toBe(false); + expect(invalidRes.error).toContain('Recipient share percentages must total 100%'); + + const validRes = modifyPlan(plan, { + recipients: [ + { address: 'G...A', sharePercentage: 60 }, + { address: 'G...B', sharePercentage: 40 }, + ], + }); + expect(validRes.success).toBe(true); + }); + + it('records detailed audit trail of all changes', () => { + const plan = createBasePlan(); + const res = modifyPlan( + plan, + { + title: 'Updated Plan Title', + description: 'Updated Description', + }, + { reason: 'Annual review' } + ); + + expect(res.success).toBe(true); + expect(res.plan.modifications).toHaveLength(2); + const titleMod = res.plan.modifications.find((m) => m.field === 'title'); + expect(titleMod).toBeDefined(); + expect(titleMod?.oldValue).toBe('Original Title'); + expect(titleMod?.newValue).toBe('Updated Plan Title'); + expect(titleMod?.reason).toBe('Annual review'); + expect(titleMod?.effectiveFromCycle).toBe(1); + }); + }); +}); diff --git a/src/lib/recurringPaymentPlan/__tests__/pauseResumeEngine.test.ts b/src/lib/recurringPaymentPlan/__tests__/pauseResumeEngine.test.ts new file mode 100644 index 00000000..65724510 --- /dev/null +++ b/src/lib/recurringPaymentPlan/__tests__/pauseResumeEngine.test.ts @@ -0,0 +1,173 @@ +import { describe, it, expect } from 'vitest'; +import { + pausePlan, + resumePlan, + isPlanPaused, + calculateTotalPausedDuration, +} from '../pauseResumeEngine'; +import { getDueExecutions } from '../scheduleEngine'; +import type { RecurringPaymentPlan } from '@/types/recurringPaymentPlan'; + +const MS_PER_DAY = 24 * 60 * 60 * 1000; + +function createBasePlan(): RecurringPaymentPlan { + const now = new Date('2025-01-01T00:00:00Z').getTime(); + return { + id: 'test_plan_pause', + title: 'Subscription to Pause', + payerAddress: 'G...Payer', + recipientAddress: 'G...Recipient', + status: 'active', + startDate: now, + currentCycle: 0, + nextExecutionDate: now, + lastExecutionDate: null, + intervalConfig: { + frequency: 'weekly', + intervalCount: 1, + }, + amountConfig: { + type: 'fixed', + baseAmount: 100, + currency: 'USDC', + }, + executions: [], + modifications: [], + pauseHistory: [], + createdAt: now, + updatedAt: now, + }; +} + +describe('Pause/Resume Engine', () => { + describe('pausePlan', () => { + it('successfully pauses an active plan', () => { + const plan = createBasePlan(); + const pauseTime = plan.startDate + 2 * MS_PER_DAY; + + const res = pausePlan(plan, pauseTime, 'Vacation pause'); + expect(res.success).toBe(true); + expect(res.plan.status).toBe('paused'); + expect(res.plan.pausedAt).toBe(pauseTime); + expect(res.plan.pauseHistory).toHaveLength(1); + expect(res.plan.pauseHistory[0].pausedAt).toBe(pauseTime); + expect(res.plan.pauseHistory[0].resumedAt).toBeNull(); + expect(res.plan.pauseHistory[0].reason).toBe('Vacation pause'); + expect(isPlanPaused(res.plan)).toBe(true); + }); + + it('rejects pausing a plan that is already paused', () => { + const plan = createBasePlan(); + const paused = pausePlan(plan).plan; + const res = pausePlan(paused); + expect(res.success).toBe(false); + expect(res.error).toContain("Cannot pause plan with status 'paused'"); + }); + + it('rejects pausing a completed or cancelled plan', () => { + const plan = createBasePlan(); + plan.status = 'completed'; + expect(pausePlan(plan).success).toBe(false); + + plan.status = 'cancelled'; + expect(pausePlan(plan).success).toBe(false); + }); + }); + + describe('resumePlan', () => { + it('successfully resumes a paused plan and closes pauseHistory record', () => { + const plan = createBasePlan(); + const pauseTime = plan.startDate + 2 * MS_PER_DAY; + const resumeTime = pauseTime + 5 * MS_PER_DAY; + + const paused = pausePlan(plan, pauseTime).plan; + const resumed = resumePlan(paused, resumeTime, 'catch_up').plan; + + expect(resumed.status).toBe('active'); + expect(resumed.pausedAt).toBeNull(); + expect(resumed.pauseHistory[0].resumedAt).toBe(resumeTime); + expect(isPlanPaused(resumed)).toBe(false); + }); + + it('handles catch_up policy by allowing due payments during pause to be processed', () => { + const start = new Date('2025-01-01T00:00:00Z').getTime(); + const plan = createBasePlan(); + plan.startDate = start; + plan.nextExecutionDate = start; + + // Plan paused on Jan 2, resumed on Jan 16 (2 weeks later) + const pauseTime = start + 1 * MS_PER_DAY; + const resumeTime = start + 15 * MS_PER_DAY; + + const paused = pausePlan(plan, pauseTime).plan; + const resumed = resumePlan(paused, resumeTime, 'catch_up').plan; + + // With catch_up policy, payments that were due during the pause are ready for execution + const due = getDueExecutions(resumed, resumeTime); + // Jan 1 (cycle 1), Jan 8 (cycle 2), Jan 15 (cycle 3) are all due + expect(due.length).toBeGreaterThanOrEqual(3); + }); + + it('handles shift_schedule policy by shifting future dates forward by pause duration', () => { + const start = new Date('2025-01-01T00:00:00Z').getTime(); + const plan = createBasePlan(); + plan.startDate = start; + plan.nextExecutionDate = start + 7 * MS_PER_DAY; + + const pauseTime = start + 2 * MS_PER_DAY; + const resumeTime = pauseTime + 10 * MS_PER_DAY; // paused for 10 days + const pauseDuration = 10 * MS_PER_DAY; + + const paused = pausePlan(plan, pauseTime).plan; + const resumed = resumePlan(paused, resumeTime, 'shift_schedule').plan; + + expect(resumed.startDate).toBe(start + pauseDuration); + expect(resumed.nextExecutionDate).toBe(plan.nextExecutionDate + pauseDuration); + }); + + it('handles next_scheduled_date policy by resetting next run to 1 interval from resume', () => { + const plan = createBasePlan(); + const resumeTime = Date.now(); + + const paused = pausePlan(plan).plan; + const resumed = resumePlan(paused, resumeTime, 'next_scheduled_date').plan; + + // Weekly plan -> next run is exactly 7 days from resumeTime + expect(resumed.nextExecutionDate).toBe(resumeTime + 7 * MS_PER_DAY); + }); + + it('rejects resuming a plan that is not paused', () => { + const plan = createBasePlan(); + const res = resumePlan(plan); + expect(res.success).toBe(false); + expect(res.error).toContain("Cannot resume plan with status 'active'"); + }); + }); + + describe('calculateTotalPausedDuration', () => { + it('sums paused durations across multiple pause and resume sessions', () => { + const plan = createBasePlan(); + const session1Start = 1000; + const session1End = 3000; // 2000 ms + const session2Start = 5000; + const session2End = 8000; // 3000 ms + + plan.pauseHistory = [ + { id: '1', pausedAt: session1Start, resumedAt: session1End }, + { id: '2', pausedAt: session2Start, resumedAt: session2End }, + ]; + + expect(calculateTotalPausedDuration(plan)).toBe(5000); + }); + + it('handles an ongoing pause session using currentTime', () => { + const plan = createBasePlan(); + plan.pauseHistory = [ + { id: '1', pausedAt: 1000, resumedAt: 2000 }, // 1000 ms + { id: '2', pausedAt: 3000, resumedAt: null }, // ongoing from 3000 + ]; + + expect(calculateTotalPausedDuration(plan, 6000)).toBe(4000); // 1000 + 3000 + }); + }); +}); diff --git a/src/lib/recurringPaymentPlan/__tests__/scheduleEngine.test.ts b/src/lib/recurringPaymentPlan/__tests__/scheduleEngine.test.ts new file mode 100644 index 00000000..08818901 --- /dev/null +++ b/src/lib/recurringPaymentPlan/__tests__/scheduleEngine.test.ts @@ -0,0 +1,288 @@ +import { describe, it, expect } from 'vitest'; +import { + addIntervalToDate, + calculateScheduledDateForCycle, + getDueExecutions, + executePayment, + catchUpDuePayments, + generateUpcomingSchedule, +} from '../scheduleEngine'; +import type { RecurringPaymentPlan } from '@/types/recurringPaymentPlan'; + +const MS_PER_DAY = 24 * 60 * 60 * 1000; + +function createBasePlan(overrides: Partial = {}): RecurringPaymentPlan { + const now = new Date('2025-01-01T00:00:00Z').getTime(); + return { + id: 'test_plan_1', + title: 'Test Subscription', + payerAddress: 'G...Payer', + recipientAddress: 'G...Recipient', + status: 'active', + startDate: now, + currentCycle: 0, + nextExecutionDate: now, + lastExecutionDate: null, + intervalConfig: { + frequency: 'monthly', + intervalCount: 1, + dayOfMonth: 1, + }, + amountConfig: { + type: 'fixed', + baseAmount: 100, + currency: 'USDC', + }, + executions: [], + modifications: [], + pauseHistory: [], + createdAt: now, + updatedAt: now, + ...overrides, + }; +} + +describe('Schedule Engine', () => { + describe('addIntervalToDate', () => { + const base = new Date('2025-01-01T12:00:00Z').getTime(); + + it('adds daily intervals accurately', () => { + const result = addIntervalToDate(base, { frequency: 'daily', intervalCount: 3 }); + expect(result).toBe(base + 3 * MS_PER_DAY); + }); + + it('adds weekly intervals accurately', () => { + const result = addIntervalToDate(base, { frequency: 'weekly', intervalCount: 2 }); + expect(result).toBe(base + 14 * MS_PER_DAY); + }); + + it('adds biweekly intervals accurately', () => { + const result = addIntervalToDate(base, { frequency: 'biweekly', intervalCount: 1 }); + expect(result).toBe(base + 14 * MS_PER_DAY); + }); + + it('adds monthly intervals and handles month boundaries', () => { + const jan15 = new Date('2025-01-15T00:00:00Z').getTime(); + const feb15 = addIntervalToDate(jan15, { frequency: 'monthly', intervalCount: 1 }); + const febDate = new Date(feb15); + expect(febDate.getMonth()).toBe(1); // Feb + expect(febDate.getDate()).toBe(15); + }); + + it('clamps month-end dates safely (e.g. Jan 31 -> Feb 28 in non-leap year)', () => { + const jan31 = new Date('2025-01-31T00:00:00Z').getTime(); + const feb = addIntervalToDate(jan31, { frequency: 'monthly', intervalCount: 1 }); + const febDate = new Date(feb); + expect(febDate.getMonth()).toBe(1); // Feb + expect(febDate.getDate()).toBe(28); // 2025 is not a leap year + }); + + it('handles leap years on month-end clamping (Jan 31 -> Feb 29 in 2024)', () => { + const jan31_2024 = new Date('2024-01-31T00:00:00Z').getTime(); + const feb2024 = addIntervalToDate(jan31_2024, { frequency: 'monthly', intervalCount: 1 }); + const febDate = new Date(feb2024); + expect(febDate.getMonth()).toBe(1); // Feb + expect(febDate.getDate()).toBe(29); // 2024 is a leap year + }); + + it('adds quarterly intervals (every 3 months)', () => { + const jan1 = new Date('2025-01-01T00:00:00Z').getTime(); + const apr1 = addIntervalToDate(jan1, { frequency: 'quarterly', intervalCount: 1 }); + const aprDate = new Date(apr1); + expect(aprDate.getMonth()).toBe(3); // April + expect(aprDate.getDate()).toBe(1); + }); + + it('adds yearly intervals', () => { + const jan1_2025 = new Date('2025-01-01T00:00:00Z').getTime(); + const jan1_2026 = addIntervalToDate(jan1_2025, { frequency: 'yearly', intervalCount: 1 }); + expect(new Date(jan1_2026).getFullYear()).toBe(2026); + }); + + it('adds custom intervals in days', () => { + const res = addIntervalToDate(base, { frequency: 'custom', intervalCount: 1, customIntervalDays: 10 }); + expect(res).toBe(base + 10 * MS_PER_DAY); + }); + }); + + describe('calculateScheduledDateForCycle', () => { + it('sets cycle 1 at startDate and cycle 2 at startDate + interval', () => { + const start = new Date('2025-01-01T00:00:00Z').getTime(); + const plan = createBasePlan({ + startDate: start, + intervalConfig: { frequency: 'weekly', intervalCount: 1 }, + }); + + expect(calculateScheduledDateForCycle(plan, 1)).toBe(start); + expect(calculateScheduledDateForCycle(plan, 2)).toBe(start + 7 * MS_PER_DAY); + expect(calculateScheduledDateForCycle(plan, 4)).toBe(start + 21 * MS_PER_DAY); + }); + }); + + describe('getDueExecutions and No Missed Payments guarantee', () => { + it('returns empty when no payments are due yet', () => { + const futureStart = Date.now() + 10 * MS_PER_DAY; + const plan = createBasePlan({ startDate: futureStart, nextExecutionDate: futureStart }); + const due = getDueExecutions(plan, Date.now()); + expect(due).toHaveLength(0); + }); + + it('identifies all overdue cycles when multiple intervals have passed', () => { + const start = new Date('2025-01-01T00:00:00Z').getTime(); + const plan = createBasePlan({ + startDate: start, + intervalConfig: { frequency: 'daily', intervalCount: 1 }, + }); + + // As of Jan 4 (3 days later), cycles 1, 2, 3, 4 should all be due + const asOf = start + 3 * MS_PER_DAY; + const due = getDueExecutions(plan, asOf); + + expect(due).toHaveLength(4); + expect(due.map((d) => d.cycle)).toEqual([1, 2, 3, 4]); + expect(due[0].isOverdue).toBe(true); + }); + + it('returns empty when plan is paused', () => { + const start = new Date('2025-01-01T00:00:00Z').getTime(); + const plan = createBasePlan({ + startDate: start, + status: 'paused', + intervalConfig: { frequency: 'daily', intervalCount: 1 }, + }); + + const due = getDueExecutions(plan, start + 5 * MS_PER_DAY); + expect(due).toHaveLength(0); + }); + + it('respects maxCycles limit when finding due payments', () => { + const start = new Date('2025-01-01T00:00:00Z').getTime(); + const plan = createBasePlan({ + startDate: start, + maxCycles: 2, + intervalConfig: { frequency: 'daily', intervalCount: 1 }, + }); + + const due = getDueExecutions(plan, start + 10 * MS_PER_DAY); + expect(due).toHaveLength(2); + expect(due.map((d) => d.cycle)).toEqual([1, 2]); + }); + + it('respects endDate limit when finding due payments', () => { + const start = new Date('2025-01-01T00:00:00Z').getTime(); + const plan = createBasePlan({ + startDate: start, + endDate: start + 2 * MS_PER_DAY, // only covers up to cycle 3 + intervalConfig: { frequency: 'daily', intervalCount: 1 }, + }); + + const due = getDueExecutions(plan, start + 10 * MS_PER_DAY); + expect(due).toHaveLength(3); // cycles 1, 2, 3 + }); + }); + + describe('executePayment', () => { + it('successfully executes a payment and advances cycle and nextExecutionDate', () => { + const start = new Date('2025-01-01T00:00:00Z').getTime(); + const plan = createBasePlan({ + startDate: start, + intervalConfig: { frequency: 'weekly', intervalCount: 1 }, + amountConfig: { type: 'fixed', baseAmount: 250, currency: 'USDC' }, + }); + + const result = executePayment(plan, start); + expect(result.success).toBe(true); + expect(result.execution).toBeDefined(); + expect(result.execution?.cycle).toBe(1); + expect(result.execution?.amount).toBe(250); + expect(result.plan.currentCycle).toBe(1); + expect(result.plan.lastExecutionDate).toBe(start); + expect(result.plan.nextExecutionDate).toBe(start + 7 * MS_PER_DAY); + expect(result.plan.executions).toHaveLength(1); + expect(result.plan.status).toBe('active'); + }); + + it('auto-completes when maxCycles is reached', () => { + const start = new Date('2025-01-01T00:00:00Z').getTime(); + const plan = createBasePlan({ + startDate: start, + maxCycles: 1, + intervalConfig: { frequency: 'daily', intervalCount: 1 }, + }); + + const result = executePayment(plan, start); + expect(result.success).toBe(true); + expect(result.plan.status).toBe('completed'); + expect(result.plan.nextExecutionDate).toBeNull(); + }); + + it('rejects execution on paused or cancelled plans', () => { + const plan = createBasePlan({ status: 'paused' }); + const result = executePayment(plan, Date.now()); + expect(result.success).toBe(false); + expect(result.error).toContain("Cannot execute payment on a plan with status 'paused'"); + }); + }); + + describe('catchUpDuePayments', () => { + it('executes all due payments sequentially without missing any', async () => { + const start = new Date('2025-01-01T00:00:00Z').getTime(); + const plan = createBasePlan({ + startDate: start, + intervalConfig: { frequency: 'weekly', intervalCount: 1 }, + amountConfig: { + type: 'tiered', + baseAmount: 100, + currency: 'USDC', + tieredAmounts: [ + { upToCycle: 1, amount: 100 }, + { upToCycle: 2, amount: 120 }, + { upToCycle: 3, amount: 140 }, + ], + }, + }); + + // 3 weeks have passed without execution + const asOf = start + 2 * 7 * MS_PER_DAY; // covers cycles 1, 2, 3 + const result = await catchUpDuePayments(plan, asOf); + + expect(result.executedCount).toBe(3); + expect(result.errors).toHaveLength(0); + expect(result.plan.currentCycle).toBe(3); + expect(result.plan.executions).toHaveLength(3); + expect(result.plan.executions[0].amount).toBe(100); + expect(result.plan.executions[1].amount).toBe(120); + expect(result.plan.executions[2].amount).toBe(140); + expect(result.plan.nextExecutionDate).toBe(start + 3 * 7 * MS_PER_DAY); + }); + }); + + describe('generateUpcomingSchedule', () => { + it('generates upcoming schedule with amount variations and limits', () => { + const start = new Date('2025-01-01T00:00:00Z').getTime(); + const plan = createBasePlan({ + startDate: start, + intervalConfig: { frequency: 'daily', intervalCount: 1 }, + amountConfig: { + type: 'percentage_step', + baseAmount: 100, + currency: 'USDC', + percentageStep: { + percentageChange: 10, + stepFrequencyCycles: 1, + compound: false, + }, + }, + }); + + const schedule = generateUpcomingSchedule(plan, 3); + expect(schedule).toHaveLength(3); + expect(schedule[0].cycle).toBe(1); + expect(schedule[0].amount).toBe(100); + expect(schedule[1].cycle).toBe(2); + expect(schedule[1].amount).toBe(110); + expect(schedule[2].cycle).toBe(3); + expect(schedule[2].amount).toBe(120); + }); + }); +}); diff --git a/src/lib/recurringPaymentPlan/amountEngine.ts b/src/lib/recurringPaymentPlan/amountEngine.ts new file mode 100644 index 00000000..e0f7714d --- /dev/null +++ b/src/lib/recurringPaymentPlan/amountEngine.ts @@ -0,0 +1,244 @@ +import type { + PlanAmountConfig, + PlanScheduledAmount, + PlanAmountTier, + PlanPercentageStep, + PlanMilestoneAmount, +} from '@/types/recurringPaymentPlan'; + +/** + * Rounds a number to a specified number of decimal places (default 7 for Stellar precision). + */ +export function roundAmount(amount: number, decimals: number = 7): number { + if (!Number.isFinite(amount)) return 0; + const factor = Math.pow(10, decimals); + return Math.round(amount * factor) / factor; +} + +/** + * Validates a PlanAmountConfig object. + */ +export function validateAmountConfig(config: PlanAmountConfig): { + valid: boolean; + errors: string[]; +} { + const errors: string[] = []; + + if (typeof config.baseAmount !== 'number' || isNaN(config.baseAmount) || config.baseAmount < 0) { + errors.push('baseAmount must be a non-negative number'); + } + + if (!config.currency || typeof config.currency !== 'string') { + errors.push('currency is required'); + } + + if (config.minimumAmount !== undefined && config.minimumAmount < 0) { + errors.push('minimumAmount cannot be negative'); + } + + if (config.maximumAmount !== undefined && config.maximumAmount < 0) { + errors.push('maximumAmount cannot be negative'); + } + + if ( + config.minimumAmount !== undefined && + config.maximumAmount !== undefined && + config.minimumAmount > config.maximumAmount + ) { + errors.push('minimumAmount cannot exceed maximumAmount'); + } + + switch (config.type) { + case 'fixed': + break; + + case 'scheduled': + if (!Array.isArray(config.scheduledAmounts) || config.scheduledAmounts.length === 0) { + errors.push('scheduledAmounts must be a non-empty array for scheduled variation'); + } else { + for (const item of config.scheduledAmounts) { + if (item.cycle < 1) { + errors.push(`Cycle must be >= 1 (got ${item.cycle})`); + } + if (item.amount < 0) { + errors.push(`Amount for cycle ${item.cycle} cannot be negative`); + } + } + } + break; + + case 'tiered': + if (!Array.isArray(config.tieredAmounts) || config.tieredAmounts.length === 0) { + errors.push('tieredAmounts must be a non-empty array for tiered variation'); + } else { + for (const tier of config.tieredAmounts) { + if (tier.upToCycle < 1) { + errors.push(`upToCycle must be >= 1 (got ${tier.upToCycle})`); + } + if (tier.amount < 0) { + errors.push(`Amount for tier upToCycle ${tier.upToCycle} cannot be negative`); + } + } + } + break; + + case 'percentage_step': + if (!config.percentageStep) { + errors.push('percentageStep configuration is required for percentage_step variation'); + } else { + if (config.percentageStep.stepFrequencyCycles < 1) { + errors.push('stepFrequencyCycles must be at least 1'); + } + if ( + config.percentageStep.minAmount !== undefined && + config.percentageStep.maxAmount !== undefined && + config.percentageStep.minAmount > config.percentageStep.maxAmount + ) { + errors.push('percentageStep minAmount cannot exceed maxAmount'); + } + } + break; + + case 'milestone': + if (!Array.isArray(config.milestoneAmounts) || config.milestoneAmounts.length === 0) { + errors.push('milestoneAmounts must be a non-empty array for milestone variation'); + } else { + for (const m of config.milestoneAmounts) { + if (m.cycle < 1) { + errors.push(`Milestone cycle must be >= 1 (got ${m.cycle})`); + } + if (m.amount < 0) { + errors.push(`Milestone amount for cycle ${m.cycle} cannot be negative`); + } + } + } + break; + + default: + errors.push(`Unsupported amount variation type: ${(config as any).type}`); + } + + return { + valid: errors.length === 0, + errors, + }; +} + +/** + * Calculates the exact amount due for a given cycle (1-based index) based on amountConfig. + */ +export function calculateCycleAmount( + config: PlanAmountConfig, + cycle: number +): number { + if (cycle < 1) { + throw new Error(`Cycle index must be positive (received: ${cycle})`); + } + + let amount = config.baseAmount; + + switch (config.type) { + case 'fixed': + amount = config.baseAmount; + break; + + case 'scheduled': { + if (config.scheduledAmounts && config.scheduledAmounts.length > 0) { + const sorted = [...config.scheduledAmounts].sort((a, b) => a.cycle - b.cycle); + const exact = sorted.find((s) => s.cycle === cycle); + if (exact) { + amount = exact.amount; + } else { + // If cycle is beyond the highest defined schedule cycle, use the last specified amount + const last = sorted[sorted.length - 1]; + if (cycle > last.cycle) { + amount = last.amount; + } else { + // Find closest preceding cycle + const preceding = sorted.filter((s) => s.cycle < cycle); + amount = preceding.length > 0 ? preceding[preceding.length - 1].amount : config.baseAmount; + } + } + } + break; + } + + case 'tiered': { + if (config.tieredAmounts && config.tieredAmounts.length > 0) { + const sorted = [...config.tieredAmounts].sort((a, b) => a.upToCycle - b.upToCycle); + const matchedTier = sorted.find((t) => cycle <= t.upToCycle); + if (matchedTier) { + amount = matchedTier.amount; + } else { + // If past highest tier, use the final tier amount + amount = sorted[sorted.length - 1].amount; + } + } + break; + } + + case 'percentage_step': { + if (config.percentageStep) { + const { percentageChange, stepFrequencyCycles, compound = true, minAmount, maxAmount } = + config.percentageStep; + const freq = Math.max(1, stepFrequencyCycles); + const steps = Math.floor((cycle - 1) / freq); + + if (steps > 0) { + if (compound) { + amount = config.baseAmount * Math.pow(1 + percentageChange / 100, steps); + } else { + amount = config.baseAmount * (1 + (percentageChange / 100) * steps); + } + } + + if (minAmount !== undefined && amount < minAmount) { + amount = minAmount; + } + if (maxAmount !== undefined && amount > maxAmount) { + amount = maxAmount; + } + } + break; + } + + case 'milestone': { + if (config.milestoneAmounts && config.milestoneAmounts.length > 0) { + const milestone = config.milestoneAmounts.find((m) => m.cycle === cycle); + if (milestone) { + amount = milestone.amount; + } else { + amount = config.baseAmount; + } + } + break; + } + } + + // Apply global minimum & maximum bounds if set + if (config.minimumAmount !== undefined && amount < config.minimumAmount) { + amount = config.minimumAmount; + } + if (config.maximumAmount !== undefined && amount > config.maximumAmount) { + amount = config.maximumAmount; + } + + // Ensure never negative + return roundAmount(Math.max(0, amount)); +} + +/** + * Projects total amount across a range of cycles [startCycle, endCycle]. + */ +export function projectTotalAmount( + config: PlanAmountConfig, + startCycle: number, + endCycle: number +): number { + if (startCycle > endCycle) return 0; + let total = 0; + for (let c = startCycle; c <= endCycle; c++) { + total += calculateCycleAmount(config, c); + } + return roundAmount(total); +} diff --git a/src/lib/recurringPaymentPlan/analyticsEngine.ts b/src/lib/recurringPaymentPlan/analyticsEngine.ts new file mode 100644 index 00000000..58dba258 --- /dev/null +++ b/src/lib/recurringPaymentPlan/analyticsEngine.ts @@ -0,0 +1,228 @@ +import type { + RecurringPaymentPlan, + SinglePlanAnalytics, + PortfolioAnalytics, +} from '@/types/recurringPaymentPlan'; +import { roundAmount, calculateCycleAmount, projectTotalAmount } from './amountEngine'; +import { calculateTotalPausedDuration } from './pauseResumeEngine'; +import { getDueExecutions, generateUpcomingSchedule } from './scheduleEngine'; + +const MS_PER_DAY = 24 * 60 * 60 * 1000; +const ON_TIME_TOLERANCE_MS = 24 * 60 * 60 * 1000; // 24 hours allowable window + +/** + * Calculates comprehensive analytics for an individual recurring payment plan. + */ +export function calculatePlanAnalytics( + plan: RecurringPaymentPlan, + currentTime: number = Date.now() +): SinglePlanAnalytics { + const completedExecutions = plan.executions.filter((e) => e.status === 'completed'); + const failedExecutions = plan.executions.filter((e) => e.status === 'failed'); + + const totalAmountPaid = roundAmount( + completedExecutions.reduce((sum, e) => sum + e.amount, 0) + ); + + const completedCycles = completedExecutions.length; + const totalCyclesPlanned = plan.maxCycles ?? null; + const remainingCycles = + totalCyclesPlanned !== null ? Math.max(0, totalCyclesPlanned - completedCycles) : null; + + // Calculate remaining projected amount + let remainingProjectedAmount = 0; + if (remainingCycles !== null && remainingCycles > 0) { + remainingProjectedAmount = projectTotalAmount( + plan.amountConfig, + completedCycles + 1, + totalCyclesPlanned! + ); + } else if (plan.endDate && plan.status === 'active') { + const upcoming = generateUpcomingSchedule(plan, 50, plan.endDate); + remainingProjectedAmount = roundAmount(upcoming.reduce((acc, u) => acc + u.amount, 0)); + } else if (plan.status === 'active') { + // Forecast next 12 cycles if open-ended + const upcoming = generateUpcomingSchedule(plan, 12); + remainingProjectedAmount = roundAmount(upcoming.reduce((acc, u) => acc + u.amount, 0)); + } + + // Payment amount stats + let averagePaymentAmount = plan.amountConfig.baseAmount; + let minPaymentAmount = plan.amountConfig.baseAmount; + let maxPaymentAmount = plan.amountConfig.baseAmount; + let amountVolatility = 0; + + if (completedCycles > 0) { + averagePaymentAmount = roundAmount(totalAmountPaid / completedCycles); + minPaymentAmount = Math.min(...completedExecutions.map((e) => e.amount)); + maxPaymentAmount = Math.max(...completedExecutions.map((e) => e.amount)); + + // Standard deviation + const variance = + completedExecutions.reduce((acc, e) => acc + Math.pow(e.amount - averagePaymentAmount, 2), 0) / + completedCycles; + amountVolatility = roundAmount(Math.sqrt(variance)); + } + + // On-time execution tracking + let onTimeExecutionRate = 100; + if (completedExecutions.length > 0) { + const onTimeCount = completedExecutions.filter( + (e) => Math.abs(e.executedDate - e.scheduledDate) <= ON_TIME_TOLERANCE_MS + ).length; + onTimeExecutionRate = roundAmount((onTimeCount / completedExecutions.length) * 100, 2); + } + + // Overdue / missed payments + const due = getDueExecutions(plan, currentTime); + const missedPaymentsCount = due.filter((d) => d.isOverdue).length; + + // Paused metrics + const timesPaused = plan.pauseHistory.length; + const totalPausedDurationMs = calculateTotalPausedDuration(plan, currentTime); + + // Lifetime + const lifetimeDays = Math.max(0, roundAmount((currentTime - plan.createdAt) / MS_PER_DAY, 1)); + + // Completion percentage + let completionPercentage = 0; + if (totalCyclesPlanned !== null && totalCyclesPlanned > 0) { + completionPercentage = roundAmount(Math.min(100, (completedCycles / totalCyclesPlanned) * 100), 1); + } else if (plan.status === 'completed') { + completionPercentage = 100; + } + + return { + planId: plan.id, + title: plan.title, + status: plan.status, + totalCyclesPlanned, + completedCycles, + remainingCycles, + totalAmountPaid, + remainingProjectedAmount, + averagePaymentAmount, + minPaymentAmount, + maxPaymentAmount, + amountVolatility, + onTimeExecutionRate, + missedPaymentsCount, + failedPaymentsCount: failedExecutions.length, + timesPaused, + totalPausedDurationMs, + lifetimeDays, + modificationsCount: plan.modifications.length, + completionPercentage, + }; +} + +/** + * Calculates portfolio-wide analytics across multiple recurring payment plans. + */ +export function calculatePortfolioAnalytics( + plans: RecurringPaymentPlan[], + options?: { currentTime?: number; forecastHorizonDays?: number } +): PortfolioAnalytics { + const currentTime = options?.currentTime ?? Date.now(); + const forecastHorizonDays = options?.forecastHorizonDays ?? 30; + const horizonTimestamp = currentTime + forecastHorizonDays * MS_PER_DAY; + + const totalPlans = plans.length; + let activePlans = 0; + let pausedPlans = 0; + let completedPlans = 0; + let cancelledPlans = 0; + + let totalVolumeCollected = 0; + let totalProjectedVolume = 0; + let totalExecutionsCount = 0; + let successfulExecutionsCount = 0; + let totalMissedPayments = 0; + let totalOnTimeRateSum = 0; + let plansWithExecutions = 0; + + const volumeByCurrency: Record = {}; + const upcomingCashflow: PortfolioAnalytics['upcomingCashflow'] = []; + + for (const plan of plans) { + if (plan.status === 'active') activePlans++; + else if (plan.status === 'paused') pausedPlans++; + else if (plan.status === 'completed') completedPlans++; + else if (plan.status === 'cancelled') cancelledPlans++; + + const planAnalytics = calculatePlanAnalytics(plan, currentTime); + + totalVolumeCollected += planAnalytics.totalAmountPaid; + totalProjectedVolume += planAnalytics.remainingProjectedAmount; + totalMissedPayments += planAnalytics.missedPaymentsCount; + + const completedExecs = plan.executions.filter((e) => e.status === 'completed').length; + totalExecutionsCount += plan.executions.length; + successfulExecutionsCount += completedExecs; + + if (plan.executions.length > 0) { + totalOnTimeRateSum += planAnalytics.onTimeExecutionRate; + plansWithExecutions++; + } + + const cur = plan.amountConfig.currency; + if (!volumeByCurrency[cur]) { + volumeByCurrency[cur] = { collected: 0, projected: 0 }; + } + volumeByCurrency[cur].collected = roundAmount( + volumeByCurrency[cur].collected + planAnalytics.totalAmountPaid + ); + volumeByCurrency[cur].projected = roundAmount( + volumeByCurrency[cur].projected + planAnalytics.remainingProjectedAmount + ); + + if (plan.status === 'active') { + const upcoming = generateUpcomingSchedule(plan, 10, horizonTimestamp); + for (const item of upcoming) { + upcomingCashflow.push({ + date: item.scheduledDate, + amount: item.amount, + currency: item.currency, + planId: plan.id, + planTitle: plan.title, + }); + } + } + } + + // Sort upcoming cashflow chronologically + upcomingCashflow.sort((a, b) => a.date - b.date); + + const overallOnTimeRate = + plansWithExecutions > 0 ? roundAmount(totalOnTimeRateSum / plansWithExecutions, 2) : 100; + + const averagePlanSize = + totalPlans > 0 + ? roundAmount((totalVolumeCollected + totalProjectedVolume) / totalPlans) + : 0; + + const pauseRate = totalPlans > 0 ? roundAmount((pausedPlans / totalPlans) * 100, 1) : 0; + const completionRate = + totalPlans > 0 ? roundAmount((completedPlans / totalPlans) * 100, 1) : 0; + const churnRate = totalPlans > 0 ? roundAmount((cancelledPlans / totalPlans) * 100, 1) : 0; + + return { + totalPlans, + activePlans, + pausedPlans, + completedPlans, + cancelledPlans, + totalVolumeCollected: roundAmount(totalVolumeCollected), + totalProjectedVolume: roundAmount(totalProjectedVolume), + averagePlanSize, + totalExecutionsCount, + successfulExecutionsCount, + overallOnTimeRate, + totalMissedPayments, + pauseRate, + completionRate, + churnRate, + volumeByCurrency, + upcomingCashflow, + }; +} diff --git a/src/lib/recurringPaymentPlan/index.ts b/src/lib/recurringPaymentPlan/index.ts new file mode 100644 index 00000000..dd127bb1 --- /dev/null +++ b/src/lib/recurringPaymentPlan/index.ts @@ -0,0 +1,6 @@ +export * from '@/types/recurringPaymentPlan'; +export * from './amountEngine'; +export * from './scheduleEngine'; +export * from './modificationEngine'; +export * from './pauseResumeEngine'; +export * from './analyticsEngine'; diff --git a/src/lib/recurringPaymentPlan/modificationEngine.ts b/src/lib/recurringPaymentPlan/modificationEngine.ts new file mode 100644 index 00000000..0ea2038e --- /dev/null +++ b/src/lib/recurringPaymentPlan/modificationEngine.ts @@ -0,0 +1,219 @@ +import type { + RecurringPaymentPlan, + PlanModificationInput, + PlanModificationRecord, + PlanIntervalConfig, + PlanAmountConfig, +} from '@/types/recurringPaymentPlan'; +import { validateAmountConfig } from './amountEngine'; +import { calculateScheduledDateForCycle } from './scheduleEngine'; + +/** + * Validates updated interval configuration. + */ +export function validateIntervalConfig(config: Partial): { + valid: boolean; + errors: string[]; +} { + const errors: string[] = []; + + if (config.intervalCount !== undefined && config.intervalCount < 1) { + errors.push('intervalCount must be at least 1'); + } + + if (config.customIntervalDays !== undefined && config.customIntervalDays < 1) { + errors.push('customIntervalDays must be at least 1'); + } + + if (config.dayOfMonth !== undefined && (config.dayOfMonth < 1 || config.dayOfMonth > 31)) { + errors.push('dayOfMonth must be between 1 and 31'); + } + + if (config.dayOfWeek !== undefined && (config.dayOfWeek < 0 || config.dayOfWeek > 6)) { + errors.push('dayOfWeek must be between 0 (Sunday) and 6 (Saturday)'); + } + + return { + valid: errors.length === 0, + errors, + }; +} + +/** + * Safely applies modifications to a plan, maintaining an audit trail and guaranteeing + * that completed past payments remain strictly immutable. + */ +export function modifyPlan( + plan: RecurringPaymentPlan, + updates: PlanModificationInput, + options?: { timestamp?: number; reason?: string } +): { + success: boolean; + plan: RecurringPaymentPlan; + error?: string; + modifications?: PlanModificationRecord[]; +} { + const now = options?.timestamp ?? Date.now(); + const reason = options?.reason ?? 'Plan updated by user'; + + // 1. Plan state guard + if (plan.status === 'completed' || plan.status === 'cancelled') { + return { + success: false, + plan, + error: `Cannot modify a plan that is '${plan.status}'`, + }; + } + + // 2. Validate maxCycles constraints + if (updates.maxCycles !== undefined && updates.maxCycles !== null) { + if (updates.maxCycles < 1) { + return { + success: false, + plan, + error: 'maxCycles must be at least 1', + }; + } + if (updates.maxCycles < plan.currentCycle) { + return { + success: false, + plan, + error: `Cannot reduce maxCycles (${updates.maxCycles}) below already completed cycles (${plan.currentCycle})`, + }; + } + } + + // 3. Validate endDate constraints + if (updates.endDate !== undefined && updates.endDate !== null) { + if (plan.lastExecutionDate && updates.endDate < plan.lastExecutionDate) { + return { + success: false, + plan, + error: `Cannot set endDate before the last executed payment date`, + }; + } + } + + // 4. Validate intervalConfig if modified + if (updates.intervalConfig) { + const mergedInterval: PlanIntervalConfig = { + ...plan.intervalConfig, + ...updates.intervalConfig, + }; + const validation = validateIntervalConfig(mergedInterval); + if (!validation.valid) { + return { + success: false, + plan, + error: `Invalid interval configuration: ${validation.errors.join(', ')}`, + }; + } + } + + // 5. Validate amountConfig if modified + if (updates.amountConfig) { + const mergedAmount: PlanAmountConfig = { + ...plan.amountConfig, + ...updates.amountConfig, + }; + const validation = validateAmountConfig(mergedAmount); + if (!validation.valid) { + return { + success: false, + plan, + error: `Invalid amount configuration: ${validation.errors.join(', ')}`, + }; + } + } + + // 6. Validate recipients if modified + if (updates.recipients) { + const hasPercentages = updates.recipients.some((r) => r.sharePercentage !== undefined); + if (hasPercentages) { + const totalPct = updates.recipients.reduce((acc, r) => acc + (r.sharePercentage || 0), 0); + if (Math.abs(totalPct - 100) > 0.01) { + return { + success: false, + plan, + error: `Recipient share percentages must total 100% (currently ${totalPct}%)`, + }; + } + } + } + + // 7. Track modifications in audit trail + const newModifications: PlanModificationRecord[] = []; + const effectiveCycle = plan.currentCycle + 1; + + const checkAndRecord = (field: string, oldVal: unknown, newVal: unknown) => { + if (newVal !== undefined && JSON.stringify(oldVal) !== JSON.stringify(newVal)) { + newModifications.push({ + id: `mod_${plan.id}_${field}_${now}_${Math.random().toString(16).substring(2, 6)}`, + timestamp: now, + field, + oldValue: oldVal, + newValue: newVal, + effectiveFromCycle: effectiveCycle, + reason, + }); + } + }; + + if (updates.title !== undefined) checkAndRecord('title', plan.title, updates.title); + if (updates.description !== undefined) checkAndRecord('description', plan.description, updates.description); + if (updates.recipients !== undefined) checkAndRecord('recipients', plan.recipients, updates.recipients); + if (updates.maxCycles !== undefined) checkAndRecord('maxCycles', plan.maxCycles, updates.maxCycles); + if (updates.endDate !== undefined) checkAndRecord('endDate', plan.endDate, updates.endDate); + if (updates.intervalConfig !== undefined) checkAndRecord('intervalConfig', plan.intervalConfig, { ...plan.intervalConfig, ...updates.intervalConfig }); + if (updates.amountConfig !== undefined) checkAndRecord('amountConfig', plan.amountConfig, { ...plan.amountConfig, ...updates.amountConfig }); + + // 8. Safely construct modified plan + const updatedInterval: PlanIntervalConfig = updates.intervalConfig + ? { ...plan.intervalConfig, ...updates.intervalConfig } + : plan.intervalConfig; + + const updatedAmount: PlanAmountConfig = updates.amountConfig + ? { ...plan.amountConfig, ...updates.amountConfig } + : plan.amountConfig; + + let updatedNextDate = plan.nextExecutionDate; + if (updates.intervalConfig && plan.status === 'active') { + // Recalculate upcoming scheduled execution date using new interval settings + updatedNextDate = calculateScheduledDateForCycle( + { startDate: plan.startDate, intervalConfig: updatedInterval }, + plan.currentCycle + 1 + ); + } + + // Check if modified maxCycles or endDate immediately concludes plan + let status = plan.status; + if (updates.maxCycles !== undefined && updates.maxCycles !== null && plan.currentCycle >= updates.maxCycles) { + status = 'completed'; + updatedNextDate = null; + } + if (updates.endDate !== undefined && updates.endDate !== null && updatedNextDate !== null && updatedNextDate > updates.endDate) { + status = 'completed'; + updatedNextDate = null; + } + + const modifiedPlan: RecurringPaymentPlan = { + ...plan, + title: updates.title ?? plan.title, + description: updates.description ?? plan.description, + recipients: updates.recipients ?? plan.recipients, + maxCycles: updates.maxCycles !== undefined ? updates.maxCycles : plan.maxCycles, + endDate: updates.endDate !== undefined ? updates.endDate : plan.endDate, + intervalConfig: updatedInterval, + amountConfig: updatedAmount, + status, + nextExecutionDate: updatedNextDate, + modifications: [...plan.modifications, ...newModifications], + updatedAt: now, + }; + + return { + success: true, + plan: modifiedPlan, + modifications: newModifications, + }; +} diff --git a/src/lib/recurringPaymentPlan/pauseResumeEngine.ts b/src/lib/recurringPaymentPlan/pauseResumeEngine.ts new file mode 100644 index 00000000..2dfd5236 --- /dev/null +++ b/src/lib/recurringPaymentPlan/pauseResumeEngine.ts @@ -0,0 +1,175 @@ +import type { + RecurringPaymentPlan, + PlanPauseRecord, + ResumeSchedulePolicy, +} from '@/types/recurringPaymentPlan'; +import { addIntervalToDate, calculateScheduledDateForCycle } from './scheduleEngine'; + +/** + * Pauses an active recurring payment plan. + */ +export function pausePlan( + plan: RecurringPaymentPlan, + pauseTimestamp: number = Date.now(), + reason?: string +): { + success: boolean; + plan: RecurringPaymentPlan; + error?: string; +} { + if (plan.status !== 'active') { + return { + success: false, + plan, + error: `Cannot pause plan with status '${plan.status}' (must be 'active')`, + }; + } + + const pauseRecord: PlanPauseRecord = { + id: `pause_${plan.id}_${pauseTimestamp}`, + pausedAt: pauseTimestamp, + resumedAt: null, + reason, + }; + + const updatedPlan: RecurringPaymentPlan = { + ...plan, + status: 'paused', + pausedAt: pauseTimestamp, + pauseHistory: [...plan.pauseHistory, pauseRecord], + updatedAt: pauseTimestamp, + modifications: [ + ...plan.modifications, + { + id: `mod_pause_${pauseTimestamp}`, + timestamp: pauseTimestamp, + field: 'status', + oldValue: 'active', + newValue: 'paused', + effectiveFromCycle: plan.currentCycle + 1, + reason: reason || 'Plan paused', + }, + ], + }; + + return { + success: true, + plan: updatedPlan, + }; +} + +/** + * Resumes a paused recurring payment plan according to a specified schedule policy. + */ +export function resumePlan( + plan: RecurringPaymentPlan, + resumeTimestamp: number = Date.now(), + policy: ResumeSchedulePolicy = 'catch_up' +): { + success: boolean; + plan: RecurringPaymentPlan; + error?: string; +} { + if (plan.status !== 'paused') { + return { + success: false, + plan, + error: `Cannot resume plan with status '${plan.status}' (must be 'paused')`, + }; + } + + const pausedAt = plan.pausedAt ?? resumeTimestamp; + const pauseDuration = Math.max(0, resumeTimestamp - pausedAt); + + // Close the latest open pause record + const updatedPauseHistory = plan.pauseHistory.map((record) => { + if (record.resumedAt === null || record.resumedAt === undefined) { + return { ...record, resumedAt: resumeTimestamp }; + } + return record; + }); + + let nextExecutionDate = plan.nextExecutionDate; + let updatedStartDate = plan.startDate; + let updatedEndDate = plan.endDate; + + switch (policy) { + case 'catch_up': + // Preserve the calendar schedule so that any payments that fell due during the pause + // are detected and caught up immediately (fulfills "No missed payments"). + if (!nextExecutionDate || nextExecutionDate < resumeTimestamp) { + // Next due cycle + nextExecutionDate = calculateScheduledDateForCycle(plan, plan.currentCycle + 1); + } + break; + + case 'shift_schedule': + // Shifts all upcoming schedule milestones forward by the exact pause duration. + updatedStartDate = plan.startDate + pauseDuration; + if (updatedEndDate) { + updatedEndDate = updatedEndDate + pauseDuration; + } + if (nextExecutionDate) { + nextExecutionDate = nextExecutionDate + pauseDuration; + } else { + nextExecutionDate = calculateScheduledDateForCycle( + { startDate: updatedStartDate, intervalConfig: plan.intervalConfig }, + plan.currentCycle + 1 + ); + } + break; + + case 'next_scheduled_date': + // Sets next execution to 1 full interval from now + nextExecutionDate = addIntervalToDate(resumeTimestamp, plan.intervalConfig, 1); + break; + } + + const updatedPlan: RecurringPaymentPlan = { + ...plan, + status: 'active', + pausedAt: null, + startDate: updatedStartDate, + endDate: updatedEndDate, + nextExecutionDate, + pauseHistory: updatedPauseHistory, + updatedAt: resumeTimestamp, + modifications: [ + ...plan.modifications, + { + id: `mod_resume_${resumeTimestamp}`, + timestamp: resumeTimestamp, + field: 'status', + oldValue: 'paused', + newValue: 'active', + effectiveFromCycle: plan.currentCycle + 1, + reason: `Plan resumed with policy: ${policy}`, + }, + ], + }; + + return { + success: true, + plan: updatedPlan, + }; +} + +/** + * Checks whether a plan is currently in a paused state. + */ +export function isPlanPaused(plan: RecurringPaymentPlan): boolean { + return plan.status === 'paused'; +} + +/** + * Computes total time spent paused in milliseconds across all pause sessions. + */ +export function calculateTotalPausedDuration( + plan: RecurringPaymentPlan, + currentTimestamp: number = Date.now() +): number { + return plan.pauseHistory.reduce((total, session) => { + const end = session.resumedAt ?? currentTimestamp; + return total + Math.max(0, end - session.pausedAt); + }, 0); +} diff --git a/src/lib/recurringPaymentPlan/scheduleEngine.ts b/src/lib/recurringPaymentPlan/scheduleEngine.ts new file mode 100644 index 00000000..71dbf734 --- /dev/null +++ b/src/lib/recurringPaymentPlan/scheduleEngine.ts @@ -0,0 +1,360 @@ +import type { + RecurringPaymentPlan, + PlanIntervalConfig, + DueExecution, + PlanExecutionRecord, + ScheduledPaymentItem, +} from '@/types/recurringPaymentPlan'; +import { calculateCycleAmount } from './amountEngine'; + +const MS_PER_DAY = 24 * 60 * 60 * 1000; + +/** + * Safely adds an interval to a base date timestamp. + * Correctly accounts for days, weeks, months (with month-end clamping), and years. + */ +export function addIntervalToDate( + baseTimestamp: number, + config: PlanIntervalConfig, + stepCount: number = 1 +): number { + if (stepCount === 0) return baseTimestamp; + + const date = new Date(baseTimestamp); + + switch (config.frequency) { + case 'daily': + return baseTimestamp + stepCount * (config.intervalCount || 1) * MS_PER_DAY; + + case 'weekly': + return baseTimestamp + stepCount * (config.intervalCount || 1) * 7 * MS_PER_DAY; + + case 'biweekly': + return baseTimestamp + stepCount * (config.intervalCount || 1) * 14 * MS_PER_DAY; + + case 'monthly': { + const intervalMonths = stepCount * (config.intervalCount || 1); + const originalDay = config.dayOfMonth ?? date.getDate(); + const targetYear = date.getFullYear(); + const targetMonth = date.getMonth() + intervalMonths; + + const newDate = new Date(targetYear, targetMonth, 1, date.getHours(), date.getMinutes(), date.getSeconds(), date.getMilliseconds()); + // Clamping: ensure day doesn't roll into next month (e.g. Jan 31 -> Feb 28) + const daysInTargetMonth = new Date(newDate.getFullYear(), newDate.getMonth() + 1, 0).getDate(); + const clampedDay = Math.min(originalDay, daysInTargetMonth); + newDate.setDate(clampedDay); + return newDate.getTime(); + } + + case 'quarterly': { + const intervalMonths = stepCount * (config.intervalCount || 1) * 3; + const originalDay = config.dayOfMonth ?? date.getDate(); + const targetYear = date.getFullYear(); + const targetMonth = date.getMonth() + intervalMonths; + + const newDate = new Date(targetYear, targetMonth, 1, date.getHours(), date.getMinutes(), date.getSeconds(), date.getMilliseconds()); + const daysInTargetMonth = new Date(newDate.getFullYear(), newDate.getMonth() + 1, 0).getDate(); + const clampedDay = Math.min(originalDay, daysInTargetMonth); + newDate.setDate(clampedDay); + return newDate.getTime(); + } + + case 'yearly': { + const intervalYears = stepCount * (config.intervalCount || 1); + const originalDay = config.dayOfMonth ?? date.getDate(); + const targetYear = date.getFullYear() + intervalYears; + const targetMonth = date.getMonth(); + + const newDate = new Date(targetYear, targetMonth, 1, date.getHours(), date.getMinutes(), date.getSeconds(), date.getMilliseconds()); + const daysInTargetMonth = new Date(newDate.getFullYear(), newDate.getMonth() + 1, 0).getDate(); + const clampedDay = Math.min(originalDay, daysInTargetMonth); + newDate.setDate(clampedDay); + return newDate.getTime(); + } + + case 'custom': { + if (config.customIntervalDays && config.customIntervalDays > 0) { + return baseTimestamp + stepCount * config.customIntervalDays * MS_PER_DAY; + } + const unit = config.unit || 'days'; + const count = (config.intervalCount || 1) * stepCount; + if (unit === 'days') { + return baseTimestamp + count * MS_PER_DAY; + } else if (unit === 'weeks') { + return baseTimestamp + count * 7 * MS_PER_DAY; + } else if (unit === 'months') { + const originalDay = config.dayOfMonth ?? date.getDate(); + const newDate = new Date(date.getFullYear(), date.getMonth() + count, 1, date.getHours(), date.getMinutes(), date.getSeconds(), date.getMilliseconds()); + const daysInTargetMonth = new Date(newDate.getFullYear(), newDate.getMonth() + 1, 0).getDate(); + newDate.setDate(Math.min(originalDay, daysInTargetMonth)); + return newDate.getTime(); + } else if (unit === 'years') { + const originalDay = config.dayOfMonth ?? date.getDate(); + const newDate = new Date(date.getFullYear() + count, date.getMonth(), 1, date.getHours(), date.getMinutes(), date.getSeconds(), date.getMilliseconds()); + const daysInTargetMonth = new Date(newDate.getFullYear(), newDate.getMonth() + 1, 0).getDate(); + newDate.setDate(Math.min(originalDay, daysInTargetMonth)); + return newDate.getTime(); + } + return baseTimestamp + count * MS_PER_DAY; + } + + default: + return baseTimestamp + stepCount * MS_PER_DAY; + } +} + +/** + * Calculates the exact scheduled timestamp for a given cycle (1-based index). + * Cycle 1 is scheduled at startDate. + * Cycle n is scheduled at startDate + (n - 1) intervals. + */ +export function calculateScheduledDateForCycle( + plan: Pick, + cycle: number +): number { + if (cycle < 1) { + throw new Error(`Cycle must be >= 1 (got ${cycle})`); + } + return addIntervalToDate(plan.startDate, plan.intervalConfig, cycle - 1); +} + +/** + * Evaluates whether a plan has reached its conclusion criteria. + */ +export function isPlanCompleted( + plan: Pick +): boolean { + if (plan.maxCycles !== null && plan.maxCycles !== undefined && plan.currentCycle >= plan.maxCycles) { + return true; + } + if (plan.endDate !== null && plan.endDate !== undefined) { + if (plan.nextExecutionDate !== null && plan.nextExecutionDate > plan.endDate) { + return true; + } + } + return false; +} + +/** + * Identifies all due/overdue executions as of a specific point in time. + * Essential for the "No missed payments" guarantee. + */ +export function getDueExecutions( + plan: RecurringPaymentPlan, + asOfTime: number = Date.now() +): DueExecution[] { + // If plan is not active (paused, completed, cancelled), no payments should execute right now + if (plan.status !== 'active') { + return []; + } + + const dueList: DueExecution[] = []; + let nextCycle = plan.currentCycle + 1; + + while (true) { + if (plan.maxCycles !== null && plan.maxCycles !== undefined && nextCycle > plan.maxCycles) { + break; + } + + const scheduledDate = calculateScheduledDateForCycle(plan, nextCycle); + + if (plan.endDate !== null && plan.endDate !== undefined && scheduledDate > plan.endDate) { + break; + } + + if (scheduledDate <= asOfTime) { + const amount = calculateCycleAmount(plan.amountConfig, nextCycle); + dueList.push({ + cycle: nextCycle, + scheduledDate, + amount, + currency: plan.amountConfig.currency, + isOverdue: asOfTime - scheduledDate > MS_PER_DAY, + }); + nextCycle++; + } else { + break; + } + } + + return dueList; +} + +/** + * Executes a single scheduled payment on the plan. + * Validates plan state, records execution, updates cycles, and computes next execution date. + */ +export function executePayment( + plan: RecurringPaymentPlan, + executionTime: number = Date.now(), + txDetails?: { txHash?: string; note?: string } +): { + success: boolean; + plan: RecurringPaymentPlan; + execution?: PlanExecutionRecord; + error?: string; +} { + if (plan.status !== 'active') { + return { + success: false, + plan, + error: `Cannot execute payment on a plan with status '${plan.status}'`, + }; + } + + const nextCycle = plan.currentCycle + 1; + + if (plan.maxCycles !== null && plan.maxCycles !== undefined && nextCycle > plan.maxCycles) { + return { + success: false, + plan: { ...plan, status: 'completed', nextExecutionDate: null }, + error: `Plan has reached its maximum cycles (${plan.maxCycles})`, + }; + } + + const scheduledDate = calculateScheduledDateForCycle(plan, nextCycle); + + if (plan.endDate !== null && plan.endDate !== undefined && scheduledDate > plan.endDate) { + return { + success: false, + plan: { ...plan, status: 'completed', nextExecutionDate: null }, + error: `Scheduled execution exceeds plan endDate`, + }; + } + + const amount = calculateCycleAmount(plan.amountConfig, nextCycle); + + const executionRecord: PlanExecutionRecord = { + id: `exec_${plan.id}_${nextCycle}_${executionTime}`, + cycle: nextCycle, + scheduledDate, + executedDate: executionTime, + amount, + currency: plan.amountConfig.currency, + status: 'completed', + txHash: txDetails?.txHash || `0x${Math.random().toString(16).substring(2, 10)}`, + note: txDetails?.note, + }; + + const updatedExecutions = [...plan.executions, executionRecord]; + const subsequentCycle = nextCycle + 1; + let nextDate: number | null = calculateScheduledDateForCycle(plan, subsequentCycle); + let status = plan.status; + + if (plan.maxCycles !== null && plan.maxCycles !== undefined && nextCycle >= plan.maxCycles) { + status = 'completed'; + nextDate = null; + } else if (plan.endDate !== null && plan.endDate !== undefined && nextDate > plan.endDate) { + status = 'completed'; + nextDate = null; + } + + const updatedPlan: RecurringPaymentPlan = { + ...plan, + status, + currentCycle: nextCycle, + lastExecutionDate: executionTime, + nextExecutionDate: nextDate, + executions: updatedExecutions, + updatedAt: executionTime, + }; + + return { + success: true, + plan: updatedPlan, + execution: executionRecord, + }; +} + +/** + * Processes all due and overdue payments sequentially to guarantee "No missed payments". + */ +export async function catchUpDuePayments( + plan: RecurringPaymentPlan, + asOfTime: number = Date.now(), + executor?: (record: PlanExecutionRecord) => boolean | Promise +): Promise<{ + plan: RecurringPaymentPlan; + executedCount: number; + errors: string[]; +}> { + let currentPlan = { ...plan }; + let executedCount = 0; + const errors: string[] = []; + + const dueList = getDueExecutions(currentPlan, asOfTime); + + for (const due of dueList) { + const result = executePayment(currentPlan, asOfTime); + if (!result.success || !result.execution) { + errors.push(result.error || `Failed to execute cycle ${due.cycle}`); + break; + } + + if (executor) { + try { + const ok = await executor(result.execution); + if (!ok) { + errors.push(`External payment executor rejected cycle ${due.cycle}`); + break; + } + } catch (err: any) { + errors.push(err.message || `External payment execution exception on cycle ${due.cycle}`); + break; + } + } + + currentPlan = result.plan; + executedCount++; + } + + return { + plan: currentPlan, + executedCount, + errors, + }; +} + +/** + * Projects upcoming payments for visual schedules, previews, and calendars. + */ +export function generateUpcomingSchedule( + plan: RecurringPaymentPlan, + limitCount: number = 10, + untilDate?: number +): ScheduledPaymentItem[] { + const items: ScheduledPaymentItem[] = []; + const startFromCycle = plan.currentCycle + 1; + let count = 0; + + for (let c = startFromCycle; count < limitCount; c++) { + if (plan.maxCycles !== null && plan.maxCycles !== undefined && c > plan.maxCycles) { + break; + } + + const scheduledDate = calculateScheduledDateForCycle(plan, c); + + if (plan.endDate !== null && plan.endDate !== undefined && scheduledDate > plan.endDate) { + break; + } + + if (untilDate && scheduledDate > untilDate) { + break; + } + + const amount = calculateCycleAmount(plan.amountConfig, c); + const now = Date.now(); + + items.push({ + cycle: c, + scheduledDate, + amount, + currency: plan.amountConfig.currency, + status: scheduledDate <= now ? 'due' : 'upcoming', + }); + + count++; + } + + return items; +} diff --git a/src/types/recurringPaymentPlan.ts b/src/types/recurringPaymentPlan.ts new file mode 100644 index 00000000..40209fd6 --- /dev/null +++ b/src/types/recurringPaymentPlan.ts @@ -0,0 +1,206 @@ +export type PlanFrequency = + | 'daily' + | 'weekly' + | 'biweekly' + | 'monthly' + | 'quarterly' + | 'yearly' + | 'custom'; + +export type PlanIntervalUnit = 'days' | 'weeks' | 'months' | 'years'; + +export interface PlanIntervalConfig { + frequency: PlanFrequency; + intervalCount: number; // e.g. 1 (every 1 unit), 2 (every 2 units), etc. + unit?: PlanIntervalUnit; + customIntervalDays?: number; // Used when frequency is 'custom' + dayOfMonth?: number; // Optional anchor day of month (1-31) + dayOfWeek?: number; // Optional anchor day of week (0-6, 0=Sunday) +} + +export type AmountVariationType = + | 'fixed' + | 'scheduled' + | 'tiered' + | 'percentage_step' + | 'milestone'; + +export interface PlanScheduledAmount { + cycle: number; // 1-based cycle index + amount: number; + label?: string; +} + +export interface PlanAmountTier { + upToCycle: number; // Applies for cycles <= upToCycle + amount: number; +} + +export interface PlanPercentageStep { + percentageChange: number; // e.g. 5 for +5%, -10 for -10% + stepFrequencyCycles: number; // Change applies every N cycles (e.g. every 3 cycles) + compound?: boolean; // Default true (compound percentage) + minAmount?: number; + maxAmount?: number; +} + +export interface PlanMilestoneAmount { + cycle: number; + amount: number; + description?: string; +} + +export interface PlanAmountConfig { + type: AmountVariationType; + baseAmount: number; + currency: string; // e.g. 'XLM' | 'USDC' + scheduledAmounts?: PlanScheduledAmount[]; + tieredAmounts?: PlanAmountTier[]; + percentageStep?: PlanPercentageStep; + milestoneAmounts?: PlanMilestoneAmount[]; + minimumAmount?: number; + maximumAmount?: number; +} + +export type PlanStatus = 'active' | 'paused' | 'completed' | 'cancelled'; + +export interface PlanRecipient { + address: string; + sharePercentage?: number; // 0-100 + fixedAmount?: number; +} + +export interface PlanPauseRecord { + id: string; + pausedAt: number; // Unix ms + resumedAt?: number | null; // Unix ms + reason?: string; +} + +export interface PlanModificationRecord { + id: string; + timestamp: number; // Unix ms + field: string; + oldValue: unknown; + newValue: unknown; + effectiveFromCycle: number; + reason?: string; +} + +export interface PlanExecutionRecord { + id: string; + cycle: number; + scheduledDate: number; // Unix ms + executedDate: number; // Unix ms + amount: number; + currency: string; + status: 'completed' | 'failed' | 'skipped'; + txHash?: string; + error?: string; + note?: string; +} + +export interface RecurringPaymentPlan { + id: string; + title: string; + description?: string; + payerAddress: string; + recipientAddress: string; + recipients?: PlanRecipient[]; + status: PlanStatus; + intervalConfig: PlanIntervalConfig; + amountConfig: PlanAmountConfig; + startDate: number; // Unix ms + endDate?: number | null; // Unix ms + maxCycles?: number | null; + currentCycle: number; // 0 initially, increments on each execution + nextExecutionDate: number | null; // Unix ms + lastExecutionDate: number | null; // Unix ms + pausedAt?: number | null; // Unix ms + executions: PlanExecutionRecord[]; + modifications: PlanModificationRecord[]; + pauseHistory: PlanPauseRecord[]; + createdAt: number; + updatedAt: number; + metadata?: Record; +} + +export interface ScheduledPaymentItem { + cycle: number; + scheduledDate: number; // Unix ms + amount: number; + currency: string; + status: 'completed' | 'due' | 'upcoming'; +} + +export interface DueExecution { + cycle: number; + scheduledDate: number; // Unix ms + amount: number; + currency: string; + isOverdue: boolean; +} + +export type ResumeSchedulePolicy = + | 'catch_up' + | 'shift_schedule' + | 'next_scheduled_date'; + +export interface SinglePlanAnalytics { + planId: string; + title: string; + status: PlanStatus; + totalCyclesPlanned: number | null; + completedCycles: number; + remainingCycles: number | null; + totalAmountPaid: number; + remainingProjectedAmount: number; + averagePaymentAmount: number; + minPaymentAmount: number; + maxPaymentAmount: number; + amountVolatility: number; + onTimeExecutionRate: number; // 0 - 100 percentage + missedPaymentsCount: number; + failedPaymentsCount: number; + timesPaused: number; + totalPausedDurationMs: number; + lifetimeDays: number; + modificationsCount: number; + completionPercentage: number; +} + +export interface PortfolioAnalytics { + totalPlans: number; + activePlans: number; + pausedPlans: number; + completedPlans: number; + cancelledPlans: number; + totalVolumeCollected: number; + totalProjectedVolume: number; + averagePlanSize: number; + totalExecutionsCount: number; + successfulExecutionsCount: number; + overallOnTimeRate: number; + totalMissedPayments: number; + pauseRate: number; // 0 - 100 percentage + completionRate: number; // 0 - 100 percentage + churnRate: number; // 0 - 100 percentage + volumeByCurrency: Record; + upcomingCashflow: Array<{ + date: number; + amount: number; + currency: string; + planId: string; + planTitle: string; + }>; +} + +export interface PlanModificationInput { + title?: string; + description?: string; + recipients?: PlanRecipient[]; + intervalConfig?: Partial; + amountConfig?: Partial; + endDate?: number | null; + maxCycles?: number | null; +}