Перейти к содержимому

F1 — Shared-либы ядра: дизайн

Единица работы F1 строительной сессии по архитектуре 2026-08-21. Реализация — 2026-08-21-f1-shared-core-libs.md.

Источники: шаг 5 §4 (округление), шаг 7 §1 (каталог событий), шаг 7 §3 (рабочий календарь), шаг 2 §9 (enum’ы операционного контура); решения №05 (минорные единицы и снапшот комиссии), №07 (доменные события через outbox), №30 (календарь платформы, таймеры без автосанкций).

Три либы, на которых стоят все последующие единицы: money — денежная арифметика, чью формулу цитирует договор; domain-events — каталог того, что ездит по шине; platform-calendar — расчёт «рабочих» сроков для окон и SLA.

Общее у всех трёх: чистые функции и типы, ноль зависимостей — в том числе от NestJS. Ни модулей, ни провайдеров, ни DI. Потребители (F2, F3.1, C2, B3.1) импортируют функции напрямую.

Новая группа libs/apis/core/ — три независимых Nx-проекта.

ПутьNx-имяАлиасТеги
libs/apis/core/moneyapi-core-money@crewsforge-back/apis/core/moneyscope:shared, type:util
libs/apis/core/domain-eventsapi-core-domain-events@crewsforge-back/apis/core/domain-eventsscope:shared, type:util
libs/apis/core/platform-calendarapi-core-platform-calendar@crewsforge-back/apis/core/platform-calendarscope:shared, type:util

Четыре имени согласованы: project.json → name, jest.config.ts → displayName, алиас в tsconfig.base.json, coverageDirectory.

Буквальное правило coding-rules §1.2 («путь без libs/apis/, через дефис») дало бы core-money. Схема сознательно расширяется до api-core-<name> — тем же способом, каким уже названы api-shared и api-util-<name>: префикс api- отличает общие библиотеки от фич сервисов, а core-money спутывалось бы с приложением core-api. Расширение фиксируется здесь, чтобы следующая сессия не считала его случайностью.

Почему отдельная группа, а не libs/apis/shared/. libs/apis/shared/ — это сам проект api-shared, его src/ лежит прямо там; вложить туда три отдельных Nx-проекта негде, а положить папками внутрь api-shared значит лишить money собственной границы. Граница нужна: решение №21 строит O-1 тремя слоями, и первый слой — границы Nx. Отдельный проект api-core-money — то, что C4.1 сможет запретить операторским библиотекам.

Чего F1 не делает. Правила depConstraints в eslint.config.mjs не трогаются, денежный линтер не пишется — это C4.1 (правило 1 из START-HERE: инвариант-тесты вводит та единица, которая вводит механизм; денежный линтер отнесён к C4.1 явно).

Обвяз каждой либы по чек-листу coding-rules §12: project.json, tsconfig.json / tsconfig.lib.json / tsconfig.spec.json, jest.config.ts, eslint.config.mjs, README.md, src/index.ts как единственный публичный API.

src/lib/money.helper.ts — чистые функции над bigint. Знаменатель базисных пунктов — BPS_DENOMINATOR = 10_000n в money.constants.ts.

calculateCommissionFee(amountMinor: bigint, commissionRateBps: number): bigint;
splitSettlement(input: SettlementSplitInput): SettlementSplit;
interface SettlementSplitInput {
holdAmountMinor: bigint;
commissionRateBps: number;
shareBps?: number; // по умолчанию 10_000 — вся сумма холда идёт команде
}
interface SettlementSplit {
teamAmountMinor: bigint;
platformFeeMinor: bigint;
founderRefundMinor: bigint;
}

Дословно по шагу 5 §4:

  1. teamGross = floor(hold × shareBps / 10000) — доля команды округляется вниз;
  2. founderRefund = hold − teamGross — разница фаундеру;
  3. platformFee = ceil(teamGross × commissionRateBps / 10000) — комиссия считается от доли команды (X-3), остаток минорной единицы всегда платформе;
  4. teamAmount = teamGross − platformFee.

Два остатка живут в разных местах и не конфликтуют: остаток доли уходит фаундеру (шаг вниз на teamGross), остаток комиссии — платформе (шаг вверх на platformFee).

На bigint округление выражается делением без плавающей точки: floor — это само деление (операнды неотрицательны, поэтому усечение к нулю совпадает с полом), ceil(a * b + (d − 1n)) / d. Ставка приходит как number, а смешивать bigint и number в одном выражении JS запрещает (5n * 3 бросает TypeError), поэтому bps приводится BigInt(bps) после проверки на целочисленность из раздела «Валидация входа» — к моменту приведения дробных значений уже быть не может.

Формула проверена на 490 комбинациях сумм, ставок и долей (включая нечётные остатки, shareBps = 3333 и границы 0/10000): сумма трёх компонент равна холду во всех, доля команды нигде не уходит в минус.

Инвариант «сумма компонент строго равна холду» — CHECK на уровне БД (шаг 2 §7, решение №13). Если бы либа отдавала слагаемые по отдельности, складывал бы их вызывающий, и нарушить инвариант можно было бы опечаткой в B3.1. Одна функция, возвращающая тройку целиком, делает нарушение невозможным на стороне вызывающего.

Полная выплата — частный случай сплита при shareBps = 10000: отдельной функции для неё нет.

calculateCommissionFee экспортируется отдельно не как способ собрать сплит по частям, а потому что это формула, которую цитирует договор и показывает интерфейс расчёта администратору (шаг 4 §6: администратор видит системный расчёт и его основание). Собирать из неё сплит вручную не нужно — splitSettlement вызывает её сама.

Внутри splitSettlement — финальная сверка team + fee + refund === hold с Error при расхождении. Это страховка не от сегодняшней формулы (она тождественна по построению), а от будущей правки: тест поймает раньше, но если правку внесут мимо тестов, продовый расчёт остановится, а не разъедется тихо.

RangeError на: отрицательные holdAmountMinor или amountMinor; commissionRateBps и shareBps вне [0, 10000]; дробный или NaN bps. Ставки приходят снапшотом из БД (commissionRateBps на проекте, решение №05), но либа — последний рубеж перед деньгами и не доверяет вызывающему.

Валюта в либу не входит: считаем в минорных единицах, бета USD-only, форматирования на бэкенде нет. Конвертация bigint → number для Stripe API появится в B2.1 вместе с первым потребителем.

Допущение (фиксируется здесь, потому что документы его не проговаривают явно). teamShareBps (сплит по решению арбитра, шаг 3 D-переходы) и completedCriteriaBps (сплит при расторжении с оценкой) — два источника одной и той же доли, а не сомножители. Поэтому параметр один — shareBps; какой источник его дал, знает вызывающий (A3 / B3.1). Если в A3 выяснится, что доли перемножаются, правится вызывающий, а не либа.

Только константы и типы. Публикатора здесь нет: транзакционная запись — F2 (outbox), запуск relay — C1.

DOMAIN_EVENT_ROUTING_KEYas const-объект на все 12 ключей из шага 7 §1, плюс выводимый из него union DomainEventRoutingKey.

Четыре union’а повторяют enum’ы шага 2 значение в значение (WebhookProvider — из §7, остальные — из §9):

type ActorType = 'USER' | 'SYSTEM' | 'TIMER';
type SubjectType =
| 'PROJECT'
| 'MILESTONE'
| 'HOLD'
| 'CONTRACT'
| 'DISPUTE'
| 'TERMINATION'
| 'OFFER'
| 'TEAM'
| 'PAYOUT_ACCOUNT'
| 'PLAN_VERSION'
| 'CONCIERGE_THREAD';
type TimerKind =
| 'FOUNDER_SILENCE'
| 'DISPUTE_POSITION_WINDOW'
| 'TERMINATION_OBJECTION_WINDOW'
| 'ENVELOPE_EXPIRY'
| 'MILESTONE_DEADLINE'
| 'SLA_TARGET'
| 'REMINDER';
type WebhookProvider = 'STRIPE' | 'SIGNING' | 'TRACKER';

Роль актора — не ActorType: шаг 2 §9 хранит её отдельным полем actorRole String? @db.VarChar(32), потому что USER может быть и фаундером, и оператором. В payload она едет так же — отдельным необязательным полем.

F1 идёт раньше схемы (F4), поэтому union’ы объявляются здесь и становятся контрактом шины. В карточку F4 уже внесено требование: Prisma-enum’ы ActorType, SubjectType, TimerKind и WebhookProvider обязаны совпадать с этими union’ами значение в значение.

Ревизия проектных документов, сделанная этой единицей (подтверждена Stark’ом, зафиксирована в step-2 §9): TimerKind расширен ENVELOPE_EXPIRY и MILESTONE_DEADLINE — шаг 7 §3 описывает семь таймеров, шаг 2 объявлял пять; SubjectType расширен CONCIERGE_THREAD — консьерж-тред введён шагом 7 §5 и публикует concierge.escalated, а OutboxEvent.aggregateType типизирован SubjectType.

interface StatusChangedPayload {
subjectType: SubjectType;
subjectId: string;
projectId?: string;
transition: string; // имя перехода: 'M06', 'H02', ...
from: string | null; // null — создание сущности (schema: fromState String?)
to: string;
actorType: ActorType;
actorRole?: string;
transitionId: string; // ссылка на запись StateTransition
occurredAt: string; // ISO-8601 UTC
}
interface TimerFiredPayload {
timerId: string;
timerKind: TimerKind;
subjectType: SubjectType;
subjectId: string;
projectId?: string;
firedAt: string;
}
interface ExternalDisputeWebhookPayload {
subjectType: 'HOLD'; // литерал, а не весь SubjectType: субъект известен
subjectId: string; // id холда, по которому пришёл chargeback
projectId?: string;
provider: WebhookProvider;
externalEventId: string; // ключ дедупа WebhookEvent
externalDisputeId: string; // id спора на стороне провайдера
occurredAt: string;
}
interface ConciergeEscalatedPayload {
subjectType: 'CONCIERGE_THREAD';
subjectId: string; // id треда
projectId?: string;
escalatedByUserId: string;
occurredAt: string;
}

DomainEventPayloadMap — карта «routing key → payload»: девять *.status.changed получают StatusChangedPayload, три остальных — свои. DomainEvent<K> — конверт { routingKey: K; payload: DomainEventPayloadMap[K] }.

Карта, а не один общий тип, нужна ради непереходных событий: у timer.fired нет from/to, зато есть вид сработавшего таймера, без которого C2 пришлось бы ходить в БД за тем, что могло приехать в сообщении.

Отступление от «payload-минимума» шага 7 §1 — одно поле: subjectType. Оно нужно уже на записи в outbox (OutboxEvent.aggregateType типизирован SubjectType) и на диспетчеризации в консьюмерах. Сумм в payload по-прежнему нет — ровно этого и требует Q-3.

Критерий приёмки требует, чтобы отсутствие денежных полей в payload обеспечивали типы, а не дисциплина (Q-3 распространён на шину, шаг 7 §1).

type PayloadScalar = string | number | boolean | null | undefined;
type ForbiddenMoneyKey =
| `${string}Minor`
| `${string}Amount`
| 'amount'
| `${string}Fee`
| 'fee'
| `${string}Price`
| 'price'
| 'currency';
type MoneyFree<T> = string extends keyof T
? never // индекс-сигнатура: любой ключ пролезет
: T extends object
? Extract<keyof T, ForbiddenMoneyKey> extends never
? T[keyof T] extends PayloadScalar
? T
: never // вложенный объект: проверить нельзя
: never
: never;

Три рубежа: имена полей, тип значения (суммы у нас bigint, а bigint не входит в PayloadScalar) и запрет вложенных объектов, за которыми проверка не видит ничего. Индекс-сигнатуры отсекаются первым условием. T extends object дистрибутивен, поэтому union-payload проверяется по каждому члену отдельно, а денежный член отсеивается.

Гейт живёт в исходниках либы, а не в тестах:

type Expect<T extends true> = T;
type Equals<A, B> = (<G>() => G extends A ? 1 : 2) extends <G>() => G extends B ? 1 : 2 ? true : false;
type MoneyFreeMap<T> = { [K in keyof T]: MoneyFree<T[K]> };
type _CatalogIsMoneyFree = Expect<Equals<DomainEventPayloadMap, MoneyFreeMap<DomainEventPayloadMap>>>;

Денежное поле делает запись карты отличной от исходной — обычно never, а для union-payload’а уцелевшим не-денежным членом, — Equals даёт false, и Expect<false> роняет компиляцию с TS2344. Срабатывает именно неравенство, а не never сам по себе. Наивная форма — объявить запись карты как MoneyFree<BadPayload> — не работает: такое объявление компилируется молча, запись просто становится never.

PayloadScalar попутно запрещает массивы и Date. Для сегодняшних четырёх payload’ов это верно — контракт шины плоский и JSON-сериализуемый, а Date в JSON всё равно станет строкой. Когда потребителю понадобится список id, расширять нужно белый список (readonly string[] в PayloadScalar), а не условие с ForbiddenMoneyKey: ослабление денежной проверки ради массива строк — молчаливая потеря Q-3 на шине.

Проверено компилятором (tsc --strict) на восьми формах нарушения — amountMinor: bigint, amountMinor?: bigint, total: bigint, currency: string, holdAmount: number, teamAmountMinor: number, union с денежным членом, вложенный объект с денежным полем — плюс на индекс-сигнатуре: каталог с любой из них не компилируется, а честные payload’ы проходят без правок.

interface WorkingCalendar {
timeZone: 'UTC';
workDays: number[]; // 1..5, нумерация Date#getUTCDay
workStartHour: number; // 10
workEndHour: number; // 18
}
const PLATFORM_CALENDAR: WorkingCalendar;
addWorkingHours(from: Date, hours: number, calendar?: WorkingCalendar): Date;
addWorkingDays(from: Date, days: number, calendar?: WorkingCalendar): Date;
addCalendarDays(from: Date, days: number): Date;
isWorkingTime(at: Date, calendar?: WorkingCalendar): boolean;

Календарь — последний необязательный аргумент с дефолтом PLATFORM_CALENDAR. Тесты граничных случаев (другое окно, шестидневка) пишутся подстановкой своего календаря — без env, без DI, без конфиг-модуля. Шаг 7 §3 называет календарь «конфигом» в смысле «параметры, а не логика»; заводить ради четырёх значений, не меняющихся на бете, полный обвяз env-переменной (coding-rules §7: Joi + геттер + jest.setup-env + две страницы документации + docker-compose) не оправдано. Изменение рабочих часов — осознанный PR.

На голом Date через UTC-геттеры, без dayjs. При timeZone: 'UTC' библиотека таймзон не даёт ничего, а появление реальной таймзоны после беты меняется внутри либы и не трогает сигнатуры.

Семантика, включая границы окна:

  • Рабочее окно — полуинтервал [10:00, 18:00). isWorkingTime в 10:00 — true, в 18:00 — false.
  • addWorkingHours — если from вне рабочего окна, отсчёт начинается с открытия следующего рабочего окна; далее часы расходуются по окнам, перешагивая ночи и выходные. Если часы исчерпываются ровно на закрытии окна, результат — момент закрытия, а не открытие следующего: addWorkingHours(пт 17:00, 1) = пятница 18:00.
  • addWorkingDays — сохраняет время суток; если from попал на выходной, сначала нормализуется к следующему рабочему дню, затем прибавляются рабочие дни.
  • addCalendarDays — прямое сложение, без календаря (шаг 7 §3: «календарные величины — прямое сложение»).

Проверка на критерии приёмки: пятница 17:00 UTC + 4 рабочих часа — час до 18:00 в пятницу, остаток три часа в понедельник → понедельник 13:00.

Праздников нет осознанно (шаг 7 §3, помеченное упрощение беты). Поля под них в WorkingCalendar на бете не заводятся: тип расширится, когда появится потребитель.

Файлы *.spec.ts рядом с тестируемыми: money.helper.spec.ts, platform-calendar.helper.spec.ts, domain-event.type.spec.ts (в последнем — типовые утверждения, значений он не исполняет). Порядок — TDD: падающий тест → минимальная реализация → зелёный → коммит.

Покрытие ровно по критериям приёмки карточки F1:

Критерий приёмкиЧем проверяется
Сумма компонент сплита всегда строго равна холдуПараметризованный набор сумм: чётные, нечётные минорные остатки, shareBps = 3333, границы 0 и 10000
Комиссия никогда не меньше расчётной (ceil, не round)Суммы, где round дал бы вниз (hold = 100, rate = 1 → комиссия 1, не 0)
«4 рабочих часа» от пятницы 17:00 UTC дают понедельникТочное равенство понедельнику 13:00; отдельно граница +1 час → пятница 18:00
«5 рабочих дней» не проскакивает выходныеСтарт в разные дни недели, включая пятницу и субботу
В payload доменного события нет денежных полей — запрещено структурноТиповые утверждения Expect<...> на восьми формах нарушения плюс индекс-сигнатура; контрольные утверждения, что честные payload’ы не отвергаются

Тест структурного запрета — типовой, а не рантаймовый: он «падает» отказом компиляции tsconfig.spec.json. Форма @ts-expect-error над объявлением типа для этого не годится — tsc выдаёт TS2578: Unused '@ts-expect-error' directive и тест краснеет всегда, независимо от того, ослаблен запрет или нет. Чтобы Jest не проходил мимо, файл содержит один рантайм-it, утверждающий состав DOMAIN_EVENT_ROUTING_KEY, — тогда падение компиляции гарантированно валит и nx test.

Тесты именуются по coding-rules §1.12: describe — имя модуля функций, вложенный describe — имя функции, it — утверждение на английском в третьем лице.

По documentation-rules: README.md каждой либы и страница docs/04-shared-and-utils/ с описанием группы core. Новых переменных окружения нет, схема БД не меняется — docs/05-data/ и docs/06-operations/environment.md не затрагиваются. CLAUDE.md дополняется строкой про libs/apis/core/ в разделе структуры репозитория: появилась группа верхнего уровня.