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) импортируют функции напрямую.
1. Размещение и обвяз
Заголовок раздела «1. Размещение и обвяз»Новая группа libs/apis/core/ — три независимых Nx-проекта.
| Путь | Nx-имя | Алиас | Теги |
|---|---|---|---|
libs/apis/core/money | api-core-money | @crewsforge-back/apis/core/money | scope:shared, type:util |
libs/apis/core/domain-events | api-core-domain-events | @crewsforge-back/apis/core/domain-events | scope:shared, type:util |
libs/apis/core/platform-calendar | api-core-platform-calendar | @crewsforge-back/apis/core/platform-calendar | scope: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.
2. money
Заголовок раздела «2. money»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:
teamGross = floor(hold × shareBps / 10000)— доля команды округляется вниз;founderRefund = hold − teamGross— разница фаундеру;platformFee = ceil(teamGross × commissionRateBps / 10000)— комиссия считается от доли команды (X-3), остаток минорной единицы всегда платформе;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 выяснится,
что доли перемножаются, правится вызывающий, а не либа.
3. domain-events
Заголовок раздела «3. domain-events»Только константы и типы. Публикатора здесь нет: транзакционная запись — F2 (outbox), запуск
relay — C1.
Каталог и enum’ы
Заголовок раздела «Каталог и enum’ы»DOMAIN_EVENT_ROUTING_KEY — as 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.
Payload’ы
Заголовок раздела «Payload’ы»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’ы проходят без правок.
4. platform-calendar
Заголовок раздела «4. platform-calendar»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
на бете не заводятся: тип расширится, когда появится потребитель.
5. Тесты
Заголовок раздела «5. Тесты»Файлы *.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 — утверждение на английском в третьем лице.
6. Документация по итогам
Заголовок раздела «6. Документация по итогам»По documentation-rules: README.md каждой либы и страница
docs/04-shared-and-utils/ с описанием группы core. Новых переменных окружения нет, схема БД не
меняется — docs/05-data/ и docs/06-operations/environment.md не затрагиваются. CLAUDE.md
дополняется строкой про libs/apis/core/ в разделе структуры репозитория: появилась группа
верхнего уровня.