1062 lines
32 KiB
TypeScript
1062 lines
32 KiB
TypeScript
import { computed, ref } from "vue";
|
|
import type {
|
|
Transaction,
|
|
RevenueOpportunity,
|
|
CashFlowProjection,
|
|
RunwayScenarios,
|
|
CashPosition,
|
|
TransactionForecast,
|
|
CombinedCashFlow,
|
|
AffordabilityCheck,
|
|
} from "~/types/cashflow";
|
|
|
|
export const useCashFlow = () => {
|
|
const currentBalance = ref(0);
|
|
const accountBalances = ref<any>(null);
|
|
const transactions = ref<Transaction[]>([]);
|
|
const revenueOpportunities = ref<RevenueOpportunity[]>([]);
|
|
|
|
// Exchange rates for balance calculations - updated from Wise API
|
|
const exchangeRates = ref({
|
|
CAD: 1.0,
|
|
EUR: 1.45, // Will be updated from Wise API
|
|
USD: 1.35, // Fallback rate
|
|
GBP: 1.65 // Fallback rate
|
|
});
|
|
// All transactions are now personal
|
|
const filteredTransactions = computed(() => transactions.value);
|
|
|
|
// Balance tracking
|
|
const currentBalanceWithTransactions = computed(() => {
|
|
const transactionTotal = transactions.value
|
|
.reduce((sum, t) => sum + t.amount * t.probability, 0);
|
|
return currentBalance.value + transactionTotal;
|
|
});
|
|
|
|
// Starting balance (without transaction projections)
|
|
const startingBalance = computed(() => {
|
|
if (!accountBalances.value) {
|
|
return currentBalance.value;
|
|
}
|
|
|
|
const manual = accountBalances.value.manual || {};
|
|
const wise = accountBalances.value.wise || { jennie: [], henry: [] };
|
|
|
|
// Personal accounts: RBC + TD + Millennium + Wise
|
|
let totalBalance = 0;
|
|
totalBalance += manual.rbc_cad || 0;
|
|
totalBalance += manual.td_cad || 0;
|
|
totalBalance += (manual.millennium_eur || 0) * exchangeRates.value.EUR;
|
|
|
|
// Add Wise balances
|
|
[...(wise.jennie || []), ...(wise.henry || [])].forEach(balance => {
|
|
const rate = exchangeRates.value[balance.currency] || 1;
|
|
totalBalance += balance.value.value * rate;
|
|
});
|
|
|
|
return totalBalance;
|
|
});
|
|
|
|
// Fetch real-time exchange rates for balance calculations
|
|
const fetchExchangeRates = async () => {
|
|
try {
|
|
// Fetch EUR to CAD rate (most common conversion)
|
|
const eurResponse = await fetch('/api/wise/exchange-rates?source=EUR&target=CAD');
|
|
if (eurResponse.ok) {
|
|
const eurData = await eurResponse.json();
|
|
exchangeRates.value.EUR = eurData.rate;
|
|
}
|
|
} catch (error) {
|
|
console.error('Failed to update exchange rates:', error);
|
|
// Keep using fallback rates
|
|
}
|
|
};
|
|
|
|
// Load data from MongoDB on initialization
|
|
const loadData = async () => {
|
|
try {
|
|
// Load transactions
|
|
const transactionsResponse = await fetch("/api/transactions");
|
|
if (transactionsResponse.ok) {
|
|
const transactionsData = await transactionsResponse.json();
|
|
transactions.value = transactionsData.map((t: any) => ({
|
|
...t,
|
|
date: new Date(t.date),
|
|
endDate: t.endDate ? new Date(t.endDate) : undefined,
|
|
// Migration: set accountType to 'personal' if missing
|
|
accountType: t.accountType || "personal",
|
|
}));
|
|
}
|
|
|
|
// Load balance
|
|
const balanceResponse = await fetch("/api/balances");
|
|
if (balanceResponse.ok) {
|
|
const balanceData = await balanceResponse.json();
|
|
currentBalance.value = balanceData.currentBalance || 0;
|
|
accountBalances.value = balanceData.accountBalances || null;
|
|
|
|
// Fetch current exchange rates for accurate balance calculations
|
|
await fetchExchangeRates();
|
|
}
|
|
} catch (error) {
|
|
console.error("Failed to load data from MongoDB:", error);
|
|
// Try to migrate from localStorage as fallback
|
|
await tryMigrateFromLocalStorage();
|
|
}
|
|
};
|
|
|
|
// Attempt to migrate from localStorage if MongoDB fails
|
|
const tryMigrateFromLocalStorage = async () => {
|
|
if (typeof window !== "undefined") {
|
|
const savedBalance = localStorage.getItem("cashflow_balance");
|
|
const savedTransactions = localStorage.getItem("cashflow_transactions");
|
|
|
|
if (savedBalance || savedTransactions) {
|
|
try {
|
|
const migrationData = {
|
|
balance: savedBalance ? parseFloat(savedBalance) : 0,
|
|
transactions: savedTransactions
|
|
? JSON.parse(savedTransactions)
|
|
: [],
|
|
};
|
|
|
|
const response = await fetch("/api/migrate/localStorage", {
|
|
method: "POST",
|
|
headers: { "Content-Type": "application/json" },
|
|
body: JSON.stringify(migrationData),
|
|
});
|
|
|
|
if (response.ok) {
|
|
console.log("Successfully migrated localStorage data to MongoDB");
|
|
// Clear localStorage after successful migration
|
|
localStorage.removeItem("cashflow_balance");
|
|
localStorage.removeItem("cashflow_transactions");
|
|
localStorage.removeItem("account_balances");
|
|
// Reload data from MongoDB
|
|
await loadData();
|
|
}
|
|
} catch (error) {
|
|
console.error("Migration failed:", error);
|
|
// Fall back to localStorage data
|
|
currentBalance.value = savedBalance ? parseFloat(savedBalance) : 0;
|
|
const parsedTransactions = savedTransactions
|
|
? JSON.parse(savedTransactions)
|
|
: [];
|
|
transactions.value = parsedTransactions.map((t: any) => ({
|
|
...t,
|
|
}));
|
|
}
|
|
}
|
|
}
|
|
};
|
|
|
|
// Initialize data loading
|
|
if (typeof window !== "undefined") {
|
|
loadData();
|
|
}
|
|
|
|
// Save to MongoDB helper
|
|
const saveToDatabase = async () => {
|
|
try {
|
|
// Save transactions
|
|
const response = await fetch("/api/transactions/bulk", {
|
|
method: "PUT",
|
|
headers: { "Content-Type": "application/json" },
|
|
body: JSON.stringify(transactions.value),
|
|
});
|
|
|
|
if (!response.ok) {
|
|
throw new Error("Failed to save transactions");
|
|
}
|
|
|
|
console.log("✅ Transactions saved to MongoDB");
|
|
} catch (error) {
|
|
console.error("Failed to save to MongoDB:", error);
|
|
// Fallback to localStorage if MongoDB fails
|
|
if (typeof window !== "undefined") {
|
|
localStorage.setItem(
|
|
"cashflow_balance",
|
|
currentBalance.value.toString()
|
|
);
|
|
localStorage.setItem(
|
|
"cashflow_transactions",
|
|
JSON.stringify(transactions.value)
|
|
);
|
|
}
|
|
}
|
|
};
|
|
|
|
// Update data methods
|
|
const updateTransactions = async (newTransactions: any[]) => {
|
|
console.log(
|
|
"🔄 updateTransactions called with:",
|
|
newTransactions.length,
|
|
"transactions"
|
|
);
|
|
|
|
transactions.value = newTransactions.map((t: any) => ({
|
|
...t,
|
|
type: t.type || (t.isRecurring ? "recurring" : "one-time"),
|
|
status: t.status || "committed",
|
|
frequency:
|
|
t.frequency ||
|
|
(t.isRecurring || t.type === "recurring" ? "monthly" : undefined),
|
|
probability: t.isConfirmed
|
|
? 1
|
|
: typeof t.probability === "number" &&
|
|
t.probability >= 0 &&
|
|
t.probability <= 1
|
|
? t.probability
|
|
: 0.5,
|
|
date: new Date(t.date),
|
|
endDate: t.endDate ? new Date(t.endDate) : undefined,
|
|
}));
|
|
|
|
console.log(
|
|
"✅ Transactions updated. Internal count:",
|
|
transactions.value.length
|
|
);
|
|
|
|
// Save to MongoDB
|
|
await saveToDatabase();
|
|
};
|
|
|
|
const updateRevenueOpportunities = (newOpportunities: any[]) => {
|
|
revenueOpportunities.value = newOpportunities.map((o: any) => ({
|
|
...o,
|
|
targetDate: new Date(o.targetDate),
|
|
}));
|
|
};
|
|
|
|
// Calculate current cash position including recurring projections
|
|
const currentCashPosition = computed((): CashPosition => {
|
|
const now = new Date();
|
|
|
|
const historicalInflow = filteredTransactions.value
|
|
.filter((t) => {
|
|
const transactionDate = new Date(t.date);
|
|
const isHistorical = t.status === "actual" || transactionDate <= now;
|
|
return t.amount > 0 && isHistorical;
|
|
})
|
|
.reduce((sum, t) => sum + t.amount * t.probability, 0);
|
|
|
|
const historicalOutflow = filteredTransactions.value
|
|
.filter((t) => {
|
|
const transactionDate = new Date(t.date);
|
|
const isHistorical = t.status === "actual" || transactionDate <= now;
|
|
return t.amount < 0 && isHistorical;
|
|
})
|
|
.reduce((sum, t) => sum + Math.abs(t.amount * t.probability), 0);
|
|
|
|
const currentMonthRecurringInflow = calculateRecurringInflowForAccount(now);
|
|
const currentMonthRecurringOutflow =
|
|
calculateRecurringOutflowForAccount(now);
|
|
|
|
return {
|
|
balance: currentBalanceWithTransactions.value,
|
|
totalInflow: historicalInflow,
|
|
totalOutflow: historicalOutflow,
|
|
netFlow: currentMonthRecurringInflow - currentMonthRecurringOutflow,
|
|
};
|
|
});
|
|
|
|
// Calculate monthly burn rate including recurring transactions
|
|
const monthlyBurnRate = computed(() => {
|
|
const now = new Date();
|
|
const lastMonth = new Date(now.getFullYear(), now.getMonth() - 1, 1);
|
|
const thisMonth = new Date(now.getFullYear(), now.getMonth(), 1);
|
|
|
|
const historicalExpenses = filteredTransactions.value
|
|
.filter((t) => {
|
|
const transactionDate = new Date(t.date);
|
|
return (
|
|
transactionDate >= lastMonth &&
|
|
transactionDate < thisMonth &&
|
|
t.amount < 0 &&
|
|
t.type === "one-time"
|
|
);
|
|
})
|
|
.reduce((sum, t) => sum + Math.abs(t.amount * t.probability), 0);
|
|
|
|
const currentRecurringOutflow = calculateRecurringOutflowForAccount(now);
|
|
|
|
const futureRecurringOutflow = filteredTransactions.value
|
|
.filter((t) => {
|
|
if (t.type !== "recurring" || t.amount >= 0) return false;
|
|
const startDate = new Date(t.date);
|
|
const endDate = t.endDate
|
|
? new Date(t.endDate)
|
|
: new Date("2099-12-31");
|
|
return startDate > now && startDate <= endDate;
|
|
})
|
|
.reduce(
|
|
(sum, t) => sum + Math.abs(getMonthlyAmount(t) * t.probability),
|
|
0
|
|
);
|
|
|
|
return (
|
|
historicalExpenses + currentRecurringOutflow + futureRecurringOutflow
|
|
);
|
|
});
|
|
|
|
// Enhanced runway calculation with multiple scenarios
|
|
const calculateRunwayScenarios = (
|
|
programBudgets?: Record<string, any>
|
|
): RunwayScenarios => {
|
|
const totalContingency = programBudgets
|
|
? Object.values(programBudgets).reduce(
|
|
(sum, budget: any) => sum + (budget.contingency?.amount || 0),
|
|
0
|
|
)
|
|
: 0;
|
|
|
|
return {
|
|
conservative: calculateRunwayFromTransactions(
|
|
transactions.value.filter((t) => t.probability === 1.0),
|
|
startingBalance.value - totalContingency
|
|
),
|
|
realistic: calculateRunwayFromTransactions(
|
|
transactions.value,
|
|
startingBalance.value - totalContingency * 0.5
|
|
),
|
|
optimistic: calculateRunwayFromTransactions(
|
|
transactions.value.map((t) => ({ ...t, probability: 1.0 })),
|
|
startingBalance.value
|
|
),
|
|
cashOnly: (() => {
|
|
const dailyBurn = monthlyBurnRate.value / 30;
|
|
if (dailyBurn <= 0) return Infinity;
|
|
return Math.floor(startingBalance.value / dailyBurn);
|
|
})(),
|
|
};
|
|
};
|
|
|
|
// Calculate runway from transactions chronologically
|
|
const calculateRunwayFromTransactions = (
|
|
transactionList: Transaction[],
|
|
startingBalance: number
|
|
) => {
|
|
let runningBalance = startingBalance;
|
|
let dayCounter = 0;
|
|
const now = new Date();
|
|
|
|
// Use all transactions (personal only)
|
|
const filteredList = transactionList;
|
|
const futureTransactions = getFutureTransactionsWithOccurrences(
|
|
filteredList,
|
|
now
|
|
);
|
|
const sortedTransactions = futureTransactions.sort(
|
|
(a, b) => new Date(a.date).getTime() - new Date(b.date).getTime()
|
|
);
|
|
|
|
for (const transaction of sortedTransactions) {
|
|
const daysSinceStart = Math.floor(
|
|
(new Date(transaction.date).getTime() - now.getTime()) /
|
|
(1000 * 60 * 60 * 24)
|
|
);
|
|
|
|
const weightedAmount = transaction.amount * transaction.probability;
|
|
|
|
if (transaction.probability < 0.2) continue;
|
|
|
|
runningBalance += weightedAmount;
|
|
dayCounter = daysSinceStart;
|
|
|
|
if (runningBalance <= 0) {
|
|
const prevTransaction =
|
|
sortedTransactions[sortedTransactions.indexOf(transaction) - 1];
|
|
if (prevTransaction) {
|
|
const prevBalance = runningBalance - weightedAmount;
|
|
const interpolatedDay =
|
|
dayCounter - 1 + prevBalance / (prevBalance - runningBalance);
|
|
return Math.max(0, Math.round(interpolatedDay));
|
|
}
|
|
return Math.max(0, dayCounter);
|
|
}
|
|
}
|
|
|
|
if (runningBalance > 0) {
|
|
const monthlyBurn = monthlyBurnRate.value;
|
|
if (monthlyBurn <= 0) return Infinity;
|
|
const additionalMonths = runningBalance / monthlyBurn;
|
|
const additionalDays = additionalMonths * 30;
|
|
return Math.round(dayCounter + additionalDays);
|
|
}
|
|
|
|
return dayCounter;
|
|
};
|
|
|
|
// Get future transactions including recurring occurrences
|
|
const getFutureTransactionsWithOccurrences = (
|
|
transactionList: Transaction[],
|
|
fromDate: Date
|
|
) => {
|
|
const futureTransactions: Transaction[] = [];
|
|
const projectionPeriod = 365;
|
|
|
|
for (const transaction of transactionList) {
|
|
if (transaction.isTest) continue;
|
|
|
|
if (transaction.type === "one-time") {
|
|
const transactionDate = new Date(transaction.date);
|
|
if (transactionDate > fromDate) {
|
|
futureTransactions.push(transaction);
|
|
}
|
|
} else if (transaction.type === "recurring") {
|
|
const startDate = new Date(transaction.date);
|
|
const endDate = transaction.endDate
|
|
? new Date(transaction.endDate)
|
|
: new Date("2099-12-31");
|
|
|
|
if (endDate > fromDate) {
|
|
const occurrences = generateRecurringOccurrences(
|
|
transaction,
|
|
fromDate,
|
|
projectionPeriod
|
|
);
|
|
futureTransactions.push(...occurrences);
|
|
}
|
|
}
|
|
}
|
|
|
|
for (const opportunity of revenueOpportunities.value) {
|
|
const opportunityDate = new Date(opportunity.targetDate);
|
|
if (opportunityDate > fromDate) {
|
|
futureTransactions.push({
|
|
id: opportunity._id || opportunity.id,
|
|
date: opportunityDate,
|
|
description: `Pipeline: ${opportunity.source}`,
|
|
amount: opportunity.amount,
|
|
category: "Income",
|
|
type: "one-time",
|
|
probability: opportunity.probability,
|
|
isConfirmed: false,
|
|
status: "projected",
|
|
notes: `Stage: ${opportunity.stage}`,
|
|
});
|
|
}
|
|
}
|
|
|
|
return futureTransactions;
|
|
};
|
|
|
|
// Generate recurring transaction occurrences
|
|
const generateRecurringOccurrences = (
|
|
transaction: Transaction,
|
|
fromDate: Date,
|
|
days: number
|
|
) => {
|
|
const occurrences: Transaction[] = [];
|
|
const endDate = new Date(fromDate);
|
|
endDate.setDate(endDate.getDate() + days);
|
|
|
|
const transactionEndDate = transaction.endDate
|
|
? new Date(transaction.endDate)
|
|
: endDate;
|
|
const actualEndDate =
|
|
transactionEndDate < endDate ? transactionEndDate : endDate;
|
|
|
|
let currentDate = new Date(
|
|
Math.max(fromDate.getTime(), new Date(transaction.date).getTime())
|
|
);
|
|
|
|
while (currentDate <= actualEndDate) {
|
|
const frequency = transaction.frequency || "monthly";
|
|
let nextDate: Date;
|
|
|
|
switch (frequency) {
|
|
case "weekly":
|
|
nextDate = new Date(currentDate);
|
|
nextDate.setDate(nextDate.getDate() + 7);
|
|
break;
|
|
case "biweekly":
|
|
nextDate = new Date(currentDate);
|
|
nextDate.setDate(nextDate.getDate() + 14);
|
|
break;
|
|
case "monthly":
|
|
nextDate = new Date(currentDate);
|
|
nextDate.setMonth(nextDate.getMonth() + 1);
|
|
break;
|
|
case "quarterly":
|
|
nextDate = new Date(currentDate);
|
|
nextDate.setMonth(nextDate.getMonth() + 3);
|
|
break;
|
|
case "custom":
|
|
nextDate = new Date(currentDate);
|
|
nextDate.setMonth(
|
|
nextDate.getMonth() + (transaction.customMonths || 1)
|
|
);
|
|
break;
|
|
default:
|
|
nextDate = new Date(currentDate);
|
|
nextDate.setMonth(nextDate.getMonth() + 1);
|
|
}
|
|
|
|
if (currentDate > fromDate && currentDate <= actualEndDate) {
|
|
occurrences.push({
|
|
...transaction,
|
|
date: new Date(currentDate),
|
|
id: `${transaction.id}-${currentDate.getTime()}`,
|
|
});
|
|
}
|
|
|
|
currentDate = nextDate;
|
|
|
|
if (occurrences.length > 1000) break;
|
|
}
|
|
|
|
return occurrences;
|
|
};
|
|
|
|
// Calculate runway in days (now uses realistic scenario)
|
|
const runwayDays = computed(() => {
|
|
const scenarios = calculateRunwayScenarios();
|
|
return scenarios.realistic;
|
|
});
|
|
|
|
// Calculate risk level
|
|
const riskLevel = computed(() => {
|
|
const runway = runwayDays.value;
|
|
const balance = currentBalance.value;
|
|
|
|
if (runway < 30 || balance < 25000) return "critical";
|
|
if (runway < 60 || balance < 50000) return "high";
|
|
if (runway < 90 || balance < 75000) return "medium";
|
|
return "low";
|
|
});
|
|
|
|
// Calculate available cash (unrestricted funds only)
|
|
const availableCash = computed(() => {
|
|
return currentBalance.value;
|
|
});
|
|
|
|
// Calculate restricted cash
|
|
const restrictedCash = computed(() => {
|
|
return 0;
|
|
});
|
|
|
|
// Calculate total cash
|
|
const totalCash = computed(() => {
|
|
return availableCash.value + restrictedCash.value;
|
|
});
|
|
|
|
// Calculate available runway based on the standard runway calculation
|
|
const availableRunwayDays = computed(() => {
|
|
return runwayDays.value;
|
|
});
|
|
|
|
// Available cash risk level
|
|
const availableRiskLevel = computed(() => {
|
|
const runway = availableRunwayDays.value;
|
|
const balance = availableCash.value;
|
|
|
|
if (runway < 14 || balance < 10000) return "critical";
|
|
if (runway < 28 || balance < 25000) return "high";
|
|
if (runway < 42 || balance < 40000) return "medium";
|
|
return "low";
|
|
});
|
|
|
|
// Generate cash flow projections
|
|
const generateProjections = (
|
|
months: number = 12,
|
|
timeContext?: { startDate?: Date; endDate?: Date }
|
|
) => {
|
|
const projections: CashFlowProjection[] = [];
|
|
let runningBalance = currentBalance.value;
|
|
|
|
if (timeContext && timeContext.startDate && timeContext.endDate) {
|
|
const startDate = new Date(timeContext.startDate);
|
|
const endDate = new Date(timeContext.endDate);
|
|
const monthsDiff = Math.round(
|
|
(endDate.getTime() - startDate.getTime()) / (1000 * 60 * 60 * 24 * 30)
|
|
);
|
|
months = Math.max(1, Math.min(monthsDiff, 24));
|
|
}
|
|
|
|
for (let i = 0; i < months; i++) {
|
|
const projectionDate = new Date();
|
|
projectionDate.setMonth(projectionDate.getMonth() + i);
|
|
|
|
const monthlyInflow = calculateRecurringInflow(projectionDate);
|
|
const monthlyOutflow = calculateRecurringOutflow(projectionDate);
|
|
|
|
const oneTimeTransactions =
|
|
getOneTimeTransactionsForMonth(projectionDate);
|
|
|
|
let oneTimeInflow = 0;
|
|
let oneTimeOutflow = 0;
|
|
|
|
oneTimeTransactions.forEach((t) => {
|
|
const amount = t.amount * t.probability;
|
|
if (amount > 0) {
|
|
if (t.status === "actual" || t.status === "committed") {
|
|
oneTimeInflow += amount;
|
|
}
|
|
} else {
|
|
oneTimeOutflow += Math.abs(amount);
|
|
}
|
|
});
|
|
|
|
const opportunityInflow = 0;
|
|
|
|
const totalInflow = monthlyInflow + oneTimeInflow + opportunityInflow;
|
|
const totalOutflow = monthlyOutflow + oneTimeOutflow;
|
|
const netFlow = totalInflow - totalOutflow;
|
|
|
|
runningBalance += netFlow;
|
|
|
|
projections.push({
|
|
date: new Date(projectionDate),
|
|
balance: runningBalance,
|
|
inflow: totalInflow,
|
|
outflow: totalOutflow,
|
|
netFlow: netFlow,
|
|
projectedBalance: runningBalance,
|
|
});
|
|
}
|
|
|
|
return projections;
|
|
};
|
|
|
|
// Generate weekly cash flow projections
|
|
const generateWeeklyProjections = (weeks: number = 13) => {
|
|
const projections: CashFlowProjection[] = [];
|
|
let runningBalance = currentBalance.value;
|
|
|
|
for (let i = 0; i < weeks; i++) {
|
|
const weekStart = new Date();
|
|
weekStart.setDate(weekStart.getDate() + i * 7);
|
|
|
|
const weekEnd = new Date(weekStart);
|
|
weekEnd.setDate(weekEnd.getDate() + 6);
|
|
weekEnd.setHours(23, 59, 59, 999);
|
|
|
|
let weeklyInflow = 0;
|
|
let weeklyOutflow = 0;
|
|
|
|
transactions.value
|
|
.filter((t) => !t.isTest)
|
|
.forEach((transaction) => {
|
|
const amount = transaction.amount * transaction.probability;
|
|
|
|
if (transaction.type === "one-time") {
|
|
const txnDate = new Date(transaction.date);
|
|
if (txnDate >= weekStart && txnDate <= weekEnd) {
|
|
if (amount > 0) {
|
|
if (
|
|
transaction.status === "actual" ||
|
|
transaction.status === "committed"
|
|
) {
|
|
weeklyInflow += amount;
|
|
}
|
|
} else {
|
|
weeklyOutflow += Math.abs(amount);
|
|
}
|
|
}
|
|
} else if (transaction.type === "recurring") {
|
|
const startDate = new Date(transaction.date);
|
|
const endDate = transaction.endDate
|
|
? new Date(transaction.endDate)
|
|
: new Date("2099-12-31");
|
|
|
|
if (weekStart <= endDate && weekEnd >= startDate) {
|
|
const weeklyAmount = getWeeklyAmountForTransaction(transaction);
|
|
|
|
if (weeklyAmount > 0) {
|
|
weeklyInflow += weeklyAmount;
|
|
} else {
|
|
weeklyOutflow += Math.abs(weeklyAmount);
|
|
}
|
|
}
|
|
}
|
|
});
|
|
|
|
revenueOpportunities.value.forEach((opportunity) => {
|
|
const oppDate = new Date(opportunity.targetDate);
|
|
if (oppDate >= weekStart && oppDate <= weekEnd) {
|
|
weeklyInflow += opportunity.amount * opportunity.probability;
|
|
}
|
|
});
|
|
|
|
const netFlow = weeklyInflow - weeklyOutflow;
|
|
runningBalance += netFlow;
|
|
|
|
projections.push({
|
|
date: new Date(weekStart),
|
|
balance: runningBalance,
|
|
inflow: weeklyInflow,
|
|
outflow: weeklyOutflow,
|
|
netFlow,
|
|
projectedBalance: runningBalance,
|
|
});
|
|
}
|
|
|
|
return projections;
|
|
};
|
|
|
|
// Helper function to calculate weekly amount for recurring transactions
|
|
const getWeeklyAmountForTransaction = (transaction: Transaction): number => {
|
|
if (transaction.type !== "recurring") return 0;
|
|
|
|
const frequency = transaction.frequency || "monthly";
|
|
const baseAmount = transaction.amount * transaction.probability;
|
|
|
|
switch (frequency) {
|
|
case "weekly":
|
|
return baseAmount;
|
|
case "biweekly":
|
|
return baseAmount / 2;
|
|
case "monthly":
|
|
return baseAmount / 4.33;
|
|
case "quarterly":
|
|
return baseAmount / 13;
|
|
case "custom":
|
|
if (transaction.customMonths) {
|
|
return baseAmount / (transaction.customMonths * 4.33);
|
|
}
|
|
return baseAmount / 4.33;
|
|
default:
|
|
return baseAmount / 4.33;
|
|
}
|
|
};
|
|
|
|
// Helper functions
|
|
const calculateRecurringInflow = (date: Date): number => {
|
|
return transactions.value
|
|
.filter(
|
|
(t) =>
|
|
t.type === "recurring" &&
|
|
t.amount > 0 &&
|
|
(t.status === "actual" || t.status === "committed") &&
|
|
isTransactionActiveInMonth(t, date)
|
|
)
|
|
.reduce((sum, t) => sum + getMonthlyAmount(t) * t.probability, 0);
|
|
};
|
|
|
|
const calculateRecurringOutflow = (date: Date): number => {
|
|
return transactions.value
|
|
.filter(
|
|
(t) =>
|
|
t.type === "recurring" &&
|
|
t.amount < 0 &&
|
|
(t.status === "actual" || t.status === "committed") &&
|
|
isTransactionActiveInMonth(t, date)
|
|
)
|
|
.reduce(
|
|
(sum, t) => sum + Math.abs(getMonthlyAmount(t) * t.probability),
|
|
0
|
|
);
|
|
};
|
|
|
|
// Account-specific helper functions
|
|
const calculateRecurringInflowForAccount = (date: Date): number => {
|
|
return filteredTransactions.value
|
|
.filter(
|
|
(t) =>
|
|
t.type === "recurring" &&
|
|
t.amount > 0 &&
|
|
(t.status === "actual" || t.status === "committed") &&
|
|
isTransactionActiveInMonth(t, date)
|
|
)
|
|
.reduce((sum, t) => sum + getMonthlyAmount(t) * t.probability, 0);
|
|
};
|
|
|
|
const calculateRecurringOutflowForAccount = (date: Date): number => {
|
|
return filteredTransactions.value
|
|
.filter(
|
|
(t) =>
|
|
t.type === "recurring" &&
|
|
t.amount < 0 &&
|
|
(t.status === "actual" || t.status === "committed") &&
|
|
isTransactionActiveInMonth(t, date)
|
|
)
|
|
.reduce(
|
|
(sum, t) => sum + Math.abs(getMonthlyAmount(t) * t.probability),
|
|
0
|
|
);
|
|
};
|
|
|
|
const getOneTimeTransactionsForMonth = (date: Date): Transaction[] => {
|
|
return transactions.value.filter((t) => {
|
|
if (t.type !== "one-time") return false;
|
|
|
|
const transactionDate = new Date(t.date);
|
|
return (
|
|
transactionDate.getMonth() === date.getMonth() &&
|
|
transactionDate.getFullYear() === date.getFullYear()
|
|
);
|
|
});
|
|
};
|
|
|
|
const isTransactionActiveInMonth = (
|
|
transaction: Transaction,
|
|
date: Date
|
|
): boolean => {
|
|
const startDate = new Date(transaction.date);
|
|
const endDate = transaction.endDate
|
|
? new Date(transaction.endDate)
|
|
: new Date("2099-12-31");
|
|
|
|
const monthStart = new Date(date.getFullYear(), date.getMonth(), 1);
|
|
const monthEnd = new Date(
|
|
date.getFullYear(),
|
|
date.getMonth() + 1,
|
|
0,
|
|
23,
|
|
59,
|
|
59,
|
|
999
|
|
);
|
|
|
|
return monthEnd >= startDate && monthStart <= endDate;
|
|
};
|
|
|
|
const getMonthlyAmount = (transaction: Transaction): number => {
|
|
if (transaction.type !== "recurring") return 0;
|
|
|
|
const frequencyMultiplier = {
|
|
weekly: 4.33,
|
|
biweekly: 2.17,
|
|
monthly: 1,
|
|
quarterly: 0.33,
|
|
custom: transaction.customMonths ? 12 / transaction.customMonths : 1,
|
|
};
|
|
|
|
return (
|
|
transaction.amount *
|
|
(frequencyMultiplier[transaction.frequency || "monthly"] || 1)
|
|
);
|
|
};
|
|
|
|
const setCurrentBalance = async (balance: number) => {
|
|
currentBalance.value = balance;
|
|
|
|
try {
|
|
// Get existing balance data first to preserve account balances
|
|
const existingResponse = await fetch("/api/balances");
|
|
let existingData = null;
|
|
if (existingResponse.ok) {
|
|
existingData = await existingResponse.json();
|
|
}
|
|
|
|
// Only update the total balance, preserve existing account balances
|
|
const response = await fetch("/api/balances", {
|
|
method: "PUT",
|
|
headers: { "Content-Type": "application/json" },
|
|
body: JSON.stringify({
|
|
currentBalance: balance,
|
|
accountBalances: existingData?.accountBalances || {
|
|
manual: {
|
|
rbc_cad: 0,
|
|
td_cad: 0,
|
|
millennium_eur: 0
|
|
},
|
|
wise: { jennie: [], henry: [] },
|
|
},
|
|
}),
|
|
});
|
|
|
|
if (!response.ok) {
|
|
throw new Error("Failed to save balance");
|
|
}
|
|
|
|
// Update local accountBalances
|
|
accountBalances.value = existingData?.accountBalances || null;
|
|
|
|
console.log("✅ Balance saved to MongoDB");
|
|
} catch (error) {
|
|
console.error("Failed to save balance to MongoDB:", error);
|
|
// Fallback to localStorage
|
|
if (typeof window !== "undefined") {
|
|
localStorage.setItem("cashflow_balance", balance.toString());
|
|
}
|
|
}
|
|
};
|
|
|
|
// Affordability checker functions
|
|
const canAfford = (
|
|
amount: number,
|
|
targetDate?: Date,
|
|
fundType: "restricted" | "unrestricted" = "unrestricted"
|
|
): AffordabilityCheck => {
|
|
const testDate = targetDate || new Date();
|
|
|
|
if (fundType === "restricted") {
|
|
const availableRestricted = restrictedCash.value;
|
|
if (Math.abs(amount) > availableRestricted) {
|
|
return {
|
|
canAfford: false,
|
|
reason: "Insufficient restricted funds",
|
|
availableAmount: availableRestricted,
|
|
shortfall: Math.abs(amount) - availableRestricted,
|
|
};
|
|
}
|
|
}
|
|
|
|
const projectedAvailableCash = getAvailableCashAtDate(testDate);
|
|
const newBalance = projectedAvailableCash + amount;
|
|
|
|
const weeklyBurn = getWeeklyBurnRate();
|
|
const newRunwayWeeks = weeklyBurn > 0 ? newBalance / weeklyBurn : Infinity;
|
|
|
|
return {
|
|
canAfford: newRunwayWeeks >= 4,
|
|
currentAvailable: projectedAvailableCash,
|
|
newBalance,
|
|
currentRunwayWeeks:
|
|
weeklyBurn > 0 ? projectedAvailableCash / weeklyBurn : Infinity,
|
|
newRunwayWeeks,
|
|
riskLevel: getRiskLevelForRunway(newRunwayWeeks),
|
|
recommendation: getAffordabilityRecommendation(newRunwayWeeks, amount),
|
|
};
|
|
};
|
|
|
|
const getAvailableCashAtDate = (date: Date): number => {
|
|
let projectedBalance = availableCash.value;
|
|
|
|
const now = new Date();
|
|
const futureTxns = transactions.value.filter((t) => {
|
|
if (t.isTest) return false;
|
|
if (t.fundType === "restricted") return false;
|
|
|
|
const txnDate = new Date(t.date);
|
|
return txnDate > now && txnDate <= date;
|
|
});
|
|
|
|
futureTxns.forEach((t) => {
|
|
projectedBalance += t.amount * t.probability;
|
|
});
|
|
|
|
return projectedBalance;
|
|
};
|
|
|
|
const getWeeklyBurnRate = (): number => {
|
|
return monthlyBurnRate.value / 4.33;
|
|
};
|
|
|
|
const getRiskLevelForRunway = (runwayWeeks: number): string => {
|
|
if (runwayWeeks < 2) return "critical";
|
|
if (runwayWeeks < 4) return "high";
|
|
if (runwayWeeks < 6) return "medium";
|
|
return "low";
|
|
};
|
|
|
|
const getAffordabilityRecommendation = (
|
|
runwayWeeks: number,
|
|
amount: number
|
|
): string => {
|
|
if (runwayWeeks >= 6) return "Safe to proceed";
|
|
if (runwayWeeks >= 4) return "Proceed with caution - monitor cash flow";
|
|
if (runwayWeeks >= 2)
|
|
return "High risk - consider delaying or reducing amount";
|
|
return "Do not proceed - insufficient runway";
|
|
};
|
|
|
|
// Test transaction management
|
|
const createTestTransaction = (
|
|
description: string,
|
|
amount: number,
|
|
date: Date,
|
|
program?: string,
|
|
fundType: "restricted" | "unrestricted" = "unrestricted"
|
|
): Transaction => {
|
|
const testTxn: Transaction = {
|
|
id: `test-${Date.now()}`,
|
|
date,
|
|
description: `[TEST] ${description}`,
|
|
amount,
|
|
category: "Expense: Want",
|
|
type: "one-time",
|
|
program,
|
|
status: "projected",
|
|
fundType,
|
|
probability: 1.0,
|
|
isConfirmed: false,
|
|
isTest: true,
|
|
notes: "Test transaction for affordability checking",
|
|
};
|
|
|
|
transactions.value.push(testTxn);
|
|
return testTxn;
|
|
};
|
|
|
|
const removeTestTransaction = (transactionId: string) => {
|
|
transactions.value = transactions.value.filter(
|
|
(t) => !(t.isTest && t.id === transactionId)
|
|
);
|
|
};
|
|
|
|
const removeAllTestTransactions = () => {
|
|
transactions.value = transactions.value.filter((t) => !t.isTest);
|
|
};
|
|
|
|
// Delete transaction method
|
|
const deleteTransaction = async (transactionId: string) => {
|
|
try {
|
|
// Call API to delete from database
|
|
const response = await fetch(`/api/transactions/${transactionId}`, {
|
|
method: "DELETE",
|
|
});
|
|
|
|
if (!response.ok) {
|
|
throw new Error("Failed to delete transaction from database");
|
|
}
|
|
|
|
// Remove from local state
|
|
transactions.value = transactions.value.filter(
|
|
(t) => t.id !== transactionId
|
|
);
|
|
|
|
console.log("✅ Transaction deleted successfully");
|
|
return { success: true };
|
|
} catch (error) {
|
|
console.error("Failed to delete transaction:", error);
|
|
throw error;
|
|
}
|
|
};
|
|
|
|
return {
|
|
// State
|
|
currentBalance,
|
|
transactions,
|
|
revenueOpportunities,
|
|
|
|
// Computed - Original
|
|
currentCashPosition,
|
|
monthlyBurnRate,
|
|
runwayDays,
|
|
riskLevel,
|
|
|
|
// Computed - Fund Tracking
|
|
availableCash,
|
|
restrictedCash,
|
|
totalCash,
|
|
availableRunwayDays,
|
|
availableRiskLevel,
|
|
|
|
// Computed
|
|
filteredTransactions,
|
|
currentBalanceWithTransactions,
|
|
startingBalance,
|
|
startingBalanceForActive: startingBalance, // Alias for compatibility
|
|
|
|
// Methods - Original
|
|
generateProjections,
|
|
generateWeeklyProjections,
|
|
updateTransactions,
|
|
updateRevenueOpportunities,
|
|
setCurrentBalance,
|
|
|
|
// Methods - Affordability & Testing
|
|
canAfford,
|
|
getAvailableCashAtDate,
|
|
createTestTransaction,
|
|
removeTestTransaction,
|
|
removeAllTestTransactions,
|
|
deleteTransaction,
|
|
|
|
// Enhanced runway calculations
|
|
calculateRunwayScenarios,
|
|
};
|
|
};
|