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}
|
||||
server.servlet.session.cookie.http-only=true
|
||||
server.servlet.session.cookie.secure=${SESSION_COOKIE_SECURE:false}
|
||||
|
||||
# Bank import file upload (Sprint 10) — limit 5MB, hard cap enforced in BankImportService too
|
||||
spring.servlet.multipart.enabled=true
|
||||
spring.servlet.multipart.max-file-size=5MB
|
||||
spring.servlet.multipart.max-request-size=6MB
|
||||
|
||||
@@ -0,0 +1,11 @@
|
||||
-- Sprint 10 Phase 3: Add SHA-256 file hash column for stronger duplicate-import detection.
|
||||
-- The Phase 1 filename-based check is kept as a soft warning; the hash provides hard 409 dedup
|
||||
-- (a renamed copy of the same file is still detected).
|
||||
ALTER TABLE bank_import_sessions
|
||||
ADD COLUMN file_hash VARCHAR(64);
|
||||
|
||||
-- Unique per club to allow the same file to be imported by different tenants in DEV/QA.
|
||||
-- NULL values are allowed for legacy rows created before V33.
|
||||
CREATE UNIQUE INDEX uk_bank_import_sessions_club_hash
|
||||
ON bank_import_sessions(club_id, file_hash)
|
||||
WHERE file_hash IS NOT NULL;
|
||||
@@ -54,6 +54,14 @@ public class BankImportSession extends AbstractTenantEntity {
|
||||
@Column(name = "completed_at")
|
||||
private Instant completedAt;
|
||||
|
||||
/**
|
||||
* SHA-256 hex digest of the uploaded file content (Sprint 10 Phase 3).
|
||||
* Used together with {@code clubId} to reject byte-identical re-uploads (HTTP 409),
|
||||
* even when the file has been renamed. Nullable for legacy rows created before V33.
|
||||
*/
|
||||
@Column(name = "file_hash", length = 64)
|
||||
private String fileHash;
|
||||
|
||||
// Getters and setters
|
||||
|
||||
public UUID getClubId() { return clubId; }
|
||||
@@ -88,4 +96,7 @@ public class BankImportSession extends AbstractTenantEntity {
|
||||
|
||||
public Instant getCompletedAt() { return completedAt; }
|
||||
public void setCompletedAt(Instant completedAt) { this.completedAt = completedAt; }
|
||||
|
||||
public String getFileHash() { return fileHash; }
|
||||
public void setFileHash(String fileHash) { this.fileHash = fileHash; }
|
||||
}
|
||||
|
||||
+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