# Fee System Consolidation — Override Hierarchy + bank_account_fees Removal

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

**Goal:** Extend `fee_configs` with account/user override hierarchy, update FeeCalculator lookup, and remove the old `bank_account_fees` system entirely.

**Architecture:** Add nullable `account_id` (integer) and `user_id` (integer) columns to `fee_configs` table. FeeCalculator lookup uses specificity order: account → user → global. Frontend gets scope tabs. Old system completely removed.

**Tech Stack:** Elixir/Phoenix, Ecto, PostgreSQL, Vue 3/PrimeVue 4, TanStack Query

---

### Task 1: Database Migration — Add Override Columns

**Files:**
- Create: `backend/priv/repo/migrations/20260309000005_add_fee_config_overrides.exs`

**Step 1: Create the migration**

```elixir
defmodule Monetarie.Repo.Migrations.AddFeeConfigOverrides do
  use Ecto.Migration

  def change do
    alter table(:fee_configs) do
      add :account_id, :integer
      add :user_id, :integer
    end

    # Index for override lookup: account-specific configs
    create index(:fee_configs, [:account_id, :fee_type, :client_type],
      where: "account_id IS NOT NULL",
      name: :fee_configs_account_override_idx
    )

    # Index for override lookup: user-specific configs
    create index(:fee_configs, [:user_id, :fee_type, :client_type],
      where: "user_id IS NOT NULL",
      name: :fee_configs_user_override_idx
    )

    # Unique constraint: prevent duplicate overrides at same scope
    create unique_index(:fee_configs,
      [:institution_id, :fee_type, :client_type, :member_status,
       :coalesce_account_id, :coalesce_user_id],
      name: :fee_configs_scope_unique_idx
    )

    # Use expression index for unique constraint with NULLs
    execute """
    CREATE UNIQUE INDEX fee_configs_scope_unique_idx
    ON fee_configs (
      institution_id, fee_type, client_type, member_status,
      COALESCE(account_id, 0),
      COALESCE(user_id, 0)
    )
    WHERE is_active = true
    """,
    "DROP INDEX IF EXISTS fee_configs_scope_unique_idx"
  end
end
```

**Step 2: Run migration locally**

Run: `cd backend && mix ecto.migrate`
Expected: Migration runs successfully

**Step 3: Commit**

```bash
git add backend/priv/repo/migrations/20260309000005_add_fee_config_overrides.exs
git commit -m "feat: add account_id/user_id override columns to fee_configs"
```

---

### Task 2: Update FeeConfig Schema

**Files:**
- Modify: `backend/lib/monetarie/fees/fee_config.ex`

**Step 1: Add fields to schema**

Add after line 67 (`field :approved_by, :binary_id`):
```elixir
    field :account_id, :integer
    field :user_id, :integer
```

**Step 2: Add to @optional list**

Add `:account_id, :user_id` to the `@optional` list.

**Step 3: Add validation**

Add a `validate_scope/1` function in changeset pipeline:
```elixir
  def changeset(config, attrs) do
    config
    |> cast(attrs, @required ++ @optional)
    |> validate_required(@required)
    |> validate_inclusion(:fee_type, @fee_types)
    |> validate_inclusion(:client_type, ["pf", "pj", "all"])
    |> validate_inclusion(:member_status, ["member", "non_member", "all"])
    |> validate_inclusion(:charging_model, ["immediate", "monthly", "annual", "per_event"])
    |> validate_number(:fee_amount_fixed, greater_than_or_equal_to: 0)
    |> validate_number(:fee_min, greater_than_or_equal_to: 0)
    |> validate_number(:free_transactions_per_month, greater_than_or_equal_to: 0)
    |> validate_fee_max()
    |> validate_date_range()
    |> validate_scope()
  end

  defp validate_scope(changeset) do
    account_id = get_field(changeset, :account_id)
    user_id = get_field(changeset, :user_id)

    if account_id && user_id do
      add_error(changeset, :account_id, "cannot set both account_id and user_id; account_id implies a specific scope")
    else
      changeset
    end
  end
```

**Step 4: Add scope helper**

```elixir
  def scope(%__MODULE__{account_id: aid, user_id: uid}) do
    cond do
      aid != nil -> :account
      uid != nil -> :user
      true -> :global
    end
  end
```

**Step 5: Commit**

```bash
git add backend/lib/monetarie/fees/fee_config.ex
git commit -m "feat: add account_id/user_id fields to FeeConfig schema"
```

---

### Task 3: Update FeeCalculator — Hierarchy Lookup

**Files:**
- Modify: `backend/lib/monetarie/fees/fee_calculator.ex`

**Step 1: Add account_id and user_id to params type**

Update the `@type params` to include:
```elixir
  @type params :: %{
          client_type: atom(),
          fee_type: atom(),
          client_id: binary(),
          institution_id: binary(),
          amount: non_neg_integer(),
          account_id: integer() | nil,
          user_id: integer() | nil
        }
```

**Step 2: Pass account_id/user_id to general_case**

In `general_case/1`, extract `account_id` and `user_id` from params:
```elixir
  defp general_case(%{institution_id: inst_id, fee_type: ft, client_type: ct,
                      client_id: client_id, amount: amount} = params) do
    ft_str = to_string(ft)
    ct_str = to_string(ct)
    is_member = ActClassifier.is_active_member?(client_id)
    act_type = if is_member, do: :cooperativo, else: :nao_cooperativo
    member_status_filter = if is_member, do: "member", else: "non_member"

    account_id = Map.get(params, :account_id)
    user_id = Map.get(params, :user_id)

    config = find_fee_config_with_hierarchy(inst_id, ft_str, ct_str, member_status_filter, amount, account_id, user_id)

    # ... rest unchanged
  end
```

**Step 3: Replace `find_fee_config` with hierarchy lookup**

```elixir
  defp find_fee_config_with_hierarchy(institution_id, fee_type, client_type, member_status, amount, account_id, user_id) do
    # Try account-specific first
    (account_id && find_fee_config(institution_id, fee_type, client_type, member_status, amount, account_id, nil))
    # Then user-specific
    || (user_id && find_fee_config(institution_id, fee_type, client_type, member_status, amount, nil, user_id))
    # Then global
    || find_fee_config(institution_id, fee_type, client_type, member_status, amount, nil, nil)
  end

  defp find_fee_config(institution_id, fee_type, client_type, member_status, amount, account_id, user_id) do
    today = Date.utc_today()

    query =
      from(fc in FeeConfig,
        where: fc.institution_id == ^institution_id,
        where: fc.fee_type == ^fee_type,
        where: fc.client_type in [^client_type, "all"],
        where: fc.member_status in [^member_status, "all"],
        where: fc.is_active == true,
        where: fc.effective_from <= ^today,
        where: is_nil(fc.effective_until) or fc.effective_until >= ^today,
        where: fc.amount_from <= ^amount,
        where: is_nil(fc.amount_to) or fc.amount_to >= ^amount,
        order_by: [
          asc: fragment("CASE WHEN ? = 'all' THEN 1 ELSE 0 END", fc.client_type),
          asc: fragment("CASE WHEN ? = 'all' THEN 1 ELSE 0 END", fc.member_status)
        ],
        limit: 1
      )

    query =
      cond do
        account_id -> from(fc in query, where: fc.account_id == ^account_id)
        user_id -> from(fc in query, where: fc.user_id == ^user_id and is_nil(fc.account_id))
        true -> from(fc in query, where: is_nil(fc.account_id) and is_nil(fc.user_id))
      end

    Repo.one(query)
  end
```

**Step 4: Update `get_free_tier_limit` similarly**

Same hierarchy for free tier lookup — account → user → global.

**Step 5: Commit**

```bash
git add backend/lib/monetarie/fees/fee_calculator.ex
git commit -m "feat: FeeCalculator hierarchy lookup (account → user → global)"
```

---

### Task 4: Update Fees Context and Controller

**Files:**
- Modify: `backend/lib/monetarie/fees.ex`
- Modify: `backend/lib/monetarie_web/controllers/admin/fee_configs_controller.ex`

**Step 1: Update context list_fee_configs filters**

Add `account_id` and `user_id` filter support in `apply_filters/2`:
```elixir
  defp apply_filters(query, filters) do
    query
    |> maybe_filter(:fee_type, filters["fee_type"])
    |> maybe_filter(:client_type, filters["client_type"])
    |> maybe_filter(:member_status, filters["member_status"])
    |> maybe_filter(:is_active, filters["is_active"])
    |> apply_scope_filter(filters["scope"], filters["account_id"], filters["user_id"])
  end

  defp apply_scope_filter(query, "account", account_id, _) when not is_nil(account_id) do
    account_id_int = parse_int(account_id)
    from(f in query, where: f.account_id == ^account_id_int)
  end

  defp apply_scope_filter(query, "user", _, user_id) when not is_nil(user_id) do
    user_id_int = parse_int(user_id)
    from(f in query, where: f.user_id == ^user_id_int and is_nil(f.account_id))
  end

  defp apply_scope_filter(query, "global", _, _) do
    from(f in query, where: is_nil(f.account_id) and is_nil(f.user_id))
  end

  defp apply_scope_filter(query, _, _, _), do: query

  defp parse_int(val) when is_integer(val), do: val
  defp parse_int(val) when is_binary(val), do: String.to_integer(val)
  defp parse_int(_), do: 0
```

**Step 2: Update controller serialize**

Add `accountId` and `userId` and `scope` to serialize output:
```elixir
  defp serialize(%FeeConfig{} = c) do
    scope = FeeConfig.scope(c)
    %{
      id: c.id,
      feeType: c.fee_type,
      clientType: c.client_type,
      # ... existing fields ...
      accountId: c.account_id,
      userId: c.user_id,
      scope: to_string(scope),
      createdAt: c.inserted_at,
      updatedAt: c.updated_at
    }
  end
```

**Step 3: Update controller normalize_params**

Add:
```elixir
      "account_id" => params["accountId"] || params["account_id"],
      "user_id" => params["userId"] || params["user_id"],
```

**Step 4: Commit**

```bash
git add backend/lib/monetarie/fees.ex backend/lib/monetarie_web/controllers/admin/fee_configs_controller.ex
git commit -m "feat: context and controller support for fee override scopes"
```

---

### Task 5: Update Frontend Types

**Files:**
- Modify: `apps/admin/src/types/fee-config.ts`

**Step 1: Add scope fields to FeeConfig interface**

```typescript
export interface FeeConfig {
  id: string
  feeType: string
  clientType: 'pf' | 'pj' | 'all'
  // ... existing fields ...
  accountId?: number | null
  userId?: number | null
  scope: 'global' | 'user' | 'account'
  createdAt: string
  updatedAt: string
}
```

**Step 2: Add scope to create payload**

```typescript
export interface FeeConfigCreatePayload {
  // ... existing fields ...
  accountId?: number
  userId?: number
}
```

**Step 3: Add scope labels**

```typescript
export const SCOPE_LABELS: Record<string, string> = {
  global: 'Global (Instituição)',
  user: 'Por Cliente',
  account: 'Por Conta',
}
```

**Step 4: Commit**

```bash
git add apps/admin/src/types/fee-config.ts
git commit -m "feat: add override scope fields to FeeConfig types"
```

---

### Task 6: Update Frontend Composable

**Files:**
- Modify: `apps/admin/src/composables/useFeeConfigs.ts`

**Step 1: Add scope to FeeConfigFilters**

```typescript
interface FeeConfigFilters {
  search?: string
  feeType?: string
  clientType?: string
  memberStatus?: string
  isActive?: boolean | null
  group?: string
  scope?: 'global' | 'user' | 'account'
  accountId?: number | null
  userId?: number | null
  page?: number
  perPage?: number
}
```

**Step 2: Commit**

```bash
git add apps/admin/src/composables/useFeeConfigs.ts
git commit -m "feat: add scope filter support to useFeeConfigs"
```

---

### Task 7: Update Frontend — Scope Tabs in FeeConfigListView

**Files:**
- Modify: `apps/admin/src/views/fee-config/FeeConfigListView.vue`

**Step 1: Add scope state and tab UI**

Add to script setup:
```typescript
import TabMenu from 'primevue/tabmenu'

const scopeTab = ref(0)
const scopeItems = [
  { label: 'Global (Instituição)', value: 'global' },
  { label: 'Por Cliente', value: 'user' },
  { label: 'Por Conta', value: 'account' },
]

const selectedUserId = ref<number | null>(null)
const selectedAccountId = ref<number | null>(null)

watch(scopeTab, (idx) => {
  const scope = scopeItems[idx].value
  selectedUserId.value = null
  selectedAccountId.value = null
  setFilters({
    scope,
    userId: null,
    accountId: null,
  })
})
```

**Step 2: Add scope column to DataTable**

Add column after "Status":
```vue
<Column field="scope" header="Escopo" sortable style="min-width: 8rem">
  <template #body="{ data }">
    <Tag
      :value="data.scope === 'global' ? 'Global' : data.scope === 'user' ? 'Cliente #' + data.userId : 'Conta #' + data.accountId"
      :severity="data.scope === 'global' ? 'info' : data.scope === 'user' ? 'warn' : 'success'"
    />
  </template>
</Column>
```

**Step 3: Add TabMenu above toolbar**

```vue
<TabMenu :model="scopeItems" v-model:activeIndex="scopeTab" class="scope-tabs" />
```

**Step 4: Add user/account search when scope requires it**

In toolbar center, add conditional inputs:
```vue
<InputNumber
  v-if="scopeItems[scopeTab].value === 'user'"
  v-model="selectedUserId"
  placeholder="ID do Cliente"
  class="scope-id-input"
  @update:modelValue="setFilters({ userId: $event, scope: 'user' })"
/>
<InputNumber
  v-if="scopeItems[scopeTab].value === 'account'"
  v-model="selectedAccountId"
  placeholder="ID da Conta"
  class="scope-id-input"
  @update:modelValue="setFilters({ accountId: $event, scope: 'account' })"
/>
```

**Step 5: Commit**

```bash
git add apps/admin/src/views/fee-config/FeeConfigListView.vue
git commit -m "feat: add scope tabs to FeeConfigListView"
```

---

### Task 8: Update Frontend — Scope in FeeConfigDialog

**Files:**
- Modify: `apps/admin/src/views/fee-config/FeeConfigDialog.vue`

**Step 1: Add scope fields to form**

In `getEmptyForm()`:
```typescript
    scope: 'global' as 'global' | 'user' | 'account',
    accountId: null as number | null,
    userId: null as number | null,
```

**Step 2: Add scope selector to template**

Before "Tipo de Taxa":
```vue
<!-- Scope -->
<div class="dialog-field">
  <label class="dialog-label">Escopo</label>
  <div class="radio-group">
    <div class="radio-item">
      <RadioButton v-model="form.scope" value="global" inputId="sc-global" :disabled="saving || isEditMode" />
      <label for="sc-global">Global (Instituição)</label>
    </div>
    <div class="radio-item">
      <RadioButton v-model="form.scope" value="user" inputId="sc-user" :disabled="saving || isEditMode" />
      <label for="sc-user">Por Cliente</label>
    </div>
    <div class="radio-item">
      <RadioButton v-model="form.scope" value="account" inputId="sc-account" :disabled="saving || isEditMode" />
      <label for="sc-account">Por Conta</label>
    </div>
  </div>
</div>

<!-- User/Account ID -->
<div v-if="form.scope === 'user'" class="dialog-field">
  <label class="dialog-label">ID do Cliente *</label>
  <InputNumber v-model="form.userId" :min="1" placeholder="ID do cliente" class="dialog-input" :disabled="saving || isEditMode" />
</div>
<div v-if="form.scope === 'account'" class="dialog-field">
  <label class="dialog-label">ID da Conta *</label>
  <InputNumber v-model="form.accountId" :min="1" placeholder="ID da conta" class="dialog-input" :disabled="saving || isEditMode" />
</div>
```

**Step 3: Include scope in save payload**

In `handleSave()`:
```typescript
    accountId: form.value.scope === 'account' ? form.value.accountId ?? undefined : undefined,
    userId: form.value.scope === 'user' ? form.value.userId ?? undefined : undefined,
```

**Step 4: Commit**

```bash
git add apps/admin/src/views/fee-config/FeeConfigDialog.vue
git commit -m "feat: add scope selector to FeeConfigDialog"
```

---

### Task 9: Remove Old bank_account_fees System — Backend

**Files:**
- Delete: `backend/lib/monetarie/bank_account_fees.ex`
- Delete: `backend/lib/monetarie/bank_account_fees/bank_account_fee.ex`
- Delete: `backend/lib/monetarie_web/controllers/admin/bank_account_fees_controller.ex`
- Modify: `backend/lib/monetarie_web/router.ex` (remove 3 routes)

**Step 1: Remove backend files**

```bash
rm backend/lib/monetarie/bank_account_fees.ex
rm -rf backend/lib/monetarie/bank_account_fees/
rm backend/lib/monetarie_web/controllers/admin/bank_account_fees_controller.ex
```

**Step 2: Remove routes from router.ex**

Remove these 3 lines from `router.ex` (around line 1445-1447):
```elixir
    get "/bank-account-fees", BankAccountFeesController, :index
    post "/bank-account-fees", BankAccountFeesController, :create
    put "/bank-account-fees/:id", BankAccountFeesController, :update
```

**Step 3: Verify compilation**

Run: `cd backend && mix compile --warnings-as-errors`
Expected: Clean compilation (no references to BankAccountFees modules)

**Step 4: Commit**

```bash
git add -A
git commit -m "feat: remove old bank_account_fees system (replaced by fee_configs)"
```

---

### Task 10: Remove Old bank_account_fees System — Frontend

**Files:**
- Delete: `apps/admin/src/views/bank-account-fees/` (entire directory, 4 files)
- Modify: `apps/admin/src/router/index.ts` (remove 4 routes)
- Modify: `apps/admin/src/layouts/AdminLayout.vue` (replace sidebar item)
- Modify: `apps/admin/src/composables/useBankAccounts.ts` (remove useBankAccountFees export)
- Modify: `apps/admin/src/i18n/locales/en.ts` (remove bankAccountFees labels)

**Step 1: Remove frontend view files**

```bash
rm -rf apps/admin/src/views/bank-account-fees/
```

**Step 2: Remove 4 routes from router/index.ts**

Remove the `bank-account-fees` route block (lines ~124-148):
```typescript
      // ── Bank Account Fees ──────────────────
      { path: 'bank-account-fees', ... },
      { path: 'bank-account-fees/create', ... },
      { path: 'bank-account-fees/:merchantId', ... },
      { path: 'bank-account-fees/edit/:id', ... },
```

**Step 3: Update sidebar menu in AdminLayout.vue**

Replace the "Tarifas de Conta" item (lines ~118-123):
```typescript
  {
    label: 'Configuração de Taxas',
    section: 'CONTAS',
    icon: markRaw(Percent as Component),
    path: '/dashboard/fee-configs',
  },
```

**Step 4: Remove useBankAccountFees from composable**

In `apps/admin/src/composables/useBankAccounts.ts`, remove the entire `useBankAccountFees` function export (around line 76-107).

**Step 5: Commit**

```bash
git add -A
git commit -m "feat: remove old bank-account-fees frontend (replaced by fee-configs)"
```

---

### Task 11: Build and Deploy

**Step 1: Build backend**

```bash
cd /Users/luizpenha/monetarie/core
tar czf /tmp/monetarie-core-backend.tar.gz backend/ docs-site/ packages/
scp -O /tmp/monetarie-core-backend.tar.gz planner@172.69.88.10:/tmp/monetarie-core-backend.tar.gz
ssh planner@172.69.88.10 "cd /tmp && tar xzf monetarie-core-backend.tar.gz && cd backend && docker build -t 172.69.88.10:5000/cc/core-api:v20260308-feev2 ."
ssh planner@172.69.88.10 "docker push 172.69.88.10:5000/cc/core-api:v20260308-feev2"
```

**Step 2: Run migration**

```bash
ssh planner@172.69.88.10 "docker run --rm [ALL_ENV_VARS] 172.69.88.10:5000/cc/core-api:v20260308-feev2 bin/monetarie eval 'Monetarie.Release.migrate()'"
```

**Step 3: Update backend service**

```bash
ssh planner@172.69.88.10 "docker service update --image 172.69.88.10:5000/cc/core-api:v20260308-feev2 cc-core_core-api"
```

**Step 4: Build frontend**

```bash
cd /Users/luizpenha/monetarie/core/apps/admin
pnpm build
tar czf /tmp/monetarie-admin-ui.tar.gz dist/
# ... SCP + docker build + push + service update for core-admin-ui
```

**Step 5: Verify**

- Login to admin panel
- Navigate to "Configuração de Taxas"
- Verify scope tabs work
- Verify "Tarifas de Conta" menu item is gone
- Verify `/api/admin/bank-account-fees` returns 404
- Verify `/api/admin/fee-configs` returns data
- Test creating override for specific user/account

**Step 6: Commit build tag**

---
