feat: add manual bank card ledger

Store exact RUB balances, atomic transfers, linked refunds and versioned edits independently of T-Bank. Add migration, HTTP and durability verification.
This commit is contained in:
2026-09-12 18:34:18 +03:00
parent 8ec172f48e
commit e6f58ef0ee
36 changed files with 2436 additions and 99 deletions
+74
View File
@@ -0,0 +1,74 @@
const assert = require('node:assert/strict');
const { spawn, execFileSync } = require('node:child_process');
const { mkdtempSync, openSync, closeSync, writeFileSync } = require('node:fs');
const { tmpdir } = require('node:os');
const { join, resolve } = require('node:path');
const { once } = require('node:events');
const { createServer } = require('node:net');
const { randomUUID } = require('node:crypto');
const repo = resolve(__dirname, '..');
assert.ok(process.argv[2], 'Pass a separately built baseline repository directory');
const baseline = resolve(process.argv[2]);
const Database = require(join(repo, 'node_modules/better-sqlite3'));
const dir = mkdtempSync(join(tmpdir(), 'wealthledger-bank-durability-'));
let file = join(dir, 'history.db');
const env = { ...process.env, DATABASE_URL: `file:${file}`, RUST_LOG: 'info', NODE_ENV: 'development', LOG_LEVEL: 'error', FRONTEND_ORIGIN: 'http://localhost:5189', JWT_SECRET: 'fictional-bank-durability-secret-long-enough', TOKEN_ENCRYPTION_KEY: Buffer.from('0123456789abcdef0123456789abcdef').toString('base64') };
let child, origin, run = 0;
const credentials = { email: 'durability@example.test', password: 'fictional password long enough' };
const rub = value => ({ currency: 'RUB', value });
async function start(root) {
const server = createServer(); server.listen(0, '127.0.0.1'); await once(server,'listening'); const port=server.address().port; await new Promise(r=>server.close(r));
origin=`http://127.0.0.1:${port}`;
const log = openSync(join(dir,`app-${++run}.log`),'w');
child=spawn(process.execPath,[join(root,'dist/src/main.js')],{cwd:root,env:{...env,DATABASE_URL:`file:${file}`,PORT:String(port)},stdio:['ignore',log,log]}); closeSync(log);
for(let i=0;i<150;i++){if(child.exitCode!==null)throw new Error(`app exited ${child.exitCode}`);try{const r=await fetch(`${origin}/api/v1/auth/me`);if(r.status===401)return;}catch{} await new Promise(r=>setTimeout(r,100));}
throw new Error('app startup timeout');
}
async function stop(){ if(!child)return;const c=child; child=null; if(c.exitCode!==null)return; const ended=once(c,'exit');c.kill('SIGTERM');await ended;}
async function http(path,method='GET',body,token,status=200){const r=await fetch(`${origin}/api/v1/${path}`,{method,headers:{...(body?{'content-type':'application/json'}:{}),...(token?{Authorization:`Bearer ${token}`}:{})},...(body?{body:JSON.stringify(body)}:{})});const text=await r.text();assert.equal(r.status,status,`${method} ${path}: ${text}`);return text?JSON.parse(text):null;}
function migrate(root){execFileSync(process.execPath,[join(root,'node_modules/prisma/build/index.js'),'migrate','deploy'],{cwd:root,env:{...env,DATABASE_URL:`file:${file}`},stdio:['ignore',openSync(join(dir,`migration-${run}.log`),'w'),'pipe']});}
function rows(table){const db=new Database(file,{readonly:true});db.defaultSafeIntegers(true);try{return db.prepare(`SELECT * FROM "${table}" ORDER BY id`).all();}finally{db.close();}}
(async()=>{
try{
migrate(baseline); await start(baseline);
const auth=await http('auth/register','POST',credentials); const owner=auth.user.id;
await stop();
const db=new Database(file);db.prepare('INSERT INTO TbankConnection (userId,encryptedToken,connectedAt) VALUES (?,?,?)').run(owner,'fictional-encrypted-token-never-used',new Date().toISOString());await db.backup(join(dir,'before-migration.db'));db.close();
const beforeUsers=rows('User'), beforeSessions=rows('Session');
migrate(repo);
assert.deepEqual(rows('User'),beforeUsers); assert.deepEqual(rows('Session'),beforeSessions); const migratedDb=new Database(file,{readonly:true});assert.equal(migratedDb.prepare('SELECT encryptedToken FROM TbankConnection WHERE userId=?').get(owner).encryptedToken,'fictional-encrypted-token-never-used');migratedDb.close();
await start(repo); let token=(await http('auth/login','POST',credentials)).accessToken;
const intent={id:randomUUID(),name:'Долговременная',openingDate:'2026-01-01',openingBalance:rub('1000')};
let card=await http('bank-cards','POST',intent,token,201);
const category=(await http('bank-cards/categories','GET',null,token)).find(x=>x.isDefault);
await http(`bank-cards/categories/${category.id}`,'PATCH',{name:'Сохранённая категория',version:category.version},token);
const expenseIntent={id:randomUUID(),kind:'expense',cardId:card.id,amount:rub('100'),date:'2026-08-01'};
let expense=await http('bank-cards/operations','POST',expenseIntent,token,201);
const refundIntent={id:randomUUID(),kind:'refund',cardId:card.id,originalExpenseId:expense.id,amount:rub('100'),date:'2026-09-01'};
const refund=await http('bank-cards/operations','POST',refundIntent,token,201);
const deletedIntent={id:randomUUID(),kind:'topup',cardId:card.id,amount:rub('12.34'),date:'2026-09-01'};
const deleted=await http('bank-cards/operations','POST',deletedIntent,token,201);
await http(`bank-cards/operations/${deleted.id}?version=${deleted.version}`,'DELETE',null,token,204);
card=await http(`bank-cards/${card.id}`,'GET',null,token);
card=await http(`bank-cards/${card.id}`,'PATCH',{version:card.version,archived:true},token);
const snapshots={cards:rows('BankCard'),operations:rows('BankCardOperation'),categories:rows('ExpenseCategory')};
await stop(); await start(repo);token=(await http('auth/login','POST',credentials)).accessToken;
assert.deepEqual(await http('bank-cards','POST',intent,token,201),card);
assert.deepEqual(await http('bank-cards/operations','POST',refundIntent,token,201),refund);
await http('bank-cards/operations','POST',deletedIntent,token,410);
assert.deepEqual(await http(`bank-cards/${card.id}`,'GET',null,token),card);
await stop();
assert.deepEqual(rows('BankCard'),snapshots.cards);assert.deepEqual(rows('BankCardOperation'),snapshots.operations);assert.deepEqual(rows('ExpenseCategory'),snapshots.categories);
const latest=new Database(file);await latest.backup(join(dir,'after-history.db'));latest.close();
await start(baseline);token=(await http('auth/login','POST',credentials)).accessToken;assert.equal((await http('auth/me','GET',null,token)).id,owner);await stop();
assert.deepEqual(rows('BankCard'),snapshots.cards);assert.deepEqual(rows('BankCardOperation'),snapshots.operations);assert.deepEqual(rows('ExpenseCategory'),snapshots.categories);
await start(repo);token=(await http('auth/login','POST',credentials)).accessToken;assert.deepEqual(await http(`bank-cards/${card.id}`,'GET',null,token),card);await stop();
const restored=join(dir,'restored.db');const backup=new Database(join(dir,'after-history.db'),{readonly:true});await backup.backup(restored);backup.close();file=restored;
assert.deepEqual(rows('BankCard'),snapshots.cards);assert.deepEqual(rows('BankCardOperation'),snapshots.operations);assert.deepEqual(rows('ExpenseCategory'),snapshots.categories);
assert.deepEqual(rows('User'),beforeUsers);assert.ok(rows('Session').some(s=>s.id===beforeSessions[0].id));
const restoredDb=new Database(file,{readonly:true});assert.equal(restoredDb.prepare('SELECT encryptedToken FROM TbankConnection WHERE userId=?').get(owner).encryptedToken,'fictional-encrypted-token-never-used');assert.equal(restoredDb.pragma('integrity_check',{simple:true}),'ok');restoredDb.close();
await start(repo);token=(await http('auth/login','POST',credentials)).accessToken;assert.deepEqual(await http(`bank-cards/${card.id}`,'GET',null,token),card);await stop();
writeFileSync(join(dir,'result.json'),JSON.stringify({result:'PASS',baseline,dir,checks:['baseline migration and auth','additive migration preserves User Session TbankConnection','restart preserves archive categories refund links tombstones fingerprints','same-intent replay after restart','previous application auth with additive schema','new application returns preserved history after rollback','SQLite backup and restored application reads history']},null,2));
console.log(`PASS durability ${dir}`);
}finally{await stop();}
})().catch(error=>{console.error(error);process.exitCode=1;});
+712
View File
@@ -0,0 +1,712 @@
import { INestApplication } from '@nestjs/common';
import { JwtService } from '@nestjs/jwt';
import { Test } from '@nestjs/testing';
import { execFileSync } from 'node:child_process';
import { randomUUID } from 'node:crypto';
import type { Server } from 'node:http';
import request from 'supertest';
import { AppModule } from '../src/app.module';
import { configureApp } from '../src/main';
import { PrismaService } from '../src/prisma/prisma.service';
import {
BankCardDto,
BankOperationDto,
BankOperationsPageDto,
BankSummaryDto,
ExpenseCategoryDto,
} from '../src/bank-cards/bank-card.dto';
jest.setTimeout(60000);
describe('Manual bank cards configured HTTP and real SQLite', () => {
let app: INestApplication<Server>, db: PrismaService;
const owner = randomUUID(),
stranger = randomUUID();
const token = (user = owner) =>
new JwtService().sign(
{ sub: user, type: 'access' },
{ secret: process.env.JWT_SECRET },
);
const money = (value: string) => ({ currency: 'RUB', value });
const auth = (
method: 'get' | 'post' | 'patch' | 'delete',
path: string,
user = owner,
) =>
request(app.getHttpServer())
[method]('/api/v1/bank-cards' + path)
.set('Authorization', `Bearer ${token(user)}`);
const createCard = async (value = '1000', user = owner, extra = {}) =>
(
await auth('post', '', user)
.send({
id: randomUUID(),
name: 'Вымышленная карта',
openingDate: '2026-01-01',
openingBalance: money(value),
...extra,
})
.expect(201)
).body as BankCardDto;
const getCard = async (id: string) =>
(await auth('get', '/' + id).expect(200)).body as BankCardDto;
const getOp = async (id: string) =>
(await auth('get', '/operations/' + id).expect(200))
.body as BankOperationDto;
const op = (cardId: string, kind = 'expense', value = '100', extra = {}) => ({
id: randomUUID(),
cardId,
kind,
amount: money(value),
date: '2026-08-01',
...extra,
});
const createOp = async (body: object) =>
(await auth('post', '/operations').send(body).expect(201))
.body as BankOperationDto;
const categories = async () =>
(await auth('get', '/categories').expect(200)).body as ExpenseCategoryDto[];
const summary = async (month: string, cardId?: string) =>
(
await auth(
'get',
`/summary?month=${month}${cardId ? '&cardId=' + cardId : ''}`,
).expect(200)
).body as BankSummaryDto;
const start = async () => {
const module = await Test.createTestingModule({
imports: [AppModule],
}).compile();
app = module.createNestApplication();
configureApp(app);
await app.init();
db = app.get(PrismaService);
};
beforeAll(async () => {
const url = process.env.DATABASE_URL;
if (
!url ||
!/^file:\/(?:private\/)?tmp\/bank-cards-[^/]+\/.+\.db$/.test(url)
)
throw new Error(
'Pass a unique /tmp/bank-cards-*/test.db DATABASE_URL. No shared/user database is allowed.',
);
execFileSync(
process.execPath,
['node_modules/prisma/build/index.js', 'migrate', 'deploy'],
{
cwd: process.cwd(),
env: { ...process.env, RUST_LOG: 'info' },
stdio: 'pipe',
},
);
await start();
await db.user.createMany({
data: [
{
id: owner,
email: owner + '@example.test',
passwordHash: 'fictional',
},
{
id: stranger,
email: stranger + '@example.test',
passwordHash: 'fictional',
},
],
});
});
afterAll(async () => {
if (app) await app.close();
});
it('requires auth and reads never seed data or need T-Bank', async () => {
for (const path of ['', '/categories', '/summary?month=2026-08'])
await request(app.getHttpServer())
.get('/api/v1/bank-cards' + path)
.expect(401);
expect((await auth('get', '').expect(200)).body).toEqual([]);
expect(await categories()).toEqual([]);
expect(await db.expenseCategory.count({ where: { userId: owner } })).toBe(
0,
);
});
it('seeds once concurrently, replays card creation and preserves current archived state', async () => {
const body = {
id: randomUUID(),
name: ' Основная ',
openingDate: '2026-01-01',
openingBalance: money('1000'),
};
const responses = await Promise.all([
auth('post', '').send(body),
auth('post', '').send(body),
auth('post', '').send({ ...body, id: randomUUID() }),
]);
expect(responses.map((r) => r.status)).toEqual([201, 201, 201]);
expect(await db.bankCard.count({ where: { userId: owner } })).toBe(2);
expect(await categories()).toHaveLength(9);
await auth('post', '')
.send({ ...body, name: 'Другое имя' })
.expect(409);
const archived = (
await auth('patch', '/' + body.id)
.send({ version: 1, archived: true })
.expect(200)
).body as BankCardDto;
const replay = (await auth('post', '').send(body).expect(201))
.body as BankCardDto;
expect(replay).toEqual(archived);
expect(replay.name).toBe('Основная');
});
it('roundtrips >2^53 and signed64 boundaries, and calculates beyond a stored value', async () => {
for (const value of [
'90071992547409.93',
'92233720368547758.07',
'-92233720368547758.08',
]) {
const card = await createCard(value);
expect(card.balance.value).toBe(value);
expect((await getCard(card.id)).openingBalance.value).toBe(value);
}
const card = await createCard('92233720368547758.07');
await createOp(op(card.id, 'topup', '0.01'));
expect((await getCard(card.id)).balance.value).toBe('92233720368547758.08');
const exact = await createCard('1');
await createOp(op(exact.id, 'expense', '0.10'));
await createOp(op(exact.id, 'expense', '0.20'));
expect((await getCard(exact.id)).balance.value).toBe('0.7');
await createOp(op(exact.id, 'expense', '1'));
expect((await getCard(exact.id)).balance.value).toBe('-0.3');
});
it('clears optional card metadata and changes opening balance without synthetic operations', async () => {
const card = await createCard('0', owner, {
bankName: 'Fake Bank',
lastFour: '1234',
});
const changed = (
await auth('patch', '/' + card.id)
.send({
version: 1,
bankName: null,
lastFour: null,
openingBalance: money('-25'),
})
.expect(200)
).body as BankCardDto;
expect(changed).toMatchObject({
bankName: null,
lastFour: null,
balance: money('-25'),
});
expect(
await db.bankCardOperation.count({ where: { cardId: card.id } }),
).toBe(0);
expect((await summary('2026-08', card.id)).net.value).toBe('0');
});
it('rejects explicit null on nonnullable write fields without changing values or versions', async () => {
const card = await createCard();
const expense = await createOp(op(card.id));
const archived = (
await auth('patch', '/' + card.id)
.send({ version: card.version, archived: true })
.expect(200)
).body as BankCardDto;
for (const field of ['archived', 'openingBalance', 'openingDate', 'name']) {
await auth('patch', '/' + card.id)
.send({ version: archived.version, [field]: null })
.expect(400);
expect(await getCard(card.id)).toEqual(archived);
}
await auth('patch', '/' + card.id)
.send({ version: archived.version, archived: false })
.expect(200);
for (const field of [
'amount',
'kind',
'cardId',
'date',
'description',
'targetCardId',
'categoryId',
'originalExpenseId',
]) {
await auth('patch', '/operations/' + expense.id)
.send({ version: expense.version, [field]: null })
.expect(400);
expect(await getOp(expense.id)).toEqual(expense);
}
const before = await db.bankCardOperation.count();
for (const field of [
'amount',
'description',
'targetCardId',
'categoryId',
'originalExpenseId',
]) {
await auth('post', '/operations')
.send({ ...op(card.id), [field]: null })
.expect(400);
}
expect(await db.bankCardOperation.count()).toBe(before);
});
it('validates actual money, calendar, field and operation-shape inputs without writes', async () => {
const card = await createCard();
const before = await db.bankCardOperation.count();
for (const amount of [
money('0'),
money('-1'),
money('1.001'),
money('92233720368547758.08'),
{ currency: 'USD', value: '1' },
money('NaN'),
])
await auth('post', '/operations')
.send({ ...op(card.id), amount })
.expect(400);
for (const extra of [
{ date: '2026-02-30' },
{ date: '2099-01-01' },
{ date: '2025-12-31' },
{ description: 'x'.repeat(501) },
{ kind: 'topup', categoryId: (await categories())[0].id },
{ kind: 'transfer', targetCardId: card.id },
])
await auth('post', '/operations')
.send(op(card.id, 'expense', '1', extra))
.expect(400);
expect(await db.bankCardOperation.count()).toBe(before);
for (const extra of [
{ name: ' ' },
{ name: 'x'.repeat(101) },
{ bankName: 'x'.repeat(101) },
{ lastFour: '123' },
{ openingDate: '2026-02-30' },
{ openingBalance: money('-92233720368547758.09') },
])
await auth('post', '')
.send({
id: randomUUID(),
name: 'Card',
openingDate: '2026-01-01',
openingBalance: money('0'),
...extra,
})
.expect(400);
});
it('scopes every card/category/expense link and edit to owner', async () => {
const own = await createCard(),
foreign = await createCard('0', stranger);
const foreignCategories = (
await auth('get', '/categories', stranger).expect(200)
).body as ExpenseCategoryDto[];
const foreignOp = (
await auth('post', '/operations', stranger)
.send(op(foreign.id))
.expect(201)
).body as BankOperationDto;
for (const path of [
'/' + foreign.id,
'/' + foreign.id + '/operations?month=2026-08',
'/' + foreign.id + '/expenses',
'/operations/' + foreignOp.id,
'/summary?month=2026-08&cardId=' + foreign.id,
])
await auth('get', path).expect(404);
for (const body of [
op(foreign.id),
op(own.id, 'expense', '1', { categoryId: foreignCategories[0].id }),
op(own.id, 'transfer', '1', { targetCardId: foreign.id }),
op(own.id, 'refund', '1', { originalExpenseId: foreignOp.id }),
])
await auth('post', '/operations').send(body).expect(404);
await auth('patch', '/' + foreign.id)
.send({ version: 1, archived: true })
.expect(404);
await auth('patch', '/categories/' + foreignCategories[0].id)
.send({ version: 1, name: 'No' })
.expect(404);
await auth('patch', '/operations/' + foreignOp.id)
.send({ version: 1, amount: money('2') })
.expect(404);
await auth('delete', '/operations/' + foreignOp.id + '?version=1').expect(
404,
);
expect((await getCard(own.id)).balance.value).toBe('1000');
});
it('renames default category without role loss; duplicates and stale rename conflict', async () => {
const category = (await categories()).find((c) => c.isDefault)!;
await auth('patch', '/categories/' + category.id)
.send({ version: category.version, name: 'Остальное' })
.expect(200);
await auth('patch', '/categories/' + category.id)
.send({ version: category.version, name: 'Stale' })
.expect(409);
await auth('post', '/categories').send({ name: ' кафе ' }).expect(409);
const custom = (
await auth('post', '/categories').send({ name: 'Питомец' }).expect(201)
).body as ExpenseCategoryDto;
expect(
(await categories()).find((c) => c.id === category.id),
).toMatchObject({ name: 'Остальное', isDefault: true });
const card = await createCard();
expect((await createOp(op(card.id))).categoryId).toBe(category.id);
expect(custom.name).toBe('Питомец');
});
it('keeps both independent expenses, exactly one same-intent operation and tombstone after editing/deleting', async () => {
const card = await createCard();
const body = op(card.id);
const responses = await Promise.all([
auth('post', '/operations').send(body),
auth('post', '/operations').send(body),
auth('post', '/operations').send(op(card.id, 'expense', '200')),
]);
expect(responses.map((r) => r.status)).toEqual([201, 201, 201]);
expect((await getCard(card.id)).balance.value).toBe('700');
await auth('post', '/operations')
.send({ ...body, amount: money('200') })
.expect(409);
const edited = (
await auth('patch', '/operations/' + body.id)
.send({ version: 1, amount: money('150') })
.expect(200)
).body as BankOperationDto;
expect(
(
(await auth('post', '/operations').send(body).expect(201))
.body as BankOperationDto
).amount.value,
).toBe('150');
await auth('delete', '/operations/' + body.id + '?version=1').expect(409);
await auth(
'delete',
`/operations/${body.id}?version=${edited.version}`,
).expect(204);
await auth('delete', '/operations/' + body.id + '?version=1').expect(204);
await auth('post', '/operations').send(body).expect(410);
expect((await getCard(card.id)).balance.value).toBe('800');
});
it('transfers atomically, edits both sides, validates old and new cards and opening dates', async () => {
const a = await createCard('100'),
b = await createCard('0'),
late = await createCard('0', owner, { openingDate: '2026-09-01' });
await auth('post', '/operations')
.send(op(a.id, 'transfer', '150', { targetCardId: late.id }))
.expect(400);
const transfer = await createOp(
op(a.id, 'transfer', '150', { targetCardId: b.id }),
);
expect((await getCard(a.id)).balance.value).toBe('-50');
expect((await getCard(b.id)).balance.value).toBe('150');
const history = (
await auth('get', `/${b.id}/operations?month=2026-08`).expect(200)
).body as BankOperationsPageDto;
expect(history.items[0]).toMatchObject({
id: transfer.id,
cardId: a.id,
targetCardId: b.id,
});
expect((await summary('2026-08', b.id)).expenses.value).toBe('0');
await auth('patch', '/' + b.id)
.send({ version: 1, openingDate: '2026-08-02' })
.expect(400);
const edited = (
await auth('patch', '/operations/' + transfer.id)
.send({ version: 1, amount: money('50') })
.expect(200)
).body as BankOperationDto;
expect((await getCard(a.id)).balance.value).toBe('50');
expect((await getCard(b.id)).balance.value).toBe('50');
await auth(
'delete',
`/operations/${transfer.id}?version=${edited.version}`,
).expect(204);
expect((await getCard(a.id)).balance.value).toBe('100');
expect((await getCard(b.id)).balance.value).toBe('0');
});
it('serializes refund limits, replays full refund before limit checks, excludes own value during edit', async () => {
const card = await createCard();
const expense = await createOp(op(card.id, 'expense', '1000'));
const responses = await Promise.all([
auth('post', '/operations').send(
op(card.id, 'refund', '600', {
date: '2026-09-01',
originalExpenseId: expense.id,
}),
),
auth('post', '/operations').send(
op(card.id, 'refund', '600', {
date: '2026-09-01',
originalExpenseId: expense.id,
}),
),
]);
expect(responses.map((r) => r.status).sort()).toEqual([201, 409]);
const body = op(card.id, 'refund', '400', {
date: '2026-09-01',
originalExpenseId: expense.id,
});
const refund = await createOp(body);
expect(refund.availableRefund?.value).toBe('400');
await auth('post', '/operations').send(body).expect(201);
await auth('patch', '/operations/' + refund.id)
.send({ version: refund.version, description: 'Тот же полный возврат' })
.expect(200);
const sept = await summary('2026-09', card.id);
expect(sept).toMatchObject({
expenses: money('0'),
refunds: money('1000'),
net: money('-1000'),
});
const august = await summary('2026-08', card.id);
expect(august.net.value).toBe('1000');
const choices = (await auth('get', '/' + card.id + '/expenses').expect(200))
.body as BankOperationsPageDto;
expect(choices.items[0].availableRefund?.value).toBe('0');
const current = await getOp(expense.id);
for (const patch of [
{ amount: money('999') },
{ date: '2026-09-02' },
{ cardId: (await createCard()).id },
])
await auth('patch', '/operations/' + expense.id)
.send({ version: current.version, ...patch })
.expect(409);
await auth(
'delete',
`/operations/${expense.id}?version=${current.version}`,
).expect(409);
const category = (await categories()).find((c) => !c.isDefault)!;
await auth('patch', '/operations/' + expense.id)
.send({ version: current.version, categoryId: category.id })
.expect(200);
expect((await summary('2026-09', card.id)).categories[0].categoryId).toBe(
category.id,
);
});
it('rejects invalid refund references, dates, immutable kind and purchase link', async () => {
const card = await createCard(),
other = await createCard();
const purchase = await createOp(op(card.id)),
topup = await createOp(op(card.id, 'topup'));
for (const body of [
op(card.id, 'refund', '1', { originalExpenseId: topup.id }),
op(other.id, 'refund', '1', { originalExpenseId: purchase.id }),
op(card.id, 'refund', '1', {
originalExpenseId: purchase.id,
date: '2026-07-01',
}),
])
await auth('post', '/operations').send(body).expect(400);
const refund = await createOp(
op(card.id, 'refund', '10', { originalExpenseId: purchase.id }),
);
await auth('patch', '/operations/' + refund.id)
.send({ version: refund.version, originalExpenseId: topup.id })
.expect(400);
await auth('patch', '/operations/' + topup.id)
.send({ version: topup.version, kind: 'expense' })
.expect(400);
});
it('moves plain operations and month totals, preserves full balance with stable pagination', async () => {
const a = await createCard(),
b = await createCard();
const first = await createOp(op(a.id, 'expense', '10'));
await createOp(op(a.id, 'expense', '20'));
await createOp(op(a.id, 'expense', '30'));
const pages: BankOperationsPageDto[] = [];
for (let page = 1; page <= 3; page++)
pages.push(
(
await auth(
'get',
`/${a.id}/operations?month=2026-08&page=${page}&limit=1`,
).expect(200)
).body as BankOperationsPageDto,
);
expect(new Set(pages.flatMap((p) => p.items.map((i) => i.id))).size).toBe(
3,
);
expect(pages.map((p) => p.hasNext)).toEqual([true, true, false]);
expect((await getCard(a.id)).balance.value).toBe('940');
expect((await summary('2026-08', a.id)).expenses.value).toBe('60');
await auth('patch', '/operations/' + first.id)
.send({ version: first.version, cardId: b.id, date: '2026-09-01' })
.expect(200);
expect((await getCard(a.id)).balance.value).toBe('950');
expect((await getCard(b.id)).balance.value).toBe('990');
expect((await summary('2026-09', b.id)).net.value).toBe('10');
});
it('serializes archive with writes, blocks financial changes, keeps metadata editable and all-card history', async () => {
const card = await createCard();
const expense = await createOp(op(card.id));
const total = (await summary('2026-08')).net.value;
await auth('patch', '/' + card.id)
.send({ version: 1, archived: true })
.expect(200);
for (const body of [
op(card.id),
op(card.id, 'transfer', '1', { targetCardId: (await createCard()).id }),
])
await auth('post', '/operations').send(body).expect(409);
await auth('patch', '/operations/' + expense.id)
.send({ version: 1, description: 'no' })
.expect(409);
await auth('delete', `/operations/${expense.id}?version=1`).expect(409);
await auth('patch', '/' + card.id)
.send({ version: 2, openingBalance: money('200') })
.expect(409);
await auth('patch', '/' + card.id)
.send({ version: 2, name: 'Архивная' })
.expect(200);
expect((await summary('2026-08')).net.value).toBe(total);
await auth('patch', '/' + card.id)
.send({ version: 1, archived: false })
.expect(409);
await auth('patch', '/' + card.id)
.send({ version: 3, archived: false })
.expect(200);
await createOp(op(card.id));
const racing = await createCard();
const results = await Promise.all([
auth('patch', '/' + racing.id).send({ version: 1, archived: true }),
auth('post', '/operations').send(op(racing.id)),
]);
expect(results[0].status).toBe(200);
expect([201, 409]).toContain(results[1].status);
expect((await getCard(racing.id)).balance.value).toBe(
results[1].status === 201 ? '900' : '1000',
);
});
it('serializes a refund against reducing its purchase, and rolls back rejected source locks', async () => {
const card = await createCard();
const expense = await createOp(op(card.id, 'expense', '1000'));
const results = await Promise.all([
auth('post', '/operations').send(
op(card.id, 'refund', '600', { originalExpenseId: expense.id }),
),
auth('patch', '/operations/' + expense.id).send({
version: expense.version,
amount: money('500'),
}),
]);
expect([201, 409]).toContain(results[0].status);
expect([200, 409]).toContain(results[1].status);
expect(results.filter((r) => r.status < 300)).toHaveLength(1);
const current = await getOp(expense.id);
const invalid = op(card.id, 'refund', '1', {
originalExpenseId: expense.id,
date: '2026-07-01',
});
await auth('post', '/operations').send(invalid).expect(400);
expect((await getOp(expense.id)).version).toBe(current.version);
const refunds = await db.bankCardOperation.findMany({
where: { originalExpenseId: expense.id, deletedAt: null },
});
const source = await db.bankCardOperation.findUniqueOrThrow({
where: { id: expense.id },
});
expect(
refunds.reduce((sum, row) => sum + row.amountKopecks, 0n) <=
source.amountKopecks,
).toBe(true);
});
it('serializes opening-date changes and validates archived previous transfer sides', async () => {
const card = await createCard();
const results = await Promise.all([
auth('patch', '/' + card.id).send({
version: 1,
openingDate: '2026-09-01',
}),
auth('post', '/operations').send(op(card.id)),
]);
expect(results.filter((r) => r.status < 300)).toHaveLength(1);
const current = await getCard(card.id);
const rows = await db.bankCardOperation.findMany({
where: { cardId: card.id, deletedAt: null },
});
expect(rows.every((row) => row.date >= current.openingDate)).toBe(true);
const a = await createCard(),
b = await createCard(),
c = await createCard();
const transfer = await createOp(
op(a.id, 'transfer', '10', { targetCardId: b.id }),
);
await auth('patch', '/' + b.id)
.send({ version: 1, archived: true })
.expect(200);
await auth('patch', '/operations/' + transfer.id)
.send({ version: 1, targetCardId: c.id })
.expect(409);
expect((await getCard(b.id)).balance.value).toBe('1010');
expect((await getCard(c.id)).balance.value).toBe('1000');
});
it('deleting refunds releases the purchase and a later replay never restores either record', async () => {
const card = await createCard();
const purchaseBody = op(card.id),
purchase = await createOp(purchaseBody);
const refundBody = op(card.id, 'refund', '50', {
originalExpenseId: purchase.id,
}),
refund = await createOp(refundBody);
await auth(
'delete',
`/operations/${refund.id}?version=${refund.version}`,
).expect(204);
const current = await getOp(purchase.id);
expect(current.availableRefund?.value).toBe('100');
await auth(
'delete',
`/operations/${purchase.id}?version=${current.version}`,
).expect(204);
await auth('post', '/operations').send(refundBody).expect(410);
await auth('post', '/operations').send(purchaseBody).expect(410);
await auth('post', '/operations')
.send(op(card.id, 'refund', '1', { originalExpenseId: purchase.id }))
.expect(400);
expect((await getCard(card.id)).balance.value).toBe('1000');
});
it('preserves history, fingerprints, archive and deleted replay across app restart', async () => {
const card = await createCard();
const purchase = await createOp(op(card.id));
const refund = await createOp(
op(card.id, 'refund', '10', { originalExpenseId: purchase.id }),
);
const deletedBody = op(card.id, 'topup', '20');
const deleted = await createOp(deletedBody);
await auth('delete', `/operations/${deleted.id}?version=1`).expect(204);
await auth('patch', '/' + card.id)
.send({ version: 1, archived: true })
.expect(200);
const before = await getCard(card.id);
await app.close();
await start();
expect(await getCard(card.id)).toEqual(before);
expect((await getOp(refund.id)).originalExpenseId).toBe(purchase.id);
await auth('post', '/operations').send(deletedBody).expect(410);
expect((await categories()).find((c) => c.isDefault)?.name).toBe(
'Остальное',
);
});
it('enforces actual FK and unique constraints', async () => {
await expect(
db.expenseCategory.create({
data: {
id: randomUUID(),
userId: randomUUID(),
name: 'Invalid',
nameKey: 'invalid',
},
}),
).rejects.toMatchObject({ code: 'P2003' });
const category = (await categories())[0];
await expect(
db.expenseCategory.create({
data: {
id: randomUUID(),
userId: owner,
name: category.name,
nameKey: category.name.toLocaleLowerCase('ru-RU'),
},
}),
).rejects.toMatchObject({ code: 'P2002' });
});
});
+1 -1
View File
@@ -1,4 +1,4 @@
process.env.DATABASE_URL = 'file:/tmp/wealth-ledger-e2e.db';
process.env.DATABASE_URL ??= 'file:/tmp/wealth-ledger-e2e.db';
process.env.FRONTEND_ORIGIN = 'http://localhost:5173';
process.env.JWT_SECRET = 'e2e-secret-that-is-at-least-thirty-two-characters';
process.env.TOKEN_ENCRYPTION_KEY = 'MDEyMzQ1Njc4OWFiY2RlZjAxMjM0NTY3ODlhYmNkZWY=';