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
+1 -1
View File
@@ -31,7 +31,7 @@ lock во время retry внешней сети. Access/cache generation sema
не JS floating-point арифметику для сумм. Currency — отдельная часть Money; не JS floating-point арифметику для сумм. Currency — отдельная часть Money;
разные валюты не складываются без явно заданного conversion contract. разные валюты не складываются без явно заданного conversion contract.
Источник: [Money](../../../docs/glossary.md#money), Источник: [Money](../../../docs/glossary.md#money),
пример: [decimal.ts](../../src/tbank/decimal.ts). UI formatting не меняет пример: [decimal.ts](../../src/common/decimal.ts). UI formatting не меняет
backend ownership расчётов и доменную семантику. backend ownership расчётов и доменную семантику.
## Schema и migration ## Schema и migration
@@ -0,0 +1,69 @@
-- CreateTable
CREATE TABLE "BankCard" (
"id" TEXT NOT NULL PRIMARY KEY,
"userId" TEXT NOT NULL,
"name" TEXT NOT NULL,
"bankName" TEXT,
"lastFour" TEXT,
"openingBalanceKopecks" BIGINT NOT NULL,
"openingDate" TEXT NOT NULL,
"version" INTEGER NOT NULL DEFAULT 1,
"archivedAt" DATETIME,
"createFingerprint" TEXT NOT NULL,
"createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" DATETIME NOT NULL,
CONSTRAINT "BankCard_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User" ("id") ON DELETE RESTRICT ON UPDATE CASCADE
);
-- CreateTable
CREATE TABLE "ExpenseCategory" (
"id" TEXT NOT NULL PRIMARY KEY,
"userId" TEXT NOT NULL,
"name" TEXT NOT NULL,
"nameKey" TEXT NOT NULL,
"seedKey" TEXT,
"version" INTEGER NOT NULL DEFAULT 1,
CONSTRAINT "ExpenseCategory_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User" ("id") ON DELETE RESTRICT ON UPDATE CASCADE
);
-- CreateTable
CREATE TABLE "BankCardOperation" (
"id" TEXT NOT NULL PRIMARY KEY,
"userId" TEXT NOT NULL,
"kind" TEXT NOT NULL,
"cardId" TEXT NOT NULL,
"targetCardId" TEXT,
"categoryId" TEXT,
"originalExpenseId" TEXT,
"amountKopecks" BIGINT NOT NULL,
"date" TEXT NOT NULL,
"description" TEXT,
"version" INTEGER NOT NULL DEFAULT 1,
"deletedAt" DATETIME,
"createFingerprint" TEXT NOT NULL,
"createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" DATETIME NOT NULL,
CONSTRAINT "BankCardOperation_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User" ("id") ON DELETE RESTRICT ON UPDATE CASCADE,
CONSTRAINT "BankCardOperation_cardId_fkey" FOREIGN KEY ("cardId") REFERENCES "BankCard" ("id") ON DELETE RESTRICT ON UPDATE CASCADE,
CONSTRAINT "BankCardOperation_targetCardId_fkey" FOREIGN KEY ("targetCardId") REFERENCES "BankCard" ("id") ON DELETE RESTRICT ON UPDATE CASCADE,
CONSTRAINT "BankCardOperation_categoryId_fkey" FOREIGN KEY ("categoryId") REFERENCES "ExpenseCategory" ("id") ON DELETE RESTRICT ON UPDATE CASCADE,
CONSTRAINT "BankCardOperation_originalExpenseId_fkey" FOREIGN KEY ("originalExpenseId") REFERENCES "BankCardOperation" ("id") ON DELETE RESTRICT ON UPDATE NO ACTION
);
-- CreateIndex
CREATE INDEX "BankCard_userId_idx" ON "BankCard"("userId");
-- CreateIndex
CREATE UNIQUE INDEX "ExpenseCategory_userId_nameKey_key" ON "ExpenseCategory"("userId", "nameKey");
-- CreateIndex
CREATE UNIQUE INDEX "ExpenseCategory_userId_seedKey_key" ON "ExpenseCategory"("userId", "seedKey");
-- CreateIndex
CREATE INDEX "BankCardOperation_userId_cardId_date_idx" ON "BankCardOperation"("userId", "cardId", "date");
-- CreateIndex
CREATE INDEX "BankCardOperation_userId_targetCardId_date_idx" ON "BankCardOperation"("userId", "targetCardId", "date");
-- CreateIndex
CREATE INDEX "BankCardOperation_originalExpenseId_idx" ON "BankCardOperation"("originalExpenseId");
+60
View File
@@ -12,6 +12,9 @@ model User {
id String @id id String @id
email String @unique email String @unique
passwordHash String passwordHash String
bankCards BankCard[]
expenseCategories ExpenseCategory[]
bankCardOperations BankCardOperation[]
sessions Session[] sessions Session[]
tbankConnection TbankConnection? tbankConnection TbankConnection?
createdAt DateTime @default(now()) createdAt DateTime @default(now())
@@ -37,3 +40,60 @@ model Session {
@@index([userId]) @@index([userId])
} }
model BankCard {
id String @id
userId String
name String
bankName String?
lastFour String?
openingBalanceKopecks BigInt
openingDate String
version Int @default(1)
archivedAt DateTime?
createFingerprint String
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
user User @relation(fields: [userId], references: [id], onDelete: Restrict)
operations BankCardOperation[] @relation("SourceCard")
incomingOperations BankCardOperation[] @relation("TargetCard")
@@index([userId])
}
model ExpenseCategory {
id String @id
userId String
name String
nameKey String
seedKey String?
version Int @default(1)
user User @relation(fields: [userId], references: [id], onDelete: Restrict)
operations BankCardOperation[]
@@unique([userId, nameKey])
@@unique([userId, seedKey])
}
model BankCardOperation {
id String @id
userId String
kind String
cardId String
targetCardId String?
categoryId String?
originalExpenseId String?
amountKopecks BigInt
date String
description String?
version Int @default(1)
deletedAt DateTime?
createFingerprint String
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
user User @relation(fields: [userId], references: [id], onDelete: Restrict)
card BankCard @relation("SourceCard", fields: [cardId], references: [id], onDelete: Restrict)
targetCard BankCard? @relation("TargetCard", fields: [targetCardId], references: [id], onDelete: Restrict)
category ExpenseCategory? @relation(fields: [categoryId], references: [id], onDelete: Restrict)
originalExpense BankCardOperation? @relation("RefundExpense", fields: [originalExpenseId], references: [id], onDelete: Restrict, onUpdate: NoAction)
refunds BankCardOperation[] @relation("RefundExpense")
@@index([userId, cardId, date])
@@index([userId, targetCardId, date])
@@index([originalExpenseId])
}
+2
View File
@@ -2,6 +2,7 @@ import { Module } from '@nestjs/common';
import { ConfigModule, ConfigService } from '@nestjs/config'; import { ConfigModule, ConfigService } from '@nestjs/config';
import { randomUUID } from 'node:crypto'; import { randomUUID } from 'node:crypto';
import { LoggerModule } from 'nestjs-pino'; import { LoggerModule } from 'nestjs-pino';
import { BankCardsModule } from './bank-cards/bank-cards.module';
import { AuthModule } from './auth/auth.module'; import { AuthModule } from './auth/auth.module';
import { LoggingModule } from './common/logging/logging.module'; import { LoggingModule } from './common/logging/logging.module';
import { Environment, validateEnv } from './config/env.validation'; import { Environment, validateEnv } from './config/env.validation';
@@ -49,6 +50,7 @@ import { TbankConnectionModule } from './tbank/tbank-connection.module';
LoggingModule, LoggingModule,
PrismaModule, PrismaModule,
AuthModule, AuthModule,
BankCardsModule,
TbankConnectionModule, TbankConnectionModule,
], ],
}) })
@@ -0,0 +1,153 @@
import {
bankBalance,
bankSummary,
isCalendarDate,
moscowToday,
} from './bank-card.calculations';
describe('bank card calculations', () => {
it('uses Moscow midnight for today and the current month', () => {
expect(moscowToday(new Date('2026-08-31T20:59:59.999Z'))).toBe(
'2026-08-31',
);
expect(moscowToday(new Date('2026-08-31T21:00:00.000Z'))).toBe(
'2026-09-01',
);
});
it.each([
'0001-01-01',
'9999-12-31',
'2024-02-29',
'2000-02-29',
'2026-09-12',
])('accepts calendar date %s', (value) => {
expect(isCalendarDate(value)).toBe(true);
});
it.each([
'0000-01-01',
'10000-01-01',
'2026-02-29',
'1900-02-29',
'2026-04-31',
'2026-00-01',
'2026-13-01',
'2026-01-00',
'2026-01-32',
'2026-1-01',
'2026-09-12\n',
'2026-09-12T00:00:00Z',
'',
])('rejects invalid calendar date %s', (value) => {
expect(isCalendarDate(value)).toBe(false);
});
it('folds expenses, topups, refunds and both transfer sides exactly', () => {
const operations = [
{ kind: 'expense', cardId: 'a', targetCardId: null, amountKopecks: 10n },
{ kind: 'expense', cardId: 'a', targetCardId: null, amountKopecks: 20n },
{ kind: 'topup', cardId: 'a', targetCardId: null, amountKopecks: 15n },
{ kind: 'refund', cardId: 'a', targetCardId: null, amountKopecks: 5n },
{ kind: 'transfer', cardId: 'a', targetCardId: 'b', amountKopecks: 120n },
{ kind: 'expense', cardId: 'c', targetCardId: null, amountKopecks: 300n },
];
expect(bankBalance('a', 100n, operations.slice(0, 2))).toBe(70n);
expect(bankBalance('a', 100n, operations)).toBe(-30n);
expect(bankBalance('b', 0n, operations)).toBe(120n);
expect(bankBalance('unused', -1n, operations)).toBe(-1n);
});
it('does not bound computed balances by the storage range', () => {
expect(
bankBalance('a', 2n ** 63n - 1n, [
{ kind: 'topup', cardId: 'a', targetCardId: null, amountKopecks: 1n },
]),
).toBe(2n ** 63n);
expect(
bankBalance('a', -(2n ** 63n), [
{ kind: 'expense', cardId: 'a', targetCardId: null, amountKopecks: 1n },
]),
).toBe(-(2n ** 63n) - 1n);
});
it('counts refunds in their own month and inherited category, excluding transfers and topups', () => {
const operations = [
{
kind: 'expense',
date: '2026-08-31',
categoryId: 'food',
amountKopecks: 1000n,
},
{
kind: 'refund',
date: '2026-09-01',
categoryId: 'food',
amountKopecks: 500n,
},
{
kind: 'expense',
date: '2026-09-30',
categoryId: 'food',
amountKopecks: 100n,
},
{
kind: 'expense',
date: '2026-09-03',
categoryId: 'travel',
amountKopecks: 20n,
},
{
kind: 'transfer',
date: '2026-09-03',
categoryId: null,
amountKopecks: 2000n,
},
{
kind: 'topup',
date: '2026-09-03',
categoryId: null,
amountKopecks: 3000n,
},
{
kind: 'expense',
date: '2026-10-01',
categoryId: 'travel',
amountKopecks: 5000n,
},
];
expect(bankSummary('2026-09', operations)).toEqual({
expenses: 120n,
refunds: 500n,
net: -380n,
categories: [
{ categoryId: 'food', expenses: 100n, refunds: 500n, net: -400n },
{ categoryId: 'travel', expenses: 20n, refunds: 0n, net: 20n },
],
});
expect(bankSummary('2026-07', operations)).toEqual({
expenses: 0n,
refunds: 0n,
net: 0n,
categories: [],
});
});
it('keeps summary sums beyond signed 64-bit exact', () => {
const operation = {
kind: 'expense',
date: '2026-09-01',
categoryId: 'food',
amountKopecks: 2n ** 63n - 1n,
};
const total = 2n * (2n ** 63n - 1n);
expect(bankSummary('2026-09', [operation, operation])).toEqual({
expenses: total,
refunds: 0n,
net: total,
categories: [
{ categoryId: 'food', expenses: total, refunds: 0n, net: total },
],
});
});
});
+86
View File
@@ -0,0 +1,86 @@
export function moscowToday(now = new Date()): string {
return new Intl.DateTimeFormat('en-CA', {
timeZone: 'Europe/Moscow',
year: 'numeric',
month: '2-digit',
day: '2-digit',
}).format(now);
}
export function isCalendarDate(value: string): boolean {
if (value.length !== 10 || !/^\d{4}-\d{2}-\d{2}$/.test(value)) return false;
if (value.startsWith('0000')) return false;
const date = new Date(`${value}T00:00:00.000Z`);
return (
!Number.isNaN(date.getTime()) && date.toISOString().slice(0, 10) === value
);
}
export function bankBalance(
cardId: string,
opening: bigint,
operations: Array<{
kind: string;
cardId: string;
targetCardId: string | null;
amountKopecks: bigint;
}>,
): bigint {
// ponytail: O(N) history fold; optimize only after measuring growing histories.
let balance = opening;
for (const operation of operations) {
if (operation.cardId === cardId) {
if (operation.kind === 'expense' || operation.kind === 'transfer')
balance -= operation.amountKopecks;
else if (operation.kind === 'topup' || operation.kind === 'refund')
balance += operation.amountKopecks;
}
if (operation.kind === 'transfer' && operation.targetCardId === cardId)
balance += operation.amountKopecks;
}
return balance;
}
export function bankSummary(
month: string,
operations: Array<{
kind: string;
date: string;
amountKopecks: bigint;
categoryId: string | null;
}>,
) {
let expenses = 0n;
let refunds = 0n;
const categories = new Map<
string,
{ categoryId: string; expenses: bigint; refunds: bigint; net: bigint }
>();
// ponytail: O(N) history fold; optimize only after measuring growing histories.
for (const operation of operations) {
if (operation.date.slice(0, 7) !== month) continue;
if (operation.kind !== 'expense' && operation.kind !== 'refund') continue;
const expense = operation.kind === 'expense' ? operation.amountKopecks : 0n;
const refund = operation.kind === 'refund' ? operation.amountKopecks : 0n;
expenses += expense;
refunds += refund;
if (operation.categoryId !== null) {
const category = categories.get(operation.categoryId) ?? {
categoryId: operation.categoryId,
expenses: 0n,
refunds: 0n,
net: 0n,
};
category.expenses += expense;
category.refunds += refund;
category.net = category.expenses - category.refunds;
categories.set(category.categoryId, category);
}
}
return {
expenses,
refunds,
net: expenses - refunds,
categories: [...categories.values()],
};
}
+230
View File
@@ -0,0 +1,230 @@
import {
ApiProperty,
ApiPropertyOptional,
OmitType,
PartialType,
} from '@nestjs/swagger';
import { Transform, Type } from 'class-transformer';
import {
IsBoolean,
IsDefined,
IsIn,
IsInt,
IsOptional,
IsString,
IsUUID,
Matches,
Max,
MaxLength,
Min,
MinLength,
ValidateIf,
ValidateNested,
} from 'class-validator';
import { MoneyDto } from '../common/money.dto';
const trim = ({ value }: { value: unknown }) =>
typeof value === 'string' ? value.trim() : value;
export const bankOperationKinds = [
'expense',
'topup',
'transfer',
'refund',
] as const;
export type BankOperationKind = (typeof bankOperationKinds)[number];
export class RubMoneyInputDto {
@ApiProperty({ enum: ['RUB'] }) @IsIn(['RUB']) currency: string;
@ApiProperty({ pattern: '^-?[0-9]+(\\.[0-9]{1,2})?$' })
@IsString()
@Matches(/^-?\d+(\.\d{1,2})?$/)
value: string;
}
export class CreateBankCardDto {
@ApiProperty({ format: 'uuid' }) @IsUUID() id: string;
@ApiProperty({ minLength: 1, maxLength: 100 })
@Transform(trim)
@IsString()
@MinLength(1)
@MaxLength(100)
name: string;
@ApiPropertyOptional({ type: String, nullable: true, maxLength: 100 })
@IsOptional()
@Transform(trim)
@IsString()
@MaxLength(100)
bankName?: string | null;
@ApiPropertyOptional({ type: String, nullable: true, pattern: '^[0-9]{4}$' })
@IsOptional()
@Matches(/^[0-9]{4}$/)
lastFour?: string | null;
@ApiProperty({ type: RubMoneyInputDto })
@IsDefined()
@ValidateNested()
@Type(() => RubMoneyInputDto)
openingBalance: MoneyDto;
@ApiProperty({ format: 'date' })
@IsString()
@Matches(/^\d{4}-\d{2}-\d{2}$/)
openingDate: string;
}
export class UpdateBankCardDto extends PartialType(
OmitType(CreateBankCardDto, ['id'] as const),
{ skipNullProperties: false },
) {
@ApiProperty({ minimum: 1 }) @IsInt() @Min(1) version: number;
@ApiPropertyOptional()
@ValidateIf((_object, value: unknown) => value !== undefined)
@IsBoolean()
archived?: boolean;
}
export class CategoryNameDto {
@ApiProperty({ minLength: 1, maxLength: 80 })
@Transform(trim)
@IsString()
@MinLength(1)
@MaxLength(80)
name: string;
}
export class UpdateExpenseCategoryDto extends CategoryNameDto {
@ApiProperty({ minimum: 1 }) @IsInt() @Min(1) version: number;
}
export class CreateBankOperationDto {
@ApiProperty({ format: 'uuid' }) @IsUUID() id: string;
@ApiProperty({ enum: bankOperationKinds })
@IsIn(bankOperationKinds)
kind: BankOperationKind;
@ApiProperty({ format: 'uuid' }) @IsUUID() cardId: string;
@ApiPropertyOptional({ format: 'uuid' })
@ValidateIf((_object, value: unknown) => value !== undefined)
@IsUUID()
targetCardId?: string;
@ApiPropertyOptional({ format: 'uuid' })
@ValidateIf((_object, value: unknown) => value !== undefined)
@IsUUID()
categoryId?: string;
@ApiPropertyOptional({ format: 'uuid' })
@ValidateIf((_object, value: unknown) => value !== undefined)
@IsUUID()
originalExpenseId?: string;
@ApiProperty({ type: RubMoneyInputDto })
@IsDefined()
@ValidateNested()
@Type(() => RubMoneyInputDto)
amount: MoneyDto;
@ApiProperty({ format: 'date' })
@IsString()
@Matches(/^\d{4}-\d{2}-\d{2}$/)
date: string;
@ApiPropertyOptional({ maxLength: 500 })
@ValidateIf((_object, value: unknown) => value !== undefined)
@IsString()
@MaxLength(500)
description?: string;
}
export class UpdateBankOperationDto extends PartialType(
OmitType(CreateBankOperationDto, ['id'] as const),
{ skipNullProperties: false },
) {
@ApiProperty({ minimum: 1 }) @IsInt() @Min(1) version: number;
}
export class BankCardsQueryDto {
@ApiPropertyOptional({ enum: ['true', 'false'] })
@IsOptional()
@IsIn(['true', 'false'])
includeArchived?: string;
}
export class BankPageQueryDto {
@ApiPropertyOptional({ minimum: 1, default: 1 })
@IsOptional()
@Type(() => Number)
@IsInt()
@Min(1)
page?: number;
@ApiPropertyOptional({ minimum: 1, maximum: 100, default: 50 })
@IsOptional()
@Type(() => Number)
@IsInt()
@Min(1)
@Max(100)
limit?: number;
}
export class BankOperationsQueryDto extends BankPageQueryDto {
@ApiProperty({ pattern: '^[0-9]{4}-(0[1-9]|1[0-2])$' })
@Matches(/^\d{4}-(0[1-9]|1[0-2])$/)
month: string;
}
export class BankSummaryQueryDto {
@ApiProperty({ pattern: '^[0-9]{4}-(0[1-9]|1[0-2])$' })
@Matches(/^\d{4}-(0[1-9]|1[0-2])$/)
month: string;
@ApiPropertyOptional({ format: 'uuid' })
@IsOptional()
@IsUUID()
cardId?: string;
}
export class BankVersionQueryDto {
@ApiProperty({ minimum: 1 })
@Type(() => Number)
@IsInt()
@Min(1)
version: number;
}
export class ExpenseCategoryDto {
@ApiProperty({ type: String }) id: string;
@ApiProperty({ type: String }) name: string;
@ApiProperty({ type: Boolean }) isDefault: boolean;
@ApiProperty({ type: Number }) version: number;
}
export class BankCardDto {
@ApiProperty({ type: String }) id: string;
@ApiProperty({ type: String }) name: string;
@ApiProperty({ type: String, nullable: true }) bankName: string | null;
@ApiProperty({ type: String, nullable: true }) lastFour: string | null;
@ApiProperty({ type: MoneyDto }) openingBalance: MoneyDto;
@ApiProperty({ type: String }) openingDate: string;
@ApiProperty({ type: MoneyDto }) balance: MoneyDto;
@ApiProperty({ type: String, nullable: true }) archivedAt: string | null;
@ApiProperty({ type: Number }) version: number;
}
export class BankOperationDto {
@ApiProperty({ type: String }) id: string;
@ApiProperty({ enum: bankOperationKinds }) kind: BankOperationKind;
@ApiProperty({ type: String }) cardId: string;
@ApiProperty({ type: String }) cardName: string;
@ApiProperty({ type: String, nullable: true }) targetCardId: string | null;
@ApiProperty({ type: String, nullable: true }) targetCardName: string | null;
@ApiProperty({ type: String, nullable: true }) categoryId: string | null;
@ApiProperty({ type: String, nullable: true }) categoryName: string | null;
@ApiProperty({ type: String, nullable: true }) originalExpenseId:
string | null;
@ApiProperty({ type: MoneyDto }) amount: MoneyDto;
@ApiProperty({ type: MoneyDto, nullable: true })
availableRefund: MoneyDto | null;
@ApiProperty({ type: String }) date: string;
@ApiProperty({ type: String, nullable: true }) description: string | null;
@ApiProperty({ type: Number }) version: number;
@ApiProperty({ type: String }) createdAt: string;
}
export class BankOperationsPageDto {
@ApiProperty({ type: BankOperationDto, isArray: true })
items: BankOperationDto[];
@ApiProperty({ type: Number }) page: number;
@ApiProperty({ type: Number }) total: number;
@ApiProperty({ type: Boolean }) hasNext: boolean;
}
export class BankSummaryCategoryDto {
@ApiProperty({ type: String }) categoryId: string;
@ApiProperty({ type: String }) categoryName: string;
@ApiProperty({ type: MoneyDto }) expenses: MoneyDto;
@ApiProperty({ type: MoneyDto }) refunds: MoneyDto;
@ApiProperty({ type: MoneyDto }) net: MoneyDto;
}
export class BankSummaryDto {
@ApiProperty({ type: String }) month: string;
@ApiProperty({ type: String, nullable: true }) cardId: string | null;
@ApiProperty({ type: MoneyDto }) expenses: MoneyDto;
@ApiProperty({ type: MoneyDto }) refunds: MoneyDto;
@ApiProperty({ type: MoneyDto }) net: MoneyDto;
@ApiProperty({ type: BankSummaryCategoryDto, isArray: true })
categories: BankSummaryCategoryDto[];
}
+175
View File
@@ -0,0 +1,175 @@
import {
Body,
Controller,
Delete,
Get,
HttpCode,
Param,
Patch,
Post,
Query,
Req,
UseGuards,
} from '@nestjs/common';
import {
ApiBearerAuth,
ApiCreatedResponse,
ApiNoContentResponse,
ApiOkResponse,
ApiTags,
ApiResponse,
getSchemaPath,
} from '@nestjs/swagger';
import type { Request } from 'express';
import { AccessPayload } from '../auth/auth.types';
import { JwtAuthGuard } from '../auth/jwt-auth.guard';
import {
ApiProblemResponses,
ProblemDetailsDto,
} from '../common/decorators/api-safe-error-response.decorator';
import {
BankCardDto,
BankCardsQueryDto,
BankOperationDto,
BankOperationsPageDto,
BankOperationsQueryDto,
BankPageQueryDto,
BankSummaryDto,
BankSummaryQueryDto,
BankVersionQueryDto,
CategoryNameDto,
CreateBankCardDto,
CreateBankOperationDto,
ExpenseCategoryDto,
UpdateBankCardDto,
UpdateBankOperationDto,
UpdateExpenseCategoryDto,
} from './bank-card.dto';
import { BankCardsService } from './bank-cards.service';
type AuthRequest = Request & { user: AccessPayload };
@ApiResponse({
status: 409,
description:
'BANK_CARD_ARCHIVED, BANK_VERSION_CONFLICT, BANK_INTENT_CONFLICT, BANK_REFUND_LIMIT, BANK_DEPENDENT_REFUNDS, BANK_DUPLICATE_CATEGORY or BANK_WRITE_CONFLICT',
content: {
'application/problem+json': {
schema: { $ref: getSchemaPath(ProblemDetailsDto) },
},
},
})
@ApiResponse({
status: 410,
description:
'BANK_OPERATION_DELETED: creation replay refers to a deleted operation',
content: {
'application/problem+json': {
schema: { $ref: getSchemaPath(ProblemDetailsDto) },
},
},
})
@ApiTags('bank-cards')
@ApiBearerAuth()
@ApiProblemResponses()
@UseGuards(JwtAuthGuard)
@Controller('bank-cards')
export class BankCardsController {
constructor(private readonly cards: BankCardsService) {}
@Get()
@ApiOkResponse({ type: BankCardDto, isArray: true })
list(@Req() req: AuthRequest, @Query() query: BankCardsQueryDto) {
return this.cards.list(req.user.sub, query.includeArchived === 'true');
}
@Post()
@ApiCreatedResponse({ type: BankCardDto })
create(@Req() req: AuthRequest, @Body() body: CreateBankCardDto) {
return this.cards.createCard(req.user.sub, body);
}
@Get('categories')
@ApiOkResponse({ type: ExpenseCategoryDto, isArray: true })
categories(@Req() req: AuthRequest) {
return this.cards.categories(req.user.sub);
}
@Post('categories')
@ApiCreatedResponse({ type: ExpenseCategoryDto })
createCategory(@Req() req: AuthRequest, @Body() body: CategoryNameDto) {
return this.cards.createCategory(req.user.sub, body.name);
}
@Patch('categories/:id')
@ApiOkResponse({ type: ExpenseCategoryDto })
updateCategory(
@Req() req: AuthRequest,
@Param('id') id: string,
@Body() body: UpdateExpenseCategoryDto,
) {
return this.cards.updateCategory(req.user.sub, id, body.name, body.version);
}
@Get('summary')
@ApiOkResponse({ type: BankSummaryDto })
summary(@Req() req: AuthRequest, @Query() query: BankSummaryQueryDto) {
return this.cards.summary(req.user.sub, query);
}
@Post('operations')
@ApiCreatedResponse({ type: BankOperationDto })
createOperation(
@Req() req: AuthRequest,
@Body() body: CreateBankOperationDto,
) {
return this.cards.createOperation(req.user.sub, body);
}
@Get('operations/:id')
@ApiOkResponse({ type: BankOperationDto })
operation(@Req() req: AuthRequest, @Param('id') id: string) {
return this.cards.getOperation(req.user.sub, id);
}
@Patch('operations/:id')
@ApiOkResponse({ type: BankOperationDto })
updateOperation(
@Req() req: AuthRequest,
@Param('id') id: string,
@Body() body: UpdateBankOperationDto,
) {
return this.cards.updateOperation(req.user.sub, id, body);
}
@Delete('operations/:id')
@HttpCode(204)
@ApiNoContentResponse()
deleteOperation(
@Req() req: AuthRequest,
@Param('id') id: string,
@Query() query: BankVersionQueryDto,
) {
return this.cards.deleteOperation(req.user.sub, id, query.version);
}
@Get(':id/operations')
@ApiOkResponse({ type: BankOperationsPageDto })
history(
@Req() req: AuthRequest,
@Param('id') id: string,
@Query() query: BankOperationsQueryDto,
) {
return this.cards.history(req.user.sub, id, query);
}
@Get(':id/expenses')
@ApiOkResponse({ type: BankOperationsPageDto })
expenses(
@Req() req: AuthRequest,
@Param('id') id: string,
@Query() query: BankPageQueryDto,
) {
return this.cards.history(req.user.sub, id, query, true);
}
@Get(':id')
@ApiOkResponse({ type: BankCardDto })
get(@Req() req: AuthRequest, @Param('id') id: string) {
return this.cards.get(req.user.sub, id);
}
@Patch(':id')
@ApiOkResponse({ type: BankCardDto })
update(
@Req() req: AuthRequest,
@Param('id') id: string,
@Body() body: UpdateBankCardDto,
) {
return this.cards.updateCard(req.user.sub, id, body);
}
}
+5
View File
@@ -0,0 +1,5 @@
import { Module } from '@nestjs/common';
import { BankCardsController } from './bank-cards.controller';
import { BankCardsService } from './bank-cards.service';
@Module({ controllers: [BankCardsController], providers: [BankCardsService] })
export class BankCardsModule {}
+670
View File
@@ -0,0 +1,670 @@
import {
BadRequestException,
ConflictException,
GoneException,
Injectable,
NotFoundException,
} from '@nestjs/common';
import { createHash, randomUUID } from 'node:crypto';
import {
Prisma,
BankCard,
BankCardOperation,
} from '../../generated/prisma/client';
import { PrismaService } from '../prisma/prisma.service';
import { parseRubMoney, rubMoney } from '../common/rub-money';
import {
bankBalance,
bankSummary,
isCalendarDate,
moscowToday,
} from './bank-card.calculations';
import {
BankCardDto,
BankOperationDto,
BankOperationsQueryDto,
BankPageQueryDto,
BankSummaryQueryDto,
CreateBankCardDto,
CreateBankOperationDto,
UpdateBankCardDto,
UpdateBankOperationDto,
} from './bank-card.dto';
type Tx = Prisma.TransactionClient;
const seeds = [
'Продукты',
'Кафе',
'Такси',
'Транспорт',
'Жильё',
'Здоровье',
'Покупки',
'Развлечения',
'Другое',
];
const fail = (code = 'BANK_INVALID_INPUT'): never => {
throw new BadRequestException({ code });
};
const conflict = (code = 'BANK_VERSION_CONFLICT'): never => {
throw new ConflictException({ code });
};
const missing = (): never => {
throw new NotFoundException({ code: 'BANK_NOT_FOUND' });
};
const fingerprint = (value: unknown) =>
createHash('sha256')
.update(
JSON.stringify(value, (_key, item: unknown) =>
typeof item === 'bigint' ? item.toString() : item,
),
)
.digest('hex');
function amount(value: Parameters<typeof parseRubMoney>[0], positive = false) {
try {
return parseRubMoney(value, positive);
} catch {
return fail('BANK_INVALID_MONEY');
}
}
function calendar(value: string) {
if (!isCalendarDate(value)) fail('BANK_INVALID_DATE');
return value;
}
function currentDate(value: string) {
calendar(value);
if (value > moscowToday()) fail('BANK_INVALID_DATE');
}
function name(value: string, max: number) {
if (typeof value !== 'string' || !value.trim() || value.trim().length > max)
fail();
return value.trim();
}
function cardInput(dto: CreateBankCardDto) {
const bankName = dto.bankName?.trim() || null;
if (bankName && bankName.length > 100) fail();
if (dto.lastFour != null && !/^\d{4}$/.test(dto.lastFour)) fail();
return {
name: name(dto.name, 100),
bankName,
lastFour: dto.lastFour || null,
openingBalanceKopecks: amount(dto.openingBalance),
openingDate: calendar(dto.openingDate),
};
}
function operationInput(dto: CreateBankOperationDto) {
if (
dto.description != null &&
(typeof dto.description !== 'string' || dto.description.length > 500)
)
fail();
return {
kind: dto.kind,
cardId: dto.cardId,
targetCardId: dto.targetCardId ?? null,
categoryId: dto.categoryId ?? null,
originalExpenseId: dto.originalExpenseId ?? null,
amountKopecks: amount(dto.amount, true),
date: calendar(dto.date),
description: dto.description || null,
};
}
@Injectable()
export class BankCardsService {
constructor(private readonly prisma: PrismaService) {}
private async write<T>(run: (tx: Tx) => Promise<T>): Promise<T> {
for (let attempt = 0; ; attempt++) {
try {
return await this.prisma.$transaction(run, {
maxWait: 10000,
timeout: 15000,
});
} catch (error) {
const code =
error instanceof Prisma.PrismaClientKnownRequestError
? error.code
: '';
const busy =
code === 'P2034' ||
code === 'P2028' ||
(error instanceof Error &&
/SQLITE_BUSY|database is locked/.test(error.message));
if ((busy || code === 'P2002') && attempt < 2) continue;
if (busy) conflict('BANK_WRITE_CONFLICT');
if (code === 'P2002') conflict('BANK_DUPLICATE_CATEGORY');
throw error;
}
}
}
private async card(tx: Tx, userId: string, id: string, active = false) {
const card = await tx.bankCard.findFirst({ where: { id, userId } });
if (!card) return missing();
if (active && card.archivedAt) conflict('BANK_CARD_ARCHIVED');
return card;
}
private async operation(tx: Tx, userId: string, id: string) {
const row = await tx.bankCardOperation.findFirst({ where: { id, userId } });
if (!row) return missing();
return row;
}
private async seed(tx: Tx, userId: string) {
for (const [index, label] of seeds.entries()) {
const seedKey = index === 8 ? 'other' : String(index);
if (
!(await tx.expenseCategory.findUnique({
where: { userId_seedKey: { userId, seedKey } },
}))
)
await tx.expenseCategory.create({
data: {
id: randomUUID(),
userId,
seedKey,
name: label,
nameKey: label.toLocaleLowerCase('ru-RU'),
},
});
}
}
private async cardView(tx: Tx, card: BankCard): Promise<BankCardDto> {
const rows = await tx.bankCardOperation.findMany({
where: {
userId: card.userId,
deletedAt: null,
OR: [{ cardId: card.id }, { targetCardId: card.id }],
},
});
return {
id: card.id,
name: card.name,
bankName: card.bankName,
lastFour: card.lastFour,
openingDate: card.openingDate,
openingBalance: rubMoney(card.openingBalanceKopecks),
balance: rubMoney(bankBalance(card.id, card.openingBalanceKopecks, rows)),
version: card.version,
archivedAt: card.archivedAt?.toISOString() ?? null,
};
}
private async operationView(
tx: Tx,
row: BankCardOperation,
): Promise<BankOperationDto> {
const card = await this.card(tx, row.userId, row.cardId);
const target = row.targetCardId
? await this.card(tx, row.userId, row.targetCardId)
: null;
const source = row.originalExpenseId
? await this.operation(tx, row.userId, row.originalExpenseId)
: null;
const categoryId = source?.categoryId ?? row.categoryId;
const category = categoryId
? await tx.expenseCategory.findFirst({
where: { id: categoryId, userId: row.userId },
})
: null;
const refunds =
row.kind === 'expense' || source
? await tx.bankCardOperation.findMany({
where: {
userId: row.userId,
originalExpenseId: source?.id ?? row.id,
deletedAt: null,
...(source ? { id: { not: row.id } } : {}),
},
})
: [];
return {
id: row.id,
kind: row.kind as BankOperationDto['kind'],
cardId: row.cardId,
cardName: card.name,
targetCardId: row.targetCardId,
targetCardName: target?.name ?? null,
categoryId,
categoryName: category?.name ?? null,
originalExpenseId: row.originalExpenseId,
amount: rubMoney(row.amountKopecks),
availableRefund:
row.kind === 'expense' || source
? rubMoney(
(source?.amountKopecks ?? row.amountKopecks) -
refunds.reduce((sum, r) => sum + r.amountKopecks, 0n),
)
: null,
date: row.date,
description: row.description,
version: row.version,
createdAt: row.createdAt.toISOString(),
};
}
list(userId: string, includeArchived = false) {
return this.prisma.$transaction(async (tx) =>
Promise.all(
(
await tx.bankCard.findMany({
where: { userId, ...(includeArchived ? {} : { archivedAt: null }) },
orderBy: [{ createdAt: 'asc' }, { id: 'asc' }],
})
).map((card) => this.cardView(tx, card)),
),
);
}
get(userId: string, id: string) {
return this.prisma.$transaction(async (tx) =>
this.cardView(tx, await this.card(tx, userId, id)),
);
}
createCard(userId: string, dto: CreateBankCardDto) {
const input = cardInput(dto),
hash = fingerprint(input);
return this.write(async (tx) => {
const existing = await tx.bankCard.findUnique({ where: { id: dto.id } });
if (existing) {
if (existing.userId !== userId) missing();
if (existing.createFingerprint !== hash)
conflict('BANK_INTENT_CONFLICT');
return this.cardView(tx, existing);
}
currentDate(input.openingDate);
await this.seed(tx, userId);
return this.cardView(
tx,
await tx.bankCard.create({
data: { ...input, id: dto.id, userId, createFingerprint: hash },
}),
);
});
}
updateCard(userId: string, id: string, dto: UpdateBankCardDto) {
return this.write(async (tx) => {
const previous = await this.card(tx, userId, id);
if (previous.version !== dto.version) conflict();
const input = cardInput({
id,
name: dto.name ?? previous.name,
bankName:
dto.bankName === undefined
? (previous.bankName ?? undefined)
: (dto.bankName ?? undefined),
lastFour:
dto.lastFour === undefined
? (previous.lastFour ?? undefined)
: (dto.lastFour ?? undefined),
openingBalance:
dto.openingBalance ?? rubMoney(previous.openingBalanceKopecks),
openingDate: dto.openingDate ?? previous.openingDate,
});
const financial =
input.openingBalanceKopecks !== previous.openingBalanceKopecks ||
input.openingDate !== previous.openingDate;
if (financial && previous.archivedAt) conflict('BANK_CARD_ARCHIVED');
currentDate(input.openingDate);
if (
await tx.bankCardOperation.findFirst({
where: {
userId,
deletedAt: null,
date: { lt: input.openingDate },
OR: [{ cardId: id }, { targetCardId: id }],
},
})
)
fail('BANK_OPENING_DATE_CONFLICT');
const changed = await tx.bankCard.updateMany({
where: { id, userId, version: dto.version },
data: {
...input,
version: { increment: 1 },
...(dto.archived === undefined
? {}
: {
archivedAt: dto.archived
? (previous.archivedAt ?? new Date())
: null,
}),
},
});
if (changed.count !== 1) conflict();
return this.cardView(tx, await this.card(tx, userId, id));
});
}
async categories(userId: string) {
return (
await this.prisma.expenseCategory.findMany({
where: { userId },
orderBy: [{ name: 'asc' }, { id: 'asc' }],
})
).map(({ id, name, seedKey, version }) => ({
id,
name,
isDefault: seedKey === 'other',
version,
}));
}
createCategory(userId: string, value: string) {
const label = name(value, 80);
return this.write(async (tx) => {
await this.seed(tx, userId);
const row = await tx.expenseCategory.create({
data: {
id: randomUUID(),
userId,
name: label,
nameKey: label.toLocaleLowerCase('ru-RU'),
},
});
return {
id: row.id,
name: row.name,
isDefault: false,
version: row.version,
};
});
}
updateCategory(userId: string, id: string, value: string, version: number) {
const label = name(value, 80);
return this.write(async (tx) => {
const row = await tx.expenseCategory.findFirst({ where: { id, userId } });
if (!row) missing();
if (
(
await tx.expenseCategory.updateMany({
where: { id, userId, version },
data: {
name: label,
nameKey: label.toLocaleLowerCase('ru-RU'),
version: { increment: 1 },
},
})
).count !== 1
)
conflict();
return {
id,
name: label,
isDefault: row!.seedKey === 'other',
version: version + 1,
};
});
}
private async validateOperation(
tx: Tx,
userId: string,
input: ReturnType<typeof operationInput>,
old?: BankCardOperation,
) {
const cards = new Set(
[input.cardId, input.targetCardId, old?.cardId, old?.targetCardId].filter(
(id): id is string => Boolean(id),
),
);
currentDate(input.date);
for (const id of cards) {
const card = await this.card(tx, userId, id, true);
if (
(id === input.cardId || id === input.targetCardId) &&
input.date < card.openingDate
)
fail('BANK_INVALID_DATE');
}
if (
old &&
(old.kind !== input.kind ||
old.originalExpenseId !== input.originalExpenseId)
)
fail('BANK_IMMUTABLE_OPERATION');
if (input.kind === 'transfer') {
if (
!input.targetCardId ||
input.targetCardId === input.cardId ||
input.categoryId ||
input.originalExpenseId
)
fail();
} else if (input.targetCardId) fail();
if (input.kind === 'expense') {
if (input.originalExpenseId) fail();
if (!input.categoryId) {
const category = await tx.expenseCategory.findUnique({
where: { userId_seedKey: { userId, seedKey: 'other' } },
});
if (!category) missing();
input.categoryId = category!.id;
}
if (
!(await tx.expenseCategory.findFirst({
where: { userId, id: input.categoryId },
}))
)
missing();
} else if (input.categoryId) fail();
if (input.kind !== 'refund' && input.originalExpenseId) fail();
if (input.kind === 'refund') {
if (!input.originalExpenseId) fail();
const source = await this.operation(tx, userId, input.originalExpenseId!);
if (
source.deletedAt ||
source.kind !== 'expense' ||
source.cardId !== input.cardId
)
fail('BANK_INVALID_REFUND');
if (
(
await tx.bankCardOperation.updateMany({
where: {
id: source.id,
userId,
version: source.version,
deletedAt: null,
},
data: { version: { increment: 1 } },
})
).count !== 1
)
conflict();
const refunds = await tx.bankCardOperation.findMany({
where: {
userId,
originalExpenseId: source.id,
deletedAt: null,
...(old ? { id: { not: old.id } } : {}),
},
});
if (input.date < source.date) fail('BANK_INVALID_DATE');
if (
refunds.reduce((sum, r) => sum + r.amountKopecks, 0n) +
input.amountKopecks >
source.amountKopecks
)
conflict('BANK_REFUND_LIMIT');
}
if (old?.kind === 'expense') {
const refunds = await tx.bankCardOperation.findMany({
where: { userId, originalExpenseId: old.id, deletedAt: null },
});
if (
refunds.length &&
(input.cardId !== old.cardId ||
refunds.some((row) => row.date < input.date) ||
refunds.reduce((sum, row) => sum + row.amountKopecks, 0n) >
input.amountKopecks)
)
conflict('BANK_DEPENDENT_REFUNDS');
}
}
createOperation(userId: string, dto: CreateBankOperationDto) {
const normalized = operationInput(dto),
hash = fingerprint(normalized);
return this.write(async (tx) => {
const existing = await tx.bankCardOperation.findUnique({
where: { id: dto.id },
});
if (existing) {
if (existing.userId !== userId) missing();
if (existing.createFingerprint !== hash)
conflict('BANK_INTENT_CONFLICT');
if (existing.deletedAt)
throw new GoneException({ code: 'BANK_OPERATION_DELETED' });
return this.operationView(tx, existing);
}
const input = { ...normalized };
await this.validateOperation(tx, userId, input);
return this.operationView(
tx,
await tx.bankCardOperation.create({
data: { ...input, id: dto.id, userId, createFingerprint: hash },
}),
);
});
}
getOperation(userId: string, id: string) {
return this.prisma.$transaction(async (tx) => {
const row = await this.operation(tx, userId, id);
if (row.deletedAt) missing();
return this.operationView(tx, row);
});
}
updateOperation(userId: string, id: string, dto: UpdateBankOperationDto) {
return this.write(async (tx) => {
const old = await this.operation(tx, userId, id);
if (old.deletedAt) missing();
if (old.version !== dto.version) conflict();
const input = operationInput({
id,
kind: dto.kind ?? (old.kind as CreateBankOperationDto['kind']),
cardId: dto.cardId ?? old.cardId,
targetCardId: dto.targetCardId ?? old.targetCardId ?? undefined,
categoryId: dto.categoryId ?? old.categoryId ?? undefined,
originalExpenseId:
dto.originalExpenseId ?? old.originalExpenseId ?? undefined,
amount: dto.amount ?? rubMoney(old.amountKopecks),
date: dto.date ?? old.date,
description: dto.description ?? old.description ?? undefined,
});
await this.validateOperation(tx, userId, input, old);
if (
(
await tx.bankCardOperation.updateMany({
where: { id, userId, version: dto.version, deletedAt: null },
data: { ...input, version: { increment: 1 } },
})
).count !== 1
)
conflict();
return this.operationView(tx, await this.operation(tx, userId, id));
});
}
deleteOperation(userId: string, id: string, version: number) {
return this.write(async (tx) => {
const row = await this.operation(tx, userId, id);
if (row.deletedAt) return;
if (row.version !== version) conflict();
await this.card(tx, userId, row.cardId, true);
if (row.targetCardId) await this.card(tx, userId, row.targetCardId, true);
if (
row.kind === 'expense' &&
(await tx.bankCardOperation.findFirst({
where: { userId, originalExpenseId: id, deletedAt: null },
}))
)
conflict('BANK_DEPENDENT_REFUNDS');
if (row.originalExpenseId) {
const source = await this.operation(tx, userId, row.originalExpenseId);
if (
(
await tx.bankCardOperation.updateMany({
where: { id: source.id, userId, version: source.version },
data: { version: { increment: 1 } },
})
).count !== 1
)
conflict();
}
if (
(
await tx.bankCardOperation.updateMany({
where: { id, userId, version, deletedAt: null },
data: { deletedAt: new Date(), version: { increment: 1 } },
})
).count !== 1
)
conflict();
});
}
history(
userId: string,
id: string,
query: BankOperationsQueryDto | BankPageQueryDto,
expenses = false,
) {
return this.prisma.$transaction(async (tx) => {
await this.card(tx, userId, id);
const page = query.page ?? 1,
limit = query.limit ?? 50;
const where: Prisma.BankCardOperationWhereInput = {
userId,
deletedAt: null,
OR: [{ cardId: id }, { targetCardId: id }],
...(expenses
? { kind: 'expense' }
: { date: { startsWith: (query as BankOperationsQueryDto).month } }),
};
const total = await tx.bankCardOperation.count({ where });
const rows = await tx.bankCardOperation.findMany({
where,
orderBy: [{ date: 'desc' }, { createdAt: 'desc' }, { id: 'desc' }],
skip: (page - 1) * limit,
take: limit,
});
return {
items: await Promise.all(
rows.map((row) => this.operationView(tx, row)),
),
total,
page,
hasNext: page * limit < total,
};
});
}
summary(userId: string, query: BankSummaryQueryDto) {
return this.prisma.$transaction(async (tx) => {
if (query.cardId) await this.card(tx, userId, query.cardId);
const rows = await tx.bankCardOperation.findMany({
where: {
userId,
deletedAt: null,
date: { startsWith: query.month },
kind: { in: ['expense', 'refund'] },
...(query.cardId ? { cardId: query.cardId } : {}),
},
include: { originalExpense: { select: { categoryId: true } } },
});
const totals = bankSummary(
query.month,
rows.map((row) => ({
...row,
categoryId: row.originalExpense?.categoryId ?? row.categoryId,
})),
);
const categories = await tx.expenseCategory.findMany({
where: { userId },
});
return {
month: query.month,
cardId: query.cardId ?? null,
expenses: rubMoney(totals.expenses),
refunds: rubMoney(totals.refunds),
net: rubMoney(totals.net),
categories: totals.categories.map((row) => ({
categoryId: row.categoryId,
categoryName: categories.find((c) => c.id === row.categoryId)!.name,
expenses: rubMoney(row.expenses),
refunds: rubMoney(row.refunds),
net: rubMoney(row.net),
})),
};
});
}
}
+62
View File
@@ -0,0 +1,62 @@
export type Decimal = { value: bigint; scale: number };
export function decimalFromString(value: string): Decimal {
if (!/^-?(?:0|[1-9]\d*)(?:\.\d+)?$/.test(value))
throw new Error('Invalid decimal string');
const negative = value.startsWith('-');
const [whole, fraction = ''] = (negative ? value.slice(1) : value).split('.');
return {
value: (negative ? -1n : 1n) * BigInt(`${whole}${fraction}`),
scale: fraction.length,
};
}
export function add(left: Decimal, right: Decimal): Decimal {
const scale = Math.max(left.scale, right.scale);
return {
value:
left.value * 10n ** BigInt(scale - left.scale) +
right.value * 10n ** BigInt(scale - right.scale),
scale,
};
}
export function subtract(left: Decimal, right: Decimal): Decimal {
return add(left, { value: -right.value, scale: right.scale });
}
export function multiply(left: Decimal, right: Decimal): Decimal {
return { value: left.value * right.value, scale: left.scale + right.scale };
}
export function isZero(value: Decimal): boolean {
return value.value === 0n;
}
export function compare(left: Decimal, right: Decimal): number {
const difference = subtract(left, right).value;
return difference === 0n ? 0 : difference > 0n ? 1 : -1;
}
export function formatDecimal({ value, scale }: Decimal): string {
if (value === 0n) return '0';
const sign = value < 0n ? '-' : '';
if (scale === 0) return `${sign}${value < 0n ? -value : value}`;
const digits = (value < 0n ? -value : value)
.toString()
.padStart(scale + 1, '0');
const whole = digits.slice(0, -scale) || '0';
const fraction = digits.slice(-scale).replace(/0+$/, '');
return `${sign}${whole}${fraction ? `.${fraction}` : ''}`;
}
// Round half away from zero; callers choose presentation precision explicitly.
export function divide(left: Decimal, right: Decimal, scale: number): Decimal {
if (right.value === 0n) throw new Error('Division by zero');
const numerator = left.value * 10n ** BigInt(right.scale + scale);
const denominator = right.value * 10n ** BigInt(left.scale);
const sign = numerator < 0n !== denominator < 0n ? -1n : 1n;
const n = numerator < 0n ? -numerator : numerator;
const d = denominator < 0n ? -denominator : denominator;
return { value: sign * ((n + d / 2n) / d), scale };
}
+9
View File
@@ -0,0 +1,9 @@
import { ApiProperty } from '@nestjs/swagger';
export class MoneyDto {
@ApiProperty()
currency: string;
@ApiProperty()
value: string;
}
+57
View File
@@ -0,0 +1,57 @@
import { parseRubMoney, rubMoney } from './rub-money';
describe('RUB money', () => {
it.each([
['0', 0n],
['-100', -10000n],
['0.01', 1n],
['123.4', 12340n],
['90071992547409.93', 9007199254740993n],
['92233720368547758.07', 2n ** 63n - 1n],
['-92233720368547758.08', -(2n ** 63n)],
])('roundtrips %s exactly', (value, kopecks) => {
expect(parseRubMoney({ currency: 'RUB', value })).toBe(kopecks);
expect(rubMoney(kopecks)).toEqual({ currency: 'RUB', value });
});
it.each([
'92233720368547758.08',
'-92233720368547758.09',
'1.001',
'1.000',
'NaN',
'Infinity',
'1e2',
'1,23',
'',
' 1',
'1\n',
'01',
])('rejects invalid input %s without rounding', (value) => {
expect(() => parseRubMoney({ currency: 'RUB', value })).toThrow();
});
it('rejects other currencies and non-string values', () => {
expect(() => parseRubMoney({ currency: 'USD', value: '1' })).toThrow();
expect(() =>
parseRubMoney({ currency: 'RUB', value: 1 as unknown as string }),
).toThrow();
});
it('requires positive operation amounts, including at the maximum', () => {
for (const value of ['0', '-0.00', '-0.01'])
expect(() => parseRubMoney({ currency: 'RUB', value }, true)).toThrow();
expect(parseRubMoney({ currency: 'RUB', value: '0.01' }, true)).toBe(1n);
expect(
parseRubMoney({ currency: 'RUB', value: '92233720368547758.07' }, true),
).toBe(2n ** 63n - 1n);
});
it('formats computed sums beyond the storage range without losing precision', () => {
expect(rubMoney(2n * (2n ** 63n - 1n))).toEqual({
currency: 'RUB',
value: '184467440737095516.14',
});
expect(rubMoney(-1n)).toEqual({ currency: 'RUB', value: '-0.01' });
});
});
+27
View File
@@ -0,0 +1,27 @@
import { decimalFromString, formatDecimal } from './decimal';
import { MoneyDto } from './money.dto';
export function parseRubMoney(money: MoneyDto, positive = false): bigint {
if (
money?.currency !== 'RUB' ||
typeof money.value !== 'string' ||
money.value !== money.value.trim()
)
throw new TypeError('Expected RUB money with a decimal string');
const amount = decimalFromString(money.value);
if (amount.scale > 2)
throw new RangeError('RUB money supports at most two decimal places');
const kopecks = amount.value * 10n ** BigInt(2 - amount.scale);
if (kopecks < -(2n ** 63n) || kopecks > 2n ** 63n - 1n)
throw new RangeError('RUB money is outside the signed 64-bit range');
if (positive && kopecks <= 0n)
throw new RangeError('Operation amount must be positive');
return kopecks;
}
export function rubMoney(kopecks: bigint): MoneyDto {
return {
currency: 'RUB',
value: formatDecimal({ value: kopecks, scale: 2 }),
};
}
+2 -1
View File
@@ -1,5 +1,6 @@
import { ApiProperty } from '@nestjs/swagger'; import { ApiProperty } from '@nestjs/swagger';
import { MoneyDto } from './portfolio.dto'; import { MoneyDto } from '../common/money.dto';
import { import {
StockSearchQueryDto, StockSearchQueryDto,
StockTickerParamsDto, StockTickerParamsDto,
+2 -2
View File
@@ -12,14 +12,14 @@ import {
} from '@nestjs/common'; } from '@nestjs/common';
import { LRUCache } from 'lru-cache'; import { LRUCache } from 'lru-cache';
import { import {
decimal,
decimalFromString, decimalFromString,
divide, divide,
formatDecimal, formatDecimal,
multiply, multiply,
subtract, subtract,
add, add,
} from './decimal'; } from '../common/decimal';
import { decimal } from './decimal';
import { NcrRatingsClient } from './ncr-ratings.client'; import { NcrRatingsClient } from './ncr-ratings.client';
import { MoexClient } from './moex.client'; import { MoexClient } from './moex.client';
import { PortfolioService } from './portfolio.service'; import { PortfolioService } from './portfolio.service';
+1 -1
View File
@@ -1,5 +1,5 @@
import { ApiProperty } from '@nestjs/swagger'; import { ApiProperty } from '@nestjs/swagger';
import { MoneyDto } from './portfolio.dto'; import { MoneyDto } from '../common/money.dto';
export class PortfolioSummaryDto { export class PortfolioSummaryDto {
@ApiProperty({ type: MoneyDto, isArray: true }) @ApiProperty({ type: MoneyDto, isArray: true })
+2 -1
View File
@@ -1,7 +1,8 @@
import { Injectable } from '@nestjs/common'; import { Injectable } from '@nestjs/common';
import { ServiceUnavailableException } from '@nestjs/common'; import { ServiceUnavailableException } from '@nestjs/common';
import { BrokerageAccountDto } from './brokerage-account.dto'; import { BrokerageAccountDto } from './brokerage-account.dto';
import { decimal, isZero } from './decimal'; import { isZero } from '../common/decimal';
import { decimal } from './decimal';
import { totalsByCurrency } from './portfolio-totals'; import { totalsByCurrency } from './portfolio-totals';
import { TbankAccessService } from './tbank-access.service'; import { TbankAccessService } from './tbank-access.service';
import { TbankClient } from './tbank.client'; import { TbankClient } from './tbank.client';
+2 -1
View File
@@ -12,7 +12,8 @@ import {
MaxLength, MaxLength,
Min, Min,
} from 'class-validator'; } from 'class-validator';
import { MoneyDto } from './portfolio.dto'; import { MoneyDto } from '../common/money.dto';
import { AccountReferenceDto } from './account-reference.dto'; import { AccountReferenceDto } from './account-reference.dto';
export const operationCategories = [ export const operationCategories = [
+2 -1
View File
@@ -1,4 +1,5 @@
import { decimal, formatDecimal } from './decimal'; import { formatDecimal } from '../common/decimal';
import { decimal } from './decimal';
import { import {
BrokerageOperationDto, BrokerageOperationDto,
OperationCategory, OperationCategory,
+2 -1
View File
@@ -1,4 +1,5 @@
import { add, decimal, formatDecimal, multiply, subtract } from './decimal'; import { add, formatDecimal, multiply, subtract } from '../common/decimal';
import { decimal } from './decimal';
describe('decimal', () => { describe('decimal', () => {
it('keeps signed nanos exact without floats', () => { it('keeps signed nanos exact without floats', () => {
+2 -62
View File
@@ -1,18 +1,8 @@
import { Decimal } from '../common/decimal';
const NANO = 1_000_000_000n; const NANO = 1_000_000_000n;
export type ProtoDecimal = { units?: string | number; nano?: string | number }; export type ProtoDecimal = { units?: string | number; nano?: string | number };
export type Decimal = { value: bigint; scale: number };
export function decimalFromString(value: string): Decimal {
if (!/^-?(?:0|[1-9]\d*)(?:\.\d+)?$/.test(value))
throw new Error('Invalid decimal string');
const negative = value.startsWith('-');
const [whole, fraction = ''] = (negative ? value.slice(1) : value).split('.');
return {
value: (negative ? -1n : 1n) * BigInt(`${whole}${fraction}`),
scale: fraction.length,
};
}
export function decimal(value: ProtoDecimal): Decimal { export function decimal(value: ProtoDecimal): Decimal {
return { return {
@@ -20,53 +10,3 @@ export function decimal(value: ProtoDecimal): Decimal {
scale: 9, scale: 9,
}; };
} }
export function add(left: Decimal, right: Decimal): Decimal {
const scale = Math.max(left.scale, right.scale);
return {
value:
left.value * 10n ** BigInt(scale - left.scale) +
right.value * 10n ** BigInt(scale - right.scale),
scale,
};
}
export function subtract(left: Decimal, right: Decimal): Decimal {
return add(left, { value: -right.value, scale: right.scale });
}
export function multiply(left: Decimal, right: Decimal): Decimal {
return { value: left.value * right.value, scale: left.scale + right.scale };
}
export function isZero(value: Decimal): boolean {
return value.value === 0n;
}
export function compare(left: Decimal, right: Decimal): number {
const difference = subtract(left, right).value;
return difference === 0n ? 0 : difference > 0n ? 1 : -1;
}
export function formatDecimal({ value, scale }: Decimal): string {
if (value === 0n) return '0';
const sign = value < 0n ? '-' : '';
if (scale === 0) return `${sign}${value < 0n ? -value : value}`;
const digits = (value < 0n ? -value : value)
.toString()
.padStart(scale + 1, '0');
const whole = digits.slice(0, -scale) || '0';
const fraction = digits.slice(-scale).replace(/0+$/, '');
return `${sign}${whole}${fraction ? `.${fraction}` : ''}`;
}
// Round half away from zero; callers choose presentation precision explicitly.
export function divide(left: Decimal, right: Decimal, scale: number): Decimal {
if (right.value === 0n) throw new Error('Division by zero');
const numerator = left.value * 10n ** BigInt(right.scale + scale);
const denominator = right.value * 10n ** BigInt(left.scale);
const sign = numerator < 0n !== denominator < 0n ? -1n : 1n;
const n = numerator < 0n ? -numerator : numerator;
const d = denominator < 0n ? -denominator : denominator;
return { value: sign * ((n + d / 2n) / d), scale };
}
+1 -1
View File
@@ -1,7 +1,7 @@
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
import { IsOptional, Matches } from 'class-validator'; import { IsOptional, Matches } from 'class-validator';
import { BrokerageOperationDto } from './brokerage-operation.dto'; import { BrokerageOperationDto } from './brokerage-operation.dto';
import { MoneyDto } from './portfolio.dto'; import { MoneyDto } from '../common/money.dto';
const dateOnly = /^\d{4}-\d{2}-\d{2}$/; const dateOnly = /^\d{4}-\d{2}-\d{2}$/;
+2 -1
View File
@@ -7,7 +7,8 @@ import {
decimalFromString, decimalFromString,
formatDecimal, formatDecimal,
subtract, subtract,
} from './decimal'; } from '../common/decimal';
import { import {
DateOnlyRange, DateOnlyRange,
IncomeBucketGranularity, IncomeBucketGranularity,
+4 -2
View File
@@ -4,8 +4,10 @@ import {
divide, divide,
formatDecimal, formatDecimal,
multiply, multiply,
} from './decimal'; } from '../common/decimal';
import { MoneyDto, PortfolioDto, PortfolioPositionDto } from './portfolio.dto';
import { MoneyDto } from '../common/money.dto';
import { PortfolioDto, PortfolioPositionDto } from './portfolio.dto';
import { StockPositionDto } from './stock.dto'; import { StockPositionDto } from './stock.dto';
export function instrumentPosition( export function instrumentPosition(
+3 -2
View File
@@ -1,6 +1,7 @@
import { add, decimalFromString, formatDecimal } from './decimal'; import { add, decimalFromString, formatDecimal } from '../common/decimal';
import { MoneyDto } from '../common/money.dto';
import { import {
MoneyDto,
PortfolioPositionDto, PortfolioPositionDto,
PortfolioStructureDto, PortfolioStructureDto,
PortfolioStructureCategoryDto, PortfolioStructureCategoryDto,
+4 -3
View File
@@ -1,12 +1,13 @@
import { import {
add, add,
decimal,
decimalFromString, decimalFromString,
formatDecimal, formatDecimal,
isZero, isZero,
multiply, multiply,
} from './decimal'; } from '../common/decimal';
import { MoneyDto } from './portfolio.dto'; import { decimal } from './decimal';
import { MoneyDto } from '../common/money.dto';
import { TbankPortfolio, TbankPositions } from './tbank.client'; import { TbankPortfolio, TbankPositions } from './tbank.client';
function totalsByCurrency( function totalsByCurrency(
+1 -8
View File
@@ -1,14 +1,7 @@
import { MoneyDto } from '../common/money.dto';
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
import { AccountReferenceDto } from './account-reference.dto'; import { AccountReferenceDto } from './account-reference.dto';
export class MoneyDto {
@ApiProperty()
currency: string;
@ApiProperty()
value: string;
}
export class PortfolioPositionDto { export class PortfolioPositionDto {
@ApiPropertyOptional({ type: String, nullable: true }) @ApiPropertyOptional({ type: String, nullable: true })
instrumentUid?: string | null; instrumentUid?: string | null;
+4 -3
View File
@@ -2,15 +2,16 @@ import { Injectable, ServiceUnavailableException } from '@nestjs/common';
import { RequestContextService } from '../common/logging/request-context.service'; import { RequestContextService } from '../common/logging/request-context.service';
import { import {
add, add,
decimal,
formatDecimal, formatDecimal,
isZero, isZero,
multiply, multiply,
subtract, subtract,
} from './decimal'; } from '../common/decimal';
import { decimal } from './decimal';
import { totalsByCurrency, sumMoneyByCurrency } from './portfolio-totals'; import { totalsByCurrency, sumMoneyByCurrency } from './portfolio-totals';
import { portfolioStructure } from './portfolio-structure'; import { portfolioStructure } from './portfolio-structure';
import { MoneyDto, PortfolioDto, PortfolioPositionDto } from './portfolio.dto'; import { MoneyDto } from '../common/money.dto';
import { PortfolioDto, PortfolioPositionDto } from './portfolio.dto';
import { import {
TbankAccessService, TbankAccessService,
TbankConnectionContext, TbankConnectionContext,
+2 -1
View File
@@ -8,7 +8,8 @@ import {
Matches, Matches,
MinLength, MinLength,
} from 'class-validator'; } from 'class-validator';
import { MoneyDto, PortfolioPositionDto } from './portfolio.dto'; import { MoneyDto } from '../common/money.dto';
import { PortfolioPositionDto } from './portfolio.dto';
export class StockSearchQueryDto { export class StockSearchQueryDto {
@ApiProperty({ minLength: 2, maxLength: 80 }) @ApiProperty({ minLength: 2, maxLength: 80 })
+2 -2
View File
@@ -13,13 +13,13 @@ import {
} from '@nestjs/common'; } from '@nestjs/common';
import { LRUCache } from 'lru-cache'; import { LRUCache } from 'lru-cache';
import { import {
decimal,
decimalFromString, decimalFromString,
divide, divide,
formatDecimal, formatDecimal,
multiply, multiply,
subtract, subtract,
} from './decimal'; } from '../common/decimal';
import { decimal } from './decimal';
import { PortfolioService } from './portfolio.service'; import { PortfolioService } from './portfolio.service';
import { stockPosition } from './stock-position'; import { stockPosition } from './stock-position';
import { TbankAccessService } from './tbank-access.service'; import { TbankAccessService } from './tbank-access.service';
+1 -1
View File
@@ -1,7 +1,7 @@
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
import { IsDateString, IsOptional, Matches } from 'class-validator'; import { IsDateString, IsOptional, Matches } from 'class-validator';
import { AccountReferenceDto } from './account-reference.dto'; import { AccountReferenceDto } from './account-reference.dto';
import { MoneyDto } from './portfolio.dto'; import { MoneyDto } from '../common/money.dto';
export class UpcomingEventsQueryDto { export class UpcomingEventsQueryDto {
@ApiPropertyOptional({ @ApiPropertyOptional({
+4 -3
View File
@@ -3,14 +3,15 @@ import { RequestContextService } from '../common/logging/request-context.service
import { StructuredLogger } from '../common/logging/structured-logger.service'; import { StructuredLogger } from '../common/logging/structured-logger.service';
import { import {
add, add,
decimal,
decimalFromString, decimalFromString,
formatDecimal, formatDecimal,
isZero, isZero,
multiply, multiply,
} from './decimal'; } from '../common/decimal';
import { decimal } from './decimal';
import { MoexBondEvent, MoexClient } from './moex.client'; import { MoexBondEvent, MoexClient } from './moex.client';
import { MoneyDto } from './portfolio.dto'; import { MoneyDto } from '../common/money.dto';
import { TbankAccessService } from './tbank-access.service'; import { TbankAccessService } from './tbank-access.service';
import { import {
TbankClient, TbankClient,
+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.FRONTEND_ORIGIN = 'http://localhost:5173';
process.env.JWT_SECRET = 'e2e-secret-that-is-at-least-thirty-two-characters'; process.env.JWT_SECRET = 'e2e-secret-that-is-at-least-thirty-two-characters';
process.env.TOKEN_ENCRYPTION_KEY = 'MDEyMzQ1Njc4OWFiY2RlZjAxMjM0NTY3ODlhYmNkZWY='; process.env.TOKEN_ENCRYPTION_KEY = 'MDEyMzQ1Njc4OWFiY2RlZjAxMjM0NTY3ODlhYmNkZWY=';