feat(sprint10): Phase 1 — Data model + bank statement parsers (MT940, CAMT.053, CSV)

Implements the Sprint 10 Phase 1 foundation for the Smart Payment Import feature:

Domain layer:
- 3 new enums: BankFormat (MT940, CAMT053, CSV), ImportSessionStatus, MatchStatus
- StaffPermission.FINANCE_IMPORT
- AuditEventType: BANK_IMPORT_STARTED/COMPLETED/FAILED + BANK_PAYMENT_CONFIRMED
- NotificationType.BANK_IMPORT_COMPLETED
- ConsentType.BANK_DATA (DSGVO consent for IBAN storage)
- 3 new entities: BankImportSession, BankTransaction, CsvColumnMapping
- Member: + iban (VARCHAR 34) + ibanConsentDate
- MemberStatus.LEFT (semantic alias for RESIGNED, referenced by Sprint 9 RetentionService)

Persistence:
- V30__bank_import_sessions.sql
- V31__bank_transactions.sql
- V32__csv_column_mappings.sql (also adds iban + iban_consent_date to members)
- 3 Spring Data repositories

Parser infrastructure (cannamanage-service/src/main/java/de/cannamanage/service/bankimport):
- BankStatementParser interface (Strategy pattern, Spring-injected list)
- ParsedTransaction + ParseResult records
- BankStatementParseException (parse errors)
- Mt940Parser: custom state machine, CENTURY_BOUNDARY=70 for YY→YYYY, proprietary
  header tolerance (skips lines before first :20: for StarMoney/WISO/Hibiscus wrappers)
- Camt053Parser: StAX streaming with XXE hardening (IS_SUPPORTING_EXTERNAL_ENTITIES,
  SUPPORT_DTD, IS_REPLACING_ENTITY_REFERENCES all false); supports camt.053.001.02
  and camt.053.001.08 namespaces
- CsvBankParser: Apache Commons CSV with configurable columns per club; German number
  format ("1.234,56"); ISO-8859-1 default encoding
- BankStatementParserService: filename-extension hint + content probe; throws
  UnrecognizedFormatException when no parser claims the file

Build verified via Docker (cannamanage-api:sprint10-phase1).

Sprint 9 fix (incidental, required to compile):
- Added MemberStatus.LEFT (Sprint 9 RetentionService referenced it but the enum
  value was missing)
- MemberListRegistryGenerator: added LEFT to formatStatus() switch (mapped to
  "Ausgetreten", same as RESIGNED)

Sprint 10 docs: analysis, plan, plan-review, testplan.

Co-Authored-By: Lumen <lumen@cannamanage.de>
This commit is contained in:
Patrick Plate
2026-06-15 17:21:55 +02:00
parent 57f418f7c9
commit 55110c95af
31 changed files with 4456 additions and 6 deletions
@@ -0,0 +1,91 @@
package de.cannamanage.domain.entity;
import de.cannamanage.domain.enums.BankFormat;
import de.cannamanage.domain.enums.ImportSessionStatus;
import jakarta.persistence.*;
import java.time.Instant;
import java.util.UUID;
/**
* Sprint 10 — One upload of a bank statement file. Owns the parsed {@link BankTransaction}s
* and tracks review progress until the admin marks the session COMPLETED.
* <p>
* Status lifecycle: {@code PENDING} → {@code IN_REVIEW} → {@code COMPLETED}.
* After COMPLETED the session is immutable per GoBD (§147 AO).
* {@code FAILED} is a terminal state for parse errors or discarded sessions.
*/
@Entity
@Table(name = "bank_import_sessions")
public class BankImportSession extends AbstractTenantEntity {
@Column(name = "club_id", nullable = false, updatable = false)
private UUID clubId;
@Column(name = "filename", nullable = false, length = 255)
private String filename;
@Enumerated(EnumType.STRING)
@Column(name = "format", nullable = false, length = 20)
private BankFormat format;
@Column(name = "total_transactions", nullable = false)
private Integer totalTransactions = 0;
@Column(name = "matched_count", nullable = false)
private Integer matchedCount = 0;
@Column(name = "confirmed_count", nullable = false)
private Integer confirmedCount = 0;
@Column(name = "skipped_count", nullable = false)
private Integer skippedCount = 0;
@Enumerated(EnumType.STRING)
@Column(name = "status", nullable = false, length = 20)
private ImportSessionStatus status = ImportSessionStatus.PENDING;
@Column(name = "uploaded_by", nullable = false)
private UUID uploadedBy;
@Column(name = "error_message", columnDefinition = "TEXT")
private String errorMessage;
@Column(name = "completed_at")
private Instant completedAt;
// Getters and setters
public UUID getClubId() { return clubId; }
public void setClubId(UUID clubId) { this.clubId = clubId; }
public String getFilename() { return filename; }
public void setFilename(String filename) { this.filename = filename; }
public BankFormat getFormat() { return format; }
public void setFormat(BankFormat format) { this.format = format; }
public Integer getTotalTransactions() { return totalTransactions; }
public void setTotalTransactions(Integer totalTransactions) { this.totalTransactions = totalTransactions; }
public Integer getMatchedCount() { return matchedCount; }
public void setMatchedCount(Integer matchedCount) { this.matchedCount = matchedCount; }
public Integer getConfirmedCount() { return confirmedCount; }
public void setConfirmedCount(Integer confirmedCount) { this.confirmedCount = confirmedCount; }
public Integer getSkippedCount() { return skippedCount; }
public void setSkippedCount(Integer skippedCount) { this.skippedCount = skippedCount; }
public ImportSessionStatus getStatus() { return status; }
public void setStatus(ImportSessionStatus status) { this.status = status; }
public UUID getUploadedBy() { return uploadedBy; }
public void setUploadedBy(UUID uploadedBy) { this.uploadedBy = uploadedBy; }
public String getErrorMessage() { return errorMessage; }
public void setErrorMessage(String errorMessage) { this.errorMessage = errorMessage; }
public Instant getCompletedAt() { return completedAt; }
public void setCompletedAt(Instant completedAt) { this.completedAt = completedAt; }
}
@@ -0,0 +1,124 @@
package de.cannamanage.domain.entity;
import de.cannamanage.domain.enums.MatchStatus;
import jakarta.persistence.*;
import java.time.LocalDate;
import java.util.UUID;
/**
* Sprint 10 — One transaction line parsed from a bank statement file.
* <p>
* Sign convention for {@code amountCents}: <strong>positive = incoming</strong>
* (potential member payment), <strong>negative = outgoing</strong> (expense).
* <p>
* Match flow: parser writes UNMATCHED → matching engine sets MATCHED/SUGGESTED →
* admin sets CONFIRMED or SKIPPED. CONFIRMED links to the created {@link Payment}
* via {@code matchedPaymentId}.
* <p>
* Foreign keys to {@code members} and {@code payments} are not modelled as JPA
* relationships to keep the entity flat (UUIDs only) — consistent with the rest
* of the schema (see {@link Payment} for the same pattern). Deletes use
* {@code SET NULL} so transaction history survives member/payment removal.
*/
@Entity
@Table(name = "bank_transactions")
public class BankTransaction extends AbstractTenantEntity {
@Column(name = "session_id", nullable = false, updatable = false)
private UUID sessionId;
@Column(name = "club_id", nullable = false, updatable = false)
private UUID clubId;
@Column(name = "booking_date", nullable = false)
private LocalDate bookingDate;
@Column(name = "value_date")
private LocalDate valueDate;
/** Positive = incoming, negative = outgoing. Stored in cents to avoid floating-point. */
@Column(name = "amount_cents", nullable = false)
private Integer amountCents;
@Column(name = "currency", nullable = false, length = 3)
private String currency = "EUR";
/** German "Verwendungszweck" — free-text payment reference. */
@Column(name = "reference_text", columnDefinition = "TEXT")
private String referenceText;
@Column(name = "counterparty_name", length = 300)
private String counterpartyName;
@Column(name = "counterparty_iban", length = 34)
private String counterpartyIban;
/** Bank's own internal transaction reference (EREF, KREF, MREF). */
@Column(name = "bank_reference", length = 100)
private String bankReference;
@Enumerated(EnumType.STRING)
@Column(name = "match_status", nullable = false, length = 20)
private MatchStatus matchStatus = MatchStatus.UNMATCHED;
/** 0-100; only meaningful when {@link #matchStatus} is not UNMATCHED. */
@Column(name = "match_confidence")
private Integer matchConfidence;
@Column(name = "matched_member_id")
private UUID matchedMemberId;
@Column(name = "matched_payment_id")
private UUID matchedPaymentId;
@Column(name = "skip_reason", length = 100)
private String skipReason;
// Getters and setters
public UUID getSessionId() { return sessionId; }
public void setSessionId(UUID sessionId) { this.sessionId = sessionId; }
public UUID getClubId() { return clubId; }
public void setClubId(UUID clubId) { this.clubId = clubId; }
public LocalDate getBookingDate() { return bookingDate; }
public void setBookingDate(LocalDate bookingDate) { this.bookingDate = bookingDate; }
public LocalDate getValueDate() { return valueDate; }
public void setValueDate(LocalDate valueDate) { this.valueDate = valueDate; }
public Integer getAmountCents() { return amountCents; }
public void setAmountCents(Integer amountCents) { this.amountCents = amountCents; }
public String getCurrency() { return currency; }
public void setCurrency(String currency) { this.currency = currency; }
public String getReferenceText() { return referenceText; }
public void setReferenceText(String referenceText) { this.referenceText = referenceText; }
public String getCounterpartyName() { return counterpartyName; }
public void setCounterpartyName(String counterpartyName) { this.counterpartyName = counterpartyName; }
public String getCounterpartyIban() { return counterpartyIban; }
public void setCounterpartyIban(String counterpartyIban) { this.counterpartyIban = counterpartyIban; }
public String getBankReference() { return bankReference; }
public void setBankReference(String bankReference) { this.bankReference = bankReference; }
public MatchStatus getMatchStatus() { return matchStatus; }
public void setMatchStatus(MatchStatus matchStatus) { this.matchStatus = matchStatus; }
public Integer getMatchConfidence() { return matchConfidence; }
public void setMatchConfidence(Integer matchConfidence) { this.matchConfidence = matchConfidence; }
public UUID getMatchedMemberId() { return matchedMemberId; }
public void setMatchedMemberId(UUID matchedMemberId) { this.matchedMemberId = matchedMemberId; }
public UUID getMatchedPaymentId() { return matchedPaymentId; }
public void setMatchedPaymentId(UUID matchedPaymentId) { this.matchedPaymentId = matchedPaymentId; }
public String getSkipReason() { return skipReason; }
public void setSkipReason(String skipReason) { this.skipReason = skipReason; }
}
@@ -0,0 +1,107 @@
package de.cannamanage.domain.entity;
import jakarta.persistence.*;
import java.util.UUID;
/**
* Sprint 10 — Saved CSV column mapping template for a club.
* <p>
* CSV bank exports have no standard layout: column order, delimiter, encoding,
* date format, and decimal separator all vary by bank. Rather than asking the
* admin to re-configure on every upload, mappings are saved per bank
* (e.g. "Sparkasse Export", "DKB Online").
* <p>
* One mapping per club may be flagged as {@link #isDefault} — used to
* pre-populate the upload wizard.
*/
@Entity
@Table(name = "csv_column_mappings")
public class CsvColumnMapping extends AbstractTenantEntity {
@Column(name = "club_id", nullable = false, updatable = false)
private UUID clubId;
/** User-facing label, e.g. "Sparkasse Export" or "DKB Online". */
@Column(name = "name", nullable = false, length = 100)
private String name;
/** 0-based column index of the booking date. */
@Column(name = "date_column", nullable = false)
private Integer dateColumn;
/** 0-based column index of the amount field (sign convention: bank's own). */
@Column(name = "amount_column", nullable = false)
private Integer amountColumn;
@Column(name = "reference_column")
private Integer referenceColumn;
@Column(name = "counterparty_column")
private Integer counterpartyColumn;
@Column(name = "iban_column")
private Integer ibanColumn;
@Column(name = "delimiter", nullable = false, length = 5)
private String delimiter = ";";
/** Pattern compatible with {@code DateTimeFormatter.ofPattern}, e.g. {@code dd.MM.yyyy}. */
@Column(name = "date_format", nullable = false, length = 20)
private String dateFormat = "dd.MM.yyyy";
/** Single character — typically "," (German) or "." (English). */
@Column(name = "decimal_separator", nullable = false, length = 1)
private String decimalSeparator = ",";
@Column(name = "skip_header_rows", nullable = false)
private Integer skipHeaderRows = 1;
/** Character set name, e.g. {@code ISO-8859-1} (German default), {@code UTF-8}, {@code windows-1252}. */
@Column(name = "encoding", nullable = false, length = 20)
private String encoding = "ISO-8859-1";
@Column(name = "is_default", nullable = false)
private Boolean isDefault = false;
// Getters and setters
public UUID getClubId() { return clubId; }
public void setClubId(UUID clubId) { this.clubId = clubId; }
public String getName() { return name; }
public void setName(String name) { this.name = name; }
public Integer getDateColumn() { return dateColumn; }
public void setDateColumn(Integer dateColumn) { this.dateColumn = dateColumn; }
public Integer getAmountColumn() { return amountColumn; }
public void setAmountColumn(Integer amountColumn) { this.amountColumn = amountColumn; }
public Integer getReferenceColumn() { return referenceColumn; }
public void setReferenceColumn(Integer referenceColumn) { this.referenceColumn = referenceColumn; }
public Integer getCounterpartyColumn() { return counterpartyColumn; }
public void setCounterpartyColumn(Integer counterpartyColumn) { this.counterpartyColumn = counterpartyColumn; }
public Integer getIbanColumn() { return ibanColumn; }
public void setIbanColumn(Integer ibanColumn) { this.ibanColumn = ibanColumn; }
public String getDelimiter() { return delimiter; }
public void setDelimiter(String delimiter) { this.delimiter = delimiter; }
public String getDateFormat() { return dateFormat; }
public void setDateFormat(String dateFormat) { this.dateFormat = dateFormat; }
public String getDecimalSeparator() { return decimalSeparator; }
public void setDecimalSeparator(String decimalSeparator) { this.decimalSeparator = decimalSeparator; }
public Integer getSkipHeaderRows() { return skipHeaderRows; }
public void setSkipHeaderRows(Integer skipHeaderRows) { this.skipHeaderRows = skipHeaderRows; }
public String getEncoding() { return encoding; }
public void setEncoding(String encoding) { this.encoding = encoding; }
public Boolean getIsDefault() { return isDefault; }
public void setIsDefault(Boolean isDefault) { this.isDefault = isDefault; }
}
@@ -3,6 +3,7 @@ package de.cannamanage.domain.entity;
import de.cannamanage.domain.enums.MemberStatus;
import jakarta.persistence.*;
import java.time.Instant;
import java.time.LocalDate;
import java.util.UUID;
@@ -49,6 +50,17 @@ public class Member extends AbstractTenantEntity {
@Column(name = "prevention_officer", nullable = false)
private boolean preventionOfficer = false;
/**
* Sprint 10 — Member's IBAN, used by the bank statement matching engine.
* Nullable: only populated after the member explicitly grants BANK_DATA consent.
*/
@Column(name = "iban", length = 34)
private String iban;
/** Sprint 10 — Timestamp when BANK_DATA consent was granted for this IBAN. */
@Column(name = "iban_consent_date")
private Instant ibanConsentDate;
public UUID getUserId() { return userId; }
public void setUserId(UUID userId) { this.userId = userId; }
@@ -81,4 +93,10 @@ public class Member extends AbstractTenantEntity {
public boolean isPreventionOfficer() { return preventionOfficer; }
public void setPreventionOfficer(boolean preventionOfficer) { this.preventionOfficer = preventionOfficer; }
public String getIban() { return iban; }
public void setIban(String iban) { this.iban = iban; }
public Instant getIbanConsentDate() { return ibanConsentDate; }
public void setIbanConsentDate(Instant ibanConsentDate) { this.ibanConsentDate = ibanConsentDate; }
}
@@ -93,5 +93,11 @@ public enum AuditEventType {
AUTHORITY_EXPORT,
DESTRUCTION_RECORDED,
TRANSPORT_RECORDED,
RETENTION_DELETED
RETENTION_DELETED,
// Sprint 10 — Smart Payment Import events
BANK_IMPORT_STARTED,
BANK_IMPORT_COMPLETED,
BANK_IMPORT_FAILED,
BANK_PAYMENT_CONFIRMED
}
@@ -0,0 +1,16 @@
package de.cannamanage.domain.enums;
/**
* Supported bank statement file formats for the payment import feature (Sprint 10).
*
* <ul>
* <li>{@link #MT940} — SWIFT MT940, the legacy German banking standard (CSV-like text format)</li>
* <li>{@link #CAMT053} — ISO 20022 camt.053 Bank-to-Customer Statement (XML)</li>
* <li>{@link #CSV} — Generic delimited text, requires a {@code CsvColumnMapping} to interpret</li>
* </ul>
*/
public enum BankFormat {
MT940,
CAMT053,
CSV
}
@@ -9,5 +9,7 @@ public enum ConsentType {
ANALYTICS,
// Sprint 7 — Push notification consent (GDPR Art. 7(1))
NOTIFICATION_PUSH,
NOTIFICATION_EMAIL
NOTIFICATION_EMAIL,
// Sprint 10 — IBAN storage consent (DSGVO Art. 6(1)(a)) for bank statement matching
BANK_DATA
}
@@ -0,0 +1,18 @@
package de.cannamanage.domain.enums;
/**
* Lifecycle status of a {@code BankImportSession} (Sprint 10).
*
* <ul>
* <li>{@link #PENDING} — file uploaded + parsed, matching not yet run</li>
* <li>{@link #IN_REVIEW} — matching completed, admin is reviewing/confirming entries</li>
* <li>{@link #COMPLETED} — admin finalized the session; session is immutable per GoBD</li>
* <li>{@link #FAILED} — parse or processing error, or admin discarded an in-progress session</li>
* </ul>
*/
public enum ImportSessionStatus {
PENDING,
IN_REVIEW,
COMPLETED,
FAILED
}
@@ -0,0 +1,20 @@
package de.cannamanage.domain.enums;
/**
* Match status of a {@code BankTransaction} against the membership/payment registry (Sprint 10).
*
* <ul>
* <li>{@link #UNMATCHED} — no member candidate found by the matching engine</li>
* <li>{@link #SUGGESTED} — a candidate was found with medium confidence (60-89%) — admin review required</li>
* <li>{@link #MATCHED} — a candidate was auto-matched with high confidence (&ge;90%) — pre-selected for confirm</li>
* <li>{@link #CONFIRMED} — admin confirmed the match; a {@code Payment} has been created</li>
* <li>{@link #SKIPPED} — admin explicitly skipped this transaction (not a member payment)</li>
* </ul>
*/
public enum MatchStatus {
UNMATCHED,
SUGGESTED,
MATCHED,
CONFIRMED,
SKIPPED
}
@@ -5,5 +5,12 @@ public enum MemberStatus {
SUSPENDED,
EXPELLED,
PENDING_APPROVAL,
RESIGNED
RESIGNED,
/**
* Sprint 9 retention semantics — used by {@code RetentionService} to identify
* members who have left the club and whose data falls under the KCanG §24
* 5-year retention rule. Treated equivalently to {@link #RESIGNED} for
* membership-lifecycle purposes.
*/
LEFT
}
@@ -28,5 +28,7 @@ public enum NotificationType {
BOARD_TERM_EXPIRING,
// Sprint 9 — Compliance:
COMPLIANCE_DEADLINE,
RETENTION_WARNING
RETENTION_WARNING,
// Sprint 10 — Smart Payment Import:
BANK_IMPORT_COMPLETED
}
@@ -26,5 +26,8 @@ public enum StaffPermission {
// Sprint 9:
GENERATE_REPORTS,
VIEW_COMPLIANCE,
MANAGE_COMPLIANCE
MANAGE_COMPLIANCE,
// Sprint 10 — Smart Payment Import:
/** Permission to upload bank statements and confirm matched transactions. */
FINANCE_IMPORT
}