feat: add account groups, scheduled payments and cash flow calendar
This commit is contained in:
1 parent
a7e0a0fe58
commit
86c6daf695
46 files changed
+2548
-290
No files matched your search
@@ -0,0 +1,161 @@
|
||||
import { Controller, Get, Query, Req } from '@nestjs/common';
|
||||
import { Prisma } from '@prisma/client';
|
||||
import Decimal from 'decimal.js';
|
||||
import { z } from 'zod';
|
||||
import { Database } from './database';
|
||||
import { UserRequest } from './auth';
|
||||
import { date, today, toBusinessDate } from './validation';
|
||||
import { businessDay, rateAt } from './calculation';
|
||||
import { currentRates, latestRevisions, endOfDay } from './queries';
|
||||
export function cashflowDelta(side: string, reason: string, before: string, after: string) {
|
||||
if (!['balance', 'correction', 'scheduled_expense'].includes(reason)) return new Decimal(0);
|
||||
const delta = new Decimal(after).minus(before);
|
||||
return side === 'liability' ? delta.neg() : delta;
|
||||
}
|
||||
export function calendarMonth(value: unknown) {
|
||||
const month = z
|
||||
.string()
|
||||
.regex(/^\d{4}-(0[1-9]|1[0-2])$/)
|
||||
.refine((s) => s >= '1900-01' && s <= today().slice(0, 7))
|
||||
.parse(value || today().slice(0, 7));
|
||||
const from = month + '-01',
|
||||
next = new Date(from + 'T00:00:00Z');
|
||||
next.setUTCMonth(next.getUTCMonth() + 1);
|
||||
const last = new Date(+next - 86400000).toISOString().slice(0, 10);
|
||||
return { month, from, to: last > today() ? today() : last, days: Number(last.slice(-2)) };
|
||||
}
|
||||
@Controller('api/calendar')
|
||||
export class CalendarController {
|
||||
constructor(private db: Database) {}
|
||||
private async replay(
|
||||
tx: Prisma.TransactionClient,
|
||||
r: UserRequest,
|
||||
from: string,
|
||||
to: string,
|
||||
details: boolean,
|
||||
) {
|
||||
const accounts = await tx.position.findMany({
|
||||
where: { userId: r.userId, kind: 'account', ...(r.revealed ? {} : { hidden: false }) },
|
||||
select: { id: true, name: true, side: true, currency: true },
|
||||
});
|
||||
const ids = accounts.map((p) => p.id),
|
||||
seeds = await latestRevisions(tx, ids, toBusinessDate(from));
|
||||
const rows = ids.length
|
||||
? await tx.revision.findMany({
|
||||
where: {
|
||||
positionId: { in: ids },
|
||||
effectiveDate: { gte: toBusinessDate(from), lt: endOfDay(to) },
|
||||
},
|
||||
select: {
|
||||
id: true,
|
||||
positionId: true,
|
||||
amount: true,
|
||||
effectiveDate: true,
|
||||
sequence: true,
|
||||
reason: true,
|
||||
...(details ? { notes: true } : {}),
|
||||
},
|
||||
orderBy: [{ effectiveDate: 'asc' }, { sequence: 'asc' }],
|
||||
})
|
||||
: [];
|
||||
const prior = new Map(seeds.map((v) => [v.positionId, v.amount.toString()]));
|
||||
const meta = new Map(accounts.map((v) => [v.id, v]));
|
||||
const changes = rows
|
||||
.map((v) => {
|
||||
const p = meta.get(v.positionId)!;
|
||||
const hasBefore = prior.has(v.positionId);
|
||||
const before = prior.get(v.positionId) || '0';
|
||||
prior.set(v.positionId, v.amount.toString());
|
||||
return {
|
||||
id: v.id,
|
||||
name: p.name,
|
||||
currency: p.currency,
|
||||
date: businessDay(v.effectiveDate),
|
||||
delta: hasBefore
|
||||
? cashflowDelta(p.side, v.reason, before, v.amount.toString())
|
||||
: new Decimal(0),
|
||||
notes: details ? (v as typeof v & { notes?: string }).notes || '' : '',
|
||||
};
|
||||
})
|
||||
.filter((v) => !v.delta.isZero());
|
||||
const user = await tx.user.findUniqueOrThrow({
|
||||
where: { id: r.userId },
|
||||
select: { baseCurrency: true },
|
||||
});
|
||||
const currencies = [...new Set(accounts.map((p) => p.currency))];
|
||||
const rates = await tx.exchangeRate.findMany({
|
||||
where: {
|
||||
userId: r.userId,
|
||||
currency: { in: currencies },
|
||||
baseCurrency: user.baseCurrency,
|
||||
date: { gte: new Date(from), lte: new Date(to) },
|
||||
},
|
||||
});
|
||||
const priorRates = await currentRates(
|
||||
tx,
|
||||
r.userId,
|
||||
currencies,
|
||||
user.baseCurrency,
|
||||
new Date(+new Date(from) - 86400000).toISOString().slice(0, 10),
|
||||
);
|
||||
const allRates = [...priorRates, ...rates];
|
||||
return {
|
||||
baseCurrency: user.baseCurrency,
|
||||
changes: changes.map((v) => {
|
||||
const rate = rateAt(allRates, v.currency, user.baseCurrency, v.date);
|
||||
return {
|
||||
...v,
|
||||
amount: v.delta.abs().toFixed(),
|
||||
direction: v.delta.isNegative() ? 'expense' : 'income',
|
||||
converted: rate ? v.delta.abs().mul(rate.value).toFixed() : null,
|
||||
};
|
||||
}),
|
||||
};
|
||||
}
|
||||
@Get() async month(@Req() r: UserRequest, @Query('month') input?: string) {
|
||||
const q = calendarMonth(input);
|
||||
return this.db.$transaction(
|
||||
async (tx) => {
|
||||
const data = await this.replay(tx, r, q.from, q.to, false);
|
||||
const items = Array.from({ length: q.days }, (_, i) => ({
|
||||
date: q.month + '-' + String(i + 1).padStart(2, '0'),
|
||||
income: new Decimal(0),
|
||||
expense: new Decimal(0),
|
||||
complete: true,
|
||||
}));
|
||||
for (const v of data.changes) {
|
||||
const item = items[Number(v.date.slice(-2)) - 1];
|
||||
if (v.converted === null) item.complete = false;
|
||||
else if (v.direction === 'income') item.income = item.income.plus(v.converted);
|
||||
else item.expense = item.expense.plus(v.converted);
|
||||
}
|
||||
return {
|
||||
month: q.month,
|
||||
baseCurrency: data.baseCurrency,
|
||||
revealed: r.revealed,
|
||||
items: items.map((v) => ({
|
||||
...v,
|
||||
income: v.income.toFixed(2),
|
||||
expense: v.expense.toFixed(2),
|
||||
})),
|
||||
};
|
||||
},
|
||||
{ timeout: 30000 },
|
||||
);
|
||||
}
|
||||
@Get('day') async day(@Req() r: UserRequest, @Query('date') input?: string) {
|
||||
const day = date.parse(input || today());
|
||||
return this.db.$transaction(
|
||||
async (tx) => {
|
||||
const data = await this.replay(tx, r, day, day, true);
|
||||
return {
|
||||
date: day,
|
||||
baseCurrency: data.baseCurrency,
|
||||
revealed: r.revealed,
|
||||
items: data.changes.map(({ delta, ...v }) => v),
|
||||
};
|
||||
},
|
||||
{ timeout: 30000 },
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user