Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
28 commits
Select commit Hold shift + click to select a range
966f567
feat: Payment 도메인 엔티티 추가
youngw0130 Nov 6, 2025
4246069
feat: Verification 도메인 엔티티 추가
youngw0130 Nov 6, 2025
b531693
feat: Payment 서비스 레이어 (DTO, Port)
youngw0130 Nov 6, 2025
89a61c4
feat: Payment 서비스 구현 (ApplicationService)
youngw0130 Nov 6, 2025
9cc9cdf
feat: Verification 서비스 레이어 (DTO, Port)
youngw0130 Nov 6, 2025
5535df3
feat: Verification 서비스 구현 (ApplicationService)
youngw0130 Nov 6, 2025
a916ed4
feat: Repository 레이어 구현 (JPA Repository, Adapter)
youngw0130 Nov 6, 2025
23c90fd
feat: Infrastructure 구현 (PortOne Adapter, WebClient)
youngw0130 Nov 6, 2025
e2530c1
feat: API 컨트롤러 구현
youngw0130 Nov 6, 2025
d529536
feat: 예외 처리 추가
youngw0130 Nov 6, 2025
a7cac67
refactor: Product 서비스 수정
youngw0130 Nov 6, 2025
b1167d3
feat: 설정 파일 업데이트
youngw0130 Nov 6, 2025
3d98611
fix: main 브랜치와의 충돌 해결
youngw0130 Nov 6, 2025
5128ece
refactor: Payment Exception을 application 레이어로 이동
youngw0130 Nov 13, 2025
9d7497b
refactor: Repository 계층 분리
youngw0130 Nov 13, 2025
805b329
refactor: Payment 서비스에 CQRS 패턴 적용
youngw0130 Nov 13, 2025
99e4437
refactor: PortOne 외부 API 연동을 @FeignClient로 변경
youngw0130 Nov 13, 2025
851f18a
refactor: PaymentController에서 ResponseEntity 제거
youngw0130 Nov 13, 2025
f287ad0
feat: Spring Cloud OpenFeign 의존성 추가
youngw0130 Nov 13, 2025
df8555f
refactor: Repository import 경로 수정 및 코드 품질 개선
youngw0130 Nov 13, 2025
2cbd16c
merge: main 브랜치의 리팩토링 변경사항 병합
youngw0130 Nov 13, 2025
303b100
refactor: CQRS 리팩토링 후 불필요한 파일 제거 및 코드 정리
youngw0130 Nov 13, 2025
f68a9c7
refactor: 환경 변수 설정 개선 및 FeignClient 설정 정리
youngw0130 Nov 13, 2025
5736da6
refactor: PortOne 결제 모듈 코드 구조 개선
youngw0130 Nov 18, 2025
61e73f6
refactor:conflicts 해결
youngw0130 Nov 20, 2025
04a121b
Merge branch 'main' into toss
youngw0130 Nov 20, 2025
157ad12
refactor: Payment 도메인 구조 개선
youngw0130 Dec 2, 2025
e56cc24
refactor: Payment 도메인 아키텍처 재설계
youngw0130 Dec 2, 2025
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
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,7 @@ out/

### VS Code ###
.vscode/
.env

### Kotlin ###
.kotlin
Expand Down
12 changes: 12 additions & 0 deletions build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,12 @@ plugins {
}
val springCloudVersion by extra("2025.0.0")

dependencyManagement {
imports {
mavenBom("org.springframework.cloud:spring-cloud-dependencies:2024.0.0")
}
}

group = "bssm.devcoop"
version = "0.0.1-SNAPSHOT"
description = "occount-stock"
Expand Down Expand Up @@ -66,6 +72,12 @@ dependencies {

// VALIDATION
implementation("org.springframework.boot:spring-boot-starter-validation")

// FEIGN CLIENT
implementation("org.springframework.cloud:spring-cloud-starter-openfeign")

// JSON
implementation("com.fasterxml.jackson.module:jackson-module-kotlin")

// TEST
testRuntimeOnly("org.junit.platform:junit-platform-launcher")
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,199 @@
package bssm.devcoop.occount.application.payment.command

import bssm.devcoop.occount.application.payment.command.port.`in`.PaymentCommandPort
import bssm.devcoop.occount.application.payment.dto.PaymentConfirmResponse
import bssm.devcoop.occount.application.payment.dto.RefundRequest
import bssm.devcoop.occount.application.payment.dto.RefundResponse
import bssm.devcoop.occount.application.payment.dto.WebhookResult
import bssm.devcoop.occount.application.payment.exception.*
import bssm.devcoop.occount.domain.payment.repository.PaymentRepositoryPort
import bssm.devcoop.occount.application.payment.port.out.PortOnePort
import bssm.devcoop.occount.domain.payment.PaymentStatus
import bssm.devcoop.occount.infrastructure.adapter.out.portone.WebhookVerificationService
import com.fasterxml.jackson.databind.ObjectMapper
import lombok.RequiredArgsConstructor
import org.slf4j.LoggerFactory
import org.springframework.stereotype.Service
import org.springframework.transaction.annotation.Transactional

@Service
@RequiredArgsConstructor
class PaymentCommandService(
private val portOnePort: PortOnePort,
private val paymentRepository: PaymentRepositoryPort,
private val webhookVerifier: WebhookVerificationService,
private val objectMapper: ObjectMapper
) : PaymentCommandPort {

private val log = LoggerFactory.getLogger(PaymentCommandService::class.java)

@Transactional
override fun confirmPayment(paymentId: String, userEmail: String): PaymentConfirmResponse {
validatePaymentId(paymentId)
validateUserEmail(userEmail)

val response = portOnePort.confirmPayment(paymentId)

if (userEmail != response.customer.id) {
throw PaymentVerificationException("사용자 인증 실패: 결제 정보와 사용자 정보가 일치하지 않습니다")
}

log.info("결제 승인 완료: paymentId={}", paymentId)
return response
}

@Transactional
override fun processRefund(refundRequest: RefundRequest, userEmail: String): RefundResponse {
validateRefundRequest(refundRequest)
validateUserEmail(userEmail)

val payment = paymentRepository.findByPaymentId(refundRequest.paymentId)
?: throw PaymentNotFoundException("결제 정보를 찾을 수 없습니다")

if (!payment.canRefund()) {
throw RefundValidationException("환불이 불가능한 결제입니다")
}

if (payment.userEmail != userEmail) {
throw RefundValidationException("환불 권한이 없습니다")
}

val refundResponse = portOnePort.processRefund(payment.paymentId, refundRequest.reason)

payment.updateStatus(PaymentStatus.REFUNDED, refundRequest.reason)
paymentRepository.save(payment)

log.info("환불 처리 완료: paymentId={}", payment.paymentId)
return refundResponse
}

@Transactional
override fun processWebhook(rawBody: String, headers: Map<String, String>): WebhookResult {
if (rawBody.isBlank()) {
throw WebhookVerificationException("웹훅 페이로드가 비어있습니다")
}

val signature = headers["webhook-signature"]
?: throw WebhookVerificationException("웹훅 서명이 없습니다")
val timestamp = headers["webhook-timestamp"]
?: throw WebhookVerificationException("웹훅 타임스탬프가 없습니다")

if (!webhookVerifier.verify(rawBody, signature, timestamp)) {
throw WebhookVerificationException("웹훅 서명이 유효하지 않습니다")
}

val eventData = parseWebhookEvent(rawBody)
val eventType = eventData["type"] as? String
?: throw WebhookVerificationException("이벤트 타입이 없습니다")
val paymentId = extractPaymentId(eventData)
?: throw WebhookVerificationException("결제 ID가 없습니다")

return when (eventType) {
"Transaction.Paid" -> handlePaymentPaid(paymentId)
"Refund.Initiated" -> handleRefundInitiated(paymentId)
"Transaction.Cancelled" -> handlePaymentCancelled(paymentId)
else -> {
log.warn("지원하지 않는 이벤트 타입: {}", eventType)
WebhookResult.IGNORED
}
}
}

private fun handlePaymentPaid(paymentId: String): WebhookResult {
val response = portOnePort.confirmPayment(paymentId)

if (response.status != "PAID") {
log.warn("결제 상태가 PAID가 아닙니다: {}", paymentId)
return WebhookResult.IGNORED
}

if (paymentId.startsWith("A")) {
handleChargePayment(response, paymentId)
return WebhookResult.SUCCESS
}

if (paymentId.startsWith("I")) {
handleInvestmentPayment(response, paymentId)
return WebhookResult.SUCCESS
}

log.warn("알 수 없는 결제 타입: {}", paymentId)
return WebhookResult.IGNORED
}

private fun handleRefundInitiated(paymentId: String): WebhookResult {
val payment = paymentRepository.findByPaymentId(paymentId) ?: return WebhookResult.IGNORED

payment.updateStatus(PaymentStatus.REFUNDED, "PortOne 환불 시작됨")
paymentRepository.save(payment)
return WebhookResult.SUCCESS
}

private fun handlePaymentCancelled(paymentId: String): WebhookResult {
val payment = paymentRepository.findByPaymentId(paymentId) ?: return WebhookResult.IGNORED

payment.updateStatus(PaymentStatus.CANCELLED, "PortOne 결제 취소됨")
paymentRepository.save(payment)
return WebhookResult.SUCCESS
}

private fun handleChargePayment(response: PaymentConfirmResponse, paymentId: String) {
log.info("충전 결제 처리: {}, 금액: {}", paymentId, response.amount.total)
}

private fun handleInvestmentPayment(response: PaymentConfirmResponse, paymentId: String) {
log.info("투자 결제 처리: {}, 금액: {}", paymentId, response.amount.total)
}

private fun validatePaymentId(paymentId: String) {
if (paymentId.isBlank()) {
throw IllegalArgumentException("결제 ID는 필수입니다")
}
if (paymentId.length > 100) {
throw IllegalArgumentException("결제 ID가 너무 깁니다")
}
}

private fun validateUserEmail(userEmail: String) {
if (userEmail.isBlank()) {
throw IllegalArgumentException("사용자 이메일은 필수입니다")
}
if (!userEmail.contains("@")) {
throw IllegalArgumentException("이메일 형식이 올바르지 않습니다")
}
if (userEmail.length > 255) {
throw IllegalArgumentException("이메일이 너무 깁니다")
}
}

private fun validateRefundRequest(refundRequest: RefundRequest) {
if (refundRequest.paymentId.isBlank()) {
throw IllegalArgumentException("결제 ID는 필수입니다")
}
if (refundRequest.reason.isBlank()) {
throw IllegalArgumentException("환불 사유는 필수입니다")
}
if (refundRequest.reason.length > 500) {
throw IllegalArgumentException("환불 사유가 너무 깁니다")
}
}

private fun parseWebhookEvent(rawBody: String): Map<String, Any> {
return try {
objectMapper.readValue(rawBody, Map::class.java) as Map<String, Any>
} catch (e: Exception) {
log.error("웹훅 이벤트 파싱 오류", e)
throw WebhookVerificationException("웹훅 페이로드 형식이 올바르지 않습니다", e)
}
}

private fun extractPaymentId(eventData: Map<String, Any>): String? {
return try {
val data = eventData["data"] as? Map<String, Any>
data?.get("paymentId") as? String
} catch (e: Exception) {
log.error("웹훅에서 결제 ID 추출 오류", e)
null
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
package bssm.devcoop.occount.application.payment.command.port.`in`

import bssm.devcoop.occount.application.payment.dto.PaymentConfirmResponse
import bssm.devcoop.occount.application.payment.dto.RefundRequest
import bssm.devcoop.occount.application.payment.dto.RefundResponse
import bssm.devcoop.occount.application.payment.dto.WebhookResult

interface PaymentCommandPort {
fun confirmPayment(paymentId: String, userEmail: String): PaymentConfirmResponse

fun processRefund(refundRequest: RefundRequest, userEmail: String): RefundResponse

fun processWebhook(rawBody: String, headers: Map<String, String>): WebhookResult
}

Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
package bssm.devcoop.occount.application.payment.dto

import java.math.BigDecimal

data class PaymentAmount(
val total: BigDecimal,
val currency: String
)

Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
package bssm.devcoop.occount.application.payment.dto

import java.math.BigDecimal

data class PaymentConfirmResponse(
val status: String,
val amount: PaymentAmount,
val customer: PaymentCustomer,
val paymentMethod: PaymentMethodDto
) {
companion object {
fun createErrorResponse(message: String): PaymentConfirmResponse {
return PaymentConfirmResponse(
status = "ERROR",
amount = PaymentAmount(BigDecimal.ZERO, "KRW"),
customer = PaymentCustomer("", "", message),
paymentMethod = PaymentMethodDto("")
)
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
package bssm.devcoop.occount.application.payment.dto

data class PaymentCustomer(
val id: String,
val name: String,
val email: String
)

Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
package bssm.devcoop.occount.application.payment.dto

import java.math.BigDecimal
import java.time.LocalDateTime

data class PaymentHistoryItem(
val paymentId: String,
val amount: BigDecimal,
val status: String,
val paymentMethod: String,
val createdAt: LocalDateTime,
val updatedAt: LocalDateTime?
)

Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
package bssm.devcoop.occount.application.payment.dto

import bssm.devcoop.occount.domain.payment.Payment

data class PaymentHistoryResponse(
val payments: List<PaymentHistoryItem>,
val totalPages: Int,
val currentPage: Int,
val totalElements: Long
) {
companion object {
fun from(payments: List<Payment>, totalPages: Int, currentPage: Int, totalElements: Long): PaymentHistoryResponse {
val items = payments.map { payment ->
PaymentHistoryItem(
paymentId = payment.paymentId,
amount = payment.amount,
status = payment.status.name,
paymentMethod = payment.paymentMethod.name,
createdAt = payment.createdAt,
updatedAt = payment.updatedAt
)
}
return PaymentHistoryResponse(items, totalPages, currentPage, totalElements)
}

fun createErrorResponse(): PaymentHistoryResponse {
return PaymentHistoryResponse(
payments = emptyList(),
totalPages = 0,
currentPage = 0,
totalElements = 0
)
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
package bssm.devcoop.occount.application.payment.dto

data class PaymentMethodDto(
val type: String
)

Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
package bssm.devcoop.occount.application.payment.dto

import jakarta.validation.constraints.NotBlank

data class RefundAccount(
@field:NotBlank(message = "은행 코드는 필수입니다")
val bankCode: String,

@field:NotBlank(message = "계좌번호는 필수입니다")
val accountNumber: String,

@field:NotBlank(message = "예금주명은 필수입니다")
val holderName: String
)

Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
package bssm.devcoop.occount.application.payment.dto

import jakarta.validation.Valid
import jakarta.validation.constraints.NotBlank
import jakarta.validation.constraints.Size

data class RefundRequest(
@field:NotBlank(message = "결제 ID는 필수입니다")
val paymentId: String,

@field:NotBlank(message = "환불 사유는 필수입니다")
@field:Size(max = 500, message = "환불 사유는 500자를 초과할 수 없습니다")
val reason: String,

@field:Valid
val refundAccount: RefundAccount? = null
)
Loading