From 3cf883efbc6fceddc889c5aadc3c8ffb410c6de5 Mon Sep 17 00:00:00 2001 From: Sergey Krylov Date: Sat, 12 Sep 2026 21:52:17 +0300 Subject: [PATCH] feat(cards): support inclusive date range queries --- src/bank-cards/bank-card.calculations.ts | 9 ++++-- src/bank-cards/bank-card.dto.ts | 38 +++++++++++++++++++----- src/bank-cards/bank-cards.service.ts | 31 +++++++++++++++++-- test/bank-cards.e2e-spec.ts | 35 ++++++++++++++++++++++ 4 files changed, 100 insertions(+), 13 deletions(-) diff --git a/src/bank-cards/bank-card.calculations.ts b/src/bank-cards/bank-card.calculations.ts index 8ed1d3d..d727ff5 100644 --- a/src/bank-cards/bank-card.calculations.ts +++ b/src/bank-cards/bank-card.calculations.ts @@ -42,7 +42,7 @@ export function bankBalance( } export function bankSummary( - month: string, + period: string | { from: string; to: string }, operations: Array<{ kind: string; date: string; @@ -58,7 +58,12 @@ export function bankSummary( >(); // 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 ( + typeof period === 'string' + ? operation.date.slice(0, 7) !== period + : operation.date < period.from || operation.date > period.to + ) + 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; diff --git a/src/bank-cards/bank-card.dto.ts b/src/bank-cards/bank-card.dto.ts index f74cdde..e79c7c4 100644 --- a/src/bank-cards/bank-card.dto.ts +++ b/src/bank-cards/bank-card.dto.ts @@ -2,6 +2,7 @@ import { ApiProperty, ApiPropertyOptional, OmitType, + IntersectionType, PartialType, } from '@nestjs/swagger'; import { Transform, Type } from 'class-transformer'; @@ -153,15 +154,34 @@ export class BankPageQueryDto { @Max(100) limit?: number; } -export class BankOperationsQueryDto extends BankPageQueryDto { - @ApiProperty({ pattern: '^[0-9]{4}-(0[1-9]|1[0-2])$' }) +export class BankPeriodQueryDto { + @ApiPropertyOptional({ + pattern: '^[0-9]{4}-(0[1-9]|1[0-2])$', + description: 'Use month or both from and to, exclusively.', + }) + @IsOptional() @Matches(/^\d{4}-(0[1-9]|1[0-2])$/) - month: string; + month?: string; + @ApiPropertyOptional({ + format: 'date', + description: 'Inclusive start date; requires to.', + }) + @IsOptional() + @Matches(/^\d{4}-\d{2}-\d{2}$/) + from?: string; + @ApiPropertyOptional({ + format: 'date', + description: 'Inclusive end date; requires from.', + }) + @IsOptional() + @Matches(/^\d{4}-\d{2}-\d{2}$/) + to?: 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; +export class BankOperationsQueryDto extends IntersectionType( + BankPageQueryDto, + BankPeriodQueryDto, +) {} +export class BankSummaryQueryDto extends BankPeriodQueryDto { @ApiPropertyOptional({ format: 'uuid' }) @IsOptional() @IsUUID() @@ -227,7 +247,9 @@ export class BankSummaryCategoryDto { @ApiProperty({ type: MoneyDto }) net: MoneyDto; } export class BankSummaryDto { - @ApiProperty({ type: String }) month: string; + @ApiPropertyOptional({ type: String }) month?: string; + @ApiPropertyOptional({ format: 'date' }) from?: string; + @ApiPropertyOptional({ format: 'date' }) to?: string; @ApiProperty({ type: String, nullable: true }) cardId: string | null; @ApiProperty({ type: MoneyDto }) expenses: MoneyDto; @ApiProperty({ type: MoneyDto }) refunds: MoneyDto; diff --git a/src/bank-cards/bank-cards.service.ts b/src/bank-cards/bank-cards.service.ts index c588aec..ec1d9b0 100644 --- a/src/bank-cards/bank-cards.service.ts +++ b/src/bank-cards/bank-cards.service.ts @@ -25,6 +25,7 @@ import { BankOperationDto, BankOperationsQueryDto, BankPageQueryDto, + BankPeriodQueryDto, BankSummaryQueryDto, CreateBankCardDto, CreateBankOperationDto, @@ -47,6 +48,25 @@ const seeds = [ const fail = (code = 'BANK_INVALID_INPUT'): never => { throw new BadRequestException({ code }); }; +function periodBounds(query: BankPeriodQueryDto) { + if (query.month && !query.from && !query.to) { + const from = `${query.month}-01`; + if (!isCalendarDate(from)) return fail(); + const end = new Date(`${from}T00:00:00Z`); + end.setUTCMonth(end.getUTCMonth() + 1, 0); + return { from, to: end.toISOString().slice(0, 10) }; + } + if ( + query.month || + !query.from || + !query.to || + !isCalendarDate(query.from) || + !isCalendarDate(query.to) || + query.from > query.to + ) + return fail(); + return { from: query.from, to: query.to }; +} const conflict = (code = 'BANK_VERSION_CONFLICT'): never => { throw new ConflictException({ code }); }; @@ -612,6 +632,9 @@ export class BankCardsService { query: BankOperationsQueryDto | BankPageQueryDto, expenses = false, ) { + const period = expenses + ? undefined + : periodBounds(query as BankOperationsQueryDto); return this.prisma.$transaction(async (tx) => { await this.card(tx, userId, id); const page = query.page ?? 1, @@ -622,7 +645,7 @@ export class BankCardsService { OR: [{ cardId: id }, { targetCardId: id }], ...(expenses ? { kind: 'expense' } - : { date: { startsWith: (query as BankOperationsQueryDto).month } }), + : { date: { gte: period!.from, lte: period!.to } }), }; const total = await tx.bankCardOperation.count({ where }); const rows = await tx.bankCardOperation.findMany({ @@ -642,20 +665,21 @@ export class BankCardsService { }); } summary(userId: string, query: BankSummaryQueryDto) { + const period = periodBounds(query); 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 }, + date: { gte: period.from, lte: period.to }, kind: { in: ['expense', 'refund'] }, ...(query.cardId ? { cardId: query.cardId } : {}), }, include: { originalExpense: { select: { categoryId: true } } }, }); const totals = bankSummary( - query.month, + period, rows.map((row) => ({ ...row, categoryId: row.originalExpense?.categoryId ?? row.categoryId, @@ -666,6 +690,7 @@ export class BankCardsService { }); return { month: query.month, + ...period, cardId: query.cardId ?? null, expenses: rubMoney(totals.expenses), refunds: rubMoney(totals.refunds), diff --git a/test/bank-cards.e2e-spec.ts b/test/bank-cards.e2e-spec.ts index 3653b7f..259f90e 100644 --- a/test/bank-cards.e2e-spec.ts +++ b/test/bank-cards.e2e-spec.ts @@ -733,4 +733,39 @@ describe('Manual bank cards configured HTTP and real SQLite', () => { .paymentSystem, ).toBe('mastercard'); }); + it('filters journal and summary inclusively across months and rejects invalid periods', async () => { + const card = await createCard(); + const dates = ['2026-07-30', '2026-07-31', '2026-08-01', '2026-08-02']; + for (const date of dates) + await createOp(op(card.id, 'expense', '10', { date })); + const range = 'from=2026-07-31&to=2026-08-01'; + const history = ( + await auth('get', `/${card.id}/operations?${range}&limit=1`).expect(200) + ).body as BankOperationsPageDto; + expect(history.total).toBe(2); + expect(history.items.map((item) => item.date)).toEqual(['2026-08-01']); + expect(history.hasNext).toBe(true); + const result = ( + await auth('get', `/summary?cardId=${card.id}&${range}`).expect(200) + ).body as BankSummaryDto; + expect(result.net.value).toBe('20'); + const single = ( + await auth( + 'get', + `/${card.id}/operations?from=2026-07-31&to=2026-07-31`, + ).expect(200) + ).body as BankOperationsPageDto; + expect(single.total).toBe(1); + for (const invalid of [ + '', + 'from=2026-07-31', + 'to=2026-08-01', + 'from=2026-08-02&to=2026-08-01', + 'from=2026-02-30&to=2026-08-01', + `${range}&month=2026-08`, + ]) { + await auth('get', `/${card.id}/operations?${invalid}`).expect(400); + await auth('get', `/summary?${invalid}`).expect(400); + } + }); });