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:
@@ -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 (≥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
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user