package api import ( "fuj-management/go/internal/domain/czech" "regexp" "sort" "strings" "time" domreconcile "fuj-management/go/internal/domain/reconcile" ) // getMonthLabels builds display labels for sortedMonths, merging month names // (e.g. "Dec+Jan 2026") when mergedMonths maps a source month into this target. // Mirrors scripts/views.py:get_month_labels. func getMonthLabels(sortedMonths []string, mergedMonths map[string]string) map[string]string { labels := make(map[string]string, len(sortedMonths)) for _, m := range sortedMonths { dt, err := time.Parse("2006-01", m) if err != nil { labels[m] = m continue } var mergedIn []string for src, dst := range mergedMonths { if dst == m { mergedIn = append(mergedIn, src) } } sort.Strings(mergedIn) if len(mergedIn) == 0 { labels[m] = dt.Format("Jan 2006") continue } allMonths := append(mergedIn, m) //nolint:gocritic // intentional: mergedIn already owned sort.Strings(allMonths) years := map[int]bool{} for _, x := range allMonths { if d, err2 := time.Parse("2006-01", x); err2 == nil { years[d.Year()] = true } } parts := make([]string, 0, len(allMonths)) if len(years) > 1 { for _, x := range allMonths { if d, err2 := time.Parse("2006-01", x); err2 == nil { parts = append(parts, d.Format("Jan 2006")) } } labels[m] = strings.Join(parts, "+") } else { for _, x := range allMonths { if d, err2 := time.Parse("2006-01", x); err2 == nil { parts = append(parts, d.Format("Jan")) } } labels[m] = strings.Join(parts, "+") + " " + dt.Format("2006") } } return labels } // labelsForMonths returns the display labels for sortedMonths in slice order. func labelsForMonths(sortedMonths []string, labels map[string]string) []string { out := make([]string, len(sortedMonths)) for i, m := range sortedMonths { out[i] = labels[m] } return out } var questionMarkRe = regexp.MustCompile(`\[\?\]\s*`) // canonicalKey returns a normalized form of a person name used for deduplication. // Mirrors scripts/match_payments.py:canonical_member_key. func canonicalKey(name string) string { return strings.Join(strings.Fields(czech.Normalize(name)), " ") } // groupRawPaymentsByPerson groups transactions by the "person" column, // canonicalizing names against memberNames where possible. // Mirrors scripts/views.py:group_payments_by_person (without the // "Unmatched / Unknown" bucket that is payments-view-specific). func groupRawPaymentsByPerson(txns []domreconcile.Transaction, memberNames []string) map[string][]RawTransaction { canonicalByKey := make(map[string]string, len(memberNames)) for _, n := range memberNames { k := canonicalKey(n) if _, exists := canonicalByKey[k]; !exists { canonicalByKey[k] = n } } grouped := make(map[string][]RawTransaction) for _, tx := range txns { person := strings.TrimSpace(tx.Person) if person == "" { continue } for _, p := range strings.Split(person, ",") { p = questionMarkRe.ReplaceAllString(p, "") p = strings.TrimSpace(p) if p == "" { continue } key := p if canonical, ok := canonicalByKey[canonicalKey(p)]; ok { key = canonical } grouped[key] = append(grouped[key], rawTxFromDomain(tx)) } } for k := range grouped { sort.Slice(grouped[k], func(i, j int) bool { return grouped[k][i].Date > grouped[k][j].Date }) } return grouped } // rawTxFromDomain converts a domain Transaction to the wire RawTransaction. func rawTxFromDomain(tx domreconcile.Transaction) RawTransaction { inferredAmount := 0.0 if tx.InferredAmount != nil { inferredAmount = *tx.InferredAmount } return RawTransaction{ Date: tx.Date, Amount: tx.Amount, ManualFix: tx.ManualFix, Person: tx.Person, Purpose: tx.Purpose, InferredAmount: inferredAmount, Sender: tx.Sender, VS: tx.VS, Message: tx.Message, BankID: tx.BankID, SyncID: tx.SyncID, } } // memberTxFromDomain converts a domain TxEntry to a wire MemberTxEntry. func memberTxFromDomain(te domreconcile.TxEntry) MemberTxEntry { return MemberTxEntry{ Amount: te.Amount, Date: te.Date, Sender: te.Sender, Message: te.Message, Confidence: te.Confidence, } } // memberOtherFromDomain converts a domain OtherEntry to a wire MemberOtherEntry. func memberOtherFromDomain(oe domreconcile.OtherEntry) MemberOtherEntry { return MemberOtherEntry{ Amount: oe.Amount, Date: oe.Date, Sender: oe.Sender, Message: oe.Message, Purpose: oe.Purpose, Confidence: oe.Confidence, } } // settledBalance computes the settled balance: sum of (paid − expected) for months // strictly before currentMonth. Months with IsUnknown=true are excluded to match // Python's isinstance(exp, int) guard (skips "?" months). func settledBalance(mr domreconcile.MemberResult, currentMonth string) int { total := 0 for m, md := range mr.Months { if m >= currentMonth || md.IsUnknown { continue } total += int(md.Paid) - md.Expected } return total } // ensureSlice returns s unchanged when non-nil, or an empty (non-nil) slice so // json.Marshal emits [] instead of null. func ensureSlice[T any](s []T) []T { if s == nil { return []T{} } return s }