Move category handlers to budget app

This commit is contained in:
Joel Jeremy Marquez
2025-02-21 10:16:57 -08:00
parent 082d957694
commit 04153b7408
3 changed files with 332 additions and 336 deletions

View File

@@ -7,13 +7,15 @@ import * as asyncStorage from '../../platform/server/asyncStorage';
import * as connection from '../../platform/server/connection';
import * as fs from '../../platform/server/fs';
import { logger } from '../../platform/server/log';
import * as monthUtils from '../../shared/months';
import { Budget } from '../../types/budget';
import { CategoryEntity, CategoryGroupEntity } from '../../types/models';
import { createApp } from '../app';
import { startBackupService, stopBackupService } from '../backups';
import * as cloudStorage from '../cloud-storage';
import * as db from '../db';
import * as mappings from '../db/mappings';
import { FileDownloadError, FileUploadError } from '../errors';
import { APIError, FileDownloadError, FileUploadError } from '../errors';
import { handleBudgetImport } from '../importers';
import { app as mainApp } from '../main-app';
import { mutator } from '../mutators';
@@ -27,7 +29,13 @@ import {
} from '../prefs';
import { getServer } from '../server-config';
import * as sheet from '../sheet';
import { clearFullSyncTimeout, initialFullSync, setSyncingMode } from '../sync';
import { resolveName } from '../spreadsheet/util';
import {
batchMessages,
clearFullSyncTimeout,
initialFullSync,
setSyncingMode,
} from '../sync';
import * as syncMigrations from '../sync/migrate';
import * as rules from '../transactions/transaction-rules';
import { clearUndo, undoable } from '../undo';
@@ -80,6 +88,20 @@ export interface BudgetHandlers {
'import-budget': typeof importBudget;
'export-budget': typeof exportBudget;
'reset-budget-cache': typeof resetBudgetCache;
'get-budget-bounds': typeof getBudgetBounds;
'envelope-budget-month': typeof envelopeBudgetMonth;
'tracking-budget-month': typeof trackingBudgetMonth;
'get-categories': typeof getCategories;
'category-create': typeof createCategory;
'category-update': typeof updateCategory;
'category-move': typeof moveCategory;
'category-delete': typeof deleteCategory;
'get-category-groups': typeof getCategoryGroups;
'category-group-create': typeof createCategoryGroup;
'category-group-update': typeof updateCategoryGroup;
'category-group-move': typeof moveCategoryGroup;
'category-group-delete': typeof deleteCategoryGroup;
'must-category-transfer': typeof isCategoryTransferIsRequired;
}
const DEMO_BUDGET_ID = '_demo-budget';
@@ -166,6 +188,20 @@ app.method('create-budget', createBudget);
app.method('import-budget', importBudget);
app.method('export-budget', exportBudget);
app.method('reset-budget-cache', mutator(resetBudgetCache));
app.method('get-budget-bounds', getBudgetBounds);
app.method('envelope-budget-month', envelopeBudgetMonth);
app.method('tracking-budget-month', trackingBudgetMonth);
app.method('get-categories', getCategories);
app.method('category-create', mutator(undoable(createCategory)));
app.method('category-update', mutator(undoable(updateCategory)));
app.method('category-move', mutator(undoable(moveCategory)));
app.method('category-delete', mutator(undoable(deleteCategory)));
app.method('get-category-groups', getCategoryGroups);
app.method('category-group-create', mutator(undoable(createCategoryGroup)));
app.method('category-group-update', mutator(undoable(updateCategoryGroup)));
app.method('category-group-move', mutator(undoable(moveCategoryGroup)));
app.method('category-group-delete', mutator(undoable(deleteCategoryGroup)));
app.method('must-category-transfer', isCategoryTransferIsRequired);
function handleValidateBudgetName({ name }: { name: string }) {
return validateBudgetName(name);
@@ -704,6 +740,299 @@ async function resetBudgetCache() {
await sheet.waitOnSpreadsheet();
}
async function getCategories() {
return {
grouped: await db.getCategoriesGrouped(),
list: await db.getCategories(),
};
}
async function getBudgetBounds() {
return await budget.createAllBudgets();
}
async function envelopeBudgetMonth({ month }: { month: string }) {
const groups = await db.getCategoriesGrouped();
const sheetName = monthUtils.sheetForMonth(month);
function value(name) {
const v = sheet.getCellValue(sheetName, name);
return { value: v === '' ? 0 : v, name: resolveName(sheetName, name) };
}
let values = [
value('available-funds'),
value('last-month-overspent'),
value('buffered'),
value('total-budgeted'),
value('to-budget'),
value('from-last-month'),
value('total-income'),
value('total-spent'),
value('total-leftover'),
];
for (const group of groups) {
if (group.is_income) {
values.push(value('total-income'));
for (const cat of group.categories) {
values.push(value(`sum-amount-${cat.id}`));
}
} else {
values = values.concat([
value(`group-budget-${group.id}`),
value(`group-sum-amount-${group.id}`),
value(`group-leftover-${group.id}`),
]);
for (const cat of group.categories) {
values = values.concat([
value(`budget-${cat.id}`),
value(`sum-amount-${cat.id}`),
value(`leftover-${cat.id}`),
value(`carryover-${cat.id}`),
value(`goal-${cat.id}`),
value(`long-goal-${cat.id}`),
]);
}
}
}
return values;
}
async function trackingBudgetMonth({ month }: { month: string }) {
const groups = await db.getCategoriesGrouped();
const sheetName = monthUtils.sheetForMonth(month);
function value(name) {
const v = sheet.getCellValue(sheetName, name);
return { value: v === '' ? 0 : v, name: resolveName(sheetName, name) };
}
let values = [
value('total-budgeted'),
value('total-budget-income'),
value('total-saved'),
value('total-income'),
value('total-spent'),
value('real-saved'),
value('total-leftover'),
];
for (const group of groups) {
values = values.concat([
value(`group-budget-${group.id}`),
value(`group-sum-amount-${group.id}`),
value(`group-leftover-${group.id}`),
]);
for (const cat of group.categories) {
values = values.concat([
value(`budget-${cat.id}`),
value(`sum-amount-${cat.id}`),
value(`leftover-${cat.id}`),
value(`goal-${cat.id}`),
value(`long-goal-${cat.id}`),
]);
if (!group.is_income) {
values.push(value(`carryover-${cat.id}`));
}
}
}
return values;
}
async function createCategory({
name,
groupId,
isIncome,
hidden,
}: {
name: CategoryEntity['name'];
groupId: CategoryGroupEntity['id'];
isIncome: CategoryEntity['is_income'];
hidden: CategoryEntity['hidden'];
}): Promise<CategoryEntity['id']> {
if (!groupId) {
throw APIError('Creating a category: groupId is required');
}
return await db.insertCategory({
name: name.trim(),
cat_group: groupId,
is_income: isIncome ? 1 : 0,
hidden: hidden ? 1 : 0,
});
}
async function updateCategory(
category: CategoryEntity,
): Promise<{ error?: { type: 'category-exists' } }> {
try {
await db.updateCategory({
...category,
name: category.name.trim(),
});
} catch (e) {
if (e.message.toLowerCase().includes('unique constraint')) {
return { error: { type: 'category-exists' } };
}
throw e;
}
return {};
}
async function moveCategory({
id,
groupId,
targetId,
}: {
id: CategoryEntity['id'];
groupId: CategoryGroupEntity['id'];
targetId: CategoryEntity['id'];
}) {
await batchMessages(async () => {
await db.moveCategory(id, groupId, targetId);
});
return 'ok';
}
async function deleteCategory({
id,
transferId,
}: {
id: CategoryEntity['id'];
transferId?: CategoryEntity['id'];
}): Promise<{ error?: 'no-categories' | 'category-type' }> {
let result = {};
await batchMessages(async () => {
const row = await db.first(
'SELECT is_income FROM categories WHERE id = ?',
[id],
);
if (!row) {
result = { error: 'no-categories' };
return;
}
const transfer =
transferId &&
(await db.first('SELECT is_income FROM categories WHERE id = ?', [
transferId,
]));
if (!row || (transferId && !transfer)) {
result = { error: 'no-categories' };
return;
} else if (transferId && row.is_income !== transfer.is_income) {
result = { error: 'category-type' };
return;
}
// Update spreadsheet values if it's an expense category
// TODO: We should do this for income too if it's a reflect budget
if (row.is_income === 0) {
if (transferId) {
await budget.doTransfer([id], transferId);
}
}
await db.deleteCategory({ id }, transferId);
});
return result;
}
async function getCategoryGroups() {
return await db.getCategoriesGrouped();
}
async function createCategoryGroup({
name,
isIncome,
hidden,
}: {
name: CategoryGroupEntity['name'];
isIncome: CategoryGroupEntity['is_income'];
hidden: CategoryGroupEntity['hidden'];
}): Promise<CategoryGroupEntity['id']> {
return await db.insertCategoryGroup({
name,
is_income: isIncome ? 1 : 0,
hidden,
});
}
async function updateCategoryGroup(group: CategoryGroupEntity) {
return await db.updateCategoryGroup(group);
}
async function moveCategoryGroup({
id,
targetId,
}: {
id: CategoryGroupEntity['id'];
targetId: CategoryGroupEntity['id'];
}) {
await batchMessages(async () => {
await db.moveCategoryGroup(id, targetId);
});
return 'ok';
}
async function deleteCategoryGroup({
id,
transferId,
}: {
id: CategoryGroupEntity['id'];
transferId?: CategoryGroupEntity['id'];
}): Promise<void> {
const groupCategories = await db.all(
'SELECT id FROM categories WHERE cat_group = ? AND tombstone = 0',
[id],
);
await batchMessages(async () => {
if (transferId) {
await budget.doTransfer(
groupCategories.map(c => c.id),
transferId,
);
}
await db.deleteCategoryGroup({ id }, transferId);
});
}
async function isCategoryTransferIsRequired({ id }) {
const res = await db.runQuery<{ count: number }>(
`SELECT count(t.id) as count FROM transactions t
LEFT JOIN category_mapping cm ON cm.id = t.category
WHERE cm.transferId = ? AND t.tombstone = 0`,
[id],
true,
);
// If there are transactions with this category, return early since
// we already know it needs to be tranferred
if (res[0].count !== 0) {
return true;
}
// If there are any non-zero budget values, also force the user to
// transfer the category.
return [...sheet.get().meta().createdMonths].some(month => {
const sheetName = monthUtils.sheetForMonth(month);
const value = sheet.get().getCellValue(sheetName, 'budget-' + id);
return value != null && value !== 0;
});
}
// util
function onSheetChange({ names }: { names: string[] }) {

View File

@@ -8,10 +8,8 @@ import * as asyncStorage from '../platform/server/asyncStorage';
import * as connection from '../platform/server/connection';
import * as fs from '../platform/server/fs';
import * as sqlite from '../platform/server/sqlite';
import * as monthUtils from '../shared/months';
import { q } from '../shared/query';
import { Handlers } from '../types/handlers';
import { CategoryEntity, CategoryGroupEntity } from '../types/models';
import { OpenIdConfig } from '../types/models/openid';
import { app as accountsApp } from './accounts/app';
@@ -20,12 +18,10 @@ import { installAPI } from './api';
import { runQuery as aqlQuery } from './aql';
import { getAvailableBackups, loadBackup, makeBackup } from './backups';
import { app as budgetApp } from './budget/app';
import * as budget from './budget/base';
import * as cloudStorage from './cloud-storage';
import { app as dashboardApp } from './dashboard/app';
import * as db from './db';
import * as encryption from './encryption';
import { APIError } from './errors';
import { app as filtersApp } from './filters/app';
import { app } from './main-app';
import { mutator, runHandler } from './mutators';
@@ -47,12 +43,11 @@ import {
makeTestMessage,
resetSync,
repairSync,
batchMessages,
} from './sync';
import { app as toolsApp } from './tools/app';
import { app as transactionsApp } from './transactions/app';
import * as rules from './transactions/transaction-rules';
import { withUndo, undo, redo } from './undo';
import { undo, redo } from './undo';
// handlers
@@ -68,285 +63,6 @@ handlers['redo'] = mutator(function () {
return redo();
});
handlers['get-categories'] = async function () {
// TODO: Force cast to CategoryGroupEntity and CategoryEntity.
// This should be updated to AQL queries. The server should not directly return DB models.
return {
grouped:
(await db.getCategoriesGrouped()) as unknown as CategoryGroupEntity[],
list: (await db.getCategories()) as unknown as CategoryEntity[],
};
};
handlers['get-budget-bounds'] = async function () {
return budget.createAllBudgets();
};
handlers['envelope-budget-month'] = async function ({ month }) {
const groups = await db.getCategoriesGrouped();
const sheetName = monthUtils.sheetForMonth(month);
function value(name) {
const v = sheet.getCellValue(sheetName, name);
return { value: v === '' ? 0 : v, name: resolveName(sheetName, name) };
}
let values = [
value('available-funds'),
value('last-month-overspent'),
value('buffered'),
value('total-budgeted'),
value('to-budget'),
value('from-last-month'),
value('total-income'),
value('total-spent'),
value('total-leftover'),
];
for (const group of groups) {
if (group.is_income) {
values.push(value('total-income'));
for (const cat of group.categories) {
values.push(value(`sum-amount-${cat.id}`));
}
} else {
values = values.concat([
value(`group-budget-${group.id}`),
value(`group-sum-amount-${group.id}`),
value(`group-leftover-${group.id}`),
]);
for (const cat of group.categories) {
values = values.concat([
value(`budget-${cat.id}`),
value(`sum-amount-${cat.id}`),
value(`leftover-${cat.id}`),
value(`carryover-${cat.id}`),
value(`goal-${cat.id}`),
value(`long-goal-${cat.id}`),
]);
}
}
}
return values;
};
handlers['tracking-budget-month'] = async function ({ month }) {
const groups = await db.getCategoriesGrouped();
const sheetName = monthUtils.sheetForMonth(month);
function value(name) {
const v = sheet.getCellValue(sheetName, name);
return { value: v === '' ? 0 : v, name: resolveName(sheetName, name) };
}
let values = [
value('total-budgeted'),
value('total-budget-income'),
value('total-saved'),
value('total-income'),
value('total-spent'),
value('real-saved'),
value('total-leftover'),
];
for (const group of groups) {
values = values.concat([
value(`group-budget-${group.id}`),
value(`group-sum-amount-${group.id}`),
value(`group-leftover-${group.id}`),
]);
for (const cat of group.categories) {
values = values.concat([
value(`budget-${cat.id}`),
value(`sum-amount-${cat.id}`),
value(`leftover-${cat.id}`),
value(`goal-${cat.id}`),
value(`long-goal-${cat.id}`),
]);
if (!group.is_income) {
values.push(value(`carryover-${cat.id}`));
}
}
}
return values;
};
handlers['category-create'] = mutator(async function ({
name,
groupId,
isIncome,
hidden,
}) {
return withUndo(async () => {
if (!groupId) {
throw APIError('Creating a category: groupId is required');
}
return db.insertCategory({
name: name.trim(),
cat_group: groupId,
is_income: isIncome ? 1 : 0,
hidden: hidden ? 1 : 0,
});
});
});
handlers['category-update'] = mutator(async function (category) {
return withUndo(async () => {
try {
await db.updateCategory({
...category,
name: category.name.trim(),
});
} catch (e) {
if (e.message.toLowerCase().includes('unique constraint')) {
return { error: { type: 'category-exists' } };
}
throw e;
}
return {};
});
});
handlers['category-move'] = mutator(async function ({ id, groupId, targetId }) {
return withUndo(async () => {
await batchMessages(async () => {
await db.moveCategory(id, groupId, targetId);
});
return 'ok';
});
});
handlers['category-delete'] = mutator(async function ({ id, transferId }) {
return withUndo(async () => {
let result = {};
await batchMessages(async () => {
const row = await db.first<Pick<db.DbCategory, 'is_income'>>(
'SELECT is_income FROM categories WHERE id = ?',
[id],
);
if (!row) {
result = { error: 'no-categories' };
return;
}
const transfer =
transferId &&
(await db.first<Pick<db.DbCategory, 'is_income'>>(
'SELECT is_income FROM categories WHERE id = ?',
[transferId],
));
if (!row || (transferId && !transfer)) {
result = { error: 'no-categories' };
return;
} else if (transferId && row.is_income !== transfer.is_income) {
result = { error: 'category-type' };
return;
}
// Update spreadsheet values if it's an expense category
// TODO: We should do this for income too if it's a reflect budget
if (row.is_income === 0) {
if (transferId) {
await budget.doTransfer([id], transferId);
}
}
await db.deleteCategory({ id }, transferId);
});
return result;
});
});
handlers['get-category-groups'] = async function () {
return await db.getCategoriesGrouped();
};
handlers['category-group-create'] = mutator(async function ({
name,
isIncome,
hidden,
}) {
return withUndo(async () => {
return db.insertCategoryGroup({
name,
is_income: isIncome ? 1 : 0,
hidden,
});
});
});
handlers['category-group-update'] = mutator(async function (group) {
return withUndo(async () => {
return db.updateCategoryGroup(group);
});
});
handlers['category-group-move'] = mutator(async function ({ id, targetId }) {
return withUndo(async () => {
await batchMessages(async () => {
await db.moveCategoryGroup(id, targetId);
});
return 'ok';
});
});
handlers['category-group-delete'] = mutator(async function ({
id,
transferId,
}) {
return withUndo(async () => {
const groupCategories = await db.all<Pick<db.DbCategory, 'id'>>(
'SELECT id FROM categories WHERE cat_group = ? AND tombstone = 0',
[id],
);
return batchMessages(async () => {
if (transferId) {
await budget.doTransfer(
groupCategories.map(c => c.id),
transferId,
);
}
await db.deleteCategoryGroup({ id }, transferId);
});
});
});
handlers['must-category-transfer'] = async function ({ id }) {
const res = await db.runQuery<{ count: number }>(
`SELECT count(t.id) as count FROM transactions t
LEFT JOIN category_mapping cm ON cm.id = t.category
WHERE cm.transferId = ? AND t.tombstone = 0`,
[id],
true,
);
// If there are transactions with this category, return early since
// we already know it needs to be tranferred
if (res[0].count !== 0) {
return true;
}
// If there are any non-zero budget values, also force the user to
// transfer the category.
return [...sheet.get().meta().createdMonths].some(month => {
const sheetName = monthUtils.sheetForMonth(month);
const value = sheet.get().getCellValue(sheetName, 'budget-' + id);
return value != null && value !== 0;
});
};
handlers['make-filters-from-conditions'] = async function ({
conditions,
applySpecialCases,

View File

@@ -3,7 +3,6 @@ import { RemoteFile } from '../server/cloud-storage';
import { Node as SpreadsheetNode } from '../server/spreadsheet/spreadsheet';
import { Message } from '../server/sync';
import { CategoryEntity, CategoryGroupEntity } from './models';
import { OpenIdConfig } from './models/openid';
// eslint-disable-next-line import/no-unresolved
import { Query } from './query';
@@ -13,56 +12,8 @@ export interface ServerHandlers {
undo: () => Promise<void>;
redo: () => Promise<void>;
'get-categories': () => Promise<{
grouped: Array<CategoryGroupEntity>;
list: Array<CategoryEntity>;
}>;
'get-earliest-transaction': () => Promise<{ date: string }>;
'get-budget-bounds': () => Promise<{ start: string; end: string }>;
'envelope-budget-month': (arg: { month }) => Promise<
{
value: string | number | boolean;
name: string;
}[]
>;
'tracking-budget-month': (arg: { month }) => Promise<
{
value: string | number | boolean;
name: string;
}[]
>;
'category-create': (arg: {
name;
groupId;
isIncome?;
hidden?: boolean;
}) => Promise<string>;
'category-update': (category) => Promise<unknown>;
'category-move': (arg: { id; groupId; targetId }) => Promise<unknown>;
'category-delete': (arg: { id; transferId? }) => Promise<{ error?: string }>;
'category-group-create': (arg: {
name;
isIncome?: boolean;
hidden?: boolean;
}) => Promise<string>;
'category-group-update': (group) => Promise<unknown>;
'category-group-move': (arg: { id; targetId }) => Promise<unknown>;
'category-group-delete': (arg: { id; transferId }) => Promise<unknown>;
'must-category-transfer': (arg: { id }) => Promise<unknown>;
'make-filters-from-conditions': (arg: {
conditions: unknown;
applySpecialCases?: boolean;