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,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; }
|
||||
}
|
||||
+48
@@ -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
|
||||
}
|
||||
Reference in New Issue
Block a user