([
['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.
+
+
+
+ >
+ )
+ : 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