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: Record = {}; revenueCategories.value.forEach((category) => { groups[category] = budgetWorksheet.value.revenue.filter( (item) => item.mainCategory === category ); }); return groups; }); const groupedExpenses = computed(() => { const groups: Record = {}; 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: Record< string, { revenue: number; expenses: number; net: number } > = {}; // 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", ], }, } );