From b2642971935ea8c51e2d5ba56ec308ac4ad7211a Mon Sep 17 00:00:00 2001
From: ValueOn AG
Date: Thu, 21 May 2026 07:18:30 +0200
Subject: [PATCH] abacus
---
env-gateway-dev.20260515_122326.backup | 97 ------
env-gateway-int.20260515_122326.backup | 92 -----
env-gateway-prod.20260515_122326.backup | 92 -----
.../trustee/accounting/accountingBridge.py | 64 +++-
.../accounting/accountingConnectorBase.py | 19 +-
.../connectors/accountingConnectorAbacus.py | 319 +++++++++++++-----
6 files changed, 298 insertions(+), 385 deletions(-)
delete mode 100644 env-gateway-dev.20260515_122326.backup
delete mode 100644 env-gateway-int.20260515_122326.backup
delete mode 100644 env-gateway-prod.20260515_122326.backup
diff --git a/env-gateway-dev.20260515_122326.backup b/env-gateway-dev.20260515_122326.backup
deleted file mode 100644
index 0517f627..00000000
--- a/env-gateway-dev.20260515_122326.backup
+++ /dev/null
@@ -1,97 +0,0 @@
-# Development Environment Configuration
-
-# System Configuration
-APP_ENV_TYPE = dev
-APP_ENV_LABEL = Development Instance Patrick
-APP_API_URL = http://localhost:8000
-APP_KEY_SYSVAR = D:/Athi/Local/Web/poweron/local/notes/key.txt
-APP_INIT_PASS_ADMIN_SECRET = DEV_ENC:Z0FBQUFBQm8xSUpEeFFtRGtQeVUtcjlrU3dab1ZxUm9WSks0MlJVYUtERFlqUElHemZrOGNENk1tcmJNX3Vxc01UMDhlNU40VzZZRVBpUGNmT3podzZrOGhOeEJIUEt4eVlSWG5UYXA3d09DVXlLT21Kb1JYSUU9
-APP_INIT_PASS_EVENT_SECRET = DEV_ENC:Z0FBQUFBQm8xSUpERzZjNm56WGVBdjJTeG5Udjd6OGQwUVotYXUzQjJ1YVNyVXVBa3NZVml3ODU0MVNkZjhWWmJwNUFkc19BcHlHMTU1Q3BRcHU0cDBoZkFlR2l6UEZQU3d2U3MtMDh5UDZteGFoQ0EyMUE1ckE9
-
-# PostgreSQL DB Host
-DB_HOST=localhost
-DB_USER=poweron_dev
-DB_PASSWORD_SECRET = DEV_ENC:Z0FBQUFBQm8xSUpEcUIxNEFfQ2xnS0RrSC1KNnUxTlVvTGZoMHgzaEI4Z3NlVzVROTVLak5Ubi1vaEZubFZaMTFKMGd6MXAxekN2d2NvMy1hRjg2UVhybktlcFA5anZ1WjFlQmZhcXdwaGhWdzRDc3ExeUhzWTg9
-DB_PORT=5432
-
-# Security Configuration
-APP_JWT_KEY_SECRET = DEV_ENC:Z0FBQUFBQm8xSUpERjlrSktmZHVuQnJ1VVJDdndLaUcxZGJsT2ZlUFRlcFdOZ001RnlzM2FhLWhRV2tjWWFhaWQwQ3hkcUFvbThMcndxSjFpYTdfRV9OZGhTcksxbXFTZWg5MDZvOHpCVXBHcDJYaHlJM0tyNWRZckZsVHpQcmxTZHJoZUs1M3lfU2ljRnJaTmNSQ0w0X085OXI0QW80M2xfQnJqZmZ6VEh3TUltX0xzeE42SGtZPQ==
-APP_TOKEN_EXPIRY=300
-
-# CORS Configuration
-APP_ALLOWED_ORIGINS=http://localhost:8080,http://localhost:5176,https://nyla.poweron.swiss,https://nyla-int.poweron.swiss,https://nyla.poweron-center.net,https://nyla-int.poweron-center.net
-
-# Logging configuration
-APP_LOGGING_LOG_LEVEL = DEBUG
-APP_LOGGING_LOG_DIR = D:/Athi/Local/Web/poweron/local/logs
-APP_LOGGING_FORMAT = %(asctime)s - %(levelname)s - %(name)s - %(message)s
-APP_LOGGING_DATE_FORMAT = %Y-%m-%d %H:%M:%S
-APP_LOGGING_CONSOLE_ENABLED = True
-APP_LOGGING_FILE_ENABLED = True
-APP_LOGGING_ROTATION_SIZE = 10485760
-APP_LOGGING_BACKUP_COUNT = 5
-
-# OAuth: Auth app (login/JWT) vs Data app (Microsoft Graph / Google APIs). Same IDs until you split apps in Azure / GCP.
-Service_MSFT_AUTH_CLIENT_ID = 840b759a-4d79-4a7a-9598-f3ed204d99d8
-Service_MSFT_AUTH_CLIENT_SECRET = DEV_ENC:Z0FBQUFBQnFBa1kxaG9WY1FJaWdCbVFVaTllUlJfU3Y3MmJkRmkzMDVDWUNtZEhlNVhISzJPcy00ZUVZcklYLXFMV0dIODV3NXNSSFBKQ0ZsZllES3diTEgySDF0T1ZCbFZHREZtcXFGSWNZN1NJbzJzczRRQWxoeVNsNzlsa0VzMHJPWHUydjBBclo=
-Service_MSFT_AUTH_REDIRECT_URI = http://localhost:8000/api/msft/auth/login/callback
-Service_MSFT_DATA_CLIENT_ID = 840b759a-4d79-4a7a-9598-f3ed204d99d8
-Service_MSFT_DATA_CLIENT_SECRET = DEV_ENC:Z0FBQUFBQnFBa1kyUW96aXFVOVJlLUdyRlVvT1hVU09ILWtMZnV2M19mVUxGMnFPV3FzNTdQa3dTbHVGTDBHTk01ZThLcjh6QUR5VldVZUpfcDlZNTh5YldtLWtjTll6VzJNQ3JCQ3ZubHdmd2JvaExDOXdvQ1pjWDVQTUtFWVAtUHhwS1lFQnJXWk4=
-Service_MSFT_DATA_REDIRECT_URI = http://localhost:8000/api/msft/auth/connect/callback
-
-Service_GOOGLE_AUTH_CLIENT_ID = 813678306829-3f23dnf1cs4aaftubjfickt46tlmkgjm.apps.googleusercontent.com
-Service_GOOGLE_AUTH_CLIENT_SECRET = DEV_ENC:Z0FBQUFBQnFBa1kyd1hPd09vcVFtbVg0Sm5Nd1VYVEEtWjZMZkFndmFVS0ZlcTU0dzJnYVYzRkZWbjh0QldyZkhseDV2cUgxYkNHTzF6MXhqQlZ2N0UtbmhPeWRKUHBVdzV0Q1ROaWNuN2xjMmVzMjNZQ2ZYZ3dOTHgxaU5sTGRjVHpfakhYeWF0ZGU=
-Service_GOOGLE_AUTH_REDIRECT_URI = http://localhost:8000/api/google/auth/login/callback
-Service_GOOGLE_DATA_CLIENT_ID = 813678306829-3f23dnf1cs4aaftubjfickt46tlmkgjm.apps.googleusercontent.com
-Service_GOOGLE_DATA_CLIENT_SECRET = DEV_ENC:Z0FBQUFBQnFBa1kySXoyd1BmTnhOd1owTUJOWm53WlZMMjFHNGJhSUwyd2NDUW9BanlRWVJPLU5jYzRlcm5QeW96d0JYUkVWVWd2dGNBVEpJbElZY2lWb0o5S0gyNnhoV1pnNXhpSFEyaklZZjcwX2lVU0ktMEJGN01DMDhXQ3k4R1BXc1Q3ejFjOEg=
-Service_GOOGLE_DATA_REDIRECT_URI = http://localhost:8000/api/google/auth/connect/callback
-
-# ClickUp OAuth (Verbindungen / automation). Create an app in ClickUp: Settings → Apps → API; set redirect URL to Service_CLICKUP_OAUTH_REDIRECT_URI exactly.
-Service_CLICKUP_CLIENT_ID = O3FX3H602A30MQN4I4SBNGJLIDBD5SL4
-Service_CLICKUP_CLIENT_SECRET = DEV_ENC:Z0FBQUFBQnB5dkd4ZWVBeHVtRnpIT0VBN0tSZDhLRmFmN05DOVBOelJtLWhkVnJDRVBqUkh3bDFTZFRWaWQ1cWowdGNLUk5IQzlGN1J6RFVCaW8zRnBwLVBnclJfdWgxV3pVRzFEV2lwcW5Rc19Xa1ROWXNJcUF0ajZaYUxOUXk0WHRsRmJLM25FaHV5T2IxdV92ZW1nRjhzaGpwU0l2Wm9FTkRnY2lJVjhuNHUwT29salAxYV8wPQ==
-Service_CLICKUP_OAUTH_REDIRECT_URI = http://localhost:8000/api/clickup/auth/connect/callback
-
-# Infomaniak: no OAuth client. Users paste a Personal Access Token (kdrive + mail) per UI.
-
-# Stripe Billing (both end with _SECRET for encryption script)
-STRIPE_SECRET_KEY_SECRET = DEV_ENC:Z0FBQUFBQnB5dkd5aHNGejgzQmpTdmprdzQxR19KZkh3MlhYUTNseFN3WnlaWjh2SDZyalN6aU9xSktkbUQwUnZrVnlvbGVRQm4yZFdiRU5aSEk5WVJuUnR4VUwtTm9OVk1WWmJQeU5QaDdib0hfVWV5U1BfYTFXRmdoOWdnOWxkb3JFQmF3bm45UjFUVUxmWGtGRkFKUGd6bmhpQlFnaVI3Q2lLdDlsY1VESk1vOEM0ZFBJNW1qcVZ0N2tPYmRLNmVKajZ2M3o3S05lWnRRVG5LdkRseW4wQ3VjNHNQZTZUdz09
-STRIPE_WEBHOOK_SECRET = DEV_ENC:Z0FBQUFBQnB5dkd5dDJMSHBrVk8wTzJhU2xzTTZCZWdvWmU2NGI2WklfRXRJZVUzaVYyOU9GLUZsalUwa2lPdEgtUHo0dVVvRDU1cy1saHJyU0Rxa2xQZjBuakExQzk3bmxBcU9WbEIxUEtpR1JoUFMxZG9ISGRZUXFhdFpSMGxvQUV3a0VLQllfUUtCOHZwTGdteV9rYTFOazBfSlN3ekNWblFpakJlZVlCTmNkWWQ4Sm01a1RCWTlnTlFHWVA0MkZYMlprUExrWFN2V0NVU1BTd1NKczFJbVo3VHpLdlc4UT09
-STRIPE_API_VERSION = 2026-01-28.clover
-STRIPE_AUTOMATIC_TAX_ENABLED = false
-STRIPE_TAX_RATE_ID_CH_VAT = txr_1TOQd14OUoIL0Osj7A0ZQlr0
-
-# AI configuration
-Connector_AiOpenai_API_SECRET = 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
-Connector_AiAnthropic_API_SECRET = D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
-Connector_AiPerplexity_API_SECRET = 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
-Connector_AiTavily_API_SECRET = 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
-Connector_AiPrivateLlm_API_SECRET = DEV_ENC:Z0FBQUFBQnBudkpGRHM5eFdUVmVZU1R1cHBwN1RlMUx4T0NlLTJLUFFVX3J2OElDWFpuZmJHVmp4Z3BNNWMwZUVVZUd2TFhRSjVmVkVlcFlVRWtybXh0ZHloZ01ZcnVvX195YjdlWVdEcjZSWFFTTlNBWUlaTlNoLWhqVFBIb0thVlBiaWhjYjFQOFY=
-Connector_AiMistral_API_SECRET = ogaEVD2fFmiIWHDhKn8oGM0FShFxnAtT
-
-Service_MSFT_TENANT_ID = common
-
-# Google Cloud Speech Services configuration
-Connector_GoogleSpeech_API_KEY_SECRET = DEV_ENC:Z0FBQUFBQm8xSUpETk5FWWM3Q0JKMzhIYTlyMkhuNjA4NlF4dk82U2NScHhTVGY3UG83NkhfX3RrcWVtWWcyLXRjU1dTT21zWEl6YWRMMUFndXpsUnJOeHh3QThsNDZKRXROTzdXRUdsT0JZajZJNVlfb0gtMXkwWm9DOERPVnpjU0pyUEZfOGJsUnprT3ltMVVhalUyUm9hMUFtZEtHUnJqOGZ4dEZjZm5SWVVTckVCWnY1UkdVSHVmUlgwbnAyc0xDQW84R3ViSko5OHVCVWZRUVNiaG1pVFB6X3EwS0FPd2dUYjhiSmRjcXh2WEZiXzI4SFZqT21tbDduUWRyVWdFZXpmcVM5ZDR0VWtzZnF5UER6cGwwS2JlLV9CSTZ0Z0IyQ1h0YW9TcmhRTXZEckp4bWhmTkt6UTNYMk4zVkpnbUJmaDIxZnoyR2dWTEYwTUFEV0w2eUdUUGpoZk9XRkt4RVF1Z1NPdUpBeTcyWV9PY1Ffd2s0ZEdVekxGekhoeEl4TmNqaXYtbUJuSVdycFducERWdWtZajZnX011Q2w4eE9VMTBqQ1ZxRmdScWhXY1E3WWhzX1JZcHhxam9FbDVPN3Q1MWtrMUZuTUg3LVFQVHp1T1hpQWNDMzEzekVJWk9ybl91YUVjSkFob1VaMi1ONEtuMnRSOEg1S3QybUMwbVZDejItajBLTjM2Zy1hNzZQMW5LLVVDVGdFWm5BZUxNeEFnUkZzU3dxV0lCUlc0LWo4b05GczVpOGZSV2ZxbFBwUml6OU5tYjdnTks3Y3hrVEZVTHlmc1NPdFh4WE5pWldEZklOQUxBbjBpMTlkX3FFQVJ6c2NSZGdzTThycE92VW82enZKamhiRGFnU25aZGlHZHhZd2lUUmhuTVptNjhoWVlJQkxIOEkzbzJNMjZCZFJyM25tdXBnQ2ZWaHV3b2p6UWJpdk9xUEhBc1dyTlNmeF9wbm5yYUhHV01UZnVXWDFlNzBkdXlWUWhvcmJpSmljbmE3LUpUZEg4VzRwZ2JVSjdYUm1sODViQXVxUzdGTmZFbVpiN2V1YW5XV3U4b2VRWmxldGVGVHZsSldoekhVLU9wZ2V0cGZIYkNqM2pXVGctQVAyUm4xTHhpd1VVLXFhcnVEV21Rby1hbTlqTl84TjVveHdYTExUVkhHQ0ltaTB2WXJnY1NQVE5PbWg3ejgySElYc1JSTlQ3NDlFUWR6STZVUjVqaXFRN200NF9LY1ljQ0R2UldlWUtKY1NQVnJ4QXRyYTBGSWVuenhyM0Z0cWtndTd1eG8xRzY5a2dNZ1hkQm5MV3BHVzA2N1QwUkd6WlRGYTZQOUhnVWQ2S0Y5U0s1dXFNVXh5Q2pLWVUxSUQ2MlR1ak52NmRIZ2hlYTk1SGZGWS1RV3hWVU9rR3d1Rk9MLS11REZXbzhqMHpsSm1HYW1jMUNLT29YOHZsRWNaLTVvOFpmT3l3MHVwaERTT0dNLWFjcGRYZ25qT2szTkVFUnRFR3JWYS1aNXFIRnMyalozTlQzNFF2NXJLVHVPVF9zdTF6ZjlkbzJ4RFc2ZENmNFFxZDZzTzhfMUl0bW96V0lPZkh1dXFYZlEteFBlSG84Si1FNS1TTi1OMkFnX2pOYW8xY3MxMVJnVC02MDUyaXZfMEVHWDQtVlRpcENmV0h3V0dCWEFRS2prQXdNRlQ5dnRFVHU0Q1dNTmh0SlBCaU55bFMydWM1TTFFLW96ODBnV3dNZHFZTWZhRURYSHlrdzF3RlRuWDBoQUhSOUJWemtRM3pxcDJFbGJoaTJ3ZktRTlJxbXltaHBoZXVJVDlxS3cxNWo2c0ZBV0NzaUstRWdsMW1xLXFkanZGYUFiU0tSLXFQa0tkcDFoMV9kak41ZjQ0R214UmtOR1ZBanRuemY3Mmw1SkZ5aDZodGIzT3N2aV85MW9kcld6c0g0ZDgtTWo3b3Y3VjJCRnR2U2tMVm9rUXNVRnVHbzZXVTZ6RmI2RkNmajBfMWVnODVFbnpkT0oyci15czJHU0p1cUowTGZJMzVnd3hIRjQyTVhKOGRkcFRKdVpyQ3Yzd01Jb1lSajFmV0paeEV0cjk1SmpmdWpDVFJMUmMtUFctOGhaTmlKQXNRVlVUNlhJemxudHZCR056SVlBb3NOTEYxRTRLaFlVd2d3TWtxVlB6ZEtQLTkxOGMyY3N0a2pYRFUweDBNaGhja2xSSklPOUZla1dKTWRNbG8tUGdSNEV5cW90OWlOZFlIUExBd3U2b2hyS1owbXVMM3p0Qm41cUtzWUxYNzB1N3JpUTNBSGdsT0NuamNTb1lIbXR4MG1sakNPVkxBUXRLVE1xX0YxWDhOcERIY1lTQVFqS01CaXZKNllFaXlIR0JsM1pKMmV1OUo3TGI1WkRaVnYxUTl1LTM0SU1qN1V1b0RCT0x0VHNLTmNLZnk1S0MxYnBBcm03WnVua0xqaEhGUzhOU253ZkppRzdudXBSVlMxeFVOSWxtZ1o2RVBSQUhEUEFuQ1hxSVZMME4yWUtaU3VyRGo3RkUyRUNjT0pNcE1BdE1ZRzdXVl8ydUtXZjdMdHdEVW4teHUtTi1HSGliLUxud21TX0NtcGVkRFBHNkZ1WTlNczR4OUJfUVluc1BoV09oWS1scUdsNnB5d1U5M1huX3k4QzAyNldtb2hybktYN2xKZ1NTNWFsaWwzV3pCRVhkaGR5eTNlV1d6ZzFfaFZTT0E4UjRpQ3pKdEZxUlJ6UFZXM3laUndyWEk2NlBXLUpoajVhZzVwQXpWVzUtVjVNZFBwdWdQa3AxZC1KdGdqNnhibjN4dmFYb2cxcEVwc1g5R09zRUdINUZtOE5QRjVUU0dpZy1QVl9odnFtVDNuWFZLSURtMXlSMlhRNTBWSVFJbEdOOWpfVWV0SmdRWDdlUXZZWE8xRUxDN1I0aEN6MHYwNzM1cmpJS0ZpMnBYWkxfb3FsbEV1VnlqWGxqdVJ6SHlwSjAzRlMycTBaQ295NXNnZERpUnJQcjhrUUd3bkI4bDVzRmxQblhkaFJPTTdISnVUQmhET3BOMTM4bjVvUEc2VmZhb2lrR1FyTUl2RWNEeGg0U0dsNnV6eU5zOUxiNDY5SXBxR0hBS00wOTgyWTFnWkQyaEtLVUloT3ZxZGh0RWVGRmJzenFsaUtfZENQM0JzdkVVeTdXR3hUSmJST1NBMUI1NkVFWncwNW5JZVVLX1p1RXdqVnFfQWpvQ08yQjZhN1NkTkpTSnUxOVRXZXE0WFEtZWxhZW1NNXYtQ2sya0VGLURmS01lMkctNVY3c2ZhN0ZGRFgwWHlabTFkeS1hcUZ1dDZ3cnpPQ3hha2IzVE11M0pqbklmU0diczBqTFBNZC1QZGp6VzNTSnJVSjJoWkJUQjVORG4tYUJmMEJtSUNUdVpEaGt6OTM3TjFOdVhXUHItZjRtZ25nU3NhZC1sVTVXNTRDTmxZbnlfeHNsdkpuMXhUYnE1MnpVQ0ZOclRWM1M4eHdXTzRXbFRZZVQtTS1iRVdXVWZMSGotcWg3MUxUYTFnSEEtanBCRHlZRUNIdGdpUFhsYjdYUndCZnRITzhMZVJ1dHFoVlVNb0duVjlxd0U4OGRuQVV3MG90R0hiYW5MWkxWVklzbWFRNzBfSUNrdzc5bVdtTXg0dExEYnRCaDI3c1I4TWFwLXZKR0wxSjRZYjZIV3ZqZjNqTWhFT0RGSDVMc1A1UzY2bDBiMGFSUy1fNVRQRzRJWDVydUpqb1ZfSHNVbldVeUN2YlAxSW5WVDdxVzJ1WHpLeUdmb0xWMDNHN05oQzY3YnhvUUdhS2xaOHNidkVvbTZtSHFlblhOYmwyR3NQdVJDRUdxREhWdF9ZcXhwUWxHc2hyLW5vUGhIUVhJNUNhY0hFU0ptVnI0TFVhZDE1TFBBUEstSkRoZWJ5MHJhUmZrR1ZrRlFtRGpxS1pOMmFMQjBsdjluY3FiYUU4eGJVVXlZVEpuNWdHVVhJMGtwaTdZR2NDbXd2eHpOQ09SeTV6N1BaVUpsR1pQVDBZcElJUUt6VnVpQmxSYnE4Y1BCWV9IRWdVV0p3enBGVHItdnBGN3NyNWFBWmkySnByWThsbDliSlExQmp3LVlBaDIyZXp6UnR6cU9rTzJmTDBlSVpON0tiWllMdm1oME1zTFl2S2ZYYllhQlY2VHNZRGtHUDY4U1lIVExLZTU4VzZxSTZrZHl1ZTBDc0g4SjI4WGYyZHV1bm9wQ3R2Z09ld1ZmUkN5alJGeHZKSHl1bWhQVXpNMzdjblpLcUhfSm02Qlh5S1FVN3lIcHl0NnlRPT0=
-
-# Feature SyncDelta JIRA configuration
-Feature_SyncDelta_JIRA_DELTA_TOKEN_SECRET = DEV_ENC:Z0FBQUFBQm8xSUpEbm0yRUJ6VUJKbUwyRW5kMnRaNW4wM2YxMkJUTXVXZUdmdVRCaUZIVHU2TTV2RWZLRmUtZkcwZE4yRUNlNDQ0aUJWYjNfdVg5YjV5c2JwMHhoUUYxZWdkeS11bXR0eGxRLWRVaVU3cUVQZWJlNDRtY1lWUDdqeDVFSlpXS0VFX21WajlRS3lHQjc0bS11akkybWV3QUFlR2hNWUNYLUdiRjZuN2dQODdDSExXWG1Dd2ZGclI2aUhlSWhETVZuY3hYdnhkb2c2LU1JTFBvWFpTNmZtMkNVOTZTejJwbDI2eGE0OS1xUlIwQnlCSmFxRFNCeVJNVzlOMDhTR1VUamx4RDRyV3p6Tk9qVHBrWWdySUM3TVRaYjd3N0JHMFhpdzFhZTNDLTFkRVQ2RVE4U19COXRhRWtNc0NVOHRqUS1CRDFpZ19xQmtFLU9YSDU3TXBZQXpVcld3PT0=
-
-# Teamsbot Browser Bot Service
-# For local testing: run the bot locally with `npm run dev` in service-teams-browser-bot
-# The bot will connect back to localhost:8000 via WebSocket
-TEAMSBOT_BROWSER_BOT_URL = http://localhost:4100
-
-# Debug Configuration
-APP_DEBUG_CHAT_WORKFLOW_ENABLED = True
-APP_DEBUG_CHAT_WORKFLOW_DIR = D:/Athi/Local/Web/poweron/local/debug
-APP_DEBUG_ACCOUNTING_SYNC_ENABLED = True
-APP_DEBUG_ACCOUNTING_SYNC_DIR = D:/Athi/Local/Web/poweron/local/debug/sync
-
-# Azure Communication Services Email Configuration
-MESSAGING_ACS_CONNECTION_STRING = endpoint=https://mailing-poweron-prod.switzerland.communication.azure.com/;accesskey=4UizRfBKBgMhDgQ92IYINM6dJsO1HIeL6W1DvIX9S0GtaS1PjIXqJQQJ99CAACULyCpHwxUcAAAAAZCSuSCt
-MESSAGING_ACS_SENDER_EMAIL = DoNotReply@poweron.swiss
-
-# Zurich WFS Parcels (dynamic map layer). Default: Stadt Zürich OGD. Override for full canton if wfs.zh.ch resolves.
-# Connector_ZhWfsParcels_WFS_URL = https://wfs.zh.ch/av
-# Connector_ZhWfsParcels_TYPENAMES = av_li_liegenschaften_a
-
diff --git a/env-gateway-int.20260515_122326.backup b/env-gateway-int.20260515_122326.backup
deleted file mode 100644
index a3033e5a..00000000
--- a/env-gateway-int.20260515_122326.backup
+++ /dev/null
@@ -1,92 +0,0 @@
-# Integration Environment Configuration
-
-# System Configuration
-APP_ENV_TYPE = int
-APP_ENV_LABEL = Integration Instance
-APP_API_URL = https://gateway-int.poweron.swiss
-# Force SameSite=None+Secure for auth cookies (cross-site UI on poweron-center.net). Optional if APP_API_URL is https://
-APP_COOKIE_SECURE = true
-APP_KEY_SYSVAR = CONFIG_KEY
-APP_INIT_PASS_ADMIN_SECRET = INT_ENC:Z0FBQUFBQm8xSVRjWm41MWZ4TUZGaVlrX3pWZWNwakJsY3Facm0wLVZDd1VKeTFoZEVZQnItcEdUUnVJS1NXeDBpM2xKbGRsYmxOSmRhc29PZjJSU2txQjdLbUVrTTE1NEJjUXBHbV9NOVJWZUR3QlJkQnJvTEU9
-APP_INIT_PASS_EVENT_SECRET = INT_ENC:Z0FBQUFBQm8xSVRjdmtrakgxa0djekZVNGtTZV8wM2I5UUpCZllveVBMWXROYk5yS3BiV3JEelJSM09VYTRONHpnY3VtMGxDRk5JTEZSRFhtcDZ0RVRmZ1RicTFhb3c5dVZRQ1o4SmlkLVpPTW5MMTU2eTQ0Vkk9
-
-# PostgreSQL DB Host
-DB_HOST=gateway-int-server.postgres.database.azure.com
-DB_USER=heeshkdlby
-DB_PASSWORD_SECRET = INT_ENC:Z0FBQUFBQm8xSVRjczYzOUtTa21MMGJVTUQ5UmFfdWc3YlhCbWZOeXFaNEE1QzdJV3BLVjhnalBkLVVCMm5BZzdxdlFXQXc2RHYzLWtPSFZkZE1iWG9rQ1NkVWlpRnF5TURVbnl1cm9iYXlSMGYxd1BGYVc0VDA9
-DB_PORT=5432
-
-# Security Configuration
-APP_JWT_KEY_SECRET = INT_ENC:Z0FBQUFBQm8xSVRjNUctb2RwU25iR3ZnanBOdHZhWUtIajZ1RnZzTEp4aDR0MktWRjNoeVBrY1Npd1R0VE9YVHp3M2w1cXRzbUxNaU82QUJvaDNFeVQyN05KblRWblBvbWtoT0VXbkNBbDQ5OHhwSUFnaDZGRG10Vmgtdm1YUkRsYUhFMzRVZURmSFlDTFIzVWg4MXNueDZyMGc5aVpFdWRxY3dkTExGM093ZTVUZVl5LUhGWnlRPQ==
-APP_TOKEN_EXPIRY=300
-
-# CORS Configuration
-APP_ALLOWED_ORIGINS=http://localhost:8080,http://localhost:5176,https://nyla.poweron.swiss,https://nyla-int.poweron.swiss,https://nyla.poweron-center.net,https://nyla-int.poweron-center.net
-
-# Logging configuration
-APP_LOGGING_LOG_LEVEL = DEBUG
-APP_LOGGING_LOG_DIR = /home/site/wwwroot/
-APP_LOGGING_FORMAT = %(asctime)s - %(levelname)s - %(name)s - %(message)s
-APP_LOGGING_DATE_FORMAT = %Y-%m-%d %H:%M:%S
-APP_LOGGING_CONSOLE_ENABLED = True
-APP_LOGGING_FILE_ENABLED = True
-APP_LOGGING_ROTATION_SIZE = 10485760
-APP_LOGGING_BACKUP_COUNT = 5
-
-# OAuth: Auth app (login/JWT) vs Data app (Graph / Google APIs)
-Service_MSFT_AUTH_CLIENT_ID = 840b759a-4d79-4a7a-9598-f3ed204d99d8
-Service_MSFT_AUTH_CLIENT_SECRET = INT_ENC:Z0FBQUFBQnFBa1kydlVubld1d1h6SUNSWW1aZ3p4X3Zod1NDTjhZVnVYS2lqOERGTFp2OXJ4TGRiNlRLVFpzLUVDTUhkZGhGUWdxa1djdEV5UWkyblN1UHZoaFBjaExNTEpGMG1PRGJEbDdHVll0Ungwcl9JemZ4ZXFzZUNFQmFlZi1DZFlCekU1S3E=
-Service_MSFT_AUTH_REDIRECT_URI = https://gateway-int.poweron.swiss/api/msft/auth/login/callback
-Service_MSFT_DATA_CLIENT_ID = 840b759a-4d79-4a7a-9598-f3ed204d99d8
-Service_MSFT_DATA_CLIENT_SECRET = INT_ENC:Z0FBQUFBQnFBa1kyS1hWZXEzUzZTTE5MUlJncVowMU95Y0hmV1hveDBZOWdLU1RIUWt3SGlXNGxVTXVKc2QyQmtmWTlJRU43ZnRDdnlDTGxQY0hTU25CWWFFdDhUem9HU0VYcTFJTVFEbVk0dUhmVzJNVlEzNTNWdjdmaW9WeUVDVW5PRmNFZEQzNTY=
-Service_MSFT_DATA_REDIRECT_URI = https://gateway-int.poweron.swiss/api/msft/auth/connect/callback
-
-Service_GOOGLE_AUTH_CLIENT_ID = 813678306829-3f23dnf1cs4aaftubjfickt46tlmkgjm.apps.googleusercontent.com
-Service_GOOGLE_AUTH_CLIENT_SECRET = INT_ENC:Z0FBQUFBQnFBa1kyUTUwNXNGaHRNaGxxbF9sdWJ3Q0xLYU5yOHB4Yk8zMDZvQ29yaEhWOE5JMENXRk5jb2ZBdzRKQ2ZTTld6ZlIxemhOYzN1VE10TjBDRWZEMXlLVWRNYjZ0VG5RZ3I3NWt0SEJzMzdsUmRzcVNmbktRNHZqTUF6a2EyUkVUSFJnZFE=
-Service_GOOGLE_AUTH_REDIRECT_URI = https://gateway-int.poweron.swiss/api/google/auth/login/callback
-Service_GOOGLE_DATA_CLIENT_ID = 813678306829-3f23dnf1cs4aaftubjfickt46tlmkgjm.apps.googleusercontent.com
-Service_GOOGLE_DATA_CLIENT_SECRET = INT_ENC:Z0FBQUFBQnFBa1kyV1FRVjF0c0d3d0dyWU1TdW9HdXVkdHdsVWZKYTJjbGZPRDhMRjA2M0FkaUZIVmhIUmFKNjg2ekFodHd6NG80VTI3TC1icW1LZ01jWVZuQ1pKRm5nMW5UREJEaGp2Wl9oRDRCSmZVT0JpTnkwXzgwY0pkV29yczQ5akF2d1ZGcVY=
-Service_GOOGLE_DATA_REDIRECT_URI = https://gateway-int.poweron.swiss/api/google/auth/connect/callback
-
-# ClickUp OAuth (Verbindungen / automation). Create an app in ClickUp: Settings → Apps → API; set redirect URL to Service_CLICKUP_OAUTH_REDIRECT_URI exactly.
-Service_CLICKUP_CLIENT_ID = O3FX3H602A30MQN4I4SBNGJLIDBD5SL4
-Service_CLICKUP_CLIENT_SECRET = INT_ENC:Z0FBQUFBQnB5dkd5SE1uVURMNVE3NkM4cHBKa2R2TjBnLWdpSXI5dHpKWGExZVFiUF95TFNnZ1NwLWFLdmh6eWFZTHVHYTBzU2FGRUpLYkVyM1NvZjZkWDZHN21qUER5ZVNOaGpCc3NrUGd3VnFTclF3OW1nUlVuWXQ1UVhDLVpyb1BwRExOeFpDeVhtbEhDVnd4TVdpbzNBNk5QQWFPdjdza0xBWGxFY1E3WFpCSUlNa1l4RDlBPQ==
-Service_CLICKUP_OAUTH_REDIRECT_URI = https://gateway-int.poweron.swiss/api/clickup/auth/connect/callback
-
-# Infomaniak: no OAuth client. Users paste a Personal Access Token (kdrive + mail) per UI.
-
-# Stripe Billing (both end with _SECRET for encryption script)
-STRIPE_SECRET_KEY_SECRET = INT_ENC:Z0FBQUFBQnB5dkd5ekdBaGNGVUlOQUpncTlzLWlTV0V5OWZzQkpDczhCUGw4U1JpTHZ0d3pfYlFNWElLRlNiNlNsaDRYTGZUTkg2OUFrTW1GZXpOUjBVbmRQWjN6ekhHd2ZSQ195OHlaeWh1TmxrUm10V2R3YmdncmFLbFMzVjdqcWJMSUJPR2xuSEozclNoZG1rZVBTaWg3OFQ1Qzdxb0wyQ2RKazc2dG1aZXBUTXlvbDZqLS1KOVI5M3BGc3NQZkZRbnFpRjIwWmh2ZHlVNlpxZVo2dWNmMjQ5eW02QmtzUT09
-STRIPE_WEBHOOK_SECRET = INT_ENC:Z0FBQUFBQnA4UXZiUUVqTl9lREVRWTh1aHFDcFpwcXRkOUx4MS1ham9Ddkl6T0xzMnJuM1hhUHdGNG5CenY1MUg4RlJBOGFQTWl5cVd5MjJ2REItcHYyRmdLX3ZlT2p5Z3BRVkMtQnRoTVkteXlfaU92MVBtOEI0Ni1kbGlfa0NiRmFRRXNHLVE2NHI=
-STRIPE_API_VERSION = 2026-01-28.clover
-STRIPE_AUTOMATIC_TAX_ENABLED = false
-STRIPE_TAX_RATE_ID_CH_VAT = txr_1TOQd14OUoIL0Osj7A0ZQlr0
-
-# AI configuration
-Connector_AiOpenai_API_SECRET = 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
-Connector_AiAnthropic_API_SECRET = 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
-Connector_AiPerplexity_API_SECRET = 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
-Connector_AiTavily_API_SECRET = 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
-Connector_AiPrivateLlm_API_SECRET = INT_ENC:Z0FBQUFBQnBudkpGSjZ1NWh0aWc1R3Z4MHNaeS1HamtUbndhcUZFZDlqUDhjSmg5eHFfdlVkU0RsVkJ2UVRaMWs3aWhraG5jSlc0YkxNWHVmR2JoSW5ENFFCdkJBM0VienlKSnhzNnBKbTJOUTFKczRfWlQ3bWpmUkRTT1I1OGNUSTlQdExacGRpeXg=
-Connector_AiMistral_API_SECRET = ogaEVD2fFmiIWHDhKn8oGM0FShFxnAtT
-
-Service_MSFT_TENANT_ID = common
-
-# Google Cloud Speech Services configuration
-Connector_GoogleSpeech_API_KEY_SECRET = INT_ENC:Z0FBQUFBQm8xSVRkNmVXZ1pWcHcydTF2MXF0ZGJoWHBydF85bTczTktiaEJ3Wk1vMW1mZVhDSG1yd0ZxR2ZuSGJTX0N3MWptWXFJTkNTWjh1SUVVTXI4UDVzcGdLMkU5SHJ2TUpkRlRoRWdnSldtYjNTQkh4UDJHY2xmdTdZQ1ZiMTZZcGZxS3RzaHdjV3dtVkZUcEpJcWx0b2xuQVR6ZmpoVFZPY1hNMTV2SnhDaC1IZEh4UUpLTy1ILXA4RG1zamJTbUJ4X0t2M2NkdzJPbEJxSmFpRzV3WC0wZThoVzlxcmpHZ3ZkLVlVY3REZk1vV19WQ05BOWN6cnJ4MWNYYnNiQ0FQSUVnUlpfM3BhMnlsVlZUOG5wM3pzM1lSN1UzWlZKUXRLczlHbjI1LTFvSUJ4SlVXMy1BNk43bE5Hb0RfTTVlWk9oZnFIaVg0SW5pbm9EcXRTTzU1RFlYY3dTcnpKWWNyNjN5T1BGZ0FmX253cEFncmhvZVRuM05KYzhkOEhFMFJsc2NBSEwzZVZ1R0JMOGxsekVwUE55alZaRXFrdzNWWVNGWXNmbnhKeWhQSFo2VXBTUlRPeHdvdVdncEFuOWgydEtsSUFneUN6cGVaTnBSdjNCdVJseGJFdmlMc203UFhLVlYyTENkaGg2dVN6Z2xwT1ZmTmN5bVZGUkM3ZWcyVkt2ckFUVVd3WFFwYnJjNVRobEh2SkVJbXRwUUpEOFJKQ1NUc0Q4NHNqUFhPSDh5cTV6MEcwSDEwRUJCQ2JiTTJlOE5nd3pMMkJaQ1dVYjMwZVVWWnlETmp2dkZ3aXEtQ29WNkxZTFkzYUkxdTlQUU1OTnhWWU12YU9MVnJQa1d2ZjRtUlhneTNubEMxTmp1eUNPOThSMlB3Y1F0T2tCdFNsNFlKalZPV25yR2QycVBUb096RmZ1V0FTaGsxLV9FWDBmenBIOXpMdGpLcUc0TWRoY2hlMFhYTzlET1ZRekw0ZHNwUVBQdVJBX2h6Q2ZzWVZJWTNybTJiekp3WmhmWF9SUFBXQzlqUjctcVlHWWVMZWVQallzR0JGTVF0WmtnWlg1aTM1bFprNVExZXY5dnNvWF93UjhwbkJ3RzNXaVJ2d2RRU3JJVlBvaVh4eTlBRUtqWkJia3dJQVVBV2Nqdm9FUTRUVW1TaHp2ZUwxT0N2ZndxQ2Nka1RYWXF0LWxIWFE0dTFQcVhncFFPM0hFdUUtYlFnemx3WkF4bjA1aDFULUdrZlVZbEJtRGRCdjJyVkdJSXozd0I0dF9zbWhOeHFqRDA4T1NVaWR5cjBwSVgwbllPU294NjZGTnM1bFhIdGpNQUxFOENWd3FCbGpSRFRmRXotQnU0N2lCVEU5RGF6Qi10S2U2NGdadDlrRjZtVE5oZkw5ZWFjXzhCTmxXQzNFTFgxRXVYY3J3YkxnbnlBSm9PY3h4MlM1NVFQbVNDRW5Ld1dvNWMxSmdoTXJuaE1pT2VFeXYwWXBHZ29MZDVlN2lwUUNIeGNCVVdQVi1rRXdJMWFncUlPTXR0MmZVQ1l0d09mZTdzWGFBWUJMUFd3b0RSOU8zeER2UWpNdzAxS0ZJWnB5S3FJdU9wUDJnTTNwMWw3VFVqVXQ3ZGZnU1RkUktkc0NhUHJ0SGFxZ0lVWDEzYjNtU2JfMGNWM1Y0dHlCTzNESEdENC1jUWF5MVppRzR1QlBNSUJySjFfRi1ENHEwcmJ4S3hQUFpXVHA0TG9DZWdoUlo5WnNSM1lCZm1KbEs2ak1yUUU4Wk9JcVJGUkJwc0NvUkMyTjhoTWxtZmVQeDREZVRKZkhYN2duLVNTeGZzdFdBVnhEandJSXB5QjM0azF0ckI3Tk1wSzFhNGVOUVRrNjU0cG9JQ29pN09xOFkwR1lMTlktaGp4TktxdTVtTnNEcldsV2pEZm5nQWpJc2hxY0hjQnVSWUR5VVdaUXBHWUloTzFZUC1oNzJ4UjZ1dnpLcDJxWEZtQlNIMWkzZ0hXWXdKeC1iLXdZWVJhcU04VFlpMU5pd2ZIdTdCdkVWVFVBdmJuRk16bEFFQTh4alBrcTV2RzliT2hGdTVPOXlRMjFuZktiRTZIamQ1VFVqS0hRTXhxcU1mdkgyQ1NjQmZfcjl4c3NJd0RIeDVMZUFBbHJqdEJxWWl3aWdGUEQxR3ZnMkNGdVB4RUxkZi1xOVlFQXh1NjRfbkFEaEJ5TVZlUGFrWVhSTVRPeGxqNlJDTHNsRWRrei1pYjhnUmZrb3BvWkQ2QXBzYjFHNXZoWU1LSExhLWtlYlJTZlJmYUM5Y1Rhb1pkMVYyWTByM3NTS0VXMG1ybm1BTVN2QXRYaXZqX2dKSkZrajZSS2cyVlNOQnd5Y29zMlVyaWlNbTJEb3FuUFFtbWNTNVpZTktUenFZSl91cVFXZjRkQUZyYmtPczU2S1RKQ19ONGFOTHlwX2hOOEE1UHZEVjhnT0xxRjMxTEE4SHhRbmlmTkZwVXJBdlJDbU5oZS05SzI4QVhEWDZaN2ZiSlFwUGRXSnB5TE9MZV9ia3pYcmZVa1dicG5FMHRXUFZXMWJQVDAwOEdDQzJmZEl0ZDhUOEFpZXZWWXl5Q2xwSmFienNCMldlb2NKb2ZRYV9KbUdHRzNUcjU1VUFhMzk1a2J6dDVuNTl6NTdpM0hGa3k0UWVtbF9pdDVsQVp2cndDLUU5dnNYOF9CLS0ySXhBSFdCSnpqV010bllBb3U0cEZZYVF5R2tSNFM5NlRhdS1fb1NqbDBKMkw0V2N0VEZhNExtQlR3ckZ3cVlCeHVXdXJ6X0s4cEtsaG5rVUxCN2RRbHQxTmcyVFBqYUxyOHJzeFBXVUJaRHpXbUoxdHZzMFBzQk1UTUFvX1pGNFNMNDFvZWdTdEUtMUNKMXNIeVlvQk1CeEdpZVdmN0tsSDVZZHJXSGt5c2o2MHdwSTZIMVBhRzM1eU43Q2FtcVNidExxczNJeUx5U2RuUG5EeHpCTlg2SV9WNk1ET3BRNXFuc0pNWlVvZUYtY21oRGtJSmwxQ09QbHBUV3BuS3B5NE9RVkhfellqZjJUQ0diSV94QlhQWmdaaC1TRWxsMUVWSXB0aE1McFZDZDNwQUVKZ2t5cXRTXzlRZVJwN0pZSnJSV21XMlh0TzFRVEl0c2I4QjBxOGRCYkNxek04a011X1lrb2poQ3h2LUhKTGJiUlhneHp5QWFBcE5nMElkNTVzM3JGOWtUQ19wNVBTaVVHUHFDNFJnNXJaWDNBSkMwbi1WbTdtSnFySkhNQl9ZQjZrR2xDcXhTRExhMmNHcGlyWjR3ZU9SSjRZd1l4ZjVPeHNiYk53SW5SYnZPTzNkd1lnZmFseV9tQ3BxM3lNYVBHT0J0elJnMTByZ3VHemxta0tVQzZZRllmQ2VLZ1ZCNDhUUTc3LWNCZXBMekFwWW1fQkQ1NktzNGFMYUdYTU0xbXprY1FONUNlUHNMY3h2NFJMMmhNa3VNdzF4TVFWQk9odnJUMjFJMVd3Z2N6Sms5aEM2SWlWZFViZ0JWTEpUWWM5NmIzOS1oQmRqdkt1NUUycFlVcUxERUZGbnZqTUxIYnJmMDBHZDEzbnJsWEEzSUo3UmNPUDg1dnRUU1FzcWtjTWZwUG9zM0JTY3RqMDdST2UxcXFTM0d0bGkwdFhnMk5LaUlxNWx3V1pLaVlLUFJXZzBzVl9Ia1V1OHdYUEFWOU50UndycGtCdzM0Q0NQamp2VTNqbFBLaGhsbUk5dUI5MjU5OHVySk1oY0drUWtXUloyVVRvOWJmbUVYRzFVeWNQczh2NXJCeVppRlZiWDNJaDhOSmRmX2lURTNVS3NXQXFZT1QtUmdvMWJoVWYxU3lqUUJhbzEyX3I3TXhwbm9wc1FoQ1ZUTlNBRjMyQTBTY2tzbHZ3RFUtTjVxQ0o1QXRTVks2WENwMGZCRGstNU1jN3FhUFJCQThyaFhhMVRsbnlSRXNGRmt3Yk01X21ldmV3bTItWm1JaGpZQWZROEFtT1d1UUtPQlhYVVFqT2NxLUxQenJHX3JfMEdscDRiMXcyZ1ZmU3NFMzVoelZJaDlvT0ZoRGQ2bmtlM0M5ZHlCd2ZMbnRZRkZUWHVBUEx4czNfTmtMckh5eXZrZFBzOEItOGRYOEhsMzBhZ0xlOWFjZzgteVBsdnpPT1pYdUxnbFNXYnhKaVB6QUxVdUJCOFpvU2x2c1FHZV94MDBOVWJhYkxISkswc0U5UmdPWFJLXzZNYklHTjN1QzRKaldKdEVHb0pOU284N3c2LXZGMGVleEZ5NGZ6OGV1dm1tM0J0aTQ3VFlNOEJrdEh3PT0=
-
-# Feature SyncDelta JIRA configuration
-Feature_SyncDelta_JIRA_DELTA_TOKEN_SECRET = INT_ENC:Z0FBQUFBQm8xSVRkTUNsWm4wX0p6eXFDZmJ4dFdHNEs1MV9MUzdrb3RzeC1jVWVYZ0REWHRyZkFiaGZLcUQtTXFBZzZkNzRmQ0gxbEhGbUNlVVFfR1JEQTc0aldkZkgyWnBOcjdlUlZxR0tDTEdKRExULXAyUEtsVmNTMkRKU1BJNnFiM0hlMXo4YndMcHlRMExtZDQ3Zm9vNFhMcEZCcHpBPT0=
-
-# Teamsbot Browser Bot Service
-TEAMSBOT_BROWSER_BOT_URL = https://cae-poweron-shared.redwater-53d21339.switzerlandnorth.azurecontainerapps.io
-
-# Debug Configuration
-APP_DEBUG_CHAT_WORKFLOW_ENABLED = FALSE
-APP_DEBUG_CHAT_WORKFLOW_DIR = ./test-chat
-APP_DEBUG_ACCOUNTING_SYNC_ENABLED = FALSE
-APP_DEBUG_ACCOUNTING_SYNC_DIR = ./debug/sync
-
-# Azure Communication Services Email Configuration
-MESSAGING_ACS_CONNECTION_STRING = endpoint=https://mailing-poweron-prod.switzerland.communication.azure.com/;accesskey=4UizRfBKBgMhDgQ92IYINM6dJsO1HIeL6W1DvIX9S0GtaS1PjIXqJQQJ99CAACULyCpHwxUcAAAAAZCSuSCt
-MESSAGING_ACS_SENDER_EMAIL = DoNotReply@poweron.swiss
diff --git a/env-gateway-prod.20260515_122326.backup b/env-gateway-prod.20260515_122326.backup
deleted file mode 100644
index 8ed1c612..00000000
--- a/env-gateway-prod.20260515_122326.backup
+++ /dev/null
@@ -1,92 +0,0 @@
-# Production Environment Configuration
-
-# System Configuration
-APP_ENV_TYPE = prod
-APP_ENV_LABEL = Production Instance
-APP_KEY_SYSVAR = CONFIG_KEY
-APP_INIT_PASS_ADMIN_SECRET = PROD_ENC:Z0FBQUFBQnBDM1Z3UnJRV0sySFlDblpXUlREclREaW1WbUt6bGtQYkdrNkZDOXNOLXFua1hqeFF2RHJnRXJ5VlVGV3hOZm41QjZOMlNTb0duYXNxZi05dXVTc2xDVkx0SVBFLUhncVo5T0VUZHE0UTZLWWw3ck09
-APP_INIT_PASS_EVENT_SECRET = PROD_ENC:Z0FBQUFBQnBDM1Z3QVpIY19DQVZSSzJmc2F0VEZvQlU1cHBhTEgxdHdnR3g4eW01aTEzYTUxc1gxTDR1RVVpSHRXYjV6N1BLZUdCUGlfOW1qdy0xSHFVRkNBcGZvaGlSSkZycXRuUllaWnpyVGRoeFg1dGEyNUk9
-APP_API_URL = https://gateway-prod.poweron.swiss
-APP_COOKIE_SECURE = true
-
-# PostgreSQL DB Host
-DB_HOST=gateway-prod-server.postgres.database.azure.com
-DB_USER=gzxxmcrdhn
-DB_PASSWORD_SECRET = PROD_ENC:Z0FBQUFBQnBDM1Z3Y1JScGxjZG9TdUkwaHRzSHZhRHpNcDV3N1U2TnIwZ21PRG5TWFFfR1k0N3BiRk5WelVadjlnXzVSTDZ6NXFQNFpqbnJ1R3dNVkJocm1zVEgtSk0xaDRiR19zNDBEbVIzSk51ekNlQ0Z3b0U9
-DB_PORT=5432
-
-# Security Configuration
-APP_JWT_KEY_SECRET = PROD_ENC:Z0FBQUFBQnBDM1Z3elhfV0Rnd2pQRjlMdkVwX1FnSmRhSzNZUlV5SVpaWXBNX1hpa2xPZGdMSWpnN2ZINHQxeGZnNHJweU5pZjlyYlY5Qm9zOUZEbl9wUEgtZHZXd1NhR19JSG9kbFU4MnFGQnllbFhRQVphRGQyNHlFVWR5VHQyUUpqN0stUmRuY2QyTi1oalczRHpLTEJqWURjZWs4YjZvT2U5YnFqcXEwdEpxV05fX05QMmtrPQ==
-APP_TOKEN_EXPIRY=300
-
-# CORS Configuration
-APP_ALLOWED_ORIGINS=http://localhost:8080,http://localhost:5176,https://nyla.poweron.swiss,https://nyla-int.poweron.swiss,https://nyla.poweron-center.net,https://nyla-int.poweron-center.net
-
-# Logging configuration
-APP_LOGGING_LOG_LEVEL = DEBUG
-APP_LOGGING_LOG_DIR = /home/site/wwwroot/
-APP_LOGGING_FORMAT = %(asctime)s - %(levelname)s - %(name)s - %(message)s
-APP_LOGGING_DATE_FORMAT = %Y-%m-%d %H:%M:%S
-APP_LOGGING_CONSOLE_ENABLED = True
-APP_LOGGING_FILE_ENABLED = True
-APP_LOGGING_ROTATION_SIZE = 10485760
-APP_LOGGING_BACKUP_COUNT = 5
-
-# OAuth: Auth app (login/JWT) vs Data app (Graph / Google APIs)
-Service_MSFT_AUTH_CLIENT_ID = 840b759a-4d79-4a7a-9598-f3ed204d99d8
-Service_MSFT_AUTH_CLIENT_SECRET = PROD_ENC:Z0FBQUFBQnFBa1kySFR2NjBKM084QTNpeUlyUmM4R0N0SU1BZ2x4MmVTZTVHQkVzRE9GdmFkV041MzhudFhobjU0RWNnd3lqeXpKUXA5aGtNZkhtYU12QjBtX0NjemVmdEZBdC1TbXVBSXJTcF9vMlJXd0ZNRTRKRFBMUXNjTF85eTBxakR4RVNfYmU=
-Service_MSFT_AUTH_REDIRECT_URI = https://gateway-prod.poweron.swiss/api/msft/auth/login/callback
-Service_MSFT_DATA_CLIENT_ID = 840b759a-4d79-4a7a-9598-f3ed204d99d8
-Service_MSFT_DATA_CLIENT_SECRET = PROD_ENC:Z0FBQUFBQnFBa1kyNVU4cVRIZFdjS3l2S1RJVTVlc1ozQ1liZXZDX1VwdFZQUzFtS0N6UWYyeGxkNGNmY1hoaWxEUDBXVU5QR2t3Vi1ZV1A2QkxqbnpobzJwOXdzYTBZaFZYdnNkeDE1VVl0bm4weHFiLXdON2gtZzAwMTkxNWRoZldFM2djSkNHVS0=
-Service_MSFT_DATA_REDIRECT_URI = https://gateway-prod.poweron.swiss/api/msft/auth/connect/callback
-
-Service_GOOGLE_AUTH_CLIENT_ID = 813678306829-3f23dnf1cs4aaftubjfickt46tlmkgjm.apps.googleusercontent.com
-Service_GOOGLE_AUTH_CLIENT_SECRET = PROD_ENC:Z0FBQUFBQnFBa1kyUmJleVpTOF9OaFV3NGVfcWVBX2oxSjUwMWRGOFZRWFRIN1FZRzZ6U3VQMlg5a21RY1drTHh3U254LW4zM1A1cXQ1TTFWYlNoek9hSHJIeE4tbm1wU1lKRXlKNU5HVWI4VGZwTVE0VnJGaV8wZmNvdkVrMjJGeXdmZ3UyNmVXN1E=
-Service_GOOGLE_AUTH_REDIRECT_URI = https://gateway-prod.poweron.swiss/api/google/auth/login/callback
-Service_GOOGLE_DATA_CLIENT_ID = 813678306829-3f23dnf1cs4aaftubjfickt46tlmkgjm.apps.googleusercontent.com
-Service_GOOGLE_DATA_CLIENT_SECRET = PROD_ENC:Z0FBQUFBQnFBa1kyY2pxMDh0U0RqWERianBMTTNtSUZPSzhKUzh4S0RTenR2MmxnRDlvQzJjbDVTczRWLUJtVnhxWTE2MmUxQjJia2xJcVUzVlFlUnpma040NFdHRzVNRUt0OXR0c2JkTkRmQ1RIYllXbXFFaExIQWNycFVHbUxHbmtYOVhOVUV2MFY=
-Service_GOOGLE_DATA_REDIRECT_URI = https://gateway-prod.poweron.swiss/api/google/auth/connect/callback
-
-# ClickUp OAuth (Verbindungen / automation). Create an app in ClickUp: Settings → Apps → API; set redirect URL to Service_CLICKUP_OAUTH_REDIRECT_URI exactly.
-Service_CLICKUP_CLIENT_ID = O3FX3H602A30MQN4I4SBNGJLIDBD5SL4
-Service_CLICKUP_CLIENT_SECRET = PROD_ENC:Z0FBQUFBQnB5dkd6VGw5WDdhdDRsVENSalhSSUV0OFFxbEx0V1l6aktNV0E5Y18xU3JHLUlqMWVJdmxyajAydVZRaDJkZzJOVXhxRV9ROFRZbWxlRjh4c3NtQnRFMmRtZWpzTWVsdngtWldlNXRKTURHQjJCOEt6alMwQlkwOFYyVVJWNURJUGJIZDIxYVlfNnBrMU54M0Q3TVdVbFZqRkJKTUtqa05wUkV4eGZvbXNsVi1nNVdBPQ==
-Service_CLICKUP_OAUTH_REDIRECT_URI = https://gateway-prod.poweron.swiss/api/clickup/auth/connect/callback
-
-# Infomaniak: no OAuth client. Users paste a Personal Access Token (kdrive + mail) per UI.
-
-# Stripe Billing (both end with _SECRET for encryption script)
-STRIPE_SECRET_KEY_SECRET = PROD_ENC:Z0FBQUFBQnB5dkd6aVA3R3VRS3VHMUgzUEVjYkR4eUZKWFhPUzFTTVlHNnBvT3FienNQaUlBWVpPLXJyVGpGMWk4LXktMXphX0J6ZTVESkJxdjNNa3ZJbF9wX2ppYzdjYlF0cmdVamlEWWJDSmJYYkJseHctTlh4dnNoQWs4SG5haVl2TTNDdXpuaFpqeDBtNkFCbUxMa0RaWG14dmxyOEdILTNrZ2licmNpbXVkN2lFSWoxZW1BODNpV0ZTQ0VaeXRmR1d4RjExMlVFS3MtQU9zZXZlZE1mTmY3OWctUXJHdz09
-STRIPE_WEBHOOK_SECRET = PROD_ENC:Z0FBQUFBQnBudkpGNUpTWldsakYydFhFelBrR1lSaWxYT3kyMENOMUljZTJUZHBWcEhhdWVCMzYxZXQ5b3VlTFVRalFiTVdsbGxrdUx0RDFwSEpsOC1sTDJRTEJNQlA3S3ZaQzBtV1h6bWp5VnlMZUgwUlF3cXYxcnljZVE5SWdzLVg3V0syOWRYS08=
-STRIPE_API_VERSION = 2026-01-28.clover
-STRIPE_AUTOMATIC_TAX_ENABLED = false
-STRIPE_TAX_RATE_ID_CH_VAT = txr_1TOQZG8WqlVsabrfFEu49pah
-
-
-# AI configuration
-Connector_AiOpenai_API_SECRET = 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
-Connector_AiAnthropic_API_SECRET = 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
-Connector_AiPerplexity_API_SECRET = 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
-Connector_AiTavily_API_SECRET = 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
-Connector_AiPrivateLlm_API_SECRET = PROD_ENC:Z0FBQUFBQnBudkpGanZ6U3pzZWkwXzVPWGtIQ040XzFrTXc5QWRnazdEeEktaUJ0akJmNnEzbWUzNHczLTJfc2dIdzBDY0FTaXZYcDhxNFdNbTNtbEJTb2VRZ0ZYd05hdlNLR1h6SUFzVml2Z1FLY1BjTl90UWozUGxtak1URnhhZmNDRWFTb0dKVUo=
-Connector_AiMistral_API_SECRET = H55rGkR3ojIhcp4YMMlgUStgvz7Wym5c
-
-Service_MSFT_TENANT_ID = common
-
-# Google Cloud Speech Services configuration
-Connector_GoogleSpeech_API_KEY_SECRET = PROD_ENC:Z0FBQUFBQnBDM1Z4NFQxaF9uN3h1cVB6dnZid1c1R1VfNDlSQ1NHMEVDZWtKanpMQ29CLXc1MXBqRm1hQ0YtWVhaejBMY1ZTOEFEVlpWQ3hrYkFza1E2RDNsYkdMMndNR0VGNTMwVDRGdURJY3hyaVFxVjEtSEYwNHJzeWM3WmlpZW9jU2E3NTgycEV2allqQ3dJRTNyRFAzaDJ6dklKeXpNRkJhYjFzUkptN2dpbkNpMklrcGxuZl9vTkt3T0JvNm1YTXd5UlkwZWptUXdWVFpnV2J4X3J2WUhIUlFkSElFVnlqMnlJRnNHTnlpMWs2R1dZc2ROWjNYZG85cndmd1E5cUZnVmZRYnVjTG43dXFmSWd2bGFfVWFWSmtpWkpndWNlSUNwcnFNU2NqZXFaV0xsY3l3SElLRkVHcHZGZERKV1ltcGhTS0dhTko1VTJLYzNoZjRkSGVEX3dTMWVVTmdDczV5cE1JQUdSbUJGUm11eFhTVjJHbkt0SzB4UG1Dc2xmbnp1Y041Y2RTeWRuWGdmQy1sTGx0MGtnM2VJQ3EyLXViRlNhTU9ybzZkR1N1bXE5SXhlZENWRFpWSGlYOWx4SUQ3UlR0ZEVxQkxNakRUVFRiUmFnbklOalphLUZkRFVVaXBRUk5NZW5PaUZydTFmQkNPSTdTVTNZd0plWXllNVFJdmN4MVcyTGlwMGFtVjBzOGRxR1FjbzhfYW5zdTB0ZEZBTTJhakltazh1dktNMUZsOUItdFdTb1pIaUxySllXNkdlY20zUS0wTnpFNTB2SU5acG1VcXhyaHBmME8takw3RDh5T043T2VGOV92TzNya2pWSlpYVjZDdXlZcjM3a0hPTlhkaW9oQmxqQlpGRFYyTTY4WmZmT3k4Tk1tdXRuSGdTUVpNT2NKenhXb05PdXBfSEdhMTNxNjdpNXlKUUI2YUgydFFPX1VvXzVJb0UxWTU2YVNiNDQ0QndZanhMMHR1cGdHWGhvcEg1QXEtSXZJdTdZUE12ZEVVWkF4QmtsQS1GYnY3SFIxSHlsOGVfcEpGS1A4QUVEQWNEOFZYYlljQ3ByTU03YU16Y0UzUnJQZEprSWNjT1ZXVEtDWi03Y3ZzRVdYUTlabXJISEo5THRHVXVuM0xqbzA4bGVlZVpOMk1QMmptb21tV0pTMlVoOXdWVU95UW1iQmttc2w1RG9mMWwxXzg1T2IxYUVmTUJEZkpUdTFDTzZ3RlBFeUFiX01iRTZNWkNaSG45TkFOM2pzbUJRZ2N0VFpoejJUTG1RODY3TzZpSzVkYUQzaEpfY2pSTkRzU0VpanlkdXVQQmJ2WU5peno4QWNLTDVxZTlhSHI3NnNiM0k0Y3JkQ0xaOU05bGtsQl8zQklvaktWSDZ4aVp2MHlYelJuUDJyTU9CZC1OZjJxNFc1dDcwSUlxaVh1LTMyWWFwU0IwUU9kOUFpMWpnOERtLTh1VmJiNGVwcXBMbU5fMjVZc0hFbmxQT2puSFd1ZGpyTkphLU5sVlBZWWxrWEZrWGJQWmVkN19tZFZfZ1l1V3pSWlA0V0ZxM2lrWnl2NU9WeTdCbDROSmhfeENKTFhMVXk1d195S2JMUFJoRXZjcVo4V2g0MTNKRnZhUE1wRkNPM3FZOGdVazJPeW5PSGpuZnFGTTdJMkRnam5rUlV6NFlqODlIelRYaEN5VjdJNnVwbllNODNCTFRHMWlXbmM1VlRxbXB3Wm9LRjVrQUpjYzRNMThUMWwwSVhBMUlyamtPZnE4R0o4bEdHay1zMjR5RDJkZ1lYRHZaNHVHU2otR3ZpN25LZlEySEU0UmdTNzJGVHNWQXMyb0dVMV9WUE13ODhZWUFaakxGOWZieGNXZkNYRnV5djEyWTZLcmdrajRBLU1rS1Z0VVRkOWlDMU9fMGVmYXFhZXJGMUhpNkdmb2hkbzZ1OWV6VlNmVzNISjVYTFh6SjJNdWR5MWZidE8yVEo2dnRrZXhMRXBPczUwTG13OGhNUVpIQm0zQmRKRnJ0Nl8wNW1Ob0dHRDVpU0NWREV3TkY2SjktdVBkMFU1ZXBmSFpHQ3FHNTRZdTJvaExpZVEtLTU4YTVyeFBpNDdEajZtWUc4c1dBeUJqQ3NIY1NLS0FIMUxGZzZxNFNkOG9ORGNHWWJCVnZuNnJVTEtoQi1mRTZyUl81ZWJJMi1KOGdERzBhNVRZeHRYUUlqY2JvMFlaNHhWMU9pWFFiZjdaLUhkaG15TTBPZVlkS2R5UVdENTI4QVFiY1RJV0ZNZnlpVWxfZmlnN1BXbGdrbjFGUkhzYl9qeHBxVVJacUE4bjZETENHVFpSamh0NVpOM2hMYTZjYzBuS3J0a3hhZGxSM1V5UHd2OTU3ZHY0Yy1xWDBkWUk0Ymp0MWVrS3YzSktKODhQZnY3QTZ1Wm1VZkZJbS1jamdreks1ZlhpQjFOUDFiOHJ2Nm9NcmdTdU5LQXV2RkZWZEFNZnVKUjVwcVY3dDdhQnpmRVJ6SmlvVXpDM0ZiYXh5bGE2X04tTE9qZ3BiTnN3TF9ZaFRxSUpjNjB1dXZBcy1TZHRHTjFjSUR3WUl4cE9VNzB5Rkk4U3Z1SVZYTl9sYXlZVk83UnFrMlVmcnBpam9lRUlCY19DdVJwOXl2TVVDV1pMRFZTZk9MY3Z1eXA0MnhGazc5YllQaWtOeTc4NjlOa2lGY05RRzY1cG9nbGpYelc4c3FicWxWRkg0YzRSamFlQ19zOU14YWJreU9pNDREZVJ3a0REMUxGTzF1XzI1bEF3VXVZRjlBeWFiLXJsOXgza3VZem1WckhWSnVNbDBNcldadU8xQ3RwOTl5NGgtVlR0QklCLWl5WkE4V1FlQTBCOVU1RE9sQlRrYUNZOGdfUmEwbEZvUTFGUEFWVmQ4V1FhOU9VNjZqemRpZm1sUDhZQTJ0YVBRbWZldkF5THV4QXpfdUtNZ0tlcGdSRFM3c0lDOTNQbnBxdmxYYWNpTmI3MW9BMlZIdTQ5RldudHpNQWQ5NDNPLVVTLXVVNzdHZXh4UXpZa3dVa2J4dTFDV1RkYjRnWXU2M3lJekRYWGNMcWU5OVh6U2xZWDh6MmpqcnpiOHlnMjA5S3RFQm1NZjNSM21adkVnTUpSYVhkTzNkNnJCTmljY0x1cl9kMkx3UHhySjZEdHREanZERzNEUTFlTkR0NWlBczAtdmFGTjdZNVpTMlkxV2czYW5RN2lqemg4eUViZDV6RjdKNXdFcUlvcVhoNkJ6eVJkR1pua1hnNzQwOEs2TXJYSlpGcW9qRDU2QjBOWFFtdXBJRkRKbmdZUF9ZSmRPVEtvUjVhLTV1NjdXQjRhS0duaEtJb2FrQnNjUTRvdFMxdkdTNk1NYlFHUFhhYTJ1eUN3WHN4UlJ4UjdrZjY0SzFGYWVFN1k0cGJnc1RjNmFUenR4NHljbVhablZSWHZmUVN3cXRHNjhsX1BSZWEzdTJUZFA0S2pTaU9YMnZIQ1ZPcGhWMFJqZkVEMWRMR1h3SnU0Z2FzZ3VGM3puNzdhVjhaQXNIWHFsbjB0TDVYSFdSNV9rdWhUUUhSZHBGYkJIVDB5SDdlMC13QTVnS0g5Qkg5RGNxSGJlelVndUhPcEQ0QkRKMTJTZUM1OXJhVm0zYjU0OVY2dk9MQVBheklIQXpVNW9Yc0ROVjEzaFZTWmVxYlBWMlNlSzladzJ6TmNuMG5FVVZkN1VZN1pfS2ZHa0lQcE80S24wSnQtVlJVV09OVWJ3M09YMkZpV2ktVF9ENHhKU2dfYUQ2aUVyamk0VHJHQmVfVHU4clpUTFoteW5aSWRPV1M0RDRMTms4NGRoYmJfVE82aUl2X3VieVJOdDhBQmRwdzdnRTVBNzZwaW93dUlZb3ZRYUtOeG9ULWxvNVp5a0haSjdkcUhRb3d6UGIxRUpCVkVYX2d6TkRqQVozUWxkNGFoc1FXYVd2YWNkME9Qclo0bjYxMFRWTy1nbnI5NTBJNzRMMDluUXRKYTFqQUN4d0d5aHVlamN3Tkk3NWJXeXR0TW9BeUg5Vnp4Q2RnZUY3b3AtMDlrNmlrSGR0eGRtbUdUd2lFRWg4MklEeWJHN2wwZEpVSXMxNDNOWjRFS0tPdWxhMmFCckhfRENIY184aEFDZXNrRDl2dHQtQW12UnRuQXJjaDJoTUpiYkNWQUtfRG9GMUZoNWM4UnBYZ29RWWs2NHcyUm5kdTF3Vk1GeFpiRUJLaVZ2UGFjbi1jV3lMV0N2ZDl4VERPN295X01NNG56ZjZkRzZoYUtmY1E5NlVXemx2SnVfb19iSXg0R2M3Mjd1a2JRPT0=
-
-# Feature SyncDelta JIRA configuration
-Feature_SyncDelta_JIRA_DELTA_TOKEN_SECRET = PROD_ENC:Z0FBQUFBQnBDM1Z4d3Z4d2x6N1FhUktMU0RKbkxfY2pTQkRzXzJ6UXVEbDNCaFM3UHMtQVFGYzNmYWs4N0lMM1R2SFJuZTVFVmx6MGVEbXc5U3NOTnY1TWN0ZDNaamlHQWloalM3VldmREJNSHQ1TlVkSVFJMTVhQWVGSVRMTGw4UTBqNGlQZFVuaHp4WUlKemR5UnBXZlh0REJFLXJ4ejR3PT0=
-
-# Teamsbot Browser Bot Service
-TEAMSBOT_BROWSER_BOT_URL = https://cae-poweron-shared.redwater-53d21339.switzerlandnorth.azurecontainerapps.io
-
-# Debug Configuration
-APP_DEBUG_CHAT_WORKFLOW_ENABLED = FALSE
-APP_DEBUG_CHAT_WORKFLOW_DIR = ./test-chat
-APP_DEBUG_ACCOUNTING_SYNC_ENABLED = FALSE
-APP_DEBUG_ACCOUNTING_SYNC_DIR = ./debug/sync
-
-# Azure Communication Services Email Configuration
-MESSAGING_ACS_CONNECTION_STRING = endpoint=https://mailing-poweron-prod.switzerland.communication.azure.com/;accesskey=4UizRfBKBgMhDgQ92IYINM6dJsO1HIeL6W1DvIX9S0GtaS1PjIXqJQQJ99CAACULyCpHwxUcAAAAAZCSuSCt
-MESSAGING_ACS_SENDER_EMAIL = DoNotReply@poweron.swiss
diff --git a/modules/features/trustee/accounting/accountingBridge.py b/modules/features/trustee/accounting/accountingBridge.py
index fec36d2d..4e0a4d59 100644
--- a/modules/features/trustee/accounting/accountingBridge.py
+++ b/modules/features/trustee/accounting/accountingBridge.py
@@ -151,15 +151,20 @@ class AccountingBridge:
logger.info("Accounting sync skipped (no accounts): positionId=%s", positionId)
return SyncResult(success=True, errorMessage="Position hat keine Kontierung (Soll-/Haben-Konto) – Sync übersprungen")
- # 1) First: ensure all documents are in RMA (upload or duplicate); collect Beleg-IDs for linking
+ # Collect document references
documentIds = []
for key in ("documentId", "bankDocumentId"):
docId = position.get(key)
if docId:
documentIds.append(docId)
- if documentIds:
+
+ pendingDocs = [] # [(documentId, fileName, fileContent, mimeType)] for post-booking attach
+ postBookingAttach = connector.requiresPostBookingDocAttach
+
+ # 1) Pre-booking document upload (RMA-style: upload first, link via belegId)
+ if documentIds and not postBookingAttach:
from modules.features.trustee.datamodelFeatureTrustee import TrusteeDocument as TrusteeDocumentModel
- logger.info("Accounting sync: positionId=%s, syncing %s document(s) to RMA ...", positionId, len(documentIds))
+ logger.info("Accounting sync: positionId=%s, uploading %s document(s) pre-booking ...", positionId, len(documentIds))
belegIds = []
belegLabels = []
for documentId in documentIds:
@@ -185,24 +190,40 @@ class AccountingBridge:
comment=booking.reference,
)
if not uploadResult.success:
- errMsg = f"Dokument konnte nicht nach RMA hochgeladen werden: {uploadResult.errorMessage}"
logger.error(
"Accounting sync failed (document upload): positionId=%s, documentId=%s, error=%s",
positionId, documentId, uploadResult.errorMessage,
)
- return SyncResult(success=False, errorMessage=errMsg)
+ return SyncResult(success=False, errorMessage=f"Dokument-Upload fehlgeschlagen: {uploadResult.errorMessage}")
belegId = uploadResult.externalId
if belegId:
self._trusteeInterface.db.recordModify(TrusteeDocumentModel, documentId, {"externalBelegId": belegId})
logger.info("Accounting sync: document uploaded & belegId=%s stored on document %s", belegId, documentId)
- else:
- logger.info("Accounting sync: document uploaded but no belegId in response (409 duplicate?), fileName=%s", fileName)
belegIds.append(belegId)
belegLabels.append(fileName)
if belegIds or belegLabels:
booking.externalDocumentIds = belegIds
booking.externalDocumentLabels = belegLabels
- logger.info("Accounting sync: positionId=%s, document sync done, pushing GL booking (POST /gl) ...", positionId)
+ logger.info("Accounting sync: positionId=%s, document upload done, pushing booking ...", positionId)
+
+ # 1b) Post-booking flow: collect raw doc data now, attach after pushBooking
+ if documentIds and postBookingAttach:
+ from modules.features.trustee.datamodelFeatureTrustee import TrusteeDocument as TrusteeDocumentModel
+ for documentId in documentIds:
+ doc = self._trusteeInterface.getDocument(documentId)
+ if not doc:
+ continue
+ existingBelegId = getattr(doc, "externalBelegId", None)
+ if existingBelegId:
+ continue
+ docData = self._trusteeInterface.getDocumentData(documentId)
+ if docData is None:
+ continue
+ fileName = getattr(doc, "documentName", None) or "beleg.pdf"
+ mimeType = getattr(doc, "documentMimeType", None) or "application/pdf"
+ pendingDocs.append((documentId, fileName, docData, mimeType))
+ if pendingDocs:
+ logger.info("Accounting sync: positionId=%s, %s document(s) queued for post-booking attach", positionId, len(pendingDocs))
# Duplicate check: if locally marked as synced, verify with Buha system
accountingSyncId = position.get("accountingSyncId")
@@ -218,7 +239,6 @@ class AccountingBridge:
positionId, booking.reference,
)
return SyncResult(success=False, errorMessage="Position already synced to this system")
- # Not found in Buha (e.g. deleted there): clear local records and re-push
logger.info(
"Accounting sync: reference %s not found in Buha (deleted?), clearing local records and re-pushing positionId=%s",
booking.reference, positionId,
@@ -230,9 +250,9 @@ class AccountingBridge:
if rid:
self._trusteeInterface.db.recordDelete(TrusteeAccountingSync, rid)
- # 2) Then: push booking (with reference to document IDs so RMA can link)
+ # 2) Push booking
if not documentIds:
- logger.info("Accounting sync: positionId=%s, no documents, pushing GL booking (POST /gl) ...", positionId)
+ logger.info("Accounting sync: positionId=%s, no documents, pushing booking ...", positionId)
result = await connector.pushBooking(plainConfig, booking)
if not result.success:
logger.error(
@@ -241,6 +261,28 @@ class AccountingBridge:
result.errorMessage or "unknown",
)
+ # 3) Post-booking document attach (Abacus-style: entry must exist before attaching docs)
+ if result.success and pendingDocs and result.externalId:
+ from modules.features.trustee.datamodelFeatureTrustee import TrusteeDocument as TrusteeDocumentModel
+ logger.info("Accounting sync: positionId=%s, attaching %s document(s) to entry %s ...", positionId, len(pendingDocs), result.externalId)
+ for documentId, fileName, docData, mimeType in pendingDocs:
+ attachResult = await connector.attachDocumentToEntry(
+ plainConfig,
+ entryId=result.externalId,
+ fileName=fileName,
+ fileContent=docData,
+ mimeType=mimeType,
+ )
+ if not attachResult.success:
+ logger.warning(
+ "Accounting sync: document attach failed (non-blocking): positionId=%s, documentId=%s, error=%s",
+ positionId, documentId, attachResult.errorMessage,
+ )
+ continue
+ if attachResult.externalId:
+ self._trusteeInterface.db.recordModify(TrusteeDocumentModel, documentId, {"externalBelegId": attachResult.externalId})
+ logger.info("Accounting sync: document attached, externalId=%s stored on document %s", attachResult.externalId, documentId)
+
# Save sync record
import uuid
syncRecord = {
diff --git a/modules/features/trustee/accounting/accountingConnectorBase.py b/modules/features/trustee/accounting/accountingConnectorBase.py
index 5d76c997..6a59509f 100644
--- a/modules/features/trustee/accounting/accountingConnectorBase.py
+++ b/modules/features/trustee/accounting/accountingConnectorBase.py
@@ -171,6 +171,12 @@ class BaseAccountingConnector(ABC):
"""
return []
+ @property
+ def requiresPostBookingDocAttach(self) -> bool:
+ """If True, documents must be attached AFTER pushBooking (e.g. Abacus GeneralLedgerEntryDocuments).
+ If False (default), documents are uploaded BEFORE the booking (e.g. RMA belege)."""
+ return False
+
async def uploadDocument(
self,
config: Dict[str, Any],
@@ -179,5 +185,16 @@ class BaseAccountingConnector(ABC):
mimeType: str = "application/pdf",
comment: Optional[str] = None,
) -> SyncResult:
- """Upload a document/receipt (e.g. beleg). comment can link to booking reference. Override in connectors that support it."""
+ """Upload a document/receipt before booking (pre-booking flow). Override in connectors that support it."""
return SyncResult(success=False, errorMessage="Document upload not supported by this connector")
+
+ async def attachDocumentToEntry(
+ self,
+ config: Dict[str, Any],
+ entryId: str,
+ fileName: str,
+ fileContent: bytes,
+ mimeType: str = "application/pdf",
+ ) -> SyncResult:
+ """Attach a document to an existing booking/entry (post-booking flow). Override in connectors that need it."""
+ return SyncResult(success=False, errorMessage="Post-booking document attach not supported by this connector")
diff --git a/modules/features/trustee/accounting/connectors/accountingConnectorAbacus.py b/modules/features/trustee/accounting/connectors/accountingConnectorAbacus.py
index e03e7df7..a1947b27 100644
--- a/modules/features/trustee/accounting/connectors/accountingConnectorAbacus.py
+++ b/modules/features/trustee/accounting/connectors/accountingConnectorAbacus.py
@@ -11,7 +11,7 @@ Account balances:
Abacus exposes an ``AccountBalances`` entity (per fiscal year), but its
availability depends on the customer's Abacus license / Profile and is
NOT guaranteed for all instances. The robust default is therefore to
- aggregate balances locally from ``GeneralJournalEntries`` (always
+ aggregate balances locally from ``GeneralLedgerEntries`` (always
present). If a future iteration confirms the entity for a specific
instance, ``getAccountBalances`` can be extended to prefer that source
via a config flag (e.g. ``useAccountBalancesEntity: true``).
@@ -58,6 +58,10 @@ class AccountingConnectorAbacus(BaseAccountingConnector):
def __init__(self):
self._tokenCache: Dict[str, Dict[str, Any]] = {}
+ @property
+ def requiresPostBookingDocAttach(self) -> bool:
+ return True
+
def getConnectorType(self) -> str:
return "abacus"
@@ -92,6 +96,14 @@ class AccountingConnectorAbacus(BaseAccountingConnector):
fieldType="password",
secret=True,
),
+ ConnectorConfigField(
+ key="defaultCostCentre",
+ label=t("Standard-Kostenstelle"),
+ fieldType="text",
+ secret=False,
+ required=False,
+ placeholder="e.g. 100",
+ ),
]
def _buildBaseUrl(self, config: Dict[str, Any]) -> str:
@@ -165,7 +177,9 @@ class AccountingConnectorAbacus(BaseAccountingConnector):
clientName = config.get("clientName")
if not clientName:
raise ValueError("Missing required config: clientName")
- return f"{baseUrl}/{clientName}/{entity}"
+ if "/api/entity/v1" not in baseUrl:
+ baseUrl = f"{baseUrl}/api/entity/v1"
+ return f"{baseUrl}/mandants/{clientName}/{entity}"
async def _buildAuthHeaders(self, config: Dict[str, Any]) -> Optional[Dict[str, str]]:
token = await self._getAccessToken(config)
@@ -218,53 +232,135 @@ class AccountingConnectorAbacus(BaseAccountingConnector):
data = await resp.json()
for item in data.get("value", []):
+ label = ""
+ for d in (item.get("Designations") or []):
+ if d.get("Language") == "de":
+ label = d.get("Text", "")
+ break
+ if not label:
+ desigs = item.get("Designations") or []
+ label = desigs[0].get("Text", "") if desigs else ""
charts.append(AccountingChart(
- accountNumber=str(item.get("AccountNumber", item.get("Id", ""))),
- label=item.get("Name", item.get("Description", "")),
- accountType=item.get("AccountType", None),
+ accountNumber=str(item.get("Id", "")),
+ label=label,
+ accountType=item.get("Segment", None),
))
url = data.get("@odata.nextLink")
except Exception as e:
logger.error(f"Abacus getChartOfAccounts error: {e}")
return charts
+ async def _fetchJournals(self, config: Dict[str, Any], headers: Dict[str, str]) -> List[Dict[str, Any]]:
+ """Fetch all journals from Abacus."""
+ try:
+ async with aiohttp.ClientSession() as session:
+ url = self._buildEntityUrl(config, "Journals")
+ async with session.get(url, headers=headers, timeout=aiohttp.ClientTimeout(total=15)) as resp:
+ if resp.status != 200:
+ return []
+ data = await resp.json()
+ return data.get("value", [])
+ except Exception:
+ return []
+
+ async def _resolveJournalId(self, config: Dict[str, Any], headers: Dict[str, str], bookingDate: str) -> Optional[str]:
+ """Find the open journal that covers the booking date."""
+ for j in await self._fetchJournals(config, headers):
+ start = j.get("StartDate", "")
+ end = j.get("EndDate", "")
+ if start <= bookingDate <= end:
+ return j.get("Id")
+ return None
+
+ async def _buildJournalFilter(self, config: Dict[str, Any], headers: Dict[str, str], dateFrom: Optional[str] = None, dateTo: Optional[str] = None) -> Optional[str]:
+ """Build an OData $filter on JournalId for journals overlapping the date range.
+ Abacus only allows filtering by JournalId, not by Date.
+ """
+ journals = await self._fetchJournals(config, headers)
+ if not journals:
+ return None
+ matchingIds = []
+ for j in journals:
+ jStart = j.get("StartDate", "")
+ jEnd = j.get("EndDate", "")
+ if dateTo and jStart > dateTo:
+ continue
+ if dateFrom and jEnd < dateFrom:
+ continue
+ matchingIds.append(j.get("Id"))
+ if not matchingIds:
+ return None
+ if len(matchingIds) == 1:
+ return f"JournalId eq '{matchingIds[0]}'"
+ parts = " or ".join(f"JournalId eq '{jid}'" for jid in matchingIds)
+ return f"({parts})"
+
async def pushBooking(self, config: Dict[str, Any], booking: AccountingBooking) -> SyncResult:
headers = await self._buildAuthHeaders(config)
if not headers:
return SyncResult(success=False, errorMessage="Failed to obtain access token")
+ debitLine = None
+ creditLine = None
+ for line in booking.lines:
+ if line.debitAmount > 0:
+ debitLine = line
+ if line.creditAmount > 0:
+ creditLine = line
+ if not debitLine or not creditLine:
+ return SyncResult(success=False, errorMessage="Booking must have at least one debit and one credit line")
+
+ amount = debitLine.debitAmount
+
+ journalId = await self._resolveJournalId(config, headers, booking.bookingDate)
+ if not journalId:
+ return SyncResult(success=False, errorMessage=f"No open journal found for date {booking.bookingDate}")
+
try:
- lines = []
- for line in booking.lines:
- entry: Dict[str, Any] = {
- "AccountId": line.accountNumber,
- "Text": line.description or booking.description,
- }
- if line.debitAmount > 0:
- entry["DebitAmount"] = line.debitAmount
- if line.creditAmount > 0:
- entry["CreditAmount"] = line.creditAmount
- if line.taxCode:
- entry["TaxCode"] = line.taxCode
- if line.costCenter:
- entry["CostCenterId"] = line.costCenter
- lines.append(entry)
+ debitAccountId = int(debitLine.accountNumber)
+ creditAccountId = int(creditLine.accountNumber)
+ except ValueError:
+ return SyncResult(success=False, errorMessage=f"Account numbers must be numeric: debit={debitLine.accountNumber}, credit={creditLine.accountNumber}")
- payload = {
- "JournalDate": booking.bookingDate,
- "Reference": booking.reference,
- "Text": booking.description,
- "Lines": lines,
- }
+ debitSide: Dict[str, Any] = {"AccountId": debitAccountId, "EnterpriseId": 0, "CrossDivisionId": 0}
+ creditSide: Dict[str, Any] = {"AccountId": creditAccountId, "EnterpriseId": 0, "CrossDivisionId": 0}
+ defaultCC = config.get("defaultCostCentre")
+ for line, side in [(debitLine, debitSide), (creditLine, creditSide)]:
+ cc = line.costCenter or defaultCC
+ if cc:
+ try:
+ side["CostCentre1Id"] = int(cc)
+ except ValueError:
+ side["CostCentre1Id"] = cc
+ payload: Dict[str, Any] = {
+ "Date": booking.bookingDate,
+ "JournalId": journalId,
+ "DivisionId": 0,
+ "Direction": "Debit",
+ "Debit": debitSide,
+ "Credit": creditSide,
+ "Amount": {"KeyAmount": amount},
+ "Texts": {"Text1": (booking.description or "")[:80]},
+ }
+ ref = (booking.reference or "")[:10]
+ if ref:
+ payload["Document"] = {"Number": ref}
+ if debitLine.taxCode:
+ payload["Tax"] = {"CodeId": debitLine.taxCode[:3]}
+
+ try:
async with aiohttp.ClientSession() as session:
- url = self._buildEntityUrl(config, "GeneralJournalEntries")
+ url = self._buildEntityUrl(config, "GeneralLedgerEntries")
async with session.post(url, headers=headers, json=payload, timeout=aiohttp.ClientTimeout(total=30)) as resp:
body = await resp.json() if resp.content_type and "json" in resp.content_type else {"raw": await resp.text()}
if resp.status in (200, 201):
externalId = str(body.get("Id", "")) if isinstance(body, dict) else None
return SyncResult(success=True, externalId=externalId, rawResponse=body)
- return SyncResult(success=False, errorMessage=f"HTTP {resp.status}", rawResponse=body)
+ errDetail = ""
+ if isinstance(body, dict) and "error" in body:
+ errDetail = body["error"].get("message", "")
+ return SyncResult(success=False, errorMessage=f"HTTP {resp.status}: {errDetail or str(body)[:200]}", rawResponse=body)
except Exception as e:
return SyncResult(success=False, errorMessage=str(e))
@@ -274,7 +370,7 @@ class AccountingConnectorAbacus(BaseAccountingConnector):
return SyncResult(success=False, errorMessage="Failed to obtain access token")
try:
async with aiohttp.ClientSession() as session:
- url = self._buildEntityUrl(config, f"GeneralJournalEntries({externalId})")
+ url = self._buildEntityUrl(config, f"GeneralLedgerEntries({externalId})")
async with session.get(url, headers=headers, timeout=aiohttp.ClientTimeout(total=15)) as resp:
if resp.status == 200:
return SyncResult(success=True, externalId=externalId)
@@ -283,22 +379,20 @@ class AccountingConnectorAbacus(BaseAccountingConnector):
return SyncResult(success=False, errorMessage=str(e))
async def getJournalEntries(self, config: Dict[str, Any], dateFrom: Optional[str] = None, dateTo: Optional[str] = None, accountNumbers: Optional[List[str]] = None) -> List[Dict[str, Any]]:
- """Read GeneralJournalEntries from Abacus (OData V4, paginated)."""
+ """Read GeneralLedgerEntries from Abacus (OData V4, paginated).
+ Each Abacus entry is a single-line (one debit + one credit account).
+ We map it to our multi-line format with two lines per entry.
+ Abacus only allows filtering by JournalId, so date filtering is done client-side.
+ """
headers = await self._buildAuthHeaders(config)
if not headers:
return []
- filterParts = []
- if dateFrom:
- filterParts.append(f"JournalDate ge {dateFrom}")
- if dateTo:
- filterParts.append(f"JournalDate le {dateTo}")
- queryParams = ""
- if filterParts:
- queryParams = "?$filter=" + " and ".join(filterParts)
+ journalFilter = await self._buildJournalFilter(config, headers, dateFrom, dateTo)
+ queryParams = f"?$filter={journalFilter}" if journalFilter else ""
entries: List[Dict[str, Any]] = []
- url: Optional[str] = self._buildEntityUrl(config, f"GeneralJournalEntries{queryParams}")
+ url: Optional[str] = self._buildEntityUrl(config, f"GeneralLedgerEntries{queryParams}")
try:
async with aiohttp.ClientSession() as session:
while url:
@@ -308,28 +402,28 @@ class AccountingConnectorAbacus(BaseAccountingConnector):
data = await resp.json()
for item in data.get("value", []):
- lines = []
- totalAmt = 0.0
- for line in (item.get("Lines") or []):
- debit = float(line.get("DebitAmount", 0))
- credit = float(line.get("CreditAmount", 0))
- lines.append({
- "accountNumber": str(line.get("AccountId", "")),
- "debitAmount": debit,
- "creditAmount": credit,
- "description": line.get("Text", ""),
- "taxCode": line.get("TaxCode"),
- "costCenter": line.get("CostCenterId"),
- })
- totalAmt += max(debit, credit)
+ entryDate = str(item.get("Date", "")).split("T")[0]
+ if dateFrom and entryDate < dateFrom:
+ continue
+ if dateTo and entryDate > dateTo:
+ continue
+ amt = float((item.get("Amount") or {}).get("KeyAmount", 0))
+ debitAcc = str((item.get("Debit") or {}).get("AccountId", ""))
+ creditAcc = str((item.get("Credit") or {}).get("AccountId", ""))
+ texts = item.get("Texts") or {}
+ desc = texts.get("Text1", "")
+ docInfo = item.get("Document") or {}
entries.append({
"externalId": str(item.get("Id", "")),
- "bookingDate": str(item.get("JournalDate", "")).split("T")[0],
- "reference": item.get("Reference", ""),
- "description": item.get("Text", ""),
+ "bookingDate": entryDate,
+ "reference": docInfo.get("Number", ""),
+ "description": desc,
"currency": "CHF",
- "totalAmount": totalAmt,
- "lines": lines,
+ "totalAmount": amt,
+ "lines": [
+ {"accountNumber": debitAcc, "debitAmount": amt, "creditAmount": 0, "description": desc},
+ {"accountNumber": creditAcc, "debitAmount": 0, "creditAmount": amt, "description": desc},
+ ],
})
url = data.get("@odata.nextLink")
except Exception as e:
@@ -374,23 +468,11 @@ class AccountingConnectorAbacus(BaseAccountingConnector):
years: List[int],
accountNumbers: Optional[List[str]] = None,
) -> List[AccountingPeriodBalance]:
- """Aggregate account balances from ``GeneralJournalEntries`` (OData V4).
+ """Aggregate account balances from GeneralLedgerEntries (OData V4).
- Strategy:
- 1. Page through ``GET GeneralJournalEntries?$filter=JournalDate le YYYY-12-31``
- until ``@odata.nextLink`` is exhausted. Including ALL prior years
- is required to compute the carry-over for balance-sheet accounts.
- 2. Per (account, year, month) accumulate ``DebitAmount``/``CreditAmount``
- from ``Lines``.
- 3. Income-statement accounts (3xxx-9xxx) reset to 0 per fiscal year;
- balance-sheet accounts (1xxx-2xxx) carry their cumulative balance.
-
- Optional optimization (not yet active): if the customer's Abacus
- instance ships the ``AccountBalances`` OData entity, it can return
- authoritative period balances directly. Detect via a probe GET on
- ``AccountBalances?$top=1`` and prefer that source. This is intentionally
- deferred until we hit a customer where the entity is available --
- the local aggregation is always-correct fallback.
+ Each Abacus entry is a single line with Debit.AccountId, Credit.AccountId,
+ and Amount.KeyAmount. We expand this into two movements per entry
+ (debit account gets +amount, credit account gets -amount).
"""
if not years:
return []
@@ -409,7 +491,7 @@ class AccountingConnectorAbacus(BaseAccountingConnector):
movements: Dict[Tuple[str, int, int], Dict[str, float]] = {}
seenAccounts: set = set()
for entry in rawEntries:
- dateRaw = str(entry.get("JournalDate") or "")[:10]
+ dateRaw = str(entry.get("Date") or "")[:10]
if len(dateRaw) < 7:
continue
try:
@@ -417,18 +499,15 @@ class AccountingConnectorAbacus(BaseAccountingConnector):
month = int(dateRaw[5:7])
except ValueError:
continue
- for line in (entry.get("Lines") or []):
- accNo = str(line.get("AccountId") or "").strip()
+ amt = float((entry.get("Amount") or {}).get("KeyAmount", 0))
+ if amt == 0:
+ continue
+ debitAcc = str((entry.get("Debit") or {}).get("AccountId", "")).strip()
+ creditAcc = str((entry.get("Credit") or {}).get("AccountId", "")).strip()
+ for accNo, debit, credit in [(debitAcc, amt, 0.0), (creditAcc, 0.0, amt)]:
if not accNo:
continue
seenAccounts.add(accNo)
- try:
- debit = float(line.get("DebitAmount") or 0)
- credit = float(line.get("CreditAmount") or 0)
- except (TypeError, ValueError):
- continue
- if debit == 0 and credit == 0:
- continue
bucket = movements.setdefault((accNo, year, month), {"debit": 0.0, "credit": 0.0})
bucket["debit"] += debit
bucket["credit"] += credit
@@ -495,14 +574,13 @@ class AccountingConnectorAbacus(BaseAccountingConnector):
headers: Dict[str, str],
dateTo: str,
) -> List[Dict[str, Any]]:
- """Page through ``GeneralJournalEntries`` (OData V4) following ``@odata.nextLink``.
-
- We filter ``JournalDate le dateTo`` to bound the result, but include
- ALL prior years (no lower bound) so cumulative balance-sheet
- carry-over is correct.
+ """Page through GeneralLedgerEntries (OData V4) following @odata.nextLink.
+ Abacus only allows filtering by JournalId, so date filtering is done client-side.
"""
results: List[Dict[str, Any]] = []
- baseUrl = self._buildEntityUrl(config, f"GeneralJournalEntries?$filter=JournalDate le {dateTo}")
+ journalFilter = await self._buildJournalFilter(config, headers, dateTo=dateTo)
+ queryParams = f"?$filter={journalFilter}" if journalFilter else ""
+ baseUrl = self._buildEntityUrl(config, f"GeneralLedgerEntries{queryParams}")
nextUrl: Optional[str] = baseUrl
async with aiohttp.ClientSession() as session:
while nextUrl:
@@ -510,11 +588,11 @@ class AccountingConnectorAbacus(BaseAccountingConnector):
async with session.get(nextUrl, headers=headers, timeout=aiohttp.ClientTimeout(total=60)) as resp:
if resp.status != 200:
body = await resp.text()
- logger.warning("Abacus GeneralJournalEntries HTTP %s: %s", resp.status, body[:200])
+ logger.warning("Abacus GeneralLedgerEntries HTTP %s: %s", resp.status, body[:200])
break
data = await resp.json()
except Exception as ex:
- logger.warning("Abacus GeneralJournalEntries request failed: %s", ex)
+ logger.warning("Abacus GeneralLedgerEntries request failed: %s", ex)
break
page = data.get("value") or []
if not isinstance(page, list):
@@ -522,3 +600,60 @@ class AccountingConnectorAbacus(BaseAccountingConnector):
results.extend(page)
nextUrl = data.get("@odata.nextLink")
return results
+
+ async def attachDocumentToEntry(
+ self,
+ config: Dict[str, Any],
+ entryId: str,
+ fileName: str,
+ fileContent: bytes,
+ mimeType: str = "application/pdf",
+ ) -> SyncResult:
+ """Attach a document to a GeneralLedgerEntry via OData V4 two-step flow:
+ 1) POST GeneralLedgerEntryDocuments (metadata) → get document ID
+ 2) PUT GeneralLedgerEntryDocuments({id})/Content (binary stream)
+ """
+ headers = await self._buildAuthHeaders(config)
+ if not headers:
+ return SyncResult(success=False, errorMessage="Failed to obtain access token")
+
+ try:
+ async with aiohttp.ClientSession() as session:
+ # Step 1: create document metadata
+ docUrl = self._buildEntityUrl(config, "GeneralLedgerEntryDocuments")
+ payload = {
+ "Name": fileName,
+ "GeneralLedgerEntryId": entryId,
+ }
+ async with session.post(docUrl, headers=headers, json=payload, timeout=aiohttp.ClientTimeout(total=30)) as resp:
+ body = await resp.text()
+ if resp.status not in (200, 201):
+ logger.error("Abacus document create failed: HTTP %s: %s", resp.status, body[:500])
+ return SyncResult(success=False, errorMessage=f"HTTP {resp.status}: {body[:200]}")
+ try:
+ docData = await resp.json(content_type=None)
+ except Exception:
+ docData = {}
+ docId = docData.get("Id")
+
+ if not docId:
+ logger.error("Abacus document create: no Id in response: %s", body[:300])
+ return SyncResult(success=False, errorMessage="No document Id returned by Abacus")
+
+ # Step 2: upload binary content stream
+ contentUrl = self._buildEntityUrl(config, f"GeneralLedgerEntryDocuments({docId})/Content")
+ streamHeaders = {
+ "Authorization": headers["Authorization"],
+ "Content-Type": mimeType,
+ }
+ async with session.put(contentUrl, headers=streamHeaders, data=fileContent, timeout=aiohttp.ClientTimeout(total=60)) as resp2:
+ if resp2.status not in (200, 204):
+ body2 = await resp2.text()
+ logger.error("Abacus document content upload failed: HTTP %s: %s", resp2.status, body2[:500])
+ return SyncResult(success=False, errorMessage=f"Content upload HTTP {resp2.status}: {body2[:200]}")
+
+ logger.info("Abacus document attached: docId=%s, entryId=%s, fileName=%s", docId, entryId, fileName)
+ return SyncResult(success=True, externalId=str(docId))
+ except Exception as e:
+ logger.error("Abacus attachDocumentToEntry error: %s", e)
+ return SyncResult(success=False, errorMessage=str(e))