basic online
This commit is contained in:
3
.gitignore
vendored
Normal file
3
.gitignore
vendored
Normal file
@@ -0,0 +1,3 @@
|
||||
*db
|
||||
*db-shm
|
||||
*db-wal
|
||||
183
README.md
183
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)
|
||||
17
go.mod
Normal file
17
go.mod
Normal 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
51
go.sum
Normal 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=
|
||||
83
internal/database/actual-repo.go
Normal file
83
internal/database/actual-repo.go
Normal 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()
|
||||
}
|
||||
116
internal/database/budget-repo.go
Normal file
116
internal/database/budget-repo.go
Normal 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
42
internal/database/db.go
Normal 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
|
||||
}
|
||||
86
internal/database/migrate.go
Normal file
86
internal/database/migrate.go
Normal 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
|
||||
}
|
||||
33
internal/handler/actuals.go
Normal file
33
internal/handler/actuals.go
Normal 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)
|
||||
}
|
||||
68
internal/handler/budget.go
Normal file
68
internal/handler/budget.go
Normal 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)
|
||||
}
|
||||
16
internal/handler/helpers.go
Normal file
16
internal/handler/helpers.go
Normal 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})
|
||||
}
|
||||
64
internal/handler/variance.go
Normal file
64
internal/handler/variance.go
Normal 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
139
internal/model/model.go
Normal 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
|
||||
}
|
||||
28
internal/service/bugdet-service.go
Normal file
28
internal/service/bugdet-service.go
Normal 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)
|
||||
}
|
||||
116
internal/service/variance-service.go
Normal file
116
internal/service/variance-service.go
Normal 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
92
main.go
Normal 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)
|
||||
}
|
||||
Reference in New Issue
Block a user