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
@@ -85,8 +85,13 @@ test('transfers are atomic, scoped, retry-safe, decimal exact and included in ba
|
||||
400,
|
||||
);
|
||||
assert.equal(
|
||||
(await call('/transfers', a.cookie, 'POST', { ...request, amount: '100', received: '100' }))
|
||||
.status,
|
||||
(
|
||||
await call('/transfers', a.cookie, 'POST', {
|
||||
...request,
|
||||
amount: '10000000000000000',
|
||||
received: '10000000000000000',
|
||||
})
|
||||
).status,
|
||||
400,
|
||||
);
|
||||
assert.equal(await db.transfer.count({ where: { userId: a.id } }), 0);
|
||||
@@ -190,10 +195,14 @@ test('transfers are atomic, scoped, retry-safe, decimal exact and included in ba
|
||||
);
|
||||
assert.equal(
|
||||
race.filter((r) => r.status === 201).length,
|
||||
1,
|
||||
2,
|
||||
JSON.stringify(race.map((r) => ({ status: r.status, data: r.data }))),
|
||||
);
|
||||
assert.equal(race.filter((r) => [400, 409].includes(r.status)).length, 1);
|
||||
assert.equal(race.filter((r) => [400, 409].includes(r.status)).length, 0);
|
||||
assert.equal(
|
||||
(await call('/positions', a.cookie)).data.find((p: any) => p.id === usd).amount,
|
||||
'-11.00000001',
|
||||
);
|
||||
assert.equal(
|
||||
(
|
||||
await call('/settings', a.cookie, 'PATCH', {
|
||||
@@ -212,7 +221,7 @@ test('transfers are atomic, scoped, retry-safe, decimal exact and included in ba
|
||||
);
|
||||
assert.equal((await call('/settings', a.cookie, 'PATCH', { showSidebar: false })).status, 400);
|
||||
const backup = (await call('/backup', a.cookie)).data;
|
||||
assert.equal(backup.transfers.length, 3);
|
||||
assert.equal(backup.transfers.length, 4);
|
||||
assert.equal(backup.preferences.showNotes, false);
|
||||
assert.deepEqual(backup.preferences.hiddenMenus, ['asset', 'debt']);
|
||||
const broken = structuredClone(backup);
|
||||
@@ -227,7 +236,7 @@ test('transfers are atomic, scoped, retry-safe, decimal exact and included in ba
|
||||
201,
|
||||
);
|
||||
const restored = (await call('/backup', b.cookie)).data;
|
||||
assert.equal(restored.transfers.length, 3);
|
||||
assert.equal(restored.transfers.length, 4);
|
||||
// Appending into an existing space preserves its display preferences.
|
||||
assert.equal((await call('/settings', b.cookie)).data.showNotes, true);
|
||||
const c = await account();
|
||||
@@ -254,7 +263,7 @@ test('transfers are atomic, scoped, retry-safe, decimal exact and included in ba
|
||||
hidden: true,
|
||||
});
|
||||
const visibleTransfers = (await call('/transfers', a.cookie)).data.items;
|
||||
assert.equal(visibleTransfers.length, 2);
|
||||
assert.equal(visibleTransfers.length, 3);
|
||||
assert.ok(
|
||||
visibleTransfers.every((t: any) => t.sourceId !== sourceId && t.targetId !== sourceId),
|
||||
);
|
||||
|
||||
@@ -0,0 +1,157 @@
|
||||
import 'dotenv/config';
|
||||
import { test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { randomUUID, randomBytes } from 'node:crypto';
|
||||
import { PrismaClient } from '@prisma/client';
|
||||
import { today } from '../src/validation';
|
||||
import { readBackupZip } from '../src/zip';
|
||||
const base = process.env.TEST_API_URL || 'http://127.0.0.1:3100/api';
|
||||
test('real MySQL: groups, scheduled atomic execution, calendar, privacy and ZIP recovery', async () => {
|
||||
const db = new PrismaClient(),
|
||||
names: string[] = [];
|
||||
const minute = (delta = 0) =>
|
||||
new Date(Date.now() + delta + 8 * 3600000).toISOString().slice(0, 16);
|
||||
async function call(path: string, cookie = '', method = 'GET', body?: unknown) {
|
||||
const res = await fetch(base + path, {
|
||||
method,
|
||||
headers: {
|
||||
Cookie: cookie,
|
||||
Origin: process.env.WEB_ORIGIN === '*' ? 'http://localhost:5173' : process.env.WEB_ORIGIN!,
|
||||
...(body ? { 'Content-Type': 'application/json' } : {}),
|
||||
},
|
||||
body: body ? JSON.stringify(body) : undefined,
|
||||
});
|
||||
return {
|
||||
status: res.status,
|
||||
data: res.headers.get('content-type')?.includes('application/zip')
|
||||
? ((await readBackupZip(Buffer.from(await res.arrayBuffer()))) as any)
|
||||
: await res.json(),
|
||||
cookie: res.headers.get('set-cookie')?.split(';')[0] || '',
|
||||
};
|
||||
}
|
||||
async function user() {
|
||||
const username = 'wp_update_' + randomUUID(),
|
||||
password = randomBytes(18).toString('hex');
|
||||
names.push(username);
|
||||
const result = await call('/auth/register', '', 'POST', { username, password });
|
||||
assert.equal(result.status, 201);
|
||||
return {
|
||||
cookie: result.cookie,
|
||||
password,
|
||||
id: (await db.user.findUniqueOrThrow({ where: { username } })).id,
|
||||
};
|
||||
}
|
||||
try {
|
||||
const a = await user(),
|
||||
b = await user();
|
||||
const create = async (name: string, amount: string, groupName: string) => {
|
||||
const result = await call('/positions', a.cookie, 'POST', {
|
||||
name,
|
||||
amount,
|
||||
groupName,
|
||||
kind: 'account',
|
||||
side: 'asset',
|
||||
category: 'bank',
|
||||
currency: 'CNY',
|
||||
date: '2026-09-01T10:00',
|
||||
});
|
||||
assert.equal(result.status, 201);
|
||||
return result.data.id;
|
||||
};
|
||||
const sourceId = await create('Daily', '10', '日常'),
|
||||
targetId = await create('Invest', '0', '理财');
|
||||
assert.equal(
|
||||
(await call('/positions', a.cookie)).data.find((p: any) => p.id === sourceId).groupName,
|
||||
'日常',
|
||||
);
|
||||
const request = {
|
||||
sourceId,
|
||||
targetId,
|
||||
amount: '25',
|
||||
received: '25',
|
||||
fee: '0',
|
||||
date: minute(),
|
||||
requestId: randomUUID(),
|
||||
};
|
||||
assert.equal((await call('/transfers', a.cookie, 'POST', request)).status, 201);
|
||||
assert.equal(
|
||||
(await call('/positions', a.cookie)).data.find((p: any) => p.id === sourceId).amount,
|
||||
'-15',
|
||||
);
|
||||
const input = {
|
||||
name: 'Rent',
|
||||
operation: 'expense',
|
||||
sourceId,
|
||||
targetId: null,
|
||||
amount: '2.5',
|
||||
received: '0',
|
||||
nextAt: minute(-60000),
|
||||
intervalDays: 0,
|
||||
notes: '',
|
||||
};
|
||||
const plan = await call('/schedules', a.cookie, 'POST', input);
|
||||
assert.equal(plan.status, 201);
|
||||
assert.equal((await call('/schedules', b.cookie)).data.length, 0);
|
||||
assert.equal(
|
||||
(await call('/schedules/' + plan.data.id, b.cookie, 'PATCH', { enabled: false })).status,
|
||||
404,
|
||||
);
|
||||
const race = await Promise.all([
|
||||
call('/schedules/run', a.cookie, 'POST', {}),
|
||||
call('/schedules/run', a.cookie, 'POST', {}),
|
||||
]);
|
||||
assert.equal(
|
||||
race.reduce((n, v) => n + v.data.executed, 0),
|
||||
1,
|
||||
);
|
||||
assert.equal(
|
||||
(await call('/positions', a.cookie)).data.find((p: any) => p.id === sourceId).amount,
|
||||
'-17.5',
|
||||
);
|
||||
assert.equal(
|
||||
(await call('/schedules/' + plan.data.id, a.cookie, 'PATCH', { enabled: true })).status,
|
||||
400,
|
||||
);
|
||||
assert.equal((await call('/schedules/run', a.cookie, 'POST', {})).data.executed, 0);
|
||||
const recurring = await call('/schedules', a.cookie, 'POST', {
|
||||
...input,
|
||||
name: 'Daily expense',
|
||||
amount: '1',
|
||||
intervalDays: 1,
|
||||
nextAt: minute(-2 * 86400000),
|
||||
});
|
||||
assert.equal(recurring.status, 201);
|
||||
for (let i = 0; i < 3; i++)
|
||||
assert.equal((await call('/schedules/run', a.cookie, 'POST', {})).data.executed, 1);
|
||||
assert.equal((await call('/schedules/run', a.cookie, 'POST', {})).data.executed, 0);
|
||||
const calendar = await call('/calendar?month=' + today().slice(0, 7), a.cookie);
|
||||
const day = calendar.data.items.find((v: any) => v.date === today());
|
||||
assert.equal(day.expense, '5.50');
|
||||
assert.equal(day.income, '0.00');
|
||||
assert.equal((await call('/calendar/day?date=' + today(), a.cookie)).data.items.length, 4);
|
||||
const backup = (await call('/backup', a.cookie)).data;
|
||||
assert.equal(backup.schedules.length, 2);
|
||||
assert.equal(
|
||||
(await call('/backup/import', b.cookie, 'POST', { confirmed: true, backup })).status,
|
||||
201,
|
||||
);
|
||||
assert.equal((await call('/schedules/run', b.cookie, 'POST', {})).data.executed, 0);
|
||||
assert.deepEqual(
|
||||
(await call('/positions', b.cookie)).data.map((v: any) => v.groupName).sort(),
|
||||
['日常', '理财'].sort(),
|
||||
);
|
||||
const restored = (await call('/backup', b.cookie)).data;
|
||||
assert.equal(restored.schedules.filter((p: any) => p.completed).length, 1);
|
||||
await call('/positions/' + sourceId, a.cookie, 'PATCH', {
|
||||
name: 'Daily',
|
||||
category: 'bank',
|
||||
groupName: '日常',
|
||||
hidden: true,
|
||||
});
|
||||
assert.equal((await call('/schedules', a.cookie)).data.length, 0);
|
||||
assert.equal((await call('/calendar/day?date=' + today(), a.cookie)).data.items.length, 0);
|
||||
} finally {
|
||||
await db.user.deleteMany({ where: { username: { in: names } } });
|
||||
await db.$disconnect();
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,345 @@
|
||||
import 'reflect-metadata';
|
||||
import { test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { randomUUID } from 'node:crypto';
|
||||
import { cashflowDelta, calendarMonth, CalendarController } from '../src/calendar';
|
||||
import { executeMovement } from '../src/transfers';
|
||||
import { transferInput, positionInput, revisionInput } from '../src/validation';
|
||||
import { scheduleInput, occurrenceId, nextOccurrence, SchedulesController } from '../src/schedules';
|
||||
import { trend, totals, type Holding } from '../src/calculation';
|
||||
import { positionAmount, money, type Position } from '../../web/src/api';
|
||||
const now = new Date(Date.now() - 60000);
|
||||
const date = new Date(+now + 8 * 3600000).toISOString().slice(0, 16);
|
||||
function fixture(
|
||||
targetKind = 'account',
|
||||
targetSide = 'asset',
|
||||
sourceBalance = '10',
|
||||
targetBalance = '0',
|
||||
) {
|
||||
const owner = randomUUID(),
|
||||
sourceId = randomUUID(),
|
||||
targetId = randomUUID();
|
||||
const accounts = [
|
||||
{ id: sourceId, kind: 'account', side: 'asset', currency: 'CNY', userId: owner },
|
||||
{ id: targetId, kind: targetKind, side: targetSide, currency: 'CNY', userId: owner },
|
||||
];
|
||||
const balances = new Map([
|
||||
[sourceId, sourceBalance],
|
||||
[targetId, targetBalance],
|
||||
]),
|
||||
entries: any[] = [],
|
||||
transfers: any[] = [];
|
||||
const tx: any = {
|
||||
$queryRaw: async (query: any) =>
|
||||
query.sql.includes('SELECT r.*')
|
||||
? accounts.map((p) => ({
|
||||
positionId: p.id,
|
||||
amount: balances.get(p.id),
|
||||
effectiveDate: new Date('2026-09-01'),
|
||||
id: randomUUID(),
|
||||
}))
|
||||
: [],
|
||||
position: { findMany: async () => accounts },
|
||||
revision: {
|
||||
create: async ({ data }: any) => {
|
||||
const row = { id: randomUUID(), ...data };
|
||||
entries.push(row);
|
||||
balances.set(data.positionId, data.amount);
|
||||
return row;
|
||||
},
|
||||
},
|
||||
transfer: {
|
||||
findFirst: async ({ where }: any) => transfers.find((v) => v.id === where.id),
|
||||
create: async ({ data }: any) => {
|
||||
transfers.push(data);
|
||||
return data;
|
||||
},
|
||||
},
|
||||
positionLink: { upsert: async () => ({}) },
|
||||
};
|
||||
const request = transferInput.parse({
|
||||
requestId: randomUUID(),
|
||||
sourceId,
|
||||
targetId,
|
||||
amount: '25',
|
||||
received: '25',
|
||||
fee: '1',
|
||||
date,
|
||||
});
|
||||
return {
|
||||
tx,
|
||||
r: { userId: owner, revealed: false },
|
||||
request,
|
||||
balances,
|
||||
entries,
|
||||
transfers,
|
||||
sourceId,
|
||||
targetId,
|
||||
};
|
||||
}
|
||||
test('overdrafts preserve exact paired balances and idempotency', async () => {
|
||||
const f = fixture();
|
||||
await executeMovement(f.tx, f.r, f.request);
|
||||
assert.equal(f.balances.get(f.sourceId), '-16');
|
||||
assert.equal(f.balances.get(f.targetId), '25');
|
||||
assert.equal(f.entries.length, 2);
|
||||
await executeMovement(f.tx, f.r, f.request);
|
||||
assert.equal(f.entries.length, 2);
|
||||
await assert.rejects(() => executeMovement(f.tx, f.r, { ...f.request, amount: '26' }));
|
||||
const p: Holding = {
|
||||
id: f.sourceId,
|
||||
name: '',
|
||||
kind: 'account',
|
||||
side: 'asset',
|
||||
currency: 'CNY',
|
||||
revisions: [{ id: '', amount: '-16', effectiveDate: now, reason: 'balance', notes: '' }],
|
||||
};
|
||||
assert.equal(totals([p], [], 'CNY', date.slice(0, 10)).net, '-16.00');
|
||||
});
|
||||
test('transfer into an overdrawn account is allowed; debt overpayment still fails before writing', async () => {
|
||||
const f = fixture('account', 'asset', '10', '-100');
|
||||
await executeMovement(f.tx, f.r, f.request);
|
||||
assert.equal(f.balances.get(f.targetId), '-75');
|
||||
const debt = fixture('debt', 'liability', '0', '10');
|
||||
await assert.rejects(() =>
|
||||
executeMovement(debt.tx, debt.r, { ...debt.request, operation: 'repay' }),
|
||||
);
|
||||
assert.equal(debt.entries.length, 0);
|
||||
await executeMovement(debt.tx, debt.r, {
|
||||
...debt.request,
|
||||
operation: 'repay',
|
||||
amount: '10',
|
||||
received: '10',
|
||||
});
|
||||
assert.equal(debt.balances.get(debt.sourceId), '-11');
|
||||
assert.equal(debt.balances.get(debt.targetId), '0');
|
||||
});
|
||||
test('negative balances are confined to asset accounts; group names and principal remain validated', () => {
|
||||
const data = {
|
||||
name: 'test',
|
||||
category: 'bank',
|
||||
kind: 'account',
|
||||
side: 'asset',
|
||||
amount: '-12.12345678',
|
||||
currency: 'CNY',
|
||||
date,
|
||||
groupName: '日常',
|
||||
};
|
||||
assert.equal(positionInput.parse(data).groupName, '日常');
|
||||
assert.equal(positionInput.safeParse({ ...data, kind: 'debt' }).success, false);
|
||||
assert.equal(positionInput.safeParse({ ...data, side: 'liability' }).success, false);
|
||||
assert.equal(positionInput.safeParse({ ...data, groupName: 'a'.repeat(61) }).success, false);
|
||||
assert.equal(revisionInput.parse({ amount: '-0.00000001', date }).amount, '-0.00000001');
|
||||
assert.equal(transferInput.safeParse({ ...fixture().request, amount: '-1' }).success, false);
|
||||
});
|
||||
test('trend starts at the first balance, retains valid zero days and handles an empty portfolio', () => {
|
||||
const p: Holding = {
|
||||
id: randomUUID(),
|
||||
name: '',
|
||||
kind: 'account',
|
||||
side: 'asset',
|
||||
currency: 'CNY',
|
||||
revisions: [
|
||||
{
|
||||
id: '',
|
||||
amount: '0',
|
||||
effectiveDate: new Date('2026-09-15T02:00:00Z'),
|
||||
notes: '',
|
||||
reason: 'initial',
|
||||
},
|
||||
],
|
||||
};
|
||||
const rows = trend([p], [], 'CNY', '2026-09-01', '2026-09-17');
|
||||
assert.deepEqual(
|
||||
rows.map((r) => r.date),
|
||||
['2026-09-15', '2026-09-16', '2026-09-17'],
|
||||
);
|
||||
assert.equal(rows[0].net, '0.00');
|
||||
assert.deepEqual(trend([], [], 'CNY', '2026-09-01', '2026-09-17'), []);
|
||||
assert.deepEqual(trend([p], [], 'CNY', '2026-09-01', '2026-09-14'), []);
|
||||
});
|
||||
test('debt signs and currency symbols preserve the original decimal strings', () => {
|
||||
const p = { kind: 'debt', side: 'liability', amount: '9999999999999999.12345678' } as Position;
|
||||
assert.equal(positionAmount(p), '-9999999999999999.12345678');
|
||||
assert.equal(money(positionAmount(p), 'HKD'), 'HK$ -9,999,999,999,999,999.12345678');
|
||||
assert.equal(positionAmount({ ...p, amount: '0.00000000' }), '0.00000000');
|
||||
assert.equal(positionAmount({ ...p, side: 'asset' }), p.amount);
|
||||
});
|
||||
test('schedule validation rejects malformed times and creates stable occurrence identifiers', () => {
|
||||
const data = {
|
||||
name: '租金',
|
||||
operation: 'expense',
|
||||
sourceId: randomUUID(),
|
||||
amount: '100',
|
||||
nextAt: '2026-11-01T09:00',
|
||||
intervalDays: 30,
|
||||
};
|
||||
assert.equal(scheduleInput.safeParse(data).success, true);
|
||||
assert.equal(scheduleInput.safeParse({ ...data, nextAt: '2026-02-30T09:00' }).success, false);
|
||||
assert.equal(scheduleInput.safeParse({ ...data, operation: 'transfer' }).success, false);
|
||||
assert.equal(scheduleInput.safeParse({ ...data, intervalDays: -1 }).success, false);
|
||||
assert.equal(occurrenceId('plan', now), occurrenceId('plan', now));
|
||||
assert.notEqual(occurrenceId('plan', now), occurrenceId('plan', nextOccurrence(now, 1)));
|
||||
assert.equal(+nextOccurrence(now, 30) - +now, 30 * 86400000);
|
||||
});
|
||||
test('due expense runs once, updates balance exactly and advances a recurring plan only after success', async () => {
|
||||
const owner = randomUUID(),
|
||||
sourceId = randomUUID();
|
||||
const plan: any = {
|
||||
id: randomUUID(),
|
||||
userId: owner,
|
||||
name: 'Rent',
|
||||
operation: 'expense',
|
||||
sourceId,
|
||||
targetId: null,
|
||||
amount: '12.00000001',
|
||||
received: '0',
|
||||
nextAt: new Date(Date.now() - 3600000),
|
||||
intervalDays: 0,
|
||||
enabled: true,
|
||||
notes: '',
|
||||
};
|
||||
const entries: any[] = [];
|
||||
let balance = '10';
|
||||
const tx: any = {
|
||||
$queryRaw: async () => [],
|
||||
position: {
|
||||
findMany: async () => [{ id: sourceId }],
|
||||
findFirst: async () => ({ id: sourceId, revisions: [{ amount: balance }] }),
|
||||
},
|
||||
schedule: {
|
||||
findMany: async () => (plan.enabled ? [{ ...plan }] : []),
|
||||
findFirst: async () => (plan.enabled ? { ...plan } : null),
|
||||
update: async ({ data }: any) => Object.assign(plan, data),
|
||||
},
|
||||
revision: {
|
||||
create: async ({ data }: any) => {
|
||||
entries.push(data);
|
||||
balance = data.amount;
|
||||
return data;
|
||||
},
|
||||
},
|
||||
};
|
||||
const db: any = { ...tx, serial: async (work: any) => work(tx) };
|
||||
const c = new SchedulesController(db),
|
||||
r: any = { userId: owner, revealed: false };
|
||||
assert.equal((await c.run(r)).executed, 1);
|
||||
assert.equal(balance, '-2.00000001');
|
||||
assert.equal(plan.enabled, false);
|
||||
assert.equal(plan.completed, true);
|
||||
assert.equal((await c.run(r)).executed, 0);
|
||||
assert.equal(entries.length, 1);
|
||||
const before = new Date(Date.now() - 3600000);
|
||||
Object.assign(plan, { enabled: true, nextAt: before, intervalDays: 30 });
|
||||
assert.equal((await c.run(r)).executed, 1);
|
||||
assert.equal(+plan.nextAt, +before + 30 * 86400000);
|
||||
const next = plan.nextAt;
|
||||
tx.position.findFirst = async () => null;
|
||||
plan.nextAt = before;
|
||||
const result = await c.run(r);
|
||||
assert.equal(result.executed, 0);
|
||||
assert.equal(result.errors.length, 1);
|
||||
assert.equal(+plan.nextAt, +before);
|
||||
assert.equal(entries.length, 2);
|
||||
});
|
||||
|
||||
test('calendar excludes transfers, loans and initial balances and reverses liability changes', () => {
|
||||
for (const reason of [
|
||||
'transfer_out',
|
||||
'transfer_in',
|
||||
'loan_repay',
|
||||
'loan_collect',
|
||||
'loan_borrow',
|
||||
'loan_lend',
|
||||
'initial',
|
||||
'valuation',
|
||||
])
|
||||
assert.equal(cashflowDelta('asset', reason, '100', '50').toFixed(), '0');
|
||||
assert.equal(cashflowDelta('asset', 'balance', '100', '150').toFixed(), '50');
|
||||
assert.equal(cashflowDelta('asset', 'scheduled_expense', '100', '50').toFixed(), '-50');
|
||||
assert.equal(cashflowDelta('liability', 'balance', '100', '150').toFixed(), '-50');
|
||||
assert.equal(calendarMonth('2024-02').days, 29);
|
||||
assert.throws(() => calendarMonth('2026-13'));
|
||||
});
|
||||
test('calendar replays only the range, preserves fractional totals, and hides incomplete FX totals', async () => {
|
||||
const accounts = [
|
||||
{ id: 'cash', side: 'asset', name: '现金', currency: 'CNY' },
|
||||
{ id: 'usd', side: 'asset', name: '外币', currency: 'USD' },
|
||||
];
|
||||
const rows = [
|
||||
{
|
||||
id: '1',
|
||||
positionId: 'cash',
|
||||
amount: '10',
|
||||
effectiveDate: new Date('2026-09-02T01:00:00Z'),
|
||||
reason: 'correction',
|
||||
sequence: 1,
|
||||
},
|
||||
{
|
||||
id: '2',
|
||||
positionId: 'cash',
|
||||
amount: '10.004',
|
||||
effectiveDate: new Date('2026-09-02T02:00:00Z'),
|
||||
reason: 'balance',
|
||||
sequence: 2,
|
||||
},
|
||||
{
|
||||
id: '3',
|
||||
positionId: 'cash',
|
||||
amount: '10.008',
|
||||
effectiveDate: new Date('2026-09-02T03:00:00Z'),
|
||||
reason: 'balance',
|
||||
sequence: 3,
|
||||
},
|
||||
{
|
||||
id: '4',
|
||||
positionId: 'cash',
|
||||
amount: '8.008',
|
||||
effectiveDate: new Date('2026-09-02T04:00:00Z'),
|
||||
reason: 'scheduled_expense',
|
||||
sequence: 4,
|
||||
},
|
||||
{
|
||||
id: '5',
|
||||
positionId: 'usd',
|
||||
amount: '10',
|
||||
effectiveDate: new Date('2026-09-03T01:00:00Z'),
|
||||
reason: 'initial',
|
||||
sequence: 1,
|
||||
},
|
||||
{
|
||||
id: '6',
|
||||
positionId: 'usd',
|
||||
amount: '11',
|
||||
effectiveDate: new Date('2026-09-03T02:00:00Z'),
|
||||
reason: 'balance',
|
||||
sequence: 2,
|
||||
},
|
||||
];
|
||||
const tx: any = {
|
||||
position: {
|
||||
findMany: async (input: any) => {
|
||||
assert.equal(input.where.userId, 'owner');
|
||||
assert.equal(input.where.hidden, false);
|
||||
return accounts;
|
||||
},
|
||||
},
|
||||
revision: {
|
||||
findMany: async (input: any) => {
|
||||
assert.equal(+input.where.effectiveDate.gte, +new Date('2026-08-31T16:00:00Z'));
|
||||
return rows;
|
||||
},
|
||||
},
|
||||
user: { findUniqueOrThrow: async () => ({ baseCurrency: 'CNY' }) },
|
||||
exchangeRate: { findMany: async () => [], findFirst: async () => null },
|
||||
$queryRaw: async () => [],
|
||||
};
|
||||
const db: any = { $transaction: async (work: any) => work(tx) };
|
||||
const c = new CalendarController(db),
|
||||
r: any = { userId: 'owner', revealed: false };
|
||||
const month = await c.month(r, '2026-09');
|
||||
assert.equal(month.items[1].income, '0.01');
|
||||
assert.equal(month.items[1].expense, '2.00');
|
||||
assert.equal(month.items[2].complete, false);
|
||||
assert.equal(month.items.length, 30);
|
||||
});
|
||||
@@ -14,6 +14,7 @@ const empty = () =>
|
||||
currencies: ['CNY'],
|
||||
icons: [],
|
||||
transfers: [],
|
||||
schedules: [],
|
||||
positions: [],
|
||||
rates: [],
|
||||
links: [],
|
||||
@@ -43,6 +44,7 @@ test('ZIP contains separate JSON files and restores settings without authenticat
|
||||
'links.json',
|
||||
'manifest.json',
|
||||
'rates.json',
|
||||
'schedules.json',
|
||||
'settings.json',
|
||||
'transfers.json',
|
||||
]);
|
||||
@@ -102,10 +104,11 @@ test('legacy v3 ZIP remains readable without icons', async () => {
|
||||
const contents = packBackup(empty());
|
||||
delete contents['icons.json'];
|
||||
delete contents['transfers.json'];
|
||||
delete contents['schedules.json'];
|
||||
const manifest = JSON.parse(contents['manifest.json']);
|
||||
manifest.version = 3;
|
||||
manifest.files = manifest.files.filter(
|
||||
(f: { name: string }) => !['icons.json', 'transfers.json'].includes(f.name),
|
||||
(f: { name: string }) => !['icons.json', 'transfers.json', 'schedules.json'].includes(f.name),
|
||||
);
|
||||
contents['manifest.json'] = JSON.stringify(manifest);
|
||||
const restored = validateBackup(await readBackupZip(await archive(contents)));
|
||||
@@ -116,9 +119,12 @@ test('legacy v3 ZIP remains readable without icons', async () => {
|
||||
test('legacy v4 ZIP remains readable without transfers', async () => {
|
||||
const contents = packBackup(empty());
|
||||
delete contents['transfers.json'];
|
||||
delete contents['schedules.json'];
|
||||
const manifest = JSON.parse(contents['manifest.json']);
|
||||
manifest.version = 4;
|
||||
manifest.files = manifest.files.filter((f: { name: string }) => f.name !== 'transfers.json');
|
||||
manifest.files = manifest.files.filter(
|
||||
(f: { name: string }) => !['transfers.json', 'schedules.json'].includes(f.name),
|
||||
);
|
||||
contents['manifest.json'] = JSON.stringify(manifest);
|
||||
const restored = validateBackup(await readBackupZip(await archive(contents)));
|
||||
assert.deepEqual(restored.icons, []);
|
||||
@@ -126,3 +132,61 @@ test('legacy v4 ZIP remains readable without transfers', async () => {
|
||||
assert.equal(restored.preferences?.showNotes, false);
|
||||
assert.deepEqual(restored.preferences?.hiddenMenus, ['asset']);
|
||||
});
|
||||
|
||||
test('ZIP v7 restores groups and schedules while v6 remains readable', async () => {
|
||||
const id = randomUUID(),
|
||||
stamp = new Date().toISOString();
|
||||
const b = validateBackup({
|
||||
...empty(),
|
||||
positions: [
|
||||
{
|
||||
id,
|
||||
name: '账户',
|
||||
groupName: '日常',
|
||||
kind: 'account',
|
||||
side: 'asset',
|
||||
category: 'bank',
|
||||
currency: 'CNY',
|
||||
notes: '',
|
||||
archived: false,
|
||||
hidden: false,
|
||||
createdAt: stamp,
|
||||
updatedAt: stamp,
|
||||
revisions: [
|
||||
{
|
||||
id: randomUUID(),
|
||||
amount: '-10',
|
||||
date: '2026-09-01T10:00',
|
||||
notes: '',
|
||||
reason: 'balance',
|
||||
createdAt: stamp,
|
||||
updatedAt: stamp,
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
schedules: [
|
||||
{
|
||||
id: randomUUID(),
|
||||
name: '租金',
|
||||
operation: 'expense',
|
||||
sourceId: id,
|
||||
targetId: null,
|
||||
amount: '100',
|
||||
received: '0',
|
||||
nextAt: '2026-11-01T10:00',
|
||||
intervalDays: 30,
|
||||
enabled: true,
|
||||
notes: '',
|
||||
},
|
||||
],
|
||||
});
|
||||
const contents = packBackup(b);
|
||||
assert.deepEqual(validateBackup(await readBackupZip(await archive(contents))), b);
|
||||
delete contents['schedules.json'];
|
||||
const manifest = JSON.parse(contents['manifest.json']);
|
||||
manifest.version = 6;
|
||||
manifest.files = manifest.files.filter((f: { name: string }) => f.name !== 'schedules.json');
|
||||
contents['manifest.json'] = JSON.stringify(manifest);
|
||||
assert.equal(validateBackup(await readBackupZip(await archive(contents))).schedules, undefined);
|
||||
});
|
||||
Reference in new issue
Block a user