Skip to content

Commit d4e57bd

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

10 files changed

Lines changed: 435 additions & 28 deletions

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

detekt.yml

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,12 @@ formatting:
1414
ParameterListWrapping:
1515
active: false
1616

17+
complexity:
18+
LongParameterList:
19+
active: true
20+
ignoreDefaultParameters: true
21+
ignoreAnnotated: Factory
22+
1723
exceptions:
1824
TooGenericExceptionThrown:
1925
active: false

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/application/service/PersonService.kt

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -56,8 +56,8 @@ class PersonService(private val personRepository: PersonRepository, private val
5656

5757
require(existingPerson is Person.Individual) { "Person with ID $id is not an individual" }
5858

59-
existingPerson.updatePersonalInfo(personalInfo)
60-
existingPerson.updateAdditionalInformation(additionalInformation)
59+
existingPerson.personalInfo = personalInfo
60+
existingPerson.additionalInformation = additionalInformation
6161

6262
val updatedPerson = personRepository.save(existingPerson)
6363
eventPublisher.publish(PersonUpdatedEvent(updatedPerson.id, updatedPerson))
@@ -78,9 +78,9 @@ class PersonService(private val personRepository: PersonRepository, private val
7878

7979
require(existingPerson is Person.LegalEntity) { "Person with ID $id is not a legal entity" }
8080

81-
existingPerson.updateGeneralInformation(generalInformation)
82-
existingPerson.updateLegalInformation(legalInformation)
83-
existingPerson.updateLegalRepresentative(legalRepresentative)
81+
existingPerson.generalInformation = generalInformation
82+
existingPerson.legalInformation = legalInformation
83+
existingPerson.legalRepresentative = legalRepresentative
8484

8585
val updatedPerson = personRepository.save(existingPerson)
8686
eventPublisher.publish(PersonUpdatedEvent(updatedPerson.id, updatedPerson))

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

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -30,7 +30,7 @@ data class Address(
3030
}
3131

3232
companion object {
33-
@Suppress("LongParameterList")
33+
@Factory
3434
fun create(
3535
type: AddressType,
3636
number: String,
@@ -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: 184 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,184 @@
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+
35+
data class BankingInfo(
36+
val id: BankingInfoId,
37+
val accountType: AccountType,
38+
val iban: IBAN? = null,
39+
val bic: BIC? = null,
40+
) {
41+
init {
42+
// Ensure that appropriate fields are provided based on account type
43+
when (accountType) {
44+
AccountType.IBAN -> require(iban != null) { "IBAN must be provided for IBAN account type" }
45+
}
46+
}
47+
48+
companion object {
49+
@Factory
50+
fun createWithIBAN(iban: IBAN, bic: BIC? = null): Result<BankingInfo> = runCatching {
51+
BankingInfo(
52+
id = BankingInfoId(UUID.randomUUID()),
53+
accountType = AccountType.IBAN,
54+
iban = iban,
55+
bic = bic,
56+
)
57+
}
58+
}
59+
}
60+
61+
@JvmInline
62+
value class BankingInfoId(val value: UUID) {
63+
override fun toString(): String = value.toString()
64+
}
65+
66+
enum class AccountType {
67+
IBAN,
68+
}
69+
70+
/**
71+
* Value object for International Bank Account Number (IBAN)
72+
* Enforces validation rules for IBAN format
73+
*/
74+
@JvmInline
75+
value class IBAN private constructor(val value: String) {
76+
companion object {
77+
@Factory
78+
fun create(value: String): Result<IBAN> = runCatching {
79+
require(value.isNotBlank()) { "IBAN cannot be blank" }
80+
81+
// Remove spaces for validation
82+
val normalizedValue = value.replace(" ", "")
83+
84+
// Basic format validation (more comprehensive validation could be added)
85+
require(
86+
normalizedValue.length >= BankingValidationConstants.IBAN_MIN_LENGTH &&
87+
normalizedValue.length <= BankingValidationConstants.IBAN_MAX_LENGTH,
88+
) {
89+
"IBAN must be between ${BankingValidationConstants.IBAN_MIN_LENGTH} and ${BankingValidationConstants.IBAN_MAX_LENGTH} characters"
90+
}
91+
require(
92+
normalizedValue.matches(
93+
Regex(
94+
"^[A-Z]{${BankingValidationConstants.IBAN_COUNTRY_CODE_LENGTH}}" +
95+
"[0-9A-Z]{${BankingValidationConstants.IBAN_MIN_REMAINING_CHARS},${BankingValidationConstants.IBAN_MAX_REMAINING_CHARS}}$",
96+
),
97+
),
98+
) {
99+
"IBAN must start with ${BankingValidationConstants.IBAN_COUNTRY_CODE_LENGTH} letters followed by " +
100+
"${BankingValidationConstants.IBAN_MIN_REMAINING_CHARS}-${BankingValidationConstants.IBAN_MAX_REMAINING_CHARS} alphanumeric characters"
101+
}
102+
103+
val bban = normalizedValue.substring(BankingValidationConstants.IBAN_PREFIX_LENGTH) +
104+
normalizedValue.substring(0, BankingValidationConstants.IBAN_PREFIX_LENGTH)
105+
val result = bban.validateModulo97()
106+
107+
require(result) { "Invalid IBAN" }
108+
109+
IBAN(normalizedValue)
110+
}
111+
}
112+
113+
/**
114+
* Returns formatted IBAN with spaces for readability
115+
*/
116+
fun formatted(): String {
117+
return value.chunked(BankingValidationConstants.IBAN_CHUNK_SIZE).joinToString(" ")
118+
}
119+
120+
override fun toString(): String = value
121+
}
122+
123+
/**
124+
* Value object for Bank Identifier Code (BIC/SWIFT)
125+
* Enforces validation rules for BIC format
126+
*/
127+
@JvmInline
128+
value class BIC private constructor(val value: String) {
129+
companion object {
130+
@Factory
131+
fun create(value: String): Result<BIC> = runCatching {
132+
require(value.isNotBlank()) { "BIC cannot be blank" }
133+
134+
// Remove spaces for validation
135+
val normalizedValue = value.replace(" ", "")
136+
137+
// BIC format validation
138+
require(
139+
normalizedValue.length == BankingValidationConstants.BIC_LENGTH_SHORT ||
140+
normalizedValue.length == BankingValidationConstants.BIC_LENGTH_LONG,
141+
) {
142+
"BIC must be either ${BankingValidationConstants.BIC_LENGTH_SHORT} or ${BankingValidationConstants.BIC_LENGTH_LONG} characters"
143+
}
144+
require(
145+
normalizedValue.matches(
146+
Regex(
147+
"^[A-Z]{${BankingValidationConstants.BIC_BANK_CODE_LENGTH}}" +
148+
"[A-Z]{${BankingValidationConstants.BIC_COUNTRY_CODE_LENGTH}}" +
149+
"[A-Z0-9]{${BankingValidationConstants.BIC_LOCATION_CODE_LENGTH}}" +
150+
"([A-Z0-9]{${BankingValidationConstants.BIC_BRANCH_CODE_LENGTH}})?$",
151+
),
152+
),
153+
) {
154+
"BIC must follow the pattern: ${BankingValidationConstants.BIC_BANK_CODE_LENGTH} letters (bank code) + " +
155+
"${BankingValidationConstants.BIC_COUNTRY_CODE_LENGTH} letters (country code) + " +
156+
"${BankingValidationConstants.BIC_LOCATION_CODE_LENGTH} alphanumeric (location code) + " +
157+
"optional ${BankingValidationConstants.BIC_BRANCH_CODE_LENGTH} alphanumeric (branch code)"
158+
}
159+
160+
BIC(normalizedValue)
161+
}
162+
}
163+
164+
override fun toString(): String = value
165+
}
166+
167+
/**
168+
* Extension function to validate a string using modulo 97 check.
169+
* The algorithm converts the alphanumeric string to a numeric value and checks if it's divisible by 97.
170+
* @return true if the string passes the modulo 97 check, false otherwise
171+
*/
172+
private fun String.validateModulo97(): Boolean {
173+
return this.toCharArray()
174+
.map { it.code }
175+
.fold(0) { previousMod: Int, char: Int ->
176+
val value = Integer.parseInt(char.toChar().toString(), BankingValidationConstants.BASE_36)
177+
val factor = if (value < BankingValidationConstants.SMALL_DIGIT_THRESHOLD) {
178+
BankingValidationConstants.SMALL_DIGIT_FACTOR
179+
} else {
180+
BankingValidationConstants.LARGE_DIGIT_FACTOR
181+
}
182+
(factor * previousMod + value) % BankingValidationConstants.MODULO_DIVISOR
183+
} == BankingValidationConstants.MODULO_EXPECTED_REMAINDER
184+
}
Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
package com.ps.person.api.domain.model
2+
3+
/**
4+
* Annotation to mark factory methods that create domain objects.
5+
* This is used to identify methods that serve as factories for creating domain entities.
6+
*/
7+
@Target(AnnotationTarget.FUNCTION)
8+
@Retention(AnnotationRetention.RUNTIME)
9+
@MustBeDocumented
10+
annotation class Factory

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

Lines changed: 10 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,8 @@ sealed class Person {
1010

1111
protected abstract val addresses: MutableList<Address>
1212

13+
protected abstract val bankingInfo: BankingInfo?
14+
1315
val allAddresses: List<Address>
1416
get() = addresses.toList()
1517

@@ -50,21 +52,24 @@ sealed class Person {
5052
var personalInfo: PersonalInfo,
5153
var additionalInformation: AdditionalInformation,
5254
override val addresses: MutableList<Address> = mutableListOf(),
55+
override val bankingInfo: BankingInfo? = null,
5356
) : Person() {
5457
companion object {
58+
@Factory
5559
fun create(
5660
personalInfo: PersonalInfo,
5761
additionalInformation: AdditionalInformation,
5862
principalAddress: Address? = null,
5963
secondaryAddresses: List<Address> = emptyList(),
64+
bankingInfo: BankingInfo? = null,
6065
): Individual {
6166
val person = Individual(
6267
id = PersonId(UUID.randomUUID()),
6368
personalInfo = personalInfo,
6469
additionalInformation = additionalInformation,
70+
bankingInfo = bankingInfo,
6571
)
6672

67-
// Process addresses to ensure at most one principal address
6873
principalAddress?.let {
6974
person.addAddress(principalAddress)
7075
}
@@ -74,14 +79,6 @@ sealed class Person {
7479
return person
7580
}
7681
}
77-
78-
fun updatePersonalInfo(personalInfo: PersonalInfo) {
79-
this.personalInfo = personalInfo
80-
}
81-
82-
fun updateAdditionalInformation(additionalInformation: AdditionalInformation) {
83-
this.additionalInformation = additionalInformation
84-
}
8582
}
8683

8784
/**
@@ -93,20 +90,24 @@ sealed class Person {
9390
var legalInformation: LegalInformation,
9491
var legalRepresentative: LegalRepresentative,
9592
override val addresses: MutableList<Address> = mutableListOf(),
93+
override val bankingInfo: BankingInfo? = null,
9694
) : Person() {
9795
companion object {
96+
@Factory
9897
fun create(
9998
generalInformation: GeneralInformation,
10099
legalInformation: LegalInformation,
101100
legalRepresentative: LegalRepresentative,
102101
principalAddress: Address? = null,
103102
secondaryAddresses: List<Address> = emptyList(),
103+
bankingInfo: BankingInfo? = null,
104104
): LegalEntity {
105105
val person = LegalEntity(
106106
id = PersonId(UUID.randomUUID()),
107107
generalInformation = generalInformation,
108108
legalInformation = legalInformation,
109109
legalRepresentative = legalRepresentative,
110+
bankingInfo = bankingInfo,
110111
)
111112

112113
// Process addresses to ensure at most one principal address
@@ -119,18 +120,6 @@ sealed class Person {
119120
return person
120121
}
121122
}
122-
123-
fun updateGeneralInformation(generalInformation: GeneralInformation) {
124-
this.generalInformation = generalInformation
125-
}
126-
127-
fun updateLegalInformation(legalInformation: LegalInformation) {
128-
this.legalInformation = legalInformation
129-
}
130-
131-
fun updateLegalRepresentative(legalRepresentative: LegalRepresentative) {
132-
this.legalRepresentative = legalRepresentative
133-
}
134123
}
135124

136125
/**

0 commit comments

Comments
 (0)