feat: support credit transfers and precious metal valuations
This commit is contained in:
1 parent
425b45c91a
commit
518aea1e59
39 files changed
+1572
-77
No files matched your search
@@ -151,7 +151,7 @@ test('borrow/lend/collect/repay pair balances atomically and survive concurrency
|
||||
200,
|
||||
);
|
||||
const backup = (await call(a.token, '/backup')).data;
|
||||
assert.equal(JSON.parse(packBackup(backup)['manifest.json']).version, 7);
|
||||
assert.equal(JSON.parse(packBackup(backup)['manifest.json']).version, 8);
|
||||
assert.equal(backup.transfers.length, 5);
|
||||
assert.ok(backup.transfers.some((p: any) => p.operation === 'lend' && p.fee === '-2'));
|
||||
assert.equal(
|
||||
|
||||
@@ -0,0 +1,296 @@
|
||||
import 'dotenv/config';
|
||||
import { test } from 'node:test';
|
||||
import { request } from 'node:http';
|
||||
import assert from 'node:assert/strict';
|
||||
import { randomUUID, randomBytes, createHash } from 'node:crypto';
|
||||
import { PrismaClient } from '@prisma/client';
|
||||
import { hash } from 'bcryptjs';
|
||||
import { readBackupZip } from '../src/zip';
|
||||
const base = process.env.TEST_API_URL || 'http://127.0.0.1:3100/api';
|
||||
const origin = process.env.WEB_ORIGIN === '*' ? 'http://localhost:5173' : process.env.WEB_ORIGIN!;
|
||||
const password = 'Fixture-session-only-42!';
|
||||
const db = new PrismaClient();
|
||||
async function fixture() {
|
||||
const u = await db.user.create({
|
||||
data: {
|
||||
username: 'wp_settings_' + randomUUID(),
|
||||
passwordHash: await hash(password, 4),
|
||||
idleMinutes: 0,
|
||||
},
|
||||
});
|
||||
const token = randomBytes(32).toString('hex');
|
||||
const id = createHash('sha256').update(token).digest('hex');
|
||||
await db.session.create({
|
||||
data: { id, userId: u.id, expiresAt: new Date(Date.now() + 86400000) },
|
||||
});
|
||||
return { ...u, sessionId: id, cookie: 'wp_session=' + token };
|
||||
}
|
||||
// Give this fixture suite its own loopback source address so independent auth
|
||||
// scenarios do not consume the existing suite's per-IP production rate limit.
|
||||
async function call(path: string, cookie: string, method = 'GET', body?: unknown) {
|
||||
const data = body === undefined ? undefined : JSON.stringify(body);
|
||||
return new Promise<{ status: number; data: any; cookie: string | null }>((resolve, reject) => {
|
||||
const req = request(
|
||||
new URL(base + path),
|
||||
{
|
||||
method,
|
||||
localAddress: '127.0.0.3',
|
||||
headers: {
|
||||
Cookie: cookie,
|
||||
Origin: origin,
|
||||
...(data === undefined
|
||||
? {}
|
||||
: { 'Content-Type': 'application/json', 'Content-Length': Buffer.byteLength(data) }),
|
||||
},
|
||||
},
|
||||
(res) => {
|
||||
const chunks: Buffer[] = [];
|
||||
res.on('data', (chunk) => chunks.push(chunk));
|
||||
res.on('error', reject);
|
||||
res.on('end', () => {
|
||||
try {
|
||||
resolve({
|
||||
status: res.statusCode!,
|
||||
data: JSON.parse(Buffer.concat(chunks).toString()),
|
||||
cookie: res.headers['set-cookie']?.[0] ?? null,
|
||||
});
|
||||
} catch (e) {
|
||||
reject(e);
|
||||
}
|
||||
});
|
||||
},
|
||||
);
|
||||
req.on('error', reject);
|
||||
req.end(data);
|
||||
});
|
||||
}
|
||||
|
||||
async function position(
|
||||
u: any,
|
||||
name: string,
|
||||
kind = 'account',
|
||||
side = 'asset',
|
||||
initial = '1000',
|
||||
category = 'bank',
|
||||
) {
|
||||
const r = await call('/positions', u.cookie, 'POST', {
|
||||
name,
|
||||
kind,
|
||||
side,
|
||||
category,
|
||||
currency: 'CNY',
|
||||
amount: initial,
|
||||
date: '2026-10-01T00:00',
|
||||
notes: '',
|
||||
});
|
||||
assert.equal(r.status, 201, JSON.stringify(r.data));
|
||||
return r.data.id;
|
||||
}
|
||||
test('all account transfer directions, replay, scheduled transfers and backup are consistent', async () => {
|
||||
const a = await fixture();
|
||||
try {
|
||||
const cash = await position(a, 'cash'),
|
||||
card = await position(a, 'card', 'account', 'liability', '200', 'credit_card'),
|
||||
other = await position(a, 'card2', 'account', 'liability', '300', 'loan');
|
||||
const payload = {
|
||||
sourceId: card,
|
||||
targetId: cash,
|
||||
amount: '100',
|
||||
received: '100',
|
||||
fee: '2',
|
||||
date: '2026-10-02T00:00',
|
||||
notes: '',
|
||||
};
|
||||
const t = await call('/transfers', a.cookie, 'POST', payload);
|
||||
assert.equal(t.status, 201, JSON.stringify(t.data));
|
||||
assert.equal(
|
||||
(
|
||||
await db.position.findUniqueOrThrow({
|
||||
where: { id: card },
|
||||
include: { revisions: { orderBy: { sequence: 'desc' }, take: 1 } },
|
||||
})
|
||||
).revisions[0].amount.toString(),
|
||||
'302',
|
||||
);
|
||||
const t2 = await call('/transfers', a.cookie, 'POST', {
|
||||
...payload,
|
||||
sourceId: cash,
|
||||
targetId: card,
|
||||
amount: '400',
|
||||
received: '400',
|
||||
fee: '0',
|
||||
date: '2026-10-02T01:00',
|
||||
});
|
||||
assert.equal(t2.status, 201);
|
||||
assert.equal((await call('/positions/' + card, a.cookie)).data.amount, '-98');
|
||||
assert.equal(
|
||||
(
|
||||
await call('/transfers/' + t.data.id, a.cookie, 'PUT', {
|
||||
...payload,
|
||||
amount: '150',
|
||||
received: '150',
|
||||
})
|
||||
).status,
|
||||
200,
|
||||
);
|
||||
assert.equal((await call('/positions/' + card, a.cookie)).data.amount, '-48');
|
||||
assert.equal((await call('/transfers/' + t2.data.id, a.cookie, 'DELETE')).status, 200);
|
||||
assert.equal((await call('/positions/' + card, a.cookie)).data.amount, '352');
|
||||
const plan = {
|
||||
name: 'credit transfer',
|
||||
operation: 'transfer',
|
||||
sourceId: card,
|
||||
targetId: other,
|
||||
amount: '50',
|
||||
received: '50',
|
||||
nextAt: '2026-10-02T02:00',
|
||||
intervalDays: 0,
|
||||
notes: '',
|
||||
};
|
||||
assert.equal((await call('/schedules', a.cookie, 'POST', plan)).status, 201);
|
||||
assert.equal((await call('/schedules/run', a.cookie, 'POST', {})).data.executed, 1);
|
||||
assert.equal((await call('/positions/' + other, a.cookie)).data.amount, '250');
|
||||
assert.equal(
|
||||
(
|
||||
await call('/schedules', a.cookie, 'POST', {
|
||||
...plan,
|
||||
operation: 'expense',
|
||||
targetId: null,
|
||||
})
|
||||
).status,
|
||||
400,
|
||||
);
|
||||
const backupController = (await import('../src/backup')).BackupController;
|
||||
const controller = new backupController(db as any);
|
||||
const backup = await (controller as any).data(a.id);
|
||||
assert.equal((await import('../src/backup')).validateBackup(backup).transfers?.length, 2);
|
||||
} finally {
|
||||
await db.user.delete({ where: { id: a.id } });
|
||||
}
|
||||
});
|
||||
test('inclusion preferences and precious metal settings, valuation and quotes restore exactly', async () => {
|
||||
const a = await fixture(),
|
||||
b = await fixture();
|
||||
try {
|
||||
const cash = await position(a, 'cash'),
|
||||
metal = await position(a, 'gold', 'asset', 'asset', '200', 'gold'),
|
||||
debt = await position(a, 'debt', 'debt', 'liability', '100', 'loan');
|
||||
assert.equal((await call('/overview', a.cookie)).data.net, '1100.00');
|
||||
assert.equal(
|
||||
(await call('/settings', a.cookie, 'PATCH', { includeIndependentAssets: false })).status,
|
||||
200,
|
||||
);
|
||||
assert.equal((await call('/overview', a.cookie)).data.net, '900.00');
|
||||
assert.equal(
|
||||
(
|
||||
await call('/positions/' + debt, a.cookie, 'PATCH', {
|
||||
name: 'debt',
|
||||
category: 'loan',
|
||||
included: false,
|
||||
notes: '',
|
||||
})
|
||||
).status,
|
||||
200,
|
||||
);
|
||||
assert.equal((await call('/overview', a.cookie)).data.net, '1000.00');
|
||||
const tr = await call('/trend?from=2026-10-01&to=2026-10-03', a.cookie);
|
||||
assert.ok(tr.data.items.every((i: any) => i.net === '1000.00'));
|
||||
assert.equal(
|
||||
(
|
||||
await call('/metals/' + cash, a.cookie, 'PUT', {
|
||||
metalType: 'gold',
|
||||
metalGrams: '10',
|
||||
metalPurity: '0.999',
|
||||
autoValuation: true,
|
||||
})
|
||||
).status,
|
||||
404,
|
||||
);
|
||||
assert.equal(
|
||||
(
|
||||
await call('/metals/' + metal, b.cookie, 'PUT', {
|
||||
metalType: 'gold',
|
||||
metalGrams: '10',
|
||||
metalPurity: '0.999',
|
||||
autoValuation: true,
|
||||
})
|
||||
).status,
|
||||
404,
|
||||
);
|
||||
assert.equal(
|
||||
(
|
||||
await call('/metals/' + metal, a.cookie, 'PUT', {
|
||||
metalType: 'gold',
|
||||
metalGrams: '10.86420978',
|
||||
metalPurity: '0.999',
|
||||
autoValuation: true,
|
||||
})
|
||||
).status,
|
||||
200,
|
||||
);
|
||||
const d = new Date(Date.now() + 8 * 3600000).toISOString().slice(0, 10);
|
||||
assert.equal(
|
||||
(
|
||||
await call('/metals/prices', a.cookie, 'POST', {
|
||||
metalType: 'gold',
|
||||
currency: 'CNY',
|
||||
price: '700.864209789012',
|
||||
date: d,
|
||||
})
|
||||
).status,
|
||||
201,
|
||||
);
|
||||
const expected = (await import('../src/metals')).metalValue(
|
||||
'10.86420978',
|
||||
'0.999',
|
||||
'700.864209789012',
|
||||
);
|
||||
assert.equal(
|
||||
(await call('/positions/' + metal, a.cookie)).data.amount,
|
||||
expected.replace(/0+$/, '').replace(/\.$/, ''),
|
||||
);
|
||||
const count = await db.revision.count({ where: { positionId: metal } });
|
||||
await call('/metals/prices', a.cookie, 'POST', {
|
||||
metalType: 'gold',
|
||||
currency: 'CNY',
|
||||
price: '700.864209789012',
|
||||
date: d,
|
||||
});
|
||||
assert.equal(await db.revision.count({ where: { positionId: metal } }), count);
|
||||
const controller = new (await import('../src/backup')).BackupController(db as any);
|
||||
const backup = await (controller as any).data(a.id);
|
||||
const archive = (await import('../src/zip')).archiveBackup(backup);
|
||||
const chunks: Buffer[] = [];
|
||||
archive.on('data', (c: Buffer) => chunks.push(c));
|
||||
const done = new Promise<void>((resolve) => archive.on('end', resolve));
|
||||
await archive.finalize();
|
||||
await done;
|
||||
const restoredBackup = await readBackupZip(Buffer.concat(chunks));
|
||||
assert.equal(
|
||||
(await call('/backup/import', b.cookie, 'POST', { confirmed: true, backup: restoredBackup }))
|
||||
.status,
|
||||
201,
|
||||
);
|
||||
assert.equal((await call('/auth/me', b.cookie)).data.includeIndependentAssets, false);
|
||||
const ps = (await call('/positions', b.cookie)).data;
|
||||
assert.equal(ps.find((p: any) => p.name === 'debt').included, false);
|
||||
assert.equal(ps.find((p: any) => p.name === 'gold').metalGrams, '10.86420978');
|
||||
assert.equal(
|
||||
(await db.metalPrice.findFirstOrThrow({ where: { userId: b.id } })).price.toString(),
|
||||
'700.864209789012',
|
||||
);
|
||||
const refreshed = await call('/metals/refresh', a.cookie, 'POST', {});
|
||||
assert.equal(refreshed.status, 201, JSON.stringify(refreshed.data));
|
||||
assert.equal(
|
||||
(
|
||||
await db.metalPrice.findFirstOrThrow({
|
||||
where: { userId: a.id, metalType: 'gold', currency: 'CNY', date: new Date(d) },
|
||||
})
|
||||
).price.toString(),
|
||||
'700.864209789012',
|
||||
);
|
||||
} finally {
|
||||
await db.user.deleteMany({ where: { id: { in: [a.id, b.id] } } });
|
||||
await db.$disconnect();
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,83 @@
|
||||
import { test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { MetalsService, metalValue } from '../src/metals';
|
||||
import { totals, trend, type Holding } from '../src/calculation';
|
||||
import { today } from '../src/validation';
|
||||
test('excluded holdings do not require FX or contribute to totals, attribution or earliest trend date', () => {
|
||||
const holding = (id: string, currency: string, included: boolean, day: string): Holding => ({
|
||||
id,
|
||||
name: id,
|
||||
kind: 'asset',
|
||||
side: 'asset',
|
||||
currency,
|
||||
included,
|
||||
revisions: [
|
||||
{ id: id + 'r', amount: '100', effectiveDate: new Date(day), notes: '', reason: 'initial' },
|
||||
],
|
||||
});
|
||||
const ps = [
|
||||
holding('excluded', 'USD', false, '2026-01-01'),
|
||||
holding('visible', 'CNY', true, '2026-10-01'),
|
||||
];
|
||||
const result = totals(ps, [], 'CNY', '2026-10-03');
|
||||
assert.equal(result.net, '100.00');
|
||||
assert.equal(result.complete, true);
|
||||
assert.equal(result.items.length, 2);
|
||||
const points = trend(ps, [], 'CNY', '2026-09-30', '2026-10-03');
|
||||
assert.equal(points[0].date, '2026-10-01');
|
||||
assert.equal(points[0].balanceChange, '100.00');
|
||||
assert.ok(points.every((p) => p.complete && p.fxChange === '0.00'));
|
||||
});
|
||||
test('public metal update preserves decimal values and manual quotes, skips cleared positions and keeps old prices on failure', async () => {
|
||||
const original = globalThis.fetch;
|
||||
const calls: string[] = [];
|
||||
const writes: any[] = [];
|
||||
var cleared = false;
|
||||
const db: any = {
|
||||
position: { findMany: async () => [{ id: 'p', currency: 'CNY', metalType: 'gold' }] },
|
||||
serial: async (fn: any) =>
|
||||
fn({
|
||||
$queryRaw: async () => [],
|
||||
position: {
|
||||
findMany: async () =>
|
||||
cleared ? [] : [{ id: 'p', currency: 'CNY', metalType: 'gold', autoValuation: false }],
|
||||
},
|
||||
metalPrice: {
|
||||
findUnique: async () => ({ source: 'manual' }),
|
||||
upsert: async (v: any) => writes.push(v),
|
||||
},
|
||||
}),
|
||||
};
|
||||
try {
|
||||
globalThis.fetch = (async (url: any) => {
|
||||
calls.push(String(url));
|
||||
return new Response(
|
||||
String(url).includes('frankfurter')
|
||||
? JSON.stringify([{ base: 'USD', quote: 'CNY', date: today(), rate: 6.700400864209 }])
|
||||
: JSON.stringify({
|
||||
currency: 'USD',
|
||||
symbol: String(url).endsWith('XAU') ? 'XAU' : 'XAG',
|
||||
price: 4141.799805864209,
|
||||
updatedAt: new Date().toISOString(),
|
||||
}),
|
||||
);
|
||||
}) as typeof fetch;
|
||||
const service = new MetalsService(db);
|
||||
await service.refresh('user');
|
||||
assert.equal(calls.length, 3);
|
||||
assert.ok(calls.every((u) => !u.includes('user')));
|
||||
assert.equal(writes.length, 0);
|
||||
cleared = true;
|
||||
await service.refresh('user');
|
||||
assert.equal(writes.length, 0);
|
||||
globalThis.fetch = (async () => {
|
||||
throw Error('offline');
|
||||
}) as typeof fetch;
|
||||
await assert.rejects(service.refresh('user'));
|
||||
assert.equal(service.status('user').state, 'error');
|
||||
assert.equal(writes.length, 0);
|
||||
assert.equal(metalValue('10.86420978', '0.999', '700.864209789012'), '7606.72146664');
|
||||
} finally {
|
||||
globalThis.fetch = original;
|
||||
}
|
||||
});
|
||||
@@ -141,15 +141,15 @@ test('transfers are atomic, scoped, retry-safe, decimal exact and included in ba
|
||||
);
|
||||
assert.equal((await call('/settings', a.cookie, 'PATCH', { showNotes: 'false' })).status, 400);
|
||||
const debt = await position(a.cookie, '信用卡', '10', 'CNY', 'liability');
|
||||
const creditTransfer = await call('/transfers', a.cookie, 'POST', {
|
||||
...request,
|
||||
requestId: randomUUID(),
|
||||
targetId: debt,
|
||||
});
|
||||
assert.equal(creditTransfer.status, 201);
|
||||
assert.equal(
|
||||
(
|
||||
await call('/transfers', a.cookie, 'POST', {
|
||||
...request,
|
||||
requestId: randomUUID(),
|
||||
targetId: debt,
|
||||
})
|
||||
).status,
|
||||
400,
|
||||
(await call('/transfers/' + creditTransfer.data.id, a.cookie, 'DELETE')).status,
|
||||
200,
|
||||
);
|
||||
assert.equal(
|
||||
(
|
||||
|
||||
Reference in new issue
Block a user