feat: unify income payment cards and drop period comparisons (WealthLedger/main#33) (#83)
feat: unify income payment cards and drop period comparisons (WealthLedger/main#33)
This commit was merged in pull request #83.
This commit is contained in:
@@ -67,28 +67,6 @@ export class IncomeSummaryDto {
|
||||
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;
|
||||
@@ -135,12 +113,6 @@ export class IncomeAnalyticsEntryDto extends BrokerageOperationDto {
|
||||
}
|
||||
|
||||
export class IncomeAnalyticsDto extends IncomeSummaryDto {
|
||||
@ApiProperty({ type: IncomeComparisonDto, nullable: true })
|
||||
declare previous: IncomeComparisonDto | null;
|
||||
|
||||
@ApiProperty({ type: IncomeComparisonDto, nullable: true })
|
||||
declare yearComparison: IncomeComparisonDto | null;
|
||||
|
||||
@ApiProperty({ enum: ['day', 'week', 'month'] })
|
||||
declare bucketGranularity: 'day' | 'month' | 'week';
|
||||
|
||||
@@ -162,7 +134,7 @@ export class IncomeSummaryResourceDto {
|
||||
declare meta: InvestmentResourceMetaDto;
|
||||
}
|
||||
|
||||
const incomeDependencyRoles = ['selected', 'previous', 'year'] as const;
|
||||
const incomeDependencyRoles = ['selected'] as const;
|
||||
|
||||
export class IncomeDependencyDto extends InvestmentDependencyDto {
|
||||
@ApiProperty({ enum: incomeDependencyRoles })
|
||||
|
||||
@@ -17,7 +17,7 @@ describe('IncomeAnalyticsService', () => {
|
||||
|
||||
beforeEach(() => jest.clearAllMocks());
|
||||
|
||||
it('labels each comparison dependency independently of its resource key', async () => {
|
||||
it('labels the selected dependency independently of its resource key', async () => {
|
||||
seed([]);
|
||||
const response = await service.analytics(
|
||||
'user',
|
||||
@@ -27,32 +27,10 @@ describe('IncomeAnalyticsService', () => {
|
||||
);
|
||||
expect(response.meta.dependencies).toEqual([
|
||||
expect.objectContaining({ role: 'selected', resourceKey: 'period/0' }),
|
||||
expect.objectContaining({ role: 'previous', resourceKey: 'period/1' }),
|
||||
expect.objectContaining({ role: 'year', resourceKey: 'period/2' }),
|
||||
]);
|
||||
});
|
||||
|
||||
it('labels an unavailable leap-day comparison as year', async () => {
|
||||
operations.collectRanges.mockImplementation(
|
||||
(_user: string, _account: string, build: () => unknown[]) =>
|
||||
build().map((_, index) => ({ items: [], meta: completeMeta(index) })),
|
||||
);
|
||||
const response = await service.analytics(
|
||||
'user',
|
||||
'account',
|
||||
'2024-02-29',
|
||||
'2024-02-29',
|
||||
);
|
||||
expect(response.meta.dependencies).toEqual([
|
||||
expect.objectContaining({ role: 'selected' }),
|
||||
expect.objectContaining({ role: 'previous' }),
|
||||
expect.objectContaining({
|
||||
role: 'year',
|
||||
availability: 'temporarily-unavailable',
|
||||
unavailableReason: 'CALENDAR_OR_ACCOUNT_HISTORY',
|
||||
}),
|
||||
]);
|
||||
expect(response.data?.yearComparison).toBeNull();
|
||||
expect(
|
||||
response.meta.dependencies.some((dependency) => dependency.role !== 'selected'),
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
function seed(items: BrokerageOperationDto[]) {
|
||||
@@ -63,7 +41,7 @@ describe('IncomeAnalyticsService', () => {
|
||||
build: (opened?: Date) => unknown[],
|
||||
) => {
|
||||
build(undefined);
|
||||
return [items, [], []].map((items, index) => ({
|
||||
return [items].map((items, index) => ({
|
||||
items,
|
||||
meta: completeMeta(index),
|
||||
}));
|
||||
@@ -247,8 +225,6 @@ describe('IncomeAnalyticsService', () => {
|
||||
entry('transfer', 'OPERATION_TYPE_DIVIDEND_TRANSFER', '99', 'RUB'),
|
||||
entry('usd', 'OPERATION_TYPE_COUPON', '1', 'USD'),
|
||||
],
|
||||
[],
|
||||
[],
|
||||
].map((items, index) => ({ items, meta: completeMeta(index) }));
|
||||
},
|
||||
);
|
||||
|
||||
@@ -6,7 +6,6 @@ import {
|
||||
compare,
|
||||
decimalFromString,
|
||||
formatDecimal,
|
||||
subtract,
|
||||
} from '../../../common/decimal';
|
||||
import { isTbankProtocolValue, TbankOperationType } from '../../../integrations/tbank/tbank-protocol';
|
||||
import { DateOnlyRange, moscowDate } from '../investment-calendar';
|
||||
@@ -18,7 +17,6 @@ import {
|
||||
import {
|
||||
IncomeAnalyticsEntryDto,
|
||||
IncomeAnalyticsResourceDto,
|
||||
IncomeComparisonDto,
|
||||
IncomeCurrencySummaryDto,
|
||||
IncomeDependencyDto,
|
||||
IncomeInstrumentDto,
|
||||
@@ -47,14 +45,11 @@ export class IncomeAnalyticsService {
|
||||
accountId: string,
|
||||
observe = false,
|
||||
): Promise<IncomeSummaryResourceDto> {
|
||||
let plan = planIncomePeriod(undefined, undefined);
|
||||
const plan = planIncomePeriod(undefined, undefined);
|
||||
const [selected] = await this.operations.collectRanges(
|
||||
userId,
|
||||
accountId,
|
||||
(openedAt) => {
|
||||
plan = planIncomePeriod(undefined, undefined, openedAt);
|
||||
return [plan.selected];
|
||||
},
|
||||
() => [plan.selected],
|
||||
observe,
|
||||
);
|
||||
if (selected.items === null) return { data: null, meta: selected.meta };
|
||||
@@ -77,44 +72,17 @@ export class IncomeAnalyticsService {
|
||||
to: string | undefined,
|
||||
observe = false,
|
||||
): Promise<IncomeAnalyticsResourceDto> {
|
||||
let plan = planIncomePeriod(from, to);
|
||||
const periods = await this.operations.collectRanges(
|
||||
const plan = planIncomePeriod(from, to);
|
||||
const [current] = await this.operations.collectRanges(
|
||||
userId,
|
||||
accountId,
|
||||
(openedAt) => {
|
||||
plan = planIncomePeriod(from, to, openedAt);
|
||||
return [
|
||||
plan.selected,
|
||||
plan.previous,
|
||||
...(plan.yearAgo ? [plan.yearAgo] : []),
|
||||
];
|
||||
},
|
||||
() => [plan.selected],
|
||||
observe,
|
||||
);
|
||||
const [current, prior, year] = periods;
|
||||
// Preserve the legacy order while consumers migrate to explicit roles.
|
||||
const dependencies: IncomeDependencyDto[] = [
|
||||
{ ...current.meta, role: 'selected' },
|
||||
{ ...prior.meta, role: 'previous' },
|
||||
{
|
||||
...(plan.yearAgo && year
|
||||
? year.meta
|
||||
: investmentResourceMeta({
|
||||
resourceKey: 'income/yearAgo', // gitleaks:allow public resource identifier
|
||||
dataRevision: 'unavailable',
|
||||
availability: 'missing',
|
||||
fetchedAt: null,
|
||||
freshForMs: 0,
|
||||
work: null,
|
||||
unavailableReason: 'CALENDAR_OR_ACCOUNT_HISTORY',
|
||||
})),
|
||||
role: 'year',
|
||||
},
|
||||
];
|
||||
const states = periods.map((period) => period.meta.sync);
|
||||
const work =
|
||||
states.find((state) => state.state === 'running') ??
|
||||
states.find((state) => state.state === 'failed');
|
||||
const work = current.meta.sync;
|
||||
const meta = {
|
||||
...investmentResourceMeta({
|
||||
resourceKey: `income/${accountId}/${plan.selected.from}/${plan.selected.to}`,
|
||||
@@ -127,7 +95,9 @@ export class IncomeAnalyticsService {
|
||||
? new Date(current.meta.lastSuccessfulAt)
|
||||
: null,
|
||||
freshForMs: current.meta.freshness === 'fresh' ? Infinity : STALE_FRESHNESS_MS,
|
||||
work: work?.id ? { ...work, id: work.id } : null,
|
||||
work: work.id
|
||||
? { id: work.id, state: work.state, errorCode: work.errorCode }
|
||||
: null,
|
||||
coverage: current.meta.coverage,
|
||||
dependencies,
|
||||
}),
|
||||
@@ -147,14 +117,6 @@ export class IncomeAnalyticsService {
|
||||
selected,
|
||||
current.meta.lastSuccessfulAt,
|
||||
),
|
||||
previous:
|
||||
prior.items === null
|
||||
? null
|
||||
: comparison(selected, plan.previous, qualifying(prior.items)),
|
||||
yearComparison:
|
||||
plan.yearAgo && year?.items !== null && year
|
||||
? comparison(selected, plan.yearAgo, qualifying(year.items))
|
||||
: null,
|
||||
bucketGranularity: plan.granularity,
|
||||
buckets: buckets(plan.selected, plan.granularity, selected),
|
||||
instruments: instruments(selected),
|
||||
@@ -198,38 +160,6 @@ function totals(entries: BrokerageOperationDto[]): IncomeCurrencySummaryDto[] {
|
||||
.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()]),
|
||||
// eslint-disable-next-line sonarjs/no-alphabetical-sort -- Code-unit ordering is intentional for canonical keys and ISO dates; locale collation would change persisted bindings.
|
||||
].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,
|
||||
@@ -293,22 +223,6 @@ function instrumentKey(entry: BrokerageOperationDto): string {
|
||||
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 } };
|
||||
}
|
||||
|
||||
@@ -23,16 +23,14 @@ describe('income period planning', () => {
|
||||
}
|
||||
});
|
||||
|
||||
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();
|
||||
it('keeps empty buckets and skips removed comparison ranges', () => {
|
||||
expect(
|
||||
rangeBuckets({ from: '2026-01-01', to: '2026-02-15' }, 'week'),
|
||||
).toHaveLength(7);
|
||||
expect(planIncomePeriod('2026-03-01', '2026-03-31')).toEqual({
|
||||
selected: { from: '2026-03-01', to: '2026-03-31' },
|
||||
granularity: 'day',
|
||||
});
|
||||
});
|
||||
|
||||
it('accepts multi-year periods and creates monthly buckets for the entire range', () => {
|
||||
@@ -43,7 +41,6 @@ describe('income period planning', () => {
|
||||
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', () => {
|
||||
|
||||
@@ -14,45 +14,28 @@ 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'
|
||||
@@ -136,34 +119,6 @@ function endOfMonth(value: string): string {
|
||||
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 });
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user