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
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
"energy-invoice" to ClassificationFiles:
src/com/getorcha/workers/ingestion/classification.clj:27-30 (invoice-subtypes set)src/com/getorcha/workers/ingestion/classification.clj:104-111 (prompt subtypes list)src/com/getorcha/schema/invoice/structured_data.clj:266-268 (InvoiceData :invoice-subtype enum)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"
Files:
src/com/getorcha/schema/invoice/structured_data.cljThis 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"
Files:
src/com/getorcha/workers/ingestion/extraction.cljThe 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"
Files:
src/com/getorcha/workers/ingestion/validation.cljStep 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"
Files:
src/com/getorcha/erp/ui/components.cljAdd 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)"
Files:
src/com/getorcha/erp/http/documents/view/energy.cljsrc/com/getorcha/erp/http/documents/view/shared.clj:134-151 (type-specific-view dispatch)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"
Files:
src/com/getorcha/erp/http/energy_portfolio.cljsrc/com/getorcha/erp/http.clj:48-56 (add route)src/com/getorcha/erp/ui/layout.clj:44-63 (add nav item)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"
Files:
dump/strabag/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
/energy-portfolio — confirm the portfolio table shows all 3 metering points with correct dataStep 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"
Files:
resources/public/css/ or similar for the main stylesheet)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:
.consumption-comparison — the current vs prior comparison card.stats-row / .stat-card — the portfolio summary stats.clickable-row — hover effect for portfolio table rows.text-green / .text-red — colored text for delta indicators (may already exist).energy-validation / .badge-row — horizontal badge layoutFollow 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 | 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.