Compare commits

...

21 Commits

Author SHA1 Message Date
9d2b3d49ea refactor: tosspay-mock 모듈 plain jar 생성 비활성화 2025-10-02 10:08:20 +09:00
aeeaaab735 feat: 테스트에서 사용하는 임시 sql 및 텍스트 파일 gitignore 추가 2025-10-01 12:48:08 +09:00
254bc980c1 feat: 두 서비스간의 tracing을 위해 RestClientBuilder에 observationRegistry 설정 추가 2025-10-01 12:43:16 +09:00
7898a93182 test: tosspay-mock 모듈 API 테스트 추가 2025-10-01 12:42:39 +09:00
8f42bd6054 feat: tosspay-mock 모듈에 테스트 및 micrometer 의존성 추가 2025-10-01 12:42:14 +09:00
3cdecaaab9 refactor: PaymentCancelRequest의 cancelAmount 필드 null 기본값 지정 2025-10-01 11:26:18 +09:00
dc17316856 feat: Base64 형식의 시크릿키를 검증하는 인터셉터 추가 2025-10-01 10:59:52 +09:00
6974418cef feat: 임의의 결제 정보를 반환하는 API 추가 2025-10-01 10:51:38 +09:00
05145ec2ba feat: 임의의 결제 정보를 반환하는 서비스 기능 추가 2025-10-01 10:51:22 +09:00
dab26c49a8 feat: 각 도메인에 DTO 변환 메서드 추가 2025-10-01 10:50:59 +09:00
cef306a918 feat: 결제 처리 요청/응답 DTO 정의 2025-10-01 10:50:41 +09:00
466b73e5b2 feat: 결제 취소시 필요한 할인금액, 결제금액을 불러오기 위한 테이블 및 Entity 정의 2025-10-01 10:32:02 +09:00
fdd55905c3 feat: 모든 결제 처리에 사용할 공통 Payment 도메인 추가 2025-10-01 10:31:11 +09:00
e25a4f6325 feat: Payment 구성에 필요한 계좌이체, 카드, 취소, 간편결제 도메인 추가 2025-10-01 10:30:59 +09:00
c921a9a89a feat: 테스트를 위해 임의의 결제 정보를 제공하기 위한 랜덤값 생성 유틸 클래스 추가 2025-10-01 10:30:19 +09:00
6eb132b644 feat: 결제 서버에서 사용할 공통 상수 정의 2025-10-01 10:29:39 +09:00
6087358f9d feat: Tosspay 전용 예외코드 추상화 및 확정 / 취소에서의 예외 정의 2025-10-01 10:27:47 +09:00
1f0dccc194 feat: Tosspay 전용 예외코드 추상화 및 확정 / 취소에서의 예외 정의 2025-10-01 10:27:31 +09:00
f1ce2d501c feat: Tosspay API 문서에 있는 예외 타입을 enum 형태로 변환 2025-10-01 10:24:31 +09:00
b55ef5e308 feat: tosspay-mock 모듈 프로젝트 초기 설정 2025-10-01 10:23:30 +09:00
8f0c563d6b refactor: 하위 모듈 gradle 설정 수정(implementation -> api) 2025-10-01 10:18:25 +09:00
47 changed files with 2001 additions and 27 deletions

6
.gitignore vendored
View File

@ -36,4 +36,8 @@ out/
### VS Code ###
.vscode/
logs
.kotlin
.kotlin
### sql
data/*.sql
data/*.txt

View File

@ -1,10 +1,9 @@
dependencies {
api("net.ttddyy.observation:datasource-micrometer-spring-boot:1.1.2")
implementation("com.fasterxml.jackson.module:jackson-module-kotlin:2.20.0")
implementation("net.ttddyy.observation:datasource-micrometer-spring-boot:1.1.2")
implementation(project(":common:utils"))
testImplementation("io.mockk:mockk:1.14.4")
implementation(project(":common:utils"))
}
tasks.named<Jar>("jar") {

View File

@ -7,17 +7,17 @@ plugins {
}
dependencies {
implementation("org.springframework.boot:spring-boot-starter-data-jpa")
implementation("com.github.f4b6a3:tsid-creator:5.2.6")
api("org.springframework.boot:spring-boot-starter-data-jpa")
api("com.github.f4b6a3:tsid-creator:5.2.6")
implementation(project(":common:utils"))
implementation(project(":common:types"))
testRuntimeOnly("com.h2database:h2")
testImplementation("org.springframework.boot:spring-boot-starter-test")
testImplementation("io.kotest:kotest-runner-junit5:5.9.1")
testImplementation("io.kotest.extensions:kotest-extensions-spring:1.3.0")
testImplementation("io.mockk:mockk:1.14.4")
implementation(project(":common:utils"))
implementation(project(":common:types"))
}
tasks.named<BootJar>("bootJar") {

View File

@ -7,16 +7,16 @@ plugins {
}
dependencies {
implementation("org.springframework.boot:spring-boot-starter-web")
implementation("org.springframework.boot:spring-boot-starter-aop")
implementation("com.fasterxml.jackson.module:jackson-module-kotlin")
api("org.springframework.boot:spring-boot-starter-web")
api("org.springframework.boot:spring-boot-starter-aop")
api("com.fasterxml.jackson.module:jackson-module-kotlin")
api(project(":common:log"))
api(project(":common:utils"))
api(project(":common:types"))
testImplementation("io.kotest:kotest-runner-junit5:5.9.1")
testImplementation("io.mockk:mockk:1.14.4")
implementation(project(":common:log"))
implementation(project(":common:utils"))
implementation(project(":common:types"))
}
tasks.named<BootJar>("bootJar") {

View File

@ -5,16 +5,10 @@ plugins {
}
dependencies {
// Spring
implementation("org.springframework.boot:spring-boot-starter-web")
implementation("org.springframework.boot:spring-boot-starter-data-jpa")
implementation("org.springframework.boot:spring-boot-starter-validation")
// API docs
implementation("org.springdoc:springdoc-openapi-starter-webmvc-ui:2.8.13")
// DB
implementation("com.github.f4b6a3:tsid-creator:5.2.6")
runtimeOnly("com.h2database:h2")
runtimeOnly("com.mysql:mysql-connector-j")
@ -24,7 +18,6 @@ dependencies {
// Logging
implementation("net.logstash.logback:logstash-logback-encoder:8.1")
implementation("com.github.loki4j:loki-logback-appender:2.0.0")
implementation("net.ttddyy.observation:datasource-micrometer-spring-boot:1.1.1")
// Observability
implementation("org.springframework.boot:spring-boot-starter-actuator")
@ -52,9 +45,6 @@ dependencies {
// submodules
implementation(project(":common:persistence"))
implementation(project(":common:utils"))
implementation(project(":common:types"))
implementation(project(":common:log"))
implementation(project(":common:web"))
}

View File

@ -1,5 +1,6 @@
package com.sangdol.roomescape.payment.infrastructure.client
import io.micrometer.observation.ObservationRegistry
import org.springframework.boot.context.properties.EnableConfigurationProperties
import org.springframework.boot.http.client.ClientHttpRequestFactoryBuilder
import org.springframework.boot.http.client.ClientHttpRequestFactorySettings
@ -17,6 +18,7 @@ class PaymentConfig {
@Bean
fun tosspayClientBuilder(
paymentProperties: PaymentProperties,
observationRegistry: ObservationRegistry
): RestClient.Builder {
val settings: ClientHttpRequestFactorySettings = ClientHttpRequestFactorySettings.defaults().also {
it.withReadTimeout(Duration.ofSeconds(paymentProperties.readTimeout.toLong()))
@ -26,6 +28,7 @@ class PaymentConfig {
return RestClient.builder()
.baseUrl(paymentProperties.apiBaseUrl)
.observationRegistry(observationRegistry)
.defaultHeader("Authorization", getAuthorizations(paymentProperties.confirmSecretKey))
.requestFactory(requestFactory)
}

View File

@ -6,4 +6,5 @@ include 'common:web'
include 'common:types'
include 'common:log'
include 'common:persistence'
include 'common:utils'
include 'common:utils'
include 'tosspay-mock'

3
tosspay-mock/.gitattributes vendored Normal file
View File

@ -0,0 +1,3 @@
/gradlew text eol=lf
*.bat text eol=crlf
*.jar binary

40
tosspay-mock/.gitignore vendored Normal file
View File

@ -0,0 +1,40 @@
HELP.md
.gradle
build/
!gradle/wrapper/gradle-wrapper.jar
!**/src/main/**/build/
!**/src/test/**/build/
### STS ###
.apt_generated
.classpath
.factorypath
.project
.settings
.springBeans
.sts4-cache
bin/
!**/src/main/**/bin/
!**/src/test/**/bin/
### IntelliJ IDEA ###
.idea
*.iws
*.iml
*.ipr
out/
!**/src/main/**/out/
!**/src/test/**/out/
### NetBeans ###
/nbproject/private/
/nbbuild/
/dist/
/nbdist/
/.nb-gradle/
### VS Code ###
.vscode/
### Kotlin ###
.kotlin

View File

@ -0,0 +1,28 @@
plugins {
id("org.springframework.boot")
kotlin("plugin.spring")
kotlin("plugin.jpa")
}
dependencies {
implementation("org.springframework.boot:spring-boot-starter-actuator")
implementation("net.logstash.logback:logstash-logback-encoder:8.1")
implementation("io.micrometer:micrometer-tracing-bridge-otel")
implementation("io.opentelemetry:opentelemetry-exporter-otlp")
implementation(project(":common:web"))
implementation(project(":common:types"))
implementation(project(":common:persistence"))
runtimeOnly("io.micrometer:micrometer-registry-prometheus")
runtimeOnly("com.h2database:h2")
testImplementation("org.springframework.boot:spring-boot-starter-test")
testImplementation("io.kotest.extensions:kotest-extensions-spring:1.3.0")
testImplementation("io.rest-assured:rest-assured:5.5.5")
testImplementation("io.rest-assured:kotlin-extensions:5.5.5")
}
tasks.named<Jar>("jar") {
enabled = false
}

Binary file not shown.

View File

@ -0,0 +1,7 @@
distributionBase=GRADLE_USER_HOME
distributionPath=wrapper/dists
distributionUrl=https\://services.gradle.org/distributions/gradle-8.14.3-bin.zip
networkTimeout=10000
validateDistributionUrl=true
zipStoreBase=GRADLE_USER_HOME
zipStorePath=wrapper/dists

251
tosspay-mock/gradlew vendored Executable file
View File

@ -0,0 +1,251 @@
#!/bin/sh
#
# Copyright © 2015-2021 the original authors.
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# https://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
#
# SPDX-License-Identifier: Apache-2.0
#
##############################################################################
#
# Gradle start up script for POSIX generated by Gradle.
#
# Important for running:
#
# (1) You need a POSIX-compliant shell to run this script. If your /bin/sh is
# noncompliant, but you have some other compliant shell such as ksh or
# bash, then to run this script, type that shell name before the whole
# command line, like:
#
# ksh Gradle
#
# Busybox and similar reduced shells will NOT work, because this script
# requires all of these POSIX shell features:
# * functions;
# * expansions «$var», «${var}», «${var:-default}», «${var+SET}»,
# «${var#prefix}», «${var%suffix}», and «$( cmd )»;
# * compound commands having a testable exit status, especially «case»;
# * various built-in commands including «command», «set», and «ulimit».
#
# Important for patching:
#
# (2) This script targets any POSIX shell, so it avoids extensions provided
# by Bash, Ksh, etc; in particular arrays are avoided.
#
# The "traditional" practice of packing multiple parameters into a
# space-separated string is a well documented source of bugs and security
# problems, so this is (mostly) avoided, by progressively accumulating
# options in "$@", and eventually passing that to Java.
#
# Where the inherited environment variables (DEFAULT_JVM_OPTS, JAVA_OPTS,
# and GRADLE_OPTS) rely on word-splitting, this is performed explicitly;
# see the in-line comments for details.
#
# There are tweaks for specific operating systems such as AIX, CygWin,
# Darwin, MinGW, and NonStop.
#
# (3) This script is generated from the Groovy template
# https://github.com/gradle/gradle/blob/HEAD/platforms/jvm/plugins-application/src/main/resources/org/gradle/api/internal/plugins/unixStartScript.txt
# within the Gradle project.
#
# You can find Gradle at https://github.com/gradle/gradle/.
#
##############################################################################
# Attempt to set APP_HOME
# Resolve links: $0 may be a link
app_path=$0
# Need this for daisy-chained symlinks.
while
APP_HOME=${app_path%"${app_path##*/}"} # leaves a trailing /; empty if no leading path
[ -h "$app_path" ]
do
ls=$( ls -ld "$app_path" )
link=${ls#*' -> '}
case $link in #(
/*) app_path=$link ;; #(
*) app_path=$APP_HOME$link ;;
esac
done
# This is normally unused
# shellcheck disable=SC2034
APP_BASE_NAME=${0##*/}
# Discard cd standard output in case $CDPATH is set (https://github.com/gradle/gradle/issues/25036)
APP_HOME=$( cd -P "${APP_HOME:-./}" > /dev/null && printf '%s\n' "$PWD" ) || exit
# Use the maximum available, or set MAX_FD != -1 to use that value.
MAX_FD=maximum
warn () {
echo "$*"
} >&2
die () {
echo
echo "$*"
echo
exit 1
} >&2
# 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 ;; #(
MSYS* | MINGW* ) msys=true ;; #(
NONSTOP* ) nonstop=true ;;
esac
CLASSPATH="\\\"\\\""
# 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
if ! command -v java >/dev/null 2>&1
then
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
fi
# Increase the maximum file descriptors if we can.
if ! "$cygwin" && ! "$darwin" && ! "$nonstop" ; then
case $MAX_FD in #(
max*)
# In POSIX sh, ulimit -H is undefined. That's why the result is checked to see if it worked.
# shellcheck disable=SC2039,SC3045
MAX_FD=$( ulimit -H -n ) ||
warn "Could not query maximum file descriptor limit"
esac
case $MAX_FD in #(
'' | soft) :;; #(
*)
# In POSIX sh, ulimit -n is undefined. That's why the result is checked to see if it worked.
# shellcheck disable=SC2039,SC3045
ulimit -n "$MAX_FD" ||
warn "Could not set maximum file descriptor limit to $MAX_FD"
esac
fi
# Collect all arguments for the java command, stacking in reverse order:
# * args from the command line
# * the main class name
# * -classpath
# * -D...appname settings
# * --module-path (only if needed)
# * DEFAULT_JVM_OPTS, JAVA_OPTS, and GRADLE_OPTS environment variables.
# For Cygwin or MSYS, switch paths to Windows format before running java
if "$cygwin" || "$msys" ; then
APP_HOME=$( cygpath --path --mixed "$APP_HOME" )
CLASSPATH=$( cygpath --path --mixed "$CLASSPATH" )
JAVACMD=$( cygpath --unix "$JAVACMD" )
# Now convert the arguments - kludge to limit ourselves to /bin/sh
for arg do
if
case $arg in #(
-*) false ;; # don't mess with options #(
/?*) t=${arg#/} t=/${t%%/*} # looks like a POSIX filepath
[ -e "$t" ] ;; #(
*) false ;;
esac
then
arg=$( cygpath --path --ignore --mixed "$arg" )
fi
# Roll the args list around exactly as many times as the number of
# args, so each arg winds up back in the position where it started, but
# possibly modified.
#
# NB: a `for` loop captures its iteration list before it begins, so
# changing the positional parameters here affects neither the number of
# iterations, nor the values presented in `arg`.
shift # remove old arg
set -- "$@" "$arg" # push replacement arg
done
fi
# 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" "-Xms64m"'
# Collect all arguments for the java command:
# * DEFAULT_JVM_OPTS, JAVA_OPTS, and optsEnvironmentVar are not allowed to contain shell fragments,
# and any embedded shellness will be escaped.
# * For example: A user cannot expect ${Hostname} to be expanded, as it is an environment variable and will be
# treated as '${Hostname}' itself on the command line.
set -- \
"-Dorg.gradle.appname=$APP_BASE_NAME" \
-classpath "$CLASSPATH" \
-jar "$APP_HOME/gradle/wrapper/gradle-wrapper.jar" \
"$@"
# Stop when "xargs" is not available.
if ! command -v xargs >/dev/null 2>&1
then
die "xargs is not available"
fi
# Use "xargs" to parse quoted args.
#
# With -n1 it outputs one arg per line, with the quotes and backslashes removed.
#
# In Bash we could simply go:
#
# readarray ARGS < <( xargs -n1 <<<"$var" ) &&
# set -- "${ARGS[@]}" "$@"
#
# but POSIX shell has neither arrays nor command substitution, so instead we
# post-process each arg (as a line of input to sed) to backslash-escape any
# character that might be a shell metacharacter, then use eval to reverse
# that process (while maintaining the separation between arguments), and wrap
# the whole thing up as a single "set" statement.
#
# This will of course break if any of these variables contains a newline or
# an unmatched quote.
#
eval "set -- $(
printf '%s\n' "$DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS" |
xargs -n1 |
sed ' s~[^-[:alnum:]+,./:=@_]~\\&~g; ' |
tr '\n' ' '
)" '"$@"'
exec "$JAVACMD" "$@"

94
tosspay-mock/gradlew.bat vendored Normal file
View File

@ -0,0 +1,94 @@
@rem
@rem Copyright 2015 the original author or authors.
@rem
@rem Licensed under the Apache License, Version 2.0 (the "License");
@rem you may not use this file except in compliance with the License.
@rem You may obtain a copy of the License at
@rem
@rem https://www.apache.org/licenses/LICENSE-2.0
@rem
@rem Unless required by applicable law or agreed to in writing, software
@rem distributed under the License is distributed on an "AS IS" BASIS,
@rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
@rem See the License for the specific language governing permissions and
@rem limitations under the License.
@rem
@rem SPDX-License-Identifier: Apache-2.0
@rem
@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=.
@rem This is normally unused
set APP_BASE_NAME=%~n0
set APP_HOME=%DIRNAME%
@rem Resolve any "." and ".." in APP_HOME to make it shorter.
for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi
@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" "-Xms64m"
@rem Find java.exe
if defined JAVA_HOME goto findJavaFromJavaHome
set JAVA_EXE=java.exe
%JAVA_EXE% -version >NUL 2>&1
if %ERRORLEVEL% equ 0 goto execute
echo. 1>&2
echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. 1>&2
echo. 1>&2
echo Please set the JAVA_HOME variable in your environment to match the 1>&2
echo location of your Java installation. 1>&2
goto fail
:findJavaFromJavaHome
set JAVA_HOME=%JAVA_HOME:"=%
set JAVA_EXE=%JAVA_HOME%/bin/java.exe
if exist "%JAVA_EXE%" goto execute
echo. 1>&2
echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% 1>&2
echo. 1>&2
echo Please set the JAVA_HOME variable in your environment to match the 1>&2
echo location of your Java installation. 1>&2
goto fail
:execute
@rem Setup the command line
set CLASSPATH=
@rem Execute Gradle
"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" -jar "%APP_HOME%\gradle\wrapper\gradle-wrapper.jar" %*
:end
@rem End local scope for the variables with windows NT shell
if %ERRORLEVEL% equ 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!
set EXIT_CODE=%ERRORLEVEL%
if %EXIT_CODE% equ 0 set EXIT_CODE=1
if not ""=="%GRADLE_EXIT_CONSOLE%" exit %EXIT_CODE%
exit /b %EXIT_CODE%
:mainEnd
if "%OS%"=="Windows_NT" endlocal
:omega

View File

@ -0,0 +1,13 @@
package com.sangdol.tosspaymock
import org.springframework.boot.autoconfigure.SpringBootApplication
import org.springframework.boot.runApplication
@SpringBootApplication(
scanBasePackages = ["com.sangdol.tosspaymock", "com.sangdol.common"]
)
class TosspayMockApplication
fun main(args: Array<String>) {
runApplication<TosspayMockApplication>(*args)
}

View File

@ -0,0 +1,111 @@
package com.sangdol.tosspaymock.business
import com.sangdol.common.persistence.IDGenerator
import com.sangdol.tosspaymock.business.domain.Payment
import com.sangdol.tosspaymock.business.domain.cancel.Cancellation
import com.sangdol.tosspaymock.exception.TosspayException
import com.sangdol.tosspaymock.exception.code.TosspayCancelErrorCode
import com.sangdol.tosspaymock.infrastructure.persistence.OrderAmountEntity
import com.sangdol.tosspaymock.infrastructure.persistence.OrderAmountRepository
import com.sangdol.tosspaymock.web.dto.PaymentCancelRequest
import com.sangdol.tosspaymock.web.dto.PaymentConfirmRequest
import com.sangdol.tosspaymock.web.dto.PaymentResponse
import io.github.oshai.kotlinlogging.KLogger
import io.github.oshai.kotlinlogging.KotlinLogging
import org.springframework.stereotype.Service
private val log: KLogger = KotlinLogging.logger {}
@Service
class TosspayService(
private val idGenerator: IDGenerator,
private val orderAmountRepository: OrderAmountRepository,
) {
fun confirm(request: PaymentConfirmRequest): PaymentResponse {
log.info { "[TosspayService.confirm] 결제 확정 시작: paymentKey=${request.paymentKey}, amount=${request.amount}" }
val payment = choosePayment(request).also { saveAmount(request.paymentKey, it) }
return payment.toResponse()
.also {
log.info { "[TosspayService.confirm] 결제 확정 완료: paymentKey=${request.paymentKey}, amount=${request.amount}" }
}
}
fun cancel(paymentKey: String, request: PaymentCancelRequest): PaymentResponse {
log.info { "[TosspayService.cancel] 결제 취소 시작: paymentKey=${paymentKey}" }
val orderAmount = orderAmountRepository.findByPaymentKey(paymentKey)
?: throw TosspayException(TosspayCancelErrorCode.NOT_FOUND_PAYMENT)
val cancellation = Cancellation.random(
cancelReason = request.cancelReason,
cancelAmount = request.cancelAmount ?: orderAmount.totalAmount(),
easyPayDiscountAmount = orderAmount.easypayDiscountAmount,
cardDiscountAmount = orderAmount.cardDiscountAmount,
transferDiscountAmount = orderAmount.transferDiscountAmount
)
return Payment.randomForCancellation(paymentKey, cancellation)
.toResponse()
.also {
log.info { "[TosspayService.cancel] 결제 취소 완료: paymentKey=${paymentKey}" }
}
}
private fun choosePayment(request: PaymentConfirmRequest): Payment {
log.info { "[TosspayService.choosePayment] 랜덤 결제 정보 생성 시작: paymentKey=${request.paymentKey}, amount=${request.amount}" }
val randomValue = Math.random()
// 70%는 간편결제에 배정
return if (randomValue < 0.7) {
// 70%의 간편결제 중 70%는 카드
if (randomValue < 0.49) {
Payment.randomWithEasypayCard(
request.paymentKey, request.orderId, request.amount, request.requestedAt
).also {
log.info { "[Tosspayment.choosePayment] 간편결제 + 카드 결제 객체 생성 완료" }
}
} else { // 30%는 간편결제 선불 충전액
Payment.randomWithEasypayPrepaid(
request.paymentKey, request.orderId, request.amount, request.requestedAt
).also {
log.info { "[Tosspayment.choosePayment] 간편결제 + 충전식 결제 객체 생성 완료" }
}
}
} else if (randomValue < 0.95) { // 남은 30% 중 25%는 일반 카드
Payment.randomWithCard(
request.paymentKey, request.orderId, request.amount, request.requestedAt
).also {
log.info { "[Tosspayment.choosePayment] 카드 결제 객체 생성 완료" }
}
} else { // 나머지는 계좌이체
Payment.randomWithBankTransfer(
request.paymentKey, request.orderId, request.amount, request.requestedAt
).also {
log.info { "[Tosspayment.choosePayment] 계좌이체 결제 객체 생성 완료" }
}
}
}
private fun saveAmount(paymentKey: String, payment: Payment) {
log.info { "[Tosspayment.saveAmount] 결제 금액 정보 저장 시작: paymentKey=${paymentKey}" }
val easypayDiscountAmount = payment.easyPay?.discountAmount ?: 0
val cardDiscountAmount = 0
val transferDiscountAmount = 0
val orderAmount = OrderAmountEntity(
idGenerator.create(),
paymentKey,
(payment.totalAmount - easypayDiscountAmount),
easypayDiscountAmount,
cardDiscountAmount,
transferDiscountAmount
)
orderAmountRepository.save(orderAmount).also {
log.info { "[Tosspayment.saveAmount] 결제 금액 정보 저장 완료: id=${it.id}, paymentKey=${paymentKey}, amount=${orderAmount.approvedAmount}, easypayDiscount=${orderAmount.easypayDiscountAmount}" }
}
}
}

View File

@ -0,0 +1,221 @@
package com.sangdol.tosspaymock.business.domain
import com.sangdol.tosspaymock.business.domain.cancel.Cancellation
import com.sangdol.tosspaymock.business.domain.card.Card
import com.sangdol.tosspaymock.business.domain.easypay.Easypay
import com.sangdol.tosspaymock.business.domain.transfer.BankTransfer
import com.sangdol.tosspaymock.web.dto.PaymentResponse
import java.time.OffsetDateTime
class Payment(
val mid: String,
val lastTransactionKey: String,
val paymentKey: String,
val orderId: String,
val orderName: String,
val taxExemptionAmount: Int = 0,
var status: PaymentStatus = PaymentStatus.DONE,
val requestedAt: OffsetDateTime,
val approvedAt: OffsetDateTime,
val useEscrow: Boolean = false,
val cultureExpense: Boolean = false,
val card: Card? = null,
val virtualAccount: String? = null,
val transfer: BankTransfer? = null,
val mobilePhone: String? = null,
val giftCertificate: String? = null,
val cashReceipt: String? = null,
val cashReceipts: String? = null,
val discount: String? = null,
var cancels: Cancellation? = null,
val secret: String? = null,
val type: PaymentType = PaymentType.NORMAL,
val easyPay: Easypay? = null,
val country: String = "KR",
val failure: String? = null,
val isPartialCancelable: Boolean = true,
val receipt: String? = null,
val checkout: String? = null,
val currency: String = "KRW",
val totalAmount: Int,
val balanceAmount: Int,
val suppliedAmount: Int,
val vat: Int,
val taxFreeAmount: Int = 0,
val method: PaymentMethod,
val version: String = "2022-11-16",
val metadata: String? = null
) {
companion object {
fun randomWithCard(
paymentKey: String,
orderId: String,
amount: Int,
requestedAt: OffsetDateTime
): Payment {
val card = Card.random(amount)
return create(
paymentKey = paymentKey,
orderId = orderId,
requestedAt = requestedAt,
amount = amount,
card = card,
easyPay = null,
bankTransfer = null
)
}
fun randomWithEasypayCard(
paymentKey: String,
orderId: String,
amount: Int,
requestedAt: OffsetDateTime
): Payment {
val easyPay = Easypay.randomWithCard(amount)
val card = Card.random(approvedAmount = (amount - easyPay.discountAmount))
return create(
paymentKey = paymentKey,
orderId = orderId,
requestedAt = requestedAt,
amount = amount,
card = card,
easyPay = easyPay,
bankTransfer = null
)
}
fun randomWithEasypayPrepaid(
paymentKey: String,
orderId: String,
amount: Int,
requestedAt: OffsetDateTime
): Payment {
val easypay = Easypay.randomWithPrepaid(amount)
return create(
paymentKey = paymentKey,
orderId = orderId,
requestedAt = requestedAt,
amount = amount,
card = null,
easyPay = easypay,
bankTransfer = null
)
}
fun randomWithBankTransfer(
paymentKey: String,
orderId: String,
amount: Int,
requestedAt: OffsetDateTime
): Payment {
val bankTransfer = BankTransfer.random()
return create(
paymentKey = paymentKey,
orderId = orderId,
requestedAt = requestedAt,
amount = amount,
card = null,
easyPay = null,
bankTransfer = bankTransfer
)
}
fun randomForCancellation(paymentKey: String, cancellation: Cancellation): Payment {
return create(
paymentKey = paymentKey,
orderId = "orderId",
requestedAt = OffsetDateTime.now(),
amount = cancellation.cancelAmount,
card = null,
easyPay = null,
bankTransfer = null
).apply {
this.status = PaymentStatus.CANCELED
this.cancels = cancellation
}
}
private fun create(
paymentKey: String,
orderId: String,
requestedAt: OffsetDateTime,
amount: Int,
card: Card?,
easyPay: Easypay?,
bankTransfer: BankTransfer?
): Payment {
val vat = (amount * 0.1).toInt()
val suppliedAmount = amount - vat
val paymentMethod = if (bankTransfer != null) {
PaymentMethod.TRANSFER
} else if (easyPay != null) {
PaymentMethod.EASY_PAY
} else {
PaymentMethod.CARD
}
return Payment(
mid = RandomPaymentValueGenerator.mId(),
lastTransactionKey = RandomPaymentValueGenerator.transactionKey(),
paymentKey = paymentKey,
orderId = orderId,
orderName = "테스트 결제",
requestedAt = requestedAt,
approvedAt = OffsetDateTime.now(),
card = card,
easyPay = easyPay,
transfer = bankTransfer,
totalAmount = amount,
balanceAmount = amount,
suppliedAmount = suppliedAmount,
vat = vat,
method = paymentMethod,
)
}
}
fun toResponse() = PaymentResponse(
mid = this.mid,
lastTransactionKey = this.lastTransactionKey,
paymentKey = this.paymentKey,
orderId = this.orderId,
orderName = this.orderName,
taxExemptionAmount = this.taxExemptionAmount,
status = this.status.name,
requestedAt = this.requestedAt,
approvedAt = this.approvedAt,
useEscrow = this.useEscrow,
cultureExpense = this.cultureExpense,
card = this.card?.toResponse(),
virtualAccount = this.virtualAccount,
transfer = this.transfer?.toResponse(),
mobilePhone = this.mobilePhone,
giftCertificate = this.giftCertificate,
cashReceipt = this.cashReceipt,
cashReceipts = this.cashReceipts,
discount = this.discount,
cancels = this.cancels?.toResponse(),
secret = this.secret,
type = this.type,
easyPay = this.easyPay?.toResponse(),
country = this.country,
failure = this.failure,
isPartialCancelable = this.isPartialCancelable,
receipt = this.receipt,
checkout = this.checkout,
currency = this.currency,
totalAmount = this.totalAmount,
balanceAmount = this.balanceAmount,
suppliedAmount = this.suppliedAmount,
vat = this.vat,
taxFreeAmount = this.taxFreeAmount,
method = this.method.koreanName,
version = this.version,
metadata = this.metadata,
)
}

View File

@ -0,0 +1,32 @@
package com.sangdol.tosspaymock.business.domain
enum class PaymentType(
) {
NORMAL,
BILLING,
BRANDPAY,
;
}
enum class PaymentMethod(
val koreanName: String,
) {
CARD("카드"),
EASY_PAY("간편결제"),
VIRTUAL_ACCOUNT("가상계좌"),
MOBILE_PHONE("휴대폰"),
TRANSFER("계좌이체"),
CULTURE_GIFT_CERTIFICATE("문화상품권"),
BOOK_GIFT_CERTIFICATE("도서문화상품권"),
GAME_GIFT_CERTIFICATE("게임문화상품권"),
;
}
enum class PaymentStatus {
IN_PROGRESS,
DONE,
CANCELED,
ABORTED,
EXPIRED,
;
}

View File

@ -0,0 +1,45 @@
package com.sangdol.tosspaymock.business.domain
object RandomCardValueGenerator {
fun cardNumber(): String {
return "${(10000000..99999999).random()}****${(100..999).random()}*"
}
fun approvalNumber(): String {
return "${(0..99999999).random()}".padStart(8, '0')
}
fun installmentPlanMonths(amount: Int): Int {
return if (amount < 50_000 || Math.random() < 0.95) {
0
} else {
(1..6).random()
}
}
}
object RandomEasypayValueGenerator {
fun point(amount: Int): Int =
if (amount < 100 || Math.random() < 0.8) {
0
} else {
// 100~amount 까지 100원 단위로 생성
((100..amount).random() / 100) * 100
}
}
object RandomPaymentValueGenerator {
fun mId(): String {
val words = ('a'..'z')
val randomValue = (1..9).map { words.random() }.joinToString("")
return "tgen_${randomValue}"
}
fun transactionKey(): String {
val prefix = "txrd"
val characters = ('0'..'9') + ('a'..'z')
val randomString = (1..24).map { characters.random() }.joinToString("")
return "${prefix}_${randomString}"
}
}

View File

@ -0,0 +1,55 @@
package com.sangdol.tosspaymock.business.domain.cancel
import com.sangdol.tosspaymock.business.domain.RandomPaymentValueGenerator
import com.sangdol.tosspaymock.web.dto.CancelResponse
import java.time.OffsetDateTime
class Cancellation(
val transactionKey: String,
val cancelReason: String,
val taxExemptionAmount: Int = 0,
val canceledAt: OffsetDateTime,
val cardDiscountAmount: Int,
val transferDiscountAmount: Int,
val easyPayDiscountAmount: Int,
val receiptKey: String? = null,
val cancelStatus: String = "DONE",
val cancelRequestId: String? = null,
val cancelAmount: Int,
val taxFreeAmount: Int = 0,
val refundableAmount: Int = 0,
) {
companion object {
fun random(
cancelReason: String,
cancelAmount: Int,
easyPayDiscountAmount: Int,
cardDiscountAmount: Int,
transferDiscountAmount: Int,
) = Cancellation(
transactionKey = RandomPaymentValueGenerator.transactionKey(),
cancelReason = cancelReason,
canceledAt = OffsetDateTime.now(),
cardDiscountAmount = cardDiscountAmount,
transferDiscountAmount = transferDiscountAmount,
easyPayDiscountAmount = easyPayDiscountAmount,
cancelAmount = cancelAmount,
)
}
fun toResponse() = CancelResponse(
transactionKey = this.transactionKey,
cancelReason = this.cancelReason,
taxExemptionAmount = this.taxExemptionAmount,
canceledAt = this.canceledAt,
cardDiscountAmount = this.cardDiscountAmount,
transferDiscountAmount = this.transferDiscountAmount,
easyPayDiscountAmount = this.easyPayDiscountAmount,
receiptKey = this.receiptKey,
cancelStatus = this.cancelStatus,
cancelRequestId = this.cancelRequestId,
cancelAmount = this.cancelAmount,
taxFreeAmount = this.taxFreeAmount,
refundableAmount = this.refundableAmount
)
}

View File

@ -0,0 +1,56 @@
package com.sangdol.tosspaymock.business.domain.card
import com.sangdol.tosspaymock.business.domain.RandomCardValueGenerator
import com.sangdol.tosspaymock.web.dto.CardResponse
class Card(
val issuerCode: CardIssuerCode,
val acquirerCode: CardIssuerCode,
val number: String,
val installmentPlanMonths: Int,
val isInterestFree: Boolean = true,
val interestPayer: String? = null,
val approveNo: String,
val useCardPoint: Boolean,
val cardType: CardType,
val ownerType: CardOwnerType,
val acquireStatus: CardAcquireStatus,
val amount: Int,
) {
companion object {
val availableCardTypes = listOf(CardType.CREDIT, CardType.CHECK)
val availableCardOwnerTypes = CardOwnerType.entries - CardOwnerType.UNKNOWN
fun random(approvedAmount: Int): Card {
return Card(
issuerCode = CardIssuerCode.entries.random(),
acquirerCode = CardIssuerCode.entries.random(),
number = RandomCardValueGenerator.cardNumber(),
installmentPlanMonths = RandomCardValueGenerator.installmentPlanMonths(approvedAmount),
isInterestFree = true,
interestPayer = null,
approveNo = RandomCardValueGenerator.approvalNumber(),
useCardPoint = false,
cardType = availableCardTypes.random(),
ownerType = availableCardOwnerTypes.random(),
acquireStatus = CardAcquireStatus.COMPLETED,
amount = approvedAmount
)
}
}
fun toResponse() = CardResponse(
issuerCode = this.issuerCode.code,
acquirerCode = this.acquirerCode.code,
number = this.number,
installmentPlanMonths = this.installmentPlanMonths,
isInterestFree = this.isInterestFree,
interestPayer = this.interestPayer,
approveNo = this.approveNo,
useCardPoint = this.useCardPoint,
cardType = this.cardType.koreanName,
ownerType = this.ownerType.koreanName,
acquireStatus = this.acquireStatus.name,
amount = this.amount
)
}

View File

@ -0,0 +1,58 @@
package com.sangdol.tosspaymock.business.domain.card
enum class CardType(
val koreanName: String
) {
CREDIT("신용"),
CHECK("체크"),
GIFT("기프트"),
UNKNOWN("미확인"),
;
}
enum class CardOwnerType(
val koreanName: String
) {
PERSONAL("개인"),
CORPORATE("법인"),
UNKNOWN("미확인"),
;
}
enum class CardAcquireStatus {
READY,
REQUESTED,
COMPLETED,
CANCEL_REQUESTED,
CANCELED
}
enum class CardIssuerCode(
val code: String,
) {
IBK_BC("3K"),
GWANGJU_BANK("46"),
LOTTE("71"),
KDB_BANK("30"),
BC("31"),
SAMSUNG("51"),
SAEMAUL("38"),
SHINHAN("41"),
SHINHYEOP("62"),
CITI("36"),
WOORI_BC("33"),
WOORI("W1"),
POST("37"),
SAVINGBANK("39"),
JEONBUK_BANK("35"),
JEJU_BANK("42"),
KAKAO_BANK("15"),
K_BANK("3A"),
TOSS_BANK("24"),
HANA("21"),
HYUNDAI("61"),
KOOKMIN("11"),
NONGHYEOP("91"),
SUHYEOP("34"),
;
}

View File

@ -0,0 +1,38 @@
package com.sangdol.tosspaymock.business.domain.easypay
import com.sangdol.tosspaymock.business.domain.RandomEasypayValueGenerator
import com.sangdol.tosspaymock.web.dto.EasypayResponse
class Easypay(
val provider: EasypayProvider,
val amount: Int,
val discountAmount: Int
) {
companion object {
fun randomWithPrepaid(amount: Int): Easypay {
val point = RandomEasypayValueGenerator.point(amount)
return Easypay(
provider = EasypayProvider.entries.random(),
amount = (amount - point),
discountAmount = point
)
}
fun randomWithCard(amount: Int): Easypay {
val point = RandomEasypayValueGenerator.point(amount)
return Easypay(
provider = EasypayProvider.entries.random(),
amount = 0,
discountAmount = point
)
}
}
fun toResponse() = EasypayResponse(
provider = this.provider.koreanName,
amount = this.amount,
discountAmount = this.discountAmount
)
}

View File

@ -0,0 +1,16 @@
package com.sangdol.tosspaymock.business.domain.easypay
enum class EasypayProvider(
val koreanName: String
) {
TOSSPAY("토스페이"),
NAVERPAY("네이버페이"),
SAMSUNGPAY("삼성페이"),
LPAY("엘페이"),
KAKAOPAY("카카오페이"),
PAYCO("페이코"),
SSG("SSG페이"),
APPLEPAY("애플페이"),
PINPAY("핀페이"),
;
}

View File

@ -0,0 +1,22 @@
package com.sangdol.tosspaymock.business.domain.transfer
import com.sangdol.tosspaymock.web.dto.BankTransferResponse
class BankTransfer(
val bankCode: BankCode,
val settlementStatus: SettlementStatus,
) {
companion object {
fun random(): BankTransfer {
return BankTransfer(
bankCode = BankCode.entries.random(),
settlementStatus = SettlementStatus.COMPLETED
)
}
}
fun toResponse() = BankTransferResponse(
bankCode = this.bankCode.code,
settlementStatus = this.settlementStatus.name
)
}

View File

@ -0,0 +1,37 @@
package com.sangdol.tosspaymock.business.domain.transfer
enum class BankCode(
val code: String,
) {
KYONGNAM_BANK("039"),
GWANGJU_BANK("034"),
LOCAL_NONGHYEOP("012"),
BUSAN_BANK("032"),
SAEMAUL("045"),
SANLIM("064"),
SHINHAN("088"),
SHINHYEOP("048"),
CITI("027"),
WOORI("020"),
POST("071"),
SAVINGBANK("050"),
JEONBUK_BANK("037"),
JEJU_BANK("035"),
KAKAO_BANK("090"),
K_BANK("089"),
TOSS_BANK("092"),
HANA("081"),
HSBC("054"),
IBK("003"),
KOOKMIN("004"),
DAEGU("031"),
KDB_BANK("002"),
NONGHYEOP("011"),
SC("023"),
SUHYEOP("007");
}
enum class SettlementStatus {
COMPLETED,
INCOMPLETED,
}

View File

@ -0,0 +1,8 @@
package com.sangdol.tosspaymock.exception
import com.sangdol.tosspaymock.exception.code.TosspayErrorCode
class TosspayException(
val errorCode: TosspayErrorCode,
override val message: String = errorCode.message
) : RuntimeException(message)

View File

@ -0,0 +1,29 @@
package com.sangdol.tosspaymock.exception
import com.sangdol.common.types.web.CommonErrorResponse
import io.github.oshai.kotlinlogging.KLogger
import io.github.oshai.kotlinlogging.KotlinLogging
import org.springframework.http.ResponseEntity
import org.springframework.web.bind.annotation.ExceptionHandler
import org.springframework.web.bind.annotation.RestControllerAdvice
private val log: KLogger = KotlinLogging.logger {}
@RestControllerAdvice
class TosspayExceptionHandler {
@ExceptionHandler(value = [TosspayException::class])
fun handleTosspayException(
e: TosspayException
): ResponseEntity<CommonErrorResponse> {
val code = e.errorCode
val name = (code as Enum<*>).name
val message = e.message
log.warn { "[TosspayExceptionHandler] 결제 처리 과정 중 오류 발생: error=${name}, message=$message" }
return ResponseEntity
.status(code.httpStatus.value())
.body(CommonErrorResponse(name, message))
}
}

View File

@ -0,0 +1,39 @@
package com.sangdol.tosspaymock.exception.code
import com.sangdol.common.types.web.HttpStatus
enum class TosspayCancelErrorCode(
override val httpStatus: HttpStatus,
override val errorCode: String,
override val message: String
) : TosspayErrorCode {
ALREADY_CANCELED_PAYMENT(HttpStatus.BAD_REQUEST, "CO001", "이미 취소된 결제 입니다."),
INVALID_REFUND_ACCOUNT_INFO(HttpStatus.BAD_REQUEST, "CO002", "환불 계좌번호와 예금주명이 일치하지 않습니다."),
EXCEED_CANCEL_AMOUNT_DISCOUNT_AMOUNT(HttpStatus.BAD_REQUEST, "CO003", "즉시할인금액보다 적은 금액은 부분취소가 불가능합니다."),
INVALID_REQUEST(HttpStatus.BAD_REQUEST, "CO004", "잘못된 요청입니다."),
INVALID_REFUND_ACCOUNT_NUMBER(HttpStatus.BAD_REQUEST, "CO005", "잘못된 환불 계좌번호입니다."),
INVALID_BANK(HttpStatus.BAD_REQUEST, "CO006", "유효하지 않은 은행입니다."),
NOT_MATCHES_REFUNDABLE_AMOUNT(HttpStatus.BAD_REQUEST, "CO007", "잔액 결과가 일치하지 않습니다."),
PROVIDER_ERROR(HttpStatus.BAD_REQUEST, "CO008", "일시적인 오류가 발생했습니다. 잠시 후 다시 시도해주세요."),
REFUND_REJECTED(HttpStatus.BAD_REQUEST, "CO009", "환불이 거절됐습니다. 결제사에 문의 부탁드립니다."),
ALREADY_REFUND_PAYMENT(HttpStatus.BAD_REQUEST, "CO010", "이미 환불된 결제입니다."),
FORBIDDEN_BANK_REFUND_REQUEST(HttpStatus.BAD_REQUEST, "CO011", "고객 계좌가 입금이 되지 않는 상태입니다."),
UNAUTHORIZED_KEY(HttpStatus.UNAUTHORIZED, "CO012", "인증되지 않은 시크릿 키 혹은 클라이언트 키 입니다."),
NOT_CANCELABLE_AMOUNT(HttpStatus.FORBIDDEN, "CO013", "취소 할 수 없는 금액 입니다."),
FORBIDDEN_CONSECUTIVE_REQUEST(HttpStatus.FORBIDDEN, "CO014", "반복적인 요청은 허용되지 않습니다. 잠시 후 다시 시도해주세요."),
FORBIDDEN_REQUEST(HttpStatus.FORBIDDEN, "CO015", "허용되지 않은 요청입니다."),
NOT_CANCELABLE_PAYMENT(HttpStatus.FORBIDDEN, "CO016", "취소 할 수 없는 결제 입니다."),
EXCEED_MAX_REFUND_DUE(HttpStatus.FORBIDDEN, "CO017", "환불 가능한 기간이 지났습니다."),
NOT_ALLOWED_PARTIAL_REFUND_WAITING_DEPOSIT(HttpStatus.FORBIDDEN, "CO018", "입금 대기중인 결제는 부분 환불이 불가합니다."),
NOT_ALLOWED_PARTIAL_REFUND(HttpStatus.FORBIDDEN, "CO019", "에스크로 주문, 현금 카드 결제일 때는 부분 환불이 불가합니다. 이외 다른 결제 수단에서 부분 취소가 되지 않을 때는 토스페이먼츠에 문의해 주세요."),
NOT_AVAILABLE_BANK(HttpStatus.FORBIDDEN, "CO020", "은행 서비스 시간이 아닙니다."),
INCORRECT_BASIC_AUTH_FORMAT(HttpStatus.FORBIDDEN, "CO021", "잘못된 요청입니다. ':' 를 포함해 인코딩해주세요."),
NOT_CANCELABLE_PAYMENT_FOR_DORMANT_USER(HttpStatus.FORBIDDEN, "CO022", "휴면 처리된 회원의 결제는 취소할 수 없습니다."),
NOT_FOUND_PAYMENT(HttpStatus.NOT_FOUND, "CO023", "존재하지 않는 결제 정보 입니다."),
FAILED_INTERNAL_SYSTEM_PROCESSING(HttpStatus.INTERNAL_SERVER_ERROR, "CO024", "내부 시스템 처리 작업이 실패했습니다. 잠시 후 다시 시도해주세요."),
FAILED_REFUND_PROCESS(HttpStatus.INTERNAL_SERVER_ERROR, "CO025", "은행 응답시간 지연이나 일시적인 오류로 환불요청에 실패했습니다."),
FAILED_METHOD_HANDLING_CANCEL(HttpStatus.INTERNAL_SERVER_ERROR, "CO026", "취소 중 결제 시 사용한 결제 수단 처리과정에서 일시적인 오류가 발생했습니다."),
FAILED_PARTIAL_REFUND(HttpStatus.INTERNAL_SERVER_ERROR, "CO027", "은행 점검, 해약 계좌 등의 사유로 부분 환불이 실패했습니다."),
COMMON_ERROR(HttpStatus.INTERNAL_SERVER_ERROR, "CO028", "일시적인 오류가 발생했습니다. 잠시 후 다시 시도해주세요."),
FAILED_PAYMENT_INTERNAL_SYSTEM_PROCESSING(HttpStatus.INTERNAL_SERVER_ERROR, "CO029", "결제가 완료되지 않았어요. 다시 시도해주세요.");
}

View File

@ -0,0 +1,57 @@
package com.sangdol.tosspaymock.exception.code
import com.sangdol.common.types.web.HttpStatus
enum class TosspayConfirmErrorCode(
override val httpStatus: HttpStatus,
override val errorCode: String,
override val message: String,
) : TosspayErrorCode {
ALREADY_PROCESSED_PAYMENT(HttpStatus.BAD_REQUEST, "CO001", "이미 처리된 결제 입니다."),
PROVIDER_ERROR(HttpStatus.BAD_REQUEST, "CO002", "일시적인 오류가 발생했습니다. 잠시 후 다시 시도해주세요."),
EXCEED_MAX_CARD_INSTALLMENT_PLAN(HttpStatus.BAD_REQUEST, "CO003", "설정 가능한 최대 할부 개월 수를 초과했습니다."),
INVALID_REQUEST(HttpStatus.BAD_REQUEST, "CO004", "잘못된 요청입니다."),
NOT_ALLOWED_POINT_USE(HttpStatus.BAD_REQUEST, "CO005", "포인트 사용이 불가한 카드로 카드 포인트 결제에 실패했습니다."),
INVALID_API_KEY(HttpStatus.BAD_REQUEST, "CO006", "잘못된 시크릿키 연동 정보 입니다."),
INVALID_REJECT_CARD(HttpStatus.BAD_REQUEST, "CO007", "카드 사용이 거절되었습니다. 카드사 문의가 필요합니다."),
BELOW_MINIMUM_AMOUNT(HttpStatus.BAD_REQUEST, "CO008", "신용카드는 결제금액이 100원 이상, 계좌는 200원이상부터 결제가 가능합니다."),
INVALID_CARD_EXPIRATION(HttpStatus.BAD_REQUEST, "CO009", "카드 정보를 다시 확인해주세요. (유효기간)"),
INVALID_STOPPED_CARD(HttpStatus.BAD_REQUEST, "CO010", "정지된 카드 입니다."),
EXCEED_MAX_DAILY_PAYMENT_COUNT(HttpStatus.BAD_REQUEST, "CO011", "하루 결제 가능 횟수를 초과했습니다."),
NOT_SUPPORTED_INSTALLMENT_PLAN_CARD_OR_MERCHANT(HttpStatus.BAD_REQUEST, "CO012", "할부가 지원되지 않는 카드 또는 가맹점 입니다."),
INVALID_CARD_INSTALLMENT_PLAN(HttpStatus.BAD_REQUEST, "CO013", "할부 개월 정보가 잘못되었습니다."),
NOT_SUPPORTED_MONTHLY_INSTALLMENT_PLAN(HttpStatus.BAD_REQUEST, "CO014", "할부가 지원되지 않는 카드입니다."),
EXCEED_MAX_PAYMENT_AMOUNT(HttpStatus.BAD_REQUEST, "CO015", "하루 결제 가능 금액을 초과했습니다."),
NOT_FOUND_TERMINAL_ID(HttpStatus.BAD_REQUEST, "CO016", "단말기번호(Terminal Id)가 없습니다. 토스페이먼츠로 문의 바랍니다."),
INVALID_AUTHORIZE_AUTH(HttpStatus.BAD_REQUEST, "CO017", "유효하지 않은 인증 방식입니다."),
INVALID_CARD_LOST_OR_STOLEN(HttpStatus.BAD_REQUEST, "CO018", "분실 혹은 도난 카드입니다."),
RESTRICTED_TRANSFER_ACCOUNT(HttpStatus.BAD_REQUEST, "CO019", "계좌는 등록 후 12시간 뒤부터 결제할 수 있습니다. 관련 정책은 해당 은행으로 문의해주세요."),
INVALID_CARD_NUMBER(HttpStatus.BAD_REQUEST, "CO020", "카드번호를 다시 확인해주세요."),
INVALID_UNREGISTERED_SUBMALL(HttpStatus.BAD_REQUEST, "CO021", "등록되지 않은 서브몰입니다. 서브몰이 없는 가맹점이라면 안심클릭이나 ISP 결제가 필요합니다."),
NOT_REGISTERED_BUSINESS(HttpStatus.BAD_REQUEST, "CO022", "등록되지 않은 사업자 번호입니다."),
EXCEED_MAX_ONE_DAY_WITHDRAW_AMOUNT(HttpStatus.BAD_REQUEST, "CO023", "1일 출금 한도를 초과했습니다."),
EXCEED_MAX_ONE_TIME_WITHDRAW_AMOUNT(HttpStatus.BAD_REQUEST, "CO024", "1회 출금 한도를 초과했습니다."),
CARD_PROCESSING_ERROR(HttpStatus.BAD_REQUEST, "CO025", "카드사에서 오류가 발생했습니다."),
EXCEED_MAX_AMOUNT(HttpStatus.BAD_REQUEST, "CO026", "거래금액 한도를 초과했습니다."),
INVALID_ACCOUNT_INFO_RE_REGISTER(HttpStatus.BAD_REQUEST, "CO027", "유효하지 않은 계좌입니다. 계좌 재등록 후 시도해주세요."),
NOT_AVAILABLE_PAYMENT(HttpStatus.BAD_REQUEST, "CO028", "결제가 불가능한 시간대입니다."),
UNAPPROVED_ORDER_ID(HttpStatus.BAD_REQUEST, "CO029", "아직 승인되지 않은 주문번호입니다."),
EXCEED_MAX_MONTHLY_PAYMENT_AMOUNT(HttpStatus.BAD_REQUEST, "CO030", "당월 결제 가능금액인 1,000,000원을 초과 하셨습니다."),
UNAUTHORIZED_KEY(HttpStatus.UNAUTHORIZED, "CO031", "인증되지 않은 시크릿 키 혹은 클라이언트 키 입니다."),
REJECT_ACCOUNT_PAYMENT(HttpStatus.FORBIDDEN, "CO032", "잔액부족으로 결제에 실패했습니다."),
REJECT_CARD_PAYMENT(HttpStatus.FORBIDDEN, "CO033", "한도초과 혹은 잔액부족으로 결제에 실패했습니다."),
REJECT_CARD_COMPANY(HttpStatus.FORBIDDEN, "CO034", "결제 승인이 거절되었습니다."),
FORBIDDEN_REQUEST(HttpStatus.FORBIDDEN, "CO035", "허용되지 않은 요청입니다."),
REJECT_TOSSPAY_INVALID_ACCOUNT(HttpStatus.FORBIDDEN, "CO036", "선택하신 출금 계좌가 출금이체 등록이 되어 있지 않아요. 계좌를 다시 등록해 주세요."),
EXCEED_MAX_AUTH_COUNT(HttpStatus.FORBIDDEN, "CO037", "최대 인증 횟수를 초과했습니다. 카드사로 문의해주세요."),
EXCEED_MAX_ONE_DAY_AMOUNT(HttpStatus.FORBIDDEN, "CO038", "일일 한도를 초과했습니다."),
NOT_AVAILABLE_BANK(HttpStatus.FORBIDDEN, "CO039", "은행 서비스 시간이 아닙니다."),
INVALID_PASSWORD(HttpStatus.FORBIDDEN, "CO040", "결제 비밀번호가 일치하지 않습니다."),
INCORRECT_BASIC_AUTH_FORMAT(HttpStatus.FORBIDDEN, "CO041", "잘못된 요청입니다. ':' 를 포함해 인코딩해주세요."),
FDS_ERROR(HttpStatus.FORBIDDEN, "CO042", "[토스페이먼츠] 위험거래가 감지되어 결제가 제한됩니다. 발송된 문자에 포함된 링크를 통해 본인인증 후 결제가 가능합니다. (고객센터: 1644-8051)"),
NOT_FOUND_PAYMENT(HttpStatus.NOT_FOUND, "CO043", "존재하지 않는 결제 정보 입니다."),
NOT_FOUND_PAYMENT_SESSION(HttpStatus.NOT_FOUND, "CO044", "결제 시간이 만료되어 결제 진행 데이터가 존재하지 않습니다."),
FAILED_PAYMENT_INTERNAL_SYSTEM_PROCESSING(HttpStatus.INTERNAL_SERVER_ERROR, "CO045", "결제가 완료되지 않았어요. 다시 시도해주세요."),
FAILED_INTERNAL_SYSTEM_PROCESSING(HttpStatus.INTERNAL_SERVER_ERROR, "CO046", "내부 시스템 처리 작업이 실패했습니다. 잠시 후 다시 시도해주세요."),
UNKNOWN_PAYMENT_ERROR(HttpStatus.INTERNAL_SERVER_ERROR, "CO047", "결제에 실패했어요. 같은 문제가 반복된다면 은행이나 카드사로 문의해주세요.");
}

View File

@ -0,0 +1,9 @@
package com.sangdol.tosspaymock.exception.code
import com.sangdol.common.types.web.HttpStatus
interface TosspayErrorCode {
val httpStatus: HttpStatus
val errorCode: String
val message: String
}

View File

@ -0,0 +1,24 @@
package com.sangdol.tosspaymock.infrastructure.persistence
import com.sangdol.common.persistence.PersistableBaseEntity
import jakarta.persistence.Column
import jakarta.persistence.Entity
import jakarta.persistence.Table
@Entity
@Table(name = "order_amount")
class OrderAmountEntity(
id: Long,
@Column(unique = true)
val paymentKey: String,
val approvedAmount: Int,
val easypayDiscountAmount: Int,
val cardDiscountAmount: Int,
val transferDiscountAmount: Int
) : PersistableBaseEntity(id) {
fun totalAmount(): Int {
return (approvedAmount + easypayDiscountAmount + cardDiscountAmount + transferDiscountAmount)
}
}

View File

@ -0,0 +1,8 @@
package com.sangdol.tosspaymock.infrastructure.persistence
import org.springframework.data.jpa.repository.JpaRepository
interface OrderAmountRepository : JpaRepository<OrderAmountEntity, Long> {
fun findByPaymentKey(paymentKey: String): OrderAmountEntity?
}

View File

@ -0,0 +1,35 @@
package com.sangdol.tosspaymock.web
import com.sangdol.tosspaymock.business.TosspayService
import com.sangdol.tosspaymock.web.dto.PaymentCancelRequest
import com.sangdol.tosspaymock.web.dto.PaymentConfirmRequest
import com.sangdol.tosspaymock.web.dto.PaymentResponse
import org.springframework.http.ResponseEntity
import org.springframework.web.bind.annotation.*
@RestController
@RequestMapping("/v1/payments")
class TosspayController(
private val tosspayService: TosspayService
) {
@PostMapping("/confirm")
fun confirmPayment(
@RequestBody request: PaymentConfirmRequest
): ResponseEntity<PaymentResponse> {
val response = tosspayService.confirm(request)
return ResponseEntity.ok(response)
}
@PostMapping("/{paymentKey}/cancel")
fun cancelPayment(
@PathVariable("paymentKey") paymentKey: String,
@RequestBody request: PaymentCancelRequest
): ResponseEntity<PaymentResponse> {
val response = tosspayService.cancel(paymentKey, request)
return ResponseEntity.ok(response)
}
}

View File

@ -0,0 +1,15 @@
package com.sangdol.tosspaymock.web.dto
import java.time.OffsetDateTime
data class PaymentConfirmRequest(
val paymentKey: String,
val orderId: String,
val amount: Int,
val requestedAt: OffsetDateTime = OffsetDateTime.now()
)
data class PaymentCancelRequest(
val cancelAmount: Int? = null,
val cancelReason: String
)

View File

@ -0,0 +1,91 @@
package com.sangdol.tosspaymock.web.dto
import com.sangdol.tosspaymock.business.domain.Payment
import com.sangdol.tosspaymock.business.domain.PaymentType
import com.sangdol.tosspaymock.business.domain.cancel.Cancellation
import com.sangdol.tosspaymock.business.domain.card.Card
import com.sangdol.tosspaymock.business.domain.easypay.Easypay
import com.sangdol.tosspaymock.business.domain.transfer.BankTransfer
import java.time.OffsetDateTime
data class PaymentResponse(
val mid: String,
val lastTransactionKey: String,
val paymentKey: String,
val orderId: String,
val orderName: String,
val taxExemptionAmount: Int,
var status: String,
val requestedAt: OffsetDateTime,
val approvedAt: OffsetDateTime,
val useEscrow: Boolean,
val cultureExpense: Boolean,
val card: CardResponse?,
val virtualAccount: String?,
val transfer: BankTransferResponse?,
val mobilePhone: String?,
val giftCertificate: String?,
val cashReceipt: String?,
val cashReceipts: String?,
val discount: String?,
var cancels: CancelResponse?,
val secret: String?,
val type: PaymentType,
val easyPay: EasypayResponse?,
val country: String,
val failure: String?,
val isPartialCancelable: Boolean,
val receipt: String?,
val checkout: String?,
val currency: String,
val totalAmount: Int,
val balanceAmount: Int,
val suppliedAmount: Int,
val vat: Int,
val taxFreeAmount: Int,
val method: String,
val version: String,
val metadata: String?,
)
data class CardResponse(
val issuerCode: String,
val acquirerCode: String,
val number: String,
val installmentPlanMonths: Int,
val isInterestFree: Boolean,
val interestPayer: String?,
val approveNo: String,
val useCardPoint: Boolean,
val cardType: String,
val ownerType: String,
val acquireStatus: String,
val amount: Int,
)
data class EasypayResponse(
val provider: String,
val amount: Int,
val discountAmount: Int
)
data class BankTransferResponse(
val bankCode: String,
val settlementStatus: String,
)
data class CancelResponse(
val transactionKey: String,
val cancelReason: String,
val taxExemptionAmount: Int,
val canceledAt: OffsetDateTime,
val cardDiscountAmount: Int,
val transferDiscountAmount: Int,
val easyPayDiscountAmount: Int,
val receiptKey: String?,
val cancelStatus: String,
val cancelRequestId: String?,
val cancelAmount: Int,
val taxFreeAmount: Int,
val refundableAmount: Int,
)

View File

@ -0,0 +1,39 @@
package com.sangdol.tosspaymock.web.supports
import com.sangdol.tosspaymock.exception.TosspayException
import com.sangdol.tosspaymock.exception.code.TosspayConfirmErrorCode
import jakarta.servlet.http.HttpServletRequest
import jakarta.servlet.http.HttpServletResponse
import org.springframework.http.HttpHeaders
import org.springframework.stereotype.Component
import org.springframework.web.method.HandlerMethod
import org.springframework.web.servlet.HandlerInterceptor
import java.util.*
@Component
class SecretKeyInterceptor : HandlerInterceptor {
companion object {
val basicAuthRegex = Regex("Basic (.*)")
}
override fun preHandle(
request: HttpServletRequest,
response: HttpServletResponse,
handler: Any
): Boolean {
if (handler !is HandlerMethod) return true
val basicAuthSecretKey: String = request.getHeader(HttpHeaders.AUTHORIZATION)
?: throw TosspayException(TosspayConfirmErrorCode.INVALID_API_KEY)
return try {
val secretKey = basicAuthRegex.find(basicAuthSecretKey)!!.groupValues[1]
Base64.getDecoder().decode(secretKey)
true
} catch (_: Exception) {
throw TosspayException(TosspayConfirmErrorCode.UNAUTHORIZED_KEY)
}
}
}

View File

@ -0,0 +1,15 @@
package com.sangdol.tosspaymock.web.supports
import org.springframework.context.annotation.Configuration
import org.springframework.web.servlet.config.annotation.InterceptorRegistry
import org.springframework.web.servlet.config.annotation.WebMvcConfigurer
@Configuration
class TosspayMvcConfig(
private val secretKeyInterceptor: SecretKeyInterceptor
) : WebMvcConfigurer {
override fun addInterceptors(registry: InterceptorRegistry) {
registry.addInterceptor(secretKeyInterceptor)
}
}

View File

@ -0,0 +1,41 @@
server:
tomcat:
mbeanregistry:
enabled: true
forward-headers-strategy: framework
port: 8000
spring:
application:
name: tosspay-mock
jpa:
properties:
hibernate:
format_sql: true
hibernate:
ddl-auto: validate
open-in-view: false
h2:
console:
enabled: true
path: /h2-console
datasource:
hikari:
jdbc-url: jdbc:h2:mem:database
driver-class-name: org.h2.Driver
username: sa
password:
sql:
init:
mode: always
schema-locations: classpath:schema/schema-h2.sql
management:
endpoints:
web:
exposure:
include: health,loggers,prometheus
base-path: ${TOSSPAY_ACTUATOR_PATH:/actuator}
endpoint:
health:
show-details: always

View File

@ -0,0 +1,92 @@
<?xml version="1.0" encoding="UTF-8"?>
<included>
<appender name="CONSOLE_JSON" class="ch.qos.logback.core.ConsoleAppender">
<encoder class="net.logstash.logback.encoder.LoggingEventCompositeJsonEncoder">
<providers>
<timestamp>
<fieldName>timestamp</fieldName>
<timeZone>UTC</timeZone>
</timestamp>
<logLevel>
<fieldName>level</fieldName>
</logLevel>
<loggerName>
<fieldName>logger</fieldName>
</loggerName>
<threadName>
<fieldName>thread</fieldName>
</threadName>
<mdc/>
<pattern>
<pattern>
{
"message": "%msg"
}
</pattern>
</pattern>
<stackTrace>
<fieldName>stack_trace</fieldName>
<throwableConverter class="net.logstash.logback.stacktrace.ShortenedThrowableConverter">
<maxDepthPerThrowable>5</maxDepthPerThrowable>
<maxLength>2048</maxLength>
<rootCauseFirst>true</rootCauseFirst>
</throwableConverter>
</stackTrace>
</providers>
</encoder>
</appender>
<appender name="FILE_JSON" class="ch.qos.logback.core.rolling.RollingFileAppender">
<rollingPolicy class="ch.qos.logback.core.rolling.SizeAndTimeBasedRollingPolicy">
<fileNamePattern>logs/application-%d{yyyy-MM-dd}.%i.log.gz</fileNamePattern>
<maxFileSize>100MB</maxFileSize>
<maxHistory>3</maxHistory>
<totalSizeCap>1GB</totalSizeCap>
</rollingPolicy>
<encoder class="net.logstash.logback.encoder.LoggingEventCompositeJsonEncoder">
<providers>
<timestamp>
<fieldName>timestamp</fieldName>
<timeZone>UTC</timeZone>
</timestamp>
<logLevel>
<fieldName>level</fieldName>
</logLevel>
<loggerName>
<fieldName>logger</fieldName>
</loggerName>
<threadName>
<fieldName>thread</fieldName>
</threadName>
<mdc/>
<pattern>
<pattern>
{
"message": "%msg"
}
</pattern>
</pattern>
<stackTrace>
<fieldName>stack_trace</fieldName>
<throwableConverter class="net.logstash.logback.stacktrace.ShortenedThrowableConverter">
<maxDepthPerThrowable>5</maxDepthPerThrowable>
<maxLength>2048</maxLength>
<rootCauseFirst>true</rootCauseFirst>
</throwableConverter>
</stackTrace>
</providers>
</encoder>
</appender>
<appender name="ASYNC_FILE_JSON" class="ch.qos.logback.classic.AsyncAppender">
<appender-ref ref="FILE_JSON"/>
<queueSize>512</queueSize>
<discardingThreshold>0</discardingThreshold> <includeCallerData>false</includeCallerData>
</appender>
<root level="INFO">
<appender-ref ref="CONSOLE_JSON"/>
<appender-ref ref="ASYNC_FILE_JSON"/>
</root>
</included>

View File

@ -0,0 +1,19 @@
<?xml version="1.0" encoding="UTF-8"?>
<included>
<property name="CONSOLE_LOG_PATTERN"
value="%d{yyyy-MM-dd HH:mm:ss.SSS} %highlight(%-5level) %green(${PID:- }) --- [%15.15thread] [%magenta(%X{traceId:-},%X{spanId:-})] %cyan(%-40logger{36}) : %msg%n%throwable"/>
<appender name="CONSOLE" class="ch.qos.logback.core.ConsoleAppender">
<encoder>
<pattern>${CONSOLE_LOG_PATTERN}</pattern>
</encoder>
</appender>
<root level="info">
<appender-ref ref="CONSOLE"/>
</root>
<logger name="com.sangdol.tosspaymock" level="info" additivity="false">
<appender-ref ref="CONSOLE"/>
</logger>
</included>

View File

@ -0,0 +1,14 @@
<?xml version="1.0" encoding="UTF-8"?>
<configuration scan="true">
<springProfile name="deploy">
<include resource="logback-deploy.xml"/>
</springProfile>
<springProfile name="local">
<include resource="logback-local.xml"/>
</springProfile>
<springProfile name="default">
<include resource="logback-local.xml"/>
</springProfile>
</configuration>

View File

@ -0,0 +1,10 @@
create table if not exists order_amount (
id bigint primary key,
payment_key varchar(255) not null,
approved_amount integer not null,
easypay_discount_amount integer not null,
card_discount_amount integer not null,
transfer_discount_amount integer not null,
constraint uk_order_amount__payment_key unique (payment_key)
);

View File

@ -0,0 +1,188 @@
package com.sangdol.tosspaymock
import com.sangdol.common.persistence.IDGenerator
import com.sangdol.tosspaymock.exception.code.TosspayCancelErrorCode
import com.sangdol.tosspaymock.exception.code.TosspayConfirmErrorCode
import com.sangdol.tosspaymock.infrastructure.persistence.OrderAmountEntity
import com.sangdol.tosspaymock.infrastructure.persistence.OrderAmountRepository
import com.sangdol.tosspaymock.web.dto.PaymentCancelRequest
import com.sangdol.tosspaymock.web.dto.PaymentConfirmRequest
import io.kotest.core.spec.style.FunSpec
import io.kotest.matchers.nulls.shouldNotBeNull
import io.kotest.matchers.shouldBe
import io.restassured.RestAssured
import io.restassured.module.kotlin.extensions.Extract
import io.restassured.module.kotlin.extensions.Given
import io.restassured.module.kotlin.extensions.Then
import io.restassured.module.kotlin.extensions.When
import org.hamcrest.CoreMatchers
import org.springframework.boot.test.context.SpringBootTest
import org.springframework.boot.test.web.server.LocalServerPort
import org.springframework.http.HttpStatus
import org.springframework.http.MediaType
@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT)
class TosspayApiTest(
@LocalServerPort private val port: Int,
private val orderAmountRepository: OrderAmountRepository,
private val idGenerator: IDGenerator
) : FunSpec({
beforeSpec {
RestAssured.port = port
}
afterTest {
orderAmountRepository.deleteAll()
}
val authorizationKey = "dGVzdF9nc2tfZG9jc19PYVB6OEw1S2RtUVhrelJ6M3k0N0JNdzY6"
val paymentConfirmRequest = PaymentConfirmRequest(
paymentKey = "5EnNZRJGvaBX7zk2yd8ydw26XvwXkLrx9POLqKQjmAw4b0e1",
orderId = "MC4wODU4ODQwMzg4NDk0",
amount = 100_000,
)
val paymentCancelRequest = PaymentCancelRequest(
cancelAmount = paymentConfirmRequest.amount,
cancelReason = "그냥.."
)
context("결제 승인") {
val paymentConfirmRequest = PaymentConfirmRequest(
paymentKey = "5EnNZRJGvaBX7zk2yd8ydw26XvwXkLrx9POLqKQjmAw4b0e1",
orderId = "MC4wODU4ODQwMzg4NDk0",
amount = 100_000,
)
test("Basic Authorization 헤더가 없으면 실패한다.") {
Given {
contentType(MediaType.APPLICATION_JSON_VALUE)
body(paymentConfirmRequest)
} When {
post("/v1/payments/confirm")
} Then {
statusCode(HttpStatus.BAD_REQUEST.value())
body("code", CoreMatchers.equalTo(TosspayConfirmErrorCode.INVALID_API_KEY.name))
}
}
test("Basic Authorization 헤더가 Base64 형식이 아니면 실패한다.") {
Given {
contentType(MediaType.APPLICATION_JSON_VALUE)
header("Authorization", "Basic hello-world")
body(paymentConfirmRequest)
} When {
post("/v1/payments/confirm")
} Then {
statusCode(HttpStatus.UNAUTHORIZED.value())
body("code", CoreMatchers.equalTo(TosspayConfirmErrorCode.UNAUTHORIZED_KEY.name))
}
}
test("임의의 결제 정보를 반환하며, 금액은 별도로 저장한다.") {
val paymentKey = Given {
contentType(MediaType.APPLICATION_JSON_VALUE)
header("Authorization", "Basic $authorizationKey")
body(paymentConfirmRequest)
} When {
post("/v1/payments/confirm")
} Then {
statusCode(HttpStatus.OK.value())
} Extract {
path<String>("paymentKey")
}
paymentKey shouldBe paymentConfirmRequest.paymentKey
orderAmountRepository.findByPaymentKey(paymentKey).shouldNotBeNull()
}
}
context("결제 취소") {
test("Basic Authorization 헤더가 없으면 실패한다.") {
Given {
contentType(MediaType.APPLICATION_JSON_VALUE)
body(paymentCancelRequest)
} When {
post("/v1/payments/${paymentConfirmRequest.paymentKey}/cancel")
} Then {
statusCode(HttpStatus.BAD_REQUEST.value())
body("code", CoreMatchers.equalTo(TosspayConfirmErrorCode.INVALID_API_KEY.name))
}
}
test("Basic Authorization 헤더가 Base64 형식이 아니면 실패한다.") {
Given {
contentType(MediaType.APPLICATION_JSON_VALUE)
header("Authorization", "Basic hello-world")
body(paymentCancelRequest)
} When {
post("/v1/payments/${paymentConfirmRequest.paymentKey}/cancel")
} Then {
statusCode(HttpStatus.UNAUTHORIZED.value())
body("code", CoreMatchers.equalTo(TosspayConfirmErrorCode.UNAUTHORIZED_KEY.name))
}
}
context("정상 응답") {
lateinit var orderAmount: OrderAmountEntity
beforeTest {
orderAmount = OrderAmountEntity(
id = idGenerator.create(),
paymentKey = paymentConfirmRequest.paymentKey,
approvedAmount = (paymentConfirmRequest.amount - 1000),
easypayDiscountAmount = 1000,
cardDiscountAmount = 0,
transferDiscountAmount = 0
).also {
orderAmountRepository.saveAndFlush(it)
}
}
test("요청에 cancelAmount를 포함하지 않으면 전체 금액을 취소한다.") {
Given {
contentType(MediaType.APPLICATION_JSON_VALUE)
header("Authorization", "Basic $authorizationKey")
body(PaymentCancelRequest(cancelReason = "그냥!"))
} When {
post("/v1/payments/${paymentConfirmRequest.paymentKey}/cancel")
} Then {
statusCode(HttpStatus.OK.value())
body("paymentKey", CoreMatchers.equalTo(paymentConfirmRequest.paymentKey))
body("cancels.easyPayDiscountAmount", CoreMatchers.equalTo(orderAmount.easypayDiscountAmount))
body("cancels.cancelAmount", CoreMatchers.equalTo(orderAmount.totalAmount()))
}
}
test("이전 결제 정보의 할인 금액을 가져온 뒤 반환한다.") {
Given {
contentType(MediaType.APPLICATION_JSON_VALUE)
header("Authorization", "Basic $authorizationKey")
body(paymentCancelRequest)
} When {
post("/v1/payments/${paymentConfirmRequest.paymentKey}/cancel")
} Then {
statusCode(HttpStatus.OK.value())
body("paymentKey", CoreMatchers.equalTo(paymentConfirmRequest.paymentKey))
body("cancels.easyPayDiscountAmount", CoreMatchers.equalTo(orderAmount.easypayDiscountAmount))
body("cancels.cancelAmount", CoreMatchers.equalTo(paymentCancelRequest.cancelAmount))
}
}
}
test("이전 결제 정보가 없으면 실패한다.") {
Given {
contentType(MediaType.APPLICATION_JSON_VALUE)
header("Authorization", "Basic $authorizationKey")
body(paymentCancelRequest)
} When {
post("/v1/payments/notExistPaymentKey/cancel")
} Then {
statusCode(HttpStatus.NOT_FOUND.value())
body("code", CoreMatchers.equalTo(TosspayCancelErrorCode.NOT_FOUND_PAYMENT.name))
}
}
}
})

View File

@ -0,0 +1,41 @@
package com.sangdol.tosspaymock.parser.origin
import com.sangdol.common.types.web.HttpStatus
import io.kotest.core.spec.style.StringSpec
import java.io.File
class TosspayErrorCodeParser: StringSpec() {
init {
"Tosspay API 문서에 있는 결제 승인 에러 코드 항목을 enum의 형태로 변환한다." {
val basePath = "${File("").absolutePath}/src/test/resources"
listOf("confirm", "cancel").forEach {
val result = StringBuilder()
val lines = File("${basePath}/tosspay_${it}_errorcode.txt")
.readLines()
lines.forEachIndexed { idx, line ->
val suffix = if (idx < (lines.size - 1)) ",\n" else ";"
result.append(parseLine(idx + 1, line)).append(suffix)
}
File("${basePath}/tosspay_${it}_errorcode_parsed.txt").writeText(result.toString())
}
}
}
private fun parseLine(idx: Int, text: String): String {
val regex = Regex("^(\\d+)\\t([A-Z_]+)\\t(.+[).])\\t([`A-Za-z].*)")
return regex.replace(text) { matchResult ->
val name = matchResult.groupValues[2]
val httpStatus = "HttpStatus.${HttpStatus.entries.first {it.code == matchResult.groupValues[1].trim().toInt()}.name}"
val errorCode = "CO${(idx).toString().padStart(3, '0')}"
val koreanMessage = matchResult.groupValues[3].split("\t")[0]
"$name($httpStatus, \"$errorCode\", \"$koreanMessage\")"
}
}
}

View File

@ -0,0 +1,29 @@
400 ALREADY_CANCELED_PAYMENT 이미 취소된 결제 입니다. The payment has already been canceled.
400 INVALID_REFUND_ACCOUNT_INFO 환불 계좌번호와 예금주명이 일치하지 않습니다. `accountNumber` and holderName is not matched.
400 EXCEED_CANCEL_AMOUNT_DISCOUNT_AMOUNT 즉시할인금액보다 적은 금액은 부분취소가 불가능합니다. The cancel amount cannot exceed discount amount
400 INVALID_REQUEST 잘못된 요청입니다. The bad request.
400 INVALID_REFUND_ACCOUNT_NUMBER 잘못된 환불 계좌번호입니다. Incorrect `accountNumber`
400 INVALID_BANK 유효하지 않은 은행입니다. It is an Invalid bank.
400 NOT_MATCHES_REFUNDABLE_AMOUNT 잔액 결과가 일치하지 않습니다. Balance results do not match.
400 PROVIDER_ERROR 일시적인 오류가 발생했습니다. 잠시 후 다시 시도해주세요. This is temporary error. Please try again in a few minutes.
400 REFUND_REJECTED 환불이 거절됐습니다. 결제사에 문의 부탁드립니다. The refund has been rejected. Please contact the respective payment provider.
400 ALREADY_REFUND_PAYMENT 이미 환불된 결제입니다. The payment has been refunded.
400 FORBIDDEN_BANK_REFUND_REQUEST 고객 계좌가 입금이 되지 않는 상태입니다. The given bank account does not allow deposits.
401 UNAUTHORIZED_KEY 인증되지 않은 시크릿 키 혹은 클라이언트 키 입니다. Unauthorized secretKey or clientKey.
403 NOT_CANCELABLE_AMOUNT 취소 할 수 없는 금액 입니다. This is a non-cancelable amount.
403 FORBIDDEN_CONSECUTIVE_REQUEST 반복적인 요청은 허용되지 않습니다. 잠시 후 다시 시도해주세요. Repetitive requests are not allowed. Please try again in a few minutes.
403 FORBIDDEN_REQUEST 허용되지 않은 요청입니다. Not allowed request
403 NOT_CANCELABLE_PAYMENT 취소 할 수 없는 결제 입니다. This is a non-cancelable payment.
403 EXCEED_MAX_REFUND_DUE 환불 가능한 기간이 지났습니다. Refundable date has passed.
403 NOT_ALLOWED_PARTIAL_REFUND_WAITING_DEPOSIT 입금 대기중인 결제는 부분 환불이 불가합니다. Partial refund is not available while pending deposit.
403 NOT_ALLOWED_PARTIAL_REFUND 에스크로 주문, 현금 카드 결제일 때는 부분 환불이 불가합니다. 이외 다른 결제 수단에서 부분 취소가 되지 않을 때는 토스페이먼츠에 문의해 주세요. Escrow orders or debit card payments cannot be partially canceled. If you are unable to partially cancel your order with any other payment method, please contact Tosspayments.
403 NOT_AVAILABLE_BANK 은행 서비스 시간이 아닙니다. It's not banking hour.
403 INCORRECT_BASIC_AUTH_FORMAT 잘못된 요청입니다. ':' 를 포함해 인코딩해주세요. Invalid request. Please encode including the ':' character.
403 NOT_CANCELABLE_PAYMENT_FOR_DORMANT_USER 휴면 처리된 회원의 결제는 취소할 수 없습니다. This is a non-cancelable payment. It is a dormant user account.
404 NOT_FOUND_PAYMENT 존재하지 않는 결제 정보 입니다. Not found payment
500 FAILED_INTERNAL_SYSTEM_PROCESSING 내부 시스템 처리 작업이 실패했습니다. 잠시 후 다시 시도해주세요. Internal system processing operation has failed. Please try again in a few minutes.
500 FAILED_REFUND_PROCESS 은행 응답시간 지연이나 일시적인 오류로 환불요청에 실패했습니다. The refund request failed due to a delay in the bank response time or a temporary error.
500 FAILED_METHOD_HANDLING_CANCEL 취소 중 결제 시 사용한 결제 수단 처리과정에서 일시적인 오류가 발생했습니다. A temporary error occurred while processing cancellation.
500 FAILED_PARTIAL_REFUND 은행 점검, 해약 계좌 등의 사유로 부분 환불이 실패했습니다. Partial refund failed due to bank check, account canceled, etc.
500 COMMON_ERROR 일시적인 오류가 발생했습니다. 잠시 후 다시 시도해주세요. This is temporary error. Please try again in a few minutes.
500 FAILED_PAYMENT_INTERNAL_SYSTEM_PROCESSING 결제가 완료되지 않았어요. 다시 시도해주세요. Payment has not been completed. please try again.

View File

@ -0,0 +1,47 @@
400 ALREADY_PROCESSED_PAYMENT 이미 처리된 결제 입니다. This is a payment that has already been processed.
400 PROVIDER_ERROR 일시적인 오류가 발생했습니다. 잠시 후 다시 시도해주세요. This is temporary error. Please try again in a few minutes.
400 EXCEED_MAX_CARD_INSTALLMENT_PLAN 설정 가능한 최대 할부 개월 수를 초과했습니다. Maximum number of installment months exceeded. (`installmentPlanMonths`)
400 INVALID_REQUEST 잘못된 요청입니다. The bad request.
400 NOT_ALLOWED_POINT_USE 포인트 사용이 불가한 카드로 카드 포인트 결제에 실패했습니다. Card point payment failed because the card cannot be used points.
400 INVALID_API_KEY 잘못된 시크릿키 연동 정보 입니다. Incorrect secret key.
400 INVALID_REJECT_CARD 카드 사용이 거절되었습니다. 카드사 문의가 필요합니다. Refer to card issuer/decline.
400 BELOW_MINIMUM_AMOUNT 신용카드는 결제금액이 100원 이상, 계좌는 200원이상부터 결제가 가능합니다. Payment can be made from 100 won or more by credit card, and 200 won or more for account.
400 INVALID_CARD_EXPIRATION 카드 정보를 다시 확인해주세요. (유효기간) Please check the card expiration date information again.
400 INVALID_STOPPED_CARD 정지된 카드 입니다. This is a suspended card.
400 EXCEED_MAX_DAILY_PAYMENT_COUNT 하루 결제 가능 횟수를 초과했습니다. You have exceeded the number of daily payments.
400 NOT_SUPPORTED_INSTALLMENT_PLAN_CARD_OR_MERCHANT 할부가 지원되지 않는 카드 또는 가맹점 입니다. This card or merchant does not support installment.
400 INVALID_CARD_INSTALLMENT_PLAN 할부 개월 정보가 잘못되었습니다. The installment month information is incorrect.
400 NOT_SUPPORTED_MONTHLY_INSTALLMENT_PLAN 할부가 지원되지 않는 카드입니다. This card does not support installment.
400 EXCEED_MAX_PAYMENT_AMOUNT 하루 결제 가능 금액을 초과했습니다. You have exceeded the amount you can pay per day.
400 NOT_FOUND_TERMINAL_ID 단말기번호(Terminal Id)가 없습니다. 토스페이먼츠로 문의 바랍니다. There is no Terminal Id. Please contact Toss Payments.
400 INVALID_AUTHORIZE_AUTH 유효하지 않은 인증 방식입니다. Invalid authentication
400 INVALID_CARD_LOST_OR_STOLEN 분실 혹은 도난 카드입니다. This is a lost or stolen card
400 RESTRICTED_TRANSFER_ACCOUNT 계좌는 등록 후 12시간 뒤부터 결제할 수 있습니다. 관련 정책은 해당 은행으로 문의해주세요. You can withdraw from this bank account after 12 hours from initial register. For related policies, please contact your bank.
400 INVALID_CARD_NUMBER 카드번호를 다시 확인해주세요. Please check your card number again.
400 INVALID_UNREGISTERED_SUBMALL 등록되지 않은 서브몰입니다. 서브몰이 없는 가맹점이라면 안심클릭이나 ISP 결제가 필요합니다. Not registered PG sub-mall business number.
400 NOT_REGISTERED_BUSINESS 등록되지 않은 사업자 번호입니다. Unregistered business registration number
400 EXCEED_MAX_ONE_DAY_WITHDRAW_AMOUNT 1일 출금 한도를 초과했습니다. You have exceeded the one-day withdrawal limit.
400 EXCEED_MAX_ONE_TIME_WITHDRAW_AMOUNT 1회 출금 한도를 초과했습니다. You have exceeded the one-time withdrawal limit.
400 CARD_PROCESSING_ERROR 카드사에서 오류가 발생했습니다. The card company was not able to process the request.
400 EXCEED_MAX_AMOUNT 거래금액 한도를 초과했습니다. The transaction amount limit has been exceeded.
400 INVALID_ACCOUNT_INFO_RE_REGISTER 유효하지 않은 계좌입니다. 계좌 재등록 후 시도해주세요. Invalid account. Please re-register the account and try again.
400 NOT_AVAILABLE_PAYMENT 결제가 불가능한 시간대입니다. Payment is unavailable at this time.
400 UNAPPROVED_ORDER_ID 아직 승인되지 않은 주문번호입니다. This order id has not been approved for payment.
400 EXCEED_MAX_MONTHLY_PAYMENT_AMOUNT 당월 결제 가능금액인 1,000,000원을 초과 하셨습니다. You have exceeded the allowed monthly payment amount of 1,000,000 KRW.
401 UNAUTHORIZED_KEY 인증되지 않은 시크릿 키 혹은 클라이언트 키 입니다. Unauthorized secretKey or clientKey.
403 REJECT_ACCOUNT_PAYMENT 잔액부족으로 결제에 실패했습니다. Payment declined due to insufficient balance.
403 REJECT_CARD_PAYMENT 한도초과 혹은 잔액부족으로 결제에 실패했습니다. Payment failed due to limit exceeded or insufficient balance.
403 REJECT_CARD_COMPANY 결제 승인이 거절되었습니다. Payment confirm is rejected
403 FORBIDDEN_REQUEST 허용되지 않은 요청입니다. Not allowed request
403 REJECT_TOSSPAY_INVALID_ACCOUNT 선택하신 출금 계좌가 출금이체 등록이 되어 있지 않아요. 계좌를 다시 등록해 주세요. Your account invalidated. Please register another account.
403 EXCEED_MAX_AUTH_COUNT 최대 인증 횟수를 초과했습니다. 카드사로 문의해주세요. Maximum authentication attempts exceeded.
403 EXCEED_MAX_ONE_DAY_AMOUNT 일일 한도를 초과했습니다. You have exceeded your daily limit.
403 NOT_AVAILABLE_BANK 은행 서비스 시간이 아닙니다. It's not banking hour.
403 INVALID_PASSWORD 결제 비밀번호가 일치하지 않습니다. Incorrect password
403 INCORRECT_BASIC_AUTH_FORMAT 잘못된 요청입니다. ':' 를 포함해 인코딩해주세요. Invalid request. Please encode including the ':' character.
403 FDS_ERROR [토스페이먼츠] 위험거래가 감지되어 결제가 제한됩니다. 발송된 문자에 포함된 링크를 통해 본인인증 후 결제가 가능합니다. (고객센터: 1644-8051) A fraudulent transaction has been detected. To complete the payment, complete the authentication process through the link included in the text message. (Customer Service: 1644-8051)
404 NOT_FOUND_PAYMENT 존재하지 않는 결제 정보 입니다. Not found payment
404 NOT_FOUND_PAYMENT_SESSION 결제 시간이 만료되어 결제 진행 데이터가 존재하지 않습니다. Payment session does not exist because the session time has expired.
500 FAILED_PAYMENT_INTERNAL_SYSTEM_PROCESSING 결제가 완료되지 않았어요. 다시 시도해주세요. Payment has not been completed. please try again.
500 FAILED_INTERNAL_SYSTEM_PROCESSING 내부 시스템 처리 작업이 실패했습니다. 잠시 후 다시 시도해주세요. Internal system processing operation has failed. Please try again in a few minutes.
500 UNKNOWN_PAYMENT_ERROR 결제에 실패했어요. 같은 문제가 반복된다면 은행이나 카드사로 문의해주세요. Payment failed. If the same problem occurs, please contact your bank or credit card company.