# Real-Time Monitoring Dashboard + Backend GL Accounting Implementation Plan

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

**Goal:** Build Phoenix Channel-based real-time monitoring dashboard and backend GL accounting endpoints with COSIF integration, enabling the admin frontend to display live operational data and proper double-entry bookkeeping.

**Architecture:** Settlement Service (port 4003) gets a WebSocket endpoint (`/socket`) with 6 channel topics broadcasting transaction flow, system health, settlement status, DICT operations, BACEN channel health, and queue depths. Simultaneously, 4 new database tables (chart_of_accounts, journal_entries, accounting_events, cost_centers) with Ecto schemas, context module, controller, and routes serve the 3 frontend accounting views that are already wired. The frontend gets a new `phoenix` WebSocket client in a Pinia store feeding a 6-panel real-time dashboard.

**Tech Stack:** Elixir/Phoenix 1.8.3, Phoenix Channels (WebSocket), Ecto, PostgreSQL, NATS JetStream, Vue 3, Pinia, TypeScript, Tailwind CSS 4, `phoenix` npm package

---

## Task 1: Database Migration — Accounting Tables + COSIF Seed

**Files:**
- Create: `backend/apps/shared/priv/repo/migrations/20260207400001_create_accounting_tables.exs`

**Step 1: Write the migration**

```elixir
defmodule Shared.Repo.Migrations.CreateAccountingTables do
  use Ecto.Migration

  def up do
    # ==========================================
    # Chart of Accounts (COSIF)
    # ==========================================
    execute """
    CREATE TABLE IF NOT EXISTS monetarie_settlement.chart_of_accounts (
      id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
      cosif_code VARCHAR(20) NOT NULL,
      name VARCHAR(200) NOT NULL,
      account_type VARCHAR(20) NOT NULL CHECK (account_type IN ('asset', 'liability', 'equity', 'revenue', 'expense')),
      parent_code VARCHAR(20),
      level INTEGER NOT NULL DEFAULT 1,
      active BOOLEAN NOT NULL DEFAULT true,
      inserted_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
      updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
    )
    """

    execute """
    CREATE UNIQUE INDEX IF NOT EXISTS idx_chart_of_accounts_cosif_code
    ON monetarie_settlement.chart_of_accounts (cosif_code)
    """

    execute """
    CREATE INDEX IF NOT EXISTS idx_chart_of_accounts_parent
    ON monetarie_settlement.chart_of_accounts (parent_code) WHERE parent_code IS NOT NULL
    """

    execute """
    CREATE INDEX IF NOT EXISTS idx_chart_of_accounts_active
    ON monetarie_settlement.chart_of_accounts (active, account_type) WHERE active = true
    """

    # ==========================================
    # Journal Entries (double-entry bookkeeping)
    # ==========================================
    execute """
    CREATE TABLE IF NOT EXISTS monetarie_settlement.journal_entries (
      id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
      date DATE NOT NULL,
      debit_account_id UUID NOT NULL REFERENCES monetarie_settlement.chart_of_accounts(id),
      credit_account_id UUID NOT NULL REFERENCES monetarie_settlement.chart_of_accounts(id),
      amount BIGINT NOT NULL CHECK (amount > 0),
      description VARCHAR(500) NOT NULL,
      reference VARCHAR(100),
      source_type VARCHAR(30) NOT NULL DEFAULT 'manual',
      source_id UUID,
      status VARCHAR(20) NOT NULL DEFAULT 'pending' CHECK (status IN ('pending', 'posted', 'reversed')),
      created_by VARCHAR(100) NOT NULL DEFAULT 'system',
      inserted_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
      updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
    )
    """

    execute """
    CREATE INDEX IF NOT EXISTS idx_journal_entries_date
    ON monetarie_settlement.journal_entries (date DESC)
    """

    execute """
    CREATE INDEX IF NOT EXISTS idx_journal_entries_debit_account
    ON monetarie_settlement.journal_entries (debit_account_id)
    """

    execute """
    CREATE INDEX IF NOT EXISTS idx_journal_entries_credit_account
    ON monetarie_settlement.journal_entries (credit_account_id)
    """

    execute """
    CREATE INDEX IF NOT EXISTS idx_journal_entries_source
    ON monetarie_settlement.journal_entries (source_type, source_id) WHERE source_id IS NOT NULL
    """

    execute """
    CREATE INDEX IF NOT EXISTS idx_journal_entries_status
    ON monetarie_settlement.journal_entries (status) WHERE status = 'pending'
    """

    # ==========================================
    # Accounting Events
    # ==========================================
    execute """
    CREATE TABLE IF NOT EXISTS monetarie_settlement.accounting_events (
      id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
      type VARCHAR(20) NOT NULL CHECK (type IN ('automatic', 'manual')),
      event_type VARCHAR(50) NOT NULL,
      description TEXT,
      amount BIGINT NOT NULL CHECK (amount >= 0),
      debit_account VARCHAR(20) NOT NULL,
      credit_account VARCHAR(20) NOT NULL,
      date DATE NOT NULL DEFAULT CURRENT_DATE,
      "user" VARCHAR(100) NOT NULL DEFAULT 'system',
      status VARCHAR(20) NOT NULL DEFAULT 'pending' CHECK (status IN ('processed', 'pending', 'error')),
      reference VARCHAR(100),
      inserted_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
      updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
    )
    """

    execute """
    CREATE INDEX IF NOT EXISTS idx_accounting_events_date
    ON monetarie_settlement.accounting_events (date DESC)
    """

    execute """
    CREATE INDEX IF NOT EXISTS idx_accounting_events_type_status
    ON monetarie_settlement.accounting_events (type, status)
    """

    # ==========================================
    # Cost Centers
    # ==========================================
    execute """
    CREATE TABLE IF NOT EXISTS monetarie_settlement.cost_centers (
      id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
      code VARCHAR(20) NOT NULL,
      name VARCHAR(200) NOT NULL,
      responsible VARCHAR(200) NOT NULL,
      status VARCHAR(20) NOT NULL DEFAULT 'active' CHECK (status IN ('active', 'inactive')),
      budget BIGINT NOT NULL DEFAULT 0,
      spent BIGINT NOT NULL DEFAULT 0,
      inserted_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
      updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
    )
    """

    execute """
    CREATE UNIQUE INDEX IF NOT EXISTS idx_cost_centers_code
    ON monetarie_settlement.cost_centers (code)
    """

    # ==========================================
    # Seed COSIF Chart of Accounts (BCB Circular 4010)
    # ==========================================
    execute """
    INSERT INTO monetarie_settlement.chart_of_accounts (cosif_code, name, account_type, parent_code, level) VALUES
      -- Level 1: Asset Groups
      ('1.0.0.00.00-0', 'Ativo Circulante e Realizável a Longo Prazo', 'asset', NULL, 1),
      ('1.1.0.00.00-0', 'Disponibilidades', 'asset', '1.0.0.00.00-0', 2),
      ('1.1.1.00.00-0', 'Caixa', 'asset', '1.1.0.00.00-0', 3),
      ('1.1.1.10.00-0', 'Depósitos Bancários à Vista', 'asset', '1.1.1.00.00-0', 4),
      ('1.1.2.00.00-0', 'Depósitos Interfinanceiros', 'asset', '1.1.0.00.00-0', 3),
      ('1.2.0.00.00-0', 'Aplicações Interfinanceiras de Liquidez', 'asset', '1.0.0.00.00-0', 2),
      ('1.3.0.00.00-0', 'Títulos e Valores Mobiliários', 'asset', '1.0.0.00.00-0', 2),
      ('1.6.0.00.00-0', 'Operações de Crédito', 'asset', '1.0.0.00.00-0', 2),
      ('1.8.0.00.00-0', 'Outros Créditos', 'asset', '1.0.0.00.00-0', 2),
      ('1.8.8.00.00-0', 'Devedores Diversos', 'asset', '1.8.0.00.00-0', 3),
      ('1.8.8.10.00-0', 'Devedores SPI - PIX Recebíveis', 'asset', '1.8.8.00.00-0', 4),
      ('1.8.8.20.00-0', 'Devedores DICT - Tarifas a Receber', 'asset', '1.8.8.00.00-0', 4),
      ('1.8.8.30.00-0', 'Devedores Liquidação - Netting a Receber', 'asset', '1.8.8.00.00-0', 4),

      -- Level 1: Liability Groups
      ('2.0.0.00.00-0', 'Passivo Circulante e Exigível a Longo Prazo', 'liability', NULL, 1),
      ('2.1.0.00.00-0', 'Depósitos', 'liability', '2.0.0.00.00-0', 2),
      ('2.1.1.00.00-0', 'Depósitos à Vista', 'liability', '2.1.0.00.00-0', 3),
      ('2.1.1.10.00-0', 'Depósitos à Vista - Pessoa Física', 'liability', '2.1.1.00.00-0', 4),
      ('2.1.1.20.00-0', 'Depósitos à Vista - Pessoa Jurídica', 'liability', '2.1.1.00.00-0', 4),
      ('2.1.2.00.00-0', 'Depósitos Interfinanceiros', 'liability', '2.1.0.00.00-0', 3),
      ('2.4.0.00.00-0', 'Relações Interfinanceiras', 'liability', '2.0.0.00.00-0', 2),
      ('2.8.0.00.00-0', 'Outras Obrigações', 'liability', '2.0.0.00.00-0', 2),
      ('2.8.8.00.00-0', 'Credores Diversos', 'liability', '2.8.0.00.00-0', 3),
      ('2.8.8.10.00-0', 'Credores SPI - PIX a Pagar', 'liability', '2.8.8.00.00-0', 4),
      ('2.8.8.20.00-0', 'Credores DICT - Tarifas a Pagar', 'liability', '2.8.8.00.00-0', 4),
      ('2.8.8.30.00-0', 'Credores Liquidação - Netting a Pagar', 'liability', '2.8.8.00.00-0', 4),

      -- Level 1: Equity
      ('3.0.0.00.00-0', 'Patrimônio Líquido', 'equity', NULL, 1),
      ('3.1.0.00.00-0', 'Capital Social', 'equity', '3.0.0.00.00-0', 2),
      ('3.5.0.00.00-0', 'Lucros ou Prejuízos Acumulados', 'equity', '3.0.0.00.00-0', 2),

      -- Level 1: Revenue
      ('7.0.0.00.00-0', 'Receitas Operacionais', 'revenue', NULL, 1),
      ('7.1.0.00.00-0', 'Receitas de Intermediação Financeira', 'revenue', '7.0.0.00.00-0', 2),
      ('7.1.7.00.00-0', 'Receitas de Prestação de Serviços', 'revenue', '7.1.0.00.00-0', 3),
      ('7.1.7.10.00-0', 'Receitas PIX - Tarifas Recebidas', 'revenue', '7.1.7.00.00-0', 4),
      ('7.1.7.20.00-0', 'Receitas PIX - Tarifas Interbancárias', 'revenue', '7.1.7.00.00-0', 4),
      ('7.1.7.30.00-0', 'Receitas PIX - Serviços DICT', 'revenue', '7.1.7.00.00-0', 4),
      ('7.1.7.40.00-0', 'Receitas Liquidação - Netting', 'revenue', '7.1.7.00.00-0', 4),
      ('7.8.0.00.00-0', 'Receitas Não Operacionais', 'revenue', '7.0.0.00.00-0', 2),

      -- Level 1: Expense
      ('8.0.0.00.00-0', 'Despesas Operacionais', 'expense', NULL, 1),
      ('8.1.0.00.00-0', 'Despesas de Intermediação Financeira', 'expense', '8.0.0.00.00-0', 2),
      ('8.1.7.00.00-0', 'Despesas de Prestação de Serviços', 'expense', '8.1.0.00.00-0', 3),
      ('8.1.7.10.00-0', 'Despesas PIX - Tarifas Pagas', 'expense', '8.1.7.00.00-0', 4),
      ('8.1.7.20.00-0', 'Despesas PIX - Infraestrutura BACEN', 'expense', '8.1.7.00.00-0', 4),
      ('8.1.7.30.00-0', 'Despesas PIX - Operação DICT', 'expense', '8.1.7.00.00-0', 4),
      ('8.1.7.40.00-0', 'Despesas Liquidação - Processamento', 'expense', '8.1.7.00.00-0', 4),
      ('8.9.0.00.00-0', 'Despesas Não Operacionais', 'expense', '8.0.0.00.00-0', 2)
    ON CONFLICT (cosif_code) DO NOTHING
    """

    # Seed default cost centers
    execute """
    INSERT INTO monetarie_settlement.cost_centers (code, name, responsible, budget) VALUES
      ('CC-001', 'Operações PIX', 'Diretoria de Operações', 50000000),
      ('CC-002', 'Infraestrutura BACEN', 'Diretoria de Tecnologia', 30000000),
      ('CC-003', 'Compliance e Auditoria', 'Diretoria de Compliance', 15000000),
      ('CC-004', 'Suporte ao Participante', 'Gerência de Suporte', 10000000),
      ('CC-005', 'Desenvolvimento de Produto', 'Diretoria de Produto', 25000000),
      ('CC-006', 'Segurança da Informação', 'CISO', 20000000)
    ON CONFLICT (code) DO NOTHING
    """
  end

  def down do
    execute "DROP TABLE IF EXISTS monetarie_settlement.accounting_events"
    execute "DROP TABLE IF EXISTS monetarie_settlement.journal_entries"
    execute "DROP TABLE IF EXISTS monetarie_settlement.cost_centers"
    execute "DROP TABLE IF EXISTS monetarie_settlement.chart_of_accounts"
  end
end
```

**Step 2: Run the migration**

Run: `cd /monetarie/pix/backend && mix ecto.migrate`
Expected: Migration runs successfully, 4 tables created in monetarie_settlement schema, 44 COSIF accounts seeded, 6 cost centers seeded.

**Step 3: Commit**

```bash
git add backend/apps/shared/priv/repo/migrations/20260207400001_create_accounting_tables.exs
git commit -m "feat: add accounting tables migration with COSIF seed data

Creates chart_of_accounts, journal_entries, accounting_events,
cost_centers in monetarie_settlement schema. Seeds 44 BCB COSIF
accounts (Circular 4010) and 6 default cost centers."
```

---

## Task 2: Ecto Schemas — 4 Accounting Schemas

**Files:**
- Create: `backend/apps/settlement_service/lib/settlement_service/accounting/chart_of_accounts.ex`
- Create: `backend/apps/settlement_service/lib/settlement_service/accounting/journal_entry.ex`
- Create: `backend/apps/settlement_service/lib/settlement_service/accounting/accounting_event.ex`
- Create: `backend/apps/settlement_service/lib/settlement_service/accounting/cost_center.ex`

Follow the exact pattern from `SettlementService.Netting.NettingCycle`:
- `@primary_key {:id, :binary_id, autogenerate: true}`
- `@foreign_key_type :binary_id`
- `@timestamps_opts [type: :utc_datetime_usec]`
- Schema prefix: `@schema_prefix "monetarie_settlement"`
- Typed struct with `@type t :: %__MODULE__{}`
- Separate changesets for create/update

**Step 1: Create ChartOfAccounts schema**

```elixir
# backend/apps/settlement_service/lib/settlement_service/accounting/chart_of_accounts.ex
defmodule SettlementService.Accounting.ChartOfAccounts do
  use Ecto.Schema
  import Ecto.Changeset

  @type t :: %__MODULE__{}

  @primary_key {:id, :binary_id, autogenerate: true}
  @timestamps_opts [type: :utc_datetime_usec]
  @schema_prefix "monetarie_settlement"

  @account_types ~w(asset liability equity revenue expense)

  schema "chart_of_accounts" do
    field :cosif_code, :string
    field :name, :string
    field :account_type, :string
    field :parent_code, :string
    field :level, :integer, default: 1
    field :active, :boolean, default: true

    timestamps()
  end

  def changeset(account, attrs) do
    account
    |> cast(attrs, [:cosif_code, :name, :account_type, :parent_code, :level, :active])
    |> validate_required([:cosif_code, :name, :account_type])
    |> validate_inclusion(:account_type, @account_types)
    |> validate_number(:level, greater_than: 0, less_than_or_equal_to: 5)
    |> unique_constraint(:cosif_code)
  end
end
```

**Step 2: Create JournalEntry schema**

```elixir
# backend/apps/settlement_service/lib/settlement_service/accounting/journal_entry.ex
defmodule SettlementService.Accounting.JournalEntry do
  use Ecto.Schema
  import Ecto.Changeset

  @type t :: %__MODULE__{}

  @primary_key {:id, :binary_id, autogenerate: true}
  @foreign_key_type :binary_id
  @timestamps_opts [type: :utc_datetime_usec]
  @schema_prefix "monetarie_settlement"

  @statuses ~w(pending posted reversed)

  schema "journal_entries" do
    field :date, :date
    field :amount, :integer
    field :description, :string
    field :reference, :string
    field :source_type, :string, default: "manual"
    field :source_id, :binary_id
    field :status, :string, default: "pending"
    field :created_by, :string, default: "system"

    belongs_to :debit_account, SettlementService.Accounting.ChartOfAccounts
    belongs_to :credit_account, SettlementService.Accounting.ChartOfAccounts

    timestamps()
  end

  def changeset(entry, attrs) do
    entry
    |> cast(attrs, [:date, :debit_account_id, :credit_account_id, :amount, :description,
                    :reference, :source_type, :source_id, :status, :created_by])
    |> validate_required([:date, :debit_account_id, :credit_account_id, :amount, :description])
    |> validate_number(:amount, greater_than: 0)
    |> validate_inclusion(:status, @statuses)
    |> foreign_key_constraint(:debit_account_id)
    |> foreign_key_constraint(:credit_account_id)
  end
end
```

**Step 3: Create AccountingEvent schema**

```elixir
# backend/apps/settlement_service/lib/settlement_service/accounting/accounting_event.ex
defmodule SettlementService.Accounting.AccountingEvent do
  use Ecto.Schema
  import Ecto.Changeset

  @type t :: %__MODULE__{}

  @primary_key {:id, :binary_id, autogenerate: true}
  @timestamps_opts [type: :utc_datetime_usec]
  @schema_prefix "monetarie_settlement"

  @types ~w(automatic manual)
  @statuses ~w(processed pending error)

  schema "accounting_events" do
    field :type, :string, default: "manual"
    field :event_type, :string
    field :description, :string
    field :amount, :integer, default: 0
    field :debit_account, :string
    field :credit_account, :string
    field :date, :date
    field :user, :string, default: "system"
    field :status, :string, default: "pending"
    field :reference, :string

    timestamps()
  end

  def changeset(event, attrs) do
    event
    |> cast(attrs, [:type, :event_type, :description, :amount, :debit_account,
                    :credit_account, :date, :user, :status, :reference])
    |> validate_required([:event_type, :description, :amount, :debit_account, :credit_account])
    |> validate_inclusion(:type, @types)
    |> validate_inclusion(:status, @statuses)
    |> validate_number(:amount, greater_than_or_equal_to: 0)
  end
end
```

**Step 4: Create CostCenter schema**

```elixir
# backend/apps/settlement_service/lib/settlement_service/accounting/cost_center.ex
defmodule SettlementService.Accounting.CostCenter do
  use Ecto.Schema
  import Ecto.Changeset

  @type t :: %__MODULE__{}

  @primary_key {:id, :binary_id, autogenerate: true}
  @timestamps_opts [type: :utc_datetime_usec]
  @schema_prefix "monetarie_settlement"

  @statuses ~w(active inactive)

  schema "cost_centers" do
    field :code, :string
    field :name, :string
    field :responsible, :string
    field :status, :string, default: "active"
    field :budget, :integer, default: 0
    field :spent, :integer, default: 0

    timestamps()
  end

  def changeset(center, attrs) do
    center
    |> cast(attrs, [:code, :name, :responsible, :status, :budget, :spent])
    |> validate_required([:code, :name, :responsible])
    |> validate_inclusion(:status, @statuses)
    |> validate_number(:budget, greater_than_or_equal_to: 0)
    |> validate_number(:spent, greater_than_or_equal_to: 0)
    |> unique_constraint(:code)
  end

  def toggle_status_changeset(center) do
    new_status = if center.status == "active", do: "inactive", else: "active"
    change(center, status: new_status)
  end
end
```

**Step 5: Commit**

```bash
git add backend/apps/settlement_service/lib/settlement_service/accounting/
git commit -m "feat: add 4 Ecto schemas for GL accounting

ChartOfAccounts, JournalEntry, AccountingEvent, CostCenter
schemas with proper changesets, types, and constraints."
```

---

## Task 3: Accounting Context Module

**Files:**
- Create: `backend/apps/settlement_service/lib/settlement_service/accounting.ex`

Follow the exact pattern from `SettlementService.Netting` context:
- `import Ecto.Query, warn: false`
- `alias SettlementService.Repo`
- Functions return `{:ok, result}` or `{:error, reason}` tuples
- NATS event publishing via `SettlementService.NATS.Publisher`

**Step 1: Write the Accounting context**

```elixir
# backend/apps/settlement_service/lib/settlement_service/accounting.ex
defmodule SettlementService.Accounting do
  @moduledoc """
  The Accounting context handles GL accounting operations.

  Manages chart of accounts (COSIF), double-entry journal entries,
  accounting events, and cost centers for PIX financial operations.
  """

  import Ecto.Query, warn: false
  alias SettlementService.Repo
  alias SettlementService.Accounting.{ChartOfAccounts, JournalEntry, AccountingEvent, CostCenter}

  # ==========================================
  # Chart of Accounts
  # ==========================================

  def list_chart_of_accounts(params \\ %{}) do
    query = from(a in ChartOfAccounts, order_by: [asc: a.cosif_code])

    query =
      case Map.get(params, "active") do
        "true" -> where(query, [a], a.active == true)
        "false" -> where(query, [a], a.active == false)
        _ -> query
      end

    query =
      case Map.get(params, "type") do
        nil -> query
        type -> where(query, [a], a.account_type == ^type)
      end

    Repo.all(query)
  end

  def get_account_by_cosif(cosif_code) do
    case Repo.get_by(ChartOfAccounts, cosif_code: cosif_code) do
      nil -> {:error, :not_found}
      account -> {:ok, account}
    end
  end

  # ==========================================
  # Journal Entries
  # ==========================================

  def list_journal_entries(params \\ %{}) do
    query =
      from(j in JournalEntry,
        join: debit in assoc(j, :debit_account),
        join: credit in assoc(j, :credit_account),
        select: %{
          id: j.id,
          date: j.date,
          account: debit.cosif_code,
          cosif_code: debit.cosif_code,
          description: j.description,
          debit: j.amount,
          credit: 0,
          balance: 0,
          reference: j.reference,
          status: j.status,
          source_type: j.source_type,
          inserted_at: j.inserted_at
        },
        order_by: [desc: j.date, desc: j.inserted_at]
      )

    query = apply_journal_filters(query, params)

    # Build paired debit/credit entries for the frontend
    entries = Repo.all(query)

    credit_query =
      from(j in JournalEntry,
        join: credit in assoc(j, :credit_account),
        select: %{
          id: j.id,
          date: j.date,
          account: credit.cosif_code,
          cosif_code: credit.cosif_code,
          description: j.description,
          debit: 0,
          credit: j.amount,
          balance: 0,
          reference: j.reference,
          status: j.status,
          source_type: j.source_type,
          inserted_at: j.inserted_at
        },
        order_by: [desc: j.date, desc: j.inserted_at]
      )

    credit_query = apply_journal_filters(credit_query, params)
    credit_entries = Repo.all(credit_query)

    all_entries =
      (entries ++ credit_entries)
      |> Enum.sort_by(& &1.date, {:desc, Date})
      |> calculate_running_balance()

    total = length(all_entries)
    %{entries: all_entries, total: total}
  end

  defp apply_journal_filters(query, params) do
    query =
      case Map.get(params, "startDate") do
        nil -> query
        date -> where(query, [j], j.date >= ^Date.from_iso8601!(date))
      end

    query =
      case Map.get(params, "endDate") do
        nil -> query
        date -> where(query, [j], j.date <= ^Date.from_iso8601!(date))
      end

    case Map.get(params, "account") do
      nil -> query
      "" -> query
      account ->
        account_pattern = "%#{account}%"
        # Filter at application level since we already joined
        query
    end
  end

  defp calculate_running_balance(entries) do
    {result, _} =
      Enum.reduce(Enum.reverse(entries), {[], 0}, fn entry, {acc, balance} ->
        new_balance = balance + entry.debit - entry.credit
        {[%{entry | balance: new_balance} | acc], new_balance}
      end)

    result
  end

  def create_journal_entry(attrs) do
    %JournalEntry{}
    |> JournalEntry.changeset(attrs)
    |> Repo.insert()
  end

  # ==========================================
  # Accounting Events
  # ==========================================

  def list_accounting_events(params \\ %{}) do
    query = from(e in AccountingEvent, order_by: [desc: e.date, desc: e.inserted_at])

    query =
      case Map.get(params, "type") do
        nil -> query
        "all" -> query
        type -> where(query, [e], e.type == ^type)
      end

    query =
      case Map.get(params, "status") do
        nil -> query
        "all" -> query
        status -> where(query, [e], e.status == ^status)
      end

    events = Repo.all(query)
    total = length(events)
    %{events: events, total: total}
  end

  def create_accounting_event(attrs) do
    attrs =
      attrs
      |> Map.put_new("type", "manual")
      |> Map.put_new("date", Date.utc_today() |> Date.to_iso8601())
      |> Map.put_new("status", "pending")
      |> Map.put_new("reference", "MAN-#{System.unique_integer([:positive])}")

    %AccountingEvent{}
    |> AccountingEvent.changeset(attrs)
    |> Repo.insert()
    |> case do
      {:ok, event} ->
        # Try to auto-create journal entry if accounts are valid COSIF codes
        maybe_create_journal_from_event(event)
        {:ok, event}

      error ->
        error
    end
  end

  defp maybe_create_journal_from_event(event) do
    with {:ok, debit_acc} <- get_account_by_cosif(event.debit_account),
         {:ok, credit_acc} <- get_account_by_cosif(event.credit_account) do
      create_journal_entry(%{
        "date" => event.date |> Date.to_iso8601(),
        "debit_account_id" => debit_acc.id,
        "credit_account_id" => credit_acc.id,
        "amount" => event.amount,
        "description" => event.description || event.event_type,
        "reference" => event.reference,
        "source_type" => event.event_type,
        "source_id" => event.id,
        "created_by" => event.user
      })

      # Mark event as processed
      event
      |> Ecto.Changeset.change(status: "processed")
      |> Repo.update()
    else
      _ -> :ok
    end
  end

  # ==========================================
  # Cost Centers
  # ==========================================

  def list_cost_centers(params \\ %{}) do
    query = from(c in CostCenter, order_by: [asc: c.code])

    query =
      case Map.get(params, "search") do
        nil -> query
        "" -> query
        search ->
          pattern = "%#{search}%"
          where(query, [c],
            ilike(c.code, ^pattern) or ilike(c.name, ^pattern) or ilike(c.responsible, ^pattern)
          )
      end

    query =
      case Map.get(params, "status") do
        nil -> query
        status -> where(query, [c], c.status == ^status)
      end

    centers = Repo.all(query)
    total = length(centers)
    %{costCenters: centers, total: total}
  end

  def get_cost_center(id) do
    case Repo.get(CostCenter, id) do
      nil -> {:error, :not_found}
      center -> {:ok, center}
    end
  end

  def create_cost_center(attrs) do
    %CostCenter{}
    |> CostCenter.changeset(attrs)
    |> Repo.insert()
  end

  def update_cost_center(id, attrs) do
    with {:ok, center} <- get_cost_center(id) do
      center
      |> CostCenter.changeset(attrs)
      |> Repo.update()
    end
  end

  def toggle_cost_center_status(id) do
    with {:ok, center} <- get_cost_center(id) do
      center
      |> CostCenter.toggle_status_changeset()
      |> Repo.update()
    end
  end
end
```

**Step 2: Verify compilation**

Run: `cd /monetarie/pix/backend && mix compile --warnings-as-errors`
Expected: Compiles without errors.

**Step 3: Commit**

```bash
git add backend/apps/settlement_service/lib/settlement_service/accounting.ex
git commit -m "feat: add Accounting context with COSIF, journal entries, events, cost centers

Full CRUD operations for all 4 accounting entities. Auto-creates
journal entries from accounting events when COSIF codes are valid.
Running balance calculation for journal entry listing."
```

---

## Task 4: Accounting Controller + Routes

**Files:**
- Create: `backend/apps/settlement_service/lib/settlement_service_web/controllers/accounting_controller.ex`
- Modify: `backend/apps/settlement_service/lib/settlement_service_web/router.ex` (add accounting scope inside existing `/api/v1` scope at line ~236)

**Step 1: Write the AccountingController**

Follow the exact pattern from `NettingController`:
- `use SettlementServiceWeb, :controller`
- `action_fallback SettlementServiceWeb.FallbackController`
- Private `*_data/1` serializer functions
- `%{data: ...}` response envelope

```elixir
# backend/apps/settlement_service/lib/settlement_service_web/controllers/accounting_controller.ex
defmodule SettlementServiceWeb.AccountingController do
  use SettlementServiceWeb, :controller
  alias SettlementService.Accounting

  action_fallback SettlementServiceWeb.FallbackController

  # ==========================================
  # Chart of Accounts
  # ==========================================

  def list_accounts(conn, params) do
    accounts = Accounting.list_chart_of_accounts(params)
    json(conn, %{data: Enum.map(accounts, &account_data/1)})
  end

  # ==========================================
  # Journal Entries
  # ==========================================

  def list_journal_entries(conn, params) do
    %{entries: entries, total: total} = Accounting.list_journal_entries(params)
    json(conn, %{entries: entries, total: total})
  end

  # ==========================================
  # Accounting Events
  # ==========================================

  def list_events(conn, params) do
    %{events: events, total: total} = Accounting.list_accounting_events(params)
    json(conn, %{events: Enum.map(events, &event_data/1), total: total})
  end

  def create_event(conn, params) do
    with {:ok, event} <- Accounting.create_accounting_event(params) do
      conn
      |> put_status(:created)
      |> json(event_data(event))
    end
  end

  # ==========================================
  # Cost Centers
  # ==========================================

  def list_cost_centers(conn, params) do
    %{costCenters: centers, total: total} = Accounting.list_cost_centers(params)
    json(conn, %{costCenters: Enum.map(centers, &cost_center_data/1), total: total})
  end

  def create_cost_center(conn, params) do
    with {:ok, center} <- Accounting.create_cost_center(params) do
      conn
      |> put_status(:created)
      |> json(cost_center_data(center))
    end
  end

  def update_cost_center(conn, %{"id" => id} = params) do
    with {:ok, center} <- Accounting.update_cost_center(id, params) do
      json(conn, cost_center_data(center))
    end
  end

  def toggle_cost_center_status(conn, %{"id" => id}) do
    with {:ok, center} <- Accounting.toggle_cost_center_status(id) do
      json(conn, cost_center_data(center))
    end
  end

  # ==========================================
  # Private Serializers
  # ==========================================

  defp account_data(account) do
    %{
      id: account.id,
      cosifCode: account.cosif_code,
      name: account.name,
      accountType: account.account_type,
      parentCode: account.parent_code,
      level: account.level,
      active: account.active
    }
  end

  defp event_data(event) do
    %{
      id: event.id,
      type: event.type,
      eventType: event.event_type,
      description: event.description,
      amount: event.amount,
      debitAccount: event.debit_account,
      creditAccount: event.credit_account,
      date: event.date,
      user: event.user,
      status: event.status,
      reference: event.reference
    }
  end

  defp cost_center_data(center) do
    %{
      id: center.id,
      code: center.code,
      name: center.name,
      responsible: center.responsible,
      status: center.status,
      budget: center.budget,
      spent: center.spent,
      createdAt: center.inserted_at
    }
  end
end
```

**Step 2: Add routes to router**

Add an `accounting` scope inside the existing `/api/v1` scope block (after the `balance-operations` scope which ends at line 236, before the gateway section at line 239).

Insert at line 237 of `backend/apps/settlement_service/lib/settlement_service_web/router.ex`:

```elixir
    # ==========================================
    # GL Accounting
    # ==========================================
    scope "/accounting" do
      get "/chart-of-accounts", AccountingController, :list_accounts
      get "/journal-entries", AccountingController, :list_journal_entries
      get "/events", AccountingController, :list_events
      post "/events", AccountingController, :create_event
      get "/cost-centers", AccountingController, :list_cost_centers
      post "/cost-centers", AccountingController, :create_cost_center
      put "/cost-centers/:id", AccountingController, :update_cost_center
      post "/cost-centers/:id/toggle-status", AccountingController, :toggle_cost_center_status
    end
```

**Step 3: Verify compilation**

Run: `cd /monetarie/pix/backend && mix compile --warnings-as-errors`
Expected: Compiles without errors.

**Step 4: Commit**

```bash
git add backend/apps/settlement_service/lib/settlement_service_web/controllers/accounting_controller.ex
git add backend/apps/settlement_service/lib/settlement_service_web/router.ex
git commit -m "feat: add accounting controller and routes

Endpoints: GET /api/v1/accounting/journal-entries,
GET/POST /api/v1/accounting/events,
GET/POST/PUT /api/v1/accounting/cost-centers,
POST /api/v1/accounting/cost-centers/:id/toggle-status,
GET /api/v1/accounting/chart-of-accounts"
```

---

## Task 5: Phoenix Channel — UserSocket + MonitoringChannel

**Files:**
- Create: `backend/apps/settlement_service/lib/settlement_service_web/channels/user_socket.ex`
- Create: `backend/apps/settlement_service/lib/settlement_service_web/channels/monitoring_channel.ex`
- Modify: `backend/apps/settlement_service/lib/settlement_service_web/endpoint.ex` (add socket declaration at line 11)

**Step 1: Create UserSocket**

```elixir
# backend/apps/settlement_service/lib/settlement_service_web/channels/user_socket.ex
defmodule SettlementServiceWeb.UserSocket do
  use Phoenix.Socket

  channel "transactions:*", SettlementServiceWeb.MonitoringChannel
  channel "system:*", SettlementServiceWeb.MonitoringChannel
  channel "settlement:*", SettlementServiceWeb.MonitoringChannel
  channel "dict:*", SettlementServiceWeb.MonitoringChannel
  channel "bacen:*", SettlementServiceWeb.MonitoringChannel
  channel "queues:*", SettlementServiceWeb.MonitoringChannel

  @impl true
  def connect(%{"token" => token}, socket, _connect_info) do
    case Shared.Auth.JwtAuth.verify_token(token) do
      {:ok, claims} ->
        {:ok, assign(socket, :user_id, claims["sub"])}

      {:error, _reason} ->
        :error
    end
  end

  def connect(_params, _socket, _connect_info), do: :error

  @impl true
  def id(socket), do: "user_socket:#{socket.assigns.user_id}"
end
```

**Step 2: Create MonitoringChannel**

```elixir
# backend/apps/settlement_service/lib/settlement_service_web/channels/monitoring_channel.ex
defmodule SettlementServiceWeb.MonitoringChannel do
  use SettlementServiceWeb, :channel

  @impl true
  def join("transactions:live", _params, socket) do
    {:ok, %{status: "connected", topic: "transactions:live"}, socket}
  end

  def join("system:health", _params, socket) do
    {:ok, %{status: "connected", topic: "system:health"}, socket}
  end

  def join("settlement:status", _params, socket) do
    {:ok, %{status: "connected", topic: "settlement:status"}, socket}
  end

  def join("dict:operations", _params, socket) do
    {:ok, %{status: "connected", topic: "dict:operations"}, socket}
  end

  def join("bacen:channels", _params, socket) do
    {:ok, %{status: "connected", topic: "bacen:channels"}, socket}
  end

  def join("queues:depth", _params, socket) do
    {:ok, %{status: "connected", topic: "queues:depth"}, socket}
  end

  def join(_topic, _params, _socket), do: {:error, %{reason: "unauthorized"}}
end
```

**Step 3: Add socket to endpoint**

In `backend/apps/settlement_service/lib/settlement_service_web/endpoint.ex`, add after line 11 (the existing `/live` socket):

```elixir
  socket "/socket", SettlementServiceWeb.UserSocket,
    websocket: [timeout: 45_000],
    longpoll: false
```

**Step 4: Verify compilation**

Run: `cd /monetarie/pix/backend && mix compile --warnings-as-errors`
Expected: Compiles without errors.

**Step 5: Commit**

```bash
git add backend/apps/settlement_service/lib/settlement_service_web/channels/
git add backend/apps/settlement_service/lib/settlement_service_web/endpoint.ex
git commit -m "feat: add Phoenix Channels for real-time monitoring

UserSocket at /socket with JWT auth, MonitoringChannel handling
6 topics: transactions:live, system:health, settlement:status,
dict:operations, bacen:channels, queues:depth"
```

---

## Task 6: Monitoring Broadcaster + Health Checker + Queue Monitor

**Files:**
- Create: `backend/apps/settlement_service/lib/settlement_service/monitoring/broadcaster.ex`
- Create: `backend/apps/settlement_service/lib/settlement_service/monitoring/health_checker.ex`
- Create: `backend/apps/settlement_service/lib/settlement_service/monitoring/queue_monitor.ex`
- Modify: `backend/apps/settlement_service/lib/settlement_service/application.ex` (add monitoring children)

**Step 1: Create Broadcaster GenServer**

This is the central hub — receives events (from NATS workers or direct calls) and broadcasts to Phoenix Channel topics.

```elixir
# backend/apps/settlement_service/lib/settlement_service/monitoring/broadcaster.ex
defmodule SettlementService.Monitoring.Broadcaster do
  @moduledoc """
  Central event broadcaster for real-time monitoring.

  Receives events from NATS workers, health checks, and direct calls,
  then broadcasts them to the appropriate Phoenix Channel topics.
  """

  use GenServer

  @pubsub SettlementService.PubSub

  def start_link(opts) do
    GenServer.start_link(__MODULE__, opts, name: __MODULE__)
  end

  # Public API — called by NATS workers, health checks, etc.

  def broadcast_transaction(event_type, data) do
    Phoenix.PubSub.broadcast(@pubsub, "transactions:live", {event_type, data})
  end

  def broadcast_system_health(data) do
    Phoenix.PubSub.broadcast(@pubsub, "system:health", {"health_update", data})
  end

  def broadcast_settlement(event_type, data) do
    Phoenix.PubSub.broadcast(@pubsub, "settlement:status", {event_type, data})
  end

  def broadcast_dict_operation(event_type, data) do
    Phoenix.PubSub.broadcast(@pubsub, "dict:operations", {event_type, data})
  end

  def broadcast_bacen_channels(data) do
    Phoenix.PubSub.broadcast(@pubsub, "bacen:channels", {"channel_health_update", data})
  end

  def broadcast_queue_depths(data) do
    Phoenix.PubSub.broadcast(@pubsub, "queues:depth", {"queue_depth_update", data})
  end

  # GenServer callbacks

  @impl true
  def init(_opts) do
    {:ok, %{started_at: DateTime.utc_now()}}
  end
end
```

**Step 2: Create HealthChecker GenServer**

Periodic health checks every 10 seconds, broadcasts results to `system:health` and `bacen:channels`.

```elixir
# backend/apps/settlement_service/lib/settlement_service/monitoring/health_checker.ex
defmodule SettlementService.Monitoring.HealthChecker do
  @moduledoc """
  Periodic health checker that broadcasts system health and BACEN channel
  status to connected WebSocket clients every 10 seconds.
  """

  use GenServer
  require Logger

  alias SettlementService.Monitoring.Broadcaster

  @check_interval 10_000
  @bacen_heartbeat_interval 30_000

  def start_link(opts) do
    GenServer.start_link(__MODULE__, opts, name: __MODULE__)
  end

  @impl true
  def init(_opts) do
    Process.send_after(self(), :check_health, @check_interval)
    Process.send_after(self(), :check_bacen, @bacen_heartbeat_interval)
    {:ok, %{last_health: nil, last_bacen: nil}}
  end

  @impl true
  def handle_info(:check_health, state) do
    health = perform_health_check()
    Broadcaster.broadcast_system_health(health)
    Process.send_after(self(), :check_health, @check_interval)
    {:noreply, %{state | last_health: health}}
  end

  def handle_info(:check_bacen, state) do
    bacen_status = check_bacen_channels()
    Broadcaster.broadcast_bacen_channels(bacen_status)
    Process.send_after(self(), :check_bacen, @bacen_heartbeat_interval)
    {:noreply, %{state | last_bacen: bacen_status}}
  end

  defp perform_health_check do
    services =
      [
        check_service("dict-service", "http://localhost:4001/health"),
        check_service("spi-service", "http://localhost:4002/health"),
        check_service("settlement-service", "http://localhost:4003/health")
      ]

    infra = %{
      database: check_database(),
      redis: check_redis(),
      nats: check_nats()
    }

    overall =
      cond do
        Enum.any?(services, &(&1.status == "unhealthy")) or
          infra.database.status == "unhealthy" ->
          "unhealthy"

        Enum.any?(services, &(&1.status == "degraded")) or
          infra.redis.status != "healthy" or
          infra.nats.status != "healthy" ->
          "degraded"

        true ->
          "healthy"
      end

    %{
      overall: overall,
      services: services,
      database: infra.database,
      redis: infra.redis,
      nats: infra.nats,
      lastUpdatedAt: DateTime.utc_now() |> DateTime.to_iso8601()
    }
  end

  defp check_service(name, url) do
    start = System.monotonic_time(:millisecond)

    result =
      try do
        case Req.get(url, receive_timeout: 5_000) do
          {:ok, %{status: status}} when status in 200..299 ->
            "healthy"

          {:ok, _} ->
            "degraded"

          {:error, _} ->
            "unhealthy"
        end
      rescue
        _ -> "unhealthy"
      end

    elapsed = System.monotonic_time(:millisecond) - start

    %{
      name: name,
      status: result,
      responseTimeMs: elapsed,
      lastCheckAt: DateTime.utc_now() |> DateTime.to_iso8601()
    }
  end

  defp check_database do
    start = System.monotonic_time(:millisecond)

    status =
      try do
        Ecto.Adapters.SQL.query!(SettlementService.Repo, "SELECT 1", [])
        "healthy"
      rescue
        _ -> "unhealthy"
      end

    %{status: status, responseTimeMs: System.monotonic_time(:millisecond) - start}
  end

  defp check_redis do
    start = System.monotonic_time(:millisecond)

    status =
      try do
        case Shared.Redis.Connection.command(["PING"]) do
          {:ok, "PONG"} -> "healthy"
          _ -> "degraded"
        end
      rescue
        _ -> "unhealthy"
      end

    %{status: status, responseTimeMs: System.monotonic_time(:millisecond) - start}
  end

  defp check_nats do
    nats_enabled = System.get_env("NATS_ENABLED", "false") == "true"

    if nats_enabled do
      %{status: "healthy", responseTimeMs: 0}
    else
      %{status: "disabled", responseTimeMs: 0}
    end
  end

  defp check_bacen_channels do
    try do
      status = Shared.Bacen.ChannelRouter.status()

      %{
        primary: %{
          status: to_string(status.primary.status),
          consecutiveFailures: status.primary.consecutive_failures,
          totalRequests: status.primary.total_requests,
          lastSuccess: status.primary.last_success
        },
        secondary: %{
          status: to_string(status.secondary.status),
          consecutiveFailures: status.secondary.consecutive_failures,
          totalRequests: status.secondary.total_requests,
          lastSuccess: status.secondary.last_success
        },
        lastCheckAt: DateTime.utc_now() |> DateTime.to_iso8601()
      }
    rescue
      _ ->
        %{
          primary: %{status: "unknown"},
          secondary: %{status: "unknown"},
          lastCheckAt: DateTime.utc_now() |> DateTime.to_iso8601()
        }
    end
  end
end
```

**Step 3: Create QueueMonitor GenServer**

Polls NATS queue depths every 15 seconds.

```elixir
# backend/apps/settlement_service/lib/settlement_service/monitoring/queue_monitor.ex
defmodule SettlementService.Monitoring.QueueMonitor do
  @moduledoc """
  Monitors NATS JetStream consumer lag and broadcasts queue depth
  information to connected WebSocket clients every 15 seconds.
  """

  use GenServer
  require Logger

  alias SettlementService.Monitoring.Broadcaster

  @poll_interval 15_000

  @consumers [
    %{stream: "MONETARIE_SPI", consumer: "spi-inbound-processor", label: "SPI Inbound"},
    %{stream: "MONETARIE_SPI", consumer: "spi-outbound-sender", label: "SPI Outbound"},
    %{stream: "MONETARIE_SPI", consumer: "spi-return-processor", label: "SPI Returns"},
    %{stream: "MONETARIE_SPI", consumer: "spi-status-updater", label: "SPI Status"},
    %{stream: "MONETARIE_SETTLEMENT", consumer: "settlement-scheduler", label: "Settlement Scheduler"},
    %{stream: "MONETARIE_SETTLEMENT", consumer: "settlement-file-importer", label: "Settlement Files"}
  ]

  def start_link(opts) do
    GenServer.start_link(__MODULE__, opts, name: __MODULE__)
  end

  @impl true
  def init(_opts) do
    nats_enabled = System.get_env("NATS_ENABLED", "false") == "true"

    if nats_enabled do
      Process.send_after(self(), :poll_queues, @poll_interval)
    end

    {:ok, %{nats_enabled: nats_enabled, last_depths: %{}}}
  end

  @impl true
  def handle_info(:poll_queues, %{nats_enabled: true} = state) do
    depths = poll_consumer_depths()
    Broadcaster.broadcast_queue_depths(depths)
    Process.send_after(self(), :poll_queues, @poll_interval)
    {:noreply, %{state | last_depths: depths}}
  end

  def handle_info(:poll_queues, state) do
    {:noreply, state}
  end

  defp poll_consumer_depths do
    queues =
      Enum.map(@consumers, fn %{stream: stream, consumer: consumer, label: label} ->
        depth = get_consumer_pending(stream, consumer)
        %{label: label, stream: stream, consumer: consumer, pending: depth}
      end)

    %{
      queues: queues,
      lastCheckAt: DateTime.utc_now() |> DateTime.to_iso8601()
    }
  end

  defp get_consumer_pending(stream, consumer) do
    try do
      case Shared.Nats.JetStream.consumer_info(stream, consumer) do
        {:ok, info} -> Map.get(info, :num_pending, 0)
        _ -> -1
      end
    rescue
      _ -> -1
    end
  end
end
```

**Step 4: Add monitoring processes to Application supervision tree**

In `backend/apps/settlement_service/lib/settlement_service/application.ex`, add monitoring children to `base_children` (after Endpoint, before NATS conditional block). Insert after line 24:

```elixir
      # Start monitoring processes
      SettlementService.Monitoring.Broadcaster,
      SettlementService.Monitoring.HealthChecker,
      SettlementService.Monitoring.QueueMonitor
```

**Step 5: Verify compilation**

Run: `cd /monetarie/pix/backend && mix compile --warnings-as-errors`
Expected: Compiles without errors.

**Step 6: Commit**

```bash
git add backend/apps/settlement_service/lib/settlement_service/monitoring/
git add backend/apps/settlement_service/lib/settlement_service/application.ex
git commit -m "feat: add monitoring broadcaster, health checker, queue monitor

Broadcaster: central hub for Phoenix Channel broadcasts.
HealthChecker: 10s system health + 30s BACEN channel status checks.
QueueMonitor: 15s NATS consumer lag polling. All 3 added to supervision tree."
```

---

## Task 7: Wire MonitoringChannel to PubSub broadcasts

**Files:**
- Modify: `backend/apps/settlement_service/lib/settlement_service_web/channels/monitoring_channel.ex`

The channel needs to subscribe to PubSub topics on join and push messages to the WebSocket client on broadcast.

**Step 1: Update MonitoringChannel with PubSub subscriptions**

Replace the channel file with the version that subscribes to PubSub on join and handles broadcasts:

```elixir
# backend/apps/settlement_service/lib/settlement_service_web/channels/monitoring_channel.ex
defmodule SettlementServiceWeb.MonitoringChannel do
  use SettlementServiceWeb, :channel

  @impl true
  def join(topic, _params, socket) when topic in [
    "transactions:live", "system:health", "settlement:status",
    "dict:operations", "bacen:channels", "queues:depth"
  ] do
    Phoenix.PubSub.subscribe(SettlementService.PubSub, topic)
    {:ok, %{status: "connected", topic: topic}, socket}
  end

  def join(_topic, _params, _socket), do: {:error, %{reason: "unauthorized"}}

  @impl true
  def handle_info({event_type, data}, socket) when is_binary(event_type) do
    push(socket, event_type, data)
    {:noreply, socket}
  end

  def handle_info({event_type, data}, socket) when is_atom(event_type) do
    push(socket, to_string(event_type), data)
    {:noreply, socket}
  end

  def handle_info(_msg, socket) do
    {:noreply, socket}
  end
end
```

**Step 2: Verify compilation**

Run: `cd /monetarie/pix/backend && mix compile --warnings-as-errors`
Expected: Compiles without errors.

**Step 3: Commit**

```bash
git add backend/apps/settlement_service/lib/settlement_service_web/channels/monitoring_channel.ex
git commit -m "feat: wire MonitoringChannel to PubSub for real-time broadcasts

Channel subscribes to PubSub topics on join and pushes events
to connected WebSocket clients."
```

---

## Task 8: Frontend — Install phoenix npm package

**Files:**
- Modify: `frontend/admin/package.json` (add `phoenix` dependency)

**Step 1: Install phoenix JS client**

Run: `cd /monetarie/pix/frontend/admin && npm install phoenix`
Expected: Package installed successfully.

**Step 2: Commit**

```bash
git add frontend/admin/package.json frontend/admin/package-lock.json
git commit -m "chore: add phoenix JS client for WebSocket monitoring"
```

---

## Task 9: Frontend — Monitoring WebSocket Pinia Store

**Files:**
- Create: `frontend/admin/src/stores/monitoring.ts`

**Step 1: Create the monitoring store**

```typescript
// frontend/admin/src/stores/monitoring.ts
import { defineStore } from 'pinia'
import { ref, computed } from 'vue'
import { Socket, Channel } from 'phoenix'

export interface TransactionEvent {
  id: string
  type: string
  amount: number
  status: string
  from_ispb: string
  to_ispb: string
  timestamp: string
  end_to_end_id: string
}

export interface SystemHealthData {
  overall: string
  services: Array<{
    name: string
    status: string
    responseTimeMs: number
    lastCheckAt: string
  }>
  database: { status: string; responseTimeMs: number }
  redis: { status: string; responseTimeMs: number }
  nats: { status: string; responseTimeMs: number }
  lastUpdatedAt: string
}

export interface SettlementEvent {
  type: string
  id: string
  status: string
  details: Record<string, unknown>
  timestamp: string
}

export interface DictEvent {
  type: string
  keyType: string
  key: string
  participant: string
  timestamp: string
}

export interface BacenChannelStatus {
  primary: { status: string; consecutiveFailures: number; totalRequests: number; lastSuccess: string | null }
  secondary: { status: string; consecutiveFailures: number; totalRequests: number; lastSuccess: string | null }
  lastCheckAt: string
}

export interface QueueDepthData {
  queues: Array<{
    label: string
    stream: string
    consumer: string
    pending: number
  }>
  lastCheckAt: string
}

export const useMonitoringStore = defineStore('monitoring', () => {
  // Connection state
  const connected = ref(false)
  const reconnecting = ref(false)

  // Data state
  const transactions = ref<TransactionEvent[]>([])
  const systemHealth = ref<SystemHealthData | null>(null)
  const settlementEvents = ref<SettlementEvent[]>([])
  const dictEvents = ref<DictEvent[]>([])
  const bacenChannels = ref<BacenChannelStatus | null>(null)
  const queueDepths = ref<QueueDepthData | null>(null)

  // Metrics
  const tpsCounter = ref(0)
  const tpsHistory = ref<number[]>([])
  const errorRate = ref(0)

  // Internal refs
  let socket: Socket | null = null
  let channels: Channel[] = []
  let tpsInterval: ReturnType<typeof setInterval> | null = null
  let txCountInWindow = 0

  const MAX_TRANSACTIONS = 100
  const MAX_EVENTS = 50

  // Computed
  const isHealthy = computed(() => systemHealth.value?.overall === 'healthy')

  function connect(token: string) {
    if (socket) disconnect()

    const wsUrl = `${window.location.protocol === 'https:' ? 'wss:' : 'ws:'}//${window.location.host}/socket`
    socket = new Socket(wsUrl, { params: { token } })

    socket.onOpen(() => {
      connected.value = true
      reconnecting.value = false
      joinChannels()
    })

    socket.onClose(() => {
      connected.value = false
      reconnecting.value = true
    })

    socket.onError(() => {
      connected.value = false
      reconnecting.value = true
    })

    socket.connect()
    startTpsCounter()
  }

  function disconnect() {
    channels.forEach((ch) => ch.leave())
    channels = []
    socket?.disconnect()
    socket = null
    connected.value = false
    reconnecting.value = false
    stopTpsCounter()
  }

  function joinChannels() {
    if (!socket) return

    // Transactions
    const txCh = socket.channel('transactions:live', {})
    txCh.on('new_payment', (data: TransactionEvent) => {
      transactions.value = [data, ...transactions.value].slice(0, MAX_TRANSACTIONS)
      txCountInWindow++
    })
    txCh.on('payment_confirmed', (data: TransactionEvent) => {
      updateTransaction(data)
    })
    txCh.on('payment_rejected', (data: TransactionEvent) => {
      updateTransaction(data)
      errorRate.value = calculateErrorRate()
    })
    txCh.on('return_initiated', (data: TransactionEvent) => {
      transactions.value = [data, ...transactions.value].slice(0, MAX_TRANSACTIONS)
    })
    txCh.join()
    channels.push(txCh)

    // System Health
    const healthCh = socket.channel('system:health', {})
    healthCh.on('health_update', (data: SystemHealthData) => {
      systemHealth.value = data
    })
    healthCh.join()
    channels.push(healthCh)

    // Settlement
    const settleCh = socket.channel('settlement:status', {})
    settleCh.on('netting_cycle_update', (data: SettlementEvent) => {
      settlementEvents.value = [data, ...settlementEvents.value].slice(0, MAX_EVENTS)
    })
    settleCh.on('reconciliation_update', (data: SettlementEvent) => {
      settlementEvents.value = [data, ...settlementEvents.value].slice(0, MAX_EVENTS)
    })
    settleCh.join()
    channels.push(settleCh)

    // DICT
    const dictCh = socket.channel('dict:operations', {})
    dictCh.on('key_registered', (data: DictEvent) => {
      dictEvents.value = [data, ...dictEvents.value].slice(0, MAX_EVENTS)
    })
    dictCh.on('key_removed', (data: DictEvent) => {
      dictEvents.value = [data, ...dictEvents.value].slice(0, MAX_EVENTS)
    })
    dictCh.on('claim_started', (data: DictEvent) => {
      dictEvents.value = [data, ...dictEvents.value].slice(0, MAX_EVENTS)
    })
    dictCh.join()
    channels.push(dictCh)

    // BACEN Channels
    const bacenCh = socket.channel('bacen:channels', {})
    bacenCh.on('channel_health_update', (data: BacenChannelStatus) => {
      bacenChannels.value = data
    })
    bacenCh.join()
    channels.push(bacenCh)

    // Queue Depths
    const queueCh = socket.channel('queues:depth', {})
    queueCh.on('queue_depth_update', (data: QueueDepthData) => {
      queueDepths.value = data
    })
    queueCh.join()
    channels.push(queueCh)
  }

  function updateTransaction(data: TransactionEvent) {
    const idx = transactions.value.findIndex((t) => t.id === data.id)
    if (idx >= 0) {
      transactions.value[idx] = { ...transactions.value[idx], ...data }
    }
  }

  function calculateErrorRate(): number {
    const recent = transactions.value.slice(0, 50)
    if (recent.length === 0) return 0
    const errors = recent.filter((t) => t.status === 'rejected' || t.status === 'error').length
    return Math.round((errors / recent.length) * 100)
  }

  function startTpsCounter() {
    tpsInterval = setInterval(() => {
      tpsCounter.value = txCountInWindow
      tpsHistory.value = [...tpsHistory.value, txCountInWindow].slice(-60)
      txCountInWindow = 0
    }, 1000)
  }

  function stopTpsCounter() {
    if (tpsInterval) {
      clearInterval(tpsInterval)
      tpsInterval = null
    }
  }

  return {
    // State
    connected,
    reconnecting,
    transactions,
    systemHealth,
    settlementEvents,
    dictEvents,
    bacenChannels,
    queueDepths,
    tpsCounter,
    tpsHistory,
    errorRate,
    // Computed
    isHealthy,
    // Actions
    connect,
    disconnect,
  }
})
```

**Step 2: Commit**

```bash
git add frontend/admin/src/stores/monitoring.ts
git commit -m "feat: add monitoring Pinia store with Phoenix WebSocket client

Manages 6 channel subscriptions, TPS counter, error rate calculation,
auto-reconnect via phoenix JS client."
```

---

## Task 10: Frontend — Real-Time Monitoring Dashboard View

**Files:**
- Create: `frontend/admin/src/views/monitoring/RealTimeMonitoringView.vue`
- Modify: `frontend/admin/src/router/index.ts` (add route)

**Step 1: Create RealTimeMonitoringView.vue**

A 6-panel dashboard using Tailwind CSS grid layout. This is the largest file in the plan — a single-page operational dashboard.

```vue
<script setup lang="ts">
import { onMounted, onUnmounted, computed } from 'vue'
import { useI18n } from 'vue-i18n'
import { useMonitoringStore } from '@/stores/monitoring'

const { t } = useI18n()
const store = useMonitoringStore()

function getStatusColor(status: string): string {
  const colors: Record<string, string> = {
    healthy: 'text-green-600 bg-green-100',
    degraded: 'text-yellow-600 bg-yellow-100',
    unhealthy: 'text-red-600 bg-red-100',
    unknown: 'text-gray-600 bg-gray-100',
    disabled: 'text-gray-400 bg-gray-50',
    down: 'text-red-600 bg-red-100',
  }
  return colors[status] || 'text-gray-600 bg-gray-100'
}

function getStatusDot(status: string): string {
  const colors: Record<string, string> = {
    healthy: 'bg-green-500',
    degraded: 'bg-yellow-500',
    unhealthy: 'bg-red-500',
    unknown: 'bg-gray-400',
    disabled: 'bg-gray-300',
    down: 'bg-red-500',
  }
  return colors[status] || 'bg-gray-400'
}

function formatCurrency(value: number): string {
  return new Intl.NumberFormat('pt-BR', { style: 'currency', currency: 'BRL' }).format(value / 100)
}

function formatTime(timestamp: string): string {
  if (!timestamp) return '-'
  return new Date(timestamp).toLocaleTimeString('pt-BR')
}

const tpsSparkline = computed(() => {
  const history = store.tpsHistory
  if (history.length === 0) return ''
  const max = Math.max(...history, 1)
  const height = 40
  const width = 200
  const step = width / Math.max(history.length - 1, 1)
  const points = history.map((v, i) => `${i * step},${height - (v / max) * height}`).join(' ')
  return points
})

onMounted(() => {
  // Get token from cookie or storage for WebSocket auth
  const token = document.cookie
    .split('; ')
    .find((row) => row.startsWith('auth_token='))
    ?.split('=')[1]
  if (token) {
    store.connect(token)
  }
})

onUnmounted(() => {
  store.disconnect()
})
</script>

<template>
  <div class="space-y-4">
    <!-- Header with connection status -->
    <div class="flex items-center justify-between">
      <div>
        <h1 class="text-2xl font-bold text-gray-900">{{ t('monitoring.title') || 'Real-Time Monitoring' }}</h1>
        <p class="text-gray-600">{{ t('monitoring.description') || 'Live operational dashboard' }}</p>
      </div>
      <div class="flex items-center gap-3">
        <div class="flex items-center gap-2">
          <div
            class="w-2.5 h-2.5 rounded-full"
            :class="store.connected ? 'bg-green-500 animate-pulse' : store.reconnecting ? 'bg-yellow-500 animate-pulse' : 'bg-red-500'"
          ></div>
          <span class="text-sm text-gray-600">
            {{ store.connected ? 'Connected' : store.reconnecting ? 'Reconnecting...' : 'Disconnected' }}
          </span>
        </div>
        <div class="text-right">
          <p class="text-2xl font-bold text-blue-600">{{ store.tpsCounter }} <span class="text-sm font-normal text-gray-500">TPS</span></p>
          <p class="text-xs text-gray-500">{{ store.errorRate }}% error rate</p>
        </div>
      </div>
    </div>

    <!-- 6-Panel Grid -->
    <div class="grid grid-cols-1 lg:grid-cols-2 xl:grid-cols-3 gap-4">
      <!-- Panel 1: Live Transaction Feed -->
      <div class="card col-span-1 xl:col-span-2 max-h-[400px] overflow-hidden flex flex-col">
        <div class="flex items-center justify-between mb-3">
          <h2 class="text-lg font-semibold text-gray-900">Live Transactions</h2>
          <div class="flex items-center gap-2">
            <svg v-if="tpsSparkline" class="text-blue-400" width="200" height="40" viewBox="0 0 200 40">
              <polyline :points="tpsSparkline" fill="none" stroke="currentColor" stroke-width="1.5" />
            </svg>
          </div>
        </div>
        <div class="overflow-y-auto flex-1 -mx-1">
          <div
            v-for="tx in store.transactions"
            :key="tx.id"
            class="flex items-center justify-between px-3 py-2 hover:bg-gray-50 rounded text-sm border-b border-gray-100 last:border-0"
          >
            <div class="flex items-center gap-3 min-w-0">
              <span
                class="inline-flex items-center px-1.5 py-0.5 rounded text-xs font-medium"
                :class="{
                  'bg-green-100 text-green-700': tx.status === 'confirmed',
                  'bg-yellow-100 text-yellow-700': tx.status === 'pending',
                  'bg-red-100 text-red-700': tx.status === 'rejected' || tx.status === 'error',
                  'bg-blue-100 text-blue-700': tx.status === 'return',
                  'bg-gray-100 text-gray-600': !['confirmed','pending','rejected','error','return'].includes(tx.status),
                }"
              >
                {{ tx.status }}
              </span>
              <span class="font-mono text-gray-500 truncate text-xs">{{ tx.end_to_end_id?.slice(0, 16) || tx.id?.slice(0, 8) }}</span>
            </div>
            <div class="flex items-center gap-4 flex-shrink-0">
              <span class="font-medium text-gray-900">{{ formatCurrency(tx.amount) }}</span>
              <span class="text-gray-400 text-xs">{{ formatTime(tx.timestamp) }}</span>
            </div>
          </div>
          <div v-if="store.transactions.length === 0" class="text-center py-8 text-gray-400 text-sm">
            Waiting for transactions...
          </div>
        </div>
      </div>

      <!-- Panel 2: System Health -->
      <div class="card">
        <h2 class="text-lg font-semibold text-gray-900 mb-3">System Health</h2>
        <div v-if="store.systemHealth" class="space-y-3">
          <!-- Overall -->
          <div class="flex items-center gap-2 p-2 rounded-lg" :class="getStatusColor(store.systemHealth.overall)">
            <div class="w-3 h-3 rounded-full" :class="getStatusDot(store.systemHealth.overall)"></div>
            <span class="font-semibold text-sm capitalize">{{ store.systemHealth.overall }}</span>
          </div>
          <!-- Services -->
          <div v-for="svc in store.systemHealth.services" :key="svc.name" class="flex items-center justify-between text-sm">
            <div class="flex items-center gap-2">
              <div class="w-2 h-2 rounded-full" :class="getStatusDot(svc.status)"></div>
              <span class="text-gray-700">{{ svc.name }}</span>
            </div>
            <span class="text-gray-400 font-mono text-xs">{{ svc.responseTimeMs }}ms</span>
          </div>
          <!-- Infrastructure -->
          <div class="border-t border-gray-100 pt-2 mt-2 space-y-1">
            <div class="flex items-center justify-between text-sm">
              <div class="flex items-center gap-2">
                <div class="w-2 h-2 rounded-full" :class="getStatusDot(store.systemHealth.database.status)"></div>
                <span class="text-gray-700">PostgreSQL</span>
              </div>
              <span class="text-gray-400 font-mono text-xs">{{ store.systemHealth.database.responseTimeMs }}ms</span>
            </div>
            <div class="flex items-center justify-between text-sm">
              <div class="flex items-center gap-2">
                <div class="w-2 h-2 rounded-full" :class="getStatusDot(store.systemHealth.redis.status)"></div>
                <span class="text-gray-700">Redis</span>
              </div>
              <span class="text-gray-400 font-mono text-xs">{{ store.systemHealth.redis.responseTimeMs }}ms</span>
            </div>
            <div class="flex items-center justify-between text-sm">
              <div class="flex items-center gap-2">
                <div class="w-2 h-2 rounded-full" :class="getStatusDot(store.systemHealth.nats.status)"></div>
                <span class="text-gray-700">NATS</span>
              </div>
              <span class="text-gray-400 font-mono text-xs">{{ store.systemHealth.nats.responseTimeMs }}ms</span>
            </div>
          </div>
          <p class="text-xs text-gray-400">Updated: {{ formatTime(store.systemHealth.lastUpdatedAt) }}</p>
        </div>
        <div v-else class="text-center py-8 text-gray-400 text-sm">Connecting...</div>
      </div>

      <!-- Panel 3: Settlement Status -->
      <div class="card max-h-[300px] overflow-hidden flex flex-col">
        <h2 class="text-lg font-semibold text-gray-900 mb-3">Settlement</h2>
        <div class="overflow-y-auto flex-1 space-y-2">
          <div
            v-for="evt in store.settlementEvents"
            :key="evt.id"
            class="flex items-center justify-between text-sm p-2 rounded bg-gray-50"
          >
            <div>
              <span class="font-medium text-gray-700">{{ evt.type }}</span>
              <span class="text-gray-400 ml-2 text-xs">{{ evt.status }}</span>
            </div>
            <span class="text-gray-400 text-xs">{{ formatTime(evt.timestamp) }}</span>
          </div>
          <div v-if="store.settlementEvents.length === 0" class="text-center py-6 text-gray-400 text-sm">
            No recent events
          </div>
        </div>
      </div>

      <!-- Panel 4: DICT Operations -->
      <div class="card max-h-[300px] overflow-hidden flex flex-col">
        <h2 class="text-lg font-semibold text-gray-900 mb-3">DICT Operations</h2>
        <div class="overflow-y-auto flex-1 space-y-2">
          <div
            v-for="op in store.dictEvents"
            :key="op.key + op.timestamp"
            class="flex items-center justify-between text-sm p-2 rounded bg-gray-50"
          >
            <div class="min-w-0">
              <span class="font-medium text-gray-700">{{ op.type }}</span>
              <span class="text-gray-500 ml-1 text-xs">{{ op.keyType }}</span>
              <p class="text-xs text-gray-400 truncate">{{ op.key }}</p>
            </div>
            <span class="text-gray-400 text-xs flex-shrink-0">{{ formatTime(op.timestamp) }}</span>
          </div>
          <div v-if="store.dictEvents.length === 0" class="text-center py-6 text-gray-400 text-sm">
            No recent operations
          </div>
        </div>
      </div>

      <!-- Panel 5: BACEN Connectivity -->
      <div class="card">
        <h2 class="text-lg font-semibold text-gray-900 mb-3">BACEN Channels</h2>
        <div v-if="store.bacenChannels" class="space-y-4">
          <!-- CPM (Primary) -->
          <div class="p-3 rounded-lg border border-gray-200">
            <div class="flex items-center justify-between mb-1">
              <span class="font-medium text-sm text-gray-700">CPM (Primary)</span>
              <span
                class="inline-flex items-center px-2 py-0.5 rounded text-xs font-medium"
                :class="getStatusColor(store.bacenChannels.primary.status)"
              >
                {{ store.bacenChannels.primary.status }}
              </span>
            </div>
            <div class="text-xs text-gray-500 space-y-0.5">
              <p>Requests: {{ store.bacenChannels.primary.totalRequests }}</p>
              <p>Failures: {{ store.bacenChannels.primary.consecutiveFailures }}</p>
            </div>
          </div>
          <!-- CSM (Secondary) -->
          <div class="p-3 rounded-lg border border-gray-200">
            <div class="flex items-center justify-between mb-1">
              <span class="font-medium text-sm text-gray-700">CSM (Secondary)</span>
              <span
                class="inline-flex items-center px-2 py-0.5 rounded text-xs font-medium"
                :class="getStatusColor(store.bacenChannels.secondary.status)"
              >
                {{ store.bacenChannels.secondary.status }}
              </span>
            </div>
            <div class="text-xs text-gray-500 space-y-0.5">
              <p>Requests: {{ store.bacenChannels.secondary.totalRequests }}</p>
              <p>Failures: {{ store.bacenChannels.secondary.consecutiveFailures }}</p>
            </div>
          </div>
          <p class="text-xs text-gray-400">Last check: {{ formatTime(store.bacenChannels.lastCheckAt) }}</p>
        </div>
        <div v-else class="text-center py-8 text-gray-400 text-sm">Connecting...</div>
      </div>

      <!-- Panel 6: Queue Depths -->
      <div class="card">
        <h2 class="text-lg font-semibold text-gray-900 mb-3">Queue Depths</h2>
        <div v-if="store.queueDepths" class="space-y-2">
          <div
            v-for="q in store.queueDepths.queues"
            :key="q.consumer"
            class="flex items-center justify-between text-sm"
          >
            <span class="text-gray-700">{{ q.label }}</span>
            <div class="flex items-center gap-2">
              <div class="w-16 bg-gray-200 rounded-full h-1.5">
                <div
                  class="h-1.5 rounded-full transition-all"
                  :class="q.pending > 100 ? 'bg-red-500' : q.pending > 10 ? 'bg-yellow-500' : 'bg-green-500'"
                  :style="{ width: `${Math.min((q.pending / 200) * 100, 100)}%` }"
                ></div>
              </div>
              <span class="font-mono text-xs w-8 text-right" :class="q.pending > 100 ? 'text-red-600' : q.pending > 10 ? 'text-yellow-600' : 'text-gray-500'">
                {{ q.pending >= 0 ? q.pending : '?' }}
              </span>
            </div>
          </div>
          <p class="text-xs text-gray-400 mt-2">Updated: {{ formatTime(store.queueDepths.lastCheckAt) }}</p>
        </div>
        <div v-else class="text-center py-8 text-gray-400 text-sm">
          {{ store.connected ? 'Waiting for data...' : 'Connecting...' }}
        </div>
      </div>
    </div>
  </div>
</template>
```

**Step 2: Add route to router**

In `frontend/admin/src/router/index.ts`, add a lazy import at the top with the other monitoring views, and add the route entry after the existing `monitoring` route (around line 266):

Add this route entry after the `monitoring` route:
```typescript
      {
        path: 'monitoring/realtime',
        name: 'monitoring-realtime',
        component: () => import('@/views/monitoring/RealTimeMonitoringView.vue'),
      },
```

**Step 3: Commit**

```bash
git add frontend/admin/src/views/monitoring/RealTimeMonitoringView.vue
git add frontend/admin/src/router/index.ts
git commit -m "feat: add real-time monitoring dashboard view

6-panel dashboard: live transactions with TPS sparkline, system health,
settlement status, DICT operations, BACEN channel health, queue depths.
Connected via Phoenix WebSocket channels."
```

---

## Task 11: Update Frontend Proxy for WebSocket

**Files:**
- Modify: `frontend/admin/vite.config.ts` (add WebSocket proxy)

**Step 1: Check current vite config and add WebSocket proxy**

The existing proxy config for `/api` needs a `/socket` entry to proxy WebSocket connections to the backend.

Add to the `server.proxy` configuration:
```typescript
      '/socket': {
        target: 'http://localhost:4003',
        ws: true,
      },
```

**Step 2: Commit**

```bash
git add frontend/admin/vite.config.ts
git commit -m "chore: add WebSocket proxy for Phoenix Channels in vite dev server"
```

---

## Task 12: Final Verification

**Step 1: Verify backend compiles**

Run: `cd /monetarie/pix/backend && mix compile --warnings-as-errors`
Expected: All 3 apps compile without errors.

**Step 2: Verify frontend builds**

Run: `cd /monetarie/pix/frontend/admin && npm run build`
Expected: Build succeeds with no TypeScript errors.

**Step 3: Verify routes list**

Run: `cd /monetarie/pix/backend && mix phx.routes SettlementServiceWeb.Router | grep accounting`
Expected: Shows all 8 accounting routes.

**Step 4: Final commit (if any fixups needed)**

```bash
git add -A
git commit -m "fix: address any compilation or build issues from monitoring + accounting implementation"
```

---

## Summary

| Task | What | Files | Est. Lines |
|------|------|-------|------------|
| 1 | DB Migration + COSIF seed | 1 migration | ~180 |
| 2 | 4 Ecto schemas | 4 schemas | ~220 |
| 3 | Accounting context | 1 context | ~230 |
| 4 | Controller + routes | 1 controller + 1 router edit | ~120 |
| 5 | Phoenix Socket + Channel | 2 channel files + 1 endpoint edit | ~60 |
| 6 | Broadcaster + HealthChecker + QueueMonitor | 3 GenServers + 1 application edit | ~260 |
| 7 | Wire Channel to PubSub | 1 channel update | ~25 |
| 8 | Install phoenix npm | package.json | ~2 |
| 9 | Monitoring Pinia store | 1 store | ~230 |
| 10 | Dashboard Vue component + route | 1 view + 1 router edit | ~290 |
| 11 | Vite WebSocket proxy | 1 config edit | ~5 |
| 12 | Final verification | — | — |
| **Total** | **12 tasks** | **~17 files** | **~1,622 lines** |
