omnipay-savings-sdk
Version:
Omnipay Savings SDK
462 lines (395 loc) • 14 kB
text/typescript
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);
};