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

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

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

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

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

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

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

Build: mvn package -DskipTests  (cannamanage-api fat JAR 92MB).
This commit is contained in:
Patrick Plate
2026-06-15 17:47:27 +02:00
parent 527e9b1219
commit 5defe42d67
13 changed files with 1061 additions and 0 deletions
@@ -0,0 +1,314 @@
package de.cannamanage.api.controller;
import de.cannamanage.api.dto.bankimport.*;
import de.cannamanage.api.security.StaffPermissionChecker;
import de.cannamanage.domain.entity.BankImportSession;
import de.cannamanage.domain.entity.BankTransaction;
import de.cannamanage.domain.entity.CsvColumnMapping;
import de.cannamanage.domain.entity.TenantContext;
import de.cannamanage.domain.enums.MatchStatus;
import de.cannamanage.domain.enums.StaffPermission;
import de.cannamanage.service.bankimport.BankImportService;
import de.cannamanage.service.repository.CsvColumnMappingRepository;
import jakarta.validation.Valid;
import lombok.extern.slf4j.Slf4j;
import org.springframework.http.HttpStatus;
import org.springframework.http.MediaType;
import org.springframework.http.ResponseEntity;
import org.springframework.security.access.AccessDeniedException;
import org.springframework.security.core.annotation.AuthenticationPrincipal;
import org.springframework.security.core.userdetails.UserDetails;
import org.springframework.web.bind.annotation.*;
import org.springframework.web.multipart.MultipartFile;
import org.springframework.web.server.ResponseStatusException;
import java.util.List;
import java.util.Optional;
import java.util.UUID;
/**
* Sprint 10 Phase 3 — REST endpoints for the bank statement import wizard.
*
* <p>All endpoints live under {@code /api/v1/finance/import/*}. Access requires
* either {@link StaffPermission#FINANCE_IMPORT} or {@link StaffPermission#MANAGE_FINANCES}
* (ADMIN role always passes). Tenant scoping is implicit via {@link TenantContext}.
*
* <p>Endpoint overview:
* <ul>
* <li>{@code POST /finance/import/sessions} — multipart upload + parse (optional {@code mappingId} query)</li>
* <li>{@code GET /finance/import/sessions} — list all sessions for the tenant</li>
* <li>{@code GET /finance/import/sessions/{id}} — single session detail</li>
* <li>{@code GET /finance/import/sessions/{id}/transactions} — transactions, optional {@code ?status=} filter</li>
* <li>{@code POST /finance/import/sessions/{id}/transactions/{txnId}/confirm} — create payment from match</li>
* <li>{@code POST /finance/import/sessions/{id}/confirm-all} — bulk-confirm high-confidence matches</li>
* <li>{@code POST /finance/import/sessions/{id}/transactions/{txnId}/assign} — manual member assignment</li>
* <li>{@code POST /finance/import/sessions/{id}/transactions/{txnId}/skip} — drop transaction with reason</li>
* <li>{@code POST /finance/import/sessions/{id}/complete} — seal session (GoBD immutability)</li>
* <li>{@code GET /finance/import/csv-mappings} — list saved CSV mapping templates</li>
* <li>{@code POST /finance/import/csv-mappings} — create a CSV mapping template</li>
* <li>{@code DELETE /finance/import/csv-mappings/{id}} — remove a CSV mapping template</li>
* </ul>
*/
@Slf4j
@RestController
@RequestMapping("/api/v1")
public class BankImportController {
private final BankImportService bankImportService;
private final StaffPermissionChecker permissionChecker;
private final CsvColumnMappingRepository mappingRepository;
public BankImportController(BankImportService bankImportService,
StaffPermissionChecker permissionChecker,
CsvColumnMappingRepository mappingRepository) {
this.bankImportService = bankImportService;
this.permissionChecker = permissionChecker;
this.mappingRepository = mappingRepository;
}
// === Sessions ===
/**
* Upload a bank statement file and parse it. Returns the persisted session with
* matching results so the frontend can immediately render the review table.
*/
@PostMapping(value = "/finance/import/sessions", consumes = MediaType.MULTIPART_FORM_DATA_VALUE)
public ResponseEntity<ImportSessionResponse> uploadSession(
@RequestParam("file") MultipartFile file,
@RequestParam(value = "mappingId", required = false) UUID mappingId,
@AuthenticationPrincipal UserDetails principal) {
requireImportPermission(principal);
UUID clubId = TenantContext.getCurrentTenant();
UUID userId = UUID.fromString(principal.getUsername());
CsvColumnMapping mapping = null;
if (mappingId != null) {
mapping = mappingRepository.findById(mappingId)
.filter(m -> clubId.equals(m.getClubId()))
.orElseThrow(() -> new ResponseStatusException(
HttpStatus.NOT_FOUND, "CSV-Vorlage nicht gefunden."));
}
BankImportSession session = bankImportService.uploadAndParse(clubId, userId, file, mapping);
return ResponseEntity.status(HttpStatus.CREATED).body(ImportSessionResponse.from(session));
}
/** List all import sessions for the current tenant, newest first. */
@GetMapping("/finance/import/sessions")
public ResponseEntity<List<ImportSessionResponse>> listSessions(@AuthenticationPrincipal UserDetails principal) {
requireImportPermission(principal);
UUID clubId = TenantContext.getCurrentTenant();
List<ImportSessionResponse> sessions = bankImportService.getSessions(clubId).stream()
.map(ImportSessionResponse::from)
.toList();
return ResponseEntity.ok(sessions);
}
/** Detail view of a single session. */
@GetMapping("/finance/import/sessions/{id}")
public ResponseEntity<ImportSessionResponse> getSession(@PathVariable UUID id,
@AuthenticationPrincipal UserDetails principal) {
requireImportPermission(principal);
BankImportSession session = bankImportService.getSession(id);
ensureSameTenant(session.getClubId());
return ResponseEntity.ok(ImportSessionResponse.from(session));
}
/**
* Transactions belonging to a session, optionally filtered by match status.
* Drives the review table (typically called with {@code ?status=MATCHED} then
* with no filter for the full audit listing).
*/
@GetMapping("/finance/import/sessions/{id}/transactions")
public ResponseEntity<List<TransactionResponse>> listTransactions(
@PathVariable UUID id,
@RequestParam(value = "status", required = false) MatchStatus status,
@AuthenticationPrincipal UserDetails principal) {
requireImportPermission(principal);
BankImportSession session = bankImportService.getSession(id);
ensureSameTenant(session.getClubId());
List<TransactionResponse> txns = bankImportService.getTransactions(id, status).stream()
.map(TransactionResponse::from)
.toList();
return ResponseEntity.ok(txns);
}
/** Confirm a single matched transaction → creates a {@code Payment} via {@code FinanceService}. */
@PostMapping("/finance/import/sessions/{id}/transactions/{txnId}/confirm")
public ResponseEntity<TransactionResponse> confirmMatch(
@PathVariable UUID id,
@PathVariable UUID txnId,
@Valid @RequestBody ConfirmRequest request,
@AuthenticationPrincipal UserDetails principal) {
requireImportPermission(principal);
ensureSameTenant(bankImportService.getSession(id).getClubId());
UUID userId = UUID.fromString(principal.getUsername());
BankTransaction txn = bankImportService.confirmMatch(id, txnId, request.memberId(), userId);
return ResponseEntity.ok(TransactionResponse.from(txn));
}
/** Bulk-confirm every {@code MATCHED} transaction with confidence ≥ 90 in the session. */
@PostMapping("/finance/import/sessions/{id}/confirm-all")
public ResponseEntity<BulkConfirmResponse> confirmAll(@PathVariable UUID id,
@AuthenticationPrincipal UserDetails principal) {
requireImportPermission(principal);
ensureSameTenant(bankImportService.getSession(id).getClubId());
UUID userId = UUID.fromString(principal.getUsername());
BankImportService.BulkConfirmResult result = bankImportService.confirmAllMatched(id, userId);
return ResponseEntity.ok(BulkConfirmResponse.from(result));
}
/** Manual assignment for unmatched transactions — sets {@code MATCHED} 100% but does NOT create a Payment yet. */
@PostMapping("/finance/import/sessions/{id}/transactions/{txnId}/assign")
public ResponseEntity<TransactionResponse> assignManually(
@PathVariable UUID id,
@PathVariable UUID txnId,
@Valid @RequestBody AssignRequest request,
@AuthenticationPrincipal UserDetails principal) {
requireImportPermission(principal);
ensureSameTenant(bankImportService.getSession(id).getClubId());
UUID userId = UUID.fromString(principal.getUsername());
BankTransaction txn = bankImportService.manualAssign(id, txnId, request.memberId(), userId);
return ResponseEntity.ok(TransactionResponse.from(txn));
}
/** Skip a transaction (e.g. refund, fee, non-member deposit) — stored with reason for audit trail. */
@PostMapping("/finance/import/sessions/{id}/transactions/{txnId}/skip")
public ResponseEntity<TransactionResponse> skipTransaction(
@PathVariable UUID id,
@PathVariable UUID txnId,
@RequestBody(required = false) SkipRequest request,
@AuthenticationPrincipal UserDetails principal) {
requireImportPermission(principal);
ensureSameTenant(bankImportService.getSession(id).getClubId());
UUID userId = UUID.fromString(principal.getUsername());
String reason = request != null ? request.reason() : null;
BankTransaction txn = bankImportService.skipTransaction(id, txnId, reason, userId);
return ResponseEntity.ok(TransactionResponse.from(txn));
}
/** Seal the session — sets status {@code COMPLETED}, after which no further mutations are permitted (GoBD §147 AO). */
@PostMapping("/finance/import/sessions/{id}/complete")
public ResponseEntity<ImportSessionResponse> completeSession(@PathVariable UUID id,
@AuthenticationPrincipal UserDetails principal) {
requireImportPermission(principal);
ensureSameTenant(bankImportService.getSession(id).getClubId());
UUID userId = UUID.fromString(principal.getUsername());
BankImportSession session = bankImportService.completeSession(id, userId);
return ResponseEntity.ok(ImportSessionResponse.from(session));
}
// === CSV Column Mappings ===
/** List saved CSV mapping templates for the current tenant. */
@GetMapping("/finance/import/csv-mappings")
public ResponseEntity<List<CsvColumnMapping>> listMappings(@AuthenticationPrincipal UserDetails principal) {
requireImportPermission(principal);
UUID clubId = TenantContext.getCurrentTenant();
return ResponseEntity.ok(mappingRepository.findByClubId(clubId));
}
/**
* Create a new CSV mapping template. If {@code isDefault} is true, the existing
* default mapping (if any) is cleared so only one template stays default per club.
*/
@PostMapping("/finance/import/csv-mappings")
public ResponseEntity<CsvColumnMapping> createMapping(@Valid @RequestBody CreateMappingRequest request,
@AuthenticationPrincipal UserDetails principal) {
requireImportPermission(principal);
UUID clubId = TenantContext.getCurrentTenant();
CsvColumnMapping mapping = new CsvColumnMapping();
mapping.setClubId(clubId);
mapping.setName(request.name());
mapping.setDateColumn(request.dateColumn());
mapping.setAmountColumn(request.amountColumn());
mapping.setReferenceColumn(request.referenceColumn());
mapping.setCounterpartyColumn(request.counterpartyColumn());
mapping.setIbanColumn(request.ibanColumn());
if (request.delimiter() != null) {
mapping.setDelimiter(request.delimiter());
}
if (request.dateFormat() != null) {
mapping.setDateFormat(request.dateFormat());
}
if (request.decimalSeparator() != null) {
mapping.setDecimalSeparator(request.decimalSeparator());
}
if (request.skipHeaderRows() != null) {
mapping.setSkipHeaderRows(request.skipHeaderRows());
}
if (request.encoding() != null) {
mapping.setEncoding(request.encoding());
}
boolean wantsDefault = Boolean.TRUE.equals(request.isDefault());
mapping.setIsDefault(wantsDefault);
if (wantsDefault) {
Optional<CsvColumnMapping> existingDefault = mappingRepository.findByClubIdAndIsDefaultTrue(clubId);
existingDefault.ifPresent(existing -> {
existing.setIsDefault(false);
mappingRepository.save(existing);
});
}
CsvColumnMapping saved = mappingRepository.save(mapping);
log.info("CSV mapping created: id={} name={} club={}", saved.getId(), saved.getName(), clubId);
return ResponseEntity.status(HttpStatus.CREATED).body(saved);
}
/** Delete a CSV mapping template — only the owner tenant may delete. */
@DeleteMapping("/finance/import/csv-mappings/{id}")
public ResponseEntity<Void> deleteMapping(@PathVariable UUID id,
@AuthenticationPrincipal UserDetails principal) {
requireImportPermission(principal);
UUID clubId = TenantContext.getCurrentTenant();
CsvColumnMapping mapping = mappingRepository.findById(id)
.filter(m -> clubId.equals(m.getClubId()))
.orElseThrow(() -> new ResponseStatusException(
HttpStatus.NOT_FOUND, "CSV-Vorlage nicht gefunden."));
mappingRepository.delete(mapping);
log.info("CSV mapping deleted: id={} club={}", id, clubId);
return ResponseEntity.noContent().build();
}
// === Helpers ===
/**
* Permission gate that accepts either {@link StaffPermission#FINANCE_IMPORT} or
* {@link StaffPermission#MANAGE_FINANCES}. ADMIN passes both automatically inside
* {@link StaffPermissionChecker}.
*/
private void requireImportPermission(UserDetails principal) {
try {
permissionChecker.requirePermission(principal, StaffPermission.FINANCE_IMPORT);
} catch (AccessDeniedException denied) {
// Fall back to MANAGE_FINANCES — finance admins are implicitly authorized.
permissionChecker.requirePermission(principal, StaffPermission.MANAGE_FINANCES);
}
}
/**
* Defence-in-depth tenant check on top of Hibernate {@code @Filter} —
* ensures path-parameter IDs from one tenant cannot reach another tenant's session.
*/
private void ensureSameTenant(UUID sessionClubId) {
UUID currentTenant = TenantContext.getCurrentTenant();
if (sessionClubId == null || !sessionClubId.equals(currentTenant)) {
throw new ResponseStatusException(HttpStatus.NOT_FOUND, "Import-Session nicht gefunden.");
}
}
}
@@ -0,0 +1,13 @@
package de.cannamanage.api.dto.bankimport;
import jakarta.validation.constraints.NotNull;
import java.util.UUID;
/**
* Sprint 10 Phase 3 — Request body for {@code POST /sessions/{id}/transactions/{txnId}/assign}.
* Used by the admin to manually attach a transaction to a member the matching engine missed.
*/
public record AssignRequest(
@NotNull UUID memberId
) {}
@@ -0,0 +1,19 @@
package de.cannamanage.api.dto.bankimport;
import de.cannamanage.service.bankimport.BankImportService.BulkConfirmResult;
/**
* Sprint 10 Phase 3 — Response of {@code POST /sessions/{id}/confirm-all}.
* Surfaces the number of transactions that were confirmed, skipped (low confidence /
* already confirmed) and failed (e.g. payment creation error) so the UI can give clear feedback.
*/
public record BulkConfirmResponse(
int confirmed,
int skipped,
int failed,
int total
) {
public static BulkConfirmResponse from(BulkConfirmResult r) {
return new BulkConfirmResponse(r.confirmed(), r.skipped(), r.failed(), r.total());
}
}
@@ -0,0 +1,15 @@
package de.cannamanage.api.dto.bankimport;
import jakarta.validation.constraints.NotNull;
import java.util.UUID;
/**
* Sprint 10 Phase 3 — Request body for {@code POST /sessions/{id}/transactions/{txnId}/confirm}.
* <p>
* The {@code memberId} is required so the caller explicitly acknowledges which member receives
* the payment, even when the matching engine had already pre-selected one.
*/
public record ConfirmRequest(
@NotNull UUID memberId
) {}
@@ -0,0 +1,26 @@
package de.cannamanage.api.dto.bankimport;
import jakarta.validation.constraints.Max;
import jakarta.validation.constraints.Min;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.Size;
/**
* Sprint 10 Phase 3 — Request body for {@code POST /finance/import/csv-mappings}.
* Captures the column layout of a club-specific CSV bank export so future imports can
* be parsed without re-mapping.
*/
public record CreateMappingRequest(
@NotBlank @Size(max = 100) String name,
@Min(0) @Max(50) int dateColumn,
@Min(0) @Max(50) int amountColumn,
@Min(0) @Max(50) int referenceColumn,
Integer counterpartyColumn,
Integer ibanColumn,
@Size(max = 4) String delimiter,
@Size(max = 32) String dateFormat,
@Size(max = 2) String decimalSeparator,
@Min(0) @Max(20) Integer skipHeaderRows,
@Size(max = 32) String encoding,
Boolean isDefault
) {}
@@ -0,0 +1,46 @@
package de.cannamanage.api.dto.bankimport;
import de.cannamanage.domain.entity.BankImportSession;
import de.cannamanage.domain.enums.BankFormat;
import de.cannamanage.domain.enums.ImportSessionStatus;
import java.time.Instant;
import java.util.UUID;
/**
* Sprint 10 Phase 3 — Summary projection of a {@code BankImportSession} for list views.
* Excludes large/sensitive fields ({@code fileHash}, {@code errorMessage} stays).
*/
public record ImportSessionResponse(
UUID id,
UUID clubId,
String filename,
BankFormat format,
ImportSessionStatus status,
Integer totalTransactions,
Integer matchedCount,
Integer confirmedCount,
Integer skippedCount,
UUID uploadedBy,
String errorMessage,
Instant createdAt,
Instant completedAt
) {
public static ImportSessionResponse from(BankImportSession s) {
return new ImportSessionResponse(
s.getId(),
s.getClubId(),
s.getFilename(),
s.getFormat(),
s.getStatus(),
s.getTotalTransactions(),
s.getMatchedCount(),
s.getConfirmedCount(),
s.getSkippedCount(),
s.getUploadedBy(),
s.getErrorMessage(),
s.getCreatedAt(),
s.getCompletedAt()
);
}
}
@@ -0,0 +1,9 @@
package de.cannamanage.api.dto.bankimport;
/**
* Sprint 10 Phase 3 — Optional request body for {@code POST /sessions/{id}/transactions/{txnId}/skip}.
* The {@code reason} field is free text shown in the audit log and review history.
*/
public record SkipRequest(
String reason
) {}
@@ -0,0 +1,48 @@
package de.cannamanage.api.dto.bankimport;
import de.cannamanage.domain.entity.BankTransaction;
import de.cannamanage.domain.enums.MatchStatus;
import java.time.LocalDate;
import java.util.UUID;
/**
* Sprint 10 Phase 3 — Single bank-statement transaction shown in the review wizard.
*/
public record TransactionResponse(
UUID id,
UUID sessionId,
LocalDate bookingDate,
LocalDate valueDate,
Integer amountCents,
String currency,
String referenceText,
String counterpartyName,
String counterpartyIban,
String bankReference,
MatchStatus matchStatus,
Integer matchConfidence,
UUID matchedMemberId,
UUID matchedPaymentId,
String skipReason
) {
public static TransactionResponse from(BankTransaction t) {
return new TransactionResponse(
t.getId(),
t.getSessionId(),
t.getBookingDate(),
t.getValueDate(),
t.getAmountCents(),
t.getCurrency(),
t.getReferenceText(),
t.getCounterpartyName(),
t.getCounterpartyIban(),
t.getBankReference(),
t.getMatchStatus(),
t.getMatchConfidence(),
t.getMatchedMemberId(),
t.getMatchedPaymentId(),
t.getSkipReason()
);
}
}
@@ -43,3 +43,8 @@ server.servlet.session.cookie.same-site=strict
cannamanage.schedulers.enabled=${SCHEDULERS_ENABLED:true}
server.servlet.session.cookie.http-only=true
server.servlet.session.cookie.secure=${SESSION_COOKIE_SECURE:false}
# Bank import file upload (Sprint 10) — limit 5MB, hard cap enforced in BankImportService too
spring.servlet.multipart.enabled=true
spring.servlet.multipart.max-file-size=5MB
spring.servlet.multipart.max-request-size=6MB
@@ -0,0 +1,11 @@
-- Sprint 10 Phase 3: Add SHA-256 file hash column for stronger duplicate-import detection.
-- The Phase 1 filename-based check is kept as a soft warning; the hash provides hard 409 dedup
-- (a renamed copy of the same file is still detected).
ALTER TABLE bank_import_sessions
ADD COLUMN file_hash VARCHAR(64);
-- Unique per club to allow the same file to be imported by different tenants in DEV/QA.
-- NULL values are allowed for legacy rows created before V33.
CREATE UNIQUE INDEX uk_bank_import_sessions_club_hash
ON bank_import_sessions(club_id, file_hash)
WHERE file_hash IS NOT NULL;