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:
Patrick Plate
2026-06-15 08:00:04 +02:00
parent cfb38e8fc6
commit 721503b231
25 changed files with 1225 additions and 3 deletions
@@ -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
) {}
@@ -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
) {}
@@ -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
) {}
@@ -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
) {}
@@ -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
) {}
@@ -0,0 +1,77 @@
-- Sprint 8: Treasury / Finance tables
-- Fee schedules (Beitragsordnung)
CREATE TABLE fee_schedules (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
club_id UUID NOT NULL REFERENCES clubs(id),
tenant_id UUID NOT NULL,
name VARCHAR(100) NOT NULL,
amount_cents INTEGER NOT NULL,
interval VARCHAR(20) NOT NULL,
is_default BOOLEAN DEFAULT FALSE,
is_active BOOLEAN DEFAULT TRUE,
created_at TIMESTAMP DEFAULT NOW(),
updated_at TIMESTAMP DEFAULT NOW()
);
-- Member fee assignment
CREATE TABLE member_fee_assignments (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
member_id UUID NOT NULL REFERENCES members(id),
club_id UUID NOT NULL REFERENCES clubs(id),
tenant_id UUID NOT NULL,
fee_schedule_id UUID NOT NULL REFERENCES fee_schedules(id),
valid_from DATE NOT NULL,
valid_to DATE,
created_at TIMESTAMP DEFAULT NOW(),
UNIQUE(member_id, valid_from)
);
-- Payments (Zahlungen)
CREATE TABLE payments (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
club_id UUID NOT NULL REFERENCES clubs(id),
tenant_id UUID NOT NULL,
member_id UUID NOT NULL REFERENCES members(id),
amount_cents INTEGER NOT NULL,
payment_method VARCHAR(30) NOT NULL,
status VARCHAR(20) NOT NULL DEFAULT 'PAID',
period_from DATE NOT NULL,
period_to DATE NOT NULL,
reference VARCHAR(200),
notes TEXT,
recorded_by UUID NOT NULL REFERENCES users(id),
paid_at TIMESTAMP NOT NULL,
voided_at TIMESTAMP,
voided_by UUID REFERENCES users(id),
void_reason TEXT,
created_at TIMESTAMP DEFAULT NOW()
);
-- Kassenbuch (cash book / ledger entries) — append-only per §147 AO
CREATE TABLE ledger_entries (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
club_id UUID NOT NULL REFERENCES clubs(id),
tenant_id UUID NOT NULL,
transaction_type VARCHAR(10) NOT NULL,
category VARCHAR(50) NOT NULL,
amount_cents INTEGER NOT NULL,
description VARCHAR(500) NOT NULL,
reference VARCHAR(200),
payment_id UUID REFERENCES payments(id),
recorded_by UUID NOT NULL REFERENCES users(id),
transaction_date DATE NOT NULL,
created_at TIMESTAMP DEFAULT NOW()
);
-- Indexes
CREATE INDEX idx_fee_schedules_club ON fee_schedules(club_id);
CREATE INDEX idx_fee_schedules_tenant ON fee_schedules(tenant_id);
CREATE INDEX idx_member_fee_assignments_member ON member_fee_assignments(member_id);
CREATE INDEX idx_member_fee_assignments_tenant ON member_fee_assignments(tenant_id);
CREATE INDEX idx_payments_club_member ON payments(club_id, member_id);
CREATE INDEX idx_payments_status ON payments(club_id, status);
CREATE INDEX idx_payments_period ON payments(club_id, period_from, period_to);
CREATE INDEX idx_payments_tenant ON payments(tenant_id);
CREATE INDEX idx_ledger_entries_club_date ON ledger_entries(club_id, transaction_date);
CREATE INDEX idx_ledger_entries_category ON ledger_entries(club_id, category);
CREATE INDEX idx_ledger_entries_tenant ON ledger_entries(tenant_id);
@@ -0,0 +1,71 @@
package de.cannamanage.domain.entity;
import de.cannamanage.domain.enums.FeeInterval;
import jakarta.persistence.*;
import java.time.Instant;
import java.util.UUID;
/**
* Fee schedule (Beitragsordnung) — defines a named fee tier for the club.
* Never hard-deleted; set isActive=false to deactivate.
*/
@Entity
@Table(name = "fee_schedules")
public class FeeSchedule extends AbstractTenantEntity {
@Column(name = "club_id", nullable = false)
private UUID clubId;
@Column(name = "name", nullable = false, length = 100)
private String name;
@Column(name = "amount_cents", nullable = false)
private Integer amountCents;
@Enumerated(EnumType.STRING)
@Column(name = "interval", nullable = false, length = 20)
private FeeInterval interval;
@Column(name = "is_default")
private Boolean isDefault = false;
@Column(name = "is_active")
private Boolean isActive = true;
@Column(name = "updated_at")
private Instant updatedAt;
@PrePersist
void onCreateFee() {
this.updatedAt = Instant.now();
}
@PreUpdate
void onUpdateFee() {
this.updatedAt = Instant.now();
}
// Getters and setters
public UUID getClubId() { return clubId; }
public void setClubId(UUID clubId) { this.clubId = clubId; }
public String getName() { return name; }
public void setName(String name) { this.name = name; }
public Integer getAmountCents() { return amountCents; }
public void setAmountCents(Integer amountCents) { this.amountCents = amountCents; }
public FeeInterval getInterval() { return interval; }
public void setInterval(FeeInterval interval) { this.interval = interval; }
public Boolean getIsDefault() { return isDefault; }
public void setIsDefault(Boolean isDefault) { this.isDefault = isDefault; }
public Boolean getIsActive() { return isActive; }
public void setIsActive(Boolean isActive) { this.isActive = isActive; }
public Instant getUpdatedAt() { return updatedAt; }
public void setUpdatedAt(Instant updatedAt) { this.updatedAt = updatedAt; }
}
@@ -0,0 +1,73 @@
package de.cannamanage.domain.entity;
import de.cannamanage.domain.enums.TransactionType;
import jakarta.persistence.*;
import java.time.LocalDate;
import java.util.UUID;
/**
* Kassenbuch entry — append-only per §147 AO (Aufbewahrungspflicht).
* NO update, NO delete. Corrections are done via compensating entries.
*/
@Entity
@Table(name = "ledger_entries")
public class LedgerEntry extends AbstractTenantEntity {
@Column(name = "club_id", nullable = false)
private UUID clubId;
@Enumerated(EnumType.STRING)
@Column(name = "transaction_type", nullable = false, length = 10)
private TransactionType transactionType;
@Column(name = "category", nullable = false, length = 50)
private String category;
@Column(name = "amount_cents", nullable = false)
private Integer amountCents;
@Column(name = "description", nullable = false, length = 500)
private String description;
@Column(name = "reference", length = 200)
private String reference;
@Column(name = "payment_id")
private UUID paymentId;
@Column(name = "recorded_by", nullable = false)
private UUID recordedBy;
@Column(name = "transaction_date", nullable = false)
private LocalDate transactionDate;
// Getters and setters
public UUID getClubId() { return clubId; }
public void setClubId(UUID clubId) { this.clubId = clubId; }
public TransactionType getTransactionType() { return transactionType; }
public void setTransactionType(TransactionType transactionType) { this.transactionType = transactionType; }
public String getCategory() { return category; }
public void setCategory(String category) { this.category = category; }
public Integer getAmountCents() { return amountCents; }
public void setAmountCents(Integer amountCents) { this.amountCents = amountCents; }
public String getDescription() { return description; }
public void setDescription(String description) { this.description = description; }
public String getReference() { return reference; }
public void setReference(String reference) { this.reference = reference; }
public UUID getPaymentId() { return paymentId; }
public void setPaymentId(UUID paymentId) { this.paymentId = paymentId; }
public UUID getRecordedBy() { return recordedBy; }
public void setRecordedBy(UUID recordedBy) { this.recordedBy = recordedBy; }
public LocalDate getTransactionDate() { return transactionDate; }
public void setTransactionDate(LocalDate transactionDate) { this.transactionDate = transactionDate; }
}
@@ -0,0 +1,48 @@
package de.cannamanage.domain.entity;
import jakarta.persistence.*;
import java.time.Instant;
import java.time.LocalDate;
import java.util.UUID;
/**
* Links a member to a specific fee schedule with a validity period.
* A member can have only one active assignment at a time (validTo = null).
*/
@Entity
@Table(name = "member_fee_assignments")
public class MemberFeeAssignment extends AbstractTenantEntity {
@Column(name = "member_id", nullable = false)
private UUID memberId;
@Column(name = "club_id", nullable = false)
private UUID clubId;
@Column(name = "fee_schedule_id", nullable = false)
private UUID feeScheduleId;
@Column(name = "valid_from", nullable = false)
private LocalDate validFrom;
@Column(name = "valid_to")
private LocalDate validTo;
// Getters and setters
public UUID getMemberId() { return memberId; }
public void setMemberId(UUID memberId) { this.memberId = memberId; }
public UUID getClubId() { return clubId; }
public void setClubId(UUID clubId) { this.clubId = clubId; }
public UUID getFeeScheduleId() { return feeScheduleId; }
public void setFeeScheduleId(UUID feeScheduleId) { this.feeScheduleId = feeScheduleId; }
public LocalDate getValidFrom() { return validFrom; }
public void setValidFrom(LocalDate validFrom) { this.validFrom = validFrom; }
public LocalDate getValidTo() { return validTo; }
public void setValidTo(LocalDate validTo) { this.validTo = validTo; }
}
@@ -0,0 +1,106 @@
package de.cannamanage.domain.entity;
import de.cannamanage.domain.enums.PaymentMethod;
import de.cannamanage.domain.enums.PaymentStatus;
import jakarta.persistence.*;
import java.time.Instant;
import java.time.LocalDate;
import java.util.UUID;
/**
* A member payment record. Covers a specific billing period.
* Voiding a payment marks it VOIDED and creates a compensating negative LedgerEntry.
*/
@Entity
@Table(name = "payments")
public class Payment extends AbstractTenantEntity {
@Column(name = "club_id", nullable = false)
private UUID clubId;
@Column(name = "member_id", nullable = false)
private UUID memberId;
@Column(name = "amount_cents", nullable = false)
private Integer amountCents;
@Enumerated(EnumType.STRING)
@Column(name = "payment_method", nullable = false, length = 30)
private PaymentMethod paymentMethod;
@Enumerated(EnumType.STRING)
@Column(name = "status", nullable = false, length = 20)
private PaymentStatus status = PaymentStatus.PAID;
@Column(name = "period_from", nullable = false)
private LocalDate periodFrom;
@Column(name = "period_to", nullable = false)
private LocalDate periodTo;
@Column(name = "reference", length = 200)
private String reference;
@Column(name = "notes", columnDefinition = "TEXT")
private String notes;
@Column(name = "recorded_by", nullable = false)
private UUID recordedBy;
@Column(name = "paid_at", nullable = false)
private Instant paidAt;
@Column(name = "voided_at")
private Instant voidedAt;
@Column(name = "voided_by")
private UUID voidedBy;
@Column(name = "void_reason", columnDefinition = "TEXT")
private String voidReason;
// Getters and setters
public UUID getClubId() { return clubId; }
public void setClubId(UUID clubId) { this.clubId = clubId; }
public UUID getMemberId() { return memberId; }
public void setMemberId(UUID memberId) { this.memberId = memberId; }
public Integer getAmountCents() { return amountCents; }
public void setAmountCents(Integer amountCents) { this.amountCents = amountCents; }
public PaymentMethod getPaymentMethod() { return paymentMethod; }
public void setPaymentMethod(PaymentMethod paymentMethod) { this.paymentMethod = paymentMethod; }
public PaymentStatus getStatus() { return status; }
public void setStatus(PaymentStatus status) { this.status = status; }
public LocalDate getPeriodFrom() { return periodFrom; }
public void setPeriodFrom(LocalDate periodFrom) { this.periodFrom = periodFrom; }
public LocalDate getPeriodTo() { return periodTo; }
public void setPeriodTo(LocalDate periodTo) { this.periodTo = periodTo; }
public String getReference() { return reference; }
public void setReference(String reference) { this.reference = reference; }
public String getNotes() { return notes; }
public void setNotes(String notes) { this.notes = notes; }
public UUID getRecordedBy() { return recordedBy; }
public void setRecordedBy(UUID recordedBy) { this.recordedBy = recordedBy; }
public Instant getPaidAt() { return paidAt; }
public void setPaidAt(Instant paidAt) { this.paidAt = paidAt; }
public Instant getVoidedAt() { return voidedAt; }
public void setVoidedAt(Instant voidedAt) { this.voidedAt = voidedAt; }
public UUID getVoidedBy() { return voidedBy; }
public void setVoidedBy(UUID voidedBy) { this.voidedBy = voidedBy; }
public String getVoidReason() { return voidReason; }
public void setVoidReason(String voidReason) { this.voidReason = voidReason; }
}
@@ -64,5 +64,12 @@ public enum AuditEventType {
FORUM_TOPIC_DELETED, FORUM_TOPIC_DELETED,
FORUM_REPLY_CREATED, FORUM_REPLY_CREATED,
FORUM_REPLY_DELETED, FORUM_REPLY_DELETED,
FORUM_REPORT_REVIEWED FORUM_REPORT_REVIEWED,
// Sprint 8 — Finance/Treasury events
PAYMENT_RECORDED,
PAYMENT_VOIDED,
FEE_SCHEDULE_CREATED,
FEE_SCHEDULE_UPDATED,
EXPENSE_RECORDED
} }
@@ -0,0 +1,15 @@
package de.cannamanage.domain.enums;
/**
* Categories for club expenses in the Kassenbuch.
*/
public enum ExpenseCategory {
RENT,
ELECTRICITY,
CANNABIS_PURCHASE,
GROW_MATERIALS,
INSURANCE,
ADMINISTRATION,
EVENTS,
OTHER
}
@@ -0,0 +1,11 @@
package de.cannamanage.domain.enums;
/**
* Billing intervals for fee schedules (Beitragsordnung).
*/
public enum FeeInterval {
MONTHLY,
QUARTERLY,
SEMI_ANNUAL,
ANNUAL
}
@@ -16,5 +16,9 @@ public enum NotificationType {
// Sprint 7 Phase 2.5 — Events: // Sprint 7 Phase 2.5 — Events:
EVENT_CREATED, EVENT_CREATED,
EVENT_REMINDER, EVENT_REMINDER,
EVENT_CANCELLED EVENT_CANCELLED,
// Sprint 8 — Finance:
PAYMENT_REMINDER,
PAYMENT_OVERDUE,
PAYMENT_RECEIVED
} }
@@ -0,0 +1,11 @@
package de.cannamanage.domain.enums;
/**
* Payment methods supported for member fee collection.
*/
public enum PaymentMethod {
CASH,
BANK_TRANSFER,
SEPA_LASTSCHRIFT,
OTHER
}
@@ -0,0 +1,11 @@
package de.cannamanage.domain.enums;
/**
* Status of a member payment.
*/
public enum PaymentStatus {
PAID,
OVERDUE,
PENDING,
VOIDED
}
@@ -17,5 +17,8 @@ public enum StaffPermission {
// Sprint 7: // Sprint 7:
SEND_NOTIFICATIONS, SEND_NOTIFICATIONS,
MANAGE_INFO_BOARD, MANAGE_INFO_BOARD,
MODERATE_FORUM MODERATE_FORUM,
// Sprint 8:
MANAGE_FINANCES,
VIEW_FINANCES
} }
@@ -0,0 +1,9 @@
package de.cannamanage.domain.enums;
/**
* Type of ledger entry — income or expense.
*/
public enum TransactionType {
INCOME,
EXPENSE
}
@@ -0,0 +1,320 @@
package de.cannamanage.service;
import de.cannamanage.domain.entity.*;
import de.cannamanage.domain.enums.*;
import de.cannamanage.service.repository.*;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.data.domain.Page;
import org.springframework.data.domain.Pageable;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.time.Instant;
import java.time.LocalDate;
import java.util.*;
/**
* Service for club treasury management — fee schedules, payments, expenses, Kassenbuch.
* All amounts in cents (Integer) to avoid floating-point precision issues.
* LedgerEntry is append-only per §147 AO.
*/
@Service
@Transactional
public class FinanceService {
private static final Logger log = LoggerFactory.getLogger(FinanceService.class);
private final FeeScheduleRepository feeScheduleRepository;
private final MemberFeeAssignmentRepository assignmentRepository;
private final PaymentRepository paymentRepository;
private final LedgerEntryRepository ledgerEntryRepository;
private final AuditService auditService;
public FinanceService(FeeScheduleRepository feeScheduleRepository,
MemberFeeAssignmentRepository assignmentRepository,
PaymentRepository paymentRepository,
LedgerEntryRepository ledgerEntryRepository,
AuditService auditService) {
this.feeScheduleRepository = feeScheduleRepository;
this.assignmentRepository = assignmentRepository;
this.paymentRepository = paymentRepository;
this.ledgerEntryRepository = ledgerEntryRepository;
this.auditService = auditService;
}
// === Fee Schedule CRUD ===
public FeeSchedule createFeeSchedule(UUID clubId, String name, int amountCents,
FeeInterval interval, boolean isDefault) {
var schedule = new FeeSchedule();
schedule.setClubId(clubId);
schedule.setName(name);
schedule.setAmountCents(amountCents);
schedule.setInterval(interval);
schedule.setIsDefault(isDefault);
schedule.setIsActive(true);
// If this is set as default, unset any existing default
if (isDefault) {
feeScheduleRepository.findByClubIdAndIsDefaultTrue(clubId)
.ifPresent(existing -> {
existing.setIsDefault(false);
feeScheduleRepository.save(existing);
});
}
FeeSchedule saved = feeScheduleRepository.save(schedule);
log.info("Fee schedule created: {} '{}' ({} cents/{}) for club {}",
saved.getId(), name, amountCents, interval, clubId);
auditService.log(AuditEventType.FEE_SCHEDULE_CREATED, "FeeSchedule",
saved.getId().toString(), "Fee schedule created: " + name);
return saved;
}
public FeeSchedule updateFeeSchedule(UUID scheduleId, String name, Integer amountCents,
FeeInterval interval, Boolean isDefault) {
FeeSchedule schedule = feeScheduleRepository.findById(scheduleId)
.orElseThrow(() -> new NoSuchElementException("Fee schedule not found: " + scheduleId));
if (name != null) schedule.setName(name);
if (amountCents != null) schedule.setAmountCents(amountCents);
if (interval != null) schedule.setInterval(interval);
if (isDefault != null && isDefault) {
feeScheduleRepository.findByClubIdAndIsDefaultTrue(schedule.getClubId())
.ifPresent(existing -> {
if (!existing.getId().equals(scheduleId)) {
existing.setIsDefault(false);
feeScheduleRepository.save(existing);
}
});
schedule.setIsDefault(true);
}
FeeSchedule saved = feeScheduleRepository.save(schedule);
auditService.log(AuditEventType.FEE_SCHEDULE_UPDATED, "FeeSchedule",
saved.getId().toString(), "Fee schedule updated: " + saved.getName());
return saved;
}
public void deactivateFeeSchedule(UUID scheduleId) {
FeeSchedule schedule = feeScheduleRepository.findById(scheduleId)
.orElseThrow(() -> new NoSuchElementException("Fee schedule not found: " + scheduleId));
schedule.setIsActive(false);
schedule.setIsDefault(false);
feeScheduleRepository.save(schedule);
log.info("Fee schedule deactivated: {}", scheduleId);
}
public List<FeeSchedule> getActiveFeeSchedules(UUID clubId) {
return feeScheduleRepository.findByClubIdAndIsActiveTrue(clubId);
}
// === Fee Assignment ===
public MemberFeeAssignment assignFeeSchedule(UUID memberId, UUID clubId,
UUID feeScheduleId, LocalDate validFrom) {
// Close any existing open assignment
assignmentRepository.findByMemberIdAndValidToIsNull(memberId)
.ifPresent(existing -> {
existing.setValidTo(validFrom.minusDays(1));
assignmentRepository.save(existing);
});
var assignment = new MemberFeeAssignment();
assignment.setMemberId(memberId);
assignment.setClubId(clubId);
assignment.setFeeScheduleId(feeScheduleId);
assignment.setValidFrom(validFrom);
return assignmentRepository.save(assignment);
}
// === Payments ===
public Payment recordPayment(UUID clubId, UUID memberId, int amountCents,
PaymentMethod paymentMethod, LocalDate periodFrom,
LocalDate periodTo, String reference, String notes,
UUID recordedBy) {
var payment = new Payment();
payment.setClubId(clubId);
payment.setMemberId(memberId);
payment.setAmountCents(amountCents);
payment.setPaymentMethod(paymentMethod);
payment.setStatus(PaymentStatus.PAID);
payment.setPeriodFrom(periodFrom);
payment.setPeriodTo(periodTo);
payment.setReference(reference);
payment.setNotes(notes);
payment.setRecordedBy(recordedBy);
payment.setPaidAt(Instant.now());
Payment saved = paymentRepository.save(payment);
// Create corresponding ledger entry (INCOME)
var ledgerEntry = new LedgerEntry();
ledgerEntry.setClubId(clubId);
ledgerEntry.setTransactionType(TransactionType.INCOME);
ledgerEntry.setCategory("MEMBERSHIP_FEE");
ledgerEntry.setAmountCents(amountCents);
ledgerEntry.setDescription("Mitgliedsbeitrag: " + periodFrom + " - " + periodTo);
ledgerEntry.setReference(reference);
ledgerEntry.setPaymentId(saved.getId());
ledgerEntry.setRecordedBy(recordedBy);
ledgerEntry.setTransactionDate(LocalDate.now());
ledgerEntryRepository.save(ledgerEntry);
auditService.log(AuditEventType.PAYMENT_RECORDED, "Payment",
saved.getId().toString(),
"Payment recorded: " + amountCents + " cents from member " + memberId);
log.info("Payment recorded: {} cents from member {} for period {}-{} in club {}",
amountCents, memberId, periodFrom, periodTo, clubId);
return saved;
}
public Payment voidPayment(UUID paymentId, UUID voidedBy, String reason) {
Payment payment = paymentRepository.findById(paymentId)
.orElseThrow(() -> new NoSuchElementException("Payment not found: " + paymentId));
if (payment.getStatus() == PaymentStatus.VOIDED) {
throw new IllegalStateException("Payment already voided: " + paymentId);
}
payment.setStatus(PaymentStatus.VOIDED);
payment.setVoidedAt(Instant.now());
payment.setVoidedBy(voidedBy);
payment.setVoidReason(reason);
Payment saved = paymentRepository.save(payment);
// Create compensating negative ledger entry
var compensating = new LedgerEntry();
compensating.setClubId(payment.getClubId());
compensating.setTransactionType(TransactionType.EXPENSE);
compensating.setCategory("MEMBERSHIP_FEE_VOID");
compensating.setAmountCents(payment.getAmountCents());
compensating.setDescription("Storno: Mitgliedsbeitrag " + payment.getPeriodFrom() + " - " + payment.getPeriodTo() + ". Grund: " + reason);
compensating.setPaymentId(paymentId);
compensating.setRecordedBy(voidedBy);
compensating.setTransactionDate(LocalDate.now());
ledgerEntryRepository.save(compensating);
auditService.log(AuditEventType.PAYMENT_VOIDED, "Payment",
paymentId.toString(), "Payment voided: " + reason);
log.info("Payment voided: {} — reason: {}", paymentId, reason);
return saved;
}
public Page<Payment> getPayments(UUID clubId, Pageable pageable) {
return paymentRepository.findByClubId(clubId, pageable);
}
public Page<Payment> getPaymentsByMember(UUID clubId, UUID memberId, Pageable pageable) {
return paymentRepository.findByClubIdAndMemberId(clubId, memberId, pageable);
}
public Page<Payment> getPaymentsByStatus(UUID clubId, PaymentStatus status, Pageable pageable) {
return paymentRepository.findByClubIdAndStatus(clubId, status, pageable);
}
// === Expenses ===
public LedgerEntry recordExpense(UUID clubId, ExpenseCategory category, int amountCents,
String description, String reference, UUID recordedBy,
LocalDate transactionDate) {
var entry = new LedgerEntry();
entry.setClubId(clubId);
entry.setTransactionType(TransactionType.EXPENSE);
entry.setCategory(category.name());
entry.setAmountCents(amountCents);
entry.setDescription(description);
entry.setReference(reference);
entry.setRecordedBy(recordedBy);
entry.setTransactionDate(transactionDate);
LedgerEntry saved = ledgerEntryRepository.save(entry);
auditService.log(AuditEventType.EXPENSE_RECORDED, "LedgerEntry",
saved.getId().toString(),
"Expense recorded: " + amountCents + " cents (" + category + ")");
log.info("Expense recorded: {} cents ({}) in club {}", amountCents, category, clubId);
return saved;
}
// === Kassenbuch / Ledger ===
public Page<LedgerEntry> getLedgerEntries(UUID clubId, LocalDate from, LocalDate to, Pageable pageable) {
return ledgerEntryRepository.findByClubIdAndTransactionDateBetween(clubId, from, to, pageable);
}
// === Financial Summary ===
public Map<String, Object> getFinancialSummary(UUID clubId, LocalDate from, LocalDate to) {
Long totalIncome = ledgerEntryRepository.sumIncomeByClubAndDateRange(clubId, from, to);
Long totalExpenses = ledgerEntryRepository.sumExpensesByClubAndDateRange(clubId, from, to);
Long balance = ledgerEntryRepository.calculateBalance(clubId, to);
Map<String, Object> summary = new LinkedHashMap<>();
summary.put("totalIncomeCents", totalIncome);
summary.put("totalExpensesCents", totalExpenses);
summary.put("netCents", totalIncome - totalExpenses);
summary.put("balanceCents", balance);
summary.put("periodFrom", from.toString());
summary.put("periodTo", to.toString());
return summary;
}
// === Member Balance ===
public Map<String, Object> getMemberBalance(UUID clubId, UUID memberId) {
Long totalPaid = paymentRepository.sumPaidByMember(clubId, memberId);
Map<String, Object> balance = new LinkedHashMap<>();
balance.put("memberId", memberId);
balance.put("totalPaidCents", totalPaid);
// Get current fee assignment
assignmentRepository.findByMemberIdAndValidToIsNull(memberId)
.ifPresent(assignment -> {
feeScheduleRepository.findById(assignment.getFeeScheduleId())
.ifPresent(schedule -> {
balance.put("currentSchedule", schedule.getName());
balance.put("amountPerIntervalCents", schedule.getAmountCents());
balance.put("interval", schedule.getInterval().name());
});
});
return balance;
}
// === Outstanding / Overdue Members ===
public List<Map<String, Object>> getOutstandingMembers(UUID clubId) {
List<MemberFeeAssignment> activeAssignments = assignmentRepository.findByClubIdAndValidToIsNull(clubId);
List<Map<String, Object>> outstanding = new ArrayList<>();
for (MemberFeeAssignment assignment : activeAssignments) {
Long totalPaid = paymentRepository.sumPaidByMember(clubId, assignment.getMemberId());
// Simple check: if no payments at all, flag as outstanding
if (totalPaid == 0) {
Map<String, Object> entry = new LinkedHashMap<>();
entry.put("memberId", assignment.getMemberId());
entry.put("feeScheduleId", assignment.getFeeScheduleId());
entry.put("validFrom", assignment.getValidFrom().toString());
entry.put("totalPaidCents", 0);
outstanding.add(entry);
}
}
return outstanding;
}
}
@@ -0,0 +1,19 @@
package de.cannamanage.service.repository;
import de.cannamanage.domain.entity.FeeSchedule;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.stereotype.Repository;
import java.util.List;
import java.util.Optional;
import java.util.UUID;
@Repository
public interface FeeScheduleRepository extends JpaRepository<FeeSchedule, UUID> {
List<FeeSchedule> findByClubIdAndIsActiveTrue(UUID clubId);
List<FeeSchedule> findByClubId(UUID clubId);
Optional<FeeSchedule> findByClubIdAndIsDefaultTrue(UUID clubId);
}
@@ -0,0 +1,48 @@
package de.cannamanage.service.repository;
import de.cannamanage.domain.entity.LedgerEntry;
import org.springframework.data.domain.Page;
import org.springframework.data.domain.Pageable;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.data.jpa.repository.Query;
import org.springframework.data.repository.query.Param;
import org.springframework.stereotype.Repository;
import java.time.LocalDate;
import java.util.List;
import java.util.UUID;
@Repository
public interface LedgerEntryRepository extends JpaRepository<LedgerEntry, UUID> {
Page<LedgerEntry> findByClubIdAndTransactionDateBetween(UUID clubId, LocalDate from, LocalDate to, Pageable pageable);
List<LedgerEntry> findByClubIdAndTransactionDateBetween(UUID clubId, LocalDate from, LocalDate to);
@Query("SELECT COALESCE(SUM(le.amountCents), 0) FROM LedgerEntry le " +
"WHERE le.clubId = :clubId AND le.category = :category " +
"AND le.transactionDate >= :from AND le.transactionDate <= :to")
Long sumByClubIdAndCategoryAndDateRange(@Param("clubId") UUID clubId,
@Param("category") String category,
@Param("from") LocalDate from,
@Param("to") LocalDate to);
@Query("SELECT COALESCE(SUM(CASE WHEN le.transactionType = 'INCOME' THEN le.amountCents ELSE 0 END), 0) " +
"FROM LedgerEntry le WHERE le.clubId = :clubId " +
"AND le.transactionDate >= :from AND le.transactionDate <= :to")
Long sumIncomeByClubAndDateRange(@Param("clubId") UUID clubId,
@Param("from") LocalDate from,
@Param("to") LocalDate to);
@Query("SELECT COALESCE(SUM(CASE WHEN le.transactionType = 'EXPENSE' THEN le.amountCents ELSE 0 END), 0) " +
"FROM LedgerEntry le WHERE le.clubId = :clubId " +
"AND le.transactionDate >= :from AND le.transactionDate <= :to")
Long sumExpensesByClubAndDateRange(@Param("clubId") UUID clubId,
@Param("from") LocalDate from,
@Param("to") LocalDate to);
@Query("SELECT COALESCE(SUM(CASE WHEN le.transactionType = 'INCOME' THEN le.amountCents " +
"ELSE -le.amountCents END), 0) FROM LedgerEntry le " +
"WHERE le.clubId = :clubId AND le.transactionDate <= :asOf")
Long calculateBalance(@Param("clubId") UUID clubId, @Param("asOf") LocalDate asOf);
}
@@ -0,0 +1,19 @@
package de.cannamanage.service.repository;
import de.cannamanage.domain.entity.MemberFeeAssignment;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.stereotype.Repository;
import java.util.List;
import java.util.Optional;
import java.util.UUID;
@Repository
public interface MemberFeeAssignmentRepository extends JpaRepository<MemberFeeAssignment, UUID> {
Optional<MemberFeeAssignment> findByMemberIdAndValidToIsNull(UUID memberId);
List<MemberFeeAssignment> findByClubId(UUID clubId);
List<MemberFeeAssignment> findByClubIdAndValidToIsNull(UUID clubId);
}
@@ -0,0 +1,44 @@
package de.cannamanage.service.repository;
import de.cannamanage.domain.entity.Payment;
import de.cannamanage.domain.enums.PaymentStatus;
import org.springframework.data.domain.Page;
import org.springframework.data.domain.Pageable;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.data.jpa.repository.Query;
import org.springframework.data.repository.query.Param;
import org.springframework.stereotype.Repository;
import java.time.LocalDate;
import java.util.List;
import java.util.UUID;
@Repository
public interface PaymentRepository extends JpaRepository<Payment, UUID> {
List<Payment> findByClubIdAndMemberId(UUID clubId, UUID memberId);
Page<Payment> findByClubId(UUID clubId, Pageable pageable);
Page<Payment> findByClubIdAndMemberId(UUID clubId, UUID memberId, Pageable pageable);
Page<Payment> findByClubIdAndStatus(UUID clubId, PaymentStatus status, Pageable pageable);
@Query("SELECT p FROM Payment p WHERE p.clubId = :clubId AND p.memberId = :memberId " +
"AND p.status = 'PAID' AND p.periodFrom >= :from AND p.periodTo <= :to")
List<Payment> findPaidByMemberAndPeriod(@Param("clubId") UUID clubId,
@Param("memberId") UUID memberId,
@Param("from") LocalDate from,
@Param("to") LocalDate to);
@Query("SELECT COALESCE(SUM(p.amountCents), 0) FROM Payment p " +
"WHERE p.clubId = :clubId AND p.status = 'PAID' " +
"AND p.periodFrom >= :from AND p.periodTo <= :to")
Long sumByClubIdAndPeriod(@Param("clubId") UUID clubId,
@Param("from") LocalDate from,
@Param("to") LocalDate to);
@Query("SELECT COALESCE(SUM(p.amountCents), 0) FROM Payment p " +
"WHERE p.clubId = :clubId AND p.memberId = :memberId AND p.status = 'PAID'")
Long sumPaidByMember(@Param("clubId") UUID clubId, @Param("memberId") UUID memberId);
}