package api import ( "fmt" "fuj-management/go/internal/config" "fuj-management/go/internal/services/membership" "sort" "time" domreconcile "fuj-management/go/internal/domain/reconcile" ) type monthSums struct{ expected, paid int } // buildAdultsResponse constructs the AdultsResponse wire type from reconcile output. // Mirrors scripts/views.py:build_adults_view_model. func buildAdultsResponse( members []domreconcile.Member, sortedMonths []string, result domreconcile.Result, txns []domreconcile.Transaction, cfg config.Config, currentMonth string, ) AdultsResponse { monthLabels := getMonthLabels(sortedMonths, membership.AdultMergedMonths) // Collect tier-A names, sorted. var adultNames []string allNames := make([]string, 0, len(members)) for _, m := range members { allNames = append(allNames, m.Name) if m.Tier == "A" { adultNames = append(adultNames, m.Name) } } sort.Strings(adultNames) // Per-month aggregate totals (expected and paid integers). monthlyTotals := make(map[string]*monthSums, len(sortedMonths)) for _, m := range sortedMonths { monthlyTotals[m] = &monthSums{} } var results []MemberRow for _, name := range adultNames { mr := result.Members[name] row, unpaidMonths, rawUnpaidMonths := buildAdultMemberRow(name, mr, sortedMonths, monthLabels, currentMonth, monthlyTotals) row.UnpaidPeriods = joinComma(unpaidMonths) row.RawUnpaidPeriods = joinPlus(rawUnpaidMonths) row.Balance = settledBalance(mr, currentMonth) row.PayableAmount = max(0, -row.Balance) results = append(results, row) } // Totals row. totalsCells := make([]TotalCell, len(sortedMonths)) for i, m := range sortedMonths { t := monthlyTotals[m] // *monthSums, never nil (initialised above) status := "empty" if t.expected > 0 || t.paid > 0 { switch { case t.paid == t.expected: status = "ok" case t.paid < t.expected: status = "unpaid" default: status = "surplus" } } totalsCells[i] = TotalCell{ Text: fmt.Sprintf("%d / %d CZK", t.paid, t.expected), Status: status, } } // Credits and debts (settled balance, past months only). var credits, debts []Credit for _, name := range adultNames { bal := settledBalance(result.Members[name], currentMonth) if bal > 0 { credits = append(credits, Credit{Name: name, Amount: bal}) } else if bal < 0 { debts = append(debts, Credit{Name: name, Amount: -bal}) } } sort.Slice(credits, func(i, j int) bool { return credits[i].Name < credits[j].Name }) sort.Slice(debts, func(i, j int) bool { return debts[i].Name < debts[j].Name }) // member_data: full reconcile output for all members (not just adults). memberData := make(map[string]AdultsMemberData, len(result.Members)) for name, mr := range result.Members { months := make(map[string]AdultsMonthData, len(mr.Months)) for m, md := range mr.Months { var exc *ExceptionData if md.Exception != nil { exc = &ExceptionData{Amount: md.Exception.Amount, Note: md.Exception.Note} } txEntries := make([]MemberTxEntry, len(md.Transactions)) for i, te := range md.Transactions { txEntries[i] = memberTxFromDomain(te) } months[m] = AdultsMonthData{ Expected: md.Expected, OriginalExpected: md.OriginalExpected, AttendanceCount: md.AttendanceCount, Exception: exc, Paid: md.Paid, Transactions: txEntries, } } otherTxs := make([]MemberOtherEntry, len(mr.OtherTransactions)) for i, oe := range mr.OtherTransactions { otherTxs[i] = memberOtherFromDomain(oe) } memberData[name] = AdultsMemberData{ Tier: mr.Tier, Months: months, OtherTransactions: otherTxs, TotalBalance: mr.TotalBalance, } } unmatched := make([]RawTransaction, len(result.Unmatched)) for i, tx := range result.Unmatched { unmatched[i] = rawTxFromDomain(tx) } return AdultsResponse{ Months: labelsForMonths(sortedMonths, monthLabels), RawMonths: sortedMonths, Results: ensureSlice(results), Totals: totalsCells, MemberData: memberData, MonthLabels: monthLabels, RawPayments: groupRawPaymentsByPerson(txns, allNames), Credits: ensureSlice(credits), Debts: ensureSlice(debts), Unmatched: unmatched, AttendanceURL: "https://docs.google.com/spreadsheets/d/" + config.AttendanceSheetID + "/edit", PaymentsURL: "https://docs.google.com/spreadsheets/d/" + config.PaymentsSheetID + "/edit", BankAccount: cfg.BankAccount, CurrentMonth: currentMonth, } } func buildAdultMemberRow( name string, mr domreconcile.MemberResult, sortedMonths []string, monthLabels map[string]string, currentMonth string, monthlyTotals map[string]*monthSums, ) (row MemberRow, unpaidMonths, rawUnpaidMonths []string) { row = MemberRow{Name: name} for _, m := range sortedMonths { md, ok := mr.Months[m] if !ok { md = domreconcile.MonthData{} } paid := int(md.Paid) expected := md.Expected if t := monthlyTotals[m]; t != nil { t.expected += expected t.paid += paid } var feeDisplay string var isOverridden bool if md.Exception != nil && md.Exception.Amount != md.OriginalExpected { isOverridden = true if md.AttendanceCount > 0 { feeDisplay = fmt.Sprintf("%d (%d) CZK (%d)", md.Exception.Amount, md.OriginalExpected, md.AttendanceCount) } else { feeDisplay = fmt.Sprintf("%d (%d) CZK", md.Exception.Amount, md.OriginalExpected) } } else { if md.AttendanceCount > 0 { feeDisplay = fmt.Sprintf("%d CZK (%d)", expected, md.AttendanceCount) } else { feeDisplay = fmt.Sprintf("%d CZK", expected) } } status := "empty" cellText := "-" amountToPay := 0 switch { case expected > 0: amountToPay = max(0, expected-paid) switch { case paid >= expected: status = "ok" cellText = fmt.Sprintf("%d/%s", paid, feeDisplay) case paid > 0: status = "partial" cellText = fmt.Sprintf("%d/%s", paid, feeDisplay) if m < currentMonth { unpaidMonths = append(unpaidMonths, monthLabels[m]) rawUnpaidMonths = append(rawUnpaidMonths, rawMonthLabel(m)) } default: status = "unpaid" cellText = fmt.Sprintf("0/%s", feeDisplay) if m < currentMonth { unpaidMonths = append(unpaidMonths, monthLabels[m]) rawUnpaidMonths = append(rawUnpaidMonths, rawMonthLabel(m)) } } case paid > 0: status = "surplus" cellText = fmt.Sprintf("PAID %d", paid) } tooltip := "" if expected > 0 || paid > 0 { tooltip = fmt.Sprintf("Received: %d, Expected: %d", paid, expected) } row.Months = append(row.Months, MonthCell{ Text: cellText, Overridden: isOverridden, Status: status, Amount: amountToPay, Month: monthLabels[m], RawMonth: m, Tooltip: tooltip, }) } return row, unpaidMonths, rawUnpaidMonths } // rawMonthLabel converts "YYYY-MM" to "MM/YYYY" matching Python's strftime("%m/%Y"). func rawMonthLabel(m string) string { dt, err := time.Parse("2006-01", m) if err != nil { return m } return dt.Format("01/2006") } func joinComma(parts []string) string { if len(parts) == 0 { return "" } result := parts[0] for _, p := range parts[1:] { result += ", " + p } return result } func joinPlus(parts []string) string { if len(parts) == 0 { return "" } result := parts[0] for _, p := range parts[1:] { result += "+" + p } return result }