diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..d5bce0f --- /dev/null +++ b/.gitignore @@ -0,0 +1,3 @@ +*db +*db-shm +*db-wal \ No newline at end of file diff --git a/README.md b/README.md index e69de29..10b5dce 100644 --- a/README.md +++ b/README.md @@ -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) \ No newline at end of file diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..42676dc --- /dev/null +++ b/go.mod @@ -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 +) diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..aadd7dd --- /dev/null +++ b/go.sum @@ -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= diff --git a/internal/database/actual-repo.go b/internal/database/actual-repo.go new file mode 100644 index 0000000..21fc749 --- /dev/null +++ b/internal/database/actual-repo.go @@ -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() +} diff --git a/internal/database/budget-repo.go b/internal/database/budget-repo.go new file mode 100644 index 0000000..7c83823 --- /dev/null +++ b/internal/database/budget-repo.go @@ -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 +} diff --git a/internal/database/db.go b/internal/database/db.go new file mode 100644 index 0000000..e8b8322 --- /dev/null +++ b/internal/database/db.go @@ -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 +} diff --git a/internal/database/migrate.go b/internal/database/migrate.go new file mode 100644 index 0000000..a492d41 --- /dev/null +++ b/internal/database/migrate.go @@ -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 +} diff --git a/internal/handler/actuals.go b/internal/handler/actuals.go new file mode 100644 index 0000000..05f7230 --- /dev/null +++ b/internal/handler/actuals.go @@ -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) +} diff --git a/internal/handler/budget.go b/internal/handler/budget.go new file mode 100644 index 0000000..5fd2be6 --- /dev/null +++ b/internal/handler/budget.go @@ -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) +} diff --git a/internal/handler/helpers.go b/internal/handler/helpers.go new file mode 100644 index 0000000..43a60fc --- /dev/null +++ b/internal/handler/helpers.go @@ -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}) +} diff --git a/internal/handler/variance.go b/internal/handler/variance.go new file mode 100644 index 0000000..0d6e0ff --- /dev/null +++ b/internal/handler/variance.go @@ -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, + } +} diff --git a/internal/model/model.go b/internal/model/model.go new file mode 100644 index 0000000..d8576cd --- /dev/null +++ b/internal/model/model.go @@ -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 +} diff --git a/internal/service/bugdet-service.go b/internal/service/bugdet-service.go new file mode 100644 index 0000000..a0e320e --- /dev/null +++ b/internal/service/bugdet-service.go @@ -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) +} diff --git a/internal/service/variance-service.go b/internal/service/variance-service.go new file mode 100644 index 0000000..051adbf --- /dev/null +++ b/internal/service/variance-service.go @@ -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 +} diff --git a/main.go b/main.go new file mode 100644 index 0000000..01c5146 --- /dev/null +++ b/main.go @@ -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) +}