|
| 1 | +package com.ps.person.api.domain.model |
| 2 | + |
| 3 | +import java.util.* |
| 4 | + |
| 5 | +/** |
| 6 | + * Constants for banking validation |
| 7 | + */ |
| 8 | +private object BankingValidationConstants { |
| 9 | + // Modulo validation constants |
| 10 | + const val MODULO_DIVISOR = 97 |
| 11 | + const val MODULO_EXPECTED_REMAINDER = 1 |
| 12 | + const val SMALL_DIGIT_FACTOR = 10 |
| 13 | + const val LARGE_DIGIT_FACTOR = 100 |
| 14 | + const val SMALL_DIGIT_THRESHOLD = 10 |
| 15 | + const val BASE_36 = 36 |
| 16 | + |
| 17 | + // IBAN validation constants |
| 18 | + const val IBAN_MIN_LENGTH = 15 |
| 19 | + const val IBAN_MAX_LENGTH = 34 |
| 20 | + const val IBAN_COUNTRY_CODE_LENGTH = 2 |
| 21 | + const val IBAN_MIN_REMAINING_CHARS = 13 |
| 22 | + const val IBAN_MAX_REMAINING_CHARS = 32 |
| 23 | + const val IBAN_PREFIX_LENGTH = 4 |
| 24 | + const val IBAN_CHUNK_SIZE = 4 |
| 25 | + |
| 26 | + // BIC validation constants |
| 27 | + const val BIC_LENGTH_SHORT = 8 |
| 28 | + const val BIC_LENGTH_LONG = 11 |
| 29 | + const val BIC_BANK_CODE_LENGTH = 4 |
| 30 | + const val BIC_COUNTRY_CODE_LENGTH = 2 |
| 31 | + const val BIC_LOCATION_CODE_LENGTH = 2 |
| 32 | + const val BIC_BRANCH_CODE_LENGTH = 3 |
| 33 | + |
| 34 | + // BBAN validation constants |
| 35 | + const val BBAN_MIN_COMBINED_LENGTH = 5 |
| 36 | + const val BBAN_MAX_COMBINED_LENGTH = 30 |
| 37 | +} |
| 38 | + |
| 39 | +data class BankingInfo( |
| 40 | + val id: BankingInfoId, |
| 41 | + val accountType: AccountType, |
| 42 | + val iban: IBAN? = null, |
| 43 | + val bic: BIC? = null, |
| 44 | + val bban: BBAN? = null, |
| 45 | +) { |
| 46 | + init { |
| 47 | + // Ensure that appropriate fields are provided based on account type |
| 48 | + when (accountType) { |
| 49 | + AccountType.IBAN -> require(iban != null) { "IBAN must be provided for IBAN account type" } |
| 50 | + AccountType.SWIFT -> require(bic != null) { "BIC must be provided for SWIFT account type" } |
| 51 | + AccountType.BBAN -> require(bban != null) { "BBAN must be provided for BBAN account type" } |
| 52 | + } |
| 53 | + } |
| 54 | + |
| 55 | + companion object { |
| 56 | + fun createWithIBAN(iban: IBAN, bic: BIC? = null): Result<BankingInfo> = runCatching { |
| 57 | + BankingInfo( |
| 58 | + id = BankingInfoId(UUID.randomUUID()), |
| 59 | + accountType = AccountType.IBAN, |
| 60 | + iban = iban, |
| 61 | + bic = bic, |
| 62 | + ) |
| 63 | + } |
| 64 | + |
| 65 | + fun createWithBIC(bic: BIC): Result<BankingInfo> = runCatching { |
| 66 | + BankingInfo( |
| 67 | + id = BankingInfoId(UUID.randomUUID()), |
| 68 | + accountType = AccountType.SWIFT, |
| 69 | + bic = bic, |
| 70 | + ) |
| 71 | + } |
| 72 | + |
| 73 | + fun createWithBBAN(bban: BBAN): Result<BankingInfo> = runCatching { |
| 74 | + BankingInfo( |
| 75 | + id = BankingInfoId(UUID.randomUUID()), |
| 76 | + accountType = AccountType.BBAN, |
| 77 | + bban = bban, |
| 78 | + ) |
| 79 | + } |
| 80 | + |
| 81 | + // For backward compatibility |
| 82 | + fun createWithBBAN(accountNumber: String, bankCode: String): Result<BankingInfo> = runCatching { |
| 83 | + val bbanResult = BBAN.create(bankCode, accountNumber) |
| 84 | + bbanResult.getOrNull()?.let { bban -> |
| 85 | + BankingInfo( |
| 86 | + id = BankingInfoId(UUID.randomUUID()), |
| 87 | + accountType = AccountType.BBAN, |
| 88 | + bban = bban, |
| 89 | + ) |
| 90 | + } ?: throw IllegalArgumentException(bbanResult.exceptionOrNull()?.message ?: "Invalid BBAN") |
| 91 | + } |
| 92 | + } |
| 93 | +} |
| 94 | + |
| 95 | +@JvmInline |
| 96 | +value class BankingInfoId(val value: UUID) { |
| 97 | + override fun toString(): String = value.toString() |
| 98 | +} |
| 99 | + |
| 100 | +enum class AccountType { |
| 101 | + IBAN, |
| 102 | + BBAN, |
| 103 | + SWIFT, |
| 104 | +} |
| 105 | + |
| 106 | +/** |
| 107 | + * Value object for International Bank Account Number (IBAN) |
| 108 | + * Enforces validation rules for IBAN format |
| 109 | + */ |
| 110 | +@JvmInline |
| 111 | +value class IBAN private constructor(val value: String) { |
| 112 | + companion object { |
| 113 | + fun create(value: String): Result<IBAN> = runCatching { |
| 114 | + require(value.isNotBlank()) { "IBAN cannot be blank" } |
| 115 | + |
| 116 | + // Remove spaces for validation |
| 117 | + val normalizedValue = value.replace(" ", "") |
| 118 | + |
| 119 | + // Basic format validation (more comprehensive validation could be added) |
| 120 | + require( |
| 121 | + normalizedValue.length >= BankingValidationConstants.IBAN_MIN_LENGTH && |
| 122 | + normalizedValue.length <= BankingValidationConstants.IBAN_MAX_LENGTH, |
| 123 | + ) { |
| 124 | + "IBAN must be between ${BankingValidationConstants.IBAN_MIN_LENGTH} and ${BankingValidationConstants.IBAN_MAX_LENGTH} characters" |
| 125 | + } |
| 126 | + require( |
| 127 | + normalizedValue.matches( |
| 128 | + Regex( |
| 129 | + "^[A-Z]{${BankingValidationConstants.IBAN_COUNTRY_CODE_LENGTH}}" + |
| 130 | + "[0-9A-Z]{${BankingValidationConstants.IBAN_MIN_REMAINING_CHARS},${BankingValidationConstants.IBAN_MAX_REMAINING_CHARS}}$", |
| 131 | + ), |
| 132 | + ), |
| 133 | + ) { |
| 134 | + "IBAN must start with ${BankingValidationConstants.IBAN_COUNTRY_CODE_LENGTH} letters followed by " + |
| 135 | + "${BankingValidationConstants.IBAN_MIN_REMAINING_CHARS}-${BankingValidationConstants.IBAN_MAX_REMAINING_CHARS} alphanumeric characters" |
| 136 | + } |
| 137 | + |
| 138 | + val bban = normalizedValue.substring(BankingValidationConstants.IBAN_PREFIX_LENGTH) + |
| 139 | + normalizedValue.substring(0, BankingValidationConstants.IBAN_PREFIX_LENGTH) |
| 140 | + val result = bban.validateModulo97() |
| 141 | + |
| 142 | + require(result) { "Invalid IBAN" } |
| 143 | + |
| 144 | + IBAN(normalizedValue) |
| 145 | + } |
| 146 | + } |
| 147 | + |
| 148 | + /** |
| 149 | + * Returns formatted IBAN with spaces for readability |
| 150 | + */ |
| 151 | + fun formatted(): String { |
| 152 | + return value.chunked(BankingValidationConstants.IBAN_CHUNK_SIZE).joinToString(" ") |
| 153 | + } |
| 154 | + |
| 155 | + override fun toString(): String = value |
| 156 | +} |
| 157 | + |
| 158 | +/** |
| 159 | + * Value object for Bank Identifier Code (BIC/SWIFT) |
| 160 | + * Enforces validation rules for BIC format |
| 161 | + */ |
| 162 | +@JvmInline |
| 163 | +value class BIC private constructor(val value: String) { |
| 164 | + companion object { |
| 165 | + fun create(value: String): Result<BIC> = runCatching { |
| 166 | + require(value.isNotBlank()) { "BIC cannot be blank" } |
| 167 | + |
| 168 | + // Remove spaces for validation |
| 169 | + val normalizedValue = value.replace(" ", "") |
| 170 | + |
| 171 | + // BIC format validation |
| 172 | + require( |
| 173 | + normalizedValue.length == BankingValidationConstants.BIC_LENGTH_SHORT || |
| 174 | + normalizedValue.length == BankingValidationConstants.BIC_LENGTH_LONG, |
| 175 | + ) { |
| 176 | + "BIC must be either ${BankingValidationConstants.BIC_LENGTH_SHORT} or ${BankingValidationConstants.BIC_LENGTH_LONG} characters" |
| 177 | + } |
| 178 | + require( |
| 179 | + normalizedValue.matches( |
| 180 | + Regex( |
| 181 | + "^[A-Z]{${BankingValidationConstants.BIC_BANK_CODE_LENGTH}}" + |
| 182 | + "[A-Z]{${BankingValidationConstants.BIC_COUNTRY_CODE_LENGTH}}" + |
| 183 | + "[A-Z0-9]{${BankingValidationConstants.BIC_LOCATION_CODE_LENGTH}}" + |
| 184 | + "([A-Z0-9]{${BankingValidationConstants.BIC_BRANCH_CODE_LENGTH}})?$", |
| 185 | + ), |
| 186 | + ), |
| 187 | + ) { |
| 188 | + "BIC must follow the pattern: ${BankingValidationConstants.BIC_BANK_CODE_LENGTH} letters (bank code) + " + |
| 189 | + "${BankingValidationConstants.BIC_COUNTRY_CODE_LENGTH} letters (country code) + " + |
| 190 | + "${BankingValidationConstants.BIC_LOCATION_CODE_LENGTH} alphanumeric (location code) + " + |
| 191 | + "optional ${BankingValidationConstants.BIC_BRANCH_CODE_LENGTH} alphanumeric (branch code)" |
| 192 | + } |
| 193 | + |
| 194 | + BIC(normalizedValue) |
| 195 | + } |
| 196 | + } |
| 197 | + |
| 198 | + override fun toString(): String = value |
| 199 | +} |
| 200 | + |
| 201 | +/** |
| 202 | + * Value object for Basic Bank Account Number (BBAN) |
| 203 | + * Enforces validation rules for BBAN format |
| 204 | + * BBAN is the country-specific portion of an IBAN, consisting of a bank code and an account number |
| 205 | + */ |
| 206 | +@JvmInline |
| 207 | +value class BBAN private constructor(val value: String) { |
| 208 | + val bankCode: String |
| 209 | + get() = value.split(":")[0] |
| 210 | + |
| 211 | + val accountNumber: String |
| 212 | + get() = value.split(":")[1] |
| 213 | + |
| 214 | + companion object { |
| 215 | + fun create(bankCode: String, accountNumber: String): Result<BBAN> = runCatching { |
| 216 | + require(bankCode.isNotBlank()) { "Bank code cannot be blank" } |
| 217 | + require(accountNumber.isNotBlank()) { "Account number cannot be blank" } |
| 218 | + |
| 219 | + // Remove spaces for validation |
| 220 | + val normalizedBankCode = bankCode.replace(" ", "") |
| 221 | + val normalizedAccountNumber = accountNumber.replace(" ", "") |
| 222 | + |
| 223 | + // Basic format validation |
| 224 | + require(normalizedBankCode.matches(Regex("^[0-9A-Z]+$"))) { |
| 225 | + "Bank code must contain only alphanumeric characters" |
| 226 | + } |
| 227 | + |
| 228 | + require(normalizedAccountNumber.matches(Regex("^[0-9A-Z]+$"))) { |
| 229 | + "Account number must contain only alphanumeric characters" |
| 230 | + } |
| 231 | + |
| 232 | + // Length validation |
| 233 | + val combinedLength = normalizedBankCode.length + normalizedAccountNumber.length |
| 234 | + require( |
| 235 | + combinedLength >= BankingValidationConstants.BBAN_MIN_COMBINED_LENGTH && |
| 236 | + combinedLength <= BankingValidationConstants.BBAN_MAX_COMBINED_LENGTH, |
| 237 | + ) { |
| 238 | + "Combined length of bank code and account number must be between " + |
| 239 | + "${BankingValidationConstants.BBAN_MIN_COMBINED_LENGTH} and " + |
| 240 | + "${BankingValidationConstants.BBAN_MAX_COMBINED_LENGTH} characters" |
| 241 | + } |
| 242 | + |
| 243 | + // Validate BBAN key using modulo 97 check |
| 244 | + val combined = "$normalizedBankCode$normalizedAccountNumber" |
| 245 | + val isValid = combined.validateModulo97() |
| 246 | + require(isValid) { "Invalid BBAN key (failed modulo 97 check)" } |
| 247 | + |
| 248 | + // Store as bankCode:accountNumber for internal representation |
| 249 | + BBAN("$normalizedBankCode:$normalizedAccountNumber") |
| 250 | + } |
| 251 | + } |
| 252 | + |
| 253 | + override fun toString(): String = "$bankCode:$accountNumber" |
| 254 | +} |
| 255 | + |
| 256 | +/** |
| 257 | + * Extension function to validate a string using modulo 97 check. |
| 258 | + * The algorithm converts the alphanumeric string to a numeric value and checks if it's divisible by 97. |
| 259 | + * @return true if the string passes the modulo 97 check, false otherwise |
| 260 | + */ |
| 261 | +private fun String.validateModulo97(): Boolean { |
| 262 | + return this.toCharArray() |
| 263 | + .map { it.code } |
| 264 | + .fold(0) { previousMod: Int, char: Int -> |
| 265 | + val value = Integer.parseInt(char.toChar().toString(), BankingValidationConstants.BASE_36) |
| 266 | + val factor = if (value < BankingValidationConstants.SMALL_DIGIT_THRESHOLD) { |
| 267 | + BankingValidationConstants.SMALL_DIGIT_FACTOR |
| 268 | + } else { |
| 269 | + BankingValidationConstants.LARGE_DIGIT_FACTOR |
| 270 | + } |
| 271 | + (factor * previousMod + value) % BankingValidationConstants.MODULO_DIVISOR |
| 272 | + } == BankingValidationConstants.MODULO_EXPECTED_REMAINDER |
| 273 | +} |
0 commit comments