Introduction of APIs to handle Schedule + a bit more. (#5584)

* Introduction of APIs to handle Schedule + a bit more. Refer to updated API documentation PR2811 on documentation.

* Fixed lint Error

* Removed unused declarations

* Fixed Bug in Test module

* Avoiding type Coercion fixes and direct assignment on conditions array.

* Lint Error Fixes

* more issues fixed

* lint errors

* Remove with Mutation in Get function. Fixed quotes. updated getIDyName for error handling

* More lint errors

* Minor final fixes.

* One type coercion removed

* One type coercion removed

* Revert back to original working code

* Added Account testing for both get by ID and updating schedules

* [autofix.ci] apply automated fixes

* Added Payee tests as well

* Payee Tests

* [autofix.ci] apply automated fixes

* Optimized condition checking at the beginning of the code and avoid ambiguity in case of corrupt schedule

* Mode debug infor on error in testing

* better bug tracking

* //more trouble shooting

* Bug fixed

* [autofix.ci] apply automated fixes

* Minor mofication to satisfy code rabbit. We should be ready for review.

* [autofix.ci] apply automated fixes

* Removing type coercion from the model

* [autofix.ci] apply automated fixes

* fixed compilation error

* Fixed new bugs

---------

Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>
This commit is contained in:
Karim Kodera
2025-09-08 11:03:11 +03:00
committed by GitHub
parent b399f290a6
commit a18a05f55a
6 changed files with 437 additions and 1 deletions

View File

@@ -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 }),
]),
);
});

View File

@@ -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');
}

View File

@@ -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<ScheduleEntity, 'id' | 'name'> & {
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;
},
};

View File

@@ -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;

View File

@@ -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<RuleEntity>;
'api/rule-delete': (id: string) => Promise<boolean>;
'api/schedule-create': (
schedule: APIScheduleEntity,
) => Promise<ScheduleEntity['id']>;
'api/schedule-update': (arg: {
id: ScheduleEntity['id'];
fields: Partial<APIScheduleEntity>;
resetNextDate?: boolean;
}) => Promise<ScheduleEntity['id']>;
'api/schedule-delete': (id: string) => Promise<void>;
'api/schedules-get': () => Promise<APIScheduleEntity[]>;
'api/get-id-by-name': (arg: {
type: string;
name: string;
}) => Promise<string>;
'api/get-server-version': () => Promise<
{ error?: string } | { version: string }
>;
}

View File

@@ -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.