Skip to content

Commit 4fefe14

Browse files
author
Richard Capraro
committed
feat: Add BankingInfo
1 parent 46bc462 commit 4fefe14

5 files changed

Lines changed: 382 additions & 1 deletion

File tree

build.gradle.kts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -64,6 +64,11 @@ dependencies {
6464
testImplementation(libs.ktor.server.test.host)
6565
testImplementation(libs.kotlin.test)
6666

67+
// Kotest
68+
testImplementation(libs.kotest.runner.junit5)
69+
testImplementation(libs.kotest.assertions.core)
70+
testImplementation(libs.kotest.property)
71+
6772
detektPlugins("io.gitlab.arturbosch.detekt:detekt-formatting:1.23.8")
6873
}
6974

gradle/libs.versions.toml

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ meilisearch = "0.14.4"
99
hikariCp = "5.1.0"
1010
flyway = "11.8.2"
1111
detekt = "1.23.8"
12+
kotest = "5.9.1"
1213

1314
[libraries]
1415
kotlin-stdlib = { module = "org.jetbrains.kotlin:kotlin-stdlib", version.ref = "kotlin" }
@@ -43,6 +44,10 @@ meilisearch = { module = "com.meilisearch.sdk:meilisearch-java", version.ref = "
4344

4445
logback = { module = "ch.qos.logback:logback-classic", version.ref = "logback" }
4546

47+
kotest-runner-junit5 = { module = "io.kotest:kotest-runner-junit5", version.ref = "kotest" }
48+
kotest-assertions-core = { module = "io.kotest:kotest-assertions-core", version.ref = "kotest" }
49+
kotest-property = { module = "io.kotest:kotest-property", version.ref = "kotest" }
50+
4651
[plugins]
4752
kotlin-jvm = { id = "org.jetbrains.kotlin.jvm", version.ref = "kotlin" }
4853
kotlin-serialization = { id = "org.jetbrains.kotlin.plugin.serialization", version.ref = "kotlin" }

src/main/kotlin/com/ps/person/api/domain/model/Address.kt

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -75,7 +75,8 @@ fun List<Address>.getSecondaryAddresses(): List<Address> = this.filter { it.isSe
7575
/**
7676
* Value object for Address ID
7777
*/
78-
data class AddressId(val value: UUID) {
78+
@JvmInline
79+
value class AddressId(val value: UUID) {
7980
override fun toString(): String = value.toString()
8081
}
8182

Lines changed: 273 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,273 @@
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

Comments
 (0)