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,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_REPLY_CREATED,
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:
EVENT_CREATED,
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:
SEND_NOTIFICATIONS,
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
}