Skip to content

Commit

Permalink
✨ Filter account with 'on budget' or 'off budget' (#3891)
Browse files Browse the repository at this point in the history
* Filter account by on budget / off budget

* small fix

* fix eval for new operations

* code review suggestion

* suggestions

* small fix for rules table

* batch loading the accounts

* Update packages/loot-core/src/server/accounts/transaction-rules.ts

Co-authored-by: Matt Fiddaman <[email protected]>

* missed this type

---------

Co-authored-by: Matt Fiddaman <[email protected]>
  • Loading branch information
lelemm and matt-fidd authored Dec 10, 2024
1 parent a289227 commit 2b908e9
Show file tree
Hide file tree
Showing 13 changed files with 149 additions and 35 deletions.
24 changes: 13 additions & 11 deletions packages/desktop-client/src/components/filters/FilterExpression.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -85,17 +85,19 @@ export function FilterExpression<T extends RuleConditionEntity>({
{mapField(field, options)}
</Text>{' '}
<Text>{friendlyOp(op, null)}</Text>{' '}
<Value
value={value}
field={field}
inline={true}
valueIsRaw={
op === 'contains' ||
op === 'matches' ||
op === 'doesNotContain' ||
op === 'hasTags'
}
/>
{!['onbudget', 'offbudget'].includes(op?.toLocaleLowerCase()) && (
<Value
value={value}
field={field}
inline={true}
valueIsRaw={
op === 'contains' ||
op === 'matches' ||
op === 'doesNotContain' ||
op === 'hasTags'
}
/>
)}
</>
)}
</div>
Expand Down
13 changes: 12 additions & 1 deletion packages/desktop-client/src/components/filters/FiltersMenu.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -117,8 +117,18 @@ function ConfigureField({
}}
/>
) : (
titleFirst(mapField(field))
<View
style={{
flexDirection: 'row',
width: '100%',
alignItems: 'center',
padding: 0,
}}
>
<View style={{ flexGrow: 1 }}>{titleFirst(mapField(field))}</View>
</View>
)}

<View style={{ flex: 1 }} />
</Stack>
</View>
Expand Down Expand Up @@ -222,6 +232,7 @@ function ConfigureField({
}
value={value}
multi={op === 'oneOf' || op === 'notOneOf'}
op={op}
style={{ marginTop: 10 }}
onChange={v => {
dispatch({ type: 'set-value', value: v });
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,9 @@ export function updateFilterReducer(
action.op === 'is' ||
action.op === 'doesNotContain' ||
action.op === 'isNot' ||
action.op === 'hasTags')
action.op === 'hasTags' ||
action.op === 'onBudget' ||
action.op === 'offBudget')
) {
// Clear out the value if switching between contains or
// is/oneof for the id or string type
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -262,6 +262,7 @@ function ConditionEditor({
field={field}
type={type}
value={value}
op={op}
multi={op === 'oneOf' || op === 'notOneOf'}
onChange={v => onChange('value', v)}
numberFormatType="currency"
Expand Down Expand Up @@ -461,6 +462,7 @@ function ActionEditor({ action, editorStyle, onChange, onDelete, onAdd }) {
<GenericInput
key={inputKey}
field={field}
op={op}
type="number"
numberFormatType={
options.method === 'fixed-percent' ? 'percentage' : 'currency'
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -49,7 +49,11 @@ export function ConditionExpression({
{prefix && <Text>{prefix} </Text>}
<Text style={valueStyle}>{mapField(field, options)}</Text>{' '}
<Text>{friendlyOp(op)}</Text>{' '}
<Value style={valueStyle} value={value} field={field} inline={inline} />
{!['onbudget', 'offbudget'].includes(
(op as string)?.toLocaleLowerCase(),
) && (
<Value style={valueStyle} value={value} field={field} inline={inline} />
)}
</View>
);
}
33 changes: 21 additions & 12 deletions packages/desktop-client/src/components/util/GenericInput.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,7 @@ export function GenericInput({
inputRef,
style,
onChange,
op = undefined,
}) {
const { grouped: categoryGroups } = useCategories();
const { data: savedReports } = useReports();
Expand Down Expand Up @@ -100,18 +101,26 @@ export function GenericInput({
break;

case 'account':
content = (
<AccountAutocomplete
type={autocompleteType}
value={value}
openOnFocus={true}
onSelect={onChange}
inputProps={{
inputRef,
...(showPlaceholder ? { placeholder: 'nothing' } : null),
}}
/>
);
switch (op) {
case 'onBudget':
case 'offBudget':
content = null;
break;
default:
content = (
<AccountAutocomplete
type={autocompleteType}
value={value}
openOnFocus={true}
onSelect={onChange}
inputProps={{
inputRef,
...(showPlaceholder ? { placeholder: 'nothing' } : null),
}}
/>
);
break;
}
break;

case 'category':
Expand Down
19 changes: 19 additions & 0 deletions packages/loot-core/src/server/accounts/rules.ts
Original file line number Diff line number Diff line change
Expand Up @@ -201,6 +201,8 @@ const CONDITION_TYPES = {
'doesNotContain',
'notOneOf',
'and',
'onBudget',
'offBudget',
],
nullable: true,
parse(op, value, fieldName) {
Expand Down Expand Up @@ -518,6 +520,21 @@ export class Condition {
console.log('invalid regexp in matches condition', e);
return false;
}

case 'onBudget':
if (!object._account) {
return false;
}

return object._account.offbudget === 0;

case 'offBudget':
if (!object._account) {
return false;
}

return object._account.offbudget === 1;

default:
}

Expand Down Expand Up @@ -948,6 +965,8 @@ const OP_SCORES: Record<RuleConditionEntity['op'], number> = {
doesNotContain: 0,
matches: 0,
hasTags: 0,
onBudget: 0,
offBudget: 0,
};

function computeScore(rule: Rule): number {
Expand Down
10 changes: 8 additions & 2 deletions packages/loot-core/src/server/accounts/sync.ts
Original file line number Diff line number Diff line change
Expand Up @@ -523,14 +523,17 @@ export async function matchTransactions(
);

// The first pass runs the rules, and preps data for fuzzy matching
const accounts: AccountEntity[] = await db.getAccounts();
const accountsMap = new Map(accounts.map(account => [account.id, account]));

const transactionsStep1 = [];
for (const {
payee_name,
trans: originalTrans,
subtransactions,
} of normalized) {
// Run the rules
const trans = await runRules(originalTrans);
const trans = await runRules(originalTrans, accountsMap);

let match = null;
let fuzzyDataset = null;
Expand Down Expand Up @@ -673,9 +676,12 @@ export async function addTransactions(
{ rawPayeeName: true },
);

const accounts: AccountEntity[] = await db.getAccounts();
const accountsMap = new Map(accounts.map(account => [account.id, account]));

for (const { trans: originalTrans, subtransactions } of normalized) {
// Run the rules
const trans = await runRules(originalTrans);
const trans = await runRules(originalTrans, accountsMap);

const finalTransaction = {
id: uuidv4(),
Expand Down
43 changes: 39 additions & 4 deletions packages/loot-core/src/server/accounts/transaction-rules.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,10 +13,11 @@ import {
type TransactionEntity,
type RuleActionEntity,
type RuleEntity,
AccountEntity,
} from '../../types/models';
import { schemaConfig } from '../aql';
import * as db from '../db';
import { getPayee, getPayeeByName, insertPayee } from '../db';
import { getPayee, getPayeeByName, insertPayee, getAccount } from '../db';
import { getMappings } from '../db/mappings';
import { RuleError } from '../errors';
import { requiredFields, toDateRepr } from '../models';
Expand Down Expand Up @@ -274,8 +275,20 @@ function onApplySync(oldValues, newValues) {
}

// Runner
export async function runRules(trans) {
let finalTrans = await prepareTransactionForRules({ ...trans });
export async function runRules(
trans,
accounts: Map<string, AccountEntity> | null = null,
) {
let accountsMap = null;
if (accounts === null) {
accountsMap = new Map(
(await db.getAccounts()).map(account => [account.id, account]),
);
} else {
accountsMap = accounts;
}

let finalTrans = await prepareTransactionForRules({ ...trans }, accountsMap);

const rules = rankRules(
fastSetMerge(
Expand Down Expand Up @@ -559,6 +572,12 @@ export function conditionsToAQL(conditions, { recurDateBounds = 100 } = {}) {
return {
$and: getValue(value).map(subExpr => mapConditionToActualQL(subExpr)),
};

case 'onBudget':
return { 'account.offbudget': false };
case 'offBudget':
return { 'account.offbudget': true };

default:
throw new Error('Unhandled operator: ' + op);
}
Expand Down Expand Up @@ -608,8 +627,14 @@ export async function applyActions(
return null;
}

const accounts: AccountEntity[] = await db.getAccounts();
const transactionsForRules = await Promise.all(
transactions.map(prepareTransactionForRules),
transactions.map(transactions =>
prepareTransactionForRules(
transactions,
new Map(accounts.map(account => [account.id, account])),
),
),
);

const updated = transactionsForRules.flatMap(trans => {
Expand Down Expand Up @@ -840,10 +865,12 @@ export async function updateCategoryRules(transactions) {

export type TransactionForRules = TransactionEntity & {
payee_name?: string;
_account?: AccountEntity;
};

export async function prepareTransactionForRules(
trans: TransactionEntity,
accounts: Map<string, AccountEntity> | null = null,
): Promise<TransactionForRules> {
const r: TransactionForRules = { ...trans };
if (trans.payee) {
Expand All @@ -853,6 +880,14 @@ export async function prepareTransactionForRules(
}
}

if (trans.account) {
if (accounts !== null && accounts.has(trans.account)) {
r._account = accounts.get(trans.account);
} else {
r._account = await getAccount(trans.account);
}
}

return r;
}

Expand Down
4 changes: 4 additions & 0 deletions packages/loot-core/src/server/db/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -473,6 +473,10 @@ export async function getPayee(id) {
return first(`SELECT * FROM payees WHERE id = ?`, [id]);
}

export async function getAccount(id) {
return first(`SELECT * FROM accounts WHERE id = ?`, [id]);
}

export async function insertPayee(payee) {
payee = payeeModel.validate(payee);
let id;
Expand Down
14 changes: 12 additions & 2 deletions packages/loot-core/src/shared/rules.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,8 @@ const TYPE_INFO = {
'isNot',
'doesNotContain',
'notOneOf',
'onBudget',
'offBudget',
],
nullable: true,
},
Expand Down Expand Up @@ -65,12 +67,16 @@ const FIELD_INFO = {
type: 'string',
disallowedOps: new Set(['hasTags']),
},
payee: { type: 'id' },
payee: { type: 'id', disallowedOps: new Set(['onBudget', 'offBudget']) },
payee_name: { type: 'string' },
date: { type: 'date' },
notes: { type: 'string' },
amount: { type: 'number' },
category: { type: 'id', internalOps: new Set(['and']) },
category: {
type: 'id',
disallowedOps: new Set(['onBudget', 'offBudget']),
internalOps: new Set(['and']),
},
account: { type: 'id' },
cleared: { type: 'boolean' },
reconciled: { type: 'boolean' },
Expand Down Expand Up @@ -199,6 +205,10 @@ export function friendlyOp(op, type?) {
return t('and');
case 'or':
return 'or';
case 'onBudget':
return 'is on budget';
case 'offBudget':
return 'is off budget';
default:
return '';
}
Expand Down
6 changes: 5 additions & 1 deletion packages/loot-core/src/types/models/rule.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,9 @@ export type RuleConditionOp =
| 'doesNotContain'
| 'hasTags'
| 'and'
| 'matches';
| 'matches'
| 'onBudget'
| 'offBudget';

type FieldValueTypes = {
account: string;
Expand Down Expand Up @@ -76,6 +78,8 @@ export type RuleConditionEntity =
| 'contains'
| 'doesNotContain'
| 'matches'
| 'onBudget'
| 'offBudget'
>
| BaseConditionEntity<
'category',
Expand Down
6 changes: 6 additions & 0 deletions upcoming-release-notes/3891.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
---
category: Enhancements
authors: [lelemm]
---

Filter accounts when on budget or off budget

0 comments on commit 2b908e9

Please sign in to comment.