adding revenue handlers and shell

This commit is contained in:
samantha42
2026-03-24 12:09:19 +01:00
parent 33b100f325
commit 1a9fe3adc7
13 changed files with 688 additions and 37 deletions

View File

@@ -53,7 +53,28 @@ func InitDB(db *sql.DB) {
value REAL NOT NULL,
FOREIGN KEY (report_id) REFERENCES revenue_reports(id),
FOREIGN KEY (currency_id) REFERENCES currencies(id)
);`
);
-- one row per category type per company
-- e.g. Apple has "product" and "location"
CREATE TABLE IF NOT EXISTS category_defs (
id INTEGER PRIMARY KEY AUTOINCREMENT,
company_id INTEGER NOT NULL,
name TEXT NOT NULL,
FOREIGN KEY (company_id) REFERENCES companies(id),
UNIQUE(company_id, name)
);
-- the actual labels belonging to each category
-- e.g. category_def "product" → "iPhone", "Mac", "Services"
CREATE TABLE IF NOT EXISTS category_labels (
id INTEGER PRIMARY KEY AUTOINCREMENT,
category_def_id INTEGER NOT NULL,
label TEXT NOT NULL,
FOREIGN KEY (category_def_id) REFERENCES category_defs(id),
UNIQUE(category_def_id, label)
);
`
if _, err := db.Exec(schema); err != nil {
log.Fatal("Failed to create tables:", err)

View File

@@ -0,0 +1,44 @@
package handlers
import (
"Portifolio/internal/model"
"Portifolio/internal/service"
"database/sql"
"encoding/json"
"net/http"
_ "github.com/mattn/go-sqlite3"
)
func AddCurrencyHandler(db *sql.DB) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
var input model.CurrencyInput
if err := json.NewDecoder(r.Body).Decode(&input); err != nil {
http.Error(w, "invalid json", http.StatusBadRequest)
return
}
id, err := service.InsertCurrency(db, input)
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusCreated)
json.NewEncoder(w).Encode(map[string]any{"status": "created", "id": id})
}
}
func GetCurrenciesHandler(db *sql.DB) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
currencies, err := service.GetAllCurrencies(db)
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(currencies)
}
}

View File

@@ -6,47 +6,104 @@ import (
"database/sql"
"encoding/json"
"net/http"
"runtime"
"time"
_ "github.com/mattn/go-sqlite3"
)
var startTime = time.Now()
func HealthHandler(db *sql.DB) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
dbStatus := "ok"
if err := db.Ping(); err != nil {
dbStatus = "error: " + err.Error()
health := checkHealth(db)
if health["status"] != "ok" {
w.WriteHeader(http.StatusServiceUnavailable)
}
json.NewEncoder(w).Encode(map[string]string{
"status": "ok",
"database": dbStatus,
})
json.NewEncoder(w).Encode(health)
}
}
func checkHealth(db *sql.DB) map[string]any {
// database
dbStatus := "ok"
dbLatency := ""
t := time.Now()
if err := db.Ping(); err != nil {
dbStatus = "error: " + err.Error()
} else {
dbLatency = time.Since(t).String()
}
// db pool stats
stats := db.Stats()
// overall status
status := "ok"
if dbStatus != "ok" {
status = "degraded"
}
var mem runtime.MemStats
runtime.ReadMemStats(&mem)
return map[string]any{
"status": status,
"uptime": time.Since(startTime).String(),
"database": map[string]any{
"status": dbStatus,
"latency": dbLatency,
"open_connections": stats.OpenConnections,
"in_use": stats.InUse,
"idle": stats.Idle,
},
"memory": map[string]any{
"alloc_mb": bToMb(mem.Alloc),
"sys_mb": bToMb(mem.Sys),
"num_gc": mem.NumGC,
},
"go_version": runtime.Version(),
"goroutines": runtime.NumGoroutine(),
}
}
func bToMb(b uint64) float64 {
return float64(b) / 1024 / 1024
}
func AddCompanyHandler(db *sql.DB) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
return
}
var input model.CompanyInput
if err := json.NewDecoder(r.Body).Decode(&input); err != nil {
http.Error(w, "invalid json", http.StatusBadRequest)
return
}
if err := service.AddCompany(input, db); err != nil {
id, err := service.InsertCompany(db, input)
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusCreated)
json.NewEncoder(w).Encode(map[string]string{"status": "created"})
json.NewEncoder(w).Encode(map[string]any{"status": "created", "id": id})
}
}
func GetCompaniesHandler(db *sql.DB) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
companies, err := service.GetAllCompanies(db)
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(companies)
}
}

View File

@@ -0,0 +1,130 @@
package handlers
import (
"Portifolio/internal/model"
"Portifolio/internal/service"
"database/sql"
"encoding/json"
"net/http"
"strconv"
_ "github.com/mattn/go-sqlite3"
)
func AddRevenueReportHandler(db *sql.DB) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
var input struct {
CompanyID int `json:"company_id"`
PeriodType string `json:"period_type"`
Year int `json:"year"`
Index int `json:"index"`
}
if err := json.NewDecoder(r.Body).Decode(&input); err != nil {
http.Error(w, "invalid json", http.StatusBadRequest)
return
}
var period model.Period
switch input.PeriodType {
case "Q":
period = model.QuarterPeriod(input.Year, input.Index)
case "H":
period = model.HalfYearPeriod(input.Year, input.Index)
case "Y":
period = model.FullYearPeriod(input.Year)
default:
http.Error(w, "invalid period_type (Q/H/Y)", http.StatusBadRequest)
return
}
id, err := service.InsertPeriod(db, period)
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusCreated)
json.NewEncoder(w).Encode(map[string]any{
"status": "created",
"period_id": id,
"period": period.String(),
})
}
}
func AddRevenueEntryHandler(db *sql.DB) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
var input struct {
CompanyID int `json:"company_id"`
CurrencyID int `json:"currency_id"`
PeriodType string `json:"period_type"`
Year int `json:"year"`
Index int `json:"index"`
Category string `json:"category"`
Label string `json:"label"`
Value float64 `json:"value"`
}
if err := json.NewDecoder(r.Body).Decode(&input); err != nil {
http.Error(w, "invalid json", http.StatusBadRequest)
return
}
var period model.Period
switch input.PeriodType {
case "Q":
period = model.QuarterPeriod(input.Year, input.Index)
case "H":
period = model.HalfYearPeriod(input.Year, input.Index)
case "Y":
period = model.FullYearPeriod(input.Year)
default:
http.Error(w, "invalid period_type", http.StatusBadRequest)
return
}
if err := service.InsertRevenue(db, input.CompanyID, input.CurrencyID, input.Category, input.Label, input.Value, period); err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusCreated)
json.NewEncoder(w).Encode(map[string]string{"status": "created"})
}
}
func GetRevenueReportHandler(db *sql.DB) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
companyID, _ := strconv.Atoi(r.URL.Query().Get("company_id"))
periodType := r.URL.Query().Get("period_type")
year, _ := strconv.Atoi(r.URL.Query().Get("year"))
idx, _ := strconv.Atoi(r.URL.Query().Get("index"))
entries, err := service.GetRevenue(db, companyID, model.PeriodType(periodType), year, idx)
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(entries)
}
}
func GetRevenueSumHandler(db *sql.DB) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
companyID, _ := strconv.Atoi(r.URL.Query().Get("company_id"))
periodType := r.URL.Query().Get("period_type")
year, _ := strconv.Atoi(r.URL.Query().Get("year"))
rs, err := service.SumRevenue(db, companyID, model.PeriodType(periodType), year)
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(rs)
}
}

View File

@@ -21,14 +21,23 @@ type Period struct {
}
// RevenueCategory is what the revenue is broken down by
type RevenueCategory string
// RevenueCategory is now just a string — categories are dynamic per company
type RevenueCategory = string
// Built-in reserved categories
const (
CategoryProduct RevenueCategory = "product"
CategoryLocation RevenueCategory = "location"
CategoryTotal RevenueCategory = "total"
CategoryTotal RevenueCategory = "total" // always required, never custom
)
// CategoryDef defines a custom category for a specific company
// e.g. Company: Apple, Name: "product", Labels: ["iPhone", "Mac", "Services"]
type CategoryDef struct {
ID int
Company *Company
Name string // e.g. "product", "location", "segment"
Labels []string // populated from category_labels table
}
// Revenue is a single line in a financial report
type Revenue struct {
ID int
@@ -59,11 +68,57 @@ func (r *RevenueReport) Total(category RevenueCategory) float64 {
return sum
}
// Validate checks that product + location sums match the total entry
func (r *RevenueReport) Validate() (bool, float64, float64) {
total := r.Total(CategoryTotal)
products := r.Total(CategoryProduct)
locations := r.Total(CategoryLocation)
// both breakdowns should equal the total
return products == total && locations == total, products, locations
// SumByCategory returns a map of category → sum for all entries
func (r *RevenueReport) SumByCategory() map[RevenueCategory]float64 {
sums := make(map[RevenueCategory]float64)
for _, e := range r.Entries {
sums[e.Category] += e.Value
}
return sums
}
// Validate checks every category independently sums to total
func (r *RevenueReport) Validate() (bool, map[RevenueCategory]float64) {
total := r.Total(CategoryTotal)
sums := r.SumByCategory()
delete(sums, CategoryTotal) // don't compare total to itself
allMatch := true
for cat, sum := range sums {
if sum != total {
allMatch = false
_ = cat // caller can inspect the map to see which failed
}
}
return allMatch, sums
}
// RevSum aggregates revenue across multiple reports or periods
// Use this when you want e.g. FY = Q1+Q2+Q3+Q4
type RevSum struct {
Company *Company
Categories map[RevenueCategory]float64 // category → total across all periods
Labels map[string]float64 // label → total (e.g. "iPhone" across quarters)
Total float64
Periods []Period // which periods were summed
}
func NewRevSum(company *Company, reports []RevenueReport) *RevSum {
rs := &RevSum{
Company: company,
Categories: make(map[RevenueCategory]float64),
Labels: make(map[string]float64),
}
for _, report := range reports {
rs.Periods = append(rs.Periods, report.Period)
for _, e := range report.Entries {
if e.Category == CategoryTotal {
rs.Total += e.Value
continue
}
rs.Categories[e.Category] += e.Value
rs.Labels[e.Label] += e.Value
}
}
return rs
}

View File

@@ -3,6 +3,8 @@ package service
import (
"Portifolio/internal/model"
"database/sql"
_ "github.com/mattn/go-sqlite3"
)
func InsertCompany(db *sql.DB, input model.CompanyInput) (int, error) {

View File

@@ -3,6 +3,8 @@ package service
import (
"Portifolio/internal/model"
"database/sql"
_ "github.com/mattn/go-sqlite3"
)
func InsertCurrency(db *sql.DB, input model.CurrencyInput) (int, error) {

135
internal/service/revenue.go Normal file
View File

@@ -0,0 +1,135 @@
package service
import (
"Portifolio/internal/model"
"database/sql"
"fmt"
"time"
_ "github.com/mattn/go-sqlite3"
)
func InsertRevenue(db *sql.DB, companyID, currencyID int, category, label string, value float64, period model.Period) error {
periodID, err := InsertPeriod(db, period)
if err != nil {
return err
}
reportID, err := getOrCreateReport(db, companyID, periodID)
if err != nil {
return err
}
_, err = db.Exec(
`INSERT INTO revenue_entries (report_id, currency_id, category, label, value) VALUES (?, ?, ?, ?, ?)`,
reportID, currencyID, category, label, value,
)
return err
}
func InsertPeriod(db *sql.DB, p model.Period) (int, error) {
res, err := db.Exec(
`INSERT INTO periods (type, year, idx, start_date, end_date) VALUES (?, ?, ?, ?, ?)
ON CONFLICT(type, year, idx) DO UPDATE SET start_date=excluded.start_date`,
string(p.Type), p.Year, p.Index, p.Start.Format("2006-01-02"), p.End.Format("2006-01-02"),
)
if err != nil {
return 0, err
}
id, err := res.LastInsertId()
return int(id), err
}
func getOrCreateReport(db *sql.DB, companyID, periodID int) (int, error) {
var id int
err := db.QueryRow(
`SELECT id FROM revenue_reports WHERE company_id = ? AND period_id = ?`,
companyID, periodID,
).Scan(&id)
if err == sql.ErrNoRows {
res, err := db.Exec(
`INSERT INTO revenue_reports (company_id, period_id) VALUES (?, ?)`,
companyID, periodID,
)
if err != nil {
return 0, err
}
lid, _ := res.LastInsertId()
return int(lid), nil
}
return id, err
}
func GetRevenue(db *sql.DB, companyID int, periodType model.PeriodType, year, idx int) ([]model.Revenue, error) {
rows, err := db.Query(`
SELECT e.id, e.category, e.label, e.value,
p.type, p.year, p.idx, p.start_date, p.end_date
FROM revenue_entries e
JOIN revenue_reports r ON e.report_id = r.id
JOIN periods p ON r.period_id = p.id
WHERE r.company_id = ? AND p.type = ? AND p.year = ? AND p.idx = ?`,
companyID, string(periodType), year, idx,
)
if err != nil {
return nil, err
}
defer rows.Close()
var entries []model.Revenue
for rows.Next() {
var e model.Revenue
var p model.Period
var start, end string
if err := rows.Scan(&e.ID, &e.Category, &e.Label, &e.Value,
&p.Type, &p.Year, &p.Index, &start, &end); err != nil {
return nil, err
}
p.Start, _ = time.Parse("2006-01-02", start)
p.End, _ = time.Parse("2006-01-02", end)
e.Period = p
entries = append(entries, e)
}
return entries, rows.Err()
}
func SumRevenue(db *sql.DB, companyID int, periodType model.PeriodType, year int) (*model.RevSum, error) {
rows, err := db.Query(`
SELECT e.category, e.label, e.value, p.type, p.year, p.idx
FROM revenue_entries e
JOIN revenue_reports r ON e.report_id = r.id
JOIN periods p ON r.period_id = p.id
WHERE r.company_id = ? AND p.type = ? AND p.year = ?`,
companyID, string(periodType), year,
)
if err != nil {
return nil, err
}
defer rows.Close()
rs := &model.RevSum{
Categories: make(map[model.RevenueCategory]float64),
Labels: make(map[string]float64),
}
seen := map[string]bool{}
for rows.Next() {
var category, label string
var value float64
var pType model.PeriodType
var pYear, pIdx int
if err := rows.Scan(&category, &label, &value, &pType, &pYear, &pIdx); err != nil {
return nil, err
}
key := fmt.Sprintf("%s-%d-%d", pType, pYear, pIdx)
if !seen[key] {
rs.Periods = append(rs.Periods, model.Period{Type: pType, Year: pYear, Index: pIdx})
seen[key] = true
}
if category == model.CategoryTotal {
rs.Total += value
} else {
rs.Categories[category] += value
rs.Labels[label] += value
}
}
return rs, rows.Err()
}

View File

@@ -52,3 +52,27 @@ func AddCompany(scanner *bufio.Scanner, db *sql.DB) {
}
fmt.Printf(" ✓ Company '%s' added.\n", input.Name)
}
func ListCompanies(db *sql.DB) {
companies, err := service.GetAllCompanies(db)
if err != nil {
fmt.Println(" ✗ Error:", err)
return
}
if len(companies) == 0 {
fmt.Println(" No companies found.")
return
}
fmt.Printf("\n %-5s %-20s %-10s %-15s %s\n", "ID", "NAME", "CURRENCY", "PRICE", "SHARES")
fmt.Println(" " + strings.Repeat("-", 60))
for _, c := range companies {
currency := "N/A"
if c.Currency != nil {
currency = c.Currency.Code
}
fmt.Printf(" %-5d %-20s %-10s %-15.2f %d\n",
c.ID, c.Name, currency, c.Price, c.SharesOutstanding)
}
}

153
internal/shell/revenue.go Normal file
View File

@@ -0,0 +1,153 @@
package shell
import (
"Portifolio/internal/model"
"Portifolio/internal/service"
"bufio"
"database/sql"
"fmt"
"strconv"
"strings"
)
func AddRevenue(scanner *bufio.Scanner, db *sql.DB) {
fmt.Print(" Company ID: ")
scanner.Scan()
companyID, err := strconv.Atoi(strings.TrimSpace(scanner.Text()))
if err != nil {
fmt.Println(" ✗ Invalid company ID")
return
}
fmt.Print(" Currency ID: ")
scanner.Scan()
currencyID, err := strconv.Atoi(strings.TrimSpace(scanner.Text()))
if err != nil {
fmt.Println(" ✗ Invalid currency ID")
return
}
fmt.Print(" Period type (Q/H/Y): ")
scanner.Scan()
periodType := strings.ToUpper(strings.TrimSpace(scanner.Text()))
fmt.Print(" Year: ")
scanner.Scan()
year, err := strconv.Atoi(strings.TrimSpace(scanner.Text()))
if err != nil {
fmt.Println(" ✗ Invalid year")
return
}
fmt.Print(" Index (Q: 1-4 | H: 1-2 | Y: 1): ")
scanner.Scan()
idx, err := strconv.Atoi(strings.TrimSpace(scanner.Text()))
if err != nil {
fmt.Println(" ✗ Invalid index")
return
}
fmt.Print(" Category (product/location/total): ")
scanner.Scan()
category := strings.TrimSpace(scanner.Text())
fmt.Print(" Label (e.g. iPhone, Americas): ")
scanner.Scan()
label := strings.TrimSpace(scanner.Text())
fmt.Print(" Value: ")
scanner.Scan()
value, err := strconv.ParseFloat(strings.TrimSpace(scanner.Text()), 64)
if err != nil {
fmt.Println(" ✗ Invalid value")
return
}
var period model.Period
switch periodType {
case "Q":
period = model.QuarterPeriod(year, idx)
case "H":
period = model.HalfYearPeriod(year, idx)
case "Y":
period = model.FullYearPeriod(year)
default:
fmt.Println(" ✗ Invalid period type")
return
}
if err := service.InsertRevenue(db, companyID, currencyID, category, label, value, period); err != nil {
fmt.Println(" ✗ Error:", err)
return
}
fmt.Printf(" ✓ Revenue entry added: %s / %s = %.2f (%s)\n", category, label, value, period.String())
}
func ListRevenue(scanner *bufio.Scanner, db *sql.DB) {
fmt.Print(" Company ID: ")
scanner.Scan()
companyID, err := strconv.Atoi(strings.TrimSpace(scanner.Text()))
if err != nil {
fmt.Println(" ✗ Invalid company ID")
return
}
fmt.Print(" Period type (Q/H/Y): ")
scanner.Scan()
periodType := strings.ToUpper(strings.TrimSpace(scanner.Text()))
fmt.Print(" Year: ")
scanner.Scan()
year, _ := strconv.Atoi(strings.TrimSpace(scanner.Text()))
fmt.Print(" Index: ")
scanner.Scan()
idx, _ := strconv.Atoi(strings.TrimSpace(scanner.Text()))
entries, err := service.GetRevenue(db, companyID, model.PeriodType(periodType), year, idx)
if err != nil {
fmt.Println(" ✗ Error:", err)
return
}
fmt.Printf("\n %-12s %-20s %12s\n", "CATEGORY", "LABEL", "VALUE")
fmt.Println(" " + strings.Repeat("-", 46))
for _, e := range entries {
fmt.Printf(" %-12s %-20s %12.2f\n", e.Category, e.Label, e.Value)
}
}
func SumRevenue(scanner *bufio.Scanner, db *sql.DB) {
fmt.Print(" Company ID: ")
scanner.Scan()
companyID, err := strconv.Atoi(strings.TrimSpace(scanner.Text()))
if err != nil {
fmt.Println(" ✗ Invalid company ID")
return
}
fmt.Print(" Period type to sum (Q/H/Y): ")
scanner.Scan()
periodType := strings.ToUpper(strings.TrimSpace(scanner.Text()))
fmt.Print(" Year: ")
scanner.Scan()
year, _ := strconv.Atoi(strings.TrimSpace(scanner.Text()))
rs, err := service.SumRevenue(db, companyID, model.PeriodType(periodType), year)
if err != nil {
fmt.Println(" ✗ Error:", err)
return
}
fmt.Printf("\n Revenue Sum — FY%d\n", year)
fmt.Printf(" Total: %.2f\n\n", rs.Total)
fmt.Println(" By Category:")
for cat, sum := range rs.Categories {
fmt.Printf(" %-15s %.2f\n", cat, sum)
}
fmt.Println("\n By Label:")
for label, sum := range rs.Labels {
fmt.Printf(" %-20s %.2f\n", label, sum)
}
}