refactor: organize backend by business responsibility

This commit is contained in:
2026-09-13 13:52:57 +03:00
parent 7fde3ece6e
commit 8ba005346d
145 changed files with 3027 additions and 2346 deletions
@@ -0,0 +1,59 @@
import { Controller, Get, Param, Query, Req, UseGuards } from '@nestjs/common';
import { ApiBearerAuth, ApiOkResponse, ApiTags } from '@nestjs/swagger';
import type { Request } from 'express';
import { ApiProblemResponses } from '../../../common/decorators/api-safe-error-response.decorator';
import { AccessPayload, JwtAuthGuard } from '../../auth/auth.api';
import {
IncomeAnalyticsDto,
IncomeAnalyticsQueryDto,
IncomeSummaryDto,
} from './income-analytics.dto';
import { IncomeAnalyticsService } from './income-analytics.service';
@ApiTags('brokerage-accounts')
@ApiProblemResponses()
@ApiBearerAuth()
@UseGuards(JwtAuthGuard)
@Controller('brokerage-accounts')
export class IncomeAnalyticsController {
constructor(private readonly income: IncomeAnalyticsService) {}
@Get('all/income-summary')
@ApiOkResponse({ type: IncomeSummaryDto })
allSummary(@Req() request: Request & { user: AccessPayload }) {
return this.income.summary(request.user.sub, 'all');
}
@Get('all/income-analytics')
@ApiOkResponse({ type: IncomeAnalyticsDto })
allAnalytics(
@Req() request: Request & { user: AccessPayload },
@Query() query: IncomeAnalyticsQueryDto,
) {
return this.income.analytics(request.user.sub, 'all', query.from, query.to);
}
@Get(':accountId/income-summary')
@ApiOkResponse({ type: IncomeSummaryDto })
summary(
@Req() request: Request & { user: AccessPayload },
@Param('accountId') accountId: string,
) {
return this.income.summary(request.user.sub, accountId);
}
@Get(':accountId/income-analytics')
@ApiOkResponse({ type: IncomeAnalyticsDto })
analytics(
@Req() request: Request & { user: AccessPayload },
@Param('accountId') accountId: string,
@Query() query: IncomeAnalyticsQueryDto,
) {
return this.income.analytics(
request.user.sub,
accountId,
query.from,
query.to,
);
}
}
@@ -0,0 +1,141 @@
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
import { IsOptional, Matches } from 'class-validator';
import { MoneyDto } from '../../../common/money.dto';
import { BrokerageOperationDto } from '../operations/operations.api';
const dateOnly = /^\d{4}-\d{2}-\d{2}$/;
export class IncomeAnalyticsQueryDto {
@ApiPropertyOptional({ format: 'date' })
@IsOptional()
@Matches(dateOnly)
from?: string;
@ApiPropertyOptional({ format: 'date' })
@IsOptional()
@Matches(dateOnly)
to?: string;
}
export class IncomePeriodDto {
@ApiProperty({ format: 'date' })
declare from: string;
@ApiProperty({ format: 'date' })
declare to: string;
}
export class IncomeCurrencySummaryDto {
@ApiProperty()
declare currency: string;
@ApiProperty({ type: MoneyDto })
declare coupon: MoneyDto;
@ApiProperty({ type: MoneyDto })
declare dividend: MoneyDto;
@ApiProperty({ type: MoneyDto })
declare total: MoneyDto;
}
export class IncomeSummaryDto {
@ApiProperty()
declare accountId: string;
@ApiProperty({ type: IncomePeriodDto })
declare period: IncomePeriodDto;
@ApiProperty({ type: IncomeCurrencySummaryDto, isArray: true })
declare totalsByCurrency: IncomeCurrencySummaryDto[];
@ApiProperty({ format: 'date-time' })
declare calculatedAt: string;
}
export class IncomeComparisonCurrencyDto {
@ApiProperty()
declare currency: string;
@ApiProperty({ type: IncomeCurrencySummaryDto })
declare selected: IncomeCurrencySummaryDto;
@ApiProperty({ type: IncomeCurrencySummaryDto })
declare compared: IncomeCurrencySummaryDto;
@ApiProperty({ type: IncomeCurrencySummaryDto })
declare difference: IncomeCurrencySummaryDto;
}
export class IncomeComparisonDto {
@ApiProperty({ type: IncomePeriodDto })
declare period: IncomePeriodDto;
@ApiProperty({ type: IncomeComparisonCurrencyDto, isArray: true })
declare totalsByCurrency: IncomeComparisonCurrencyDto[];
}
export class IncomeBucketDto {
@ApiProperty({ type: IncomePeriodDto })
declare period: IncomePeriodDto;
@ApiProperty({ type: IncomeCurrencySummaryDto, isArray: true })
declare totalsByCurrency: IncomeCurrencySummaryDto[];
}
export class IncomeInstrumentDto {
@ApiProperty()
declare key: string;
@ApiProperty({ type: String, nullable: true })
declare figi: string | null;
@ApiProperty({ type: String, nullable: true })
declare instrumentUid: string | null;
@ApiProperty({ type: String, nullable: true })
declare ticker: string | null;
@ApiProperty({ type: String, nullable: true })
declare instrumentType: string | null;
@ApiProperty()
declare displayName: string;
@ApiProperty({ type: MoneyDto })
declare coupon: MoneyDto;
@ApiProperty({ type: MoneyDto })
declare dividend: MoneyDto;
@ApiProperty({ type: MoneyDto })
declare total: MoneyDto;
@ApiProperty()
declare receiptCount: number;
}
export class IncomeAnalyticsEntryDto extends BrokerageOperationDto {
@ApiProperty()
declare instrumentKey: string;
}
export class IncomeAnalyticsDto extends IncomeSummaryDto {
@ApiProperty({ type: IncomeComparisonDto })
declare previous: IncomeComparisonDto;
@ApiProperty({ type: IncomeComparisonDto, nullable: true })
declare yearComparison: IncomeComparisonDto | null;
@ApiProperty({ enum: ['day', 'week', 'month'] })
declare bucketGranularity: 'day' | 'week' | 'month';
@ApiProperty({ type: IncomeBucketDto, isArray: true })
declare buckets: IncomeBucketDto[];
@ApiProperty({ type: IncomeInstrumentDto, isArray: true })
declare instruments: IncomeInstrumentDto[];
@ApiProperty({ type: IncomeAnalyticsEntryDto, isArray: true })
declare entries: IncomeAnalyticsEntryDto[];
}
@@ -0,0 +1,224 @@
jest.mock('../operations/application/brokerage-operations.service', () => ({
BrokerageOperationsService: class BrokerageOperationsService {},
}));
import { BrokerageOperationDto } from '../operations/presentation/brokerage-operation.dto';
import { IncomeAnalyticsService } from './income-analytics.service';
describe('IncomeAnalyticsService', () => {
const operations = { collectRanges: jest.fn() };
const service = new IncomeAnalyticsService(operations as never);
beforeEach(() => jest.clearAllMocks());
function seed(items: BrokerageOperationDto[]) {
operations.collectRanges.mockImplementation(
(
_user: string,
_account: string,
build: (opened?: Date) => unknown[],
) => {
build(undefined);
return { pages: [[{ items }], [{ items: [] }], [{ items: [] }]] };
},
);
}
it.each([
{
identity: {
instrumentUid: 'UID',
figi: 'FIGI',
ticker: 'T',
instrumentType: 'bond',
},
key: 'uid:UID:RUB',
},
{
identity: { figi: 'FIGI', ticker: 'T', instrumentType: 'bond' },
key: 'figi:FIGI:RUB',
},
{
identity: { ticker: 'T', instrumentType: 'bond' },
key: 'ticker:T:bond:RUB',
},
{ identity: { ticker: 'T' }, key: 'ticker:T::RUB' },
{
identity: { ticker: null, instrumentType: 'bond' },
key: 'ticker::bond:RUB',
},
{ identity: { ticker: null }, key: 'operation:receipt:RUB' },
])(
'uses one membership for entry and aggregate: $key',
async ({ identity, key }) => {
const payment = {
...entry('receipt', 'OPERATION_TYPE_COUPON', '10.25', 'RUB'),
...identity,
};
seed([payment]);
const result = await service.analytics(
'user',
'account',
'2026-01-01',
'2026-01-31',
);
expect(result.entries).toEqual([{ ...payment, instrumentKey: key }]);
expect(result.instruments).toEqual([
expect.objectContaining({
key,
receiptCount: 1,
total: { currency: 'RUB', value: '10.25' },
}),
]);
},
);
it('preserves entry order, repeated IDs, currency groups and signed exact totals', async () => {
const selected = [
{
...entry('same-id', 'OPERATION_TYPE_COUPON', '10.25', 'RUB'),
instrumentUid: 'UID',
},
{
...entry('same-id', 'OPERATION_TYPE_DIVIDEND', '-0.25', 'RUB'),
instrumentUid: 'UID',
},
{
...entry('usd', 'OPERATION_TYPE_COUPON', '20', 'USD'),
instrumentUid: 'UID',
},
];
seed(selected);
const result = await service.analytics(
'user',
'account',
'2026-01-01',
'2026-01-31',
);
expect(result.entries).toEqual(
selected.map((item) => ({
...item,
instrumentKey: `uid:UID:${item.payment?.currency}`,
})),
);
expect(
result.instruments.map(
({ key, receiptCount, coupon, dividend, total }) => ({
key,
receiptCount,
coupon,
dividend,
total,
}),
),
).toEqual([
{
key: 'uid:UID:USD',
receiptCount: 1,
coupon: { currency: 'USD', value: '20' },
dividend: { currency: 'USD', value: '0' },
total: { currency: 'USD', value: '20' },
},
{
key: 'uid:UID:RUB',
receiptCount: 2,
coupon: { currency: 'RUB', value: '10.25' },
dividend: { currency: 'RUB', value: '-0.25' },
total: { currency: 'RUB', value: '10' },
},
]);
expect(
result.entries
.filter((item) => item.instrumentKey === 'uid:UID:RUB')
.map((item) => item.payment?.value),
).toEqual(['10.25', '-0.25']);
});
it('aggregates only exact coupon and dividend payments by source currency', async () => {
operations.collectRanges.mockImplementation(
(
_user: string,
_account: string,
build: (opened?: Date) => unknown[],
) => {
build(undefined);
return {
pages: [
[
{
items: [
entry('coupon', 'OPERATION_TYPE_COUPON', '10.25', 'RUB'),
entry('dividend', 'OPERATION_TYPE_DIVIDEND', '-2.5', 'RUB'),
entry(
'transfer',
'OPERATION_TYPE_DIVIDEND_TRANSFER',
'99',
'RUB',
),
entry('usd', 'OPERATION_TYPE_COUPON', '1', 'USD'),
],
},
],
[{ items: [] }],
[{ items: [] }],
],
};
},
);
const result = await service.analytics(
'user',
'account',
'2026-01-01',
'2026-01-31',
);
expect(result.totalsByCurrency).toEqual([
expect.objectContaining({
currency: 'RUB',
coupon: { currency: 'RUB', value: '10.25' },
dividend: { currency: 'RUB', value: '-2.5' },
total: { currency: 'RUB', value: '7.75' },
}),
expect.objectContaining({
currency: 'USD',
total: { currency: 'USD', value: '1' },
}),
]);
expect(result.entries.map((entry) => entry.id)).toEqual([
'coupon',
'dividend',
'usd',
]);
expect(result.buckets).toHaveLength(31);
});
});
function entry(
id: string,
type: string,
value: string,
currency: string,
): BrokerageOperationDto {
return {
id,
occurredAt: '2026-01-15T12:00:00.000Z',
type,
category: 'income',
description: id,
figi: null,
instrumentUid: null,
ticker: id,
instrumentType: null,
logoName: null,
payment: { currency, value },
quantity: null,
price: null,
accruedInterest: null,
commission: null,
};
}
@@ -0,0 +1,267 @@
import { Injectable } from '@nestjs/common';
import {
add,
compare,
decimalFromString,
formatDecimal,
subtract,
} from '../../../common/decimal';
import { DateOnlyRange, moscowDate } from '../investment-calendar';
import {
BrokerageOperationDto,
BrokerageOperationsService,
} from '../operations/operations.api';
import {
IncomeAnalyticsDto,
IncomeAnalyticsEntryDto,
IncomeComparisonDto,
IncomeCurrencySummaryDto,
IncomeInstrumentDto,
IncomeSummaryDto,
} from './income-analytics.dto';
import {
IncomeBucketGranularity,
planIncomePeriod,
rangeBuckets,
} from './income-period';
type Total = {
coupon: ReturnType<typeof decimalFromString>;
dividend: ReturnType<typeof decimalFromString>;
};
@Injectable()
export class IncomeAnalyticsService {
constructor(private readonly operations: BrokerageOperationsService) {}
async summary(userId: string, accountId: string): Promise<IncomeSummaryDto> {
let plan: ReturnType<typeof planIncomePeriod> | undefined;
const collected = await this.operations.collectRanges(
userId,
accountId,
(openedAt) => {
const planned = planIncomePeriod(undefined, undefined, openedAt);
plan = planned;
return [planned.selected];
},
);
if (!plan) throw new Error('Income period was not planned');
return summaryResponse(
accountId,
plan.selected,
qualifying(collected.pages[0].flatMap((page) => page.items)),
new Date().toISOString(),
);
}
async analytics(
userId: string,
accountId: string,
from: string | undefined,
to: string | undefined,
): Promise<IncomeAnalyticsDto> {
let plan: ReturnType<typeof planIncomePeriod> | undefined;
const collected = await this.operations.collectRanges(
userId,
accountId,
(openedAt) => {
const planned = planIncomePeriod(from, to, openedAt);
plan = planned;
return [
planned.selected,
planned.previous,
...(planned.yearAgo ? [planned.yearAgo] : []),
];
},
);
if (!plan) throw new Error('Income period was not planned');
const selected = qualifying(
collected.pages[0].flatMap((page) => page.items),
).map((entry) => ({
...entry,
instrumentKey: `${instrumentKey(entry)}:${entry.payment?.currency ?? ''}`,
}));
const previous = qualifying(
collected.pages[1].flatMap((page) => page.items),
);
const yearAgo = plan.yearAgo
? qualifying(collected.pages[2].flatMap((page) => page.items))
: null;
const calculatedAt = new Date().toISOString();
const base = summaryResponse(
accountId,
plan.selected,
selected,
calculatedAt,
);
return {
...base,
previous: comparison(selected, plan.previous, previous),
yearComparison:
plan.yearAgo && yearAgo
? comparison(selected, plan.yearAgo, yearAgo)
: null,
bucketGranularity: plan.granularity,
buckets: buckets(plan.selected, plan.granularity, selected),
instruments: instruments(selected),
entries: selected,
};
}
}
function qualifying(entries: BrokerageOperationDto[]): BrokerageOperationDto[] {
return entries.filter(
(entry) =>
Boolean(entry.payment) &&
['OPERATION_TYPE_COUPON', 'OPERATION_TYPE_DIVIDEND'].includes(entry.type),
);
}
function summaryResponse(
accountId: string,
period: DateOnlyRange,
entries: BrokerageOperationDto[],
calculatedAt: string,
): IncomeSummaryDto {
return { accountId, period, totalsByCurrency: totals(entries), calculatedAt };
}
function totals(entries: BrokerageOperationDto[]): IncomeCurrencySummaryDto[] {
const values = new Map<string, Total>();
for (const entry of entries) {
if (!entry.payment) continue;
const total = values.get(entry.payment.currency) ?? zeroTotal();
const value = decimalFromString(entry.payment.value);
if (entry.type === 'OPERATION_TYPE_COUPON')
total.coupon = add(total.coupon, value);
else total.dividend = add(total.dividend, value);
values.set(entry.payment.currency, total);
}
return [...values]
.sort(([left], [right]) => left.localeCompare(right))
.map(([currency, total]) => presentTotal(currency, total));
}
function comparison(
selectedEntries: BrokerageOperationDto[],
comparedPeriod: DateOnlyRange,
comparedEntries: BrokerageOperationDto[],
): IncomeComparisonDto {
const selected = new Map(
totals(selectedEntries).map((total) => [total.currency, total]),
);
const compared = new Map(
totals(comparedEntries).map((total) => [total.currency, total]),
);
const currencies = [
...new Set([...selected.keys(), ...compared.keys()]),
].sort();
return {
period: comparedPeriod,
totalsByCurrency: currencies.map((currency) => {
const current =
selected.get(currency) ?? presentTotal(currency, zeroTotal());
const prior =
compared.get(currency) ?? presentTotal(currency, zeroTotal());
return {
currency,
selected: current,
compared: prior,
difference: difference(current, prior),
};
}),
};
}
function buckets(
range: DateOnlyRange,
granularity: IncomeBucketGranularity,
entries: BrokerageOperationDto[],
) {
return rangeBuckets(range, granularity).map((period) => ({
period,
totalsByCurrency: totals(
entries.filter((entry) => {
const date = moscowDate(new Date(entry.occurredAt));
return date >= period.from && date <= period.to;
}),
),
}));
}
function instruments(
entries: IncomeAnalyticsEntryDto[],
): IncomeInstrumentDto[] {
const groups = new Map<string, IncomeAnalyticsEntryDto[]>();
for (const entry of entries) {
const group = groups.get(entry.instrumentKey);
if (group) group.push(entry);
else groups.set(entry.instrumentKey, [entry]);
}
return [...groups]
.map(([key, group]) => {
const first = group[0];
const [total] = totals(group);
return {
key,
figi: first.figi,
instrumentUid: first.instrumentUid,
ticker: first.ticker,
instrumentType: first.instrumentType,
displayName: first.ticker ?? (first.description || first.type),
coupon: total.coupon,
dividend: total.dividend,
total: total.total,
receiptCount: group.length,
};
})
.sort(
(left, right) =>
compare(
decimalFromString(right.total.value),
decimalFromString(left.total.value),
) || left.key.localeCompare(right.key),
);
}
function instrumentKey(entry: BrokerageOperationDto): string {
if (entry.instrumentUid) return `uid:${entry.instrumentUid}`;
if (entry.figi) return `figi:${entry.figi}`;
if (entry.ticker || entry.instrumentType)
return `ticker:${entry.ticker ?? ''}:${entry.instrumentType ?? ''}`;
return `operation:${entry.account ? `${entry.account.id}:` : ''}${entry.id}`;
}
function difference(
left: IncomeCurrencySummaryDto,
right: IncomeCurrencySummaryDto,
): IncomeCurrencySummaryDto {
return presentTotal(left.currency, {
coupon: subtract(
decimalFromString(left.coupon.value),
decimalFromString(right.coupon.value),
),
dividend: subtract(
decimalFromString(left.dividend.value),
decimalFromString(right.dividend.value),
),
});
}
function zeroTotal(): Total {
return { coupon: { value: 0n, scale: 0 }, dividend: { value: 0n, scale: 0 } };
}
function presentTotal(
currency: string,
value: Total,
): IncomeCurrencySummaryDto {
const total = add(value.coupon, value.dividend);
return {
currency,
coupon: { currency, value: formatDecimal(value.coupon) },
dividend: { currency, value: formatDecimal(value.dividend) },
total: { currency, value: formatDecimal(total) },
};
}
@@ -0,0 +1,56 @@
import { BadRequestException } from '@nestjs/common';
import { dateOnlyBounds } from '../investment-calendar';
import { planIncomePeriod, rangeBuckets } from './income-period';
describe('income period planning', () => {
afterEach(() => jest.useRealTimers());
it('uses the Moscow current month and validates strict inclusive ranges', () => {
jest.useFakeTimers();
jest.setSystemTime(new Date('2026-01-31T22:30:00.000Z'));
expect(planIncomePeriod(undefined, undefined)).toMatchObject({
selected: { from: '2026-02-01', to: '2026-02-01' },
});
for (const [from, to] of [
['2026-02-30', '2026-03-01'],
['2026-03-02', '2026-03-01'],
['2026-01-01', '2027-01-02'],
['2026-01-01', '2026-04-01'],
])
expect(() => planIncomePeriod(from, to)).toThrow(BadRequestException);
});
it('uses a full previous month, skips impossible year-ago dates, and keeps empty buckets', () => {
const fullMonth = planIncomePeriod('2026-03-01', '2026-03-31');
expect(fullMonth.previous).toEqual({
from: '2026-02-01',
to: '2026-02-28',
});
expect(planIncomePeriod('2024-02-29', '2024-02-29').yearAgo).toBeNull();
expect(
rangeBuckets({ from: '2026-01-01', to: '2026-02-15' }, 'week'),
).toHaveLength(7);
});
it('accepts multi-year periods and creates monthly buckets for the entire range', () => {
jest.useFakeTimers();
jest.setSystemTime(new Date('2026-09-09T12:00:00Z'));
const plan = planIncomePeriod('2020-01-01', '2025-12-31');
expect(plan.selected).toEqual({ from: '2020-01-01', to: '2025-12-31' });
expect(plan.granularity).toBe('month');
expect(rangeBuckets(plan.selected, plan.granularity)).toHaveLength(72);
expect(plan.yearAgo).toEqual({ from: '2019-01-01', to: '2024-12-31' });
});
it('does not send the unfinished Moscow day as a future instant', () => {
jest.useFakeTimers();
jest.setSystemTime(new Date('2026-08-27T21:44:18.510Z'));
expect(dateOnlyBounds({ from: '2026-08-01', to: '2026-08-28' })).toEqual({
from: new Date('2026-07-31T21:00:00.000Z'),
to: new Date('2026-08-27T21:44:18.510Z'),
});
});
});
@@ -0,0 +1,141 @@
import { BadRequestException } from '@nestjs/common';
import {
DateOnlyRange,
dateParts,
moscowDate,
utcDate,
} from '../investment-calendar';
export type IncomeBucketGranularity = 'day' | 'week' | 'month';
export function planIncomePeriod(
from: string | undefined,
to: string | undefined,
openedDate?: Date,
): {
selected: DateOnlyRange;
previous: DateOnlyRange;
yearAgo: DateOnlyRange | null;
granularity: IncomeBucketGranularity;
} {
if (Boolean(from) !== Boolean(to)) throw invalidPeriod();
const today = moscowDate(new Date());
const selected =
from && to ? { from, to } : { from: firstOfMonth(today), to: today };
assertRange(selected, today);
const previous = isFullMonth(selected)
? previousMonth(selected.from)
: shiftDays(selected, -rangeDays(selected));
const candidate = yearAgo(selected);
const yearAgoRange =
candidate && (!openedDate || candidate.from >= moscowDate(openedDate))
? candidate
: null;
return {
selected,
previous,
yearAgo: yearAgoRange,
granularity:
rangeDays(selected) <= 45
? 'day'
: rangeDays(selected) <= 180
? 'week'
: 'month',
};
}
export function rangeBuckets(
range: DateOnlyRange,
granularity: IncomeBucketGranularity,
): DateOnlyRange[] {
if (granularity === 'day')
return dates(range).map((date) => ({ from: date, to: date }));
const buckets: DateOnlyRange[] = [];
let cursor =
granularity === 'week'
? addDays(range.from, -((utcDate(range.from).getUTCDay() + 6) % 7))
: `${range.from.slice(0, 7)}-01`;
while (cursor <= range.to) {
const end =
granularity === 'week' ? addDays(cursor, 6) : endOfMonth(cursor);
buckets.push({
from: cursor < range.from ? range.from : cursor,
to: end > range.to ? range.to : end,
});
cursor = addDays(end, 1);
}
return buckets;
}
export function rangeDays(range: DateOnlyRange): number {
return (
Math.round(
(utcDate(range.to).valueOf() - utcDate(range.from).valueOf()) /
86_400_000,
) + 1
);
}
function assertRange(range: DateOnlyRange, today: string): void {
try {
dateParts(range.from);
dateParts(range.to);
} catch {
throw invalidPeriod();
}
if (range.from > range.to || range.to > today) throw invalidPeriod();
}
function addDays(value: string, days: number): string {
const date = utcDate(value);
date.setUTCDate(date.getUTCDate() + days);
return date.toISOString().slice(0, 10);
}
function dates(range: DateOnlyRange): string[] {
const result: string[] = [];
for (let date = range.from; date <= range.to; date = addDays(date, 1))
result.push(date);
return result;
}
function firstOfMonth(value: string): string {
return `${value.slice(0, 7)}-01`;
}
function endOfMonth(value: string): string {
const [year, month] = dateParts(firstOfMonth(value));
return new Date(Date.UTC(year, month, 0)).toISOString().slice(0, 10);
}
function isFullMonth(range: DateOnlyRange): boolean {
return (
range.from === firstOfMonth(range.from) && range.to === endOfMonth(range.to)
);
}
function previousMonth(from: string): DateOnlyRange {
const last = addDays(firstOfMonth(from), -1);
return { from: firstOfMonth(last), to: last };
}
function shiftDays(range: DateOnlyRange, days: number): DateOnlyRange {
return { from: addDays(range.from, days), to: addDays(range.to, days) };
}
function yearAgo(range: DateOnlyRange): DateOnlyRange | null {
const shift = (value: string) => {
const [year, month, day] = dateParts(value);
const result = `${String(year - 1).padStart(4, '0')}-${String(month).padStart(2, '0')}-${String(day).padStart(2, '0')}`;
return utcDate(result).toISOString().slice(0, 10) === result
? result
: null;
};
const from = shift(range.from);
const to = shift(range.to);
return from && to ? { from, to } : null;
}
function invalidPeriod(): BadRequestException {
return new BadRequestException({ code: 'INVALID_INCOME_PERIOD' });
}
@@ -0,0 +1,12 @@
import { Module } from '@nestjs/common';
import { AuthModule } from '../../auth/auth.module';
import { OperationsModule } from '../operations/operations.module';
import { IncomeAnalyticsController } from './income-analytics.controller';
import { IncomeAnalyticsService } from './income-analytics.service';
@Module({
imports: [AuthModule, OperationsModule],
controllers: [IncomeAnalyticsController],
providers: [IncomeAnalyticsService],
})
export class IncomeModule {}