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(
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;
+30 -8
View File
@@ -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;
+28 -3
View File
@@ -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),
+35
View File
@@ -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);
}
});
});