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:
+314
@@ -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
|
||||||
|
) {}
|
||||||
+19
@@ -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
|
||||||
|
) {}
|
||||||
+26
@@ -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
|
||||||
|
) {}
|
||||||
+46
@@ -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
|
||||||
|
) {}
|
||||||
+48
@@ -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}
|
cannamanage.schedulers.enabled=${SCHEDULERS_ENABLED:true}
|
||||||
server.servlet.session.cookie.http-only=true
|
server.servlet.session.cookie.http-only=true
|
||||||
server.servlet.session.cookie.secure=${SESSION_COOKIE_SECURE:false}
|
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")
|
@Column(name = "completed_at")
|
||||||
private Instant completedAt;
|
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
|
// Getters and setters
|
||||||
|
|
||||||
public UUID getClubId() { return clubId; }
|
public UUID getClubId() { return clubId; }
|
||||||
@@ -88,4 +96,7 @@ public class BankImportSession extends AbstractTenantEntity {
|
|||||||
|
|
||||||
public Instant getCompletedAt() { return completedAt; }
|
public Instant getCompletedAt() { return completedAt; }
|
||||||
public void setCompletedAt(Instant completedAt) { this.completedAt = completedAt; }
|
public void setCompletedAt(Instant completedAt) { this.completedAt = completedAt; }
|
||||||
|
|
||||||
|
public String getFileHash() { return fileHash; }
|
||||||
|
public void setFileHash(String fileHash) { this.fileHash = fileHash; }
|
||||||
}
|
}
|
||||||
|
|||||||
+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. */
|
/** Tier-limit enforcement: count Starter-plan imports in the current month. */
|
||||||
long countByClubIdAndCreatedAtAfter(UUID clubId, Instant since);
|
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