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