diff --git a/src/main/kotlin/com/dietmap/yaak/api/config/ApiCommons.kt b/src/main/kotlin/com/dietmap/yaak/api/config/ApiCommons.kt index 3fcbe64..cc75feb 100644 --- a/src/main/kotlin/com/dietmap/yaak/api/config/ApiCommons.kt +++ b/src/main/kotlin/com/dietmap/yaak/api/config/ApiCommons.kt @@ -1,5 +1,6 @@ package com.dietmap.yaak.api.config object ApiCommons { - const val API_KEY_HEADER : String = "Authorization" + const val API_KEY_HEADER: String = "Authorization" + const val TENANT_HEADER: String = "X-Tenant" } \ No newline at end of file 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 99fffb6..0269b19 100644 --- a/src/main/kotlin/com/dietmap/yaak/api/googleplay/GooglePlaySubscriptionController.kt +++ b/src/main/kotlin/com/dietmap/yaak/api/googleplay/GooglePlaySubscriptionController.kt @@ -1,5 +1,6 @@ package com.dietmap.yaak.api.googleplay +import com.dietmap.yaak.api.config.ApiCommons.TENANT_HEADER import com.dietmap.yaak.domain.googleplay.GooglePlaySubscriptionService import com.fasterxml.jackson.annotation.JsonCreator import com.fasterxml.jackson.module.kotlin.jacksonObjectMapper @@ -7,9 +8,7 @@ import com.google.api.services.androidpublisher.model.SubscriptionPurchase import mu.KotlinLogging import org.apache.commons.codec.binary.Base64 import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty -import org.springframework.web.bind.annotation.PostMapping -import org.springframework.web.bind.annotation.RequestBody -import org.springframework.web.bind.annotation.RestController +import org.springframework.web.bind.annotation.* import org.springframework.web.reactive.function.client.WebClientResponseException import org.springframework.web.server.ResponseStatusException import java.nio.charset.StandardCharsets.UTF_8 @@ -25,10 +24,13 @@ class GooglePlaySubscriptionController(val subscriptionService: GooglePlaySubscr private val logger = KotlinLogging.logger { } @PostMapping("/api/googleplay/subscriptions/purchases") - fun purchase(@RequestBody @Valid purchaseRequest: PurchaseRequest): SubscriptionPurchase? { + fun purchase( + @RequestHeader(TENANT_HEADER, required = false) tenant: String?, + @RequestBody @Valid purchaseRequest: PurchaseRequest + ): SubscriptionPurchase? { logger.info { "Received purchase request from Google Play: $purchaseRequest" } try { - return subscriptionService.handlePurchase(purchaseRequest) + return subscriptionService.handlePurchase(purchaseRequest, tenant) } catch (e: WebClientResponseException) { logger.error(e) { "Error sending notification to user app" } throw ResponseStatusException(e.statusCode, "Error sending notification to user app", e) @@ -36,24 +38,33 @@ class GooglePlaySubscriptionController(val subscriptionService: GooglePlaySubscr } @PostMapping("/api/googleplay/subscriptions/cancel") - fun cancelSubscriptionPurchase(@RequestBody @Valid cancelRequest: SubscriptionCancelRequest) { + fun cancelSubscriptionPurchase( + @RequestHeader(TENANT_HEADER, required = false) tenant: String?, + @RequestBody @Valid cancelRequest: SubscriptionCancelRequest + ) { logger.info { "Received subscription purchase cancellation request: $cancelRequest" } - subscriptionService.cancelPurchase(cancelRequest) + subscriptionService.cancelPurchase(cancelRequest, tenant) } @PostMapping("/api/googleplay/subscriptions/orders/verify") - fun verifyOrders(@RequestBody @Valid ordersRequest: VerifyOrdersRequest): VerifyOrdersResponse { + fun verifyOrders( + @RequestHeader(TENANT_HEADER, required = false) tenant: String?, + @RequestBody @Valid ordersRequest: VerifyOrdersRequest + ): VerifyOrdersResponse { logger.info { "Received user orders for verification: ${ordersRequest.orders}" } - return VerifyOrdersResponse(subscriptionService.verifyOrders(ordersRequest.orders)) + return VerifyOrdersResponse(subscriptionService.verifyOrders(ordersRequest.orders, tenant)) } /** * Publicly accessible PubSub notification webhook */ - @PostMapping("/public/api/googleplay/subscriptions/notifications") - fun update(@RequestBody pubsubRequest: PubSubRequest) { + @PostMapping("/public/api/googleplay/subscriptions/notifications/{tenant}") + fun update( + @PathVariable("tenant") tenant: String, + @RequestBody pubsubRequest: PubSubRequest + ) { logger.info { "Received Google PubSub subscription notification: $pubsubRequest" } - subscriptionService.handleSubscriptionNotification(pubsubRequest.message.developerNotification) + subscriptionService.handleSubscriptionNotification(pubsubRequest.message.developerNotification, tenant) } } diff --git a/src/main/kotlin/com/dietmap/yaak/domain/googleplay/AndroidPublisherApiClient.kt b/src/main/kotlin/com/dietmap/yaak/domain/googleplay/AndroidPublisherApiClient.kt index c22e44e..144ee16 100644 --- a/src/main/kotlin/com/dietmap/yaak/domain/googleplay/AndroidPublisherApiClient.kt +++ b/src/main/kotlin/com/dietmap/yaak/domain/googleplay/AndroidPublisherApiClient.kt @@ -12,6 +12,7 @@ import com.google.common.base.Strings import mu.KotlinLogging import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty import org.springframework.boot.context.properties.ConfigurationProperties +import org.springframework.boot.context.properties.ConstructorBinding import org.springframework.context.annotation.Bean import org.springframework.context.annotation.Configuration import org.springframework.stereotype.Component @@ -23,12 +24,25 @@ import java.util.* @Component -@ConfigurationProperties(prefix = "yaak.googleplay") +@ConstructorBinding +@ConfigurationProperties(prefix = "yaak") class GoogleDeveloperApiClientProperties { - lateinit var serviceAccountApiKeyBase64: String - lateinit var serviceAccountEmail: String - lateinit var applicationName: String; + lateinit var googleplay: GooglePlayProperties + var multitenant: List = emptyList() +} + +@ConstructorBinding +class MultitenantGooglePlayProperties( + val tenant: String, + val googleplay: GooglePlayProperties +) +@ConstructorBinding +class GooglePlayProperties( + val serviceAccountApiKeyBase64: String, + val serviceAccountEmail: String, + val applicationName: String +) { fun getServiceAccountApiKeyInputStream(): InputStream { val decoded = Base64.getDecoder().decode(serviceAccountApiKeyBase64) return ByteArrayInputStream(decoded) @@ -56,23 +70,27 @@ class AndroidPublisherClientConfiguration(val properties: GoogleDeveloperApiClie /** Global instance of the HTTP transport. */ private var HTTP_TRANSPORT: HttpTransport? = null + companion object { + const val DEFAULT_TENANT = "DEFAULT" + } + @Bean @Throws(IOException::class, GeneralSecurityException::class) - fun androidPublisherApiClient(): AndroidPublisher { - Preconditions.checkArgument(!Strings.isNullOrEmpty(properties.applicationName), - "applicationName cannot be null or empty!") - // Authorization. - newTrustedTransport() - val credential = authorizeWithServiceAccount(properties.serviceAccountEmail) + fun androidPublishers() = properties.multitenant + .associate { m -> m.tenant to createAndroidPublisher(m.googleplay) } + .plus(DEFAULT_TENANT to createAndroidPublisher(properties.googleplay)) - // Set up and return API client. - return AndroidPublisher.Builder( - HTTP_TRANSPORT!!, JSON_FACTORY, credential).setApplicationName(properties.applicationName) - .build() + private fun createAndroidPublisher(properties: GooglePlayProperties): AndroidPublisher { + Preconditions.checkArgument(!Strings.isNullOrEmpty(properties.applicationName), "applicationName cannot be null or empty!") + newTrustedTransport() + return AndroidPublisher + .Builder(HTTP_TRANSPORT!!, JSON_FACTORY, authorizeWithServiceAccount(properties.serviceAccountEmail, properties)) + .setApplicationName(properties.applicationName) + .build() } @Throws(GeneralSecurityException::class, IOException::class) - private fun authorizeWithServiceAccount(serviceAccountEmail: String): Credential { + private fun authorizeWithServiceAccount(serviceAccountEmail: String, properties: GooglePlayProperties): Credential { logger.info { "Authorizing using Service Account: $serviceAccountEmail" } // Build service account credential. return GoogleCredential.fromStream(properties.getServiceAccountApiKeyInputStream(), HTTP_TRANSPORT, JSON_FACTORY) diff --git a/src/main/kotlin/com/dietmap/yaak/domain/googleplay/AndroidPublisherService.kt b/src/main/kotlin/com/dietmap/yaak/domain/googleplay/AndroidPublisherService.kt new file mode 100644 index 0000000..04006b9 --- /dev/null +++ b/src/main/kotlin/com/dietmap/yaak/domain/googleplay/AndroidPublisherService.kt @@ -0,0 +1,14 @@ +package com.dietmap.yaak.domain.googleplay + +import com.dietmap.yaak.domain.googleplay.AndroidPublisherClientConfiguration.Companion.DEFAULT_TENANT +import com.google.api.services.androidpublisher.AndroidPublisher +import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty +import org.springframework.stereotype.Service + +@ConditionalOnProperty("yaak.google-play.enabled", havingValue = "true") +@Service +class AndroidPublisherService(private val androidPublishers: Map) { + + fun tenant(tenant: String?) = androidPublishers.getOrDefault(tenant?.toUpperCase() ?: DEFAULT_TENANT, androidPublishers[DEFAULT_TENANT])!! + +} \ No newline at end of file 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 b022595..7d53f2c 100644 --- a/src/main/kotlin/com/dietmap/yaak/domain/googleplay/GooglePlaySubscriptionService.kt +++ b/src/main/kotlin/com/dietmap/yaak/domain/googleplay/GooglePlaySubscriptionService.kt @@ -8,18 +8,17 @@ import com.dietmap.yaak.api.googleplay.PurchaseRequest import com.dietmap.yaak.api.googleplay.SubscriptionCancelRequest import com.dietmap.yaak.domain.checkArgument import com.dietmap.yaak.domain.userapp.* -import com.google.api.services.androidpublisher.AndroidPublisher import com.google.api.services.androidpublisher.model.SubscriptionPurchase import com.google.api.services.androidpublisher.model.SubscriptionPurchasesAcknowledgeRequest import mu.KotlinLogging -import org.springframework.boot.autoconfigure.condition.ConditionalOnBean +import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty import org.springframework.stereotype.Service import java.math.BigDecimal -@ConditionalOnBean(AndroidPublisher::class) +@ConditionalOnProperty("yaak.google-play.enabled", havingValue = "true") @Service -class GooglePlaySubscriptionService(val androidPublisherApiClient: AndroidPublisher, val userAppClient: UserAppClient) { +class GooglePlaySubscriptionService(val androidPublisherService: AndroidPublisherService, val userAppClient: UserAppClient) { private val PAYMENT_RECEIVED_CODE = 1 private val PAYMENT_FREE_TRIAL_CODE = 2 @@ -27,8 +26,9 @@ class GooglePlaySubscriptionService(val androidPublisherApiClient: AndroidPublis private val USER_APP_STATUS_ACTIVE = "ACTIVE" private val logger = KotlinLogging.logger { } - fun handlePurchase(purchaseRequest: PurchaseRequest): SubscriptionPurchase? { - val subscription = androidPublisherApiClient.purchases().subscriptions().get(purchaseRequest.packageName, purchaseRequest.subscriptionId, purchaseRequest.purchaseToken).execute() + fun handlePurchase(purchaseRequest: PurchaseRequest, tenant: String? = null): SubscriptionPurchase? { + val subscription = androidPublisherService.tenant(tenant).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}" } logger.info { "Handling purchase: $subscription, initial: ${subscription.isInitialPurchase()}" } @@ -54,29 +54,31 @@ class GooglePlaySubscriptionService(val androidPublisherApiClient: AndroidPublis if (subscription.acknowledgementState == 0) { logger.info { "Acknowledging Google Play subscription purchase of id=${subscription.orderId}, purchaseToken=${purchaseRequest.purchaseToken}" } val content = SubscriptionPurchasesAcknowledgeRequest().setDeveloperPayload("{ applicationOrderId: ${notificationResponse?.orderId}, orderingUserId: ${purchaseRequest.orderingUserId} }") - androidPublisherApiClient.Purchases().Subscriptions().acknowledge(purchaseRequest.packageName, purchaseRequest.subscriptionId, purchaseRequest.purchaseToken, content).execute() + androidPublisherService.tenant(tenant).purchases().subscriptions() + .acknowledge(purchaseRequest.packageName, purchaseRequest.subscriptionId, purchaseRequest.purchaseToken, content).execute() } return subscription } - fun cancelPurchase(cancelRequest: SubscriptionCancelRequest) { + fun cancelPurchase(cancelRequest: SubscriptionCancelRequest, tenant: String? = null) { logger.info { "Cancelling subscription: $cancelRequest" } try { - androidPublisherApiClient.purchases().subscriptions().cancel(cancelRequest.packageName, cancelRequest.subscriptionId, cancelRequest.purchaseToken).execute() + androidPublisherService.tenant(tenant).purchases().subscriptions() + .cancel(cancelRequest.packageName, cancelRequest.subscriptionId, cancelRequest.purchaseToken).execute() } catch (e: Exception) { logger.error(e) { "Error occurred during an attempt to cancel Google Play subscription: $cancelRequest" } throw e } } - fun handleSubscriptionNotification(pubsubNotification: PubSubDeveloperNotification) { + fun handleSubscriptionNotification(pubsubNotification: PubSubDeveloperNotification, tenant: String) { 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)) - else -> handleStatusUpdate(pubsubNotification.packageName, it) + SUBSCRIPTION_PURCHASED -> handlePurchase(PurchaseRequest(pubsubNotification.packageName, it.subscriptionId, it.purchaseToken), tenant) + SUBSCRIPTION_RENEWED -> handlePurchase(PurchaseRequest(pubsubNotification.packageName, it.subscriptionId, it.purchaseToken), tenant) + else -> handleStatusUpdate(pubsubNotification.packageName, it, tenant) } } catch (e: Exception) { logger.error(e) { "Error handling PubSub notification" } @@ -85,22 +87,23 @@ class GooglePlaySubscriptionService(val androidPublisherApiClient: AndroidPublis } } - fun verifyOrders(orders: Collection): Boolean { + fun verifyOrders(orders: Collection, tenant: String?): Boolean { orders - .map(::tryToVerifyOrder) - .also { logger.info { "Verified ${it.size} user orders" } } + .map { o -> tryToVerifyOrder(o, tenant) } + .also { logger.info { "Verified ${it.size} user orders" } } return userAppClient.checkSubscription()?.status == USER_APP_STATUS_ACTIVE } - private fun tryToVerifyOrder(purchaseRequest: PurchaseRequest) = try { + private fun tryToVerifyOrder(purchaseRequest: PurchaseRequest, tenant: String?) = try { logger.debug { "About to verify user order: $purchaseRequest" } - handlePurchase(purchaseRequest) + handlePurchase(purchaseRequest, tenant) } catch (e: Exception) { logger.error(e) { "Error during verification of user order" } } - private fun handleStatusUpdate(packageName: String, notification: GooglePlaySubscriptionNotification) { - val subscription = androidPublisherApiClient.Purchases().Subscriptions().get(packageName, notification.subscriptionId, notification.purchaseToken).execute() + private fun handleStatusUpdate(packageName: String, notification: GooglePlaySubscriptionNotification, tenant: String?) { + val subscription = androidPublisherService.tenant(tenant).purchases().subscriptions() + .get(packageName, notification.subscriptionId, notification.purchaseToken).execute() logger.debug { "Google Play subscription details: $subscription" } subscription.cancelReason?.let { logger.info { "Subscription cancel reason: $it" } } subscription.cancelSurveyResult?.let { logger.info { "Subscription cancel survey result: $it" } } 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 2101621..3e0d9fb 100644 --- a/src/test/kotlin/com/dietmap/yaak/api/googleplay/GooglePlaySubscriptionControllerTest.kt +++ b/src/test/kotlin/com/dietmap/yaak/api/googleplay/GooglePlaySubscriptionControllerTest.kt @@ -1,11 +1,15 @@ package com.dietmap.yaak.api.googleplay import com.dietmap.yaak.SupportController +import com.dietmap.yaak.api.config.ApiCommons import com.dietmap.yaak.domain.googleplay.AndroidPublisherClientConfiguration import com.dietmap.yaak.domain.googleplay.GooglePlaySubscriptionService import com.nimbusds.jose.util.Base64 +import org.assertj.core.api.Assertions.assertThat import org.junit.jupiter.api.Test +import org.mockito.ArgumentCaptor import org.mockito.BDDMockito.`when` +import org.mockito.Captor import org.mockito.Mockito import org.springframework.boot.test.mock.mockito.MockBean import org.springframework.http.HttpStatus @@ -18,11 +22,16 @@ import org.springframework.web.server.ResponseStatusException @TestPropertySource(properties = ["yaak.google-play.enabled = true"]) internal class GooglePlaySubscriptionControllerTest : SupportController() { + @MockBean lateinit var config: AndroidPublisherClientConfiguration + @MockBean lateinit var subscriptionService: GooglePlaySubscriptionService + @Captor + lateinit var tenantCaptor: ArgumentCaptor + @Test fun `should handle PubSub requests`() { val testNotification = PubSubDeveloperNotification( @@ -36,7 +45,7 @@ internal class GooglePlaySubscriptionControllerTest : SupportController() { val request = PubSubRequest("app.subscription", PubSubMessage("message.id", base64Data.toString())) mockMvc.perform( - post("/public/api/googleplay/subscriptions/notifications") + post("/public/api/googleplay/subscriptions/notifications/sample-tenant") .contentType(MediaType.APPLICATION_JSON) .characterEncoding("UTF-8") .content(asJsonString(request)) @@ -85,4 +94,31 @@ internal class GooglePlaySubscriptionControllerTest : SupportController() { Mockito.verify(subscriptionService).cancelPurchase(request) } -} \ No newline at end of file + @Test + fun `should handle multiple tenants`() { + val request = PurchaseRequest( + packageName = "app.package", + subscriptionId = "app.subscription.id", + purchaseToken = "purchase.token", + orderingUserId = "1", + discountCode = "234" + ) + `when`(subscriptionService.handlePurchase(any(), capture(tenantCaptor))).thenReturn(null) + + mockMvc.perform( + post("/api/googleplay/subscriptions/purchases") + .contentType(MediaType.APPLICATION_JSON) + .header(ApiCommons.TENANT_HEADER, "sample-tenant") + .characterEncoding("UTF-8") + .content(asJsonString(request)) + .accept(MediaType.APPLICATION_JSON)) + .andExpect(status().isOk) + + assertThat(tenantCaptor.value).isEqualTo("sample-tenant") + } + +} + +inline fun any(): T = Mockito.any(T::class.java) + +fun capture(argumentCaptor: ArgumentCaptor): T = argumentCaptor.capture() \ No newline at end of file