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,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);
}