diff --git a/.ai/conventions/backend-data.md b/.ai/conventions/backend-data.md index fb66c33..4ad2b53 100644 --- a/.ai/conventions/backend-data.md +++ b/.ai/conventions/backend-data.md @@ -31,7 +31,7 @@ lock во время retry внешней сети. Access/cache generation sema не JS floating-point арифметику для сумм. Currency — отдельная часть Money; разные валюты не складываются без явно заданного conversion contract. Источник: [Money](../../../docs/glossary.md#money), -пример: [decimal.ts](../../src/tbank/decimal.ts). UI formatting не меняет +пример: [decimal.ts](../../src/common/decimal.ts). UI formatting не меняет backend ownership расчётов и доменную семантику. ## Schema и migration diff --git a/prisma/migrations/20260912090000_add_manual_bank_cards/migration.sql b/prisma/migrations/20260912090000_add_manual_bank_cards/migration.sql new file mode 100644 index 0000000..4d8d90c --- /dev/null +++ b/prisma/migrations/20260912090000_add_manual_bank_cards/migration.sql @@ -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"); diff --git a/prisma/schema.prisma b/prisma/schema.prisma index c6f629e..9ffd9bd 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -12,6 +12,9 @@ model User { id String @id email String @unique passwordHash String + bankCards BankCard[] + expenseCategories ExpenseCategory[] + bankCardOperations BankCardOperation[] sessions Session[] tbankConnection TbankConnection? createdAt DateTime @default(now()) @@ -37,3 +40,60 @@ model Session { @@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]) +} diff --git a/src/app.module.ts b/src/app.module.ts index 9e60b02..08db9cf 100644 --- a/src/app.module.ts +++ b/src/app.module.ts @@ -2,6 +2,7 @@ import { Module } from '@nestjs/common'; import { ConfigModule, ConfigService } from '@nestjs/config'; import { randomUUID } from 'node:crypto'; import { LoggerModule } from 'nestjs-pino'; +import { BankCardsModule } from './bank-cards/bank-cards.module'; import { AuthModule } from './auth/auth.module'; import { LoggingModule } from './common/logging/logging.module'; import { Environment, validateEnv } from './config/env.validation'; @@ -49,6 +50,7 @@ import { TbankConnectionModule } from './tbank/tbank-connection.module'; LoggingModule, PrismaModule, AuthModule, + BankCardsModule, TbankConnectionModule, ], }) diff --git a/src/bank-cards/bank-card.calculations.spec.ts b/src/bank-cards/bank-card.calculations.spec.ts new file mode 100644 index 0000000..9585110 --- /dev/null +++ b/src/bank-cards/bank-card.calculations.spec.ts @@ -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 }, + ], + }); + }); +}); diff --git a/src/bank-cards/bank-card.calculations.ts b/src/bank-cards/bank-card.calculations.ts new file mode 100644 index 0000000..8ed1d3d --- /dev/null +++ b/src/bank-cards/bank-card.calculations.ts @@ -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()], + }; +} diff --git a/src/bank-cards/bank-card.dto.ts b/src/bank-cards/bank-card.dto.ts new file mode 100644 index 0000000..265482e --- /dev/null +++ b/src/bank-cards/bank-card.dto.ts @@ -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[]; +} diff --git a/src/bank-cards/bank-cards.controller.ts b/src/bank-cards/bank-cards.controller.ts new file mode 100644 index 0000000..e601d27 --- /dev/null +++ b/src/bank-cards/bank-cards.controller.ts @@ -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); + } +} diff --git a/src/bank-cards/bank-cards.module.ts b/src/bank-cards/bank-cards.module.ts new file mode 100644 index 0000000..5f0e8d3 --- /dev/null +++ b/src/bank-cards/bank-cards.module.ts @@ -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 {} diff --git a/src/bank-cards/bank-cards.service.ts b/src/bank-cards/bank-cards.service.ts new file mode 100644 index 0000000..9a092c7 --- /dev/null +++ b/src/bank-cards/bank-cards.service.ts @@ -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[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(run: (tx: Tx) => Promise): Promise { + 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 { + 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 { + 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, + 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), + })), + }; + }); + } +} diff --git a/src/common/decimal.ts b/src/common/decimal.ts new file mode 100644 index 0000000..998e7a5 --- /dev/null +++ b/src/common/decimal.ts @@ -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 }; +} diff --git a/src/common/money.dto.ts b/src/common/money.dto.ts new file mode 100644 index 0000000..1dd816e --- /dev/null +++ b/src/common/money.dto.ts @@ -0,0 +1,9 @@ +import { ApiProperty } from '@nestjs/swagger'; + +export class MoneyDto { + @ApiProperty() + currency: string; + + @ApiProperty() + value: string; +} diff --git a/src/common/rub-money.spec.ts b/src/common/rub-money.spec.ts new file mode 100644 index 0000000..383c230 --- /dev/null +++ b/src/common/rub-money.spec.ts @@ -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' }); + }); +}); diff --git a/src/common/rub-money.ts b/src/common/rub-money.ts new file mode 100644 index 0000000..3d0d74d --- /dev/null +++ b/src/common/rub-money.ts @@ -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 }), + }; +} diff --git a/src/tbank/bond.dto.ts b/src/tbank/bond.dto.ts index d0b86b6..bd87594 100644 --- a/src/tbank/bond.dto.ts +++ b/src/tbank/bond.dto.ts @@ -1,5 +1,6 @@ import { ApiProperty } from '@nestjs/swagger'; -import { MoneyDto } from './portfolio.dto'; +import { MoneyDto } from '../common/money.dto'; + import { StockSearchQueryDto, StockTickerParamsDto, diff --git a/src/tbank/bond.service.ts b/src/tbank/bond.service.ts index 38950bd..b793cca 100644 --- a/src/tbank/bond.service.ts +++ b/src/tbank/bond.service.ts @@ -12,14 +12,14 @@ import { } from '@nestjs/common'; import { LRUCache } from 'lru-cache'; import { - decimal, decimalFromString, divide, formatDecimal, multiply, subtract, add, -} from './decimal'; +} from '../common/decimal'; +import { decimal } from './decimal'; import { NcrRatingsClient } from './ncr-ratings.client'; import { MoexClient } from './moex.client'; import { PortfolioService } from './portfolio.service'; diff --git a/src/tbank/brokerage-account.dto.ts b/src/tbank/brokerage-account.dto.ts index b9066c8..868cdbb 100644 --- a/src/tbank/brokerage-account.dto.ts +++ b/src/tbank/brokerage-account.dto.ts @@ -1,5 +1,5 @@ import { ApiProperty } from '@nestjs/swagger'; -import { MoneyDto } from './portfolio.dto'; +import { MoneyDto } from '../common/money.dto'; export class PortfolioSummaryDto { @ApiProperty({ type: MoneyDto, isArray: true }) diff --git a/src/tbank/brokerage-accounts.service.ts b/src/tbank/brokerage-accounts.service.ts index 5201609..649c2e4 100644 --- a/src/tbank/brokerage-accounts.service.ts +++ b/src/tbank/brokerage-accounts.service.ts @@ -1,7 +1,8 @@ import { Injectable } from '@nestjs/common'; import { ServiceUnavailableException } from '@nestjs/common'; 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 { TbankAccessService } from './tbank-access.service'; import { TbankClient } from './tbank.client'; diff --git a/src/tbank/brokerage-operation.dto.ts b/src/tbank/brokerage-operation.dto.ts index 40d5ef8..169d592 100644 --- a/src/tbank/brokerage-operation.dto.ts +++ b/src/tbank/brokerage-operation.dto.ts @@ -12,7 +12,8 @@ import { MaxLength, Min, } from 'class-validator'; -import { MoneyDto } from './portfolio.dto'; +import { MoneyDto } from '../common/money.dto'; + import { AccountReferenceDto } from './account-reference.dto'; export const operationCategories = [ diff --git a/src/tbank/brokerage-operation.mapper.ts b/src/tbank/brokerage-operation.mapper.ts index 47119b7..1fc1ae9 100644 --- a/src/tbank/brokerage-operation.mapper.ts +++ b/src/tbank/brokerage-operation.mapper.ts @@ -1,4 +1,5 @@ -import { decimal, formatDecimal } from './decimal'; +import { formatDecimal } from '../common/decimal'; +import { decimal } from './decimal'; import { BrokerageOperationDto, OperationCategory, diff --git a/src/tbank/decimal.spec.ts b/src/tbank/decimal.spec.ts index 54b39d8..8028e85 100644 --- a/src/tbank/decimal.spec.ts +++ b/src/tbank/decimal.spec.ts @@ -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', () => { it('keeps signed nanos exact without floats', () => { diff --git a/src/tbank/decimal.ts b/src/tbank/decimal.ts index 11e817a..423c599 100644 --- a/src/tbank/decimal.ts +++ b/src/tbank/decimal.ts @@ -1,18 +1,8 @@ +import { Decimal } from '../common/decimal'; + const NANO = 1_000_000_000n; 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 { return { @@ -20,53 +10,3 @@ export function decimal(value: ProtoDecimal): Decimal { 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 }; -} diff --git a/src/tbank/income-analytics.dto.ts b/src/tbank/income-analytics.dto.ts index 59f9c44..82a8846 100644 --- a/src/tbank/income-analytics.dto.ts +++ b/src/tbank/income-analytics.dto.ts @@ -1,7 +1,7 @@ import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; import { IsOptional, Matches } from 'class-validator'; 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}$/; diff --git a/src/tbank/income-analytics.service.ts b/src/tbank/income-analytics.service.ts index e8f51eb..4bd5c5b 100644 --- a/src/tbank/income-analytics.service.ts +++ b/src/tbank/income-analytics.service.ts @@ -7,7 +7,8 @@ import { decimalFromString, formatDecimal, subtract, -} from './decimal'; +} from '../common/decimal'; + import { DateOnlyRange, IncomeBucketGranularity, diff --git a/src/tbank/instrument-position.ts b/src/tbank/instrument-position.ts index 2e53a24..b083ec0 100644 --- a/src/tbank/instrument-position.ts +++ b/src/tbank/instrument-position.ts @@ -4,8 +4,10 @@ import { divide, formatDecimal, multiply, -} from './decimal'; -import { MoneyDto, PortfolioDto, PortfolioPositionDto } from './portfolio.dto'; +} from '../common/decimal'; + +import { MoneyDto } from '../common/money.dto'; +import { PortfolioDto, PortfolioPositionDto } from './portfolio.dto'; import { StockPositionDto } from './stock.dto'; export function instrumentPosition( diff --git a/src/tbank/portfolio-structure.ts b/src/tbank/portfolio-structure.ts index 486f544..ba5ead5 100644 --- a/src/tbank/portfolio-structure.ts +++ b/src/tbank/portfolio-structure.ts @@ -1,6 +1,7 @@ -import { add, decimalFromString, formatDecimal } from './decimal'; +import { add, decimalFromString, formatDecimal } from '../common/decimal'; + +import { MoneyDto } from '../common/money.dto'; import { - MoneyDto, PortfolioPositionDto, PortfolioStructureDto, PortfolioStructureCategoryDto, diff --git a/src/tbank/portfolio-totals.ts b/src/tbank/portfolio-totals.ts index a507e48..b61335a 100644 --- a/src/tbank/portfolio-totals.ts +++ b/src/tbank/portfolio-totals.ts @@ -1,12 +1,13 @@ import { add, - decimal, decimalFromString, formatDecimal, isZero, multiply, -} from './decimal'; -import { MoneyDto } from './portfolio.dto'; +} from '../common/decimal'; +import { decimal } from './decimal'; +import { MoneyDto } from '../common/money.dto'; + import { TbankPortfolio, TbankPositions } from './tbank.client'; function totalsByCurrency( diff --git a/src/tbank/portfolio.dto.ts b/src/tbank/portfolio.dto.ts index 865799d..a161bb9 100644 --- a/src/tbank/portfolio.dto.ts +++ b/src/tbank/portfolio.dto.ts @@ -1,14 +1,7 @@ +import { MoneyDto } from '../common/money.dto'; import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; import { AccountReferenceDto } from './account-reference.dto'; -export class MoneyDto { - @ApiProperty() - currency: string; - - @ApiProperty() - value: string; -} - export class PortfolioPositionDto { @ApiPropertyOptional({ type: String, nullable: true }) instrumentUid?: string | null; diff --git a/src/tbank/portfolio.service.ts b/src/tbank/portfolio.service.ts index f2de97d..fe67f17 100644 --- a/src/tbank/portfolio.service.ts +++ b/src/tbank/portfolio.service.ts @@ -2,15 +2,16 @@ import { Injectable, ServiceUnavailableException } from '@nestjs/common'; import { RequestContextService } from '../common/logging/request-context.service'; import { add, - decimal, formatDecimal, isZero, multiply, subtract, -} from './decimal'; +} from '../common/decimal'; +import { decimal } from './decimal'; import { totalsByCurrency, sumMoneyByCurrency } from './portfolio-totals'; 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 { TbankAccessService, TbankConnectionContext, diff --git a/src/tbank/stock.dto.ts b/src/tbank/stock.dto.ts index 4f6f344..275c7c7 100644 --- a/src/tbank/stock.dto.ts +++ b/src/tbank/stock.dto.ts @@ -8,7 +8,8 @@ import { Matches, MinLength, } from 'class-validator'; -import { MoneyDto, PortfolioPositionDto } from './portfolio.dto'; +import { MoneyDto } from '../common/money.dto'; +import { PortfolioPositionDto } from './portfolio.dto'; export class StockSearchQueryDto { @ApiProperty({ minLength: 2, maxLength: 80 }) diff --git a/src/tbank/stock.service.ts b/src/tbank/stock.service.ts index 7d0f606..015946d 100644 --- a/src/tbank/stock.service.ts +++ b/src/tbank/stock.service.ts @@ -13,13 +13,13 @@ import { } from '@nestjs/common'; import { LRUCache } from 'lru-cache'; import { - decimal, decimalFromString, divide, formatDecimal, multiply, subtract, -} from './decimal'; +} from '../common/decimal'; +import { decimal } from './decimal'; import { PortfolioService } from './portfolio.service'; import { stockPosition } from './stock-position'; import { TbankAccessService } from './tbank-access.service'; diff --git a/src/tbank/upcoming-events.dto.ts b/src/tbank/upcoming-events.dto.ts index f70d91f..c0cb27d 100644 --- a/src/tbank/upcoming-events.dto.ts +++ b/src/tbank/upcoming-events.dto.ts @@ -1,7 +1,7 @@ import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; import { IsDateString, IsOptional, Matches } from 'class-validator'; import { AccountReferenceDto } from './account-reference.dto'; -import { MoneyDto } from './portfolio.dto'; +import { MoneyDto } from '../common/money.dto'; export class UpcomingEventsQueryDto { @ApiPropertyOptional({ diff --git a/src/tbank/upcoming-events.service.ts b/src/tbank/upcoming-events.service.ts index c07fd39..84021b1 100644 --- a/src/tbank/upcoming-events.service.ts +++ b/src/tbank/upcoming-events.service.ts @@ -3,14 +3,15 @@ import { RequestContextService } from '../common/logging/request-context.service import { StructuredLogger } from '../common/logging/structured-logger.service'; import { add, - decimal, decimalFromString, formatDecimal, isZero, multiply, -} from './decimal'; +} from '../common/decimal'; +import { decimal } from './decimal'; import { MoexBondEvent, MoexClient } from './moex.client'; -import { MoneyDto } from './portfolio.dto'; +import { MoneyDto } from '../common/money.dto'; + import { TbankAccessService } from './tbank-access.service'; import { TbankClient, diff --git a/test/bank-cards-durability.cjs b/test/bank-cards-durability.cjs new file mode 100644 index 0000000..9374ad4 --- /dev/null +++ b/test/bank-cards-durability.cjs @@ -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;}); diff --git a/test/bank-cards.e2e-spec.ts b/test/bank-cards.e2e-spec.ts new file mode 100644 index 0000000..0aa7729 --- /dev/null +++ b/test/bank-cards.e2e-spec.ts @@ -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, 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' }); + }); +}); diff --git a/test/e2e-env.cjs b/test/e2e-env.cjs index e371cd0..9ebae4f 100644 --- a/test/e2e-env.cjs +++ b/test/e2e-env.cjs @@ -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=';