feat(sprint8): Phase 1 — Treasury backend (fee schedules, payments, Kassenbuch)
- Extend StaffPermission with MANAGE_FINANCES, VIEW_FINANCES - Extend AuditEventType with PAYMENT_RECORDED, PAYMENT_VOIDED, FEE_SCHEDULE_CREATED, FEE_SCHEDULE_UPDATED, EXPENSE_RECORDED - Extend NotificationType with PAYMENT_REMINDER, PAYMENT_OVERDUE, PAYMENT_RECEIVED - New enums: PaymentMethod, PaymentStatus, TransactionType, FeeInterval, ExpenseCategory - V18 Flyway migration: fee_schedules, member_fee_assignments, payments, ledger_entries tables - Entities: FeeSchedule, MemberFeeAssignment, Payment, LedgerEntry - Repositories with financial queries (balance, outstanding, period sums) - FinanceService: fee schedule CRUD, record/void payments, expenses, Kassenbuch, summaries - FinanceController: 14 admin endpoints + 2 portal self-service endpoints - LedgerEntry is append-only per §147 AO (no update/delete) - All amounts in cents (Integer) to avoid floating-point precision issues
This commit is contained in:
@@ -0,0 +1,251 @@
|
||||
package de.cannamanage.api.controller;
|
||||
|
||||
import de.cannamanage.api.dto.finance.*;
|
||||
import de.cannamanage.api.security.StaffPermissionChecker;
|
||||
import de.cannamanage.domain.entity.*;
|
||||
import de.cannamanage.domain.enums.PaymentStatus;
|
||||
import de.cannamanage.domain.enums.StaffPermission;
|
||||
import de.cannamanage.service.FinanceService;
|
||||
import de.cannamanage.service.repository.MemberRepository;
|
||||
import de.cannamanage.service.repository.PaymentRepository;
|
||||
import jakarta.validation.Valid;
|
||||
import org.springframework.data.domain.Page;
|
||||
import org.springframework.data.domain.PageRequest;
|
||||
import org.springframework.data.domain.Sort;
|
||||
import org.springframework.http.HttpStatus;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
import org.springframework.security.core.annotation.AuthenticationPrincipal;
|
||||
import org.springframework.security.core.userdetails.UserDetails;
|
||||
import org.springframework.web.bind.annotation.*;
|
||||
|
||||
import java.time.LocalDate;
|
||||
import java.util.*;
|
||||
|
||||
/**
|
||||
* REST controller for club treasury management.
|
||||
* Admin endpoints require MANAGE_FINANCES or VIEW_FINANCES permission.
|
||||
* Portal endpoints allow members to view their own payment history and balance.
|
||||
*/
|
||||
@RestController
|
||||
@RequestMapping("/api/v1")
|
||||
public class FinanceController {
|
||||
|
||||
private final FinanceService financeService;
|
||||
private final StaffPermissionChecker permissionChecker;
|
||||
private final MemberRepository memberRepository;
|
||||
|
||||
public FinanceController(FinanceService financeService,
|
||||
StaffPermissionChecker permissionChecker,
|
||||
MemberRepository memberRepository) {
|
||||
this.financeService = financeService;
|
||||
this.permissionChecker = permissionChecker;
|
||||
this.memberRepository = memberRepository;
|
||||
}
|
||||
|
||||
// === Fee Schedules ===
|
||||
|
||||
@PostMapping("/finance/fee-schedules")
|
||||
public ResponseEntity<FeeSchedule> createFeeSchedule(@Valid @RequestBody CreateFeeScheduleRequest request,
|
||||
@AuthenticationPrincipal UserDetails principal) {
|
||||
permissionChecker.requirePermission(principal, StaffPermission.MANAGE_FINANCES);
|
||||
UUID clubId = TenantContext.getCurrentTenant();
|
||||
|
||||
FeeSchedule schedule = financeService.createFeeSchedule(
|
||||
clubId, request.name(), request.amountCents(), request.interval(),
|
||||
request.isDefault() != null && request.isDefault()
|
||||
);
|
||||
|
||||
return ResponseEntity.status(HttpStatus.CREATED).body(schedule);
|
||||
}
|
||||
|
||||
@GetMapping("/finance/fee-schedules")
|
||||
public ResponseEntity<List<FeeSchedule>> listFeeSchedules(@AuthenticationPrincipal UserDetails principal) {
|
||||
permissionChecker.requirePermission(principal, StaffPermission.VIEW_FINANCES);
|
||||
UUID clubId = TenantContext.getCurrentTenant();
|
||||
return ResponseEntity.ok(financeService.getActiveFeeSchedules(clubId));
|
||||
}
|
||||
|
||||
@PutMapping("/finance/fee-schedules/{id}")
|
||||
public ResponseEntity<FeeSchedule> updateFeeSchedule(@PathVariable UUID id,
|
||||
@RequestBody UpdateFeeScheduleRequest request,
|
||||
@AuthenticationPrincipal UserDetails principal) {
|
||||
permissionChecker.requirePermission(principal, StaffPermission.MANAGE_FINANCES);
|
||||
FeeSchedule updated = financeService.updateFeeSchedule(
|
||||
id, request.name(), request.amountCents(), request.interval(), request.isDefault()
|
||||
);
|
||||
return ResponseEntity.ok(updated);
|
||||
}
|
||||
|
||||
@PostMapping("/finance/fee-schedules/{id}/deactivate")
|
||||
public ResponseEntity<Void> deactivateFeeSchedule(@PathVariable UUID id,
|
||||
@AuthenticationPrincipal UserDetails principal) {
|
||||
permissionChecker.requirePermission(principal, StaffPermission.MANAGE_FINANCES);
|
||||
financeService.deactivateFeeSchedule(id);
|
||||
return ResponseEntity.noContent().build();
|
||||
}
|
||||
|
||||
// === Fee Assignment ===
|
||||
|
||||
@PostMapping("/finance/members/{memberId}/assign-fee")
|
||||
public ResponseEntity<MemberFeeAssignment> assignFeeSchedule(@PathVariable UUID memberId,
|
||||
@Valid @RequestBody AssignFeeRequest request,
|
||||
@AuthenticationPrincipal UserDetails principal) {
|
||||
permissionChecker.requirePermission(principal, StaffPermission.MANAGE_FINANCES);
|
||||
UUID clubId = TenantContext.getCurrentTenant();
|
||||
|
||||
MemberFeeAssignment assignment = financeService.assignFeeSchedule(
|
||||
memberId, clubId, request.feeScheduleId(), request.validFrom()
|
||||
);
|
||||
|
||||
return ResponseEntity.status(HttpStatus.CREATED).body(assignment);
|
||||
}
|
||||
|
||||
// === Payments ===
|
||||
|
||||
@PostMapping("/finance/payments")
|
||||
public ResponseEntity<Payment> recordPayment(@Valid @RequestBody RecordPaymentRequest request,
|
||||
@AuthenticationPrincipal UserDetails principal) {
|
||||
permissionChecker.requirePermission(principal, StaffPermission.MANAGE_FINANCES);
|
||||
UUID clubId = TenantContext.getCurrentTenant();
|
||||
UUID userId = UUID.fromString(principal.getUsername());
|
||||
|
||||
Payment payment = financeService.recordPayment(
|
||||
clubId, request.memberId(), request.amountCents(), request.paymentMethod(),
|
||||
request.periodFrom(), request.periodTo(), request.reference(), request.notes(), userId
|
||||
);
|
||||
|
||||
return ResponseEntity.status(HttpStatus.CREATED).body(payment);
|
||||
}
|
||||
|
||||
@GetMapping("/finance/payments")
|
||||
public ResponseEntity<Page<Payment>> listPayments(
|
||||
@RequestParam(required = false) UUID memberId,
|
||||
@RequestParam(required = false) PaymentStatus status,
|
||||
@RequestParam(defaultValue = "0") int page,
|
||||
@RequestParam(defaultValue = "20") int size,
|
||||
@AuthenticationPrincipal UserDetails principal) {
|
||||
permissionChecker.requirePermission(principal, StaffPermission.VIEW_FINANCES);
|
||||
UUID clubId = TenantContext.getCurrentTenant();
|
||||
var pageable = PageRequest.of(page, size, Sort.by(Sort.Direction.DESC, "createdAt"));
|
||||
|
||||
Page<Payment> result;
|
||||
if (memberId != null) {
|
||||
result = financeService.getPaymentsByMember(clubId, memberId, pageable);
|
||||
} else if (status != null) {
|
||||
result = financeService.getPaymentsByStatus(clubId, status, pageable);
|
||||
} else {
|
||||
result = financeService.getPayments(clubId, pageable);
|
||||
}
|
||||
|
||||
return ResponseEntity.ok(result);
|
||||
}
|
||||
|
||||
@PostMapping("/finance/payments/{id}/void")
|
||||
public ResponseEntity<Payment> voidPayment(@PathVariable UUID id,
|
||||
@Valid @RequestBody VoidPaymentRequest request,
|
||||
@AuthenticationPrincipal UserDetails principal) {
|
||||
permissionChecker.requirePermission(principal, StaffPermission.MANAGE_FINANCES);
|
||||
UUID userId = UUID.fromString(principal.getUsername());
|
||||
|
||||
Payment voided = financeService.voidPayment(id, userId, request.reason());
|
||||
return ResponseEntity.ok(voided);
|
||||
}
|
||||
|
||||
// === Expenses ===
|
||||
|
||||
@PostMapping("/finance/expenses")
|
||||
public ResponseEntity<LedgerEntry> recordExpense(@Valid @RequestBody RecordExpenseRequest request,
|
||||
@AuthenticationPrincipal UserDetails principal) {
|
||||
permissionChecker.requirePermission(principal, StaffPermission.MANAGE_FINANCES);
|
||||
UUID clubId = TenantContext.getCurrentTenant();
|
||||
UUID userId = UUID.fromString(principal.getUsername());
|
||||
|
||||
LedgerEntry entry = financeService.recordExpense(
|
||||
clubId, request.category(), request.amountCents(),
|
||||
request.description(), request.reference(), userId, request.transactionDate()
|
||||
);
|
||||
|
||||
return ResponseEntity.status(HttpStatus.CREATED).body(entry);
|
||||
}
|
||||
|
||||
// === Ledger / Kassenbuch ===
|
||||
|
||||
@GetMapping("/finance/ledger")
|
||||
public ResponseEntity<Page<LedgerEntry>> getLedger(
|
||||
@RequestParam LocalDate from,
|
||||
@RequestParam LocalDate to,
|
||||
@RequestParam(defaultValue = "0") int page,
|
||||
@RequestParam(defaultValue = "50") int size,
|
||||
@AuthenticationPrincipal UserDetails principal) {
|
||||
permissionChecker.requirePermission(principal, StaffPermission.VIEW_FINANCES);
|
||||
UUID clubId = TenantContext.getCurrentTenant();
|
||||
var pageable = PageRequest.of(page, size, Sort.by(Sort.Direction.DESC, "transactionDate"));
|
||||
|
||||
return ResponseEntity.ok(financeService.getLedgerEntries(clubId, from, to, pageable));
|
||||
}
|
||||
|
||||
// === Financial Summary ===
|
||||
|
||||
@GetMapping("/finance/summary")
|
||||
public ResponseEntity<Map<String, Object>> getFinancialSummary(
|
||||
@RequestParam LocalDate from,
|
||||
@RequestParam LocalDate to,
|
||||
@AuthenticationPrincipal UserDetails principal) {
|
||||
permissionChecker.requirePermission(principal, StaffPermission.VIEW_FINANCES);
|
||||
UUID clubId = TenantContext.getCurrentTenant();
|
||||
|
||||
return ResponseEntity.ok(financeService.getFinancialSummary(clubId, from, to));
|
||||
}
|
||||
|
||||
// === Outstanding ===
|
||||
|
||||
@GetMapping("/finance/outstanding")
|
||||
public ResponseEntity<List<Map<String, Object>>> getOutstandingMembers(
|
||||
@AuthenticationPrincipal UserDetails principal) {
|
||||
permissionChecker.requirePermission(principal, StaffPermission.VIEW_FINANCES);
|
||||
UUID clubId = TenantContext.getCurrentTenant();
|
||||
|
||||
return ResponseEntity.ok(financeService.getOutstandingMembers(clubId));
|
||||
}
|
||||
|
||||
// === Member Balance (Admin) ===
|
||||
|
||||
@GetMapping("/finance/members/{memberId}/balance")
|
||||
public ResponseEntity<Map<String, Object>> getMemberBalance(@PathVariable UUID memberId,
|
||||
@AuthenticationPrincipal UserDetails principal) {
|
||||
permissionChecker.requirePermission(principal, StaffPermission.VIEW_FINANCES);
|
||||
UUID clubId = TenantContext.getCurrentTenant();
|
||||
|
||||
return ResponseEntity.ok(financeService.getMemberBalance(clubId, memberId));
|
||||
}
|
||||
|
||||
// === Portal Endpoints (member self-service) ===
|
||||
|
||||
@GetMapping("/portal/finance/payments")
|
||||
public ResponseEntity<Page<Payment>> getMyPayments(
|
||||
@RequestParam(defaultValue = "0") int page,
|
||||
@RequestParam(defaultValue = "20") int size,
|
||||
@AuthenticationPrincipal UserDetails principal) {
|
||||
UUID userId = UUID.fromString(principal.getUsername());
|
||||
UUID clubId = TenantContext.getCurrentTenant();
|
||||
UUID memberId = getMemberIdForUser(userId, clubId);
|
||||
|
||||
var pageable = PageRequest.of(page, size, Sort.by(Sort.Direction.DESC, "createdAt"));
|
||||
return ResponseEntity.ok(financeService.getPaymentsByMember(clubId, memberId, pageable));
|
||||
}
|
||||
|
||||
@GetMapping("/portal/finance/balance")
|
||||
public ResponseEntity<Map<String, Object>> getMyBalance(@AuthenticationPrincipal UserDetails principal) {
|
||||
UUID userId = UUID.fromString(principal.getUsername());
|
||||
UUID clubId = TenantContext.getCurrentTenant();
|
||||
UUID memberId = getMemberIdForUser(userId, clubId);
|
||||
|
||||
return ResponseEntity.ok(financeService.getMemberBalance(clubId, memberId));
|
||||
}
|
||||
|
||||
private UUID getMemberIdForUser(UUID userId, UUID clubId) {
|
||||
return memberRepository.findByUserId(userId)
|
||||
.map(Member::getId)
|
||||
.orElseThrow(() -> new NoSuchElementException("Member not found for user: " + userId));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
package de.cannamanage.api.dto.finance;
|
||||
|
||||
import jakarta.validation.constraints.NotNull;
|
||||
|
||||
import java.time.LocalDate;
|
||||
import java.util.UUID;
|
||||
|
||||
public record AssignFeeRequest(
|
||||
@NotNull UUID feeScheduleId,
|
||||
@NotNull LocalDate validFrom
|
||||
) {}
|
||||
+13
@@ -0,0 +1,13 @@
|
||||
package de.cannamanage.api.dto.finance;
|
||||
|
||||
import de.cannamanage.domain.enums.FeeInterval;
|
||||
import jakarta.validation.constraints.Min;
|
||||
import jakarta.validation.constraints.NotBlank;
|
||||
import jakarta.validation.constraints.NotNull;
|
||||
|
||||
public record CreateFeeScheduleRequest(
|
||||
@NotBlank String name,
|
||||
@NotNull @Min(1) Integer amountCents,
|
||||
@NotNull FeeInterval interval,
|
||||
Boolean isDefault
|
||||
) {}
|
||||
+16
@@ -0,0 +1,16 @@
|
||||
package de.cannamanage.api.dto.finance;
|
||||
|
||||
import de.cannamanage.domain.enums.ExpenseCategory;
|
||||
import jakarta.validation.constraints.Min;
|
||||
import jakarta.validation.constraints.NotBlank;
|
||||
import jakarta.validation.constraints.NotNull;
|
||||
|
||||
import java.time.LocalDate;
|
||||
|
||||
public record RecordExpenseRequest(
|
||||
@NotNull ExpenseCategory category,
|
||||
@NotNull @Min(1) Integer amountCents,
|
||||
@NotBlank String description,
|
||||
String reference,
|
||||
@NotNull LocalDate transactionDate
|
||||
) {}
|
||||
+18
@@ -0,0 +1,18 @@
|
||||
package de.cannamanage.api.dto.finance;
|
||||
|
||||
import de.cannamanage.domain.enums.PaymentMethod;
|
||||
import jakarta.validation.constraints.Min;
|
||||
import jakarta.validation.constraints.NotNull;
|
||||
|
||||
import java.time.LocalDate;
|
||||
import java.util.UUID;
|
||||
|
||||
public record RecordPaymentRequest(
|
||||
@NotNull UUID memberId,
|
||||
@NotNull @Min(1) Integer amountCents,
|
||||
@NotNull PaymentMethod paymentMethod,
|
||||
@NotNull LocalDate periodFrom,
|
||||
@NotNull LocalDate periodTo,
|
||||
String reference,
|
||||
String notes
|
||||
) {}
|
||||
+10
@@ -0,0 +1,10 @@
|
||||
package de.cannamanage.api.dto.finance;
|
||||
|
||||
import de.cannamanage.domain.enums.FeeInterval;
|
||||
|
||||
public record UpdateFeeScheduleRequest(
|
||||
String name,
|
||||
Integer amountCents,
|
||||
FeeInterval interval,
|
||||
Boolean isDefault
|
||||
) {}
|
||||
@@ -0,0 +1,7 @@
|
||||
package de.cannamanage.api.dto.finance;
|
||||
|
||||
import jakarta.validation.constraints.NotBlank;
|
||||
|
||||
public record VoidPaymentRequest(
|
||||
@NotBlank String reason
|
||||
) {}
|
||||
Reference in New Issue
Block a user