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

F1 — Shared-либы ядра: план реализации

For agentic workers: REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (- [ ]) syntax for tracking.

Дизайн и обоснование решений: 2026-08-21-f1-shared-core-libs-design.md. Карточка единицы: F1 в ROADMAP. Правила кода: coding-rules.md.

Goal: три shared-либы без зависимостей — money (денежная арифметика), domain-events (типизированный каталог шины), platform-calendar (расчёт рабочих сроков), — на которых стоят единицы F2, F3.1, C2, B3.1.

Architecture: новая группа Nx-проектов libs/apis/core/. Каждая либа — чистые функции и типы, ноль рантайм-зависимостей, в том числе от NestJS: ни модулей, ни провайдеров, ни DI. Публичный API — только src/index.ts. Деньги считаются в bigint минорных единицах; каталог событий структурно запрещает денежные поля в payload; календарь параметризуется объектом WorkingCalendar с дефолтом-константой вместо env.

Tech Stack: TypeScript 5.9 (strict), Nx 22, Jest 30 + ts-jest, Prettier 2.6 (singleQuote, printWidth: 110). NestJS, Prisma и dayjs здесь не используются.


  • Язык. Код, идентификаторы, тексты исключений — английские. Комментарии — русские, редкие, про «почему». Тесты: describe — имя модуля/функции, it — фраза на английском в третьем лице («returns», «rejects»), не «should».
  • Глубина путей. Все либы лежат на libs/apis/core/<name> — это четыре уровня от корня, поэтому во всех конфигах относительный путь до корня ../../../../. Ошибка здесь ломает nx test невнятным сообщением про preset.
  • Проверка после каждой задачи — точные команды указаны в шагах. Заявлять «работает» без просмотра вывода нельзя (coding-rules §11).
  • Форматирование перед коммитом: npx prettier --write <изменённые файлы>.
  • Ничего лишнего. Не добавлять функции, которых нет в плане: конвертация в валюту, форматирование сумм, работа с праздниками, публикатор событий — всё это чужие единицы (B2.1, F2, C1).

libs/apis/core/money/
project.json tsconfig.json tsconfig.lib.json tsconfig.spec.json
jest.config.ts eslint.config.mjs README.md
src/index.ts
src/lib/money.constants.ts BPS_DENOMINATOR, MAX_BPS
src/lib/money.type.ts SettlementSplitInput, SettlementSplit
src/lib/money.helper.ts calculateCommissionFee, splitSettlement + внутренняя валидация
src/lib/money.helper.spec.ts
libs/apis/core/domain-events/
<тот же обвяз>
src/lib/domain-event.constants.ts DOMAIN_EVENT_ROUTING_KEY
src/lib/domain-event.type.ts union'ы, payload'ы, карта, MoneyFree, компиляционные гейты
src/lib/domain-event.type.spec.ts
libs/apis/core/platform-calendar/
<тот же обвяз>
src/lib/platform-calendar.constants.ts PLATFORM_CALENDAR
src/lib/platform-calendar.type.ts WorkingCalendar
src/lib/platform-calendar.helper.ts isWorkingTime, addWorkingHours, addWorkingDays, addCalendarDays
src/lib/platform-calendar.helper.spec.ts
tsconfig.base.json три алиаса
CLAUDE.md строка про libs/apis/core/ в структуре репозитория
docs/04-shared-and-utils/core-libs.md новая страница
docs/README.md ссылка на новую страницу
docs/01-overview/architecture.md группа core/ в дереве libs/apis/
docs/rules/coding-rules.md §1.2: строка в таблицу схем имён

Разделение файлов внутри либы — по роли, как требует coding-rules §1.1: константы, типы, функции, тест. Функций мало, поэтому один *.helper.ts на либу; дробить дальше нечего.


Files:

  • Create: libs/apis/core/money/project.json

  • Create: libs/apis/core/money/tsconfig.json

  • Create: libs/apis/core/money/tsconfig.lib.json

  • Create: libs/apis/core/money/tsconfig.spec.json

  • Create: libs/apis/core/money/jest.config.ts

  • Create: libs/apis/core/money/eslint.config.mjs

  • Create: libs/apis/core/money/src/index.ts

  • Create: libs/apis/core/money/src/lib/money.constants.ts

  • Modify: tsconfig.base.json (добавить алиас в конец объекта paths)

  • Step 1: Создать конфиги проекта

libs/apis/core/money/project.json:

{
"name": "api-core-money",
"$schema": "../../../../node_modules/nx/schemas/project-schema.json",
"sourceRoot": "libs/apis/core/money/src",
"projectType": "library",
"tags": ["scope:shared", "type:util"],
"// targets": "to see all targets run: nx show project api-core-money --web",
"targets": {}
}

libs/apis/core/money/tsconfig.json:

{
"extends": "../../../../tsconfig.base.json",
"compilerOptions": {
"module": "commonjs",
"forceConsistentCasingInFileNames": true,
"strict": true,
"importHelpers": true,
"noImplicitOverride": true,
"noImplicitReturns": true,
"noFallthroughCasesInSwitch": true,
"noPropertyAccessFromIndexSignature": true
},
"files": [],
"include": [],
"references": [{ "path": "./tsconfig.lib.json" }, { "path": "./tsconfig.spec.json" }]
}

libs/apis/core/money/tsconfig.lib.json:

{
"extends": "./tsconfig.json",
"compilerOptions": {
"outDir": "../../../../dist/out-tsc",
"declaration": true,
"types": ["node"],
"target": "es2021",
"strictNullChecks": true,
"noImplicitAny": true,
"strictBindCallApply": true,
"forceConsistentCasingInFileNames": true,
"noFallthroughCasesInSwitch": true,
"esModuleInterop": true,
"allowSyntheticDefaultImports": true
},
"include": ["src/**/*.ts"],
"exclude": ["jest.config.ts", "src/**/*.spec.ts", "src/**/*.test.ts"]
}

target: es2021 обязателен: литералы 10_000n требуют ES2020+.

libs/apis/core/money/tsconfig.spec.json:

{
"extends": "./tsconfig.json",
"compilerOptions": {
"outDir": "../../../../dist/out-tsc",
"module": "commonjs",
"moduleResolution": "node10",
"target": "es2021",
"types": ["jest", "node"]
},
"include": ["jest.config.ts", "src/**/*.test.ts", "src/**/*.spec.ts", "src/**/*.d.ts"]
}

libs/apis/core/money/jest.config.ts:

export default {
displayName: 'api-core-money',
preset: '../../../../jest.preset.js',
testEnvironment: 'node',
transform: {
'^.+\\.[tj]s$': ['ts-jest', { tsconfig: '<rootDir>/tsconfig.spec.json' }],
},
moduleFileExtensions: ['ts', 'js', 'html'],
coverageDirectory: '../../../../coverage/libs/apis/core/money',
};

libs/apis/core/money/eslint.config.mjs:

import baseConfig from '../../../../eslint.config.mjs';
export default [...baseConfig];
  • Step 2: Создать константы и barrel

libs/apis/core/money/src/lib/money.constants.ts:

/** Знаменатель базисных пунктов: 10 000 bps = 100%. */
export const BPS_DENOMINATOR = 10_000n;
/** Максимальная ставка в базисных пунктах. */
export const MAX_BPS = 10_000;

libs/apis/core/money/src/index.ts:

export * from './lib/money.constants';
  • Step 3: Зарегистрировать алиас

В tsconfig.base.json, в объекте compilerOptions.paths, после последней записи (@crewsforge-back/apis/providers/admin-api/data-access) добавить — не забыв запятую после предыдущей записи:

"@crewsforge-back/apis/core/money": ["libs/apis/core/money/src/index.ts"]
  • Step 4: Проверить, что Nx видит проект и линт зелёный
Окно терминала
npx nx show project api-core-money --json | head -5
npx nx lint api-core-money

Ожидается: команда show project печатает JSON с "name": "api-core-money"; линт — Successfully ran target lint. Тесты на этом шаге не запускаются: тестовых файлов ещё нет, Jest упал бы.

  • Step 5: Коммит
Окно терминала
npx prettier --write libs/apis/core/money tsconfig.base.json
git add libs/apis/core/money tsconfig.base.json
git commit -m "feat(core): scaffold the api-core-money library"

Files:

  • Create: libs/apis/core/money/src/lib/money.helper.ts

  • Create: libs/apis/core/money/src/lib/money.helper.spec.ts

  • Modify: libs/apis/core/money/src/index.ts

  • Step 1: Написать падающий тест

libs/apis/core/money/src/lib/money.helper.spec.ts:

import { calculateCommissionFee } from './money.helper';
describe('money.helper', () => {
describe('calculateCommissionFee', () => {
it('rounds the fee up so the platform never receives less than the calculated share', () => {
// 100 × 0.01% = 0.01 минорной единицы: round дал бы 0, ceil даёт 1
expect(calculateCommissionFee(100n, 1)).toBe(1n);
});
it('returns an exact fee when the rate divides the amount without a remainder', () => {
expect(calculateCommissionFee(1_000_000n, 1500)).toBe(150_000n);
});
it('returns zero for a zero rate', () => {
expect(calculateCommissionFee(500_000n, 0)).toBe(0n);
});
it('returns the whole amount for a rate of 100%', () => {
expect(calculateCommissionFee(500_000n, 10_000)).toBe(500_000n);
});
it('rejects a negative amount', () => {
expect(() => calculateCommissionFee(-1n, 1500)).toThrow(RangeError);
});
it('rejects a rate above 100%', () => {
expect(() => calculateCommissionFee(1_000n, 10_001)).toThrow(RangeError);
});
it('rejects a fractional rate', () => {
expect(() => calculateCommissionFee(1_000n, 15.5)).toThrow(RangeError);
});
it('rejects NaN as a rate', () => {
expect(() => calculateCommissionFee(1_000n, Number.NaN)).toThrow(RangeError);
});
});
});
  • Step 2: Запустить тест и убедиться, что он падает
Окно терминала
npx nx test api-core-money

Ожидается: FAIL — Cannot find module './money.helper'.

  • Step 3: Минимальная реализация

libs/apis/core/money/src/lib/money.helper.ts:

import { BPS_DENOMINATOR, MAX_BPS } from './money.constants';
const assertNonNegativeAmount = (amountMinor: bigint, field: string): void => {
if (amountMinor < 0n) {
throw new RangeError(`${field} must not be negative`);
}
};
const assertBps = (bps: number, field: string): void => {
if (!Number.isInteger(bps) || bps < 0 || bps > MAX_BPS) {
throw new RangeError(`${field} must be an integer within [0, ${MAX_BPS}]`);
}
};
// Округление вверх на bigint: (a × bps + 9999) / 10000. Операнды неотрицательны,
// поэтому усечение деления к нулю совпадает с полом, и формула даёт ровно ceil.
const multiplyByBpsCeil = (amountMinor: bigint, bps: number): bigint =>
(amountMinor * BigInt(bps) + BPS_DENOMINATOR - 1n) / BPS_DENOMINATOR;
export const calculateCommissionFee = (amountMinor: bigint, commissionRateBps: number): bigint => {
assertNonNegativeAmount(amountMinor, 'amountMinor');
assertBps(commissionRateBps, 'commissionRateBps');
return multiplyByBpsCeil(amountMinor, commissionRateBps);
};

BigInt(bps) вызывается только после assertBps: смешивать bigint и number в одном выражении JS запрещает, а дробное значение к этому моменту уже отсеяно.

Дополнить libs/apis/core/money/src/index.ts:

export * from './lib/money.constants';
export * from './lib/money.helper';
  • Step 4: Запустить тест и убедиться, что он зелёный
Окно терминала
npx nx test api-core-money

Ожидается: PASS, 8 тестов.

  • Step 5: Коммит
Окно терминала
npx prettier --write libs/apis/core/money
git add libs/apis/core/money
git commit -m "feat(core): add the commission fee calculation"

Files:

  • Create: libs/apis/core/money/src/lib/money.type.ts

  • Modify: libs/apis/core/money/src/lib/money.helper.ts

  • Modify: libs/apis/core/money/src/lib/money.helper.spec.ts

  • Modify: libs/apis/core/money/src/index.ts

  • Step 1: Написать падающий тест

Добавить в money.helper.spec.ts внутрь describe('money.helper', ...) — импорт дополнить splitSettlement:

describe('splitSettlement', () => {
it('gives the whole hold to the team minus the fee when no share is passed', () => {
expect(splitSettlement({ holdAmountMinor: 1_000_000n, commissionRateBps: 1500 })).toEqual({
teamAmountMinor: 850_000n,
platformFeeMinor: 150_000n,
founderRefundMinor: 0n,
});
});
it('rounds the team share down and refunds the difference to the founder', () => {
// 101 × 33.33% = 33.66 → 33 команде брутто, 68 фаундеру; комиссия 15% от 33 = 4.95 → 5
expect(splitSettlement({ holdAmountMinor: 101n, commissionRateBps: 1500, shareBps: 3333 })).toEqual({
teamAmountMinor: 28n,
platformFeeMinor: 5n,
founderRefundMinor: 68n,
});
});
it('takes the commission from the team share, not from the hold', () => {
const { platformFeeMinor } = splitSettlement({
holdAmountMinor: 1_000_000n,
commissionRateBps: 1500,
shareBps: 5000,
});
expect(platformFeeMinor).toBe(75_000n); // 15% от 500 000, а не от 1 000 000
});
it('refunds the whole hold to the founder for a zero share', () => {
expect(splitSettlement({ holdAmountMinor: 999n, commissionRateBps: 1500, shareBps: 0 })).toEqual({
teamAmountMinor: 0n,
platformFeeMinor: 0n,
founderRefundMinor: 999n,
});
});
it('rejects a negative hold amount', () => {
expect(() => splitSettlement({ holdAmountMinor: -1n, commissionRateBps: 1500 })).toThrow(RangeError);
});
it('rejects a share above 100%', () => {
expect(() =>
splitSettlement({ holdAmountMinor: 1_000n, commissionRateBps: 1500, shareBps: 10_001 })
).toThrow(RangeError);
});
});
  • Step 2: Запустить тест и убедиться, что он падает
Окно терминала
npx nx test api-core-money

Ожидается: FAIL — splitSettlement is not a function (или ошибка компиляции ts-jest про отсутствующий экспорт).

  • Step 3: Минимальная реализация

libs/apis/core/money/src/lib/money.type.ts:

export interface SettlementSplitInput {
/** Сумма холда в минорных единицах. */
holdAmountMinor: bigint;
/** Снапшот ставки комиссии на проекте, базисные пункты. */
commissionRateBps: number;
/** Доля команды в базисных пунктах: решение арбитра или оценка выполненных критериев. */
shareBps?: number;
}
export interface SettlementSplit {
teamAmountMinor: bigint;
platformFeeMinor: bigint;
founderRefundMinor: bigint;
}

Дописать в money.helper.ts (импорт типов — сверху файла):

import { SettlementSplit, SettlementSplitInput } from './money.type';
// Округление вниз: усечение bigint-деления при неотрицательных операндах и есть пол.
const multiplyByBpsFloor = (amountMinor: bigint, bps: number): bigint =>
(amountMinor * BigInt(bps)) / BPS_DENOMINATOR;
export const splitSettlement = ({
holdAmountMinor,
commissionRateBps,
shareBps = MAX_BPS,
}: SettlementSplitInput): SettlementSplit => {
assertNonNegativeAmount(holdAmountMinor, 'holdAmountMinor');
assertBps(commissionRateBps, 'commissionRateBps');
assertBps(shareBps, 'shareBps');
const teamGrossMinor = multiplyByBpsFloor(holdAmountMinor, shareBps);
const founderRefundMinor = holdAmountMinor - teamGrossMinor;
const platformFeeMinor = multiplyByBpsCeil(teamGrossMinor, commissionRateBps);
const teamAmountMinor = teamGrossMinor - platformFeeMinor;
// Страховка не от текущей формулы (она тождественна по построению), а от будущей правки:
// расчёт обязан остановиться, а не разъехаться с CHECK-констрейнтом на Settlement.
if (teamAmountMinor + platformFeeMinor + founderRefundMinor !== holdAmountMinor) {
throw new Error('settlement split does not add up to the hold amount');
}
return { teamAmountMinor, platformFeeMinor, founderRefundMinor };
};

Дополнить src/index.ts:

export * from './lib/money.constants';
export * from './lib/money.helper';
export * from './lib/money.type';
  • Step 4: Запустить тест и убедиться, что он зелёный
Окно терминала
npx nx test api-core-money

Ожидается: PASS, 14 тестов.

  • Step 5: Коммит
Окно терминала
npx prettier --write libs/apis/core/money
git add libs/apis/core/money
git commit -m "feat(core): add the settlement split calculation"

Критерий приёмки карточки F1 требует не примера, а свойства: сумма компонент равна холду всегда. Проверяется перебором, а не отдельными случаями.

Files:

  • Modify: libs/apis/core/money/src/lib/money.helper.spec.ts

  • Step 1: Написать падающий тест

Добавить в money.helper.spec.ts новый блок внутри describe('money.helper', ...):

describe('splitSettlement invariants', () => {
const HOLDS = [0n, 1n, 3n, 7n, 99n, 101n, 12_345n, 500_000n, 999_999n, 1_000_001n];
const RATES = [0, 1, 250, 1500, 3333, 9999, 10_000];
const SHARES = [0, 1, 3333, 5000, 6667, 9999, 10_000];
const cases = HOLDS.flatMap((holdAmountMinor) =>
RATES.flatMap((commissionRateBps) =>
SHARES.map((shareBps) => ({ holdAmountMinor, commissionRateBps, shareBps }))
)
);
it.each(cases)(
'keeps the components adding up to the hold for case %#',
({ holdAmountMinor, commissionRateBps, shareBps }) => {
const split = splitSettlement({ holdAmountMinor, commissionRateBps, shareBps });
expect(split.teamAmountMinor + split.platformFeeMinor + split.founderRefundMinor).toBe(holdAmountMinor);
}
);
it.each(cases)('never produces a negative component for case %#', (input) => {
const split = splitSettlement(input);
expect(split.teamAmountMinor).toBeGreaterThanOrEqual(0n);
expect(split.platformFeeMinor).toBeGreaterThanOrEqual(0n);
expect(split.founderRefundMinor).toBeGreaterThanOrEqual(0n);
});
});
  • Step 2: Запустить и убедиться, что тесты зелёные
Окно терминала
npx nx test api-core-money

Ожидается: PASS, 994 теста (14 + 2 × 490 комбинаций). Этот тест не должен падать — реализация из Task 3 уже корректна; смысл шага в том, чтобы свойство было закреплено тестом, а не примером. Если хотя бы один случай красный — останови работу и разбирайся: это ошибка в формуле, а не в тесте.

Шаблон имени — %# (порядковый номер случая), а не %j: %j сериализует случай через JSON.stringify, а тот бросает TypeError: Do not know how to serialize a BigInt. С %j оба блока схлопываются в два упавших теста, и предыдущий абзац отправил бы чинить исправную формулу.

  • Step 3: Коммит
Окно терминала
npx prettier --write libs/apis/core/money
git add libs/apis/core/money
git commit -m "test(core): sweep the settlement split invariant across amounts and rates"

Files:

  • Create: libs/apis/core/domain-events/{project.json,tsconfig.json,tsconfig.lib.json,tsconfig.spec.json,jest.config.ts,eslint.config.mjs}

  • Create: libs/apis/core/domain-events/src/index.ts

  • Create: libs/apis/core/domain-events/src/lib/domain-event.constants.ts

  • Modify: tsconfig.base.json

  • Step 1: Скопировать обвяз из Task 1 с заменой имени

Все шесть конфигов идентичны файлам libs/apis/core/money/, кроме четырёх мест: project.json → name и sourceRoot, jest.config.ts → displayName и coverageDirectory. Имя проекта встречается ещё и в строке-комментарии "// targets" — заменить и там. Значения: api-core-domain-events, libs/apis/core/domain-events/src, ../../../../coverage/libs/apis/core/domain-events. Теги те же: ["scope:shared", "type:util"].

  • Step 2: Создать каталог routing keys

libs/apis/core/domain-events/src/lib/domain-event.constants.ts:

/**
* Каталог routing keys шины (шаг 7 §1). Публикует события только исполнитель машин
* через outbox — руками ключи нигде не собираются.
*/
export const DOMAIN_EVENT_ROUTING_KEY = {
PROJECT_STATUS_CHANGED: 'project.status.changed',
MILESTONE_STATUS_CHANGED: 'milestone.status.changed',
HOLD_STATUS_CHANGED: 'hold.status.changed',
CONTRACT_STATUS_CHANGED: 'contract.status.changed',
DISPUTE_STATUS_CHANGED: 'dispute.status.changed',
TERMINATION_STATUS_CHANGED: 'termination.status.changed',
OFFER_STATUS_CHANGED: 'offer.status.changed',
PLAN_VERSION_STATUS_CHANGED: 'plan-version.status.changed',
PAYOUT_ACCOUNT_STATUS_CHANGED: 'payout-account.status.changed',
TIMER_FIRED: 'timer.fired',
WEBHOOK_EXTERNAL_DISPUTE: 'webhook.external-dispute',
CONCIERGE_ESCALATED: 'concierge.escalated',
} as const;
export type DomainEventRoutingKey = (typeof DOMAIN_EVENT_ROUTING_KEY)[keyof typeof DOMAIN_EVENT_ROUTING_KEY];

libs/apis/core/domain-events/src/index.ts:

export * from './lib/domain-event.constants';
  • Step 3: Зарегистрировать алиас

В tsconfig.base.json рядом с алиасом money:

"@crewsforge-back/apis/core/domain-events": ["libs/apis/core/domain-events/src/index.ts"]
  • Step 4: Проверить
Окно терминала
npx nx show project api-core-domain-events --json | head -5
npx nx lint api-core-domain-events

Ожидается: JSON с нужным именем; Successfully ran target lint.

  • Step 5: Коммит
Окно терминала
npx prettier --write libs/apis/core/domain-events tsconfig.base.json
git add libs/apis/core/domain-events tsconfig.base.json
git commit -m "feat(core): scaffold the api-core-domain-events library"

Task 6: Типы каталога и структурный запрет денег

Заголовок раздела «Task 6: Типы каталога и структурный запрет денег»

Самая тонкая часть единицы. Гейт живёт в исходниках либы, а не в тесте: денежное поле в payload обязано валить компиляцию.

Files:

  • Create: libs/apis/core/domain-events/src/lib/domain-event.type.ts

  • Modify: libs/apis/core/domain-events/src/index.ts

  • Step 1: Написать типы

libs/apis/core/domain-events/src/lib/domain-event.type.ts:

import { DomainEventRoutingKey } from './domain-event.constants';
/**
* Значения enum'ов повторяют схему шага 2 (§9, а WebhookProvider — §7) значение в значение.
* Prisma-enum'ы появятся в F4 и обязаны совпасть с этими union'ами.
*/
export type ActorType = 'USER' | 'SYSTEM' | 'TIMER';
export type SubjectType =
| 'PROJECT'
| 'MILESTONE'
| 'HOLD'
| 'CONTRACT'
| 'DISPUTE'
| 'TERMINATION'
| 'OFFER'
| 'TEAM'
| 'PAYOUT_ACCOUNT'
| 'PLAN_VERSION'
| 'CONCIERGE_THREAD';
export type TimerKind =
| 'FOUNDER_SILENCE'
| 'DISPUTE_POSITION_WINDOW'
| 'TERMINATION_OBJECTION_WINDOW'
| 'ENVELOPE_EXPIRY'
| 'MILESTONE_DEADLINE'
| 'SLA_TARGET'
| 'REMINDER';
export type WebhookProvider = 'STRIPE' | 'SIGNING' | 'TRACKER';
/** Скаляры, допустимые в payload. bigint отсутствует умышленно: суммы у нас bigint. */
export type PayloadScalar = string | number | boolean | null | undefined;
export type ForbiddenMoneyKey =
| `${string}Minor`
| `${string}Amount`
| 'amount'
| `${string}Fee`
| 'fee'
| `${string}Price`
| 'price'
| 'currency';
/**
* Q-3 на шине: события читают все консьюмеры, поэтому денежным полям в payload места нет.
* Три рубежа — имя поля, тип значения и запрет вложенных объектов, за которыми проверка
* ничего не видит. Индекс-сигнатура отсекается первым условием, union проверяется по членам.
*/
export 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;
export type Expect<T extends true> = T;
export type Equals<A, B> = (<G>() => G extends A ? 1 : 2) extends <G>() => G extends B ? 1 : 2 ? true : false;
export type MoneyFreeMap<T> = { [K in keyof T]: MoneyFree<T[K]> };
export interface StatusChangedPayload {
subjectType: SubjectType;
subjectId: string;
projectId?: string;
/** Имя перехода из машины: 'M06', 'H02', ... */
transition: string;
/** null — создание сущности; в схеме это StateTransition.fromState String? */
from: string | null;
to: string;
actorType: ActorType;
/** Роль хранится отдельно от типа актора: USER может быть и фаундером, и оператором. */
actorRole?: string;
transitionId: string;
/** ISO-8601 UTC: payload едет через JSON. */
occurredAt: string;
}
export interface TimerFiredPayload {
timerId: string;
timerKind: TimerKind;
subjectType: SubjectType;
subjectId: string;
projectId?: string;
firedAt: string;
}
export interface ExternalDisputeWebhookPayload {
subjectType: 'HOLD';
subjectId: string;
projectId?: string;
provider: WebhookProvider;
/** Ключ дедупа WebhookEvent. */
externalEventId: string;
externalDisputeId: string;
occurredAt: string;
}
export interface ConciergeEscalatedPayload {
subjectType: 'CONCIERGE_THREAD';
subjectId: string;
projectId?: string;
escalatedByUserId: string;
occurredAt: string;
}
export interface DomainEventPayloadMap {
'project.status.changed': StatusChangedPayload;
'milestone.status.changed': StatusChangedPayload;
'hold.status.changed': StatusChangedPayload;
'contract.status.changed': StatusChangedPayload;
'dispute.status.changed': StatusChangedPayload;
'termination.status.changed': StatusChangedPayload;
'offer.status.changed': StatusChangedPayload;
'plan-version.status.changed': StatusChangedPayload;
'payout-account.status.changed': StatusChangedPayload;
'timer.fired': TimerFiredPayload;
'webhook.external-dispute': ExternalDisputeWebhookPayload;
'concierge.escalated': ConciergeEscalatedPayload;
}
export interface DomainEvent<K extends DomainEventRoutingKey = DomainEventRoutingKey> {
routingKey: K;
payload: DomainEventPayloadMap[K];
}
/**
* Компиляционные гейты. Денежное поле делает запись карты отличной от исходной (обычно never,
* а для union — уцелевшим не-денежным членом), Equals даёт false, и Expect<false> роняет tsc.
* Объявить запись карты как MoneyFree<Payload> нельзя: такое объявление компилируется молча.
*/
export type CatalogIsMoneyFree = Expect<Equals<DomainEventPayloadMap, MoneyFreeMap<DomainEventPayloadMap>>>;
/** Второй гейт: в карте нет забытых и лишних ключей относительно каталога. */
export type CatalogCoversEveryRoutingKey = Expect<Equals<keyof DomainEventPayloadMap, DomainEventRoutingKey>>;

Дополнить src/index.ts:

export * from './lib/domain-event.constants';
export * from './lib/domain-event.type';
  • Step 2: Убедиться, что гейты пропускают честный каталог
Окно терминала
npx nx lint api-core-domain-events
npx tsc --noEmit -p libs/apis/core/domain-events/tsconfig.lib.json

Ожидается: обе команды без ошибок. Если tsc ругается на CatalogIsMoneyFree — гейт ложно срабатывает на честном payload’е, и чинить надо MoneyFree, а не payload.

  • Step 3: Проверить, что гейт действительно бьёт

Временно добавить в конец domain-event.type.ts:

export interface BadPayload {
subjectId: string;
amountMinor: bigint;
}
export type BadCatalog = { 'hold.status.changed': BadPayload };
export type BadGate = Expect<Equals<BadCatalog, MoneyFreeMap<BadCatalog>>>;
Окно терминала
npx tsc --noEmit -p libs/apis/core/domain-events/tsconfig.lib.json

Ожидается: error TS2344: Type 'false' does not satisfy the constraint 'true' на BadGate. После проверки удалить эти три объявления — постоянная версия проверки появится в Task 7.

  • Step 4: Коммит
Окно терминала
npx prettier --write libs/apis/core/domain-events
git add libs/apis/core/domain-events
git commit -m "feat(core): add the domain event catalog types"

Тест здесь двойной: типовые утверждения (падают отказом компиляции) плюс один рантайм-it, без которого Jest прошёл бы мимо файла и падение компиляции не свалило бы nx test.

Files:

  • Create: libs/apis/core/domain-events/src/lib/domain-event.type.spec.ts

  • Step 1: Написать тест

libs/apis/core/domain-events/src/lib/domain-event.type.spec.ts:

import { DOMAIN_EVENT_ROUTING_KEY, DomainEventRoutingKey } from './domain-event.constants';
import {
ConciergeEscalatedPayload,
DomainEventPayloadMap,
Equals,
Expect,
ExternalDisputeWebhookPayload,
MoneyFree,
StatusChangedPayload,
TimerFiredPayload,
} from './domain-event.type';
/** Утверждение «тип отвергнут»: MoneyFree вернул не то, что получил. */
type Rejects<T> = Equals<MoneyFree<T>, T> extends true ? false : true;
// Денежное поле по имени.
export type _RejectsAmountMinor = Expect<Rejects<{ subjectId: string; amountMinor: bigint }>>;
export type _RejectsOptionalAmountMinor = Expect<Rejects<{ subjectId: string; amountMinor?: bigint }>>;
export type _RejectsHoldAmount = Expect<Rejects<{ subjectId: string; holdAmount: number }>>;
export type _RejectsPlatformFee = Expect<Rejects<{ subjectId: string; platformFee: number }>>;
export type _RejectsCurrency = Expect<Rejects<{ subjectId: string; currency: string }>>;
// Денежное значение под нейтральным именем: суммы у нас bigint.
export type _RejectsBigIntValue = Expect<Rejects<{ subjectId: string; total: bigint }>>;
// Дыры, которые проверка обязана закрывать.
export type _RejectsUnionMember = Expect<Rejects<{ subjectId: string } | { amountMinor: bigint }>>;
export type _RejectsNestedObject = Expect<Rejects<{ subjectId: string; meta: { amountMinor: bigint } }>>;
export type _RejectsIndexSignature = Expect<Rejects<{ [key: string]: string }>>;
// Контроль: честные payload'ы не отвергаются, иначе гейт бесполезен.
export type _AcceptsStatusChanged = Expect<Equals<MoneyFree<StatusChangedPayload>, StatusChangedPayload>>;
export type _AcceptsTimerFired = Expect<Equals<MoneyFree<TimerFiredPayload>, TimerFiredPayload>>;
export type _AcceptsExternalDispute = Expect<
Equals<MoneyFree<ExternalDisputeWebhookPayload>, ExternalDisputeWebhookPayload>
>;
export type _AcceptsConciergeEscalated = Expect<
Equals<MoneyFree<ConciergeEscalatedPayload>, ConciergeEscalatedPayload>
>;
describe('domain-event.type', () => {
describe('DOMAIN_EVENT_ROUTING_KEY', () => {
it('declares twelve distinct routing keys', () => {
const keys = Object.values(DOMAIN_EVENT_ROUTING_KEY);
expect(keys).toHaveLength(12);
expect(new Set(keys).size).toBe(12);
});
it('declares a payload for every routing key', () => {
const catalogued: Record<DomainEventRoutingKey, true> = Object.values(DOMAIN_EVENT_ROUTING_KEY).reduce(
(acc, key) => ({ ...acc, [key]: true }),
{} as Record<DomainEventRoutingKey, true>
);
// Ключи карты — те же литералы, что и в каталоге: расхождение поймает тип ниже.
const mapKeys: (keyof DomainEventPayloadMap)[] = Object.keys(
catalogued
) as (keyof DomainEventPayloadMap)[];
expect(mapKeys).toHaveLength(12);
});
});
});
  • Step 2: Запустить и убедиться, что тест зелёный
Окно терминала
npx nx test api-core-domain-events

Ожидается: PASS, 2 теста. Если компиляция падает на одном из _Rejects* — значит MoneyFree пропускает соответствующую форму, и чинить надо тип в domain-event.type.ts.

  • Step 3: Убедиться, что тест поймал бы ослабление запрета

Временно заменить в domain-event.type.ts тело MoneyFree на export type MoneyFree<T> = T; и выполнить:

Окно терминала
npx nx test api-core-domain-events

Ожидается: FAIL с TS2344 на каждом _Rejects*. Вернуть исходное тело MoneyFree и убедиться, что тест снова зелёный.

  • Step 4: Коммит
Окно терминала
npx prettier --write libs/apis/core/domain-events
git add libs/apis/core/domain-events
git commit -m "test(core): assert the domain event payloads stay money-free"

Files:

  • Create: libs/apis/core/platform-calendar/{project.json,tsconfig.json,tsconfig.lib.json,tsconfig.spec.json,jest.config.ts,eslint.config.mjs}

  • Create: libs/apis/core/platform-calendar/src/index.ts

  • Create: libs/apis/core/platform-calendar/src/lib/platform-calendar.type.ts

  • Create: libs/apis/core/platform-calendar/src/lib/platform-calendar.constants.ts

  • Modify: tsconfig.base.json

  • Step 1: Скопировать обвяз из Task 1 с заменой имени

Имя проекта — api-core-platform-calendar, sourceRootlibs/apis/core/platform-calendar/src, displayName — то же имя проекта, coverageDirectory../../../../coverage/libs/apis/core/platform-calendar, теги те же. Имя проекта заменить и в строке-комментарии "// targets".

  • Step 2: Тип и константа календаря

libs/apis/core/platform-calendar/src/lib/platform-calendar.type.ts:

export interface WorkingCalendar {
/** На бете — только UTC: реальная таймзона появится вместе с потребителем. */
timeZone: 'UTC';
/** Рабочие дни недели в нумерации Date#getUTCDay: 0 — воскресенье. */
workDays: readonly number[];
workStartHour: number;
workEndHour: number;
}

libs/apis/core/platform-calendar/src/lib/platform-calendar.constants.ts:

import { WorkingCalendar } from './platform-calendar.type';
/** Рабочий календарь платформы (шаг 7 §3): UTC, 10:00–18:00, пн–пт, без праздников. */
export const PLATFORM_CALENDAR: WorkingCalendar = {
timeZone: 'UTC',
workDays: [1, 2, 3, 4, 5],
workStartHour: 10,
workEndHour: 18,
};

libs/apis/core/platform-calendar/src/index.ts:

export * from './lib/platform-calendar.constants';
export * from './lib/platform-calendar.type';
  • Step 3: Зарегистрировать алиас
"@crewsforge-back/apis/core/platform-calendar": ["libs/apis/core/platform-calendar/src/index.ts"]
  • Step 4: Проверить
Окно терминала
npx nx show project api-core-platform-calendar --json | head -5
npx nx lint api-core-platform-calendar
  • Step 5: Коммит
Окно терминала
npx prettier --write libs/apis/core/platform-calendar tsconfig.base.json
git add libs/apis/core/platform-calendar tsconfig.base.json
git commit -m "feat(core): scaffold the api-core-platform-calendar library"

Files:

  • Create: libs/apis/core/platform-calendar/src/lib/platform-calendar.helper.ts
  • Create: libs/apis/core/platform-calendar/src/lib/platform-calendar.helper.spec.ts
  • Modify: libs/apis/core/platform-calendar/src/index.ts

Даты в тестах задаются ISO-строками с Z — иначе new Date('2026-08-21T17:00') возьмёт локальную таймзону машины и тест станет плавающим.

  • Step 1: Написать падающий тест

libs/apis/core/platform-calendar/src/lib/platform-calendar.helper.spec.ts:

import { addCalendarDays, isWorkingTime } from './platform-calendar.helper';
// 2026-08-21 — пятница; 22-е — суббота, 24-е — понедельник.
const FRIDAY_17_00 = new Date('2026-08-21T17:00:00.000Z');
const SATURDAY_12_00 = new Date('2026-08-22T12:00:00.000Z');
const MONDAY_10_00 = new Date('2026-08-24T10:00:00.000Z');
describe('platform-calendar.helper', () => {
describe('isWorkingTime', () => {
it('accepts a weekday inside the window', () => {
expect(isWorkingTime(FRIDAY_17_00)).toBe(true);
});
it('accepts the exact opening moment', () => {
expect(isWorkingTime(MONDAY_10_00)).toBe(true);
});
it('rejects the exact closing moment', () => {
expect(isWorkingTime(new Date('2026-08-24T18:00:00.000Z'))).toBe(false);
});
it('rejects a moment before the window opens', () => {
expect(isWorkingTime(new Date('2026-08-24T09:59:59.999Z'))).toBe(false);
});
it('rejects a weekend', () => {
expect(isWorkingTime(SATURDAY_12_00)).toBe(false);
});
it('honours a calendar passed explicitly', () => {
const sixDayWeek = {
timeZone: 'UTC',
workDays: [1, 2, 3, 4, 5, 6],
workStartHour: 9,
workEndHour: 19,
} as const;
expect(isWorkingTime(SATURDAY_12_00, sixDayWeek)).toBe(true);
});
});
describe('addCalendarDays', () => {
it('adds days without looking at the calendar', () => {
expect(addCalendarDays(FRIDAY_17_00, 5)).toEqual(new Date('2026-08-26T17:00:00.000Z'));
});
it('keeps the time of day', () => {
expect(addCalendarDays(SATURDAY_12_00, 1)).toEqual(new Date('2026-08-23T12:00:00.000Z'));
});
});
});
  • Step 2: Запустить и убедиться, что падает
Окно терминала
npx nx test api-core-platform-calendar

Ожидается: FAIL — Cannot find module './platform-calendar.helper'.

  • Step 3: Минимальная реализация

libs/apis/core/platform-calendar/src/lib/platform-calendar.helper.ts:

import { PLATFORM_CALENDAR } from './platform-calendar.constants';
import { WorkingCalendar } from './platform-calendar.type';
const MS_IN_DAY = 86_400_000;
const assertCalendar = (calendar: WorkingCalendar): void => {
if (calendar.workDays.length === 0) {
throw new RangeError('calendar must declare at least one working day');
}
if (calendar.workStartHour >= calendar.workEndHour) {
throw new RangeError('workStartHour must be earlier than workEndHour');
}
};
const isWorkingDay = (at: Date, calendar: WorkingCalendar): boolean =>
calendar.workDays.includes(at.getUTCDay());
const windowBound = (at: Date, hour: number): Date => {
const bound = new Date(at);
bound.setUTCHours(hour, 0, 0, 0);
return bound;
};
export const isWorkingTime = (at: Date, calendar: WorkingCalendar = PLATFORM_CALENDAR): boolean => {
assertCalendar(calendar);
// Окно — полуинтервал [start, end): 10:00 внутри, 18:00 уже вне.
return (
isWorkingDay(at, calendar) &&
at >= windowBound(at, calendar.workStartHour) &&
at < windowBound(at, calendar.workEndHour)
);
};
export const addCalendarDays = (from: Date, days: number): Date => {
if (!Number.isInteger(days)) {
throw new RangeError('days must be an integer');
}
return new Date(from.getTime() + days * MS_IN_DAY);
};

Дополнить src/index.ts строкой export * from './lib/platform-calendar.helper';.

  • Step 4: Запустить и убедиться, что зелёный
Окно терминала
npx nx test api-core-platform-calendar

Ожидается: PASS, 8 тестов.

  • Step 5: Коммит
Окно терминала
npx prettier --write libs/apis/core/platform-calendar
git add libs/apis/core/platform-calendar
git commit -m "feat(core): add the working time check and calendar day arithmetic"

Files:

  • Modify: libs/apis/core/platform-calendar/src/lib/platform-calendar.helper.ts

  • Modify: libs/apis/core/platform-calendar/src/lib/platform-calendar.helper.spec.ts

  • Step 1: Написать падающий тест

Импорт дополнить addWorkingHours, добавить блок:

describe('addWorkingHours', () => {
it('carries the remainder over the weekend', () => {
// Критерий приёмки F1: час до 18:00 в пятницу, три часа с 10:00 понедельника.
expect(addWorkingHours(FRIDAY_17_00, 4)).toEqual(new Date('2026-08-24T13:00:00.000Z'));
});
it('returns the closing moment when the hours run out exactly at it', () => {
expect(addWorkingHours(FRIDAY_17_00, 1)).toEqual(new Date('2026-08-21T18:00:00.000Z'));
});
it('starts counting from the next opening when the start is outside the window', () => {
expect(addWorkingHours(SATURDAY_12_00, 2)).toEqual(new Date('2026-08-24T12:00:00.000Z'));
});
it('stays inside a single window when the hours fit', () => {
expect(addWorkingHours(MONDAY_10_00, 3)).toEqual(new Date('2026-08-24T13:00:00.000Z'));
});
it('keeps sub-hour precision', () => {
expect(addWorkingHours(FRIDAY_17_00, 0.5)).toEqual(new Date('2026-08-21T17:30:00.000Z'));
});
it('rejects a negative amount of hours', () => {
expect(() => addWorkingHours(FRIDAY_17_00, -1)).toThrow(RangeError);
});
});
  • Step 2: Запустить и убедиться, что падает
Окно терминала
npx nx test api-core-platform-calendar

Ожидается: FAIL. Падение происходит на компиляции, а не в рантайме: TS2305: Module './platform-calendar.helper' has no exported member 'addWorkingHours'.

  • Step 3: Минимальная реализация

Дописать в platform-calendar.helper.ts (константу MS_IN_HOUR — рядом с MS_IN_DAY):

const MS_IN_HOUR = 3_600_000;
/** Ближайший момент, с которого можно расходовать рабочее время: сам `at`, если он внутри окна. */
const nextWorkingMoment = (at: Date, calendar: WorkingCalendar): Date => {
let cursor = new Date(at);
// Календарь провалидирован вызывающим: хотя бы один рабочий день есть, цикл конечен.
for (;;) {
if (isWorkingDay(cursor, calendar)) {
const opensAt = windowBound(cursor, calendar.workStartHour);
const closesAt = windowBound(cursor, calendar.workEndHour);
if (cursor < opensAt) {
return opensAt;
}
if (cursor < closesAt) {
return cursor;
}
}
cursor = new Date(Date.UTC(cursor.getUTCFullYear(), cursor.getUTCMonth(), cursor.getUTCDate() + 1));
}
};
export const addWorkingHours = (
from: Date,
hours: number,
calendar: WorkingCalendar = PLATFORM_CALENDAR
): Date => {
assertCalendar(calendar);
if (!Number.isFinite(hours) || hours < 0) {
throw new RangeError('hours must be a non-negative finite number');
}
let cursor = nextWorkingMoment(from, calendar);
let remainingMs = hours * MS_IN_HOUR;
while (remainingMs > 0) {
const closesAt = windowBound(cursor, calendar.workEndHour);
const availableMs = closesAt.getTime() - cursor.getTime();
// Ровно исчерпали окно — возвращаем момент закрытия, а не открытие следующего.
if (remainingMs <= availableMs) {
return new Date(cursor.getTime() + remainingMs);
}
remainingMs -= availableMs;
cursor = nextWorkingMoment(closesAt, calendar);
}
return cursor;
};
  • Step 4: Запустить и убедиться, что зелёный
Окно терминала
npx nx test api-core-platform-calendar

Ожидается: PASS, 14 тестов.

  • Step 5: Коммит
Окно терминала
npx prettier --write libs/apis/core/platform-calendar
git add libs/apis/core/platform-calendar
git commit -m "feat(core): add working hours arithmetic"

Files:

  • Modify: libs/apis/core/platform-calendar/src/lib/platform-calendar.helper.ts

  • Modify: libs/apis/core/platform-calendar/src/lib/platform-calendar.helper.spec.ts

  • Step 1: Написать падающий тест

Импорт дополнить addWorkingDays, добавить блок:

describe('addWorkingDays', () => {
it('steps over the weekend instead of counting it', () => {
// Критерий приёмки F1: пять рабочих дней с пятницы — следующая пятница.
expect(addWorkingDays(FRIDAY_17_00, 5)).toEqual(new Date('2026-08-28T17:00:00.000Z'));
});
it('normalises a weekend start to the next working day', () => {
// Суббота → понедельник, затем пять рабочих дней → следующий понедельник.
expect(addWorkingDays(SATURDAY_12_00, 5)).toEqual(new Date('2026-08-31T12:00:00.000Z'));
});
it('counts from a mid-week start', () => {
expect(addWorkingDays(new Date('2026-08-19T09:00:00.000Z'), 5)).toEqual(
new Date('2026-08-26T09:00:00.000Z')
);
});
it('keeps the time of day, including outside the window', () => {
expect(addWorkingDays(new Date('2026-08-24T23:15:00.000Z'), 1)).toEqual(
new Date('2026-08-25T23:15:00.000Z')
);
});
it('normalises a weekend start even for zero days', () => {
expect(addWorkingDays(SATURDAY_12_00, 0)).toEqual(new Date('2026-08-24T12:00:00.000Z'));
});
it('rejects a fractional amount of days', () => {
expect(() => addWorkingDays(FRIDAY_17_00, 1.5)).toThrow(RangeError);
});
});
  • Step 2: Запустить и убедиться, что падает
Окно терминала
npx nx test api-core-platform-calendar

Ожидается: FAIL, снова на компиляции: TS2305: Module './platform-calendar.helper' has no exported member 'addWorkingDays'.

  • Step 3: Минимальная реализация
export const addWorkingDays = (
from: Date,
days: number,
calendar: WorkingCalendar = PLATFORM_CALENDAR
): Date => {
assertCalendar(calendar);
if (!Number.isInteger(days) || days < 0) {
throw new RangeError('days must be a non-negative integer');
}
// Прибавление суток в UTC точное: перевода часов здесь нет, время суток сохраняется.
let cursor = new Date(from);
while (!isWorkingDay(cursor, calendar)) {
cursor = new Date(cursor.getTime() + MS_IN_DAY);
}
let remaining = days;
while (remaining > 0) {
cursor = new Date(cursor.getTime() + MS_IN_DAY);
if (isWorkingDay(cursor, calendar)) {
remaining -= 1;
}
}
return cursor;
};
  • Step 4: Запустить и убедиться, что зелёный
Окно терминала
npx nx test api-core-platform-calendar

Ожидается: PASS, 20 тестов.

  • Step 5: Коммит
Окно терминала
npx prettier --write libs/apis/core/platform-calendar
git add libs/apis/core/platform-calendar
git commit -m "feat(core): add working days arithmetic"

Документация — часть определения готовности единицы, не опция (documentation-rules).

Files:

  • Create: libs/apis/core/money/README.md

  • Create: libs/apis/core/domain-events/README.md

  • Create: libs/apis/core/platform-calendar/README.md

  • Create: docs/04-shared-and-utils/core-libs.md

  • Modify: docs/README.md (строка в таблицу раздела 04)

  • Modify: docs/01-overview/architecture.md (группа core/ в дереве libs/apis/)

  • Modify: docs/rules/coding-rules.md §1.2 (строка в таблицу схем имён)

  • Modify: CLAUDE.md (строка libs/apis/core/<name>/ в блоке структуры репозитория)

  • Step 1: README каждой либы

Скелет (заполняется по-своему для каждой либы):

# <nx-имя проекта>
<Одно предложение: что либа делает и почему существует.>
**Алиас:** `@crewsforge-back/apis/core/<name>` · **Теги:** `scope:shared`, `type:util` ·
**Зависимости:** нет
## API
| Экспорт | Назначение |
| ------- | ---------- |
| ... | ... |
## Пример
```ts
...
```
Дизайн и обоснование: [F1](../../../../docs/plans/2026-08-21-f1-shared-core-libs-design.md).

Наполнение: назначение, публичный API, пример вызова, ссылка на дизайн. Для money обязательно указать порядок округления и то, что сумма компонент сплита равна холду тождественно. Для domain-events — что публикатора здесь нет (он в F2/C1) и что union’ы обязаны совпадать с Prisma-enum’ами из F4. Для platform-calendar — границы окна [10:00, 18:00) и что праздников на бете нет.

  • Step 2: Страница docs/04-shared-and-utils/core-libs.md

Описать группу libs/apis/core/: зачем отдельная от shared, три либы, их назначение, теги и алиасы, а также правило «сюда попадает то, что не зависит ни от NestJS, ни от инфраструктуры». Добавить строку в таблицу раздела 04 в docs/README.md.

  • Step 3: Дерево групп и таблица схем имён

В docs/01-overview/architecture.md найти дерево верхнеуровневых групп libs/apis/ (configs/, providers/, shared/, utils/) и добавить в него core/ — иначе документ описывает структуру, которой больше нет.

Mermaid-диаграмму зависимостей в том же файле не трогать: у либ ядра пока нет ни одного потребителя, и подграф core висел бы в ней без единой стрелки. Подграф добавляет F2 — первая единица, которая на них сошлётся.

В docs/rules/coding-rules.md §1.2, в таблицу схем имён Nx-проектов, добавить строку:

| доменное ядро | api-core-<name> | api-core-money |

Расширение схемы объявлено в дизайне намеренно; без строки в таблице следующая сессия прочтёт правило буквально и заведёт core-money.

  • Step 4: Строка в CLAUDE.md

В блоке структуры репозитория, перед libs/apis/configs/, добавить:

libs/apis/core/<name>/ доменное ядро без зависимостей: деньги, каталог событий, календарь
  • Step 5: Проверить ссылки
Окно терминала
grep -n "core-libs" docs/README.md
grep -n "libs/apis/core" CLAUDE.md
grep -n "api-core" docs/rules/coding-rules.md
grep -n "── core/" docs/01-overview/architecture.md

Ожидается: по одному совпадению на команду. В architecture.md ищется именно ветка дерева (├── core/), а не полный путь: префикс libs/apis/ стоит там отдельной строкой над ветками, поэтому подстроки libs/apis/core в файле не появится.

  • Step 6: Коммит
Окно терминала
npx prettier --write libs/apis/core docs CLAUDE.md
git add libs/apis/core docs CLAUDE.md
git commit -m "docs(core): document the core libraries group"

  • Step 1: Прогнать полный набор проверок
Окно терминала
npx nx affected -t lint,test,build --base=main

Ожидается: Successfully ran targets lint, test, build. Если build пытается собрать приложения, зависящие от новых либ, — их и должен: новые либы ни от чего не зависят, но проверка сборки приложений подтверждает, что алиасы прописаны верно.

  • Step 2: Проверить, что границы Nx не нарушены
Окно терминала
for p in api-core-money api-core-domain-events api-core-platform-calendar; do
echo -n "$p: "; npx nx show project "$p" --json | node -e \
'let s="";process.stdin.on("data",d=>s+=d).on("end",()=>console.log(JSON.stringify(JSON.parse(s).tags)))'
done
npx nx graph --file=/tmp/graph.json
node -e 'const g=require("/tmp/graph.json").graph;for(const p of ["api-core-money","api-core-domain-events","api-core-platform-calendar"])console.log(p, JSON.stringify(g.dependencies[p]))'

Ожидается: у каждого проекта теги ["scope:shared","type:util"] и пустой список зависимостей — либы ядра не зависят ни от чего. grep по файлу графа здесь не годится: JSON однострочный, и счёт строк ничего не покажет.

  • Step 3: Свериться с критериями приёмки карточки F1

Каждый критерий подтверждается именем теста и выводом команды, а не утверждением:

КритерийТест
Сумма компонент сплита всегда равна холдуsplitSettlement invariants → keeps the components adding up to the hold (490 случаев)
Комиссия по ceil, а не roundcalculateCommissionFee → rounds the fee up ...
Пятница 17:00 + 4 рабочих часа → понедельникaddWorkingHours → carries the remainder over the weekend
5 рабочих дней не проскакивают выходныеaddWorkingDays → steps over the weekend instead of counting it
В payload нет денежных полей, запрещено структурно_Rejects* в domain-event.type.spec.ts + гейт CatalogIsMoneyFree в исходниках
  • Step 4: Закрыть единицу по шагу 11 протокола

Статус F1 в ROADMAP.mdDONE, закрывающая запись в SESSION-LOG.md (обязательно раздел «Отклонения»: ревизия шага 2 §7/§9 — TimerKind, SubjectType, проза об округлении), push, PR. Хендофф следующей сессии: F4 обязана сгенерировать Prisma-enum’ы, совпадающие с union’ами domain-events; B2.1 добавит конвертацию bigint → number для Stripe; C4.1 навесит денежный линтер на границу api-core-money.