diff --git a/packages/api/methods.test.ts b/packages/api/methods.test.ts index b40657aa9d..b6fce1dca5 100644 --- a/packages/api/methods.test.ts +++ b/packages/api/methods.test.ts @@ -740,3 +740,122 @@ describe('API CRUD operations', () => { expect(transactions[0].notes).toBeNull(); }); }); + +//apis: createSchedule, getSchedules, updateSchedule, deleteSchedule +test('Schedules: successfully complete schedules operations', async () => { + await api.loadBudget(budgetName); + //test a schedule with a recuring configuration + const ScheduleId1 = await api.createSchedule({ + name: 'test-schedule 1', + posts_transaction: true, + // amount: -5000, + amountOp: 'is', + date: { + frequency: 'monthly', + interval: 1, + start: '2025-06-13', + patterns: [], + skipWeekend: false, + weekendSolveMode: 'after', + endMode: 'never', + }, + }); + //test the creation of non recurring schedule + const ScheduleId2 = await api.createSchedule({ + name: 'test-schedule 2', + posts_transaction: false, + amount: 4000, + amountOp: 'is', + date: '2025-06-13', + }); + let schedules = await api.getSchedules(); + + // Schedules successfully created + expect(schedules).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + name: 'test-schedule 1', + posts_transaction: true, + // amount: -5000, + amountOp: 'is', + date: { + frequency: 'monthly', + interval: 1, + start: '2025-06-13', + patterns: [], + skipWeekend: false, + weekendSolveMode: 'after', + endMode: 'never', + }, + }), + expect.objectContaining({ + name: 'test-schedule 2', + posts_transaction: false, + amount: 4000, + amountOp: 'is', + date: '2025-06-13', + }), + ]), + ); + //check getIDByName works on schedules + expect(await api.getIDByName('schedules', 'test-schedule 1')).toEqual( + ScheduleId1, + ); + expect(await api.getIDByName('schedules', 'test-schedule 2')).toEqual( + ScheduleId2, + ); + + //check getIDByName works on accounts + const schedAccountId1 = await api.createAccount( + { name: 'sched-test-account1', offbudget: true }, + 1000, + ); + + expect(await api.getIDByName('accounts', 'sched-test-account1')).toEqual( + schedAccountId1, + ); + + //check getIDByName works on payees + const schedPayeeId1 = await api.createPayee({ name: 'sched-test-payee1' }); + + expect(await api.getIDByName('payees', 'sched-test-payee1')).toEqual( + schedPayeeId1, + ); + await api.updateSchedule(ScheduleId1, { + amount: -10000, + account: schedAccountId1, + }); + await api.deleteSchedule(ScheduleId2); + + // schedules successfully updated, and one of them deleted + await api.updateSchedule(ScheduleId1, { + amount: -10000, + account: schedAccountId1, + payee: schedPayeeId1, + }); + await api.deleteSchedule(ScheduleId2); + + schedules = await api.getSchedules(); + expect(schedules).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + id: ScheduleId1, + posts_transaction: true, + amount: -10000, + account: schedAccountId1, + payee: schedPayeeId1, + amountOp: 'is', + date: { + frequency: 'monthly', + interval: 1, + start: '2025-06-13', + patterns: [], + skipWeekend: false, + weekendSolveMode: 'after', + endMode: 'never', + }, + }), + expect.not.objectContaining({ id: ScheduleId2 }), + ]), + ); +}); diff --git a/packages/api/methods.ts b/packages/api/methods.ts index 738bd7c0d2..e65e11ddf7 100644 --- a/packages/api/methods.ts +++ b/packages/api/methods.ts @@ -239,3 +239,31 @@ export function holdBudgetForNextMonth(month, amount) { export function resetBudgetHold(month) { return send('api/budget-reset-hold', { month }); } + +export function createSchedule(schedule) { + return send('api/schedule-create', schedule); +} + +export function updateSchedule(id, fields, resetNextDate?: boolean) { + return send('api/schedule-update', { + id, + fields, + resetNextDate, + }); +} + +export function deleteSchedule(scheduleId) { + return send('api/schedule-delete', scheduleId); +} + +export function getSchedules() { + return send('api/schedules-get'); +} + +export function getIDByName(type, name) { + return send('api/get-id-by-name', { type, name }); +} + +export function getServerVersion() { + return send('api/get-server-version'); +} diff --git a/packages/loot-core/src/server/api-models.ts b/packages/loot-core/src/server/api-models.ts index 5030cf0f09..ee37c13a35 100644 --- a/packages/loot-core/src/server/api-models.ts +++ b/packages/loot-core/src/server/api-models.ts @@ -4,6 +4,8 @@ import type { CategoryEntity, CategoryGroupEntity, PayeeEntity, + ScheduleEntity, + RecurConfig, } from '../types/models'; import { RemoteFile } from './cloud-storage'; @@ -153,3 +155,62 @@ export const budgetModel = { return file as Budget; }, }; + +export type AmountOPType = 'is' | 'isapprox' | 'isbetween'; + +export type APIScheduleEntity = Pick & { + rule?: string; //All schedules has an associated underlying rule. not to be supplied iwth a new schedule + next_date?: string; //Next occurence of a schedule. not to be supplied iwth a new schedule + completed?: boolean; //not to be supplied iwth a new schedule + posts_transaction: boolean; //Whethere the schedule should auto post transaction on your behalf. Default to false + payee?: string | null; // Optional will default to null + account?: string | null; // Optional will default to null + amount: number | { num1: number; num2: number }; // Provide only 1 number except if the Amount + amountOp: AmountOPType; // 'is' | 'isapprox' | 'isbetween' + date: RecurConfig; // mandatory field in creating a schedule Mandatory field in creation +}; + +export const scheduleModel = { + toExternal(schedule: ScheduleEntity): APIScheduleEntity { + return { + id: schedule.id, + name: schedule.name, + rule: schedule.rule, + next_date: schedule.next_date, + completed: schedule.completed, + posts_transaction: schedule.posts_transaction, + payee: schedule._payee, + account: schedule._account, + amount: schedule._amount, + amountOp: schedule._amountOp as 'is' | 'isapprox' | 'isbetween', // e.g. 'isapprox', 'is', etc. + date: schedule._date, + }; + }, + //just an update + + fromExternal(schedule: APIScheduleEntity): ScheduleEntity { + const result: ScheduleEntity = { + id: schedule.id, + name: schedule.name, + rule: String(schedule.rule), + next_date: String(schedule.next_date), + completed: Boolean(schedule.completed), + posts_transaction: schedule.posts_transaction, + tombstone: false, + _payee: String(schedule.payee), + _account: String(schedule.account), + _amount: schedule.amount, + _amountOp: schedule.amountOp, // e.g. 'isapprox', 'is', etc. + _date: schedule.date, + _conditions: [ + { op: 'is', field: 'payee', value: String(schedule.payee) }, + { op: 'is', field: 'account', value: String(schedule.account) }, + { op: 'isapprox', field: 'date', value: schedule.date }, + { op: schedule.amountOp, field: 'amount', value: schedule.amount }, + ], + _actions: [], // empty array, as you requested + }; + + return result; + }, +}; diff --git a/packages/loot-core/src/server/api.ts b/packages/loot-core/src/server/api.ts index 9b3b58da71..3c12287408 100644 --- a/packages/loot-core/src/server/api.ts +++ b/packages/loot-core/src/server/api.ts @@ -17,7 +17,11 @@ import { } from '../shared/transactions'; import { integerToAmount } from '../shared/util'; import { Handlers } from '../types/handlers'; -import { AccountEntity, CategoryGroupEntity } from '../types/models'; +import { + AccountEntity, + CategoryGroupEntity, + ScheduleEntity, +} from '../types/models'; import { ServerHandlers } from '../types/server-handlers'; import { addTransactions } from './accounts/sync'; @@ -28,6 +32,9 @@ import { categoryGroupModel, payeeModel, remoteFileModel, + scheduleModel, + APIScheduleEntity, + AmountOPType, } from './api-models'; import { aqlQuery } from './aql'; import * as cloudStorage from './cloud-storage'; @@ -768,6 +775,198 @@ handlers['api/rule-delete'] = withMutation(async function (id) { return handlers['rule-delete'](id); }); +handlers['api/schedules-get'] = async function () { + checkFileOpen(); + const { data } = await aqlQuery(q('schedules').select('*')); + const schedules = data as ScheduleEntity[]; + return schedules.map(schedule => scheduleModel.toExternal(schedule)); +}; + +handlers['api/schedule-create'] = withMutation(async function ( + schedule: APIScheduleEntity, +) { + checkFileOpen(); + const internalSchedule = scheduleModel.fromExternal(schedule); + const partialSchedule = { + name: internalSchedule.name, + posts_transaction: internalSchedule.posts_transaction, + }; + return handlers['schedule/create']({ + schedule: partialSchedule, + conditions: internalSchedule._conditions, + }); +}); + +handlers['api/schedule-update'] = withMutation(async function ({ + id, + fields, + resetNextDate, +}) { + checkFileOpen(); + const { data } = await aqlQuery(q('schedules').filter({ id }).select('*')); + if (!data || data.length === 0) { + throw APIError(`Schedule ${id} not found`); + } + + const sched = data[0] as ScheduleEntity; + let conditionsUpdated = false; + // Find all indices to avoid direct assignment + const payeeIndex = sched._conditions.findIndex(c => c.field === 'payee'); + const accountIndex = sched._conditions.findIndex(c => c.field === 'account'); + const dateIndex = sched._conditions.findIndex(c => c.field === 'date'); + const amountIndex = sched._conditions.findIndex(c => c.field === 'amount'); + + for (const key in fields) { + const typedKey = key as keyof APIScheduleEntity; + const value = fields[typedKey]; + + switch (typedKey) { + case 'name': { + const newName = String(value); + const { data: existing } = await aqlQuery( + q('schedules').filter({ name: newName }).select('*'), + ); + if (!existing || existing.length === 0 || existing[0].id === sched.id) { + sched.name = newName; + conditionsUpdated = true; + } else { + throw APIError(`There is already a schedule named: ${newName}`); + } + break; + } + case 'next_date': + case 'completed': { + throw APIError( + `Field ${typedKey} is system-managed and not user-editable.`, + ); + } + case 'posts_transaction': { + sched.posts_transaction = Boolean(value); + conditionsUpdated = true; + break; + } + case 'payee': { + if (payeeIndex !== -1) { + sched._conditions[payeeIndex].value = value; + conditionsUpdated = true; + } else { + sched._conditions.push({ + field: 'payee', + op: 'is', + value: String(value), + }); + conditionsUpdated = true; + } + break; + } + case 'account': { + if (accountIndex !== -1) { + sched._conditions[accountIndex].value = value; + conditionsUpdated = true; + } else { + sched._conditions.push({ + field: 'account', + op: 'is', + value: String(value), + }); + conditionsUpdated = true; + } + break; + } + case 'amountOp': { + if (amountIndex !== -1) { + let convertedOp: AmountOPType; + switch (value) { + case 'is': + convertedOp = 'is'; + break; + case 'isapprox': + convertedOp = 'isapprox'; + break; + case 'isbetween': + convertedOp = 'isbetween'; + break; + default: + throw APIError( + `Invalid amount operator: ${value}. Expected: is, isapprox, or isbetween`, + ); + } + sched._conditions[amountIndex].op = convertedOp; + conditionsUpdated = true; + } else { + throw APIError(`Ammount can not be found. There is a bug here`); + } + break; + } + case 'amount': { + if (amountIndex !== -1) { + sched._conditions[amountIndex].value = value; + conditionsUpdated = true; + } else { + throw APIError(`Ammount can not be found. There is a bug here`); + } + break; + } + case 'date': { + if (dateIndex !== -1) { + sched._conditions[dateIndex].value = value; + conditionsUpdated = true; + } else { + throw APIError( + `Date can not be found. Schedules can not be created without a date there is a bug here`, + ); + } + break; + } + default: { + throw APIError(`Unhandled field: ${typedKey}`); + } + } + } + + if (conditionsUpdated) { + return handlers['schedule/update']({ + schedule: { + id: sched.id, + posts_transaction: sched.posts_transaction, + name: sched.name, + }, + conditions: sched._conditions, + resetNextDate, + }); + } else { + return sched.id; + } +}); + +handlers['api/schedule-delete'] = withMutation(async function (id: string) { + checkFileOpen(); + return handlers['schedule/delete']({ id }); +}); + +handlers['api/get-id-by-name'] = async function ({ type, name }) { + checkFileOpen(); + + const allowedTypes = ['payees', 'categories', 'schedules', 'accounts']; + + if (!allowedTypes.includes(type)) { + throw APIError('Provide a valid type'); + } + + const { data } = await aqlQuery(q(type).filter({ name }).select('*')); + + if (!data || data.length === 0) { + throw APIError(`Not found: ${type} with name ${name}`); + } + + return data[0].id; +}; + +handlers['api/get-server-version'] = async function () { + checkFileOpen(); + return handlers['get-server-version'](); +}; + export function installAPI(serverHandlers: ServerHandlers) { const merged = Object.assign({}, serverHandlers, handlers); handlers = merged as Handlers; diff --git a/packages/loot-core/src/types/api-handlers.ts b/packages/loot-core/src/types/api-handlers.ts index 5b3bba34c5..36a0f28243 100644 --- a/packages/loot-core/src/types/api-handlers.ts +++ b/packages/loot-core/src/types/api-handlers.ts @@ -8,6 +8,7 @@ import type { APICategoryGroupEntity, APIFileEntity, APIPayeeEntity, + APIScheduleEntity, } from '../server/api-models'; import { BudgetFileHandlers } from '../server/budgetfiles/app'; import { type batchUpdateTransactions } from '../server/transactions'; @@ -17,6 +18,7 @@ import type { NewRuleEntity, RuleEntity, TransactionEntity, + ScheduleEntity, } from './models'; export interface ApiHandlers { @@ -188,4 +190,25 @@ export interface ApiHandlers { 'api/rule-update': (arg: { rule: RuleEntity }) => Promise; 'api/rule-delete': (id: string) => Promise; + + 'api/schedule-create': ( + schedule: APIScheduleEntity, + ) => Promise; + + 'api/schedule-update': (arg: { + id: ScheduleEntity['id']; + fields: Partial; + resetNextDate?: boolean; + }) => Promise; + + 'api/schedule-delete': (id: string) => Promise; + + 'api/schedules-get': () => Promise; + 'api/get-id-by-name': (arg: { + type: string; + name: string; + }) => Promise; + 'api/get-server-version': () => Promise< + { error?: string } | { version: string } + >; } diff --git a/upcoming-release-notes/5584.md b/upcoming-release-notes/5584.md new file mode 100644 index 0000000000..0036d4ea24 --- /dev/null +++ b/upcoming-release-notes/5584.md @@ -0,0 +1,6 @@ +--- +category: Enhancements +authors: [karimkodera] +--- + +Introduction of APIs to handle Schedule + a bit more. Refer to updated API documentation PR2811 on documentation.