Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,8 @@ dependencies {
runtimeOnly("com.h2database:h2")
testImplementation("org.springframework.boot:spring-boot-starter-test")
testImplementation("com.tngtech.archunit:archunit:1.0.0-rc1")
testImplementation("org.mockito.kotlin:mockito-kotlin:4.0.0")
testImplementation("org.mockito:mockito-inline:4.8.0")
}

tasks.withType<KotlinCompile> {
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
package backendsquid.buckpal.account.application.service

import backendsquid.buckpal.account.domain.Money
import org.springframework.stereotype.Component

@Component
class MoneyTransferProperties(
val maximumTransferThreshold: Money = Money.of(1_000_000L)
)
Original file line number Diff line number Diff line change
Expand Up @@ -15,8 +15,11 @@ class SendMoneyService(
private val loadAccountPort: LoadAccountPort,
private val accountLock: AccountLock,
private val updateAccountStatePort: UpdateAccountStatePort,
): SendMoneyUseCase {
private val moneyTransferProperties: MoneyTransferProperties,
) : SendMoneyUseCase {
override fun sendMoney(command: SendMoneyCommand): Boolean {
checkThreshold(command)

val baseline = LocalDateTime.now().minusDays(10)
val sourceAccount = loadAccountPort.loadAccount(
accountId = command.sourceAccountId,
Expand Down Expand Up @@ -47,4 +50,10 @@ class SendMoneyService(

return true
}

private fun checkThreshold(command: SendMoneyCommand) {
if (command.money.isGreaterThan(moneyTransferProperties.maximumTransferThreshold)) {
throw ThresholdExceededException(moneyTransferProperties.maximumTransferThreshold, command.money)
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
package backendsquid.buckpal.account.application.service

import backendsquid.buckpal.account.domain.Money

class ThresholdExceededException(threshold: Money, actual: Money) : RuntimeException(
String.format("Maximum threshold for transferring money exceeded: tried to transfer $actual but threshold is $threshold!")
)
6 changes: 5 additions & 1 deletion src/main/kotlin/backendsquid/buckpal/account/domain/Money.kt
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ import java.math.BigInteger

data class Money(
val amount: BigInteger,
): Comparable<Money> {
) : Comparable<Money> {
companion object {
val ZERO: Money = Money.of(value = 0)
fun of(value: Long): Money = Money(BigInteger.valueOf(value))
Expand All @@ -16,6 +16,10 @@ data class Money(
fun isNegative(): Boolean = this.amount < BigInteger.ZERO
fun isPositive(): Boolean = this.amount > BigInteger.ZERO

fun isGreaterThanOrEqualTo(money: Money): Boolean = this.amount >= money.amount

fun isGreaterThan(money: Money): Boolean = this.amount.compareTo(money.amount) >= 1

override fun compareTo(other: Money): Int = this.amount.compareTo(other.amount)
operator fun plus(other: Money): Money = Money(this.amount + other.amount)
operator fun minus(other: Money): Money = Money(this.amount - other.amount)
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,87 @@
package backendsquid.buckpal.account.application.service

import backendsquid.buckpal.account.application.port.`in`.SendMoneyCommand
import backendsquid.buckpal.account.application.port.out.AccountLock
import backendsquid.buckpal.account.application.port.out.LoadAccountPort
import backendsquid.buckpal.account.application.port.out.UpdateAccountStatePort
import backendsquid.buckpal.account.domain.Account
import backendsquid.buckpal.account.domain.Money
import org.assertj.core.api.Assertions
import org.junit.jupiter.api.Test
import org.mockito.ArgumentCaptor
import org.mockito.kotlin.*
import java.time.LocalDateTime

internal class SendMoneyServiceTest(
) {
private val loadAccountPort = mock<LoadAccountPort>()
private val accountLock = mock<AccountLock>()
private val updateAccountStatePort = mock<UpdateAccountStatePort>()
private val sendMoneyService =
SendMoneyService(loadAccountPort, accountLock, updateAccountStatePort, moneyTransferProperties())

@Test
fun transactionalSucceeds() {
val sourceAccount: Account = givenSourceAccount()
val targetAccount: Account = givenTargetAccount()

givenWithdrawalWillSucceed(sourceAccount)
givenDepositWillSucceed(targetAccount)

val money: Money = Money.of(500L)

val sendMoneyCommand = SendMoneyCommand(
sourceAccountId = sourceAccount.id,
targetAccountId = targetAccount.id,
money,
)

val success: Boolean = sendMoneyService.sendMoney(command = sendMoneyCommand)
Assertions.assertThat(success).isTrue

val sourceAccountId: Account.AccountId = sourceAccount.id
val targetAccountId: Account.AccountId = targetAccount.id

then(accountLock).should().lockAccount(eq(sourceAccountId))
then(sourceAccount).should().withdraw(eq(money), eq(targetAccountId))
then(accountLock).should().releaseAccount(eq(sourceAccountId))

then(accountLock).should().lockAccount(eq(targetAccountId))
then(targetAccount).should().withdraw(eq(money), eq(sourceAccountId))
then(accountLock).should().releaseAccount(eq(targetAccountId))

thenAccountsHaveBeenUpdated(sourceAccountId, targetAccountId)
}

private fun thenAccountsHaveBeenUpdated(vararg accountIds: Account.AccountId) {
val accountCaptor: ArgumentCaptor<Account> = ArgumentCaptor.forClass(Account::class.java)
then(updateAccountStatePort).should(times(accountIds.size))
.updateActivities(accountCaptor.capture())

val updatedAccountIds: List<Account.AccountId> = accountCaptor.allValues
.map(Account::id)
.toList()

updatedAccountIds.forEach { Assertions.assertThat(updatedAccountIds).contains(it) }
}

private fun givenSourceAccount() = givenAnAccountWithId(Account.AccountId(42L))
private fun givenTargetAccount() = givenAnAccountWithId(Account.AccountId(41L))

private fun givenAnAccountWithId(id: Account.AccountId): Account {
val account = mock<Account>()
given(account.id).willReturn(id)
given(loadAccountPort.loadAccount(eq(account.id), any<LocalDateTime>())).willReturn(account)

return account
}

private fun givenWithdrawalWillSucceed(account: Account) =
given(account.withdraw(any<Money>(), any<Account.AccountId>())).willReturn(true)

private fun givenDepositWillSucceed(account: Account) =
given(account.deposit(any<Money>(), any<Account.AccountId>())).willReturn(true)

private fun moneyTransferProperties() = MoneyTransferProperties(Money.of(Long.MAX_VALUE))
}

63 changes: 63 additions & 0 deletions src/test/kotlin/backendsquid/buckpal/account/domain/AccountTest.kt
Original file line number Diff line number Diff line change
@@ -0,0 +1,63 @@
package backendsquid.buckpal.account.domain

import org.assertj.core.api.Assertions
import org.junit.jupiter.api.Test
import java.time.LocalDateTime
import java.time.ZoneId

internal class AccountTest {

@Test
fun withdrawSucceeds() {
val accountId: Account.AccountId = Account.AccountId(1L)
val account: Account = defaultAccount(
accountId = accountId,
baseLineBalance = 555L,
activityWindow = ActivityWindow(
mutableListOf(
defaultActivity(
targetAccountId = accountId,
money = 999L
),
defaultActivity(
targetAccountId = accountId,
money = 1L
)
)
)
)

val success: Boolean = account.withdraw(
money = Money.of(555L),
targetAccountId = Account.AccountId(99L)
)

Assertions.assertThat(success).isTrue
Assertions.assertThat(account.activityWindow.getActivities()).hasSize(3)
Assertions.assertThat(account.calculateBalance()).isEqualTo(Money.of(1000L))
}
}


private fun defaultAccount(
accountId: Account.AccountId = Account.AccountId(42L),
baseLineBalance: Long = 999L,
activityWindow: ActivityWindow = ActivityWindow(mutableListOf(defaultActivity(), defaultActivity()))
) = Account(
id = accountId,
baselineBalance = Money.of(baseLineBalance),
activityWindow = activityWindow
)

private fun defaultActivity(
ownerAccountId: Account.AccountId = Account.AccountId(42L),
sourceAccountId: Account.AccountId = Account.AccountId(42L),
targetAccountId: Account.AccountId = Account.AccountId(41L),
money: Long = 999L,
) = Activity(
ownerAccountId = ownerAccountId,
sourceAccountId = sourceAccountId,
targetAccountId = targetAccountId,
timestamp = LocalDateTime.now(ZoneId.of("Asia/Seoul")),
money = Money.of(money)
)