diff --git a/src/main/java/com/yello/server/domain/purchase/controller/PurchaseNotificationController.java b/src/main/java/com/yello/server/domain/purchase/controller/PurchaseNotificationController.java index dbe3dd05..d94083ae 100644 --- a/src/main/java/com/yello/server/domain/purchase/controller/PurchaseNotificationController.java +++ b/src/main/java/com/yello/server/domain/purchase/controller/PurchaseNotificationController.java @@ -4,6 +4,7 @@ import static com.yello.server.global.common.SuccessCode.POST_GOOGLE_NOTIFICATION_SUCCESS; import com.yello.server.domain.purchase.dto.request.AppleNotificationRequest; +import com.yello.server.domain.purchase.dto.request.GooglePubSubNotificationRequest; import com.yello.server.domain.purchase.service.PurchaseService; import com.yello.server.global.common.dto.BaseResponse; import com.yello.server.global.common.dto.EmptyObject; @@ -32,9 +33,9 @@ public BaseResponse appleNotification( @PostMapping("/v2/google/notifications") public BaseResponse googleNotification( - @RequestBody Object request + @RequestBody GooglePubSubNotificationRequest request ) { - System.out.println("request = " + request); - return BaseResponse.success(POST_GOOGLE_NOTIFICATION_SUCCESS); + purchaseService.googleNotification(request); + return BaseResponse.success(POST_GOOGLE_NOTIFICATION_SUCCESS, EmptyObject.builder().build()); } } diff --git a/src/main/java/com/yello/server/domain/purchase/dto/request/GooglePubSubNotificationRequest.java b/src/main/java/com/yello/server/domain/purchase/dto/request/GooglePubSubNotificationRequest.java new file mode 100644 index 00000000..426fff4e --- /dev/null +++ b/src/main/java/com/yello/server/domain/purchase/dto/request/GooglePubSubNotificationRequest.java @@ -0,0 +1,10 @@ +package com.yello.server.domain.purchase.dto.request; + +import com.yello.server.domain.purchase.dto.request.google.GooglePubSubMessage; + +public record GooglePubSubNotificationRequest( + GooglePubSubMessage message, + String subscription +) { + +} diff --git a/src/main/java/com/yello/server/domain/purchase/dto/request/google/DeveloperNotification.java b/src/main/java/com/yello/server/domain/purchase/dto/request/google/DeveloperNotification.java new file mode 100644 index 00000000..e33c2375 --- /dev/null +++ b/src/main/java/com/yello/server/domain/purchase/dto/request/google/DeveloperNotification.java @@ -0,0 +1,42 @@ +package com.yello.server.domain.purchase.dto.request.google; + +import static com.yello.server.global.common.util.ConstantUtil.GOOGLE_FIVE_TICKET_ID; +import static com.yello.server.global.common.util.ConstantUtil.GOOGLE_ONE_TICKET_ID; +import static com.yello.server.global.common.util.ConstantUtil.GOOGLE_TWO_TICKET_ID; + +import com.fasterxml.jackson.annotation.JsonInclude; +import com.fasterxml.jackson.annotation.JsonInclude.Include; +import com.yello.server.domain.purchase.entity.ProductType; +import org.springframework.util.ObjectUtils; + +@JsonInclude(Include.NON_NULL) +public record DeveloperNotification( + String version, + String packageName, + Long eventTimeMillis, + OneTimeProductNotification oneTimeProductNotification, + SubscriptionNotification subscriptionNotification, + TestNotification testNotification +) { + + public ProductType getProductType() { + if (ObjectUtils.isEmpty(testNotification)) { + return ProductType.TEST; + } else if (!ObjectUtils.isEmpty(subscriptionNotification)) { + return ProductType.YELLO_PLUS; + } else if (!ObjectUtils.isEmpty(oneTimeProductNotification)) { + switch (oneTimeProductNotification.sku()) { + case GOOGLE_ONE_TICKET_ID -> { + return ProductType.ONE_TICKET; + } + case GOOGLE_TWO_TICKET_ID -> { + return ProductType.TWO_TICKET; + } + case GOOGLE_FIVE_TICKET_ID -> { + return ProductType.FIVE_TICKET; + } + } + } + return null; + } +} diff --git a/src/main/java/com/yello/server/domain/purchase/dto/request/google/GooglePubSubMessage.java b/src/main/java/com/yello/server/domain/purchase/dto/request/google/GooglePubSubMessage.java new file mode 100644 index 00000000..1ca465b0 --- /dev/null +++ b/src/main/java/com/yello/server/domain/purchase/dto/request/google/GooglePubSubMessage.java @@ -0,0 +1,15 @@ +package com.yello.server.domain.purchase.dto.request.google; + +import java.util.Map; +import java.util.Optional; + +public record GooglePubSubMessage( + Optional> attributes, + String data, + Long messageId, + Optional message_id, + Optional publishTime, + Optional publish_time +) { + +} diff --git a/src/main/java/com/yello/server/domain/purchase/dto/request/google/OneTimeProductNotification.java b/src/main/java/com/yello/server/domain/purchase/dto/request/google/OneTimeProductNotification.java new file mode 100644 index 00000000..aff6158c --- /dev/null +++ b/src/main/java/com/yello/server/domain/purchase/dto/request/google/OneTimeProductNotification.java @@ -0,0 +1,10 @@ +package com.yello.server.domain.purchase.dto.request.google; + +public record OneTimeProductNotification( + String version, + OneTimeProductNotificationType notificationType, + String purchaseToken, + String sku +) { + +} diff --git a/src/main/java/com/yello/server/domain/purchase/dto/request/google/OneTimeProductNotificationType.java b/src/main/java/com/yello/server/domain/purchase/dto/request/google/OneTimeProductNotificationType.java new file mode 100644 index 00000000..b1be01c0 --- /dev/null +++ b/src/main/java/com/yello/server/domain/purchase/dto/request/google/OneTimeProductNotificationType.java @@ -0,0 +1,20 @@ +package com.yello.server.domain.purchase.dto.request.google; + +import lombok.Getter; + +@Getter +public enum OneTimeProductNotificationType { + ONE_TIME_PRODUCT_PURCHASED(1), + ONE_TIME_PRODUCT_CANCELED(2); + + private final Long value; + + OneTimeProductNotificationType(long value) { + this.value = value; + } + + @Override + public String toString() { + return this.name(); + } +} diff --git a/src/main/java/com/yello/server/domain/purchase/dto/request/google/SubscriptionNotification.java b/src/main/java/com/yello/server/domain/purchase/dto/request/google/SubscriptionNotification.java new file mode 100644 index 00000000..3a65e446 --- /dev/null +++ b/src/main/java/com/yello/server/domain/purchase/dto/request/google/SubscriptionNotification.java @@ -0,0 +1,10 @@ +package com.yello.server.domain.purchase.dto.request.google; + +public record SubscriptionNotification( + String version, + SubscriptionNotificationType subscriptionNotificationType, + String purchaseToken, + String subscriptionId +) { + +} diff --git a/src/main/java/com/yello/server/domain/purchase/dto/request/google/SubscriptionNotificationType.java b/src/main/java/com/yello/server/domain/purchase/dto/request/google/SubscriptionNotificationType.java new file mode 100644 index 00000000..d4c49aef --- /dev/null +++ b/src/main/java/com/yello/server/domain/purchase/dto/request/google/SubscriptionNotificationType.java @@ -0,0 +1,31 @@ +package com.yello.server.domain.purchase.dto.request.google; + +import lombok.Getter; + +@Getter +public enum SubscriptionNotificationType { + SUBSCRIPTION_RECOVERED(1), + SUBSCRIPTION_RENEWED(2), + SUBSCRIPTION_CANCELED(3), + SUBSCRIPTION_PURCHASED(4), + SUBSCRIPTION_ON_HOLD(5), + SUBSCRIPTION_IN_GRACE_PERIOD(6), + SUBSCRIPTION_RESTARTED(7), + SUBSCRIPTION_PRICE_CHANGE_CONFIRMED(8), + SUBSCRIPTION_DEFERRED(9), + SUBSCRIPTION_PAUSED(10), + SUBSCRIPTION_PAUSE_SCHEDULE_CHANGED(11), + SUBSCRIPTION_REVOKED(12), + SUBSCRIPTION_EXPIRED(13); + + private final Long value; + + SubscriptionNotificationType(long value) { + this.value = value; + } + + @Override + public String toString() { + return this.name(); + } +} diff --git a/src/main/java/com/yello/server/domain/purchase/dto/request/google/TestNotification.java b/src/main/java/com/yello/server/domain/purchase/dto/request/google/TestNotification.java new file mode 100644 index 00000000..8a653502 --- /dev/null +++ b/src/main/java/com/yello/server/domain/purchase/dto/request/google/TestNotification.java @@ -0,0 +1,7 @@ +package com.yello.server.domain.purchase.dto.request.google; + +public record TestNotification( + String version +) { + +} diff --git a/src/main/java/com/yello/server/domain/purchase/entity/ProductType.java b/src/main/java/com/yello/server/domain/purchase/entity/ProductType.java index 28ad71c2..a8e9f81d 100644 --- a/src/main/java/com/yello/server/domain/purchase/entity/ProductType.java +++ b/src/main/java/com/yello/server/domain/purchase/entity/ProductType.java @@ -1,5 +1,14 @@ package com.yello.server.domain.purchase.entity; +import static com.yello.server.global.common.util.ConstantUtil.FIVE_TICKET_ID; +import static com.yello.server.global.common.util.ConstantUtil.GOOGLE_FIVE_TICKET_ID; +import static com.yello.server.global.common.util.ConstantUtil.GOOGLE_ONE_TICKET_ID; +import static com.yello.server.global.common.util.ConstantUtil.GOOGLE_TWO_TICKET_ID; +import static com.yello.server.global.common.util.ConstantUtil.GOOGLE_YELLO_PLUS_ID; +import static com.yello.server.global.common.util.ConstantUtil.ONE_TICKET_ID; +import static com.yello.server.global.common.util.ConstantUtil.TWO_TICKET_ID; +import static com.yello.server.global.common.util.ConstantUtil.YELLO_PLUS_ID; + import java.text.MessageFormat; import java.util.Arrays; import lombok.Getter; @@ -11,7 +20,8 @@ public enum ProductType { YELLO_PLUS("yello_plus"), ONE_TICKET("one_ticket"), TWO_TICKET("two_ticket"), - FIVE_TICKET("five_ticket"); + FIVE_TICKET("five_ticket"), + TEST("test"); private final String intial; @@ -23,8 +33,26 @@ public static ProductType fromCode(String dbData) { MessageFormat.format("존재하지 않는 상품타입입니다. {0}", dbData))); } + public static ProductType getProductType(String productId) { + return switch (productId) { + case GOOGLE_ONE_TICKET_ID, ONE_TICKET_ID -> ProductType.ONE_TICKET; + case GOOGLE_TWO_TICKET_ID, TWO_TICKET_ID -> ProductType.TWO_TICKET; + case GOOGLE_FIVE_TICKET_ID, FIVE_TICKET_ID -> ProductType.FIVE_TICKET; + case GOOGLE_YELLO_PLUS_ID, YELLO_PLUS_ID -> ProductType.YELLO_PLUS; + default -> null; + }; + } + + public static Integer getTicketAmount(ProductType productType) { + return switch (productType) { + case ONE_TICKET -> 1; + case TWO_TICKET -> 2; + case FIVE_TICKET -> 5; + default -> null; + }; + } + public String intial() { return intial; } - } diff --git a/src/main/java/com/yello/server/domain/purchase/entity/Purchase.java b/src/main/java/com/yello/server/domain/purchase/entity/Purchase.java index 771dcffe..a324793d 100644 --- a/src/main/java/com/yello/server/domain/purchase/entity/Purchase.java +++ b/src/main/java/com/yello/server/domain/purchase/entity/Purchase.java @@ -33,6 +33,10 @@ name = "transactionId_unique", columnNames = {"transactionId"} ), + @UniqueConstraint( + name = "puchaseToken_unique", + columnNames = {"purchaseToken"} + ), } ) public class Purchase extends AuditingTimeEntity { @@ -55,16 +59,29 @@ public class Purchase extends AuditingTimeEntity { @Convert(converter = GatewayConverter.class) private Gateway gateway; + @Column + private String purchaseToken; + + @Column + @Convert(converter = PurchaseStateConverter.class) + private PurchaseState state; + + @Column + private String rawData; + @Column(nullable = false) @Convert(converter = ProductTypeConverter.class) private ProductType productType; public static Purchase of(User user, ProductType productType, Gateway gateway, - String transactionId) { + String transactionId, String purchaseToken, PurchaseState purchaseState, String rawData) { return Purchase.builder() .price(setPrice(productType.toString())) .user(user) .gateway(gateway) + .purchaseToken(purchaseToken) + .state(purchaseState) + .rawData(rawData) .productType(productType) .transactionId(transactionId) .build(); @@ -86,8 +103,16 @@ public static int setPrice(String productType) { } public static Purchase createPurchase(User user, ProductType productType, Gateway gateway, - String transactionId) { - return Purchase.of(user, productType, gateway, transactionId); + String transactionId, String purchaseToken, PurchaseState purchaseState, String rawData) { + return Purchase.of(user, productType, gateway, transactionId, purchaseToken, purchaseState, rawData); + } + + public void setPurchaseState(PurchaseState state) { + this.state = state; + } + + public void setRawData(String rawData) { + this.rawData = rawData; } public void setTransactionId(String transactionId) { diff --git a/src/main/java/com/yello/server/domain/purchase/entity/PurchaseState.java b/src/main/java/com/yello/server/domain/purchase/entity/PurchaseState.java new file mode 100644 index 00000000..9c97a1f5 --- /dev/null +++ b/src/main/java/com/yello/server/domain/purchase/entity/PurchaseState.java @@ -0,0 +1,29 @@ +package com.yello.server.domain.purchase.entity; + +import java.text.MessageFormat; +import java.util.Arrays; +import lombok.Getter; +import lombok.RequiredArgsConstructor; + +@Getter +@RequiredArgsConstructor +public enum PurchaseState { + ACTIVE("ACTIVE"), + CANCELED("CANCELED"), + PAUSED("PAUSED"), + INACTIVE("INACTIVE"); + + private final String intial; + + public static PurchaseState fromCode(String dbData) { + return Arrays.stream(PurchaseState.values()) + .filter(v -> v.getIntial().equals(dbData)) + .findAny() + .orElseThrow(() -> new IllegalArgumentException( + MessageFormat.format("존재하지 않는 결제 상태입니다. {0}", dbData))); + } + + public String intial() { + return intial; + } +} diff --git a/src/main/java/com/yello/server/domain/purchase/entity/PurchaseStateConverter.java b/src/main/java/com/yello/server/domain/purchase/entity/PurchaseStateConverter.java new file mode 100644 index 00000000..d27759a6 --- /dev/null +++ b/src/main/java/com/yello/server/domain/purchase/entity/PurchaseStateConverter.java @@ -0,0 +1,32 @@ +package com.yello.server.domain.purchase.entity; + +import javax.persistence.AttributeConverter; +import javax.persistence.Converter; +import lombok.extern.log4j.Log4j2; + +@Converter +@Log4j2 +public class PurchaseStateConverter implements AttributeConverter { + + @Override + public String convertToDatabaseColumn(PurchaseState purchaseState) { + if (purchaseState == null) { + return null; + } + return purchaseState.getIntial(); + } + + @Override + public PurchaseState convertToEntityAttribute(String dbData) { + if (dbData == null) { + return null; + } + try { + return PurchaseState.fromCode(dbData); + } catch (IllegalArgumentException exception) { + log.error("failure to convert cause unexpected code" + dbData + exception); + throw exception; + } + } + +} diff --git a/src/main/java/com/yello/server/domain/purchase/repository/PurchaseJpaRepository.java b/src/main/java/com/yello/server/domain/purchase/repository/PurchaseJpaRepository.java index 7c33c2ee..831d590e 100644 --- a/src/main/java/com/yello/server/domain/purchase/repository/PurchaseJpaRepository.java +++ b/src/main/java/com/yello/server/domain/purchase/repository/PurchaseJpaRepository.java @@ -11,6 +11,8 @@ public interface PurchaseJpaRepository extends JpaRepository { Optional findByTransactionId(String transactionId); + Optional findByPurchaseToken(String purchaseToken); + List findAllByUser(User user); Optional findTopByUserAndProductTypeOrderByCreatedAtDesc(User user, diff --git a/src/main/java/com/yello/server/domain/purchase/repository/PurchaseRepository.java b/src/main/java/com/yello/server/domain/purchase/repository/PurchaseRepository.java index e1867e74..57d6fee5 100644 --- a/src/main/java/com/yello/server/domain/purchase/repository/PurchaseRepository.java +++ b/src/main/java/com/yello/server/domain/purchase/repository/PurchaseRepository.java @@ -14,6 +14,8 @@ public interface PurchaseRepository { Optional findByTransactionId(String transactionId); + Optional findByPurchaseToken(String purchaseToken); + List findAllByUser(User user); Optional findTopByUserAndProductTypeOrderByCreatedAtDesc(User user, diff --git a/src/main/java/com/yello/server/domain/purchase/repository/PurchaseRepositoryImpl.java b/src/main/java/com/yello/server/domain/purchase/repository/PurchaseRepositoryImpl.java index 96f0b3d9..daaae69f 100644 --- a/src/main/java/com/yello/server/domain/purchase/repository/PurchaseRepositoryImpl.java +++ b/src/main/java/com/yello/server/domain/purchase/repository/PurchaseRepositoryImpl.java @@ -29,6 +29,11 @@ public Optional findByTransactionId(String transactionId) { return purchaseJpaRepository.findByTransactionId(transactionId); } + @Override + public Optional findByPurchaseToken(String purchaseToken) { + return purchaseJpaRepository.findByPurchaseToken(purchaseToken); + } + @Override public List findAllByUser(User user) { return purchaseJpaRepository.findAllByUser(user); @@ -40,7 +45,7 @@ public Optional findTopByUserAndProductTypeOrderByCreatedAtDesc(User u return purchaseJpaRepository.findTopByUserAndProductTypeOrderByCreatedAtDesc(user, productType); } - + @Override public void delete(Purchase purchase) { purchaseJpaRepository.delete(purchase); diff --git a/src/main/java/com/yello/server/domain/purchase/service/PurchaseManager.java b/src/main/java/com/yello/server/domain/purchase/service/PurchaseManager.java index 4d61ba81..98f9d78a 100644 --- a/src/main/java/com/yello/server/domain/purchase/service/PurchaseManager.java +++ b/src/main/java/com/yello/server/domain/purchase/service/PurchaseManager.java @@ -1,23 +1,28 @@ package com.yello.server.domain.purchase.service; import com.yello.server.domain.purchase.dto.apple.AppleNotificationPayloadVO; +import com.yello.server.domain.purchase.dto.apple.ApplePurchaseVO; import com.yello.server.domain.purchase.dto.apple.TransactionInfoResponse; import com.yello.server.domain.purchase.entity.Gateway; import com.yello.server.domain.purchase.entity.ProductType; import com.yello.server.domain.purchase.entity.Purchase; +import com.yello.server.domain.purchase.entity.PurchaseState; import com.yello.server.domain.user.entity.User; import com.yello.server.infrastructure.slack.dto.response.SlackAppleNotificationResponse; import org.springframework.http.ResponseEntity; public interface PurchaseManager { - Purchase createSubscribe(User user, Gateway gateway, String transactionId); + Purchase createSubscribe(User user, Gateway gateway, String transactionId, String purchaseToken, + PurchaseState purchaseState, String rawData); Purchase createTicket(User user, ProductType productType, Gateway gateway, - String transactionId); + String transactionId, String purchaseToken, PurchaseState purchaseState, String rawData); void handleAppleTransactionError(ResponseEntity response, String transactionId); + + ApplePurchaseVO getPurchaseData(AppleNotificationPayloadVO payloadVO); AppleNotificationPayloadVO decodeApplePayload(String signedPayload); diff --git a/src/main/java/com/yello/server/domain/purchase/service/PurchaseManagerImpl.java b/src/main/java/com/yello/server/domain/purchase/service/PurchaseManagerImpl.java index 777017df..e642a65e 100644 --- a/src/main/java/com/yello/server/domain/purchase/service/PurchaseManagerImpl.java +++ b/src/main/java/com/yello/server/domain/purchase/service/PurchaseManagerImpl.java @@ -16,6 +16,7 @@ import com.yello.server.domain.purchase.entity.Gateway; import com.yello.server.domain.purchase.entity.ProductType; import com.yello.server.domain.purchase.entity.Purchase; +import com.yello.server.domain.purchase.entity.PurchaseState; import com.yello.server.domain.purchase.exception.AppleTokenServerErrorException; import com.yello.server.domain.purchase.exception.PurchaseConflictException; import com.yello.server.domain.purchase.exception.PurchaseNotFoundException; @@ -41,19 +42,21 @@ public class PurchaseManagerImpl implements PurchaseManager { private final UserRepository userRepository; @Override - public Purchase createSubscribe(User user, Gateway gateway, String transactionId) { + public Purchase createSubscribe(User user, Gateway gateway, String transactionId, String purchaseToken, + PurchaseState purchaseState, String rawData) { user.setSubscribe(Subscribe.ACTIVE); Purchase newPurchase = - Purchase.createPurchase(user, ProductType.YELLO_PLUS, gateway, transactionId); + Purchase.createPurchase(user, ProductType.YELLO_PLUS, gateway, transactionId, purchaseToken, purchaseState, + rawData); user.addTicketCount(3); return purchaseRepository.save(newPurchase); } @Override - public Purchase createTicket(User user, ProductType productType, Gateway gateway, - String transactionId) { + public Purchase createTicket(User user, ProductType productType, Gateway gateway, String transactionId, + String purchaseToken, PurchaseState purchaseState, String rawData) { Purchase newPurchase = - Purchase.createPurchase(user, productType, gateway, transactionId); + Purchase.createPurchase(user, productType, gateway, transactionId, purchaseToken, purchaseState, rawData); return purchaseRepository.save(newPurchase); } @@ -80,11 +83,11 @@ public AppleNotificationPayloadVO decodeApplePayload(String signedPayload) { String notificationType = jsonPayload.get("notificationType").toString(); String subtype = - (jsonPayload.get("subtype")!=null) ? jsonPayload.get("subtype").toString() : null; + (jsonPayload.get("subtype") != null) ? jsonPayload.get("subtype").toString() : null; Map data = (Map) jsonPayload.get("data"); String notificationUUID = - (jsonPayload.get("notificationUUID")!=null) ? jsonPayload.get("notificationUUID") + (jsonPayload.get("notificationUUID") != null) ? jsonPayload.get("notificationUUID") .toString() : null; ApplePayloadDataVO payloadVO = objectMapper.convertValue(data, ApplePayloadDataVO.class); @@ -165,7 +168,8 @@ public void reSubscribeApple(AppleNotificationPayloadVO payloadVO) { .orElseThrow(() -> new PurchaseConflictException(NOT_FOUND_TRANSACTION_EXCEPTION)); Purchase reSubscribePurchase = - createSubscribe(purchase.getUser(), Gateway.APPLE, appleJwtDecode.transactionId()); + createSubscribe(purchase.getUser(), Gateway.APPLE, appleJwtDecode.transactionId(), null, + PurchaseState.ACTIVE, appleJwtDecode.toString()); purchaseRepository.save(reSubscribePurchase); } @@ -176,6 +180,7 @@ public void validateTicketCount(int ticketCount, User user) { } } + @Override public ApplePurchaseVO getPurchaseData(AppleNotificationPayloadVO payloadVO) { String transactionId = decodeAppleNotificationData( diff --git a/src/main/java/com/yello/server/domain/purchase/service/PurchaseService.java b/src/main/java/com/yello/server/domain/purchase/service/PurchaseService.java index 0c6d496b..8a6d6852 100644 --- a/src/main/java/com/yello/server/domain/purchase/service/PurchaseService.java +++ b/src/main/java/com/yello/server/domain/purchase/service/PurchaseService.java @@ -1,6 +1,13 @@ package com.yello.server.domain.purchase.service; +import static com.yello.server.domain.purchase.entity.ProductType.FIVE_TICKET; +import static com.yello.server.domain.purchase.entity.ProductType.ONE_TICKET; +import static com.yello.server.domain.purchase.entity.ProductType.TWO_TICKET; +import static com.yello.server.domain.purchase.entity.ProductType.YELLO_PLUS; +import static com.yello.server.domain.purchase.entity.ProductType.getProductType; +import static com.yello.server.domain.purchase.entity.ProductType.getTicketAmount; import static com.yello.server.global.common.ErrorCode.GOOGLE_INAPP_BAD_REQUEST_EXCEPTION; +import static com.yello.server.global.common.ErrorCode.GOOGLE_NOTIFICATION_BAD_REQUEST_EXCEPTION; import static com.yello.server.global.common.ErrorCode.GOOGLE_SUBSCRIPTIONS_FORBIDDEN_EXCEPTION; import static com.yello.server.global.common.ErrorCode.GOOGLE_SUBSCRIPTIONS_SUBSCRIPTION_EXCEPTION; import static com.yello.server.global.common.ErrorCode.GOOGLE_SUBSCRIPTION_DUPLICATED_CANCEL_EXCEPTION; @@ -11,6 +18,7 @@ import static com.yello.server.global.common.ErrorCode.GOOGLE_TOKEN_SERVER_EXCEPTION; import static com.yello.server.global.common.ErrorCode.NOT_FOUND_NOTIFICATION_TYPE_EXCEPTION; import static com.yello.server.global.common.ErrorCode.NOT_FOUND_TRANSACTION_EXCEPTION; +import static com.yello.server.global.common.ErrorCode.PURCHASE_TOKEN_NOT_FOUND_PURCHASE_EXCEPTION; import static com.yello.server.global.common.ErrorCode.SUBSCRIBE_ACTIVE_EXCEPTION; import static com.yello.server.global.common.util.ConstantUtil.APPLE_NOTIFICATION_CONSUMPTION_REQUEST; import static com.yello.server.global.common.util.ConstantUtil.APPLE_NOTIFICATION_EXPIRED; @@ -19,8 +27,6 @@ import static com.yello.server.global.common.util.ConstantUtil.APPLE_NOTIFICATION_SUBSCRIPTION_STATUS_CHANGE; import static com.yello.server.global.common.util.ConstantUtil.APPLE_NOTIFICATION_TEST; import static com.yello.server.global.common.util.ConstantUtil.FIVE_TICKET_ID; -import static com.yello.server.global.common.util.ConstantUtil.GOOGLE_FIVE_TICKET_ID; -import static com.yello.server.global.common.util.ConstantUtil.GOOGLE_TWO_TICKET_ID; import static com.yello.server.global.common.util.ConstantUtil.ONE_TICKET_ID; import static com.yello.server.global.common.util.ConstantUtil.TWO_TICKET_ID; import static com.yello.server.global.common.util.ConstantUtil.YELLO_PLUS_ID; @@ -28,18 +34,23 @@ import com.google.gson.Gson; import com.google.gson.JsonObject; import com.yello.server.domain.purchase.dto.apple.AppleNotificationPayloadVO; +import com.yello.server.domain.purchase.dto.apple.ApplePurchaseVO; import com.yello.server.domain.purchase.dto.apple.AppleTransaction; import com.yello.server.domain.purchase.dto.apple.TransactionInfoResponse; import com.yello.server.domain.purchase.dto.request.AppleInAppRefundRequest; import com.yello.server.domain.purchase.dto.request.AppleNotificationRequest; +import com.yello.server.domain.purchase.dto.request.GooglePubSubNotificationRequest; import com.yello.server.domain.purchase.dto.request.GoogleSubscriptionGetRequest; import com.yello.server.domain.purchase.dto.request.GoogleTicketGetRequest; +import com.yello.server.domain.purchase.dto.request.google.DeveloperNotification; +import com.yello.server.domain.purchase.dto.request.google.OneTimeProductNotificationType; +import com.yello.server.domain.purchase.dto.request.google.SubscriptionNotificationType; import com.yello.server.domain.purchase.dto.response.GoogleSubscriptionGetResponse; import com.yello.server.domain.purchase.dto.response.GoogleTicketGetResponse; import com.yello.server.domain.purchase.dto.response.UserSubscribeNeededResponse; import com.yello.server.domain.purchase.entity.Gateway; -import com.yello.server.domain.purchase.entity.ProductType; import com.yello.server.domain.purchase.entity.Purchase; +import com.yello.server.domain.purchase.entity.PurchaseState; import com.yello.server.domain.purchase.exception.GoogleBadRequestException; import com.yello.server.domain.purchase.exception.GoogleTokenNotFoundException; import com.yello.server.domain.purchase.exception.GoogleTokenServerErrorException; @@ -61,32 +72,46 @@ import java.io.IOException; import java.time.Duration; import java.time.LocalDateTime; +import java.util.Base64; +import java.util.Objects; import java.util.Optional; import lombok.Builder; -import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Value; import org.springframework.http.ResponseEntity; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; +import org.springframework.util.ObjectUtils; @Slf4j @Builder @Service -@RequiredArgsConstructor @Transactional(readOnly = true) public class PurchaseService { - private final UserRepository userRepository; - private final PurchaseRepository purchaseRepository; + private final ApiWebClient apiWebClient; private final GoogleTokenRepository googleTokenRepository; private final PurchaseManager purchaseManager; - private final ApiWebClient apiWebClient; + private final PurchaseRepository purchaseRepository; + private final UserRepository userRepository; + private String googlePubSubName; + + public PurchaseService(ApiWebClient apiWebClient, GoogleTokenRepository googleTokenRepository, + PurchaseManager purchaseManager, PurchaseRepository purchaseRepository, UserRepository userRepository, + @Value("${google.cloud.pub-sub.subscriptions.name}") String googlePubSubName) { + this.apiWebClient = apiWebClient; + this.googleTokenRepository = googleTokenRepository; + this.purchaseManager = purchaseManager; + this.purchaseRepository = purchaseRepository; + this.userRepository = userRepository; + this.googlePubSubName = googlePubSubName; + } public UserSubscribeNeededResponse getUserSubscribe(User user, LocalDateTime time) { final Optional mostRecentPurchase = purchaseRepository.findTopByUserAndProductTypeOrderByCreatedAtDesc( - user, ProductType.YELLO_PLUS); - final Boolean isSubscribeNeeded = user.getSubscribe()==Subscribe.CANCELED + user, YELLO_PLUS); + final Boolean isSubscribeNeeded = user.getSubscribe() == Subscribe.CANCELED && mostRecentPurchase.isPresent() && Duration.between(mostRecentPurchase.get().getCreatedAt(), time).getSeconds() < 1 * 24 * 60 * 60; @@ -103,7 +128,7 @@ public void verifyAppleSubscriptionTransaction(Long userId, purchaseManager.handleAppleTransactionError(verifyReceiptResponse, request.transactionId()); - if (user.getSubscribe()==Subscribe.ACTIVE) { + if (user.getSubscribe() == Subscribe.ACTIVE) { throw new SubscriptionConflictException(SUBSCRIBE_ACTIVE_EXCEPTION); } @@ -111,7 +136,8 @@ public void verifyAppleSubscriptionTransaction(Long userId, throw new PurchaseException(NOT_FOUND_TRANSACTION_EXCEPTION); } - purchaseManager.createSubscribe(user, Gateway.APPLE, request.transactionId()); + purchaseManager.createSubscribe(user, Gateway.APPLE, request.transactionId(), null, PurchaseState.ACTIVE, + verifyReceiptResponse.getBody().toString()); } @@ -126,18 +152,21 @@ public void verifyAppleTicketTransaction(Long userId, AppleTransaction request) switch (request.productId()) { case ONE_TICKET_ID: - purchaseManager.createTicket(user, ProductType.ONE_TICKET, Gateway.APPLE, - request.transactionId()); + purchaseManager.createTicket(user, ONE_TICKET, Gateway.APPLE, + request.transactionId(), null, PurchaseState.ACTIVE, + verifyReceiptResponse.getBody().toString()); user.addTicketCount(1); break; case TWO_TICKET_ID: - purchaseManager.createTicket(user, ProductType.TWO_TICKET, Gateway.APPLE, - request.transactionId()); + purchaseManager.createTicket(user, TWO_TICKET, Gateway.APPLE, + request.transactionId(), null, PurchaseState.ACTIVE, + verifyReceiptResponse.getBody().toString()); user.addTicketCount(2); break; case FIVE_TICKET_ID: - purchaseManager.createTicket(user, ProductType.FIVE_TICKET, Gateway.APPLE, - request.transactionId()); + purchaseManager.createTicket(user, FIVE_TICKET, Gateway.APPLE, + request.transactionId(), null, PurchaseState.ACTIVE, + verifyReceiptResponse.getBody().toString()); user.addTicketCount(5); break; default: @@ -151,7 +180,7 @@ public GoogleSubscriptionGetResponse verifyGoogleSubscriptionTransaction(Long us User user = userRepository.getById(userId); // exception - if (user.getSubscribe()!=Subscribe.NORMAL) { + if (user.getSubscribe() != Subscribe.NORMAL) { throw new PurchaseConflictException(GOOGLE_SUBSCRIPTIONS_FORBIDDEN_EXCEPTION); } @@ -197,7 +226,7 @@ public GoogleSubscriptionGetResponse verifyGoogleSubscriptionTransaction(Long us GOOGLE_SUBSCRIPTION_TRANSACTION_EXPIRED_EXCEPTION); } case ConstantUtil.GOOGLE_PURCHASE_SUBSCRIPTION_CANCELED -> { - if (user.getSubscribe()==Subscribe.CANCELED) { + if (user.getSubscribe() == Subscribe.CANCELED) { throw new GoogleBadRequestException( GOOGLE_SUBSCRIPTION_DUPLICATED_CANCEL_EXCEPTION); } else { @@ -207,7 +236,8 @@ public GoogleSubscriptionGetResponse verifyGoogleSubscriptionTransaction(Long us } case ConstantUtil.GOOGLE_PURCHASE_SUBSCRIPTION_ACTIVE -> { final Purchase subscribe = - purchaseManager.createSubscribe(user, Gateway.GOOGLE, request.orderId()); + purchaseManager.createSubscribe(user, Gateway.GOOGLE, request.orderId(), request.purchaseToken(), + PurchaseState.ACTIVE, subscribeResponse.getBody().toString()); subscribe.setTransactionId(request.orderId()); } } @@ -250,7 +280,7 @@ public GoogleTicketGetResponse verifyGoogleTicketTransaction(Long userId, throw new GoogleTokenServerErrorException(GOOGLE_TOKEN_SERVER_EXCEPTION); } - if (inAppResponse.getBody().purchaseState()==0) { + if (inAppResponse.getBody().purchaseState() == 0) { purchaseRepository.findByTransactionId(inAppResponse.getBody().orderId()) .ifPresent(action -> { throw new PurchaseConflictException( @@ -259,8 +289,9 @@ public GoogleTicketGetResponse verifyGoogleTicketTransaction(Long userId, Purchase ticket = purchaseManager.createTicket(user, getProductType(request.productId()), - Gateway.GOOGLE, request.orderId()); - user.addTicketCount(getTicketAmount(request.productId()) * request.quantity()); + Gateway.GOOGLE, request.orderId(), request.purchaseToken(), PurchaseState.ACTIVE, + inAppResponse.getBody().toString()); + user.addTicketCount(getTicketAmount(getProductType(request.productId())) * request.quantity()); ticket.setTransactionId(inAppResponse.getBody().orderId()); } else { throw new GoogleBadRequestException(GOOGLE_INAPP_BAD_REQUEST_EXCEPTION); @@ -297,6 +328,12 @@ public void appleNotification(AppleNotificationRequest request) { AppleNotificationPayloadVO payloadVO = purchaseManager.decodeApplePayload(request.signedPayload()); + + ApplePurchaseVO purchaseData = purchaseManager.getPurchaseData(payloadVO); + purchaseRepository.findByTransactionId(purchaseData.transactionId()) + .ifPresent((purchase) -> { + purchase.setRawData(purchaseData.toString()); + }); switch (payloadVO.notificationType()) { case APPLE_NOTIFICATION_CONSUMPTION_REQUEST: @@ -316,27 +353,95 @@ public void appleNotification(AppleNotificationRequest request) { } } - public ProductType getProductType(String googleInAppId) { - if (googleInAppId.equals(ConstantUtil.GOOGLE_ONE_TICKET_ID)) { - return ProductType.ONE_TICKET; - } else if (googleInAppId.equals(GOOGLE_TWO_TICKET_ID)) { - return ProductType.TWO_TICKET; - } else if (googleInAppId.equals(GOOGLE_FIVE_TICKET_ID)) { - return ProductType.FIVE_TICKET; + @Transactional + public void googleNotification(GooglePubSubNotificationRequest request) { + if (!Objects.equals(request.subscription(), googlePubSubName)) { + throw new GoogleBadRequestException(GOOGLE_NOTIFICATION_BAD_REQUEST_EXCEPTION); } - return null; - } + Gson gson = new Gson(); + + final String data = new String( + Base64.getDecoder() + .decode(request.message().data()) + ); - public Integer getTicketAmount(String googleInAppId) { - if (googleInAppId.equals(ConstantUtil.GOOGLE_ONE_TICKET_ID)) { - return 1; - } else if (googleInAppId.equals(GOOGLE_TWO_TICKET_ID)) { - return 2; - } else if (googleInAppId.equals(GOOGLE_FIVE_TICKET_ID)) { - return 5; + final DeveloperNotification developerNotification = gson.fromJson(data, DeveloperNotification.class); + if (ObjectUtils.isEmpty(developerNotification.getProductType())) { + throw new GoogleBadRequestException(GOOGLE_NOTIFICATION_BAD_REQUEST_EXCEPTION); } - return null; + final Optional purchase = purchaseRepository.findByPurchaseToken( + developerNotification.subscriptionNotification().purchaseToken()); + + switch (developerNotification.getProductType()) { + + case TEST -> { + log.info("TEST notification is sent by Google Play Console"); + return; + } + case YELLO_PLUS -> { + if (purchase.isEmpty()) { + log.info("notification is rejected by INVALID PurchaseToken"); + throw new PurchaseNotFoundException(PURCHASE_TOKEN_NOT_FOUND_PURCHASE_EXCEPTION); + } + purchase.get().setRawData(data); + + SubscriptionNotificationType subscriptionNotificationType = developerNotification + .subscriptionNotification() + .subscriptionNotificationType(); + + log.info(String.format("notification is type of %s", subscriptionNotificationType.toString())); + switch (subscriptionNotificationType) { + case SUBSCRIPTION_RECOVERED, SUBSCRIPTION_RENEWED, SUBSCRIPTION_PURCHASED, SUBSCRIPTION_DEFERRED -> { + // ACTIVE + purchase.get().setPurchaseState(PurchaseState.ACTIVE); + purchase.get().getUser().setSubscribe(Subscribe.ACTIVE); + } + case SUBSCRIPTION_CANCELED, SUBSCRIPTION_RESTARTED, SUBSCRIPTION_REVOKED -> { + // CANCELED + purchase.get().setPurchaseState(PurchaseState.CANCELED); + purchase.get().getUser().setSubscribe(Subscribe.CANCELED); + } + case SUBSCRIPTION_ON_HOLD, SUBSCRIPTION_IN_GRACE_PERIOD, SUBSCRIPTION_PAUSED, SUBSCRIPTION_PAUSE_SCHEDULE_CHANGED -> { + // PAUSED + purchase.get().setPurchaseState(PurchaseState.PAUSED); + purchase.get().getUser().setSubscribe(Subscribe.NORMAL); + } + case SUBSCRIPTION_EXPIRED -> { + // INACTIVE + purchase.get().setPurchaseState(PurchaseState.INACTIVE); + purchase.get().getUser().setSubscribe(Subscribe.NORMAL); + } + case SUBSCRIPTION_PRICE_CHANGE_CONFIRMED -> { + // NOTHING + return; + } + } + } + case ONE_TICKET, TWO_TICKET, FIVE_TICKET -> { + if (purchase.isEmpty()) { + log.info("notification is rejected by INVALID PurchaseToken"); + throw new PurchaseNotFoundException(PURCHASE_TOKEN_NOT_FOUND_PURCHASE_EXCEPTION); + } + purchase.get().setRawData(data); + + OneTimeProductNotificationType notificationType = developerNotification + .oneTimeProductNotification() + .notificationType(); + + log.info(String.format("notification is type of %s", notificationType.toString())); + switch (notificationType) { + case ONE_TIME_PRODUCT_PURCHASED -> { + purchase.get().setPurchaseState(PurchaseState.ACTIVE); + } + case ONE_TIME_PRODUCT_CANCELED -> { + purchase.get().setPurchaseState(PurchaseState.INACTIVE); + purchase.get().getUser() + .subTicketCount(getTicketAmount(developerNotification.getProductType())); + } + } + } + } } } diff --git a/src/main/java/com/yello/server/domain/user/entity/User.java b/src/main/java/com/yello/server/domain/user/entity/User.java index 74aa3dbc..cb7bdd9d 100644 --- a/src/main/java/com/yello/server/domain/user/entity/User.java +++ b/src/main/java/com/yello/server/domain/user/entity/User.java @@ -174,6 +174,15 @@ public void addTicketCount(int ticketCount) { this.ticketCount += ticketCount; } + public void subTicketCount(int ticketCount) { + if (this.ticketCount - ticketCount <= 0) { + this.ticketCount = 0; + } else { + this.ticketCount -= ticketCount; + } + } + + public String toGroupString() { /** * TODO 고등학교 중학교 처참함. groupAdmissionYear를 '입학년도'로 볼 것이 아닌, diff --git a/src/main/java/com/yello/server/global/common/ErrorCode.java b/src/main/java/com/yello/server/global/common/ErrorCode.java index f60c887c..fc426e59 100644 --- a/src/main/java/com/yello/server/global/common/ErrorCode.java +++ b/src/main/java/com/yello/server/global/common/ErrorCode.java @@ -33,6 +33,7 @@ public enum ErrorCode { GOOGLE_SUBSCRIPTION_DUPLICATED_CANCEL_EXCEPTION(BAD_REQUEST, "이미 CANCELED한 유저에게 CANCELED 요청을 보내어 중복됩니다"), GOOGLE_INAPP_BAD_REQUEST_EXCEPTION(BAD_REQUEST, "해당 영수증은 취소되었거나, 대기 중인 결제입니다."), + GOOGLE_NOTIFICATION_BAD_REQUEST_EXCEPTION(BAD_REQUEST, "올바르지 않은 Pub-Sub의 요청입니다."), APPLE_IN_APP_BAD_REQUEST_EXCEPTION(BAD_REQUEST, "존재하지 않는 영수증입니다."), METHOD_ARGUMENT_TYPE_MISMATCH_EXCEPTION(BAD_REQUEST, "입력한 값의 타입이 올바르지 않습니다."), USER_ADMIN_BAD_REQUEST_EXCEPTION(BAD_REQUEST, "검색 필드가 없습니다."), @@ -80,6 +81,7 @@ public enum ErrorCode { NOT_FOUND_FRIEND_EXCEPTION(NOT_FOUND, "존재하지 않는 친구이거나 친구 관계가 아닙니다."), REDIS_NOT_FOUND_UUID(NOT_FOUND, "uuid에 해당하는 디바이스 토큰 정보를 찾을 수 없습니다."), NOT_FOUND_TRANSACTION_EXCEPTION(NOT_FOUND, "존재하지 않는 거래입니다"), + PURCHASE_TOKEN_NOT_FOUND_PURCHASE_EXCEPTION(NOT_FOUND, "purchaseToken에 해당하는 Purchase가 없습니다"), NOT_FOUND_PRODUCT_ID_EXCEPTION(NOT_FOUND, "존재하지 않는 상품 아이디 입니다"), GOOGLE_TOKEN_NOT_FOUND_EXCEPTION(NOT_FOUND, "Google OAuth 2.0 토큰 튜플이 DB에 없습니다. DBA에게 문의해주세요."), GOOGLE_TOKEN_FIELD_NOT_FOUND_EXCEPTION(NOT_FOUND, diff --git a/src/test/java/com/yello/server/domain/purchase/FakePurchaseManager.java b/src/test/java/com/yello/server/domain/purchase/FakePurchaseManager.java index f7ff039d..e0782369 100644 --- a/src/test/java/com/yello/server/domain/purchase/FakePurchaseManager.java +++ b/src/test/java/com/yello/server/domain/purchase/FakePurchaseManager.java @@ -4,10 +4,12 @@ import static com.yello.server.global.common.ErrorCode.GOOGLE_SUBSCRIPTIONS_SUBSCRIPTION_EXCEPTION; import com.yello.server.domain.purchase.dto.apple.AppleNotificationPayloadVO; +import com.yello.server.domain.purchase.dto.apple.ApplePurchaseVO; import com.yello.server.domain.purchase.dto.apple.TransactionInfoResponse; import com.yello.server.domain.purchase.entity.Gateway; import com.yello.server.domain.purchase.entity.ProductType; import com.yello.server.domain.purchase.entity.Purchase; +import com.yello.server.domain.purchase.entity.PurchaseState; import com.yello.server.domain.purchase.exception.AppleTokenServerErrorException; import com.yello.server.domain.purchase.exception.PurchaseConflictException; import com.yello.server.domain.purchase.repository.PurchaseRepository; @@ -26,19 +28,21 @@ public FakePurchaseManager(PurchaseRepository purchaseRepository) { } @Override - public Purchase createSubscribe(User user, Gateway gateway, String transactionId) { + public Purchase createSubscribe(User user, Gateway gateway, String transactionId, String purchaseToken, + PurchaseState purchaseState, String rawData) { user.setSubscribe(Subscribe.ACTIVE); Purchase newPurchase = - Purchase.createPurchase(user, ProductType.YELLO_PLUS, gateway, transactionId); + Purchase.createPurchase(user, ProductType.YELLO_PLUS, gateway, transactionId, purchaseToken, purchaseState, + rawData); return purchaseRepository.save(newPurchase); } @Override - public Purchase createTicket(User user, ProductType productType, Gateway gateway, - String transactionId) { + public Purchase createTicket(User user, ProductType productType, Gateway gateway, String transactionId, + String purchaseToken, PurchaseState purchaseState, String rawData) { Purchase newPurchase = - Purchase.createPurchase(user, productType, gateway, transactionId); + Purchase.createPurchase(user, productType, gateway, transactionId, purchaseToken, purchaseState, rawData); return purchaseRepository.save(newPurchase); } @@ -54,6 +58,11 @@ public void handleAppleTransactionError(ResponseEntity }); } + @Override + public ApplePurchaseVO getPurchaseData(AppleNotificationPayloadVO payloadVO) { + return null; + } + @Override public AppleNotificationPayloadVO decodeApplePayload(String signedPayload) { return null; diff --git a/src/test/java/com/yello/server/domain/purchase/FakePurchaseRepository.java b/src/test/java/com/yello/server/domain/purchase/FakePurchaseRepository.java index e0e5d700..a9902b85 100644 --- a/src/test/java/com/yello/server/domain/purchase/FakePurchaseRepository.java +++ b/src/test/java/com/yello/server/domain/purchase/FakePurchaseRepository.java @@ -18,12 +18,12 @@ public class FakePurchaseRepository implements PurchaseRepository { @Override public Purchase save(Purchase purchase) { - if (purchase.getId()!=null && purchase.getId() > id) { + if (purchase.getId() != null && purchase.getId() > id) { id = purchase.getId(); } final Purchase newPurchase = Purchase.builder() - .id(purchase.getId()==null ? ++id : purchase.getId()) + .id(purchase.getId() == null ? ++id : purchase.getId()) .price(purchase.getPrice()) .user(purchase.getUser()) .gateway(purchase.getGateway()) @@ -71,6 +71,13 @@ public Optional findByTransactionId(String transactionId) { .findFirst(); } + @Override + public Optional findByPurchaseToken(String purchaseToken) { + return data.stream() + .filter(purchase -> purchase.getPurchaseToken().equals(purchaseToken)) + .findFirst(); + } + @Override public void delete(Purchase purchase) { data.remove(purchase);