예제 코드
This commit is contained in:
36
.github/workflows/ci.yml
vendored
Normal file
36
.github/workflows/ci.yml
vendored
Normal file
@@ -0,0 +1,36 @@
|
||||
name: CI
|
||||
|
||||
on: [push]
|
||||
|
||||
jobs:
|
||||
build:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
|
||||
- name: "Checkout sources"
|
||||
uses: actions/checkout@v1
|
||||
|
||||
- name: "Setup Java"
|
||||
uses: actions/setup-java@v1
|
||||
with:
|
||||
java-version: 14
|
||||
|
||||
- name: "Initialize Gradle dependencies cache"
|
||||
uses: actions/cache@v2
|
||||
with:
|
||||
path: ~/.gradle/caches
|
||||
key: ${{ runner.os }}-gradle-caches-${{ hashFiles('**/build.gradle') }}
|
||||
|
||||
- name: "Run Gradle build"
|
||||
run: chmod 755 gradlew && ./gradlew build
|
||||
|
||||
- name: "Zip build reports"
|
||||
if: failure()
|
||||
run: zip -r reports.zip build/reports
|
||||
|
||||
- uses: actions/upload-artifact@v1
|
||||
name: "Upload build reports"
|
||||
if: failure()
|
||||
with:
|
||||
name: reports
|
||||
path: reports.zip
|
||||
29
.gitignore
vendored
Normal file
29
.gitignore
vendored
Normal file
@@ -0,0 +1,29 @@
|
||||
HELP.md
|
||||
.gradle
|
||||
build/
|
||||
!gradle/wrapper/gradle-wrapper.jar
|
||||
|
||||
### STS ###
|
||||
.apt_generated
|
||||
.classpath
|
||||
.factorypath
|
||||
.project
|
||||
.settings
|
||||
.springBeans
|
||||
.sts4-cache
|
||||
|
||||
### IntelliJ IDEA ###
|
||||
.idea
|
||||
*.iws
|
||||
*.iml
|
||||
*.ipr
|
||||
|
||||
### NetBeans ###
|
||||
/nbproject/private/
|
||||
/nbbuild/
|
||||
/dist/
|
||||
/nbdist/
|
||||
/.nb-gradle/
|
||||
|
||||
### VS Code ###
|
||||
.vscode/
|
||||
19
README.md
Normal file
19
README.md
Normal file
@@ -0,0 +1,19 @@
|
||||
# Example Implementation of a Hexagonal Architecture
|
||||
|
||||
[](https://github.com/thombergs/buckpal/actions/workflows/ci.yml)
|
||||
|
||||
[](https://reflectoring.io/book)
|
||||
|
||||
This is the companion code to my eBook [Get Your Hands Dirty on Clean Architecture](https://leanpub.com/get-your-hands-dirty-on-clean-architecture).
|
||||
|
||||
It implements a domain-centric "Hexagonal" approach of a common web application with Java and Spring Boot.
|
||||
|
||||
## Companion Articles
|
||||
|
||||
* [Hexagonal Architecture with Java and Spring](https://reflectoring.io/spring-hexagonal/)
|
||||
* [Building a Multi-Module Spring Boot Application with Gradle](https://reflectoring.io/spring-boot-gradle-multi-module/)
|
||||
|
||||
## Prerequisites
|
||||
|
||||
* JDK 11
|
||||
* this project uses Lombok, so enable annotation processing in your IDE
|
||||
47
build.gradle
Normal file
47
build.gradle
Normal file
@@ -0,0 +1,47 @@
|
||||
plugins {
|
||||
id 'org.springframework.boot' version '2.4.3'
|
||||
id 'io.spring.dependency-management' version '1.0.11.RELEASE'
|
||||
}
|
||||
|
||||
|
||||
group = 'io.reflectoring.buckpal'
|
||||
version = '0.0.1-SNAPSHOT'
|
||||
|
||||
apply plugin: 'java'
|
||||
apply plugin: 'io.spring.dependency-management'
|
||||
apply plugin: 'java-library'
|
||||
|
||||
repositories {
|
||||
mavenCentral()
|
||||
}
|
||||
|
||||
compileJava {
|
||||
sourceCompatibility = 11
|
||||
targetCompatibility = 11
|
||||
}
|
||||
|
||||
dependencies {
|
||||
compileOnly 'org.projectlombok:lombok'
|
||||
annotationProcessor 'org.projectlombok:lombok'
|
||||
|
||||
implementation ('org.springframework.boot:spring-boot-starter-web')
|
||||
implementation 'org.springframework.boot:spring-boot-starter-validation'
|
||||
implementation 'org.springframework.boot:spring-boot-starter-data-jpa'
|
||||
|
||||
testImplementation('org.springframework.boot:spring-boot-starter-test') {
|
||||
exclude group: 'junit' // excluding junit 4
|
||||
}
|
||||
testImplementation 'org.junit.jupiter:junit-jupiter-engine:5.0.1'
|
||||
testImplementation 'org.mockito:mockito-junit-jupiter:2.23.0'
|
||||
testImplementation 'com.tngtech.archunit:archunit:0.16.0'
|
||||
testImplementation 'org.junit.platform:junit-platform-launcher:1.4.2'
|
||||
testImplementation 'com.h2database:h2'
|
||||
|
||||
runtimeOnly 'com.h2database:h2'
|
||||
|
||||
}
|
||||
|
||||
test {
|
||||
useJUnitPlatform()
|
||||
}
|
||||
|
||||
BIN
gradle/wrapper/gradle-wrapper.jar
vendored
Normal file
BIN
gradle/wrapper/gradle-wrapper.jar
vendored
Normal file
Binary file not shown.
5
gradle/wrapper/gradle-wrapper.properties
vendored
Normal file
5
gradle/wrapper/gradle-wrapper.properties
vendored
Normal file
@@ -0,0 +1,5 @@
|
||||
distributionBase=GRADLE_USER_HOME
|
||||
distributionPath=wrapper/dists
|
||||
distributionUrl=https\://services.gradle.org/distributions/gradle-6.8.2-bin.zip
|
||||
zipStoreBase=GRADLE_USER_HOME
|
||||
zipStorePath=wrapper/dists
|
||||
172
gradlew
vendored
Normal file
172
gradlew
vendored
Normal file
@@ -0,0 +1,172 @@
|
||||
#!/usr/bin/env sh
|
||||
|
||||
##############################################################################
|
||||
##
|
||||
## Gradle start up script for UN*X
|
||||
##
|
||||
##############################################################################
|
||||
|
||||
# Attempt to set APP_HOME
|
||||
# Resolve links: $0 may be a link
|
||||
PRG="$0"
|
||||
# Need this for relative symlinks.
|
||||
while [ -h "$PRG" ] ; do
|
||||
ls=`ls -ld "$PRG"`
|
||||
link=`expr "$ls" : '.*-> \(.*\)$'`
|
||||
if expr "$link" : '/.*' > /dev/null; then
|
||||
PRG="$link"
|
||||
else
|
||||
PRG=`dirname "$PRG"`"/$link"
|
||||
fi
|
||||
done
|
||||
SAVED="`pwd`"
|
||||
cd "`dirname \"$PRG\"`/" >/dev/null
|
||||
APP_HOME="`pwd -P`"
|
||||
cd "$SAVED" >/dev/null
|
||||
|
||||
APP_NAME="Gradle"
|
||||
APP_BASE_NAME=`basename "$0"`
|
||||
|
||||
# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
|
||||
DEFAULT_JVM_OPTS='"-Xmx64m"'
|
||||
|
||||
# Use the maximum available, or set MAX_FD != -1 to use that value.
|
||||
MAX_FD="maximum"
|
||||
|
||||
warn () {
|
||||
echo "$*"
|
||||
}
|
||||
|
||||
die () {
|
||||
echo
|
||||
echo "$*"
|
||||
echo
|
||||
exit 1
|
||||
}
|
||||
|
||||
# OS specific support (must be 'true' or 'false').
|
||||
cygwin=false
|
||||
msys=false
|
||||
darwin=false
|
||||
nonstop=false
|
||||
case "`uname`" in
|
||||
CYGWIN* )
|
||||
cygwin=true
|
||||
;;
|
||||
Darwin* )
|
||||
darwin=true
|
||||
;;
|
||||
MINGW* )
|
||||
msys=true
|
||||
;;
|
||||
NONSTOP* )
|
||||
nonstop=true
|
||||
;;
|
||||
esac
|
||||
|
||||
CLASSPATH=$APP_HOME/gradle/wrapper/gradle-wrapper.jar
|
||||
|
||||
# Determine the Java command to use to start the JVM.
|
||||
if [ -n "$JAVA_HOME" ] ; then
|
||||
if [ -x "$JAVA_HOME/jre/sh/java" ] ; then
|
||||
# IBM's JDK on AIX uses strange locations for the executables
|
||||
JAVACMD="$JAVA_HOME/jre/sh/java"
|
||||
else
|
||||
JAVACMD="$JAVA_HOME/bin/java"
|
||||
fi
|
||||
if [ ! -x "$JAVACMD" ] ; then
|
||||
die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME
|
||||
|
||||
Please set the JAVA_HOME variable in your environment to match the
|
||||
location of your Java installation."
|
||||
fi
|
||||
else
|
||||
JAVACMD="java"
|
||||
which java >/dev/null 2>&1 || die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH.
|
||||
|
||||
Please set the JAVA_HOME variable in your environment to match the
|
||||
location of your Java installation."
|
||||
fi
|
||||
|
||||
# Increase the maximum file descriptors if we can.
|
||||
if [ "$cygwin" = "false" -a "$darwin" = "false" -a "$nonstop" = "false" ] ; then
|
||||
MAX_FD_LIMIT=`ulimit -H -n`
|
||||
if [ $? -eq 0 ] ; then
|
||||
if [ "$MAX_FD" = "maximum" -o "$MAX_FD" = "max" ] ; then
|
||||
MAX_FD="$MAX_FD_LIMIT"
|
||||
fi
|
||||
ulimit -n $MAX_FD
|
||||
if [ $? -ne 0 ] ; then
|
||||
warn "Could not set maximum file descriptor limit: $MAX_FD"
|
||||
fi
|
||||
else
|
||||
warn "Could not query maximum file descriptor limit: $MAX_FD_LIMIT"
|
||||
fi
|
||||
fi
|
||||
|
||||
# For Darwin, add options to specify how the application appears in the dock
|
||||
if $darwin; then
|
||||
GRADLE_OPTS="$GRADLE_OPTS \"-Xdock:name=$APP_NAME\" \"-Xdock:icon=$APP_HOME/media/gradle.icns\""
|
||||
fi
|
||||
|
||||
# For Cygwin, switch paths to Windows format before running java
|
||||
if $cygwin ; then
|
||||
APP_HOME=`cygpath --path --mixed "$APP_HOME"`
|
||||
CLASSPATH=`cygpath --path --mixed "$CLASSPATH"`
|
||||
JAVACMD=`cygpath --unix "$JAVACMD"`
|
||||
|
||||
# We build the pattern for arguments to be converted via cygpath
|
||||
ROOTDIRSRAW=`find -L / -maxdepth 1 -mindepth 1 -type d 2>/dev/null`
|
||||
SEP=""
|
||||
for dir in $ROOTDIRSRAW ; do
|
||||
ROOTDIRS="$ROOTDIRS$SEP$dir"
|
||||
SEP="|"
|
||||
done
|
||||
OURCYGPATTERN="(^($ROOTDIRS))"
|
||||
# Add a user-defined pattern to the cygpath arguments
|
||||
if [ "$GRADLE_CYGPATTERN" != "" ] ; then
|
||||
OURCYGPATTERN="$OURCYGPATTERN|($GRADLE_CYGPATTERN)"
|
||||
fi
|
||||
# Now convert the arguments - kludge to limit ourselves to /bin/sh
|
||||
i=0
|
||||
for arg in "$@" ; do
|
||||
CHECK=`echo "$arg"|egrep -c "$OURCYGPATTERN" -`
|
||||
CHECK2=`echo "$arg"|egrep -c "^-"` ### Determine if an option
|
||||
|
||||
if [ $CHECK -ne 0 ] && [ $CHECK2 -eq 0 ] ; then ### Added a condition
|
||||
eval `echo args$i`=`cygpath --path --ignore --mixed "$arg"`
|
||||
else
|
||||
eval `echo args$i`="\"$arg\""
|
||||
fi
|
||||
i=$((i+1))
|
||||
done
|
||||
case $i in
|
||||
(0) set -- ;;
|
||||
(1) set -- "$args0" ;;
|
||||
(2) set -- "$args0" "$args1" ;;
|
||||
(3) set -- "$args0" "$args1" "$args2" ;;
|
||||
(4) set -- "$args0" "$args1" "$args2" "$args3" ;;
|
||||
(5) set -- "$args0" "$args1" "$args2" "$args3" "$args4" ;;
|
||||
(6) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" ;;
|
||||
(7) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" ;;
|
||||
(8) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" "$args7" ;;
|
||||
(9) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" "$args7" "$args8" ;;
|
||||
esac
|
||||
fi
|
||||
|
||||
# Escape application args
|
||||
save () {
|
||||
for i do printf %s\\n "$i" | sed "s/'/'\\\\''/g;1s/^/'/;\$s/\$/' \\\\/" ; done
|
||||
echo " "
|
||||
}
|
||||
APP_ARGS=$(save "$@")
|
||||
|
||||
# Collect all arguments for the java command, following the shell quoting and substitution rules
|
||||
eval set -- $DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS "\"-Dorg.gradle.appname=$APP_BASE_NAME\"" -classpath "\"$CLASSPATH\"" org.gradle.wrapper.GradleWrapperMain "$APP_ARGS"
|
||||
|
||||
# by default we should be in the correct project dir, but when run from Finder on Mac, the cwd is wrong
|
||||
if [ "$(uname)" = "Darwin" ] && [ "$HOME" = "$PWD" ]; then
|
||||
cd "$(dirname "$0")"
|
||||
fi
|
||||
|
||||
exec "$JAVACMD" "$@"
|
||||
84
gradlew.bat
vendored
Normal file
84
gradlew.bat
vendored
Normal file
@@ -0,0 +1,84 @@
|
||||
@if "%DEBUG%" == "" @echo off
|
||||
@rem ##########################################################################
|
||||
@rem
|
||||
@rem Gradle startup script for Windows
|
||||
@rem
|
||||
@rem ##########################################################################
|
||||
|
||||
@rem Set local scope for the variables with windows NT shell
|
||||
if "%OS%"=="Windows_NT" setlocal
|
||||
|
||||
set DIRNAME=%~dp0
|
||||
if "%DIRNAME%" == "" set DIRNAME=.
|
||||
set APP_BASE_NAME=%~n0
|
||||
set APP_HOME=%DIRNAME%
|
||||
|
||||
@rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
|
||||
set DEFAULT_JVM_OPTS="-Xmx64m"
|
||||
|
||||
@rem Find java.exe
|
||||
if defined JAVA_HOME goto findJavaFromJavaHome
|
||||
|
||||
set JAVA_EXE=java.exe
|
||||
%JAVA_EXE% -version >NUL 2>&1
|
||||
if "%ERRORLEVEL%" == "0" goto init
|
||||
|
||||
echo.
|
||||
echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH.
|
||||
echo.
|
||||
echo Please set the JAVA_HOME variable in your environment to match the
|
||||
echo location of your Java installation.
|
||||
|
||||
goto fail
|
||||
|
||||
:findJavaFromJavaHome
|
||||
set JAVA_HOME=%JAVA_HOME:"=%
|
||||
set JAVA_EXE=%JAVA_HOME%/bin/java.exe
|
||||
|
||||
if exist "%JAVA_EXE%" goto init
|
||||
|
||||
echo.
|
||||
echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME%
|
||||
echo.
|
||||
echo Please set the JAVA_HOME variable in your environment to match the
|
||||
echo location of your Java installation.
|
||||
|
||||
goto fail
|
||||
|
||||
:init
|
||||
@rem Get command-line arguments, handling Windows variants
|
||||
|
||||
if not "%OS%" == "Windows_NT" goto win9xME_args
|
||||
|
||||
:win9xME_args
|
||||
@rem Slurp the command line arguments.
|
||||
set CMD_LINE_ARGS=
|
||||
set _SKIP=2
|
||||
|
||||
:win9xME_args_slurp
|
||||
if "x%~1" == "x" goto execute
|
||||
|
||||
set CMD_LINE_ARGS=%*
|
||||
|
||||
:execute
|
||||
@rem Setup the command line
|
||||
|
||||
set CLASSPATH=%APP_HOME%\gradle\wrapper\gradle-wrapper.jar
|
||||
|
||||
@rem Execute Gradle
|
||||
"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" org.gradle.wrapper.GradleWrapperMain %CMD_LINE_ARGS%
|
||||
|
||||
:end
|
||||
@rem End local scope for the variables with windows NT shell
|
||||
if "%ERRORLEVEL%"=="0" goto mainEnd
|
||||
|
||||
:fail
|
||||
rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of
|
||||
rem the _cmd.exe /c_ return code!
|
||||
if not "" == "%GRADLE_EXIT_CONSOLE%" exit 1
|
||||
exit /b 1
|
||||
|
||||
:mainEnd
|
||||
if "%OS%"=="Windows_NT" endlocal
|
||||
|
||||
:omega
|
||||
@@ -0,0 +1,13 @@
|
||||
package io.reflectoring.buckpal;
|
||||
|
||||
import org.springframework.boot.SpringApplication;
|
||||
import org.springframework.boot.autoconfigure.SpringBootApplication;
|
||||
|
||||
@SpringBootApplication
|
||||
public class BuckPalApplication {
|
||||
|
||||
public static void main(String[] args) {
|
||||
SpringApplication.run(BuckPalApplication.class, args);
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
package io.reflectoring.buckpal;
|
||||
|
||||
import io.reflectoring.buckpal.account.application.service.MoneyTransferProperties;
|
||||
import io.reflectoring.buckpal.account.domain.Money;
|
||||
import org.springframework.boot.context.properties.EnableConfigurationProperties;
|
||||
import org.springframework.context.annotation.Bean;
|
||||
import org.springframework.context.annotation.Configuration;
|
||||
|
||||
@Configuration
|
||||
@EnableConfigurationProperties(BuckPalConfigurationProperties.class)
|
||||
public class BuckPalConfiguration {
|
||||
|
||||
/**
|
||||
* Adds a use-case-specific {@link MoneyTransferProperties} object to the application context. The properties
|
||||
* are read from the Spring-Boot-specific {@link BuckPalConfigurationProperties} object.
|
||||
*/
|
||||
@Bean
|
||||
public MoneyTransferProperties moneyTransferProperties(BuckPalConfigurationProperties buckPalConfigurationProperties){
|
||||
return new MoneyTransferProperties(Money.of(buckPalConfigurationProperties.getTransferThreshold()));
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
package io.reflectoring.buckpal;
|
||||
|
||||
import lombok.Data;
|
||||
import org.springframework.boot.context.properties.ConfigurationProperties;
|
||||
|
||||
@Data
|
||||
@ConfigurationProperties(prefix = "buckpal")
|
||||
public class BuckPalConfigurationProperties {
|
||||
|
||||
private long transferThreshold = Long.MAX_VALUE;
|
||||
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
package io.reflectoring.buckpal.account.adapter.in.web;
|
||||
|
||||
import io.reflectoring.buckpal.account.application.port.in.SendMoneyUseCase;
|
||||
import io.reflectoring.buckpal.account.application.port.in.SendMoneyCommand;
|
||||
import io.reflectoring.buckpal.common.WebAdapter;
|
||||
import io.reflectoring.buckpal.account.domain.Account.AccountId;
|
||||
import io.reflectoring.buckpal.account.domain.Money;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import org.springframework.web.bind.annotation.PathVariable;
|
||||
import org.springframework.web.bind.annotation.PostMapping;
|
||||
import org.springframework.web.bind.annotation.RestController;
|
||||
|
||||
@WebAdapter
|
||||
@RestController
|
||||
@RequiredArgsConstructor
|
||||
class SendMoneyController {
|
||||
|
||||
private final SendMoneyUseCase sendMoneyUseCase;
|
||||
|
||||
@PostMapping(path = "/accounts/send/{sourceAccountId}/{targetAccountId}/{amount}")
|
||||
void sendMoney(
|
||||
@PathVariable("sourceAccountId") Long sourceAccountId,
|
||||
@PathVariable("targetAccountId") Long targetAccountId,
|
||||
@PathVariable("amount") Long amount) {
|
||||
|
||||
SendMoneyCommand command = new SendMoneyCommand(
|
||||
new AccountId(sourceAccountId),
|
||||
new AccountId(targetAccountId),
|
||||
Money.of(amount));
|
||||
|
||||
sendMoneyUseCase.sendMoney(command);
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
package io.reflectoring.buckpal.account.adapter.out.persistence;
|
||||
|
||||
import javax.persistence.Entity;
|
||||
import javax.persistence.GeneratedValue;
|
||||
import javax.persistence.Id;
|
||||
import javax.persistence.Table;
|
||||
|
||||
import lombok.AllArgsConstructor;
|
||||
import lombok.Data;
|
||||
import lombok.NoArgsConstructor;
|
||||
|
||||
@Entity
|
||||
@Table(name = "account")
|
||||
@Data
|
||||
@AllArgsConstructor
|
||||
@NoArgsConstructor
|
||||
class AccountJpaEntity {
|
||||
|
||||
@Id
|
||||
@GeneratedValue
|
||||
private Long id;
|
||||
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
package io.reflectoring.buckpal.account.adapter.out.persistence;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
|
||||
import io.reflectoring.buckpal.account.domain.Account;
|
||||
import io.reflectoring.buckpal.account.domain.Account.AccountId;
|
||||
import io.reflectoring.buckpal.account.domain.Activity;
|
||||
import io.reflectoring.buckpal.account.domain.Activity.ActivityId;
|
||||
import io.reflectoring.buckpal.account.domain.ActivityWindow;
|
||||
import io.reflectoring.buckpal.account.domain.Money;
|
||||
import org.springframework.stereotype.Component;
|
||||
|
||||
@Component
|
||||
class AccountMapper {
|
||||
|
||||
Account mapToDomainEntity(
|
||||
AccountJpaEntity account,
|
||||
List<ActivityJpaEntity> activities,
|
||||
Long withdrawalBalance,
|
||||
Long depositBalance) {
|
||||
|
||||
Money baselineBalance = Money.subtract(
|
||||
Money.of(depositBalance),
|
||||
Money.of(withdrawalBalance));
|
||||
|
||||
return Account.withId(
|
||||
new AccountId(account.getId()),
|
||||
baselineBalance,
|
||||
mapToActivityWindow(activities));
|
||||
|
||||
}
|
||||
|
||||
ActivityWindow mapToActivityWindow(List<ActivityJpaEntity> activities) {
|
||||
List<Activity> mappedActivities = new ArrayList<>();
|
||||
|
||||
for (ActivityJpaEntity activity : activities) {
|
||||
mappedActivities.add(new Activity(
|
||||
new ActivityId(activity.getId()),
|
||||
new AccountId(activity.getOwnerAccountId()),
|
||||
new AccountId(activity.getSourceAccountId()),
|
||||
new AccountId(activity.getTargetAccountId()),
|
||||
activity.getTimestamp(),
|
||||
Money.of(activity.getAmount())));
|
||||
}
|
||||
|
||||
return new ActivityWindow(mappedActivities);
|
||||
}
|
||||
|
||||
ActivityJpaEntity mapToJpaEntity(Activity activity) {
|
||||
return new ActivityJpaEntity(
|
||||
activity.getId() == null ? null : activity.getId().getValue(),
|
||||
activity.getTimestamp(),
|
||||
activity.getOwnerAccountId().getValue(),
|
||||
activity.getSourceAccountId().getValue(),
|
||||
activity.getTargetAccountId().getValue(),
|
||||
activity.getMoney().getAmount().longValue());
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,72 @@
|
||||
package io.reflectoring.buckpal.account.adapter.out.persistence;
|
||||
|
||||
import javax.persistence.EntityNotFoundException;
|
||||
|
||||
import java.time.LocalDateTime;
|
||||
import java.util.List;
|
||||
|
||||
import io.reflectoring.buckpal.account.application.port.out.LoadAccountPort;
|
||||
import io.reflectoring.buckpal.account.application.port.out.UpdateAccountStatePort;
|
||||
import io.reflectoring.buckpal.account.domain.Account;
|
||||
import io.reflectoring.buckpal.account.domain.Account.AccountId;
|
||||
import io.reflectoring.buckpal.account.domain.Activity;
|
||||
import io.reflectoring.buckpal.common.PersistenceAdapter;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
|
||||
@RequiredArgsConstructor
|
||||
@PersistenceAdapter
|
||||
class AccountPersistenceAdapter implements
|
||||
LoadAccountPort,
|
||||
UpdateAccountStatePort {
|
||||
|
||||
private final SpringDataAccountRepository accountRepository;
|
||||
private final ActivityRepository activityRepository;
|
||||
private final AccountMapper accountMapper;
|
||||
|
||||
@Override
|
||||
public Account loadAccount(
|
||||
AccountId accountId,
|
||||
LocalDateTime baselineDate) {
|
||||
|
||||
AccountJpaEntity account =
|
||||
accountRepository.findById(accountId.getValue())
|
||||
.orElseThrow(EntityNotFoundException::new);
|
||||
|
||||
List<ActivityJpaEntity> activities =
|
||||
activityRepository.findByOwnerSince(
|
||||
accountId.getValue(),
|
||||
baselineDate);
|
||||
|
||||
Long withdrawalBalance = orZero(activityRepository
|
||||
.getWithdrawalBalanceUntil(
|
||||
accountId.getValue(),
|
||||
baselineDate));
|
||||
|
||||
Long depositBalance = orZero(activityRepository
|
||||
.getDepositBalanceUntil(
|
||||
accountId.getValue(),
|
||||
baselineDate));
|
||||
|
||||
return accountMapper.mapToDomainEntity(
|
||||
account,
|
||||
activities,
|
||||
withdrawalBalance,
|
||||
depositBalance);
|
||||
|
||||
}
|
||||
|
||||
private Long orZero(Long value){
|
||||
return value == null ? 0L : value;
|
||||
}
|
||||
|
||||
|
||||
@Override
|
||||
public void updateActivities(Account account) {
|
||||
for (Activity activity : account.getActivityWindow().getActivities()) {
|
||||
if (activity.getId() == null) {
|
||||
activityRepository.save(accountMapper.mapToJpaEntity(activity));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
package io.reflectoring.buckpal.account.adapter.out.persistence;
|
||||
|
||||
import javax.persistence.Column;
|
||||
import javax.persistence.Entity;
|
||||
import javax.persistence.GeneratedValue;
|
||||
import javax.persistence.Id;
|
||||
import javax.persistence.Table;
|
||||
|
||||
import java.time.LocalDateTime;
|
||||
|
||||
import lombok.AllArgsConstructor;
|
||||
import lombok.Data;
|
||||
import lombok.NoArgsConstructor;
|
||||
|
||||
@Entity
|
||||
@Table(name = "activity")
|
||||
@Data
|
||||
@AllArgsConstructor
|
||||
@NoArgsConstructor
|
||||
class ActivityJpaEntity {
|
||||
|
||||
@Id
|
||||
@GeneratedValue
|
||||
private Long id;
|
||||
|
||||
@Column
|
||||
private LocalDateTime timestamp;
|
||||
|
||||
@Column
|
||||
private Long ownerAccountId;
|
||||
|
||||
@Column
|
||||
private Long sourceAccountId;
|
||||
|
||||
@Column
|
||||
private Long targetAccountId;
|
||||
|
||||
@Column
|
||||
private Long amount;
|
||||
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
package io.reflectoring.buckpal.account.adapter.out.persistence;
|
||||
|
||||
import java.time.LocalDateTime;
|
||||
import java.util.List;
|
||||
|
||||
import org.springframework.data.jpa.repository.JpaRepository;
|
||||
import org.springframework.data.jpa.repository.Query;
|
||||
import org.springframework.data.repository.query.Param;
|
||||
|
||||
interface ActivityRepository extends JpaRepository<ActivityJpaEntity, Long> {
|
||||
|
||||
@Query("select a from ActivityJpaEntity a " +
|
||||
"where a.ownerAccountId = :ownerAccountId " +
|
||||
"and a.timestamp >= :since")
|
||||
List<ActivityJpaEntity> findByOwnerSince(
|
||||
@Param("ownerAccountId") Long ownerAccountId,
|
||||
@Param("since") LocalDateTime since);
|
||||
|
||||
@Query("select sum(a.amount) from ActivityJpaEntity a " +
|
||||
"where a.targetAccountId = :accountId " +
|
||||
"and a.ownerAccountId = :accountId " +
|
||||
"and a.timestamp < :until")
|
||||
Long getDepositBalanceUntil(
|
||||
@Param("accountId") Long accountId,
|
||||
@Param("until") LocalDateTime until);
|
||||
|
||||
@Query("select sum(a.amount) from ActivityJpaEntity a " +
|
||||
"where a.sourceAccountId = :accountId " +
|
||||
"and a.ownerAccountId = :accountId " +
|
||||
"and a.timestamp < :until")
|
||||
Long getWithdrawalBalanceUntil(
|
||||
@Param("accountId") Long accountId,
|
||||
@Param("until") LocalDateTime until);
|
||||
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
package io.reflectoring.buckpal.account.adapter.out.persistence;
|
||||
|
||||
import org.springframework.data.jpa.repository.JpaRepository;
|
||||
|
||||
interface SpringDataAccountRepository extends JpaRepository<AccountJpaEntity, Long> {
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
package io.reflectoring.buckpal.account.application.port.in;
|
||||
|
||||
import io.reflectoring.buckpal.account.domain.Account.AccountId;
|
||||
import io.reflectoring.buckpal.account.domain.Money;
|
||||
|
||||
public interface GetAccountBalanceQuery {
|
||||
|
||||
Money getAccountBalance(AccountId accountId);
|
||||
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
package io.reflectoring.buckpal.account.application.port.in;
|
||||
|
||||
import io.reflectoring.buckpal.account.domain.Account.AccountId;
|
||||
import io.reflectoring.buckpal.account.domain.Money;
|
||||
import io.reflectoring.buckpal.common.SelfValidating;
|
||||
import lombok.EqualsAndHashCode;
|
||||
import lombok.Value;
|
||||
|
||||
import javax.validation.constraints.NotNull;
|
||||
|
||||
@Value
|
||||
@EqualsAndHashCode(callSuper = false)
|
||||
public
|
||||
class SendMoneyCommand extends SelfValidating<SendMoneyCommand> {
|
||||
|
||||
@NotNull
|
||||
private final AccountId sourceAccountId;
|
||||
|
||||
@NotNull
|
||||
private final AccountId targetAccountId;
|
||||
|
||||
@NotNull
|
||||
private final Money money;
|
||||
|
||||
public SendMoneyCommand(
|
||||
AccountId sourceAccountId,
|
||||
AccountId targetAccountId,
|
||||
Money money) {
|
||||
this.sourceAccountId = sourceAccountId;
|
||||
this.targetAccountId = targetAccountId;
|
||||
this.money = money;
|
||||
this.validateSelf();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
package io.reflectoring.buckpal.account.application.port.in;
|
||||
|
||||
import io.reflectoring.buckpal.account.domain.Account.AccountId;
|
||||
import io.reflectoring.buckpal.account.domain.Money;
|
||||
import io.reflectoring.buckpal.common.SelfValidating;
|
||||
|
||||
public interface SendMoneyUseCase {
|
||||
|
||||
boolean sendMoney(SendMoneyCommand command);
|
||||
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
package io.reflectoring.buckpal.account.application.port.out;
|
||||
|
||||
import io.reflectoring.buckpal.account.domain.Account;
|
||||
|
||||
public interface AccountLock {
|
||||
|
||||
void lockAccount(Account.AccountId accountId);
|
||||
|
||||
void releaseAccount(Account.AccountId accountId);
|
||||
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
package io.reflectoring.buckpal.account.application.port.out;
|
||||
|
||||
import java.time.LocalDateTime;
|
||||
|
||||
import io.reflectoring.buckpal.account.domain.Account;
|
||||
import io.reflectoring.buckpal.account.domain.Account.AccountId;
|
||||
|
||||
public interface LoadAccountPort {
|
||||
|
||||
Account loadAccount(AccountId accountId, LocalDateTime baselineDate);
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
package io.reflectoring.buckpal.account.application.port.out;
|
||||
|
||||
import io.reflectoring.buckpal.account.domain.Account;
|
||||
|
||||
public interface UpdateAccountStatePort {
|
||||
|
||||
void updateActivities(Account account);
|
||||
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
package io.reflectoring.buckpal.account.application.service;
|
||||
|
||||
import java.time.LocalDateTime;
|
||||
|
||||
import io.reflectoring.buckpal.account.application.port.in.GetAccountBalanceQuery;
|
||||
import io.reflectoring.buckpal.account.application.port.out.LoadAccountPort;
|
||||
import io.reflectoring.buckpal.account.domain.Account.AccountId;
|
||||
import io.reflectoring.buckpal.account.domain.Money;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
|
||||
@RequiredArgsConstructor
|
||||
class GetAccountBalanceService implements GetAccountBalanceQuery {
|
||||
|
||||
private final LoadAccountPort loadAccountPort;
|
||||
|
||||
@Override
|
||||
public Money getAccountBalance(AccountId accountId) {
|
||||
return loadAccountPort.loadAccount(accountId, LocalDateTime.now())
|
||||
.calculateBalance();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
package io.reflectoring.buckpal.account.application.service;
|
||||
|
||||
import io.reflectoring.buckpal.account.domain.Money;
|
||||
import lombok.AllArgsConstructor;
|
||||
import lombok.Data;
|
||||
import lombok.NoArgsConstructor;
|
||||
|
||||
/**
|
||||
* Configuration properties for money transfer use cases.
|
||||
*/
|
||||
@Data
|
||||
@AllArgsConstructor
|
||||
@NoArgsConstructor
|
||||
public class MoneyTransferProperties {
|
||||
|
||||
private Money maximumTransferThreshold = Money.of(1_000_000L);
|
||||
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
package io.reflectoring.buckpal.account.application.service;
|
||||
|
||||
import io.reflectoring.buckpal.account.application.port.out.AccountLock;
|
||||
import io.reflectoring.buckpal.account.domain.Account.AccountId;
|
||||
import org.springframework.stereotype.Component;
|
||||
|
||||
@Component
|
||||
class NoOpAccountLock implements AccountLock {
|
||||
|
||||
@Override
|
||||
public void lockAccount(AccountId accountId) {
|
||||
// do nothing
|
||||
}
|
||||
|
||||
@Override
|
||||
public void releaseAccount(AccountId accountId) {
|
||||
// do nothing
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,77 @@
|
||||
package io.reflectoring.buckpal.account.application.service;
|
||||
|
||||
import io.reflectoring.buckpal.account.application.port.in.SendMoneyCommand;
|
||||
import io.reflectoring.buckpal.account.application.port.in.SendMoneyUseCase;
|
||||
import io.reflectoring.buckpal.account.application.port.out.AccountLock;
|
||||
import io.reflectoring.buckpal.account.application.port.out.LoadAccountPort;
|
||||
import io.reflectoring.buckpal.account.application.port.out.UpdateAccountStatePort;
|
||||
import io.reflectoring.buckpal.common.UseCase;
|
||||
import io.reflectoring.buckpal.account.domain.Account;
|
||||
import io.reflectoring.buckpal.account.domain.Account.AccountId;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
|
||||
import javax.transaction.Transactional;
|
||||
import java.time.LocalDateTime;
|
||||
|
||||
@RequiredArgsConstructor
|
||||
@UseCase
|
||||
@Transactional
|
||||
public class SendMoneyService implements SendMoneyUseCase {
|
||||
|
||||
private final LoadAccountPort loadAccountPort;
|
||||
private final AccountLock accountLock;
|
||||
private final UpdateAccountStatePort updateAccountStatePort;
|
||||
private final MoneyTransferProperties moneyTransferProperties;
|
||||
|
||||
@Override
|
||||
public boolean sendMoney(SendMoneyCommand command) {
|
||||
|
||||
checkThreshold(command);
|
||||
|
||||
LocalDateTime baselineDate = LocalDateTime.now().minusDays(10);
|
||||
|
||||
Account sourceAccount = loadAccountPort.loadAccount(
|
||||
command.getSourceAccountId(),
|
||||
baselineDate);
|
||||
|
||||
Account targetAccount = loadAccountPort.loadAccount(
|
||||
command.getTargetAccountId(),
|
||||
baselineDate);
|
||||
|
||||
AccountId sourceAccountId = sourceAccount.getId()
|
||||
.orElseThrow(() -> new IllegalStateException("expected source account ID not to be empty"));
|
||||
AccountId targetAccountId = targetAccount.getId()
|
||||
.orElseThrow(() -> new IllegalStateException("expected target account ID not to be empty"));
|
||||
|
||||
accountLock.lockAccount(sourceAccountId);
|
||||
if (!sourceAccount.withdraw(command.getMoney(), targetAccountId)) {
|
||||
accountLock.releaseAccount(sourceAccountId);
|
||||
return false;
|
||||
}
|
||||
|
||||
accountLock.lockAccount(targetAccountId);
|
||||
if (!targetAccount.deposit(command.getMoney(), sourceAccountId)) {
|
||||
accountLock.releaseAccount(sourceAccountId);
|
||||
accountLock.releaseAccount(targetAccountId);
|
||||
return false;
|
||||
}
|
||||
|
||||
updateAccountStatePort.updateActivities(sourceAccount);
|
||||
updateAccountStatePort.updateActivities(targetAccount);
|
||||
|
||||
accountLock.releaseAccount(sourceAccountId);
|
||||
accountLock.releaseAccount(targetAccountId);
|
||||
return true;
|
||||
}
|
||||
|
||||
private void checkThreshold(SendMoneyCommand command) {
|
||||
if(command.getMoney().isGreaterThan(moneyTransferProperties.getMaximumTransferThreshold())){
|
||||
throw new ThresholdExceededException(moneyTransferProperties.getMaximumTransferThreshold(), command.getMoney());
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -0,0 +1,11 @@
|
||||
package io.reflectoring.buckpal.account.application.service;
|
||||
|
||||
import io.reflectoring.buckpal.account.domain.Money;
|
||||
|
||||
public class ThresholdExceededException extends RuntimeException {
|
||||
|
||||
public ThresholdExceededException(Money threshold, Money actual) {
|
||||
super(String.format("Maximum threshold for transferring money exceeded: tried to transfer %s but threshold is %s!", actual, threshold));
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,118 @@
|
||||
package io.reflectoring.buckpal.account.domain;
|
||||
|
||||
import java.time.LocalDateTime;
|
||||
import java.util.Optional;
|
||||
|
||||
import lombok.AccessLevel;
|
||||
import lombok.AllArgsConstructor;
|
||||
import lombok.Getter;
|
||||
import lombok.Value;
|
||||
|
||||
/**
|
||||
* An account that holds a certain amount of money. An {@link Account} object only
|
||||
* contains a window of the latest account activities. The total balance of the account is
|
||||
* the sum of a baseline balance that was valid before the first activity in the
|
||||
* window and the sum of the activity values.
|
||||
*/
|
||||
@AllArgsConstructor(access = AccessLevel.PRIVATE)
|
||||
public class Account {
|
||||
|
||||
/**
|
||||
* The unique ID of the account.
|
||||
*/
|
||||
@Getter private final AccountId id;
|
||||
|
||||
/**
|
||||
* The baseline balance of the account. This was the balance of the account before the first
|
||||
* activity in the activityWindow.
|
||||
*/
|
||||
@Getter private final Money baselineBalance;
|
||||
|
||||
/**
|
||||
* The window of latest activities on this account.
|
||||
*/
|
||||
@Getter private final ActivityWindow activityWindow;
|
||||
|
||||
/**
|
||||
* Creates an {@link Account} entity without an ID. Use to create a new entity that is not yet
|
||||
* persisted.
|
||||
*/
|
||||
public static Account withoutId(
|
||||
Money baselineBalance,
|
||||
ActivityWindow activityWindow) {
|
||||
return new Account(null, baselineBalance, activityWindow);
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates an {@link Account} entity with an ID. Use to reconstitute a persisted entity.
|
||||
*/
|
||||
public static Account withId(
|
||||
AccountId accountId,
|
||||
Money baselineBalance,
|
||||
ActivityWindow activityWindow) {
|
||||
return new Account(accountId, baselineBalance, activityWindow);
|
||||
}
|
||||
|
||||
public Optional<AccountId> getId(){
|
||||
return Optional.ofNullable(this.id);
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculates the total balance of the account by adding the activity values to the baseline balance.
|
||||
*/
|
||||
public Money calculateBalance() {
|
||||
return Money.add(
|
||||
this.baselineBalance,
|
||||
this.activityWindow.calculateBalance(this.id));
|
||||
}
|
||||
|
||||
/**
|
||||
* Tries to withdraw a certain amount of money from this account.
|
||||
* If successful, creates a new activity with a negative value.
|
||||
* @return true if the withdrawal was successful, false if not.
|
||||
*/
|
||||
public boolean withdraw(Money money, AccountId targetAccountId) {
|
||||
|
||||
if (!mayWithdraw(money)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
Activity withdrawal = new Activity(
|
||||
this.id,
|
||||
this.id,
|
||||
targetAccountId,
|
||||
LocalDateTime.now(),
|
||||
money);
|
||||
this.activityWindow.addActivity(withdrawal);
|
||||
return true;
|
||||
}
|
||||
|
||||
private boolean mayWithdraw(Money money) {
|
||||
return Money.add(
|
||||
this.calculateBalance(),
|
||||
money.negate())
|
||||
.isPositiveOrZero();
|
||||
}
|
||||
|
||||
/**
|
||||
* Tries to deposit a certain amount of money to this account.
|
||||
* If sucessful, creates a new activity with a positive value.
|
||||
* @return true if the deposit was successful, false if not.
|
||||
*/
|
||||
public boolean deposit(Money money, AccountId sourceAccountId) {
|
||||
Activity deposit = new Activity(
|
||||
this.id,
|
||||
sourceAccountId,
|
||||
this.id,
|
||||
LocalDateTime.now(),
|
||||
money);
|
||||
this.activityWindow.addActivity(deposit);
|
||||
return true;
|
||||
}
|
||||
|
||||
@Value
|
||||
public static class AccountId {
|
||||
private Long value;
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,74 @@
|
||||
package io.reflectoring.buckpal.account.domain;
|
||||
|
||||
import java.time.LocalDateTime;
|
||||
|
||||
import lombok.Getter;
|
||||
import lombok.NonNull;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.Value;
|
||||
|
||||
/**
|
||||
* A money transfer activity between {@link Account}s.
|
||||
*/
|
||||
@Value
|
||||
@RequiredArgsConstructor
|
||||
public class Activity {
|
||||
|
||||
@Getter
|
||||
private ActivityId id;
|
||||
|
||||
/**
|
||||
* The account that owns this activity.
|
||||
*/
|
||||
@Getter
|
||||
@NonNull
|
||||
private final Account.AccountId ownerAccountId;
|
||||
|
||||
/**
|
||||
* The debited account.
|
||||
*/
|
||||
@Getter
|
||||
@NonNull
|
||||
private final Account.AccountId sourceAccountId;
|
||||
|
||||
/**
|
||||
* The credited account.
|
||||
*/
|
||||
@Getter
|
||||
@NonNull
|
||||
private final Account.AccountId targetAccountId;
|
||||
|
||||
/**
|
||||
* The timestamp of the activity.
|
||||
*/
|
||||
@Getter
|
||||
@NonNull
|
||||
private final LocalDateTime timestamp;
|
||||
|
||||
/**
|
||||
* The money that was transferred between the accounts.
|
||||
*/
|
||||
@Getter
|
||||
@NonNull
|
||||
private final Money money;
|
||||
|
||||
public Activity(
|
||||
@NonNull Account.AccountId ownerAccountId,
|
||||
@NonNull Account.AccountId sourceAccountId,
|
||||
@NonNull Account.AccountId targetAccountId,
|
||||
@NonNull LocalDateTime timestamp,
|
||||
@NonNull Money money) {
|
||||
this.id = null;
|
||||
this.ownerAccountId = ownerAccountId;
|
||||
this.sourceAccountId = sourceAccountId;
|
||||
this.targetAccountId = targetAccountId;
|
||||
this.timestamp = timestamp;
|
||||
this.money = money;
|
||||
}
|
||||
|
||||
@Value
|
||||
public static class ActivityId {
|
||||
private final Long value;
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,76 @@
|
||||
package io.reflectoring.buckpal.account.domain;
|
||||
|
||||
import java.time.LocalDateTime;
|
||||
import java.util.ArrayList;
|
||||
import java.util.Arrays;
|
||||
import java.util.Collections;
|
||||
import java.util.Comparator;
|
||||
import java.util.List;
|
||||
|
||||
import io.reflectoring.buckpal.account.domain.Account.AccountId;
|
||||
import lombok.NonNull;
|
||||
|
||||
/**
|
||||
* A window of account activities.
|
||||
*/
|
||||
public class ActivityWindow {
|
||||
|
||||
/**
|
||||
* The list of account activities within this window.
|
||||
*/
|
||||
private List<Activity> activities;
|
||||
|
||||
/**
|
||||
* The timestamp of the first activity within this window.
|
||||
*/
|
||||
public LocalDateTime getStartTimestamp() {
|
||||
return activities.stream()
|
||||
.min(Comparator.comparing(Activity::getTimestamp))
|
||||
.orElseThrow(IllegalStateException::new)
|
||||
.getTimestamp();
|
||||
}
|
||||
|
||||
/**
|
||||
* The timestamp of the last activity within this window.
|
||||
* @return
|
||||
*/
|
||||
public LocalDateTime getEndTimestamp() {
|
||||
return activities.stream()
|
||||
.max(Comparator.comparing(Activity::getTimestamp))
|
||||
.orElseThrow(IllegalStateException::new)
|
||||
.getTimestamp();
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculates the balance by summing up the values of all activities within this window.
|
||||
*/
|
||||
public Money calculateBalance(AccountId accountId) {
|
||||
Money depositBalance = activities.stream()
|
||||
.filter(a -> a.getTargetAccountId().equals(accountId))
|
||||
.map(Activity::getMoney)
|
||||
.reduce(Money.ZERO, Money::add);
|
||||
|
||||
Money withdrawalBalance = activities.stream()
|
||||
.filter(a -> a.getSourceAccountId().equals(accountId))
|
||||
.map(Activity::getMoney)
|
||||
.reduce(Money.ZERO, Money::add);
|
||||
|
||||
return Money.add(depositBalance, withdrawalBalance.negate());
|
||||
}
|
||||
|
||||
public ActivityWindow(@NonNull List<Activity> activities) {
|
||||
this.activities = activities;
|
||||
}
|
||||
|
||||
public ActivityWindow(@NonNull Activity... activities) {
|
||||
this.activities = new ArrayList<>(Arrays.asList(activities));
|
||||
}
|
||||
|
||||
public List<Activity> getActivities() {
|
||||
return Collections.unmodifiableList(this.activities);
|
||||
}
|
||||
|
||||
public void addActivity(Activity activity) {
|
||||
this.activities.add(activity);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
package io.reflectoring.buckpal.account.domain;
|
||||
|
||||
import java.math.BigInteger;
|
||||
|
||||
import lombok.NonNull;
|
||||
import lombok.Value;
|
||||
|
||||
@Value
|
||||
public class Money {
|
||||
|
||||
public static Money ZERO = Money.of(0L);
|
||||
|
||||
@NonNull
|
||||
private final BigInteger amount;
|
||||
|
||||
public boolean isPositiveOrZero(){
|
||||
return this.amount.compareTo(BigInteger.ZERO) >= 0;
|
||||
}
|
||||
|
||||
public boolean isNegative(){
|
||||
return this.amount.compareTo(BigInteger.ZERO) < 0;
|
||||
}
|
||||
|
||||
public boolean isPositive(){
|
||||
return this.amount.compareTo(BigInteger.ZERO) > 0;
|
||||
}
|
||||
|
||||
public boolean isGreaterThanOrEqualTo(Money money){
|
||||
return this.amount.compareTo(money.amount) >= 0;
|
||||
}
|
||||
|
||||
public boolean isGreaterThan(Money money){
|
||||
return this.amount.compareTo(money.amount) >= 1;
|
||||
}
|
||||
|
||||
public static Money of(long value) {
|
||||
return new Money(BigInteger.valueOf(value));
|
||||
}
|
||||
|
||||
public static Money add(Money a, Money b) {
|
||||
return new Money(a.amount.add(b.amount));
|
||||
}
|
||||
|
||||
public Money minus(Money money){
|
||||
return new Money(this.amount.subtract(money.amount));
|
||||
}
|
||||
|
||||
public Money plus(Money money){
|
||||
return new Money(this.amount.add(money.amount));
|
||||
}
|
||||
|
||||
public static Money subtract(Money a, Money b) {
|
||||
return new Money(a.amount.subtract(b.amount));
|
||||
}
|
||||
|
||||
public Money negate(){
|
||||
return new Money(this.amount.negate());
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
package io.reflectoring.buckpal.common;
|
||||
|
||||
import java.lang.annotation.Documented;
|
||||
import java.lang.annotation.ElementType;
|
||||
import java.lang.annotation.Retention;
|
||||
import java.lang.annotation.RetentionPolicy;
|
||||
import java.lang.annotation.Target;
|
||||
|
||||
import org.springframework.core.annotation.AliasFor;
|
||||
import org.springframework.stereotype.Component;
|
||||
|
||||
@Target({ElementType.TYPE})
|
||||
@Retention(RetentionPolicy.RUNTIME)
|
||||
@Documented
|
||||
@Component
|
||||
public @interface PersistenceAdapter {
|
||||
|
||||
/**
|
||||
* The value may indicate a suggestion for a logical component name,
|
||||
* to be turned into a Spring bean in case of an autodetected component.
|
||||
* @return the suggested component name, if any (or empty String otherwise)
|
||||
*/
|
||||
@AliasFor(annotation = Component.class)
|
||||
String value() default "";
|
||||
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
package io.reflectoring.buckpal.common;
|
||||
|
||||
import javax.validation.ConstraintViolation;
|
||||
import javax.validation.ConstraintViolationException;
|
||||
import javax.validation.Validation;
|
||||
import javax.validation.Validator;
|
||||
import javax.validation.ValidatorFactory;
|
||||
import java.util.Set;
|
||||
|
||||
public abstract class SelfValidating<T> {
|
||||
|
||||
private Validator validator;
|
||||
|
||||
public SelfValidating() {
|
||||
ValidatorFactory factory = Validation.buildDefaultValidatorFactory();
|
||||
validator = factory.getValidator();
|
||||
}
|
||||
|
||||
/**
|
||||
* Evaluates all Bean Validations on the attributes of this
|
||||
* instance.
|
||||
*/
|
||||
protected void validateSelf() {
|
||||
Set<ConstraintViolation<T>> violations = validator.validate((T) this);
|
||||
if (!violations.isEmpty()) {
|
||||
throw new ConstraintViolationException(violations);
|
||||
}
|
||||
}
|
||||
}
|
||||
26
src/main/java/io/reflectoring/buckpal/common/UseCase.java
Normal file
26
src/main/java/io/reflectoring/buckpal/common/UseCase.java
Normal file
@@ -0,0 +1,26 @@
|
||||
package io.reflectoring.buckpal.common;
|
||||
|
||||
import java.lang.annotation.Documented;
|
||||
import java.lang.annotation.ElementType;
|
||||
import java.lang.annotation.Retention;
|
||||
import java.lang.annotation.RetentionPolicy;
|
||||
import java.lang.annotation.Target;
|
||||
|
||||
import org.springframework.core.annotation.AliasFor;
|
||||
import org.springframework.stereotype.Component;
|
||||
|
||||
@Target({ElementType.TYPE})
|
||||
@Retention(RetentionPolicy.RUNTIME)
|
||||
@Documented
|
||||
@Component
|
||||
public @interface UseCase {
|
||||
|
||||
/**
|
||||
* The value may indicate a suggestion for a logical component name,
|
||||
* to be turned into a Spring bean in case of an autodetected component.
|
||||
* @return the suggested component name, if any (or empty String otherwise)
|
||||
*/
|
||||
@AliasFor(annotation = Component.class)
|
||||
String value() default "";
|
||||
|
||||
}
|
||||
26
src/main/java/io/reflectoring/buckpal/common/WebAdapter.java
Normal file
26
src/main/java/io/reflectoring/buckpal/common/WebAdapter.java
Normal file
@@ -0,0 +1,26 @@
|
||||
package io.reflectoring.buckpal.common;
|
||||
|
||||
import org.springframework.core.annotation.AliasFor;
|
||||
import org.springframework.stereotype.Component;
|
||||
|
||||
import java.lang.annotation.Documented;
|
||||
import java.lang.annotation.ElementType;
|
||||
import java.lang.annotation.Retention;
|
||||
import java.lang.annotation.RetentionPolicy;
|
||||
import java.lang.annotation.Target;
|
||||
|
||||
@Target({ElementType.TYPE})
|
||||
@Retention(RetentionPolicy.RUNTIME)
|
||||
@Documented
|
||||
@Component
|
||||
public @interface WebAdapter {
|
||||
|
||||
/**
|
||||
* The value may indicate a suggestion for a logical component name,
|
||||
* to be turned into a Spring bean in case of an autodetected component.
|
||||
* @return the suggested component name, if any (or empty String otherwise)
|
||||
*/
|
||||
@AliasFor(annotation = Component.class)
|
||||
String value() default "";
|
||||
|
||||
}
|
||||
2
src/main/resources/application.yml
Normal file
2
src/main/resources/application.yml
Normal file
@@ -0,0 +1,2 @@
|
||||
buckpal:
|
||||
transferThreshold: 10000
|
||||
@@ -0,0 +1,16 @@
|
||||
package io.reflectoring.buckpal;
|
||||
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.junit.jupiter.api.extension.ExtendWith;
|
||||
import org.springframework.boot.test.context.SpringBootTest;
|
||||
import org.springframework.test.context.junit.jupiter.SpringExtension;
|
||||
|
||||
@ExtendWith(SpringExtension.class)
|
||||
@SpringBootTest
|
||||
class BuckPalApplicationTests {
|
||||
|
||||
@Test
|
||||
void contextLoads() {
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
package io.reflectoring.buckpal;
|
||||
|
||||
import com.tngtech.archunit.core.importer.ClassFileImporter;
|
||||
import io.reflectoring.buckpal.archunit.HexagonalArchitecture;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import static com.tngtech.archunit.lang.syntax.ArchRuleDefinition.*;
|
||||
|
||||
class DependencyRuleTests {
|
||||
|
||||
@Test
|
||||
void validateRegistrationContextArchitecture() {
|
||||
HexagonalArchitecture.boundedContext("io.reflectoring.buckpal.account")
|
||||
|
||||
.withDomainLayer("domain")
|
||||
|
||||
.withAdaptersLayer("adapter")
|
||||
.incoming("in.web")
|
||||
.outgoing("out.persistence")
|
||||
.and()
|
||||
|
||||
.withApplicationLayer("application")
|
||||
.services("service")
|
||||
.incomingPorts("port.in")
|
||||
.outgoingPorts("port.out")
|
||||
.and()
|
||||
|
||||
.withConfiguration("configuration")
|
||||
.check(new ClassFileImporter()
|
||||
.importPackages("io.reflectoring.buckpal.."));
|
||||
}
|
||||
|
||||
@Test
|
||||
void testPackageDependencies() {
|
||||
noClasses()
|
||||
.that()
|
||||
.resideInAPackage("io.reflectoring.reviewapp.domain..")
|
||||
.should()
|
||||
.dependOnClassesThat()
|
||||
.resideInAnyPackage("io.reflectoring.reviewapp.application..")
|
||||
.check(new ClassFileImporter()
|
||||
.importPackages("io.reflectoring.reviewapp.."));
|
||||
}
|
||||
|
||||
}
|
||||
104
src/test/java/io/reflectoring/buckpal/SendMoneySystemTest.java
Normal file
104
src/test/java/io/reflectoring/buckpal/SendMoneySystemTest.java
Normal file
@@ -0,0 +1,104 @@
|
||||
package io.reflectoring.buckpal;
|
||||
|
||||
import java.time.LocalDateTime;
|
||||
|
||||
import io.reflectoring.buckpal.account.application.port.out.LoadAccountPort;
|
||||
import io.reflectoring.buckpal.account.domain.Account;
|
||||
import io.reflectoring.buckpal.account.domain.Account.AccountId;
|
||||
import io.reflectoring.buckpal.account.domain.Money;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
import org.springframework.boot.test.context.SpringBootTest;
|
||||
import org.springframework.boot.test.context.SpringBootTest.WebEnvironment;
|
||||
import org.springframework.boot.test.web.client.TestRestTemplate;
|
||||
import org.springframework.http.HttpEntity;
|
||||
import org.springframework.http.HttpHeaders;
|
||||
import org.springframework.http.HttpMethod;
|
||||
import org.springframework.http.HttpStatus;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
import org.springframework.test.context.jdbc.Sql;
|
||||
import static org.assertj.core.api.BDDAssertions.*;
|
||||
|
||||
@SpringBootTest(webEnvironment = WebEnvironment.RANDOM_PORT)
|
||||
class SendMoneySystemTest {
|
||||
|
||||
@Autowired
|
||||
private TestRestTemplate restTemplate;
|
||||
|
||||
@Autowired
|
||||
private LoadAccountPort loadAccountPort;
|
||||
|
||||
@Test
|
||||
@Sql("SendMoneySystemTest.sql")
|
||||
void sendMoney() {
|
||||
|
||||
Money initialSourceBalance = sourceAccount().calculateBalance();
|
||||
Money initialTargetBalance = targetAccount().calculateBalance();
|
||||
|
||||
ResponseEntity response = whenSendMoney(
|
||||
sourceAccountId(),
|
||||
targetAccountId(),
|
||||
transferredAmount());
|
||||
|
||||
then(response.getStatusCode())
|
||||
.isEqualTo(HttpStatus.OK);
|
||||
|
||||
then(sourceAccount().calculateBalance())
|
||||
.isEqualTo(initialSourceBalance.minus(transferredAmount()));
|
||||
|
||||
then(targetAccount().calculateBalance())
|
||||
.isEqualTo(initialTargetBalance.plus(transferredAmount()));
|
||||
|
||||
}
|
||||
|
||||
private Account sourceAccount() {
|
||||
return loadAccount(sourceAccountId());
|
||||
}
|
||||
|
||||
private Account targetAccount() {
|
||||
return loadAccount(targetAccountId());
|
||||
}
|
||||
|
||||
private Account loadAccount(AccountId accountId) {
|
||||
return loadAccountPort.loadAccount(
|
||||
accountId,
|
||||
LocalDateTime.now());
|
||||
}
|
||||
|
||||
|
||||
private ResponseEntity whenSendMoney(
|
||||
AccountId sourceAccountId,
|
||||
AccountId targetAccountId,
|
||||
Money amount) {
|
||||
HttpHeaders headers = new HttpHeaders();
|
||||
headers.add("Content-Type", "application/json");
|
||||
HttpEntity<Void> request = new HttpEntity<>(null, headers);
|
||||
|
||||
return restTemplate.exchange(
|
||||
"/accounts/send/{sourceAccountId}/{targetAccountId}/{amount}",
|
||||
HttpMethod.POST,
|
||||
request,
|
||||
Object.class,
|
||||
sourceAccountId.getValue(),
|
||||
targetAccountId.getValue(),
|
||||
amount.getAmount());
|
||||
}
|
||||
|
||||
private Money transferredAmount() {
|
||||
return Money.of(500L);
|
||||
}
|
||||
|
||||
private Money balanceOf(AccountId accountId) {
|
||||
Account account = loadAccountPort.loadAccount(accountId, LocalDateTime.now());
|
||||
return account.calculateBalance();
|
||||
}
|
||||
|
||||
private AccountId sourceAccountId() {
|
||||
return new AccountId(1L);
|
||||
}
|
||||
|
||||
private AccountId targetAccountId() {
|
||||
return new AccountId(2L);
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
package io.reflectoring.buckpal.account.adapter.in.web;
|
||||
|
||||
import io.reflectoring.buckpal.account.application.port.in.SendMoneyUseCase;
|
||||
import io.reflectoring.buckpal.account.application.port.in.SendMoneyCommand;
|
||||
import io.reflectoring.buckpal.account.domain.Account.AccountId;
|
||||
import io.reflectoring.buckpal.account.domain.Money;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
import org.springframework.boot.test.autoconfigure.web.servlet.WebMvcTest;
|
||||
import org.springframework.boot.test.mock.mockito.MockBean;
|
||||
import org.springframework.test.web.servlet.MockMvc;
|
||||
import static org.mockito.BDDMockito.*;
|
||||
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.*;
|
||||
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.*;
|
||||
|
||||
@WebMvcTest(controllers = SendMoneyController.class)
|
||||
class SendMoneyControllerTest {
|
||||
|
||||
@Autowired
|
||||
private MockMvc mockMvc;
|
||||
|
||||
@MockBean
|
||||
private SendMoneyUseCase sendMoneyUseCase;
|
||||
|
||||
@Test
|
||||
void testSendMoney() throws Exception {
|
||||
|
||||
mockMvc.perform(post("/accounts/send/{sourceAccountId}/{targetAccountId}/{amount}",
|
||||
41L, 42L, 500)
|
||||
.header("Content-Type", "application/json"))
|
||||
.andExpect(status().isOk());
|
||||
|
||||
then(sendMoneyUseCase).should()
|
||||
.sendMoney(eq(new SendMoneyCommand(
|
||||
new AccountId(41L),
|
||||
new AccountId(42L),
|
||||
Money.of(500L))));
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
package io.reflectoring.buckpal.account.adapter.out.persistence;
|
||||
|
||||
import java.time.LocalDateTime;
|
||||
|
||||
import io.reflectoring.buckpal.account.domain.Account;
|
||||
import io.reflectoring.buckpal.account.domain.Account.AccountId;
|
||||
import io.reflectoring.buckpal.account.domain.ActivityWindow;
|
||||
import io.reflectoring.buckpal.account.domain.Money;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
import org.springframework.boot.test.autoconfigure.orm.jpa.DataJpaTest;
|
||||
import org.springframework.context.annotation.Import;
|
||||
import org.springframework.test.context.jdbc.Sql;
|
||||
import static io.reflectoring.buckpal.common.AccountTestData.*;
|
||||
import static io.reflectoring.buckpal.common.ActivityTestData.*;
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
|
||||
@DataJpaTest
|
||||
@Import({AccountPersistenceAdapter.class, AccountMapper.class})
|
||||
class AccountPersistenceAdapterTest {
|
||||
|
||||
@Autowired
|
||||
private AccountPersistenceAdapter adapterUnderTest;
|
||||
|
||||
@Autowired
|
||||
private ActivityRepository activityRepository;
|
||||
|
||||
@Test
|
||||
@Sql("AccountPersistenceAdapterTest.sql")
|
||||
void loadsAccount() {
|
||||
Account account = adapterUnderTest.loadAccount(new AccountId(1L), LocalDateTime.of(2018, 8, 10, 0, 0));
|
||||
|
||||
assertThat(account.getActivityWindow().getActivities()).hasSize(2);
|
||||
assertThat(account.calculateBalance()).isEqualTo(Money.of(500));
|
||||
}
|
||||
|
||||
@Test
|
||||
void updatesActivities() {
|
||||
Account account = defaultAccount()
|
||||
.withBaselineBalance(Money.of(555L))
|
||||
.withActivityWindow(new ActivityWindow(
|
||||
defaultActivity()
|
||||
.withId(null)
|
||||
.withMoney(Money.of(1L)).build()))
|
||||
.build();
|
||||
|
||||
adapterUnderTest.updateActivities(account);
|
||||
|
||||
assertThat(activityRepository.count()).isEqualTo(1);
|
||||
|
||||
ActivityJpaEntity savedActivity = activityRepository.findAll().get(0);
|
||||
assertThat(savedActivity.getAmount()).isEqualTo(1L);
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,97 @@
|
||||
package io.reflectoring.buckpal.account.domain;
|
||||
|
||||
import io.reflectoring.buckpal.account.domain.Account.AccountId;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import static io.reflectoring.buckpal.common.AccountTestData.*;
|
||||
import static io.reflectoring.buckpal.common.ActivityTestData.*;
|
||||
import static org.assertj.core.api.Assertions.*;
|
||||
|
||||
class AccountTest {
|
||||
|
||||
@Test
|
||||
void calculatesBalance() {
|
||||
AccountId accountId = new AccountId(1L);
|
||||
Account account = defaultAccount()
|
||||
.withAccountId(accountId)
|
||||
.withBaselineBalance(Money.of(555L))
|
||||
.withActivityWindow(new ActivityWindow(
|
||||
defaultActivity()
|
||||
.withTargetAccount(accountId)
|
||||
.withMoney(Money.of(999L)).build(),
|
||||
defaultActivity()
|
||||
.withTargetAccount(accountId)
|
||||
.withMoney(Money.of(1L)).build()))
|
||||
.build();
|
||||
|
||||
Money balance = account.calculateBalance();
|
||||
|
||||
assertThat(balance).isEqualTo(Money.of(1555L));
|
||||
}
|
||||
|
||||
@Test
|
||||
void withdrawalSucceeds() {
|
||||
AccountId accountId = new AccountId(1L);
|
||||
Account account = defaultAccount()
|
||||
.withAccountId(accountId)
|
||||
.withBaselineBalance(Money.of(555L))
|
||||
.withActivityWindow(new ActivityWindow(
|
||||
defaultActivity()
|
||||
.withTargetAccount(accountId)
|
||||
.withMoney(Money.of(999L)).build(),
|
||||
defaultActivity()
|
||||
.withTargetAccount(accountId)
|
||||
.withMoney(Money.of(1L)).build()))
|
||||
.build();
|
||||
|
||||
boolean success = account.withdraw(Money.of(555L), new AccountId(99L));
|
||||
|
||||
assertThat(success).isTrue();
|
||||
assertThat(account.getActivityWindow().getActivities()).hasSize(3);
|
||||
assertThat(account.calculateBalance()).isEqualTo(Money.of(1000L));
|
||||
}
|
||||
|
||||
@Test
|
||||
void withdrawalFailure() {
|
||||
AccountId accountId = new AccountId(1L);
|
||||
Account account = defaultAccount()
|
||||
.withAccountId(accountId)
|
||||
.withBaselineBalance(Money.of(555L))
|
||||
.withActivityWindow(new ActivityWindow(
|
||||
defaultActivity()
|
||||
.withTargetAccount(accountId)
|
||||
.withMoney(Money.of(999L)).build(),
|
||||
defaultActivity()
|
||||
.withTargetAccount(accountId)
|
||||
.withMoney(Money.of(1L)).build()))
|
||||
.build();
|
||||
|
||||
boolean success = account.withdraw(Money.of(1556L), new AccountId(99L));
|
||||
|
||||
assertThat(success).isFalse();
|
||||
assertThat(account.getActivityWindow().getActivities()).hasSize(2);
|
||||
assertThat(account.calculateBalance()).isEqualTo(Money.of(1555L));
|
||||
}
|
||||
|
||||
@Test
|
||||
void depositSuccess() {
|
||||
AccountId accountId = new AccountId(1L);
|
||||
Account account = defaultAccount()
|
||||
.withAccountId(accountId)
|
||||
.withBaselineBalance(Money.of(555L))
|
||||
.withActivityWindow(new ActivityWindow(
|
||||
defaultActivity()
|
||||
.withTargetAccount(accountId)
|
||||
.withMoney(Money.of(999L)).build(),
|
||||
defaultActivity()
|
||||
.withTargetAccount(accountId)
|
||||
.withMoney(Money.of(1L)).build()))
|
||||
.build();
|
||||
|
||||
boolean success = account.deposit(Money.of(445L), new AccountId(99L));
|
||||
|
||||
assertThat(success).isTrue();
|
||||
assertThat(account.getActivityWindow().getActivities()).hasSize(3);
|
||||
assertThat(account.calculateBalance()).isEqualTo(Money.of(2000L));
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,68 @@
|
||||
package io.reflectoring.buckpal.account.domain;
|
||||
|
||||
import java.time.LocalDateTime;
|
||||
|
||||
import io.reflectoring.buckpal.account.domain.Account.AccountId;
|
||||
import org.assertj.core.api.Assertions;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import static io.reflectoring.buckpal.common.ActivityTestData.*;
|
||||
|
||||
class ActivityWindowTest {
|
||||
|
||||
@Test
|
||||
void calculatesStartTimestamp() {
|
||||
ActivityWindow window = new ActivityWindow(
|
||||
defaultActivity().withTimestamp(startDate()).build(),
|
||||
defaultActivity().withTimestamp(inBetweenDate()).build(),
|
||||
defaultActivity().withTimestamp(endDate()).build());
|
||||
|
||||
Assertions.assertThat(window.getStartTimestamp()).isEqualTo(startDate());
|
||||
}
|
||||
|
||||
@Test
|
||||
void calculatesEndTimestamp() {
|
||||
ActivityWindow window = new ActivityWindow(
|
||||
defaultActivity().withTimestamp(startDate()).build(),
|
||||
defaultActivity().withTimestamp(inBetweenDate()).build(),
|
||||
defaultActivity().withTimestamp(endDate()).build());
|
||||
|
||||
Assertions.assertThat(window.getEndTimestamp()).isEqualTo(endDate());
|
||||
}
|
||||
|
||||
@Test
|
||||
void calculatesBalance() {
|
||||
|
||||
AccountId account1 = new AccountId(1L);
|
||||
AccountId account2 = new AccountId(2L);
|
||||
|
||||
ActivityWindow window = new ActivityWindow(
|
||||
defaultActivity()
|
||||
.withSourceAccount(account1)
|
||||
.withTargetAccount(account2)
|
||||
.withMoney(Money.of(999)).build(),
|
||||
defaultActivity()
|
||||
.withSourceAccount(account1)
|
||||
.withTargetAccount(account2)
|
||||
.withMoney(Money.of(1)).build(),
|
||||
defaultActivity()
|
||||
.withSourceAccount(account2)
|
||||
.withTargetAccount(account1)
|
||||
.withMoney(Money.of(500)).build());
|
||||
|
||||
Assertions.assertThat(window.calculateBalance(account1)).isEqualTo(Money.of(-500));
|
||||
Assertions.assertThat(window.calculateBalance(account2)).isEqualTo(Money.of(500));
|
||||
}
|
||||
|
||||
private LocalDateTime startDate() {
|
||||
return LocalDateTime.of(2019, 8, 3, 0, 0);
|
||||
}
|
||||
|
||||
private LocalDateTime inBetweenDate() {
|
||||
return LocalDateTime.of(2019, 8, 4, 0, 0);
|
||||
}
|
||||
|
||||
private LocalDateTime endDate() {
|
||||
return LocalDateTime.of(2019, 8, 5, 0, 0);
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,148 @@
|
||||
package io.reflectoring.buckpal.account.application.service;
|
||||
|
||||
import io.reflectoring.buckpal.account.application.port.in.SendMoneyCommand;
|
||||
import io.reflectoring.buckpal.account.application.port.out.AccountLock;
|
||||
import io.reflectoring.buckpal.account.application.port.out.LoadAccountPort;
|
||||
import io.reflectoring.buckpal.account.application.port.out.UpdateAccountStatePort;
|
||||
import io.reflectoring.buckpal.account.domain.Account;
|
||||
import io.reflectoring.buckpal.account.domain.Account.AccountId;
|
||||
import io.reflectoring.buckpal.account.domain.Money;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.mockito.ArgumentCaptor;
|
||||
import org.mockito.Mockito;
|
||||
|
||||
import java.time.LocalDateTime;
|
||||
import java.util.List;
|
||||
import java.util.Optional;
|
||||
import java.util.stream.Collectors;
|
||||
|
||||
import static org.assertj.core.api.Assertions.*;
|
||||
import static org.mockito.BDDMockito.*;
|
||||
|
||||
class SendMoneyServiceTest {
|
||||
|
||||
private final LoadAccountPort loadAccountPort =
|
||||
Mockito.mock(LoadAccountPort.class);
|
||||
|
||||
private final AccountLock accountLock =
|
||||
Mockito.mock(AccountLock.class);
|
||||
|
||||
private final UpdateAccountStatePort updateAccountStatePort =
|
||||
Mockito.mock(UpdateAccountStatePort.class);
|
||||
|
||||
private final SendMoneyService sendMoneyService =
|
||||
new SendMoneyService(loadAccountPort, accountLock, updateAccountStatePort, moneyTransferProperties());
|
||||
|
||||
@Test
|
||||
void givenWithdrawalFails_thenOnlySourceAccountIsLockedAndReleased() {
|
||||
|
||||
AccountId sourceAccountId = new AccountId(41L);
|
||||
Account sourceAccount = givenAnAccountWithId(sourceAccountId);
|
||||
|
||||
AccountId targetAccountId = new AccountId(42L);
|
||||
Account targetAccount = givenAnAccountWithId(targetAccountId);
|
||||
|
||||
givenWithdrawalWillFail(sourceAccount);
|
||||
givenDepositWillSucceed(targetAccount);
|
||||
|
||||
SendMoneyCommand command = new SendMoneyCommand(
|
||||
sourceAccountId,
|
||||
targetAccountId,
|
||||
Money.of(300L));
|
||||
|
||||
boolean success = sendMoneyService.sendMoney(command);
|
||||
|
||||
assertThat(success).isFalse();
|
||||
|
||||
then(accountLock).should().lockAccount(eq(sourceAccountId));
|
||||
then(accountLock).should().releaseAccount(eq(sourceAccountId));
|
||||
then(accountLock).should(times(0)).lockAccount(eq(targetAccountId));
|
||||
}
|
||||
|
||||
@Test
|
||||
void transactionSucceeds() {
|
||||
|
||||
Account sourceAccount = givenSourceAccount();
|
||||
Account targetAccount = givenTargetAccount();
|
||||
|
||||
givenWithdrawalWillSucceed(sourceAccount);
|
||||
givenDepositWillSucceed(targetAccount);
|
||||
|
||||
Money money = Money.of(500L);
|
||||
|
||||
SendMoneyCommand command = new SendMoneyCommand(
|
||||
sourceAccount.getId().get(),
|
||||
targetAccount.getId().get(),
|
||||
money);
|
||||
|
||||
boolean success = sendMoneyService.sendMoney(command);
|
||||
|
||||
assertThat(success).isTrue();
|
||||
|
||||
AccountId sourceAccountId = sourceAccount.getId().get();
|
||||
AccountId targetAccountId = targetAccount.getId().get();
|
||||
|
||||
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().deposit(eq(money), eq(sourceAccountId));
|
||||
then(accountLock).should().releaseAccount(eq(targetAccountId));
|
||||
|
||||
thenAccountsHaveBeenUpdated(sourceAccountId, targetAccountId);
|
||||
}
|
||||
|
||||
private void thenAccountsHaveBeenUpdated(AccountId... accountIds){
|
||||
ArgumentCaptor<Account> accountCaptor = ArgumentCaptor.forClass(Account.class);
|
||||
then(updateAccountStatePort).should(times(accountIds.length))
|
||||
.updateActivities(accountCaptor.capture());
|
||||
|
||||
List<AccountId> updatedAccountIds = accountCaptor.getAllValues()
|
||||
.stream()
|
||||
.map(Account::getId)
|
||||
.map(Optional::get)
|
||||
.collect(Collectors.toList());
|
||||
|
||||
for(AccountId accountId : accountIds){
|
||||
assertThat(updatedAccountIds).contains(accountId);
|
||||
}
|
||||
}
|
||||
|
||||
private void givenDepositWillSucceed(Account account) {
|
||||
given(account.deposit(any(Money.class), any(AccountId.class)))
|
||||
.willReturn(true);
|
||||
}
|
||||
|
||||
private void givenWithdrawalWillFail(Account account) {
|
||||
given(account.withdraw(any(Money.class), any(AccountId.class)))
|
||||
.willReturn(false);
|
||||
}
|
||||
|
||||
private void givenWithdrawalWillSucceed(Account account) {
|
||||
given(account.withdraw(any(Money.class), any(AccountId.class)))
|
||||
.willReturn(true);
|
||||
}
|
||||
|
||||
private Account givenTargetAccount(){
|
||||
return givenAnAccountWithId(new AccountId(42L));
|
||||
}
|
||||
|
||||
private Account givenSourceAccount(){
|
||||
return givenAnAccountWithId(new AccountId(41L));
|
||||
}
|
||||
|
||||
private Account givenAnAccountWithId(AccountId id) {
|
||||
Account account = Mockito.mock(Account.class);
|
||||
given(account.getId())
|
||||
.willReturn(Optional.of(id));
|
||||
given(loadAccountPort.loadAccount(eq(account.getId().get()), any(LocalDateTime.class)))
|
||||
.willReturn(account);
|
||||
return account;
|
||||
}
|
||||
|
||||
private MoneyTransferProperties moneyTransferProperties(){
|
||||
return new MoneyTransferProperties(Money.of(Long.MAX_VALUE));
|
||||
}
|
||||
|
||||
}
|
||||
62
src/test/java/io/reflectoring/buckpal/archunit/Adapters.java
Normal file
62
src/test/java/io/reflectoring/buckpal/archunit/Adapters.java
Normal file
@@ -0,0 +1,62 @@
|
||||
package io.reflectoring.buckpal.archunit;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
|
||||
import com.tngtech.archunit.core.domain.JavaClasses;
|
||||
|
||||
public class Adapters extends ArchitectureElement {
|
||||
|
||||
private final HexagonalArchitecture parentContext;
|
||||
private List<String> incomingAdapterPackages = new ArrayList<>();
|
||||
private List<String> outgoingAdapterPackages = new ArrayList<>();
|
||||
|
||||
Adapters(HexagonalArchitecture parentContext, String basePackage) {
|
||||
super(basePackage);
|
||||
this.parentContext = parentContext;
|
||||
}
|
||||
|
||||
public Adapters outgoing(String packageName) {
|
||||
this.incomingAdapterPackages.add(fullQualifiedPackage(packageName));
|
||||
return this;
|
||||
}
|
||||
|
||||
public Adapters incoming(String packageName) {
|
||||
this.outgoingAdapterPackages.add(fullQualifiedPackage(packageName));
|
||||
return this;
|
||||
}
|
||||
|
||||
List<String> allAdapterPackages() {
|
||||
List<String> allAdapters = new ArrayList<>();
|
||||
allAdapters.addAll(incomingAdapterPackages);
|
||||
allAdapters.addAll(outgoingAdapterPackages);
|
||||
return allAdapters;
|
||||
}
|
||||
|
||||
public HexagonalArchitecture and() {
|
||||
return parentContext;
|
||||
}
|
||||
|
||||
String getBasePackage() {
|
||||
return basePackage;
|
||||
}
|
||||
|
||||
void dontDependOnEachOther(JavaClasses classes) {
|
||||
List<String> allAdapters = allAdapterPackages();
|
||||
for (String adapter1 : allAdapters) {
|
||||
for (String adapter2 : allAdapters) {
|
||||
if (!adapter1.equals(adapter2)) {
|
||||
denyDependency(adapter1, adapter2, classes);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
void doesNotDependOn(String packageName, JavaClasses classes) {
|
||||
denyDependency(this.basePackage, packageName, classes);
|
||||
}
|
||||
|
||||
void doesNotContainEmptyPackages() {
|
||||
denyEmptyPackages(allAdapterPackages());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
package io.reflectoring.buckpal.archunit;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
|
||||
import com.tngtech.archunit.core.domain.JavaClasses;
|
||||
|
||||
public class ApplicationLayer extends ArchitectureElement {
|
||||
|
||||
private final HexagonalArchitecture parentContext;
|
||||
private List<String> incomingPortsPackages = new ArrayList<>();
|
||||
private List<String> outgoingPortsPackages = new ArrayList<>();
|
||||
private List<String> servicePackages = new ArrayList<>();
|
||||
|
||||
public ApplicationLayer(String basePackage, HexagonalArchitecture parentContext) {
|
||||
super(basePackage);
|
||||
this.parentContext = parentContext;
|
||||
}
|
||||
|
||||
public ApplicationLayer incomingPorts(String packageName) {
|
||||
this.incomingPortsPackages.add(fullQualifiedPackage(packageName));
|
||||
return this;
|
||||
}
|
||||
|
||||
public ApplicationLayer outgoingPorts(String packageName) {
|
||||
this.outgoingPortsPackages.add(fullQualifiedPackage(packageName));
|
||||
return this;
|
||||
}
|
||||
|
||||
public ApplicationLayer services(String packageName) {
|
||||
this.servicePackages.add(fullQualifiedPackage(packageName));
|
||||
return this;
|
||||
}
|
||||
|
||||
public HexagonalArchitecture and() {
|
||||
return parentContext;
|
||||
}
|
||||
|
||||
public void doesNotDependOn(String packageName, JavaClasses classes) {
|
||||
denyDependency(this.basePackage, packageName, classes);
|
||||
}
|
||||
|
||||
public void incomingAndOutgoingPortsDoNotDependOnEachOther(JavaClasses classes) {
|
||||
denyAnyDependency(this.incomingPortsPackages, this.outgoingPortsPackages, classes);
|
||||
denyAnyDependency(this.outgoingPortsPackages, this.incomingPortsPackages, classes);
|
||||
}
|
||||
|
||||
private List<String> allPackages() {
|
||||
List<String> allPackages = new ArrayList<>();
|
||||
allPackages.addAll(incomingPortsPackages);
|
||||
allPackages.addAll(outgoingPortsPackages);
|
||||
allPackages.addAll(servicePackages);
|
||||
return allPackages;
|
||||
}
|
||||
|
||||
void doesNotContainEmptyPackages() {
|
||||
denyEmptyPackages(allPackages());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
package io.reflectoring.buckpal.archunit;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
import com.tngtech.archunit.core.domain.JavaClasses;
|
||||
import com.tngtech.archunit.core.importer.ClassFileImporter;
|
||||
import static com.tngtech.archunit.base.DescribedPredicate.*;
|
||||
import static com.tngtech.archunit.lang.conditions.ArchConditions.*;
|
||||
import static com.tngtech.archunit.lang.syntax.ArchRuleDefinition.*;
|
||||
|
||||
abstract class ArchitectureElement {
|
||||
|
||||
final String basePackage;
|
||||
|
||||
public ArchitectureElement(String basePackage) {
|
||||
this.basePackage = basePackage;
|
||||
}
|
||||
|
||||
String fullQualifiedPackage(String relativePackage) {
|
||||
return this.basePackage + "." + relativePackage;
|
||||
}
|
||||
|
||||
static void denyDependency(String fromPackageName, String toPackageName, JavaClasses classes) {
|
||||
noClasses()
|
||||
.that()
|
||||
.resideInAPackage("io.reflectoring.reviewapp.domain..")
|
||||
.should()
|
||||
.dependOnClassesThat()
|
||||
.resideInAnyPackage("io.reflectoring.reviewapp.application..")
|
||||
.check(classes);
|
||||
}
|
||||
|
||||
static void denyAnyDependency(
|
||||
List<String> fromPackages, List<String> toPackages, JavaClasses classes) {
|
||||
for (String fromPackage : fromPackages) {
|
||||
for (String toPackage : toPackages) {
|
||||
noClasses()
|
||||
.that()
|
||||
.resideInAPackage(matchAllClassesInPackage(fromPackage))
|
||||
.should()
|
||||
.dependOnClassesThat()
|
||||
.resideInAnyPackage(matchAllClassesInPackage(toPackage))
|
||||
.check(classes);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
static String matchAllClassesInPackage(String packageName) {
|
||||
return packageName + "..";
|
||||
}
|
||||
|
||||
void denyEmptyPackage(String packageName) {
|
||||
classes()
|
||||
.that()
|
||||
.resideInAPackage(matchAllClassesInPackage(packageName))
|
||||
.should(containNumberOfElements(greaterThanOrEqualTo(1)))
|
||||
.check(classesInPackage(packageName));
|
||||
}
|
||||
|
||||
private JavaClasses classesInPackage(String packageName) {
|
||||
return new ClassFileImporter().importPackages(packageName);
|
||||
}
|
||||
|
||||
void denyEmptyPackages(List<String> packages) {
|
||||
for (String packageName : packages) {
|
||||
denyEmptyPackage(packageName);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
package io.reflectoring.buckpal.archunit;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.Collections;
|
||||
import java.util.List;
|
||||
|
||||
import com.tngtech.archunit.core.domain.JavaClasses;
|
||||
|
||||
public class HexagonalArchitecture extends ArchitectureElement {
|
||||
|
||||
private Adapters adapters;
|
||||
private ApplicationLayer applicationLayer;
|
||||
private String configurationPackage;
|
||||
private List<String> domainPackages = new ArrayList<>();
|
||||
|
||||
public static HexagonalArchitecture boundedContext(String basePackage) {
|
||||
return new HexagonalArchitecture(basePackage);
|
||||
}
|
||||
|
||||
public HexagonalArchitecture(String basePackage) {
|
||||
super(basePackage);
|
||||
}
|
||||
|
||||
public Adapters withAdaptersLayer(String adaptersPackage) {
|
||||
this.adapters = new Adapters(this, fullQualifiedPackage(adaptersPackage));
|
||||
return this.adapters;
|
||||
}
|
||||
|
||||
public HexagonalArchitecture withDomainLayer(String domainPackage) {
|
||||
this.domainPackages.add(fullQualifiedPackage(domainPackage));
|
||||
return this;
|
||||
}
|
||||
|
||||
public ApplicationLayer withApplicationLayer(String applicationPackage) {
|
||||
this.applicationLayer = new ApplicationLayer(fullQualifiedPackage(applicationPackage), this);
|
||||
return this.applicationLayer;
|
||||
}
|
||||
|
||||
public HexagonalArchitecture withConfiguration(String packageName) {
|
||||
this.configurationPackage = fullQualifiedPackage(packageName);
|
||||
return this;
|
||||
}
|
||||
|
||||
private void domainDoesNotDependOnOtherPackages(JavaClasses classes) {
|
||||
denyAnyDependency(
|
||||
this.domainPackages, Collections.singletonList(adapters.basePackage), classes);
|
||||
denyAnyDependency(
|
||||
this.domainPackages, Collections.singletonList(applicationLayer.basePackage), classes);
|
||||
}
|
||||
|
||||
public void check(JavaClasses classes) {
|
||||
this.adapters.doesNotContainEmptyPackages();
|
||||
this.adapters.dontDependOnEachOther(classes);
|
||||
this.adapters.doesNotDependOn(this.configurationPackage, classes);
|
||||
this.applicationLayer.doesNotContainEmptyPackages();
|
||||
this.applicationLayer.doesNotDependOn(this.adapters.getBasePackage(), classes);
|
||||
this.applicationLayer.doesNotDependOn(this.configurationPackage, classes);
|
||||
this.applicationLayer.incomingAndOutgoingPortsDoNotDependOnEachOther(classes);
|
||||
this.domainDoesNotDependOnOtherPackages(classes);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
package io.reflectoring.buckpal.common;
|
||||
|
||||
import io.reflectoring.buckpal.account.domain.Account;
|
||||
import io.reflectoring.buckpal.account.domain.Account.AccountId;
|
||||
import io.reflectoring.buckpal.account.domain.ActivityWindow;
|
||||
import io.reflectoring.buckpal.account.domain.Money;
|
||||
|
||||
public class AccountTestData {
|
||||
|
||||
public static AccountBuilder defaultAccount() {
|
||||
return new AccountBuilder()
|
||||
.withAccountId(new AccountId(42L))
|
||||
.withBaselineBalance(Money.of(999L))
|
||||
.withActivityWindow(new ActivityWindow(
|
||||
ActivityTestData.defaultActivity().build(),
|
||||
ActivityTestData.defaultActivity().build()));
|
||||
}
|
||||
|
||||
|
||||
public static class AccountBuilder {
|
||||
|
||||
private AccountId accountId;
|
||||
private Money baselineBalance;
|
||||
private ActivityWindow activityWindow;
|
||||
|
||||
public AccountBuilder withAccountId(AccountId accountId) {
|
||||
this.accountId = accountId;
|
||||
return this;
|
||||
}
|
||||
|
||||
public AccountBuilder withBaselineBalance(Money baselineBalance) {
|
||||
this.baselineBalance = baselineBalance;
|
||||
return this;
|
||||
}
|
||||
|
||||
public AccountBuilder withActivityWindow(ActivityWindow activityWindow) {
|
||||
this.activityWindow = activityWindow;
|
||||
return this;
|
||||
}
|
||||
|
||||
public Account build() {
|
||||
return Account.withId(this.accountId, this.baselineBalance, this.activityWindow);
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
package io.reflectoring.buckpal.common;
|
||||
|
||||
import java.time.LocalDateTime;
|
||||
|
||||
import io.reflectoring.buckpal.account.domain.Account.AccountId;
|
||||
import io.reflectoring.buckpal.account.domain.Activity;
|
||||
import io.reflectoring.buckpal.account.domain.Activity.ActivityId;
|
||||
import io.reflectoring.buckpal.account.domain.Money;
|
||||
|
||||
public class ActivityTestData {
|
||||
|
||||
public static ActivityBuilder defaultActivity(){
|
||||
return new ActivityBuilder()
|
||||
.withOwnerAccount(new AccountId(42L))
|
||||
.withSourceAccount(new AccountId(42L))
|
||||
.withTargetAccount(new AccountId(41L))
|
||||
.withTimestamp(LocalDateTime.now())
|
||||
.withMoney(Money.of(999L));
|
||||
}
|
||||
|
||||
public static class ActivityBuilder {
|
||||
private ActivityId id;
|
||||
private AccountId ownerAccountId;
|
||||
private AccountId sourceAccountId;
|
||||
private AccountId targetAccountId;
|
||||
private LocalDateTime timestamp;
|
||||
private Money money;
|
||||
|
||||
public ActivityBuilder withId(ActivityId id) {
|
||||
this.id = id;
|
||||
return this;
|
||||
}
|
||||
|
||||
public ActivityBuilder withOwnerAccount(AccountId accountId) {
|
||||
this.ownerAccountId = accountId;
|
||||
return this;
|
||||
}
|
||||
|
||||
public ActivityBuilder withSourceAccount(AccountId accountId) {
|
||||
this.sourceAccountId = accountId;
|
||||
return this;
|
||||
}
|
||||
|
||||
public ActivityBuilder withTargetAccount(AccountId accountId) {
|
||||
this.targetAccountId = accountId;
|
||||
return this;
|
||||
}
|
||||
|
||||
public ActivityBuilder withTimestamp(LocalDateTime timestamp) {
|
||||
this.timestamp = timestamp;
|
||||
return this;
|
||||
}
|
||||
|
||||
public ActivityBuilder withMoney(Money money) {
|
||||
this.money = money;
|
||||
return this;
|
||||
}
|
||||
|
||||
public Activity build() {
|
||||
return new Activity(
|
||||
this.id,
|
||||
this.ownerAccountId,
|
||||
this.sourceAccountId,
|
||||
this.targetAccountId,
|
||||
this.timestamp,
|
||||
this.money);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
insert into account (id) values (1);
|
||||
insert into account (id) values (2);
|
||||
|
||||
insert into activity (id, timestamp, owner_account_id, source_account_id, target_account_id, amount)
|
||||
values (1001, '2018-08-08 08:00:00.0', 1, 1, 2, 500);
|
||||
|
||||
insert into activity (id, timestamp, owner_account_id, source_account_id, target_account_id, amount)
|
||||
values (1002, '2018-08-08 08:00:00.0', 2, 1, 2, 500);
|
||||
|
||||
insert into activity (id, timestamp, owner_account_id, source_account_id, target_account_id, amount)
|
||||
values (1003, '2018-08-09 10:00:00.0', 1, 2, 1, 1000);
|
||||
|
||||
insert into activity (id, timestamp, owner_account_id, source_account_id, target_account_id, amount)
|
||||
values (1004, '2018-08-09 10:00:00.0', 2, 2, 1, 1000);
|
||||
|
||||
insert into activity (id, timestamp, owner_account_id, source_account_id, target_account_id, amount)
|
||||
values (1005, '2019-08-09 09:00:00.0', 1, 1, 2, 1000);
|
||||
|
||||
insert into activity (id, timestamp, owner_account_id, source_account_id, target_account_id, amount)
|
||||
values (1006, '2019-08-09 09:00:00.0', 2, 1, 2, 1000);
|
||||
|
||||
insert into activity (id, timestamp, owner_account_id, source_account_id, target_account_id, amount)
|
||||
values (1007, '2019-08-09 10:00:00.0', 1, 2, 1, 1000);
|
||||
|
||||
insert into activity (id, timestamp, owner_account_id, source_account_id, target_account_id, amount)
|
||||
values (1008, '2019-08-09 10:00:00.0', 2, 2, 1, 1000);
|
||||
@@ -0,0 +1,26 @@
|
||||
insert into account (id) values (1);
|
||||
insert into account (id) values (2);
|
||||
|
||||
insert into activity (id, timestamp, owner_account_id, source_account_id, target_account_id, amount)
|
||||
values (1, '2018-08-08 08:00:00.0', 1, 1, 2, 500);
|
||||
|
||||
insert into activity (id, timestamp, owner_account_id, source_account_id, target_account_id, amount)
|
||||
values (2, '2018-08-08 08:00:00.0', 2, 1, 2, 500);
|
||||
|
||||
insert into activity (id, timestamp, owner_account_id, source_account_id, target_account_id, amount)
|
||||
values (3, '2018-08-09 10:00:00.0', 1, 2, 1, 1000);
|
||||
|
||||
insert into activity (id, timestamp, owner_account_id, source_account_id, target_account_id, amount)
|
||||
values (4, '2018-08-09 10:00:00.0', 2, 2, 1, 1000);
|
||||
|
||||
insert into activity (id, timestamp, owner_account_id, source_account_id, target_account_id, amount)
|
||||
values (5, '2019-08-09 09:00:00.0', 1, 1, 2, 1000);
|
||||
|
||||
insert into activity (id, timestamp, owner_account_id, source_account_id, target_account_id, amount)
|
||||
values (6, '2019-08-09 09:00:00.0', 2, 1, 2, 1000);
|
||||
|
||||
insert into activity (id, timestamp, owner_account_id, source_account_id, target_account_id, amount)
|
||||
values (7, '2019-08-09 10:00:00.0', 1, 2, 1, 1000);
|
||||
|
||||
insert into activity (id, timestamp, owner_account_id, source_account_id, target_account_id, amount)
|
||||
values (8, '2019-08-09 10:00:00.0', 2, 2, 1, 1000);
|
||||
Reference in New Issue
Block a user