diff --git a/src/main/java/umc/th/juinjang/api/apple/controller/AppleController.java b/src/main/java/umc/th/juinjang/api/apple/controller/AppleController.java index cbe3a0d2..35bc8740 100644 --- a/src/main/java/umc/th/juinjang/api/apple/controller/AppleController.java +++ b/src/main/java/umc/th/juinjang/api/apple/controller/AppleController.java @@ -1,13 +1,52 @@ package umc.th.juinjang.api.apple.controller; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestBody; import org.springframework.web.bind.annotation.RequestMapping; import org.springframework.web.bind.annotation.RestController; +import com.apple.itunes.storekit.model.Data; +import com.apple.itunes.storekit.model.JWSTransactionDecodedPayload; +import com.apple.itunes.storekit.model.NotificationTypeV2; +import com.apple.itunes.storekit.model.ResponseBodyV2; +import com.apple.itunes.storekit.model.ResponseBodyV2DecodedPayload; + +import io.swagger.v3.oas.annotations.Operation; import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import umc.th.juinjang.api.apple.service.AppleService; +import umc.th.juinjang.api.pencil.service.PencilCommandService; +import umc.th.juinjang.api.pencil.service.PencilQueryService; @RestController -@RequestMapping("/api/v2/apple") +@RequestMapping("/api/apple") @RequiredArgsConstructor +@Slf4j public class AppleController { + private final AppleService appleService; + private final PencilQueryService pencilQueryService; + private final PencilCommandService pencilCommandService; + + @Operation(summary = "애플 서버 알림 API") + @PostMapping("notifications/v2") + public ResponseEntity handleNotificationV2(@RequestBody ResponseBodyV2 requestBody) { + ResponseBodyV2DecodedPayload payload = appleService.getNotificationPayload(requestBody); + NotificationTypeV2 type = payload.getNotificationType(); + + Data data = payload.getData(); + JWSTransactionDecodedPayload transactionPayload = + appleService.getSignedTransactionPayload(data); + if (type == NotificationTypeV2.CONSUMPTION_REQUEST) { + log.info("Apple IAP Consumption Request Notification Received."); + String transactionId = transactionPayload.getTransactionId(); + appleService.sendConsumptionData(transactionId, pencilQueryService.getConsumptionRequest(transactionId)); + } else if (type == NotificationTypeV2.REFUND) { + log.info("Apple IAP ReFund Notification Received."); + String transactionId = transactionPayload.getOriginalTransactionId(); + pencilCommandService.handleRefundPurchase(transactionId); + } + return ResponseEntity.ok().build(); + } } diff --git a/src/main/java/umc/th/juinjang/api/apple/service/AppleService.java b/src/main/java/umc/th/juinjang/api/apple/service/AppleService.java index 506fc4de..3c12e600 100644 --- a/src/main/java/umc/th/juinjang/api/apple/service/AppleService.java +++ b/src/main/java/umc/th/juinjang/api/apple/service/AppleService.java @@ -6,6 +6,7 @@ import java.util.Set; import org.springframework.beans.factory.annotation.Value; +import org.springframework.context.annotation.Profile; import org.springframework.core.io.ClassPathResource; import org.springframework.retry.annotation.Backoff; import org.springframework.retry.annotation.Retryable; @@ -13,19 +14,29 @@ import com.apple.itunes.storekit.client.APIException; import com.apple.itunes.storekit.client.AppStoreServerAPIClient; +import com.apple.itunes.storekit.model.ConsumptionRequest; +import com.apple.itunes.storekit.model.Data; import com.apple.itunes.storekit.model.Environment; import com.apple.itunes.storekit.model.JWSTransactionDecodedPayload; +import com.apple.itunes.storekit.model.ResponseBodyV2; +import com.apple.itunes.storekit.model.ResponseBodyV2DecodedPayload; import com.apple.itunes.storekit.model.TransactionInfoResponse; import com.apple.itunes.storekit.verification.SignedDataVerifier; import com.apple.itunes.storekit.verification.VerificationException; import jakarta.annotation.PostConstruct; +import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import umc.th.juinjang.api.apple.service.command.AppleTransactionVerifyCommand; +import umc.th.juinjang.api.pencil.service.PencilQueryService; import umc.th.juinjang.api.pencil.service.response.VerificationResult; +import umc.th.juinjang.common.code.status.ErrorStatus; +import umc.th.juinjang.common.exception.handler.AppleHandler; @Slf4j @Service +@Profile("!local") +@RequiredArgsConstructor public class AppleService { @Value("${apple.iap.bundle-id}") @@ -51,6 +62,7 @@ public class AppleService { private SignedDataVerifier signedDataVerifier; private AppStoreServerAPIClient appStoreServerAPIClient; + private PencilQueryService pencilQueryService; @PostConstruct public void init() { @@ -121,6 +133,35 @@ public VerificationResult verifyAppleTransaction(AppleTransactionVerifyCommand c } } + public void sendConsumptionData(String transactionId, ConsumptionRequest request) { + try { + appStoreServerAPIClient.sendConsumptionData(transactionId, request); + } catch (IOException | APIException e) { + throw new AppleHandler(ErrorStatus.APPLE_VERIFICATION_ERROR); + } + + } + + public ResponseBodyV2DecodedPayload getNotificationPayload(ResponseBodyV2 responseBody) { + try { + return signedDataVerifier.verifyAndDecodeNotification( + responseBody.getSignedPayload()); + } catch (VerificationException e) { + throw new AppleHandler(ErrorStatus.APPLE_VERIFICATION_ERROR); + } + } + + public JWSTransactionDecodedPayload getSignedTransactionPayload( + Data data + ) { + try { + return signedDataVerifier.verifyAndDecodeTransaction( + data.getSignedTransactionInfo()); + } catch (VerificationException e) { + throw new AppleHandler(ErrorStatus.APPLE_VERIFICATION_ERROR); + } + } + private boolean validateTransaction(JWSTransactionDecodedPayload decodedPayload, AppleTransactionVerifyCommand command) { // 트랜잭션 아이디가 정상적으로 일치하는 지 여부 @@ -226,4 +267,5 @@ private String loadSigningKey() { throw new RuntimeException("Failed to load signing key", e); } } + } diff --git a/src/main/java/umc/th/juinjang/api/pencil/controller/request/AppleIAPPurchaseRequest.java b/src/main/java/umc/th/juinjang/api/pencil/controller/request/AppleIAPPurchaseRequest.java index caf5c78f..ec2a99c4 100644 --- a/src/main/java/umc/th/juinjang/api/pencil/controller/request/AppleIAPPurchaseRequest.java +++ b/src/main/java/umc/th/juinjang/api/pencil/controller/request/AppleIAPPurchaseRequest.java @@ -13,11 +13,11 @@ public class AppleIAPPurchaseRequest { private Long pencilQuantity; private Long price; private String productId; - private Long playTime; + private Integer playTime; @Builder private AppleIAPPurchaseRequest(String transactionId, UUID appAccountToken, Long pencilQuantity, Long price, - String productId , Long playTime) { + String productId , Integer playTime) { this.transactionId = transactionId; this.appAccountToken = appAccountToken; this.pencilQuantity = pencilQuantity; @@ -27,7 +27,7 @@ private AppleIAPPurchaseRequest(String transactionId, UUID appAccountToken, Long } public static AppleIAPPurchaseRequest of(String transactionId, UUID appAccountToken, Long pencilQuantity, - Long price, String productId, Long playTime) { + Long price, String productId, Integer playTime) { return AppleIAPPurchaseRequest.builder() .transactionId(transactionId) .appAccountToken(appAccountToken) diff --git a/src/main/java/umc/th/juinjang/api/pencil/service/AcquiredPencilFinder.java b/src/main/java/umc/th/juinjang/api/pencil/service/AcquiredPencilFinder.java index c9ef1eb6..b2e1cc63 100644 --- a/src/main/java/umc/th/juinjang/api/pencil/service/AcquiredPencilFinder.java +++ b/src/main/java/umc/th/juinjang/api/pencil/service/AcquiredPencilFinder.java @@ -26,4 +26,8 @@ public boolean existsByMemberAndIsReadFalse(Member member) { public AcquiredPencil findById(Long id) { return acquiredPencilRepository.findById(id).orElse(null); } + + public boolean existsByMember(Member member) { + return acquiredPencilRepository.existsByMember(member); + } } diff --git a/src/main/java/umc/th/juinjang/api/pencil/service/PencilCommandService.java b/src/main/java/umc/th/juinjang/api/pencil/service/PencilCommandService.java index 02973b7f..4d66b4bc 100644 --- a/src/main/java/umc/th/juinjang/api/pencil/service/PencilCommandService.java +++ b/src/main/java/umc/th/juinjang/api/pencil/service/PencilCommandService.java @@ -20,6 +20,7 @@ import umc.th.juinjang.domain.pencil.purchased.model.PurchasedPencil; import umc.th.juinjang.domain.pencil.purchased.model.TransactionStatus; import umc.th.juinjang.domain.pencilaccount.model.PencilAccount; +import umc.th.juinjang.event.publisher.PaymentEventPublisher; @Slf4j @Service @@ -32,6 +33,7 @@ public class PencilCommandService { private final PurchasedPencilFinder purchasedPencilFinder; private final AcquiredPencilFinder acquiredPencilFinder; private final PencilAccountFinder pencilAccountFinder; + private final PaymentEventPublisher paymentEventPublisher; @Transactional public Boolean markAcquiredPencilAsRead(Long acquiredPencilId) { @@ -84,8 +86,8 @@ public AppleIAPPurchaseResponse validateAndCommitApplePurchase(AppleIAPPurchaseR // 성공 시, DB에 저장 handleSuccessfulApplePurchase(request, member, now); - // TODO : 디스코드 알림 추가 필요 - // paymentEventPublisher.publishPaymentEvent(member,request.getPrice(), pencilAmount,TransactionStatus.SUCCESS); + paymentEventPublisher.publishPaymentEvent(member, request.getPrice(), request.getPencilQuantity(), + TransactionStatus.SUCCESS); PencilAccount buyer = pencilAccountFinder.findByMember(member); return AppleIAPPurchaseResponse.ofSuccess(transactionId, request.getPencilQuantity(), buyer.getTotalBalance()); @@ -93,8 +95,8 @@ public AppleIAPPurchaseResponse validateAndCommitApplePurchase(AppleIAPPurchaseR // 실패 시, DB에 저장 handleFailureApplePurchase(request, member, now); - // TODO : 디스코드 알림 추가 필요 - // paymentEventPublisher.publishPaymentEvent(member,request.getPrice(), pencilAmount,TransactionStatus.VALIDATION_FAILED); + paymentEventPublisher.publishPaymentEvent(member, request.getPrice(), request.getPencilQuantity(), + TransactionStatus.VALIDATION_FAILED); return AppleIAPPurchaseResponse.ofValidationFailure(transactionId); } } @@ -122,7 +124,8 @@ public void handleFailureApplePurchase(AppleIAPPurchaseRequest request, Member m request.getPlayTime(), transactionId, request.getAppAccountToken(), now)); } - private PurchasedPencil retryPurchasedPencil(AppleIAPPurchaseRequest request, PurchasedPencil pencil, + @Transactional + public PurchasedPencil retryPurchasedPencil(AppleIAPPurchaseRequest request, PurchasedPencil pencil, Member member) { if (pencil.getRetryCount() >= 3) { // 재시도 횟수가 3회 이상일 경우 실패로 처리 return pencil; @@ -147,4 +150,35 @@ private String createTitle(Long pencilAmount) { return String.format("연필 %d개 구매", pencilAmount); } + @Transactional + public void handleRefundPurchase(String transactionId) { + PurchasedPencil pencil = purchasedPencilFinder.findByTransactionId(transactionId) + .orElseThrow( + () -> new EntityNotFoundException("PurchasedPencil not found with transactionId: " + transactionId)); + + log.info("Refund processed for transactionId: {}", transactionId); + + pencil.markAsRefund(); + + PencilAccount buyerAccount = pencilAccountFinder.findByMemberWithLock(pencil.getMember()); + executeRefund(buyerAccount, pencil.getPurchaseQuantity(), pencil.getPrice()); + + } + + public void executeRefund(PencilAccount buyerAccount, long pencilQuantity, long price) { + long purchasedToUse = Math.min(buyerAccount.getPurchasedBalance(), pencilQuantity); + buyerAccount.decreasePurchasedBalance(purchasedToUse); + + long remaining = pencilQuantity - purchasedToUse; + long acquiredToUse = Math.min(buyerAccount.getAcquiredBalance(), remaining); + buyerAccount.decreaseAcquiredBalance(acquiredToUse); + + buyerAccount.increaseTotalRefundAmount(price); + // 남은 수량이 0이 아닐 경우 로그 기록 + if (remaining - acquiredToUse > 0) { + log.warn("Not enough balance to fully refund {} pencils. Refunded only {}.", pencilQuantity, + (purchasedToUse + acquiredToUse)); + } + } + } diff --git a/src/main/java/umc/th/juinjang/api/pencil/service/PencilQueryService.java b/src/main/java/umc/th/juinjang/api/pencil/service/PencilQueryService.java index d912dbfb..07bfcc9c 100644 --- a/src/main/java/umc/th/juinjang/api/pencil/service/PencilQueryService.java +++ b/src/main/java/umc/th/juinjang/api/pencil/service/PencilQueryService.java @@ -1,25 +1,45 @@ package umc.th.juinjang.api.pencil.service; +import java.time.LocalDateTime; +import java.time.temporal.ChronoUnit; import java.util.List; +import java.util.Optional; import org.springframework.stereotype.Service; +import com.apple.itunes.storekit.model.AccountTenure; +import com.apple.itunes.storekit.model.ConsumptionRequest; +import com.apple.itunes.storekit.model.ConsumptionStatus; +import com.apple.itunes.storekit.model.DeliveryStatus; +import com.apple.itunes.storekit.model.LifetimeDollarsPurchased; +import com.apple.itunes.storekit.model.LifetimeDollarsRefunded; +import com.apple.itunes.storekit.model.Platform; +import com.apple.itunes.storekit.model.PlayTime; +import com.apple.itunes.storekit.model.RefundPreference; + import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; import umc.th.juinjang.api.pencil.service.response.AcquiredPencilResponse; import umc.th.juinjang.api.pencil.service.response.PurchasedPencilResponse; import umc.th.juinjang.api.pencil.service.response.UsedPencilResponse; +import umc.th.juinjang.api.pencilAccount.service.PencilAccountFinder; +import umc.th.juinjang.common.exception.handler.PencilAccountHandler; import umc.th.juinjang.domain.member.model.Member; import umc.th.juinjang.domain.pencil.acquired.model.AcquiredPencil; import umc.th.juinjang.domain.pencil.purchased.model.PurchasedPencil; import umc.th.juinjang.domain.pencil.used.model.UsedPencil; +import umc.th.juinjang.domain.pencilaccount.model.PencilAccount; +@Slf4j @Service @RequiredArgsConstructor public class PencilQueryService { + private static final double DOLLAR_EXCHANGE_RATE = 1374.0; private final AcquiredPencilFinder acquiredPencilFinder; private final PurchasedPencilFinder purchasedPencilFinder; private final UsedPencilFinder usedPencilFinder; + private final PencilAccountFinder pencilAccountFinder; public List getAcquiredPencils(Member member) { List acquiredPencils = acquiredPencilFinder.findAllByMemberOrderByCreatedAtDesc(member); @@ -47,4 +67,164 @@ public boolean isAcquiredPencilReadStatus(Member member) { // false 인 것이 존재하면 안됨. return !acquiredPencilFinder.existsByMemberAndIsReadFalse(member); } + + public ConsumptionRequest getConsumptionRequest(String transactionId) { + return converterToConsumptionRequest(purchasedPencilFinder.findByTransactionId(transactionId)); + } + + private ConsumptionRequest converterToConsumptionRequest(Optional purchasedPencil) { + if (purchasedPencil.isPresent()) { + PurchasedPencil purchase = purchasedPencil.get(); + Member member = purchase.getMember(); + + ConsumptionRequest request = new ConsumptionRequest(); + request.setCustomerConsented(true); + request.setPlayTime(calculatePlayTime(purchase.getPlayTime())); + request.setAppAccountToken(purchase.getAppAccountToken()); + request.setDeliveryStatus(DeliveryStatus.fromValue(purchase.getDeliveryStatus().getAppleCode())); + request.setConsumptionStatus( + converterToConsumptionStatus(purchase.getPurchaseQuantity(), purchase.getRemainQuantity())); + request.setAccountTenure(calculateAccountTenure(member)); + request.setLifetimeDollarsPurchased(calculateLifeDollarPurchased(member)); + request.setLifetimeDollarsRefunded(calculateLifeDollarRefunded(member)); + request.setPlatform(Platform.APPLE); + request.setSampleContentProvided(getSampleContentProvided(member)); + // request.setUserStatusgetUserStatus(member)); + request.setRefundPreference(RefundPreference.PREFER_GRANT); + log.info("getConsumptionRequest : {}", request); + + return request; + } + return null; + } + + private LifetimeDollarsPurchased calculateLifeDollarPurchased(Member member) { + try { + PencilAccount buyerAccount = pencilAccountFinder.findByMember(member); + long totalPrice = buyerAccount.getTotalPurchaseAmount() - buyerAccount.getTotalRefundAmount(); + + if (totalPrice == 0L) { + return LifetimeDollarsPurchased.ZERO_DOLLARS; + } + + double usdAmount = totalPrice / DOLLAR_EXCHANGE_RATE; + + if (usdAmount <= 0.0) { + return LifetimeDollarsPurchased.ZERO_DOLLARS; + } else if (usdAmount < 50) { + return LifetimeDollarsPurchased.ONE_CENT_TO_FORTY_NINE_DOLLARS_AND_NINETY_NINE_CENTS; + } else if (usdAmount < 100) { + return LifetimeDollarsPurchased.FIFTY_DOLLARS_TO_NINETY_NINE_DOLLARS_AND_NINETY_NINE_CENTS; + } else if (usdAmount < 500) { + return LifetimeDollarsPurchased.ONE_HUNDRED_DOLLARS_TO_FOUR_HUNDRED_NINETY_NINE_DOLLARS_AND_NINETY_NINE_CENTS; + } else if (usdAmount < 1000) { + return LifetimeDollarsPurchased.FIVE_HUNDRED_DOLLARS_TO_NINE_HUNDRED_NINETY_NINE_DOLLARS_AND_NINETY_NINE_CENTS; + } else if (usdAmount < 2000) { + return LifetimeDollarsPurchased.ONE_THOUSAND_DOLLARS_TO_ONE_THOUSAND_NINE_HUNDRED_NINETY_NINE_DOLLARS_AND_NINETY_NINE_CENTS; + } else { + return LifetimeDollarsPurchased.TWO_THOUSAND_DOLLARS_OR_GREATER; + } + } catch (PencilAccountHandler exception) { + return LifetimeDollarsPurchased.UNDECLARED; + } + } + + private LifetimeDollarsRefunded calculateLifeDollarRefunded(Member member) { + try { + PencilAccount buyerAccount = pencilAccountFinder.findByMember(member); + long totalRefundWon = buyerAccount.getTotalRefundAmount(); + + if (totalRefundWon == 0L) { + return LifetimeDollarsRefunded.ZERO_DOLLARS; + } + + double usdAmount = totalRefundWon / DOLLAR_EXCHANGE_RATE; + + if (usdAmount <= 0.0) { + return LifetimeDollarsRefunded.ZERO_DOLLARS; + } else if (usdAmount < 50) { + return LifetimeDollarsRefunded.ONE_CENT_TO_FORTY_NINE_DOLLARS_AND_NINETY_NINE_CENTS; + } else if (usdAmount < 100) { + return LifetimeDollarsRefunded.FIFTY_DOLLARS_TO_NINETY_NINE_DOLLARS_AND_NINETY_NINE_CENTS; + } else if (usdAmount < 500) { + return LifetimeDollarsRefunded.ONE_HUNDRED_DOLLARS_TO_FOUR_HUNDRED_NINETY_NINE_DOLLARS_AND_NINETY_NINE_CENTS; + } else if (usdAmount < 1000) { + return LifetimeDollarsRefunded.FIVE_HUNDRED_DOLLARS_TO_NINE_HUNDRED_NINETY_NINE_DOLLARS_AND_NINETY_NINE_CENTS; + } else if (usdAmount < 2000) { + return LifetimeDollarsRefunded.ONE_THOUSAND_DOLLARS_TO_ONE_THOUSAND_NINE_HUNDRED_NINETY_NINE_DOLLARS_AND_NINETY_NINE_CENTS; + } else { + return LifetimeDollarsRefunded.TWO_THOUSAND_DOLLARS_OR_GREATER; + } + } catch (PencilAccountHandler exception) { + return LifetimeDollarsRefunded.UNDECLARED; + } + + } + + private boolean getSampleContentProvided(Member member) { + return acquiredPencilFinder.existsByMember(member); + } + + private ConsumptionStatus converterToConsumptionStatus(Long purchaseQuantity, Long remainQuantity) { + if (remainQuantity == null || purchaseQuantity == null) { + return ConsumptionStatus.UNDECLARED; + } + + if (remainQuantity.equals(purchaseQuantity)) { + return ConsumptionStatus.NOT_CONSUMED; + } else if (remainQuantity == 0) { + return ConsumptionStatus.FULLY_CONSUMED; + } else if (remainQuantity > 0) { + return ConsumptionStatus.PARTIALLY_CONSUMED; + } + + return ConsumptionStatus.UNDECLARED; + } + + private AccountTenure calculateAccountTenure(Member member) { + // 회원 가입일로부터 현재까지의 기간을 계산 + LocalDateTime memberCreatedAt = member.getCreatedAt(); + LocalDateTime now = LocalDateTime.now(); + long daysBetween = ChronoUnit.DAYS.between(memberCreatedAt, now); + + // 기간에 따라 AccountTenure 반환 + if (daysBetween <= 3) { + return AccountTenure.ZERO_TO_THREE_DAYS; + } else if (daysBetween <= 10) { + return AccountTenure.THREE_DAYS_TO_TEN_DAYS; + } else if (daysBetween <= 30) { + return AccountTenure.TEN_DAYS_TO_THIRTY_DAYS; + } else if (daysBetween <= 90) { + return AccountTenure.THIRTY_DAYS_TO_NINETY_DAYS; + } else if (daysBetween <= 180) { + return AccountTenure.NINETY_DAYS_TO_ONE_HUNDRED_EIGHTY_DAYS; + } else if (daysBetween <= 365) { + return AccountTenure.ONE_HUNDRED_EIGHTY_DAYS_TO_THREE_HUNDRED_SIXTY_FIVE_DAYS; + } else { + return AccountTenure.GREATER_THAN_THREE_HUNDRED_SIXTY_FIVE_DAYS; + } + } + + private PlayTime calculatePlayTime(Integer playTime) { + if (playTime == null || playTime < 0) { + return PlayTime.UNDECLARED; + } + + if (playTime <= 5) { + return PlayTime.ZERO_TO_FIVE_MINUTES; + } else if (playTime <= 60) { + return PlayTime.FIVE_TO_SIXTY_MINUTES; + } else if (playTime <= 360) { // 6시간 + return PlayTime.ONE_TO_SIX_HOURS; + } else if (playTime <= 1440) { // 24시간 + return PlayTime.SIX_HOURS_TO_TWENTY_FOUR_HOURS; + } else if (playTime <= 5760) { // 4일 + return PlayTime.ONE_DAY_TO_FOUR_DAYS; + } else if (playTime <= 23040) { // 16일 + return PlayTime.FOUR_DAYS_TO_SIXTEEN_DAYS; + } else { + return PlayTime.OVER_SIXTEEN_DAYS; + } + } + } diff --git a/src/main/java/umc/th/juinjang/api/pencil/service/PurchasedPencilFinder.java b/src/main/java/umc/th/juinjang/api/pencil/service/PurchasedPencilFinder.java index 62475aad..bf01670f 100644 --- a/src/main/java/umc/th/juinjang/api/pencil/service/PurchasedPencilFinder.java +++ b/src/main/java/umc/th/juinjang/api/pencil/service/PurchasedPencilFinder.java @@ -22,4 +22,16 @@ public List findAllByMemberWhereDeliverySuccessOrderByCreatedAt public Optional findByTransactionIdAndMember(String transactionId, Member member) { return purchasedPencilRepository.findByTransactionIdAndMember(transactionId, member); } + + public Optional findByTransactionId(String transactionId) { + return purchasedPencilRepository.findByTransactionId(transactionId); + } + + public Long getSumPriceWhereMemberAndSuccess(Member member) { + return purchasedPencilRepository.getSumPriceWhereMemberAndSuccess(member).orElse(0L); + } + + public Long getSumPriceWhereMemberAndRefund(Member member) { + return purchasedPencilRepository.getSumPriceWhereMemberAndRefund(member).orElse(0L); + } } diff --git a/src/main/java/umc/th/juinjang/api/pencil/service/response/AppleIAPPurchaseResponse.java b/src/main/java/umc/th/juinjang/api/pencil/service/response/AppleIAPPurchaseResponse.java index 69fee1bd..ff4ea2ff 100644 --- a/src/main/java/umc/th/juinjang/api/pencil/service/response/AppleIAPPurchaseResponse.java +++ b/src/main/java/umc/th/juinjang/api/pencil/service/response/AppleIAPPurchaseResponse.java @@ -10,6 +10,9 @@ public class AppleIAPPurchaseResponse { private final TransactionStatus status; private final String transactionId; private final Long purchaseQuantity; + /** + * 구매한 후에 남아 있는 연필 개수 + */ private final Long remainQuantity; @Builder diff --git a/src/main/java/umc/th/juinjang/common/code/status/ErrorStatus.java b/src/main/java/umc/th/juinjang/common/code/status/ErrorStatus.java index 31ea6a20..1f105800 100644 --- a/src/main/java/umc/th/juinjang/common/code/status/ErrorStatus.java +++ b/src/main/java/umc/th/juinjang/common/code/status/ErrorStatus.java @@ -105,6 +105,9 @@ public enum ErrorStatus implements BaseErrorCode { // PencilAccount alert PENCIL_ACCOUNT_NOT_FOUND(HttpStatus.BAD_REQUEST, "ACCOUNT4000", "멤버에 해당하는 계좌가 존재하지 않습니다."), + // Apple ALERT + APPLE_VERIFICATION_ERROR(HttpStatus.BAD_REQUEST, "APPLE4000", "애플 인증 관련 에러가 발생했습니다."), + SHAREDNOTE_NOT_FOUND(HttpStatus.NOT_FOUND, "SHAREDNOTE4000", "해당하는 공유노트가 존재하지 않습니다."), SHAREDNOTE_NOT_ENOUGH_PENCIL(HttpStatus.BAD_REQUEST, "SHAREDNOTE4001", "보유한 연필 수가 부족합니다."), SHAREDNOTE_CONFLICT(HttpStatus.CONFLICT, "SHAREDNOTE4002", "이미 구매한 노트입니다."), diff --git a/src/main/java/umc/th/juinjang/common/exception/handler/AppleHandler.java b/src/main/java/umc/th/juinjang/common/exception/handler/AppleHandler.java new file mode 100644 index 00000000..6a39fdee --- /dev/null +++ b/src/main/java/umc/th/juinjang/common/exception/handler/AppleHandler.java @@ -0,0 +1,11 @@ +package umc.th.juinjang.common.exception.handler; + +import umc.th.juinjang.common.code.BaseErrorCode; +import umc.th.juinjang.common.exception.GeneralException; + +public class AppleHandler extends GeneralException { + public AppleHandler(BaseErrorCode code) { + super(code); + } + +} diff --git a/src/main/java/umc/th/juinjang/domain/pencil/acquired/repository/AcquiredPencilRepository.java b/src/main/java/umc/th/juinjang/domain/pencil/acquired/repository/AcquiredPencilRepository.java index 4d0cb022..43593982 100644 --- a/src/main/java/umc/th/juinjang/domain/pencil/acquired/repository/AcquiredPencilRepository.java +++ b/src/main/java/umc/th/juinjang/domain/pencil/acquired/repository/AcquiredPencilRepository.java @@ -11,4 +11,5 @@ public interface AcquiredPencilRepository extends JpaRepository findAllByMemberOrderByCreatedAtDesc(Member member); boolean existsByMemberAndIsReadFalse(Member member); + boolean existsByMember(Member member); } diff --git a/src/main/java/umc/th/juinjang/domain/pencil/purchased/model/PurchasedPencil.java b/src/main/java/umc/th/juinjang/domain/pencil/purchased/model/PurchasedPencil.java index 14176121..d3fee350 100644 --- a/src/main/java/umc/th/juinjang/domain/pencil/purchased/model/PurchasedPencil.java +++ b/src/main/java/umc/th/juinjang/domain/pencil/purchased/model/PurchasedPencil.java @@ -55,7 +55,7 @@ public class PurchasedPencil { @Column(nullable = false) private TransactionStatus transactionStatus; - private Long playTime; + private Integer playTime; private Long retryCount = 0L; @@ -66,7 +66,7 @@ public class PurchasedPencil { @Builder public PurchasedPencil(Member member, String title, Long purchaseQuantity, - Long remainQuantity, TransactionStatus transactionStatus, Long playTime, + Long remainQuantity, TransactionStatus transactionStatus, Integer playTime, Long price, String transactionId, UUID appAccountToken, DeliveryStatus deliveryStatus, LocalDateTime purchasedAt) { this.member = member; @@ -91,13 +91,17 @@ public void markAsSuccess(){ this.deliveryStatus = DeliveryStatus.DELIVERY_SUCCESS; } + public void markAsRefund(){ + this.transactionStatus = TransactionStatus.REFUNDED; + } + public void updateRetryCount(Long retryCount) { this.retryCount = retryCount; } private static PurchasedPencilBuilder baseBuilder( Member member, String title, Long quantity, Long price, - Long playTime, String transactionId, UUID token, LocalDateTime purchasedAt + Integer playTime, String transactionId, UUID token, LocalDateTime purchasedAt ) { return PurchasedPencil.builder() .member(member) @@ -113,7 +117,7 @@ private static PurchasedPencilBuilder baseBuilder( // ✅ 결제 성공 public static PurchasedPencil successOf(Member member, String title, Long quantity, - Long price, Long playTime, String transactionId, + Long price, Integer playTime, String transactionId, UUID token, LocalDateTime purchasedAt) { return baseBuilder(member, title, quantity, price, playTime, transactionId, token, purchasedAt) .transactionStatus(TransactionStatus.SUCCESS) @@ -123,7 +127,7 @@ public static PurchasedPencil successOf(Member member, String title, Long quanti // ✅ 서버 에러 public static PurchasedPencil failedDueToServerError(Member member, String title, Long quantity, - Long price, Long playTime, String transactionId, UUID token, LocalDateTime purchasedAt) { + Long price, Integer playTime, String transactionId, UUID token, LocalDateTime purchasedAt) { return baseBuilder(member, title, quantity, price, playTime, transactionId, token, purchasedAt) .transactionStatus(TransactionStatus.DB_FAILED) .deliveryStatus(DeliveryStatus.SERVER_ERROR) @@ -132,7 +136,7 @@ public static PurchasedPencil failedDueToServerError(Member member, String title // ✅ 검증 실패 public static PurchasedPencil failedDueToValidation(Member member, String title, Long quantity, - Long price, Long playTime, String transactionId, UUID token, LocalDateTime purchasedAt) { + Long price, Integer playTime, String transactionId, UUID token, LocalDateTime purchasedAt) { return baseBuilder(member, title, quantity, price, playTime, transactionId, token, purchasedAt) .transactionStatus(TransactionStatus.VALIDATION_FAILED) .deliveryStatus(DeliveryStatus.OTHER_REASONS) diff --git a/src/main/java/umc/th/juinjang/domain/pencil/purchased/model/TransactionStatus.java b/src/main/java/umc/th/juinjang/domain/pencil/purchased/model/TransactionStatus.java index c03b12ac..e50c0c78 100644 --- a/src/main/java/umc/th/juinjang/domain/pencil/purchased/model/TransactionStatus.java +++ b/src/main/java/umc/th/juinjang/domain/pencil/purchased/model/TransactionStatus.java @@ -4,5 +4,6 @@ public enum TransactionStatus { // PENDING, SUCCESS, VALIDATION_FAILED, + REFUNDED, DB_FAILED; } diff --git a/src/main/java/umc/th/juinjang/domain/pencil/purchased/repository/PurchasedPencilRepository.java b/src/main/java/umc/th/juinjang/domain/pencil/purchased/repository/PurchasedPencilRepository.java index 6eecc6f6..746e0821 100644 --- a/src/main/java/umc/th/juinjang/domain/pencil/purchased/repository/PurchasedPencilRepository.java +++ b/src/main/java/umc/th/juinjang/domain/pencil/purchased/repository/PurchasedPencilRepository.java @@ -22,4 +22,17 @@ List findByMemberAndDeliverySuccessAndRemainQuantityGreaterThan Optional findByTransactionIdAndMember(String transactionId, Member member); + Optional findByTransactionId(String transactionId); + + @Query("SELECT SUM(p.price) FROM PurchasedPencil p " + + "WHERE p.member = :member " + + "AND p.deliveryStatus = 0 " + + "AND p.transactionStatus = 'SUCCESS'") + Optional getSumPriceWhereMemberAndSuccess(Member member); + + @Query("SELECT SUM(p.price) FROM PurchasedPencil p " + + "WHERE p.member = :member " + + "AND p.deliveryStatus = 0 " + + "AND p.transactionStatus = 'REFUNDED'") + Optional getSumPriceWhereMemberAndRefund(Member member); } diff --git a/src/main/java/umc/th/juinjang/domain/pencilaccount/model/PencilAccount.java b/src/main/java/umc/th/juinjang/domain/pencilaccount/model/PencilAccount.java index 057a3730..48338cd7 100644 --- a/src/main/java/umc/th/juinjang/domain/pencilaccount/model/PencilAccount.java +++ b/src/main/java/umc/th/juinjang/domain/pencilaccount/model/PencilAccount.java @@ -84,4 +84,8 @@ public void decreaseAcquiredBalance(long price) { this.acquiredBalance -= price; this.totalBalance = this.purchasedBalance + this.acquiredBalance; } + + public void increaseTotalRefundAmount(long price) { + this.totalRefundAmount += price; + } } diff --git a/src/main/java/umc/th/juinjang/event/PaymentEvent.java b/src/main/java/umc/th/juinjang/event/PaymentEvent.java new file mode 100644 index 00000000..678bcbe8 --- /dev/null +++ b/src/main/java/umc/th/juinjang/event/PaymentEvent.java @@ -0,0 +1,15 @@ +package umc.th.juinjang.event; + +import umc.th.juinjang.domain.pencil.purchased.model.TransactionStatus; + +public record PaymentEvent( + Long memberId, + String nickname, + Long price, + Long pencilQuantity, + TransactionStatus transactionStatus +) { + public static PaymentEvent of(Long memberId, String nickname, Long price, Long pencilQuantity, TransactionStatus transactionStatus) { + return new PaymentEvent(memberId, nickname, price, pencilQuantity,transactionStatus); + } +} diff --git a/src/main/java/umc/th/juinjang/event/publisher/ApplicationPaymentEventPublisherAdapter.java b/src/main/java/umc/th/juinjang/event/publisher/ApplicationPaymentEventPublisherAdapter.java new file mode 100644 index 00000000..fd71001b --- /dev/null +++ b/src/main/java/umc/th/juinjang/event/publisher/ApplicationPaymentEventPublisherAdapter.java @@ -0,0 +1,29 @@ +package umc.th.juinjang.event.publisher; + +import org.springframework.context.ApplicationEventPublisher; +import org.springframework.stereotype.Component; + +import lombok.RequiredArgsConstructor; +import umc.th.juinjang.domain.member.model.Member; +import umc.th.juinjang.domain.pencil.purchased.model.TransactionStatus; +import umc.th.juinjang.event.PaymentEvent; + +@RequiredArgsConstructor +@Component +public class ApplicationPaymentEventPublisherAdapter implements PaymentEventPublisher { + + private final ApplicationEventPublisher applicationEventPublisher; + + @Override + public void publishPaymentEvent(Member buyer, Long price, Long pencilQuantity, TransactionStatus transactionStatus) { + applicationEventPublisher.publishEvent( + PaymentEvent.of( + buyer.getMemberId(), + buyer.getNickname(), + price, + pencilQuantity, + transactionStatus + ) + ); + } +} diff --git a/src/main/java/umc/th/juinjang/event/publisher/PaymentEventPublisher.java b/src/main/java/umc/th/juinjang/event/publisher/PaymentEventPublisher.java new file mode 100644 index 00000000..ee760ca9 --- /dev/null +++ b/src/main/java/umc/th/juinjang/event/publisher/PaymentEventPublisher.java @@ -0,0 +1,8 @@ +package umc.th.juinjang.event.publisher; + +import umc.th.juinjang.domain.member.model.Member; +import umc.th.juinjang.domain.pencil.purchased.model.TransactionStatus; + +public interface PaymentEventPublisher { + void publishPaymentEvent(Member buyer, Long price, Long pencilQuantity, TransactionStatus transactionStatus); +} diff --git a/src/main/java/umc/th/juinjang/event/subscriber/DiscordEventListener.java b/src/main/java/umc/th/juinjang/event/subscriber/DiscordEventListener.java index 2dae9f05..746c20c1 100644 --- a/src/main/java/umc/th/juinjang/event/subscriber/DiscordEventListener.java +++ b/src/main/java/umc/th/juinjang/event/subscriber/DiscordEventListener.java @@ -10,6 +10,7 @@ import org.springframework.transaction.event.TransactionalEventListener; import umc.th.juinjang.event.FlagSharedNoteEvent; +import umc.th.juinjang.event.PaymentEvent; import umc.th.juinjang.event.SignUpEvent; import umc.th.juinjang.external.openfeign.discord.DiscordAlertProvider; @@ -42,6 +43,15 @@ public void handleFlagSharedNoteEvent(FlagSharedNoteEvent event) { )); } + @TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT) + @Async + public void handlePaymentEvent(PaymentEvent event) { + discordAlertProvider.sendPaymentAlertToDiscord(String.format( + EventMessage.PAYMENT_COMPLETED_MESSAGE.getMessage(), + event.memberId(),event.nickname(),event.pencilQuantity(), event.price(), event.transactionStatus() + )); + } + private boolean isProdEnv() { return environment.acceptsProfiles(Profiles.of("prod")); } diff --git a/src/main/java/umc/th/juinjang/event/subscriber/EventMessage.java b/src/main/java/umc/th/juinjang/event/subscriber/EventMessage.java index c4374d57..660763a4 100644 --- a/src/main/java/umc/th/juinjang/event/subscriber/EventMessage.java +++ b/src/main/java/umc/th/juinjang/event/subscriber/EventMessage.java @@ -8,7 +8,9 @@ @Getter public enum EventMessage { SIGN_UP_MESSAGE("주인장에 %s %d번째 유저 < %s >님이 생겼어요!"), - FLAG_SHARED_NOTE_MESSAGE("< %d >번 유저가 [ %s ]의 사유로 < %d >번 유저의 < %d >번 공유 노트를 신고했습니다."); + FLAG_SHARED_NOTE_MESSAGE("< %d >번 유저가 [ %s ]의 사유로 < %d >번 유저의 < %d >번 공유 노트를 신고했습니다."), + PAYMENT_COMPLETED_MESSAGE("<%d>번 유저 < %s >님이 %d개의 연필을 %d원에 결제했습니다. (상태: %s)"); + private final String message; } diff --git a/src/main/java/umc/th/juinjang/external/openfeign/discord/DiscordAlertProvider.java b/src/main/java/umc/th/juinjang/external/openfeign/discord/DiscordAlertProvider.java index b9a2b7b9..0faeff47 100644 --- a/src/main/java/umc/th/juinjang/external/openfeign/discord/DiscordAlertProvider.java +++ b/src/main/java/umc/th/juinjang/external/openfeign/discord/DiscordAlertProvider.java @@ -22,6 +22,9 @@ public class DiscordAlertProvider { @Value("${discord.report-shared-note}") private String reportSharedNoteWebhookUrl; + @Value("${discord.execute-payment}") + private String executePaymentWebhookUrl; + public DiscordAlertProvider(WebClient.Builder builder) { this.webClient = builder.build(); } @@ -51,4 +54,12 @@ public void sendReportSharedNoteAlertToDiscord(String content) { log.info(StatusMessage.DISCORD_ALERT_ERROR.getMessage() + " " + e.getMessage()); } } -} \ No newline at end of file + + public void sendPaymentAlertToDiscord(String content) { + try { + sendWebClient(executePaymentWebhookUrl, content); + } catch (FeignException e) { + log.info("{} {}", StatusMessage.DISCORD_ALERT_ERROR.getMessage(), e.getMessage()); + } + } +} diff --git a/src/test/java/umc/th/juinjang/api/pencil/service/PencilCommandServiceTest.java b/src/test/java/umc/th/juinjang/api/pencil/service/PencilCommandServiceTest.java index cb4ef725..6dac1e79 100644 --- a/src/test/java/umc/th/juinjang/api/pencil/service/PencilCommandServiceTest.java +++ b/src/test/java/umc/th/juinjang/api/pencil/service/PencilCommandServiceTest.java @@ -138,7 +138,7 @@ void processAppleIAPPurchase_Validation_Fail() throws APIException, Verification private AppleIAPPurchaseRequest createValidRequest() { return AppleIAPPurchaseRequest - .of(transactionId, appAccountToken, 20L, 3000L, productId,10L); + .of(transactionId, appAccountToken, 20L, 3000L, productId,10); } } diff --git a/src/test/java/umc/th/juinjang/api/pencil/service/PencilQueryServiceTest.java b/src/test/java/umc/th/juinjang/api/pencil/service/PencilQueryServiceTest.java index e76dbe94..8f839e21 100644 --- a/src/test/java/umc/th/juinjang/api/pencil/service/PencilQueryServiceTest.java +++ b/src/test/java/umc/th/juinjang/api/pencil/service/PencilQueryServiceTest.java @@ -12,6 +12,13 @@ import org.junit.jupiter.api.Test; import org.springframework.beans.factory.annotation.Autowired; +import com.apple.itunes.storekit.model.ConsumptionRequest; +import com.apple.itunes.storekit.model.ConsumptionStatus; +import com.apple.itunes.storekit.model.LifetimeDollarsPurchased; +import com.apple.itunes.storekit.model.LifetimeDollarsRefunded; +import com.apple.itunes.storekit.model.Platform; +import com.apple.itunes.storekit.model.PlayTime; + import lombok.extern.slf4j.Slf4j; import umc.th.juinjang.api.IntegrationTestSupport; import umc.th.juinjang.api.pencil.service.response.AcquiredPencilResponse; @@ -155,16 +162,11 @@ void getPurchasedPencilsOrderedByCreatedAtDesc() { UUID uuid5 = UUID.randomUUID(); // 명확한 순서로 데이터 생성 (시간 역순으로) - PurchasedPencil pencil1 = PurchasedPencil.successOf(member, "10개 연필팩", 10L, 1000L, 0L, "transaction1", uuid1, - time1); - PurchasedPencil pencil2 = PurchasedPencil.successOf(member, "20개 연필팩", 20L, 2000L, 0L, "transaction2", uuid2, - time2); - PurchasedPencil pencil3 = PurchasedPencil.successOf(member, "30개 연필팩", 30L, 3000L, 0L, "transaction3", uuid3, - time3); - PurchasedPencil pencil4 = PurchasedPencil.successOf(member, "15개 연필팩", 15L, 1500L, 0L, "transaction4", uuid4, - time4); - PurchasedPencil pencil5 = PurchasedPencil.successOf(member, "25개 연필팩", 25L, 2500L, 0L, "transaction5", uuid5, - time5); + PurchasedPencil pencil1 = PurchasedPencil.successOf(member, "10개 연필팩", 10L, 1000L, 0,"transaction1", uuid1, time1); + PurchasedPencil pencil2 = PurchasedPencil.successOf(member, "20개 연필팩", 20L, 2000L, 0,"transaction2", uuid2, time2); + PurchasedPencil pencil3 = PurchasedPencil.successOf(member, "30개 연필팩", 30L, 3000L,0 ,"transaction3", uuid3, time3); + PurchasedPencil pencil4 = PurchasedPencil.successOf(member, "15개 연필팩", 15L, 1500L, 0,"transaction4", uuid4, time4); + PurchasedPencil pencil5 = PurchasedPencil.successOf(member, "25개 연필팩", 25L, 2500L, 0,"transaction5", uuid5, time5); purchasedPencilRepository.saveAll(List.of(pencil1, pencil2, pencil3, pencil4, pencil5)); @@ -197,8 +199,7 @@ void getPurchasedPencilsWithoutDeliveryStatus() { LocalDateTime time = LocalDateTime.now(); UUID uuid = UUID.randomUUID(); - PurchasedPencil pencil = PurchasedPencil.failedDueToServerError(member, "10개 연필팩", 10L, 1000L, 10L, - "transaction1", uuid, time); + PurchasedPencil pencil = PurchasedPencil.failedDueToServerError(member, "10개 연필팩", 10L, 1000L, 10,"transaction1", uuid, time); purchasedPencilRepository.saveAll(List.of(pencil)); @@ -244,42 +245,53 @@ void getUsedPencilsOrderedByCreatedAtDesc() { ); } - @DisplayName("얻은 연필 목록 중 읽지 않은 연필이 없으면 전체 읽음으로 판단된다.") + @DisplayName("ConsumptionRequest가 PurchasedPencil 데이터를 기반으로 올바르게 생성된다.") @Test - void returnTrueIfAllAcquiredPencilsAreRead() { + void getConsumptionRequestFromPurchasedPencil() { // given Member member = MemberFixture.createDefaultMember(); memberRepository.save(member); - AcquiredPencil pencil1 = AcquiredPencil.create(member, "연필 1", 1L, 10L, true, AcquiredType.NOTE); - AcquiredPencil pencil2 = AcquiredPencil.create(member, "연필 2", 2L, 20L, true, AcquiredType.SOLD); - acquiredPencilRepository.saveAll(List.of(pencil1, pencil2)); - - // when - boolean isTotalRead = pencilService.isAcquiredPencilReadStatus(member); - - // then - assertThat(isTotalRead).isTrue(); - } + LocalDateTime now = LocalDateTime.now(); + String transactionId = "test-transaction-id"; + UUID appAccountToken = UUID.randomUUID(); + + PurchasedPencil pencil = PurchasedPencil.successOf( + member, + "테스트 연필팩", + 20L, + 2000L, + 10, + transactionId, + appAccountToken, + now + ); - @DisplayName("얻은 연필 목록 중 읽지 않은 연필이 하나라도 있으면 전체 읽음으로 판단되지 않는다.") - @Test - void returnFalseIfAnyAcquiredPencilIsUnread() { - // given - Member member = MemberFixture.createDefaultMember(); - memberRepository.save(member); + purchasedPencilRepository.save(pencil); - AcquiredPencil pencil1 = AcquiredPencil.create(member, "연필 1", 1L, 10L, true, AcquiredType.NOTE); - AcquiredPencil pencil2 = AcquiredPencil.create(member, "연필 2", 2L, 20L, false, AcquiredType.SOLD); - acquiredPencilRepository.saveAll(List.of(pencil1, pencil2)); + // AcquiredPencil 데이터를 하나라도 만들어줘야 sampleContentProvided == true + acquiredPencilRepository.save( + AcquiredPencil.create(member, "노트 작성", 1L, 10L, false, AcquiredType.NOTE) + ); // when - boolean isTotalRead = pencilService.isAcquiredPencilReadStatus(member); + ConsumptionRequest request = pencilService.getConsumptionRequest(transactionId); // then - assertThat(isTotalRead).isFalse(); + assertThat(request).isNotNull(); + assertThat(request.getAppAccountToken()).isEqualTo(appAccountToken); + assertThat(request.getDeliveryStatus().getValue()).isEqualTo(pencil.getDeliveryStatus().getAppleCode()); + assertThat(request.getPlayTime()).isEqualTo(PlayTime.FIVE_TO_SIXTY_MINUTES); + assertThat(request.getLifetimeDollarsPurchased()).isEqualTo(LifetimeDollarsPurchased.ONE_CENT_TO_FORTY_NINE_DOLLARS_AND_NINETY_NINE_CENTS); + assertThat(request.getLifetimeDollarsRefunded()).isEqualTo(LifetimeDollarsRefunded.ZERO_DOLLARS); + assertThat(request.getCustomerConsented()).isTrue(); + assertThat(request.getSampleContentProvided()).isTrue(); + assertThat(request.getPlatform()).isEqualTo(Platform.APPLE); + + assertThat(request.getConsumptionStatus()).isEqualTo(ConsumptionStatus.NOT_CONSUMED); } + private AcquiredPencil createAcquiredPencilWithTime(LocalDateTime createdAt, String content, Long sharedNoteId, Long acquiredQuantity, boolean isRead, AcquiredType type, Member member) { return AcquiredPencil.createWithDate(member, content, sharedNoteId, acquiredQuantity, isRead, type, createdAt);