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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
42 changes: 42 additions & 0 deletions apps/pg-simulator/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
## PG-Simulator (PaymentGateway)

### Description
Loopback BE 과정을 위해 PaymentGateway 를 시뮬레이션하는 App Module 입니다.
`local` 프로필로 실행 권장하며, 커머스 서비스와의 동시 실행을 위해 서버 포트가 조정되어 있습니다.
- server port : 8082
- actuator port : 8083

### Getting Started
부트 서버를 아래 명령어 혹은 `intelliJ` 통해 실행해주세요.
```shell
./gradlew :apps:pg-simulator:bootRun
```

API 는 아래와 같이 주어지니, 커머스 서비스와 동시에 실행시킨 후 진행해주시면 됩니다.
- 결제 요청 API
- 결제 정보 확인 `by transactionKey`
- 결제 정보 목록 조회 `by orderId`

```http request
### 결제 요청
POST {{pg-simulator}}/api/v1/payments
X-USER-ID: 135135
Content-Type: application/json
{
"orderId": "1351039135",
"cardType": "SAMSUNG",
"cardNo": "1234-5678-9814-1451",
"amount" : "5000",
"callbackUrl": "http://localhost:8080/api/v1/examples/callback"
}
### 결제 정보 확인
GET {{pg-simulator}}/api/v1/payments/20250816:TR:9577c5
X-USER-ID: 135135
### 주문에 엮인 결제 정보 조회
GET {{pg-simulator}}/api/v1/payments?orderId=1351039135
X-USER-ID: 135135
```
40 changes: 40 additions & 0 deletions apps/pg-simulator/build.gradle.kts
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
plugins {
val kotlinVersion = "2.0.20"

id("org.jetbrains.kotlin.jvm") version(kotlinVersion)
id("org.jetbrains.kotlin.kapt") version(kotlinVersion)
id("org.jetbrains.kotlin.plugin.spring") version(kotlinVersion)
id("org.jetbrains.kotlin.plugin.jpa") version(kotlinVersion)
}

kotlin {
compilerOptions {
jvmToolchain(21)
freeCompilerArgs.addAll("-Xjsr305=strict")
}
}

dependencies {
// add-ons
implementation(project(":modules:jpa"))
implementation(project(":modules:redis"))
implementation(project(":supports:jackson"))
implementation(project(":supports:logging"))
implementation(project(":supports:monitoring"))

// kotlin
implementation("org.jetbrains.kotlin:kotlin-reflect")
implementation("org.jetbrains.kotlin:kotlin-stdlib-jdk8")

// web
implementation("org.springframework.boot:spring-boot-starter-web")
implementation("org.springframework.boot:spring-boot-starter-actuator")
implementation("org.springdoc:springdoc-openapi-starter-webmvc-ui:${project.properties["springDocOpenApiVersion"]}")

// querydsl
kapt("com.querydsl:querydsl-apt::jakarta")

// test-fixtures
testImplementation(testFixtures(project(":modules:jpa")))
testImplementation(testFixtures(project(":modules:redis")))
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
package com.loopers

import jakarta.annotation.PostConstruct
import org.springframework.boot.autoconfigure.SpringBootApplication
import org.springframework.boot.context.properties.ConfigurationPropertiesScan
import org.springframework.boot.runApplication
import org.springframework.scheduling.annotation.EnableAsync
import java.util.TimeZone

@ConfigurationPropertiesScan
@EnableAsync
@SpringBootApplication
class PaymentGatewayApplication {

@PostConstruct
fun started() {
// set timezone
TimeZone.setDefault(TimeZone.getTimeZone("Asia/Seoul"))
}
}

fun main(args: Array<String>) {
runApplication<PaymentGatewayApplication>(*args)
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
package com.loopers.application.payment

/**
* 결제 주문 정보
*
* 결제는 주문에 대한 다수 트랜잭션으로 구성됩니다.
*
* @property orderId 주문 정보
* @property transactions 주문에 엮인 트랜잭션 목록
*/
data class OrderInfo(
val orderId: String,
val transactions: List<TransactionInfo>,
)
Original file line number Diff line number Diff line change
@@ -0,0 +1,88 @@
package com.loopers.application.payment

import com.loopers.domain.payment.Payment
import com.loopers.domain.payment.PaymentEvent
import com.loopers.domain.payment.PaymentEventPublisher
import com.loopers.domain.payment.PaymentRelay
import com.loopers.domain.payment.PaymentRepository
import com.loopers.domain.payment.TransactionKeyGenerator
import com.loopers.domain.user.UserInfo
import com.loopers.support.error.CoreException
import com.loopers.support.error.ErrorType
import org.springframework.stereotype.Component
import org.springframework.transaction.annotation.Transactional

@Component
class PaymentApplicationService(
private val paymentRepository: PaymentRepository,
private val paymentEventPublisher: PaymentEventPublisher,
private val paymentRelay: PaymentRelay,
private val transactionKeyGenerator: TransactionKeyGenerator,
) {
companion object {
private val RATE_LIMIT_EXCEEDED = (1..20)
private val RATE_INVALID_CARD = (21..30)
}

@Transactional
fun createTransaction(command: PaymentCommand.CreateTransaction): TransactionInfo {
command.validate()

val transactionKey = transactionKeyGenerator.generate()
val payment = paymentRepository.save(
Payment(
transactionKey = transactionKey,
userId = command.userId,
orderId = command.orderId,
cardType = command.cardType,
cardNo = command.cardNo,
amount = command.amount,
callbackUrl = command.callbackUrl,
),
)

paymentEventPublisher.publish(PaymentEvent.PaymentCreated.from(payment = payment))

return TransactionInfo.from(payment)
}

@Transactional(readOnly = true)
fun getTransactionDetailInfo(userInfo: UserInfo, transactionKey: String): TransactionInfo {
val payment = paymentRepository.findByTransactionKey(userId = userInfo.userId, transactionKey = transactionKey)
?: throw CoreException(ErrorType.NOT_FOUND, "(transactionKey: $transactionKey) 결제건이 존재하지 않습니다.")
return TransactionInfo.from(payment)
}

@Transactional(readOnly = true)
fun findTransactionsByOrderId(userInfo: UserInfo, orderId: String): OrderInfo {
val payments = paymentRepository.findByOrderId(userId = userInfo.userId, orderId = orderId)
if (payments.isEmpty()) {
throw CoreException(ErrorType.NOT_FOUND, "(orderId: $orderId) 에 해당하는 결제건이 존재하지 않습니다.")
}

return OrderInfo(
orderId = orderId,
transactions = payments.map { TransactionInfo.from(it) },
)
}

@Transactional
fun handle(transactionKey: String) {
val payment = paymentRepository.findByTransactionKey(transactionKey)
?: throw CoreException(ErrorType.NOT_FOUND, "(transactionKey: $transactionKey) 결제건이 존재하지 않습니다.")

val rate = (1..100).random()
when (rate) {
in RATE_LIMIT_EXCEEDED -> payment.limitExceeded()
in RATE_INVALID_CARD -> payment.invalidCard()
else -> payment.approve()
}
paymentEventPublisher.publish(event = PaymentEvent.PaymentHandled.from(payment))
}

fun notifyTransactionResult(transactionKey: String) {
val payment = paymentRepository.findByTransactionKey(transactionKey)
?: throw CoreException(ErrorType.NOT_FOUND, "(transactionKey: $transactionKey) 결제건이 존재하지 않습니다.")
paymentRelay.notify(callbackUrl = payment.callbackUrl, transactionInfo = TransactionInfo.from(payment))
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
package com.loopers.application.payment

import com.loopers.domain.payment.CardType
import com.loopers.support.error.CoreException
import com.loopers.support.error.ErrorType

object PaymentCommand {
data class CreateTransaction(
val userId: String,
val orderId: String,
val cardType: CardType,
val cardNo: String,
val amount: Long,
val callbackUrl: String,
) {
fun validate() {
if (amount <= 0L) {
throw CoreException(ErrorType.BAD_REQUEST, "요청 금액은 0 보다 큰 정수여야 합니다.")
}
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
package com.loopers.application.payment

import com.loopers.domain.payment.CardType
import com.loopers.domain.payment.Payment
import com.loopers.domain.payment.TransactionStatus

/**
* 트랜잭션 정보
*
* @property transactionKey 트랜잭션 KEY
* @property orderId 주문 ID
* @property cardType 카드 종류
* @property cardNo 카드 번호
* @property amount 금액
* @property status 처리 상태
* @property reason 처리 사유
*/
data class TransactionInfo(
val transactionKey: String,
val orderId: String,
val cardType: CardType,
val cardNo: String,
val amount: Long,
val status: TransactionStatus,
val reason: String?,
) {
companion object {
fun from(payment: Payment): TransactionInfo =
TransactionInfo(
transactionKey = payment.transactionKey,
orderId = payment.orderId,
cardType = payment.cardType,
cardNo = payment.cardNo,
amount = payment.amount,
status = payment.status,
reason = payment.reason,
)
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
package com.loopers.config.web

import com.loopers.interfaces.api.argumentresolver.UserInfoArgumentResolver
import org.springframework.context.annotation.Configuration
import org.springframework.web.method.support.HandlerMethodArgumentResolver
import org.springframework.web.servlet.config.annotation.WebMvcConfigurer

@Configuration
class WebMvcConfig : WebMvcConfigurer {
override fun addArgumentResolvers(resolvers: MutableList<HandlerMethodArgumentResolver?>) {
resolvers.add(UserInfoArgumentResolver())
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
package com.loopers.domain.payment

enum class CardType {
SAMSUNG,
KB,
HYUNDAI,
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,87 @@
package com.loopers.domain.payment

import com.loopers.support.error.CoreException
import com.loopers.support.error.ErrorType
import jakarta.persistence.Column
import jakarta.persistence.Entity
import jakarta.persistence.EnumType
import jakarta.persistence.Enumerated
import jakarta.persistence.Id
import jakarta.persistence.Index
import jakarta.persistence.Table
import java.time.LocalDateTime

@Entity
@Table(
name = "payments",
indexes = [
Index(name = "idx_user_transaction", columnList = "user_id, transaction_key"),
Index(name = "idx_user_order", columnList = "user_id, order_id"),
Index(name = "idx_unique_user_order_transaction", columnList = "user_id, order_id, transaction_key", unique = true),
]
)
class Payment(
@Id
@Column(name = "transaction_key", nullable = false, unique = true)
val transactionKey: String,

@Column(name = "user_id", nullable = false)
val userId: String,

@Column(name = "order_id", nullable = false)
val orderId: String,

@Enumerated(EnumType.STRING)
@Column(name = "card_type", nullable = false)
val cardType: CardType,

@Column(name = "card_no", nullable = false)
val cardNo: String,

@Column(name = "amount", nullable = false)
val amount: Long,

@Column(name = "callback_url", nullable = false)
val callbackUrl: String,
) {
@Enumerated(EnumType.STRING)
@Column(name = "status", nullable = false)
var status: TransactionStatus = TransactionStatus.PENDING
private set

@Column(name = "reason", nullable = true)
var reason: String? = null
private set

@Column(name = "created_at", nullable = false)
var createdAt: LocalDateTime = LocalDateTime.now()
private set

@Column(name = "updated_at", nullable = false)
var updatedAt: LocalDateTime = LocalDateTime.now()
private set

fun approve() {
if (status != TransactionStatus.PENDING) {
throw CoreException(ErrorType.INTERNAL_ERROR, "결제승인은 대기상태에서만 가능합니다.")
}
status = TransactionStatus.SUCCESS
reason = "정상 승인되었습니다."
}

fun invalidCard() {
if (status != TransactionStatus.PENDING) {
throw CoreException(ErrorType.INTERNAL_ERROR, "결제처리는 대기상태에서만 가능합니다.")
}
status = TransactionStatus.FAILED
reason = "잘못된 카드입니다. 다른 카드를 선택해주세요."
}

fun limitExceeded() {
if (status != TransactionStatus.PENDING) {
throw CoreException(ErrorType.INTERNAL_ERROR, "한도초과 처리는 대기상태에서만 가능합니다.")
}
status = TransactionStatus.FAILED
reason = "한도초과입니다. 다른 카드를 선택해주세요."
}
}
Loading