diff --git a/packages/desktop-client/src/components/modals/EditRuleModal.jsx b/packages/desktop-client/src/components/modals/EditRuleModal.jsx index 0ddd6fd8deb..49d8938b8fc 100644 --- a/packages/desktop-client/src/components/modals/EditRuleModal.jsx +++ b/packages/desktop-client/src/components/modals/EditRuleModal.jsx @@ -347,6 +347,7 @@ function ScheduleDescription({ id }) { const actionFields = [ 'category', 'payee', + 'payee_name', 'notes', 'cleared', 'account', @@ -372,7 +373,12 @@ function ActionEditor({ action, editorStyle, onChange, onDelete, onAdd }) { const templated = options?.template !== undefined; // Even if the feature flag is disabled, we still want to be able to turn off templating - const isTemplatingEnabled = useFeatureFlag('actionTemplating') || templated; + const actionTemplating = useFeatureFlag('actionTemplating'); + const isTemplatingEnabled = actionTemplating || templated; + + const fields = ( + options?.splitIndex ? splitActionFields : actionFields + ).filter(([s]) => actionTemplating || !s.includes('_name') || field === s); return ( @@ -385,7 +391,7 @@ function ActionEditor({ action, editorStyle, onChange, onDelete, onAdd }) { /> onChange('field', value)} /> diff --git a/packages/desktop-client/src/components/rules/Value.tsx b/packages/desktop-client/src/components/rules/Value.tsx index 3d26c5a0b44..9e10fa10148 100644 --- a/packages/desktop-client/src/components/rules/Value.tsx +++ b/packages/desktop-client/src/components/rules/Value.tsx @@ -86,6 +86,7 @@ export function Value({ return value ? formatDate(parseISO(value), 'yyyy') : null; case 'notes': case 'imported_payee': + case 'payee_name': return value; case 'payee': case 'category': diff --git a/packages/loot-core/src/server/accounts/rules.ts b/packages/loot-core/src/server/accounts/rules.ts index f67aa04b88c..a4dc3db0af9 100644 --- a/packages/loot-core/src/server/accounts/rules.ts +++ b/packages/loot-core/src/server/accounts/rules.ts @@ -44,6 +44,10 @@ function registerHandlebarsHelpers() { const helpers = { regex: (value: unknown, regex: unknown, replace: unknown) => { + if (value == null) { + return null; + } + if (typeof regex !== 'string' || typeof replace !== 'string') { return ''; } diff --git a/packages/loot-core/src/server/accounts/sync.ts b/packages/loot-core/src/server/accounts/sync.ts index b453a5b88d4..a861b374075 100644 --- a/packages/loot-core/src/server/accounts/sync.ts +++ b/packages/loot-core/src/server/accounts/sync.ts @@ -460,7 +460,7 @@ export async function matchTransactions( subtransactions, } of normalized) { // Run the rules - const trans = runRules(originalTrans); + const trans = await runRules(originalTrans); let match = null; let fuzzyDataset = null; @@ -605,7 +605,7 @@ export async function addTransactions( for (const { trans: originalTrans, subtransactions } of normalized) { // Run the rules - const trans = runRules(originalTrans); + const trans = await runRules(originalTrans); const finalTransaction = { id: uuidv4(), diff --git a/packages/loot-core/src/server/accounts/transaction-rules.test.ts b/packages/loot-core/src/server/accounts/transaction-rules.test.ts index 3ca6834d99d..4a043e0ef09 100644 --- a/packages/loot-core/src/server/accounts/transaction-rules.test.ts +++ b/packages/loot-core/src/server/accounts/transaction-rules.test.ts @@ -126,7 +126,7 @@ describe('Transaction rules', () => { spy.mockRestore(); // Finally make sure the rule is actually in place and runs - const transaction = runRules({ + const transaction = await runRules({ date: '2019-05-10', notes: '', category: null, @@ -149,7 +149,7 @@ describe('Transaction rules', () => { }); expect(getRules().length).toBe(1); - let transaction = runRules({ + let transaction = await runRules({ imported_payee: 'Kroger', notes: '', category: null, @@ -165,7 +165,7 @@ describe('Transaction rules', () => { }); expect(getRules().length).toBe(1); - transaction = runRules({ + transaction = await runRules({ imported_payee: 'Kroger', notes: '', category: null, @@ -179,7 +179,7 @@ describe('Transaction rules', () => { id, conditions: [{ op: 'is', field: 'imported_payee', value: 'ABC' }], }); - transaction = runRules({ + transaction = await runRules({ imported_payee: 'ABC', notes: '', category: null, @@ -201,7 +201,7 @@ describe('Transaction rules', () => { }); expect(getRules().length).toBe(1); - let transaction = runRules({ + let transaction = await runRules({ payee: 'Kroger', notes: '', category: null, @@ -211,7 +211,7 @@ describe('Transaction rules', () => { await deleteRule(id); expect(getRules().length).toBe(0); - transaction = runRules({ + transaction = await runRules({ payee: 'Kroger', notes: '', category: null, @@ -242,14 +242,14 @@ describe('Transaction rules', () => { await loadRules(); expect(getRules().length).toBe(2); - let transaction = runRules({ + let transaction = await runRules({ imported_payee: 'blah Lowes blah', payee: null, category: null, }); expect(transaction.payee).toBe('lowes'); - transaction = runRules({ + transaction = await runRules({ imported_payee: 'kroger', category: null, }); @@ -315,7 +315,7 @@ describe('Transaction rules', () => { expect(rule2.conditions[1].value).toBe('beer_id'); }); - test('runRules runs all the rules in each phase', async () => { + test('await runRules runs all the rules in each phase', async () => { await loadRules(); await insertRule({ stage: 'post', @@ -354,7 +354,7 @@ describe('Transaction rules', () => { }); expect( - runRules({ + await runRules({ imported_payee: '123 kroger', date: '2020-08-11', amount: 50, diff --git a/packages/loot-core/src/server/accounts/transaction-rules.ts b/packages/loot-core/src/server/accounts/transaction-rules.ts index 710f057b03a..2d8bc7c5428 100644 --- a/packages/loot-core/src/server/accounts/transaction-rules.ts +++ b/packages/loot-core/src/server/accounts/transaction-rules.ts @@ -16,6 +16,7 @@ import { } from '../../types/models'; import { schemaConfig } from '../aql'; import * as db from '../db'; +import { getPayee, getPayeeByName, insertPayee } from '../db'; import { getMappings } from '../db/mappings'; import { RuleError } from '../errors'; import { requiredFields, toDateRepr } from '../models'; @@ -273,8 +274,8 @@ function onApplySync(oldValues, newValues) { } // Runner -export function runRules(trans) { - let finalTrans = { ...trans }; +export async function runRules(trans) { + let finalTrans = await prepareTransactionForRules({ ...trans }); const rules = rankRules( fastSetMerge( @@ -287,7 +288,7 @@ export function runRules(trans) { finalTrans = rules[i].apply(finalTrans); } - return finalTrans; + return await finalizeTransactionForRules(finalTrans); } // This does the inverse: finds all the transactions matching a rule @@ -539,11 +540,20 @@ export async function applyActions( return null; } - const updated = transactions.flatMap(trans => { + const transactionsForRules = await Promise.all( + transactions.map(prepareTransactionForRules), + ); + + const updated = transactionsForRules.flatMap(trans => { return ungroupTransaction(execActions(parsedActions, trans)); }); - return batchUpdateTransactions({ updated }); + const finalized: TransactionEntity[] = []; + for (const trans of updated) { + finalized.push(await finalizeTransactionForRules(trans)); + } + + return batchUpdateTransactions({ updated: finalized }); } export function getRulesForPayee(payeeId) { @@ -759,3 +769,42 @@ export async function updateCategoryRules(transactions) { } }); } + +export type TransactionForRules = TransactionEntity & { + payee_name?: string; +}; + +export async function prepareTransactionForRules( + trans: TransactionEntity, +): Promise { + const r: TransactionForRules = { ...trans }; + if (trans.payee) { + const payee = await getPayee(trans.payee); + if (payee) { + r.payee_name = payee.name; + } + } + + return r; +} + +export async function finalizeTransactionForRules( + trans: TransactionEntity | TransactionForRules, +): Promise { + if ('payee_name' in trans) { + if (trans.payee_name) { + let payeeId = (await getPayeeByName(trans.payee_name))?.id; + payeeId ??= await insertPayee({ + name: trans.payee_name, + }); + + trans.payee = payeeId; + } else { + trans.payee = null; + } + + delete trans.payee_name; + } + + return trans; +} diff --git a/packages/loot-core/src/shared/rules.ts b/packages/loot-core/src/shared/rules.ts index fe4839b2229..e194266d4aa 100644 --- a/packages/loot-core/src/shared/rules.ts +++ b/packages/loot-core/src/shared/rules.ts @@ -61,6 +61,7 @@ const FIELD_INFO = { disallowedOps: new Set(['hasTags']), }, payee: { type: 'id' }, + payee_name: { type: 'string' }, date: { type: 'date' }, notes: { type: 'string' }, amount: { type: 'number' }, @@ -112,6 +113,8 @@ export function mapField(field, opts?) { switch (field) { case 'imported_payee': return 'imported payee'; + case 'payee_name': + return 'payee (name)'; case 'amount': if (opts.inflow) { return 'amount (inflow)'; diff --git a/packages/loot-core/src/types/models/rule.d.ts b/packages/loot-core/src/types/models/rule.d.ts index b3e2b6befd5..1bfc9c0a0cd 100644 --- a/packages/loot-core/src/types/models/rule.d.ts +++ b/packages/loot-core/src/types/models/rule.d.ts @@ -35,6 +35,7 @@ type FieldValueTypes = { date: string; notes: string; payee: string; + payee_name: string; imported_payee: string; saved: string; cleared: boolean; diff --git a/upcoming-release-notes/3619.md b/upcoming-release-notes/3619.md new file mode 100644 index 00000000000..5d69e3f600b --- /dev/null +++ b/upcoming-release-notes/3619.md @@ -0,0 +1,6 @@ +--- +category: Enhancements +authors: [UnderKoen] +--- + +Add action rule templating for `payee_name`