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: + *
+ * 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: + *
+ * 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: + *
+ * 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