"""
GL Accounts API — backend for Excel Web Office Script.
Architecture:
Excel Web (Office Script) → HTTP GET /gl/all → this API → PostgreSQL on vps107.geneva.hosting
Run locally:
pip install -r requirements.txt
uvicorn main:app --reload --port 8000
Expose for Excel Web (Office Scripts run on Microsoft servers, need a public URL):
ngrok http 8000
→ copy the https://xxxx.ngrok.io URL into the Office Script API_BASE constant
"""
from fastapi import FastAPI, HTTPException, Query, Request
from fastapi.middleware.cors import CORSMiddleware
from fastapi.responses import HTMLResponse, PlainTextResponse
from dotenv import load_dotenv
import psycopg2
import os
load_dotenv()
app = FastAPI(title="GL Accounts API")
app.add_middleware(
CORSMiddleware,
allow_origins=["*"], # Excel Web / Office Scripts need open CORS for POC
allow_methods=["GET"],
allow_headers=["*"],
)
DB_CONFIG = {
"host": os.getenv("DB_HOST", "vps107.geneva.hosting"),
"port": int(os.getenv("DB_PORT", "5432")),
"dbname": os.getenv("DB_NAME", "tradon"),
"user": os.getenv("DB_USER", "tradon_readonly"),
"password": os.getenv("DB_PASSWORD", "change_me"),
"connect_timeout": 10,
}
# Ordered list used by the Office Script to map rows 6-13 in the Functions sheet
GL_FUNCTIONS = [
"get_account_base_amount",
"get_account_real_base_amount",
"get_account_amount",
"get_account_abbr",
"get_account_name",
"get_account_contact",
"get_account_contact_code",
"get_account_contact_name",
]
def run_query(function_name: str, company: str, account: str, currency: str, value_date: str):
with psycopg2.connect(**DB_CONFIG) as conn:
with conn.cursor() as cur:
cur.execute(
f"SELECT excel_api.{function_name}(%s, %s, %s, %s::date)",
(company, account, currency, value_date),
)
row = cur.fetchone()
return row[0] if row and row[0] is not None else None
@app.get("/gl/all")
def get_all(
company: str = Query(..., description="Company party code or name"),
account: str = Query(..., description="GL account code"),
currency: str = Query(..., description="Currency code / name / id"),
date: str = Query(..., description="Cutoff date YYYY-MM-DD"),
):
"""
Returns all 8 GL function results in one round-trip.
Called by the Office Script RefreshGLApi.ts.
"""
if function_name_invalid := [f for f in GL_FUNCTIONS if f not in GL_FUNCTIONS]:
raise HTTPException(400, f"Unknown function: {function_name_invalid}")
results: dict = {}
errors: dict = {}
for fn in GL_FUNCTIONS:
try:
results[fn] = run_query(fn, company, account, currency, date)
except Exception as e:
results[fn] = None
errors[fn] = str(e)
return {"results": results, "errors": errors or None}
@app.get("/health")
def health():
try:
with psycopg2.connect(**DB_CONFIG) as conn:
with conn.cursor() as cur:
cur.execute("SELECT 1")
return {"status": "ok", "db": "connected"}
except Exception as e:
raise HTTPException(503, f"DB unreachable: {e}")
# ── Excel Custom Functions Add-in ────────────────────────────────────────────
ADDIN_GUID = "3F4A8B2C-9D1E-4F5A-B6C7-8D9E0F1A2B3C"
@app.get("/addin/icon.png")
def addin_icon():
# Minimal 1x1 green PNG
import base64
png = base64.b64decode(
"iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNk+M9QDwADhgGAWjR9awAAAABJRU5ErkJggg=="
)
from fastapi.responses import Response
return Response(content=png, media_type="image/png")
@app.get("/addin/manifest.xml", response_class=PlainTextResponse)
def addin_manifest(request: Request):
base = str(request.base_url).rstrip("/")
xml = f"""
Tradon GL API Add-in loaded.
""") @app.get("/addin/functions.json") def addin_functions_json(): functions = [ ("GET_ACCOUNT_BASE_AMOUNT", "get_account_base_amount", "Balance converted to base currency"), ("GET_ACCOUNT_REAL_BASE_AMOUNT", "get_account_real_base_amount", "Posted balance in base currency"), ("GET_ACCOUNT_AMOUNT", "get_account_amount", "Balance in transaction currency"), ("GET_ACCOUNT_ABBR", "get_account_abbr", "Account abbreviation/code"), ("GET_ACCOUNT_NAME", "get_account_name", "Account name"), ("GET_ACCOUNT_CONTACT", "get_account_contact", "Contact (code or name)"), ("GET_ACCOUNT_CONTACT_CODE", "get_account_contact_code", "Contact party codes"), ("GET_ACCOUNT_CONTACT_NAME", "get_account_contact_name", "Contact party names"), ] params = [ {"name": "company", "dimensionality": "scalar", "type": "string"}, {"name": "account", "dimensionality": "scalar", "type": "string"}, {"name": "currency", "dimensionality": "scalar", "type": "string"}, {"name": "date", "dimensionality": "scalar", "type": "string"}, ] return { "functions": [ {"id": fn_id, "name": fn_id, "description": desc, "parameters": params, "result": {"dimensionality": "scalar", "type": "string"}} for fn_id, _, desc in functions ] } @app.get("/addin/functions.js", response_class=PlainTextResponse) def addin_functions_js(request: Request): base = str(request.base_url).rstrip("/") js = f"""/* Tradon GL Custom Functions — auto-generated, base={base} */ const _API = "{base}"; const _HDR = {{"ngrok-skip-browser-warning": "true"}}; async function _gl(fn, company, account, currency, date) {{ const url = _API + "/gl/all?company=" + encodeURIComponent(company) + "&account=" + encodeURIComponent(account) + "¤cy=" + encodeURIComponent(currency) + "&date=" + encodeURIComponent(date); const r = await fetch(url, {{headers: _HDR}}); if (!r.ok) throw new Error("HTTP " + r.status); const d = await r.json(); const v = d.results[fn]; return v !== null && v !== undefined ? v : ""; }} CustomFunctions.associate("GET_ACCOUNT_BASE_AMOUNT", (c,a,u,d) => _gl("get_account_base_amount", c,a,u,d)); CustomFunctions.associate("GET_ACCOUNT_REAL_BASE_AMOUNT", (c,a,u,d) => _gl("get_account_real_base_amount", c,a,u,d)); CustomFunctions.associate("GET_ACCOUNT_AMOUNT", (c,a,u,d) => _gl("get_account_amount", c,a,u,d)); CustomFunctions.associate("GET_ACCOUNT_ABBR", (c,a,u,d) => _gl("get_account_abbr", c,a,u,d)); CustomFunctions.associate("GET_ACCOUNT_NAME", (c,a,u,d) => _gl("get_account_name", c,a,u,d)); CustomFunctions.associate("GET_ACCOUNT_CONTACT", (c,a,u,d) => _gl("get_account_contact", c,a,u,d)); CustomFunctions.associate("GET_ACCOUNT_CONTACT_CODE", (c,a,u,d) => _gl("get_account_contact_code", c,a,u,d)); CustomFunctions.associate("GET_ACCOUNT_CONTACT_NAME", (c,a,u,d) => _gl("get_account_contact_name", c,a,u,d)); """ return PlainTextResponse(content=js, media_type="application/javascript")