feat(cards): support inclusive date range queries
This commit is contained in:
@@ -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;
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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),
|
||||||
|
|||||||
@@ -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);
|
||||||
|
}
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user