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>
This commit is contained in:
203
go/internal/services/membership/format_reconcile_test.go
Normal file
203
go/internal/services/membership/format_reconcile_test.go
Normal file
@@ -0,0 +1,203 @@
|
||||
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)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user