basic online

This commit is contained in:
zipfriis
2026-03-20 14:01:46 +01:00
parent 5083212e07
commit cd2db504d1
16 changed files with 1137 additions and 0 deletions

3
.gitignore vendored Normal file
View File

@@ -0,0 +1,3 @@
*db
*db-shm
*db-wal

183
README.md
View File

@@ -0,0 +1,183 @@
# FP&A Budgeting Engine
A production-grade REST API for corporate budgeting, variance analysis, and department-level financial reporting. Built to automate the workflows FP&A teams typically run manually in Excel.
---
## The Business Problem
Every month, FP&A analysts pull actuals from ERP systems, paste them into spreadsheets, and manually calculate budget vs. actual variances by department and cost center. This is slow, error-prone, and doesn't scale.
This engine replaces that workflow with a reliable API: budget data is structured in a normalized schema, actuals are ingested on a schedule, and variance reports are available on demand — for any department, any period, any GL account.
---
## What It Does
- **Budget management** — Create and version annual budgets by department and GL account
- **Actuals ingestion** — Load actuals from CSV or JSON (ERP export format)
- **Variance analysis** — Budget vs. actual, favourable/unfavourable, percentage and absolute
- **Rollups** — P&L rollup by department, cost center, and fiscal period
- **Alerts** — Flag accounts exceeding budget by a configurable threshold
- **Audit trail** — Every budget change is timestamped and attributed
---
## Tech Stack
| Layer | Technology |
|---|---|
| API server | Go (net/http + chi router) |
| Database | PostgreSQL |
| Schema migrations | golang-migrate |
| Config | Environment variables (.env) |
| Docs | OpenAPI 3.0 (docs/openapi.yaml) |
---
## Project Structure
```
fpa-budgeting-engine/
├── cmd/
│ └── server/
│ └── main.go # Entry point
├── internal/
│ ├── handler/ # HTTP handlers
│ │ ├── budget.go
│ │ ├── actuals.go
│ │ └── variance.go
│ ├── model/ # Domain types
│ │ ├── budget.go
│ │ ├── actuals.go
│ │ └── variance.go
│ ├── repository/ # DB layer
│ │ ├── budget_repo.go
│ │ └── actuals_repo.go
│ └── service/ # Business logic
│ ├── budget_service.go
│ └── variance_service.go
├── migrations/
│ ├── 001_create_departments.up.sql
│ ├── 002_create_gl_accounts.up.sql
│ ├── 003_create_budgets.up.sql
│ ├── 004_create_actuals.up.sql
│ └── 005_create_audit_log.up.sql
├── docs/
│ └── openapi.yaml
├── scripts/
│ └── seed_demo.sql # Sample data for demo/testing
├── .env.example
├── docker-compose.yml
├── Makefile
└── README.md
```
---
## Key API Endpoints
```
POST /api/v1/budgets Create a new budget entry
GET /api/v1/budgets?dept=&period= List budgets with filters
PUT /api/v1/budgets/{id} Update a budget line
POST /api/v1/actuals/ingest Ingest actuals (JSON or CSV)
GET /api/v1/variance?dept=&period= Variance report
GET /api/v1/variance/summary Full P&L summary
GET /api/v1/variance/alerts Accounts over threshold
GET /api/v1/rollup?by=department Rollup by dimension
```
---
## Finance Concepts Implemented
**Chart of Accounts** — GL accounts are typed (revenue, COGS, opex, capex) and roll up into P&L line items correctly.
**Variance conventions** — Revenue variances are favourable when actuals exceed budget. Cost variances are favourable when actuals are below budget. The engine handles both correctly.
**Fiscal periods** — Supports fiscal year offsets (e.g. FY starting April). Periods are stored as fiscal quarters and months, not calendar months.
**Budget versioning** — Original budget, Forecast 1, Forecast 2 are tracked separately, enabling Budget vs. Forecast vs. Actual three-way comparison.
---
## Getting Started
```bash
# Clone and configure
git clone https://gitea.yoursite.com/yourname/fpa-budgeting-engine
cp .env.example .env
# Start Postgres
docker-compose up -d db
# Run migrations
make migrate-up
# Seed demo data
psql $DATABASE_URL < scripts/seed_demo.sql
# Start the server
make run
# → API available at http://localhost:8080
```
---
## Example: Variance Report Response
```json
GET /api/v1/variance?dept=engineering&period=2024-Q3
{
"department": "Engineering",
"period": "2024-Q3",
"currency": "DKK",
"lines": [
{
"gl_account": "6100",
"description": "Salaries & Wages",
"budget": 4200000,
"actual": 4380000,
"variance": -180000,
"variance_pct": -4.3,
"status": "unfavourable"
},
{
"gl_account": "6300",
"description": "Software Subscriptions",
"budget": 320000,
"actual": 289000,
"variance": 31000,
"variance_pct": 9.7,
"status": "favourable"
}
],
"total_budget": 5180000,
"total_actual": 5290000,
"total_variance": -110000,
"total_variance_pct": -2.1
}
```
---
## Why Go for a Finance API?
Go's compile-time type safety, zero-cost abstractions, and simple concurrency model make it well-suited for financial data services:
- **No null pointer surprises** — strict typing prevents the class of bugs that corrupt financial calculations
- **Fast and predictable** — consistent sub-10ms response times under load
- **Easy to deploy** — single binary, no runtime dependencies, runs anywhere
- **Explicit error handling** — every failure path is handled, not swallowed
---
## Roadmap
- [ ] Multi-currency support with FX rate table
- [ ] Excel export of variance reports (finance teams need this)
- [ ] Webhook notifications when accounts breach threshold
- [ ] Integration adapter for SAP/NetSuite actuals export format
- [ ] Role-based access (department managers see only their cost centers)

17
go.mod Normal file
View File

@@ -0,0 +1,17 @@
module Engine
go 1.25.0
require modernc.org/sqlite v1.47.0
require (
github.com/dustin/go-humanize v1.0.1 // indirect
github.com/google/uuid v1.6.0 // indirect
github.com/mattn/go-isatty v0.0.20 // indirect
github.com/ncruces/go-strftime v1.0.0 // indirect
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
golang.org/x/sys v0.42.0 // indirect
modernc.org/libc v1.70.0 // indirect
modernc.org/mathutil v1.7.1 // indirect
modernc.org/memory v1.11.0 // indirect
)

51
go.sum Normal file
View File

@@ -0,0 +1,51 @@
github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e h1:ijClszYn+mADRFY17kjQEVQ1XRhq2/JR1M3sGqeJoxs=
github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e/go.mod h1:boTsfXsheKC2y+lKOCMpSfarhxDeIzfZG1jqGcPl3cA=
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/hashicorp/golang-lru/v2 v2.0.7 h1:a+bsQ5rvGLjzHuww6tVxozPZFVghXaHOwFs4luLUK2k=
github.com/hashicorp/golang-lru/v2 v2.0.7/go.mod h1:QeFd9opnmA6QUJc5vARoKUSoFhyfM2/ZepoAG6RGpeM=
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
github.com/ncruces/go-strftime v1.0.0 h1:HMFp8mLCTPp341M/ZnA4qaf7ZlsbTc+miZjCLOFAw7w=
github.com/ncruces/go-strftime v1.0.0/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls=
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE=
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo=
golang.org/x/mod v0.33.0 h1:tHFzIWbBifEmbwtGz65eaWyGiGZatSrT9prnU8DbVL8=
golang.org/x/mod v0.33.0/go.mod h1:swjeQEj+6r7fODbD2cqrnje9PnziFuw4bmLbBZFrQ5w=
golang.org/x/sync v0.19.0 h1:vV+1eWNmZ5geRlYjzm2adRgW2/mcpevXNg50YZtPCE4=
golang.org/x/sync v0.19.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI=
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.42.0 h1:omrd2nAlyT5ESRdCLYdm3+fMfNFE/+Rf4bDIQImRJeo=
golang.org/x/sys v0.42.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
golang.org/x/tools v0.42.0 h1:uNgphsn75Tdz5Ji2q36v/nsFSfR/9BRFvqhGBaJGd5k=
golang.org/x/tools v0.42.0/go.mod h1:Ma6lCIwGZvHK6XtgbswSoWroEkhugApmsXyrUmBhfr0=
modernc.org/cc/v4 v4.27.1 h1:9W30zRlYrefrDV2JE2O8VDtJ1yPGownxciz5rrbQZis=
modernc.org/cc/v4 v4.27.1/go.mod h1:uVtb5OGqUKpoLWhqwNQo/8LwvoiEBLvZXIQ/SmO6mL0=
modernc.org/ccgo/v4 v4.32.0 h1:hjG66bI/kqIPX1b2yT6fr/jt+QedtP2fqojG2VrFuVw=
modernc.org/ccgo/v4 v4.32.0/go.mod h1:6F08EBCx5uQc38kMGl+0Nm0oWczoo1c7cgpzEry7Uc0=
modernc.org/fileutil v1.4.0 h1:j6ZzNTftVS054gi281TyLjHPp6CPHr2KCxEXjEbD6SM=
modernc.org/fileutil v1.4.0/go.mod h1:EqdKFDxiByqxLk8ozOxObDSfcVOv/54xDs/DUHdvCUU=
modernc.org/gc/v2 v2.6.5 h1:nyqdV8q46KvTpZlsw66kWqwXRHdjIlJOhG6kxiV/9xI=
modernc.org/gc/v2 v2.6.5/go.mod h1:YgIahr1ypgfe7chRuJi2gD7DBQiKSLMPgBQe9oIiito=
modernc.org/gc/v3 v3.1.2 h1:ZtDCnhonXSZexk/AYsegNRV1lJGgaNZJuKjJSWKyEqo=
modernc.org/gc/v3 v3.1.2/go.mod h1:HFK/6AGESC7Ex+EZJhJ2Gni6cTaYpSMmU/cT9RmlfYY=
modernc.org/goabi0 v0.2.0 h1:HvEowk7LxcPd0eq6mVOAEMai46V+i7Jrj13t4AzuNks=
modernc.org/goabi0 v0.2.0/go.mod h1:CEFRnnJhKvWT1c1JTI3Avm+tgOWbkOu5oPA8eH8LnMI=
modernc.org/libc v1.70.0 h1:U58NawXqXbgpZ/dcdS9kMshu08aiA6b7gusEusqzNkw=
modernc.org/libc v1.70.0/go.mod h1:OVmxFGP1CI/Z4L3E0Q3Mf1PDE0BucwMkcXjjLntvHJo=
modernc.org/mathutil v1.7.1 h1:GCZVGXdaN8gTqB1Mf/usp1Y/hSqgI2vAGGP4jZMCxOU=
modernc.org/mathutil v1.7.1/go.mod h1:4p5IwJITfppl0G4sUEDtCr4DthTaT47/N3aT6MhfgJg=
modernc.org/memory v1.11.0 h1:o4QC8aMQzmcwCK3t3Ux/ZHmwFPzE6hf2Y5LbkRs+hbI=
modernc.org/memory v1.11.0/go.mod h1:/JP4VbVC+K5sU2wZi9bHoq2MAkCnrt2r98UGeSK7Mjw=
modernc.org/opt v0.1.4 h1:2kNGMRiUjrp4LcaPuLY2PzUfqM/w9N23quVwhKt5Qm8=
modernc.org/opt v0.1.4/go.mod h1:03fq9lsNfvkYSfxrfUhZCWPk1lm4cq4N+Bh//bEtgns=
modernc.org/sortutil v1.2.1 h1:+xyoGf15mM3NMlPDnFqrteY07klSFxLElE2PVuWIJ7w=
modernc.org/sortutil v1.2.1/go.mod h1:7ZI3a3REbai7gzCLcotuw9AC4VZVpYMjDzETGsSMqJE=
modernc.org/sqlite v1.47.0 h1:R1XyaNpoW4Et9yly+I2EeX7pBza/w+pmYee/0HJDyKk=
modernc.org/sqlite v1.47.0/go.mod h1:hWjRO6Tj/5Ik8ieqxQybiEOUXy0NJFNp2tpvVpKlvig=
modernc.org/strutil v1.2.1 h1:UneZBkQA+DX2Rp35KcM69cSsNES9ly8mQWD71HKlOA0=
modernc.org/strutil v1.2.1/go.mod h1:EHkiggD70koQxjVdSBM3JKM7k6L0FbGE5eymy9i3B9A=
modernc.org/token v1.1.0 h1:Xl7Ap9dKaEs5kLoOQeQmPWevfnk/DM5qcLcYlA8ys6Y=
modernc.org/token v1.1.0/go.mod h1:UGzOrNV1mAFSEB63lOFHIpNRUVMvYTc6yu1SMY/XTDM=

View File

@@ -0,0 +1,83 @@
package database
import (
"context"
"fmt"
"Engine/internal/model"
)
type ActualsRepo struct {
db *DB
}
func NewActualsRepo(db *DB) *ActualsRepo {
return &ActualsRepo{db: db}
}
func (r *ActualsRepo) Ingest(ctx context.Context, req model.IngestActualsRequest) (*model.Actual, error) {
var deptID, glID int
if err := r.db.QueryRowContext(ctx, `SELECT id FROM departments WHERE code=?`, req.DeptCode).Scan(&deptID); err != nil {
return nil, fmt.Errorf("department %q not found: %w", req.DeptCode, err)
}
if err := r.db.QueryRowContext(ctx, `SELECT id FROM gl_accounts WHERE code=?`, req.GLCode).Scan(&glID); err != nil {
return nil, fmt.Errorf("GL account %q not found: %w", req.GLCode, err)
}
_, err := r.db.ExecContext(ctx, `
INSERT INTO actuals (fiscal_year, fiscal_period, department_id, gl_account_id, amount, currency, source)
VALUES (?,?,?,?,?,?,?)
ON CONFLICT (fiscal_year, fiscal_period, department_id, gl_account_id)
DO UPDATE SET amount=excluded.amount, source=excluded.source`,
req.FiscalYear, req.FiscalPeriod, deptID, glID,
req.Amount, req.Currency, req.Source,
)
if err != nil {
return nil, fmt.Errorf("upsert actual: %w", err)
}
a := &model.Actual{}
err = r.db.QueryRowContext(ctx, `
SELECT id, fiscal_year, fiscal_period, department_id, gl_account_id,
amount, currency, source, ingested_at
FROM actuals
WHERE fiscal_year=? AND fiscal_period=? AND department_id=? AND gl_account_id=?`,
req.FiscalYear, req.FiscalPeriod, deptID, glID,
).Scan(
&a.ID, &a.FiscalYear, &a.FiscalPeriod,
&a.DepartmentID, &a.GLAccountID,
&a.Amount, &a.Currency, &a.Source, &a.IngestedAt,
)
if err != nil {
return nil, fmt.Errorf("fetch ingested actual: %w", err)
}
return a, nil
}
func (r *ActualsRepo) ListByPeriod(ctx context.Context, fiscalYear, fiscalPeriod int, deptCode string) (map[string]float64, error) {
rows, err := r.db.QueryContext(ctx, `
SELECT g.code, a.amount
FROM actuals a
JOIN departments d ON d.id = a.department_id
JOIN gl_accounts g ON g.id = a.gl_account_id
WHERE a.fiscal_year = ?
AND a.fiscal_period = ?
AND (? = '' OR d.code = ?)`,
fiscalYear, fiscalPeriod, deptCode, deptCode,
)
if err != nil {
return nil, fmt.Errorf("list actuals: %w", err)
}
defer rows.Close()
result := make(map[string]float64)
for rows.Next() {
var code string
var amount float64
if err := rows.Scan(&code, &amount); err != nil {
return nil, err
}
result[code] += amount
}
return result, rows.Err()
}

View File

@@ -0,0 +1,116 @@
package database
import (
"context"
"fmt"
"Engine/internal/model"
)
type BudgetRepo struct {
db *DB
}
func NewBudgetRepo(db *DB) *BudgetRepo {
return &BudgetRepo{db: db}
}
func (r *BudgetRepo) Create(ctx context.Context, req model.CreateBudgetRequest) (*model.Budget, error) {
res, err := r.db.ExecContext(ctx, `
INSERT INTO budgets
(fiscal_year, fiscal_period, version, department_id, gl_account_id, amount, currency, notes, created_by)
VALUES (?,?,?,?,?,?,?,?,?)`,
req.FiscalYear, req.FiscalPeriod, req.Version,
req.DepartmentID, req.GLAccountID, req.Amount,
req.Currency, req.Notes, req.CreatedBy,
)
if err != nil {
return nil, fmt.Errorf("create budget: %w", err)
}
id, _ := res.LastInsertId()
b := &model.Budget{}
err = r.db.QueryRowContext(ctx, `
SELECT id, fiscal_year, fiscal_period, version, department_id, gl_account_id,
amount, currency, notes, created_by, created_at, updated_at
FROM budgets WHERE id = ?`, id,
).Scan(
&b.ID, &b.FiscalYear, &b.FiscalPeriod, &b.Version,
&b.DepartmentID, &b.GLAccountID, &b.Amount, &b.Currency,
&b.Notes, &b.CreatedBy, &b.CreatedAt, &b.UpdatedAt,
)
if err != nil {
return nil, fmt.Errorf("fetch created budget: %w", err)
}
return b, nil
}
func (r *BudgetRepo) List(ctx context.Context, fiscalYear, fiscalPeriod int, deptCode string, version model.BudgetVersion) ([]model.BudgetRow, error) {
rows, err := r.db.QueryContext(ctx, `
SELECT g.code, g.description, g.type, g.favour_high, b.amount, b.currency
FROM budgets b
JOIN departments d ON d.id = b.department_id
JOIN gl_accounts g ON g.id = b.gl_account_id
WHERE b.fiscal_year = ?
AND b.fiscal_period = ?
AND (? = '' OR d.code = ?)
AND (? = '' OR b.version = ?)
ORDER BY g.code`,
fiscalYear, fiscalPeriod,
deptCode, deptCode,
string(version), string(version),
)
if err != nil {
return nil, fmt.Errorf("list budgets: %w", err)
}
defer rows.Close()
var result []model.BudgetRow
for rows.Next() {
var row model.BudgetRow
if err := rows.Scan(&row.GLCode, &row.GLDescription, &row.GLType, &row.FavourHigh, &row.Amount, &row.Currency); err != nil {
return nil, err
}
result = append(result, row)
}
return result, rows.Err()
}
func (r *BudgetRepo) Update(ctx context.Context, id int, amount float64, notes, _ string) (*model.Budget, error) {
_, err := r.db.ExecContext(ctx, `
UPDATE budgets
SET amount=?, notes=?, updated_at=CURRENT_TIMESTAMP
WHERE id=?`,
amount, notes, id,
)
if err != nil {
return nil, fmt.Errorf("update budget %d: %w", id, err)
}
b := &model.Budget{}
err = r.db.QueryRowContext(ctx, `
SELECT id, fiscal_year, fiscal_period, version, department_id, gl_account_id,
amount, currency, notes, created_by, created_at, updated_at
FROM budgets WHERE id = ?`, id,
).Scan(
&b.ID, &b.FiscalYear, &b.FiscalPeriod, &b.Version,
&b.DepartmentID, &b.GLAccountID, &b.Amount, &b.Currency,
&b.Notes, &b.CreatedBy, &b.CreatedAt, &b.UpdatedAt,
)
if err != nil {
return nil, fmt.Errorf("fetch updated budget: %w", err)
}
return b, nil
}
func (r *BudgetRepo) Delete(ctx context.Context, id int) error {
res, err := r.db.ExecContext(ctx, `DELETE FROM budgets WHERE id=?`, id)
if err != nil {
return fmt.Errorf("delete budget: %w", err)
}
if n, _ := res.RowsAffected(); n == 0 {
return fmt.Errorf("budget %d not found", id)
}
return nil
}

42
internal/database/db.go Normal file
View File

@@ -0,0 +1,42 @@
package database
import (
"context"
"database/sql"
"fmt"
_ "modernc.org/sqlite"
)
// DB wraps sql.DB so the rest of the codebase has a single type to depend on.
type DB struct {
*sql.DB
}
// Connect opens (or creates) the SQLite file at path and runs pragmas for
// safe, performant operation. Pass ":memory:" for tests.
func Connect(_ context.Context, path string) (*DB, error) {
db, err := sql.Open("sqlite", path)
if err != nil {
return nil, fmt.Errorf("open sqlite: %w", err)
}
// WAL mode gives much better read concurrency without blocking reads
// during writes. foreign_keys must be enabled per-connection in SQLite.
pragmas := []string{
"PRAGMA journal_mode=WAL;",
"PRAGMA foreign_keys=ON;",
"PRAGMA busy_timeout=5000;",
}
for _, p := range pragmas {
if _, err := db.Exec(p); err != nil {
return nil, fmt.Errorf("pragma %q: %w", p, err)
}
}
if err := db.Ping(); err != nil {
return nil, fmt.Errorf("ping sqlite: %w", err)
}
return &DB{db}, nil
}

View File

@@ -0,0 +1,86 @@
package database
import "fmt"
// Migrate runs the schema bootstrap. SQLite doesn't need a migration tool —
// CREATE TABLE IF NOT EXISTS is idempotent so this is safe to call on every start.
func Migrate(db *DB) error {
stmts := []string{
`CREATE TABLE IF NOT EXISTS departments (
id INTEGER PRIMARY KEY AUTOINCREMENT,
code TEXT NOT NULL UNIQUE,
name TEXT NOT NULL,
cost_center TEXT NOT NULL DEFAULT '',
active INTEGER NOT NULL DEFAULT 1,
created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%SZ','now'))
)`,
`CREATE TABLE IF NOT EXISTS gl_accounts (
id INTEGER PRIMARY KEY AUTOINCREMENT,
code TEXT NOT NULL UNIQUE,
description TEXT NOT NULL,
type TEXT NOT NULL CHECK(type IN ('revenue','cogs','opex','capex','headcount')),
favour_high INTEGER NOT NULL DEFAULT 0,
active INTEGER NOT NULL DEFAULT 1
)`,
`CREATE TABLE IF NOT EXISTS budgets (
id INTEGER PRIMARY KEY AUTOINCREMENT,
fiscal_year INTEGER NOT NULL,
fiscal_period INTEGER NOT NULL CHECK(fiscal_period BETWEEN 1 AND 12),
version TEXT NOT NULL DEFAULT 'original'
CHECK(version IN ('original','forecast_1','forecast_2','forecast_3')),
department_id INTEGER NOT NULL REFERENCES departments(id),
gl_account_id INTEGER NOT NULL REFERENCES gl_accounts(id),
amount REAL NOT NULL,
currency TEXT NOT NULL DEFAULT 'DKK',
notes TEXT NOT NULL DEFAULT '',
created_by TEXT NOT NULL,
created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%SZ','now')),
updated_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%SZ','now')),
UNIQUE(fiscal_year, fiscal_period, version, department_id, gl_account_id)
)`,
`CREATE TABLE IF NOT EXISTS actuals (
id INTEGER PRIMARY KEY AUTOINCREMENT,
fiscal_year INTEGER NOT NULL,
fiscal_period INTEGER NOT NULL CHECK(fiscal_period BETWEEN 1 AND 12),
department_id INTEGER NOT NULL REFERENCES departments(id),
gl_account_id INTEGER NOT NULL REFERENCES gl_accounts(id),
amount REAL NOT NULL,
currency TEXT NOT NULL DEFAULT 'DKK',
source TEXT NOT NULL DEFAULT '',
ingested_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%SZ','now')),
UNIQUE(fiscal_year, fiscal_period, department_id, gl_account_id)
)`,
// Seed reference data if not already present
`INSERT OR IGNORE INTO departments (code, name, cost_center) VALUES
('ENG', 'Engineering', 'CC-100'),
('SALES', 'Sales', 'CC-200'),
('MKTG', 'Marketing', 'CC-300'),
('FINANCE', 'Finance & Operations', 'CC-400'),
('PRODUCT', 'Product', 'CC-500')`,
`INSERT OR IGNORE INTO gl_accounts (code, description, type, favour_high) VALUES
('4000', 'Subscription Revenue', 'revenue', 1),
('4100', 'Professional Services', 'revenue', 1),
('5000', 'Cloud Infrastructure', 'cogs', 0),
('5100', 'Third-party Licenses', 'cogs', 0),
('6100', 'Salaries & Wages', 'headcount', 0),
('6110', 'Employer Payroll Tax', 'headcount', 0),
('6120', 'Employee Benefits', 'headcount', 0),
('6300', 'Software Subscriptions', 'opex', 0),
('6310', 'Travel & Entertainment', 'opex', 0),
('6400', 'Marketing & Advertising', 'opex', 0),
('6500', 'Consulting & Contractors', 'opex', 0),
('6600', 'Office & Facilities', 'opex', 0)`,
}
for _, stmt := range stmts {
if _, err := db.Exec(stmt); err != nil {
return fmt.Errorf("migrate: %w", err)
}
}
return nil
}

View File

@@ -0,0 +1,33 @@
package handler
import (
"encoding/json"
"net/http"
"Engine/internal/database"
"Engine/internal/model"
)
type ActualsHandler struct {
repo *database.ActualsRepo
}
func NewActualsHandler(repo *database.ActualsRepo) *ActualsHandler {
return &ActualsHandler{repo: repo}
}
func (h *ActualsHandler) Ingest(w http.ResponseWriter, r *http.Request) {
var req model.IngestActualsRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
writeError(w, http.StatusBadRequest, "invalid request body")
return
}
actual, err := h.repo.Ingest(r.Context(), req)
if err != nil {
writeError(w, http.StatusInternalServerError, err.Error())
return
}
writeJSON(w, http.StatusCreated, actual)
}

View File

@@ -0,0 +1,68 @@
package handler
import (
"encoding/json"
"net/http"
"strconv"
"Engine/internal/model"
"Engine/internal/service"
)
type BudgetHandler struct {
svc *service.BudgetService
}
func NewBudgetHandler(svc *service.BudgetService) *BudgetHandler {
return &BudgetHandler{svc: svc}
}
func (h *BudgetHandler) Create(w http.ResponseWriter, r *http.Request) {
var req model.CreateBudgetRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
writeError(w, http.StatusBadRequest, "invalid request body")
return
}
budget, err := h.svc.Create(r.Context(), req)
if err != nil {
writeError(w, http.StatusInternalServerError, err.Error())
return
}
writeJSON(w, http.StatusCreated, budget)
}
func (h *BudgetHandler) Update(w http.ResponseWriter, r *http.Request) {
id, err := strconv.Atoi(r.PathValue("id"))
if err != nil {
writeError(w, http.StatusBadRequest, "invalid id")
return
}
var body struct {
Amount float64 `json:"amount"`
Notes string `json:"notes"`
ChangedBy string `json:"changed_by"`
}
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
writeError(w, http.StatusBadRequest, "invalid request body")
return
}
budget, err := h.svc.Update(r.Context(), id, body.Amount, body.Notes, body.ChangedBy)
if err != nil {
writeError(w, http.StatusInternalServerError, err.Error())
return
}
writeJSON(w, http.StatusOK, budget)
}
func (h *BudgetHandler) Delete(w http.ResponseWriter, r *http.Request) {
id, err := strconv.Atoi(r.PathValue("id"))
if err != nil {
writeError(w, http.StatusBadRequest, "invalid id")
return
}
if err := h.svc.Delete(r.Context(), id); err != nil {
writeError(w, http.StatusInternalServerError, err.Error())
return
}
w.WriteHeader(http.StatusNoContent)
}

View File

@@ -0,0 +1,16 @@
package handler
import (
"encoding/json"
"net/http"
)
func writeJSON(w http.ResponseWriter, status int, v any) {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(status)
json.NewEncoder(w).Encode(v)
}
func writeError(w http.ResponseWriter, status int, msg string) {
writeJSON(w, status, map[string]string{"error": msg})
}

View File

@@ -0,0 +1,64 @@
package handler
import (
"net/http"
"strconv"
"Engine/internal/model"
"Engine/internal/service"
)
type VarianceHandler struct {
svc *service.VarianceService
}
func NewVarianceHandler(svc *service.VarianceService) *VarianceHandler {
return &VarianceHandler{svc: svc}
}
func (h *VarianceHandler) Report(w http.ResponseWriter, r *http.Request) {
f := filterFromQuery(r)
report, err := h.svc.Report(r.Context(), f)
if err != nil {
writeError(w, http.StatusInternalServerError, err.Error())
return
}
writeJSON(w, http.StatusOK, report)
}
func (h *VarianceHandler) Alerts(w http.ResponseWriter, r *http.Request) {
f := filterFromQuery(r)
threshold := 10.0
if t := r.URL.Query().Get("threshold"); t != "" {
if v, err := strconv.ParseFloat(t, 64); err == nil {
threshold = v
}
}
alerts, err := h.svc.Alerts(r.Context(), f, threshold)
if err != nil {
writeError(w, http.StatusInternalServerError, err.Error())
return
}
writeJSON(w, http.StatusOK, alerts)
}
func filterFromQuery(r *http.Request) service.VarianceFilter {
q := r.URL.Query()
year, _ := strconv.Atoi(q.Get("year"))
period, _ := strconv.Atoi(q.Get("period"))
version := model.BudgetVersion(q.Get("version"))
if version == "" {
version = model.VersionOriginal
}
return service.VarianceFilter{
FiscalYear: year,
FiscalPeriod: period,
DeptCode: q.Get("dept"),
Version: version,
}
}

139
internal/model/model.go Normal file
View File

@@ -0,0 +1,139 @@
package model
import "time"
type GLAccountType string
const (
GLRevenue GLAccountType = "revenue"
GLCOGS GLAccountType = "cogs"
GLOpex GLAccountType = "opex"
GLCapex GLAccountType = "capex"
GLHeadcount GLAccountType = "headcount"
)
type BudgetVersion string
const (
VersionOriginal BudgetVersion = "original"
VersionForecast1 BudgetVersion = "forecast_1"
VersionForecast2 BudgetVersion = "forecast_2"
)
type Department struct {
ID int `json:"id"`
Code string `json:"code"`
Name string `json:"name"`
CostCenter string `json:"cost_center"`
}
type GLAccount struct {
ID int `json:"id"`
Code string `json:"code"`
Description string `json:"description"`
Type GLAccountType `json:"type"`
FavourHigh bool `json:"favour_high"`
}
type Budget struct {
ID int `json:"id"`
FiscalYear int `json:"fiscal_year"`
FiscalPeriod int `json:"fiscal_period"`
Version BudgetVersion `json:"version"`
DepartmentID int `json:"department_id"`
GLAccountID int `json:"gl_account_id"`
Amount float64 `json:"amount"`
Currency string `json:"currency"`
Notes string `json:"notes,omitempty"`
CreatedBy string `json:"created_by"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
}
type CreateBudgetRequest struct {
FiscalYear int `json:"fiscal_year"`
FiscalPeriod int `json:"fiscal_period"`
Version BudgetVersion `json:"version"`
DepartmentID int `json:"department_id"`
GLAccountID int `json:"gl_account_id"`
Amount float64 `json:"amount"`
Currency string `json:"currency"`
Notes string `json:"notes"`
CreatedBy string `json:"created_by"`
}
type Actual struct {
ID int `json:"id"`
FiscalYear int `json:"fiscal_year"`
FiscalPeriod int `json:"fiscal_period"`
DepartmentID int `json:"department_id"`
GLAccountID int `json:"gl_account_id"`
GLCode string `json:"gl_code"`
Amount float64 `json:"amount"`
Currency string `json:"currency"`
Source string `json:"source"`
IngestedAt time.Time `json:"ingested_at"`
}
type IngestActualsRequest struct {
FiscalYear int `json:"fiscal_year"`
FiscalPeriod int `json:"fiscal_period"`
DeptCode string `json:"dept_code"`
GLCode string `json:"gl_code"`
Amount float64 `json:"amount"`
Currency string `json:"currency"`
Source string `json:"source"`
}
type VarianceStatus string
const (
StatusFavourable VarianceStatus = "favourable"
StatusUnfavourable VarianceStatus = "unfavourable"
StatusOnBudget VarianceStatus = "on_budget"
)
type VarianceLine struct {
GLCode string `json:"gl_code"`
GLDescription string `json:"gl_description"`
GLType GLAccountType `json:"gl_type"`
Budget float64 `json:"budget"`
Actual float64 `json:"actual"`
VarianceAbs float64 `json:"variance_abs"`
VariancePct *float64 `json:"variance_pct"`
Status VarianceStatus `json:"status"`
Currency string `json:"currency"`
}
type VarianceReport struct {
Department string `json:"department"`
FiscalYear int `json:"fiscal_year"`
FiscalPeriod int `json:"fiscal_period"`
Version BudgetVersion `json:"version"`
Currency string `json:"currency"`
Lines []VarianceLine `json:"lines"`
TotalBudget float64 `json:"total_budget"`
TotalActual float64 `json:"total_actual"`
TotalVariance float64 `json:"total_variance"`
VariancePct *float64 `json:"total_variance_pct"`
}
type AlertThreshold struct {
GLCode string `json:"gl_code"`
Description string `json:"description"`
Budget float64 `json:"budget"`
Actual float64 `json:"actual"`
VariancePct float64 `json:"variance_pct"`
Status VarianceStatus `json:"status"`
Department string `json:"department"`
}
// BudgetRow is used internally by the variance service
type BudgetRow struct {
GLCode string
GLDescription string
GLType GLAccountType
FavourHigh bool
Amount float64
Currency string
}

View File

@@ -0,0 +1,28 @@
package service
import (
"context"
"Engine/internal/database"
"Engine/internal/model"
)
type BudgetService struct {
repo *database.BudgetRepo
}
func NewBudgetService(repo *database.BudgetRepo) *BudgetService {
return &BudgetService{repo: repo}
}
func (s *BudgetService) Create(ctx context.Context, req model.CreateBudgetRequest) (*model.Budget, error) {
return s.repo.Create(ctx, req)
}
func (s *BudgetService) Update(ctx context.Context, id int, amount float64, notes, changedBy string) (*model.Budget, error) {
return s.repo.Update(ctx, id, amount, notes, changedBy)
}
func (s *BudgetService) Delete(ctx context.Context, id int) error {
return s.repo.Delete(ctx, id)
}

View File

@@ -0,0 +1,116 @@
package service
import (
"context"
"math"
"Engine/internal/database"
"Engine/internal/model"
)
type VarianceService struct {
budgets *database.BudgetRepo
actuals *database.ActualsRepo
}
func NewVarianceService(b *database.BudgetRepo, a *database.ActualsRepo) *VarianceService {
return &VarianceService{budgets: b, actuals: a}
}
type VarianceFilter struct {
FiscalYear int
FiscalPeriod int
DeptCode string
Version model.BudgetVersion
}
func (s *VarianceService) Report(ctx context.Context, f VarianceFilter) (*model.VarianceReport, error) {
budgets, err := s.budgets.List(ctx, f.FiscalYear, f.FiscalPeriod, f.DeptCode, f.Version)
if err != nil {
return nil, err
}
actualsByGL, err := s.actuals.ListByPeriod(ctx, f.FiscalYear, f.FiscalPeriod, f.DeptCode)
if err != nil {
return nil, err
}
report := &model.VarianceReport{
FiscalYear: f.FiscalYear,
FiscalPeriod: f.FiscalPeriod,
Version: f.Version,
Department: f.DeptCode,
Currency: "DKK",
}
for _, b := range budgets {
actual := actualsByGL[b.GLCode]
varAbs := actual - b.Amount
var varPct *float64
if b.Amount != 0 {
v := math.Round((varAbs/b.Amount)*10000) / 100
varPct = &v
}
report.Lines = append(report.Lines, model.VarianceLine{
GLCode: b.GLCode,
GLDescription: b.GLDescription,
GLType: b.GLType,
Budget: b.Amount,
Actual: actual,
VarianceAbs: varAbs,
VariancePct: varPct,
Status: computeStatus(varAbs, b.FavourHigh),
Currency: b.Currency,
})
report.TotalBudget += b.Amount
report.TotalActual += actual
}
report.TotalVariance = report.TotalActual - report.TotalBudget
if report.TotalBudget != 0 {
v := math.Round((report.TotalVariance/report.TotalBudget)*10000) / 100
report.VariancePct = &v
}
return report, nil
}
func (s *VarianceService) Alerts(ctx context.Context, f VarianceFilter, thresholdPct float64) ([]model.AlertThreshold, error) {
report, err := s.Report(ctx, f)
if err != nil {
return nil, err
}
var alerts []model.AlertThreshold
for _, line := range report.Lines {
if line.VariancePct == nil {
continue
}
if math.Abs(*line.VariancePct) >= thresholdPct {
alerts = append(alerts, model.AlertThreshold{
GLCode: line.GLCode,
Description: line.GLDescription,
Budget: line.Budget,
Actual: line.Actual,
VariancePct: *line.VariancePct,
Status: line.Status,
Department: report.Department,
})
}
}
return alerts, nil
}
func computeStatus(varAbs float64, favourHigh bool) model.VarianceStatus {
const epsilon = 0.01
if math.Abs(varAbs) < epsilon {
return model.StatusOnBudget
}
if (favourHigh && varAbs > 0) || (!favourHigh && varAbs < 0) {
return model.StatusFavourable
}
return model.StatusUnfavourable
}

92
main.go Normal file
View File

@@ -0,0 +1,92 @@
package main
import (
"context"
"log/slog"
"net/http"
"os"
"os/signal"
"syscall"
"time"
"Engine/internal/database"
"Engine/internal/handler"
"Engine/internal/service"
)
func main() {
logger := slog.New(slog.NewJSONHandler(os.Stdout, nil))
dbPath := os.Getenv("DB_PATH")
if dbPath == "" {
dbPath = "fpa.db"
}
db, err := database.Connect(context.Background(), dbPath)
if err != nil {
logger.Error("failed to open database", "error", err)
os.Exit(1)
}
defer db.Close()
if err := database.Migrate(db); err != nil {
logger.Error("migration failed", "error", err)
os.Exit(1)
}
budgetRepo := database.NewBudgetRepo(db)
actualsRepo := database.NewActualsRepo(db)
budgetSvc := service.NewBudgetService(budgetRepo)
varianceSvc := service.NewVarianceService(budgetRepo, actualsRepo)
budgetH := handler.NewBudgetHandler(budgetSvc)
actualsH := handler.NewActualsHandler(actualsRepo)
varianceH := handler.NewVarianceHandler(varianceSvc)
mux := http.NewServeMux()
mux.HandleFunc("POST /api/v1/budgets", budgetH.Create)
mux.HandleFunc("PUT /api/v1/budgets/{id}", budgetH.Update)
mux.HandleFunc("DELETE /api/v1/budgets/{id}", budgetH.Delete)
mux.HandleFunc("POST /api/v1/actuals/ingest", actualsH.Ingest)
mux.HandleFunc("GET /api/v1/variance", varianceH.Report)
mux.HandleFunc("GET /api/v1/variance/alerts", varianceH.Alerts)
mux.HandleFunc("GET /api/v1/health", func(w http.ResponseWriter, r *http.Request) {
if err := db.PingContext(r.Context()); err != nil {
http.Error(w, `{"error":"db unhealthy"}`, http.StatusServiceUnavailable)
return
}
w.Header().Set("Content-Type", "application/json")
w.Write([]byte(`{"status":"ok"}`))
})
port := os.Getenv("PORT")
if port == "" {
port = "8080"
}
srv := &http.Server{
Addr: ":" + port,
Handler: mux,
ReadTimeout: 10 * time.Second,
WriteTimeout: 30 * time.Second,
IdleTimeout: 120 * time.Second,
}
quit := make(chan os.Signal, 1)
signal.Notify(quit, syscall.SIGINT, syscall.SIGTERM)
go func() {
logger.Info("server starting", "port", port, "db", dbPath)
if err := srv.ListenAndServe(); err != nil && err != http.ErrServerClosed {
logger.Error("server error", "error", err)
os.Exit(1)
}
}()
<-quit
logger.Info("shutting down gracefully")
ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second)
defer cancel()
srv.Shutdown(ctx)
}