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:
+538
@@ -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; }
|
||||
}
|
||||
}
|
||||
+6
@@ -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);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user