// Package ledger does the ledger.
package ledger
import (
"errors"
"fmt"
"sort"
"strconv"
"strings"
"time"
)
// Entry represents an item in the ledger.
type Entry struct {
Date string // "Y-m-d"
Description string
Change int // in cents
}
var lang = map[string][]string{
"en-US": {"Date", "Description", "Change",
",", // thousand separator
"."}, // decimal separator
"nl-NL": {"Datum", "Omschrijving", "Verandering", ".", ","},
}
var curr = map[string]string{
"EUR": "€",
"USD": "$",
}
// Refactoring steps:
// - use copy for creating entriesCopy
// - use switch for locale-based header handling
// - use sort.Slice for sorting entries
// - use fmt.Sprintf for padding
// - define ntry struct instead of anon struct
// - use time.Parse() for checking Date format
// - use time.Format() for formatting Date
// - use fmt.Sprintf for outputting entries
// - use map for locale strings
// - use map for currencies
// - replace change string conversion with centString()
// - remove concurreny - too much overhead, no gain
// - use strings.Builder{} for creating output
// - separate thoucents() from centString()
// - fail soon for currency errors
// - fill in error messages
// - add comments
// FormatLedger pretty-prints the ledger.
func FormatLedger(currency string, locale string, entries []Entry) (string, error) {
if _, ok := curr[currency]; !ok {
return "", errors.New("unknown currency")
}
st, ok := lang[locale]
if !ok {
return "", errors.New("unrecognized locale")
}
entriesCopy := make([]Entry, len(entries))
copy(entriesCopy, entries)
sort.Slice(entriesCopy, func(i, j int) bool {
if entriesCopy[i].Date < entriesCopy[j].Date {
return true
}
if entriesCopy[i].Description < entriesCopy[j].Description {
return true
}
if entriesCopy[i].Change < entriesCopy[j].Change {
return true
}
return false
})
// Header of the table
s := strings.Builder{}
s.WriteString(fmt.Sprintf("%-10s | %-25s | %s\n", st[0], st[1], st[2]))
for _, entry := range entriesCopy {
date, err := time.Parse("2006-01-02", entry.Date)
if err != nil {
return "", errors.New("date parsing failed: %w")
}
var d string
if locale == "nl-NL" {
d = date.Format("02-01-2006")
} else if locale == "en-US" {
d = date.Format("01/02/2006")
}
de := entry.Description
if len(de) > 25 {
de = de[:22] + "..."
} else {
de = fmt.Sprintf("%-25s", de)
}
a, err := centString(currency, locale, entry.Change)
if err != nil {
return "", errors.New("conversion error: %w")
}
s.WriteString(fmt.Sprintf("%-10s | %s | %13s\n", d, de, a))
}
return s.String(), nil
}
// centString returns the pretty-printed amound based on locale, currency and value.
func centString(currency, locale string, amount int) (string, error) {
a := thoucents(amount, lang[locale][3], lang[locale][4])
ret := strings.Builder{}
switch locale {
case "en-US":
if amount < 0 {
ret.WriteString("(" + curr[currency] + a + ")")
} else {
ret.WriteString(curr[currency] + a + " ")
}
case "nl-NL":
ret.WriteString(curr[currency] + " " + a)
if amount < 0 {
ret.WriteRune('-')
} else {
ret.WriteRune(' ')
}
}
return ret.String(), nil
}
// thoucents returns the thousand and decimal separated string of amount.
// t is the thousand, d is the decimal separator
func thoucents(amount int, t, d string) string {
str := strconv.Itoa(amount / 100)
str = strings.TrimLeft(str, "-") // remove '-' sign from negative numbers
ret := strings.Builder{}
// thousand separation
for i, ch := range str {
ret.WriteRune(ch)
if (i+1)%3 == len(str)%3 && i != len(str)-1 {
ret.WriteString(t)
}
}
// cents
cents := amount % 100
if cents < 0 {
cents = -cents
}
ret.WriteString(fmt.Sprintf("%s%02d", d, cents))
return ret.String()
}