diff --git a/packages/api/methods.test.ts b/packages/api/methods.test.ts index ff8830010d2debac89e3325a270402b5f69ef5e4..7d14d596875ba418b2dc04c97eb34c7d88ff383c 100644 --- a/packages/api/methods.test.ts +++ b/packages/api/methods.test.ts @@ -346,6 +346,214 @@ describe('API CRUD operations', () => { ); }); + // apis: getRules, getPayeeRules, createRule, updateRule, deleteRule + test('Rules: successfully update rules', async () => { + await api.createPayee({ name: 'test-payee' }); + await api.createPayee({ name: 'test-payee2' }); + + // create our test rules + const rule = await api.createRule({ + stage: 'pre', + conditionsOp: 'and', + conditions: [ + { + field: 'payee', + op: 'is', + value: 'test-payee', + }, + ], + actions: [ + { + op: 'set', + field: 'category', + value: 'fc3825fd-b982-4b72-b768-5b30844cf832', + }, + ], + }); + const rule2 = await api.createRule({ + stage: 'pre', + conditionsOp: 'and', + conditions: [ + { + field: 'payee', + op: 'is', + value: 'test-payee2', + }, + ], + actions: [ + { + op: 'set', + field: 'category', + value: 'fc3825fd-b982-4b72-b768-5b30844cf832', + }, + ], + }); + + // get existing rules + const rules = await api.getRules(); + expect(rules).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + actions: expect.arrayContaining([ + expect.objectContaining({ + field: 'category', + op: 'set', + type: 'id', + value: 'fc3825fd-b982-4b72-b768-5b30844cf832', + }), + ]), + conditions: expect.arrayContaining([ + expect.objectContaining({ + field: 'payee', + op: 'is', + type: 'id', + value: 'test-payee2', + }), + ]), + conditionsOp: 'and', + id: rule2.id, + stage: 'pre', + }), + expect.objectContaining({ + actions: expect.arrayContaining([ + expect.objectContaining({ + field: 'category', + op: 'set', + type: 'id', + value: 'fc3825fd-b982-4b72-b768-5b30844cf832', + }), + ]), + conditions: expect.arrayContaining([ + expect.objectContaining({ + field: 'payee', + op: 'is', + type: 'id', + value: 'test-payee', + }), + ]), + conditionsOp: 'and', + id: rule.id, + stage: 'pre', + }), + ]), + ); + + // get by payee + expect(await api.getPayeeRules('test-payee')).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + actions: expect.arrayContaining([ + expect.objectContaining({ + field: 'category', + op: 'set', + type: 'id', + value: 'fc3825fd-b982-4b72-b768-5b30844cf832', + }), + ]), + conditions: expect.arrayContaining([ + expect.objectContaining({ + field: 'payee', + op: 'is', + type: 'id', + value: 'test-payee', + }), + ]), + conditionsOp: 'and', + id: rule.id, + stage: 'pre', + }), + ]), + ); + + expect(await api.getPayeeRules('test-payee2')).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + actions: expect.arrayContaining([ + expect.objectContaining({ + field: 'category', + op: 'set', + type: 'id', + value: 'fc3825fd-b982-4b72-b768-5b30844cf832', + }), + ]), + conditions: expect.arrayContaining([ + expect.objectContaining({ + field: 'payee', + op: 'is', + type: 'id', + value: 'test-payee2', + }), + ]), + conditionsOp: 'and', + id: rule2.id, + stage: 'pre', + }), + ]), + ); + + // update one rule + const updatedRule = { + ...rule, + stage: 'post', + conditionsOp: 'or', + }; + expect(await api.updateRule(updatedRule)).toEqual(updatedRule); + + expect(await api.getRules()).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + actions: expect.arrayContaining([ + expect.objectContaining({ + field: 'category', + op: 'set', + type: 'id', + value: 'fc3825fd-b982-4b72-b768-5b30844cf832', + }), + ]), + conditions: expect.arrayContaining([ + expect.objectContaining({ + field: 'payee', + op: 'is', + type: 'id', + value: 'test-payee', + }), + ]), + conditionsOp: 'or', + id: rule.id, + stage: 'post', + }), + expect.objectContaining({ + actions: expect.arrayContaining([ + expect.objectContaining({ + field: 'category', + op: 'set', + type: 'id', + value: 'fc3825fd-b982-4b72-b768-5b30844cf832', + }), + ]), + conditions: expect.arrayContaining([ + expect.objectContaining({ + field: 'payee', + op: 'is', + type: 'id', + value: 'test-payee2', + }), + ]), + conditionsOp: 'and', + id: rule2.id, + stage: 'pre', + }), + ]), + ); + + // delete rules + await api.deleteRule(rules[1]); + expect(await api.getRules()).toHaveLength(1); + + await api.deleteRule(rules[0]); + expect(await api.getRules()).toHaveLength(0); + }); + // apis: addTransactions, getTransactions, importTransactions, updateTransaction, deleteTransaction test('Transactions: successfully update transactions', async () => { const accountId = await api.createAccount({ name: 'test-account' }, 0); diff --git a/packages/api/methods.ts b/packages/api/methods.ts index cf8a6aa46257bfd7daf7b1589c0bbd21c169f6c6..bc69223e2672ee0dfd67437d0101ac2fa51a5c7a 100644 --- a/packages/api/methods.ts +++ b/packages/api/methods.ts @@ -168,3 +168,23 @@ export function updatePayee(id, fields) { export function deletePayee(id) { return send('api/payee-delete', { id }); } + +export function getRules() { + return send('api/rules-get'); +} + +export function getPayeeRules(id) { + return send('api/payee-rules-get', { id }); +} + +export function createRule(rule) { + return send('api/rule-create', { rule }); +} + +export function updateRule(rule) { + return send('api/rule-update', { rule }); +} + +export function deleteRule(id) { + return send('api/rule-delete', { id }); +} diff --git a/packages/desktop-client/src/components/ManageRules.tsx b/packages/desktop-client/src/components/ManageRules.tsx index d48c6a080405b8cfcbe3cb73413c74606015b473..65131e41aa40bbb83b98fcd7a820eccc3c8adb21 100644 --- a/packages/desktop-client/src/components/ManageRules.tsx +++ b/packages/desktop-client/src/components/ManageRules.tsx @@ -15,7 +15,7 @@ import { send } from 'loot-core/src/platform/client/fetch'; import * as undo from 'loot-core/src/platform/client/undo'; import { mapField, friendlyOp } from 'loot-core/src/shared/rules'; import { describeSchedule } from 'loot-core/src/shared/schedules'; -import { type RuleEntity } from 'loot-core/src/types/models'; +import { type NewRuleEntity } from 'loot-core/src/types/models'; import { useAccounts } from '../hooks/useAccounts'; import { useCategories } from '../hooks/useCategories'; @@ -210,7 +210,7 @@ function ManageRulesContent({ }, []); function onCreateRule() { - const rule: RuleEntity = { + const rule: NewRuleEntity = { stage: null, conditionsOp: 'and', conditions: [ diff --git a/packages/loot-core/src/client/state-types/modals.d.ts b/packages/loot-core/src/client/state-types/modals.d.ts index f3112e48973ca440f46ae7c3c504444bfb0740fc..4f0b190dcabdae9de61f040bb87d9304eb490ba7 100644 --- a/packages/loot-core/src/client/state-types/modals.d.ts +++ b/packages/loot-core/src/client/state-types/modals.d.ts @@ -5,7 +5,7 @@ import type { CategoryGroupEntity, GoCardlessToken, } from '../../types/models'; -import type { RuleEntity } from '../../types/models/rule'; +import type { NewRuleEntity, RuleEntity } from '../../types/models/rule'; import type { EmptyObject, StripNever } from '../../types/util'; import type * as constants from '../constants'; export type ModalType = keyof FinanceModals; @@ -51,7 +51,7 @@ type FinanceModals = { 'manage-rules': { payeeId?: string }; 'edit-rule': { - rule: RuleEntity; + rule: RuleEntity | NewRuleEntity; onSave: (rule: RuleEntity) => void; }; 'merge-unused-payees': { diff --git a/packages/loot-core/src/server/api.ts b/packages/loot-core/src/server/api.ts index 13c731235a0b59e1546524a2e35e5c940099a361..d66c5071d5dd7058096823a69dfcfffc502f02d8 100644 --- a/packages/loot-core/src/server/api.ts +++ b/packages/loot-core/src/server/api.ts @@ -600,6 +600,43 @@ handlers['api/payee-delete'] = withMutation(async function ({ id }) { return handlers['payees-batch-change']({ deleted: [{ id }] }); }); +handlers['api/rules-get'] = async function () { + checkFileOpen(); + return handlers['rules-get'](); +}; + +handlers['api/payee-rules-get'] = async function ({ id }) { + checkFileOpen(); + return handlers['payees-get-rules']({ id }); +}; + +handlers['api/rule-create'] = withMutation(async function ({ rule }) { + checkFileOpen(); + const addedRule = await handlers['rule-add'](rule); + + if ('error' in addedRule) { + throw APIError('Failed creating a new rule', addedRule.error); + } + + return addedRule; +}); + +handlers['api/rule-update'] = withMutation(async function ({ rule }) { + checkFileOpen(); + const updatedRule = handlers['rule-update'](rule); + + if ('error' in updatedRule) { + throw APIError('Failed updating the rule', updatedRule.error); + } + + return updatedRule; +}); + +handlers['api/rule-delete'] = withMutation(async function ({ id }) { + checkFileOpen(); + return handlers['rule-delete'](id); +}); + export function installAPI(serverHandlers: ServerHandlers) { const merged = Object.assign({}, serverHandlers, handlers); handlers = merged as Handlers; diff --git a/packages/loot-core/src/server/errors.ts b/packages/loot-core/src/server/errors.ts index 1edf89a9b6838d8ccec547e41f07c274446b6706..d7228baab8e83a9c3336dec637c8312d7774fbff 100644 --- a/packages/loot-core/src/server/errors.ts +++ b/packages/loot-core/src/server/errors.ts @@ -62,8 +62,9 @@ export class RuleError extends Error { } } -export function APIError(msg: string) { - return { type: 'APIError', message: msg }; +// eslint-disable-next-line @typescript-eslint/no-explicit-any +export function APIError(msg: string, meta?: Record<string, any>) { + return { type: 'APIError', message: msg, meta }; } export function FileDownloadError( diff --git a/packages/loot-core/src/server/rules/app.ts b/packages/loot-core/src/server/rules/app.ts index abd02e0c15ca4d9a32f04ec2450542811c99d398..dd8bd11c68215eb5f96cf7ed9922f6d8e699caf5 100644 --- a/packages/loot-core/src/server/rules/app.ts +++ b/packages/loot-core/src/server/rules/app.ts @@ -92,7 +92,7 @@ app.method( } const id = await rules.insertRule(rule); - return { id }; + return { id, ...rule }; }), ); @@ -105,7 +105,7 @@ app.method( } await rules.updateRule(rule); - return {}; + return rule; }), ); diff --git a/packages/loot-core/src/types/api-handlers.d.ts b/packages/loot-core/src/types/api-handlers.d.ts index 5fc820e4a2289dc07f40a096cf277d930e8b8ed3..d5fbd3ba5cdcbdbb7e7e180cec3a3e596a8af5e0 100644 --- a/packages/loot-core/src/types/api-handlers.d.ts +++ b/packages/loot-core/src/types/api-handlers.d.ts @@ -1,12 +1,12 @@ import { type batchUpdateTransactions } from '../server/accounts/transactions'; import type { - APICategoryEntity, APIAccountEntity, + APICategoryEntity, APICategoryGroupEntity, APIPayeeEntity, } from '../server/api-models'; -import type { TransactionEntity } from './models'; +import type { NewRuleEntity, RuleEntity, TransactionEntity } from './models'; import { type ServerHandlers } from './server-handlers'; export interface ApiHandlers { @@ -143,4 +143,14 @@ export interface ApiHandlers { 'api/payee-update': (arg: { id; fields }) => Promise<unknown>; 'api/payee-delete': (arg: { id }) => Promise<unknown>; + + 'api/rules-get': () => Promise<RuleEntity[]>; + + 'api/payee-rules-get': (arg: { id: string }) => Promise<RuleEntity[]>; + + 'api/rule-create': (arg: { rule: NewRuleEntity }) => Promise<RuleEntity>; + + 'api/rule-update': (arg: { rule: RuleEntity }) => Promise<RuleEntity>; + + 'api/rule-delete': (arg: { id: string }) => Promise<boolean>; } diff --git a/packages/loot-core/src/types/models/rule.d.ts b/packages/loot-core/src/types/models/rule.d.ts index 86e9348d30a0e718edc62d99c48be16c9a2154cd..026aef47a259e06a33321af21fefbcbfdd42ff01 100644 --- a/packages/loot-core/src/types/models/rule.d.ts +++ b/packages/loot-core/src/types/models/rule.d.ts @@ -1,7 +1,6 @@ import { type ScheduleEntity } from './schedule'; -export interface RuleEntity { - id?: string; +export interface NewRuleEntity { stage: string; conditionsOp: 'any' | 'and'; conditions: RuleConditionEntity[]; @@ -9,6 +8,10 @@ export interface RuleEntity { tombstone?: boolean; } +export interface RuleEntity extends NewRuleEntity { + id: string; +} + export type RuleConditionOp = | 'is' | 'isNot' diff --git a/packages/loot-core/src/types/server-handlers.d.ts b/packages/loot-core/src/types/server-handlers.d.ts index 78c51f399ae86f33cf1ba1698a4f1a4944def4da..88370eaf38135187fc3c0fe90bbefdacaf807d32 100644 --- a/packages/loot-core/src/types/server-handlers.d.ts +++ b/packages/loot-core/src/types/server-handlers.d.ts @@ -14,6 +14,7 @@ import { GoCardlessToken, GoCardlessInstitution, SimpleFinAccount, + RuleEntity, PayeeEntity, } from './models'; import { GlobalPrefs, LocalPrefs } from './prefs'; @@ -118,7 +119,7 @@ export interface ServerHandlers { 'payees-check-orphaned': (arg: { ids }) => Promise<unknown>; - 'payees-get-rules': (arg: { id }) => Promise<unknown>; + 'payees-get-rules': (arg: { id: string }) => Promise<RuleEntity[]>; 'make-filters-from-conditions': (arg: { conditions; diff --git a/upcoming-release-notes/2568.md b/upcoming-release-notes/2568.md new file mode 100644 index 0000000000000000000000000000000000000000..3df69fb597183441ac21fc68dd5d2439ffc09d8b --- /dev/null +++ b/upcoming-release-notes/2568.md @@ -0,0 +1,6 @@ +--- +category: Enhancements +authors: [psybers] +--- + +Add API for working with rules.