actual/packages/loot-core/src/server/schedules/find-schedules.js
2022-07-24 08:53:05 +01:00

405 lines
10 KiB
JavaScript

import * as d from 'date-fns';
import * as db from '../db';
import { Schedule as RSchedule } from '../util/rschedule';
import { groupBy } from '../../shared/util';
import { fromDateRepr } from '../models';
import { runQuery as aqlQuery } from '../aql/schema/run-query';
import q from '../../shared/query';
import { getApproxNumberThreshold } from '../../shared/rules';
import { recurConfigToRSchedule } from '../../shared/schedules';
import { dayFromDate, parseDate, subDays } from '../../shared/months';
import { conditionsToAQL } from '../accounts/transaction-rules';
const uuid = require('../../platform/uuid');
function takeDates(config) {
let schedule = new RSchedule({ rrules: recurConfigToRSchedule(config) });
return schedule
.occurrences({ take: 3 })
.toArray()
.map(d => d.date);
}
async function getTransactions(date, account) {
let { data } = await aqlQuery(
q('transactions')
.filter({
account,
schedule: null,
// Don't match transfers
'payee.transfer_acct': null,
$and: [
{ date: { $gte: d.subDays(date, 2) } },
{ date: { $lte: d.addDays(date, 2) } }
]
})
.select('*')
.options({ splits: 'none' })
);
return data;
}
function getRank(day1, day2) {
let dayDiff = Math.abs(d.differenceInDays(parseDate(day1), parseDate(day2)));
// The amount of days off determines the rank: exact same day
// is highest rank 1, 1 day off is .5, etc. This will find the
// best start date that matches all the dates the closest
return 1 / (dayDiff + 1);
}
export function matchSchedules(allOccurs, config, partialMatchRank = 0.5) {
allOccurs = [...allOccurs].reverse();
let baseOccur = allOccurs[0];
let occurs = allOccurs.slice(1);
let schedules = [];
for (let trans of baseOccur.transactions) {
let threshold = getApproxNumberThreshold(trans.amount);
let payee = trans.payee;
let account = trans.account;
let found = occurs.map(occur => {
let matched = occur.transactions.find(
t =>
t.amount >= trans.amount - threshold &&
t.amount <= trans.amount + threshold
);
matched = matched && matched.payee === payee ? matched : null;
if (matched) {
return { trans: matched, rank: getRank(occur.date, matched.date) };
}
return null;
});
if (found.indexOf(null) !== -1) {
continue;
}
let rank = found.reduce(
(total, match) => total + match.rank,
getRank(baseOccur.date, trans.date)
);
let exactAmount = found.reduce(
(exact, match) => exact && match.trans.amount === trans.amount,
true
);
schedules.push({
rank,
amount: trans.amount,
account: trans.account,
payee: trans.payee,
date: config,
// Exact dates rank as 1, so all of them matches exactly it
// would equal the number of `allOccurs`
exactDate: rank === allOccurs.length,
exactAmount
});
}
return schedules;
}
async function schedulesForPattern(
baseStart,
numDays,
baseConfig,
accountId,
partialMatchRank
) {
let schedules = [];
let i = 0;
for (let i = 0; i < numDays; i++) {
let start = d.addDays(baseStart, i);
let config;
if (typeof baseConfig === 'function') {
config = baseConfig(start);
if (config === false) {
// Skip this one
continue;
}
} else {
config = { ...baseConfig, start };
}
// Our recur config expects a day string, not a native date format
config.start = dayFromDate(config.start);
let data = [];
let dates = takeDates(config);
for (let date of dates) {
data.push({
date: dayFromDate(date),
transactions: await getTransactions(date, accountId)
});
}
schedules = schedules.concat(
matchSchedules(data, config, partialMatchRank)
);
}
return schedules;
}
async function weekly(startDate, accountId) {
return schedulesForPattern(
d.subWeeks(parseDate(startDate), 4),
7 * 2,
{ frequency: 'weekly' },
accountId
);
}
async function every2weeks(startDate, accountId) {
return schedulesForPattern(
// 6 weeks would cover 3 instances, but we also scan an addition
// week back
d.subWeeks(parseDate(startDate), 7),
7 * 2,
{ frequency: 'weekly', interval: 2 },
accountId
);
}
async function monthly(startDate, accountId) {
return schedulesForPattern(
d.subMonths(parseDate(startDate), 4),
31 * 2,
start => {
// 28 is the max number of days that all months are guaranteed
// to have. We don't want to go any higher than that because
// we'll end up skipping months that don't have that day.
// The use cases of end of month days will be covered with the
// `monthlyLastDay` pattern;
if (d.getDate(start) > 28) {
return false;
}
return { start, frequency: 'monthly' };
},
accountId
);
}
async function monthlyLastDay(startDate, accountId) {
// We do two separate calls because this pattern doesn't fit into
// how `schedulesForPattern` works
let s1 = await schedulesForPattern(
d.subMonths(parseDate(startDate), 3),
1,
{ frequency: 'monthly', patterns: [{ type: 'day', value: -1 }] },
accountId,
// Last day patterns should win over day-specific ones that just
// happen to match
0.75
);
let s2 = await schedulesForPattern(
d.subMonths(parseDate(startDate), 4),
1,
{ frequency: 'monthly', patterns: [{ type: 'day', value: -1 }] },
accountId,
0.75
);
return s1.concat(s2);
}
async function monthly1stor3rd(startDate, accountId) {
return schedulesForPattern(
d.subWeeks(parseDate(startDate), 8),
14,
start => {
let day = d.format(new Date(), 'iiii');
let dayValue = day.slice(0, 2).toUpperCase();
return {
start,
frequency: 'monthly',
patterns: [
{ type: dayValue, value: 1 },
{ type: dayValue, value: 3 }
]
};
},
accountId
);
}
async function monthly2ndor4th(startDate, accountId) {
return schedulesForPattern(
d.subMonths(parseDate(startDate), 8),
14,
start => {
let day = d.format(new Date(), 'iiii');
let dayValue = day.slice(0, 2).toUpperCase();
return {
start,
frequency: 'monthly',
patterns: [
{ type: dayValue, value: 2 },
{ type: dayValue, value: 4 }
]
};
},
accountId
);
}
async function findStartDate(schedule) {
let conditions = schedule._conditions;
let dateCond = conditions.find(c => c.field === 'date');
let currentConfig = dateCond.value;
while (1) {
let prevConfig = currentConfig;
currentConfig = { ...prevConfig };
switch (currentConfig.frequency) {
case 'weekly':
currentConfig.start = dayFromDate(
d.subWeeks(
parseDate(currentConfig.start),
currentConfig.interval || 1
)
);
break;
case 'monthly':
currentConfig.start = dayFromDate(
d.subMonths(
parseDate(currentConfig.start),
currentConfig.interval || 1
)
);
break;
case 'yearly':
currentConfig.start = dayFromDate(
d.subYears(
parseDate(currentConfig.start),
currentConfig.interval || 1
)
);
break;
default:
throw new Error('findStartDate: invalid frequency');
}
let newConditions = conditions.map(c =>
c.field === 'date' ? { ...c, value: currentConfig } : c
);
let { filters, errors } = conditionsToAQL(newConditions, {
recurDateBounds: 1
});
if (errors.length > 0) {
// Somehow we generated an invalid config. Abort the whole
// process and don't change the date at all
currentConfig = null;
break;
}
let { data } = await aqlQuery(
q('transactions')
.filter({ $and: filters })
.select('*')
);
if (data.length === 0) {
// No data, revert back to the last valid value and stop
currentConfig = prevConfig;
break;
}
}
if (currentConfig) {
return {
...schedule,
date: currentConfig,
_conditions: conditions.map(c =>
c.field === 'date' ? { ...c, value: currentConfig } : c
)
};
}
return schedule;
}
export async function findSchedules() {
// Patterns to look for:
// * Weekly
// * Every two weeks
// * Monthly on day X
// * Monthly on every 1st or 3rd day
// * Monthly on every 2nd or 4th day
//
// Search for them approx (+- 2 days) but track which transactions
// and find the best one...
let { data: accounts } = await aqlQuery(
q('accounts')
.filter({ closed: false })
.select('*')
);
let allSchedules = [];
for (let account of accounts) {
// Find latest transaction-ish to start with
let latestTrans = await db.first(
'SELECT * FROM v_transactions WHERE account = ? AND parent_id IS NULL ORDER BY date DESC LIMIT 1',
[account.id]
);
if (latestTrans) {
let latestDate = fromDateRepr(latestTrans.date);
allSchedules = allSchedules.concat(
await weekly(latestDate, account.id),
await every2weeks(latestDate, account.id),
await monthly(latestDate, account.id),
await monthlyLastDay(latestDate, account.id),
await monthly1stor3rd(latestDate, account.id),
await monthly2ndor4th(latestDate, account.id)
);
}
}
let schedules = [...groupBy(allSchedules, 'payee').entries()].map(
([payeeId, schedules]) => {
schedules.sort((s1, s2) => s2.rank - s1.rank);
let winner = schedules[0];
// Convert to schedule and return it
return {
id: uuid.v4Sync(),
account: winner.account,
payee: winner.payee,
date: winner.date,
amount: winner.amount,
_conditions: [
{ op: 'is', field: 'account', value: winner.account },
{ op: 'is', field: 'payee', value: winner.payee },
{
op: winner.exactDate ? 'is' : 'isapprox',
field: 'date',
value: winner.date
},
{
op: winner.exactAmount ? 'is' : 'isapprox',
field: 'amount',
value: winner.amount
}
]
};
}
);
let finalized = [];
for (let schedule of schedules) {
finalized.push(await findStartDate(schedule));
}
return finalized;
}