diff --git a/cannamanage-api/src/main/java/de/cannamanage/api/controller/BankImportController.java b/cannamanage-api/src/main/java/de/cannamanage/api/controller/BankImportController.java new file mode 100644 index 0000000..76d8f6c --- /dev/null +++ b/cannamanage-api/src/main/java/de/cannamanage/api/controller/BankImportController.java @@ -0,0 +1,314 @@ +package de.cannamanage.api.controller; + +import de.cannamanage.api.dto.bankimport.*; +import de.cannamanage.api.security.StaffPermissionChecker; +import de.cannamanage.domain.entity.BankImportSession; +import de.cannamanage.domain.entity.BankTransaction; +import de.cannamanage.domain.entity.CsvColumnMapping; +import de.cannamanage.domain.entity.TenantContext; +import de.cannamanage.domain.enums.MatchStatus; +import de.cannamanage.domain.enums.StaffPermission; +import de.cannamanage.service.bankimport.BankImportService; +import de.cannamanage.service.repository.CsvColumnMappingRepository; +import jakarta.validation.Valid; +import lombok.extern.slf4j.Slf4j; +import org.springframework.http.HttpStatus; +import org.springframework.http.MediaType; +import org.springframework.http.ResponseEntity; +import org.springframework.security.access.AccessDeniedException; +import org.springframework.security.core.annotation.AuthenticationPrincipal; +import org.springframework.security.core.userdetails.UserDetails; +import org.springframework.web.bind.annotation.*; +import org.springframework.web.multipart.MultipartFile; +import org.springframework.web.server.ResponseStatusException; + +import java.util.List; +import java.util.Optional; +import java.util.UUID; + +/** + * Sprint 10 Phase 3 — REST endpoints for the bank statement import wizard. + * + *

All endpoints live under {@code /api/v1/finance/import/*}. Access requires + * either {@link StaffPermission#FINANCE_IMPORT} or {@link StaffPermission#MANAGE_FINANCES} + * (ADMIN role always passes). Tenant scoping is implicit via {@link TenantContext}. + * + *

Endpoint overview: + *

+ */ +@Slf4j +@RestController +@RequestMapping("/api/v1") +public class BankImportController { + + private final BankImportService bankImportService; + private final StaffPermissionChecker permissionChecker; + private final CsvColumnMappingRepository mappingRepository; + + public BankImportController(BankImportService bankImportService, + StaffPermissionChecker permissionChecker, + CsvColumnMappingRepository mappingRepository) { + this.bankImportService = bankImportService; + this.permissionChecker = permissionChecker; + this.mappingRepository = mappingRepository; + } + + // === Sessions === + + /** + * Upload a bank statement file and parse it. Returns the persisted session with + * matching results so the frontend can immediately render the review table. + */ + @PostMapping(value = "/finance/import/sessions", consumes = MediaType.MULTIPART_FORM_DATA_VALUE) + public ResponseEntity uploadSession( + @RequestParam("file") MultipartFile file, + @RequestParam(value = "mappingId", required = false) UUID mappingId, + @AuthenticationPrincipal UserDetails principal) { + + requireImportPermission(principal); + UUID clubId = TenantContext.getCurrentTenant(); + UUID userId = UUID.fromString(principal.getUsername()); + + CsvColumnMapping mapping = null; + if (mappingId != null) { + mapping = mappingRepository.findById(mappingId) + .filter(m -> clubId.equals(m.getClubId())) + .orElseThrow(() -> new ResponseStatusException( + HttpStatus.NOT_FOUND, "CSV-Vorlage nicht gefunden.")); + } + + BankImportSession session = bankImportService.uploadAndParse(clubId, userId, file, mapping); + return ResponseEntity.status(HttpStatus.CREATED).body(ImportSessionResponse.from(session)); + } + + /** List all import sessions for the current tenant, newest first. */ + @GetMapping("/finance/import/sessions") + public ResponseEntity> listSessions(@AuthenticationPrincipal UserDetails principal) { + requireImportPermission(principal); + UUID clubId = TenantContext.getCurrentTenant(); + List sessions = bankImportService.getSessions(clubId).stream() + .map(ImportSessionResponse::from) + .toList(); + return ResponseEntity.ok(sessions); + } + + /** Detail view of a single session. */ + @GetMapping("/finance/import/sessions/{id}") + public ResponseEntity getSession(@PathVariable UUID id, + @AuthenticationPrincipal UserDetails principal) { + requireImportPermission(principal); + BankImportSession session = bankImportService.getSession(id); + ensureSameTenant(session.getClubId()); + return ResponseEntity.ok(ImportSessionResponse.from(session)); + } + + /** + * Transactions belonging to a session, optionally filtered by match status. + * Drives the review table (typically called with {@code ?status=MATCHED} then + * with no filter for the full audit listing). + */ + @GetMapping("/finance/import/sessions/{id}/transactions") + public ResponseEntity> listTransactions( + @PathVariable UUID id, + @RequestParam(value = "status", required = false) MatchStatus status, + @AuthenticationPrincipal UserDetails principal) { + + requireImportPermission(principal); + BankImportSession session = bankImportService.getSession(id); + ensureSameTenant(session.getClubId()); + + List txns = bankImportService.getTransactions(id, status).stream() + .map(TransactionResponse::from) + .toList(); + return ResponseEntity.ok(txns); + } + + /** Confirm a single matched transaction → creates a {@code Payment} via {@code FinanceService}. */ + @PostMapping("/finance/import/sessions/{id}/transactions/{txnId}/confirm") + public ResponseEntity confirmMatch( + @PathVariable UUID id, + @PathVariable UUID txnId, + @Valid @RequestBody ConfirmRequest request, + @AuthenticationPrincipal UserDetails principal) { + + requireImportPermission(principal); + ensureSameTenant(bankImportService.getSession(id).getClubId()); + UUID userId = UUID.fromString(principal.getUsername()); + + BankTransaction txn = bankImportService.confirmMatch(id, txnId, request.memberId(), userId); + return ResponseEntity.ok(TransactionResponse.from(txn)); + } + + /** Bulk-confirm every {@code MATCHED} transaction with confidence ≥ 90 in the session. */ + @PostMapping("/finance/import/sessions/{id}/confirm-all") + public ResponseEntity confirmAll(@PathVariable UUID id, + @AuthenticationPrincipal UserDetails principal) { + requireImportPermission(principal); + ensureSameTenant(bankImportService.getSession(id).getClubId()); + UUID userId = UUID.fromString(principal.getUsername()); + + BankImportService.BulkConfirmResult result = bankImportService.confirmAllMatched(id, userId); + return ResponseEntity.ok(BulkConfirmResponse.from(result)); + } + + /** Manual assignment for unmatched transactions — sets {@code MATCHED} 100% but does NOT create a Payment yet. */ + @PostMapping("/finance/import/sessions/{id}/transactions/{txnId}/assign") + public ResponseEntity assignManually( + @PathVariable UUID id, + @PathVariable UUID txnId, + @Valid @RequestBody AssignRequest request, + @AuthenticationPrincipal UserDetails principal) { + + requireImportPermission(principal); + ensureSameTenant(bankImportService.getSession(id).getClubId()); + UUID userId = UUID.fromString(principal.getUsername()); + + BankTransaction txn = bankImportService.manualAssign(id, txnId, request.memberId(), userId); + return ResponseEntity.ok(TransactionResponse.from(txn)); + } + + /** Skip a transaction (e.g. refund, fee, non-member deposit) — stored with reason for audit trail. */ + @PostMapping("/finance/import/sessions/{id}/transactions/{txnId}/skip") + public ResponseEntity skipTransaction( + @PathVariable UUID id, + @PathVariable UUID txnId, + @RequestBody(required = false) SkipRequest request, + @AuthenticationPrincipal UserDetails principal) { + + requireImportPermission(principal); + ensureSameTenant(bankImportService.getSession(id).getClubId()); + UUID userId = UUID.fromString(principal.getUsername()); + String reason = request != null ? request.reason() : null; + + BankTransaction txn = bankImportService.skipTransaction(id, txnId, reason, userId); + return ResponseEntity.ok(TransactionResponse.from(txn)); + } + + /** Seal the session — sets status {@code COMPLETED}, after which no further mutations are permitted (GoBD §147 AO). */ + @PostMapping("/finance/import/sessions/{id}/complete") + public ResponseEntity completeSession(@PathVariable UUID id, + @AuthenticationPrincipal UserDetails principal) { + requireImportPermission(principal); + ensureSameTenant(bankImportService.getSession(id).getClubId()); + UUID userId = UUID.fromString(principal.getUsername()); + + BankImportSession session = bankImportService.completeSession(id, userId); + return ResponseEntity.ok(ImportSessionResponse.from(session)); + } + + // === CSV Column Mappings === + + /** List saved CSV mapping templates for the current tenant. */ + @GetMapping("/finance/import/csv-mappings") + public ResponseEntity> listMappings(@AuthenticationPrincipal UserDetails principal) { + requireImportPermission(principal); + UUID clubId = TenantContext.getCurrentTenant(); + return ResponseEntity.ok(mappingRepository.findByClubId(clubId)); + } + + /** + * Create a new CSV mapping template. If {@code isDefault} is true, the existing + * default mapping (if any) is cleared so only one template stays default per club. + */ + @PostMapping("/finance/import/csv-mappings") + public ResponseEntity createMapping(@Valid @RequestBody CreateMappingRequest request, + @AuthenticationPrincipal UserDetails principal) { + requireImportPermission(principal); + UUID clubId = TenantContext.getCurrentTenant(); + + CsvColumnMapping mapping = new CsvColumnMapping(); + mapping.setClubId(clubId); + mapping.setName(request.name()); + mapping.setDateColumn(request.dateColumn()); + mapping.setAmountColumn(request.amountColumn()); + mapping.setReferenceColumn(request.referenceColumn()); + mapping.setCounterpartyColumn(request.counterpartyColumn()); + mapping.setIbanColumn(request.ibanColumn()); + if (request.delimiter() != null) { + mapping.setDelimiter(request.delimiter()); + } + if (request.dateFormat() != null) { + mapping.setDateFormat(request.dateFormat()); + } + if (request.decimalSeparator() != null) { + mapping.setDecimalSeparator(request.decimalSeparator()); + } + if (request.skipHeaderRows() != null) { + mapping.setSkipHeaderRows(request.skipHeaderRows()); + } + if (request.encoding() != null) { + mapping.setEncoding(request.encoding()); + } + boolean wantsDefault = Boolean.TRUE.equals(request.isDefault()); + mapping.setIsDefault(wantsDefault); + + if (wantsDefault) { + Optional existingDefault = mappingRepository.findByClubIdAndIsDefaultTrue(clubId); + existingDefault.ifPresent(existing -> { + existing.setIsDefault(false); + mappingRepository.save(existing); + }); + } + + CsvColumnMapping saved = mappingRepository.save(mapping); + log.info("CSV mapping created: id={} name={} club={}", saved.getId(), saved.getName(), clubId); + return ResponseEntity.status(HttpStatus.CREATED).body(saved); + } + + /** Delete a CSV mapping template — only the owner tenant may delete. */ + @DeleteMapping("/finance/import/csv-mappings/{id}") + public ResponseEntity deleteMapping(@PathVariable UUID id, + @AuthenticationPrincipal UserDetails principal) { + requireImportPermission(principal); + UUID clubId = TenantContext.getCurrentTenant(); + + CsvColumnMapping mapping = mappingRepository.findById(id) + .filter(m -> clubId.equals(m.getClubId())) + .orElseThrow(() -> new ResponseStatusException( + HttpStatus.NOT_FOUND, "CSV-Vorlage nicht gefunden.")); + + mappingRepository.delete(mapping); + log.info("CSV mapping deleted: id={} club={}", id, clubId); + return ResponseEntity.noContent().build(); + } + + // === Helpers === + + /** + * Permission gate that accepts either {@link StaffPermission#FINANCE_IMPORT} or + * {@link StaffPermission#MANAGE_FINANCES}. ADMIN passes both automatically inside + * {@link StaffPermissionChecker}. + */ + private void requireImportPermission(UserDetails principal) { + try { + permissionChecker.requirePermission(principal, StaffPermission.FINANCE_IMPORT); + } catch (AccessDeniedException denied) { + // Fall back to MANAGE_FINANCES — finance admins are implicitly authorized. + permissionChecker.requirePermission(principal, StaffPermission.MANAGE_FINANCES); + } + } + + /** + * Defence-in-depth tenant check on top of Hibernate {@code @Filter} — + * ensures path-parameter IDs from one tenant cannot reach another tenant's session. + */ + private void ensureSameTenant(UUID sessionClubId) { + UUID currentTenant = TenantContext.getCurrentTenant(); + if (sessionClubId == null || !sessionClubId.equals(currentTenant)) { + throw new ResponseStatusException(HttpStatus.NOT_FOUND, "Import-Session nicht gefunden."); + } + } +} diff --git a/cannamanage-api/src/main/java/de/cannamanage/api/dto/bankimport/AssignRequest.java b/cannamanage-api/src/main/java/de/cannamanage/api/dto/bankimport/AssignRequest.java new file mode 100644 index 0000000..e60cd33 --- /dev/null +++ b/cannamanage-api/src/main/java/de/cannamanage/api/dto/bankimport/AssignRequest.java @@ -0,0 +1,13 @@ +package de.cannamanage.api.dto.bankimport; + +import jakarta.validation.constraints.NotNull; + +import java.util.UUID; + +/** + * Sprint 10 Phase 3 — Request body for {@code POST /sessions/{id}/transactions/{txnId}/assign}. + * Used by the admin to manually attach a transaction to a member the matching engine missed. + */ +public record AssignRequest( + @NotNull UUID memberId +) {} diff --git a/cannamanage-api/src/main/java/de/cannamanage/api/dto/bankimport/BulkConfirmResponse.java b/cannamanage-api/src/main/java/de/cannamanage/api/dto/bankimport/BulkConfirmResponse.java new file mode 100644 index 0000000..82332ad --- /dev/null +++ b/cannamanage-api/src/main/java/de/cannamanage/api/dto/bankimport/BulkConfirmResponse.java @@ -0,0 +1,19 @@ +package de.cannamanage.api.dto.bankimport; + +import de.cannamanage.service.bankimport.BankImportService.BulkConfirmResult; + +/** + * Sprint 10 Phase 3 — Response of {@code POST /sessions/{id}/confirm-all}. + * Surfaces the number of transactions that were confirmed, skipped (low confidence / + * already confirmed) and failed (e.g. payment creation error) so the UI can give clear feedback. + */ +public record BulkConfirmResponse( + int confirmed, + int skipped, + int failed, + int total +) { + public static BulkConfirmResponse from(BulkConfirmResult r) { + return new BulkConfirmResponse(r.confirmed(), r.skipped(), r.failed(), r.total()); + } +} diff --git a/cannamanage-api/src/main/java/de/cannamanage/api/dto/bankimport/ConfirmRequest.java b/cannamanage-api/src/main/java/de/cannamanage/api/dto/bankimport/ConfirmRequest.java new file mode 100644 index 0000000..22bfadf --- /dev/null +++ b/cannamanage-api/src/main/java/de/cannamanage/api/dto/bankimport/ConfirmRequest.java @@ -0,0 +1,15 @@ +package de.cannamanage.api.dto.bankimport; + +import jakarta.validation.constraints.NotNull; + +import java.util.UUID; + +/** + * Sprint 10 Phase 3 — Request body for {@code POST /sessions/{id}/transactions/{txnId}/confirm}. + *

+ * The {@code memberId} is required so the caller explicitly acknowledges which member receives + * the payment, even when the matching engine had already pre-selected one. + */ +public record ConfirmRequest( + @NotNull UUID memberId +) {} diff --git a/cannamanage-api/src/main/java/de/cannamanage/api/dto/bankimport/CreateMappingRequest.java b/cannamanage-api/src/main/java/de/cannamanage/api/dto/bankimport/CreateMappingRequest.java new file mode 100644 index 0000000..8871531 --- /dev/null +++ b/cannamanage-api/src/main/java/de/cannamanage/api/dto/bankimport/CreateMappingRequest.java @@ -0,0 +1,26 @@ +package de.cannamanage.api.dto.bankimport; + +import jakarta.validation.constraints.Max; +import jakarta.validation.constraints.Min; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.Size; + +/** + * Sprint 10 Phase 3 — Request body for {@code POST /finance/import/csv-mappings}. + * Captures the column layout of a club-specific CSV bank export so future imports can + * be parsed without re-mapping. + */ +public record CreateMappingRequest( + @NotBlank @Size(max = 100) String name, + @Min(0) @Max(50) int dateColumn, + @Min(0) @Max(50) int amountColumn, + @Min(0) @Max(50) int referenceColumn, + Integer counterpartyColumn, + Integer ibanColumn, + @Size(max = 4) String delimiter, + @Size(max = 32) String dateFormat, + @Size(max = 2) String decimalSeparator, + @Min(0) @Max(20) Integer skipHeaderRows, + @Size(max = 32) String encoding, + Boolean isDefault +) {} diff --git a/cannamanage-api/src/main/java/de/cannamanage/api/dto/bankimport/ImportSessionResponse.java b/cannamanage-api/src/main/java/de/cannamanage/api/dto/bankimport/ImportSessionResponse.java new file mode 100644 index 0000000..0b19e44 --- /dev/null +++ b/cannamanage-api/src/main/java/de/cannamanage/api/dto/bankimport/ImportSessionResponse.java @@ -0,0 +1,46 @@ +package de.cannamanage.api.dto.bankimport; + +import de.cannamanage.domain.entity.BankImportSession; +import de.cannamanage.domain.enums.BankFormat; +import de.cannamanage.domain.enums.ImportSessionStatus; + +import java.time.Instant; +import java.util.UUID; + +/** + * Sprint 10 Phase 3 — Summary projection of a {@code BankImportSession} for list views. + * Excludes large/sensitive fields ({@code fileHash}, {@code errorMessage} stays). + */ +public record ImportSessionResponse( + UUID id, + UUID clubId, + String filename, + BankFormat format, + ImportSessionStatus status, + Integer totalTransactions, + Integer matchedCount, + Integer confirmedCount, + Integer skippedCount, + UUID uploadedBy, + String errorMessage, + Instant createdAt, + Instant completedAt +) { + public static ImportSessionResponse from(BankImportSession s) { + return new ImportSessionResponse( + s.getId(), + s.getClubId(), + s.getFilename(), + s.getFormat(), + s.getStatus(), + s.getTotalTransactions(), + s.getMatchedCount(), + s.getConfirmedCount(), + s.getSkippedCount(), + s.getUploadedBy(), + s.getErrorMessage(), + s.getCreatedAt(), + s.getCompletedAt() + ); + } +} diff --git a/cannamanage-api/src/main/java/de/cannamanage/api/dto/bankimport/SkipRequest.java b/cannamanage-api/src/main/java/de/cannamanage/api/dto/bankimport/SkipRequest.java new file mode 100644 index 0000000..25dfe7c --- /dev/null +++ b/cannamanage-api/src/main/java/de/cannamanage/api/dto/bankimport/SkipRequest.java @@ -0,0 +1,9 @@ +package de.cannamanage.api.dto.bankimport; + +/** + * Sprint 10 Phase 3 — Optional request body for {@code POST /sessions/{id}/transactions/{txnId}/skip}. + * The {@code reason} field is free text shown in the audit log and review history. + */ +public record SkipRequest( + String reason +) {} diff --git a/cannamanage-api/src/main/java/de/cannamanage/api/dto/bankimport/TransactionResponse.java b/cannamanage-api/src/main/java/de/cannamanage/api/dto/bankimport/TransactionResponse.java new file mode 100644 index 0000000..a164b39 --- /dev/null +++ b/cannamanage-api/src/main/java/de/cannamanage/api/dto/bankimport/TransactionResponse.java @@ -0,0 +1,48 @@ +package de.cannamanage.api.dto.bankimport; + +import de.cannamanage.domain.entity.BankTransaction; +import de.cannamanage.domain.enums.MatchStatus; + +import java.time.LocalDate; +import java.util.UUID; + +/** + * Sprint 10 Phase 3 — Single bank-statement transaction shown in the review wizard. + */ +public record TransactionResponse( + UUID id, + UUID sessionId, + LocalDate bookingDate, + LocalDate valueDate, + Integer amountCents, + String currency, + String referenceText, + String counterpartyName, + String counterpartyIban, + String bankReference, + MatchStatus matchStatus, + Integer matchConfidence, + UUID matchedMemberId, + UUID matchedPaymentId, + String skipReason +) { + public static TransactionResponse from(BankTransaction t) { + return new TransactionResponse( + t.getId(), + t.getSessionId(), + t.getBookingDate(), + t.getValueDate(), + t.getAmountCents(), + t.getCurrency(), + t.getReferenceText(), + t.getCounterpartyName(), + t.getCounterpartyIban(), + t.getBankReference(), + t.getMatchStatus(), + t.getMatchConfidence(), + t.getMatchedMemberId(), + t.getMatchedPaymentId(), + t.getSkipReason() + ); + } +} diff --git a/cannamanage-api/src/main/resources/application.properties b/cannamanage-api/src/main/resources/application.properties index 82d7173..860b09b 100644 --- a/cannamanage-api/src/main/resources/application.properties +++ b/cannamanage-api/src/main/resources/application.properties @@ -43,3 +43,8 @@ server.servlet.session.cookie.same-site=strict cannamanage.schedulers.enabled=${SCHEDULERS_ENABLED:true} server.servlet.session.cookie.http-only=true server.servlet.session.cookie.secure=${SESSION_COOKIE_SECURE:false} + +# Bank import file upload (Sprint 10) — limit 5MB, hard cap enforced in BankImportService too +spring.servlet.multipart.enabled=true +spring.servlet.multipart.max-file-size=5MB +spring.servlet.multipart.max-request-size=6MB diff --git a/cannamanage-api/src/main/resources/db/migration/V33__bank_import_file_hash.sql b/cannamanage-api/src/main/resources/db/migration/V33__bank_import_file_hash.sql new file mode 100644 index 0000000..e2055dd --- /dev/null +++ b/cannamanage-api/src/main/resources/db/migration/V33__bank_import_file_hash.sql @@ -0,0 +1,11 @@ +-- Sprint 10 Phase 3: Add SHA-256 file hash column for stronger duplicate-import detection. +-- The Phase 1 filename-based check is kept as a soft warning; the hash provides hard 409 dedup +-- (a renamed copy of the same file is still detected). +ALTER TABLE bank_import_sessions + ADD COLUMN file_hash VARCHAR(64); + +-- Unique per club to allow the same file to be imported by different tenants in DEV/QA. +-- NULL values are allowed for legacy rows created before V33. +CREATE UNIQUE INDEX uk_bank_import_sessions_club_hash + ON bank_import_sessions(club_id, file_hash) + WHERE file_hash IS NOT NULL; diff --git a/cannamanage-domain/src/main/java/de/cannamanage/domain/entity/BankImportSession.java b/cannamanage-domain/src/main/java/de/cannamanage/domain/entity/BankImportSession.java index 5a50fe4..caf1685 100644 --- a/cannamanage-domain/src/main/java/de/cannamanage/domain/entity/BankImportSession.java +++ b/cannamanage-domain/src/main/java/de/cannamanage/domain/entity/BankImportSession.java @@ -54,6 +54,14 @@ public class BankImportSession extends AbstractTenantEntity { @Column(name = "completed_at") private Instant completedAt; + /** + * SHA-256 hex digest of the uploaded file content (Sprint 10 Phase 3). + * Used together with {@code clubId} to reject byte-identical re-uploads (HTTP 409), + * even when the file has been renamed. Nullable for legacy rows created before V33. + */ + @Column(name = "file_hash", length = 64) + private String fileHash; + // Getters and setters public UUID getClubId() { return clubId; } @@ -88,4 +96,7 @@ public class BankImportSession extends AbstractTenantEntity { public Instant getCompletedAt() { return completedAt; } public void setCompletedAt(Instant completedAt) { this.completedAt = completedAt; } + + public String getFileHash() { return fileHash; } + public void setFileHash(String fileHash) { this.fileHash = fileHash; } } diff --git a/cannamanage-service/src/main/java/de/cannamanage/service/bankimport/BankImportService.java b/cannamanage-service/src/main/java/de/cannamanage/service/bankimport/BankImportService.java new file mode 100644 index 0000000..dbf7c5c --- /dev/null +++ b/cannamanage-service/src/main/java/de/cannamanage/service/bankimport/BankImportService.java @@ -0,0 +1,538 @@ +package de.cannamanage.service.bankimport; + +import de.cannamanage.domain.entity.BankImportSession; +import de.cannamanage.domain.entity.BankTransaction; +import de.cannamanage.domain.entity.CsvColumnMapping; +import de.cannamanage.domain.entity.Member; +import de.cannamanage.domain.entity.Payment; +import de.cannamanage.domain.enums.AuditEventType; +import de.cannamanage.domain.enums.BankFormat; +import de.cannamanage.domain.enums.ImportSessionStatus; +import de.cannamanage.domain.enums.MatchStatus; +import de.cannamanage.domain.enums.NotificationType; +import de.cannamanage.domain.enums.PaymentMethod; +import de.cannamanage.service.AuditService; +import de.cannamanage.service.FinanceService; +import de.cannamanage.service.NotificationService; +import de.cannamanage.service.repository.BankImportSessionRepository; +import de.cannamanage.service.repository.BankTransactionRepository; +import de.cannamanage.service.repository.MemberRepository; +import lombok.extern.slf4j.Slf4j; +import org.apache.commons.codec.digest.DigestUtils; +import org.apache.commons.io.FilenameUtils; +import org.springframework.http.HttpStatus; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; +import org.springframework.web.multipart.MultipartFile; +import org.springframework.web.server.ResponseStatusException; + +import java.io.ByteArrayInputStream; +import java.io.IOException; +import java.time.Instant; +import java.time.LocalDate; +import java.util.List; +import java.util.NoSuchElementException; +import java.util.Optional; +import java.util.UUID; + +/** + * Sprint 10 Phase 3 — Orchestrator for bank statement import. + *

+ * Coordinates the full import workflow: + *

    + *
  1. {@link #uploadAndParse} — validate, hash, dedup, parse, persist, match
  2. + *
  3. review phase (UI calls {@link #getTransactions} repeatedly)
  4. + *
  5. {@link #confirmMatch} / {@link #skipTransaction} / {@link #manualAssign} per transaction + * (or {@link #confirmAllMatched} as bulk operation for high-confidence MATCHED entries)
  6. + *
  7. {@link #completeSession} — finalize. After COMPLETED the session is immutable per GoBD §147 AO.
  8. + *
+ *

+ * Transactional boundary design: the public {@link #uploadAndParse} method is + * deliberately not {@link Transactional}. File I/O, hashing and parsing happen outside + * the transaction; the {@link #persistParseResults} helper opens a fresh write transaction only + * once the data is fully prepared. This keeps long-running parses from holding DB locks. + *

+ * Tenant scoping: all queries pass through Hibernate's {@code tenantFilter}, + * so {@code clubId} is an additional safety constraint and never a security boundary. + */ +@Slf4j +@Service +public class BankImportService { + + /** Hard cap (5 MB) — matches {@code spring.servlet.multipart.max-file-size}. */ + public static final long MAX_FILE_SIZE_BYTES = 5L * 1024 * 1024; + + /** Confidence cutoff above which a match is considered "MATCHED" (eligible for bulk-confirm). */ + public static final int MATCHED_CONFIDENCE_THRESHOLD = 90; + + private final BankImportSessionRepository sessionRepository; + private final BankTransactionRepository transactionRepository; + private final MemberRepository memberRepository; + private final BankStatementParserService parserService; + private final PaymentMatchingService matchingService; + private final FinanceService financeService; + private final AuditService auditService; + private final NotificationService notificationService; + + public BankImportService(BankImportSessionRepository sessionRepository, + BankTransactionRepository transactionRepository, + MemberRepository memberRepository, + BankStatementParserService parserService, + PaymentMatchingService matchingService, + FinanceService financeService, + AuditService auditService, + NotificationService notificationService) { + this.sessionRepository = sessionRepository; + this.transactionRepository = transactionRepository; + this.memberRepository = memberRepository; + this.parserService = parserService; + this.matchingService = matchingService; + this.financeService = financeService; + this.auditService = auditService; + this.notificationService = notificationService; + } + + // =================================================================================== + // Upload + parse + match + // =================================================================================== + + /** + * Uploads a bank statement file, parses it, persists the resulting session and transactions, + * and runs the auto-matching engine against the club's membership. + *

+ * The session is left in {@link ImportSessionStatus#IN_REVIEW} ready for admin confirmation. + *

+ * Validation: + *

    + *
  • File must be non-empty and ≤ {@value #MAX_FILE_SIZE_BYTES} bytes (HTTP 400)
  • + *
  • SHA-256 hash must not collide with an existing session in the same club (HTTP 409)
  • + *
  • File format must be recognized (HTTP 400)
  • + *
+ * + * @param clubId target club (tenant) + * @param userId acting user (admin or staff with {@code FINANCE_IMPORT}) + * @param file uploaded file + * @param mapping optional CSV column mapping; required iff file is detected as CSV + * @return the persisted session in {@code IN_REVIEW} state (or {@code FAILED} on parse error) + */ + public BankImportSession uploadAndParse(UUID clubId, UUID userId, MultipartFile file, CsvColumnMapping mapping) { + // --- Step 1: cheap validation (outside transaction) ----------------------------- + if (file == null || file.isEmpty()) { + throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "Die hochgeladene Datei ist leer."); + } + if (file.getSize() > MAX_FILE_SIZE_BYTES) { + throw new ResponseStatusException(HttpStatus.PAYLOAD_TOO_LARGE, + "Die Datei ist zu groß. Maximal 5 MB sind erlaubt."); + } + + byte[] content; + try { + content = file.getBytes(); + } catch (IOException e) { + log.error("Failed to read uploaded file for club {}", clubId, e); + throw new ResponseStatusException(HttpStatus.BAD_REQUEST, + "Die Datei konnte nicht gelesen werden."); + } + + // --- Step 2: hash + duplicate check -------------------------------------------- + String fileHash = DigestUtils.sha256Hex(content); + if (sessionRepository.existsByClubIdAndFileHash(clubId, fileHash)) { + log.warn("Duplicate bank import attempted: club={} hash={}", clubId, fileHash); + throw new ResponseStatusException(HttpStatus.CONFLICT, + "Diese Datei wurde bereits importiert."); + } + + // --- Step 3: sanitize filename -------------------------------------------------- + String safeFilename = FilenameUtils.getName( + Optional.ofNullable(file.getOriginalFilename()).orElse("upload.bin")); + if (safeFilename.length() > 255) { + safeFilename = safeFilename.substring(0, 255); + } + + // --- Step 4: detect format + parse (outside transaction) ------------------------ + BankFormat format; + ParseResult parseResult; + try { + format = parserService.detectFormat(safeFilename, content); + parseResult = parserService.parse( + new ByteArrayInputStream(content), safeFilename, format, mapping); + } catch (BankStatementParserService.UnrecognizedFormatException e) { + log.warn("Unrecognized bank file format: club={} filename={}", clubId, safeFilename); + // Persist a FAILED session so the admin sees the failed attempt in history. + persistFailedSession(clubId, userId, safeFilename, fileHash, null, + "Dateiformat nicht erkannt: " + e.getMessage()); + throw new ResponseStatusException(HttpStatus.BAD_REQUEST, + "Dateiformat nicht erkannt. Unterstützt: MT940, CAMT.053, CSV."); + } catch (RuntimeException e) { + log.error("Bank file parsing failed: club={} filename={}", clubId, safeFilename, e); + persistFailedSession(clubId, userId, safeFilename, fileHash, null, + "Fehler beim Parsen: " + e.getMessage()); + throw new ResponseStatusException(HttpStatus.BAD_REQUEST, + "Die Datei konnte nicht verarbeitet werden: " + e.getMessage()); + } + + // --- Step 5: persist session + transactions + run matching (inside transaction)- + return persistParseResults(clubId, userId, safeFilename, fileHash, format, parseResult); + } + + /** + * Inner {@code @Transactional} step: persists the session, transactions, runs the + * matching engine, updates counters, audits, and notifies the uploader. + */ + @Transactional + protected BankImportSession persistParseResults(UUID clubId, UUID userId, String filename, + String fileHash, BankFormat format, + ParseResult parseResult) { + // Create session in PENDING state + BankImportSession session = new BankImportSession(); + session.setClubId(clubId); + session.setFilename(filename); + session.setFileHash(fileHash); + session.setFormat(format); + session.setUploadedBy(userId); + session.setStatus(ImportSessionStatus.PENDING); + session.setTotalTransactions(parseResult.transactions().size()); + session = sessionRepository.save(session); + + // Audit the start + auditService.log(AuditEventType.BANK_IMPORT_STARTED, userId, + session.getId().toString(), + "Bank import started: " + filename + " (" + format + ", " + + parseResult.transactions().size() + " Transaktionen)"); + + // Run the matching engine (returns detached BankTransaction entities) + List matched = matchingService.matchTransactions( + parseResult.transactions(), clubId, session.getId()); + + // Persist all transactions in one batch + if (!matched.isEmpty()) { + transactionRepository.saveAll(matched); + } + + // Update session counters and flip to IN_REVIEW + long matchedCount = matched.stream() + .filter(t -> t.getMatchStatus() == MatchStatus.MATCHED + || t.getMatchStatus() == MatchStatus.SUGGESTED) + .count(); + session.setMatchedCount((int) matchedCount); + session.setStatus(ImportSessionStatus.IN_REVIEW); + session = sessionRepository.save(session); + + // Notify the uploader that the file is ready for review + notificationService.sendNotification( + userId, + NotificationType.BANK_IMPORT_COMPLETED, + "Bankimport bereit zur Prüfung", + "Die Datei \"" + filename + "\" wurde geparst: " + + parseResult.transactions().size() + " Transaktionen, " + + matchedCount + " automatisch zugeordnet.", + "/finance/import/" + session.getId()); + + log.info("Bank import session {} created for club {} ({} transactions, {} matched)", + session.getId(), clubId, parseResult.transactions().size(), matchedCount); + + return session; + } + + /** Persists a placeholder session for a failed parse so the admin sees the attempt in history. */ + @Transactional + protected void persistFailedSession(UUID clubId, UUID userId, String filename, String fileHash, + BankFormat format, String errorMessage) { + try { + BankImportSession session = new BankImportSession(); + session.setClubId(clubId); + session.setFilename(filename); + session.setFileHash(fileHash); + session.setFormat(format != null ? format : BankFormat.CSV); + session.setUploadedBy(userId); + session.setStatus(ImportSessionStatus.FAILED); + session.setErrorMessage(errorMessage); + session.setCompletedAt(Instant.now()); + sessionRepository.save(session); + + auditService.log(AuditEventType.BANK_IMPORT_FAILED, userId, + session.getId().toString(), + "Bank import failed: " + filename + " — " + errorMessage); + } catch (RuntimeException persistError) { + // Never let a failed-session persistence error mask the original parse failure. + log.warn("Could not persist FAILED session marker for club {}", clubId, persistError); + } + } + + // =================================================================================== + // Confirm / skip / assign + // =================================================================================== + + /** + * Confirms a single match — creates a {@link Payment} via {@link FinanceService} and links it + * back to the transaction. Idempotent: a transaction already in CONFIRMED state is rejected. + */ + @Transactional + public BankTransaction confirmMatch(UUID sessionId, UUID transactionId, UUID memberId, UUID userId) { + BankImportSession session = requireSession(sessionId); + assertSessionMutable(session); + + BankTransaction txn = requireTransaction(transactionId, sessionId); + if (txn.getMatchStatus() == MatchStatus.CONFIRMED) { + throw new ResponseStatusException(HttpStatus.CONFLICT, + "Diese Transaktion wurde bereits bestätigt."); + } + if (txn.getMatchStatus() == MatchStatus.SKIPPED) { + throw new ResponseStatusException(HttpStatus.CONFLICT, + "Diese Transaktion wurde bereits übersprungen."); + } + if (txn.getAmountCents() == null || txn.getAmountCents() <= 0) { + throw new ResponseStatusException(HttpStatus.BAD_REQUEST, + "Nur eingehende Zahlungen können als Mitgliedsbeitrag bestätigt werden."); + } + + Member member = memberRepository.findById(memberId) + .orElseThrow(() -> new ResponseStatusException(HttpStatus.NOT_FOUND, + "Mitglied nicht gefunden: " + memberId)); + if (!member.getClubId().equals(session.getClubId())) { + throw new ResponseStatusException(HttpStatus.FORBIDDEN, + "Mitglied gehört nicht zum aktuellen Verein."); + } + + // Use the transaction's booking date as the payment period (single-month coverage). + // The admin can later edit the Payment if a different period is needed. + LocalDate period = txn.getBookingDate(); + String reference = buildPaymentReference(txn); + + Payment payment = financeService.recordPayment( + session.getClubId(), memberId, txn.getAmountCents(), + PaymentMethod.BANK_TRANSFER, + period.withDayOfMonth(1), + period.withDayOfMonth(period.lengthOfMonth()), + reference, + "Importiert aus " + session.getFilename(), + userId); + + txn.setMatchedMemberId(memberId); + txn.setMatchedPaymentId(payment.getId()); + txn.setMatchStatus(MatchStatus.CONFIRMED); + BankTransaction saved = transactionRepository.save(txn); + + // Bump session confirmed counter + session.setConfirmedCount(session.getConfirmedCount() + 1); + sessionRepository.save(session); + + auditService.log(AuditEventType.BANK_PAYMENT_CONFIRMED, userId, + saved.getId().toString(), + "Bank transaction confirmed: " + txn.getAmountCents() + " cents → member " + memberId + + " (payment " + payment.getId() + ", session " + sessionId + ")"); + + return saved; + } + + /** + * Bulk-confirms all transactions in the session that the matching engine flagged as + * {@link MatchStatus#MATCHED} with confidence ≥ {@value #MATCHED_CONFIDENCE_THRESHOLD}. + * SUGGESTED entries (medium confidence) are intentionally skipped — those require + * individual review. + * + * @return a summary of how many entries were confirmed, skipped, or failed. + */ + @Transactional + public BulkConfirmResult confirmAllMatched(UUID sessionId, UUID userId) { + BankImportSession session = requireSession(sessionId); + assertSessionMutable(session); + + List candidates = transactionRepository + .findBySessionIdAndMatchStatus(sessionId, MatchStatus.MATCHED); + + int confirmed = 0; + int skipped = 0; + int failed = 0; + for (BankTransaction txn : candidates) { + if (txn.getMatchConfidence() == null || txn.getMatchConfidence() < MATCHED_CONFIDENCE_THRESHOLD) { + skipped++; + continue; + } + if (txn.getMatchedMemberId() == null) { + skipped++; + continue; + } + try { + confirmMatch(sessionId, txn.getId(), txn.getMatchedMemberId(), userId); + confirmed++; + } catch (RuntimeException e) { + log.warn("Bulk-confirm failed for txn {}: {}", txn.getId(), e.getMessage()); + failed++; + } + } + + log.info("Bulk-confirm session {}: confirmed={}, skipped={}, failed={}", + sessionId, confirmed, skipped, failed); + return new BulkConfirmResult(confirmed, skipped, failed); + } + + /** Marks a transaction as deliberately skipped (not a member payment). */ + @Transactional + public BankTransaction skipTransaction(UUID sessionId, UUID transactionId, String reason, UUID userId) { + BankImportSession session = requireSession(sessionId); + assertSessionMutable(session); + + BankTransaction txn = requireTransaction(transactionId, sessionId); + if (txn.getMatchStatus() == MatchStatus.CONFIRMED) { + throw new ResponseStatusException(HttpStatus.CONFLICT, + "Eine bereits bestätigte Transaktion kann nicht übersprungen werden."); + } + + txn.setMatchStatus(MatchStatus.SKIPPED); + txn.setSkipReason(reason != null && !reason.isBlank() ? reason : "manuell übersprungen"); + BankTransaction saved = transactionRepository.save(txn); + + session.setSkippedCount(session.getSkippedCount() + 1); + sessionRepository.save(session); + + log.debug("Bank transaction skipped: session={} txn={} reason={}", + sessionId, transactionId, saved.getSkipReason()); + return saved; + } + + /** + * Manually re-assigns a transaction to a member the engine didn't pick. Sets status to + * MATCHED with 100% confidence — but does not create a payment yet (admin must still confirm). + */ + @Transactional + public BankTransaction manualAssign(UUID sessionId, UUID transactionId, UUID memberId, UUID userId) { + BankImportSession session = requireSession(sessionId); + assertSessionMutable(session); + + BankTransaction txn = requireTransaction(transactionId, sessionId); + if (txn.getMatchStatus() == MatchStatus.CONFIRMED) { + throw new ResponseStatusException(HttpStatus.CONFLICT, + "Eine bereits bestätigte Transaktion kann nicht erneut zugeordnet werden."); + } + + Member member = memberRepository.findById(memberId) + .orElseThrow(() -> new ResponseStatusException(HttpStatus.NOT_FOUND, + "Mitglied nicht gefunden: " + memberId)); + if (!member.getClubId().equals(session.getClubId())) { + throw new ResponseStatusException(HttpStatus.FORBIDDEN, + "Mitglied gehört nicht zum aktuellen Verein."); + } + + txn.setMatchedMemberId(memberId); + txn.setMatchStatus(MatchStatus.MATCHED); + txn.setMatchConfidence(100); + BankTransaction saved = transactionRepository.save(txn); + log.debug("Bank transaction manually assigned: session={} txn={} member={}", + sessionId, transactionId, memberId); + return saved; + } + + // =================================================================================== + // Complete session + // =================================================================================== + + /** + * Finalizes the session: sets {@code completedAt}, transitions to {@link ImportSessionStatus#COMPLETED}. + * After this call the session is immutable per GoBD §147 AO. + */ + @Transactional + public BankImportSession completeSession(UUID sessionId, UUID userId) { + BankImportSession session = requireSession(sessionId); + assertSessionMutable(session); + + session.setStatus(ImportSessionStatus.COMPLETED); + session.setCompletedAt(Instant.now()); + BankImportSession saved = sessionRepository.save(session); + + auditService.log(AuditEventType.BANK_IMPORT_COMPLETED, userId, + saved.getId().toString(), + "Bank import completed: " + session.getFilename() + " (" + + session.getConfirmedCount() + " bestätigt, " + + session.getSkippedCount() + " übersprungen)"); + + log.info("Bank import session {} completed by {}", sessionId, userId); + return saved; + } + + // =================================================================================== + // Queries + // =================================================================================== + + @Transactional(readOnly = true) + public BankImportSession getSession(UUID sessionId) { + return requireSession(sessionId); + } + + @Transactional(readOnly = true) + public List getSessions(UUID clubId) { + return sessionRepository.findByClubIdOrderByCreatedAtDesc(clubId); + } + + @Transactional(readOnly = true) + public List getTransactions(UUID sessionId, MatchStatus filter) { + // Verify session exists and belongs to current tenant (filter applies automatically). + requireSession(sessionId); + if (filter == null) { + return transactionRepository.findBySessionIdOrderByBookingDateAsc(sessionId); + } + return transactionRepository.findBySessionIdAndMatchStatus(sessionId, filter); + } + + // =================================================================================== + // GoBD immutability guard + // =================================================================================== + + /** + * Refuses any write operation on a COMPLETED or FAILED session. + *

+ * GoBD §147 AO ("Grundsätze ordnungsmäßiger Buchführung") requires accounting records to be + * tamper-proof once posted. A COMPLETED import session is the German legal equivalent of a + * closed accounting period for that batch of payments. FAILED sessions are terminal too — + * the admin must start a fresh import rather than mutate a failed one. + */ + private void assertSessionMutable(BankImportSession session) { + if (session.getStatus() == ImportSessionStatus.COMPLETED) { + throw new ResponseStatusException(HttpStatus.CONFLICT, + "Diese Importsitzung ist abgeschlossen und kann nicht mehr geändert werden (GoBD §147 AO)."); + } + if (session.getStatus() == ImportSessionStatus.FAILED) { + throw new ResponseStatusException(HttpStatus.CONFLICT, + "Diese Importsitzung ist fehlgeschlagen und kann nicht mehr geändert werden."); + } + } + + // =================================================================================== + // Helpers + // =================================================================================== + + private BankImportSession requireSession(UUID sessionId) { + return sessionRepository.findById(sessionId) + .orElseThrow(() -> new ResponseStatusException(HttpStatus.NOT_FOUND, + "Importsitzung nicht gefunden: " + sessionId)); + } + + private BankTransaction requireTransaction(UUID transactionId, UUID sessionId) { + BankTransaction txn = transactionRepository.findById(transactionId) + .orElseThrow(() -> new ResponseStatusException(HttpStatus.NOT_FOUND, + "Transaktion nicht gefunden: " + transactionId)); + if (!txn.getSessionId().equals(sessionId)) { + throw new ResponseStatusException(HttpStatus.BAD_REQUEST, + "Transaktion gehört nicht zur angegebenen Importsitzung."); + } + return txn; + } + + private static String buildPaymentReference(BankTransaction txn) { + StringBuilder sb = new StringBuilder("BANK:"); + if (txn.getBankReference() != null && !txn.getBankReference().isBlank()) { + sb.append(txn.getBankReference()); + } else if (txn.getReferenceText() != null && !txn.getReferenceText().isBlank()) { + String ref = txn.getReferenceText(); + sb.append(ref.length() > 80 ? ref.substring(0, 80) : ref); + } else { + sb.append(txn.getId()); + } + return sb.toString(); + } + + /** Result of a {@link #confirmAllMatched} call. */ + public record BulkConfirmResult(int confirmed, int skipped, int failed) { + public int total() { return confirmed + skipped + failed; } + } +} diff --git a/cannamanage-service/src/main/java/de/cannamanage/service/repository/BankImportSessionRepository.java b/cannamanage-service/src/main/java/de/cannamanage/service/repository/BankImportSessionRepository.java index 0a37511..d77e94c 100644 --- a/cannamanage-service/src/main/java/de/cannamanage/service/repository/BankImportSessionRepository.java +++ b/cannamanage-service/src/main/java/de/cannamanage/service/repository/BankImportSessionRepository.java @@ -35,4 +35,10 @@ public interface BankImportSessionRepository extends JpaRepository