Use owner enums for error and T-Bank protocol values with ESLint regression checks. Preserve open wire fallbacks and keep protocol imports independent of DI and Prisma. Verified npm ci, verify, 226 unit tests, 262 isolated e2e tests and unchanged OpenAPI.
170 lines
5.0 KiB
TypeScript
170 lines
5.0 KiB
TypeScript
import { BadRequestException } from '@nestjs/common';
|
|
|
|
import { ISO_DATE_LENGTH, ISO_DATE_START, MILLISECONDS_PER_DAY } from '../../../common/time';
|
|
import {
|
|
DateOnlyRange,
|
|
dateParts,
|
|
moscowDate,
|
|
utcDate,
|
|
} from '../investment-calendar';
|
|
|
|
enum IncomeErrorCode { InvalidPeriod = 'INVALID_INCOME_PERIOD' }
|
|
|
|
const MONDAY_WEEKDAY_INDEX = 1;
|
|
const INCLUSIVE_DAY_OFFSET = 1;
|
|
const NEXT_DAY_OFFSET = 1;
|
|
const PREVIOUS_MONTH_LAST_DAY = 0;
|
|
const PREVIOUS_DAY_OFFSET = -1;
|
|
const PREVIOUS_YEAR_OFFSET = 1;
|
|
const DATE_COMPONENT_DIGITS = 2;
|
|
|
|
const DAILY_BUCKET_MAX_DAYS = 45;
|
|
const WEEKLY_BUCKET_MAX_DAYS = 180;
|
|
const DAYS_PER_WEEK = 7;
|
|
const ISO_MONTH_LENGTH = 7;
|
|
const ISO_YEAR_LENGTH = 4;
|
|
|
|
export type IncomeBucketGranularity = 'day' | 'month' | 'week';
|
|
|
|
export function planIncomePeriod(
|
|
from: string | undefined,
|
|
to: string | undefined,
|
|
openedDate?: Date,
|
|
): {
|
|
granularity: IncomeBucketGranularity;
|
|
previous: DateOnlyRange;
|
|
selected: DateOnlyRange;
|
|
yearAgo: DateOnlyRange | null;
|
|
} {
|
|
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) <= DAILY_BUCKET_MAX_DAYS
|
|
? 'day'
|
|
: rangeDays(selected) <= WEEKLY_BUCKET_MAX_DAYS
|
|
? '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() + DAYS_PER_WEEK - MONDAY_WEEKDAY_INDEX) %
|
|
DAYS_PER_WEEK
|
|
),
|
|
)
|
|
: `${range.from.slice(ISO_DATE_START, ISO_MONTH_LENGTH)}-01`;
|
|
while (cursor <= range.to) {
|
|
const end =
|
|
granularity === 'week'
|
|
? addDays(cursor, DAYS_PER_WEEK - INCLUSIVE_DAY_OFFSET)
|
|
: endOfMonth(cursor);
|
|
buckets.push({
|
|
from: cursor < range.from ? range.from : cursor,
|
|
to: end > range.to ? range.to : end,
|
|
});
|
|
cursor = addDays(end, NEXT_DAY_OFFSET);
|
|
}
|
|
return buckets;
|
|
}
|
|
|
|
export function rangeDays(range: DateOnlyRange): number {
|
|
return (
|
|
Math.round(
|
|
(utcDate(range.to).valueOf() - utcDate(range.from).valueOf()) /
|
|
MILLISECONDS_PER_DAY,
|
|
) + INCLUSIVE_DAY_OFFSET
|
|
);
|
|
}
|
|
|
|
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(ISO_DATE_START, ISO_DATE_LENGTH);
|
|
}
|
|
|
|
function dates(range: DateOnlyRange): string[] {
|
|
const result: string[] = [];
|
|
for (let date = range.from; date <= range.to; date = addDays(date, NEXT_DAY_OFFSET)) {
|
|
result.push(date);
|
|
}
|
|
return result;
|
|
}
|
|
|
|
function firstOfMonth(value: string): string {
|
|
return `${value.slice(ISO_DATE_START, ISO_MONTH_LENGTH)}-01`;
|
|
}
|
|
|
|
function endOfMonth(value: string): string {
|
|
const [year, month] = dateParts(firstOfMonth(value));
|
|
return new Date(Date.UTC(year, month, PREVIOUS_MONTH_LAST_DAY)).toISOString().slice(ISO_DATE_START, ISO_DATE_LENGTH);
|
|
}
|
|
|
|
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), PREVIOUS_DAY_OFFSET);
|
|
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 - PREVIOUS_YEAR_OFFSET).padStart(ISO_YEAR_LENGTH, '0')}-${String(month).padStart(DATE_COMPONENT_DIGITS, '0')}-${String(day).padStart(DATE_COMPONENT_DIGITS, '0')}`;
|
|
return utcDate(result).toISOString().slice(ISO_DATE_START, ISO_DATE_LENGTH) === 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: IncomeErrorCode.InvalidPeriod });
|
|
}
|