Kulanz accounting
General ledger that runs your books in one system
Kulanz is not a sync layer on top of another accounting tool. Sales, purchases, payments, and posting all land in the Kulanz native ledger so your team can close faster with one source of truth.
Native GL
Chart of accounts, journals, periods, and reports in-app.
Operational workflow
Voucher classification to posting with clear accounting ownership.
Export-ready
CSV/XLSX/PDF outputs for accountant collaboration.
Native accounting decision
Kulanz runs native accounting in its own general ledger. This document records the product and architecture decision to use Kulanz as the accounting system of record.
Decision
Kulanz stores and posts accounting data in its own Postgres general ledger tables (ledger_accounts, accounting_journal_entries, accounting_journal_lines, related reporting tables). External accounting platform sync is not part of the core product direction.
Scope
- Sales, purchases, and payment flows post into Kulanz journals.
- Plaid-assisted reconciliation and accounting reports operate on Kulanz GL data.
- Domain accounting APIs are implemented in
kulanz-backendunder/api/v1/organizations/{slug}/....
Why
- One source of truth for journals and reports reduces reconciliation drift.
- Native GL ownership simplifies iOS and web flows around posting and reporting.
- Product velocity is higher when accounting behavior is implemented once in backend domain APIs.
Migration and imports
Historical data enters via import paths (for example Dynacom and CSV tooling) and is posted into Kulanz GL tables. Migration strategy does not depend on fetching from external accounting ledgers.
Export model
Kulanz supports generic accountant-facing exports (CSV/XLSX/PDF and report surfaces) without coupling to a specific downstream accounting product.
Non-goals
- Per-organization OAuth credentials for external GL products.
- Two-way sync with external accounting systems.
- Productized mappings for external accounting entities.
Related docs
General ledger (Kulanz)
Technical overview of the core GL in this repository. For product checklist status, see README.md.
Product direction
Kulanz uses native accounting as the system of record. See architecture/native-accounting.md for the decision and scope.
Architecture
Tables
| Table | Purpose |
|---|---|
ledger_accounts | Chart of accounts per organization (code, name, type, activation status) |
organization_accounting_preferences | Default cash, AR, and revenue accounts; functional currency (CAD default) |
sales_payments | Customer payments (draft or posted); optional Dynacom import id |
sales_payment_allocations | Payment amounts applied to sales_invoices |
accounting_journal_entries | Draft or posted entries; document_date vs posted_at |
accounting_journal_lines | Debit/credit lines; optional currency / functional_amount for future FX |
accounting_fiscal_periods | Monthly and quarterly periods; open or closed |
accounting_transactions | Inbound payment events (webhook); optional link to posted journal |
exchange_rates | Schema for future multi-currency (no UI in Wave 1) |
Posting rules
- Draft entries have
status = draft,posted_atnull, and editable lines. - Post validates: at least two lines, debit XOR credit per line, debits = credits, active accounts, and posting period open (if a fiscal period covers the posting date and is closed, post is rejected).
- Webhook
payment_received+succeeded: when default cash and accounts receivable are configured, posts Dr Cash / Cr AR usingmetadata.allocations(invoice ref + amount). Otherwise falls back to Dr Cash / Cr Revenue when cash and default payment revenue are set. - Sales invoice to AR:
POST /api/organizations/:slug/sales-invoices/:ref/post-to-arposts Dr AR / Cr Revenue per invoice line (grouped by revenue ledger account). - Sales payment to AR:
POST /api/organizations/:slug/sales-payments/:paymentId?action=postposts Dr Cash / Cr AR for the payment total.
Period lock uses posting date (UTC calendar day), not document date.
Accounts receivable (sales)
Invoice headers track amount_paid, amount_outstanding, and payment_status (derived from allocations). Do not mirror Dynacom InvoiHdr.Balance.
Dynacom import order (after migration 0027):
pnpm ledger:import-coapnpm dynacom:import-customerspnpm dynacom:import-invoicespnpm dynacom:import-payments→ optionalpnpm dynacom:verify-paymentspnpm dynacom:import-gl-posted— postedGLDet(EXP/GJ) →accounting_journal_entriespnpm expenses:backfill-posted-vouchers— link CSV vouchers to imported journalspnpm dynacom:verify-gl-import
Configure AR and cash accounts under Accounting → Preferences before batch posting invoices to GL.
Classification: Purchases → Inbox shows unmapped / unbalanced / ready work; posted history is under General ledger. See docs/architecture/accounting-classification.md.
APIs (organization-scoped)
All routes require session auth and org membership unless noted.
| Route | Methods | Notes |
|---|---|---|
/api/organizations/:slug/ledger-accounts | GET, POST | POST requires admin |
/api/organizations/:slug/ledger-accounts/:id | PATCH | Admin |
/api/organizations/:slug/accounting-preferences | GET, PATCH | PATCH admin |
/api/organizations/:slug/journal-entries | GET, POST | POST creates draft |
/api/organizations/:slug/journal-entries/:id | GET, PATCH | PATCH draft only |
/api/organizations/:slug/journal-entries/:id/post | POST | Admin; posts draft |
/api/organizations/:slug/fiscal-periods | GET, POST | POST generates year |
/api/organizations/:slug/fiscal-periods/:id | PATCH | Close/reopen; admin |
/api/organizations/:slug/reports/trial-balance | GET | Query from, to (YYYY-MM-DD) |
/api/organizations/:slug/reports/ar-aging | GET | Query asOf; optional clientExternalId for statement |
/api/organizations/:slug/sales-payments | GET, POST | List / create payment + allocations |
/api/organizations/:slug/sales-payments/:paymentId | GET | Detail; POST?action=post posts GL |
/api/organizations/:slug/sales-invoices/:ref/post-to-ar | POST | Post invoice to AR (admin) |
Inbound (no session): POST /api/webhooks/accounting/transactions with Bearer secret and X-Organization-Id.
Dashboard
Accounting in the org dashboard sidebar (/dashboard/organization/:slug/accounting): chart of accounts, journals, fiscal periods, preferences, trial balance report, and in-app help.
Enable the org Accounting service flag under Organization → General.
Tests
pnpm test:accounting # unit tests (validators, period lock)
pnpm test # full unit suite (vitest.config.ts)
Optional integration tests against a database: set DATABASE_URL and add cases under src/server/accounting/accounting.integration.test.ts.
Purchases (expense vouchers)
- UI: Accounting → Purchases (formerly a separate Expenses nav item).
expense_voucher_lines.gl_codeis linked toledger_accounts.codevialedger_account_id(set on import when the code exists in COA).- Post to GL:
POST /api/organizations/:slug/expense-vouchers/:id/post-to-glcreates a postedaccounting_journal_entryand setsexpense_vouchers.journal_entry_id. - Import with validation:
pnpm expenses:import-2025 -- --strict-glfails when a line GL code is missing from COA. - Backfill links:
POST /api/organizations/:slug/expense-vouchers/sync-ledger-links.
Activation status
Each ledger_accounts row has activation_status:
| Status | Meaning |
|---|---|
active | Approved; may be used on posted journals and payment posting |
pending_approval | Imported or created for review; not postable (distinct from inactive) |
inactive | Retired from use |
Before migration: pnpm sales:verify-code-overlap reports sales_code_references codes that already exist in the COA (those rows are skipped, not duplicated).
Related (not core GL)
sales_code_referencesandexpense_code_referenceswere removed — revenue / purchase lines link toledger_accountsvialedger_account_id.
Accounting classification architecture
Glossary
| Term | Meaning |
|---|---|
| Chart of accounts (COA) | ledger_accounts — master GL codes for the organization |
| Voucher | expense_vouchers — imported purchase document (AP-style) with lines before or at GL posting |
| Journal entry | accounting_journal_entries — official double-entry GL record (draft or posted) |
Vouchers and journals are not the same layer. Vouchers are the import/work queue; journals are the ledger.
Pipeline
Voucher status (computed)
| Status | Rule |
|---|---|
| posted | journal_entry_id is set |
| unmapped | Any line has gl_code without ledger_account_id |
| unbalanced | Sum debits ≠ sum credits (tolerance 0.02) |
| ready | Mapped, balanced, not posted, ≥2 lines with amounts |
Default Inbox excludes posted vouchers. Historical Dynacom GLDet imports must set journal_entry_id at import time.
UI surfaces (no duplication)
| Surface | Scope |
|---|---|
| Accounting → Chart of accounts | COA CRUD only |
| Accounting → Purchases → Inbox | Work queue: unmapped / unbalanced / ready |
| Accounting → Purchases → Suppliers / All expenses | Browse with status visibility |
| Accounting → General ledger | Posted journals + manual drafts |
Do not add COA or duplicate journal lists under Purchases.
API ownership
Canonical REST: kulanz-backend /api/v1/organizations/{slug}/…
Web dashboard and iOS call the backend API directly (Bearer JWT). kulanz-online has no domain BFF for classification, vouchers, or journals — use createOrgApi from src/lib/org-api.ts.
Expense voucher export (CSV/XLSX/PDF) remains a kulanz-online /api/organizations/.../expense-vouchers/export route until migrated.
See ios-v1.md for mobile contracts.
Import order (Dynacom migration)
pnpm ledger:import-coa
pnpm dynacom:import-customers
pnpm dynacom:import-invoices
pnpm dynacom:import-payments
pnpm dynacom:import-purchases
pnpm dynacom:import-gl-posted
pnpm dynacom:verify-gl-import
CSV (expenses:import-2025) remains for ad-hoc files; SQLite + GLDet is canonical for bulk migration.
Backend split: kulanz-online ↔ kulanz-backend
Ownership
| Concern | Owner |
|---|---|
| Drizzle schema + migrations | kulanz-online (src/server/db/schema/) |
| SQLAlchemy mirrors | kulanz-backend (src/kulanz_backend/db/models/) — update in the same PR as Drizzle changes |
| Dashboard UI | kulanz-online — calls backend with apiClient + Bearer JWT |
| Agents + Python REST | kulanz-backend only |
| Browser auth | Supabase session in the browser → Authorization: Bearer on api.kulanz.audla.ai |
| iOS native app | kulanz-app → https://api.kulanz.audla.ai /api/v1/... |
Client API pattern (web)
import { createOrgApi } from '@/lib/org-api'
const orgApi = createOrgApi(organizationSlug)
await orgApi.get('/journal-entries?limit=50')
window.__KULANZ_CONFIG__.backendUrl is set from NEXT_PUBLIC_KULANZ_BACKEND_URL in the root layout.
The dashboard calls kulanz-backend for org domain APIs, auth profile/password/bootstrap, members, uploads, and expense journal export. Remaining kulanz-online /api/* route handlers are webhook proxies only (prefer backend URLs in production).
iOS (kulanz-app)
- Production:
https://api.kulanz.audla.ai - Debug:
http://127.0.0.1:8000 - Contract:
docs/api-contracts/ios-v1.md
Local development
cd kulanz-backend && uv run kulanz-api(port 8000)- In
kulanz-online/.env.local:KULANZ_BACKEND_URL=http://127.0.0.1:8000(server/scripts if needed)NEXT_PUBLIC_KULANZ_BACKEND_URL=http://127.0.0.1:8000NEXT_PUBLIC_SUPABASE_URL/NEXT_PUBLIC_SUPABASE_PUBLISHABLE_KEYKULANZ_BACKEND_SERVICE_TOKEN(service-to-service only)
Framework: Next.js App Router (app/). UI code lives in app/**, src/components, src/lib, src/views.
Production
| Setting | Value |
|---|---|
| Web/iOS API base | https://api.kulanz.audla.ai |
| Accounting webhooks | https://api.kulanz.audla.ai/api/v1/webhooks/accounting/transactions |
| Stripe webhooks | https://api.kulanz.audla.ai/api/v1/webhooks/stripe (preferred); online proxy may still exist) |
| Sales webhooks | Prefer backend; online /api/webhooks/sales/:source until fully migrated |
| Contact form | POST https://api.kulanz.audla.ai/api/v1/contact |
Agents
Web UI calls POST /api/v1/organizations/{slug}/agents/{name}/run (and COA sessions under /api/v1/.../agents/coa_assistant/sessions) directly on the Python API.
Import scripts
Historical import and maintenance scripts under kulanz-online/scripts/ may still use Drizzle/Postgres locally; new posting logic belongs in kulanz-backend.