mirror of
https://github.com/actualbudget/actual.git
synced 2026-03-11 20:44:32 -05:00
Compare commits
12 Commits
react-quer
...
server-bud
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
6988db3f4a | ||
|
|
97ce99497f | ||
|
|
6af875972e | ||
|
|
6ffe7fba4e | ||
|
|
323a97849a | ||
|
|
04153b7408 | ||
|
|
082d957694 | ||
|
|
1af9491e4f | ||
|
|
df2f9daa1f | ||
|
|
270fb862bb | ||
|
|
c4658f9455 | ||
|
|
c711e2b6c1 |
@@ -703,7 +703,7 @@ export async function createTestBudget(handlers: Handlers) {
|
|||||||
for (const category of group.categories) {
|
for (const category of group.categories) {
|
||||||
const categoryId = await handlers['category-create']({
|
const categoryId = await handlers['category-create']({
|
||||||
...category,
|
...category,
|
||||||
isIncome: category.is_income ? 1 : 0,
|
isIncome: category.is_income,
|
||||||
groupId,
|
groupId,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -1,11 +1,111 @@
|
|||||||
|
// @ts-strict-ignore
|
||||||
|
import * as CRDT from '@actual-app/crdt';
|
||||||
|
|
||||||
|
import { createTestBudget } from '../../mocks/budget';
|
||||||
|
import { captureBreadcrumb, captureException } from '../../platform/exceptions';
|
||||||
|
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 { 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 { APIError, FileDownloadError, FileUploadError } from '../errors';
|
||||||
|
import { handleBudgetImport } from '../importers';
|
||||||
|
import { app as mainApp } from '../main-app';
|
||||||
import { mutator } from '../mutators';
|
import { mutator } from '../mutators';
|
||||||
import { undoable } from '../undo';
|
import * as Platform from '../platform';
|
||||||
|
import {
|
||||||
|
getDefaultPrefs,
|
||||||
|
getPrefs,
|
||||||
|
loadPrefs,
|
||||||
|
savePrefs,
|
||||||
|
unloadPrefs,
|
||||||
|
} from '../prefs';
|
||||||
|
import { getServer } from '../server-config';
|
||||||
|
import * as sheet from '../sheet';
|
||||||
|
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';
|
||||||
|
import { updateVersion } from '../update';
|
||||||
|
import {
|
||||||
|
idFromBudgetName,
|
||||||
|
uniqueBudgetName,
|
||||||
|
validateBudgetName,
|
||||||
|
} from '../util/budget-name';
|
||||||
|
|
||||||
import * as actions from './actions';
|
import * as actions from './actions';
|
||||||
|
import * as budget from './base';
|
||||||
import * as cleanupActions from './cleanup-template';
|
import * as cleanupActions from './cleanup-template';
|
||||||
import * as goalActions from './goaltemplates';
|
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/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;
|
||||||
|
'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;
|
||||||
|
'validate-budget-name': typeof handleValidateBudgetName;
|
||||||
|
'unique-budget-name': typeof handleUniqueBudgetName;
|
||||||
|
'get-budgets': typeof getBudgets;
|
||||||
|
'upload-budget': typeof uploadBudget;
|
||||||
|
'download-budget': typeof downloadBudget;
|
||||||
|
'sync-budget': typeof syncBudget;
|
||||||
|
'load-budget': typeof loadBudget;
|
||||||
|
'create-demo-budget': typeof createDemoBudget;
|
||||||
|
'close-budget': typeof closeBudget;
|
||||||
|
'delete-budget': typeof deleteBudget;
|
||||||
|
'duplicate-budget': typeof duplicateBudget;
|
||||||
|
'create-budget': typeof createBudget;
|
||||||
|
'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';
|
||||||
|
const TEST_BUDGET_ID = '_test-budget';
|
||||||
|
|
||||||
export const app = createApp<BudgetHandlers>();
|
export const app = createApp<BudgetHandlers>();
|
||||||
|
|
||||||
@@ -72,3 +172,873 @@ app.method(
|
|||||||
'budget/set-carryover',
|
'budget/set-carryover',
|
||||||
mutator(undoable(actions.setCategoryCarryover)),
|
mutator(undoable(actions.setCategoryCarryover)),
|
||||||
);
|
);
|
||||||
|
|
||||||
|
app.method('validate-budget-name', handleValidateBudgetName);
|
||||||
|
app.method('unique-budget-name', handleUniqueBudgetName);
|
||||||
|
app.method('get-budgets', getBudgets);
|
||||||
|
app.method('upload-budget', uploadBudget);
|
||||||
|
app.method('download-budget', downloadBudget);
|
||||||
|
app.method('sync-budget', syncBudget);
|
||||||
|
app.method('load-budget', loadBudget);
|
||||||
|
app.method('create-demo-budget', createDemoBudget);
|
||||||
|
app.method('close-budget', closeBudget);
|
||||||
|
app.method('delete-budget', deleteBudget);
|
||||||
|
app.method('duplicate-budget', duplicateBudget);
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleUniqueBudgetName({ name }: { name: string }) {
|
||||||
|
return uniqueBudgetName(name);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function getBudgets(): Promise<Budget[]> {
|
||||||
|
const paths = await fs.listDir(fs.getDocumentDir());
|
||||||
|
const budgets: (Budget | null)[] = await Promise.all(
|
||||||
|
paths.map(async name => {
|
||||||
|
const prefsPath = fs.join(fs.getDocumentDir(), name, 'metadata.json');
|
||||||
|
if (await fs.exists(prefsPath)) {
|
||||||
|
let prefs;
|
||||||
|
try {
|
||||||
|
prefs = JSON.parse(await fs.readFile(prefsPath));
|
||||||
|
} catch (e) {
|
||||||
|
console.log('Error parsing metadata:', e.stack);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// We treat the directory name as the canonical id so that if
|
||||||
|
// the user moves it around/renames/etc, nothing breaks. The
|
||||||
|
// id is stored in prefs just for convenience (and the prefs
|
||||||
|
// will always update to the latest given id)
|
||||||
|
if (name !== DEMO_BUDGET_ID) {
|
||||||
|
return {
|
||||||
|
id: name,
|
||||||
|
...(prefs.cloudFileId ? { cloudFileId: prefs.cloudFileId } : {}),
|
||||||
|
...(prefs.encryptKeyId ? { encryptKeyId: prefs.encryptKeyId } : {}),
|
||||||
|
...(prefs.groupId ? { groupId: prefs.groupId } : {}),
|
||||||
|
...(prefs.owner ? { owner: prefs.owner } : {}),
|
||||||
|
name: prefs.budgetName || '(no name)',
|
||||||
|
} satisfies Budget;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
return budgets.filter(x => x != null);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function uploadBudget({ id }: { id?: string } = {}): Promise<{
|
||||||
|
error?: ReturnType<typeof FileUploadError>;
|
||||||
|
}> {
|
||||||
|
if (id) {
|
||||||
|
if (getPrefs()) {
|
||||||
|
throw new Error('upload-budget: id given but prefs already loaded');
|
||||||
|
}
|
||||||
|
|
||||||
|
await loadPrefs(id);
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
await cloudStorage.upload();
|
||||||
|
} catch (e) {
|
||||||
|
console.log(e);
|
||||||
|
if (e.type === 'FileUploadError') {
|
||||||
|
return { error: e };
|
||||||
|
}
|
||||||
|
captureException(e);
|
||||||
|
return { error: FileUploadError('internal') };
|
||||||
|
} finally {
|
||||||
|
if (id) {
|
||||||
|
unloadPrefs();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return {};
|
||||||
|
}
|
||||||
|
|
||||||
|
async function downloadBudget({ fileId }: { fileId: string }): Promise<{
|
||||||
|
id?: string;
|
||||||
|
error?: ReturnType<typeof FileDownloadError>;
|
||||||
|
}> {
|
||||||
|
let result;
|
||||||
|
try {
|
||||||
|
result = await cloudStorage.download(fileId);
|
||||||
|
} catch (e) {
|
||||||
|
if (e.type === 'FileDownloadError') {
|
||||||
|
if (e.reason === 'file-exists' && e.meta.id) {
|
||||||
|
await loadPrefs(e.meta.id);
|
||||||
|
const name = getPrefs().budgetName;
|
||||||
|
unloadPrefs();
|
||||||
|
|
||||||
|
e.meta = { ...e.meta, name };
|
||||||
|
}
|
||||||
|
|
||||||
|
return { error: e };
|
||||||
|
} else {
|
||||||
|
captureException(e);
|
||||||
|
return { error: FileDownloadError('internal') };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const id = result.id;
|
||||||
|
await loadBudget({ id });
|
||||||
|
result = await syncBudget();
|
||||||
|
|
||||||
|
if (result.error) {
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
return { id };
|
||||||
|
}
|
||||||
|
|
||||||
|
// open and sync, but don’t close
|
||||||
|
async function syncBudget() {
|
||||||
|
setSyncingMode('enabled');
|
||||||
|
const result = await initialFullSync();
|
||||||
|
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadBudget({
|
||||||
|
id,
|
||||||
|
}: {
|
||||||
|
id: string;
|
||||||
|
}): Promise<{ error?: LoadBudgetError }> {
|
||||||
|
const currentPrefs = getPrefs();
|
||||||
|
|
||||||
|
if (currentPrefs) {
|
||||||
|
if (currentPrefs.id === id) {
|
||||||
|
// If it's already loaded, do nothing
|
||||||
|
return {};
|
||||||
|
} else {
|
||||||
|
// Otherwise, close the currently loaded budget
|
||||||
|
await closeBudget();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const res = await _loadBudget(id);
|
||||||
|
|
||||||
|
return res;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function createDemoBudget() {
|
||||||
|
// Make sure the read only flag isn't leftover (normally it's
|
||||||
|
// reset when signing in, but you don't have to sign in for the
|
||||||
|
// demo budget)
|
||||||
|
await asyncStorage.setItem('readOnly', '');
|
||||||
|
|
||||||
|
return await createBudget({
|
||||||
|
budgetName: 'Demo Budget',
|
||||||
|
testMode: true,
|
||||||
|
testBudgetId: DEMO_BUDGET_ID,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function closeBudget(): Promise<'ok'> {
|
||||||
|
captureBreadcrumb({ message: 'Closing budget' });
|
||||||
|
|
||||||
|
// The spreadsheet may be running, wait for it to complete
|
||||||
|
await sheet.waitOnSpreadsheet();
|
||||||
|
sheet.unloadSpreadsheet();
|
||||||
|
|
||||||
|
clearFullSyncTimeout();
|
||||||
|
await app.stopServices();
|
||||||
|
|
||||||
|
await db.closeDatabase();
|
||||||
|
|
||||||
|
try {
|
||||||
|
await asyncStorage.setItem('lastBudget', '');
|
||||||
|
} catch (e) {
|
||||||
|
// This might fail if we are shutting down after failing to load a
|
||||||
|
// budget. We want to unload whatever has already been loaded but
|
||||||
|
// be resilient to anything failing
|
||||||
|
}
|
||||||
|
|
||||||
|
unloadPrefs();
|
||||||
|
await stopBackupService();
|
||||||
|
return 'ok';
|
||||||
|
}
|
||||||
|
|
||||||
|
async function deleteBudget({
|
||||||
|
id,
|
||||||
|
cloudFileId,
|
||||||
|
}: {
|
||||||
|
id?: string;
|
||||||
|
cloudFileId?: string;
|
||||||
|
}): Promise<'ok' | 'fail'> {
|
||||||
|
// If it's a cloud file, you can delete it from the server by
|
||||||
|
// passing its cloud id
|
||||||
|
if (cloudFileId) {
|
||||||
|
await cloudStorage.removeFile(cloudFileId).catch(() => {});
|
||||||
|
}
|
||||||
|
|
||||||
|
// If a local file exists, you can delete it by passing its local id
|
||||||
|
if (id) {
|
||||||
|
// opening and then closing the database is a hack to be able to delete
|
||||||
|
// the budget file if it hasn't been opened yet. This needs a better
|
||||||
|
// way, but works for now.
|
||||||
|
try {
|
||||||
|
await db.openDatabase(id);
|
||||||
|
await db.closeDatabase();
|
||||||
|
const budgetDir = fs.getBudgetDir(id);
|
||||||
|
await fs.removeDirRecursively(budgetDir);
|
||||||
|
} catch (e) {
|
||||||
|
return 'fail';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return 'ok';
|
||||||
|
}
|
||||||
|
|
||||||
|
async function duplicateBudget({
|
||||||
|
id,
|
||||||
|
newName,
|
||||||
|
cloudSync,
|
||||||
|
open,
|
||||||
|
}: {
|
||||||
|
id?: string | undefined;
|
||||||
|
newName: string;
|
||||||
|
cloudSync?: boolean;
|
||||||
|
open: 'none' | 'original' | 'copy';
|
||||||
|
}): Promise<string> {
|
||||||
|
if (!id) throw new Error('Unable to duplicate a budget that is not local.');
|
||||||
|
|
||||||
|
const { valid, message } = await validateBudgetName(newName);
|
||||||
|
if (!valid) throw new Error(message);
|
||||||
|
|
||||||
|
const budgetDir = fs.getBudgetDir(id);
|
||||||
|
|
||||||
|
const newId = await idFromBudgetName(newName);
|
||||||
|
|
||||||
|
// copy metadata from current budget
|
||||||
|
// replace id with new budget id and budgetName with new budget name
|
||||||
|
const metadataText = await fs.readFile(fs.join(budgetDir, 'metadata.json'));
|
||||||
|
const metadata = JSON.parse(metadataText);
|
||||||
|
metadata.id = newId;
|
||||||
|
metadata.budgetName = newName;
|
||||||
|
[
|
||||||
|
'cloudFileId',
|
||||||
|
'groupId',
|
||||||
|
'lastUploaded',
|
||||||
|
'encryptKeyId',
|
||||||
|
'lastSyncedTimestamp',
|
||||||
|
].forEach(item => {
|
||||||
|
if (metadata[item]) delete metadata[item];
|
||||||
|
});
|
||||||
|
|
||||||
|
try {
|
||||||
|
const newBudgetDir = fs.getBudgetDir(newId);
|
||||||
|
await fs.mkdir(newBudgetDir);
|
||||||
|
|
||||||
|
// write metadata for new budget
|
||||||
|
await fs.writeFile(
|
||||||
|
fs.join(newBudgetDir, 'metadata.json'),
|
||||||
|
JSON.stringify(metadata),
|
||||||
|
);
|
||||||
|
|
||||||
|
await fs.copyFile(
|
||||||
|
fs.join(budgetDir, 'db.sqlite'),
|
||||||
|
fs.join(newBudgetDir, 'db.sqlite'),
|
||||||
|
);
|
||||||
|
} catch (error) {
|
||||||
|
// Clean up any partially created files
|
||||||
|
try {
|
||||||
|
const newBudgetDir = fs.getBudgetDir(newId);
|
||||||
|
if (await fs.exists(newBudgetDir)) {
|
||||||
|
await fs.removeDirRecursively(newBudgetDir);
|
||||||
|
}
|
||||||
|
} catch {} // Ignore cleanup errors
|
||||||
|
throw new Error(`Failed to duplicate budget file: ${error.message}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// load in and validate
|
||||||
|
const { error } = await _loadBudget(newId);
|
||||||
|
if (error) {
|
||||||
|
console.log('Error duplicating budget: ' + error);
|
||||||
|
return error;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (cloudSync) {
|
||||||
|
try {
|
||||||
|
await cloudStorage.upload();
|
||||||
|
} catch (error) {
|
||||||
|
console.warn('Failed to sync duplicated budget to cloud:', error);
|
||||||
|
// Ignore any errors uploading. If they are offline they should
|
||||||
|
// still be able to create files.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
await closeBudget();
|
||||||
|
if (open === 'original') await _loadBudget(id);
|
||||||
|
if (open === 'copy') await _loadBudget(newId);
|
||||||
|
|
||||||
|
return newId;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function createBudget({
|
||||||
|
budgetName,
|
||||||
|
avoidUpload,
|
||||||
|
testMode,
|
||||||
|
testBudgetId,
|
||||||
|
}: {
|
||||||
|
budgetName?: string;
|
||||||
|
avoidUpload?: boolean;
|
||||||
|
testMode?: boolean;
|
||||||
|
testBudgetId?: string;
|
||||||
|
} = {}): Promise<{ error?: LoadBudgetError }> {
|
||||||
|
let id;
|
||||||
|
if (testMode) {
|
||||||
|
budgetName = budgetName || 'Test Budget';
|
||||||
|
id = testBudgetId || TEST_BUDGET_ID;
|
||||||
|
|
||||||
|
if (await fs.exists(fs.getBudgetDir(id))) {
|
||||||
|
await fs.removeDirRecursively(fs.getBudgetDir(id));
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// Generate budget name if not given
|
||||||
|
if (!budgetName) {
|
||||||
|
budgetName = await uniqueBudgetName();
|
||||||
|
}
|
||||||
|
|
||||||
|
id = await idFromBudgetName(budgetName);
|
||||||
|
}
|
||||||
|
|
||||||
|
const budgetDir = fs.getBudgetDir(id);
|
||||||
|
await fs.mkdir(budgetDir);
|
||||||
|
|
||||||
|
// Create the initial database
|
||||||
|
await fs.copyFile(fs.bundledDatabasePath, fs.join(budgetDir, 'db.sqlite'));
|
||||||
|
|
||||||
|
// Create the initial prefs file
|
||||||
|
await fs.writeFile(
|
||||||
|
fs.join(budgetDir, 'metadata.json'),
|
||||||
|
JSON.stringify(getDefaultPrefs(id, budgetName)),
|
||||||
|
);
|
||||||
|
|
||||||
|
// Load it in
|
||||||
|
const { error } = await _loadBudget(id);
|
||||||
|
if (error) {
|
||||||
|
console.log('Error creating budget: ' + error);
|
||||||
|
return { error };
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!avoidUpload && !testMode) {
|
||||||
|
try {
|
||||||
|
await cloudStorage.upload();
|
||||||
|
} catch (e) {
|
||||||
|
// Ignore any errors uploading. If they are offline they should
|
||||||
|
// still be able to create files.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (testMode) {
|
||||||
|
await createTestBudget(mainApp.handlers);
|
||||||
|
}
|
||||||
|
|
||||||
|
return {};
|
||||||
|
}
|
||||||
|
|
||||||
|
async function importBudget({
|
||||||
|
filepath,
|
||||||
|
type,
|
||||||
|
}: {
|
||||||
|
filepath: string;
|
||||||
|
type: 'ynab4' | 'ynab5' | 'actual';
|
||||||
|
}): Promise<{ error?: string }> {
|
||||||
|
try {
|
||||||
|
if (!(await fs.exists(filepath))) {
|
||||||
|
throw new Error(`File not found at the provided path: ${filepath}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const buffer = Buffer.from(await fs.readFile(filepath, 'binary'));
|
||||||
|
const results = await handleBudgetImport(type, filepath, buffer);
|
||||||
|
return results || {};
|
||||||
|
} catch (err) {
|
||||||
|
err.message = 'Error importing budget: ' + err.message;
|
||||||
|
captureException(err);
|
||||||
|
return { error: 'internal-error' };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function exportBudget(): Promise<{ data: Buffer } | { error: string }> {
|
||||||
|
try {
|
||||||
|
return {
|
||||||
|
data: await cloudStorage.exportBuffer(),
|
||||||
|
};
|
||||||
|
} catch (err) {
|
||||||
|
err.message = 'Error exporting budget: ' + err.message;
|
||||||
|
captureException(err);
|
||||||
|
return { error: 'internal-error' };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
type LoadBudgetError =
|
||||||
|
| 'budget-not-found'
|
||||||
|
| 'loading-budget'
|
||||||
|
| 'out-of-sync-migrations'
|
||||||
|
| 'out-of-sync-data'
|
||||||
|
| 'opening-budget'
|
||||||
|
| 'loading-budget';
|
||||||
|
|
||||||
|
async function _loadBudget(id: string): Promise<{
|
||||||
|
error?: LoadBudgetError;
|
||||||
|
}> {
|
||||||
|
let dir: string;
|
||||||
|
try {
|
||||||
|
dir = fs.getBudgetDir(id);
|
||||||
|
} catch (e) {
|
||||||
|
captureException(
|
||||||
|
new Error('`getBudgetDir` failed in `loadBudget`: ' + e.message),
|
||||||
|
);
|
||||||
|
return { error: 'budget-not-found' };
|
||||||
|
}
|
||||||
|
|
||||||
|
captureBreadcrumb({ message: 'Loading budget ' + dir });
|
||||||
|
|
||||||
|
if (!(await fs.exists(dir))) {
|
||||||
|
captureException(new Error('budget directory does not exist'));
|
||||||
|
return { error: 'budget-not-found' };
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
await loadPrefs(id);
|
||||||
|
await db.openDatabase(id);
|
||||||
|
} catch (e) {
|
||||||
|
captureBreadcrumb({ message: 'Error loading budget ' + id });
|
||||||
|
captureException(e);
|
||||||
|
await closeBudget();
|
||||||
|
return { error: 'opening-budget' };
|
||||||
|
}
|
||||||
|
|
||||||
|
// Older versions didn't tag the file with the current user, so do
|
||||||
|
// so now
|
||||||
|
if (!getPrefs().userId) {
|
||||||
|
const userId = await asyncStorage.getItem('user-token');
|
||||||
|
await savePrefs({ userId });
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
await updateVersion();
|
||||||
|
} catch (e) {
|
||||||
|
console.warn('Error updating', e);
|
||||||
|
let result;
|
||||||
|
if (e.message.includes('out-of-sync-migrations')) {
|
||||||
|
result = { error: 'out-of-sync-migrations' };
|
||||||
|
} else if (e.message.includes('out-of-sync-data')) {
|
||||||
|
result = { error: 'out-of-sync-data' };
|
||||||
|
} else {
|
||||||
|
captureException(e);
|
||||||
|
logger.info('Error updating budget ' + id, e);
|
||||||
|
console.log('Error updating budget', e);
|
||||||
|
result = { error: 'loading-budget' };
|
||||||
|
}
|
||||||
|
|
||||||
|
await closeBudget();
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
await db.loadClock();
|
||||||
|
|
||||||
|
if (getPrefs().resetClock) {
|
||||||
|
// If we need to generate a fresh clock, we need to generate a new
|
||||||
|
// client id. This happens when the database is transferred to a
|
||||||
|
// new device.
|
||||||
|
//
|
||||||
|
// TODO: The client id should be stored elsewhere. It shouldn't
|
||||||
|
// work this way, but it's fine for now.
|
||||||
|
CRDT.getClock().timestamp.setNode(CRDT.makeClientId());
|
||||||
|
await db.runQuery(
|
||||||
|
'INSERT OR REPLACE INTO messages_clock (id, clock) VALUES (1, ?)',
|
||||||
|
[CRDT.serializeClock(CRDT.getClock())],
|
||||||
|
);
|
||||||
|
|
||||||
|
await savePrefs({ resetClock: false });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (
|
||||||
|
!Platform.isWeb &&
|
||||||
|
!Platform.isMobile &&
|
||||||
|
process.env.NODE_ENV !== 'test'
|
||||||
|
) {
|
||||||
|
await startBackupService(id);
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
await sheet.loadSpreadsheet(db, onSheetChange);
|
||||||
|
} catch (e) {
|
||||||
|
captureException(e);
|
||||||
|
await closeBudget();
|
||||||
|
return { error: 'opening-budget' };
|
||||||
|
}
|
||||||
|
|
||||||
|
// This is a bit leaky, but we need to set the initial budget type
|
||||||
|
const { value: budgetType = 'rollover' } =
|
||||||
|
(await db.first<Pick<db.DbPreference, 'value'>>(
|
||||||
|
'SELECT value from preferences WHERE id = ?',
|
||||||
|
['budgetType'],
|
||||||
|
)) ?? {};
|
||||||
|
sheet.get().meta().budgetType = budgetType;
|
||||||
|
await budget.createAllBudgets();
|
||||||
|
|
||||||
|
// Load all the in-memory state
|
||||||
|
await mappings.loadMappings();
|
||||||
|
await rules.loadRules();
|
||||||
|
await syncMigrations.listen();
|
||||||
|
await app.startServices();
|
||||||
|
|
||||||
|
clearUndo();
|
||||||
|
|
||||||
|
// Ensure that syncing is enabled
|
||||||
|
if (process.env.NODE_ENV !== 'test') {
|
||||||
|
if (id === DEMO_BUDGET_ID) {
|
||||||
|
setSyncingMode('disabled');
|
||||||
|
} else {
|
||||||
|
if (getServer()) {
|
||||||
|
setSyncingMode('enabled');
|
||||||
|
} else {
|
||||||
|
setSyncingMode('disabled');
|
||||||
|
}
|
||||||
|
|
||||||
|
await asyncStorage.setItem('lastBudget', id);
|
||||||
|
|
||||||
|
// Only upload periodically on desktop
|
||||||
|
if (!Platform.isMobile) {
|
||||||
|
await cloudStorage.possiblyUpload();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
app.events.emit('load-budget', { id });
|
||||||
|
|
||||||
|
return {};
|
||||||
|
}
|
||||||
|
|
||||||
|
async function resetBudgetCache() {
|
||||||
|
// Recomputing everything will update the cache
|
||||||
|
await sheet.loadUserBudgets(db);
|
||||||
|
sheet.get().recomputeAll();
|
||||||
|
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'] | undefined;
|
||||||
|
hidden?: CategoryEntity['hidden'] | undefined;
|
||||||
|
}): 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'] | undefined;
|
||||||
|
hidden?: CategoryGroupEntity['hidden'] | undefined;
|
||||||
|
}): 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[] }) {
|
||||||
|
const nodes = names.map(name => {
|
||||||
|
const node = sheet.get()._getNode(name);
|
||||||
|
return { name: node.name, value: node.value };
|
||||||
|
});
|
||||||
|
connection.send('cells-changed', nodes);
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,91 +0,0 @@
|
|||||||
import type { Notification } from '../../../client/notifications/notificationsSlice';
|
|
||||||
|
|
||||||
export interface BudgetHandlers {
|
|
||||||
'budget/budget-amount': (arg: {
|
|
||||||
category: string /* category id */;
|
|
||||||
month: string;
|
|
||||||
amount: number;
|
|
||||||
}) => Promise<void>;
|
|
||||||
|
|
||||||
'budget/copy-previous-month': (arg: { month: string }) => Promise<void>;
|
|
||||||
|
|
||||||
'budget/set-zero': (arg: { month: string }) => Promise<void>;
|
|
||||||
|
|
||||||
'budget/set-3month-avg': (arg: { month: string }) => Promise<void>;
|
|
||||||
|
|
||||||
'budget/set-6month-avg': (arg: { month: string }) => Promise<void>;
|
|
||||||
|
|
||||||
'budget/set-12month-avg': (arg: { month: string }) => Promise<void>;
|
|
||||||
|
|
||||||
'budget/check-templates': () => Promise<Notification>;
|
|
||||||
|
|
||||||
'budget/apply-goal-template': (arg: {
|
|
||||||
month: string;
|
|
||||||
}) => Promise<Notification>;
|
|
||||||
|
|
||||||
'budget/overwrite-goal-template': (arg: {
|
|
||||||
month: string;
|
|
||||||
}) => Promise<Notification>;
|
|
||||||
|
|
||||||
'budget/cleanup-goal-template': (arg: {
|
|
||||||
month: string;
|
|
||||||
}) => Promise<Notification>;
|
|
||||||
|
|
||||||
'budget/hold-for-next-month': (arg: {
|
|
||||||
month: string;
|
|
||||||
amount: number;
|
|
||||||
}) => Promise<boolean>;
|
|
||||||
|
|
||||||
'budget/reset-hold': (arg: { month: string }) => Promise<void>;
|
|
||||||
|
|
||||||
'budget/cover-overspending': (arg: {
|
|
||||||
month: string;
|
|
||||||
to: string;
|
|
||||||
from: string;
|
|
||||||
}) => Promise<void>;
|
|
||||||
|
|
||||||
'budget/transfer-available': (arg: {
|
|
||||||
month: string;
|
|
||||||
amount: number;
|
|
||||||
category: string;
|
|
||||||
}) => Promise<void>;
|
|
||||||
|
|
||||||
'budget/cover-overbudgeted': (arg: {
|
|
||||||
month: string;
|
|
||||||
category: string;
|
|
||||||
}) => Promise<void>;
|
|
||||||
|
|
||||||
'budget/transfer-category': (arg: {
|
|
||||||
month: string;
|
|
||||||
amount: number;
|
|
||||||
to: string;
|
|
||||||
from: string;
|
|
||||||
}) => Promise<void>;
|
|
||||||
|
|
||||||
'budget/set-carryover': (arg: {
|
|
||||||
startMonth: string;
|
|
||||||
category: string;
|
|
||||||
flag: boolean;
|
|
||||||
}) => Promise<void>;
|
|
||||||
|
|
||||||
'budget/apply-single-template': (arg: {
|
|
||||||
month: string;
|
|
||||||
category: string; //category id
|
|
||||||
}) => Promise<Notification>;
|
|
||||||
|
|
||||||
'budget/set-n-month-avg': (arg: {
|
|
||||||
month: string;
|
|
||||||
N: number;
|
|
||||||
category: string; //category id
|
|
||||||
}) => Promise<void>;
|
|
||||||
|
|
||||||
'budget/copy-single-month': (arg: {
|
|
||||||
month: string;
|
|
||||||
category: string; //category id
|
|
||||||
}) => Promise<void>;
|
|
||||||
|
|
||||||
'budget/apply-multiple-templates': (arg: {
|
|
||||||
month: string;
|
|
||||||
categoryIds: string[];
|
|
||||||
}) => Promise<Notification>;
|
|
||||||
}
|
|
||||||
@@ -87,7 +87,12 @@ export function APIError(msg: string, meta?: Record<string, any>) {
|
|||||||
|
|
||||||
export function FileDownloadError(
|
export function FileDownloadError(
|
||||||
reason: string,
|
reason: string,
|
||||||
meta?: { fileId?: string; isMissingKey?: boolean },
|
meta?: {
|
||||||
|
fileId?: string;
|
||||||
|
isMissingKey?: boolean;
|
||||||
|
name?: string;
|
||||||
|
id?: string;
|
||||||
|
},
|
||||||
) {
|
) {
|
||||||
return { type: 'FileDownloadError', reason, meta };
|
return { type: 'FileDownloadError', reason, meta };
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,44 +2,27 @@
|
|||||||
import './polyfills';
|
import './polyfills';
|
||||||
|
|
||||||
import * as injectAPI from '@actual-app/api/injected';
|
import * as injectAPI from '@actual-app/api/injected';
|
||||||
import * as CRDT from '@actual-app/crdt';
|
|
||||||
import { v4 as uuidv4 } from 'uuid';
|
import { v4 as uuidv4 } from 'uuid';
|
||||||
|
|
||||||
import { createTestBudget } from '../mocks/budget';
|
|
||||||
import { captureException, captureBreadcrumb } from '../platform/exceptions';
|
|
||||||
import * as asyncStorage from '../platform/server/asyncStorage';
|
import * as asyncStorage from '../platform/server/asyncStorage';
|
||||||
import * as connection from '../platform/server/connection';
|
import * as connection from '../platform/server/connection';
|
||||||
import * as fs from '../platform/server/fs';
|
import * as fs from '../platform/server/fs';
|
||||||
import { logger } from '../platform/server/log';
|
|
||||||
import * as sqlite from '../platform/server/sqlite';
|
import * as sqlite from '../platform/server/sqlite';
|
||||||
import * as monthUtils from '../shared/months';
|
|
||||||
import { q } from '../shared/query';
|
import { q } from '../shared/query';
|
||||||
import { type Budget } from '../types/budget';
|
|
||||||
import { Handlers } from '../types/handlers';
|
import { Handlers } from '../types/handlers';
|
||||||
import { CategoryEntity, CategoryGroupEntity } from '../types/models';
|
|
||||||
import { OpenIdConfig } from '../types/models/openid';
|
import { OpenIdConfig } from '../types/models/openid';
|
||||||
|
|
||||||
import { app as accountsApp } from './accounts/app';
|
import { app as accountsApp } from './accounts/app';
|
||||||
import { app as adminApp } from './admin/app';
|
import { app as adminApp } from './admin/app';
|
||||||
import { installAPI } from './api';
|
import { installAPI } from './api';
|
||||||
import { runQuery as aqlQuery } from './aql';
|
import { runQuery as aqlQuery } from './aql';
|
||||||
import {
|
import { getAvailableBackups, loadBackup, makeBackup } from './backups';
|
||||||
getAvailableBackups,
|
|
||||||
loadBackup,
|
|
||||||
makeBackup,
|
|
||||||
startBackupService,
|
|
||||||
stopBackupService,
|
|
||||||
} from './backups';
|
|
||||||
import { app as budgetApp } from './budget/app';
|
import { app as budgetApp } from './budget/app';
|
||||||
import * as budget from './budget/base';
|
|
||||||
import * as cloudStorage from './cloud-storage';
|
import * as cloudStorage from './cloud-storage';
|
||||||
import { app as dashboardApp } from './dashboard/app';
|
import { app as dashboardApp } from './dashboard/app';
|
||||||
import * as db from './db';
|
import * as db from './db';
|
||||||
import * as mappings from './db/mappings';
|
|
||||||
import * as encryption from './encryption';
|
import * as encryption from './encryption';
|
||||||
import { APIError } from './errors';
|
|
||||||
import { app as filtersApp } from './filters/app';
|
import { app as filtersApp } from './filters/app';
|
||||||
import { handleBudgetImport } from './importers';
|
|
||||||
import { app } from './main-app';
|
import { app } from './main-app';
|
||||||
import { mutator, runHandler } from './mutators';
|
import { mutator, runHandler } from './mutators';
|
||||||
import { app as notesApp } from './notes/app';
|
import { app as notesApp } from './notes/app';
|
||||||
@@ -55,39 +38,16 @@ import { getServer, isValidBaseURL, setServer } from './server-config';
|
|||||||
import * as sheet from './sheet';
|
import * as sheet from './sheet';
|
||||||
import { resolveName, unresolveName } from './spreadsheet/util';
|
import { resolveName, unresolveName } from './spreadsheet/util';
|
||||||
import {
|
import {
|
||||||
initialFullSync,
|
|
||||||
fullSync,
|
fullSync,
|
||||||
setSyncingMode,
|
setSyncingMode,
|
||||||
makeTestMessage,
|
makeTestMessage,
|
||||||
clearFullSyncTimeout,
|
|
||||||
resetSync,
|
resetSync,
|
||||||
repairSync,
|
repairSync,
|
||||||
batchMessages,
|
|
||||||
} from './sync';
|
} from './sync';
|
||||||
import * as syncMigrations from './sync/migrate';
|
|
||||||
import { app as toolsApp } from './tools/app';
|
import { app as toolsApp } from './tools/app';
|
||||||
import { app as transactionsApp } from './transactions/app';
|
import { app as transactionsApp } from './transactions/app';
|
||||||
import * as rules from './transactions/transaction-rules';
|
import * as rules from './transactions/transaction-rules';
|
||||||
import { clearUndo, undo, redo, withUndo } from './undo';
|
import { undo, redo } from './undo';
|
||||||
import { updateVersion } from './update';
|
|
||||||
import {
|
|
||||||
uniqueBudgetName,
|
|
||||||
idFromBudgetName,
|
|
||||||
validateBudgetName,
|
|
||||||
} from './util/budget-name';
|
|
||||||
|
|
||||||
const DEMO_BUDGET_ID = '_demo-budget';
|
|
||||||
const TEST_BUDGET_ID = '_test-budget';
|
|
||||||
|
|
||||||
// util
|
|
||||||
|
|
||||||
function onSheetChange({ names }) {
|
|
||||||
const nodes = names.map(name => {
|
|
||||||
const node = sheet.get()._getNode(name);
|
|
||||||
return { name: node.name, value: node.value };
|
|
||||||
});
|
|
||||||
connection.send('cells-changed', nodes);
|
|
||||||
}
|
|
||||||
|
|
||||||
// handlers
|
// handlers
|
||||||
|
|
||||||
@@ -103,285 +63,6 @@ handlers['redo'] = mutator(function () {
|
|||||||
return redo();
|
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 ({
|
handlers['make-filters-from-conditions'] = async function ({
|
||||||
conditions,
|
conditions,
|
||||||
applySpecialCases,
|
applySpecialCases,
|
||||||
@@ -770,55 +451,6 @@ handlers['sync'] = async function () {
|
|||||||
return fullSync();
|
return fullSync();
|
||||||
};
|
};
|
||||||
|
|
||||||
handlers['validate-budget-name'] = async function ({ name }) {
|
|
||||||
return validateBudgetName(name);
|
|
||||||
};
|
|
||||||
|
|
||||||
handlers['unique-budget-name'] = async function ({ name }) {
|
|
||||||
return uniqueBudgetName(name);
|
|
||||||
};
|
|
||||||
|
|
||||||
handlers['get-budgets'] = async function () {
|
|
||||||
const paths = await fs.listDir(fs.getDocumentDir());
|
|
||||||
const budgets = (
|
|
||||||
await Promise.all(
|
|
||||||
paths.map(async name => {
|
|
||||||
const prefsPath = fs.join(fs.getDocumentDir(), name, 'metadata.json');
|
|
||||||
if (await fs.exists(prefsPath)) {
|
|
||||||
let prefs;
|
|
||||||
try {
|
|
||||||
prefs = JSON.parse(await fs.readFile(prefsPath));
|
|
||||||
} catch (e) {
|
|
||||||
console.log('Error parsing metadata:', e.stack);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// We treat the directory name as the canonical id so that if
|
|
||||||
// the user moves it around/renames/etc, nothing breaks. The
|
|
||||||
// id is stored in prefs just for convenience (and the prefs
|
|
||||||
// will always update to the latest given id)
|
|
||||||
if (name !== DEMO_BUDGET_ID) {
|
|
||||||
return {
|
|
||||||
id: name,
|
|
||||||
...(prefs.cloudFileId ? { cloudFileId: prefs.cloudFileId } : {}),
|
|
||||||
...(prefs.encryptKeyId
|
|
||||||
? { encryptKeyId: prefs.encryptKeyId }
|
|
||||||
: {}),
|
|
||||||
...(prefs.groupId ? { groupId: prefs.groupId } : {}),
|
|
||||||
...(prefs.owner ? { owner: prefs.owner } : {}),
|
|
||||||
name: prefs.budgetName || '(no name)',
|
|
||||||
} satisfies Budget;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return null;
|
|
||||||
}),
|
|
||||||
)
|
|
||||||
).filter(x => x);
|
|
||||||
|
|
||||||
return budgets;
|
|
||||||
};
|
|
||||||
|
|
||||||
handlers['get-remote-files'] = async function () {
|
handlers['get-remote-files'] = async function () {
|
||||||
return cloudStorage.listRemoteFiles();
|
return cloudStorage.listRemoteFiles();
|
||||||
};
|
};
|
||||||
@@ -827,331 +459,6 @@ handlers['get-user-file-info'] = async function (fileId: string) {
|
|||||||
return cloudStorage.getRemoteFile(fileId);
|
return cloudStorage.getRemoteFile(fileId);
|
||||||
};
|
};
|
||||||
|
|
||||||
handlers['reset-budget-cache'] = mutator(async function () {
|
|
||||||
// Recomputing everything will update the cache
|
|
||||||
await sheet.loadUserBudgets(db);
|
|
||||||
sheet.get().recomputeAll();
|
|
||||||
await sheet.waitOnSpreadsheet();
|
|
||||||
});
|
|
||||||
|
|
||||||
handlers['upload-budget'] = async function ({ id }: { id? } = {}) {
|
|
||||||
if (id) {
|
|
||||||
if (prefs.getPrefs()) {
|
|
||||||
throw new Error('upload-budget: id given but prefs already loaded');
|
|
||||||
}
|
|
||||||
|
|
||||||
await prefs.loadPrefs(id);
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
await cloudStorage.upload();
|
|
||||||
} catch (e) {
|
|
||||||
console.log(e);
|
|
||||||
if (e.type === 'FileUploadError') {
|
|
||||||
return { error: e };
|
|
||||||
}
|
|
||||||
captureException(e);
|
|
||||||
return { error: { reason: 'internal' } };
|
|
||||||
} finally {
|
|
||||||
if (id) {
|
|
||||||
prefs.unloadPrefs();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return {};
|
|
||||||
};
|
|
||||||
|
|
||||||
handlers['download-budget'] = async function ({ fileId }) {
|
|
||||||
let result;
|
|
||||||
try {
|
|
||||||
result = await cloudStorage.download(fileId);
|
|
||||||
} catch (e) {
|
|
||||||
if (e.type === 'FileDownloadError') {
|
|
||||||
if (e.reason === 'file-exists' && e.meta.id) {
|
|
||||||
await prefs.loadPrefs(e.meta.id);
|
|
||||||
const name = prefs.getPrefs().budgetName;
|
|
||||||
prefs.unloadPrefs();
|
|
||||||
|
|
||||||
e.meta = { ...e.meta, name };
|
|
||||||
}
|
|
||||||
|
|
||||||
return { error: e };
|
|
||||||
} else {
|
|
||||||
captureException(e);
|
|
||||||
return { error: { reason: 'internal' } };
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const id = result.id;
|
|
||||||
await handlers['load-budget']({ id });
|
|
||||||
result = await handlers['sync-budget']();
|
|
||||||
|
|
||||||
if (result.error) {
|
|
||||||
return result;
|
|
||||||
}
|
|
||||||
return { id };
|
|
||||||
};
|
|
||||||
|
|
||||||
// open and sync, but don’t close
|
|
||||||
handlers['sync-budget'] = async function () {
|
|
||||||
setSyncingMode('enabled');
|
|
||||||
const result = await initialFullSync();
|
|
||||||
|
|
||||||
return result;
|
|
||||||
};
|
|
||||||
|
|
||||||
handlers['load-budget'] = async function ({ id }) {
|
|
||||||
const currentPrefs = prefs.getPrefs();
|
|
||||||
|
|
||||||
if (currentPrefs) {
|
|
||||||
if (currentPrefs.id === id) {
|
|
||||||
// If it's already loaded, do nothing
|
|
||||||
return {};
|
|
||||||
} else {
|
|
||||||
// Otherwise, close the currently loaded budget
|
|
||||||
await handlers['close-budget']();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const res = await loadBudget(id);
|
|
||||||
|
|
||||||
return res;
|
|
||||||
};
|
|
||||||
|
|
||||||
handlers['create-demo-budget'] = async function () {
|
|
||||||
// Make sure the read only flag isn't leftover (normally it's
|
|
||||||
// reset when signing in, but you don't have to sign in for the
|
|
||||||
// demo budget)
|
|
||||||
await asyncStorage.setItem('readOnly', '');
|
|
||||||
|
|
||||||
return handlers['create-budget']({
|
|
||||||
budgetName: 'Demo Budget',
|
|
||||||
testMode: true,
|
|
||||||
testBudgetId: DEMO_BUDGET_ID,
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
handlers['close-budget'] = async function () {
|
|
||||||
captureBreadcrumb({ message: 'Closing budget' });
|
|
||||||
|
|
||||||
// The spreadsheet may be running, wait for it to complete
|
|
||||||
await sheet.waitOnSpreadsheet();
|
|
||||||
sheet.unloadSpreadsheet();
|
|
||||||
|
|
||||||
clearFullSyncTimeout();
|
|
||||||
await app.stopServices();
|
|
||||||
|
|
||||||
await db.closeDatabase();
|
|
||||||
|
|
||||||
try {
|
|
||||||
await asyncStorage.setItem('lastBudget', '');
|
|
||||||
} catch (e) {
|
|
||||||
// This might fail if we are shutting down after failing to load a
|
|
||||||
// budget. We want to unload whatever has already been loaded but
|
|
||||||
// be resilient to anything failing
|
|
||||||
}
|
|
||||||
|
|
||||||
prefs.unloadPrefs();
|
|
||||||
await stopBackupService();
|
|
||||||
return 'ok';
|
|
||||||
};
|
|
||||||
|
|
||||||
handlers['delete-budget'] = async function ({ id, cloudFileId }) {
|
|
||||||
// If it's a cloud file, you can delete it from the server by
|
|
||||||
// passing its cloud id
|
|
||||||
if (cloudFileId) {
|
|
||||||
await cloudStorage.removeFile(cloudFileId).catch(() => {});
|
|
||||||
}
|
|
||||||
|
|
||||||
// If a local file exists, you can delete it by passing its local id
|
|
||||||
if (id) {
|
|
||||||
// opening and then closing the database is a hack to be able to delete
|
|
||||||
// the budget file if it hasn't been opened yet. This needs a better
|
|
||||||
// way, but works for now.
|
|
||||||
try {
|
|
||||||
await db.openDatabase(id);
|
|
||||||
await db.closeDatabase();
|
|
||||||
const budgetDir = fs.getBudgetDir(id);
|
|
||||||
await fs.removeDirRecursively(budgetDir);
|
|
||||||
} catch (e) {
|
|
||||||
return 'fail';
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return 'ok';
|
|
||||||
};
|
|
||||||
|
|
||||||
handlers['duplicate-budget'] = async function ({
|
|
||||||
id,
|
|
||||||
newName,
|
|
||||||
cloudSync,
|
|
||||||
open,
|
|
||||||
}): Promise<string> {
|
|
||||||
if (!id) throw new Error('Unable to duplicate a budget that is not local.');
|
|
||||||
|
|
||||||
const { valid, message } = await validateBudgetName(newName);
|
|
||||||
if (!valid) throw new Error(message);
|
|
||||||
|
|
||||||
const budgetDir = fs.getBudgetDir(id);
|
|
||||||
|
|
||||||
const newId = await idFromBudgetName(newName);
|
|
||||||
|
|
||||||
// copy metadata from current budget
|
|
||||||
// replace id with new budget id and budgetName with new budget name
|
|
||||||
const metadataText = await fs.readFile(fs.join(budgetDir, 'metadata.json'));
|
|
||||||
const metadata = JSON.parse(metadataText);
|
|
||||||
metadata.id = newId;
|
|
||||||
metadata.budgetName = newName;
|
|
||||||
[
|
|
||||||
'cloudFileId',
|
|
||||||
'groupId',
|
|
||||||
'lastUploaded',
|
|
||||||
'encryptKeyId',
|
|
||||||
'lastSyncedTimestamp',
|
|
||||||
].forEach(item => {
|
|
||||||
if (metadata[item]) delete metadata[item];
|
|
||||||
});
|
|
||||||
|
|
||||||
try {
|
|
||||||
const newBudgetDir = fs.getBudgetDir(newId);
|
|
||||||
await fs.mkdir(newBudgetDir);
|
|
||||||
|
|
||||||
// write metadata for new budget
|
|
||||||
await fs.writeFile(
|
|
||||||
fs.join(newBudgetDir, 'metadata.json'),
|
|
||||||
JSON.stringify(metadata),
|
|
||||||
);
|
|
||||||
|
|
||||||
await fs.copyFile(
|
|
||||||
fs.join(budgetDir, 'db.sqlite'),
|
|
||||||
fs.join(newBudgetDir, 'db.sqlite'),
|
|
||||||
);
|
|
||||||
} catch (error) {
|
|
||||||
// Clean up any partially created files
|
|
||||||
try {
|
|
||||||
const newBudgetDir = fs.getBudgetDir(newId);
|
|
||||||
if (await fs.exists(newBudgetDir)) {
|
|
||||||
await fs.removeDirRecursively(newBudgetDir);
|
|
||||||
}
|
|
||||||
} catch {} // Ignore cleanup errors
|
|
||||||
throw new Error(`Failed to duplicate budget file: ${error.message}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
// load in and validate
|
|
||||||
const { error } = await loadBudget(newId);
|
|
||||||
if (error) {
|
|
||||||
console.log('Error duplicating budget: ' + error);
|
|
||||||
return error;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (cloudSync) {
|
|
||||||
try {
|
|
||||||
await cloudStorage.upload();
|
|
||||||
} catch (error) {
|
|
||||||
console.warn('Failed to sync duplicated budget to cloud:', error);
|
|
||||||
// Ignore any errors uploading. If they are offline they should
|
|
||||||
// still be able to create files.
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
handlers['close-budget']();
|
|
||||||
if (open === 'original') await loadBudget(id);
|
|
||||||
if (open === 'copy') await loadBudget(newId);
|
|
||||||
|
|
||||||
return newId;
|
|
||||||
};
|
|
||||||
|
|
||||||
handlers['create-budget'] = async function ({
|
|
||||||
budgetName,
|
|
||||||
avoidUpload,
|
|
||||||
testMode,
|
|
||||||
testBudgetId,
|
|
||||||
}: {
|
|
||||||
budgetName?;
|
|
||||||
avoidUpload?;
|
|
||||||
testMode?;
|
|
||||||
testBudgetId?;
|
|
||||||
} = {}) {
|
|
||||||
let id;
|
|
||||||
if (testMode) {
|
|
||||||
budgetName = budgetName || 'Test Budget';
|
|
||||||
id = testBudgetId || TEST_BUDGET_ID;
|
|
||||||
|
|
||||||
if (await fs.exists(fs.getBudgetDir(id))) {
|
|
||||||
await fs.removeDirRecursively(fs.getBudgetDir(id));
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
// Generate budget name if not given
|
|
||||||
if (!budgetName) {
|
|
||||||
budgetName = await uniqueBudgetName();
|
|
||||||
}
|
|
||||||
|
|
||||||
id = await idFromBudgetName(budgetName);
|
|
||||||
}
|
|
||||||
|
|
||||||
const budgetDir = fs.getBudgetDir(id);
|
|
||||||
await fs.mkdir(budgetDir);
|
|
||||||
|
|
||||||
// Create the initial database
|
|
||||||
await fs.copyFile(fs.bundledDatabasePath, fs.join(budgetDir, 'db.sqlite'));
|
|
||||||
|
|
||||||
// Create the initial prefs file
|
|
||||||
await fs.writeFile(
|
|
||||||
fs.join(budgetDir, 'metadata.json'),
|
|
||||||
JSON.stringify(prefs.getDefaultPrefs(id, budgetName)),
|
|
||||||
);
|
|
||||||
|
|
||||||
// Load it in
|
|
||||||
const { error } = await loadBudget(id);
|
|
||||||
if (error) {
|
|
||||||
console.log('Error creating budget: ' + error);
|
|
||||||
return { error };
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!avoidUpload && !testMode) {
|
|
||||||
try {
|
|
||||||
await cloudStorage.upload();
|
|
||||||
} catch (e) {
|
|
||||||
// Ignore any errors uploading. If they are offline they should
|
|
||||||
// still be able to create files.
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (testMode) {
|
|
||||||
await createTestBudget(handlers);
|
|
||||||
}
|
|
||||||
|
|
||||||
return {};
|
|
||||||
};
|
|
||||||
|
|
||||||
handlers['import-budget'] = async function ({ filepath, type }) {
|
|
||||||
try {
|
|
||||||
if (!(await fs.exists(filepath))) {
|
|
||||||
throw new Error(`File not found at the provided path: ${filepath}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
const buffer = Buffer.from(await fs.readFile(filepath, 'binary'));
|
|
||||||
const results = await handleBudgetImport(type, filepath, buffer);
|
|
||||||
return results || {};
|
|
||||||
} catch (err) {
|
|
||||||
err.message = 'Error importing budget: ' + err.message;
|
|
||||||
captureException(err);
|
|
||||||
return { error: 'internal-error' };
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
handlers['export-budget'] = async function () {
|
|
||||||
try {
|
|
||||||
return {
|
|
||||||
data: await cloudStorage.exportBuffer(),
|
|
||||||
};
|
|
||||||
} catch (err) {
|
|
||||||
err.message = 'Error exporting budget: ' + err.message;
|
|
||||||
captureException(err);
|
|
||||||
return { error: 'internal-error' };
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
handlers['enable-openid'] = async function (loginConfig) {
|
handlers['enable-openid'] = async function (loginConfig) {
|
||||||
try {
|
try {
|
||||||
const userToken = await asyncStorage.getItem('user-token');
|
const userToken = await asyncStorage.getItem('user-token');
|
||||||
@@ -1233,137 +540,6 @@ handlers['get-openid-config'] = async function () {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
async function loadBudget(id: string) {
|
|
||||||
let dir: string;
|
|
||||||
try {
|
|
||||||
dir = fs.getBudgetDir(id);
|
|
||||||
} catch (e) {
|
|
||||||
captureException(
|
|
||||||
new Error('`getBudgetDir` failed in `loadBudget`: ' + e.message),
|
|
||||||
);
|
|
||||||
return { error: 'budget-not-found' };
|
|
||||||
}
|
|
||||||
|
|
||||||
captureBreadcrumb({ message: 'Loading budget ' + dir });
|
|
||||||
|
|
||||||
if (!(await fs.exists(dir))) {
|
|
||||||
captureException(new Error('budget directory does not exist'));
|
|
||||||
return { error: 'budget-not-found' };
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
await prefs.loadPrefs(id);
|
|
||||||
await db.openDatabase(id);
|
|
||||||
} catch (e) {
|
|
||||||
captureBreadcrumb({ message: 'Error loading budget ' + id });
|
|
||||||
captureException(e);
|
|
||||||
await handlers['close-budget']();
|
|
||||||
return { error: 'opening-budget' };
|
|
||||||
}
|
|
||||||
|
|
||||||
// Older versions didn't tag the file with the current user, so do
|
|
||||||
// so now
|
|
||||||
if (!prefs.getPrefs().userId) {
|
|
||||||
const userId = await asyncStorage.getItem('user-token');
|
|
||||||
prefs.savePrefs({ userId });
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
await updateVersion();
|
|
||||||
} catch (e) {
|
|
||||||
console.warn('Error updating', e);
|
|
||||||
let result;
|
|
||||||
if (e.message.includes('out-of-sync-migrations')) {
|
|
||||||
result = { error: 'out-of-sync-migrations' };
|
|
||||||
} else if (e.message.includes('out-of-sync-data')) {
|
|
||||||
result = { error: 'out-of-sync-data' };
|
|
||||||
} else {
|
|
||||||
captureException(e);
|
|
||||||
logger.info('Error updating budget ' + id, e);
|
|
||||||
console.log('Error updating budget', e);
|
|
||||||
result = { error: 'loading-budget' };
|
|
||||||
}
|
|
||||||
|
|
||||||
await handlers['close-budget']();
|
|
||||||
return result;
|
|
||||||
}
|
|
||||||
|
|
||||||
await db.loadClock();
|
|
||||||
|
|
||||||
if (prefs.getPrefs().resetClock) {
|
|
||||||
// If we need to generate a fresh clock, we need to generate a new
|
|
||||||
// client id. This happens when the database is transferred to a
|
|
||||||
// new device.
|
|
||||||
//
|
|
||||||
// TODO: The client id should be stored elsewhere. It shouldn't
|
|
||||||
// work this way, but it's fine for now.
|
|
||||||
CRDT.getClock().timestamp.setNode(CRDT.makeClientId());
|
|
||||||
await db.runQuery(
|
|
||||||
'INSERT OR REPLACE INTO messages_clock (id, clock) VALUES (1, ?)',
|
|
||||||
[CRDT.serializeClock(CRDT.getClock())],
|
|
||||||
);
|
|
||||||
|
|
||||||
await prefs.savePrefs({ resetClock: false });
|
|
||||||
}
|
|
||||||
|
|
||||||
if (
|
|
||||||
!Platform.isWeb &&
|
|
||||||
!Platform.isMobile &&
|
|
||||||
process.env.NODE_ENV !== 'test'
|
|
||||||
) {
|
|
||||||
await startBackupService(id);
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
await sheet.loadSpreadsheet(db, onSheetChange);
|
|
||||||
} catch (e) {
|
|
||||||
captureException(e);
|
|
||||||
await handlers['close-budget']();
|
|
||||||
return { error: 'opening-budget' };
|
|
||||||
}
|
|
||||||
|
|
||||||
// This is a bit leaky, but we need to set the initial budget type
|
|
||||||
const { value: budgetType = 'rollover' } =
|
|
||||||
(await db.first<Pick<db.DbPreference, 'value'>>(
|
|
||||||
'SELECT value from preferences WHERE id = ?',
|
|
||||||
['budgetType'],
|
|
||||||
)) ?? {};
|
|
||||||
sheet.get().meta().budgetType = budgetType;
|
|
||||||
await budget.createAllBudgets();
|
|
||||||
|
|
||||||
// Load all the in-memory state
|
|
||||||
await mappings.loadMappings();
|
|
||||||
await rules.loadRules();
|
|
||||||
await syncMigrations.listen();
|
|
||||||
await app.startServices();
|
|
||||||
|
|
||||||
clearUndo();
|
|
||||||
|
|
||||||
// Ensure that syncing is enabled
|
|
||||||
if (process.env.NODE_ENV !== 'test') {
|
|
||||||
if (id === DEMO_BUDGET_ID) {
|
|
||||||
setSyncingMode('disabled');
|
|
||||||
} else {
|
|
||||||
if (getServer()) {
|
|
||||||
setSyncingMode('enabled');
|
|
||||||
} else {
|
|
||||||
setSyncingMode('disabled');
|
|
||||||
}
|
|
||||||
|
|
||||||
await asyncStorage.setItem('lastBudget', id);
|
|
||||||
|
|
||||||
// Only upload periodically on desktop
|
|
||||||
if (!Platform.isMobile) {
|
|
||||||
await cloudStorage.possiblyUpload();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
app.events.emit('load-budget', { id });
|
|
||||||
|
|
||||||
return {};
|
|
||||||
}
|
|
||||||
|
|
||||||
handlers['upload-file-web'] = async function ({ filename, contents }) {
|
handlers['upload-file-web'] = async function ({ filename, contents }) {
|
||||||
if (!Platform.isWeb) {
|
if (!Platform.isWeb) {
|
||||||
return null;
|
return null;
|
||||||
|
|||||||
@@ -43,6 +43,7 @@ export class Spreadsheet {
|
|||||||
this.events = mitt();
|
this.events = mitt();
|
||||||
this._meta = {
|
this._meta = {
|
||||||
createdMonths: new Set(),
|
createdMonths: new Set(),
|
||||||
|
budgetType: undefined,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,12 +1,18 @@
|
|||||||
// @ts-strict-ignore
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
import { type HandlerFunctions } from '../types/handlers';
|
type AnyFunction = (...args: any[]) => any;
|
||||||
|
|
||||||
export function sequential<T extends HandlerFunctions>(
|
export function sequential<T extends AnyFunction>(
|
||||||
fn: T,
|
fn: T,
|
||||||
): (...args: Parameters<T>) => Promise<Awaited<ReturnType<T>>> {
|
): (...args: Parameters<T>) => Promise<Awaited<ReturnType<T>>> {
|
||||||
const sequenceState: {
|
const sequenceState: {
|
||||||
running: Promise<Awaited<ReturnType<T>>> | null;
|
running: Promise<Awaited<ReturnType<T>>> | null;
|
||||||
queue: Array<{ args: Parameters<T>; resolve; reject }>;
|
queue: Array<{
|
||||||
|
args: Parameters<T>;
|
||||||
|
resolve: (
|
||||||
|
value: Awaited<ReturnType<T>> | PromiseLike<Awaited<ReturnType<T>>>,
|
||||||
|
) => void;
|
||||||
|
reject: (reason?: unknown) => void;
|
||||||
|
}>;
|
||||||
} = {
|
} = {
|
||||||
running: null,
|
running: null,
|
||||||
queue: [],
|
queue: [],
|
||||||
@@ -21,13 +27,19 @@ export function sequential<T extends HandlerFunctions>(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function run(args: Parameters<T>, resolve, reject) {
|
function run(
|
||||||
|
args: Parameters<T>,
|
||||||
|
resolve: (
|
||||||
|
value: Awaited<ReturnType<T>> | PromiseLike<Awaited<ReturnType<T>>>,
|
||||||
|
) => void,
|
||||||
|
reject: (reason?: unknown) => void,
|
||||||
|
) {
|
||||||
sequenceState.running = fn.apply(null, args).then(
|
sequenceState.running = fn.apply(null, args).then(
|
||||||
val => {
|
(val: Awaited<ReturnType<T>> | PromiseLike<Awaited<ReturnType<T>>>) => {
|
||||||
pump();
|
pump();
|
||||||
resolve(val);
|
resolve(val);
|
||||||
},
|
},
|
||||||
err => {
|
(err: unknown) => {
|
||||||
pump();
|
pump();
|
||||||
reject(err);
|
reject(err);
|
||||||
},
|
},
|
||||||
@@ -47,7 +59,7 @@ export function sequential<T extends HandlerFunctions>(
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
export function once<T extends HandlerFunctions>(
|
export function once<T extends AnyFunction>(
|
||||||
fn: T,
|
fn: T,
|
||||||
): (...args: Parameters<T>) => Promise<Awaited<ReturnType<T>>> | null {
|
): (...args: Parameters<T>) => Promise<Awaited<ReturnType<T>>> | null {
|
||||||
let promise: Promise<Awaited<ReturnType<T>>> | null = null;
|
let promise: Promise<Awaited<ReturnType<T>>> | null = null;
|
||||||
|
|||||||
@@ -40,7 +40,15 @@ export function getUploadError({
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export function getDownloadError({ reason, meta, fileName }) {
|
export function getDownloadError({
|
||||||
|
reason,
|
||||||
|
meta,
|
||||||
|
fileName,
|
||||||
|
}: {
|
||||||
|
reason: string;
|
||||||
|
meta?: Record<string, unknown>;
|
||||||
|
fileName?: string;
|
||||||
|
}) {
|
||||||
switch (reason) {
|
switch (reason) {
|
||||||
case 'network':
|
case 'network':
|
||||||
case 'download-failure':
|
case 'download-failure':
|
||||||
|
|||||||
2
packages/loot-core/src/types/handlers.d.ts
vendored
2
packages/loot-core/src/types/handlers.d.ts
vendored
@@ -1,6 +1,6 @@
|
|||||||
import type { AccountHandlers } from '../server/accounts/app';
|
import type { AccountHandlers } from '../server/accounts/app';
|
||||||
import type { AdminHandlers } from '../server/admin/types/handlers';
|
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 { DashboardHandlers } from '../server/dashboard/types/handlers';
|
||||||
import type { FiltersHandlers } from '../server/filters/types/handlers';
|
import type { FiltersHandlers } from '../server/filters/types/handlers';
|
||||||
import type { NotesHandlers } from '../server/notes/types/handlers';
|
import type { NotesHandlers } from '../server/notes/types/handlers';
|
||||||
|
|||||||
110
packages/loot-core/src/types/server-handlers.d.ts
vendored
110
packages/loot-core/src/types/server-handlers.d.ts
vendored
@@ -3,8 +3,6 @@ import { RemoteFile } from '../server/cloud-storage';
|
|||||||
import { Node as SpreadsheetNode } from '../server/spreadsheet/spreadsheet';
|
import { Node as SpreadsheetNode } from '../server/spreadsheet/spreadsheet';
|
||||||
import { Message } from '../server/sync';
|
import { Message } from '../server/sync';
|
||||||
|
|
||||||
import { Budget } from './budget';
|
|
||||||
import { CategoryEntity, CategoryGroupEntity } from './models';
|
|
||||||
import { OpenIdConfig } from './models/openid';
|
import { OpenIdConfig } from './models/openid';
|
||||||
// eslint-disable-next-line import/no-unresolved
|
// eslint-disable-next-line import/no-unresolved
|
||||||
import { Query } from './query';
|
import { Query } from './query';
|
||||||
@@ -14,56 +12,8 @@ export interface ServerHandlers {
|
|||||||
undo: () => Promise<void>;
|
undo: () => Promise<void>;
|
||||||
redo: () => Promise<void>;
|
redo: () => Promise<void>;
|
||||||
|
|
||||||
'get-categories': () => Promise<{
|
|
||||||
grouped: Array<CategoryGroupEntity>;
|
|
||||||
list: Array<CategoryEntity>;
|
|
||||||
}>;
|
|
||||||
|
|
||||||
'get-earliest-transaction': () => Promise<{ date: string }>;
|
'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: {
|
'make-filters-from-conditions': (arg: {
|
||||||
conditions: unknown;
|
conditions: unknown;
|
||||||
applySpecialCases?: boolean;
|
applySpecialCases?: boolean;
|
||||||
@@ -178,70 +128,10 @@ export interface ServerHandlers {
|
|||||||
| { messages: Message[] }
|
| { messages: Message[] }
|
||||||
>;
|
>;
|
||||||
|
|
||||||
'validate-budget-name': (arg: {
|
|
||||||
name: string;
|
|
||||||
}) => Promise<{ valid: boolean; message?: string }>;
|
|
||||||
|
|
||||||
'unique-budget-name': (arg: { name: string }) => Promise<string>;
|
|
||||||
|
|
||||||
'get-budgets': () => Promise<Budget[]>;
|
|
||||||
|
|
||||||
'get-remote-files': () => Promise<RemoteFile[]>;
|
'get-remote-files': () => Promise<RemoteFile[]>;
|
||||||
|
|
||||||
'get-user-file-info': (fileId: string) => Promise<RemoteFile | null>;
|
'get-user-file-info': (fileId: string) => Promise<RemoteFile | null>;
|
||||||
|
|
||||||
'reset-budget-cache': () => Promise<unknown>;
|
|
||||||
|
|
||||||
'upload-budget': (arg: { id }) => Promise<{ error?: string }>;
|
|
||||||
|
|
||||||
'download-budget': (arg: { fileId; replace? }) => Promise<{ error; id }>;
|
|
||||||
|
|
||||||
'sync-budget': () => Promise<{
|
|
||||||
error?: { message: string; reason: string; meta: unknown };
|
|
||||||
}>;
|
|
||||||
|
|
||||||
'load-budget': (arg: { id: string }) => Promise<{ error }>;
|
|
||||||
|
|
||||||
'create-demo-budget': () => Promise<unknown>;
|
|
||||||
|
|
||||||
'close-budget': () => Promise<'ok'>;
|
|
||||||
|
|
||||||
'delete-budget': (arg: {
|
|
||||||
id?: string | undefined;
|
|
||||||
cloudFileId?: string | undefined;
|
|
||||||
}) => Promise<'ok' | 'fail'>;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Duplicates a budget file.
|
|
||||||
* @param {Object} arg - The arguments for duplicating a budget.
|
|
||||||
* @param {string} [arg.id] - The ID of the local budget to duplicate.
|
|
||||||
* @param {string} [arg.cloudId] - The ID of the cloud-synced budget to duplicate.
|
|
||||||
* @param {string} arg.newName - The name for the duplicated budget.
|
|
||||||
* @param {boolean} [arg.cloudSync] - Whether to sync the duplicated budget to the cloud.
|
|
||||||
* @returns {Promise<string>} The ID of the newly created budget.
|
|
||||||
*/
|
|
||||||
'duplicate-budget': (arg: {
|
|
||||||
id?: string | undefined;
|
|
||||||
cloudId?: string | undefined;
|
|
||||||
newName: string;
|
|
||||||
cloudSync?: boolean;
|
|
||||||
open: 'none' | 'original' | 'copy';
|
|
||||||
}) => Promise<string>;
|
|
||||||
|
|
||||||
'create-budget': (arg: {
|
|
||||||
budgetName?;
|
|
||||||
avoidUpload?;
|
|
||||||
testMode?: boolean;
|
|
||||||
testBudgetId?;
|
|
||||||
}) => Promise<unknown>;
|
|
||||||
|
|
||||||
'import-budget': (arg: {
|
|
||||||
filepath: string;
|
|
||||||
type: 'ynab4' | 'ynab5' | 'actual';
|
|
||||||
}) => Promise<{ error?: string }>;
|
|
||||||
|
|
||||||
'export-budget': () => Promise<{ data: Buffer } | { error: string }>;
|
|
||||||
|
|
||||||
'upload-file-web': (arg: {
|
'upload-file-web': (arg: {
|
||||||
filename: string;
|
filename: string;
|
||||||
contents: ArrayBuffer;
|
contents: ArrayBuffer;
|
||||||
|
|||||||
6
upcoming-release-notes/4419.md
Normal file
6
upcoming-release-notes/4419.md
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
---
|
||||||
|
category: Maintenance
|
||||||
|
authors: [joel-jeremy]
|
||||||
|
---
|
||||||
|
|
||||||
|
Extract budget related server handlers from main.ts to server/budget/app.ts
|
||||||
Reference in New Issue
Block a user