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
@@ -0,0 +1,2 @@
ALTER TABLE charge
ADD COLUMN is_first_charged BIT(1) NULL COMMENT '최초 유료 충전 여부, 최초 완료 전환에서만 확정';
@@ -6,6 +6,7 @@ import kr.co.vividnext.sodalive.can.use.UseCan
import kr.co.vividnext.sodalive.common.BaseEntity import kr.co.vividnext.sodalive.common.BaseEntity
import kr.co.vividnext.sodalive.member.Member import kr.co.vividnext.sodalive.member.Member
import javax.persistence.CascadeType import javax.persistence.CascadeType
import javax.persistence.Column
import javax.persistence.Entity import javax.persistence.Entity
import javax.persistence.EnumType import javax.persistence.EnumType
import javax.persistence.Enumerated import javax.persistence.Enumerated
@@ -44,4 +45,7 @@ data class Charge(
var title: String? = null var title: String? = null
var googleProductId: 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( data class ChargeCompleteResponse(
val price: BigDecimal, val price: BigDecimal,
val currencyCode: String, 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 getOldestChargeWhereRewardCanGreaterThan0(chargeId: Long, memberId: Long, container: String): Charge?
fun getOldestChargeWhereChargeCanGreaterThan0(chargeId: Long, memberId: Long, container: String): Charge? fun getOldestChargeWhereChargeCanGreaterThan0(chargeId: Long, memberId: Long, container: String): Charge?
fun getChargeCountAfterDate(memberId: Long, date: LocalDateTime): Int fun getChargeCountAfterDate(memberId: Long, date: LocalDateTime): Int
fun isFirstCharged(memberId: Long): Boolean fun existsSuccessfulPaidCharge(memberId: Long, excludedChargeId: Long): Boolean
fun getChargeCount(memberId: Long): Int fun getChargeCount(memberId: Long): Int
} }
@@ -86,19 +86,24 @@ class ChargeQueryRepositoryImpl(private val queryFactory: JPAQueryFactory) : Cha
.size .size
} }
override fun isFirstCharged(memberId: Long): Boolean { override fun existsSuccessfulPaidCharge(memberId: Long, excludedChargeId: Long): Boolean {
return queryFactory return queryFactory
.select(charge.id) .selectOne()
.from(charge) .from(charge)
.innerJoin(charge.member, member)
.innerJoin(charge.payment, payment) .innerJoin(charge.payment, payment)
.where( .where(
member.id.eq(memberId), charge.member.id.eq(memberId),
charge.status.eq(ChargeStatus.CHARGE), charge.id.ne(excludedChargeId),
payment.status.eq(PaymentStatus.COMPLETE) 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() .fetchFirst() != null
.size <= 1
} }
override fun getChargeCount(memberId: Long): Int { 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.Backoff
import org.springframework.retry.annotation.Retryable import org.springframework.retry.annotation.Retryable
import org.springframework.stereotype.Service import org.springframework.stereotype.Service
import org.springframework.transaction.annotation.Isolation
import org.springframework.transaction.annotation.Transactional import org.springframework.transaction.annotation.Transactional
import java.math.BigDecimal import java.math.BigDecimal
import java.math.RoundingMode import java.math.RoundingMode
@@ -98,7 +99,7 @@ class ChargeService(
private val serverEnv: String private val serverEnv: String
) { ) {
@Transactional @Transactional(isolation = Isolation.READ_COMMITTED)
fun payverseWebhook(request: PayverseWebhookRequest): Boolean { fun payverseWebhook(request: PayverseWebhookRequest): Boolean {
val chargeId = request.orderId.toLongOrNull() ?: return false val chargeId = request.orderId.toLongOrNull() ?: return false
val charge = chargeRepository.findByIdForUpdate(chargeId) ?: return false val charge = chargeRepository.findByIdForUpdate(chargeId) ?: return false
@@ -142,6 +143,8 @@ class ChargeService(
request.sign == expectedSign request.sign == expectedSign
if (isSuccess) { if (isSuccess) {
val member = memberRepository.findByIdForUpdate(charge.member!!.id!!) ?: return false
recordFirstFact(charge, member.id!!)
// payverseVerify의 226~246 라인과 동일 처리 // payverseVerify의 226~246 라인과 동일 처리
charge.payment?.receiptId = request.tid charge.payment?.receiptId = request.tid
val mappedMethod = if (request.schemeGroup == "PVKR") { val mappedMethod = if (request.schemeGroup == "PVKR") {
@@ -153,7 +156,6 @@ class ChargeService(
charge.payment?.status = PaymentStatus.COMPLETE charge.payment?.status = PaymentStatus.COMPLETE
charge.payment?.locale = request.requestCurrency charge.payment?.locale = request.requestCurrency
val member = memberRepository.findByIdForUpdate(charge.member!!.id!!) ?: return false
member.charge(charge.chargeCan, charge.rewardCan, "pg") member.charge(charge.chargeCan, charge.rewardCan, "pg")
chargeEventJobService.createAndProcessImmediate(charge.id!!, member.id!!) chargeEventJobService.createAndProcessImmediate(charge.id!!, member.id!!)
true true
@@ -300,10 +302,13 @@ class ChargeService(
return PayverseChargeResponse(chargeId = charge.id!!, payloadJson = payloadJson) return PayverseChargeResponse(chargeId = charge.id!!, payloadJson = payloadJson)
} }
@Transactional @Transactional(isolation = Isolation.READ_COMMITTED)
fun payverseVerify(memberId: Long, verifyRequest: PayverseVerifyRequest): ChargeCompleteResponse { fun payverseVerify(memberId: Long, verifyRequest: PayverseVerifyRequest): ChargeCompleteResponse {
val charge = chargeRepository.findByIdForUpdate(verifyRequest.orderId.toLong()) val charge = chargeRepository.findByIdForUpdate(verifyRequest.orderId.toLong())
?: throw SodaException(messageKey = "can.charge.invalid_payment_info") ?: 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) val member = memberRepository.findByIdForUpdate(memberId)
?: throw SodaException(messageKey = "common.error.bad_credentials") ?: throw SodaException(messageKey = "common.error.bad_credentials")
@@ -354,6 +359,7 @@ class ChargeService(
verifyResponse.requestAmount.compareTo(expectedAmount) == 0 verifyResponse.requestAmount.compareTo(expectedAmount) == 0
if (isSuccess) { if (isSuccess) {
recordFirstFact(charge, member.id!!)
// verify 함수의 232~248 라인과 동일 처리 // verify 함수의 232~248 라인과 동일 처리
charge.payment?.receiptId = verifyResponse.tid charge.payment?.receiptId = verifyResponse.tid
val mappedMethod = if (verifyResponse.schemeGroup == "PVKR") { val mappedMethod = if (verifyResponse.schemeGroup == "PVKR") {
@@ -369,11 +375,7 @@ class ChargeService(
member.charge(charge.chargeCan, charge.rewardCan, "pg") member.charge(charge.chargeCan, charge.rewardCan, "pg")
chargeEventJobService.createAndProcessImmediate(charge.id!!, member.id!!) chargeEventJobService.createAndProcessImmediate(charge.id!!, member.id!!)
return ChargeCompleteResponse( return completeResponse(charge)
price = charge.payment!!.price,
currencyCode = charge.payment!!.locale?.takeLast(3) ?: "KRW",
isFirstCharged = chargeRepository.isFirstCharged(memberId)
)
} else { } else {
throw SodaException(messageKey = "can.charge.invalid_payment_info") throw SodaException(messageKey = "can.charge.invalid_payment_info")
} }
@@ -384,11 +386,7 @@ class ChargeService(
PaymentStatus.COMPLETE -> { PaymentStatus.COMPLETE -> {
// 이미 결제가 완료된 경우, 동일한 데이터로 즉시 반환 // 이미 결제가 완료된 경우, 동일한 데이터로 즉시 반환
return ChargeCompleteResponse( return completeResponse(charge)
price = charge.payment!!.price,
currencyCode = charge.payment!!.locale?.takeLast(3) ?: "KRW",
isFirstCharged = chargeRepository.isFirstCharged(memberId)
)
} }
else -> { else -> {
@@ -416,19 +414,22 @@ class ChargeService(
return ChargeResponse(chargeId = charge.id!!) return ChargeResponse(chargeId = charge.id!!)
} }
@Transactional @Transactional(isolation = Isolation.READ_COMMITTED)
fun verify(memberId: Long, verifyRequest: VerifyRequest): ChargeCompleteResponse { fun verify(memberId: Long, verifyRequest: VerifyRequest): ChargeCompleteResponse {
val charge = chargeRepository.findByIdForUpdate(verifyRequest.orderId.toLong()) val charge = chargeRepository.findByIdForUpdate(verifyRequest.orderId.toLong())
?: throw SodaException(messageKey = "can.charge.invalid_payment_info") ?: 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) val member = memberRepository.findByIdForUpdate(memberId)
?: throw SodaException(messageKey = "common.error.bad_credentials") ?: throw SodaException(messageKey = "common.error.bad_credentials")
if (charge.payment!!.paymentGateway == PaymentGateway.PG) { if (charge.payment!!.paymentGateway == PaymentGateway.PG) {
if (charge.payment?.status == PaymentStatus.COMPLETE) { if (charge.payment?.status == PaymentStatus.COMPLETE) {
return completeResponse(charge, memberId) return completeResponse(charge)
} }
val bootpay = Bootpay(bootpayApplicationId, bootpayPrivateKey) val bootpay = createBootpay(bootpayApplicationId, bootpayPrivateKey)
try { try {
bootpay.accessToken bootpay.accessToken
@@ -438,13 +439,14 @@ class ChargeService(
) )
if (verifyResult.status == 1 && verifyResult.price == charge.can?.price) { if (verifyResult.status == 1 && verifyResult.price == charge.can?.price) {
recordFirstFact(charge, member.id!!)
charge.payment?.receiptId = verifyResult.receiptId charge.payment?.receiptId = verifyResult.receiptId
charge.payment?.method = verifyResult.method charge.payment?.method = verifyResult.method
charge.payment?.status = PaymentStatus.COMPLETE charge.payment?.status = PaymentStatus.COMPLETE
member.charge(charge.chargeCan, charge.rewardCan, "pg") member.charge(charge.chargeCan, charge.rewardCan, "pg")
chargeEventJobService.createAndProcessImmediate(charge.id!!, member.id!!) chargeEventJobService.createAndProcessImmediate(charge.id!!, member.id!!)
return completeResponse(charge, memberId) return completeResponse(charge)
} else { } else {
throw SodaException(messageKey = "can.charge.invalid_payment_info") 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 { fun verifyHecto(memberId: Long, verifyRequest: VerifyRequest): ChargeCompleteResponse {
val charge = chargeRepository.findByIdForUpdate(verifyRequest.orderId.toLong()) val charge = chargeRepository.findByIdForUpdate(verifyRequest.orderId.toLong())
?: throw SodaException(messageKey = "can.charge.invalid_payment_info") ?: 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) val member = memberRepository.findByIdForUpdate(memberId)
?: throw SodaException(messageKey = "common.error.bad_credentials") ?: throw SodaException(messageKey = "common.error.bad_credentials")
if (charge.payment!!.paymentGateway == PaymentGateway.PG) { if (charge.payment!!.paymentGateway == PaymentGateway.PG) {
if (charge.payment?.status == PaymentStatus.COMPLETE) { if (charge.payment?.status == PaymentStatus.COMPLETE) {
return completeResponse(charge, memberId) return completeResponse(charge)
} }
val bootpay = Bootpay(bootpayHectoApplicationId, bootpayHectoPrivateKey) val bootpay = createBootpay(bootpayHectoApplicationId, bootpayHectoPrivateKey)
try { try {
bootpay.accessToken bootpay.accessToken
@@ -478,6 +483,7 @@ class ChargeService(
) )
if (verifyResult.status == 1) { if (verifyResult.status == 1) {
recordFirstFact(charge, member.id!!)
charge.payment?.receiptId = verifyResult.receiptId charge.payment?.receiptId = verifyResult.receiptId
charge.payment?.method = if (verifyResult.pg.contains("카카오")) { charge.payment?.method = if (verifyResult.pg.contains("카카오")) {
"${verifyResult.pg}-${verifyResult.method}" "${verifyResult.pg}-${verifyResult.method}"
@@ -488,7 +494,7 @@ class ChargeService(
member.charge(charge.chargeCan, charge.rewardCan, "pg") member.charge(charge.chargeCan, charge.rewardCan, "pg")
chargeEventJobService.createAndProcessImmediate(charge.id!!, member.id!!) chargeEventJobService.createAndProcessImmediate(charge.id!!, member.id!!)
return completeResponse(charge, memberId) return completeResponse(charge)
} else { } else {
throw SodaException(messageKey = "can.charge.invalid_payment_info") throw SodaException(messageKey = "can.charge.invalid_payment_info")
} }
@@ -522,20 +528,24 @@ class ChargeService(
return ChargeResponse(chargeId = charge.id!!) return ChargeResponse(chargeId = charge.id!!)
} }
@Transactional @Transactional(isolation = Isolation.READ_COMMITTED)
fun appleVerify(memberId: Long, verifyRequest: AppleVerifyRequest): ChargeCompleteResponse { fun appleVerify(memberId: Long, verifyRequest: AppleVerifyRequest): ChargeCompleteResponse {
val charge = chargeRepository.findByIdForUpdate(verifyRequest.chargeId) val charge = chargeRepository.findByIdForUpdate(verifyRequest.chargeId)
?: throw SodaException(messageKey = "can.charge.invalid_payment_info") ?: 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) val member = memberRepository.findByIdForUpdate(memberId)
?: throw SodaException(messageKey = "common.error.bad_credentials") ?: throw SodaException(messageKey = "common.error.bad_credentials")
if (charge.payment!!.paymentGateway == PaymentGateway.APPLE_IAP) { if (charge.payment!!.paymentGateway == PaymentGateway.APPLE_IAP) {
if (charge.payment?.status == PaymentStatus.COMPLETE) { if (charge.payment?.status == PaymentStatus.COMPLETE) {
return completeResponse(charge, memberId) return completeResponse(charge)
} }
// 검증로직 // 검증로직
if (requestRealServerVerify(verifyRequest)) { if (requestRealServerVerify(verifyRequest)) {
recordFirstFact(charge, member.id!!)
charge.payment?.receiptId = verifyRequest.receiptString charge.payment?.receiptId = verifyRequest.receiptString
charge.payment?.method = messageSource charge.payment?.method = messageSource
.getMessage("can.charge.payment_method.apple_iap", langContext.lang) .getMessage("can.charge.payment_method.apple_iap", langContext.lang)
@@ -544,7 +554,7 @@ class ChargeService(
member.charge(charge.chargeCan, charge.rewardCan, "ios") member.charge(charge.chargeCan, charge.rewardCan, "ios")
chargeEventJobService.createAndProcessImmediate(charge.id!!, member.id!!) chargeEventJobService.createAndProcessImmediate(charge.id!!, member.id!!)
return completeResponse(charge, memberId) return completeResponse(charge)
} else { } else {
throw SodaException(messageKey = "can.charge.invalid_payment_info") throw SodaException(messageKey = "can.charge.invalid_payment_info")
} }
@@ -583,7 +593,7 @@ class ChargeService(
return charge.id!! return charge.id!!
} }
@Transactional @Transactional(isolation = Isolation.READ_COMMITTED)
fun processGoogleIap( fun processGoogleIap(
memberId: Long, memberId: Long,
chargeId: Long, chargeId: Long,
@@ -592,23 +602,27 @@ class ChargeService(
): ChargeCompleteResponse { ): ChargeCompleteResponse {
val charge = chargeRepository.findByIdForUpdate(chargeId) val charge = chargeRepository.findByIdForUpdate(chargeId)
?: throw SodaException(messageKey = "can.charge.invalid_payment_info") ?: 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) val member = memberRepository.findByIdForUpdate(memberId)
?: throw SodaException(messageKey = "common.error.bad_credentials") ?: throw SodaException(messageKey = "common.error.bad_credentials")
if (charge.payment!!.status == PaymentStatus.REQUEST) { if (charge.payment!!.status == PaymentStatus.REQUEST) {
val orderId = verifyPurchase(purchaseToken, productId) val orderId = verifyPurchase(purchaseToken, productId)
if (orderId.isNotBlank()) { if (orderId.isNotBlank()) {
recordFirstFact(charge, member.id!!)
charge.payment!!.orderId = orderId charge.payment!!.orderId = orderId
charge.payment!!.status = PaymentStatus.COMPLETE charge.payment!!.status = PaymentStatus.COMPLETE
member.charge(charge.chargeCan, 0, "aos") member.charge(charge.chargeCan, 0, "aos")
chargeEventJobService.createAndProcessImmediate(charge.id!!, member.id!!) chargeEventJobService.createAndProcessImmediate(charge.id!!, member.id!!)
return completeResponse(charge, memberId) return completeResponse(charge)
} else { } else {
throw SodaException(messageKey = "can.charge.purchase_failed_contact") throw SodaException(messageKey = "can.charge.purchase_failed_contact")
} }
} else if (charge.payment!!.status == PaymentStatus.COMPLETE) { } else if (charge.payment!!.status == PaymentStatus.COMPLETE) {
return completeResponse(charge, memberId) return completeResponse(charge)
} else { } else {
throw SodaException(messageKey = "can.charge.invalid_payment_info") throw SodaException(messageKey = "can.charge.invalid_payment_info")
} }
@@ -690,11 +704,23 @@ class ChargeService(
return String.format(template, *args) 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( return ChargeCompleteResponse(
price = charge.payment!!.price, price = charge.payment!!.price,
currencyCode = charge.payment!!.locale?.takeLast(3) ?: "KRW", 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
) )
} }
@@ -0,0 +1,120 @@
package kr.co.vividnext.sodalive.can.charge
import kr.co.vividnext.sodalive.can.payment.Payment
import kr.co.vividnext.sodalive.can.payment.PaymentGateway
import kr.co.vividnext.sodalive.can.payment.PaymentStatus
import kr.co.vividnext.sodalive.configs.QueryDslConfig
import kr.co.vividnext.sodalive.member.Member
import org.hibernate.dialect.MySQL8Dialect
import org.hibernate.type.BooleanType
import org.junit.jupiter.api.Assertions.assertEquals
import org.junit.jupiter.api.Assertions.assertTrue
import org.junit.jupiter.api.DisplayName
import org.junit.jupiter.api.Test
import org.junit.jupiter.params.ParameterizedTest
import org.junit.jupiter.params.provider.Arguments
import org.junit.jupiter.params.provider.MethodSource
import org.junit.jupiter.params.provider.NullSource
import org.junit.jupiter.params.provider.ValueSource
import org.springframework.beans.factory.annotation.Autowired
import org.springframework.boot.test.autoconfigure.orm.jpa.DataJpaTest
import org.springframework.context.annotation.Import
import java.time.LocalDateTime
import javax.persistence.Column
import javax.persistence.EntityManager
@DataJpaTest(properties = ["spring.cache.type=none"])
@Import(QueryDslConfig::class)
class ChargeRepositoryTest @Autowired constructor(
private val entityManager: EntityManager,
private val repository: ChargeRepository
) {
@Test
@DisplayName("최초 유료 충전 fact는 엔티티에 저장되는 속성이다")
fun shouldMapFirstFactAsPersistentAttribute() {
assertTrue(entityManager.metamodel.entity(Charge::class.java).attributes.any { it.name == "isFirstCharged" })
val column = Charge::class.java.getDeclaredField("isFirstCharged").getAnnotation(Column::class.java)
assertEquals("is_first_charged", column.name)
assertEquals(true, column.nullable)
assertEquals("bit", MySQL8Dialect().getTypeName(BooleanType.INSTANCE.sqlType()))
}
@ParameterizedTest
@NullSource
@ValueSource(booleans = [true, false])
@DisplayName("nullable 최초 fact의 null true false가 DB 왕복 후 보존된다")
fun shouldRoundTripNullableFirstFact(marker: Boolean?) {
val owner = owner()
val charge = saveCharge(owner, ChargeStatus.CHARGE, PaymentStatus.REQUEST, PaymentGateway.PG).apply {
isFirstCharged = marker
}
entityManager.flush()
entityManager.clear()
assertEquals(marker, repository.findById(charge.id!!).orElseThrow().isFirstCharged)
}
@ParameterizedTest
@MethodSource("historyCases")
@DisplayName("유료 이력은 네 gateway와 정확한 충전 완료 또는 취소 환불 쌍만 인정한다")
fun shouldFindOnlyOtherSameMemberPaidChargeOrReturnedCancellation(
chargeStatus: ChargeStatus,
paymentStatus: PaymentStatus,
gateway: PaymentGateway,
expected: Boolean
) {
val owner = owner()
val current = saveCharge(owner, ChargeStatus.CHARGE, PaymentStatus.COMPLETE, PaymentGateway.PG)
saveCharge(owner, chargeStatus, paymentStatus, gateway)
entityManager.flush()
entityManager.clear()
assertEquals(expected, repository.existsSuccessfulPaidCharge(owner.id!!, current.id!!))
}
@Test
@DisplayName("현재 charge와 다른 회원 이력은 제외하고 생성 순서 잔액 marker로 이력을 제한하지 않는다")
fun shouldExcludeCurrentAndOtherMembersWithoutOrderingOrMarkerFilters() {
val owner = owner()
val current = saveCharge(owner, ChargeStatus.CHARGE, PaymentStatus.COMPLETE, PaymentGateway.PG)
saveCharge(owner(), ChargeStatus.CHARGE, PaymentStatus.COMPLETE, PaymentGateway.APPLE_IAP)
assertEquals(false, repository.existsSuccessfulPaidCharge(owner.id!!, current.id!!))
val later = saveCharge(owner, ChargeStatus.CANCEL, PaymentStatus.RETURN, PaymentGateway.PAYVERSE)
later.createdAt = LocalDateTime.of(2020, 1, 1, 0, 0)
current.createdAt = LocalDateTime.of(2030, 1, 1, 0, 0)
entityManager.flush()
entityManager.clear()
assertEquals(true, repository.existsSuccessfulPaidCharge(owner.id!!, current.id!!))
}
private fun owner(): Member {
return Member(password = "password", nickname = "history-owner").also { entityManager.persist(it) }
}
private fun saveCharge(owner: Member, status: ChargeStatus, paymentStatus: PaymentStatus, gateway: PaymentGateway): Charge {
return repository.save(
Charge(0, 0, status).apply {
member = owner
payment = Payment(paymentStatus, gateway)
}
)
}
companion object {
@JvmStatic
fun historyCases(): List<Arguments> {
val pairs = listOf(
Triple(ChargeStatus.CHARGE, PaymentStatus.COMPLETE, true),
Triple(ChargeStatus.CANCEL, PaymentStatus.RETURN, true),
Triple(ChargeStatus.CHARGE, PaymentStatus.RETURN, false),
Triple(ChargeStatus.CANCEL, PaymentStatus.COMPLETE, false),
Triple(ChargeStatus.CHARGE, PaymentStatus.REQUEST, false),
Triple(ChargeStatus.CANCEL, PaymentStatus.REQUEST, false)
) + listOf(ChargeStatus.EVENT, ChargeStatus.COUPON, ChargeStatus.ADMIN, ChargeStatus.ADS, ChargeStatus.REFUND_CHARGE)
.flatMap { status -> PaymentStatus.values().map { Triple(status, it, false) } }
return pairs.flatMap { (status, payment, paid) ->
listOf(PaymentGateway.PG, PaymentGateway.PAYVERSE, PaymentGateway.APPLE_IAP, PaymentGateway.GOOGLE_IAP)
.map { Arguments.of(status, payment, it, paid) } +
Arguments.of(status, payment, PaymentGateway.POINT_CLICK_AD, false)
}
}
}
}
@@ -0,0 +1,576 @@
package kr.co.vividnext.sodalive.can.charge
import kr.co.bootpay.Bootpay
import kr.co.vividnext.sodalive.admin.charge.AdminChargeRefundRequest
import kr.co.vividnext.sodalive.admin.charge.AdminChargeRefundService
import kr.co.vividnext.sodalive.can.Can
import kr.co.vividnext.sodalive.can.CanStatus
import kr.co.vividnext.sodalive.can.payment.Payment
import kr.co.vividnext.sodalive.can.payment.PaymentGateway
import kr.co.vividnext.sodalive.can.payment.PaymentStatus
import kr.co.vividnext.sodalive.common.SodaException
import kr.co.vividnext.sodalive.google.GooglePlayService
import kr.co.vividnext.sodalive.member.Member
import kr.co.vividnext.sodalive.member.MemberRepository
import kr.co.vividnext.sodalive.support.EmbeddedRedisInitializer
import kr.co.vividnext.sodalive.v2.can.charge.event.ChargeEventJobRepository
import kr.co.vividnext.sodalive.v2.can.charge.event.ChargeEventJobService
import okhttp3.Call
import okhttp3.MediaType.Companion.toMediaType
import okhttp3.OkHttpClient
import okhttp3.Protocol
import okhttp3.Request
import okhttp3.Response
import okhttp3.ResponseBody.Companion.toResponseBody
import org.apache.commons.codec.digest.DigestUtils
import org.junit.jupiter.api.Assertions.assertAll
import org.junit.jupiter.api.Assertions.assertEquals
import org.junit.jupiter.api.Assertions.assertThrows
import org.junit.jupiter.api.Assertions.assertTrue
import org.junit.jupiter.api.BeforeEach
import org.junit.jupiter.api.DisplayName
import org.junit.jupiter.api.Test
import org.junit.jupiter.params.ParameterizedTest
import org.junit.jupiter.params.provider.Arguments
import org.junit.jupiter.params.provider.EnumSource
import org.junit.jupiter.params.provider.MethodSource
import org.junit.jupiter.params.provider.ValueSource
import org.mockito.Mockito
import org.springframework.beans.factory.annotation.Autowired
import org.springframework.boot.test.context.SpringBootTest
import org.springframework.boot.test.mock.mockito.MockBean
import org.springframework.boot.test.mock.mockito.SpyBean
import org.springframework.core.env.Environment
import org.springframework.test.annotation.DirtiesContext
import org.springframework.test.context.ContextConfiguration
import org.springframework.transaction.TransactionDefinition
import org.springframework.transaction.annotation.Propagation
import org.springframework.transaction.annotation.Transactional
import org.springframework.transaction.support.TransactionSynchronizationManager
import org.springframework.transaction.support.TransactionTemplate
import java.math.BigDecimal
import java.util.concurrent.CountDownLatch
import java.util.concurrent.Executors
import java.util.concurrent.TimeUnit
import javax.persistence.EntityManager
@SpringBootTest(
properties = [
"spring.cache.type=none",
"spring.datasource.url=jdbc:h2:mem:charge-service-integration;" +
"MODE=MySQL;DATABASE_TO_UPPER=false;NON_KEYWORDS=VALUE;DB_CLOSE_DELAY=-1;DB_CLOSE_ON_EXIT=FALSE",
"apple.iap-verify-url=https://apple.invalid/verifyReceipt",
"apple.iap-verify-sandbox-url=https://apple-sandbox.invalid/verifyReceipt",
"payverse.host=https://payverse.invalid",
"payverse.usd-mid=t2-test-mid",
"payverse.usd-secret-key=t2-test-secret"
]
)
@ContextConfiguration(initializers = [EmbeddedRedisInitializer::class])
@DirtiesContext(classMode = DirtiesContext.ClassMode.AFTER_CLASS)
@Transactional(propagation = Propagation.NOT_SUPPORTED)
class ChargeServiceIntegrationTest @Autowired constructor(
private val chargeRepository: ChargeRepository,
private val memberRepository: MemberRepository,
private val jobRepository: ChargeEventJobRepository,
private val transactionTemplate: TransactionTemplate,
private val entityManager: EntityManager,
private val environment: Environment,
private val refundService: AdminChargeRefundService
) {
@SpyBean
private lateinit var service: ChargeService
@MockBean
private lateinit var okHttpClient: OkHttpClient
@MockBean
private lateinit var googlePlayService: GooglePlayService
@SpyBean
private lateinit var chargeEventJobService: ChargeEventJobService
private lateinit var bootpay: Bootpay
@BeforeEach
fun setUp() {
val blockedRequest = Request.Builder().url("https://network-blocked.invalid").build()
Mockito.doThrow(AssertionError("외부 HTTP 호출 금지"))
.`when`(okHttpClient).newCall(Mockito.any(Request::class.java) ?: blockedRequest)
Mockito.doThrow(AssertionError("실제 Google 구매 검증 금지"))
.`when`(googlePlayService).verifyAndConsumePurchase(Mockito.anyString(), Mockito.anyString())
bootpay = Mockito.mock(Bootpay::class.java)
Mockito.doThrow(AssertionError("실제 Bootpay 토큰 요청 금지")).`when`(bootpay).accessToken
Mockito.doThrow(AssertionError("실제 Bootpay 영수증 조회 금지"))
.`when`(bootpay).getReceipt(Mockito.anyString())
Mockito.doReturn(bootpay).`when`(service).createBootpay(Mockito.anyString(), Mockito.anyString())
}
@ParameterizedTest
@EnumSource(VerificationPath::class)
@DisplayName("타인 소유 완료 충전은 다섯 검증 경로에서 완료 shortcut보다 먼저 거부된다")
fun shouldRejectForeignCompletedChargeBeforeShortcut(path: VerificationPath) {
val fixture = createFixture(path.gateway, PaymentStatus.COMPLETE)
val before = storedState(fixture)
assertAll(
{
val exception = assertThrows(SodaException::class.java) {
verify(path, fixture.requesterId, fixture.chargeId)
}
assertEquals("can.charge.invalid_payment_info", exception.messageKey)
},
{ assertEquals(before, storedState(fixture)) },
{ assertNoProviderOrEventCalls() }
)
}
@ParameterizedTest
@EnumSource(VerificationPath::class)
@DisplayName("타인 소유 요청 충전은 다섯 검증 경로에서 provider 호출과 변경 전에 거부된다")
fun shouldRejectForeignPendingChargeBeforeProviderOrMutation(path: VerificationPath) {
val fixture = createFixture(path.gateway, PaymentStatus.REQUEST)
val before = storedState(fixture)
assertAll(
{
val exception = assertThrows(SodaException::class.java) {
verify(path, fixture.requesterId, fixture.chargeId)
}
assertEquals("can.charge.invalid_payment_info", exception.messageKey)
},
{ assertEquals(before, storedState(fixture)) },
{ assertNoProviderOrEventCalls() }
)
}
@ParameterizedTest
@EnumSource(VerificationPath::class)
@DisplayName("본인 소유 완료 충전 재조회는 상태와 잔액을 중복 변경하지 않는다")
fun shouldReplayOwnCompletedChargeWithoutMutation(path: VerificationPath) {
val fixture = createFixture(path.gateway, PaymentStatus.COMPLETE)
val before = storedState(fixture)
val response = verify(path, fixture.ownerId, fixture.chargeId)
assertAll(
{ assertEquals(0, BigDecimal("9.99").compareTo(response.price)) },
{ assertEquals("USD", response.currencyCode) },
{ assertEquals(false, response.isFirstCharged) },
{ assertEquals(before, storedState(fixture)) },
{ assertNoProviderOrEventCalls() }
)
}
@ParameterizedTest
@EnumSource(VerificationPath::class)
@DisplayName("첫 완료 fact는 저장되고 이후 구매 뒤에도 첫 결제 응답에 유지된다")
fun shouldPersistFirstFactOnlyForFirstPaidCompletion(path: VerificationPath) {
val fixture = createFixture(path.gateway, PaymentStatus.REQUEST)
enableSuccessfulProvider(path, fixture)
assertEquals(true, verify(path, fixture.ownerId, fixture.chargeId).isFirstCharged)
assertEquals(true, storedState(fixture).firstFact)
val second = addCharge(fixture, PaymentGateway.GOOGLE_IAP)
enableSuccessfulProvider(VerificationPath.GOOGLE, second)
assertEquals(false, verify(VerificationPath.GOOGLE, second.ownerId, second.chargeId).isFirstCharged)
assertEquals(false, storedState(second).firstFact)
val beforeReplay = storedState(fixture)
assertEquals(true, verify(path, fixture.ownerId, fixture.chargeId).isFirstCharged)
assertEquals(beforeReplay, storedState(fixture))
}
@ParameterizedTest
@MethodSource("paidHistories")
@DisplayName("과거 완료 null과 환불 null은 gateway가 달라도 다음 최초 fact를 false로 확정한다")
fun shouldRespectLegacyPaidHistory(gateway: PaymentGateway, returned: Boolean) {
val history = createFixture(gateway, if (returned) PaymentStatus.RETURN else PaymentStatus.COMPLETE)
if (returned) {
transactionTemplate.executeWithoutResult {
chargeRepository.findById(history.chargeId).orElseThrow().status = ChargeStatus.CANCEL
}
}
val next = addCharge(history, PaymentGateway.GOOGLE_IAP)
enableSuccessfulProvider(VerificationPath.GOOGLE, next)
assertEquals(false, verify(VerificationPath.GOOGLE, next.ownerId, next.chargeId).isFirstCharged)
assertEquals(false, storedState(next).firstFact)
assertEquals(null, storedState(history).firstFact)
}
@ParameterizedTest
@EnumSource(VerificationPath::class)
@DisplayName("과거 완료 null은 보존하고 실제 관리자 환불 후에도 확정된 최초 fact는 보존한다")
fun shouldPreserveLegacyNullOnReplayAndPersistFactAcrossRefund(path: VerificationPath) {
val legacy = createFixture(path.gateway, PaymentStatus.COMPLETE)
assertEquals(false, verify(path, legacy.ownerId, legacy.chargeId).isFirstCharged)
assertEquals(null, storedState(legacy).firstFact)
val first = createFixture(path.gateway, PaymentStatus.REQUEST)
enableSuccessfulProvider(path, first)
assertEquals(true, verify(path, first.ownerId, first.chargeId).isFirstCharged)
refundService.refund(AdminChargeRefundRequest(first.chargeId))
val refunded = storedState(first)
assertEquals(ChargeStatus.CANCEL, refunded.chargeStatus)
assertEquals(PaymentStatus.RETURN, refunded.paymentStatus)
assertEquals(true, refunded.firstFact)
val next = addCharge(first, PaymentGateway.GOOGLE_IAP)
enableSuccessfulProvider(VerificationPath.GOOGLE, next)
assertEquals(false, verify(VerificationPath.GOOGLE, next.ownerId, next.chargeId).isFirstCharged)
assertEquals(true, storedState(first).firstFact)
}
@ParameterizedTest
@MethodSource("refundedReverifications")
@DisplayName("기존 PG Hecto Apple 환불 재검증 동작은 유지하되 marker는 재계산하지 않는다")
fun shouldNotRecomputeFactForNonRequestReverification(path: VerificationPath, marker: Boolean?) {
val fixture = createFixture(path.gateway, PaymentStatus.RETURN)
transactionTemplate.executeWithoutResult {
chargeRepository.findById(fixture.chargeId).orElseThrow().isFirstCharged = marker
}
enableSuccessfulProvider(path, fixture)
assertEquals(marker == true, verify(path, fixture.ownerId, fixture.chargeId).isFirstCharged)
assertEquals(marker, storedState(fixture).firstFact)
}
@ParameterizedTest
@EnumSource(VerificationPath::class)
@DisplayName("완료 후 이벤트 처리 실패는 최초 fact 결제 상태와 잔액을 함께 rollback한다")
fun shouldRollBackFirstFactWhenCompletionFails(path: VerificationPath) {
val fixture = createFixture(path.gateway, PaymentStatus.REQUEST)
enableSuccessfulProvider(path, fixture)
val before = storedState(fixture)
failAfterCompletion(fixture)
assertThrows(RuntimeException::class.java) { verify(path, fixture.ownerId, fixture.chargeId) }
assertEquals(before, storedState(fixture))
}
@Test
@DisplayName("Payverse webhook 최초 완료는 fact를 저장하고 이후 verify는 중복 지급 없이 재생한다")
fun shouldPersistFactWhenPayverseWebhookCompletesFirst() {
val fixture = createFixture(PaymentGateway.PAYVERSE, PaymentStatus.REQUEST)
val request = webhook(fixture)
val before = storedState(fixture)
assertEquals(false, service.payverseWebhook(request.copy(sign = "invalid")))
assertEquals(before, storedState(fixture))
Mockito.doAnswer { invocation ->
assertReadCommitted()
invocation.callRealMethod()
}.`when`(chargeEventJobService).createAndProcessImmediate(fixture.chargeId, fixture.ownerId)
assertEquals(true, service.payverseWebhook(request))
assertEquals(true, storedState(fixture).firstFact)
val next = addCharge(fixture, PaymentGateway.GOOGLE_IAP)
enableSuccessfulProvider(VerificationPath.GOOGLE, next)
assertEquals(false, verify(VerificationPath.GOOGLE, next.ownerId, next.chargeId).isFirstCharged)
val completed = storedState(fixture)
assertEquals(true, service.payverseWebhook(request))
assertEquals(true, verify(VerificationPath.PAYVERSE, fixture.ownerId, fixture.chargeId).isFirstCharged)
assertEquals(completed, storedState(fixture))
assertNoProviderOrEventCallsForReplay(fixture)
}
@Test
@DisplayName("Payverse webhook 실패도 최초 fact와 잔액을 함께 rollback한다")
fun shouldRollBackWebhookFactWhenCompletionFails() {
val fixture = createFixture(PaymentGateway.PAYVERSE, PaymentStatus.REQUEST)
val before = storedState(fixture)
failAfterCompletion(fixture)
assertThrows(IllegalStateException::class.java) { service.payverseWebhook(webhook(fixture)) }
assertEquals(before, storedState(fixture))
}
@ParameterizedTest
@ValueSource(booleans = [false, true])
@DisplayName("동일 회원 다른 charge의 동시 완료는 기존 이력이 없으면 하나만 true 있으면 둘 다 false다")
fun shouldSetExactlyOneFirstFactForConcurrentCharges(existingHistory: Boolean) {
val first = createFixture(PaymentGateway.GOOGLE_IAP, PaymentStatus.REQUEST)
val second = addCharge(first, PaymentGateway.PG)
if (existingHistory) addCharge(first, PaymentGateway.APPLE_IAP, PaymentStatus.COMPLETE)
enableSuccessfulProvider(VerificationPath.GOOGLE, first)
enableSuccessfulProvider(VerificationPath.HECTO, second)
val bothChargesLocked = CountDownLatch(2)
Mockito.doAnswer { invocation ->
awaitOtherLockedCharge(first, bothChargesLocked)
invocation.callRealMethod()
}.`when`(service).processGoogleIap(first.ownerId, first.chargeId, "test-product", "test-purchase")
Mockito.doAnswer { invocation ->
awaitOtherLockedCharge(second, bothChargesLocked)
invocation.callRealMethod()
}.`when`(service).verifyHecto(second.ownerId, VerifyRequest("test-receipt", second.chargeId.toString()))
val executor = Executors.newFixedThreadPool(2)
try {
val futures = listOf(first to VerificationPath.GOOGLE, second to VerificationPath.HECTO).map { (fixture, path) ->
executor.submit<ChargeCompleteResponse> {
verify(path, fixture.ownerId, fixture.chargeId)
}
}
val responses = futures.map { it.get(30, TimeUnit.SECONDS) }
assertEquals(if (existingHistory) 0 else 1, responses.count { it.isFirstCharged })
val states = listOf(storedState(first), storedState(second))
assertEquals(if (existingHistory) 0 else 1, states.count { it.firstFact == true })
assertEquals(if (existingHistory) 2 else 1, states.count { it.firstFact == false })
assertTrue(states.all { it.paymentStatus == PaymentStatus.COMPLETE })
assertEquals(listOf(200, 20, 300, 20, 300, 30), states.first().balances.first())
} finally {
executor.shutdownNow()
assertTrue(executor.awaitTermination(10, TimeUnit.SECONDS))
}
}
private fun awaitOtherLockedCharge(fixture: Fixture, bothChargesLocked: CountDownLatch) {
assertReadCommitted()
val locked = chargeRepository.findByIdForUpdate(fixture.chargeId)!!
assertEquals(PaymentStatus.REQUEST, locked.payment!!.status)
bothChargesLocked.countDown()
assertTrue(bothChargesLocked.await(10, TimeUnit.SECONDS))
}
@Test
@DisplayName("동일 charge 동시 재시도는 한 번만 지급하고 같은 최초 fact를 반환한다")
fun shouldCreditSameChargeOnlyOnceUnderConcurrentReplay() {
val fixture = createFixture(PaymentGateway.GOOGLE_IAP, PaymentStatus.REQUEST)
enableSuccessfulProvider(VerificationPath.GOOGLE, fixture)
val ready = CountDownLatch(2)
val executor = Executors.newFixedThreadPool(2)
try {
val futures = (1..2).map {
executor.submit<ChargeCompleteResponse> {
ready.countDown()
assertTrue(ready.await(10, TimeUnit.SECONDS))
verify(VerificationPath.GOOGLE, fixture.ownerId, fixture.chargeId)
}
}
assertTrue(futures.map { it.get(30, TimeUnit.SECONDS) }.all { it.isFirstCharged })
assertEquals(true, storedState(fixture).firstFact)
assertEquals(listOf(100, 10, 300, 20, 300, 30), storedState(fixture).balances.first())
Mockito.verify(googlePlayService, Mockito.times(1)).verifyAndConsumePurchase("test-purchase", "test-product")
} finally {
executor.shutdownNow()
assertTrue(executor.awaitTermination(10, TimeUnit.SECONDS))
}
}
private fun failAfterCompletion(fixture: Fixture) {
Mockito.doAnswer {
assertReadCommitted()
val charge = chargeRepository.findById(fixture.chargeId).orElseThrow()
assertEquals(true, charge.isFirstCharged)
assertEquals(PaymentStatus.COMPLETE, charge.payment!!.status)
entityManager.flush()
throw IllegalStateException("test event failure after completion")
}.`when`(chargeEventJobService).createAndProcessImmediate(fixture.chargeId, fixture.ownerId)
}
private fun assertReadCommitted() {
assertTrue(TransactionSynchronizationManager.isActualTransactionActive())
assertEquals(
TransactionDefinition.ISOLATION_READ_COMMITTED,
TransactionSynchronizationManager.getCurrentTransactionIsolationLevel()
)
}
private fun assertNoProviderOrEventCallsForReplay(fixture: Fixture) {
Mockito.verifyNoInteractions(bootpay, okHttpClient)
Mockito.verify(chargeEventJobService, Mockito.times(1)).createAndProcessImmediate(fixture.chargeId, fixture.ownerId)
}
private fun webhook(fixture: Fixture): PayverseWebhookRequest {
val sign = DigestUtils.sha512Hex("||t2-test-secret||t2-test-mid||${fixture.chargeId}||9.99||20261103||")
return PayverseWebhookRequest(
"PAYMENT", "t2-test-mid", "test-tid", "OTHER", "card", fixture.chargeId.toString(),
"USD", BigDecimal("9.99"), "SUCCESS", "20261103", sign
)
}
private fun enableSuccessfulProvider(path: VerificationPath, fixture: Fixture) {
when (path) {
VerificationPath.VERIFY, VerificationPath.HECTO -> {
Mockito.doAnswer {
assertReadCommitted()
hashMapOf<String, Any>("access_token" to "test-token")
}.`when`(bootpay).accessToken
Mockito.doReturn(
hashMapOf<String, Any>(
"receipt_id" to "test-receipt",
"method" to "card",
"pg" to "test-pg",
"status" to 1,
"price" to BigDecimal("9.9900")
)
).`when`(bootpay).getReceipt("test-receipt")
}
VerificationPath.GOOGLE -> Mockito.doAnswer {
assertReadCommitted()
"test-order"
}.`when`(googlePlayService)
.verifyAndConsumePurchase("test-purchase", "test-product")
VerificationPath.APPLE, VerificationPath.PAYVERSE -> {
val url = if (path == VerificationPath.APPLE) {
"https://apple.invalid/verifyReceipt"
} else {
"https://payverse.invalid/payment/search/transaction/test-transaction"
}
val body = if (path == VerificationPath.APPLE) {
"""{"status":0}"""
} else {
val customerId = "${environment.getRequiredProperty("server.env")}_user_${fixture.ownerId}"
"""{"resultStatus":"SUCCESS","tid":"test-tid","schemeGroup":"OTHER","schemeCode":"card",
"transactionType":"PAYMENT","transactionStatus":"SUCCESS","transactionMessage":"ok",
"orderId":"${fixture.chargeId}","customerId":"$customerId",
"requestCurrency":"USD","requestAmount":9.99}"""
}
val call = Mockito.mock(Call::class.java)
Mockito.doAnswer {
assertReadCommitted()
Response.Builder().request(Request.Builder().url(url).build()).protocol(Protocol.HTTP_1_1)
.code(200).message("OK").body(body.toResponseBody("application/json".toMediaType())).build()
}.`when`(call).execute()
Mockito.doAnswer { invocation ->
val request = invocation.getArgument<Request>(0)
assertEquals(url, request.url.toString())
call
}.`when`(okHttpClient).newCall(Mockito.any(Request::class.java) ?: Request.Builder().url(url).build())
}
}
}
private fun addCharge(fixture: Fixture, gateway: PaymentGateway, status: PaymentStatus = PaymentStatus.REQUEST): Fixture {
return transactionTemplate.execute {
val original = chargeRepository.findById(fixture.chargeId).orElseThrow()
val charge = Charge(100, 10).apply {
title = "100 + 10"
can = original.can
member = original.member
payment = Payment(status, gateway).apply {
price = BigDecimal("9.99")
locale = "USD"
}
}
chargeRepository.saveAndFlush(charge)
entityManager.clear()
fixture.copy(chargeId = charge.id!!)
}!!
}
private fun verify(path: VerificationPath, memberId: Long, chargeId: Long): ChargeCompleteResponse {
return when (path) {
VerificationPath.VERIFY -> service.verify(memberId, VerifyRequest("test-receipt", chargeId.toString()))
VerificationPath.HECTO -> service.verifyHecto(memberId, VerifyRequest("test-receipt", chargeId.toString()))
VerificationPath.PAYVERSE -> service.payverseVerify(
memberId,
PayverseVerifyRequest("test-transaction", chargeId.toString())
)
VerificationPath.APPLE -> service.appleVerify(memberId, AppleVerifyRequest("test-receipt", chargeId))
VerificationPath.GOOGLE -> service.processGoogleIap(memberId, chargeId, "test-product", "test-purchase")
}
}
private fun createFixture(gateway: PaymentGateway, status: PaymentStatus): Fixture {
return transactionTemplate.execute {
val owner = Member(password = "password", nickname = "charge-owner").apply {
pgChargeCan = 100
pgRewardCan = 10
googleChargeCan = 200
googleRewardCan = 20
appleChargeCan = 300
appleRewardCan = 30
}
val requester = Member(password = "password", nickname = "charge-requester").apply {
pgChargeCan = 400
pgRewardCan = 40
googleChargeCan = 500
googleRewardCan = 50
appleChargeCan = 600
appleRewardCan = 60
}
entityManager.persist(owner)
entityManager.persist(requester)
val product = Can("100 + 10", 100, 10, BigDecimal("9.99"), "USD", CanStatus.SALE)
entityManager.persist(product)
val charge = Charge(100, 10).apply {
can = product
title = "100 + 10"
member = owner
payment = Payment(status = status, paymentGateway = gateway).apply {
price = BigDecimal("9.99")
locale = "USD"
receiptId = "stored-receipt"
method = "stored-method"
orderId = "stored-order"
}
}
chargeRepository.saveAndFlush(charge)
val fixture = Fixture(owner.id!!, requester.id!!, charge.id!!)
entityManager.clear()
fixture
}!!
}
private fun storedState(fixture: Fixture): StoredState {
return transactionTemplate.execute {
entityManager.flush()
entityManager.clear()
val charge = chargeRepository.findById(fixture.chargeId).orElseThrow()
val payment = charge.payment!!
val balances = listOf(fixture.ownerId, fixture.requesterId).map { memberId ->
val member = memberRepository.findById(memberId).orElseThrow()
listOf(
member.pgChargeCan,
member.pgRewardCan,
member.googleChargeCan,
member.googleRewardCan,
member.appleChargeCan,
member.appleRewardCan
)
}
StoredState(
charge.member!!.id!!, charge.status, charge.chargeCan, charge.rewardCan,
payment.status, payment.receiptId, payment.method, payment.orderId,
balances, chargeRepository.count(), jobRepository.count(), charge.isFirstCharged
)
}!!
}
private fun assertNoProviderOrEventCalls() {
Mockito.verifyNoInteractions(bootpay, okHttpClient, googlePlayService)
Mockito.verify(service, Mockito.never()).createBootpay(Mockito.anyString(), Mockito.anyString())
Mockito.verify(chargeEventJobService, Mockito.never())
.createAndProcessImmediate(Mockito.anyLong(), Mockito.anyLong())
}
enum class VerificationPath(val gateway: PaymentGateway) {
VERIFY(PaymentGateway.PG),
HECTO(PaymentGateway.PG),
PAYVERSE(PaymentGateway.PAYVERSE),
APPLE(PaymentGateway.APPLE_IAP),
GOOGLE(PaymentGateway.GOOGLE_IAP)
}
private data class Fixture(val ownerId: Long, val requesterId: Long, val chargeId: Long)
private data class StoredState(
val ownerId: Long,
val chargeStatus: ChargeStatus,
val chargeCan: Int,
val rewardCan: Int,
val paymentStatus: PaymentStatus,
val receiptId: String?,
val method: String?,
val orderId: String?,
val balances: List<List<Int>>,
val chargeCount: Long,
val eventJobCount: Long,
val firstFact: Boolean?
)
companion object {
@JvmStatic
fun paidHistories(): List<Arguments> {
return listOf(PaymentGateway.PG, PaymentGateway.PAYVERSE, PaymentGateway.APPLE_IAP, PaymentGateway.GOOGLE_IAP)
.flatMap { gateway -> listOf(Arguments.of(gateway, false), Arguments.of(gateway, true)) }
}
@JvmStatic
fun refundedReverifications(): List<Arguments> {
return listOf(VerificationPath.VERIFY, VerificationPath.HECTO, VerificationPath.APPLE)
.flatMap { path -> listOf(null, true, false).map { Arguments.of(path, it) } }
}
}
}