feat(charge): 최초 유료 충전 판정을 저장하고 완료 정보를 확장한다
This commit is contained in:
@@ -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
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -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) } }
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user