feat(charge): 최초 유료 충전 판정을 저장하고 완료 정보를 확장한다

This commit is contained in:
2026-11-03 07:09:14 +09:00
parent 6bcc900e5c
commit 4c0fc4298b
7 changed files with 773 additions and 39 deletions
@@ -6,6 +6,7 @@ import kr.co.vividnext.sodalive.can.use.UseCan
import kr.co.vividnext.sodalive.common.BaseEntity
import kr.co.vividnext.sodalive.member.Member
import javax.persistence.CascadeType
import javax.persistence.Column
import javax.persistence.Entity
import javax.persistence.EnumType
import javax.persistence.Enumerated
@@ -44,4 +45,7 @@ data class Charge(
var title: String? = null
var googleProductId: String? = null
@Column(name = "is_first_charged")
var isFirstCharged: Boolean? = null
}
@@ -5,5 +5,6 @@ import java.math.BigDecimal
data class ChargeCompleteResponse(
val price: BigDecimal,
val currencyCode: String,
val isFirstCharged: Boolean
val isFirstCharged: Boolean,
val isIap: Boolean
)
@@ -26,7 +26,7 @@ interface ChargeQueryRepository {
fun getOldestChargeWhereRewardCanGreaterThan0(chargeId: Long, memberId: Long, container: String): Charge?
fun getOldestChargeWhereChargeCanGreaterThan0(chargeId: Long, memberId: Long, container: String): Charge?
fun getChargeCountAfterDate(memberId: Long, date: LocalDateTime): Int
fun isFirstCharged(memberId: Long): Boolean
fun existsSuccessfulPaidCharge(memberId: Long, excludedChargeId: Long): Boolean
fun getChargeCount(memberId: Long): Int
}
@@ -86,19 +86,24 @@ class ChargeQueryRepositoryImpl(private val queryFactory: JPAQueryFactory) : Cha
.size
}
override fun isFirstCharged(memberId: Long): Boolean {
override fun existsSuccessfulPaidCharge(memberId: Long, excludedChargeId: Long): Boolean {
return queryFactory
.select(charge.id)
.selectOne()
.from(charge)
.innerJoin(charge.member, member)
.innerJoin(charge.payment, payment)
.where(
member.id.eq(memberId),
charge.status.eq(ChargeStatus.CHARGE),
payment.status.eq(PaymentStatus.COMPLETE)
charge.member.id.eq(memberId),
charge.id.ne(excludedChargeId),
payment.paymentGateway.`in`(
PaymentGateway.PG,
PaymentGateway.PAYVERSE,
PaymentGateway.APPLE_IAP,
PaymentGateway.GOOGLE_IAP
),
charge.status.eq(ChargeStatus.CHARGE).and(payment.status.eq(PaymentStatus.COMPLETE))
.or(charge.status.eq(ChargeStatus.CANCEL).and(payment.status.eq(PaymentStatus.RETURN)))
)
.fetch()
.size <= 1
.fetchFirst() != null
}
override fun getChargeCount(memberId: Long): Int {
@@ -32,6 +32,7 @@ import org.springframework.http.HttpHeaders
import org.springframework.retry.annotation.Backoff
import org.springframework.retry.annotation.Retryable
import org.springframework.stereotype.Service
import org.springframework.transaction.annotation.Isolation
import org.springframework.transaction.annotation.Transactional
import java.math.BigDecimal
import java.math.RoundingMode
@@ -98,7 +99,7 @@ class ChargeService(
private val serverEnv: String
) {
@Transactional
@Transactional(isolation = Isolation.READ_COMMITTED)
fun payverseWebhook(request: PayverseWebhookRequest): Boolean {
val chargeId = request.orderId.toLongOrNull() ?: return false
val charge = chargeRepository.findByIdForUpdate(chargeId) ?: return false
@@ -142,6 +143,8 @@ class ChargeService(
request.sign == expectedSign
if (isSuccess) {
val member = memberRepository.findByIdForUpdate(charge.member!!.id!!) ?: return false
recordFirstFact(charge, member.id!!)
// payverseVerify의 226~246 라인과 동일 처리
charge.payment?.receiptId = request.tid
val mappedMethod = if (request.schemeGroup == "PVKR") {
@@ -153,7 +156,6 @@ class ChargeService(
charge.payment?.status = PaymentStatus.COMPLETE
charge.payment?.locale = request.requestCurrency
val member = memberRepository.findByIdForUpdate(charge.member!!.id!!) ?: return false
member.charge(charge.chargeCan, charge.rewardCan, "pg")
chargeEventJobService.createAndProcessImmediate(charge.id!!, member.id!!)
true
@@ -300,10 +302,13 @@ class ChargeService(
return PayverseChargeResponse(chargeId = charge.id!!, payloadJson = payloadJson)
}
@Transactional
@Transactional(isolation = Isolation.READ_COMMITTED)
fun payverseVerify(memberId: Long, verifyRequest: PayverseVerifyRequest): ChargeCompleteResponse {
val charge = chargeRepository.findByIdForUpdate(verifyRequest.orderId.toLong())
?: throw SodaException(messageKey = "can.charge.invalid_payment_info")
if (charge.member?.id != memberId) {
throw SodaException(messageKey = "can.charge.invalid_payment_info")
}
val member = memberRepository.findByIdForUpdate(memberId)
?: throw SodaException(messageKey = "common.error.bad_credentials")
@@ -354,6 +359,7 @@ class ChargeService(
verifyResponse.requestAmount.compareTo(expectedAmount) == 0
if (isSuccess) {
recordFirstFact(charge, member.id!!)
// verify 함수의 232~248 라인과 동일 처리
charge.payment?.receiptId = verifyResponse.tid
val mappedMethod = if (verifyResponse.schemeGroup == "PVKR") {
@@ -369,11 +375,7 @@ class ChargeService(
member.charge(charge.chargeCan, charge.rewardCan, "pg")
chargeEventJobService.createAndProcessImmediate(charge.id!!, member.id!!)
return ChargeCompleteResponse(
price = charge.payment!!.price,
currencyCode = charge.payment!!.locale?.takeLast(3) ?: "KRW",
isFirstCharged = chargeRepository.isFirstCharged(memberId)
)
return completeResponse(charge)
} else {
throw SodaException(messageKey = "can.charge.invalid_payment_info")
}
@@ -384,11 +386,7 @@ class ChargeService(
PaymentStatus.COMPLETE -> {
// 이미 결제가 완료된 경우, 동일한 데이터로 즉시 반환
return ChargeCompleteResponse(
price = charge.payment!!.price,
currencyCode = charge.payment!!.locale?.takeLast(3) ?: "KRW",
isFirstCharged = chargeRepository.isFirstCharged(memberId)
)
return completeResponse(charge)
}
else -> {
@@ -416,19 +414,22 @@ class ChargeService(
return ChargeResponse(chargeId = charge.id!!)
}
@Transactional
@Transactional(isolation = Isolation.READ_COMMITTED)
fun verify(memberId: Long, verifyRequest: VerifyRequest): ChargeCompleteResponse {
val charge = chargeRepository.findByIdForUpdate(verifyRequest.orderId.toLong())
?: throw SodaException(messageKey = "can.charge.invalid_payment_info")
if (charge.member?.id != memberId) {
throw SodaException(messageKey = "can.charge.invalid_payment_info")
}
val member = memberRepository.findByIdForUpdate(memberId)
?: throw SodaException(messageKey = "common.error.bad_credentials")
if (charge.payment!!.paymentGateway == PaymentGateway.PG) {
if (charge.payment?.status == PaymentStatus.COMPLETE) {
return completeResponse(charge, memberId)
return completeResponse(charge)
}
val bootpay = Bootpay(bootpayApplicationId, bootpayPrivateKey)
val bootpay = createBootpay(bootpayApplicationId, bootpayPrivateKey)
try {
bootpay.accessToken
@@ -438,13 +439,14 @@ class ChargeService(
)
if (verifyResult.status == 1 && verifyResult.price == charge.can?.price) {
recordFirstFact(charge, member.id!!)
charge.payment?.receiptId = verifyResult.receiptId
charge.payment?.method = verifyResult.method
charge.payment?.status = PaymentStatus.COMPLETE
member.charge(charge.chargeCan, charge.rewardCan, "pg")
chargeEventJobService.createAndProcessImmediate(charge.id!!, member.id!!)
return completeResponse(charge, memberId)
return completeResponse(charge)
} else {
throw SodaException(messageKey = "can.charge.invalid_payment_info")
}
@@ -456,19 +458,22 @@ class ChargeService(
}
}
@Transactional
@Transactional(isolation = Isolation.READ_COMMITTED)
fun verifyHecto(memberId: Long, verifyRequest: VerifyRequest): ChargeCompleteResponse {
val charge = chargeRepository.findByIdForUpdate(verifyRequest.orderId.toLong())
?: throw SodaException(messageKey = "can.charge.invalid_payment_info")
if (charge.member?.id != memberId) {
throw SodaException(messageKey = "can.charge.invalid_payment_info")
}
val member = memberRepository.findByIdForUpdate(memberId)
?: throw SodaException(messageKey = "common.error.bad_credentials")
if (charge.payment!!.paymentGateway == PaymentGateway.PG) {
if (charge.payment?.status == PaymentStatus.COMPLETE) {
return completeResponse(charge, memberId)
return completeResponse(charge)
}
val bootpay = Bootpay(bootpayHectoApplicationId, bootpayHectoPrivateKey)
val bootpay = createBootpay(bootpayHectoApplicationId, bootpayHectoPrivateKey)
try {
bootpay.accessToken
@@ -478,6 +483,7 @@ class ChargeService(
)
if (verifyResult.status == 1) {
recordFirstFact(charge, member.id!!)
charge.payment?.receiptId = verifyResult.receiptId
charge.payment?.method = if (verifyResult.pg.contains("카카오")) {
"${verifyResult.pg}-${verifyResult.method}"
@@ -488,7 +494,7 @@ class ChargeService(
member.charge(charge.chargeCan, charge.rewardCan, "pg")
chargeEventJobService.createAndProcessImmediate(charge.id!!, member.id!!)
return completeResponse(charge, memberId)
return completeResponse(charge)
} else {
throw SodaException(messageKey = "can.charge.invalid_payment_info")
}
@@ -522,20 +528,24 @@ class ChargeService(
return ChargeResponse(chargeId = charge.id!!)
}
@Transactional
@Transactional(isolation = Isolation.READ_COMMITTED)
fun appleVerify(memberId: Long, verifyRequest: AppleVerifyRequest): ChargeCompleteResponse {
val charge = chargeRepository.findByIdForUpdate(verifyRequest.chargeId)
?: throw SodaException(messageKey = "can.charge.invalid_payment_info")
if (charge.member?.id != memberId) {
throw SodaException(messageKey = "can.charge.invalid_payment_info")
}
val member = memberRepository.findByIdForUpdate(memberId)
?: throw SodaException(messageKey = "common.error.bad_credentials")
if (charge.payment!!.paymentGateway == PaymentGateway.APPLE_IAP) {
if (charge.payment?.status == PaymentStatus.COMPLETE) {
return completeResponse(charge, memberId)
return completeResponse(charge)
}
// 검증로직
if (requestRealServerVerify(verifyRequest)) {
recordFirstFact(charge, member.id!!)
charge.payment?.receiptId = verifyRequest.receiptString
charge.payment?.method = messageSource
.getMessage("can.charge.payment_method.apple_iap", langContext.lang)
@@ -544,7 +554,7 @@ class ChargeService(
member.charge(charge.chargeCan, charge.rewardCan, "ios")
chargeEventJobService.createAndProcessImmediate(charge.id!!, member.id!!)
return completeResponse(charge, memberId)
return completeResponse(charge)
} else {
throw SodaException(messageKey = "can.charge.invalid_payment_info")
}
@@ -583,7 +593,7 @@ class ChargeService(
return charge.id!!
}
@Transactional
@Transactional(isolation = Isolation.READ_COMMITTED)
fun processGoogleIap(
memberId: Long,
chargeId: Long,
@@ -592,23 +602,27 @@ class ChargeService(
): ChargeCompleteResponse {
val charge = chargeRepository.findByIdForUpdate(chargeId)
?: throw SodaException(messageKey = "can.charge.invalid_payment_info")
if (charge.member?.id != memberId) {
throw SodaException(messageKey = "can.charge.invalid_payment_info")
}
val member = memberRepository.findByIdForUpdate(memberId)
?: throw SodaException(messageKey = "common.error.bad_credentials")
if (charge.payment!!.status == PaymentStatus.REQUEST) {
val orderId = verifyPurchase(purchaseToken, productId)
if (orderId.isNotBlank()) {
recordFirstFact(charge, member.id!!)
charge.payment!!.orderId = orderId
charge.payment!!.status = PaymentStatus.COMPLETE
member.charge(charge.chargeCan, 0, "aos")
chargeEventJobService.createAndProcessImmediate(charge.id!!, member.id!!)
return completeResponse(charge, memberId)
return completeResponse(charge)
} else {
throw SodaException(messageKey = "can.charge.purchase_failed_contact")
}
} else if (charge.payment!!.status == PaymentStatus.COMPLETE) {
return completeResponse(charge, memberId)
return completeResponse(charge)
} else {
throw SodaException(messageKey = "can.charge.invalid_payment_info")
}
@@ -690,11 +704,23 @@ class ChargeService(
return String.format(template, *args)
}
private fun completeResponse(charge: Charge, memberId: Long): ChargeCompleteResponse {
internal fun createBootpay(applicationId: String, privateKey: String): Bootpay {
return Bootpay(applicationId, privateKey)
}
private fun recordFirstFact(charge: Charge, memberId: Long) {
if (charge.payment?.status == PaymentStatus.REQUEST && charge.isFirstCharged == null) {
charge.isFirstCharged = !chargeRepository.existsSuccessfulPaidCharge(memberId, charge.id!!)
}
}
private fun completeResponse(charge: Charge): ChargeCompleteResponse {
return ChargeCompleteResponse(
price = charge.payment!!.price,
currencyCode = charge.payment!!.locale?.takeLast(3) ?: "KRW",
isFirstCharged = chargeRepository.isFirstCharged(memberId)
isFirstCharged = charge.isFirstCharged == true,
isIap = charge.payment!!.paymentGateway == PaymentGateway.GOOGLE_IAP ||
charge.payment!!.paymentGateway == PaymentGateway.APPLE_IAP
)
}