295 lines
8.7 KiB
TypeScript
295 lines
8.7 KiB
TypeScript
import { defineStore } from "pinia";
|
|
import type { OneOffEvent } from '~/types/cash'
|
|
|
|
export interface CashEvent {
|
|
id: string
|
|
date: string
|
|
week: number
|
|
month: number
|
|
type: 'Influx' | 'Outflow'
|
|
amount: number
|
|
sourceRef: string
|
|
policyTag: string
|
|
category: string
|
|
name: string
|
|
certainty?: 'Confirmed' | 'Likely' | 'Potential'
|
|
isRecurring: boolean
|
|
notes?: string
|
|
}
|
|
|
|
export const useCashStore = defineStore("cash", () => {
|
|
// 13-week calendar events
|
|
const cashEvents = ref<CashEvent[]>([]);
|
|
|
|
// One-off events for longer-term planning (12+ months)
|
|
const oneOffEvents = ref<OneOffEvent[]>([]);
|
|
|
|
// Payment queue - staged payments within policy
|
|
const paymentQueue = ref([]);
|
|
|
|
// Week that first breaches minimum cushion
|
|
const firstBreachWeek = ref(null);
|
|
|
|
// Current cash and savings balances - start empty
|
|
const currentCash = ref(0);
|
|
const currentSavings = ref(0);
|
|
|
|
// Computed weekly projections
|
|
const weeklyProjections = computed(() => {
|
|
const weeks = [];
|
|
let runningBalance = currentCash.value;
|
|
const budgetStore = useBudgetStore();
|
|
|
|
// Get budget data for the next 3+ months to cover 13 weeks
|
|
const today = new Date();
|
|
let totalMonthlyRevenue = 0;
|
|
let totalMonthlyExpenses = 0;
|
|
|
|
// Average across current and next 2 months
|
|
for (let monthOffset = 0; monthOffset < 3; monthOffset++) {
|
|
const targetMonth = new Date(today.getFullYear(), today.getMonth() + monthOffset, 1);
|
|
const monthKey = `${targetMonth.getFullYear()}-${String(targetMonth.getMonth() + 1).padStart(2, '0')}`;
|
|
totalMonthlyRevenue += budgetStore.monthlyTotals?.[monthKey]?.revenue || 0;
|
|
totalMonthlyExpenses += budgetStore.monthlyTotals?.[monthKey]?.expenses || 0;
|
|
}
|
|
|
|
// Convert to weekly averages (3 months = 13 weeks)
|
|
const weeklyRevenue = totalMonthlyRevenue / 13;
|
|
const weeklyExpenses = totalMonthlyExpenses / 13;
|
|
|
|
for (let week = 1; week <= 13; week++) {
|
|
// Start with budget-based weekly flows
|
|
let weekInflow = weeklyRevenue;
|
|
let weekOutflow = weeklyExpenses;
|
|
|
|
// Add any specific cash events for this week
|
|
const weekEvents = cashEvents.value.filter((e) => e.week === week);
|
|
weekInflow += weekEvents
|
|
.filter((e) => e.type === "Influx")
|
|
.reduce((sum, e) => sum + e.amount, 0);
|
|
weekOutflow += weekEvents
|
|
.filter((e) => e.type === "Outflow")
|
|
.reduce((sum, e) => sum + e.amount, 0);
|
|
|
|
// Add one-off transactions that fall in this week
|
|
const weekStart = new Date(today.getTime() + (week - 1) * 7 * 24 * 60 * 60 * 1000);
|
|
const weekEnd = new Date(weekStart.getTime() + 6 * 24 * 60 * 60 * 1000);
|
|
|
|
const weekOneOffs = oneOffEvents.value.filter(event => {
|
|
if (!event.dateExpected) return false;
|
|
const eventDate = new Date(event.dateExpected);
|
|
return eventDate >= weekStart && eventDate <= weekEnd;
|
|
});
|
|
|
|
weekOneOffs.forEach(event => {
|
|
if (event.type === 'income') {
|
|
weekInflow += event.amount;
|
|
} else {
|
|
weekOutflow += event.amount;
|
|
}
|
|
});
|
|
|
|
const net = weekInflow - weekOutflow;
|
|
runningBalance += net;
|
|
|
|
weeks.push({
|
|
number: week,
|
|
inflow: Math.round(weekInflow),
|
|
outflow: Math.round(weekOutflow),
|
|
net: Math.round(net),
|
|
balance: Math.round(runningBalance),
|
|
cushion: Math.round(runningBalance),
|
|
breachesCushion: runningBalance < 0,
|
|
weekStart: weekStart.toISOString().split('T')[0],
|
|
weekEnd: weekEnd.toISOString().split('T')[0],
|
|
oneOffEvents: weekOneOffs
|
|
});
|
|
}
|
|
|
|
return weeks;
|
|
});
|
|
|
|
// Computed monthly projections including one-off events
|
|
const monthlyProjections = computed(() => {
|
|
const months = [];
|
|
let runningBalance = 0; // Always start with $0 for new cooperatives
|
|
const budgetStore = useBudgetStore();
|
|
|
|
for (let month = 0; month < 12; month++) {
|
|
let monthlyRevenue = 0;
|
|
let monthlyExpenses = 0;
|
|
|
|
// Get regular revenue/expenses from budget
|
|
if (budgetStore.monthlyTotals) {
|
|
const today = new Date();
|
|
const targetMonth = new Date(today.getFullYear(), today.getMonth() + month, 1);
|
|
const monthKey = `${targetMonth.getFullYear()}-${String(targetMonth.getMonth() + 1).padStart(2, '0')}`;
|
|
monthlyRevenue = budgetStore.monthlyTotals[monthKey]?.revenue || 0;
|
|
monthlyExpenses = budgetStore.monthlyTotals[monthKey]?.expenses || 0;
|
|
}
|
|
|
|
// Add one-off events for this month
|
|
const monthOneOffs = oneOffEvents.value.filter(event => event.month === month);
|
|
monthOneOffs.forEach(event => {
|
|
if (event.type === 'income') {
|
|
monthlyRevenue += event.amount;
|
|
} else {
|
|
monthlyExpenses += event.amount;
|
|
}
|
|
});
|
|
|
|
const netCashFlow = monthlyRevenue - monthlyExpenses;
|
|
runningBalance += netCashFlow;
|
|
|
|
months.push({
|
|
month,
|
|
monthName: new Date(2024, month).toLocaleString('en', { month: 'short' }),
|
|
revenue: monthlyRevenue,
|
|
expenses: monthlyExpenses,
|
|
netCashFlow,
|
|
runningBalance,
|
|
oneOffEvents: monthOneOffs
|
|
});
|
|
}
|
|
|
|
return months;
|
|
});
|
|
|
|
// Actions
|
|
function addCashEvent(event) {
|
|
cashEvents.value.push({
|
|
id: Date.now().toString(),
|
|
date: event.date,
|
|
week: event.week,
|
|
type: event.type, // Influx|Outflow
|
|
amount: event.amount,
|
|
sourceRef: event.sourceRef,
|
|
policyTag: event.policyTag, // Payroll|Tax|Vendor|SavingsSweep
|
|
...event,
|
|
});
|
|
}
|
|
|
|
function updateCashEvent(id, updates) {
|
|
const event = cashEvents.value.find((e) => e.id === id);
|
|
if (event) {
|
|
Object.assign(event, updates);
|
|
}
|
|
}
|
|
|
|
function removeCashEvent(id) {
|
|
const index = cashEvents.value.findIndex((e) => e.id === id);
|
|
if (index > -1) {
|
|
cashEvents.value.splice(index, 1);
|
|
}
|
|
}
|
|
|
|
function addToPaymentQueue(payment) {
|
|
paymentQueue.value.push({
|
|
id: Date.now().toString(),
|
|
amount: payment.amount,
|
|
recipient: payment.recipient,
|
|
scheduledWeek: payment.scheduledWeek,
|
|
priority: payment.priority,
|
|
canStage: payment.canStage !== false,
|
|
...payment,
|
|
});
|
|
}
|
|
|
|
function stagePayment(paymentId, newWeek) {
|
|
const payment = paymentQueue.value.find((p) => p.id === paymentId);
|
|
if (payment && payment.canStage) {
|
|
payment.scheduledWeek = newWeek;
|
|
}
|
|
}
|
|
|
|
function updateCurrentBalances(cash, savings) {
|
|
currentCash.value = cash;
|
|
currentSavings.value = savings;
|
|
}
|
|
|
|
// One-off events management
|
|
function addOneOffEvent(event: Omit<OneOffEvent, 'id'>): string {
|
|
const id = `oneoff-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`;
|
|
// Calculate month from dateExpected
|
|
const eventDate = new Date(event.dateExpected);
|
|
const month = eventDate.getMonth();
|
|
|
|
const newEvent: OneOffEvent = {
|
|
id,
|
|
month,
|
|
...event
|
|
};
|
|
oneOffEvents.value.push(newEvent);
|
|
return id;
|
|
}
|
|
|
|
function updateOneOffEvent(eventId: string, updates: Partial<OneOffEvent>) {
|
|
const event = oneOffEvents.value.find(e => e.id === eventId);
|
|
if (event) {
|
|
Object.assign(event, updates);
|
|
}
|
|
}
|
|
|
|
function removeOneOffEvent(eventId: string) {
|
|
const index = oneOffEvents.value.findIndex(e => e.id === eventId);
|
|
if (index > -1) {
|
|
oneOffEvents.value.splice(index, 1);
|
|
}
|
|
}
|
|
|
|
function getEventsByMonth(month: number) {
|
|
return oneOffEvents.value.filter(event => event.month === month);
|
|
}
|
|
|
|
function addCashEventFromOneOff(oneOffEvent: OneOffEvent, weekNumber: number) {
|
|
// Convert a one-off event to a weekly cash event
|
|
const cashEvent: Partial<CashEvent> = {
|
|
date: oneOffEvent.dateExpected,
|
|
week: weekNumber,
|
|
month: oneOffEvent.month,
|
|
type: oneOffEvent.type === 'income' ? 'Influx' : 'Outflow',
|
|
amount: oneOffEvent.amount,
|
|
sourceRef: oneOffEvent.id,
|
|
policyTag: oneOffEvent.category,
|
|
category: oneOffEvent.category,
|
|
name: oneOffEvent.name,
|
|
isRecurring: false
|
|
};
|
|
|
|
addCashEvent(cashEvent);
|
|
}
|
|
|
|
return {
|
|
cashEvents: readonly(cashEvents),
|
|
oneOffEvents: readonly(oneOffEvents),
|
|
paymentQueue: readonly(paymentQueue),
|
|
firstBreachWeek: readonly(firstBreachWeek),
|
|
currentCash: readonly(currentCash),
|
|
currentSavings: readonly(currentSavings),
|
|
weeklyProjections,
|
|
monthlyProjections,
|
|
addCashEvent,
|
|
updateCashEvent,
|
|
removeCashEvent,
|
|
addToPaymentQueue,
|
|
stagePayment,
|
|
updateCurrentBalances,
|
|
// One-off events
|
|
addOneOffEvent,
|
|
updateOneOffEvent,
|
|
removeOneOffEvent,
|
|
getEventsByMonth,
|
|
addCashEventFromOneOff
|
|
};
|
|
}, {
|
|
persist: {
|
|
key: "urgent-tools-cash",
|
|
paths: [
|
|
"currentCash",
|
|
"currentSavings",
|
|
"cashEvents",
|
|
"paymentQueue",
|
|
"oneOffEvents"
|
|
],
|
|
},
|
|
});
|