diff --git a/cannamanage-service/pom.xml b/cannamanage-service/pom.xml
index bf9691a..8cdfaec 100644
--- a/cannamanage-service/pom.xml
+++ b/cannamanage-service/pom.xml
@@ -90,57 +90,26 @@
stripe-java
28.2.0
+
+
+ com.fasterxml.jackson.core
+ jackson-databind
+
-
-
-
- org.jacoco
- jacoco-maven-plugin
-
-
- prepare-agent
-
- prepare-agent
-
-
-
- report
- test
-
- report
-
-
-
- check
-
- check
-
-
-
-
- CLASS
-
- de.cannamanage.service.ComplianceService
-
-
-
- LINE
- COVEREDRATIO
- 1.00
-
-
- BRANCH
- COVEREDRATIO
- 1.00
-
-
-
-
-
-
-
-
-
-
+
diff --git a/cannamanage-service/src/test/java/de/cannamanage/service/AbstractServiceTest.java b/cannamanage-service/src/test/java/de/cannamanage/service/AbstractServiceTest.java
new file mode 100644
index 0000000..e505346
--- /dev/null
+++ b/cannamanage-service/src/test/java/de/cannamanage/service/AbstractServiceTest.java
@@ -0,0 +1,72 @@
+package de.cannamanage.service;
+
+import org.junit.jupiter.api.extension.ExtendWith;
+import org.mockito.junit.jupiter.MockitoExtension;
+
+import java.math.BigDecimal;
+import java.math.RoundingMode;
+import java.time.Clock;
+import java.time.Instant;
+import java.time.LocalDate;
+import java.time.ZoneId;
+import java.time.ZoneOffset;
+import java.util.UUID;
+
+/**
+ * Common base for Mockito-driven service unit tests in Sprint 11.
+ *
+ *
Provides:
+ *
+ * - Stable UUIDs for tenants, members, users, staff — readable and constant
+ * across runs (no hidden randomness in assertions).
+ * - A fixed {@link Clock} pinned to 2026-06-15T10:00:00Z (Europe/Berlin) so
+ * any time-dependent service can be tested deterministically.
+ * - Money helpers ({@link #cents(long)}, {@link #euros(String)}) that
+ * enforce 2-decimal HALF_UP semantics consistent with the German
+ * financial domain (GoBD, §147 AO).
+ *
+ *
+ * Subclasses should declare their service under test with {@code @InjectMocks}
+ * and collaborators with {@code @Mock}; the {@link MockitoExtension} is
+ * already applied here.
+ */
+@ExtendWith(MockitoExtension.class)
+public abstract class AbstractServiceTest {
+
+ // ---------------------------------------------------------------------
+ // Stable identifiers — readable in assertions, constant across runs.
+ // ---------------------------------------------------------------------
+ protected static final UUID TEST_CLUB_ID = UUID.fromString("aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa");
+ protected static final UUID TEST_MEMBER_ID = UUID.fromString("bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb");
+ protected static final UUID TEST_USER_ID = UUID.fromString("cccccccc-cccc-cccc-cccc-cccccccccccc");
+ protected static final UUID TEST_STAFF_ID = UUID.fromString("dddddddd-dddd-dddd-dddd-dddddddddddd");
+ protected static final UUID TEST_BATCH_ID = UUID.fromString("eeeeeeee-eeee-eeee-eeee-eeeeeeeeeeee");
+ protected static final UUID TEST_STRAIN_ID = UUID.fromString("ffffffff-ffff-ffff-ffff-ffffffffffff");
+ protected static final UUID TEST_PAYMENT_ID = UUID.fromString("11111111-1111-1111-1111-111111111111");
+ protected static final UUID TEST_INVOICE_ID = UUID.fromString("22222222-2222-2222-2222-222222222222");
+
+ // ---------------------------------------------------------------------
+ // Deterministic clock — 2026-06-15T10:00:00Z, Europe/Berlin.
+ // Pinned to a date inside the active sprint so seasonal logic
+ // (quotas, harvest cycles, reporting deadlines) is reproducible.
+ // ---------------------------------------------------------------------
+ protected static final ZoneId TEST_ZONE = ZoneId.of("Europe/Berlin");
+ protected static final Instant TEST_INSTANT = Instant.parse("2026-06-15T10:00:00Z");
+ protected static final LocalDate TEST_TODAY = LocalDate.of(2026, 6, 15);
+ protected static final Clock TEST_CLOCK = Clock.fixed(TEST_INSTANT, TEST_ZONE);
+ protected static final Clock TEST_UTC_CLOCK = Clock.fixed(TEST_INSTANT, ZoneOffset.UTC);
+
+ // ---------------------------------------------------------------------
+ // Money helpers — 2-decimal HALF_UP semantics for euro arithmetic.
+ // ---------------------------------------------------------------------
+
+ /** Build a euro amount from integer cents (e.g. {@code cents(1234)} → 12.34 €). */
+ protected static BigDecimal cents(long cents) {
+ return new BigDecimal(cents).movePointLeft(2).setScale(2, RoundingMode.HALF_UP);
+ }
+
+ /** Build a euro amount from a literal string (e.g. {@code euros("12.34")}). */
+ protected static BigDecimal euros(String amount) {
+ return new BigDecimal(amount).setScale(2, RoundingMode.HALF_UP);
+ }
+}
diff --git a/cannamanage-service/src/test/java/de/cannamanage/service/FinanceServiceTest.java b/cannamanage-service/src/test/java/de/cannamanage/service/FinanceServiceTest.java
new file mode 100644
index 0000000..7f1f186
--- /dev/null
+++ b/cannamanage-service/src/test/java/de/cannamanage/service/FinanceServiceTest.java
@@ -0,0 +1,502 @@
+package de.cannamanage.service;
+
+import de.cannamanage.domain.entity.*;
+import de.cannamanage.domain.enums.*;
+import de.cannamanage.service.repository.*;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.DisplayName;
+import org.junit.jupiter.api.Nested;
+import org.junit.jupiter.api.Test;
+import org.mockito.ArgumentCaptor;
+import org.mockito.InjectMocks;
+import org.mockito.Mock;
+
+import java.time.Instant;
+import java.time.LocalDate;
+import java.util.*;
+
+import static org.assertj.core.api.Assertions.*;
+import static org.mockito.ArgumentMatchers.*;
+import static org.mockito.Mockito.*;
+
+/**
+ * Sprint 11 — Unit tests for {@link FinanceService}.
+ *
+ * Covers fee schedule lifecycle, fee assignment transitions, payment recording
+ * with dual ledger writes, void compensation entries, expense tracking, and
+ * financial summary calculations. Every monetary path is verified for
+ * §147 AO append-only correctness.
+ */
+class FinanceServiceTest extends AbstractServiceTest {
+
+ @Mock private FeeScheduleRepository feeScheduleRepository;
+ @Mock private MemberFeeAssignmentRepository assignmentRepository;
+ @Mock private PaymentRepository paymentRepository;
+ @Mock private LedgerEntryRepository ledgerEntryRepository;
+ @Mock private AuditService auditService;
+ @Mock private NotificationService notificationService;
+ @Mock private MemberRepository memberRepository;
+
+ @InjectMocks
+ private FinanceService financeService;
+
+ private UUID scheduleId;
+ private UUID paymentId;
+
+ @BeforeEach
+ void initIds() {
+ scheduleId = UUID.fromString("99999999-0000-0000-0000-000000000001");
+ paymentId = TEST_PAYMENT_ID;
+ }
+
+ // ============================================================
+ // Fee Schedule CRUD
+ // ============================================================
+
+ @Nested
+ @DisplayName("Fee Schedule lifecycle")
+ class FeeScheduleLifecycle {
+
+ @Test
+ @DisplayName("createFeeSchedule with isDefault=true unsets the previous default")
+ void createFeeSchedule_default_unsetsExistingDefault() {
+ FeeSchedule existing = new FeeSchedule();
+ existing.setId(UUID.fromString("99999999-0000-0000-0000-0000000000ee"));
+ existing.setIsDefault(true);
+ existing.setClubId(TEST_CLUB_ID);
+ when(feeScheduleRepository.findByClubIdAndIsDefaultTrue(TEST_CLUB_ID))
+ .thenReturn(Optional.of(existing));
+ when(feeScheduleRepository.save(any(FeeSchedule.class)))
+ .thenAnswer(inv -> {
+ FeeSchedule s = inv.getArgument(0);
+ if (s.getId() == null) s.setId(scheduleId);
+ return s;
+ });
+
+ FeeSchedule result = financeService.createFeeSchedule(
+ TEST_CLUB_ID, "Standard", 2500, FeeInterval.MONTHLY, true);
+
+ assertThat(existing.getIsDefault()).isFalse();
+ assertThat(result.getIsDefault()).isTrue();
+ assertThat(result.getIsActive()).isTrue();
+ assertThat(result.getAmountCents()).isEqualTo(2500);
+ verify(feeScheduleRepository, times(2)).save(any(FeeSchedule.class));
+ verify(auditService).log(eq(AuditEventType.FEE_SCHEDULE_CREATED),
+ eq("FeeSchedule"), any(), contains("Standard"));
+ }
+
+ @Test
+ @DisplayName("createFeeSchedule with isDefault=false does not touch existing default")
+ void createFeeSchedule_nonDefault_doesNotTouchExisting() {
+ when(feeScheduleRepository.save(any(FeeSchedule.class)))
+ .thenAnswer(inv -> {
+ FeeSchedule s = inv.getArgument(0);
+ if (s.getId() == null) s.setId(scheduleId);
+ return s;
+ });
+
+ financeService.createFeeSchedule(
+ TEST_CLUB_ID, "Premium", 5000, FeeInterval.ANNUAL, false);
+
+ verify(feeScheduleRepository, never()).findByClubIdAndIsDefaultTrue(any());
+ verify(feeScheduleRepository, times(1)).save(any(FeeSchedule.class));
+ }
+
+ @Test
+ @DisplayName("updateFeeSchedule only writes fields that were provided")
+ void updateFeeSchedule_partialUpdate_onlyChangesProvidedFields() {
+ FeeSchedule existing = new FeeSchedule();
+ existing.setId(scheduleId);
+ existing.setName("Old name");
+ existing.setAmountCents(1000);
+ existing.setInterval(FeeInterval.MONTHLY);
+ existing.setIsDefault(false);
+ when(feeScheduleRepository.findById(scheduleId)).thenReturn(Optional.of(existing));
+ when(feeScheduleRepository.save(any(FeeSchedule.class)))
+ .thenAnswer(inv -> inv.getArgument(0));
+
+ FeeSchedule result = financeService.updateFeeSchedule(
+ scheduleId, "New name", null, null, null);
+
+ assertThat(result.getName()).isEqualTo("New name");
+ assertThat(result.getAmountCents()).isEqualTo(1000); // unchanged
+ assertThat(result.getInterval()).isEqualTo(FeeInterval.MONTHLY);
+ assertThat(result.getIsDefault()).isFalse();
+ }
+
+ @Test
+ @DisplayName("updateFeeSchedule throws when schedule is unknown")
+ void updateFeeSchedule_notFound_throws() {
+ when(feeScheduleRepository.findById(scheduleId)).thenReturn(Optional.empty());
+
+ assertThatThrownBy(() -> financeService.updateFeeSchedule(
+ scheduleId, "X", null, null, null))
+ .isInstanceOf(NoSuchElementException.class)
+ .hasMessageContaining(scheduleId.toString());
+ }
+
+ @Test
+ @DisplayName("updateFeeSchedule with isDefault=true unsets a different existing default")
+ void updateFeeSchedule_setDefault_unsetsOther() {
+ FeeSchedule target = new FeeSchedule();
+ target.setId(scheduleId);
+ target.setClubId(TEST_CLUB_ID);
+ target.setIsDefault(false);
+ FeeSchedule other = new FeeSchedule();
+ UUID otherId = UUID.fromString("99999999-0000-0000-0000-000000000002");
+ other.setId(otherId);
+ other.setClubId(TEST_CLUB_ID);
+ other.setIsDefault(true);
+
+ when(feeScheduleRepository.findById(scheduleId)).thenReturn(Optional.of(target));
+ when(feeScheduleRepository.findByClubIdAndIsDefaultTrue(TEST_CLUB_ID))
+ .thenReturn(Optional.of(other));
+ when(feeScheduleRepository.save(any(FeeSchedule.class)))
+ .thenAnswer(inv -> inv.getArgument(0));
+
+ financeService.updateFeeSchedule(scheduleId, null, null, null, true);
+
+ assertThat(other.getIsDefault()).isFalse();
+ assertThat(target.getIsDefault()).isTrue();
+ }
+
+ @Test
+ @DisplayName("deactivateFeeSchedule sets inactive and removes default flag")
+ void deactivateFeeSchedule_setsInactiveAndNonDefault() {
+ FeeSchedule existing = new FeeSchedule();
+ existing.setIsActive(true);
+ existing.setIsDefault(true);
+ when(feeScheduleRepository.findById(scheduleId)).thenReturn(Optional.of(existing));
+
+ financeService.deactivateFeeSchedule(scheduleId);
+
+ assertThat(existing.getIsActive()).isFalse();
+ assertThat(existing.getIsDefault()).isFalse();
+ verify(feeScheduleRepository).save(existing);
+ }
+
+ @Test
+ @DisplayName("deactivateFeeSchedule throws when not found")
+ void deactivateFeeSchedule_notFound_throws() {
+ when(feeScheduleRepository.findById(scheduleId)).thenReturn(Optional.empty());
+
+ assertThatThrownBy(() -> financeService.deactivateFeeSchedule(scheduleId))
+ .isInstanceOf(NoSuchElementException.class);
+ }
+
+ @Test
+ @DisplayName("getActiveFeeSchedules delegates to repository")
+ void getActiveFeeSchedules_delegatesToRepository() {
+ FeeSchedule s = new FeeSchedule();
+ when(feeScheduleRepository.findByClubIdAndIsActiveTrue(TEST_CLUB_ID))
+ .thenReturn(List.of(s));
+
+ List result = financeService.getActiveFeeSchedules(TEST_CLUB_ID);
+
+ assertThat(result).containsExactly(s);
+ }
+ }
+
+ // ============================================================
+ // Fee Assignment
+ // ============================================================
+
+ @Nested
+ @DisplayName("Fee assignment transitions")
+ class FeeAssignmentLifecycle {
+
+ @Test
+ @DisplayName("assignFeeSchedule closes the previous open assignment with validTo = validFrom - 1")
+ void assignFeeSchedule_closesExistingOpenAssignment() {
+ MemberFeeAssignment existing = new MemberFeeAssignment();
+ existing.setMemberId(TEST_MEMBER_ID);
+ existing.setValidFrom(LocalDate.of(2025, 1, 1));
+ when(assignmentRepository.findByMemberIdAndValidToIsNull(TEST_MEMBER_ID))
+ .thenReturn(Optional.of(existing));
+ when(assignmentRepository.save(any(MemberFeeAssignment.class)))
+ .thenAnswer(inv -> inv.getArgument(0));
+
+ LocalDate from = LocalDate.of(2026, 7, 1);
+ MemberFeeAssignment result = financeService.assignFeeSchedule(
+ TEST_MEMBER_ID, TEST_CLUB_ID, scheduleId, from);
+
+ assertThat(existing.getValidTo()).isEqualTo(LocalDate.of(2026, 6, 30));
+ assertThat(result.getValidFrom()).isEqualTo(from);
+ assertThat(result.getMemberId()).isEqualTo(TEST_MEMBER_ID);
+ assertThat(result.getFeeScheduleId()).isEqualTo(scheduleId);
+ verify(assignmentRepository, times(2)).save(any(MemberFeeAssignment.class));
+ }
+
+ @Test
+ @DisplayName("assignFeeSchedule simply saves when no open assignment exists")
+ void assignFeeSchedule_noExistingAssignment_simplySaves() {
+ when(assignmentRepository.findByMemberIdAndValidToIsNull(TEST_MEMBER_ID))
+ .thenReturn(Optional.empty());
+ when(assignmentRepository.save(any(MemberFeeAssignment.class)))
+ .thenAnswer(inv -> inv.getArgument(0));
+
+ financeService.assignFeeSchedule(
+ TEST_MEMBER_ID, TEST_CLUB_ID, scheduleId, TEST_TODAY);
+
+ verify(assignmentRepository, times(1)).save(any(MemberFeeAssignment.class));
+ }
+ }
+
+ // ============================================================
+ // Payment recording + dual write to ledger + notification
+ // ============================================================
+
+ @Nested
+ @DisplayName("Payment recording / voiding")
+ class PaymentLifecycle {
+
+ @Test
+ @DisplayName("recordPayment creates Payment + INCOME LedgerEntry + audit log + notification")
+ void recordPayment_createsPaymentLedgerEntryAndNotifies() {
+ when(paymentRepository.save(any(Payment.class))).thenAnswer(inv -> {
+ Payment p = inv.getArgument(0);
+ if (p.getId() == null) p.setId(paymentId);
+ return p;
+ });
+ when(ledgerEntryRepository.save(any(LedgerEntry.class)))
+ .thenAnswer(inv -> inv.getArgument(0));
+ Member member = new Member();
+ member.setUserId(TEST_USER_ID);
+ when(memberRepository.findById(TEST_MEMBER_ID)).thenReturn(Optional.of(member));
+
+ Payment result = financeService.recordPayment(
+ TEST_CLUB_ID, TEST_MEMBER_ID, 5000, PaymentMethod.BANK_TRANSFER,
+ LocalDate.of(2026, 6, 1), LocalDate.of(2026, 6, 30),
+ "REF-001", "Mai-Beitrag", TEST_STAFF_ID);
+
+ assertThat(result.getAmountCents()).isEqualTo(5000);
+ assertThat(result.getStatus()).isEqualTo(PaymentStatus.PAID);
+ assertThat(result.getRecordedBy()).isEqualTo(TEST_STAFF_ID);
+ assertThat(result.getPaidAt()).isNotNull();
+
+ ArgumentCaptor ledgerCaptor = ArgumentCaptor.forClass(LedgerEntry.class);
+ verify(ledgerEntryRepository).save(ledgerCaptor.capture());
+ LedgerEntry entry = ledgerCaptor.getValue();
+ assertThat(entry.getTransactionType()).isEqualTo(TransactionType.INCOME);
+ assertThat(entry.getCategory()).isEqualTo("MEMBERSHIP_FEE");
+ assertThat(entry.getAmountCents()).isEqualTo(5000);
+ assertThat(entry.getDescription()).contains("2026-06-01", "2026-06-30");
+
+ verify(auditService).log(eq(AuditEventType.PAYMENT_RECORDED),
+ eq("Payment"), any(), contains("5000"));
+ verify(notificationService).sendNotification(
+ eq(TEST_USER_ID),
+ eq(NotificationType.PAYMENT_RECEIVED),
+ eq("Zahlung erfasst"),
+ contains("50,00"),
+ eq("/portal/finance"));
+ }
+
+ @Test
+ @DisplayName("recordPayment skips notification when member has no linked user account")
+ void recordPayment_memberWithoutUser_skipsNotification() {
+ when(paymentRepository.save(any(Payment.class))).thenAnswer(inv -> {
+ Payment p = inv.getArgument(0);
+ if (p.getId() == null) p.setId(paymentId);
+ return p;
+ });
+ when(ledgerEntryRepository.save(any(LedgerEntry.class)))
+ .thenAnswer(inv -> inv.getArgument(0));
+ Member member = new Member();
+ member.setUserId(null); // unlinked member
+ when(memberRepository.findById(TEST_MEMBER_ID)).thenReturn(Optional.of(member));
+
+ financeService.recordPayment(
+ TEST_CLUB_ID, TEST_MEMBER_ID, 2500, PaymentMethod.CASH,
+ TEST_TODAY, TEST_TODAY, "R", "n", TEST_STAFF_ID);
+
+ verify(ledgerEntryRepository).save(any(LedgerEntry.class));
+ verifyNoInteractions(notificationService);
+ }
+
+ @Test
+ @DisplayName("voidPayment creates a compensating EXPENSE entry and marks payment VOIDED")
+ void voidPayment_createsCompensatingEntry() {
+ Payment original = new Payment();
+ original.setClubId(TEST_CLUB_ID);
+ original.setAmountCents(5000);
+ original.setStatus(PaymentStatus.PAID);
+ original.setPeriodFrom(LocalDate.of(2026, 6, 1));
+ original.setPeriodTo(LocalDate.of(2026, 6, 30));
+ original.setId(paymentId);
+ when(paymentRepository.findById(paymentId)).thenReturn(Optional.of(original));
+ when(paymentRepository.save(any(Payment.class))).thenAnswer(inv -> inv.getArgument(0));
+ when(ledgerEntryRepository.save(any(LedgerEntry.class)))
+ .thenAnswer(inv -> inv.getArgument(0));
+
+ Payment result = financeService.voidPayment(paymentId, TEST_STAFF_ID, "Doppelbuchung");
+
+ assertThat(result.getStatus()).isEqualTo(PaymentStatus.VOIDED);
+ assertThat(result.getVoidedBy()).isEqualTo(TEST_STAFF_ID);
+ assertThat(result.getVoidReason()).isEqualTo("Doppelbuchung");
+ assertThat(result.getVoidedAt()).isNotNull();
+
+ ArgumentCaptor ledgerCaptor = ArgumentCaptor.forClass(LedgerEntry.class);
+ verify(ledgerEntryRepository).save(ledgerCaptor.capture());
+ LedgerEntry comp = ledgerCaptor.getValue();
+ assertThat(comp.getTransactionType()).isEqualTo(TransactionType.EXPENSE);
+ assertThat(comp.getCategory()).isEqualTo("MEMBERSHIP_FEE_VOID");
+ assertThat(comp.getAmountCents()).isEqualTo(5000);
+ assertThat(comp.getDescription()).contains("Storno", "Doppelbuchung");
+
+ verify(auditService).log(eq(AuditEventType.PAYMENT_VOIDED),
+ eq("Payment"), eq(paymentId.toString()), contains("Doppelbuchung"));
+ }
+
+ @Test
+ @DisplayName("voidPayment throws IllegalStateException when payment is already voided")
+ void voidPayment_alreadyVoided_throws() {
+ Payment original = new Payment();
+ original.setStatus(PaymentStatus.VOIDED);
+ when(paymentRepository.findById(paymentId)).thenReturn(Optional.of(original));
+
+ assertThatThrownBy(() -> financeService.voidPayment(paymentId, TEST_STAFF_ID, "x"))
+ .isInstanceOf(IllegalStateException.class)
+ .hasMessageContaining(paymentId.toString());
+
+ verify(ledgerEntryRepository, never()).save(any());
+ verify(auditService, never()).log(eq(AuditEventType.PAYMENT_VOIDED), anyString(), anyString(), anyString());
+ }
+
+ @Test
+ @DisplayName("voidPayment throws NoSuchElementException when payment is missing")
+ void voidPayment_notFound_throws() {
+ when(paymentRepository.findById(paymentId)).thenReturn(Optional.empty());
+
+ assertThatThrownBy(() -> financeService.voidPayment(paymentId, TEST_STAFF_ID, "x"))
+ .isInstanceOf(NoSuchElementException.class);
+ }
+ }
+
+ // ============================================================
+ // Expenses + Summary + Outstanding
+ // ============================================================
+
+ @Nested
+ @DisplayName("Expenses, summaries, outstanding members")
+ class ExpensesAndReports {
+
+ @Test
+ @DisplayName("recordExpense persists EXPENSE LedgerEntry with full audit trail")
+ void recordExpense_createsExpenseLedgerEntry() {
+ when(ledgerEntryRepository.save(any(LedgerEntry.class)))
+ .thenAnswer(inv -> {
+ LedgerEntry e = inv.getArgument(0);
+ if (e.getId() == null) e.setId(UUID.fromString("99999999-0000-0000-0000-0000000000aa"));
+ return e;
+ });
+
+ LedgerEntry result = financeService.recordExpense(
+ TEST_CLUB_ID, ExpenseCategory.RENT, 80000,
+ "Miete Juni", "INV-2026-06", TEST_STAFF_ID, LocalDate.of(2026, 6, 1));
+
+ assertThat(result.getTransactionType()).isEqualTo(TransactionType.EXPENSE);
+ assertThat(result.getCategory()).isEqualTo("RENT");
+ assertThat(result.getAmountCents()).isEqualTo(80000);
+ assertThat(result.getDescription()).isEqualTo("Miete Juni");
+ assertThat(result.getReference()).isEqualTo("INV-2026-06");
+
+ verify(auditService).log(eq(AuditEventType.EXPENSE_RECORDED),
+ eq("LedgerEntry"), any(), contains("RENT"));
+ }
+
+ @Test
+ @DisplayName("getFinancialSummary computes net = income - expenses correctly")
+ void getFinancialSummary_calculatesNetCorrectly() {
+ LocalDate from = LocalDate.of(2026, 1, 1);
+ LocalDate to = LocalDate.of(2026, 12, 31);
+ when(ledgerEntryRepository.sumIncomeByClubAndDateRange(TEST_CLUB_ID, from, to)).thenReturn(15000L);
+ when(ledgerEntryRepository.sumExpensesByClubAndDateRange(TEST_CLUB_ID, from, to)).thenReturn(8500L);
+ when(ledgerEntryRepository.calculateBalance(TEST_CLUB_ID, to)).thenReturn(6500L);
+
+ Map summary = financeService.getFinancialSummary(TEST_CLUB_ID, from, to);
+
+ assertThat(summary)
+ .containsEntry("totalIncomeCents", 15000L)
+ .containsEntry("totalExpensesCents", 8500L)
+ .containsEntry("netCents", 6500L)
+ .containsEntry("balanceCents", 6500L)
+ .containsEntry("periodFrom", "2026-01-01")
+ .containsEntry("periodTo", "2026-12-31");
+ }
+
+ @Test
+ @DisplayName("getOutstandingMembers returns only members with zero payments")
+ void getOutstandingMembers_returnsOnlyZeroBalanceMembers() {
+ UUID memberWithPayments = UUID.fromString("aaaaaaaa-0000-0000-0000-0000000000aa");
+ UUID memberWithoutPayments = TEST_MEMBER_ID;
+
+ MemberFeeAssignment a1 = new MemberFeeAssignment();
+ a1.setMemberId(memberWithPayments);
+ a1.setFeeScheduleId(scheduleId);
+ a1.setValidFrom(LocalDate.of(2026, 1, 1));
+
+ MemberFeeAssignment a2 = new MemberFeeAssignment();
+ a2.setMemberId(memberWithoutPayments);
+ a2.setFeeScheduleId(scheduleId);
+ a2.setValidFrom(LocalDate.of(2026, 1, 1));
+
+ when(assignmentRepository.findByClubIdAndValidToIsNull(TEST_CLUB_ID))
+ .thenReturn(List.of(a1, a2));
+ when(paymentRepository.sumPaidByMember(TEST_CLUB_ID, memberWithPayments))
+ .thenReturn(5000L);
+ when(paymentRepository.sumPaidByMember(TEST_CLUB_ID, memberWithoutPayments))
+ .thenReturn(0L);
+
+ List