Files
fuj-management/go/internal/services/membership/format_reconcile_test.go
Jan Novak 56aa2303a8 feat(go/M2.11-12): wire fuj fees + fuj reconcile subcommands
Add internal/services/membership package: AttendanceLoader,
TransactionLoader, ExceptionLoader interfaces + NewStubSources stub
(returns ErrIOPending until M4 lands real Sheets loaders).

FeesReport and ReconcileReport orchestrate domain/fees + domain/reconcile
and write fixed-width text reports matching Python calculate_fees.py and
match_payments.py print_report output. 13 unit tests cover all formatter
branches and orchestration wiring via fake loaders.

cmd/fuj/main.go: fees and reconcile subcommands now dispatch; sync/infer
retain the [M4] placeholder.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-06 17:50:31 +02:00

204 lines
6.2 KiB
Go

package membership
// Golden strings verified against scripts/match_payments.py print_report() on 2026-05-06:
//
// PYTHONPATH=scripts:. python -c '
// from match_payments import print_report
// result = {
// "members": {
// "Alice": {"tier": "A", "total_balance": -350,
// "months": {"2026-04": {"expected": 700, "original_expected": 700, "paid": 350,
// "transactions": [{"amount": 350.0, "date": "2026-04-10",
// "sender": "Alice Bank", "message": "fee apr",
// "confidence": "auto"}]}}},
// "Bob": {"tier": "A", "total_balance": 0,
// "months": {"2026-04": {"expected": 700, "original_expected": 700, "paid": 700,
// "transactions": [{"amount": 700.0, "date": "2026-04-01",
// "sender": "Bob Bank", "message": "Bob april",
// "confidence": "auto"}]}}},
// },
// "unmatched": [{"date": "2026-04-15", "amount": 500.0, "sender": "Unknown", "message": "?"}],
// }
// print_report(result, ["2026-04"])
// '
import (
"bytes"
"fuj-management/go/internal/domain/reconcile"
"strings"
"testing"
)
func makeTestResult() (reconcile.Result, []string) {
sortedMonths := []string{"2026-04"}
aliceApr := reconcile.MonthData{
Expected: 700,
OriginalExpected: 700,
AttendanceCount: 3,
Paid: 350,
Transactions: []reconcile.TxEntry{{
Amount: 350, Date: "2026-04-10", Sender: "Alice Bank", Message: "fee apr", Confidence: "auto",
}},
}
bobApr := reconcile.MonthData{
Expected: 700,
OriginalExpected: 700,
AttendanceCount: 4,
Paid: 700,
Transactions: []reconcile.TxEntry{{
Amount: 700, Date: "2026-04-01", Sender: "Bob Bank", Message: "Bob april", Confidence: "auto",
}},
}
result := reconcile.Result{
Members: map[string]reconcile.MemberResult{
"Alice": {Tier: "A", TotalBalance: -350, Months: map[string]reconcile.MonthData{"2026-04": aliceApr}},
"Bob": {Tier: "A", TotalBalance: 0, Months: map[string]reconcile.MonthData{"2026-04": bobApr}},
},
Unmatched: []reconcile.Transaction{{
Date: "2026-04-15", Amount: 500, Sender: "Unknown", Message: "?",
}},
}
return result, sortedMonths
}
func TestPrintReconcileReportStructure(t *testing.T) {
t.Parallel()
result, sortedMonths := makeTestResult()
var buf bytes.Buffer
printReconcileReport(&buf, result, sortedMonths)
got := buf.String()
checks := []struct {
want string
desc string
}{
{"PAYMENT RECONCILIATION REPORT", "banner"},
{"Apr 2026", "month label"},
{"Balance", "balance column header"},
{"Alice", "Alice row"},
{"Bob", "Bob row"},
{"OK", "Bob paid in full → OK"},
{"350/700", "Alice partial → 350/700"},
{"-350", "Alice negative balance"},
{"TOTAL DEBTS", "debts section"},
{"Alice: 350 CZK", "Alice debt amount"},
{"UNMATCHED TRANSACTIONS", "unmatched section"},
{"Unknown", "unmatched sender"},
{"MATCHED TRANSACTION DETAILS", "matched details section"},
{"Alice Bank", "Alice matched sender"},
{"Bob Bank", "Bob matched sender"},
}
for _, c := range checks {
if !strings.Contains(got, c.want) {
t.Errorf("missing %s: want %q in output:\n%s", c.desc, c.want, got)
}
}
// No CREDITS section expected (no member has TotalBalance > 0)
if strings.Contains(got, "TOTAL CREDITS") {
t.Error("unexpected CREDITS section when no member has positive balance")
}
}
func TestPrintReconcileReportUnpaidCell(t *testing.T) {
t.Parallel()
result := reconcile.Result{
Members: map[string]reconcile.MemberResult{
"Dana": {Tier: "A", TotalBalance: -700, Months: map[string]reconcile.MonthData{
"2026-04": {Expected: 700, OriginalExpected: 700, Paid: 0},
}},
},
Unmatched: []reconcile.Transaction{},
}
var buf bytes.Buffer
printReconcileReport(&buf, result, []string{"2026-04"})
got := buf.String()
if !strings.Contains(got, "UNPAID 700") {
t.Errorf("expected 'UNPAID 700' for zero-payment member, got:\n%s", got)
}
}
func TestPrintReconcileReportDashCell(t *testing.T) {
t.Parallel()
result := reconcile.Result{
Members: map[string]reconcile.MemberResult{
"Eve": {Tier: "A", TotalBalance: 0, Months: map[string]reconcile.MonthData{
"2026-04": {Expected: 0, Paid: 0},
}},
},
Unmatched: []reconcile.Transaction{},
}
var buf bytes.Buffer
printReconcileReport(&buf, result, []string{"2026-04"})
got := buf.String()
eveLine := ""
for _, l := range strings.Split(got, "\n") {
if strings.HasPrefix(strings.TrimSpace(l), "Eve") {
eveLine = l
break
}
}
if eveLine == "" {
t.Fatal("no Eve line found")
}
if !strings.Contains(eveLine, "-") {
t.Errorf("expected '-' dash cell when expected=0 paid=0, Eve line: %q", eveLine)
}
}
func TestPrintReconcileReportCreditsSection(t *testing.T) {
t.Parallel()
result := reconcile.Result{
Members: map[string]reconcile.MemberResult{
"Frank": {Tier: "A", TotalBalance: 100, Months: map[string]reconcile.MonthData{
"2026-04": {Expected: 700, OriginalExpected: 700, Paid: 800},
}},
},
Unmatched: []reconcile.Transaction{},
}
var buf bytes.Buffer
printReconcileReport(&buf, result, []string{"2026-04"})
got := buf.String()
if !strings.Contains(got, "TOTAL CREDITS") {
t.Errorf("expected CREDITS section, got:\n%s", got)
}
if !strings.Contains(got, "Frank: 100 CZK") {
t.Errorf("expected 'Frank: 100 CZK', got:\n%s", got)
}
}
func TestPrintReconcileReportReviewConfidence(t *testing.T) {
t.Parallel()
result := reconcile.Result{
Members: map[string]reconcile.MemberResult{
"Grace": {Tier: "A", TotalBalance: 0, Months: map[string]reconcile.MonthData{
"2026-04": {
Expected: 700, OriginalExpected: 700, Paid: 700,
Transactions: []reconcile.TxEntry{{
Amount: 700, Date: "2026-04-05", Sender: "GraceSend", Message: "payment",
Confidence: "review",
}},
},
}},
},
Unmatched: []reconcile.Transaction{},
}
var buf bytes.Buffer
printReconcileReport(&buf, result, []string{"2026-04"})
got := buf.String()
if !strings.Contains(got, "[REVIEW]") {
t.Errorf("expected '[REVIEW]' annotation for review-confidence tx, got:\n%s", got)
}
}