app/stores/cash.ts

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"
],
},
});