feat(cards): support inclusive date range queries

This commit is contained in:
2026-09-12 21:52:17 +03:00
parent 8c074d85de
commit 3cf883efbc
4 changed files with 100 additions and 13 deletions
+7 -2
View File
@@ -42,7 +42,7 @@ export function bankBalance(
} }
export function bankSummary( export function bankSummary(
month: string, period: string | { from: string; to: string },
operations: Array<{ operations: Array<{
kind: string; kind: string;
date: string; date: string;
@@ -58,7 +58,12 @@ export function bankSummary(
>(); >();
// ponytail: O(N) history fold; optimize only after measuring growing histories. // ponytail: O(N) history fold; optimize only after measuring growing histories.
for (const operation of operations) { 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; if (operation.kind !== 'expense' && operation.kind !== 'refund') continue;
const expense = operation.kind === 'expense' ? operation.amountKopecks : 0n; const expense = operation.kind === 'expense' ? operation.amountKopecks : 0n;
const refund = operation.kind === 'refund' ? operation.amountKopecks : 0n; const refund = operation.kind === 'refund' ? operation.amountKopecks : 0n;
+30 -8
View File
@@ -2,6 +2,7 @@ import {
ApiProperty, ApiProperty,
ApiPropertyOptional, ApiPropertyOptional,
OmitType, OmitType,
IntersectionType,
PartialType, PartialType,
} from '@nestjs/swagger'; } from '@nestjs/swagger';
import { Transform, Type } from 'class-transformer'; import { Transform, Type } from 'class-transformer';
@@ -153,15 +154,34 @@ export class BankPageQueryDto {
@Max(100) @Max(100)
limit?: number; limit?: number;
} }
export class BankOperationsQueryDto extends BankPageQueryDto { export class BankPeriodQueryDto {
@ApiProperty({ pattern: '^[0-9]{4}-(0[1-9]|1[0-2])$' }) @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])$/) @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 { export class BankOperationsQueryDto extends IntersectionType(
@ApiProperty({ pattern: '^[0-9]{4}-(0[1-9]|1[0-2])$' }) BankPageQueryDto,
@Matches(/^\d{4}-(0[1-9]|1[0-2])$/) BankPeriodQueryDto,
month: string; ) {}
export class BankSummaryQueryDto extends BankPeriodQueryDto {
@ApiPropertyOptional({ format: 'uuid' }) @ApiPropertyOptional({ format: 'uuid' })
@IsOptional() @IsOptional()
@IsUUID() @IsUUID()
@@ -227,7 +247,9 @@ export class BankSummaryCategoryDto {
@ApiProperty({ type: MoneyDto }) net: MoneyDto; @ApiProperty({ type: MoneyDto }) net: MoneyDto;
} }
export class BankSummaryDto { 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: String, nullable: true }) cardId: string | null;
@ApiProperty({ type: MoneyDto }) expenses: MoneyDto; @ApiProperty({ type: MoneyDto }) expenses: MoneyDto;
@ApiProperty({ type: MoneyDto }) refunds: MoneyDto; @ApiProperty({ type: MoneyDto }) refunds: MoneyDto;
+28 -3
View File
@@ -25,6 +25,7 @@ import {
BankOperationDto, BankOperationDto,
BankOperationsQueryDto, BankOperationsQueryDto,
BankPageQueryDto, BankPageQueryDto,
BankPeriodQueryDto,
BankSummaryQueryDto, BankSummaryQueryDto,
CreateBankCardDto, CreateBankCardDto,
CreateBankOperationDto, CreateBankOperationDto,
@@ -47,6 +48,25 @@ const seeds = [
const fail = (code = 'BANK_INVALID_INPUT'): never => { const fail = (code = 'BANK_INVALID_INPUT'): never => {
throw new BadRequestException({ code }); 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 => { const conflict = (code = 'BANK_VERSION_CONFLICT'): never => {
throw new ConflictException({ code }); throw new ConflictException({ code });
}; };
@@ -612,6 +632,9 @@ export class BankCardsService {
query: BankOperationsQueryDto | BankPageQueryDto, query: BankOperationsQueryDto | BankPageQueryDto,
expenses = false, expenses = false,
) { ) {
const period = expenses
? undefined
: periodBounds(query as BankOperationsQueryDto);
return this.prisma.$transaction(async (tx) => { return this.prisma.$transaction(async (tx) => {
await this.card(tx, userId, id); await this.card(tx, userId, id);
const page = query.page ?? 1, const page = query.page ?? 1,
@@ -622,7 +645,7 @@ export class BankCardsService {
OR: [{ cardId: id }, { targetCardId: id }], OR: [{ cardId: id }, { targetCardId: id }],
...(expenses ...(expenses
? { kind: 'expense' } ? { kind: 'expense' }
: { date: { startsWith: (query as BankOperationsQueryDto).month } }), : { date: { gte: period!.from, lte: period!.to } }),
}; };
const total = await tx.bankCardOperation.count({ where }); const total = await tx.bankCardOperation.count({ where });
const rows = await tx.bankCardOperation.findMany({ const rows = await tx.bankCardOperation.findMany({
@@ -642,20 +665,21 @@ export class BankCardsService {
}); });
} }
summary(userId: string, query: BankSummaryQueryDto) { summary(userId: string, query: BankSummaryQueryDto) {
const period = periodBounds(query);
return this.prisma.$transaction(async (tx) => { return this.prisma.$transaction(async (tx) => {
if (query.cardId) await this.card(tx, userId, query.cardId); if (query.cardId) await this.card(tx, userId, query.cardId);
const rows = await tx.bankCardOperation.findMany({ const rows = await tx.bankCardOperation.findMany({
where: { where: {
userId, userId,
deletedAt: null, deletedAt: null,
date: { startsWith: query.month }, date: { gte: period.from, lte: period.to },
kind: { in: ['expense', 'refund'] }, kind: { in: ['expense', 'refund'] },
...(query.cardId ? { cardId: query.cardId } : {}), ...(query.cardId ? { cardId: query.cardId } : {}),
}, },
include: { originalExpense: { select: { categoryId: true } } }, include: { originalExpense: { select: { categoryId: true } } },
}); });
const totals = bankSummary( const totals = bankSummary(
query.month, period,
rows.map((row) => ({ rows.map((row) => ({
...row, ...row,
categoryId: row.originalExpense?.categoryId ?? row.categoryId, categoryId: row.originalExpense?.categoryId ?? row.categoryId,
@@ -666,6 +690,7 @@ export class BankCardsService {
}); });
return { return {
month: query.month, month: query.month,
...period,
cardId: query.cardId ?? null, cardId: query.cardId ?? null,
expenses: rubMoney(totals.expenses), expenses: rubMoney(totals.expenses),
refunds: rubMoney(totals.refunds), refunds: rubMoney(totals.refunds),
+35
View File
@@ -733,4 +733,39 @@ describe('Manual bank cards configured HTTP and real SQLite', () => {
.paymentSystem, .paymentSystem,
).toBe('mastercard'); ).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);
}
});
}); });