package parser import ( "fmt" "strconv" "strings" ) // ParseAmount converts a decimal string from a statement into signed minor // units. It is deliberately forgiving, because statements are messy: // // "1.234,56" (decimal ",", thousands ".") -> 123456 // "-45.20" -> -4520 // "45,20-" (trailing minus) -> -4520 // "(45.20)" (accounting negative) -> -4520 // "1 234.56 EUR" -> 123456 // // decimal defaults to "."; every other separator is discarded, so thousands is // only a readability hint in account.toml and needs no special handling here. // digits is the account's minor-unit scale; extra fraction digits are rounded // half away from zero. func ParseAmount(s, decimal, thousands string, digits int) (int64, error) { _ = thousands // dropped along with all other non-decimal separators orig := s if decimal == "" { decimal = "." } s = strings.TrimSpace(s) if s == "" { return 0, fmt.Errorf("empty amount") } neg := false if strings.HasPrefix(s, "(") && strings.HasSuffix(s, ")") { neg = true s = strings.TrimSuffix(strings.TrimPrefix(s, "("), ")") } if strings.HasSuffix(s, "-") { neg = true s = strings.TrimSuffix(s, "-") } // Keep only digits and the decimal separator. Everything else -- currency // codes, symbols, thin and non-breaking spaces, thousands separators -- // is noise. A minus anywhere flips the sign; a plus is ignored. var b strings.Builder for _, r := range s { switch { case r >= '0' && r <= '9': b.WriteRune(r) case r == '-' || r == '−': neg = !neg case string(r) == decimal: b.WriteRune('.') } } clean := b.String() if clean == "" || clean == "." { return 0, fmt.Errorf("cannot parse amount %q", orig) } intPart, fracPart, _ := strings.Cut(clean, ".") if strings.Contains(fracPart, ".") { return 0, fmt.Errorf("cannot parse amount %q: multiple decimal separators", orig) } if intPart == "" { intPart = "0" } whole, err := strconv.ParseInt(intPart, 10, 64) if err != nil { return 0, fmt.Errorf("cannot parse amount %q: %w", orig, err) } scale := int64(1) for range digits { scale *= 10 } var frac int64 if digits > 0 { switch { case len(fracPart) > digits: // Round half away from zero on the first dropped digit. kept, err := strconv.ParseInt(fracPart[:digits], 10, 64) if err != nil { return 0, fmt.Errorf("cannot parse amount %q: %w", orig, err) } frac = kept if fracPart[digits] >= '5' { frac++ } case fracPart != "": padded := fracPart + strings.Repeat("0", digits-len(fracPart)) if frac, err = strconv.ParseInt(padded, 10, 64); err != nil { return 0, fmt.Errorf("cannot parse amount %q: %w", orig, err) } } } else if fracPart != "" && fracPart[0] >= '5' { whole++ } v := whole*scale + frac if neg { v = -v } return v, nil }