Note (2026-04-24): After this document was written,
legal_entitywas renamed totenantand the oldtenantwas renamed toorganization. Read references to these terms with the pre-rename meaning.
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:
resources/migrations/YYYYMMDDHHMMSS-datev-rewe-link-table.up.sqlresources/migrations/YYYYMMDDHHMMSS-datev-rewe-link-table.down.sqlStep 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"
Files:
resources/com/getorcha/config.ednStep 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"
Files:
src/com/getorcha/integrations/maesn.cljStep 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:
src/com/getorcha/erp/datev_rewe.cljtest/com/getorcha/erp/datev_rewe_test.cljStep 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:
src/com/getorcha/erp/datev_rewe.cljtest/com/getorcha/erp/datev_rewe_test.cljStep 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"
Files:
src/com/getorcha/erp/datev_rewe.cljtest/com/getorcha/erp/datev_rewe_test.cljStep 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"
Files:
src/com/getorcha/erp/datev_rewe.cljStep 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"
Files:
src/com/getorcha/erp/http/connect/datev_rewe.cljStep 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"
Files:
src/com/getorcha/erp/http.cljStep 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"
Files:
src/com/getorcha/erp/http/settings/integrations.cljStep 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"
Files:
test/com/getorcha/erp/http/connect/datev_rewe_test.cljStep 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"
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
Step 4: Final commit if any cleanup needed
git add -p
git commit -m "DATEV REWE integration: final cleanup"
This plan implements the full DATEV REWE integration:
datev_rewe_link table, booking_history_source enumcom.getorcha.erp.datev-rewe namespace with link management and sync/connect/datev-rewe/{token}get-journal-entries functionKey implementation notes: