Architecture¶
SAE Books is built around a clean boundary: the API is the product, and everything else — the web UI, document vault, accounting-package sync, ATO lodgement relay, licensing server — is a service orbiting it.
This is not a cosmetic distinction. The API is the AGPLv3 licensed artifact. Every service connects to it over HTTP, the same way a third-party integration would. No service reaches into the API's Postgres schema directly.
The orbit¶
FastAPI · SQLAlchemy · Postgres
port 8042 (REST) · 50051 (gRPC)"] WEB["saebooks-web
Jinja2 · HTMX
port 8080"] PG[("Postgres 16
+ RLS")] API --- PG WEB -->|"REST calls"| API end subgraph "SAE Engineering services" LICENSE["license.saebooks.com.au
Ed25519 JWT · Stripe"] LODGE["lodge.saebooks.com.au
ATO SBR relay · STP · BAS"] FEEDS["feeds.saebooks.com.au
CDR bank-feed relay
(SISS Data Services)"] MARKETING["saebooks.com.au
marketing · Stripe Checkout"] end subgraph "Third-party services (customer-operated)" SYNC["saebooks-sync
Xero / MYOB / QBO
bidirectional adapter"] VAULT["saebooks-vault
invoice attachment store"] DISCOURSE["discourse.saebooks.com.au
forum · DiscourseConnect SSO"] end API -->|"licence check"| LICENSE API -->|"lodge STP/BAS"| LODGE API -->|"bank feed pull"| FEEDS SYNC -->|"REST"| API VAULT -->|"REST"| API WEB -->|"DiscourseConnect"| DISCOURSE
On-prem components¶
saebooks-api — the ledger. FastAPI with async SQLAlchemy 2.x on Postgres. Every accounting operation goes through the REST API. No side-channel writes. Runs on port 8042.
saebooks-web — the browser UI. FastAPI + Jinja2 + HTMX. No JavaScript build step (Tailwind CSS is pre-compiled at image build time). Holds a signed session cookie (itsdangerous) that stores the API bearer token. Proxies all data operations through saebooks-api. Runs on port 8080 in production; 8043 in local dev.
Postgres — system of record. Row-level security on every domain table, keyed on tenant_id. The current tenant is bound per-request via SET LOCAL app.current_tenant = '...'. Cross-tenant access is an explicit, audited code path — not a query-parameter.
SAE Engineering relays¶
These run on SAE Engineering's infrastructure. Customer installs call out to them; SAE Engineering holds zero customer accounting data.
license.saebooks.com.au — Ed25519 JWT licence minting. Stripe Checkout webhook → issue trial or paid licence → email to customer. The JWT the customer pastes into their .env is verified locally by the API binary (no network call on every request — only a once-daily refresh).
lodge.saebooks.com.au — ATO SBR relay. Signs and forwards STP/BAS/TPAR/SuperStream envelopes through SAE Engineering's Machine Credential. Customer never touches the credential. SBR lodgement is a Pro+ feature; the lodge-server verifies the licence JWT before forwarding.
feeds.saebooks.com.au — CDR bank-feed relay. Proxies through SAE Engineering's SISS Data Services (ACSISS) agreement. Customers bring their bank consent; SAE holds the data-recipient credential. Enterprise feature.
saebooks.com.au — marketing site and Stripe Checkout. Not part of the self-hosted stack.
Third-party services¶
saebooks-sync — bidirectional accounting-package sync (Xero, MYOB, QBO). Not yet shipped. See Sync overview.
saebooks-vault — invoice attachment and document store. Postgres metadata + blob store, separate service. Planned, not yet shipped.
Discourse — community forum at discourse.saebooks.com.au. Discourse is the DiscourseConnect identity provider and SAE Books is the consumer: the web process's /auth/discourse/login redirects to Discourse's /session/sso_provider, Discourse authenticates the user, and SAE Books consumes the signed identity payload on return. See DiscourseConnect.
Request path (browser to data)¶
Browser
→ Caddy / reverse proxy (TLS termination)
→ saebooks-web :8080 (session auth, Jinja render, CSRF)
→ saebooks-api :8042 (JWT auth, business logic)
→ Postgres :5432 (RLS-enforced query)
State-changing requests carry a CSRF token (double-submit cookie pattern). Origin/Referer enforcement runs before CSRF.
Multi-tenancy model¶
Every domain table carries tenant_id UUID NOT NULL. Postgres RLS policies enforce per-tenant visibility. Each install is single-tenant by default; Enterprise licences enable multi-tenant operator tooling.
The discipline holds even on single-tenant installs. No global queries. Every insert asserts tenant_id. This is what makes a future cloud-hosted pivot a deployment change rather than a rewrite.
See Tenancy & RLS for the implementation detail.
Design philosophy¶
- The API is the product surface. The OpenAPI spec at
/openapi.jsonis the contract. If it's not in the spec, it doesn't exist. - Stateless app servers. All state lives in Postgres. The app container is disposable —
docker compose up -d --buildand the new container picks up exactly where the old one left off. - External services called by config URL.
LICENSE_SERVER_URL,LODGE_SERVER_URL,FEEDS_SERVER_URLdefault to the SAE Engineering endpoints but can be overridden. An air-gapped install can point at its own relay. - No vendor lock-in. Your data is in Postgres. Dump it any time. Import it into anything that can read Postgres. The sync service connects outbound to Xero/MYOB/QBO — not the other way around.
Built for the next 20 years, not the last 20.