# Shared Operation Layer — Implementation Plan

> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task.

**Goal:** Stop CLI/MCP behavioural drift by extracting the duplicated operation layer (normalisation, queries, conversion, write flows) into shared packages; commands/tools become thin adapters.

**Architecture:** A command/tool collects input (CLI prompts / MCP params), calls one shared operation, renders output (CLI table / MCP JSON). Validation, normalisation, DB queries, conversion, and writes live in `@firma/domain` / `@firma/db` / `@firma/portfolio` — so they cannot diverge.

**Tech Stack:** TypeScript, Turborepo + Yarn Berry, Drizzle ORM + better-sqlite3, tsup, Vitest, Commander + Clack (CLI), `@modelcontextprotocol/sdk` (MCP).

**Design doc:** `docs/plans/2026-05-16-shared-operation-layer-design.md`
**Branch:** `refactor/shared-operation-layer` (off `main`).
**Build order:** `@firma/finnhub` → `@firma/mcp` → `@evan-moon/firma`.
**Refactor discipline:** each task adds characterisation tests *before* extracting where coverage is thin; the full suite + CLI/MCP parity harness must stay green at every commit.

---

## Task 1: `normalizeTicker` → `@firma/domain`

**Files:** Modify `packages/domain/domain/src/market/symbol.ts` (+ barrel); test alongside. Then update `apps/cli/src/commands/add.ts` and `apps/mcp/src/tools/mutate.ts`.

- Add `normalizeTicker(assetType: AssetType, raw: string): string` — `stock` → `raw.trim().toUpperCase()`, else `raw.trim()`.
- Replace the inline `isStock ? trim().toUpperCase() : trim()` logic in `add.ts` (non-stock name + stock ticker paths) and `mutate.ts` (`add_txn`/`add_txns`/`edit_txn`) with calls to it.
- Tests: stock upper+trim, non-stock trim-only (Korean/space preserved).

**Commit:** `refactor(domain): extract normalizeTicker`

---

## Task 2: `transactions.findByTicker` → `@firma/db`

**Files:** Modify `packages/shared/db/src/repositories.ts` (+ tests). Then `apps/cli/src/commands/txns.ts` and `apps/mcp/src/tools/portfolio.ts` (`show_txns`).

- Add `transactions.findByTicker(ticker: string): Transaction[]` — case-insensitive match on `ticker`, ordered by date asc (matching today's behaviour).
- `txns.ts` `filterTxnsByTicker` and MCP `show_txns`'s `lower(ticker)=…` query both route through it. Remove the duplicated query logic.
- Tests: case-insensitive stock + non-stock match; no-ticker returns all.

**Commit:** `refactor(db): transactions.findByTicker repository method`

---

## Task 3: snapshot query operations → `@firma/db`

**Files:** Modify `packages/shared/db/src/repositories.ts` (+ tests). Then `apps/cli/src/commands/snapshot.ts` and `apps/mcp/src/tools/snapshot.ts`.

- Ensure the snapshot repository exposes: `findBySymbol(symbol)` / a date-range query / `updateBySymbol(date, symbol, fields)` — whatever `show_snapshot` / `edit_snapshot` / CLI snapshot need.
- MCP `edit_snapshot` / `show_snapshot` currently use raw `db.update/select` — route through the repo. CLI `snapshot.ts` (`resolveSnapshotRows`, `editSnapshotCommand`) likewise.
- Tests: symbol lookup/update for stock and non-stock (`market: null`).

**Commit:** `refactor(db): snapshot query/update repository methods`

---

## Task 4: `nativeToUsd` → `@firma/db`

**Files:** Add to `@firma/db` (near the FX repository); test alongside. Then replace usages in `apps/cli/src/commands/add.ts` (`convertTxnPriceToUsd`), `apps/cli/src/commands/price.ts` (`convertPriceToUsd`), `apps/mcp/src/helpers.ts` (`nativeToUsdPrice`).

- `nativeToUsd({ amount, currency, date, fxRepo, liveRates? }): number | null` — convert via the `fx_rates` cache, then `liveRates`. **No `FALLBACK_RATES` fallback** — return `null` (caller errors) when no rate resolves.
- USD passes through unchanged. Zero passes through.
- Replace the three converters; callers keep their existing "error and abort on null" handling.
- Live-rate *fetching* stays per-app (CLI `fetchFxRatesWithFallback`, MCP `getLiveFxRates`).
- Scope: write-side only — do NOT touch `usdToDisplayAtDate` (display).
- Tests: cache hit, live fallback, null when no rate, USD/zero pass-through.

**Commit:** `refactor(db): unify native→USD conversion (no hardcoded fallback)`

---

## Task 5: `addTransaction` / `editTransaction` use-cases → `@firma/portfolio`

**Files:** Add `packages/use-case/portfolio/src/transactions.ts` (+ tests, barrel). Then rewrite `apps/cli/src/commands/add.ts` + `edit.ts` and `apps/mcp/src/tools/mutate.ts` (`add_txn`/`add_txns`/`edit_txn`) as adapters.

- `addTransaction({ repo, input, liveRates? })` — semantic validation → `normalizeTicker` → `nativeToUsd` → `repo.transactions.insert`. `editTransaction` similarly. Input is the structured txn fields (asset_type, market, ticker, type, shares, native price + currency, date, reason).
- Returns a result type carrying the stored row or a typed error (e.g. no-FX-rate).
- CLI `add txn` collects via prompts (keeps thin live-format validation only), calls the use-case, renders. MCP `add_txn`/`add_txns`/`edit_txn` map params → use-case → JSON.
- Tests: the use-case directly (stock + non-stock, conversion, validation failure); CLI/MCP parity for an identical logical input.

**Commit:** `refactor(use-case): addTransaction/editTransaction owns the txn write`

---

## Task 6: `setManualPrice` use-case → `@firma/portfolio`

**Files:** Add `packages/use-case/portfolio/src/manual-price.ts` (+ tests, barrel). Then rewrite `apps/cli/src/commands/price.ts` and the MCP `add_price` tool as adapters.

- `setManualPrice({ repo, input, liveRates? })` — resolve the non-stock holding, reject stocks, `nativeToUsd`, `buildManualPriceRow`, upsert. Returns the stored row or a typed error.
- CLI `add price` (batch + select + direct forms) and MCP `add_price` collect input and call it.
- Tests: the use-case directly; stock rejection; CLI/MCP parity.

**Commit:** `refactor(use-case): setManualPrice owns the manual-price write`

---

## Task 7: Verification + parity sweep

**Files:** `test/parity.test.ts` and sibling parity tests; design doc status.

- Extend the parity harness so each shared operation is exercised from both surfaces and asserted equal.
- Audit: no command/tool still contains `select().where()` for a domain query, inline ticker normalisation, or its own native→USD converter.
- Run `yarn typecheck` (17/17), `yarn build` (15/15), `npx vitest run` (all green).
- Mark the design doc `Status: Implemented`.

**Commit:** `test: CLI/MCP parity sweep for the shared operation layer`

---

## Notes

- Behaviour-preserving except the deliberate C change (CLI write path no longer falls back to `FALLBACK_RATES`; it errors — matching MCP).
- `usdToDisplayAtDate` and other display-currency conversion is out of scope.
