All checks were successful
Deploy to K8s / deploy (push) Successful in 17s
Show only the last MONTHS_TO_SHOW months (default 5) in the fee table columns so the page fits on screen without horizontal scrolling. Reconciliation still runs over the full month history so balances, credits, and debts are unaffected. Set MONTHS_TO_SHOW=0 to show all months. Implemented in both Python and Go. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
165 lines
4.9 KiB
Go
165 lines
4.9 KiB
Go
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
|
|
}
|
|
|
|
// FlushCache invalidates all cached data via the underlying Sources.
|
|
func (h *Handler) FlushCache() (int, error) { return h.Sources.FlushCache() }
|
|
|
|
// 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) {
|
|
resp, err := h.AssembleAdults(r.Context())
|
|
if err != nil {
|
|
h.writeError(w, r, err)
|
|
return
|
|
}
|
|
writeJSON(w, resp)
|
|
}
|
|
|
|
// AssembleAdults loads all data and builds the adults view model.
|
|
// Shared between the JSON API route and the HTML handler.
|
|
func (h *Handler) AssembleAdults(ctx context.Context) (AdultsResponse, error) {
|
|
members, sortedMonths, txns, exceptions, err := h.loadAll(ctx, true)
|
|
if err != nil {
|
|
return AdultsResponse{}, err
|
|
}
|
|
result := domreconcile.Reconcile(members, sortedMonths, txns, exceptions, time.Now().Year())
|
|
return buildAdultsResponse(members, lastNMonths(sortedMonths, h.Config.MonthsToShow), result, txns, h.Config, time.Now().Format("2006-01")), nil
|
|
}
|
|
|
|
// ServeJuniors handles GET /api/juniors.
|
|
func (h *Handler) ServeJuniors(w http.ResponseWriter, r *http.Request) {
|
|
resp, err := h.AssembleJuniors(r.Context())
|
|
if err != nil {
|
|
h.writeError(w, r, err)
|
|
return
|
|
}
|
|
writeJSON(w, resp)
|
|
}
|
|
|
|
// AssembleJuniors loads all data and builds the juniors view model.
|
|
// Shared between the JSON API route and the HTML handler.
|
|
func (h *Handler) AssembleJuniors(ctx context.Context) (JuniorsResponse, error) {
|
|
members, sortedMonths, txns, exceptions, err := h.loadAll(ctx, false)
|
|
if err != nil {
|
|
return JuniorsResponse{}, err
|
|
}
|
|
result := domreconcile.Reconcile(members, sortedMonths, txns, exceptions, time.Now().Year())
|
|
return buildJuniorsResponse(members, lastNMonths(sortedMonths, h.Config.MonthsToShow), result, txns, h.Config, time.Now().Format("2006-01")), nil
|
|
}
|
|
|
|
// lastNMonths returns the last n elements of months.
|
|
// If n <= 0 or n >= len(months), the full slice is returned unchanged.
|
|
func lastNMonths(months []string, n int) []string {
|
|
if n > 0 && len(months) > n {
|
|
return months[len(months)-n:]
|
|
}
|
|
return months
|
|
}
|
|
|
|
// ServePayments handles GET /api/payments.
|
|
func (h *Handler) ServePayments(w http.ResponseWriter, r *http.Request) {
|
|
resp, err := h.AssemblePayments(r.Context())
|
|
if err != nil {
|
|
h.writeError(w, r, err)
|
|
return
|
|
}
|
|
writeJSON(w, resp)
|
|
}
|
|
|
|
// AssemblePayments loads transactions and builds the payments view model.
|
|
// Shared between the JSON API route and the HTML handler.
|
|
func (h *Handler) AssemblePayments(ctx context.Context) (PaymentsResponse, error) {
|
|
txns, err := h.Sources.LoadTransactions(ctx)
|
|
if err != nil {
|
|
return PaymentsResponse{}, fmt.Errorf("load transactions: %w", err)
|
|
}
|
|
return buildPaymentsResponse(txns, h.allMemberNames(ctx)), nil
|
|
}
|
|
|
|
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)
|
|
}
|