diff --git a/src/main/kotlin/com/dietmap/yaak/api/googleplay/GooglePlaySubscriptionController.kt b/src/main/kotlin/com/dietmap/yaak/api/googleplay/GooglePlaySubscriptionController.kt index ba9de11..99fffb6 100644 --- a/src/main/kotlin/com/dietmap/yaak/api/googleplay/GooglePlaySubscriptionController.kt +++ b/src/main/kotlin/com/dietmap/yaak/api/googleplay/GooglePlaySubscriptionController.kt @@ -15,6 +15,7 @@ import org.springframework.web.server.ResponseStatusException import java.nio.charset.StandardCharsets.UTF_8 import javax.validation.Valid import javax.validation.constraints.NotBlank +import javax.validation.constraints.NotEmpty @ConditionalOnProperty("yaak.google-play.enabled", havingValue = "true") @@ -40,6 +41,12 @@ class GooglePlaySubscriptionController(val subscriptionService: GooglePlaySubscr subscriptionService.cancelPurchase(cancelRequest) } + @PostMapping("/api/googleplay/subscriptions/orders/verify") + fun verifyOrders(@RequestBody @Valid ordersRequest: VerifyOrdersRequest): VerifyOrdersResponse { + logger.info { "Received user orders for verification: ${ordersRequest.orders}" } + return VerifyOrdersResponse(subscriptionService.verifyOrders(ordersRequest.orders)) + } + /** * Publicly accessible PubSub notification webhook */ @@ -59,7 +66,18 @@ data class PurchaseRequest( @NotBlank val purchaseToken: String, val orderingUserId: String? = null, - val discountCode: String? = null + val discountCode: String? = null, + val purchaseTime: Long? = null, + val effectivePrice: Long? = null +) + +data class VerifyOrdersRequest( + @NotEmpty + val orders: Collection +) + +data class VerifyOrdersResponse( + val hasActiveSubscription: Boolean ) data class SubscriptionCancelRequest( diff --git a/src/main/kotlin/com/dietmap/yaak/domain/googleplay/GooglePlaySubscriptionService.kt b/src/main/kotlin/com/dietmap/yaak/domain/googleplay/GooglePlaySubscriptionService.kt index a18e59a..6043e2e 100644 --- a/src/main/kotlin/com/dietmap/yaak/domain/googleplay/GooglePlaySubscriptionService.kt +++ b/src/main/kotlin/com/dietmap/yaak/domain/googleplay/GooglePlaySubscriptionService.kt @@ -20,41 +20,29 @@ import java.math.BigDecimal @ConditionalOnBean(AndroidPublisher::class) @Service class GooglePlaySubscriptionService(val androidPublisherApiClient: AndroidPublisher, val userAppClient: UserAppClient) { + private val PAYMENT_RECEIVED_CODE = 1 private val PAYMENT_FREE_TRIAL_CODE = 2 private val USER_ACCOUNT_ID_KEY = "obfuscatedExternalAccountId" + private val USER_APP_STATUS_ACTIVE = "ACTIVE" private val logger = KotlinLogging.logger { } - fun handlePurchase(purchaseRequest: PurchaseRequest, initialPurchase: Boolean = true): SubscriptionPurchase? { - val subscription = androidPublisherApiClient.Purchases().Subscriptions().get(purchaseRequest.packageName, purchaseRequest.subscriptionId, purchaseRequest.purchaseToken).execute() + fun handlePurchase(purchaseRequest: PurchaseRequest): SubscriptionPurchase? { + val subscription = androidPublisherApiClient.purchases().subscriptions().get(purchaseRequest.packageName, purchaseRequest.subscriptionId, purchaseRequest.purchaseToken).execute() checkArgument(subscription.paymentState in listOf(PAYMENT_RECEIVED_CODE, PAYMENT_FREE_TRIAL_CODE)) { "Subscription has not been paid yet, paymentState=${subscription.paymentState}" } - var effectivePrice = BigDecimal(subscription.priceAmountMicros).divide(BigDecimal(1000 * 1000)) - - // test purchase - if (subscription.purchaseType == 0) { - effectivePrice = BigDecimal(0.0) - } - - // introductory price purchase - if (!subscription.orderId.contains("..")) { - if (subscription.introductoryPriceInfo?.introductoryPriceAmountMicros != null) { - effectivePrice = BigDecimal(subscription.introductoryPriceInfo.introductoryPriceAmountMicros).divide(BigDecimal(1000 * 1000)) - } - } - - logger.info { "Handling purchase: $subscription, initial: $initialPurchase" } + logger.info { "Handling purchase: $subscription, initial: ${subscription.isInitialPurchase()}" } val notificationResponse = userAppClient.sendSubscriptionNotification(UserAppSubscriptionNotification( - notificationType = if (initialPurchase) NotificationType.SUBSCRIPTION_PURCHASED else NotificationType.SUBSCRIPTION_RENEWED, + notificationType = if (subscription.isInitialPurchase()) NotificationType.SUBSCRIPTION_PURCHASED else NotificationType.SUBSCRIPTION_RENEWED, appMarketplace = AppMarketplace.GOOGLE_PLAY, countryCode = subscription.countryCode, - price = effectivePrice, + price = subscription.calculateEffectivePrice(purchaseRequest.effectivePrice), currencyCode = subscription.priceCurrencyCode, transactionId = subscription.orderId, - originalTransactionId = toInitialOrderId(subscription.orderId), + originalTransactionId = subscription.getInitialOrderId(), productId = purchaseRequest.subscriptionId, - description = "Google Play ${if (initialPurchase) "initial" else "renewal"} subscription order", + description = "Google Play ${if (subscription.isInitialPurchase()) "initial" else "renewal"} subscription order", orderingUserId = purchaseRequest.orderingUserId ?: subscription[USER_ACCOUNT_ID_KEY] as String?, discountCode = purchaseRequest.discountCode, expiryTimeMillis = subscription.expiryTimeMillis, @@ -68,7 +56,7 @@ class GooglePlaySubscriptionService(val androidPublisherApiClient: AndroidPublis val content = SubscriptionPurchasesAcknowledgeRequest().setDeveloperPayload("{ applicationOrderId: ${notificationResponse?.orderId}, orderingUserId: ${purchaseRequest.orderingUserId} }") androidPublisherApiClient.Purchases().Subscriptions().acknowledge(purchaseRequest.packageName, purchaseRequest.subscriptionId, purchaseRequest.purchaseToken, content).execute() } - return subscription; + return subscription } fun cancelPurchase(cancelRequest: SubscriptionCancelRequest) { @@ -81,20 +69,13 @@ class GooglePlaySubscriptionService(val androidPublisherApiClient: AndroidPublis } } - private fun toInitialOrderId(orderId: String?): String { - return if (orderId != null) { - val split = orderId.split("..") - return split[0] - } else "" - } - fun handleSubscriptionNotification(pubsubNotification: PubSubDeveloperNotification) { pubsubNotification.subscriptionNotification?.let { logger.info { "Handling PubSub notification of type: ${it.notificationType}" } try { when (it.notificationType) { SUBSCRIPTION_PURCHASED -> handlePurchase(PurchaseRequest(pubsubNotification.packageName, it.subscriptionId, it.purchaseToken)) - SUBSCRIPTION_RENEWED -> handlePurchase(PurchaseRequest(pubsubNotification.packageName, it.subscriptionId, it.purchaseToken),false) + SUBSCRIPTION_RENEWED -> handlePurchase(PurchaseRequest(pubsubNotification.packageName, it.subscriptionId, it.purchaseToken)) else -> handleStatusUpdate(pubsubNotification.packageName, it) } } catch (e: Exception) { @@ -104,6 +85,13 @@ class GooglePlaySubscriptionService(val androidPublisherApiClient: AndroidPublis } } + fun verifyOrders(orders: Collection): Boolean { + orders + .map(::handlePurchase) + .also { logger.info { "Verified ${it.size} user orders" } } + return userAppClient.checkSubscription()?.status == USER_APP_STATUS_ACTIVE + } + private fun handleStatusUpdate(packageName: String, notification: GooglePlaySubscriptionNotification) { val subscription = androidPublisherApiClient.Purchases().Subscriptions().get(packageName, notification.subscriptionId, notification.purchaseToken).execute() logger.debug { "Google Play subscription details: $subscription" } @@ -117,7 +105,7 @@ class GooglePlaySubscriptionService(val androidPublisherApiClient: AndroidPublis price = BigDecimal(subscription.priceAmountMicros).divide(BigDecimal(1000 * 1000)), currencyCode = subscription.priceCurrencyCode, transactionId = subscription.orderId, - originalTransactionId = toInitialOrderId(subscription.orderId), + originalTransactionId = subscription.getInitialOrderId(), appMarketplace = AppMarketplace.GOOGLE_PLAY, expiryTimeMillis = subscription.expiryTimeMillis, googlePlayPurchaseDetails = GooglePlayPurchaseDetails(packageName, notification.subscriptionId, notification.purchaseToken) @@ -125,4 +113,22 @@ class GooglePlaySubscriptionService(val androidPublisherApiClient: AndroidPublis userAppClient.sendSubscriptionNotification(subscriptionUpdate) logger.info { "Google Play subscription notification has been sent to user app: $subscriptionUpdate" } } + + private fun SubscriptionPurchase.calculateEffectivePrice(effectivePrice: Long?) = when { + effectivePrice != null -> effectivePrice + isTestPurchase() -> 0L + isIntroductoryPricePurchase() -> introductoryPriceInfo.introductoryPriceAmountMicros + else -> priceAmountMicros + } + .let(::BigDecimal) + .let { it.divide(BigDecimal(1000 * 1000)) } + + private fun SubscriptionPurchase.isTestPurchase() = purchaseType == 0 + + private fun SubscriptionPurchase.isInitialPurchase() = !orderId.contains("..") + + private fun SubscriptionPurchase.isIntroductoryPricePurchase() = isInitialPurchase() && introductoryPriceInfo?.introductoryPriceAmountMicros != null + + private fun SubscriptionPurchase.getInitialOrderId() = orderId?.let { it.split("..")[0] } ?: "" + } \ No newline at end of file diff --git a/src/main/kotlin/com/dietmap/yaak/domain/userapp/UserAppClient.kt b/src/main/kotlin/com/dietmap/yaak/domain/userapp/UserAppClient.kt index bb09b33..6846623 100644 --- a/src/main/kotlin/com/dietmap/yaak/domain/userapp/UserAppClient.kt +++ b/src/main/kotlin/com/dietmap/yaak/domain/userapp/UserAppClient.kt @@ -31,9 +31,13 @@ import java.util.function.Consumer @Component -class UserAppClient(val webClient: WebClient, @Value("\${yaak.user-app.subscription-webhook-url}") handleSubscriptionUpdateUrl: String) { +class UserAppClient( + val webClient: WebClient, + @Value("\${yaak.user-app.subscription-webhook-url}") handleSubscriptionUpdateUrl: String, + @Value("\${yaak.user-app.subscription-check-url}") checkSubscriptionUrl: String) { private val subscriptionNotificationUrl: String = handleSubscriptionUpdateUrl + private val subscriptionCheckUrl: String = checkSubscriptionUrl private val logger = KotlinLogging.logger { } fun sendSubscriptionNotification(notification: UserAppSubscriptionNotification): UserAppSubscriptionOrder? { @@ -47,6 +51,17 @@ class UserAppClient(val webClient: WebClient, @Value("\${yaak.user-app.subscript .bodyToFlux(UserAppSubscriptionOrder::class.java) .blockFirst() } + + fun checkSubscription(): UserAppSubscriptionStatus? { + logger.debug { "Checking user subscription in user app" } + return webClient.get() + .uri(subscriptionCheckUrl) + .accept(MediaType.APPLICATION_JSON) + .retrieve() + .bodyToFlux(UserAppSubscriptionStatus::class.java) + .blockFirst() + } + } @Configuration diff --git a/src/main/kotlin/com/dietmap/yaak/domain/userapp/UserAppSubscriptionNotification.kt b/src/main/kotlin/com/dietmap/yaak/domain/userapp/UserAppSubscriptionNotification.kt index 0e8efe8..750bcd1 100644 --- a/src/main/kotlin/com/dietmap/yaak/domain/userapp/UserAppSubscriptionNotification.kt +++ b/src/main/kotlin/com/dietmap/yaak/domain/userapp/UserAppSubscriptionNotification.kt @@ -94,6 +94,10 @@ data class UserAppSubscriptionOrder ( val status: String ) +data class UserAppSubscriptionStatus( + val status: String +) + data class GooglePlayPurchaseDetails( val packageName: String, val subscriptionId: String, diff --git a/src/main/resources/application.yml b/src/main/resources/application.yml index c5b3c81..71b53ee 100644 --- a/src/main/resources/application.yml +++ b/src/main/resources/application.yml @@ -42,7 +42,8 @@ yaak: api-key: MyApiKey123 user-app: - subscription-webhook-url: http://localhost:8080/subscriptions/notifications/yaak + subscription-webhook-url: http://localhost:8086/user-subscription/notifications/yaak + subscription-check-url: http://localhost:8086/user-subscription google-play: enabled: false diff --git a/src/test/kotlin/com/dietmap/yaak/api/googleplay/GooglePlaySubscriptionControllerTest.kt b/src/test/kotlin/com/dietmap/yaak/api/googleplay/GooglePlaySubscriptionControllerTest.kt index d382966..2101621 100644 --- a/src/test/kotlin/com/dietmap/yaak/api/googleplay/GooglePlaySubscriptionControllerTest.kt +++ b/src/test/kotlin/com/dietmap/yaak/api/googleplay/GooglePlaySubscriptionControllerTest.kt @@ -54,7 +54,7 @@ internal class GooglePlaySubscriptionControllerTest : SupportController() { orderingUserId = "1", discountCode = "234" ) - `when`(subscriptionService.handlePurchase(request, true)).thenThrow(ResponseStatusException(HttpStatus.BAD_REQUEST, "Error communicating with user app")) + `when`(subscriptionService.handlePurchase(request)).thenThrow(ResponseStatusException(HttpStatus.BAD_REQUEST, "Error communicating with user app")) mockMvc.perform( post("/api/googleplay/subscriptions/purchases")