Note (2026-04-24): After this document was written, legal_entity was renamed to tenant and the old tenant was renamed to organization. Read references to these terms with the pre-rename meaning.

DATEV REWE Integration Implementation Plan

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

Goal: Enable users to connect their DATEV Rechnungswesen account via shareable link so tax advisors can authorize data import without needing an Orcha account.

Architecture: Shareable link flow generates time-limited tokens; tax advisor visits public HTMX page, authenticates with DATEV via Maesn OAuth, callback stores encrypted credentials and triggers initial sync; journal entries map to existing booking_history_item table.

Tech Stack: Clojure, Reitit, HTMX, HoneySQL, PostgreSQL, Maesn API, KMS encryption


Files:

Step 1: Create the migration files

Create migration with bb migrate create "datev-rewe-link-table", then add content.

up.sql:

-- Add datev_rewe to integration_type enum
ALTER TYPE integration_type ADD VALUE IF NOT EXISTS 'datev_rewe';

--;;

-- Create enum for booking history source
CREATE TYPE booking_history_source AS ENUM ('csv_upload', 'datev_rewe');

--;;

-- Add source column to booking_history_upload
ALTER TABLE booking_history_upload
    ADD COLUMN source booking_history_source NOT NULL DEFAULT 'csv_upload';

--;;

-- Create datev_rewe_link table
CREATE TABLE datev_rewe_link (
    id               UUID PRIMARY KEY DEFAULT gen_random_uuid(),
    legal_entity_id  UUID NOT NULL REFERENCES legal_entity(id) ON DELETE CASCADE,
    token            TEXT NOT NULL UNIQUE,
    expires_at       TIMESTAMPTZ NOT NULL,
    created_by       UUID REFERENCES identity(id) ON DELETE SET NULL,
    used_at          TIMESTAMPTZ,
    created_at       TIMESTAMPTZ NOT NULL DEFAULT now()
);

--;;

CREATE INDEX idx_datev_rewe_link_token ON datev_rewe_link(token);

--;;

CREATE INDEX idx_datev_rewe_link_legal_entity ON datev_rewe_link(legal_entity_id);

down.sql:

DROP INDEX IF EXISTS idx_datev_rewe_link_legal_entity;

--;;

DROP INDEX IF EXISTS idx_datev_rewe_link_token;

--;;

DROP TABLE IF EXISTS datev_rewe_link;

--;;

ALTER TABLE booking_history_upload DROP COLUMN IF EXISTS source;

--;;

DROP TYPE IF EXISTS booking_history_source;

-- Note: Cannot remove enum value from integration_type in PostgreSQL
-- datev_rewe value will remain but be unused

Step 2: Run migration to verify it works

Run: bb migrate migrate Expected: Migration applies successfully

Step 3: Commit

git add resources/migrations/*datev-rewe-link-table*
git commit -m "Add datev_rewe_link table and booking_history_source enum"

Task 2: Add config for DATEV REWE auth endpoint

Files:

Step 1: Add datev-rewe config alongside existing datev config

Find the :datev key under :com.getorcha/integrations (around line 37) and add :datev-rewe config:

:datev-rewe #merge [{:account-key-encryption-key-arn #ref [:com.getorcha/db-secrets-key-arn]}
                    #profile {:local-dev {:api-key       #orcha/param "/v1-orcha/integrations/maesn/api-key-sandbox"
                                          :auth-endpoint "/auth/datev-rewe-sandbox-longtoken"}
                              :default   {:api-key       #orcha/param "/v1-orcha/integrations/maesn/api-key-sandbox"
                                          :auth-endpoint "/auth/datev-rewe-longtoken"}}]

Step 2: Commit

git add resources/com/getorcha/config.edn
git commit -m "Add DATEV REWE config for Maesn integration"

Task 3: Add Maesn API function to fetch journal entries

Files:

Step 1: Add get-journal-entries function

Add after the get-user-info function (around line 950):

(defn get-journal-entries
  "Fetches journal entries from DATEV Rechnungswesen via Maesn API.

   Arguments:
     datev-config - Config map with :api-key
     account-key  - Decrypted DATEV REWE account key
     fiscal-year  - Year to fetch (required for DATEV REWE)
     page         - Page number (1-indexed)
     limit        - Results per page (default 100)

   Returns map with :entries (vector of journal entries) and :pagination info.
   Each entry contains :id, :number, :transactionDate, :description, :currency,
   and :journalLineItems with account numbers, amounts, dimensions."
  [{:keys [api-key] :as _datev-config} account-key fiscal-year page & {:keys [limit] :or {limit 100}}]
  (with-api-logging :get-journal-entries {:fiscal-year fiscal-year :page page}
    (let [response (http/request {:url               (str api-base-url "/accounting/journalEntries")
                                   :method            :get
                                   :headers           {"X-API-KEY"     api-key
                                                       "X-ACCOUNT-KEY" account-key}
                                   :query-params      {"fiscalYear" fiscal-year
                                                       "page"       page
                                                       "limit"      limit}
                                   :as                :json
                                   :throw-exceptions? false
                                   :socket-timeout    60000
                                   :conn-timeout      10000})]
      (if (= 200 (:status response))
        {:entries    (get-in response [:body :data])
         :pagination (get-in response [:body :meta :pagination])}
        (throw (ex-info "Failed to fetch journal entries"
                        {:status      (:status response)
                         :fiscal-year fiscal-year
                         :page        page}))))))

Step 2: Commit

git add src/com/getorcha/integrations/maesn.clj
git commit -m "Add get-journal-entries function for DATEV REWE"

Files:

Step 1: Write test for token generation

(ns com.getorcha.erp.datev-rewe-test
  (:require [clojure.test :refer [deftest is testing]]
            [com.getorcha.erp.datev-rewe :as datev-rewe]))


(deftest generate-token-test
  (testing "generates URL-safe base64 token"
    (let [token (datev-rewe/generate-token)]
      (is (string? token))
      (is (= 43 (count token)))  ; 32 bytes -> 43 chars base64
      (is (re-matches #"[A-Za-z0-9_-]+" token)))))

Step 2: Run test to verify it fails

Run: clj -X:test:silent :nses '[com.getorcha.erp.datev-rewe-test]' Expected: FAIL - namespace not found

Step 3: Write minimal implementation

(ns com.getorcha.erp.datev-rewe
  "DATEV REWE integration for importing historical journal entries.

   Provides shareable link generation for tax advisor authentication
   and journal entry sync functionality."
  (:require [com.getorcha.db.sql :as db.sql]
            [com.getorcha.integrations.maesn :as maesn]
            [com.getorcha.util.text :as util.text]
            [clojure.tools.logging :as log])
  (:import (java.security SecureRandom)
           (java.time Instant)
           (java.time.temporal ChronoUnit)
           (java.util Base64)))


(defn generate-token
  "Generates a cryptographically secure URL-safe token for shareable links.

   Returns a 32-byte random value encoded as URL-safe base64 (43 characters)."
  []
  (let [random (SecureRandom.)
        bytes  (byte-array 32)]
    (.nextBytes random bytes)
    (.encodeToString (Base64/getUrlEncoder) bytes)))

Step 4: Run test to verify it passes

Run: clj -X:test:silent :nses '[com.getorcha.erp.datev-rewe-test]' Expected: PASS

Step 5: Commit

git add src/com/getorcha/erp/datev_rewe.clj test/com/getorcha/erp/datev_rewe_test.clj
git commit -m "Add DATEV REWE namespace with token generation"

Files:

Step 1: Add integration test for link creation

Add to test file:

(ns com.getorcha.erp.datev-rewe-test
  (:require [clojure.test :refer [deftest is testing use-fixtures]]
            [com.getorcha.erp.datev-rewe :as datev-rewe]
            [com.getorcha.test.fixtures :as fixtures]
            [com.getorcha.test.notification-helpers :as helpers]))


(use-fixtures :once fixtures/with-running-system)
(use-fixtures :each fixtures/with-db-rollback)


(deftest generate-token-test
  (testing "generates URL-safe base64 token"
    (let [token (datev-rewe/generate-token)]
      (is (string? token))
      (is (= 43 (count token)))
      (is (re-matches #"[A-Za-z0-9_-]+" token)))))


(deftest create-link-test
  (testing "creates link with 7-day expiry"
    (let [legal-entity-id (helpers/create-legal-entity!)
          user-id         (random-uuid)
          link            (datev-rewe/create-link! fixtures/*db*
                                                    legal-entity-id
                                                    user-id)]
      (is (uuid? (:datev-rewe-link/id link)))
      (is (= legal-entity-id (:datev-rewe-link/legal-entity-id link)))
      (is (string? (:datev-rewe-link/token link)))
      (is (nil? (:datev-rewe-link/used-at link))))))


(deftest validate-link-test
  (testing "valid link returns link record"
    (let [legal-entity-id (helpers/create-legal-entity!)
          link            (datev-rewe/create-link! fixtures/*db* legal-entity-id nil)
          token           (:datev-rewe-link/token link)
          result          (datev-rewe/validate-link fixtures/*db* token)]
      (is (= :valid (:status result)))
      (is (= (:datev-rewe-link/id link) (get-in result [:link :datev-rewe-link/id])))))

  (testing "invalid token returns :not-found"
    (let [result (datev-rewe/validate-link fixtures/*db* "nonexistent-token")]
      (is (= :not-found (:status result)))))

  (testing "used link returns :already-used"
    (let [legal-entity-id (helpers/create-legal-entity!)
          link            (datev-rewe/create-link! fixtures/*db* legal-entity-id nil)
          _               (datev-rewe/mark-link-used! fixtures/*db* (:datev-rewe-link/id link))
          result          (datev-rewe/validate-link fixtures/*db* (:datev-rewe-link/token link))]
      (is (= :already-used (:status result))))))

Step 2: Run tests to verify they fail

Run: clj -X:test:silent :nses '[com.getorcha.erp.datev-rewe-test]' Expected: FAIL - functions not defined

Step 3: Implement link functions

Add to datev_rewe.clj:

(def ^:private link-expiry-days 7)


(defn create-link!
  "Creates a shareable link for DATEV REWE authentication.

   Arguments:
     db              - Database connection
     legal-entity-id - Legal entity UUID
     created-by      - Identity UUID of user creating the link (can be nil for admin)

   Returns the created link record."
  [db legal-entity-id created-by]
  (let [token      (generate-token)
        expires-at (.plus (Instant/now) link-expiry-days ChronoUnit/DAYS)]
    (db.sql/execute-one!
     db
     {:insert-into :datev-rewe-link
      :values      [{:legal-entity-id legal-entity-id
                     :token           token
                     :expires-at      expires-at
                     :created-by      created-by}]
      :returning   [:*]})))


(defn validate-link
  "Validates a DATEV REWE link token.

   Returns:
     {:status :valid :link <record>}       - Link is valid and unused
     {:status :not-found}                  - Token doesn't exist
     {:status :expired}                    - Link has expired
     {:status :already-used}               - Link was already used"
  [db token]
  (if-let [link (db.sql/execute-one!
                  db
                  {:select [:*]
                   :from   [:datev-rewe-link]
                   :where  [:= :token token]})]
    (cond
      (some? (:datev-rewe-link/used-at link))
      {:status :already-used}

      (.isBefore ^Instant (:datev-rewe-link/expires-at link) (Instant/now))
      {:status :expired}

      :else
      {:status :valid :link link})
    {:status :not-found}))


(defn mark-link-used!
  "Marks a link as used by setting used_at timestamp."
  [db link-id]
  (db.sql/execute-one!
   db
   {:update :datev-rewe-link
    :set    {:used-at [:now]}
    :where  [:= :id link-id]}))

Step 4: Run tests to verify they pass

Run: clj -X:test:silent :nses '[com.getorcha.erp.datev-rewe-test]' Expected: PASS

Step 5: Commit

git add src/com/getorcha/erp/datev_rewe.clj test/com/getorcha/erp/datev_rewe_test.clj
git commit -m "Add DATEV REWE link creation and validation"

Task 6: Add journal entry sync function

Files:

Step 1: Add test for journal entry mapping

(deftest map-journal-entry-test
  (testing "maps Maesn journal entry to booking history item"
    (let [entry {:id              "entry-123"
                 :description     "Hotel invoice"
                 :journalLineItems [{:accountNumber        "6300"
                                     :debitCreditIndicator "DEBIT"
                                     :description          "Hotel stay Berlin"
                                     :totalNetAmount       500.0
                                     :dimensions           [{:code "CC-100"}]}
                                    {:accountNumber        "1400"
                                     :debitCreditIndicator "CREDIT"
                                     :description          "Hotel stay Berlin"
                                     :totalNetAmount       500.0}]}
          items (datev-rewe/map-journal-entry-to-items entry)]
      (is (= 2 (count items)))
      (let [debit-item (first items)]
        (is (= "Hotel stay Berlin" (:supplier-name debit-item)))
        (is (= "Hotel stay Berlin" (:description debit-item)))
        (is (= "6300" (:debit-account debit-item)))
        (is (nil? (:credit-account debit-item)))
        (is (= "CC-100" (:cost-center debit-item)))
        (is (= 500.0 (:net-amount debit-item)))))))

Step 2: Run test to verify it fails

Run: clj -X:test:silent :nses '[com.getorcha.erp.datev-rewe-test]' Expected: FAIL - function not defined

Step 3: Implement mapping function

Add to datev_rewe.clj:

(defn map-journal-entry-to-items
  "Maps a Maesn journal entry to booking history items.

   Each line item becomes a separate booking history item.
   DEBIT lines get debit_account, CREDIT lines get credit_account."
  [{:keys [description journalLineItems] :as _entry}]
  (mapv (fn [{:keys [accountNumber debitCreditIndicator totalNetAmount dimensions]
              line-description :description}]
          (let [desc        (or line-description description)
                cost-center (some-> dimensions first :code)]
            {:supplier-name  desc
             :description    desc
             :debit-account  (when (= "DEBIT" debitCreditIndicator) accountNumber)
             :credit-account (when (= "CREDIT" debitCreditIndicator) accountNumber)
             :cost-center    cost-center
             :net-amount     totalNetAmount}))
        journalLineItems))

Step 4: Run test to verify it passes

Run: clj -X:test:silent :nses '[com.getorcha.erp.datev-rewe-test]' Expected: PASS

Step 5: Commit

git add src/com/getorcha/erp/datev_rewe.clj test/com/getorcha/erp/datev_rewe_test.clj
git commit -m "Add journal entry to booking history item mapping"

Task 7: Add sync-journal-entries! function

Files:

Step 1: Implement sync function

Add to datev_rewe.clj:

(defn ^:private insert-booking-history-items!
  "Inserts journal entries as booking history items.

   Creates a booking_history_upload record with source='datev_rewe',
   then inserts all items. Does NOT soft-delete existing items (append-only)."
  [db {:keys [legal-entity-id items]}]
  (when (seq items)
    (db.sql/with-transaction [tx db]
      (let [upload    (db.sql/execute-one! tx
                        {:insert-into :booking-history-upload
                         :values      [{:legal-entity-id legal-entity-id
                                        :filename        (str "DATEV REWE Sync " (java.time.LocalDate/now))
                                        :row-count       (count items)
                                        :source          (db.sql/->cast :datev_rewe :booking-history-source)}]
                         :returning   [:id]})
            upload-id (:booking-history-upload/id upload)
            db-items  (mapv (fn [{:keys [supplier-name description debit-account
                                         credit-account cost-center net-amount]}]
                              {:upload-id                upload-id
                               :supplier-name-normalized (util.text/normalize-supplier-name supplier-name)
                               :description-normalized   (util.text/normalize-text description)
                               :supplier-name            (or supplier-name "")
                               :description              (or description "")
                               :debit-account            (or debit-account "")
                               :credit-account           credit-account
                               :cost-center              cost-center
                               :net-amount               net-amount})
                            items)]
        (db.sql/execute! tx
          {:insert-into :booking-history-item
           :values      db-items})
        {:upload-id  upload-id
         :item-count (count items)}))))


(defn sync-journal-entries!
  "Fetches journal entries from DATEV REWE and stores them as booking history.

   Fetches current year and previous year. Paginates through all results.

   Arguments:
     db           - Database connection
     datev-config - Config map with :api-key
     aws          - AWS config for KMS
     integration  - legal_entity_integration record with encrypted credentials

   Returns {:success true :item-count N} or {:success false :error ...}"
  [db datev-config aws integration]
  ;; TODO: This sync is currently triggered inline from the ERP handler.
  ;; In the future, this should be moved to a queued process handled by workers.
  ;; See design doc: docs/plans/2026-02-18-datev-rewe-integration-design.md
  (try
    (let [legal-entity-id (:legal-entity-integration/legal-entity-id integration)
          account-key     (maesn/decrypt-account-key
                            aws
                            (:legal-entity-integration/credentials-encrypted integration))
          current-year    (.getYear (java.time.LocalDate/now))
          fiscal-years    [current-year (dec current-year)]
          all-items       (atom [])]

      ;; Fetch all pages for each fiscal year
      (doseq [fiscal-year fiscal-years]
        (loop [page 1]
          (let [{:keys [entries pagination]} (maesn/get-journal-entries
                                               datev-config
                                               account-key
                                               fiscal-year
                                               page)
                items (mapcat map-journal-entry-to-items entries)]
            (swap! all-items into items)
            (when (< page (:totalPages pagination 1))
              (recur (inc page))))))

      ;; Insert all items
      (let [result (insert-booking-history-items!
                     db
                     {:legal-entity-id legal-entity-id
                      :items           @all-items})]
        ;; Update last_sync_at in integration config
        (db.sql/execute-one!
          db
          {:update :legal-entity-integration
           :set    {:config     [:|| :config [:lift {:last-sync-at (str (Instant/now))}]]
                    :updated-at [:now]}
           :where  [:= :id (:legal-entity-integration/id integration)]})
        {:success    true
         :item-count (or (:item-count result) 0)}))
    (catch Exception e
      (log/error e "DATEV REWE sync failed"
                 {:legal-entity-id (:legal-entity-integration/legal-entity-id integration)})
      {:success false
       :error   (.getMessage e)})))

Step 2: Commit

git add src/com/getorcha/erp/datev_rewe.clj
git commit -m "Add sync-journal-entries! function for DATEV REWE"

Task 8: Create public HTMX landing page handler

Files:

Step 1: Create the handler namespace

(ns com.getorcha.erp.http.connect.datev-rewe
  "Public HTMX pages for DATEV REWE tax advisor authentication.

   These pages are unauthenticated - tax advisors don't need an Orcha account."
  (:require [com.getorcha.db.sql :as db.sql]
            [com.getorcha.erp.datev-rewe :as datev-rewe]
            [com.getorcha.erp.http.routes :as erp.http.routes]
            [com.getorcha.integrations.maesn :as maesn]
            [hiccup2.core :as h]
            [reitit.core :as reitit]
            [ring.util.response :as ring.resp]
            [clojure.tools.logging :as log])
  (:import (java.time Instant)
           (javax.crypto Mac)
           (javax.crypto.spec SecretKeySpec)
           (java.util Base64)))


(def ^:private hmac-secret
  "Secret for signing state tokens. In production, this should come from config."
  ;; TODO: Move to config
  "datev-rewe-state-secret-change-me")


(defn ^:private sign-state
  "Signs state data with HMAC-SHA256."
  [data]
  (let [mac  (doto (Mac/getInstance "HmacSHA256")
               (.init (SecretKeySpec. (.getBytes hmac-secret "UTF-8") "HmacSHA256")))
        sig  (.doFinal mac (.getBytes data "UTF-8"))]
    (str data "." (.encodeToString (Base64/getUrlEncoder) sig))))


(defn ^:private verify-state
  "Verifies and extracts data from signed state. Returns nil if invalid."
  [signed-state]
  (when signed-state
    (let [[data sig] (clojure.string/split signed-state #"\." 2)]
      (when (and data sig)
        (let [expected (sign-state data)]
          (when (= signed-state expected)
            data))))))


(defn ^:private base-layout
  "Base HTML layout for public DATEV REWE pages."
  [title & body]
  (str
    (h/html
      [:html {:lang "en"}
       [:head
        [:meta {:charset "UTF-8"}]
        [:meta {:name "viewport" :content "width=device-width, initial-scale=1.0"}]
        [:title title]
        [:script {:src "https://unpkg.com/htmx.org@1.9.10"}]
        [:style
         "body { font-family: system-ui, sans-serif; max-width: 600px; margin: 40px auto; padding: 20px; }
          .card { border: 1px solid #e0e0e0; border-radius: 8px; padding: 24px; }
          .card h1 { margin-top: 0; }
          .btn { display: inline-block; padding: 12px 24px; background: #2563eb; color: white;
                 text-decoration: none; border-radius: 6px; font-weight: 500; }
          .btn:hover { background: #1d4ed8; }
          .error { color: #dc2626; }
          .success { color: #16a34a; }"]]
       [:body body]])))


(defn ^:private landing-content
  "HTMX partial for landing page content."
  [router legal-entity-name token & {:keys [error]}]
  (h/html
    [:div#landing-content.card
     (if error
       [:div
        [:h1.error "Connection Failed"]
        [:p error]
        [:p "Please close this window and request a new link."]]
       [:div
        [:h1 "Connect DATEV REWE"]
        [:p "You've been invited to connect DATEV Rechnungswesen for:"]
        [:p [:strong legal-entity-name]]
        [:p "Click below to authenticate with your DATEV account."]
        [:a.btn {:href (str (erp.http.routes/path-for router ::authorize) "?token=" token)}
         "Continue to DATEV"]])]))


(defn ^:private success-content
  "HTMX partial for success message."
  [legal-entity-name]
  (h/html
    [:div#landing-content.card
     [:h1.success "Connected Successfully"]
     [:p "DATEV REWE has been connected for " [:strong legal-entity-name] "."]
     [:p "Data sync is in progress. You can close this window."]]))


(defn ^:private landing-page
  "Public landing page for DATEV REWE connection."
  [{:keys [db-pool path-params ::reitit/router] :as _request}
   respond
   _raise]
  (let [token  (:token path-params)
        result (datev-rewe/validate-link db-pool token)]
    (case (:status result)
      :valid
      (let [link            (:link result)
            legal-entity    (db.sql/execute-one!
                              db-pool
                              {:select [:name]
                               :from   [:legal-entity]
                               :where  [:= :id (:datev-rewe-link/legal-entity-id link)]})
            legal-entity-name (:legal-entity/name legal-entity)]
        (respond (-> (ring.resp/ok (base-layout
                                     "Connect DATEV REWE"
                                     (landing-content router legal-entity-name token)))
                     (ring.resp/content-type "text/html"))))

      :not-found
      (respond (-> (ring.resp/not-found
                     (base-layout "Invalid Link"
                                  [:div.card
                                   [:h1.error "Invalid Link"]
                                   [:p "This link is not valid. Please request a new one."]]))
                   (ring.resp/content-type "text/html")))

      :expired
      (respond (-> (ring.resp/ok
                     (base-layout "Link Expired"
                                  [:div.card
                                   [:h1.error "Link Expired"]
                                   [:p "This link has expired. Please request a new one."]]))
                   (ring.resp/content-type "text/html")))

      :already-used
      (respond (-> (ring.resp/ok
                     (base-layout "Link Already Used"
                                  [:div.card
                                   [:h1.error "Link Already Used"]
                                   [:p "This link has already been used."]]))
                   (ring.resp/content-type "text/html"))))))


(defn ^:private authorize
  "Initiates OAuth flow to DATEV REWE via Maesn."
  [{:keys [db-pool integrations query-params ::reitit/router] :as _request}
   respond
   _raise]
  (let [token  (get query-params "token")
        result (datev-rewe/validate-link db-pool token)]
    (if (= :valid (:status result))
      (let [callback-url (str (get-in integrations [:base-url])
                              (erp.http.routes/path-for router ::callback))
            state        (sign-state token)
            ;; Maesn auth endpoint returns a URL to redirect to
            login-url    (maesn/build-auth-url (:datev-rewe integrations) callback-url)]
        ;; Append state to the login URL
        (respond (ring.resp/found (str login-url "&state=" state))))
      (respond (ring.resp/found (erp.http.routes/path-for router ::landing {:token token}))))))


(defn ^:private callback
  "Handles OAuth callback from DATEV REWE via Maesn."
  [{:keys [aws db-pool integrations query-params ::reitit/router] :as _request}
   respond
   _raise]
  (let [account-key (get query-params "accountKey")
        state       (get query-params "state")
        token       (verify-state state)]
    (if (and account-key token)
      (let [result (datev-rewe/validate-link db-pool token)]
        (if (= :valid (:status result))
          (let [link            (:link result)
                legal-entity-id (:datev-rewe-link/legal-entity-id link)
                legal-entity    (db.sql/execute-one!
                                  db-pool
                                  {:select [:name]
                                   :from   [:legal-entity]
                                   :where  [:= :id legal-entity-id]})
                kms-key-arn     (get-in integrations [:datev-rewe :account-key-encryption-key-arn])
                encrypted-key   (maesn/encrypt-account-key aws kms-key-arn account-key)

                ;; Get user/company info from Maesn
                user-info       (try
                                  (maesn/get-user-info (:datev-rewe integrations) account-key)
                                  (catch Exception e
                                    (log/warn e "Failed to get DATEV REWE user info")
                                    {:company-name nil}))
                metadata        (-> (select-keys user-info [:company-name])
                                    (assoc :connected-at (str (Instant/now))
                                           :connected-via-link-id (str (:datev-rewe-link/id link))))]

            ;; Upsert integration record
            (let [integration (db.sql/execute-one!
                                db-pool
                                {:insert-into   :legal-entity-integration
                                 :values        [{:legal-entity-id        legal-entity-id
                                                  :integration-type       (db.sql/->cast :datev_rewe :integration-type)
                                                  :is-active              true
                                                  :credentials-encrypted  encrypted-key
                                                  :config                 [:lift {}]
                                                  :metadata               [:lift metadata]}]
                                 :on-conflict   [:legal-entity-id :integration-type]
                                 :do-update-set {:is-active              true
                                                 :credentials-encrypted  encrypted-key
                                                 :disconnect-reason      nil
                                                 :config                 [:lift {}]
                                                 :metadata               [:lift metadata]
                                                 :updated-at             [:now]}
                                 :returning     [:*]})]

              ;; Mark link as used
              (datev-rewe/mark-link-used! db-pool (:datev-rewe-link/id link))

              ;; TODO: This sync runs inline. In the future, queue via SQS to workers.
              ;; Trigger async sync in virtual thread
              (Thread/startVirtualThread
                (fn []
                  (try
                    (datev-rewe/sync-journal-entries!
                      db-pool
                      (:datev-rewe integrations)
                      aws
                      integration)
                    (catch Exception e
                      (log/error e "Background DATEV REWE sync failed"
                                 {:legal-entity-id legal-entity-id})))))

              ;; Return success page
              (respond (-> (ring.resp/ok
                             (base-layout
                               "Connected Successfully"
                               (success-content (:legal-entity/name legal-entity))))
                           (ring.resp/content-type "text/html")))))

          ;; Link validation failed
          (respond (ring.resp/found (erp.http.routes/path-for router ::landing {:token token})))))

      ;; Missing account key or invalid state
      (respond (-> (ring.resp/ok
                     (base-layout "Connection Failed"
                                  [:div.card
                                   [:h1.error "Connection Failed"]
                                   [:p "Authentication failed. Please try again."]]))
                   (ring.resp/content-type "text/html"))))))


(defn routes [_config]
  ["/connect/datev-rewe"
   ["/:token"
    {:name ::landing
     :get  {:summary "DATEV REWE connection landing page"
            :handler #'landing-page}}]
   ["/authorize"
    {:name ::authorize
     :get  {:summary "Initiate DATEV REWE OAuth"
            :handler #'authorize}}]
   ["/callback"
    {:name ::callback
     :get  {:summary "Handle DATEV REWE OAuth callback"
            :handler #'callback}}]])

Step 2: Commit

git add src/com/getorcha/erp/http/connect/datev_rewe.clj
git commit -m "Add public HTMX pages for DATEV REWE connection"

Task 9: Register routes in ERP HTTP handler

Files:

Step 1: Add require for new namespace

Find the requires section and add:

[com.getorcha.erp.http.connect.datev-rewe :as connect.datev-rewe]

Step 2: Add routes to router

Find where routes are combined (look for reitit.ring/router) and add:

(connect.datev-rewe/routes config)

Make sure these routes are NOT wrapped in auth middleware since they're public.

Step 3: Commit

git add src/com/getorcha/erp/http.clj
git commit -m "Register DATEV REWE public routes"

Task 10: Add DATEV REWE section to settings integrations page

Files:

Step 1: Add datev-rewe section renderer function

Add after the accounting-system-section function (around line 255):

(defn ^:private get-pending-link
  "Gets the most recent pending (unused, unexpired) link for a legal entity."
  [db legal-entity-id]
  (db.sql/execute-one!
   db
   {:select   [:*]
    :from     [:datev-rewe-link]
    :where    [:and
               [:= :legal-entity-id legal-entity-id]
               [:is :used-at nil]
               [:> :expires-at [:now]]]
    :order-by [[:created-at :desc]]
    :limit    1}))


(defn ^:private datev-rewe-section
  "Renders the DATEV REWE section for historical data import.

   States:
   - :not-connected - No active integration, no pending link
   - :link-pending  - Link generated but not yet used
   - :connected     - Has active integration"
  [router db-pool legal-entity-id integration & {:keys [show-toast toast-message]}]
  (let [{:legal-entity-integration/keys [is-active credentials-encrypted metadata]} integration
        pending-link    (get-pending-link db-pool legal-entity-id)
        connected?      (and is-active (some? credentials-encrypted))
        company-name    (:company-name metadata)
        connected-at    (:connected-at metadata)
        connection-status (cond
                            connected?              :connected
                            (some? pending-link)    :link-pending
                            :else                   :not-connected)]
    [:section#datev-rewe-section.master-data-section {:aria-labelledby "datev-rewe-heading"}
     (when show-toast
       [:div.success-toast {:role "status"}
        [:iconify-icon {:icon :lucide:check-circle}]
        (or toast-message "Updated")])
     [:h2#datev-rewe-heading "DATEV REWE (Historical Data)"]
     [:p.section-description "Import historical bookings from DATEV Rechnungswesen for GL account matching."]

     (case connection-status
       :not-connected
       [:div.connection-card.not-connected
        [:div.connection-icon [:iconify-icon {:icon :lucide:history}]]
        [:div.connection-info
         [:div.connection-title "Not connected"]
         [:div.connection-description "Generate a link to share with your tax advisor."]]
        [:button.btn.btn-primary
         {:hx-post   (erp.http.routes/path-for router ::datev-rewe-generate-link)
          :hx-target "#datev-rewe-section"
          :hx-swap   "outerHTML"}
         "Generate Link"]]

       :link-pending
       (let [link-url   (str "https://app.getorcha.com/connect/datev-rewe/"
                             (:datev-rewe-link/token pending-link))
             expires-at (:datev-rewe-link/expires-at pending-link)]
         [:div.connection-card.pending
          [:div.connection-icon [:iconify-icon {:icon :lucide:link}]]
          [:div.connection-info
           [:div.connection-title "Link pending"]
           [:div.connection-description "Share this link with your tax advisor:"]
           [:div.link-display
            [:input.link-input {:type "text" :value link-url :readonly true :id "datev-rewe-link"}]
            [:button.btn.btn-secondary
             {:onclick "navigator.clipboard.writeText(document.getElementById('datev-rewe-link').value)"}
             "Copy"]]
           [:div.connection-expiry
            (str "Expires " (format-timestamp expires-at))]]
          [:button.btn.btn-secondary
           {:hx-post   (erp.http.routes/path-for router ::datev-rewe-generate-link)
            :hx-target "#datev-rewe-section"
            :hx-swap   "outerHTML"}
           "Generate New Link"]])

       :connected
       [:div.connection-card.connected
        [:div.connection-icon [:iconify-icon {:icon :lucide:check-circle}]]
        [:div.connection-info
         [:div.connection-title "Connected"]
         (when company-name
           [:div.connection-company company-name])
         (when connected-at
           [:div.connection-description (str "Connected " connected-at)])]
        [:div.connection-actions
         [:button.btn.btn-secondary
          {:hx-post    (erp.http.routes/path-for router ::datev-rewe-disconnect)
           :hx-target  "#datev-rewe-section"
           :hx-swap    "outerHTML"
           :hx-confirm "Disconnect DATEV REWE? Historical data will remain."}
          "Disconnect"]
         [:button.btn.btn-secondary
          {:hx-post   (erp.http.routes/path-for router ::datev-rewe-generate-link)
           :hx-target "#datev-rewe-section"
           :hx-swap   "outerHTML"}
          "Generate New Link"]]])]))

Step 2: Add route handlers for generate-link and disconnect

Add after the datev-rewe-section function:

(defn ^:private datev-rewe-generate-link
  "Generates a new DATEV REWE shareable link."
  [{:keys [db-pool ::reitit/router] :as request}
   respond
   _raise]
  (let [legal-entity-id (:legal-entity/id
                          (first (erp.http.identity/legal-entities request)))
        user-id         (erp.http.identity/identity-id request)
        _link           (datev-rewe/create-link! db-pool legal-entity-id user-id)
        integration     (db.sql/execute-one!
                          db-pool
                          {:select [:*]
                           :from   [:legal-entity-integration]
                           :where  [:and
                                    [:= :legal-entity-id legal-entity-id]
                                    [:= :integration-type
                                     (db.sql/->cast :datev_rewe :integration-type)]]})]
    (respond (ring.resp/ok
               (datev-rewe-section router db-pool legal-entity-id integration
                                   :show-toast true
                                   :toast-message "Link generated")))))


(defn ^:private datev-rewe-disconnect
  "Disconnects DATEV REWE integration."
  [{:keys [db-pool ::reitit/router] :as request}
   respond
   _raise]
  (let [legal-entity-id (:legal-entity/id
                          (first (erp.http.identity/legal-entities request)))]
    (db.sql/execute-one!
      db-pool
      {:update :legal-entity-integration
       :set    {:credentials-encrypted  nil
                :config                 [:lift {}]
                :metadata               [:lift {}]
                :disconnect-reason      nil
                :updated-at             [:now]}
       :where  [:and
                [:= :legal-entity-id legal-entity-id]
                [:= :integration-type (db.sql/->cast :datev_rewe :integration-type)]]})
    (respond (ring.resp/ok
               (datev-rewe-section router db-pool legal-entity-id nil
                                   :show-toast true
                                   :toast-message "DATEV REWE disconnected")))))

Step 3: Add require for datev-rewe namespace

Add to requires:

[com.getorcha.erp.datev-rewe :as datev-rewe]

Step 4: Render datev-rewe-section in page handler

Find the page handler and add a call to render datev-rewe-section after the accounting system section. Query for the datev_rewe integration record.

Step 5: Add routes

Add to the routes vector:

["/datev-rewe/generate-link"
 {:name ::datev-rewe-generate-link
  :post {:summary "Generate DATEV REWE shareable link"
         :handler #'datev-rewe-generate-link}}]
["/datev-rewe/disconnect"
 {:name ::datev-rewe-disconnect
  :post {:summary "Disconnect DATEV REWE"
         :handler #'datev-rewe-disconnect}}]

Step 6: Commit

git add src/com/getorcha/erp/http/settings/integrations.clj
git commit -m "Add DATEV REWE section to settings integrations page"

Task 11: Write integration tests

Files:

Step 1: Create test file

(ns com.getorcha.erp.http.connect.datev-rewe-test
  (:require [clojure.test :refer [deftest is testing use-fixtures]]
            [com.getorcha.erp.datev-rewe :as datev-rewe]
            [com.getorcha.erp.http.connect.datev-rewe :as connect.datev-rewe]
            [com.getorcha.test.fixtures :as fixtures]
            [com.getorcha.test.notification-helpers :as helpers]))


(use-fixtures :once fixtures/with-running-system)
(use-fixtures :each fixtures/with-db-rollback)


(deftest landing-page-shows-legal-entity-name-test
  (testing "valid link shows legal entity name"
    (let [legal-entity-id (helpers/create-legal-entity! :name "Test Company GmbH")
          link            (datev-rewe/create-link! fixtures/*db* legal-entity-id nil)
          response        (fixtures/request
                            {:route  [:com.getorcha.erp.http.connect.datev-rewe/landing
                                      {:token (:datev-rewe-link/token link)}]
                             :method :get})]
      (is (= 200 (:status response)))
      (is (clojure.string/includes? (:body response) "Test Company GmbH")))))


(deftest landing-page-handles-invalid-token-test
  (testing "invalid token shows error"
    (let [response (fixtures/request
                     {:route  [:com.getorcha.erp.http.connect.datev-rewe/landing
                               {:token "invalid-token"}]
                      :method :get})]
      (is (= 404 (:status response)))
      (is (clojure.string/includes? (:body response) "Invalid Link")))))


(deftest link-lifecycle-test
  (testing "link can only be used once"
    (let [legal-entity-id (helpers/create-legal-entity!)
          link            (datev-rewe/create-link! fixtures/*db* legal-entity-id nil)
          token           (:datev-rewe-link/token link)]

      ;; First validation succeeds
      (is (= :valid (:status (datev-rewe/validate-link fixtures/*db* token))))

      ;; Mark as used
      (datev-rewe/mark-link-used! fixtures/*db* (:datev-rewe-link/id link))

      ;; Second validation fails
      (is (= :already-used (:status (datev-rewe/validate-link fixtures/*db* token)))))))

Step 2: Run tests

Run: clj -X:test:silent :nses '[com.getorcha.erp.http.connect.datev-rewe-test]' Expected: PASS

Step 3: Commit

git add test/com/getorcha/erp/http/connect/datev_rewe_test.clj
git commit -m "Add integration tests for DATEV REWE connection flow"

Task 12: Final verification and cleanup

Step 1: Run all related tests

clj -X:test:silent :nses '[com.getorcha.erp.datev-rewe-test com.getorcha.erp.http.connect.datev-rewe-test]'

Step 2: Verify REPL reload works

(require '[integrant.repl :refer [reset]])
(reset)

Step 3: Manual smoke test

  1. Start the system
  2. Navigate to settings integrations page
  3. Verify DATEV REWE section appears
  4. Click "Generate Link" and verify link is displayed
  5. Copy link and open in incognito window
  6. Verify landing page shows legal entity name

Step 4: Final commit if any cleanup needed

git add -p
git commit -m "DATEV REWE integration: final cleanup"

Summary

This plan implements the full DATEV REWE integration:

  1. Database: New datev_rewe_link table, booking_history_source enum
  2. Core logic: com.getorcha.erp.datev-rewe namespace with link management and sync
  3. Public pages: HTMX landing/callback pages at /connect/datev-rewe/{token}
  4. Settings UI: New section for link generation and connection status
  5. Maesn API: New get-journal-entries function

Key implementation notes: