new rev sys

This commit is contained in:
zipfriis
2026-03-24 19:42:49 +01:00
parent a4c5c4cb1d
commit 7e2c332e60
9 changed files with 263 additions and 395 deletions

View File

@@ -1,6 +1,10 @@
package model
import "time"
import (
"database/sql"
"fmt"
"time"
)
// PeriodType defines the granularity of the revenue entry
type PeriodType string
@@ -13,6 +17,7 @@ const (
// Period holds the actual time range for a revenue entry
type Period struct {
ID int
Type PeriodType
Year int
Index int // Q1=1 Q2=2 Q3=3 Q4=4 | H1=1 H2=2 | FY=1
@@ -20,22 +25,69 @@ type Period struct {
End time.Time
}
// RevenueCategory is what the revenue is broken down by
// RevenueCategory is now just a string — categories are dynamic per company
type RevenueCategory = string
func (p *Period) Insert(db *sql.DB) error {
_, 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, end_date=excluded.end_date`,
string(p.Type), p.Year, p.Index, p.Start.Format("2006-01-02"), p.End.Format("2006-01-02"),
)
if err != nil {
return fmt.Errorf("upsert period: %w", err)
}
// Built-in reserved categories
const (
CategoryTotal RevenueCategory = "total" // always required, never custom
)
var id int
err = db.QueryRow(
`SELECT id FROM periods WHERE type = ? AND year = ? AND idx = ?`,
string(p.Type), p.Year, p.Index,
).Scan(&id)
if err != nil {
return fmt.Errorf("select period: %w", err)
}
p.ID = id
return nil
}
// 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
type RevenueCategory struct {
ID int
CompanyID int
ParentID *int
Name string // e.g. "product", "location", "segment"
}
func (rc *RevenueCategory) Validate() error {
if rc.ID == 0 {
return fmt.Errorf("No ID Set")
} else if rc.Name == "" {
return fmt.Errorf("No Name found")
} else if rc.CompanyID == 0 {
return fmt.Errorf("No Company Set")
}
return nil
}
func (rc *RevenueCategory) Insert(db *sql.DB) error {
if err := rc.Validate(); err != nil {
return fmt.Errorf("failed to insert: %w", err)
}
_, err := db.Exec(
`INSERT INTO category (company_id, parent_id, name) VALUES (?, ?, ?)
ON CONFLICT(company_id, name) DO UPDATE SET parent_id=excluded.parent_id`,
rc.CompanyID, rc.ParentID, rc.Name,
)
if err != nil {
return fmt.Errorf("upsert category: %w", err)
}
err = db.QueryRow(
`SELECT id FROM category WHERE company_id = ? AND name = ?`,
rc.CompanyID, rc.Name,
).Scan(&rc.ID)
if err != nil {
return fmt.Errorf("select category id: %w", err)
}
return nil
}
// Revenue is a single line in a financial report
@@ -43,82 +95,7 @@ type Revenue struct {
ID int
Company *Company
Currency *Currency
Category RevenueCategory
Label string // e.g. "North America", "iPhone", "Total"
Category *RevenueCategory
Period *Period
Value float64
Period Period
}
// RevenueReport groups revenue lines for a company/period
// and verifies that sub-categories sum to total
type RevenueReport struct {
Company *Company
Period Period
Entries []Revenue
}
// Total returns the sum of all entries matching a category
func (r *RevenueReport) Total(category RevenueCategory) float64 {
var sum float64
for _, e := range r.Entries {
if e.Category == category {
sum += e.Value
}
}
return sum
}
// 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
}