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,204 @@
import { Injectable } from '@nestjs/common';
import {
decimalFromString,
divide,
formatDecimal,
multiply,
} from '../../../../../common/decimal';
import { MoexClient } from '../../../../../integrations/moex/moex.api';
import { NcrRatingsClient } from '../../../../../integrations/ncr/ncr.api';
import {
TbankClient,
type TbankConnectionContext,
} from '../../../../../integrations/tbank/tbank.api';
import { PortfolioService } from '../../../portfolio/portfolio.api';
import {
moscowDay,
optional,
timestamp,
} from '../../application/instrument-market';
import { instrumentPosition } from '../../application/instrument-position';
import {
BondCouponsDto,
BondMetricsDto,
BondPositionDto,
} from '../presentation/bond.dto';
import { BondMarketService } from './bond-market.service';
import { money } from '../infrastructure/bond-money.mapper';
import { BondCatalogService } from './bond-catalog.service';
@Injectable()
export class BondIncomeService {
constructor(
private readonly catalog: BondCatalogService,
private readonly tbank: TbankClient,
private readonly portfolios: PortfolioService,
private readonly market: BondMarketService,
private readonly moex: MoexClient,
private readonly ratings: NcrRatingsClient,
) {}
metrics(userId: string, uid: string): Promise<BondMetricsDto> {
return this.catalog.cached(userId, `metrics:${uid}`, async (connection) => {
const instrument = await this.catalog.bond(userId, connection, uid);
const { ticker, isin, classCode } = instrument;
const today = moscowDay(new Date().toISOString());
const from = `${Number(today.slice(0, 4)) - 5}${today.slice(4)}`;
const to = `${Number(today.slice(0, 4)) + 1}${today.slice(4)}`;
const unavailable: string[] = [];
const [events, quote, effectiveYield, creditRating] = await Promise.all([
optional(
() => this.couponEvents(userId, connection, uid, from, to),
'coupons',
unavailable,
),
optional(() => this.market.quote(userId, uid), 'quote', unavailable),
ticker && isin && classCode
? optional(
() => this.moex.bondYield(ticker, isin, classCode),
'yield',
unavailable,
)
: null,
isin
? optional(() => this.ratings.rating(isin), 'rating', unavailable)
: null,
]);
const next = events
?.filter((event) => {
const date = timestamp(event.couponDate)?.slice(0, 10);
return date && date >= today;
})
.sort(
(a, b) =>
Number(a.couponDate?.seconds) - Number(b.couponDate?.seconds),
)[0];
const coupon = money(next?.payOneBond);
const period = next?.couponPeriod;
const couponPeriodDays =
period && Number.isInteger(period) && period > 0 ? period : null;
const clean = quote?.cleanPrice;
const couponYieldPercent =
coupon &&
couponPeriodDays &&
clean &&
coupon.currency === clean.currency &&
decimalFromString(coupon.value).value >= 0n &&
decimalFromString(clean.value).value > 0n
? formatDecimal(
divide(
multiply(
decimalFromString(coupon.value),
decimalFromString('36500'),
),
multiply(
decimalFromString(String(couponPeriodDays)),
decimalFromString(clean.value),
),
4,
),
)
: null;
const couponType =
next?.couponType && next.couponType !== 'COUPON_TYPE_UNSPECIFIED'
? next.couponType
: instrument.floatingCouponFlag === true
? 'COUPON_TYPE_FLOATING'
: null;
return {
creditRating,
coupon,
couponDate: timestamp(next?.couponDate)?.slice(0, 10) ?? null,
couponType,
couponPeriodDays,
couponYieldPercent,
couponYieldPriceAt:
couponYieldPercent !== null ? (quote?.priceAt ?? null) : null,
effectiveYield,
unavailable,
observedAt: new Date().toISOString(),
};
});
}
private couponEvents(
userId: string,
connection: TbankConnectionContext,
uid: string,
from: string,
to: string,
) {
return this.catalog.cachedForConnection(
userId,
connection,
`coupon-events:${uid}:${from}:${to}`,
() => this.tbank.getBondCoupons(connection.token, uid, from, to),
);
}
coupons(userId: string, uid: string): Promise<BondCouponsDto> {
return this.catalog.cached(userId, `coupons:${uid}`, async (connection) => {
await this.catalog.bond(userId, connection, uid);
const today = moscowDay(new Date().toISOString());
const from = `${Number(today.slice(0, 4)) - 5}${today.slice(4)}`;
const to = `${Number(today.slice(0, 4)) + 1}${today.slice(4)}`;
const unavailable: string[] = [];
const [events, position] = await Promise.all([
this.couponEvents(userId, connection, uid, from, to),
optional(() => this.position(userId, uid), 'position', unavailable),
]);
const quantity =
position?.accounts.length &&
position.accounts.every((p) => decimalFromString(p.quantity).value > 0n)
? decimalFromString(position.quantity)
: null;
const coupons = events
.map((event) => {
const paymentDate = timestamp(event.couponDate)?.slice(0, 10) ?? null;
const recordDate = timestamp(event.fixDate)?.slice(0, 10) ?? null;
const perBond = money(event.payOneBond);
const timing = !paymentDate
? ('undated' as const)
: paymentDate >= today
? ('upcoming' as const)
: ('past' as const);
return {
number: event.couponNumber ?? null,
paymentDate,
recordDate,
perBond,
couponType: event.couponType ?? null,
periodDays: event.couponPeriod ?? null,
timing,
estimatedAmount:
quantity &&
perBond &&
timing === 'upcoming' &&
recordDate &&
recordDate >= today
? {
currency: perBond.currency,
value: formatDecimal(
multiply(quantity, decimalFromString(perBond.value)),
),
}
: null,
};
})
.sort((a, b) =>
(a.paymentDate ?? '').localeCompare(b.paymentDate ?? ''),
);
return {
coupons,
from,
to,
observedAt: new Date().toISOString(),
positionAt: quantity ? (position?.observedAt ?? null) : null,
estimateUnavailable: unavailable.length > 0,
positionIsStale: position?.isStale ?? false,
};
});
}
async position(userId: string, uid: string): Promise<BondPositionDto> {
return this.catalog.cached(userId, `position:${uid}`, async () =>
instrumentPosition(await this.portfolios.getAll(userId), uid, 'BOND'),
);
}
}