Skip to content
Merged
Show file tree
Hide file tree
Changes from 4 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
77 changes: 77 additions & 0 deletions docs/adr/0049-per-company-classifier-config.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,77 @@
---
name: Per-Company Classifier Configuration
description: Company-scoped invoice classifier settings with env-var fallback, enabling per-company ML models while preserving local dev simplicity.
tags: [classifier, services, settings, multi-tenant]
author: emil
date: 2026-04-23
status: Accepted
---

# 0049. Per-Company Classifier Configuration

Date: 2026-04-23

## Status

Accepted

## Context

KSeF Hub runs three sidecar services: pdf-renderer, invoice-extractor, and invoice-classifier. All were configured via environment variables, identical for every company.

Two problems:

1. **Per-company ML models** — different companies may need different classifier endpoints (dedicated models trained on their data). A global URL can't support this.
2. **Local dev with remote DB** — if config lived only in the database, developers connecting to a shared DB (e.g., Supabase) would get production sidecar URLs instead of localhost, breaking local development.

PDF renderer and invoice extractor are shared infrastructure that rarely changes — only the classifier needs per-company configuration.

## Decision

### Env vars remain the default

All sidecar URLs and tokens stay in `runtime.exs` as environment variables. This preserves local dev: `PDF_RENDERER_URL=http://localhost:3001` just works regardless of which database you connect to.

### Classifier gets per-company DB overrides

A new `classifier_configs` table stores optional per-company overrides:

| Column | Type | Purpose |
|--------|------|---------|
| `company_id` | FK, unique | One config per company |
| `enabled` | boolean (default false) | When false, env vars are used |
| `url` | string | Classifier endpoint URL |
| `api_token_encrypted` | binary | AES-256-GCM encrypted bearer token |
| `category_confidence_threshold` | float | Auto-apply threshold |
| `tag_confidence_threshold` | float | Auto-apply threshold |

When `enabled = true`, the company's DB values override env vars for that company's classification operations. When `enabled = false` (default), env vars drive everything — the DB row is inert.

### UI in Settings → Services

Owner/admin-only settings page at `/c/:company_id/settings/services` with:
- Enable/disable toggle with fieldset that grays out when disabled
- Env var values shown as placeholders so users know what they're overriding
- Health check before save (warns if unreachable, allows save anyway)
- Collapsible API endpoint documentation

### What was considered and rejected

- **Generic service_configurations table** — started here, but only the classifier varies per company. A generic table added complexity (service_name column, settings JSON map, generic card loop) for no real benefit.
- **DB-only config (no env vars)** — broke local development when connecting to a remote database. Env vars as the baseline solved this cleanly.
- **Global (non-company-scoped) config** — one company changing the classifier URL would affect all companies. Unacceptable for a multi-tenant SaaS.

## Consequences

- Classifier client modules still read `Application.get_env` directly. Wiring per-company resolution into the classification pipeline is a separate task (the `resolve_*` functions and client behaviour changes needed).
- PDF renderer and invoice extractor have no UI — they use env vars exclusively. If per-company needs arise later, the pattern is established.
- Three migration files exist from the iteration (create generic table → update → replace with classifier-specific). Harmless on fresh DBs but noisy.

## Key Files

| File | Purpose |
|------|---------|
| `lib/ksef_hub/service_config.ex` | Context — CRUD, env_defaults |
| `lib/ksef_hub/service_config/classifier_config.ex` | Ecto schema with conditional validation |
| `lib/ksef_hub_web/live/settings_live/services.ex` | LiveView — settings page |
| `priv/repo/migrations/*classifier_configs.exs` | DB migration |
2 changes: 2 additions & 0 deletions docs/architecture.md
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ Quick reference for developers. Before touching a feature area:
| Public invoice sharing (token links) | `invoices/public_tokens.ex`, `invoices/invoice_public_token.ex`, `live/invoice_live/public_show.ex` |
| Invoice approval & auto-approval | `invoices.ex` (`approve_invoice`), `invoices/auto_approval.ex` |
| Invoice categories & ML classification | `invoices/category.ex`, `invoice_classifier/` |
| Per-company classifier config | `service_config.ex`, `service_config/classifier_config.ex`, `live/settings_live/services.ex` |
| PDF generation | `pdf_renderer.ex`, `pdf_renderer/client.ex` |
| PDF extraction (expense uploads) | `invoice_extractor/` |
| Inbound email invoices | `inbound_email/` |
Expand Down Expand Up @@ -107,3 +108,4 @@ Read only the ADR(s) relevant to your task — the summaries below tell you whic
| 0046-per-user-public-invoice-tokens.md | Per-User Public Invoice Tokens | Accepted | Per-(invoice, user) tokens in `invoice_public_tokens` table; only SHA-256 digest stored; 30-day TTL; revoked on member block |
| 0047-approver-analyst-roles.md | Approver and Analyst Roles | Accepted | Rename `:reviewer` → `:approver` and `:viewer` → `:analyst`; analyst has same data scope as approver (access grants required for restricted invoices) |
| 0048-public-landing-page.md | Public Landing Page as Standalone Astro Project | Accepted | Ship marketing site as separate Astro 5 + Tailwind v4 project in `landing/`, deployed to GitHub Pages; zero runtime coupling with Phoenix |
| 0049-per-company-classifier-config.md | Per-Company Classifier Configuration | Accepted | Company-scoped classifier settings in `classifier_configs` table with env-var fallback; only classifier supports per-company overrides |
6 changes: 3 additions & 3 deletions docs/infrastructure.md
Original file line number Diff line number Diff line change
Expand Up @@ -121,7 +121,7 @@ ML-based category and tag prediction using a LightGBM model. Model weights are l
| Resources | 512Mi RAM · 1 CPU |
| Startup probe | `GET /health` · 5s delay · 5s period · 10 failures |
| Liveness probe | `GET /health` · 30s period · 3 failures |
| Auth | Bearer token via `INVOICE_CLASSIFIER_API_TOKEN` secret |
| Auth | Bearer token via `INVOICE_CLASSIFIER_API_TOKEN` secret (overridable per-company in Settings → Services) |
| Volume mount | `/app/models` ← `gs://au-ksef-ex-ml-models` (read-only, GCS FUSE) |
| Env vars (app) | `INVOICE_CLASSIFIER_URL=http://localhost:3003` · `INVOICE_CLASSIFIER_API_TOKEN` |
| Confidence threshold | ≥ 80% auto-applied; below threshold queued for manual review |
Expand Down Expand Up @@ -160,8 +160,8 @@ All secrets are injected as environment variables at runtime via GCP Secret Mana
| `credential-encryption-key` | `CREDENTIAL_ENCRYPTION_KEY` | app | AES-256-GCM encryption for PKCS12 certs |
| `mailgun-signing-key` | `MAILGUN_SIGNING_KEY` | app | Inbound webhook verification |
| `mailgun-api-key` | `MAILGUN_API_KEY` | app | Outbound email sending |
| `invoice-extractor-api-token` | `INVOICE_EXTRACTOR_API_TOKEN` | app + extractor sidecar | Bearer auth between app and extractor |
| `invoice-classifier-api-token` | `INVOICE_CLASSIFIER_API_TOKEN` | app + classifier sidecar | Bearer auth between app and classifier |
| `invoice-extractor-api-token` | `INVOICE_EXTRACTOR_API_TOKEN` / `API_TOKEN` | app + extractor sidecar | Bearer auth between app and extractor |
| `invoice-classifier-api-token` | `INVOICE_CLASSIFIER_API_TOKEN` / `API_TOKEN` | app + classifier sidecar | Bearer auth between app and classifier |
| `anthropic-api-key` | `ANTHROPIC_API_KEY` | extractor sidecar | Claude API calls for PDF extraction |

---
Expand Down
8 changes: 5 additions & 3 deletions docs/sidecar-services.md
Original file line number Diff line number Diff line change
Expand Up @@ -145,12 +145,14 @@ Or use `make models.train` to see these instructions at any time.

### Main app (ksef-hub)

Sidecar configuration comes from environment variables (see below). The **invoice classifier** has a per-company override UI in **Settings → Services** (stored in the `classifier_configs` table), but the classification pipeline does not yet read those DB overrides — it still uses global `Application.get_env` settings (see `lib/ksef_hub/invoice_classifier.ex`). The per-company override UI is available but the runtime wiring is pending (see ADR-0049), so DB values do not currently take precedence over env vars.

| Variable | Service | Description |
|----------|---------|-------------|
| `PDF_RENDERER_URL` | pdf-renderer | Sidecar URL (`http://localhost:3001`) |
| `INVOICE_EXTRACTOR_URL` | invoice-extractor | Sidecar URL (`http://localhost:3002`) |
| `PDF_RENDERER_URL` | pdf-renderer | Sidecar URL (default: `http://localhost:3001`) |
| `INVOICE_EXTRACTOR_URL` | invoice-extractor | Sidecar URL (default: `http://localhost:3002`) |
| `INVOICE_EXTRACTOR_API_TOKEN` | invoice-extractor | Bearer token for authentication |
| `INVOICE_CLASSIFIER_URL` | invoice-classifier | Sidecar URL (`http://localhost:3003`) |
| `INVOICE_CLASSIFIER_URL` | invoice-classifier | Sidecar URL (default: `http://localhost:3003`) |
| `INVOICE_CLASSIFIER_API_TOKEN` | invoice-classifier | Bearer token for authentication |

### Invoice extractor sidecar
Expand Down
1 change: 1 addition & 0 deletions lib/ksef_hub/authorization.ex
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,7 @@ defmodule KsefHub.Authorization do
| :view_payment_requests
| :manage_payment_requests
| :manage_bank_accounts
| :manage_services

@admin_denied MapSet.new([:delete_company, :transfer_ownership])

Expand Down
4 changes: 2 additions & 2 deletions lib/ksef_hub/invoice_classifier.ex
Original file line number Diff line number Diff line change
Expand Up @@ -4,8 +4,8 @@ defmodule KsefHub.InvoiceClassifier do
prediction for expense invoices using the au-payroll-model-categories sidecar.

Auto-applies predictions when confidence meets configurable thresholds:
- Category: default 71%, set via `CATEGORY_CONFIDENCE_THRESHOLD` env var
- Tag: default 95%, set via `TAG_CONFIDENCE_THRESHOLD` env var
- Category: default 71%, via `CATEGORY_CONFIDENCE_THRESHOLD` env var or Settings → Services
- Tag: default 95%, via `TAG_CONFIDENCE_THRESHOLD` env var or Settings → Services

Categories require a matching record in the company to be auto-applied.
Tags are free-form strings — all tags from the probability distribution
Expand Down
103 changes: 103 additions & 0 deletions lib/ksef_hub/service_config.ex
Original file line number Diff line number Diff line change
@@ -0,0 +1,103 @@
defmodule KsefHub.ServiceConfig do
@moduledoc """
Context for per-company sidecar service configuration.

Currently supports the invoice classifier only (the main per-company use case).
PDF renderer and invoice extractor use global env vars exclusively.

When a company's classifier config is **enabled**, its values override
the global env-var defaults for that company's operations.
"""

alias KsefHub.ActivityLog.TrackedRepo
alias KsefHub.Credentials.Encryption
alias KsefHub.Repo
alias KsefHub.ServiceConfig.ClassifierConfig

@doc "Returns the classifier config for a company, or nil if none exists."
@spec get_classifier_config(Ecto.UUID.t()) :: ClassifierConfig.t() | nil
def get_classifier_config(company_id) do
Repo.get_by(ClassifierConfig, company_id: company_id)
end

@doc """
Returns the classifier config for a company, creating a disabled default if none exists.
"""
@spec get_or_create_classifier_config(Ecto.UUID.t()) :: ClassifierConfig.t()
def get_or_create_classifier_config(company_id) do
case get_classifier_config(company_id) do
nil ->
%ClassifierConfig{company_id: company_id}
|> Ecto.Changeset.change()
|> Repo.insert(on_conflict: :nothing, conflict_target: :company_id)
|> case do
{:ok, %{id: nil}} -> Repo.get_by!(ClassifierConfig, company_id: company_id)
{:ok, config} -> config
end

config ->
config
end
end

@doc """
Returns a changeset for a classifier config, suitable for form building and validation.
"""
@spec change_classifier_config(ClassifierConfig.t(), map()) :: Ecto.Changeset.t()
def change_classifier_config(%ClassifierConfig{} = config, attrs \\ %{}) do
ClassifierConfig.changeset(config, attrs)
end

@doc """
Updates a classifier config, encrypting the API token if provided.

Actor metadata (`user_id`, `actor_label`) is passed via `opts` and forwarded
to TrackedRepo for activity log emission. The `updated_by_id` field is derived
from `opts[:user_id]` server-side.
"""
@spec update_classifier_config(ClassifierConfig.t(), map(), keyword()) ::
{:ok, ClassifierConfig.t()} | {:error, Ecto.Changeset.t()}
def update_classifier_config(%ClassifierConfig{} = config, attrs, opts \\ []) do
changeset = ClassifierConfig.changeset(config, attrs)

# Set updated_by_id from actor opts, not from form attrs
changeset =
case Keyword.get(opts, :user_id) do
nil -> changeset
user_id -> Ecto.Changeset.put_change(changeset, :updated_by_id, user_id)
end

# Handle API token: Ecto casts "" to nil, so check raw params for explicit blank
changeset =
case Map.get(attrs, "api_token") || Map.get(attrs, :api_token) do
nil ->
changeset

"" ->
Ecto.Changeset.put_change(changeset, :api_token_encrypted, nil)

token when is_binary(token) ->
{:ok, encrypted} = Encryption.encrypt(token)
Ecto.Changeset.put_change(changeset, :api_token_encrypted, encrypted)
Comment on lines +79 to +81

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

Handle encryption failures without crashing the save flow.

Line 71 pattern-matches on Encryption.encrypt/1. If encryption returns {:error, reason}, this raises and turns a recoverable validation/persistence error into a LiveView crash. Convert that failure into a normal changeset error instead.

Possible fix
         token when is_binary(token) ->
-          {:ok, encrypted} = Encryption.encrypt(token)
-          Ecto.Changeset.put_change(changeset, :api_token_encrypted, encrypted)
+          with {:ok, encrypted} <- Encryption.encrypt(token) do
+            Ecto.Changeset.put_change(changeset, :api_token_encrypted, encrypted)
+          else
+            {:error, reason} ->
+              Ecto.Changeset.add_error(
+                changeset,
+                :api_token,
+                "could not be encrypted: #{inspect(reason)}"
+              )
+          end

As per coding guidelines, "Use with for multi-step operations that can fail in Elixir code".

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@lib/ksef_hub/service_config.ex` around lines 70 - 72, The current clause
pattern-matches on Encryption.encrypt(token) which will crash if it returns
{:error, reason}; change the logic in the token when is_binary(token) branch to
use a with (or case) to call Encryption.encrypt(token) and, on {:ok, encrypted},
call Ecto.Changeset.put_change(changeset, :api_token_encrypted, encrypted), but
on {:error, reason} return the original changeset with an added error via
Ecto.Changeset.add_error(changeset, :api_token_encrypted, "encryption failed:
#{inspect(reason)}") so encryption failures become normal changeset errors
instead of raising.

end

TrackedRepo.update(
changeset,
opts ++ [action: "classifier_config.updated"]
)
end

@doc """
Returns the current env-var defaults for display in the UI.
"""
@spec env_defaults() :: map()
def env_defaults do
%{
url: Application.get_env(:ksef_hub, :invoice_classifier_url),
api_token_configured: Application.get_env(:ksef_hub, :invoice_classifier_api_token) != nil,
category_confidence_threshold:
Application.get_env(:ksef_hub, :category_confidence_threshold, 0.71),
tag_confidence_threshold: Application.get_env(:ksef_hub, :tag_confidence_threshold, 0.95)
}
end
end
93 changes: 93 additions & 0 deletions lib/ksef_hub/service_config/classifier_config.ex
Original file line number Diff line number Diff line change
@@ -0,0 +1,93 @@
defmodule KsefHub.ServiceConfig.ClassifierConfig do
@moduledoc """
Per-company configuration for the invoice classifier sidecar.

When `enabled` is true, these values override the global env-var defaults.
When `enabled` is false (default), the app uses env vars from `runtime.exs`.
"""

use Ecto.Schema

import Ecto.Changeset

alias KsefHub.Companies.Company

@type t :: %__MODULE__{
id: Ecto.UUID.t() | nil,
company_id: Ecto.UUID.t() | nil,
enabled: boolean(),
url: String.t() | nil,
api_token_encrypted: binary() | nil,
category_confidence_threshold: float() | nil,
tag_confidence_threshold: float() | nil,
updated_by_id: Ecto.UUID.t() | nil,
inserted_at: DateTime.t() | nil,
updated_at: DateTime.t() | nil
}

@primary_key {:id, :binary_id, autogenerate: true}
@foreign_key_type :binary_id
schema "classifier_configs" do
belongs_to :company, Company
field :enabled, :boolean, default: false
field :url, :string
field :api_token_encrypted, :binary
field :category_confidence_threshold, :float
field :tag_confidence_threshold, :float
field :updated_by_id, :binary_id

# Virtual field for accepting plaintext token in forms
field :api_token, :string, virtual: true

timestamps()
end

@doc "Builds a changeset for creating or updating a classifier config."
@spec changeset(t(), map()) :: Ecto.Changeset.t()
def changeset(config, attrs) do
config
|> cast(attrs, [
:enabled,
:url,
:api_token,
:category_confidence_threshold,
:tag_confidence_threshold
])
Comment thread
coderabbitai[bot] marked this conversation as resolved.
|> validate_when_enabled()
end

@spec validate_when_enabled(Ecto.Changeset.t()) :: Ecto.Changeset.t()
defp validate_when_enabled(changeset) do
if get_field(changeset, :enabled) do
changeset
|> validate_required([:url, :category_confidence_threshold, :tag_confidence_threshold])
|> validate_url()
|> validate_number(:category_confidence_threshold,
greater_than: 0.0,
less_than: 1.0,
message: "must be between 0 and 1"
)
|> validate_number(:tag_confidence_threshold,
greater_than: 0.0,
less_than: 1.0,
message: "must be between 0 and 1"
)
else
changeset
end
end

@spec validate_url(Ecto.Changeset.t()) :: Ecto.Changeset.t()
defp validate_url(changeset) do
validate_change(changeset, :url, fn :url, url ->
case URI.parse(url) do
%URI{scheme: scheme, host: host}
when scheme in ["http", "https"] and is_binary(host) and host != "" ->
[]

_ ->
[url: "must be a valid HTTP(S) URL"]
end
end)
end
end
3 changes: 2 additions & 1 deletion lib/ksef_hub_web/components/settings_components.ex
Original file line number Diff line number Diff line change
Expand Up @@ -75,7 +75,8 @@ defmodule KsefHubWeb.SettingsComponents do
{:manage_tokens, "API Tokens", ~p"/c/#{id}/settings/tokens", "hero-key"},
{:manage_certificates, "Certificates", ~p"/c/#{id}/settings/certificates",
"hero-shield-check"},
{:manage_team, "Activity Log", ~p"/c/#{id}/settings/activity-log", "hero-clock"}
{:manage_team, "Activity Log", ~p"/c/#{id}/settings/activity-log", "hero-clock"},
{:manage_services, "Services", ~p"/c/#{id}/settings/services", "hero-server-stack"}
]
|> Enum.filter(fn {perm, _label, _path, _icon} ->
is_nil(perm) or Authorization.can?(role, perm)
Expand Down
Loading
Loading