From 52d154fd20d9384dace374a191e6ddbc1c4e3f3e Mon Sep 17 00:00:00 2001 From: Joel Jeremy Marquez Date: Mon, 24 Feb 2025 01:52:30 -0800 Subject: [PATCH 1/6] Extract category related server handlers from main.ts to server/budget/app.ts --- packages/loot-core/src/mocks/budget.ts | 2 +- packages/loot-core/src/server/budget/app.ts | 363 +++++++++++++++++- .../src/server/budget/types/handlers.d.ts | 91 ----- packages/loot-core/src/server/db/index.ts | 14 +- packages/loot-core/src/server/main.ts | 278 -------------- packages/loot-core/src/types/handlers.d.ts | 2 +- 6 files changed, 374 insertions(+), 376 deletions(-) delete mode 100644 packages/loot-core/src/server/budget/types/handlers.d.ts diff --git a/packages/loot-core/src/mocks/budget.ts b/packages/loot-core/src/mocks/budget.ts index 112cf41cf59..70add4ff813 100644 --- a/packages/loot-core/src/mocks/budget.ts +++ b/packages/loot-core/src/mocks/budget.ts @@ -703,7 +703,7 @@ export async function createTestBudget(handlers: Handlers) { for (const category of group.categories) { const categoryId = await handlers['category-create']({ ...category, - isIncome: category.is_income ? 1 : 0, + isIncome: category.is_income, groupId, }); diff --git a/packages/loot-core/src/server/budget/app.ts b/packages/loot-core/src/server/budget/app.ts index 345f0097f45..d327d4c55c7 100644 --- a/packages/loot-core/src/server/budget/app.ts +++ b/packages/loot-core/src/server/budget/app.ts @@ -1,11 +1,56 @@ +import * as monthUtils from '../../shared/months'; +import { CategoryEntity, CategoryGroupEntity } from '../../types/models'; import { createApp } from '../app'; +import * as db from '../db'; +import { APIError } from '../errors'; import { mutator } from '../mutators'; +import * as sheet from '../sheet'; +import { resolveName } from '../spreadsheet/util'; +import { batchMessages } from '../sync'; import { undoable } from '../undo'; import * as actions from './actions'; +import * as budget from './base'; import * as cleanupActions from './cleanup-template'; import * as goalActions from './goaltemplates'; -import { BudgetHandlers } from './types/handlers'; + +export interface BudgetHandlers { + 'budget/budget-amount': typeof actions.setBudget; + 'budget/copy-previous-month': typeof actions.copyPreviousMonth; + 'budget/copy-single-month': typeof actions.copySinglePreviousMonth; + 'budget/set-zero': typeof actions.setZero; + 'budget/set-3month-avg': typeof actions.set3MonthAvg; + 'budget/set-6month-avg': typeof actions.set6MonthAvg; + 'budget/set-12month-avg': typeof actions.set12MonthAvg; + 'budget/set-n-month-avg': typeof actions.setNMonthAvg; + 'budget/check-templates': typeof goalActions.runCheckTemplates; + 'budget/apply-goal-template': typeof goalActions.applyTemplate; + 'budget/apply-multiple-templates': typeof goalActions.applyMultipleCategoryTemplates; + 'budget/overwrite-goal-template': typeof goalActions.overwriteTemplate; + 'budget/apply-single-template': typeof goalActions.applySingleCategoryTemplate; + 'budget/cleanup-goal-template': typeof cleanupActions.cleanupTemplate; + 'budget/hold-for-next-month': typeof actions.holdForNextMonth; + 'budget/reset-hold': typeof actions.resetHold; + 'budget/cover-overspending': typeof actions.coverOverspending; + 'budget/transfer-available': typeof actions.transferAvailable; + 'budget/cover-overbudgeted': typeof actions.coverOverbudgeted; + 'budget/transfer-category': typeof actions.transferCategory; + 'budget/set-carryover': typeof actions.setCategoryCarryover; + 'get-categories': typeof getCategories; + 'get-budget-bounds': typeof getBudgetBounds; + 'envelope-budget-month': typeof envelopeBudgetMonth; + 'tracking-budget-month': typeof trackingBudgetMonth; + '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 isCategoryTransferRequired; +} export const app = createApp(); @@ -72,3 +117,319 @@ app.method( 'budget/set-carryover', mutator(undoable(actions.setCategoryCarryover)), ); +app.method('get-categories', getCategories); +app.method('get-budget-bounds', getBudgetBounds); +app.method('envelope-budget-month', envelopeBudgetMonth); +app.method('tracking-budget-month', trackingBudgetMonth); +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', isCategoryTransferRequired); + +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: string) { + 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) { + const categories = group.categories ?? []; + + if (group.is_income) { + values.push(value('total-income')); + + for (const cat of 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 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: string) { + 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}`), + ]); + + const categories = group.categories ?? []; + + for (const cat of 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: string; + groupId: number; + isIncome?: boolean; + hidden?: boolean; +}): Promise { + 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 instanceof Error && + 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']; +}): Promise { + await batchMessages(async () => { + await db.moveCategory(id, groupId, targetId); + }); +} + +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 { + return await db.insertCategoryGroup({ + name, + is_income: isIncome ? 1 : 0, + hidden: hidden ? 1 : 0, + }); +} + +async function updateCategoryGroup(group: CategoryGroupEntity) { + await db.updateCategoryGroup(group); +} + +async function moveCategoryGroup({ + id, + targetId, +}: { + id: CategoryGroupEntity['id']; + targetId: CategoryGroupEntity['id']; +}): Promise { + await batchMessages(async () => { + await db.moveCategoryGroup(id, targetId); + }); +} + +async function deleteCategoryGroup({ + id, + transferId, +}: { + id: CategoryGroupEntity['id']; + transferId: CategoryGroupEntity['id']; +}): Promise { + 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 isCategoryTransferRequired({ + id, +}: { + id: CategoryEntity['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 as Set)].some(month => { + const sheetName = monthUtils.sheetForMonth(month); + const value = sheet.get().getCellValue(sheetName, 'budget-' + id); + + return value != null && value !== 0; + }); +} diff --git a/packages/loot-core/src/server/budget/types/handlers.d.ts b/packages/loot-core/src/server/budget/types/handlers.d.ts deleted file mode 100644 index 1c70706c9a6..00000000000 --- a/packages/loot-core/src/server/budget/types/handlers.d.ts +++ /dev/null @@ -1,91 +0,0 @@ -import type { Notification } from '../../../client/state-types/notifications'; - -export interface BudgetHandlers { - 'budget/budget-amount': (arg: { - category: string /* category id */; - month: string; - amount: number; - }) => Promise; - - 'budget/copy-previous-month': (arg: { month: string }) => Promise; - - 'budget/set-zero': (arg: { month: string }) => Promise; - - 'budget/set-3month-avg': (arg: { month: string }) => Promise; - - 'budget/set-6month-avg': (arg: { month: string }) => Promise; - - 'budget/set-12month-avg': (arg: { month: string }) => Promise; - - 'budget/check-templates': () => Promise; - - 'budget/apply-goal-template': (arg: { - month: string; - }) => Promise; - - 'budget/overwrite-goal-template': (arg: { - month: string; - }) => Promise; - - 'budget/cleanup-goal-template': (arg: { - month: string; - }) => Promise; - - 'budget/hold-for-next-month': (arg: { - month: string; - amount: number; - }) => Promise; - - 'budget/reset-hold': (arg: { month: string }) => Promise; - - 'budget/cover-overspending': (arg: { - month: string; - to: string; - from: string; - }) => Promise; - - 'budget/transfer-available': (arg: { - month: string; - amount: number; - category: string; - }) => Promise; - - 'budget/cover-overbudgeted': (arg: { - month: string; - category: string; - }) => Promise; - - 'budget/transfer-category': (arg: { - month: string; - amount: number; - to: string; - from: string; - }) => Promise; - - 'budget/set-carryover': (arg: { - startMonth: string; - category: string; - flag: boolean; - }) => Promise; - - 'budget/apply-single-template': (arg: { - month: string; - category: string; //category id - }) => Promise; - - 'budget/set-n-month-avg': (arg: { - month: string; - N: number; - category: string; //category id - }) => Promise; - - 'budget/copy-single-month': (arg: { - month: string; - category: string; //category id - }) => Promise; - - 'budget/apply-multiple-templates': (arg: { - month: string; - categoryIds: string[]; - }) => Promise; -} diff --git a/packages/loot-core/src/server/db/index.ts b/packages/loot-core/src/server/db/index.ts index ad5262cebcd..42539ac45d8 100644 --- a/packages/loot-core/src/server/db/index.ts +++ b/packages/loot-core/src/server/db/index.ts @@ -349,7 +349,9 @@ export async function getCategoriesGrouped( }); } -export async function insertCategoryGroup(group) { +export async function insertCategoryGroup( + group, +): Promise { // Don't allow duplicate group const existingGroup = await first< Pick @@ -372,7 +374,11 @@ export async function insertCategoryGroup(group) { ...categoryGroupModel.validate(group), sort_order, }; - return insertWithUUID('category_groups', group); + const id: CategoryGroupEntity['id'] = await insertWithUUID( + 'category_groups', + group, + ); + return id; } export function updateCategoryGroup(group) { @@ -405,10 +411,10 @@ export async function deleteCategoryGroup(group, transferId?: string) { export async function insertCategory( category, { atEnd } = { atEnd: undefined }, -) { +): Promise { let sort_order; - let id_; + let id_: CategoryEntity['id']; await batchMessages(async () => { // Dont allow duplicated names in groups const existingCatInGroup = await first>( diff --git a/packages/loot-core/src/server/main.ts b/packages/loot-core/src/server/main.ts index 10107b3467b..48164befb23 100644 --- a/packages/loot-core/src/server/main.ts +++ b/packages/loot-core/src/server/main.ts @@ -12,7 +12,6 @@ import * as connection from '../platform/server/connection'; import * as fs from '../platform/server/fs'; import { logger } from '../platform/server/log'; import * as sqlite from '../platform/server/sqlite'; -import * as monthUtils from '../shared/months'; import { q } from '../shared/query'; import { type Budget } from '../types/budget'; import { Handlers } from '../types/handlers'; @@ -36,7 +35,6 @@ import { app as dashboardApp } from './dashboard/app'; import * as db from './db'; import * as mappings from './db/mappings'; import * as encryption from './encryption'; -import { APIError } from './errors'; import { app as filtersApp } from './filters/app'; import { handleBudgetImport } from './importers'; import { app } from './main-app'; @@ -101,282 +99,6 @@ handlers['redo'] = mutator(function () { return redo(); }); -handlers['get-categories'] = async function () { - return { - grouped: await db.getCategoriesGrouped(), - list: await db.getCategories(), - }; -}; - -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>( - '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; - }); -}); - -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( - '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['payee-create'] = mutator(async function ({ name }) { return withUndo(async () => { return db.insertPayee({ name }); diff --git a/packages/loot-core/src/types/handlers.d.ts b/packages/loot-core/src/types/handlers.d.ts index 24f666d08d5..720f4cc68f3 100644 --- a/packages/loot-core/src/types/handlers.d.ts +++ b/packages/loot-core/src/types/handlers.d.ts @@ -1,6 +1,6 @@ import type { AccountHandlers } from '../server/accounts/app'; import type { AdminHandlers } from '../server/admin/types/handlers'; -import type { BudgetHandlers } from '../server/budget/types/handlers'; +import type { BudgetHandlers } from '../server/budget/app'; import type { DashboardHandlers } from '../server/dashboard/types/handlers'; import type { FiltersHandlers } from '../server/filters/types/handlers'; import type { NotesHandlers } from '../server/notes/types/handlers'; From 9ea769d574dc0125cae381bd91d6c807a3788ce8 Mon Sep 17 00:00:00 2001 From: Joel Jeremy Marquez Date: Mon, 24 Feb 2025 01:54:13 -0800 Subject: [PATCH 2/6] Release notes --- upcoming-release-notes/4442.md | 6 ++++++ 1 file changed, 6 insertions(+) create mode 100644 upcoming-release-notes/4442.md diff --git a/upcoming-release-notes/4442.md b/upcoming-release-notes/4442.md new file mode 100644 index 00000000000..d015915b53b --- /dev/null +++ b/upcoming-release-notes/4442.md @@ -0,0 +1,6 @@ +--- +category: Maintenance +authors: [joel-jeremy] +--- + +Extract budget category related server handlers from main.ts to server/budget/app.ts \ No newline at end of file From 1c5c2f1c717c85e99ad2c9514fdff411622fb9cd Mon Sep 17 00:00:00 2001 From: Joel Jeremy Marquez Date: Mon, 24 Feb 2025 09:30:25 -0800 Subject: [PATCH 3/6] On DB layer, replace Entity model usage with DB models --- packages/loot-core/src/server/api.ts | 4 +- packages/loot-core/src/server/budget/app.ts | 10 ++-- packages/loot-core/src/server/db/index.ts | 15 +++--- packages/loot-core/src/server/models.ts | 57 +++++++++++++++++++++ 4 files changed, 73 insertions(+), 13 deletions(-) diff --git a/packages/loot-core/src/server/api.ts b/packages/loot-core/src/server/api.ts index 32b78ee7197..7439ef25eaf 100644 --- a/packages/loot-core/src/server/api.ts +++ b/packages/loot-core/src/server/api.ts @@ -33,6 +33,7 @@ import * as cloudStorage from './cloud-storage'; import { type RemoteFile } from './cloud-storage'; import * as db from './db'; import { APIError } from './errors'; +import { categoryGroupModel as serverCategoryGroupModel } from './models'; import { runMutator } from './mutators'; import * as prefs from './prefs'; import * as sheet from './sheet'; @@ -355,7 +356,8 @@ handlers['api/budget-month'] = async function ({ month }) { checkFileOpen(); await validateMonth(month); - const groups = await db.getCategoriesGrouped(); + const dbGroups = await db.getCategoriesGrouped(); + const groups = dbGroups.map(serverCategoryGroupModel.fromDb); const sheetName = monthUtils.sheetForMonth(month); function value(name) { diff --git a/packages/loot-core/src/server/budget/app.ts b/packages/loot-core/src/server/budget/app.ts index d327d4c55c7..69dcbab5427 100644 --- a/packages/loot-core/src/server/budget/app.ts +++ b/packages/loot-core/src/server/budget/app.ts @@ -3,6 +3,7 @@ import { CategoryEntity, CategoryGroupEntity } from '../../types/models'; import { createApp } from '../app'; import * as db from '../db'; import { APIError } from '../errors'; +import { categoryGroupModel, categoryModel } from '../models'; import { mutator } from '../mutators'; import * as sheet from '../sheet'; import { resolveName } from '../spreadsheet/util'; @@ -132,10 +133,11 @@ app.method('category-group-move', mutator(undoable(moveCategoryGroup))); app.method('category-group-delete', mutator(undoable(deleteCategoryGroup))); app.method('must-category-transfer', isCategoryTransferRequired); +// Server must return AQL entities not the raw DB data async function getCategories() { return { - grouped: await db.getCategoriesGrouped(), - list: await db.getCategories(), + grouped: await getCategoryGroups(), + list: (await db.getCategories()).map(categoryModel.fromDb), }; } @@ -346,8 +348,10 @@ async function deleteCategory({ return result; } +// Server must return AQL entities not the raw DB data async function getCategoryGroups() { - return await db.getCategoriesGrouped(); + const categoryGroups = await db.getCategoriesGrouped(); + return categoryGroups.map(categoryGroupModel.fromDb); } async function createCategoryGroup({ diff --git a/packages/loot-core/src/server/db/index.ts b/packages/loot-core/src/server/db/index.ts index 42539ac45d8..716f4f5358e 100644 --- a/packages/loot-core/src/server/db/index.ts +++ b/packages/loot-core/src/server/db/index.ts @@ -15,7 +15,6 @@ import * as fs from '../../platform/server/fs'; import * as sqlite from '../../platform/server/sqlite'; import * as monthUtils from '../../shared/months'; import { groupById } from '../../shared/util'; -import { CategoryEntity, CategoryGroupEntity } from '../../types/models'; import { schema, schemaConfig, @@ -308,19 +307,17 @@ export function updateWithSchema(table, fields) { // Data-specific functions. Ideally this would be split up into // different files -// TODO: Fix return type. This should returns a DbCategory[]. export async function getCategories( ids?: Array, -): Promise { +): Promise { const whereIn = ids ? `c.id IN (${toSqlQueryParameters(ids)}) AND` : ''; const query = `SELECT c.* FROM categories c WHERE ${whereIn} c.tombstone = 0 ORDER BY c.sort_order, c.id`; return ids ? await all(query, [...ids]) : await all(query); } -// TODO: Fix return type. This should returns a [DbCategoryGroup, ...DbCategory]. export async function getCategoriesGrouped( ids?: Array, -): Promise> { +): Promise> { const categoryGroupWhereIn = ids ? `cg.id IN (${toSqlQueryParameters(ids)}) AND` : ''; @@ -351,7 +348,7 @@ export async function getCategoriesGrouped( export async function insertCategoryGroup( group, -): Promise { +): Promise { // Don't allow duplicate group const existingGroup = await first< Pick @@ -374,7 +371,7 @@ export async function insertCategoryGroup( ...categoryGroupModel.validate(group), sort_order, }; - const id: CategoryGroupEntity['id'] = await insertWithUUID( + const id: DbCategoryGroup['id'] = await insertWithUUID( 'category_groups', group, ); @@ -411,10 +408,10 @@ export async function deleteCategoryGroup(group, transferId?: string) { export async function insertCategory( category, { atEnd } = { atEnd: undefined }, -): Promise { +): Promise { let sort_order; - let id_: CategoryEntity['id']; + let id_: DbCategory['id']; await batchMessages(async () => { // Dont allow duplicated names in groups const existingCatInGroup = await first>( diff --git a/packages/loot-core/src/server/models.ts b/packages/loot-core/src/server/models.ts index 772ead3f154..35a9c7c552b 100644 --- a/packages/loot-core/src/server/models.ts +++ b/packages/loot-core/src/server/models.ts @@ -5,6 +5,14 @@ import { PayeeEntity, } from '../types/models'; +import { + convertForInsert, + convertForUpdate, + convertFromSelect, + schema, + schemaConfig, +} from './aql'; +import { DbCategory, DbCategoryGroup } from './db'; import { ValidationError } from './errors'; export function requiredFields( @@ -74,6 +82,22 @@ export const categoryModel = { const { sort_order, ...rest } = category; return { ...rest, hidden: rest.hidden ? 1 : 0 }; }, + toDb( + category: CategoryEntity, + { update }: { update?: boolean } = {}, + ): DbCategory { + return update + ? convertForUpdate(schema, schemaConfig, 'categories', category) + : convertForInsert(schema, schemaConfig, 'categories', category); + }, + fromDb(category: DbCategory): CategoryEntity { + return convertFromSelect( + schema, + schemaConfig, + 'categories', + category, + ) as CategoryEntity; + }, }; export const categoryGroupModel = { @@ -91,6 +115,39 @@ export const categoryGroupModel = { const { sort_order, ...rest } = categoryGroup; return { ...rest, hidden: rest.hidden ? 1 : 0 }; }, + toDb( + categoryGroup: CategoryGroupEntity, + { update }: { update?: boolean } = {}, + ): DbCategoryGroup { + return update + ? convertForUpdate(schema, schemaConfig, 'category_groups', categoryGroup) + : convertForInsert( + schema, + schemaConfig, + 'category_groups', + categoryGroup, + ); + }, + fromDb( + categoryGroup: DbCategoryGroup & { + categories: DbCategory[]; + }, + ): CategoryGroupEntity { + const { categories, ...rest } = categoryGroup; + const categoryGroupEntity = convertFromSelect( + schema, + schemaConfig, + 'category_groups', + rest, + ) as CategoryGroupEntity; + + return { + ...categoryGroupEntity, + categories: categories + .filter(category => category.cat_group === categoryGroup.id) + .map(categoryModel.fromDb), + }; + }, }; export const payeeModel = { From ca4c989e74faeba531487efa9bcd882570df9c1e Mon Sep 17 00:00:00 2001 From: Joel Jeremy Marquez Date: Mon, 24 Feb 2025 09:42:21 -0800 Subject: [PATCH 4/6] Fix typecheck errors --- packages/loot-core/src/server/models.ts | 31 ++++++++++++++++--------- 1 file changed, 20 insertions(+), 11 deletions(-) diff --git a/packages/loot-core/src/server/models.ts b/packages/loot-core/src/server/models.ts index 35a9c7c552b..296292a0162 100644 --- a/packages/loot-core/src/server/models.ts +++ b/packages/loot-core/src/server/models.ts @@ -86,9 +86,11 @@ export const categoryModel = { category: CategoryEntity, { update }: { update?: boolean } = {}, ): DbCategory { - return update - ? convertForUpdate(schema, schemaConfig, 'categories', category) - : convertForInsert(schema, schemaConfig, 'categories', category); + return ( + update + ? convertForUpdate(schema, schemaConfig, 'categories', category) + : convertForInsert(schema, schemaConfig, 'categories', category) + ) as DbCategory; }, fromDb(category: DbCategory): CategoryEntity { return convertFromSelect( @@ -119,14 +121,21 @@ export const categoryGroupModel = { categoryGroup: CategoryGroupEntity, { update }: { update?: boolean } = {}, ): DbCategoryGroup { - return update - ? convertForUpdate(schema, schemaConfig, 'category_groups', categoryGroup) - : convertForInsert( - schema, - schemaConfig, - 'category_groups', - categoryGroup, - ); + return ( + update + ? convertForUpdate( + schema, + schemaConfig, + 'category_groups', + categoryGroup, + ) + : convertForInsert( + schema, + schemaConfig, + 'category_groups', + categoryGroup, + ) + ) as DbCategoryGroup; }, fromDb( categoryGroup: DbCategoryGroup & { From 51b27e0b3769aa8bb94dbaff39fc70ca7dde362d Mon Sep 17 00:00:00 2001 From: Joel Jeremy Marquez Date: Tue, 4 Mar 2025 11:17:15 -0800 Subject: [PATCH 5/6] Fix type error --- packages/loot-core/src/server/budget/app.ts | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/packages/loot-core/src/server/budget/app.ts b/packages/loot-core/src/server/budget/app.ts index 69dcbab5427..32fd01e64d3 100644 --- a/packages/loot-core/src/server/budget/app.ts +++ b/packages/loot-core/src/server/budget/app.ts @@ -311,7 +311,7 @@ async function deleteCategory({ }): Promise<{ error?: 'no-categories' | 'category-type' }> { let result = {}; await batchMessages(async () => { - const row = await db.first( + const row = await db.first>( 'SELECT is_income FROM categories WHERE id = ?', [id], ); @@ -322,9 +322,10 @@ async function deleteCategory({ const transfer = transferId && - (await db.first('SELECT is_income FROM categories WHERE id = ?', [ - transferId, - ])); + (await db.first>( + 'SELECT is_income FROM categories WHERE id = ?', + [transferId], + )); if (!row || (transferId && !transfer)) { result = { error: 'no-categories' }; From cb9a20149407aa32bb2447c1d00403a7a4149f9c Mon Sep 17 00:00:00 2001 From: Joel Jeremy Marquez Date: Tue, 4 Mar 2025 11:42:05 -0800 Subject: [PATCH 6/6] Fix types --- packages/loot-core/src/server/budget/app.ts | 21 ++++++++++------ packages/loot-core/src/server/db/index.ts | 28 +++++++++++++++------ packages/loot-core/src/server/models.ts | 23 ++++++++++------- 3 files changed, 49 insertions(+), 23 deletions(-) diff --git a/packages/loot-core/src/server/budget/app.ts b/packages/loot-core/src/server/budget/app.ts index 32fd01e64d3..1cd00a76300 100644 --- a/packages/loot-core/src/server/budget/app.ts +++ b/packages/loot-core/src/server/budget/app.ts @@ -252,7 +252,7 @@ async function createCategory({ hidden, }: { name: string; - groupId: number; + groupId: CategoryGroupEntity['id']; isIncome?: boolean; hidden?: boolean; }): Promise { @@ -272,10 +272,12 @@ async function updateCategory( category: CategoryEntity, ): Promise<{ error?: { type: 'category-exists' } }> { try { - await db.updateCategory({ - ...category, - name: category.name.trim(), - }); + await db.updateCategory( + categoryModel.toDb({ + ...category, + name: category.name.trim(), + }), + ); } catch (e) { if ( e instanceof Error && @@ -330,7 +332,12 @@ async function deleteCategory({ if (!row || (transferId && !transfer)) { result = { error: 'no-categories' }; return; - } else if (transferId && row.is_income !== transfer.is_income) { + } else if ( + transferId && + row && + transfer && + row.is_income !== transfer.is_income + ) { result = { error: 'category-type' }; return; } @@ -372,7 +379,7 @@ async function createCategoryGroup({ } async function updateCategoryGroup(group: CategoryGroupEntity) { - await db.updateCategoryGroup(group); + await db.updateCategoryGroup(categoryGroupModel.toDb(group)); } async function moveCategoryGroup({ diff --git a/packages/loot-core/src/server/db/index.ts b/packages/loot-core/src/server/db/index.ts index 716f4f5358e..b49ca314ea2 100644 --- a/packages/loot-core/src/server/db/index.ts +++ b/packages/loot-core/src/server/db/index.ts @@ -15,6 +15,7 @@ import * as fs from '../../platform/server/fs'; import * as sqlite from '../../platform/server/sqlite'; import * as monthUtils from '../../shared/months'; import { groupById } from '../../shared/util'; +import { WithRequired } from '../../types/util'; import { schema, schemaConfig, @@ -347,7 +348,7 @@ export async function getCategoriesGrouped( } export async function insertCategoryGroup( - group, + group: WithRequired, 'name'>, ): Promise { // Don't allow duplicate group const existingGroup = await first< @@ -378,12 +379,17 @@ export async function insertCategoryGroup( return id; } -export function updateCategoryGroup(group) { +export function updateCategoryGroup( + group: WithRequired, 'name' | 'is_income'>, +) { group = categoryGroupModel.validate(group, { update: true }); return update('category_groups', group); } -export async function moveCategoryGroup(id, targetId) { +export async function moveCategoryGroup( + id: DbCategoryGroup['id'], + targetId: DbCategoryGroup['id'], +) { const groups = await all( `SELECT id, sort_order FROM category_groups WHERE tombstone = 0 ORDER BY sort_order, id`, ); @@ -395,7 +401,10 @@ export async function moveCategoryGroup(id, targetId) { await update('category_groups', { id, sort_order }); } -export async function deleteCategoryGroup(group, transferId?: string) { +export async function deleteCategoryGroup( + group: Pick, + transferId?: string, +) { const categories = await all('SELECT * FROM categories WHERE cat_group = ?', [ group.id, ]); @@ -406,8 +415,8 @@ export async function deleteCategoryGroup(group, transferId?: string) { } export async function insertCategory( - category, - { atEnd } = { atEnd: undefined }, + category: WithRequired, 'name' | 'cat_group'>, + { atEnd }: { atEnd?: boolean | undefined } = { atEnd: undefined }, ): Promise { let sort_order; @@ -460,7 +469,12 @@ export async function insertCategory( return id_; } -export function updateCategory(category) { +export function updateCategory( + category: WithRequired< + Partial, + 'name' | 'is_income' | 'cat_group' + >, +) { category = categoryModel.validate(category, { update: true }); return update('categories', category); } diff --git a/packages/loot-core/src/server/models.ts b/packages/loot-core/src/server/models.ts index 296292a0162..cbe283ee456 100644 --- a/packages/loot-core/src/server/models.ts +++ b/packages/loot-core/src/server/models.ts @@ -1,5 +1,4 @@ import { - AccountEntity, CategoryEntity, CategoryGroupEntity, PayeeEntity, @@ -12,7 +11,7 @@ import { schema, schemaConfig, } from './aql'; -import { DbCategory, DbCategoryGroup } from './db'; +import { DbAccount, DbCategory, DbCategoryGroup } from './db'; import { ValidationError } from './errors'; export function requiredFields( @@ -58,7 +57,10 @@ export function fromDateRepr(number: number) { } export const accountModel = { - validate(account: AccountEntity, { update }: { update?: boolean } = {}) { + validate( + account: Partial, + { update }: { update?: boolean } = {}, + ): DbAccount { requiredFields( 'account', account, @@ -66,12 +68,15 @@ export const accountModel = { update, ); - return account; + return account as DbAccount; }, }; export const categoryModel = { - validate(category: CategoryEntity, { update }: { update?: boolean } = {}) { + validate( + category: Partial, + { update }: { update?: boolean } = {}, + ): DbCategory { requiredFields( 'category', category, @@ -80,7 +85,7 @@ export const categoryModel = { ); const { sort_order, ...rest } = category; - return { ...rest, hidden: rest.hidden ? 1 : 0 }; + return { ...rest } as DbCategory; }, toDb( category: CategoryEntity, @@ -104,9 +109,9 @@ export const categoryModel = { export const categoryGroupModel = { validate( - categoryGroup: CategoryGroupEntity, + categoryGroup: Partial, { update }: { update?: boolean } = {}, - ) { + ): DbCategoryGroup { requiredFields( 'categoryGroup', categoryGroup, @@ -115,7 +120,7 @@ export const categoryGroupModel = { ); const { sort_order, ...rest } = categoryGroup; - return { ...rest, hidden: rest.hidden ? 1 : 0 }; + return { ...rest } as DbCategoryGroup; }, toDb( categoryGroup: CategoryGroupEntity,