STRABAG Energy Invoice Analysis — Implementation Plan

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

Goal: Extract energy-specific structured data from Austrian electricity invoices and display it in a dedicated energy detail view plus a portfolio overview page.

Architecture: Add energy-details as a new top-level section in invoice structured data, alongside existing line-items. Classification detects energy invoices via a new "energy-invoice" subtype. The extraction prompt includes energy-specific instructions when the subtype matches. Two new UI surfaces: enhanced invoice detail view for energy data, and a portfolio overview page listing all energy metering points.

Tech Stack: Clojure, Malli (schemas), Integrant, HTMX/Hiccup (UI), PostgreSQL JSONB, Claude/Gemini LLM extraction

Design doc: docs/plans/2026-03-12-strabag-energy-invoice-design.md


Task 1: Create Demo Branch

Files:

Step 1: Create and switch to demo branch

git checkout -b strabag-energy-demo

Step 2: Verify branch

git branch --show-current

Expected: strabag-energy-demo


Task 2: Add "energy-invoice" to Classification

Files:

Step 1: Add "energy-invoice" to the invoice-subtypes set

In classification.clj, line 29, add "energy-invoice" to the set:

(def invoice-subtypes
  "Valid invoice subtypes for invoice documents."
  #{"standard-invoice" "advance-payment" "credit-note" "progress-invoice"
    "final-invoice" "partial-invoice" "cancellation-invoice" "energy-invoice"})

Step 2: Add energy-invoice description to the classification prompt

In classification.clj, after line 111 (cancellation-invoice), add:

- energy-invoice: Energy/utility invoice (Strom, Gas, Fernwärme) with metering points (Zählpunkt), meter readings (Ablesedaten), and categorized cost breakdowns (Energie/Netz/Abgaben)

Step 3: Add "energy-invoice" to the InvoiceData schema enum

In structured_data.clj, lines 266-268, add "energy-invoice" to the :invoice-subtype enum:

[:invoice-subtype [:enum "standard-invoice" "advance-payment" "credit-note"
                         "progress-invoice" "final-invoice" "partial-invoice"
                         "cancellation-invoice" "energy-invoice"]]

Step 4: Lint

clj-kondo --lint src/com/getorcha/workers/ingestion/classification.clj src/com/getorcha/schema/invoice/structured_data.clj

Expected: No errors.

Step 5: Commit

git add src/com/getorcha/workers/ingestion/classification.clj src/com/getorcha/schema/invoice/structured_data.clj
git commit -m "Add energy-invoice subtype to classification and schema"

Task 3: Define Malli Schema for Energy Details

Files:

This task adds the EnergyDetails schema and wires it into InvoiceData as an optional field.

Step 1: Define the energy sub-schemas

Add these definitions before the InvoiceData def (around line 185, after existing schema defs). Follow the existing pattern of defining sub-schemas as standalone defs:

;; Energy Invoice Schemas
;; -----------------------------------------------------------------------------

(def EnergyMeasuredValue
  "A measured or computed value with its unit."
  [:map
   [:value number?]
   [:unit :string]])


(def EnergyMeterReading
  "Single meter register reading for a billing period."
  [:map
   [:register :string]                          ;; "HT", "NT", "MAX", "Blind", or single-register label
   [:period common/ServicePeriod]
   [:stand-alt number?]
   [:stand-neu number?]
   [:differenz number?]
   [:ableseart [:maybe :string]]                ;; "NBE", "N", "K", "G"
   [:faktor number?]
   [:verbrauch EnergyMeasuredValue]])


(def EnergyMeter
  "A physical meter with its readings."
  [:map
   [:number :string]
   [:readings [:vector EnergyMeterReading]]])


(def EnergyMeteringPoint
  "A metering point (Zählpunkt) on the grid."
  [:map
   [:zaehlpunkt :string]
   [:netzebene [:maybe :int]]
   [:anschlusswert {:optional true} EnergyMeasuredValue]
   [:tarif {:optional true} [:maybe :string]]
   [:netzbetreiber {:optional true} [:maybe :string]]
   [:address {:optional true} [:maybe :string]]
   [:meters [:vector EnergyMeter]]])


(def EnergyConsumptionPeriod
  "Consumption data for a single period."
  [:map
   [:value number?]
   [:unit :string]
   [:days :int]
   [:per-day number?]])


(def EnergyConsumptionSummary
  "Comparison of current vs prior period consumption."
  [:map
   [:current EnergyConsumptionPeriod]
   [:prior {:optional true} EnergyConsumptionPeriod]
   [:delta {:optional true} [:maybe number?]]])


(def EnergyCostPosition
  "A single cost position within a category."
  [:map
   [:description :string]
   [:period common/ServicePeriod]
   [:basis {:optional true} EnergyMeasuredValue]   ;; kWh, kW, Tag(e), Einheit
   [:days {:optional true} [:maybe :int]]
   [:rate {:optional true} EnergyMeasuredValue]     ;; value + unit (Ct/kWh, €/Jahr, €/kW, etc.)
   [:amount number?]])


(def EnergyCostCategory
  "A cost category with its positions and subtotal."
  [:map
   [:category [:enum "energie" "netz" "abgaben"]]
   [:subtotal number?]
   [:positions [:vector EnergyCostPosition]]])


(def EnergyDetails
  "Energy-specific structured data extracted from utility invoices.
   Present only when invoice-subtype is energy-invoice."
  [:map
   [:metering-points [:vector EnergyMeteringPoint]]
   [:consumption-summary {:optional true} EnergyConsumptionSummary]
   [:cost-categories [:vector EnergyCostCategory]]])

Step 2: Add EnergyDetails as optional field in InvoiceData

In the InvoiceData schema, after the :fraud-flags field (line 277) and before :validation-results (line 280), add:

;; Energy details (added by extraction for energy-invoice subtype)
[:energy-details {:optional true} EnergyDetails]

Step 3: Lint

clj-kondo --lint src/com/getorcha/schema/invoice/structured_data.clj

Expected: No errors.

Step 4: Verify schema compiles

Use the REPL to check:

(require '[com.getorcha.schema.invoice.structured-data :as sd] :reload)
sd/EnergyDetails

Expected: Returns the Malli schema object without errors.

Step 5: Commit

git add src/com/getorcha/schema/invoice/structured_data.clj
git commit -m "Add Malli schema for energy-details in invoice structured data"

Task 4: Extend Extraction Prompt for Energy Invoices

Files:

The extraction prompt needs two additions: energy-specific extraction rules and the energy-details JSON shape in the output schema.

Step 1: Add energy extraction rules to the prompt

In the workers/-prompt :extraction method, add an energy-specific section after the line-items rules (after line 270, before the supplier selection section). Add:

ENERGY INVOICES (Strom/Gas/Fernwärme):
When the document contains metering point data (Zählpunkt), meter readings (Ablesedaten/Zählerstand), and categorized cost breakdowns (Energie/Netz/Abgaben or Energieentgelt/Netzentgelt/Steuern), this is an energy invoice. Extract energy-details:

24. METERING POINTS: Extract all Zählpunkt IDs (AT00...), Netzebene, Anschlusswert/Anmeldeleistung with unit.
    Include tariff name and Netzbetreiber if present. Include the site/installation address if shown.

25. METER READINGS: For each physical meter (Zählernummer), extract all register readings:
    - register: "HT" (Hochtarif), "NT" (Niedertarif), "MAX" (Leistungsspitze), "Blind" (Blindstrom), or the register label shown
    - stand-alt, stand-neu, differenz: the old reading, new reading, and difference
    - ableseart: "NBE" (Netzbetreiber elektronisch), "N" (Netzbetreiber), "K" (Kunde), "G" (rechnerisch ermittelt) — extract the code shown
    - faktor: the multiplication constant (Konstante/UF/ZW-Faktor)
    - verbrauch: the computed consumption with unit (kWh, kVARh, kW)

26. CONSUMPTION SUMMARY: If the invoice shows a current vs prior period comparison (Verbrauch aktuell/Vorperiode),
    extract both periods with total, days, per-day average, and the delta.

27. COST CATEGORIES: Group all detailed cost positions (Berechnung/Detailaufstellung) into exactly three categories:
    - "energie": Energy supply costs (Energie, Verbrauchspreis, Stromqualität, Ökoenergie, ZAM, Grundpreis Energie)
    - "netz": Network costs (Netznutzung, Netzverlust, Grundpreis Netz, Messpreis, Leistungspauschale, Bereitstellungsentgelt)
    - "abgaben": Taxes and levies (Elektrizitätsabgabe, Gebrauchsabgabe/Benützungsabgabe, EAG Förderbeiträge, EAG-Pauschale, Gebotszonentrennung, Biomasseförderbeitrag)
    Each position: description, period (start/end), basis (value + unit), days, rate (value + unit like "Ct/kWh", "€/Jahr", "€/kW", "Ct/Tag"), amount.
    Each category includes its subtotal (Summe Energie, Summe Netz, Summe Abgaben/Steuern).

28. Also extract standard line-items from the same cost positions for accounting purposes.
    The energy cost positions and line-items represent the same data from different perspectives.

Step 2: Add energy-details to the JSON output schema

In the JSON schema section of the prompt (after line 493, before the // PAGE TRACKING section), add:

  // ENERGY DETAILS (only for energy invoices with Zählpunkt/meter readings)
  "energy-details": {
    "metering-points": [
      {
        "zaehlpunkt": string,
        "netzebene": number | null,
        "anschlusswert": {"value": number, "unit": string} | null,
        "tarif": string | null,
        "netzbetreiber": string | null,
        "address": string | null,
        "meters": [
          {
            "number": string,
            "readings": [
              {
                "register": string,
                "period": {"start": "YYYY-MM-DD", "end": "YYYY-MM-DD"},
                "stand-alt": number,
                "stand-neu": number,
                "differenz": number,
                "ableseart": string | null,
                "faktor": number,
                "verbrauch": {"value": number, "unit": string}
              }
            ]
          }
        ]
      }
    ],
    "consumption-summary": {
      "current": {"value": number, "unit": string, "days": number, "per-day": number},
      "prior": {"value": number, "unit": string, "days": number, "per-day": number} | null,
      "delta": number | null
    } | null,
    "cost-categories": [
      {
        "category": "energie" | "netz" | "abgaben",
        "subtotal": number,
        "positions": [
          {
            "description": string,
            "period": {"start": "YYYY-MM-DD", "end": "YYYY-MM-DD"},
            "basis": {"value": number, "unit": string} | null,
            "days": number | null,
            "rate": {"value": number, "unit": string} | null,
            "amount": number
          }
        ]
      }
    ]
  } | null,

Step 3: Lint

clj-kondo --lint src/com/getorcha/workers/ingestion/extraction.clj

Step 4: Commit

git add src/com/getorcha/workers/ingestion/extraction.clj
git commit -m "Add energy-specific extraction rules and JSON schema to prompt"

Task 5: Add Energy-Specific Validation Rules

Files:

Step 1: Add energy validation helper functions

Add these before the validate multimethod (around line 800). These check the energy-specific invariants from the design doc:

;; Energy Invoice Validation
;; -----------------------------------------------------------------------------

(defn ^:private check-energy-meter-math
  "Validates meter reading arithmetic: (stand-neu - stand-alt) × faktor = verbrauch."
  [{:keys [energy-details] :as _structured-data}]
  (if-not energy-details
    {:status "pass" :message "Not an energy invoice"}
    (let [readings  (for [mp (:metering-points energy-details)
                          m  (:meters mp)
                          r  (:readings m)]
                      r)
          errors    (keep (fn [{:keys [stand-alt stand-neu faktor verbrauch register]
                                :as _reading}]
                      (when (and stand-alt stand-neu faktor verbrauch)
                        (let [expected (* (- stand-neu stand-alt) faktor)
                              actual   (:value verbrauch)
                              diff     (abs (- expected actual))
                              tolerance (max 0.1 (* 0.003 (abs actual)))]
                          (when (> diff tolerance)
                            {:register register
                             :expected expected
                             :actual   actual
                             :diff     diff}))))
                    readings)]
      (if (seq errors)
        {:status  "error"
         :message (str (count errors) " meter reading(s) failed math check")
         :details {:errors errors}}
        {:status  "pass"
         :message (str (count readings) " meter reading(s) verified")}))))


(defn ^:private check-energy-category-subtotals
  "Validates that cost-category position amounts sum to category subtotal."
  [{:keys [energy-details] :as _structured-data}]
  (if-not energy-details
    {:status "pass" :message "Not an energy invoice"}
    (let [categories (:cost-categories energy-details)
          errors     (keep (fn [{:keys [category subtotal positions]}]
                       (let [sum       (reduce + 0 (map :amount positions))
                             diff      (abs (- sum subtotal))
                             tolerance (max 0.02 (* 0.006 (abs subtotal)))]
                         (when (> diff tolerance)
                           {:category category
                            :expected subtotal
                            :computed sum
                            :diff     diff})))
                     categories)]
      (if (seq errors)
        {:status  "warning"
         :message (str (count errors) " category subtotal(s) don't match position sums")
         :details {:errors errors}}
        {:status  "pass"
         :message (str (count categories) " category subtotal(s) verified")}))))


(defn ^:private check-energy-total-reconciliation
  "Validates that sum of category subtotals matches invoice subtotal."
  [{:keys [energy-details subtotal] :as _structured-data}]
  (if-not (and energy-details subtotal)
    {:status "pass" :message "Not an energy invoice or no subtotal"}
    (let [category-sum (reduce + 0 (map :subtotal (:cost-categories energy-details)))
          diff         (abs (- category-sum subtotal))
          tolerance    (max 0.10 (* 0.006 (abs subtotal)))]
      (if (> diff tolerance)
        {:status  "warning"
         :message (format "Category sum %.2f differs from invoice subtotal %.2f by %.2f"
                          category-sum subtotal diff)
         :details {:category-sum category-sum :invoice-subtotal subtotal :diff diff}}
        {:status  "pass"
         :message "Category subtotals reconcile with invoice subtotal"}))))

Step 2: Wire energy checks into the invoice validation

In the validate "invoice" method (line 822-832), add the three energy checks to the :validation-results map:

(defmethod validate "invoice"
  [structured-data]
  (assoc structured-data
         :validation-results
         (cond-> {:financial-math      (check-financial-math structured-data)
                  :required-fields     (check-required-fields structured-data)
                  :tax-id-format       (check-tax-id-format structured-data)
                  :iban-format         (check-iban structured-data)
                  :date-reasonableness (check-date-reasonableness structured-data)
                  :issuer-country      (check-issuer-country structured-data)
                  :recipient-country   (check-recipient-country structured-data)}
           (:energy-details structured-data)
           (assoc :energy-meter-math          (check-energy-meter-math structured-data)
                  :energy-category-subtotals  (check-energy-category-subtotals structured-data)
                  :energy-total-reconciliation (check-energy-total-reconciliation structured-data)))))

Note: uses cond-> so energy checks are only included when energy-details is present.

Step 3: Lint

clj-kondo --lint src/com/getorcha/workers/ingestion/validation.clj

Step 4: Write a unit test for the energy validation functions

Create test/com/getorcha/workers/ingestion/validation_energy_test.clj:

(ns com.getorcha.workers.ingestion.validation-energy-test
  (:require [clojure.test :refer [deftest is testing]]
            [com.getorcha.workers.ingestion.validation :as validation]))


(def sample-energy-data
  {:document-type    "invoice"
   :invoice-subtype  "energy-invoice"
   :invoice-number   "20/957164/2025"
   :invoice-date     "2025-07-10"
   :total            21291.58
   :subtotal         17742.98
   :tax-amount       3548.60
   :tax-rate         20
   :issuer           {:name "Energie Klagenfurt GmbH" :country "AT"}
   :energy-details
   {:metering-points
    [{:zaehlpunkt "AT0071000902000000000002124420101"
      :netzebene  5
      :meters
      [{:number "70275998"
        :readings
        [{:register "HT"
          :period {:start "2025-06-01" :end "2025-06-30"}
          :stand-alt 104.46 :stand-neu 116.82 :differenz 12.37
          :ableseart "NBE" :faktor 3200
          :verbrauch {:value 39584.0 :unit "kWh"}}   ;; 12.37 * 3200 = 39584.0
         {:register "NT"
          :period {:start "2025-06-01" :end "2025-06-30"}
          :stand-alt 21.88 :stand-neu 24.24 :differenz 2.36
          :ableseart "NBE" :faktor 3200
          :verbrauch {:value 7552.0 :unit "kWh"}}]}]}  ;; 2.36 * 3200 = 7552.0
    :consumption-summary
    {:current {:value 47120.0 :unit "kWh" :days 30 :per-day 1570.67}
     :prior   {:value 60617.6 :unit "kWh" :days 31 :per-day 1955.41}
     :delta   -13497.6}
    :cost-categories
    [{:category "energie"  :subtotal 9457.45  :positions [{:description "Energie" :period {:start "2025-06-01" :end "2025-06-30"} :amount 9300.07}
                                                          {:description "Stromqualität" :period {:start "2025-06-01" :end "2025-06-30"} :amount 146.07}
                                                          {:description "Mehrkosten Ökoenergie" :period {:start "2025-06-01" :end "2025-06-30"} :amount 11.31}]}
     {:category "netz"     :subtotal 5936.50  :positions [{:description "Netznutzung" :period {:start "2025-06-01" :end "2025-06-30"} :amount 5936.50}]}
     {:category "abgaben"  :subtotal 2349.03  :positions [{:description "Elektrizitätsabgabe" :period {:start "2025-06-01" :end "2025-06-30"} :amount 2349.03}]}]}})


(deftest test-energy-meter-math-pass
  (testing "meter readings with correct math pass validation"
    (let [result (-> sample-energy-data validation/validate :validation-results :energy-meter-math)]
      (is (= "pass" (:status result))))))


(deftest test-energy-meter-math-fail
  (testing "meter reading with wrong verbrauch fails"
    (let [bad-data (assoc-in sample-energy-data
                             [:energy-details :metering-points 0 :meters 0 :readings 0 :verbrauch :value]
                             99999.0)
          result   (-> bad-data validation/validate :validation-results :energy-meter-math)]
      (is (= "error" (:status result)))
      (is (= 1 (count (get-in result [:details :errors])))))))


(deftest test-energy-category-subtotals-pass
  (testing "category position sums match subtotals"
    (let [result (-> sample-energy-data validation/validate :validation-results :energy-category-subtotals)]
      (is (= "pass" (:status result))))))


(deftest test-energy-total-reconciliation-pass
  (testing "category subtotals sum to invoice subtotal"
    (let [result (-> sample-energy-data validation/validate :validation-results :energy-total-reconciliation)]
      (is (= "pass" (:status result))))))


(deftest test-non-energy-invoice-skips-checks
  (testing "non-energy invoices don't get energy validation keys"
    (let [plain-invoice {:document-type "invoice" :invoice-number "123" :invoice-date "2025-01-01"
                         :total 100 :issuer {:name "Test"}}
          result        (validation/validate plain-invoice)]
      (is (nil? (get-in result [:validation-results :energy-meter-math]))))))

Step 5: Run the tests

clj -X:test:silent :nses '[com.getorcha.workers.ingestion.validation-energy-test]'

Expected: All tests pass.

Step 6: Commit

git add src/com/getorcha/workers/ingestion/validation.clj test/com/getorcha/workers/ingestion/validation_energy_test.clj
git commit -m "Add energy-specific validation rules with tests"

Task 6: Energy Invoice Detail UI Components

Files:

Add three new components for energy invoice rendering. These go at the end of the file, after the existing component definitions.

Step 1: Add the metering-point-card component

;; Energy Invoice Components
;; -----------------------------------------------------------------------------

(defn energy-metering-section
  "Renders metering point metadata and meter readings table."
  [{:keys [metering-points consumption-summary] :as _energy-details}]
  [:div.section
   [:h3.section-title "Metering Points & Readings"]
   (for [{:keys [zaehlpunkt netzebene anschlusswert tarif netzbetreiber address meters]
          :as _metering-point} metering-points]
     [:div.card {:key zaehlpunkt}
      [:div.card-header
       [:div.grid.grid-cols-2.gap-4
        [:div
         [:div.label "Zählpunkt"]
         [:div.value.font-mono zaehlpunkt]]
        [:div
         (when netzebene
           [:<>
            [:div.label "Netzebene"]
            [:div.value (str netzebene)]])
         (when anschlusswert
           [:<>
            [:div.label "Anschlusswert"]
            [:div.value (str (:value anschlusswert) " " (:unit anschlusswert))]])]
        (when (or tarif netzbetreiber)
          [:div
           (when tarif
             [:<>
              [:div.label "Tarif"]
              [:div.value tarif]])
           (when netzbetreiber
             [:<>
              [:div.label "Netzbetreiber"]
              [:div.value netzbetreiber]])])
        (when address
          [:div
           [:div.label "Standort"]
           [:div.value address]])]]
      ;; Meter readings table
      (for [{:keys [number readings]} meters]
        [:div {:key number}
         [:div.subsection-label (str "Zähler " number)]
         [:table.data-table
          [:thead
           [:tr
            [:th "Register"] [:th "Zeitraum"] [:th.text-right "Stand alt"]
            [:th.text-right "Stand neu"] [:th.text-right "Differenz"]
            [:th.text-right "Faktor"] [:th.text-right "Verbrauch"]]]
          [:tbody
           (for [{:keys [register period stand-alt stand-neu differenz faktor verbrauch]} readings]
             [:tr {:key (str number "-" register)}
              [:td register]
              [:td (str (:start period) " – " (:end period))]
              [:td.text-right (format-number stand-alt)]
              [:td.text-right (format-number stand-neu)]
              [:td.text-right (format-number differenz)]
              [:td.text-right (format-number faktor)]
              [:td.text-right [:strong (format-number (:value verbrauch)) " " (:unit verbrauch)]]])]]])])
   ;; Consumption comparison
   (when consumption-summary
     (let [{:keys [current prior delta]} consumption-summary]
       [:div.card.consumption-comparison
        [:div.card-header [:h4 "Consumption Comparison"]]
        [:div.grid.grid-cols-3.gap-4
         [:div
          [:div.label "Current Period"]
          [:div.value (str (format-number (:value current)) " " (:unit current))]
          [:div.sublabel (str (:days current) " days, " (format-number (:per-day current)) " " (:unit current) "/day")]]
         (when prior
           [:div
            [:div.label "Prior Period"]
            [:div.value (str (format-number (:value prior)) " " (:unit prior))]
            [:div.sublabel (str (:days prior) " days, " (format-number (:per-day prior)) " " (:unit prior) "/day")]])
         (when delta
           (let [pct (when (and prior (pos? (:value prior)))
                       (* 100.0 (/ delta (:value prior))))]
             [:div
              [:div.label "Change"]
              [:div.value {:class (if (neg? delta) "text-green" "text-red")}
               (str (when (pos? delta) "+") (format-number delta) " " (:unit current))
               (when pct
                 (str " (" (when (pos? pct) "+") (format "%.1f" pct) "%)"))]]))]]))])

Step 2: Add the cost-categories component

(def ^:private category-labels
  {"energie" "Energiekosten"
   "netz"    "Netzkosten"
   "abgaben" "Steuern & Abgaben"})


(defn energy-cost-categories
  "Renders the three cost category sections (Energie, Netz, Abgaben) with positions table."
  [{:keys [cost-categories] :as _energy-details} currency]
  [:div.section
   [:h3.section-title "Cost Breakdown"]
   (for [{:keys [category subtotal positions]} cost-categories]
     [:div.card {:key category}
      [:div.card-header
       [:h4 (get category-labels category category)]
       [:div.subtotal (format-currency subtotal currency)]]
      [:table.data-table
       [:thead
        [:tr
         [:th "Position"] [:th "Zeitraum"] [:th.text-right "Basis"]
         [:th.text-right "Tarif"] [:th.text-right "Nettobetrag"]]]
       [:tbody
        (for [{:keys [description period basis rate amount]} positions]
          [:tr {:key description}
           [:td description]
           [:td (str (:start period) " – " (:end period))]
           [:td.text-right (when basis (str (format-number (:value basis)) " " (:unit basis)))]
           [:td.text-right (when rate (str (format-number (:value rate)) " " (:unit rate)))]
           [:td.text-right (format-currency amount currency)]])]]])])

Step 3: Add the energy validation badges component

(defn energy-validation-badges
  "Renders energy-specific validation results as inline badges."
  [validation-results]
  (let [energy-checks (select-keys validation-results
                                    [:energy-meter-math :energy-category-subtotals :energy-total-reconciliation])
        check-labels  {:energy-meter-math           "Meter Math"
                       :energy-category-subtotals   "Category Subtotals"
                       :energy-total-reconciliation "Total Reconciliation"}]
    (when (seq energy-checks)
      [:div.energy-validation
       [:h4 "Energy Checks"]
       [:div.badge-row
        (for [[k v] energy-checks]
          [:span.badge {:key   (name k)
                        :class (case (:status v)
                                 "pass"    "badge-success"
                                 "warning" "badge-warning"
                                 "error"   "badge-error"
                                 "badge-neutral")}
           (get check-labels k (name k)) ": " (:status v)])]])))

Note: The format-number and format-currency helper functions should already exist in components.clj. Check this — if they don't exist by those exact names, find what the existing helpers are called and use those instead. Look for functions that format numbers with locale-appropriate separators and currency symbols.

Step 4: Lint

clj-kondo --lint src/com/getorcha/erp/ui/components.clj

Step 5: Commit

git add src/com/getorcha/erp/ui/components.clj
git commit -m "Add energy invoice UI components (metering, cost categories, validation badges)"

Task 7: Energy Invoice Detail View

Files:

Step 1: Create the energy detail view

Create src/com/getorcha/erp/http/documents/view/energy.clj. This follows the same pattern as invoice.clj but replaces the line items section with energy-specific components:

(ns com.getorcha.erp.http.documents.view.energy
  "Energy invoice detail view with metering points, cost categories, and energy validation."
  (:require [com.getorcha.erp.http.documents.view.invoice :as view.invoice]
            [com.getorcha.erp.ui.components :as erp.ui.components]))


(defn energy-invoice-detail-view
  "Renders an energy invoice with metering points, cost categories, and energy validation.
   Falls back to standard invoice view when energy-details is absent."
  [router document-id created-at
   {:keys [energy-details currency validation-results line-items]
    :as   structured-data}
   & {:keys [export-audit can-export? awaiting-auto-export? has-datev-connection? supplier-verification]}]
  (if-not energy-details
    ;; Fallback to standard invoice view if no energy-details extracted
    (view.invoice/invoice-detail-view
     router document-id created-at structured-data
     :export-audit export-audit
     :can-export? can-export?
     :awaiting-auto-export? awaiting-auto-export?
     :has-datev-connection? has-datev-connection?
     :supplier-verification supplier-verification)
    ;; Energy-specific view
    (let [has-accounts?     (some #(or (:debit-account %) (:credit-account %)) line-items)
          has-accruals?     (some :accrual line-items)
          has-cost-centers? (some :cost-center line-items)
          has-vat?          (some :vat-validation line-items)
          has-bu-code?      (some :bu-code line-items)
          use-enhanced?     (or has-accounts? has-accruals? has-cost-centers? has-vat? has-bu-code?)]
      [:div
       ;; DATEV export section
       (when has-datev-connection?
         (view.invoice/datev-export-section router document-id export-audit can-export? awaiting-auto-export?))

       ;; Header grid: invoice metadata and parties
       (erp.ui.components/invoice-header structured-data created-at)

       ;; Combined validation section (formal + fraud + energy)
       (erp.ui.components/validation-section structured-data supplier-verification)

       ;; Energy-specific validation badges
       (when validation-results
         (erp.ui.components/energy-validation-badges validation-results))

       ;; Supplier verification
       (when supplier-verification
         (erp.ui.components/supplier-verification-box supplier-verification))

       ;; Metering points & readings
       (erp.ui.components/energy-metering-section energy-details)

       ;; Cost breakdown by category
       (erp.ui.components/energy-cost-categories energy-details currency)

       ;; Accounting line items (below the energy-specific sections)
       (when (seq line-items)
         [:<>
          [:h3.section-title "Accounting Line Items"]
          (if use-enhanced?
            (erp.ui.components/enhanced-line-items-table line-items currency)
            (erp.ui.components/line-items-table line-items currency))])

       ;; Payment summary
       (erp.ui.components/payment-summary structured-data)])))

Note: datev-export-section in view.invoice is defined as ^:private. If so, you'll need to either make it public or extract it to a shared namespace. Check the var's metadata. If it's private, the simplest approach for the demo branch is to remove the ^:private metadata from datev-export-section in invoice.clj.

Step 2: Wire energy view into the shared dispatch

In shared.clj, lines 134-151, modify type-specific-view to check for energy-invoice subtype:

(defn ^:private type-specific-view
  "Dispatches to the correct type-specific view renderer."
  [doc-type router document-id created-at structured-data
   {:keys [export-audit can-export? awaiting-auto-export? has-datev-connection? supplier-verification]
    :as   _opts}]
  (case (some-> doc-type name)
    "invoice"            (if (= "energy-invoice" (:invoice-subtype structured-data))
                           (view.energy/energy-invoice-detail-view
                            router document-id created-at structured-data
                            :export-audit export-audit
                            :can-export? can-export?
                            :awaiting-auto-export? awaiting-auto-export?
                            :has-datev-connection? has-datev-connection?
                            :supplier-verification supplier-verification)
                           (view.invoice/invoice-detail-view
                            router document-id created-at structured-data
                            :export-audit export-audit
                            :can-export? can-export?
                            :awaiting-auto-export? awaiting-auto-export?
                            :has-datev-connection? has-datev-connection?
                            :supplier-verification supplier-verification))
    "financial-notice"   (view.notice/notice-detail-view structured-data)
    "contract"           (view.contract/contract-detail-view structured-data (view.contract/contract-status doc-type structured-data) _opts)
    "purchase-order"     (view.purchase-order/po-detail-view structured-data)
    "goods-received-note" (view.goods-received-note/grn-detail-view structured-data)
    nil))

Don't forget to add the require for view.energy in shared.clj's namespace declaration.

Step 3: Lint

clj-kondo --lint src/com/getorcha/erp/http/documents/view/energy.clj src/com/getorcha/erp/http/documents/view/shared.clj

Step 4: Commit

git add src/com/getorcha/erp/http/documents/view/energy.clj src/com/getorcha/erp/http/documents/view/shared.clj src/com/getorcha/erp/http/documents/view/invoice.clj
git commit -m "Add energy invoice detail view with dispatch from shared view"

Task 8: Energy Portfolio Overview Page

Files:

Step 1: Create the portfolio handler and routes

Create src/com/getorcha/erp/http/energy_portfolio.clj:

(ns com.getorcha.erp.http.energy-portfolio
  "Energy portfolio overview — lists all energy metering points across ingested invoices."
  (:require [com.getorcha.erp.http.middleware :as http.middleware]
            [com.getorcha.erp.ui.layout :as layout]
            [next.jdbc.sql :as db.sql]
            [ring.util.response :as ring.resp]))


(def ^:private portfolio-query
  "Retrieves energy invoice data for the portfolio overview.
   Extracts key fields from the JSONB structured_data."
  "SELECT d.id,
          d.structured_data->>'invoice-number' AS invoice_number,
          d.structured_data->>'invoice-date' AS invoice_date,
          d.structured_data->'service-period'->>'start' AS period_start,
          d.structured_data->'service-period'->>'end' AS period_end,
          d.structured_data->>'subtotal' AS subtotal,
          d.structured_data->>'currency' AS currency,
          d.structured_data->'recipient'->>'name' AS recipient_name,
          d.structured_data->'energy-details'->'metering-points'->0->>'zaehlpunkt' AS zaehlpunkt,
          d.structured_data->'energy-details'->'metering-points'->0->>'netzebene' AS netzebene,
          d.structured_data->'energy-details'->'metering-points'->0->>'address' AS site_address,
          d.structured_data->'energy-details'->'consumption-summary'->'current'->>'value' AS consumption_kwh,
          d.structured_data->'energy-details'->'consumption-summary'->'current'->>'days' AS consumption_days,
          d.structured_data->'energy-details'->'consumption-summary'->>'delta' AS consumption_delta
   FROM document d
   WHERE d.tenant_id = ?
     AND d.structured_data->>'invoice-subtype' = 'energy-invoice'
     AND d.structured_data->'energy-details' IS NOT NULL
   ORDER BY d.structured_data->>'invoice-date' DESC")


(defn ^:private parse-double-safe [s]
  (when (and s (not= s "null"))
    (try (Double/parseDouble s) (catch Exception _ nil))))


(defn ^:private portfolio-page
  "Renders the energy portfolio overview page."
  [router metering-data]
  (let [total-consumption (reduce + 0 (keep :consumption-kwh metering-data))
        total-cost        (reduce + 0 (keep :subtotal metering-data))
        avg-cost-per-kwh  (when (pos? total-consumption) (/ total-cost total-consumption))
        point-count       (count metering-data)]
    [:div.page-content
     [:h1 "Energy Portfolio"]
     ;; Summary stats
     [:div.stats-row
      [:div.stat-card
       [:div.stat-label "Total Consumption"]
       [:div.stat-value (format "%,.0f kWh" total-consumption)]]
      [:div.stat-card
       [:div.stat-label "Total Cost (netto)"]
       [:div.stat-value (format "%,.2f €" total-cost)]]
      [:div.stat-card
       [:div.stat-label "Avg. Cost"]
       [:div.stat-value (if avg-cost-per-kwh (format "%.4f €/kWh" avg-cost-per-kwh) "—")]]
      [:div.stat-card
       [:div.stat-label "Metering Points"]
       [:div.stat-value (str point-count)]]]
     ;; Portfolio table
     [:table.data-table
      [:thead
       [:tr
        [:th "Zählpunkt"] [:th "Standort"] [:th "NE"] [:th "Zeitraum"]
        [:th.text-right "Verbrauch (kWh)"] [:th.text-right "Kosten (€)"]
        [:th.text-right "€/kWh"] [:th.text-right "Δ Vorperiode"]]]
      [:tbody
       (if (empty? metering-data)
         [:tr [:td {:colspan 8} "No energy invoices found. Ingest energy invoices to see them here."]]
         (for [{:keys [id zaehlpunkt site-address netzebene period-start period-end
                       consumption-kwh subtotal consumption-delta]} metering-data]
           (let [cost-per-kwh (when (and subtotal consumption-kwh (pos? consumption-kwh))
                                (/ subtotal consumption-kwh))
                 delta-pct    (when (and consumption-delta consumption-kwh
                                        (not (zero? consumption-kwh)))
                                (let [prior (- consumption-kwh (or consumption-delta 0))]
                                  (when (pos? prior)
                                    (* 100.0 (/ consumption-delta prior)))))]
             [:tr {:key (str id)
                   :class "clickable-row"
                   :hx-get (str "/documents/view/" id)
                   :hx-push-url "true"
                   :hx-target "body"}
              [:td.font-mono (or zaehlpunkt "—")]
              [:td (or site-address "—")]
              [:td (or netzebene "—")]
              [:td (str (or period-start "") " – " (or period-end ""))]
              [:td.text-right (if consumption-kwh (format "%,.0f" consumption-kwh) "—")]
              [:td.text-right (if subtotal (format "%,.2f" subtotal) "—")]
              [:td.text-right (if cost-per-kwh (format "%.4f" cost-per-kwh) "—")]
              [:td.text-right {:class (cond
                                        (and delta-pct (> delta-pct 20))  "text-red"
                                        (and delta-pct (< delta-pct -20)) "text-green"
                                        (and delta-pct (neg? delta-pct))  "text-green"
                                        (and delta-pct (pos? delta-pct))  "text-red"
                                        :else "")}
               (if delta-pct
                 (str (when (pos? delta-pct) "+") (format "%.1f%%" delta-pct))
                 "—")]])))]]]))


(defn ^:private get-portfolio
  "Handler for the energy portfolio page."
  [{:keys [::http.middleware/db-pool ::http.middleware/router identity] :as _request}
   respond _raise]
  (let [tenant-id (:tenant/id identity)
        rows      (db.sql/query db-pool [portfolio-query tenant-id])
        parsed    (mapv (fn [row]
                          (-> row
                              (update :subtotal parse-double-safe)
                              (update :consumption-kwh parse-double-safe)
                              (update :consumption-delta parse-double-safe)
                              (update :netzebene parse-double-safe)))
                        rows)]
    (respond
     (-> (layout/page router "Energy Portfolio" :energy-portfolio (:identity _request)
                       (portfolio-page router parsed))
         ring.resp/ok
         (ring.resp/content-type "text/html")))))


(defn routes
  "Energy portfolio routes."
  [_config]
  ["/energy-portfolio"
   {:name ::list
    :get  {:handler get-portfolio}}])

Note: The layout/page call signature may differ. Check the existing handler patterns in documents/accounts_payable.clj or similar for the exact layout/page usage pattern (how identity, tenants, and active nav state are passed). Adapt accordingly.

Step 2: Register the route

In src/com/getorcha/erp/http.clj, add the energy portfolio route to the authenticated routes section (around line 49-56):

["" {:middleware [(erp.http.middleware.auth/wrap-authentication)]}
 (erp.http.documents/routes config)
 (erp.http.energy-portfolio/routes config)   ;; ADD THIS LINE
 (erp.http.oauth/routes config)
 ...

Add the require for the namespace in the ns declaration.

Step 3: Add nav item

In src/com/getorcha/erp/ui/layout.clj, add an "Energy Portfolio" nav item after the "Accounts Payable" item (around line 51):

Add the path binding at line 43:

energy-path       (erp.http.routes/path-for router :com.getorcha.erp.http.energy-portfolio/list)

Add the nav item after the Accounts Payable [:li ...] (after line 51):

[:li {:class (when (= active :energy-portfolio) "active")}
 [:a {:href energy-path}
  [:iconify-icon {:icon :lucide:zap}]
  "Energy Portfolio"]]

Step 4: Lint

clj-kondo --lint src/com/getorcha/erp/http/energy_portfolio.clj src/com/getorcha/erp/http.clj src/com/getorcha/erp/ui/layout.clj

Step 5: Commit

git add src/com/getorcha/erp/http/energy_portfolio.clj src/com/getorcha/erp/http.clj src/com/getorcha/erp/ui/layout.clj
git commit -m "Add energy portfolio overview page with nav link and route"

Task 9: End-to-End Test with Sample PDFs

Files:

This task verifies the full pipeline works by ingesting the sample energy invoices.

Step 1: Start the system

Ensure the local dev system is running with (reset) in the REPL.

Step 2: Ingest the sample PDFs

Use the existing document ingestion mechanism to ingest the 3 PDFs from dump/strabag/ (ATP1EQ67.pdf, ATP1DVPV.pdf, ATKABHH9.pdf). The method depends on how local ingestion is triggered — likely via the UI upload or a REPL function. Check for an ingestion helper in the dev namespace.

Step 3: Verify classification

After ingestion, check that the documents are classified as invoice-subtype: "energy-invoice":

SELECT id, structured_data->>'invoice-subtype' as subtype,
       structured_data->'energy-details' IS NOT NULL as has_energy
FROM document
WHERE structured_data->>'invoice-subtype' = 'energy-invoice';

Expected: All 3 documents classified as energy-invoice with energy-details present.

Step 4: Verify extraction quality

For each ingested document, spot-check the energy-details in the database:

SELECT structured_data->'energy-details'->'metering-points'->0->>'zaehlpunkt' as zp,
       structured_data->'energy-details'->'consumption-summary'->'current'->>'value' as consumption,
       jsonb_array_length(structured_data->'energy-details'->'cost-categories') as num_categories
FROM document
WHERE structured_data->>'invoice-subtype' = 'energy-invoice';

Expected:

Step 5: Verify validation

SELECT structured_data->>'invoice-number',
       structured_data->'validation-results'->'energy-meter-math'->>'status' as meter_math,
       structured_data->'validation-results'->'energy-category-subtotals'->>'status' as cat_sums,
       structured_data->'validation-results'->'energy-total-reconciliation'->>'status' as total_recon
FROM document
WHERE structured_data->>'invoice-subtype' = 'energy-invoice';

Expected: All "pass" (or "warning" with small rounding differences).

Step 6: Verify UI

  1. Open the document detail view for each energy invoice in the browser
  2. Confirm: metering point card, readings table, consumption comparison, cost categories render correctly
  3. Open /energy-portfolio — confirm the portfolio table shows all 3 metering points with correct data

Step 7: Fix any extraction issues

If the LLM doesn't extract energy-details correctly for some invoices, iterate on the extraction prompt (Task 4). Common issues:

Step 8: Commit any fixes

git add -p  # stage only relevant changes
git commit -m "Fix extraction prompt based on e2e testing with STRABAG samples"

Task 10: CSS Styling for Energy Components

Files:

The energy components need basic styling. Check where existing component styles (.card, .data-table, .section, .badge) are defined and add energy-specific styles there.

Key styles needed:

Follow existing patterns for card styling, spacing, and colors. The demo should look polished.

Step 1: Add styles following existing patterns

Check the existing CSS for .card, .badge, .data-table patterns and extend them for the energy components.

Step 2: Verify in browser

Load an energy invoice detail view and the portfolio page. Adjust spacing and alignment.

Step 3: Commit

git add resources/public/css/  # or wherever the CSS lives
git commit -m "Add CSS styling for energy invoice components and portfolio page"

Task Summary

Task Description Dependencies
1 Create demo branch None
2 Add energy-invoice subtype 1
3 Define Malli schema 1
4 Extend extraction prompt 2, 3
5 Add energy validation rules 3
6 Energy UI components 3
7 Energy detail view + dispatch 6
8 Energy portfolio page 6
9 End-to-end test with samples 2, 4, 5, 7, 8
10 CSS styling 6, 7, 8

Tasks 2-3 can run in parallel. Tasks 4-5 can run in parallel after 2-3. Tasks 6-8 can run in parallel after 3. Task 9 requires all prior tasks. Task 10 can start after 6.