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>
204 lines
6.2 KiB
Go
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)
|
|
}
|
|
}
|