diff --git a/cannamanage-api/src/main/java/de/cannamanage/api/controller/FinanceController.java b/cannamanage-api/src/main/java/de/cannamanage/api/controller/FinanceController.java new file mode 100644 index 0000000..cc97f2c --- /dev/null +++ b/cannamanage-api/src/main/java/de/cannamanage/api/controller/FinanceController.java @@ -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 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> 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 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 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 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 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> 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 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 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 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> 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> 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>> 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> 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> 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> 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)); + } +} diff --git a/cannamanage-api/src/main/java/de/cannamanage/api/dto/finance/AssignFeeRequest.java b/cannamanage-api/src/main/java/de/cannamanage/api/dto/finance/AssignFeeRequest.java new file mode 100644 index 0000000..e467346 --- /dev/null +++ b/cannamanage-api/src/main/java/de/cannamanage/api/dto/finance/AssignFeeRequest.java @@ -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 +) {} diff --git a/cannamanage-api/src/main/java/de/cannamanage/api/dto/finance/CreateFeeScheduleRequest.java b/cannamanage-api/src/main/java/de/cannamanage/api/dto/finance/CreateFeeScheduleRequest.java new file mode 100644 index 0000000..aca1bec --- /dev/null +++ b/cannamanage-api/src/main/java/de/cannamanage/api/dto/finance/CreateFeeScheduleRequest.java @@ -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 +) {} diff --git a/cannamanage-api/src/main/java/de/cannamanage/api/dto/finance/RecordExpenseRequest.java b/cannamanage-api/src/main/java/de/cannamanage/api/dto/finance/RecordExpenseRequest.java new file mode 100644 index 0000000..c4423f7 --- /dev/null +++ b/cannamanage-api/src/main/java/de/cannamanage/api/dto/finance/RecordExpenseRequest.java @@ -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 +) {} diff --git a/cannamanage-api/src/main/java/de/cannamanage/api/dto/finance/RecordPaymentRequest.java b/cannamanage-api/src/main/java/de/cannamanage/api/dto/finance/RecordPaymentRequest.java new file mode 100644 index 0000000..3b81537 --- /dev/null +++ b/cannamanage-api/src/main/java/de/cannamanage/api/dto/finance/RecordPaymentRequest.java @@ -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 +) {} diff --git a/cannamanage-api/src/main/java/de/cannamanage/api/dto/finance/UpdateFeeScheduleRequest.java b/cannamanage-api/src/main/java/de/cannamanage/api/dto/finance/UpdateFeeScheduleRequest.java new file mode 100644 index 0000000..88f849f --- /dev/null +++ b/cannamanage-api/src/main/java/de/cannamanage/api/dto/finance/UpdateFeeScheduleRequest.java @@ -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 +) {} diff --git a/cannamanage-api/src/main/java/de/cannamanage/api/dto/finance/VoidPaymentRequest.java b/cannamanage-api/src/main/java/de/cannamanage/api/dto/finance/VoidPaymentRequest.java new file mode 100644 index 0000000..9ed20ad --- /dev/null +++ b/cannamanage-api/src/main/java/de/cannamanage/api/dto/finance/VoidPaymentRequest.java @@ -0,0 +1,7 @@ +package de.cannamanage.api.dto.finance; + +import jakarta.validation.constraints.NotBlank; + +public record VoidPaymentRequest( + @NotBlank String reason +) {} diff --git a/cannamanage-api/src/main/resources/db/migration/V18__treasury.sql b/cannamanage-api/src/main/resources/db/migration/V18__treasury.sql new file mode 100644 index 0000000..0da699e --- /dev/null +++ b/cannamanage-api/src/main/resources/db/migration/V18__treasury.sql @@ -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); diff --git a/cannamanage-domain/src/main/java/de/cannamanage/domain/entity/FeeSchedule.java b/cannamanage-domain/src/main/java/de/cannamanage/domain/entity/FeeSchedule.java new file mode 100644 index 0000000..f457ca3 --- /dev/null +++ b/cannamanage-domain/src/main/java/de/cannamanage/domain/entity/FeeSchedule.java @@ -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; } +} diff --git a/cannamanage-domain/src/main/java/de/cannamanage/domain/entity/LedgerEntry.java b/cannamanage-domain/src/main/java/de/cannamanage/domain/entity/LedgerEntry.java new file mode 100644 index 0000000..50c2453 --- /dev/null +++ b/cannamanage-domain/src/main/java/de/cannamanage/domain/entity/LedgerEntry.java @@ -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; } +} diff --git a/cannamanage-domain/src/main/java/de/cannamanage/domain/entity/MemberFeeAssignment.java b/cannamanage-domain/src/main/java/de/cannamanage/domain/entity/MemberFeeAssignment.java new file mode 100644 index 0000000..5d07ab7 --- /dev/null +++ b/cannamanage-domain/src/main/java/de/cannamanage/domain/entity/MemberFeeAssignment.java @@ -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; } +} diff --git a/cannamanage-domain/src/main/java/de/cannamanage/domain/entity/Payment.java b/cannamanage-domain/src/main/java/de/cannamanage/domain/entity/Payment.java new file mode 100644 index 0000000..8c4544e --- /dev/null +++ b/cannamanage-domain/src/main/java/de/cannamanage/domain/entity/Payment.java @@ -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; } +} diff --git a/cannamanage-domain/src/main/java/de/cannamanage/domain/enums/AuditEventType.java b/cannamanage-domain/src/main/java/de/cannamanage/domain/enums/AuditEventType.java index 6cfca9d..970fd63 100644 --- a/cannamanage-domain/src/main/java/de/cannamanage/domain/enums/AuditEventType.java +++ b/cannamanage-domain/src/main/java/de/cannamanage/domain/enums/AuditEventType.java @@ -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 } diff --git a/cannamanage-domain/src/main/java/de/cannamanage/domain/enums/ExpenseCategory.java b/cannamanage-domain/src/main/java/de/cannamanage/domain/enums/ExpenseCategory.java new file mode 100644 index 0000000..b428113 --- /dev/null +++ b/cannamanage-domain/src/main/java/de/cannamanage/domain/enums/ExpenseCategory.java @@ -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 +} diff --git a/cannamanage-domain/src/main/java/de/cannamanage/domain/enums/FeeInterval.java b/cannamanage-domain/src/main/java/de/cannamanage/domain/enums/FeeInterval.java new file mode 100644 index 0000000..77090cf --- /dev/null +++ b/cannamanage-domain/src/main/java/de/cannamanage/domain/enums/FeeInterval.java @@ -0,0 +1,11 @@ +package de.cannamanage.domain.enums; + +/** + * Billing intervals for fee schedules (Beitragsordnung). + */ +public enum FeeInterval { + MONTHLY, + QUARTERLY, + SEMI_ANNUAL, + ANNUAL +} diff --git a/cannamanage-domain/src/main/java/de/cannamanage/domain/enums/NotificationType.java b/cannamanage-domain/src/main/java/de/cannamanage/domain/enums/NotificationType.java index b7e0607..86323e0 100644 --- a/cannamanage-domain/src/main/java/de/cannamanage/domain/enums/NotificationType.java +++ b/cannamanage-domain/src/main/java/de/cannamanage/domain/enums/NotificationType.java @@ -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 } diff --git a/cannamanage-domain/src/main/java/de/cannamanage/domain/enums/PaymentMethod.java b/cannamanage-domain/src/main/java/de/cannamanage/domain/enums/PaymentMethod.java new file mode 100644 index 0000000..9dfccef --- /dev/null +++ b/cannamanage-domain/src/main/java/de/cannamanage/domain/enums/PaymentMethod.java @@ -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 +} diff --git a/cannamanage-domain/src/main/java/de/cannamanage/domain/enums/PaymentStatus.java b/cannamanage-domain/src/main/java/de/cannamanage/domain/enums/PaymentStatus.java new file mode 100644 index 0000000..d81e688 --- /dev/null +++ b/cannamanage-domain/src/main/java/de/cannamanage/domain/enums/PaymentStatus.java @@ -0,0 +1,11 @@ +package de.cannamanage.domain.enums; + +/** + * Status of a member payment. + */ +public enum PaymentStatus { + PAID, + OVERDUE, + PENDING, + VOIDED +} diff --git a/cannamanage-domain/src/main/java/de/cannamanage/domain/enums/StaffPermission.java b/cannamanage-domain/src/main/java/de/cannamanage/domain/enums/StaffPermission.java index 20396a2..fbb6859 100644 --- a/cannamanage-domain/src/main/java/de/cannamanage/domain/enums/StaffPermission.java +++ b/cannamanage-domain/src/main/java/de/cannamanage/domain/enums/StaffPermission.java @@ -17,5 +17,8 @@ public enum StaffPermission { // Sprint 7: SEND_NOTIFICATIONS, MANAGE_INFO_BOARD, - MODERATE_FORUM + MODERATE_FORUM, + // Sprint 8: + MANAGE_FINANCES, + VIEW_FINANCES } diff --git a/cannamanage-domain/src/main/java/de/cannamanage/domain/enums/TransactionType.java b/cannamanage-domain/src/main/java/de/cannamanage/domain/enums/TransactionType.java new file mode 100644 index 0000000..368e855 --- /dev/null +++ b/cannamanage-domain/src/main/java/de/cannamanage/domain/enums/TransactionType.java @@ -0,0 +1,9 @@ +package de.cannamanage.domain.enums; + +/** + * Type of ledger entry — income or expense. + */ +public enum TransactionType { + INCOME, + EXPENSE +} diff --git a/cannamanage-service/src/main/java/de/cannamanage/service/FinanceService.java b/cannamanage-service/src/main/java/de/cannamanage/service/FinanceService.java new file mode 100644 index 0000000..76c2804 --- /dev/null +++ b/cannamanage-service/src/main/java/de/cannamanage/service/FinanceService.java @@ -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 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 getPayments(UUID clubId, Pageable pageable) { + return paymentRepository.findByClubId(clubId, pageable); + } + + public Page getPaymentsByMember(UUID clubId, UUID memberId, Pageable pageable) { + return paymentRepository.findByClubIdAndMemberId(clubId, memberId, pageable); + } + + public Page 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 getLedgerEntries(UUID clubId, LocalDate from, LocalDate to, Pageable pageable) { + return ledgerEntryRepository.findByClubIdAndTransactionDateBetween(clubId, from, to, pageable); + } + + // === Financial Summary === + + public Map 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 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 getMemberBalance(UUID clubId, UUID memberId) { + Long totalPaid = paymentRepository.sumPaidByMember(clubId, memberId); + + Map 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> getOutstandingMembers(UUID clubId) { + List activeAssignments = assignmentRepository.findByClubIdAndValidToIsNull(clubId); + List> 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 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; + } +} diff --git a/cannamanage-service/src/main/java/de/cannamanage/service/repository/FeeScheduleRepository.java b/cannamanage-service/src/main/java/de/cannamanage/service/repository/FeeScheduleRepository.java new file mode 100644 index 0000000..2223834 --- /dev/null +++ b/cannamanage-service/src/main/java/de/cannamanage/service/repository/FeeScheduleRepository.java @@ -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 { + + List findByClubIdAndIsActiveTrue(UUID clubId); + + List findByClubId(UUID clubId); + + Optional findByClubIdAndIsDefaultTrue(UUID clubId); +} diff --git a/cannamanage-service/src/main/java/de/cannamanage/service/repository/LedgerEntryRepository.java b/cannamanage-service/src/main/java/de/cannamanage/service/repository/LedgerEntryRepository.java new file mode 100644 index 0000000..e25aff3 --- /dev/null +++ b/cannamanage-service/src/main/java/de/cannamanage/service/repository/LedgerEntryRepository.java @@ -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 { + + Page findByClubIdAndTransactionDateBetween(UUID clubId, LocalDate from, LocalDate to, Pageable pageable); + + List 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); +} diff --git a/cannamanage-service/src/main/java/de/cannamanage/service/repository/MemberFeeAssignmentRepository.java b/cannamanage-service/src/main/java/de/cannamanage/service/repository/MemberFeeAssignmentRepository.java new file mode 100644 index 0000000..da9d7d7 --- /dev/null +++ b/cannamanage-service/src/main/java/de/cannamanage/service/repository/MemberFeeAssignmentRepository.java @@ -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 { + + Optional findByMemberIdAndValidToIsNull(UUID memberId); + + List findByClubId(UUID clubId); + + List findByClubIdAndValidToIsNull(UUID clubId); +} diff --git a/cannamanage-service/src/main/java/de/cannamanage/service/repository/PaymentRepository.java b/cannamanage-service/src/main/java/de/cannamanage/service/repository/PaymentRepository.java new file mode 100644 index 0000000..fd2690b --- /dev/null +++ b/cannamanage-service/src/main/java/de/cannamanage/service/repository/PaymentRepository.java @@ -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 { + + List findByClubIdAndMemberId(UUID clubId, UUID memberId); + + Page findByClubId(UUID clubId, Pageable pageable); + + Page findByClubIdAndMemberId(UUID clubId, UUID memberId, Pageable pageable); + + Page 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 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); +}