fix: account credentials, record deletion refresh and backup recovery

This commit is contained in:
陈煜 committed 2026-10-03 11:23:19 +08:00
1 parent 9adf7fdbc5
commit e77650f0c0
22 files changed
+711 -115

No files matched your search

+1 -1
View File
@@ -9,7 +9,7 @@
"db:generate": "prisma generate",
"db:migrate": "node scripts/database.cjs deploy",
"db:status": "node scripts/database.cjs status",
"test:integration": "tsx --test --test-concurrency=1 test/integration.test.ts test/privacy.test.ts test/icons.test.ts test/transfers.test.ts test/queries.test.ts test/debts.test.ts test/update-integration.test.ts test/record-edit.test.ts",
"test:integration": "tsx --test --test-concurrency=1 test/integration.test.ts test/privacy.test.ts test/icons.test.ts test/transfers.test.ts test/queries.test.ts test/debts.test.ts test/update-integration.test.ts test/record-edit.test.ts test/security-backup.test.ts",
"test:performance": "tsx scripts/performance.ts after",
"icons:seed": "node scripts/seed-icons.cjs"
},
+45 -2
View File
@@ -3,6 +3,8 @@ import {
Controller,
Get,
Post,
Patch,
BadRequestException,
Body,
Req,
Res,
@@ -18,7 +20,8 @@ import { Request, Response } from 'express';
import { randomBytes, createHash } from 'node:crypto';
import { hash, compare } from 'bcryptjs';
import { Database } from './database';
import { credentials } from './validation';
import { credentials, credentialChange } from './validation';
import { Prisma } from '@prisma/client';
export type UserRequest = Request & { userId: string; sessionId: string; revealed: boolean };
const Public = () => SetMetadata('public', true);
const digest = (s: string) => createHash('sha256').update(s).digest('hex');
@@ -58,6 +61,9 @@ export class AuthService {
const token = randomBytes(32).toString('hex'),
expiresAt = new Date(Date.now() + 7 * 86400000);
await this.db.session.create({ data: { id: digest(token), userId, expiresAt } });
this.cookie(token, expiresAt, res);
}
cookie(token: string, expiresAt: Date, res: Response) {
res.cookie('wp_session', token, {
httpOnly: true,
sameSite: 'strict',
@@ -166,7 +172,44 @@ export class AuthController {
idleMinutes: true,
},
});
return { ...user, hiddenMenus: user.hiddenMenus.split(',').filter(Boolean) };
const session = await this.db.session.findUniqueOrThrow({ where: { id: req.sessionId } });
return {
...user,
hiddenMenus: user.hiddenMenus.split(',').filter(Boolean),
revealed: req.revealed,
revealUntil: session.revealUntil,
lastActivity: session.lastActivity,
};
}
@Patch('auth/credentials') async changeCredentials(
@Req() r: UserRequest,
@Body() body: unknown,
@Res({ passthrough: true }) res: Response,
) {
this.auth.limit(r);
const v = credentialChange.parse(body);
const user = await this.db.user.findUniqueOrThrow({ where: { id: r.userId } });
if (!(await compare(v.currentPassword, user.passwordHash)))
throw new ForbiddenException('当前密码错误');
if ((!v.username || v.username === user.username) && !v.newPassword)
throw new BadRequestException('请填写新的账号或密码');
const passwordHash = v.newPassword ? await hash(v.newPassword, 12) : user.passwordHash;
const token = randomBytes(32).toString('hex'),
expiresAt = new Date(Date.now() + 7 * 86400000);
await this.db.serial(async (tx) => {
await tx.$queryRaw(Prisma.sql`SELECT id FROM User WHERE id = ${r.userId} FOR UPDATE`);
const current = await tx.user.findUniqueOrThrow({ where: { id: r.userId } });
if (current.passwordHash !== user.passwordHash || current.username !== user.username)
throw new ForbiddenException('账号已变更,请重新登录后操作');
await tx.user.update({
where: { id: r.userId },
data: { username: v.username, passwordHash },
});
await tx.session.deleteMany({ where: { userId: r.userId } });
await tx.session.create({ data: { id: digest(token), userId: r.userId, expiresAt } });
});
this.auth.cookie(token, expiresAt, res);
return { ok: true };
}
@Post('auth/activity') async activity(@Req() r: UserRequest) {
await this.db.session.update({
+14 -15
View File
@@ -56,16 +56,14 @@ const record = positionMeta
importedFromId: z.string().uuid().nullable().optional(),
createdAt: timestamp,
updatedAt: timestamp,
revisions: z
.array(
revisionInput.extend({
id: z.string().uuid(),
sequence: z.number().int().positive().max(2147483647).optional(),
createdAt: timestamp,
updatedAt: timestamp,
}),
)
.min(1),
revisions: z.array(
revisionInput.extend({
id: z.string().uuid(),
sequence: z.number().int().positive().max(2147483647).optional(),
createdAt: timestamp,
updatedAt: timestamp,
}),
),
})
.strict();
const backupSchema = z
@@ -151,7 +149,7 @@ export function validateBackup(raw: unknown) {
archived: p.archived,
hidden: p.hidden,
amount: '0',
date: p.revisions[0].date,
date: p.revisions[0]?.date || '1900-01-01',
});
const sequences = new Set<number>();
for (const r of p.revisions) {
@@ -225,11 +223,11 @@ export function validateBackup(raw: unknown) {
current = ordered[index];
if (
usedRevisions.has(revId) ||
index < 1 ||
index < 0 ||
!current ||
current.reason !== reason ||
current.date !== t.date ||
!new Decimal(current.amount).minus(ordered[index - 1].amount).eq(delta)
!new Decimal(current.amount).minus(ordered[index - 1]?.amount || '0').eq(delta)
)
throw new BadRequestException('转账历史与双方金额不一致');
usedRevisions.add(revId);
@@ -296,7 +294,7 @@ export class BackupController implements OnModuleDestroy, OnModuleInit {
}
return prefix.toString() === 'PK'
? readBackupZip(path)
: JSON.parse(await readFile(path, 'utf8'));
: JSON.parse((await readFile(path, 'utf8')).replace(/^\uFEFF/, ''));
}
constructor(private db: Database) {}
private async data(
@@ -420,7 +418,7 @@ export class BackupController implements OnModuleDestroy, OnModuleInit {
});
return b;
},
{ isolationLevel: Prisma.TransactionIsolationLevel.Serializable },
{ isolationLevel: Prisma.TransactionIsolationLevel.Serializable, timeout: 300000 },
);
res.setHeader(
'Content-Disposition',
@@ -494,6 +492,7 @@ export class BackupController implements OnModuleDestroy, OnModuleInit {
);
data.currencies.sort();
data.transfers?.sort((a, b) => a.id.localeCompare(b.id));
data.schedules?.sort((a, b) => a.id.localeCompare(b.id));
data.icons?.sort((a, b) => a.id.localeCompare(b.id));
return createHash('sha256').update(JSON.stringify(data)).digest('hex');
}
+4 -3
View File
@@ -71,9 +71,10 @@ export class CalendarController {
name: p.name,
currency: p.currency,
date: businessDay(v.effectiveDate),
delta: hasBefore
? cashflowDelta(p.side, v.reason, before, v.amount.toString())
: new Decimal(0),
delta:
hasBefore || v.reason === 'scheduled_expense'
? cashflowDelta(p.side, v.reason, before, v.amount.toString())
: new Decimal(0),
notes: details ? (v as typeof v & { notes?: string }).notes || '' : '',
};
})
+1
View File
@@ -7,6 +7,7 @@ export class Database extends PrismaClient implements OnModuleInit, OnModuleDest
try {
return await this.$transaction(work, {
isolationLevel: Prisma.TransactionIsolationLevel.Serializable,
timeout: 30000,
});
} catch (error) {
// Serializable deadlock/write conflict; retry the entire atomic operation.
+2
View File
@@ -4,6 +4,7 @@ import { scheduleInput } from './schedules';
import { z } from 'zod';
import {
credentials,
credentialChange,
positionInput,
positionMeta,
revisionInput,
@@ -27,6 +28,7 @@ export function setupOpenApi(app: INestApplication) {
const bodies: Record<string, z.ZodType> = {
'POST /api/auth/register': credentials,
'POST /api/auth/login': credentials,
'PATCH /api/auth/credentials': credentialChange,
'POST /api/auth/reveal': credentials.pick({ password: true }),
'POST /api/positions': positionInput,
'PATCH /api/positions/{id}': positionMeta,
+12 -3
View File
@@ -30,6 +30,7 @@ import { Prisma } from '@prisma/client';
import { IconsService } from './icons';
import { RatesService } from './rates';
import { captureReplay } from './replay';
import { changeMovement } from './transfers';
@Controller('api')
export class PortfolioController {
constructor(
@@ -229,7 +230,6 @@ export class PortfolioController {
@Param('revisionId') revisionId: string,
) {
return this.db.serial(async (tx) => {
const replay = await captureReplay(tx, r.userId, [id]);
const p = await tx.position.findFirst({
where: { id, userId: r.userId, ...(r.revealed ? {} : { hidden: false }) },
});
@@ -237,8 +237,17 @@ export class PortfolioController {
if (p.archived) throw new ConflictException('请先恢复归档项目');
const row = await tx.revision.findFirst({ where: { id: revisionId, positionId: id } });
if (!row) throw new NotFoundException('历史记录不存在');
if (pairedReasons.includes(row.reason))
throw new ConflictException('请在资金往来中删除完整配对记录');
if (pairedReasons.includes(row.reason)) {
const movement = await tx.transfer.findFirst({
where: {
userId: r.userId,
OR: [{ sourceRevisionId: revisionId }, { targetRevisionId: revisionId }],
},
});
if (!movement) throw new ConflictException('配对记录不完整,无法删除');
return changeMovement(tx, r, movement.id);
}
const replay = await captureReplay(tx, r.userId, [id]);
await tx.revision.delete({ where: { id: revisionId } });
await replay();
return { ok: true };
+2 -3
View File
@@ -51,10 +51,9 @@ export async function captureReplay(tx: Prisma.TransactionClient, userId: string
: undefined;
if (pairedReasons.includes(row.reason) && !delta)
throw new ConflictException('配对记录不完整,无法重算');
if (delta && !balances.has(row.positionId))
throw new ConflictException('资金操作之前必须保留一条余额记录');
// With the initial observation deleted, remaining movements start at zero.
const amount = delta
? balances.get(row.positionId)!.plus(delta)
? (balances.get(row.positionId) || new Decimal(0)).plus(delta)
: new Decimal(row.amount.toString());
if (amount.isNegative() && (p.kind !== 'account' || p.side !== 'asset'))
throw new BadRequestException('修改后债务或资产金额不能为负数');
+2 -2
View File
@@ -222,8 +222,8 @@ export class SchedulesController {
revisions: { orderBy: [{ effectiveDate: 'desc' }, { sequence: 'desc' }], take: 1 },
},
});
if (!account?.revisions[0]) throw new BadRequestException('计划账户已归档或不可用');
const after = new Decimal(account.revisions[0].amount.toString()).minus(
if (!account) throw new BadRequestException('计划账户已归档或不可用');
const after = new Decimal(account.revisions[0]?.amount.toString() || '0').minus(
plan.amount.toString(),
);
if (after.abs().gte('10000000000000000'))
+69 -47
View File
@@ -48,6 +48,25 @@ export class TransfersController {
revealed: r.revealed,
};
}
@Get('revision/:revisionId') async byRevision(
@Req() r: UserRequest,
@Param('revisionId') revisionId: string,
) {
const row = await this.db.transfer.findFirst({
where: {
userId: r.userId,
OR: [{ sourceRevisionId: revisionId }, { targetRevisionId: revisionId }],
...(r.revealed ? {} : { source: { hidden: false }, target: { hidden: false } }),
},
include: {
source: { select: { name: true, kind: true } },
target: { select: { name: true, kind: true } },
},
});
if (!row) throw new NotFoundException('资金往来记录不存在');
const { userId, importedFromId, effectiveDate, ...v } = row;
return { ...v, date: businessTime(effectiveDate) };
}
@Put(':id') async edit(@Req() r: UserRequest, @Param('id') id: string, @Body() body: unknown) {
const v = transferInput.parse(body);
return this.change(r, id, v);
@@ -57,50 +76,7 @@ export class TransfersController {
}
private async change(r: UserRequest, id: string, v?: ReturnType<typeof transferInput.parse>) {
return this.db.serial(async (tx) => {
const row = await tx.transfer.findFirst({
where: {
id,
userId: r.userId,
...(r.revealed ? {} : { source: { hidden: false }, target: { hidden: false } }),
},
include: { source: true, target: true },
});
if (!row) throw new NotFoundException('资金往来记录不存在');
if (row.source.archived || row.target.archived)
throw new ConflictException('请先恢复归档项目');
const replay = await captureReplay(tx, r.userId, [row.sourceId, row.targetId]);
if (v) {
if (
v.sourceId !== row.sourceId ||
v.targetId !== row.targetId ||
v.operation !== row.operation
)
throw new BadRequestException('修改记录不能更换账户或操作类型,请删除后重新创建');
if (row.sourceCurrency === row.targetCurrency && !new Decimal(v.amount).eq(v.received))
throw new BadRequestException('同币种转出与到账金额必须一致,手续费单独填写');
const effectiveDate = toBusinessDate(v.date);
await tx.transfer.update({
where: { id },
data: {
amount: v.amount,
received: v.received,
fee: v.fee,
notes: v.notes,
effectiveDate,
},
});
await tx.revision.updateMany({
where: { id: { in: [row.sourceRevisionId, row.targetRevisionId] } },
data: { effectiveDate, notes: v.notes },
});
} else {
await tx.transfer.delete({ where: { id } });
await tx.revision.deleteMany({
where: { id: { in: [row.sourceRevisionId, row.targetRevisionId] } },
});
}
await replay();
return { ok: true };
return changeMovement(tx, r, id, v);
});
}
@Post() async create(@Req() r: UserRequest, @Body() body: unknown) {
@@ -108,6 +84,52 @@ export class TransfersController {
return this.db.serial((tx) => executeMovement(tx, r, v));
}
}
export async function changeMovement(
tx: Prisma.TransactionClient,
r: Pick<UserRequest, 'userId' | 'revealed'>,
id: string,
v?: ReturnType<typeof transferInput.parse>,
) {
const row = await tx.transfer.findFirst({
where: {
id,
userId: r.userId,
...(r.revealed ? {} : { source: { hidden: false }, target: { hidden: false } }),
},
include: { source: true, target: true },
});
if (!row) throw new NotFoundException('资金往来记录不存在');
if (row.source.archived || row.target.archived) throw new ConflictException('请先恢复归档项目');
const replay = await captureReplay(tx, r.userId, [row.sourceId, row.targetId]);
if (v) {
if (v.sourceId !== row.sourceId || v.targetId !== row.targetId || v.operation !== row.operation)
throw new BadRequestException('修改记录不能更换账户或操作类型,请删除后重新创建');
if (row.sourceCurrency === row.targetCurrency && !new Decimal(v.amount).eq(v.received))
throw new BadRequestException('同币种转出与到账金额必须一致,手续费单独填写');
const effectiveDate = toBusinessDate(v.date);
await tx.transfer.update({
where: { id },
data: {
amount: v.amount,
received: v.received,
fee: v.fee,
notes: v.notes,
effectiveDate,
},
});
await tx.revision.updateMany({
where: { id: { in: [row.sourceRevisionId, row.targetRevisionId] } },
data: { effectiveDate, notes: v.notes },
});
} else {
await tx.transfer.delete({ where: { id } });
await tx.revision.deleteMany({
where: { id: { in: [row.sourceRevisionId, row.targetRevisionId] } },
});
}
await replay();
return { ok: true };
}
export async function executeMovement(
tx: Prisma.TransactionClient,
r: Pick<UserRequest, 'userId' | 'revealed'>,
@@ -166,14 +188,14 @@ export async function executeMovement(
target.side !== (['borrow', 'repay'].includes(v.operation) ? 'liability' : 'asset'))
)
throw new BadRequestException('请选择有效的资产账户和对应借入或借出债务');
if (accounts.some((p) => !p.revisions[0] || +p.revisions[0].effectiveDate > +when))
if (accounts.some((p) => p.revisions[0] && +p.revisions[0].effectiveDate > +when))
throw new ConflictException('转账时间不能早于任一账户的最新余额记录,请以当前余额转账');
if (source.currency === target.currency && !new Decimal(v.amount).eq(v.received))
throw new BadRequestException('同币种转出与到账金额必须一致,手续费单独填写');
const deltas = movementDeltas(v.operation, v.amount, v.received, v.fee);
const before = new Decimal(source.revisions[0].amount.toString());
const before = new Decimal(source.revisions[0]?.amount.toString() || '0');
const sourceAfter = before.plus(deltas.source);
const after = new Decimal(target.revisions[0].amount.toString()).plus(deltas.target);
const after = new Decimal(target.revisions[0]?.amount.toString() || '0').plus(deltas.target);
if (target.kind === 'debt' && after.isNegative())
throw new BadRequestException('收款或还款不能超过剩余债务');
if (after.abs().gte('10000000000000000') || sourceAfter.abs().gte('10000000000000000'))
+9
View File
@@ -173,3 +173,12 @@ export const pairedReasons = [
'loan_collect',
'loan_repay',
];
export const credentialChange = z
.object({
currentPassword: credentials.shape.password,
username: credentials.shape.username.optional(),
newPassword: credentials.shape.password.optional(),
})
.strict()
.refine((v) => !!v.username || !!v.newPassword, '请填写新的账号或密码');
+18 -1
View File
@@ -119,7 +119,24 @@ test('record edits replay paired movements, expense deltas, anchors and calendar
await portfolio.deleteRevision(r, a.id, expense.id);
assert.equal(await balance(a.id), '190');
assert.equal((await calendar.day(r, '2026-09-03')).items.length, 0);
await assert.rejects(portfolio.deleteRevision(r, a.id, a.revisions[0].id));
await portfolio.deleteRevision(r, a.id, a.revisions[0].id);
assert.equal(await balance(a.id), '-10');
const restoredInitial = await db.revision.create({
data: {
positionId: a.id,
amount: '200',
effectiveDate: toBusinessDate('2026-09-01T00:00'),
reason: 'initial',
notes: '',
},
});
a.revisions[0].id = restoredInitial.id;
// Replay after restoring a baseline through the correction API.
await portfolio.correct(r, a.id, restoredInitial.id, {
amount: '200',
date: '2026-09-01T00:00',
notes: '',
});
assert.equal(await balance(a.id), '190');
// An observed absolute balance is an anchor, including after a moved transfer.
await portfolio.revise(r, a.id, {
+247
View File
@@ -0,0 +1,247 @@
import 'dotenv/config';
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { randomUUID, randomBytes, createHash } from 'node:crypto';
import { hash } from 'bcryptjs';
import { PrismaClient } from '@prisma/client';
import { readBackupZip } from '../src/zip';
const base = process.env.TEST_API_URL || 'http://127.0.0.1:3100/api';
test('credentials rotate sessions; deleting paired and empty histories preserves ZIP recovery', async () => {
const db = new PrismaClient(),
ids: string[] = [];
const origin = process.env.WEB_ORIGIN === '*' ? 'http://localhost:5173' : process.env.WEB_ORIGIN!;
async function call(path: string, cookie = '', method = 'GET', body?: unknown) {
const res = await fetch(base + path, {
method,
headers: {
Cookie: cookie,
Origin: origin,
...(body ? { 'Content-Type': 'application/json' } : {}),
},
body: body ? JSON.stringify(body) : undefined,
});
return {
status: res.status,
data: await res.json(),
cookie: res.headers.get('set-cookie')?.split(';')[0] || '',
};
}
async function session(userId: string) {
const token = randomBytes(32).toString('hex');
await db.session.create({
data: {
id: createHash('sha256').update(token).digest('hex'),
userId,
expiresAt: new Date(Date.now() + 3600000),
},
});
return 'wp_session=' + token;
}
async function user() {
const username = 'wp_fix_' + randomUUID(),
password = randomBytes(18).toString('hex');
const user = await db.user.create({
data: { username, passwordHash: await hash(password, 12) },
});
ids.push(user.id);
const cookie = await session(user.id);
return { id: user.id, username, password, cookie };
}
try {
const a = await user(),
b = await user();
const other = { cookie: await session(a.id) };
assert.equal(
(
await call('/auth/credentials', a.cookie, 'PATCH', {
username: b.username,
currentPassword: 'wrong-password',
})
).status,
403,
);
assert.equal(
(
await call('/auth/credentials', a.cookie, 'PATCH', {
username: b.username,
currentPassword: a.password,
})
).status,
409,
);
assert.equal((await call('/auth/me', other.cookie)).status, 200);
const username = 'wp_changed_' + randomUUID(),
password = randomBytes(18).toString('hex');
const changed = await call('/auth/credentials', a.cookie, 'PATCH', {
username,
currentPassword: a.password,
newPassword: password,
});
assert.equal(changed.status, 200);
a.cookie = changed.cookie;
assert.equal((await call('/auth/me', other.cookie)).status, 401);
assert.equal((await call('/auth/me', a.cookie)).data.username, username);
assert.equal(
(await call('/auth/login', '', 'POST', { username: a.username, password: a.password }))
.status,
401,
);
assert.equal(
(await call('/auth/login', '', 'POST', { username, password: a.password })).status,
401,
);
assert.equal((await call('/auth/login', '', 'POST', { username, password })).status, 201);
await db.session.updateMany({
where: { userId: a.id },
data: { revealUntil: new Date(Date.now() + 300000) },
});
assert.equal((await call('/auth/me', a.cookie)).data.revealed, true);
async function create(name: string, amount: string) {
const result = await call('/positions', a.cookie, 'POST', {
name,
amount,
kind: 'account',
side: 'asset',
category: 'bank',
currency: 'CNY',
date: '2026-09-01T00:00',
});
assert.equal(result.status, 201);
return result.data.id;
}
const sourceId = await create('source', '100'),
targetId = await create('target', '0'),
emptyId = await create('empty', '1');
const rev = (id: string) =>
db.revision.findFirstOrThrow({ where: { positionId: id }, orderBy: { sequence: 'asc' } });
const remove = (id: string, revisionId: string, cookie = a.cookie) =>
call('/positions/' + id + '/revisions/' + revisionId, cookie, 'DELETE');
assert.equal((await remove(emptyId, (await rev(emptyId)).id)).status, 200);
const movement = await call('/transfers', a.cookie, 'POST', {
sourceId,
targetId,
amount: '10',
received: '10',
fee: '0',
date: '2026-09-02T00:00',
});
assert.equal(movement.status, 201);
const pair = await db.transfer.findUniqueOrThrow({ where: { id: movement.data.id } });
assert.equal((await remove(sourceId, pair.sourceRevisionId, b.cookie)).status, 404);
assert.equal((await remove(sourceId, (await rev(sourceId)).id)).status, 200);
assert.equal((await remove(targetId, (await rev(targetId)).id)).status, 200);
assert.equal(
(await call('/transfers/revision/' + pair.sourceRevisionId, a.cookie)).data.id,
pair.id,
);
const download = await fetch(base + '/backup', { headers: { Cookie: a.cookie } });
assert.equal(download.status, 200);
assert.equal(download.headers.get('content-type')?.includes('application/zip'), true);
const bytes = Buffer.from(await download.arrayBuffer());
const backup: any = await readBackupZip(bytes);
assert.equal(backup.positions.find((p: any) => p.id === emptyId).revisions.length, 0);
assert.equal(backup.positions.find((p: any) => p.id === sourceId).revisions[0].amount, '-10');
const form = new FormData();
form.append('file', new Blob([bytes]), 'backup.zip');
const upload = await fetch(base + '/backup/upload', {
method: 'POST',
headers: { Cookie: b.cookie, Origin: origin },
body: form,
});
assert.equal(upload.status, 201);
const preview: any = await upload.json();
assert.equal(
(
await call('/backup/import-file', a.cookie, 'POST', {
token: preview.token,
confirmed: true,
})
).status,
400,
);
assert.equal(
(
await call('/backup/import-file', b.cookie, 'POST', {
token: preview.token,
confirmed: true,
})
).status,
201,
);
const restored = (await call('/positions', b.cookie)).data;
assert.equal(restored.find((p: any) => p.name === 'source').amount, '-10');
assert.equal(restored.find((p: any) => p.name === 'target').amount, '10');
assert.equal(restored.find((p: any) => p.name === 'empty').amount, '0');
await db.position.update({ where: { id: targetId }, data: { hidden: true } });
await call('/auth/lock', a.cookie, 'POST');
assert.equal((await remove(sourceId, pair.sourceRevisionId)).status, 404);
assert.equal(await db.transfer.count({ where: { id: pair.id } }), 1);
await db.session.updateMany({
where: { userId: a.id },
data: { revealUntil: new Date(Date.now() + 300000) },
});
assert.equal((await remove(sourceId, pair.sourceRevisionId)).status, 200);
assert.equal(await db.transfer.count({ where: { id: pair.id } }), 0);
assert.equal(
await db.revision.count({
where: { id: { in: [pair.sourceRevisionId, pair.targetRevisionId] } },
}),
0,
);
assert.equal((await call('/positions/' + sourceId, a.cookie)).data.amount, '0');
assert.equal((await call('/positions/' + targetId, a.cookie)).data.amount, '0');
assert.equal((await remove(sourceId, pair.sourceRevisionId)).status, 404);
const minute = (delta = 0) =>
new Date(Date.now() + delta + 8 * 3600000).toISOString().slice(0, 16);
const plan = await call('/schedules', a.cookie, 'POST', {
name: 'Empty account expense',
operation: 'expense',
sourceId,
targetId: null,
amount: '2',
received: '0',
nextAt: minute(-60000),
intervalDays: 0,
notes: '',
});
assert.equal(plan.status, 201);
assert.equal((await call('/schedules/run', a.cookie, 'POST', {})).data.executed, 1);
const today = minute().slice(0, 10);
const calendar = (await call('/calendar?month=' + today.slice(0, 7), a.cookie)).data;
assert.equal(
calendar.items.find((v: any) => v.date === minute(-60000).slice(0, 10)).expense,
'2.00',
);
assert.equal(
(
await call('/transfers', a.cookie, 'POST', {
sourceId,
targetId,
amount: '3',
received: '3',
fee: '0',
date: minute(),
})
).status,
201,
);
const emptyAccountBackup = await fetch(base + '/backup', { headers: { Cookie: a.cookie } });
assert.equal(emptyAccountBackup.status, 200);
const emptyAccountData: any = await readBackupZip(
Buffer.from(await emptyAccountBackup.arrayBuffer()),
);
assert.equal(
emptyAccountData.positions.find((p: any) => p.id === sourceId).revisions.at(-1).amount,
'-5',
);
assert.equal((await call('/backup/preview', b.cookie, 'POST', emptyAccountData)).status, 409);
const fresh = await user();
assert.equal(
(await call('/backup/preview', fresh.cookie, 'POST', emptyAccountData)).status,
201,
);
} finally {
await db.user.deleteMany({ where: { id: { in: ids } } });
await db.$disconnect();
}
});