Files
backend/src/modules/investments/income/income-period.ts
T
codex-bot d37fa2c903
Backend verify / commits (push) Skipped
Backend verify / security (push) Successful in 10s
Backend verify / verify (push) Failing after 6m8s
refactor: publish typed problem detail codes (WealthLedger/backend#117) (#144)
2026-10-28 17:11:41 +03:00

125 lines
3.4 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';
export 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 DAILY_BUCKET_MAX_DAYS = 45;
const WEEKLY_BUCKET_MAX_DAYS = 180;
const DAYS_PER_WEEK = 7;
const ISO_MONTH_LENGTH = 7;
export type IncomeBucketGranularity = 'day' | 'month' | 'week';
export function planIncomePeriod(
from: string | undefined,
to: string | undefined,
): {
granularity: IncomeBucketGranularity;
selected: DateOnlyRange;
} {
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);
return {
selected,
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 invalidPeriod(): BadRequestException {
return new BadRequestException({ code: IncomeErrorCode.InvalidPeriod });
}