app/stores/budget.ts

658 lines
25 KiB
TypeScript

import { defineStore } from "pinia";
export const useBudgetStore = defineStore(
"budget",
() => {
// Schema version for persistence
const schemaVersion = "2.0";
// Canonical categories from WizardRevenueStep - matches the wizard exactly
const revenueCategories = ref([
'Games & Products',
'Services & Contracts',
'Grants & Funding',
'Community Support',
'Partnerships',
'Investment Income',
'In-Kind Contributions'
]);
// Revenue subcategories by main category (for reference and grouping)
const revenueSubcategories = ref({
'Games & Products': ['Direct sales', 'Platform revenue share', 'DLC/expansions', 'Merchandise'],
'Services & Contracts': ['Contract development', 'Consulting', 'Workshops/teaching', 'Technical services'],
'Grants & Funding': ['Government funding', 'Arts council grants', 'Foundation support', 'Research grants'],
'Community Support': ['Patreon/subscriptions', 'Crowdfunding', 'Donations', 'Mutual aid received'],
'Partnerships': ['Corporate partnerships', 'Academic partnerships', 'Sponsorships'],
'Investment Income': ['Impact investment', 'Venture capital', 'Loans'],
'In-Kind Contributions': ['Office space', 'Equipment/hardware', 'Software licenses', 'Professional services', 'Marketing/PR services', 'Legal services']
});
const expenseCategories = ref([
'Salaries & Benefits',
'Development Costs',
'Equipment & Technology',
'Marketing & Outreach',
'Office & Operations',
'Legal & Professional',
'Other Expenses'
]);
// NEW: Budget worksheet structure (starts empty, populated from wizard data)
const budgetWorksheet = ref({
revenue: [],
expenses: []
});
// Track if worksheet has been initialized from wizard data
const isInitialized = ref(false);
// LEGACY: Keep for backward compatibility
const budgetLines = ref({});
const overheadCosts = ref([]);
const productionCosts = ref([]);
// Computed grouped data for category headers
const groupedRevenue = computed(() => {
const groups = {};
revenueCategories.value.forEach(category => {
groups[category] = budgetWorksheet.value.revenue.filter(item => item.mainCategory === category);
});
return groups;
});
const groupedExpenses = computed(() => {
const groups = {};
expenseCategories.value.forEach(category => {
groups[category] = budgetWorksheet.value.expenses.filter(item => item.mainCategory === category);
});
return groups;
});
// Computed totals for budget worksheet (legacy - keep for backward compatibility)
const budgetTotals = computed(() => {
const years = ['year1', 'year2', 'year3'];
const scenarios = ['best', 'worst', 'mostLikely'];
const totals = {};
years.forEach(year => {
totals[year] = {};
scenarios.forEach(scenario => {
// Calculate revenue total
const revenueTotal = budgetWorksheet.value.revenue.reduce((sum, item) => {
return sum + (item.values?.[year]?.[scenario] || 0);
}, 0);
// Calculate expenses total
const expensesTotal = budgetWorksheet.value.expenses.reduce((sum, item) => {
return sum + (item.values?.[year]?.[scenario] || 0);
}, 0);
// Calculate net income
const netIncome = revenueTotal - expensesTotal;
totals[year][scenario] = {
revenue: revenueTotal,
expenses: expensesTotal,
net: netIncome
};
});
});
return totals;
});
// Monthly totals computation
const monthlyTotals = computed(() => {
const totals = {};
// Generate month keys for next 12 months
const today = new Date();
for (let i = 0; i < 12; i++) {
const date = new Date(today.getFullYear(), today.getMonth() + i, 1);
const monthKey = `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, '0')}`;
// Calculate revenue total for this month
const revenueTotal = budgetWorksheet.value.revenue.reduce((sum, item) => {
return sum + (item.monthlyValues?.[monthKey] || 0);
}, 0);
// Calculate expenses total for this month
const expensesTotal = budgetWorksheet.value.expenses.reduce((sum, item) => {
return sum + (item.monthlyValues?.[monthKey] || 0);
}, 0);
// Calculate net income
const netIncome = revenueTotal - expensesTotal;
totals[monthKey] = {
revenue: revenueTotal,
expenses: expensesTotal,
net: netIncome
};
}
return totals;
});
// LEGACY: Keep for backward compatibility
const currentDate = new Date();
const currentYear = currentDate.getFullYear();
const currentMonth = String(currentDate.getMonth() + 1).padStart(2, '0');
const currentPeriod = ref(`${currentYear}-${currentMonth}`);
const currentBudget = computed(() => {
return (
budgetLines.value[currentPeriod.value] || {
period: currentPeriod.value,
revenueByStream: {},
payrollCosts: { memberHours: [], oncostApplied: 0 },
overheadCosts: [],
productionCosts: [],
savingsChange: 0,
net: 0,
}
);
});
// Actions
function setBudgetLine(period, budgetData) {
budgetLines.value[period] = {
period,
...budgetData,
};
}
function updateRevenue(period, streamId, type, amount) {
if (!budgetLines.value[period]) {
budgetLines.value[period] = { period, revenueByStream: {} };
}
if (!budgetLines.value[period].revenueByStream[streamId]) {
budgetLines.value[period].revenueByStream[streamId] = {};
}
budgetLines.value[period].revenueByStream[streamId][type] = amount;
}
// Wizard-required actions
function addOverheadLine(cost) {
// Allow creating a blank line so the user can fill it out in the UI
const safeName = cost?.name ?? "";
const safeAmountMonthly =
typeof cost?.amountMonthly === "number" &&
!Number.isNaN(cost.amountMonthly)
? cost.amountMonthly
: 0;
overheadCosts.value.push({
id: Date.now().toString(),
name: safeName,
amount: safeAmountMonthly,
category: cost?.category || "Operations",
recurring: cost?.recurring ?? true,
...cost,
});
}
function removeOverheadLine(id) {
const index = overheadCosts.value.findIndex((c) => c.id === id);
if (index > -1) {
overheadCosts.value.splice(index, 1);
}
}
function addOverheadCost(cost) {
addOverheadLine({ name: cost.name, amountMonthly: cost.amount, ...cost });
}
function addProductionCost(cost) {
productionCosts.value.push({
id: Date.now().toString(),
name: cost.name,
amount: cost.amount,
category: cost.category || "Production",
period: cost.period,
...cost,
});
}
function setCurrentPeriod(period) {
currentPeriod.value = period;
}
// Initialize worksheet from wizard data
async function initializeFromWizardData() {
if (isInitialized.value && budgetWorksheet.value.revenue.length > 0) {
console.log('Already initialized with data, skipping...');
return;
}
console.log('Initializing budget from wizard data...');
// Import stores dynamically to avoid circular deps
const { useStreamsStore } = await import('./streams');
const { useMembersStore } = await import('./members');
const { usePoliciesStore } = await import('./policies');
const streamsStore = useStreamsStore();
const membersStore = useMembersStore();
const policiesStore = usePoliciesStore();
console.log('Streams:', streamsStore.streams.length, 'streams');
console.log('Members capacity:', membersStore.capacityTotals);
console.log('Policies wage:', policiesStore.equalHourlyWage);
// Clear existing data
budgetWorksheet.value.revenue = [];
budgetWorksheet.value.expenses = [];
// Add revenue streams from wizard
if (streamsStore.streams.length === 0) {
console.log('No wizard streams found, adding sample data');
// Initialize with minimal demo if no wizard data exists
await streamsStore.initializeWithFixtures();
}
streamsStore.streams.forEach(stream => {
const monthlyAmount = stream.targetMonthlyAmount || 0;
console.log('Adding stream:', stream.name, 'category:', stream.category, 'subcategory:', stream.subcategory, 'amount:', monthlyAmount);
console.log('Full stream object:', stream);
// Simple category mapping - just map the key categories we know exist
let mappedCategory = 'Games & Products'; // Default
const categoryLower = (stream.category || '').toLowerCase();
if (categoryLower === 'games' || categoryLower === 'product') mappedCategory = 'Games & Products';
else if (categoryLower === 'services' || categoryLower === 'service') mappedCategory = 'Services & Contracts';
else if (categoryLower === 'grants' || categoryLower === 'grant') mappedCategory = 'Grants & Funding';
else if (categoryLower === 'community') mappedCategory = 'Community Support';
else if (categoryLower === 'partnerships' || categoryLower === 'partnership') mappedCategory = 'Partnerships';
else if (categoryLower === 'investment') mappedCategory = 'Investment Income';
console.log('Mapped category from', stream.category, 'to', mappedCategory);
// Create monthly values - split the annual target evenly across 12 months
const monthlyValues = {};
const today = new Date();
for (let i = 0; i < 12; i++) {
const date = new Date(today.getFullYear(), today.getMonth() + i, 1);
const monthKey = `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, '0')}`;
monthlyValues[monthKey] = monthlyAmount;
}
console.log('Created monthly values for', stream.name, ':', monthlyValues);
budgetWorksheet.value.revenue.push({
id: `revenue-${stream.id}`,
name: stream.name,
mainCategory: mappedCategory,
subcategory: stream.subcategory || 'Direct sales', // Use actual subcategory from stream
source: 'wizard',
monthlyValues,
values: {
year1: { best: monthlyAmount * 12, worst: monthlyAmount * 6, mostLikely: monthlyAmount * 10 },
year2: { best: monthlyAmount * 15, worst: monthlyAmount * 8, mostLikely: monthlyAmount * 12 },
year3: { best: monthlyAmount * 18, worst: monthlyAmount * 10, mostLikely: monthlyAmount * 15 }
}
});
});
// Add payroll from wizard data
const totalHours = membersStore.capacityTotals.targetHours || 0;
const hourlyWage = policiesStore.equalHourlyWage || 0;
const oncostPct = policiesStore.payrollOncostPct || 0;
if (totalHours > 0 && hourlyWage > 0) {
const monthlyPayroll = totalHours * hourlyWage * (1 + oncostPct / 100);
// Create monthly values for payroll
const monthlyValues = {};
const today = new Date();
for (let i = 0; i < 12; i++) {
const date = new Date(today.getFullYear(), today.getMonth() + i, 1);
const monthKey = `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, '0')}`;
monthlyValues[monthKey] = monthlyPayroll;
}
budgetWorksheet.value.expenses.push({
id: 'expense-payroll',
name: 'Payroll',
mainCategory: 'Salaries & Benefits',
subcategory: 'Base wages and benefits',
source: 'wizard',
monthlyValues,
values: {
year1: { best: monthlyPayroll * 12, worst: monthlyPayroll * 8, mostLikely: monthlyPayroll * 12 },
year2: { best: monthlyPayroll * 14, worst: monthlyPayroll * 10, mostLikely: monthlyPayroll * 13 },
year3: { best: monthlyPayroll * 16, worst: monthlyPayroll * 12, mostLikely: monthlyPayroll * 15 }
}
});
}
// Add overhead costs from wizard
overheadCosts.value.forEach(cost => {
if (cost.amount > 0) {
const annualAmount = cost.amount * 12;
// Map overhead cost categories to expense categories
let expenseCategory = 'Other Expenses'; // Default
if (cost.category === 'Operations') expenseCategory = 'Office & Operations';
else if (cost.category === 'Technology') expenseCategory = 'Equipment & Technology';
else if (cost.category === 'Legal') expenseCategory = 'Legal & Professional';
else if (cost.category === 'Marketing') expenseCategory = 'Marketing & Outreach';
// Create monthly values for overhead costs
const monthlyValues = {};
const today = new Date();
for (let i = 0; i < 12; i++) {
const date = new Date(today.getFullYear(), today.getMonth() + i, 1);
const monthKey = `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, '0')}`;
monthlyValues[monthKey] = cost.amount;
}
budgetWorksheet.value.expenses.push({
id: `expense-${cost.id}`,
name: cost.name,
mainCategory: expenseCategory,
subcategory: cost.name, // Use the cost name as subcategory
source: 'wizard',
monthlyValues,
values: {
year1: { best: annualAmount, worst: annualAmount * 0.8, mostLikely: annualAmount },
year2: { best: annualAmount * 1.1, worst: annualAmount * 0.9, mostLikely: annualAmount * 1.05 },
year3: { best: annualAmount * 1.2, worst: annualAmount, mostLikely: annualAmount * 1.1 }
}
});
}
});
// Add production costs from wizard
productionCosts.value.forEach(cost => {
if (cost.amount > 0) {
const annualAmount = cost.amount * 12;
// Create monthly values for production costs
const monthlyValues = {};
const today = new Date();
for (let i = 0; i < 12; i++) {
const date = new Date(today.getFullYear(), today.getMonth() + i, 1);
const monthKey = `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, '0')}`;
monthlyValues[monthKey] = cost.amount;
}
budgetWorksheet.value.expenses.push({
id: `expense-${cost.id}`,
name: cost.name,
mainCategory: 'Development Costs',
subcategory: cost.name, // Use the cost name as subcategory
source: 'wizard',
monthlyValues,
values: {
year1: { best: annualAmount, worst: annualAmount * 0.7, mostLikely: annualAmount * 0.9 },
year2: { best: annualAmount * 1.2, worst: annualAmount * 0.8, mostLikely: annualAmount },
year3: { best: annualAmount * 1.3, worst: annualAmount * 0.9, mostLikely: annualAmount * 1.1 }
}
});
}
});
// If still no data after initialization, add a sample row
if (budgetWorksheet.value.revenue.length === 0) {
console.log('Adding sample revenue line');
// Create monthly values for sample revenue
const monthlyValues = {};
const today = new Date();
for (let i = 0; i < 12; i++) {
const date = new Date(today.getFullYear(), today.getMonth() + i, 1);
const monthKey = `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, '0')}`;
monthlyValues[monthKey] = 667; // ~8000/12
}
budgetWorksheet.value.revenue.push({
id: 'revenue-sample',
name: 'Sample Revenue',
mainCategory: 'Games & Products',
subcategory: 'Direct sales',
source: 'user',
monthlyValues,
values: {
year1: { best: 10000, worst: 5000, mostLikely: 8000 },
year2: { best: 12000, worst: 6000, mostLikely: 10000 },
year3: { best: 15000, worst: 8000, mostLikely: 12000 }
}
});
}
if (budgetWorksheet.value.expenses.length === 0) {
console.log('Adding sample expense line');
// Create monthly values for sample expense
const monthlyValues = {};
const today = new Date();
for (let i = 0; i < 12; i++) {
const date = new Date(today.getFullYear(), today.getMonth() + i, 1);
const monthKey = `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, '0')}`;
monthlyValues[monthKey] = 67; // ~800/12
}
budgetWorksheet.value.expenses.push({
id: 'expense-sample',
name: 'Sample Expense',
mainCategory: 'Other Expenses',
subcategory: 'Miscellaneous',
source: 'user',
monthlyValues,
values: {
year1: { best: 1000, worst: 500, mostLikely: 800 },
year2: { best: 1200, worst: 600, mostLikely: 1000 },
year3: { best: 1500, worst: 800, mostLikely: 1200 }
}
});
}
// Debug: Log all revenue items and their categories
console.log('Final revenue items:');
budgetWorksheet.value.revenue.forEach(item => {
console.log(`- ${item.name}: ${item.mainCategory} > ${item.subcategory} (${item.values.year1.mostLikely})`);
});
console.log('Final expense items:');
budgetWorksheet.value.expenses.forEach(item => {
console.log(`- ${item.name}: ${item.mainCategory} > ${item.subcategory} (${item.values.year1.mostLikely})`);
});
// Ensure all items have monthlyValues and new structure - migrate existing items
[...budgetWorksheet.value.revenue, ...budgetWorksheet.value.expenses].forEach(item => {
// Migrate to new structure if needed
if (item.category && !item.mainCategory) {
console.log('Migrating item structure for:', item.name);
item.mainCategory = item.category; // Old category becomes mainCategory
// Set appropriate subcategory based on the main category and item name
if (item.category === 'Games & Products') {
const gameSubcategories = ['Direct sales', 'Platform revenue share', 'DLC/expansions', 'Merchandise'];
item.subcategory = gameSubcategories.includes(item.name) ? item.name : 'Direct sales';
} else if (item.category === 'Services & Contracts') {
const serviceSubcategories = ['Contract development', 'Consulting', 'Workshops/teaching', 'Technical services'];
item.subcategory = serviceSubcategories.includes(item.name) ? item.name : 'Contract development';
} else if (item.category === 'Investment Income') {
const investmentSubcategories = ['Impact investment', 'Venture capital', 'Loans'];
item.subcategory = investmentSubcategories.includes(item.name) ? item.name : 'Impact investment';
} else if (item.category === 'Salaries & Benefits') {
item.subcategory = 'Base wages and benefits';
} else if (item.category === 'Office & Operations') {
// Map specific office tools to appropriate subcategories
if (item.name.toLowerCase().includes('rent') || item.name.toLowerCase().includes('office')) {
item.subcategory = 'Rent';
} else if (item.name.toLowerCase().includes('util')) {
item.subcategory = 'Utilities';
} else if (item.name.toLowerCase().includes('insurance')) {
item.subcategory = 'Insurance';
} else {
item.subcategory = 'Office supplies';
}
} else {
// For other categories, use appropriate default
item.subcategory = 'Miscellaneous';
}
delete item.category; // Remove old property
}
if (!item.monthlyValues) {
console.log('Migrating item to monthly values:', item.name);
item.monthlyValues = {};
const today = new Date();
for (let i = 0; i < 12; i++) {
const date = new Date(today.getFullYear(), today.getMonth() + i, 1);
const monthKey = `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, '0')}`;
// Try to use most likely value divided by 12, or default to 0
const yearlyValue = item.values?.year1?.mostLikely || 0;
item.monthlyValues[monthKey] = Math.round(yearlyValue / 12);
}
console.log('Added monthly values to', item.name, ':', item.monthlyValues);
}
});
console.log('Initialization complete. Revenue items:', budgetWorksheet.value.revenue.length, 'Expense items:', budgetWorksheet.value.expenses.length);
isInitialized.value = true;
}
// NEW: Budget worksheet functions
function updateBudgetValue(category, itemId, year, scenario, value) {
const items = budgetWorksheet.value[category];
const item = items.find(i => i.id === itemId);
if (item) {
item.values[year][scenario] = Number(value) || 0;
}
}
function updateMonthlyValue(category, itemId, monthKey, value) {
const items = budgetWorksheet.value[category];
const item = items.find(i => i.id === itemId);
if (item) {
if (!item.monthlyValues) {
item.monthlyValues = {};
}
item.monthlyValues[monthKey] = Number(value) || 0;
}
}
function addBudgetItem(category, name, selectedCategory = '') {
const id = `${category}-${Date.now()}`;
// Create empty monthly values for next 12 months
const monthlyValues = {};
const today = new Date();
for (let i = 0; i < 12; i++) {
const date = new Date(today.getFullYear(), today.getMonth() + i, 1);
const monthKey = `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, '0')}`;
monthlyValues[monthKey] = 0;
}
const newItem = {
id,
name,
mainCategory: selectedCategory || (category === 'revenue' ? 'Games & Products' : 'Other Expenses'),
subcategory: '', // Will be set by user via dropdown
source: 'user',
monthlyValues,
values: {
year1: { best: 0, worst: 0, mostLikely: 0 },
year2: { best: 0, worst: 0, mostLikely: 0 },
year3: { best: 0, worst: 0, mostLikely: 0 }
}
};
budgetWorksheet.value[category].push(newItem);
return id;
}
function removeBudgetItem(category, itemId) {
const items = budgetWorksheet.value[category];
const index = items.findIndex(i => i.id === itemId);
if (index > -1) {
items.splice(index, 1);
}
}
function renameBudgetItem(category, itemId, newName) {
const items = budgetWorksheet.value[category];
const item = items.find(i => i.id === itemId);
if (item) {
item.name = newName;
}
}
function updateBudgetCategory(category, itemId, newCategory) {
const items = budgetWorksheet.value[category];
const item = items.find(i => i.id === itemId);
if (item) {
item.category = newCategory;
}
}
function addCustomCategory(type, categoryName) {
if (type === 'revenue' && !revenueCategories.value.includes(categoryName)) {
revenueCategories.value.push(categoryName);
} else if (type === 'expenses' && !expenseCategories.value.includes(categoryName)) {
expenseCategories.value.push(categoryName);
}
}
// Reset function
function resetBudgetOverhead() {
overheadCosts.value = [];
productionCosts.value = [];
}
function resetBudgetWorksheet() {
// Reset all values to 0 but keep the structure
[...budgetWorksheet.value.revenue, ...budgetWorksheet.value.expenses].forEach(item => {
Object.keys(item.values).forEach(year => {
Object.keys(item.values[year]).forEach(scenario => {
item.values[year][scenario] = 0;
});
});
});
}
return {
// NEW: Budget worksheet
budgetWorksheet,
budgetTotals,
monthlyTotals,
revenueCategories,
expenseCategories,
revenueSubcategories,
groupedRevenue,
groupedExpenses,
isInitialized,
initializeFromWizardData,
updateBudgetValue,
updateMonthlyValue,
addBudgetItem,
removeBudgetItem,
renameBudgetItem,
updateBudgetCategory,
addCustomCategory,
resetBudgetWorksheet,
// LEGACY: Keep for backward compatibility
budgetLines,
overheadCosts,
productionCosts,
currentPeriod: readonly(currentPeriod),
currentBudget,
schemaVersion,
setBudgetLine,
updateRevenue,
// Wizard actions
addOverheadLine,
removeOverheadLine,
resetBudgetOverhead,
// Legacy actions
addOverheadCost,
addProductionCost,
setCurrentPeriod,
};
},
{
persist: {
key: "urgent-tools-budget",
paths: ["budgetWorksheet", "revenueCategories", "expenseCategories", "isInitialized", "overheadCosts", "productionCosts", "currentPeriod"],
},
}
);