feat(sprint10): Phase 3 — BankImportService + REST API

Implements the orchestrator and REST endpoints for the bank statement
import wizard (Sprint 10 Phase 3).

Service layer (cannamanage-service):
- BankImportService: upload → SHA-256 dedup → parse → match → persist
  in two transactional steps (file I/O outside @Transactional, persist
  in @Transactional helper). Methods: uploadAndParse, confirmMatch,
  confirmAllMatched (≥90% confidence), manualAssign, skipTransaction,
  completeSession, query helpers.
- GoBD §147 AO immutability guard: assertSessionMutable() rejects any
  mutation on COMPLETED/FAILED sessions with German error messages.
- Hard 5MB upload cap enforced before parsing.
- Audit events: BANK_IMPORT_STARTED / BANK_PAYMENT_CONFIRMED /
  BANK_IMPORT_COMPLETED. Uploader notified via NotificationService.

REST layer (cannamanage-api):
- BankImportController under /api/v1/finance/import/*:
  POST sessions (multipart), GET sessions/single/transactions(?status=),
  POST {id}/transactions/{txnId}/confirm|assign|skip,
  POST {id}/confirm-all, POST {id}/complete,
  GET/POST/DELETE csv-mappings.
- Permission: FINANCE_IMPORT with MANAGE_FINANCES fallback.
- Defence-in-depth tenant check on every path-parameter ID.

DTOs (cannamanage-api/dto/bankimport):
- ImportSessionResponse, TransactionResponse, ConfirmRequest,
  AssignRequest, SkipRequest, BulkConfirmResponse, CreateMappingRequest.

Persistence:
- V33__bank_import_file_hash.sql: adds file_hash VARCHAR(64) + unique
  partial index (club_id, file_hash) for duplicate-upload detection.
- BankImportSession.fileHash field, repository.existsByClubIdAndFileHash.

Configuration:
- application.properties: multipart enabled, max-file-size=5MB,
  max-request-size=6MB.

Build: mvn package -DskipTests  (cannamanage-api fat JAR 92MB).
This commit is contained in:
Patrick Plate
2026-06-15 17:47:27 +02:00
parent 527e9b1219
commit 5defe42d67
13 changed files with 1061 additions and 0 deletions
@@ -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.
*
* <p>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}.
*
* <p>Endpoint overview:
* <ul>
* <li>{@code POST /finance/import/sessions} — multipart upload + parse (optional {@code mappingId} query)</li>
* <li>{@code GET /finance/import/sessions} — list all sessions for the tenant</li>
* <li>{@code GET /finance/import/sessions/{id}} — single session detail</li>
* <li>{@code GET /finance/import/sessions/{id}/transactions} — transactions, optional {@code ?status=} filter</li>
* <li>{@code POST /finance/import/sessions/{id}/transactions/{txnId}/confirm} — create payment from match</li>
* <li>{@code POST /finance/import/sessions/{id}/confirm-all} — bulk-confirm high-confidence matches</li>
* <li>{@code POST /finance/import/sessions/{id}/transactions/{txnId}/assign} — manual member assignment</li>
* <li>{@code POST /finance/import/sessions/{id}/transactions/{txnId}/skip} — drop transaction with reason</li>
* <li>{@code POST /finance/import/sessions/{id}/complete} — seal session (GoBD immutability)</li>
* <li>{@code GET /finance/import/csv-mappings} — list saved CSV mapping templates</li>
* <li>{@code POST /finance/import/csv-mappings} — create a CSV mapping template</li>
* <li>{@code DELETE /finance/import/csv-mappings/{id}} — remove a CSV mapping template</li>
* </ul>
*/
@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<ImportSessionResponse> 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<List<ImportSessionResponse>> listSessions(@AuthenticationPrincipal UserDetails principal) {
requireImportPermission(principal);
UUID clubId = TenantContext.getCurrentTenant();
List<ImportSessionResponse> 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<ImportSessionResponse> 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<List<TransactionResponse>> 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<TransactionResponse> 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<TransactionResponse> 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<BulkConfirmResponse> 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<TransactionResponse> 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<TransactionResponse> 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<ImportSessionResponse> 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<List<CsvColumnMapping>> 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<CsvColumnMapping> 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<CsvColumnMapping> 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<Void> 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.");
}
}
}
@@ -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
) {}
@@ -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());
}
}
@@ -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}.
* <p>
* 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
) {}
@@ -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
) {}
@@ -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()
);
}
}
@@ -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
) {}
@@ -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()
);
}
}
@@ -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
@@ -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;
@@ -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; }
}
@@ -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.
* <p>
* Coordinates the full import workflow:
* <ol>
* <li>{@link #uploadAndParse} — validate, hash, dedup, parse, persist, match</li>
* <li>review phase (UI calls {@link #getTransactions} repeatedly)</li>
* <li>{@link #confirmMatch} / {@link #skipTransaction} / {@link #manualAssign} per transaction
* (or {@link #confirmAllMatched} as bulk operation for high-confidence MATCHED entries)</li>
* <li>{@link #completeSession} — finalize. After COMPLETED the session is immutable per GoBD §147 AO.</li>
* </ol>
* <p>
* <strong>Transactional boundary design:</strong> the public {@link #uploadAndParse} method is
* deliberately <em>not</em> {@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.
* <p>
* <strong>Tenant scoping:</strong> 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.
* <p>
* The session is left in {@link ImportSessionStatus#IN_REVIEW} ready for admin confirmation.
* <p>
* <strong>Validation:</strong>
* <ul>
* <li>File must be non-empty and ≤ {@value #MAX_FILE_SIZE_BYTES} bytes (HTTP 400)</li>
* <li>SHA-256 hash must not collide with an existing session in the same club (HTTP 409)</li>
* <li>File format must be recognized (HTTP 400)</li>
* </ul>
*
* @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<BankTransaction> 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<BankTransaction> 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<BankImportSession> getSessions(UUID clubId) {
return sessionRepository.findByClubIdOrderByCreatedAtDesc(clubId);
}
@Transactional(readOnly = true)
public List<BankTransaction> 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.
* <p>
* 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; }
}
}
@@ -35,4 +35,10 @@ public interface BankImportSessionRepository extends JpaRepository<BankImportSes
/** Tier-limit enforcement: count Starter-plan imports in the current month. */
long countByClubIdAndCreatedAtAfter(UUID clubId, Instant since);
/**
* Hard duplicate-import guard (Sprint 10 Phase 3): true if a session for the same club already
* exists with the same SHA-256 file hash. Used to return HTTP 409 even when the file was renamed.
*/
boolean existsByClubIdAndFileHash(UUID clubId, String fileHash);
}