From 7d48e8f6078d1531094542cdbf1fc16de3a9906f Mon Sep 17 00:00:00 2001 From: Jan Novak Date: Thu, 7 May 2026 20:13:38 +0200 Subject: [PATCH 1/2] =?UTF-8?q?feat(go):=20M5.2=20=E2=80=94=20HTTP=20handl?= =?UTF-8?q?ers=20for=20/api/adults,=20/api/juniors,=20/api/payments,=20/ap?= =?UTF-8?q?i/version?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add web/api/handler.go: Handler struct wiring Sources+Config into ServeAdults, ServeJuniors, ServePayments, ServeVersion - Add web/api/build_common.go: getMonthLabels, groupRawPaymentsByPerson, settledBalance, domain-to-wire converters, ensureSlice generic helper - Add web/api/build_adults.go: buildAdultsResponse + buildAdultMemberRow mirroring scripts/views.py:build_adults_view_model - Add web/api/build_juniors.go: buildJuniorsResponse + buildJuniorMemberRow mirroring scripts/views.py:build_juniors_view_model, including "?" sentinel and :NJ,MA breakdown - Add web/api/build_payments.go: buildPaymentsResponse with Unmatched/Unknown bucket - Extend reconcile.FeeData/MonthData with IsUnknown, JuniorAttendance, AdultAttendance - Extend reconcile.Transaction with ManualFix, VS, BankID, SyncID for raw_payments wire field - Export membership.AdultMergedMonths and JuniorMergedMonths - Update sources.go to propagate new FeeData fields and parse extra transaction columns - Wire sources+cfg into web.Run; register /api/* routes via Go 1.22 method+path patterns - Fix pre-existing gofumpt formatting in fio_test.go and fio_table.go Co-Authored-By: Claude Sonnet 4.6 --- go/cmd/fuj/main.go | 10 +- go/internal/domain/reconcile/reconcile.go | 19 +- .../domain/reconcile/reconcile_test.go | 38 +-- go/internal/io/fio/fio_test.go | 4 +- go/internal/services/banksync/fio_table.go | 3 +- go/internal/services/membership/sources.go | 30 +- go/internal/web/api/build_adults.go | 263 +++++++++++++++++ go/internal/web/api/build_common.go | 184 ++++++++++++ go/internal/web/api/build_juniors.go | 276 ++++++++++++++++++ go/internal/web/api/build_payments.go | 44 +++ go/internal/web/api/handler.go | 125 ++++++++ go/internal/web/server.go | 18 +- 12 files changed, 978 insertions(+), 36 deletions(-) create mode 100644 go/internal/web/api/build_adults.go create mode 100644 go/internal/web/api/build_common.go create mode 100644 go/internal/web/api/build_juniors.go create mode 100644 go/internal/web/api/build_payments.go create mode 100644 go/internal/web/api/handler.go diff --git a/go/cmd/fuj/main.go b/go/cmd/fuj/main.go index dfa21a3..78cb40e 100644 --- a/go/cmd/fuj/main.go +++ b/go/cmd/fuj/main.go @@ -73,10 +73,18 @@ func serverCmd(args []string) { cfg.ServerAddr = *addr } + ctx := context.Background() logger := logging.New(cfg.LogLevel) + + sources, err := membership.NewSources(ctx, cfg) + if err != nil { + fmt.Fprintf(os.Stderr, "fuj server: init sources: %v\n", err) + os.Exit(1) + } + build := web.BuildInfo{Version: version, Commit: commit, BuildDate: buildDate} - if err := web.Run(logger, cfg.ServerAddr, build); err != nil { + if err := web.Run(logger, cfg.ServerAddr, build, sources, cfg); err != nil { fmt.Fprintln(os.Stderr, err) os.Exit(1) } diff --git a/go/internal/domain/reconcile/reconcile.go b/go/internal/domain/reconcile/reconcile.go index 4089f87..2db5152 100644 --- a/go/internal/domain/reconcile/reconcile.go +++ b/go/internal/domain/reconcile/reconcile.go @@ -20,10 +20,13 @@ type Exception struct { Note string } -// FeeData holds the expected fee and attendance count for one member in one month. +// FeeData holds the expected fee and attendance data for one member in one month. type FeeData struct { - Expected int - Attendance int + Expected int + IsUnknown bool // true when junior has exactly 1 session (manual review; Python sentinel "?") + Attendance int + JuniorAttendance int // junior-tab sessions; used for the :NJ,MA breakdown in the juniors view + AdultAttendance int // adult-tab sessions for J-tier members; used for the :NJ,MA breakdown } // Member is one row from the attendance sheet. @@ -39,11 +42,15 @@ type Member struct { type Transaction struct { Date string Amount float64 + ManualFix string // "manual fix" column; non-empty disables re-inference Person string // comma-separated canonical names (empty → use inference) Purpose string // comma-separated "YYYY-MM" or "other:…" (empty → use inference) InferredAmount *float64 // nil → fall back to Amount Sender string + VS string // Variabilní symbol (Czech variable payment symbol) Message string + BankID string + SyncID string UserID string } @@ -69,8 +76,11 @@ type OtherEntry struct { // MonthData is the ledger state for one member in one month. type MonthData struct { Expected int + IsUnknown bool // mirrors FeeData.IsUnknown; not overridden by exceptions OriginalExpected int AttendanceCount int + JuniorAttendance int // junior-tab sessions; for :NJ,MA breakdown in juniors view + AdultAttendance int // adult-tab sessions; for :NJ,MA breakdown Exception *Exception Paid float64 Transactions []TxEntry @@ -173,8 +183,11 @@ func Reconcile( ledger[name][m] = MonthData{ Expected: expected, + IsUnknown: fd.IsUnknown, OriginalExpected: originalExpected, AttendanceCount: attendanceCount, + JuniorAttendance: fd.JuniorAttendance, + AdultAttendance: fd.AdultAttendance, Exception: exInfo, Paid: 0, Transactions: []TxEntry{}, diff --git a/go/internal/domain/reconcile/reconcile_test.go b/go/internal/domain/reconcile/reconcile_test.go index edaa2ff..2005a3c 100644 --- a/go/internal/domain/reconcile/reconcile_test.go +++ b/go/internal/domain/reconcile/reconcile_test.go @@ -29,7 +29,7 @@ func tx(person, purpose string, amount float64) Transaction { func TestReconcileExceptionOverride(t *testing.T) { t.Parallel() - members := []Member{{Name: "Alice", Tier: "A", Fees: map[string]FeeData{"2026-01": {750, 4}}}} + members := []Member{{Name: "Alice", Tier: "A", Fees: map[string]FeeData{"2026-01": {Expected: 750, Attendance: 4}}}} exceptions := map[ExceptionKey]Exception{ {Name: "alice", Period: "2026-01"}: {Amount: 400, Note: "Test exception"}, } @@ -54,7 +54,7 @@ func TestReconcileExceptionOverride(t *testing.T) { func TestReconcileFallbackToAttendance(t *testing.T) { t.Parallel() - members := []Member{{Name: "Alice", Tier: "A", Fees: map[string]FeeData{"2026-01": {750, 4}}}} + members := []Member{{Name: "Alice", Tier: "A", Fees: map[string]FeeData{"2026-01": {Expected: 750, Attendance: 4}}}} result := Reconcile(members, []string{"2026-01"}, nil, nil, defaultYear) @@ -68,9 +68,9 @@ func TestReconcileGreedyExactMatch(t *testing.T) { members := []Member{{ Name: "Alice", Tier: "A", Fees: map[string]FeeData{ - "2026-02": {750, 3}, - "2026-03": {350, 3}, - "2026-04": {150, 2}, + "2026-02": {Expected: 750, Attendance: 3}, + "2026-03": {Expected: 350, Attendance: 3}, + "2026-04": {Expected: 150, Attendance: 2}, }, }} sortedMonths := []string{"2026-02", "2026-03", "2026-04"} @@ -93,7 +93,7 @@ func TestReconcileGreedyOverpaymentGoesToCredit(t *testing.T) { t.Parallel() members := []Member{{ Name: "Alice", Tier: "A", - Fees: map[string]FeeData{"2026-01": {750, 3}, "2026-02": {750, 3}}, + Fees: map[string]FeeData{"2026-01": {Expected: 750, Attendance: 3}, "2026-02": {Expected: 750, Attendance: 3}}, }} sortedMonths := []string{"2026-01", "2026-02"} @@ -115,7 +115,7 @@ func TestReconcileProportionalUnderpayment(t *testing.T) { t.Parallel() members := []Member{{ Name: "Alice", Tier: "A", - Fees: map[string]FeeData{"2026-02": {750, 3}, "2026-03": {350, 3}, "2026-04": {750, 3}}, + Fees: map[string]FeeData{"2026-02": {Expected: 750, Attendance: 3}, "2026-03": {Expected: 350, Attendance: 3}, "2026-04": {Expected: 750, Attendance: 3}}, }} sortedMonths := []string{"2026-02", "2026-03", "2026-04"} amount := 1250.0 @@ -146,7 +146,7 @@ func TestReconcileProportionalUnderpayment(t *testing.T) { func TestReconcileSingleMonthUnchanged(t *testing.T) { t.Parallel() - members := []Member{{Name: "Alice", Tier: "A", Fees: map[string]FeeData{"2026-01": {750, 3}}}} + members := []Member{{Name: "Alice", Tier: "A", Fees: map[string]FeeData{"2026-01": {Expected: 750, Attendance: 3}}}} result := Reconcile(members, []string{"2026-01"}, []Transaction{tx("Alice", "2026-01", 750)}, nil, defaultYear) @@ -158,8 +158,8 @@ func TestReconcileSingleMonthUnchanged(t *testing.T) { func TestReconcileTwoMembersMultiMonth(t *testing.T) { t.Parallel() members := []Member{ - {Name: "Alice", Tier: "A", Fees: map[string]FeeData{"2026-01": {750, 3}, "2026-02": {350, 3}}}, - {Name: "Bob", Tier: "A", Fees: map[string]FeeData{"2026-01": {750, 3}, "2026-02": {350, 3}}}, + {Name: "Alice", Tier: "A", Fees: map[string]FeeData{"2026-01": {Expected: 750, Attendance: 3}, "2026-02": {Expected: 350, Attendance: 3}}}, + {Name: "Bob", Tier: "A", Fees: map[string]FeeData{"2026-01": {Expected: 750, Attendance: 3}, "2026-02": {Expected: 350, Attendance: 3}}}, } sortedMonths := []string{"2026-01", "2026-02"} @@ -180,7 +180,7 @@ func TestReconcileEvenSplitFallbackWhenNoExpected(t *testing.T) { t.Parallel() members := []Member{{ Name: "Alice", Tier: "A", - Fees: map[string]FeeData{"2026-01": {0, 0}, "2026-02": {0, 0}}, + Fees: map[string]FeeData{"2026-01": {Expected: 0, Attendance: 0}, "2026-02": {Expected: 0, Attendance: 0}}, }} sortedMonths := []string{"2026-01", "2026-02"} @@ -197,7 +197,7 @@ func TestReconcileEvenSplitFallbackWhenNoExpected(t *testing.T) { func TestReconcileDiacriticsTolerantPersonMatching(t *testing.T) { t.Parallel() - members := []Member{{Name: "Mária Maco", Tier: "A", Fees: map[string]FeeData{"2026-04": {750, 4}}}} + members := []Member{{Name: "Mária Maco", Tier: "A", Fees: map[string]FeeData{"2026-04": {Expected: 750, Attendance: 4}}}} txFn := func(person string) Transaction { return Transaction{ Date: "2026-04-15", Amount: 750, Person: person, Purpose: "2026-04", @@ -232,7 +232,7 @@ func TestReconcileDiacriticsTolerantPersonMatching(t *testing.T) { func TestReconcileTrulyUnknownPersonIsUnmatched(t *testing.T) { t.Parallel() - members := []Member{{Name: "Mária Maco", Tier: "A", Fees: map[string]FeeData{"2026-04": {750, 4}}}} + members := []Member{{Name: "Mária Maco", Tier: "A", Fees: map[string]FeeData{"2026-04": {Expected: 750, Attendance: 4}}}} txs := []Transaction{{ Date: "2026-04-15", Amount: 750, Person: "Někdo Neznámý", Purpose: "2026-04", @@ -252,7 +252,7 @@ func TestReconcileTrulyUnknownPersonIsUnmatched(t *testing.T) { // [Go] Test that [?] markers are stripped from the Person field before lookup. func TestReconcileQuestionMarkMarkerStripped(t *testing.T) { t.Parallel() - members := []Member{{Name: "Alice", Tier: "A", Fees: map[string]FeeData{"2026-01": {750, 3}}}} + members := []Member{{Name: "Alice", Tier: "A", Fees: map[string]FeeData{"2026-01": {Expected: 750, Attendance: 3}}}} txs := []Transaction{{ Date: "2026-01-01", Amount: 750, Person: "[?] Alice", Purpose: "2026-01", @@ -269,7 +269,7 @@ func TestReconcileQuestionMarkMarkerStripped(t *testing.T) { // [Go] Purpose "other:shirt" puts payment in OtherTransactions, not in month ledger. func TestReconcileOtherPurpose(t *testing.T) { t.Parallel() - members := []Member{{Name: "Alice", Tier: "A", Fees: map[string]FeeData{"2026-01": {750, 3}}}} + members := []Member{{Name: "Alice", Tier: "A", Fees: map[string]FeeData{"2026-01": {Expected: 750, Attendance: 3}}}} txs := []Transaction{{ Date: "2026-01-01", Amount: 300, Person: "Alice", Purpose: "other:shirt", @@ -297,7 +297,7 @@ func TestReconcileOtherPurpose(t *testing.T) { func TestReconcileOutOfWindowGoesToCredit(t *testing.T) { t.Parallel() // Window shows only 2026-01. Transaction references 2026-01 (in) and 2026-02 (out). - members := []Member{{Name: "Alice", Tier: "A", Fees: map[string]FeeData{"2026-01": {600, 3}}}} + members := []Member{{Name: "Alice", Tier: "A", Fees: map[string]FeeData{"2026-01": {Expected: 600, Attendance: 3}}}} txs := []Transaction{{ Date: "2026-01-01", Amount: 1200, Person: "Alice", Purpose: "2026-01, 2026-02", @@ -322,7 +322,7 @@ func TestReconcileOutOfWindowGoesToCredit(t *testing.T) { // [Go] No person/purpose → inference fallback resolves sender name and date month. func TestReconcileInferenceFallback(t *testing.T) { t.Parallel() - members := []Member{{Name: "Tomáš Němeček", Tier: "A", Fees: map[string]FeeData{"2026-04": {750, 3}}}} + members := []Member{{Name: "Tomáš Němeček", Tier: "A", Fees: map[string]FeeData{"2026-04": {Expected: 750, Attendance: 3}}}} txs := []Transaction{{ Date: "2026-04-15", Amount: 750, // Person and Purpose are empty → inference path @@ -340,7 +340,7 @@ func TestReconcileInferenceFallback(t *testing.T) { // [Go] Transaction with no match at all ends up in Unmatched; ledger unchanged. func TestReconcileNoMatchGoesToUnmatched(t *testing.T) { t.Parallel() - members := []Member{{Name: "Alice", Tier: "A", Fees: map[string]FeeData{"2026-01": {750, 3}}}} + members := []Member{{Name: "Alice", Tier: "A", Fees: map[string]FeeData{"2026-01": {Expected: 750, Attendance: 3}}}} txs := []Transaction{{ Date: "2026-01-01", Amount: 500, // empty person+purpose and sender name not matching any member @@ -360,7 +360,7 @@ func TestReconcileNoMatchGoesToUnmatched(t *testing.T) { // [Go] Empty transaction list leaves every month at paid=0 and balance=–expected. func TestReconcileNoTransactionsAllUnpaid(t *testing.T) { t.Parallel() - members := []Member{{Name: "Alice", Tier: "A", Fees: map[string]FeeData{"2026-01": {750, 3}}}} + members := []Member{{Name: "Alice", Tier: "A", Fees: map[string]FeeData{"2026-01": {Expected: 750, Attendance: 3}}}} result := Reconcile(members, []string{"2026-01"}, nil, nil, defaultYear) diff --git a/go/internal/io/fio/fio_test.go b/go/internal/io/fio/fio_test.go index 84d0281..89229dc 100644 --- a/go/internal/io/fio/fio_test.go +++ b/go/internal/io/fio/fio_test.go @@ -98,8 +98,8 @@ func TestParseCzechDate(t *testing.T) { {"7.5.2026", "2026-05-07"}, // non-padded — real Fio transparent page format {"3.12.2025", "2025-12-03"}, // non-padded single-digit day, double-digit month {"07.05.26", "2026-05-07"}, // padded 2-digit year — current Fio transparent page format - {"7.5.26", "2026-05-07"}, // non-padded 2-digit year - {"07/05/26", "2026-05-07"}, // slash variant + {"7.5.26", "2026-05-07"}, // non-padded 2-digit year + {"07/05/26", "2026-05-07"}, // slash variant {"", ""}, {"invalid", ""}, } diff --git a/go/internal/services/banksync/fio_table.go b/go/internal/services/banksync/fio_table.go index 0fbb2e6..efb8b55 100644 --- a/go/internal/services/banksync/fio_table.go +++ b/go/internal/services/banksync/fio_table.go @@ -2,10 +2,9 @@ package banksync import ( "fmt" + "fuj-management/go/internal/io/fio" "io" "text/tabwriter" - - "fuj-management/go/internal/io/fio" ) func printFioTable(w io.Writer, txns []fio.Transaction, syncIDs []string, existing map[string]bool) { diff --git a/go/internal/services/membership/sources.go b/go/internal/services/membership/sources.go index 95e5f1a..5ea07e1 100644 --- a/go/internal/services/membership/sources.go +++ b/go/internal/services/membership/sources.go @@ -25,12 +25,12 @@ const ( firstDateCol = 3 ) -// adultMergedMonths mirrors ADULT_MERGED_MONTHS in scripts/attendance.py. +// AdultMergedMonths mirrors ADULT_MERGED_MONTHS in scripts/attendance.py. // Source month → target month (source attendance accumulated into target). -var adultMergedMonths = map[string]string{} +var AdultMergedMonths = map[string]string{} -// juniorMergedMonths mirrors JUNIOR_MERGED_MONTHS in scripts/attendance.py. -var juniorMergedMonths = map[string]string{ +// JuniorMergedMonths mirrors JUNIOR_MERGED_MONTHS in scripts/attendance.py. +var JuniorMergedMonths = map[string]string{ "2025-12": "2026-01", "2025-09": "2025-10", } @@ -195,7 +195,7 @@ func parseAdultRows(rows [][]string) ([]reconcile.Member, []string, error) { return nil, nil, nil } dates := parseDates(rows[0]) - months := groupByMonth(dates, adultMergedMonths) + months := groupByMonth(dates, AdultMergedMonths) sortedMonths := sortedKeys(months) var members []reconcile.Member @@ -243,8 +243,8 @@ func parseJuniorRows(adultRows, juniorRows [][]string) ([]reconcile.Member, []st mainDates := parseDates(adultRows[0]) juniorDates := parseDates(juniorRows[0]) - mainMonths := groupByMonth(mainDates, juniorMergedMonths) - jrMonths := groupByMonth(juniorDates, juniorMergedMonths) + mainMonths := groupByMonth(mainDates, JuniorMergedMonths) + jrMonths := groupByMonth(juniorDates, JuniorMergedMonths) allMonths := make(map[string]bool) for m := range mainMonths { @@ -337,7 +337,13 @@ func parseJuniorRows(adultRows, juniorRows [][]string) ([]reconcile.Member, []st if !exp.Unknown { fee = exp.Value } - feeMap[m] = reconcile.FeeData{Expected: fee, Attendance: total} + feeMap[m] = reconcile.FeeData{ + Expected: fee, + IsUnknown: exp.Unknown, + Attendance: total, + JuniorAttendance: c.junior, + AdultAttendance: c.adult, + } } members = append(members, reconcile.Member{Name: name, Tier: data.tier, Fees: feeMap}) } @@ -365,11 +371,15 @@ func parseTransactionRows(rows [][]any) ([]reconcile.Transaction, error) { } idxDate := idx("date") idxAmount := idx("amount") + idxManualFix := idx("manual fix") idxPerson := idx("person") idxPurpose := idx("purpose") idxInferred := idx("inferred amount") idxSender := idx("sender") + idxVS := idx("vs") idxMessage := idx("message") + idxBankID := idx("bank id") + idxSyncID := idx("sync id") for _, label := range []string{"date", "amount", "person", "purpose"} { if idx(label) == -1 { @@ -403,11 +413,15 @@ func parseTransactionRows(rows [][]any) ([]reconcile.Transaction, error) { txns = append(txns, reconcile.Transaction{ Date: dateStr, Amount: amount, + ManualFix: getVal(row, idxManualFix), Person: getVal(row, idxPerson), Purpose: getVal(row, idxPurpose), InferredAmount: inferredAmount, Sender: getVal(row, idxSender), + VS: getVal(row, idxVS), Message: getVal(row, idxMessage), + BankID: getVal(row, idxBankID), + SyncID: getVal(row, idxSyncID), }) } return txns, nil diff --git a/go/internal/web/api/build_adults.go b/go/internal/web/api/build_adults.go new file mode 100644 index 0000000..f4218e6 --- /dev/null +++ b/go/internal/web/api/build_adults.go @@ -0,0 +1,263 @@ +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 +} diff --git a/go/internal/web/api/build_common.go b/go/internal/web/api/build_common.go new file mode 100644 index 0000000..df12212 --- /dev/null +++ b/go/internal/web/api/build_common.go @@ -0,0 +1,184 @@ +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 +} diff --git a/go/internal/web/api/build_juniors.go b/go/internal/web/api/build_juniors.go new file mode 100644 index 0000000..c8f8a61 --- /dev/null +++ b/go/internal/web/api/build_juniors.go @@ -0,0 +1,276 @@ +package api + +import ( + "fmt" + "fuj-management/go/internal/config" + "fuj-management/go/internal/services/membership" + "sort" + "strconv" + + domreconcile "fuj-management/go/internal/domain/reconcile" +) + +// buildJuniorsResponse constructs the JuniorsResponse wire type from reconcile output. +// Mirrors scripts/views.py:build_juniors_view_model. +func buildJuniorsResponse( + members []domreconcile.Member, + sortedMonths []string, + result domreconcile.Result, + txns []domreconcile.Transaction, + cfg config.Config, + currentMonth string, +) JuniorsResponse { + monthLabels := getMonthLabels(sortedMonths, membership.JuniorMergedMonths) + + allNames := make([]string, 0, len(members)) + juniorNames := make([]string, 0, len(members)) + for _, m := range members { + allNames = append(allNames, m.Name) + juniorNames = append(juniorNames, m.Name) + } + sort.Strings(juniorNames) + + monthlyTotals := make(map[string]*monthSums, len(sortedMonths)) + for _, m := range sortedMonths { + monthlyTotals[m] = &monthSums{} + } + + var results []MemberRow + for _, name := range juniorNames { + mr := result.Members[name] + row, unpaidMonths, rawUnpaidMonths := buildJuniorMemberRow(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, + } + } + + var credits, debts []Credit + for _, name := range juniorNames { + 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 junior members. + memberData := make(map[string]JuniorsMemberData, len(result.Members)) + for name, mr := range result.Members { + months := make(map[string]JuniorsMonthData, 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] = JuniorsMonthData{ + Expected: juniorExpected(md), + OriginalExpected: juniorOriginalExpected(md), + 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] = JuniorsMemberData{ + 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) + } + + juniorURL := "https://docs.google.com/spreadsheets/d/" + config.AttendanceSheetID + + "/edit#gid=" + config.JuniorSheetGID + + return JuniorsResponse{ + 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: juniorURL, + PaymentsURL: "https://docs.google.com/spreadsheets/d/" + config.PaymentsSheetID + "/edit", + BankAccount: cfg.BankAccount, + CurrentMonth: currentMonth, + } +} + +func buildJuniorMemberRow( + 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) + + // Update monthly totals (skip "?" months for expected). + if t := monthlyTotals[m]; t != nil { + if !md.IsUnknown { + t.expected += md.Expected + } + t.paid += paid + } + + // Attendance breakdown string e.g. ":3J,2A". + var breakdown string + jc, ac := md.JuniorAttendance, md.AdultAttendance + switch { + case jc > 0 && ac > 0: + breakdown = fmt.Sprintf(":%dJ,%dA", jc, ac) + case jc > 0: + breakdown = fmt.Sprintf(":%dJ", jc) + case ac > 0: + breakdown = fmt.Sprintf(":%dA", ac) + } + countStr := "" + if md.AttendanceCount > 0 { + countStr = fmt.Sprintf(" (%d%s)", md.AttendanceCount, breakdown) + } + + // Fee display string. + var feeDisplay string + var isOverridden bool + if md.Exception != nil { + overrideAmount := md.Exception.Amount + var origStr string + if md.IsUnknown { + origStr = "?" + isOverridden = true + } else { + origStr = strconv.Itoa(md.OriginalExpected) + isOverridden = overrideAmount != md.OriginalExpected + } + if isOverridden { + feeDisplay = fmt.Sprintf("%d (%s) CZK%s", overrideAmount, origStr, countStr) + } else { + feeDisplay = fmt.Sprintf("%d CZK%s", md.Expected, countStr) + } + } else { + if md.IsUnknown { + feeDisplay = "? CZK" + countStr + } else { + feeDisplay = fmt.Sprintf("%d CZK%s", md.Expected, countStr) + } + } + + status := "empty" + cellText := "-" + amountToPay := 0 + + switch { + case md.IsUnknown: + cellText = "?" + countStr + case md.Expected > 0: + switch { + case paid >= md.Expected: + status = "ok" + cellText = fmt.Sprintf("%d/%s", paid, feeDisplay) + case paid > 0: + status = "partial" + cellText = fmt.Sprintf("%d/%s", paid, feeDisplay) + amountToPay = md.Expected - paid + if m < currentMonth { + unpaidMonths = append(unpaidMonths, monthLabels[m]) + rawUnpaidMonths = append(rawUnpaidMonths, rawMonthLabel(m)) + } + default: + status = "unpaid" + cellText = fmt.Sprintf("0/%s", feeDisplay) + amountToPay = md.Expected + 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 (!md.IsUnknown && md.Expected > 0) || paid > 0 { + tooltip = fmt.Sprintf("Received: %d, Expected: %d", paid, md.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 +} + +// juniorExpected converts domain MonthData to the Expected wire type. +// When an exception exists it always produces a concrete int; otherwise +// the "?" sentinel is used when IsUnknown=true. +func juniorExpected(md domreconcile.MonthData) Expected { + if md.Exception == nil && md.IsUnknown { + return Expected{Unknown: true} + } + return Expected{Value: md.Expected} +} + +// juniorOriginalExpected converts the original (pre-exception) expected fee. +func juniorOriginalExpected(md domreconcile.MonthData) Expected { + if md.IsUnknown { + return Expected{Unknown: true} + } + return Expected{Value: md.OriginalExpected} +} diff --git a/go/internal/web/api/build_payments.go b/go/internal/web/api/build_payments.go new file mode 100644 index 0000000..57d874c --- /dev/null +++ b/go/internal/web/api/build_payments.go @@ -0,0 +1,44 @@ +package api + +import ( + "fuj-management/go/internal/config" + "sort" + "strings" + + domreconcile "fuj-management/go/internal/domain/reconcile" +) + +// buildPaymentsResponse constructs the PaymentsResponse wire type. +// Mirrors scripts/views.py:build_payments_view_model. +func buildPaymentsResponse( + txns []domreconcile.Transaction, + memberNames []string, +) PaymentsResponse { + grouped := groupRawPaymentsByPerson(txns, memberNames) + + // Add unmatched/unknown bucket for transactions with no person set. + const unknownKey = "Unmatched / Unknown" + for _, tx := range txns { + if strings.TrimSpace(tx.Person) == "" { + grouped[unknownKey] = append(grouped[unknownKey], rawTxFromDomain(tx)) + } + } + // Sort the unknown bucket newest-first (others are sorted in groupRawPaymentsByPerson). + if rows, ok := grouped[unknownKey]; ok { + sort.Slice(rows, func(i, j int) bool { return rows[i].Date > rows[j].Date }) + grouped[unknownKey] = rows + } + + sortedPeople := make([]string, 0, len(grouped)) + for p := range grouped { + sortedPeople = append(sortedPeople, p) + } + sort.Strings(sortedPeople) + + return PaymentsResponse{ + GroupedPayments: grouped, + SortedPeople: sortedPeople, + AttendanceURL: "https://docs.google.com/spreadsheets/d/" + config.AttendanceSheetID + "/edit", + PaymentsURL: "https://docs.google.com/spreadsheets/d/" + config.PaymentsSheetID + "/edit", + } +} diff --git a/go/internal/web/api/handler.go b/go/internal/web/api/handler.go new file mode 100644 index 0000000..ccd5f3e --- /dev/null +++ b/go/internal/web/api/handler.go @@ -0,0 +1,125 @@ +package api + +import ( + "context" + "encoding/json" + "fmt" + "fuj-management/go/internal/config" + "fuj-management/go/internal/services/membership" + "log/slog" + "net/http" + "time" + + domreconcile "fuj-management/go/internal/domain/reconcile" +) + +// Handler holds the shared dependencies for all /api/* routes. +type Handler struct { + BuildVersion string + BuildCommit string + BuildDate string + Sources membership.Sources + Config config.Config + Logger *slog.Logger +} + +// ServeVersion handles GET /api/version. +func (h *Handler) ServeVersion(w http.ResponseWriter, r *http.Request) { + writeJSON(w, VersionResponse{ + Tag: h.BuildVersion, + Commit: h.BuildCommit, + BuildDate: h.BuildDate, + }) +} + +// ServeAdults handles GET /api/adults. +func (h *Handler) ServeAdults(w http.ResponseWriter, r *http.Request) { + ctx := r.Context() + members, sortedMonths, txns, exceptions, err := h.loadAll(ctx, true) + if err != nil { + h.writeError(w, r, err) + return + } + result := domreconcile.Reconcile(members, sortedMonths, txns, exceptions, time.Now().Year()) + writeJSON(w, buildAdultsResponse(members, sortedMonths, result, txns, h.Config, time.Now().Format("2006-01"))) +} + +// ServeJuniors handles GET /api/juniors. +func (h *Handler) ServeJuniors(w http.ResponseWriter, r *http.Request) { + ctx := r.Context() + members, sortedMonths, txns, exceptions, err := h.loadAll(ctx, false) + if err != nil { + h.writeError(w, r, err) + return + } + result := domreconcile.Reconcile(members, sortedMonths, txns, exceptions, time.Now().Year()) + writeJSON(w, buildJuniorsResponse(members, sortedMonths, result, txns, h.Config, time.Now().Format("2006-01"))) +} + +// ServePayments handles GET /api/payments. +func (h *Handler) ServePayments(w http.ResponseWriter, r *http.Request) { + ctx := r.Context() + txns, err := h.Sources.LoadTransactions(ctx) + if err != nil { + h.writeError(w, r, fmt.Errorf("load transactions: %w", err)) + return + } + writeJSON(w, buildPaymentsResponse(txns, h.allMemberNames(ctx))) +} + +func (h *Handler) loadAll(ctx context.Context, adults bool) ( + members []domreconcile.Member, + sortedMonths []string, + txns []domreconcile.Transaction, + exceptions map[domreconcile.ExceptionKey]domreconcile.Exception, + err error, +) { + if adults { + members, sortedMonths, err = h.Sources.LoadAdults(ctx) + } else { + members, sortedMonths, err = h.Sources.LoadJuniors(ctx) + } + if err != nil { + err = fmt.Errorf("load members: %w", err) + return + } + txns, err = h.Sources.LoadTransactions(ctx) + if err != nil { + err = fmt.Errorf("load transactions: %w", err) + return + } + exceptions, err = h.Sources.LoadExceptions(ctx) + if err != nil { + err = fmt.Errorf("load exceptions: %w", err) + } + return +} + +func (h *Handler) allMemberNames(ctx context.Context) []string { + var names []string + if adults, _, err := h.Sources.LoadAdults(ctx); err == nil { + for _, m := range adults { + names = append(names, m.Name) + } + } + if juniors, _, err := h.Sources.LoadJuniors(ctx); err == nil { + for _, m := range juniors { + names = append(names, m.Name) + } + } + return names +} + +func (h *Handler) writeError(w http.ResponseWriter, r *http.Request, err error) { + if h.Logger != nil { + h.Logger.Error("api error", "path", r.URL.Path, "err", err) + } + w.Header().Set("Content-Type", "application/json; charset=utf-8") + w.WriteHeader(http.StatusInternalServerError) + _ = json.NewEncoder(w).Encode(map[string]string{"error": err.Error()}) +} + +func writeJSON(w http.ResponseWriter, v any) { + w.Header().Set("Content-Type", "application/json; charset=utf-8") + _ = json.NewEncoder(w).Encode(v) +} diff --git a/go/internal/web/server.go b/go/internal/web/server.go index 9be196b..d62ab7b 100644 --- a/go/internal/web/server.go +++ b/go/internal/web/server.go @@ -2,6 +2,9 @@ package web import ( "fmt" + "fuj-management/go/internal/config" + "fuj-management/go/internal/services/membership" + "fuj-management/go/internal/web/api" "fuj-management/go/internal/web/middleware" "log/slog" "net/http" @@ -15,9 +18,22 @@ type BuildInfo struct { } // Run registers routes and starts the HTTP server on addr. -func Run(logger *slog.Logger, addr string, build BuildInfo) error { +func Run(logger *slog.Logger, addr string, build BuildInfo, sources membership.Sources, cfg config.Config) error { + h := &api.Handler{ + BuildVersion: build.Version, + BuildCommit: build.Commit, + BuildDate: build.BuildDate, + Sources: sources, + Config: cfg, + Logger: logger, + } + mux := http.NewServeMux() mux.HandleFunc("GET /{$}", helloHandler(build)) + mux.HandleFunc("GET /api/version", h.ServeVersion) + mux.HandleFunc("GET /api/adults", h.ServeAdults) + mux.HandleFunc("GET /api/juniors", h.ServeJuniors) + mux.HandleFunc("GET /api/payments", h.ServePayments) logger.Info("starting server", "addr", addr) return http.ListenAndServe(addr, middleware.RequestTimer(logger, mux)) -- 2.49.1 From 2b7eff14c413c8e728bd284b698eb51d04c920ed Mon Sep 17 00:00:00 2001 From: Jan Novak Date: Thu, 7 May 2026 21:02:54 +0200 Subject: [PATCH 2/2] chore: CHANGELOG and progress tracker for M5.2 Co-Authored-By: Claude Sonnet 4.6 --- CHANGELOG.md | 9 +++++++++ .../plans/2026-05-03-2349-go-backend-rewrite-progress.md | 2 +- 2 files changed, 10 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index f9f9469..f3d2fcf 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,14 @@ # Changelog +## 2026-05-07 20:13 CEST — feat(go): M5.2 — HTTP handlers for /api/adults, /api/juniors, /api/payments, /api/version + +- `web/api/handler.go`: `Handler` struct + `ServeAdults`, `ServeJuniors`, `ServePayments`, `ServeVersion` using `membership.Sources`. +- `web/api/build_{adults,juniors,payments,common}.go`: ports of `scripts/views.py` view-model builders; `buildJuniorMemberRow` handles `"?"` sentinel, `:NJ,MA` breakdown, unknown-month skip. +- Extended `reconcile.FeeData`/`MonthData` with `IsUnknown`, `JuniorAttendance`, `AdultAttendance`; `Transaction` with `ManualFix`, `VS`, `BankID`, `SyncID`. +- `sources.go` exports `AdultMergedMonths`/`JuniorMergedMonths`; parses new FeeData and transaction columns. +- `web/server.go` + `cmd/fuj/main.go` wired to register `/api/*` routes. +- PR #17. + ## 2026-05-07 17:37 CEST — feat(go): M5.1 — /api/* wire types + JSON Schemas - New `go/internal/web/api/` package: `AdultsResponse`, `JuniorsResponse`, `PaymentsResponse`, `VersionResponse` with explicit `json:` tags matching Python view-model keys. diff --git a/docs/plans/2026-05-03-2349-go-backend-rewrite-progress.md b/docs/plans/2026-05-03-2349-go-backend-rewrite-progress.md index b8f14e3..883b487 100644 --- a/docs/plans/2026-05-03-2349-go-backend-rewrite-progress.md +++ b/docs/plans/2026-05-03-2349-go-backend-rewrite-progress.md @@ -98,7 +98,7 @@ Goal: every external IO (Sheets, Drive, Fio, file cache) accessed through a narr Goal: byte-equal JSON between Python and Go for every route. This is the parity contract. - [x] **M5.1** Hand-author Go structs for `/api/adults`, `/api/juniors`, `/api/payments`, `/api/version` with explicit `json:` tags matching Python keys; emit JSON Schemas via `github.com/invopop/jsonschema` to `tests/fixtures/api-schema/` — `f253e3f` -- [ ] **M5.2** Implement Go handlers for `/api/*` routes composing `services/*` results into the JSON structs +- [x] **M5.2** Implement Go handlers for `/api/*` routes composing `services/*` results into the JSON structs — `7d48e8f` - [ ] **M5.3** Add Python `/api/X` shadow endpoints in [app.py](app.py): `jsonify(view_model_dict)` — no transformation - [ ] **M5.4** Build `cmd/parity/main.go`: hits both backends' `/api/X`, normalizes allowlist (`render_time.total`, `build_meta`), prints `cmp.Diff`. Add `make parity` target -- 2.49.1