# PR-4: Каркас `m2m-core` — FSM сделки, репозитории, идемпотентность

## Цель

Реализовать ядро бизнес-логики M2M-перевода: конечный автомат сделки,
типизированный доступ к БД, идемпотентность по GUID, метрики SLA по
этапам. Сетевые интеграции (НРД, ЛК, crypto-service) пока
**заглушками через интерфейсы** — реальные клиенты подключаются
в PR-5 и далее.

## Зависимости

- PR-1 (Go-модели M2M) — для типизированных сообщений.
- PR-2 (DDL Fansy) — на схему БД.

## Источники

- `docs/architecture/plan.md` — раздел «Поток одной заявки end-to-end»
  и «SLA и регуляторные сроки».
- `internal/m2m/` — модели сообщений (после PR-1).

## Состав PR

### 1. Конечный автомат сделки

`internal/m2mcore/fsm.go`:

```
Draft → Validated → SubmittedToNSD → AwaitingDecision →
    Confirmed → AwaitingSUB16 → Done
                            ↘ Rejected
                            ↘ TimedOut (после 10→5 мин без Decision)
                            ↘ ManualApproval (опц. ветка)
```

Переходы — явные методы:
- `(d *Deal) Validate() error`,
- `(d *Deal) Submit(ctx) error`,
- `(d *Deal) ReceiveDecision(ctx, m2m.M2MTransferDecision) error`,
- `(d *Deal) Timeout(ctx) error`,
- `(d *Deal) SendToManualApproval(ctx, reason string) error`,
- `(d *Deal) ApproveManually(ctx, operator string) error`,
- `(d *Deal) RejectManually(ctx, operator, code, comment string) error`.

Каждый переход:
- проверяет текущее состояние (нельзя `Submit` если не `Validated`);
- пишет событие в `deal_events` (event sourcing для аудита);
- эмитит метрику `m2m_stage_duration_seconds{stage=...}`.

### 2. Доменная модель сделки

`internal/m2mcore/deal.go`:

```go
type Deal struct {
    ID          uuid.UUID
    GUID        m2m.UUID  // тот, что в M2MTransferRequest.Header.GUID
    State       State
    InvestorID  uuid.UUID
    SignedClaim []byte  // подписанный XML от ЛК
    Request     *m2m.M2MTransferRequest
    Response    *m2m.M2MTransferResponse
    Decision    *m2m.M2MTransferDecision
    CreatedAt   time.Time
    UpdatedAt   time.Time
    Stages      []StageRecord  // история состояний с timestamp
}
```

### 3. Репозиторий

`internal/m2mcore/repo.go`:

Интерфейс `Repository` с реализацией на pgx:

```go
type Repository interface {
    Create(ctx, *Deal) error
    GetByGUID(ctx, m2m.UUID) (*Deal, error)
    Update(ctx, *Deal) error
    List(ctx, Filter) ([]*Deal, error)
    AppendEvent(ctx, *Deal, Event) error
}
```

Идемпотентность: `Create` использует `INSERT ... ON CONFLICT (guid) DO NOTHING RETURNING ...` —
повторный запрос с тем же GUID не создаёт дубль, а возвращает существующий.

### 4. Внешние зависимости — интерфейсы (для PR-5+)

`internal/m2mcore/ports.go`:

```go
type NSDSender interface {
    Send(ctx, *m2m.M2MTransferRequest) (*m2m.M2MTransferResponse, error)
    SendDecision(ctx, *m2m.M2MTransferDecision) error
}

type LKCallbackClient interface {
    UpdateStatus(ctx, claimID, status, reason string) error
}

type CryptoVerifier interface {
    VerifyXMLDSig(ctx, payload []byte) (CertInfo, error)
}

type FansyStore interface {
    GetClientByID(ctx, uuid.UUID) (*Client, error)
    GetDepoAccount(ctx, deponentCode, accountID string) (*DepoAccount, error)
    CheckBalance(ctx, depoAccountID uuid.UUID, isin string) (Quantity, error)
}
```

В PR-4 — заглушки `noopNSDSender`, `noopLKCallbackClient` и т. п.
Реальные клиенты подключаются в PR-5, PR-6.

### 5. Сборка enrichment Request из заявки

`internal/m2mcore/enrich.go`:

Функция «из заявки от ЛК + данные из `fansy-store` → `M2MTransferRequest`».

Шаги:
1. Распарсить подписанное заявление (XML от ЛК).
2. Поднять реквизиты сторон, депо-счета, остатки из FansyStore.
3. Проверить достаточность остатков по каждой ЦБ.
4. Сгенерировать `GUID` (uuid v4) и `ReferenceId` для каждой ЦБ
   (`M2M` + дата `YYYYMMDD` + 5 случайных символов A-Z0-9).
5. Заполнить `Header.CreationTimestamp` = `nsdxml.NSDDateTime.Now()`.
6. Вернуть готовый `*m2m.M2MTransferRequest`, валидированный.

### 6. Метрики SLA

`internal/m2mcore/metrics.go`:

Prometheus метрики:
- `m2m_stage_duration_seconds{stage="..."}` — histogram длительности этапа,
- `m2m_deals_total{state="..."}` — counter сделок по итоговому состоянию,
- `m2m_sla_breaches_total{stage="...",budget="5m|2m"}` — counter
  превышений 80% бюджета SLA.

Endpoint `/metrics` поднимается в `cmd/m2m-core/main.go`.

### 7. Заменить заглушку `cmd/m2m-core/main.go`

Минимальный рабочий сервер:
- читает конфиг (env: `BJ_DSN`, `BJ_LOG_LEVEL`, ...),
- открывает соединение с Postgres,
- инициализирует Repository,
- поднимает HTTP-эндпоинты `/healthz`, `/metrics`,
- логирует все запуски.

### 8. Тесты

- Юнит-тесты FSM (все переходы, в т. ч. недопустимые).
- Интеграционный тест с PostgreSQL через `testcontainers-go` (или
  `dockertest`): создал сделку, обновил, прочитал, проверил
  идемпотентность по GUID.
- Тест `enrich.go` на эталонной заявке (fixtures из примеров ESIA
  Finance API).

## Требования

- Без эмодзи. Комментарии — на русском.
- `make ci` зелёный.
- Покрытие тестами `internal/m2mcore/` — не менее 60%.

## Коммит

```
feat(m2m-core): FSM сделки, репозиторий на pgx, идемпотентность по GUID, метрики SLA

- internal/m2mcore/fsm.go: конечный автомат с переходами и аудит-событиями
- internal/m2mcore/deal.go: доменная модель сделки
- internal/m2mcore/repo.go: репозиторий на pgx с идемпотентным Create
- internal/m2mcore/ports.go: интерфейсы для внешних зависимостей (заглушки)
- internal/m2mcore/enrich.go: сборка M2MTransferRequest из заявки + Fansy
- internal/m2mcore/metrics.go: Prometheus-метрики этапов и SLA
- cmd/m2m-core/main.go: минимальный сервер с /healthz и /metrics

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
```

## После коммита

Обновить `docs/tasks/README.md`: PR-4 — «выполнено».
