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;
|
||||
Reference in New Issue
Block a user