mirror of
https://github.com/actualbudget/actual.git
synced 2026-03-11 12:43:09 -05:00
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:
@@ -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 }),
|
||||
]),
|
||||
);
|
||||
});
|
||||
|
||||
@@ -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');
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
},
|
||||
};
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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 }
|
||||
>;
|
||||
}
|
||||
|
||||
6
upcoming-release-notes/5584.md
Normal file
6
upcoming-release-notes/5584.md
Normal 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.
|
||||
Reference in New Issue
Block a user