diff --git a/.env.sample b/.env.sample index 4cc1152..99f6e91 100644 --- a/.env.sample +++ b/.env.sample @@ -14,7 +14,7 @@ PASSWORD_SALT="fake" BREVO_API_KEY="fake" CONFIG_ALLOW_SIGNUPS="false" -CONFIG_ENABLED_APPS="news,notes,photos" # dashboard and files cannot be disabled +CONFIG_ENABLED_APPS="news,notes,photos,expenses" # dashboard and files cannot be disabled CONFIG_FILES_ROOT_PATH="data-files" CONFIG_ENABLE_EMAILS="false" # if true, email verification will be required for signups (using Brevo) CONFIG_ENABLE_FOREVER_SIGNUP="true" # if true, all signups become active for 100 years diff --git a/components/Header.tsx b/components/Header.tsx index 753fe59..ad01016 100644 --- a/components/Header.tsx +++ b/components/Header.tsx @@ -50,6 +50,12 @@ export default function Header({ route, user }: Data) { label: 'Photos', } : null, + isAppEnabled('expenses') + ? { + url: '/expenses', + label: 'Expenses', + } + : null, ]; const menuItems = potentialMenuItems.filter(Boolean) as MenuItem[]; @@ -67,6 +73,10 @@ export default function Header({ route, user }: Data) { pageLabel = 'Settings'; } + if (route.startsWith('/expenses')) { + pageLabel = 'Budgets & Expenses'; + } + return ( <> diff --git a/components/expenses/BudgetModal.tsx b/components/expenses/BudgetModal.tsx new file mode 100644 index 0000000..f731a1a --- /dev/null +++ b/components/expenses/BudgetModal.tsx @@ -0,0 +1,119 @@ +import { useSignal } from '@preact/signals'; +import { useEffect } from 'preact/hooks'; + +import { Budget } from '/lib/types.ts'; + +interface BudgetModalProps { + isOpen: boolean; + budget: Budget | null; + onClickSave: (newBudgetName: string, newBudgetMonth: string, newBudgetValue: number) => Promise; + onClickDelete: () => Promise; + onClose: () => void; +} + +export default function BudgetModal( + { isOpen, budget, onClickSave, onClickDelete, onClose }: BudgetModalProps, +) { + const newBudgetName = useSignal(budget?.name ?? ''); + const newBudgetMonth = useSignal(budget?.month ?? new Date().toISOString().substring(0, 10)); + const newBudgetValue = useSignal(budget?.value ?? 100); + + useEffect(() => { + if (budget) { + newBudgetName.value = budget.name; + newBudgetMonth.value = `${budget.month}-15`; + newBudgetValue.value = budget.value; + } else { + newBudgetName.value = ''; + newBudgetMonth.value = new Date().toISOString().substring(0, 10); + newBudgetValue.value = 100; + } + }, [budget]); + + return ( + <> +
+
+ +
+

{budget ? 'Edit Budget' : 'Create New Budget'}

+
+
+ + { + newBudgetName.value = event.currentTarget.value; + }} + placeholder='Amazing' + /> +
+ +
+ + { + newBudgetMonth.value = event.currentTarget.value; + }} + placeholder='2025-01-01' + /> +
+ +
+ + { + newBudgetValue.value = Number(event.currentTarget.value); + }} + placeholder='100' + /> +
+
+
+ {budget + ? ( + + ) + : null} + + +
+
+ + ); +} diff --git a/components/expenses/ExpenseModal.tsx b/components/expenses/ExpenseModal.tsx new file mode 100644 index 0000000..bb76a14 --- /dev/null +++ b/components/expenses/ExpenseModal.tsx @@ -0,0 +1,249 @@ +import { useSignal } from '@preact/signals'; +import { useEffect } from 'preact/hooks'; + +import { Budget, Expense } from '/lib/types.ts'; + +import { + RequestBody as SuggestionsRequestBody, + ResponseBody as SuggestionsResponse, +} from '/routes/api/expenses/auto-complete.tsx'; + +interface ExpenseModalProps { + isOpen: boolean; + expense: Expense | null; + budgets: Budget[]; + onClickSave: ( + newExpenseCost: number, + newExpenseDescription: string, + newExpenseBudget: string, + newExpenseDate: string, + newExpenseIsRecurring: boolean, + ) => Promise; + onClickDelete: () => Promise; + onClose: () => void; +} + +export default function ExpenseModal( + { isOpen, expense, budgets, onClickSave, onClickDelete, onClose }: ExpenseModalProps, +) { + const newExpenseCost = useSignal(expense?.cost ?? ''); + const newExpenseDescription = useSignal(expense?.description ?? ''); + const newExpenseBudget = useSignal(expense?.budget ?? 'Misc'); + const newExpenseDate = useSignal(expense?.date ?? ''); + const newExpenseIsRecurring = useSignal(expense?.is_recurring ?? false); + const suggestions = useSignal([]); + const showSuggestions = useSignal(false); + + useEffect(() => { + if (expense) { + newExpenseCost.value = expense.cost; + newExpenseDescription.value = expense.description; + newExpenseBudget.value = expense.budget; + newExpenseDate.value = expense.date; + newExpenseIsRecurring.value = expense.is_recurring; + showSuggestions.value = false; + } else { + newExpenseCost.value = ''; + newExpenseDescription.value = ''; + newExpenseBudget.value = 'Misc'; + newExpenseDate.value = ''; + newExpenseIsRecurring.value = false; + showSuggestions.value = false; + } + }, [expense]); + + const sortedBudgetNames = budgets.map((budget) => budget.name).sort(); + + if (!sortedBudgetNames.includes('Misc')) { + sortedBudgetNames.push('Misc'); + sortedBudgetNames.sort(); + } + + const fetchSuggestions = async (name: string) => { + if (name.length < 2) { + suggestions.value = []; + showSuggestions.value = false; + return; + } + + try { + const requestBody: SuggestionsRequestBody = { + name, + }; + + const response = await fetch(`/api/expenses/auto-complete`, { + method: 'POST', + body: JSON.stringify(requestBody), + }); + + if (response.ok) { + const result = await response.json() as SuggestionsResponse; + suggestions.value = result.suggestions; + showSuggestions.value = true; + } + } catch (error) { + console.error('Failed to fetch suggestions:', error); + suggestions.value = []; + showSuggestions.value = false; + } + }; + + return ( + <> +
+
+ +
+

{expense ? 'Edit Expense' : 'Create New Expense'}

+
+
+ + { + newExpenseCost.value = Number(event.currentTarget.value); + }} + placeholder='10.99' + /> +
+ +
+ + { + newExpenseDescription.value = event.currentTarget.value; + fetchSuggestions(event.currentTarget.value); + }} + onFocus={() => { + if (suggestions.value.length > 0) { + showSuggestions.value = true; + } + }} + onBlur={() => { + setTimeout(() => { + showSuggestions.value = false; + }, 200); + }} + placeholder='Lunch' + /> + {showSuggestions.value && suggestions.value.length > 0 && ( +
    + {suggestions.value.map((suggestion) => ( +
  • { + newExpenseDescription.value = suggestion; + showSuggestions.value = false; + suggestions.value = []; + }} + > + {suggestion} +
  • + ))} +
+ )} +
+ +
+ + +
+ +
+ + { + newExpenseDate.value = event.currentTarget.value; + }} + placeholder='2025-01-01' + /> +
+ + {expense + ? ( +
+ + { + newExpenseIsRecurring.value = event.currentTarget.checked; + }} + /> +
+ ) + : null} +
+
+ {expense + ? ( + + ) + : null} + + +
+
+ + ); +} diff --git a/components/expenses/ListBudgets.tsx b/components/expenses/ListBudgets.tsx new file mode 100644 index 0000000..ce87f11 --- /dev/null +++ b/components/expenses/ListBudgets.tsx @@ -0,0 +1,138 @@ +import { useSignal } from '@preact/signals'; +import { useEffect, useRef } from 'preact/hooks'; +import { Chart } from 'chart.js'; + +import { formatNumber } from '/lib/utils/misc.ts'; +import { Budget, SupportedCurrencySymbol } from '/lib/types.ts'; + +interface ListBudgetsProps { + budgets: Budget[]; + month: string; + currency: SupportedCurrencySymbol; + onClickEditBudget: (budgetId: string) => void; +} + +export default function ListBudgets( + { + budgets, + month, + currency, + onClickEditBudget, + }: ListBudgetsProps, +) { + const view = useSignal<'list' | 'chart'>('list'); + const chartRef = useRef(null); + + // Calculate a total budget to show before all others + const totalBudget: Omit = { + id: 'total', + name: 'Total', + month, + value: budgets.reduce((accumulatedValue, budget) => accumulatedValue + budget.value, 0), + extra: { + usedValue: budgets.reduce((accumulatedValue, budget) => accumulatedValue + budget.extra.usedValue, 0), + availableValue: budgets.reduce((accumulatedValue, budget) => accumulatedValue + budget.extra.availableValue, 0), + }, + }; + + function swapView(newView: 'list' | 'chart') { + view.value = view.value === newView ? 'list' : newView; + } + + useEffect(() => { + if (view.value === 'chart') { + const budgetColors = [totalBudget, ...budgets].map((_, index) => + index === 0 + ? 'rgba(59, 130, 246, 0.8)' + : `hsl(${(index - 1) * (360 / budgets.length)}, ${index % 2 ? 85 : 70}%, ${index % 2 ? 55 : 65}%, 0.8)` + ); + + new Chart(chartRef.current as HTMLCanvasElement, { + type: 'doughnut', + data: { + labels: [{ name: 'Available' }, ...budgets].map((budget) => budget.name), + datasets: [{ + label: '', + data: [totalBudget, ...budgets].map((budget) => + budget.id === 'total' ? budget.extra.availableValue : budget.extra.usedValue + ), + backgroundColor: budgetColors, + borderWidth: 1, + borderColor: '#222', + }], + }, + options: { + backgroundColor: '#222', + plugins: { + legend: { + position: 'bottom', + fullSize: true, + labels: { + usePointStyle: true, + pointStyle: 'circle', + padding: 10, + }, + }, + }, + }, + }); + } + }, [view.value]); + + return ( +
+ {budgets.length === 0 + ? ( +
+
No budgets to show for {month}
+
+ ) + : ( +
+ {view.value === 'list' + ? [totalBudget, ...budgets].map((budget) => { + let backgroundColorClass = 'bg-green-600'; + let usedValuePercentage = Math.ceil(100 * budget.extra.usedValue / budget.value); + + if (usedValuePercentage >= 100) { + usedValuePercentage = 100; + backgroundColorClass = 'bg-red-600'; + } + + return ( +
budget.id === 'total' ? swapView('chart') : onClickEditBudget(budget.id)} + class='flex max-w-sm gap-y-4 gap-x-4 rounded shadow-md bg-slate-700 relative cursor-pointer py-4 px-6 hover:opacity-80' + > +
+ + {formatNumber(currency, budget.extra.usedValue)} of {formatNumber(currency, budget.value)} + + + + + + {budget.name} +
+ + {formatNumber(currency, budget.extra.availableValue)} + +
+ ); + }) + : ( +
swapView('list')}> + +
+ )} +
+ )} +
+ ); +} diff --git a/components/expenses/ListExpenses.tsx b/components/expenses/ListExpenses.tsx new file mode 100644 index 0000000..8b251a3 --- /dev/null +++ b/components/expenses/ListExpenses.tsx @@ -0,0 +1,65 @@ +import { Expense, SupportedCurrencySymbol } from '/lib/types.ts'; +import { formatNumber } from '/lib/utils/misc.ts'; +interface ListExpensesProps { + expenses: Expense[]; + currency: SupportedCurrencySymbol; + onClickEditExpense: (expenseId: string) => void; +} + +export default function ListExpenses( + { + expenses, + currency, + onClickEditExpense, + }: ListExpensesProps, +) { + const dateFormat = new Intl.DateTimeFormat('en-US', { + month: 'short', + day: 'numeric', + }); + + return ( +
+ {expenses.length === 0 + ? ( +
+
No expenses to show
+
+ ) + : ( +
+ + + + + + + + + + + {expenses.map((expense) => ( + onClickEditExpense(expense.id)} + > + + + + + + ))} + +
{expense.description} + {expense.budget} + + {dateFormat.format(new Date(expense.date))} + + {formatNumber(currency, expense.cost)} +
+
+ )} +
+ ); +} diff --git a/components/expenses/MainExpenses.tsx b/components/expenses/MainExpenses.tsx new file mode 100644 index 0000000..bc1a722 --- /dev/null +++ b/components/expenses/MainExpenses.tsx @@ -0,0 +1,794 @@ +import { useSignal } from '@preact/signals'; +import { useEffect } from 'preact/hooks'; + +import { Budget, Expense, SupportedCurrencySymbol } from '/lib/types.ts'; +import { + RequestBody as ImportRequestBody, + ResponseBody as ImportResponseBody, +} from '/routes/api/expenses/import-expenses.tsx'; +import { + RequestBody as ExportRequestBody, + ResponseBody as ExportResponseBody, +} from '/routes/api/expenses/export-expenses.tsx'; +import { + RequestBody as AddExpenseRequestBody, + ResponseBody as AddExpenseResponseBody, +} from '/routes/api/expenses/add-expense.tsx'; +import { + RequestBody as AddBudgetRequestBody, + ResponseBody as AddBudgetResponseBody, +} from '/routes/api/expenses/add-budget.tsx'; +import { + RequestBody as UpdateExpenseRequestBody, + ResponseBody as UpdateExpenseResponseBody, +} from '/routes/api/expenses/update-expense.tsx'; +import { + RequestBody as UpdateBudgetRequestBody, + ResponseBody as UpdateBudgetResponseBody, +} from '/routes/api/expenses/update-budget.tsx'; +import { + RequestBody as DeleteExpenseRequestBody, + ResponseBody as DeleteExpenseResponseBody, +} from '/routes/api/expenses/delete-expense.tsx'; +import { + RequestBody as DeleteBudgetRequestBody, + ResponseBody as DeleteBudgetResponseBody, +} from '/routes/api/expenses/delete-budget.tsx'; +import ListBudgets from '/components/expenses/ListBudgets.tsx'; +import ListExpenses from '/components/expenses/ListExpenses.tsx'; +import ExpenseModal from './ExpenseModal.tsx'; +import BudgetModal from './BudgetModal.tsx'; + +interface MainExpensesProps { + initialBudgets: Budget[]; + initialExpenses: Expense[]; + initialMonth: string; + currency: SupportedCurrencySymbol; +} + +export default function MainExpenses({ initialBudgets, initialExpenses, initialMonth, currency }: MainExpensesProps) { + const isSaving = useSignal(false); + const isImporting = useSignal(false); + const isExporting = useSignal(false); + const isSearching = useSignal(false); + const budgets = useSignal(initialBudgets); + const expenses = useSignal(initialExpenses); + const currentMonth = useSignal(initialMonth); + const areNewOptionsOption = useSignal(false); + const isExpenseModalOpen = useSignal(false); + const editingExpense = useSignal(null); + const isBudgetModalOpen = useSignal(false); + const editingBudget = useSignal(null); + const searchTimeout = useSignal>(0); + + const dateFormat = new Intl.DateTimeFormat('en-GB', { year: 'numeric', month: 'long' }); + const thisMonth = new Date().toISOString().substring(0, 7); + + function onClickImportFile() { + areNewOptionsOption.value = false; + + const fileInput = document.createElement('input'); + fileInput.type = 'file'; + fileInput.multiple = true; + fileInput.accept = 'text/pain,application/json,.json'; + fileInput.ariaLabel = 'Import your budgets and expenses'; + fileInput.click(); + + fileInput.onchange = async (event) => { + const chosenFilesList = (event.target as HTMLInputElement)?.files!; + + const chosenFiles = Array.from(chosenFilesList); + + isImporting.value = true; + + for (const chosenFile of chosenFiles) { + if (!chosenFile) { + continue; + } + + areNewOptionsOption.value = false; + + let importedFileData: { budgets?: ImportRequestBody['budgets']; expenses?: ImportRequestBody['expenses'] } = {}; + + try { + importedFileData = JSON.parse(await chosenFile.text()); + } catch (_error) { + importedFileData = {}; + } + + if ( + !Object.prototype.hasOwnProperty.call(importedFileData, 'budgets') && + !Object.prototype.hasOwnProperty.call(importedFileData, 'expenses') + ) { + alert('Could not parse the file. Please confirm what you chose is correct.'); + return; + } + + const budgetsToImport = importedFileData.budgets || []; + const expensesToImport = importedFileData.expenses || []; + + const mergeOrReplace = prompt( + 'Do you want to merge or replace the existing expenses and budgets? (merge/replace)', + ); + + if (!mergeOrReplace || (mergeOrReplace !== 'merge' && mergeOrReplace !== 'replace')) { + alert('Invalid input. Please enter "merge" or "replace".'); + return; + } + + try { + const requestBody: ImportRequestBody = { + budgets: budgetsToImport, + expenses: expensesToImport, + month: currentMonth.value, + replace: mergeOrReplace === 'replace', + }; + const response = await fetch(`/api/expenses/import-expenses`, { + method: 'POST', + body: JSON.stringify(requestBody), + }); + + if (!response.ok) { + throw new Error(`Failed to import expenses and budgets! ${response.statusText} ${await response.text()}`); + } + + const result = await response.json() as ImportResponseBody; + + if (!result.success) { + throw new Error('Failed to import expenses and budgets!'); + } + + budgets.value = [...result.newBudgets]; + expenses.value = [...result.newExpenses]; + } catch (error) { + console.error(error); + alert(error); + } + } + + isImporting.value = false; + }; + } + + async function onClickExportFile() { + areNewOptionsOption.value = false; + + isExporting.value = true; + + const fileName = `expenses-data-export-${new Date().toISOString().substring(0, 19).replace(/:/g, '-')}.json`; + + try { + const requestBody: ExportRequestBody = {}; + const response = await fetch(`/api/expenses/export-expenses`, { + method: 'POST', + body: JSON.stringify(requestBody), + }); + const result = await response.json() as ExportResponseBody; + + if (!result.success) { + throw new Error('Failed to get contact!'); + } + + const exportContents = JSON.stringify(result.jsonContents, null, 2); + + // Add content-type + const jsonContent = `data:application/json; charset=utf-8,${encodeURIComponent(exportContents)}`; + + // Download the file + const data = jsonContent; + const link = document.createElement('a'); + link.setAttribute('href', data); + link.setAttribute('download', fileName); + link.click(); + link.remove(); + } catch (error) { + console.error(error); + alert(error); + } + + isExporting.value = false; + } + + function onClickCreateExpense() { + areNewOptionsOption.value = false; + + if (isExpenseModalOpen.value) { + isExpenseModalOpen.value = false; + return; + } + + editingExpense.value = null; + isExpenseModalOpen.value = true; + } + + function onClickCreateBudget() { + areNewOptionsOption.value = false; + + if (isBudgetModalOpen.value) { + isBudgetModalOpen.value = false; + return; + } + + editingBudget.value = null; + isBudgetModalOpen.value = true; + } + + function onClickEditExpense(expenseId: string) { + areNewOptionsOption.value = false; + + if (isExpenseModalOpen.value) { + isExpenseModalOpen.value = false; + return; + } + + editingExpense.value = expenses.value.find((expense) => expense.id === expenseId)!; + isExpenseModalOpen.value = true; + } + + function onClickEditBudget(budgetId: string) { + areNewOptionsOption.value = false; + + if (isBudgetModalOpen.value) { + isBudgetModalOpen.value = false; + return; + } + + // Can't edit the total budget + if (budgetId === 'total') { + return; + } + + editingBudget.value = budgets.value.find((budget) => budget.id === budgetId)!; + isBudgetModalOpen.value = true; + } + + async function onClickSaveExpense( + newExpenseCost: number, + newExpenseDescription: string, + newExpenseBudget: string, + newExpenseDate: string, + newExpenseIsRecurring: boolean, + ) { + if (isSaving.value) { + return; + } + + if (!newExpenseCost || Number.isNaN(newExpenseCost) || !newExpenseDescription) { + return; + } + + areNewOptionsOption.value = false; + isSaving.value = true; + + if (editingExpense.value) { + const requestBody: UpdateExpenseRequestBody = { + id: editingExpense.value.id, + cost: newExpenseCost, + description: newExpenseDescription, + budget: newExpenseBudget, + date: newExpenseDate, + is_recurring: newExpenseIsRecurring, + month: currentMonth.value, + }; + + try { + const response = await fetch(`/api/expenses/update-expense`, { + method: 'POST', + body: JSON.stringify(requestBody), + }); + + if (!response.ok) { + throw new Error(`Failed to update expense! ${response.statusText} ${await response.text()}`); + } + + const result = await response.json() as UpdateExpenseResponseBody; + + if (!result.success) { + throw new Error('Failed to update expense!'); + } + + expenses.value = [...result.newExpenses]; + budgets.value = [...result.newBudgets]; + + isExpenseModalOpen.value = false; + editingExpense.value = null; + } catch (error) { + console.error(error); + alert(error); + } + } else { + const requestBody: AddExpenseRequestBody = { + cost: newExpenseCost, + description: newExpenseDescription, + budget: newExpenseBudget, + date: newExpenseDate, + is_recurring: newExpenseIsRecurring, + month: currentMonth.value, + }; + + try { + const response = await fetch(`/api/expenses/add-expense`, { + method: 'POST', + body: JSON.stringify(requestBody), + }); + + if (!response.ok) { + throw new Error(`Failed to add expense! ${response.statusText} ${await response.text()}`); + } + + const result = await response.json() as AddExpenseResponseBody; + + if (!result.success) { + throw new Error('Failed to add expense!'); + } + + expenses.value = [...result.newExpenses]; + budgets.value = [...result.newBudgets]; + + isExpenseModalOpen.value = false; + } catch (error) { + console.error(error); + alert(error); + } + } + + isSaving.value = false; + } + + async function onClickSaveBudget(newBudgetName: string, newBudgetMonth: string, newBudgetValue: number) { + if (isSaving.value) { + return; + } + + if ( + !newBudgetName || !newBudgetMonth || !newBudgetMonth.match(/^\d{4}-\d{2}$/) || !newBudgetValue || + Number.isNaN(newBudgetValue) + ) { + return; + } + + areNewOptionsOption.value = false; + isSaving.value = true; + + if (editingBudget.value) { + const requestBody: UpdateBudgetRequestBody = { + id: editingBudget.value.id, + name: newBudgetName, + month: newBudgetMonth, + value: newBudgetValue, + currentMonth: currentMonth.value, + }; + + try { + const response = await fetch(`/api/expenses/update-budget`, { + method: 'POST', + body: JSON.stringify(requestBody), + }); + + if (!response.ok) { + throw new Error(`Failed to update budget! ${response.statusText} ${await response.text()}`); + } + + const result = await response.json() as UpdateBudgetResponseBody; + + if (!result.success) { + throw new Error('Failed to update budget!'); + } + + budgets.value = [...result.newBudgets]; + + isBudgetModalOpen.value = false; + editingBudget.value = null; + } catch (error) { + console.error(error); + alert(error); + } + } else { + const requestBody: AddBudgetRequestBody = { + name: newBudgetName, + month: newBudgetMonth, + value: newBudgetValue, + currentMonth: currentMonth.value, + }; + + try { + const response = await fetch(`/api/expenses/add-budget`, { + method: 'POST', + body: JSON.stringify(requestBody), + }); + + if (!response.ok) { + throw new Error(`Failed to add budget! ${response.statusText} ${await response.text()}`); + } + + const result = await response.json() as AddBudgetResponseBody; + + if (!result.success) { + throw new Error('Failed to add budget!'); + } + + budgets.value = [...result.newBudgets]; + + isBudgetModalOpen.value = false; + } catch (error) { + console.error(error); + alert(error); + } + } + + isSaving.value = false; + } + + async function onClickDeleteExpense() { + if (isSaving.value) { + return; + } + + if (!editingExpense.value) { + return; + } + + if (!confirm('Are you sure you want to delete this expense?')) { + return; + } + + isSaving.value = true; + + const requestBody: DeleteExpenseRequestBody = { + id: editingExpense.value.id, + month: currentMonth.value, + }; + + try { + const response = await fetch(`/api/expenses/delete-expense`, { + method: 'POST', + body: JSON.stringify(requestBody), + }); + + if (!response.ok) { + throw new Error(`Failed to delete expense! ${response.statusText} ${await response.text()}`); + } + + const result = await response.json() as DeleteExpenseResponseBody; + + if (!result.success) { + throw new Error('Failed to delete expense!'); + } + + expenses.value = [...result.newExpenses]; + budgets.value = [...result.newBudgets]; + + isExpenseModalOpen.value = false; + editingExpense.value = null; + } catch (error) { + console.error(error); + alert(error); + } + + isSaving.value = false; + } + + async function onClickDeleteBudget() { + if (isSaving.value) { + return; + } + + if (!editingBudget.value) { + return; + } + + if (!confirm('Are you sure you want to delete this budget?')) { + return; + } + + isSaving.value = true; + + const requestBody: DeleteBudgetRequestBody = { + id: editingBudget.value.id, + currentMonth: currentMonth.value, + }; + + try { + const response = await fetch(`/api/expenses/delete-budget`, { + method: 'POST', + body: JSON.stringify(requestBody), + }); + + if (!response.ok) { + throw new Error(`Failed to delete budget! ${response.statusText} ${await response.text()}`); + } + + const result = await response.json() as DeleteBudgetResponseBody; + + if (!result.success) { + throw new Error('Failed to delete budget!'); + } + + budgets.value = [...result.newBudgets]; + + isBudgetModalOpen.value = false; + editingBudget.value = null; + } catch (error) { + console.error(error); + alert(error); + } + + isSaving.value = false; + } + + function onCloseExpense() { + isExpenseModalOpen.value = false; + editingExpense.value = null; + } + + function onCloseBudget() { + isBudgetModalOpen.value = false; + editingBudget.value = null; + } + + function toggleNewOptionsDropdown() { + areNewOptionsOption.value = !areNewOptionsOption.value; + } + + function onClickChangeMonth(changeTo: 'previous' | 'next' | 'today') { + const previousMonth = new Date( + new Date(`${currentMonth.value}-15`).setUTCMonth(new Date(`${currentMonth.value}-15`).getUTCMonth() - 1), + ).toISOString() + .substring(0, 7); + const nextMonth = new Date( + new Date(`${currentMonth.value}-15`).setUTCMonth(new Date(`${currentMonth.value}-15`).getUTCMonth() + 1), + ).toISOString() + .substring(0, 7); + + if (changeTo === 'today') { + if (thisMonth === currentMonth.value) { + return; + } + + window.location.href = `/expenses?month=${thisMonth}`; + return; + } + + if (changeTo === 'previous') { + const newStartDate = previousMonth; + + if (newStartDate === currentMonth.value) { + return; + } + + window.location.href = `/expenses?month=${newStartDate}`; + return; + } + + const newStartDate = nextMonth; + + if (newStartDate === currentMonth.value) { + return; + } + + window.location.href = `/expenses?month=${newStartDate}`; + } + + function searchExpenses(searchTerm: string) { + if (searchTimeout.value) { + clearTimeout(searchTimeout.value); + } + + if (searchTerm.trim().length < 2) { + expenses.value = initialExpenses; + return; + } + + searchTimeout.value = setTimeout(() => { + isSearching.value = true; + + try { + const normalizedSearchTerm = searchTerm.trim().normalize().toLowerCase(); + const filteredExpenses = initialExpenses.filter((expense) => { + const descriptionMatch = expense.description.toLowerCase().includes(normalizedSearchTerm); + const budgetMatch = expense.budget.toLowerCase().includes(normalizedSearchTerm); + return descriptionMatch || budgetMatch; + }); + + expenses.value = filteredExpenses; + } catch (error) { + console.error(error); + alert(error); + expenses.value = initialExpenses; + } + + isSearching.value = false; + }, 500); + } + + useEffect(() => { + return () => { + if (searchTimeout.value) { + clearTimeout(searchTimeout.value); + } + }; + }, []); + + return ( + <> +
+
+
+ searchExpenses(event.currentTarget.value)} + /> + {isSearching.value ? : null} +
+
+
+

+ +

+
+ + + +
+
+
+ +
+ + +
+
+
+ +
+ + + + + + {isSaving.value + ? ( + <> + Saving... + + ) + : null} + {isImporting.value + ? ( + <> + Importing... + + ) + : null} + {isExporting.value + ? ( + <> + Exporting... + + ) + : null} + {!isSaving.value && !isImporting.value && !isExporting.value ? <>  : null} + +
+ + + + + + ); +} diff --git a/db-migrations/003-expenses-app.pgsql b/db-migrations/003-expenses-app.pgsql new file mode 100644 index 0000000..a0d4a77 --- /dev/null +++ b/db-migrations/003-expenses-app.pgsql @@ -0,0 +1,40 @@ +SET statement_timeout = 0; +SET lock_timeout = 0; +SET idle_in_transaction_session_timeout = 0; +SET client_encoding = 'UTF8'; +SET standard_conforming_strings = on; +SELECT pg_catalog.set_config('search_path', '', false); +SET check_function_bodies = false; +SET xmloption = content; +SET client_min_messages = warning; +SET row_security = off; + +CREATE TABLE public.bewcloud_budgets ( + id uuid DEFAULT gen_random_uuid(), + user_id uuid DEFAULT gen_random_uuid(), + name text NOT NULL, + month character varying NOT NULL, + value numeric NOT NULL, + extra jsonb NOT NULL, + created_at timestamp with time zone DEFAULT now() +); + +ALTER TABLE ONLY public.bewcloud_budgets ADD CONSTRAINT bewcloud_budgets_pkey PRIMARY KEY (id); +ALTER TABLE ONLY public.bewcloud_budgets ADD CONSTRAINT bewcloud_budgets_user_id_fkey FOREIGN KEY (user_id) REFERENCES public.bewcloud_users(id); +ALTER TABLE ONLY public.bewcloud_budgets ADD CONSTRAINT bewcloud_budgets_user_id_name_month_unique UNIQUE (user_id, name, month); + + +CREATE TABLE public.bewcloud_expenses ( + id uuid DEFAULT gen_random_uuid(), + user_id uuid DEFAULT gen_random_uuid(), + cost numeric NOT NULL, + description text NOT NULL, + budget text NOT NULL, + date character varying NOT NULL, + is_recurring boolean NOT NULL, + extra jsonb NOT NULL, + created_at timestamp with time zone DEFAULT now() +); + +ALTER TABLE ONLY public.bewcloud_expenses ADD CONSTRAINT bewcloud_expenses_pkey PRIMARY KEY (id); +ALTER TABLE ONLY public.bewcloud_expenses ADD CONSTRAINT bewcloud_expenses_user_id_fkey FOREIGN KEY (user_id) REFERENCES public.bewcloud_users(id); diff --git a/deno.json b/deno.json index afcdce0..4bb1d31 100644 --- a/deno.json +++ b/deno.json @@ -35,6 +35,7 @@ "tailwindcss/": "npm:/tailwindcss@3.4.15/", "tailwindcss/plugin": "npm:/tailwindcss@3.4.15/plugin.js", "std/": "https://deno.land/std@0.224.0/", - "$std/": "https://deno.land/std@0.224.0/" + "$std/": "https://deno.land/std@0.224.0/", + "chart.js": "https://esm.sh/chart.js@4.4.7/auto" } } diff --git a/fresh.gen.ts b/fresh.gen.ts index 70423b4..ab51818 100644 --- a/fresh.gen.ts +++ b/fresh.gen.ts @@ -7,6 +7,15 @@ import * as $_app from './routes/_app.tsx'; import * as $_middleware from './routes/_middleware.tsx'; import * as $api_dashboard_save_links from './routes/api/dashboard/save-links.tsx'; import * as $api_dashboard_save_notes from './routes/api/dashboard/save-notes.tsx'; +import * as $api_expenses_add_budget from './routes/api/expenses/add-budget.tsx'; +import * as $api_expenses_add_expense from './routes/api/expenses/add-expense.tsx'; +import * as $api_expenses_auto_complete from './routes/api/expenses/auto-complete.tsx'; +import * as $api_expenses_delete_budget from './routes/api/expenses/delete-budget.tsx'; +import * as $api_expenses_delete_expense from './routes/api/expenses/delete-expense.tsx'; +import * as $api_expenses_export_expenses from './routes/api/expenses/export-expenses.tsx'; +import * as $api_expenses_import_expenses from './routes/api/expenses/import-expenses.tsx'; +import * as $api_expenses_update_budget from './routes/api/expenses/update-budget.tsx'; +import * as $api_expenses_update_expense from './routes/api/expenses/update-expense.tsx'; import * as $api_files_create_directory from './routes/api/files/create-directory.tsx'; import * as $api_files_delete_directory from './routes/api/files/delete-directory.tsx'; import * as $api_files_delete from './routes/api/files/delete.tsx'; @@ -26,6 +35,7 @@ import * as $api_news_refresh_articles from './routes/api/news/refresh-articles. import * as $api_notes_save from './routes/api/notes/save.tsx'; import * as $dashboard from './routes/dashboard.tsx'; import * as $dav from './routes/dav.tsx'; +import * as $expenses from './routes/expenses.tsx'; import * as $files from './routes/files.tsx'; import * as $files_open_fileName_ from './routes/files/open/[fileName].tsx'; import * as $index from './routes/index.tsx'; @@ -42,6 +52,7 @@ import * as $signup from './routes/signup.tsx'; import * as $Settings from './islands/Settings.tsx'; import * as $dashboard_Links from './islands/dashboard/Links.tsx'; import * as $dashboard_Notes from './islands/dashboard/Notes.tsx'; +import * as $expenses_ExpensesWrapper from './islands/expenses/ExpensesWrapper.tsx'; import * as $files_FilesWrapper from './islands/files/FilesWrapper.tsx'; import * as $news_Articles from './islands/news/Articles.tsx'; import * as $news_Feeds from './islands/news/Feeds.tsx'; @@ -57,6 +68,15 @@ const manifest = { './routes/_middleware.tsx': $_middleware, './routes/api/dashboard/save-links.tsx': $api_dashboard_save_links, './routes/api/dashboard/save-notes.tsx': $api_dashboard_save_notes, + './routes/api/expenses/add-budget.tsx': $api_expenses_add_budget, + './routes/api/expenses/add-expense.tsx': $api_expenses_add_expense, + './routes/api/expenses/auto-complete.tsx': $api_expenses_auto_complete, + './routes/api/expenses/delete-budget.tsx': $api_expenses_delete_budget, + './routes/api/expenses/delete-expense.tsx': $api_expenses_delete_expense, + './routes/api/expenses/export-expenses.tsx': $api_expenses_export_expenses, + './routes/api/expenses/import-expenses.tsx': $api_expenses_import_expenses, + './routes/api/expenses/update-budget.tsx': $api_expenses_update_budget, + './routes/api/expenses/update-expense.tsx': $api_expenses_update_expense, './routes/api/files/create-directory.tsx': $api_files_create_directory, './routes/api/files/delete-directory.tsx': $api_files_delete_directory, './routes/api/files/delete.tsx': $api_files_delete, @@ -76,6 +96,7 @@ const manifest = { './routes/api/notes/save.tsx': $api_notes_save, './routes/dashboard.tsx': $dashboard, './routes/dav.tsx': $dav, + './routes/expenses.tsx': $expenses, './routes/files.tsx': $files, './routes/files/open/[fileName].tsx': $files_open_fileName_, './routes/index.tsx': $index, @@ -94,6 +115,7 @@ const manifest = { './islands/Settings.tsx': $Settings, './islands/dashboard/Links.tsx': $dashboard_Links, './islands/dashboard/Notes.tsx': $dashboard_Notes, + './islands/expenses/ExpensesWrapper.tsx': $expenses_ExpensesWrapper, './islands/files/FilesWrapper.tsx': $files_FilesWrapper, './islands/news/Articles.tsx': $news_Articles, './islands/news/Feeds.tsx': $news_Feeds, diff --git a/islands/Settings.tsx b/islands/Settings.tsx index 3947505..11f4003 100644 --- a/islands/Settings.tsx +++ b/islands/Settings.tsx @@ -1,5 +1,7 @@ import { convertObjectToFormData, helpEmail } from '/lib/utils/misc.ts'; import { FormField, generateFieldHtml, getFormDataField } from '/lib/form-utils.tsx'; +import { currencyMap, SupportedCurrencySymbol } from '/lib/types.ts'; +import { isAppEnabled } from '/lib/config.ts'; interface SettingsProps { formData: Record; @@ -11,6 +13,7 @@ interface SettingsProps { title: string; message: string; }; + currency?: SupportedCurrencySymbol; } export type Action = @@ -18,7 +21,8 @@ export type Action = | 'verify-change-email' | 'change-password' | 'change-dav-password' - | 'delete-account'; + | 'delete-account' + | 'change-currency'; export const actionWords = new Map([ ['change-email', 'change email'], @@ -26,9 +30,10 @@ export const actionWords = new Map([ ['change-password', 'change password'], ['change-dav-password', 'change WebDav password'], ['delete-account', 'delete account'], + ['change-currency', 'change currency'], ]); -function formFields(action: Action, formData: FormData) { +function formFields(action: Action, formData: FormData, currency?: SupportedCurrencySymbol) { const fields: FormField[] = [ { name: 'action', @@ -98,11 +103,23 @@ function formFields(action: Action, formData: FormData) { description: 'You need to input your password in order to delete your account.', required: true, }); + } else if (action === 'change-currency') { + fields.push({ + name: 'currency', + label: 'Currency', + type: 'select', + options: Array.from(currencyMap.keys()).map((currencySymbol) => ({ + value: currencySymbol, + label: `${currencySymbol} (${currencyMap.get(currencySymbol)})`, + })), + value: getFormDataField(formData, 'currency') || currency, + required: true, + }); } return fields; } -export default function Settings({ formData: formDataObject, error, notice }: SettingsProps) { +export default function Settings({ formData: formDataObject, error, notice, currency }: SettingsProps) { const formData = convertObjectToFormData(formDataObject); const action = getFormDataField(formData, 'action') as Action; @@ -157,6 +174,24 @@ export default function Settings({ formData: formDataObject, error, notice }: Se + {isAppEnabled('expenses') + ? ( + <> +

Change your currency

+

+ This is only used in the expenses app, visually. It changes nothing about the stored data or values. +

+ +
+ {formFields('change-currency', formData, currency).map((field) => generateFieldHtml(field, formData))} +
+ +
+
+ + ) + : null} +

Delete your account

Deleting your account is instant and deletes all your data. If you need help, please{' '} diff --git a/islands/expenses/ExpensesWrapper.tsx b/islands/expenses/ExpensesWrapper.tsx new file mode 100644 index 0000000..2650eb9 --- /dev/null +++ b/islands/expenses/ExpensesWrapper.tsx @@ -0,0 +1,23 @@ +import { Budget, Expense, SupportedCurrencySymbol } from '/lib/types.ts'; +import MainExpenses from '/components/expenses/MainExpenses.tsx'; + +interface ExpensesWrapperProps { + initialBudgets: Budget[]; + initialExpenses: Expense[]; + initialMonth: string; + currency: SupportedCurrencySymbol; +} + +// This wrapper is necessary because islands need to be the first frontend component, but they don't support functions as props, so the more complex logic needs to live in the component itself +export default function ExpensesWrapper( + { initialBudgets, initialExpenses, initialMonth, currency }: ExpensesWrapperProps, +) { + return ( + + ); +} diff --git a/lib/config.ts b/lib/config.ts index 89d42fe..91b0392 100644 --- a/lib/config.ts +++ b/lib/config.ts @@ -14,7 +14,7 @@ export async function isSignupAllowed() { return false; } -export function isAppEnabled(app: 'news' | 'notes' | 'photos') { +export function isAppEnabled(app: 'news' | 'notes' | 'photos' | 'expenses') { const enabledApps = (Deno.env.get('CONFIG_ENABLED_APPS') || '').split(',') as typeof app[]; return enabledApps.includes(app); diff --git a/lib/data/expenses.ts b/lib/data/expenses.ts new file mode 100644 index 0000000..ddf6f64 --- /dev/null +++ b/lib/data/expenses.ts @@ -0,0 +1,469 @@ +import Database, { sql } from '/lib/interfaces/database.ts'; +import Locker from '/lib/interfaces/locker.ts'; +import { Budget, Expense } from '/lib/types.ts'; + +const db = new Database(); + +export async function getBudgets( + userId: string, + month: string, + { skipRecalculation = false }: { skipRecalculation?: boolean } = {}, +) { + if (!skipRecalculation) { + await recalculateMonthBudgets(userId, month); + } + + const budgets = await db.query( + sql`SELECT * FROM "bewcloud_budgets" WHERE "user_id" = $1 AND "month" = $2 ORDER BY cast("extra"->>'availableValue' as numeric) DESC, "value" DESC, "name" ASC`, + [ + userId, + month, + ], + ); + + // Numeric values come as strings, so we need to convert them to numbers + return budgets.map((budget) => ({ + ...budget, + value: Number(budget.value), + })); +} + +export async function getBudgetByName(userId: string, month: string, name: string) { + const budget = (await db.query( + sql`SELECT * FROM "bewcloud_budgets" WHERE "user_id" = $1 AND "month" = $2 AND LOWER("name") = LOWER($3)`, + [ + userId, + month, + name, + ], + ))[0]; + + if (!budget) { + return null; + } + + // Numeric values come as strings, so we need to convert them to numbers + return { + ...budget, + value: Number(budget.value), + }; +} + +export async function getBudgetById(userId: string, id: string) { + const budget = (await db.query( + sql`SELECT * FROM "bewcloud_budgets" WHERE "user_id" = $1 AND "id" = $2`, + [userId, id], + ))[0]; + + if (!budget) { + return null; + } + + // Numeric values come as strings, so we need to convert them to numbers + return { + ...budget, + value: Number(budget.value), + }; +} + +export async function getAllBudgetsForExport( + userId: string, +): Promise<(Omit & { extra: Record })[]> { + const budgets = await db.query( + sql`SELECT * FROM "bewcloud_budgets" WHERE "user_id" = $1 ORDER BY "month" DESC, "name" ASC`, + [ + userId, + ], + ); + + return budgets.map((budget) => ({ + name: budget.name, + month: budget.month, + // Numeric values come as strings, so we need to convert them to numbers + value: Number(budget.value), + extra: {}, + })); +} + +export async function getExpenses(userId: string, month: string) { + const expenses = await db.query( + sql`SELECT * FROM "bewcloud_expenses" WHERE "user_id" = $1 AND "date" >= $2 AND "date" <= $3 ORDER BY "date" DESC, "created_at" DESC`, + [ + userId, + `${month}-01`, + `${month}-31`, + ], + ); + + // Numeric values come as strings, so we need to convert them to numbers + return expenses.map((expense) => ({ + ...expense, + cost: Number(expense.cost), + })); +} + +export async function getExpenseByName(userId: string, name: string) { + const expense = (await db.query( + sql`SELECT * FROM "bewcloud_expenses" WHERE "user_id" = $1 AND LOWER("description") = LOWER($2) ORDER BY "date" DESC, "created_at" DESC`, + [ + userId, + name, + ], + ))[0]; + + if (!expense) { + return null; + } + + // Numeric values come as strings, so we need to convert them to numbers + return { + ...expense, + cost: Number(expense.cost), + }; +} + +export async function getExpenseSuggestions(userId: string, name: string) { + const expenses = await db.query>( + sql`SELECT DISTINCT "description" FROM "bewcloud_expenses" WHERE "user_id" = $1 AND LOWER("description") ILIKE LOWER($2) ORDER BY "description" ASC`, + [ + userId, + `%${name}%`, + ], + ); + + return expenses.map((expense) => expense.description); +} + +export async function getAllExpensesForExport( + userId: string, +): Promise<(Omit & { extra: Record })[]> { + const expenses = await db.query( + sql`SELECT * FROM "bewcloud_expenses" WHERE "user_id" = $1 ORDER BY "date" DESC, "created_at" DESC`, + [ + userId, + ], + ); + + return expenses.map((expense) => ({ + description: expense.description, + budget: expense.budget, + date: expense.date, + is_recurring: expense.is_recurring, + // Numeric values come as strings, so we need to convert them to numbers + cost: Number(expense.cost), + extra: {}, + })); +} + +export async function getExpenseById(userId: string, id: string) { + const expense = (await db.query( + sql`SELECT * FROM "bewcloud_expenses" WHERE "user_id" = $1 AND "id" = $2`, + [userId, id], + ))[0]; + + if (!expense) { + return null; + } + + // Numeric values come as strings, so we need to convert them to numbers + return { + ...expense, + cost: Number(expense.cost), + }; +} + +export async function createBudget(userId: string, name: string, month: string, value: number) { + const extra: Budget['extra'] = { + usedValue: 0, + availableValue: value, + }; + + const newBudget = (await db.query( + sql`INSERT INTO "bewcloud_budgets" ( + "user_id", + "name", + "month", + "value", + "extra" + ) VALUES ($1, $2, $3, $4, $5) + RETURNING *`, + [ + userId, + name, + month, + value, + JSON.stringify(extra), + ], + ))[0]; + + // Numeric values come as strings, so we need to convert them to numbers + return { + ...newBudget, + value: Number(newBudget.value), + }; +} + +export async function updateBudget( + budget: Budget, + { skipRecalculation = false }: { skipRecalculation?: boolean } = {}, +) { + await db.query( + sql`UPDATE "bewcloud_budgets" SET + "name" = $2, + "month" = $3, + "value" = $4, + "extra" = $5 + WHERE "id" = $1`, + [ + budget.id, + budget.name, + budget.month, + budget.value, + JSON.stringify(budget.extra), + ], + ); + + if (!skipRecalculation) { + await recalculateMonthBudgets(budget.user_id, budget.month); + } +} + +export async function deleteBudget(userId: string, id: string) { + await db.query( + sql`DELETE FROM "bewcloud_budgets" WHERE "id" = $1 AND "user_id" = $2`, + [ + id, + userId, + ], + ); +} + +export async function deleteAllBudgetsAndExpenses(userId: string) { + await db.query( + sql`DELETE FROM "bewcloud_expenses" WHERE "user_id" = $1`, + [ + userId, + ], + ); + + await db.query( + sql`DELETE FROM "bewcloud_budgets" WHERE "user_id" = $1`, + [ + userId, + ], + ); +} + +export async function createExpense( + userId: string, + cost: number, + description: string, + budget: string, + date: string, + is_recurring: boolean, + { skipRecalculation = false, skipBudgetMatching = false, skipBudgetCreation = false }: { + skipRecalculation?: boolean; + skipBudgetMatching?: boolean; + skipBudgetCreation?: boolean; + } = {}, +) { + const extra: Expense['extra'] = {}; + + if (!budget.trim()) { + budget = 'Misc'; + } + + // Match budget to an existing expense "by default" + if (!skipBudgetMatching && budget === 'Misc') { + const existingExpense = await getExpenseByName(userId, description); + + if (existingExpense) { + budget = existingExpense.budget; + } + } + + if (!skipBudgetCreation) { + const existingBudgetInMonth = await getBudgetByName(userId, date.substring(0, 7), budget); + + if (!existingBudgetInMonth) { + await createBudget(userId, budget, date.substring(0, 7), 100); + } + } + + const newExpense = (await db.query( + sql`INSERT INTO "bewcloud_expenses" ( + "user_id", + "cost", + "description", + "budget", + "date", + "is_recurring", + "extra" + ) VALUES ($1, $2, $3, $4, $5, $6, $7) + RETURNING *`, + [ + userId, + cost, + description, + budget, + date, + is_recurring, + JSON.stringify(extra), + ], + ))[0]; + + if (!skipRecalculation) { + await recalculateMonthBudgets(userId, date.substring(0, 7)); + } + + // Numeric values come as strings, so we need to convert them to numbers + return { + ...newExpense, + cost: Number(newExpense.cost), + }; +} + +export async function updateExpense(expense: Expense) { + const existingBudgetInMonth = await getBudgetByName(expense.user_id, expense.date.substring(0, 7), expense.budget); + + if (!existingBudgetInMonth) { + await createBudget(expense.user_id, expense.budget, expense.date.substring(0, 7), 100); + } + + await db.query( + sql`UPDATE "bewcloud_expenses" SET + "cost" = $2, + "description" = $3, + "budget" = $4, + "date" = $5, + "is_recurring" = $6, + "extra" = $7 + WHERE "id" = $1`, + [ + expense.id, + expense.cost, + expense.description, + expense.budget, + expense.date, + expense.is_recurring, + JSON.stringify(expense.extra), + ], + ); + + await recalculateMonthBudgets(expense.user_id, expense.date.substring(0, 7)); +} + +export async function deleteExpense(userId: string, id: string) { + const expense = await getExpenseById(userId, id); + + await db.query( + sql`DELETE FROM "bewcloud_expenses" WHERE "id" = $1 AND "user_id" = $2`, + [ + id, + userId, + ], + ); + + await recalculateMonthBudgets(userId, expense!.date.substring(0, 7)); +} + +export async function generateMonthlyBudgetsAndExpenses(userId: string, month: string) { + const lock = new Locker(`expenses:${userId}:${month}`); + + await lock.acquire(); + + let addedBudgetsCount = 0; + let addedExpensesCount = 0; + + try { + // Confirm there are no budgets or expenses for this month + const monthBudgets = await getBudgets(userId, month, { skipRecalculation: true }); + const monthExpenses = await getExpenses(userId, month); + + if (monthBudgets.length > 0 || monthExpenses.length > 0) { + throw new Error('Budgets and expenses already exist for this month!'); + } + + // Get the previous month's budgets, to copy over + const previousMonthDate = new Date(month); + previousMonthDate.setMonth(previousMonthDate.getMonth() - 1); + + const previousMonth = previousMonthDate.toISOString().substring(0, 7); + + const budgets = await getBudgets(userId, previousMonth, { skipRecalculation: true }); + + for (const budget of budgets) { + await createBudget(userId, budget.name, month, budget.value); + + addedBudgetsCount++; + } + + // Get the recurring expenses for the previous month, to copy over + const recurringExpenses = (await getExpenses(userId, previousMonth)).filter((expense) => expense.is_recurring); + + for (const expense of recurringExpenses) { + await createExpense( + userId, + expense.cost, + expense.description, + expense.budget, + expense.date.replace(previousMonth, month), + expense.is_recurring, + { skipRecalculation: true }, + ); + + addedExpensesCount++; + } + + console.info(`Added ${addedBudgetsCount} new budgets and ${addedExpensesCount} new expenses for ${month}`); + + lock.release(); + } catch (error) { + lock.release(); + + throw error; + } +} + +export async function recalculateMonthBudgets(userId: string, month: string) { + const lock = new Locker(`expenses:${userId}:${month}`); + + await lock.acquire(); + + try { + const budgets = await getBudgets(userId, month, { skipRecalculation: true }); + const expenses = await getExpenses(userId, month); + + // Calculate total expenses for each budget + const budgetExpenses = new Map(); + for (const expense of expenses) { + const currentTotal = Number(budgetExpenses.get(expense.budget) || 0); + budgetExpenses.set(expense.budget, Number(currentTotal + expense.cost)); + } + + // Update each budget with new calculations + for (const budget of budgets) { + const usedValue = Number(budgetExpenses.get(budget.name) || 0); + const availableValue = Number(budget.value - usedValue); + + const updatedBudget: Budget = { + ...budget, + extra: { + ...budget.extra, + usedValue, + availableValue, + }, + }; + + if (budget.extra.usedValue !== usedValue || budget.extra.availableValue !== availableValue) { + await updateBudget(updatedBudget, { skipRecalculation: true }); + } + } + + lock.release(); + } catch (error) { + lock.release(); + + throw error; + } +} diff --git a/lib/types.ts b/lib/types.ts index 5a24d24..39d6a81 100644 --- a/lib/types.ts +++ b/lib/types.ts @@ -12,6 +12,7 @@ export interface User { is_email_verified: boolean; is_admin?: boolean; dav_hashed_password?: string; + expenses_currency?: SupportedCurrencySymbol; }; created_at: Date; } @@ -81,7 +82,7 @@ export interface NewsFeedArticle { article_summary: string; article_date: Date; is_read: boolean; - extra: Record; + extra: Record; // NOTE: Here for potential future fields created_at: Date; } @@ -104,3 +105,39 @@ export interface DirectoryFile { updated_at: Date; created_at: Date; } + +export interface Budget { + id: string; + user_id: string; + name: string; + month: string; + value: number; + extra: { + usedValue: number; + availableValue: number; + }; + created_at: Date; +} + +export interface Expense { + id: string; + user_id: string; + cost: number; + description: string; + budget: string; + date: string; + is_recurring: boolean; + extra: Record; // NOTE: Here for potential future fields + created_at: Date; +} + +export type SupportedCurrencySymbol = '$' | '€' | '£' | '¥' | '₹'; +type SupportedCurrency = 'USD' | 'EUR' | 'GBP' | 'JPY' | 'INR'; + +export const currencyMap = new Map([ + ['$', 'USD'], + ['€', 'EUR'], + ['£', 'GBP'], + ['¥', 'JPY'], + ['₹', 'INR'], +]); diff --git a/lib/utils/misc.ts b/lib/utils/misc.ts index 8f30f50..023cbff 100644 --- a/lib/utils/misc.ts +++ b/lib/utils/misc.ts @@ -1,3 +1,6 @@ +import { currencyMap } from '/lib/types.ts'; +import { SupportedCurrencySymbol } from '/lib/types.ts'; + let BASE_URL = typeof window !== 'undefined' && window.location ? `${window.location.protocol}//${window.location.host}` : ''; @@ -256,3 +259,12 @@ export const capitalizeWord = (string: string) => { export function getRandomItem(items: Readonly>): T { return items[Math.floor(Math.random() * items.length)]; } + +export function formatNumber(currency: SupportedCurrencySymbol, number: number) { + return new Intl.NumberFormat('en-US', { + style: 'currency', + currency: currencyMap.get(currency) || 'USD', + minimumFractionDigits: 0, + maximumFractionDigits: 2, + }).format(Number.parseFloat(`${number}`.replace(',', '.'))); +} diff --git a/lib/utils/misc_test.ts b/lib/utils/misc_test.ts index 029ab4c..8874a9c 100644 --- a/lib/utils/misc_test.ts +++ b/lib/utils/misc_test.ts @@ -1,8 +1,11 @@ import { assertEquals } from 'std/assert/assert_equals.ts'; + +import { SupportedCurrencySymbol } from '/lib/types.ts'; import { convertFormDataToObject, convertObjectToFormData, escapeHtml, + formatNumber, generateHash, generateRandomCode, isRunningLocally, @@ -269,3 +272,22 @@ Deno.test('that isRunningLocally works', () => { assertEquals(result, test.expected); } }); + +Deno.test('that formatNumber works', () => { + const tests: { currency: SupportedCurrencySymbol; number: number; expected: string }[] = [ + { currency: '$', number: 10000, expected: '$10,000' }, + { currency: '$', number: 10000.5, expected: '$10,000.5' }, + { currency: '€', number: 10000, expected: '€10,000' }, + { currency: '€', number: 900.999, expected: '€901' }, + { currency: '€', number: 900.991, expected: '€900.99' }, + { currency: '$', number: 50.11, expected: '$50.11' }, + { currency: '£', number: 900.999, expected: '£901' }, + { currency: '£', number: 900.991, expected: '£900.99' }, + { currency: '£', number: 50.11, expected: '£50.11' }, + ]; + + for (const test of tests) { + const result = formatNumber(test.currency, test.number); + assertEquals(result, test.expected); + } +}); diff --git a/routes/api/expenses/add-budget.tsx b/routes/api/expenses/add-budget.tsx new file mode 100644 index 0000000..edb6bf4 --- /dev/null +++ b/routes/api/expenses/add-budget.tsx @@ -0,0 +1,57 @@ +import { Handlers } from 'fresh/server.ts'; + +import { Budget, FreshContextState } from '/lib/types.ts'; +import { createBudget, getBudgets } from '/lib/data/expenses.ts'; + +interface Data {} + +export interface RequestBody { + name: string; + month: string; + value: number; + currentMonth: string; +} + +export interface ResponseBody { + success: boolean; + newBudgets: Budget[]; +} + +export const handler: Handlers = { + async POST(request, context) { + if (!context.state.user) { + return new Response('Unauthorized', { status: 401 }); + } + + const requestBody = await request.clone().json() as RequestBody; + + if ( + !requestBody.name || !requestBody.month || !requestBody.month.match(/^\d{4}-\d{2}$/) || !requestBody.value || + Number.isNaN(requestBody.value) || !requestBody.currentMonth || !requestBody.currentMonth.match(/^\d{4}-\d{2}$/) + ) { + return new Response('Bad request', { status: 400 }); + } + + try { + const newBudget = await createBudget( + context.state.user.id, + requestBody.name, + requestBody.month, + requestBody.value, + ); + + if (!newBudget) { + throw new Error('Failed to add budget!'); + } + } catch (error) { + console.error(error); + return new Response(`${error}`, { status: 500 }); + } + + const newBudgets = await getBudgets(context.state.user.id, requestBody.currentMonth); + + const responseBody: ResponseBody = { success: true, newBudgets }; + + return new Response(JSON.stringify(responseBody)); + }, +}; diff --git a/routes/api/expenses/add-expense.tsx b/routes/api/expenses/add-expense.tsx new file mode 100644 index 0000000..e6f9ee9 --- /dev/null +++ b/routes/api/expenses/add-expense.tsx @@ -0,0 +1,80 @@ +import { Handlers } from 'fresh/server.ts'; + +import { Budget, Expense, FreshContextState } from '/lib/types.ts'; +import { createExpense, getBudgets, getExpenses } from '/lib/data/expenses.ts'; + +interface Data {} + +export interface RequestBody { + cost: number; + description: string; + budget: string; + date: string; + is_recurring: boolean; + month: string; +} + +export interface ResponseBody { + success: boolean; + newExpenses: Expense[]; + newBudgets: Budget[]; +} + +export const handler: Handlers = { + async POST(request, context) { + if (!context.state.user) { + return new Response('Unauthorized', { status: 401 }); + } + + const requestBody = await request.clone().json() as RequestBody; + + if ( + !requestBody.cost || Number.isNaN(requestBody.cost) || !requestBody.description || !requestBody.month || + !requestBody.month.match(/^\d{4}-\d{2}$/) + ) { + return new Response('Bad request', { status: 400 }); + } + + if (!requestBody.budget) { + requestBody.budget = 'Misc'; + } + + if (!requestBody.date) { + requestBody.date = new Date().toISOString().substring(0, 10); + } + + if (!requestBody.date.match(/^\d{4}-\d{2}-\d{2}$/)) { + return new Response('Bad request', { status: 400 }); + } + + if (!requestBody.is_recurring) { + requestBody.is_recurring = false; + } + + try { + const newExpense = await createExpense( + context.state.user.id, + requestBody.cost, + requestBody.description, + requestBody.budget, + requestBody.date, + requestBody.is_recurring, + ); + + if (!newExpense) { + throw new Error('Failed to add expense!'); + } + } catch (error) { + console.error(error); + return new Response(`${error}`, { status: 500 }); + } + + const newExpenses = await getExpenses(context.state.user.id, requestBody.month); + + const newBudgets = await getBudgets(context.state.user.id, requestBody.month); + + const responseBody: ResponseBody = { success: true, newExpenses, newBudgets }; + + return new Response(JSON.stringify(responseBody)); + }, +}; diff --git a/routes/api/expenses/auto-complete.tsx b/routes/api/expenses/auto-complete.tsx new file mode 100644 index 0000000..a424498 --- /dev/null +++ b/routes/api/expenses/auto-complete.tsx @@ -0,0 +1,38 @@ +import { Handlers } from 'fresh/server.ts'; + +import { FreshContextState } from '/lib/types.ts'; +import { getExpenseSuggestions } from '/lib/data/expenses.ts'; + +interface Data {} + +export interface RequestBody { + name: string; +} + +export interface ResponseBody { + success: boolean; + suggestions: string[]; +} + +export const handler: Handlers = { + async POST(request, context) { + if (!context.state.user) { + return new Response('Unauthorized', { status: 401 }); + } + + const requestBody = await request.clone().json() as RequestBody; + + if (!requestBody.name || requestBody.name.length < 2) { + return new Response('Bad request', { status: 400 }); + } + + const suggestions = await getExpenseSuggestions( + context.state.user.id, + requestBody.name, + ); + + const responseBody: ResponseBody = { success: true, suggestions }; + + return new Response(JSON.stringify(responseBody)); + }, +}; diff --git a/routes/api/expenses/delete-budget.tsx b/routes/api/expenses/delete-budget.tsx new file mode 100644 index 0000000..b89118f --- /dev/null +++ b/routes/api/expenses/delete-budget.tsx @@ -0,0 +1,51 @@ +import { Handlers } from 'fresh/server.ts'; + +import { Budget, FreshContextState } from '/lib/types.ts'; +import { deleteBudget, getBudgetById, getBudgets } from '/lib/data/expenses.ts'; + +interface Data {} + +export interface RequestBody { + id: string; + currentMonth: string; +} + +export interface ResponseBody { + success: boolean; + newBudgets: Budget[]; +} + +export const handler: Handlers = { + async POST(request, context) { + if (!context.state.user) { + return new Response('Unauthorized', { status: 401 }); + } + + const requestBody = await request.clone().json() as RequestBody; + + if ( + !requestBody.id || !requestBody.currentMonth || !requestBody.currentMonth.match(/^\d{4}-\d{2}$/) + ) { + return new Response('Bad request', { status: 400 }); + } + + const budget = await getBudgetById(context.state.user.id, requestBody.id); + + if (!budget) { + return new Response('Not found', { status: 404 }); + } + + try { + await deleteBudget(context.state.user.id, requestBody.id); + } catch (error) { + console.error(error); + return new Response(`${error}`, { status: 500 }); + } + + const newBudgets = await getBudgets(context.state.user.id, requestBody.currentMonth); + + const responseBody: ResponseBody = { success: true, newBudgets }; + + return new Response(JSON.stringify(responseBody)); + }, +}; diff --git a/routes/api/expenses/delete-expense.tsx b/routes/api/expenses/delete-expense.tsx new file mode 100644 index 0000000..0c880e7 --- /dev/null +++ b/routes/api/expenses/delete-expense.tsx @@ -0,0 +1,54 @@ +import { Handlers } from 'fresh/server.ts'; + +import { Budget, Expense, FreshContextState } from '/lib/types.ts'; +import { deleteExpense, getBudgets, getExpenseById, getExpenses } from '/lib/data/expenses.ts'; + +interface Data {} + +export interface RequestBody { + id: string; + month: string; +} + +export interface ResponseBody { + success: boolean; + newExpenses: Expense[]; + newBudgets: Budget[]; +} + +export const handler: Handlers = { + async POST(request, context) { + if (!context.state.user) { + return new Response('Unauthorized', { status: 401 }); + } + + const requestBody = await request.clone().json() as RequestBody; + + if ( + !requestBody.id || !requestBody.month || !requestBody.month.match(/^\d{4}-\d{2}$/) + ) { + return new Response('Bad request', { status: 400 }); + } + + const expense = await getExpenseById(context.state.user.id, requestBody.id); + + if (!expense) { + return new Response('Not found', { status: 404 }); + } + + try { + await deleteExpense(context.state.user.id, requestBody.id); + } catch (error) { + console.error(error); + return new Response(`${error}`, { status: 500 }); + } + + const newExpenses = await getExpenses(context.state.user.id, requestBody.month); + + const newBudgets = await getBudgets(context.state.user.id, requestBody.month); + + const responseBody: ResponseBody = { success: true, newExpenses, newBudgets }; + + return new Response(JSON.stringify(responseBody)); + }, +}; diff --git a/routes/api/expenses/export-expenses.tsx b/routes/api/expenses/export-expenses.tsx new file mode 100644 index 0000000..591d64f --- /dev/null +++ b/routes/api/expenses/export-expenses.tsx @@ -0,0 +1,32 @@ +import { Handlers } from 'fresh/server.ts'; + +import { Budget, Expense, FreshContextState } from '/lib/types.ts'; +import { getAllBudgetsForExport, getAllExpensesForExport } from '/lib/data/expenses.ts'; + +interface Data {} + +export interface RequestBody {} + +export interface ResponseBody { + success: boolean; + jsonContents: { + budgets: (Omit & { extra: Record })[]; + expenses: (Omit & { extra: Record })[]; + }; +} + +export const handler: Handlers = { + async POST(request, context) { + if (!context.state.user) { + return new Response('Unauthorized', { status: 401 }); + } + + const newExpenses = await getAllExpensesForExport(context.state.user.id); + + const newBudgets = await getAllBudgetsForExport(context.state.user.id); + + const responseBody: ResponseBody = { success: true, jsonContents: { expenses: newExpenses, budgets: newBudgets } }; + + return new Response(JSON.stringify(responseBody)); + }, +}; diff --git a/routes/api/expenses/import-expenses.tsx b/routes/api/expenses/import-expenses.tsx new file mode 100644 index 0000000..be6c1ba --- /dev/null +++ b/routes/api/expenses/import-expenses.tsx @@ -0,0 +1,82 @@ +import { Handlers } from 'fresh/server.ts'; + +import { Budget, Expense, FreshContextState } from '/lib/types.ts'; +import { concurrentPromises } from '/lib/utils/misc.ts'; +import { + createBudget, + createExpense, + deleteAllBudgetsAndExpenses, + getBudgets, + getExpenses, +} from '/lib/data/expenses.ts'; + +interface Data {} + +export interface RequestBody { + budgets: Pick[]; + expenses: Pick[]; + month: string; + replace: boolean; +} + +export interface ResponseBody { + success: boolean; + newBudgets: Budget[]; + newExpenses: Expense[]; +} + +export const handler: Handlers = { + async POST(request, context) { + if (!context.state.user) { + return new Response('Unauthorized', { status: 401 }); + } + + const requestBody = await request.clone().json() as RequestBody; + + if ( + (requestBody.budgets.length === 0 && requestBody.expenses.length === 0) || !requestBody.month || + !requestBody.month.match(/^\d{4}-\d{2}$/) + ) { + return new Response('Bad request', { status: 400 }); + } + + if (requestBody.replace) { + await deleteAllBudgetsAndExpenses(context.state.user.id); + } + + try { + await concurrentPromises( + requestBody.budgets.map((budget) => () => + createBudget(context.state.user!.id, budget.name, budget.month, budget.value) + ), + 5, + ); + + await concurrentPromises( + requestBody.expenses.map((expense) => () => + createExpense( + context.state.user!.id, + expense.cost, + expense.description, + expense.budget, + expense.date, + expense.is_recurring ?? false, + { skipRecalculation: true, skipBudgetMatching: true, skipBudgetCreation: true }, + ) + ), + 5, + ); + } catch (error) { + console.error(error); + return new Response(`${error}`, { status: 500 }); + } + + const newExpenses = await getExpenses(context.state.user.id, requestBody.month); + + const newBudgets = await getBudgets(context.state.user.id, requestBody.month); + + const responseBody: ResponseBody = { success: true, newExpenses, newBudgets }; + + return new Response(JSON.stringify(responseBody)); + }, +}; diff --git a/routes/api/expenses/update-budget.tsx b/routes/api/expenses/update-budget.tsx new file mode 100644 index 0000000..8fd81b4 --- /dev/null +++ b/routes/api/expenses/update-budget.tsx @@ -0,0 +1,60 @@ +import { Handlers } from 'fresh/server.ts'; + +import { Budget, FreshContextState } from '/lib/types.ts'; +import { getBudgetById, getBudgets, updateBudget } from '/lib/data/expenses.ts'; + +interface Data {} + +export interface RequestBody { + id: string; + name: string; + month: string; + value: number; + currentMonth: string; +} + +export interface ResponseBody { + success: boolean; + newBudgets: Budget[]; +} + +export const handler: Handlers = { + async POST(request, context) { + if (!context.state.user) { + return new Response('Unauthorized', { status: 401 }); + } + + const requestBody = await request.clone().json() as RequestBody; + + if ( + !requestBody.id || !requestBody.name || !requestBody.month || !requestBody.month.match(/^\d{4}-\d{2}$/) || + !requestBody.value || Number.isNaN(requestBody.value) || !requestBody.currentMonth || + !requestBody.currentMonth.match(/^\d{4}-\d{2}$/) + ) { + return new Response('Bad request', { status: 400 }); + } + + const budget = await getBudgetById(context.state.user.id, requestBody.id); + + if (!budget) { + return new Response('Not found', { status: 404 }); + } + + budget.name = requestBody.name; + budget.month = requestBody.month; + budget.value = requestBody.value; + + try { + await updateBudget(budget); + } catch (error) { + console.error(error); + return new Response(`${error}`, { status: 500 }); + } + + const newBudgets = await getBudgets(context.state.user.id, requestBody.currentMonth); + + const responseBody: ResponseBody = { success: true, newBudgets }; + + return new Response(JSON.stringify(responseBody)); + }, +}; diff --git a/routes/api/expenses/update-expense.tsx b/routes/api/expenses/update-expense.tsx new file mode 100644 index 0000000..1a7268f --- /dev/null +++ b/routes/api/expenses/update-expense.tsx @@ -0,0 +1,82 @@ +import { Handlers } from 'fresh/server.ts'; + +import { Budget, Expense, FreshContextState } from '/lib/types.ts'; +import { getBudgets, getExpenseById, getExpenses, updateExpense } from '/lib/data/expenses.ts'; + +interface Data {} + +export interface RequestBody { + id: string; + cost: number; + description: string; + budget: string; + date: string; + is_recurring: boolean; + month: string; +} + +export interface ResponseBody { + success: boolean; + newExpenses: Expense[]; + newBudgets: Budget[]; +} + +export const handler: Handlers = { + async POST(request, context) { + if (!context.state.user) { + return new Response('Unauthorized', { status: 401 }); + } + + const requestBody = await request.clone().json() as RequestBody; + + if ( + !requestBody.id || !requestBody.cost || Number.isNaN(requestBody.cost) || !requestBody.description || + !requestBody.month || !requestBody.month.match(/^\d{4}-\d{2}$/) + ) { + return new Response('Bad request', { status: 400 }); + } + + const expense = await getExpenseById(context.state.user.id, requestBody.id); + + if (!expense) { + return new Response('Not found', { status: 404 }); + } + + if (!requestBody.budget) { + requestBody.budget = 'Misc'; + } + + if (!requestBody.date) { + requestBody.date = new Date().toISOString().substring(0, 10); + } + + if (!requestBody.date.match(/^\d{4}-\d{2}-\d{2}$/)) { + return new Response('Bad request', { status: 400 }); + } + + if (!requestBody.is_recurring) { + requestBody.is_recurring = false; + } + + expense.cost = requestBody.cost; + expense.description = requestBody.description; + expense.budget = requestBody.budget; + expense.date = requestBody.date; + expense.is_recurring = requestBody.is_recurring; + + try { + await updateExpense(expense); + } catch (error) { + console.error(error); + return new Response(`${error}`, { status: 500 }); + } + + const newExpenses = await getExpenses(context.state.user.id, requestBody.month); + + const newBudgets = await getBudgets(context.state.user.id, requestBody.month); + + const responseBody: ResponseBody = { success: true, newExpenses, newBudgets }; + + return new Response(JSON.stringify(responseBody)); + }, +}; diff --git a/routes/expenses.tsx b/routes/expenses.tsx new file mode 100644 index 0000000..f43d7fa --- /dev/null +++ b/routes/expenses.tsx @@ -0,0 +1,73 @@ +import { Handlers, PageProps } from 'fresh/server.ts'; + +import { Budget, Expense, FreshContextState, SupportedCurrencySymbol } from '/lib/types.ts'; +import { isAppEnabled } from '/lib/config.ts'; +import { generateMonthlyBudgetsAndExpenses, getBudgets, getExpenses } from '/lib/data/expenses.ts'; +import ExpensesWrapper from '/islands/expenses/ExpensesWrapper.tsx'; + +interface Data { + userBudgets: Budget[]; + userExpenses: Expense[]; + initialMonth: string; + currency: SupportedCurrencySymbol; +} + +export const handler: Handlers = { + async GET(request, context) { + if (!context.state.user) { + return new Response('Redirect', { status: 303, headers: { 'Location': `/login` } }); + } + + if (!isAppEnabled('expenses')) { + return new Response('Redirect', { status: 303, headers: { 'Location': `/files` } }); + } + + const searchParams = new URL(request.url).searchParams; + + let initialMonth = searchParams.get('month') || new Date().toISOString().substring(0, 7); + + const currentMonth = new Date().toISOString().substring(0, 7); + const nextMonth = new Date(new Date(currentMonth).setUTCMonth(new Date(currentMonth).getUTCMonth() + 1)) + .toISOString() + .substring(0, 7); + + // Send invalid months (format) back to current month + if (!initialMonth.match(/^\d{4}-\d{2}$/)) { + initialMonth = currentMonth; + } + + // Reset to next month if the selected month is too far in the future + if (initialMonth > nextMonth) { + initialMonth = nextMonth; + } + + let userBudgets = await getBudgets(context.state.user.id, initialMonth); + + let userExpenses = await getExpenses(context.state.user.id, initialMonth); + + // If there are no budgets or expenses, and the selected month is in the future, generate the month's budgets and expenses + if (userBudgets.length === 0 && userExpenses.length === 0 && initialMonth >= currentMonth) { + await generateMonthlyBudgetsAndExpenses(context.state.user.id, initialMonth); + + userBudgets = await getBudgets(context.state.user.id, initialMonth); + userExpenses = await getExpenses(context.state.user.id, initialMonth); + } + + const currency = context.state.user.extra.expenses_currency || '$'; + + return await context.render({ userBudgets, userExpenses, initialMonth, currency }); + }, +}; + +export default function ExpensesPage({ data }: PageProps) { + return ( +

+ +
+ ); +} diff --git a/routes/settings.tsx b/routes/settings.tsx index 3e2961f..351a753 100644 --- a/routes/settings.tsx +++ b/routes/settings.tsx @@ -1,6 +1,6 @@ import { Handlers, PageProps } from 'fresh/server.ts'; -import { FreshContextState } from '/lib/types.ts'; +import { currencyMap, FreshContextState, SupportedCurrencySymbol } from '/lib/types.ts'; import { PASSWORD_SALT } from '/lib/auth.ts'; import { createVerificationCode, @@ -25,6 +25,7 @@ interface Data { message: string; }; formData: Record; + currency?: SupportedCurrencySymbol; } export const handler: Handlers = { @@ -33,7 +34,10 @@ export const handler: Handlers = { return new Response('Redirect', { status: 303, headers: { 'Location': `/login` } }); } - return await context.render(); + return await context.render({ + formData: {}, + currency: context.state.user.extra.expenses_currency, + }); }, async POST(request, context) { if (!context.state.user) { @@ -48,9 +52,9 @@ export const handler: Handlers = { const formData = await request.clone().formData(); - try { - const { user } = context.state; + const { user } = context.state; + try { action = getFormDataField(formData, 'action') as Action; if (action !== 'change-email' && action !== 'verify-change-email') { @@ -154,6 +158,19 @@ export const handler: Handlers = { status: 303, headers: { 'location': `/signup?success=delete` }, }); + } else if (action === 'change-currency') { + const newCurrencySymbol = getFormDataField(formData, 'currency') as SupportedCurrencySymbol; + + if (!currencyMap.has(newCurrencySymbol)) { + throw new Error(`Invalid currency.`); + } + + user.extra.expenses_currency = newCurrencySymbol; + + await updateUser(user); + + successTitle = 'Currency changed!'; + successMessage = 'Currency changed successfully.'; } const notice = successTitle @@ -166,6 +183,7 @@ export const handler: Handlers = { return await context.render({ notice, formData: convertFormDataToObject(formData), + currency: user.extra.expenses_currency, }); } catch (error) { console.error(error); @@ -175,6 +193,7 @@ export const handler: Handlers = { return await context.render({ error: { title: errorTitle, message: errorMessage }, formData: convertFormDataToObject(formData), + currency: user.extra.expenses_currency, }); } }, @@ -183,7 +202,7 @@ export const handler: Handlers = { export default function SettingsPage({ data }: PageProps) { return (
- +
); } diff --git a/static/images/expenses.svg b/static/images/expenses.svg new file mode 100644 index 0000000..3f82e79 --- /dev/null +++ b/static/images/expenses.svg @@ -0,0 +1 @@ + \ No newline at end of file