From 269d7c10af6ec083974a872fc01fc7a657fdac9c Mon Sep 17 00:00:00 2001 From: Wojtek Bauman Date: Wed, 9 Sep 2020 15:43:36 +0200 Subject: [PATCH 1/3] Add endpoint for on-demand user orders re-submission/verification (only applies to Google Play subscriptions) as a safety net for any possibly lost subscription notifications --- .../GooglePlaySubscriptionController.kt | 15 ++++- .../GooglePlaySubscriptionService.kt | 65 ++++++++++--------- .../GooglePlaySubscriptionControllerTest.kt | 2 +- 3 files changed, 49 insertions(+), 33 deletions(-) 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..d01cb9c 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: UserOrdersRequest) { + logger.info { "Received user orders for verification: ${ordersRequest.orders}" } + return subscriptionService.verifyOrders(ordersRequest.orders) + } + /** * Publicly accessible PubSub notification webhook */ @@ -59,7 +66,13 @@ data class PurchaseRequest( @NotBlank val purchaseToken: String, val orderingUserId: String? = null, - val discountCode: String? = null + val discountCode: String? = null, + val purchaseTime: Long? = null +) + +data class UserOrdersRequest( + @NotEmpty + val orders: Collection ) 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..c3af5dd 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,28 @@ 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 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(), 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 +55,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 +68,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 +84,12 @@ class GooglePlaySubscriptionService(val androidPublisherApiClient: AndroidPublis } } + fun verifyOrders(orders: Collection) { + orders + .map(::handlePurchase) + .also { logger.info { "Verified ${it.size} user orders" } } + } + 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 +103,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 +111,21 @@ 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() = when { + 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/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") From 2a23230767143fb41d78f74d762ee5090c6a85d8 Mon Sep 17 00:00:00 2001 From: Wojtek Bauman Date: Thu, 10 Sep 2020 16:08:54 +0200 Subject: [PATCH 2/3] Use effectivePrice field if specified in the request Check user app for user subscription status once orders verification is done --- .../GooglePlaySubscriptionController.kt | 13 +++++++++---- .../googleplay/GooglePlaySubscriptionService.kt | 9 ++++++--- .../yaak/domain/userapp/UserAppClient.kt | 17 ++++++++++++++++- .../userapp/UserAppSubscriptionNotification.kt | 4 ++++ 4 files changed, 35 insertions(+), 8 deletions(-) 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 d01cb9c..99fffb6 100644 --- a/src/main/kotlin/com/dietmap/yaak/api/googleplay/GooglePlaySubscriptionController.kt +++ b/src/main/kotlin/com/dietmap/yaak/api/googleplay/GooglePlaySubscriptionController.kt @@ -42,9 +42,9 @@ class GooglePlaySubscriptionController(val subscriptionService: GooglePlaySubscr } @PostMapping("/api/googleplay/subscriptions/orders/verify") - fun verifyOrders(@RequestBody @Valid ordersRequest: UserOrdersRequest) { + fun verifyOrders(@RequestBody @Valid ordersRequest: VerifyOrdersRequest): VerifyOrdersResponse { logger.info { "Received user orders for verification: ${ordersRequest.orders}" } - return subscriptionService.verifyOrders(ordersRequest.orders) + return VerifyOrdersResponse(subscriptionService.verifyOrders(ordersRequest.orders)) } /** @@ -67,14 +67,19 @@ data class PurchaseRequest( val purchaseToken: String, val orderingUserId: String? = null, val discountCode: String? = null, - val purchaseTime: Long? = null + val purchaseTime: Long? = null, + val effectivePrice: Long? = null ) -data class UserOrdersRequest( +data class VerifyOrdersRequest( @NotEmpty val orders: Collection ) +data class VerifyOrdersResponse( + val hasActiveSubscription: Boolean +) + data class SubscriptionCancelRequest( @NotBlank val packageName: String, @NotBlank val subscriptionId: String, 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 c3af5dd..6043e2e 100644 --- a/src/main/kotlin/com/dietmap/yaak/domain/googleplay/GooglePlaySubscriptionService.kt +++ b/src/main/kotlin/com/dietmap/yaak/domain/googleplay/GooglePlaySubscriptionService.kt @@ -24,6 +24,7 @@ class GooglePlaySubscriptionService(val androidPublisherApiClient: AndroidPublis 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): SubscriptionPurchase? { @@ -36,7 +37,7 @@ class GooglePlaySubscriptionService(val androidPublisherApiClient: AndroidPublis notificationType = if (subscription.isInitialPurchase()) NotificationType.SUBSCRIPTION_PURCHASED else NotificationType.SUBSCRIPTION_RENEWED, appMarketplace = AppMarketplace.GOOGLE_PLAY, countryCode = subscription.countryCode, - price = subscription.calculateEffectivePrice(), + price = subscription.calculateEffectivePrice(purchaseRequest.effectivePrice), currencyCode = subscription.priceCurrencyCode, transactionId = subscription.orderId, originalTransactionId = subscription.getInitialOrderId(), @@ -84,10 +85,11 @@ class GooglePlaySubscriptionService(val androidPublisherApiClient: AndroidPublis } } - fun verifyOrders(orders: Collection) { + 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) { @@ -112,7 +114,8 @@ class GooglePlaySubscriptionService(val androidPublisherApiClient: AndroidPublis logger.info { "Google Play subscription notification has been sent to user app: $subscriptionUpdate" } } - private fun SubscriptionPurchase.calculateEffectivePrice() = when { + private fun SubscriptionPurchase.calculateEffectivePrice(effectivePrice: Long?) = when { + effectivePrice != null -> effectivePrice isTestPurchase() -> 0L isIntroductoryPricePurchase() -> introductoryPriceInfo.introductoryPriceAmountMicros else -> priceAmountMicros 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, From 45b94ac238e923848fe0ea0ee248312328aeb9bb Mon Sep 17 00:00:00 2001 From: Wojtek Bauman Date: Thu, 10 Sep 2020 16:23:17 +0200 Subject: [PATCH 3/3] Use effectivePrice field if specified in the request Check user app for user subscription status once orders verification is done --- src/main/resources/application.yml | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) 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