UNPKG

omnipay-savings-sdk

Version:

Omnipay Savings SDK

462 lines (395 loc) 14 kB
import {getFrequencyEnum} from './constants'; import {FrequencyType} from './types'; /** * Interface for savings calculation parameters */ export interface SavingsCalculationParams { savingsTarget: number; selectedFrequency: FrequencyType; startDate: Date; endDate: Date; annualInterestRate: number; // e.g., 10 for 10% isInterestDisabled?: boolean; currentBalance?: number; // For existing savings } /** * Interface for periodic amount calculation parameters */ export interface PeriodicAmountParams { periodicAmount: number; selectedFrequency: FrequencyType; startDate: Date; endDate: Date; annualInterestRate: number; isInterestDisabled?: boolean; currentBalance?: number; } /** * Get the number of days for each frequency type */ const getFrequencyDays = (frequency: FrequencyType): number => { const frequencyEnum = getFrequencyEnum(frequency); switch (frequencyEnum) { case 1: return 1; // Daily case 2: return 7; // Weekly case 3: return 30; // Monthly case 4: return 1; // Save as you collect (treat as daily for calculation) default: return 1; } }; /** * Calculate the total savings period in days */ const getSavingsPeriodDays = (startDate: Date, endDate: Date): number => { const timeDiff = endDate.getTime() - startDate.getTime(); return Math.ceil(timeDiff / (1000 * 3600 * 24)); }; /** * Calculate number of 1st-of-month dates within the period for monthly savings */ const getMonthlyDepositsCount = (startDate: Date, endDate: Date): number => { let count = 0; let currentDate = new Date(startDate); // Find the first 1st of the month on or after start date if (currentDate.getDate() === 1) { // If start date is the 1st, use it currentDate = new Date(currentDate); } else { // Otherwise, move to the 1st of next month currentDate.setMonth(currentDate.getMonth() + 1); currentDate.setDate(1); } // Count all 1st-of-month dates within the period while (currentDate <= endDate) { count++; currentDate.setMonth(currentDate.getMonth() + 1); } return count; }; /** * Get the next Sunday from a given date (or the same date if it's already Sunday) */ const getNextSunday = (date: Date): Date => { const nextSunday = new Date(date); const dayOfWeek = date.getDay(); // 0 = Sunday, 1 = Monday, etc. if (dayOfWeek !== 0) { // If not Sunday, move to next Sunday nextSunday.setDate(date.getDate() + (7 - dayOfWeek)); } return nextSunday; }; /** * Calculate number of Sundays between two dates for weekly savings */ const getSundaysBetween = (startDate: Date, endDate: Date): number => { const firstSunday = getNextSunday(startDate); // If the first Sunday is after the end date, no Sundays in range if (firstSunday > endDate) { return 0; } // Count Sundays from first Sunday to end date const timeDiff = endDate.getTime() - firstSunday.getTime(); const daysDiff = Math.floor(timeDiff / (1000 * 3600 * 24)); return Math.floor(daysDiff / 7) + 1; // +1 to include the first Sunday }; /** * Calculate final balance with compound interest * Interest accrues daily but compounds monthly (on the 1st of each month) */ export const calculateFinalBalanceWithCompoundInterest = ( params: PeriodicAmountParams, ): number => { const { periodicAmount, selectedFrequency, startDate, endDate, annualInterestRate, isInterestDisabled = false, currentBalance = 0, } = params; if (!periodicAmount || !selectedFrequency || !startDate || !endDate) return currentBalance; const annualRate = annualInterestRate / 100; const dailyRate = annualRate / 365; const frequencyDays = getFrequencyDays(selectedFrequency); let balance = currentBalance; let accruedInterest = 0; const currentDate = new Date(startDate); const finalDate = new Date(endDate); // Track when to add periodic deposits let nextDepositDate = new Date(startDate); const frequencyEnum = getFrequencyEnum(selectedFrequency); // Set initial deposit date based on frequency if (frequencyEnum === 2) { // Weekly - start from the first Sunday nextDepositDate = getNextSunday(startDate); } else if (frequencyEnum === 3) { // Monthly - start from the 1st of the appropriate month if (startDate.getDate() === 1) { // If start date is the 1st, use it nextDepositDate = new Date(startDate); } else { // Otherwise, move to the 1st of next month nextDepositDate = new Date(startDate); nextDepositDate.setMonth(nextDepositDate.getMonth() + 1); nextDepositDate.setDate(1); } } while (currentDate <= finalDate) { // Add periodic deposit if it's time if (currentDate >= nextDepositDate) { balance += periodicAmount; // Calculate next deposit date based on frequency type nextDepositDate = new Date(nextDepositDate); if (frequencyEnum === 3) { // Monthly - move to 1st of next month nextDepositDate.setMonth(nextDepositDate.getMonth() + 1); nextDepositDate.setDate(1); } else if (frequencyEnum === 2) { // Weekly - use next Sunday nextDepositDate.setDate(nextDepositDate.getDate() + 7); } else { // For daily and save-as-you-collect, use day counting nextDepositDate.setDate(nextDepositDate.getDate() + frequencyDays); } } // Accrue daily interest on current balance (only if interest is enabled) if (balance > 0 && !isInterestDisabled) { accruedInterest += balance * dailyRate; } // Compound interest monthly (add to balance on 1st of each month) if (currentDate.getDate() === 1 && accruedInterest > 0) { balance += accruedInterest; accruedInterest = 0; } // Move to next day currentDate.setDate(currentDate.getDate() + 1); } // Add any remaining accrued interest at the end balance += accruedInterest; return balance; }; /** * Calculate total interest earned over the savings period */ export const calculateProjectedInterestEarnings = ( params: PeriodicAmountParams, ): number => { const { periodicAmount, selectedFrequency, startDate, endDate, annualInterestRate, isInterestDisabled = false, currentBalance = 0, } = params; if ( !periodicAmount || !annualInterestRate || isInterestDisabled || !startDate || !endDate ) return 0; const annualRate = annualInterestRate / 100; const dailyRate = annualRate / 365; const frequencyDays = getFrequencyDays(selectedFrequency); let balance = currentBalance; let accruedInterest = 0; let totalInterestEarned = 0; const currentDate = new Date(startDate); const finalDate = new Date(endDate); // Track when to add periodic deposits let nextDepositDate = new Date(startDate); const frequencyEnum = getFrequencyEnum(selectedFrequency); // Set initial deposit date based on frequency if (frequencyEnum === 2) { // Weekly - start from the first Sunday nextDepositDate = getNextSunday(startDate); } else if (frequencyEnum === 3) { // Monthly - start from the 1st of the appropriate month if (startDate.getDate() === 1) { // If start date is the 1st, use it nextDepositDate = new Date(startDate); } else { // Otherwise, move to the 1st of next month nextDepositDate = new Date(startDate); nextDepositDate.setMonth(nextDepositDate.getMonth() + 1); nextDepositDate.setDate(1); } } while (currentDate <= finalDate) { // Add periodic deposit if it's time if (currentDate >= nextDepositDate) { balance += periodicAmount; // Calculate next deposit date based on frequency type nextDepositDate = new Date(nextDepositDate); if (frequencyEnum === 3) { // Monthly - move to 1st of next month nextDepositDate.setMonth(nextDepositDate.getMonth() + 1); nextDepositDate.setDate(1); } else if (frequencyEnum === 2) { // Weekly - use next Sunday nextDepositDate.setDate(nextDepositDate.getDate() + 7); } else { // For daily and save-as-you-collect, use day counting nextDepositDate.setDate(nextDepositDate.getDate() + frequencyDays); } } // Accrue daily interest on current balance if (balance > 0) { accruedInterest += balance * dailyRate; } // Compound interest monthly (add to balance on 1st of each month) if (currentDate.getDate() === 1 && accruedInterest > 0) { balance += accruedInterest; totalInterestEarned += accruedInterest; accruedInterest = 0; } // Move to next day currentDate.setDate(currentDate.getDate() + 1); } // Add any remaining accrued interest at the end totalInterestEarned += accruedInterest; return totalInterestEarned; }; /** * Calculate the required periodic amount to reach a savings target, considering compound interest * Uses binary search to find the optimal periodic amount */ export const calculateRequiredPeriodicAmountWithInterest = ( params: SavingsCalculationParams, ): number => { const { savingsTarget, selectedFrequency, startDate, endDate, annualInterestRate, isInterestDisabled = false, currentBalance = 0, } = params; if (!savingsTarget || !selectedFrequency || !startDate || !endDate) return 0; const periodDays = getSavingsPeriodDays(startDate, endDate); const frequencyDays = getFrequencyDays(selectedFrequency); const frequencyEnum = getFrequencyEnum(selectedFrequency); // Use appropriate counting method based on frequency type const numberOfPeriods = (() => { if (frequencyEnum === 3) { // Monthly - count actual 1st-of-month dates return Math.max(1, getMonthlyDepositsCount(startDate, endDate)); } else if (frequencyEnum === 2) { // Weekly - count Sundays return Math.max(1, getSundaysBetween(startDate, endDate)); } else { // Daily and save-as-you-collect - use day-based calculation return Math.floor(periodDays / frequencyDays); } })(); if (numberOfPeriods <= 0) return 0; // If interest is disabled, use simple calculation if (isInterestDisabled || annualInterestRate === 0) { const remainingTarget = Math.max(0, savingsTarget - currentBalance); return Math.ceil(remainingTarget / numberOfPeriods); } // Account for current balance - we need less savings target const remainingTarget = Math.max(0, savingsTarget - currentBalance); if (remainingTarget <= 0) return 0; // Binary search to find the optimal periodic amount let low = 0; let high = remainingTarget; // Start with a reasonable upper bound let bestAmount = 0; const tolerance = 1; // Allow 1 naira tolerance // If interest will likely contribute significantly, reduce the upper bound if (annualInterestRate > 0) { // Rough estimate: reduce upper bound by potential interest contribution const estimatedInterestContribution = (remainingTarget * (annualInterestRate / 100) * (periodDays / 365)) / 2; high = Math.max(100, remainingTarget - estimatedInterestContribution); } let iterations = 0; const maxIterations = 50; // Prevent infinite loops while (low <= high && iterations < maxIterations) { iterations++; const mid = Math.floor((low + high) / 2); // Calculate what the final balance would be with this periodic amount const finalBalance = calculateFinalBalanceWithCompoundInterest({ periodicAmount: mid, selectedFrequency, startDate, endDate, annualInterestRate, isInterestDisabled, currentBalance, }); const difference = finalBalance - savingsTarget; // If we're within tolerance, we found a good amount if (Math.abs(difference) <= tolerance) { bestAmount = mid; break; } if (finalBalance < savingsTarget) { // Need to save more low = mid + 1; bestAmount = mid + 1; // Keep track of the last insufficient amount } else { // We're saving too much, can reduce high = mid - 1; bestAmount = mid; // This amount reaches the target } } // Ensure we don't return 0 if we couldn't find exact match if (bestAmount === 0 && remainingTarget > 0) { // Fallback to simple calculation with a small buffer for interest const simpleAmount = Math.ceil(remainingTarget / numberOfPeriods); const interestBuffer = isInterestDisabled ? 0 : Math.max(1, simpleAmount * 0.05); // 5% buffer bestAmount = Math.max(1, simpleAmount - interestBuffer); } return Math.max(1, Math.ceil(bestAmount)); // Always return at least 1 naira }; /** * Simple calculation without interest (for backward compatibility) */ export const calculateRequiredPeriodicAmountSimple = ( params: Omit< SavingsCalculationParams, 'annualInterestRate' | 'isInterestDisabled' >, ): number => { const { savingsTarget, selectedFrequency, startDate, endDate, currentBalance = 0, } = params; if (!savingsTarget || !selectedFrequency || !startDate || !endDate) return 0; const periodDays = getSavingsPeriodDays(startDate, endDate); const frequencyDays = getFrequencyDays(selectedFrequency); const frequencyEnum = getFrequencyEnum(selectedFrequency); // Use appropriate counting method based on frequency type const numberOfPeriods = (() => { if (frequencyEnum === 3) { // Monthly - count actual 1st-of-month dates return Math.max(1, getMonthlyDepositsCount(startDate, endDate)); } else if (frequencyEnum === 2) { // Weekly - count Sundays return Math.max(1, getSundaysBetween(startDate, endDate)); } else { // Daily and save-as-you-collect - use day-based calculation return Math.floor(periodDays / frequencyDays); } })(); if (numberOfPeriods <= 0) return 0; const remainingTarget = Math.max(0, savingsTarget - currentBalance); return Math.ceil(remainingTarget / numberOfPeriods); };