diff --git a/acceptance-tests/Dockerfile.stripe b/acceptance-tests/Dockerfile.stripe new file mode 100644 index 000000000..c31494c56 --- /dev/null +++ b/acceptance-tests/Dockerfile.stripe @@ -0,0 +1,9 @@ +FROM stripe/stripe-cli + +LABEL maintainer="dev@redotter.sg" + +COPY script/stripe-proxy.sh /proxy.sh + +ENTRYPOINT [] + +CMD ["/proxy.sh"] diff --git a/acceptance-tests/README.md b/acceptance-tests/README.md index a8a95c7b7..44e093085 100644 --- a/acceptance-tests/README.md +++ b/acceptance-tests/README.md @@ -1,6 +1,6 @@ -#Acceptance tests +# Acceptance tests -PRELIMINARY, EXTREMELY INCOMPLETE DOCUMENTATION, MUST BE IMPROVED! +_PRELIMINARY, EXTREMELY INCOMPLETE DOCUMENTATION, MUST BE IMPROVED!_ The ostelco system is complex in that it consists of multiple components that communicate both with internal components and external components. @@ -13,46 +13,91 @@ a context where all the external components are also present either as externally hosted test instances, or as mocked out-components running locally. -The acceptance tests themselves are run using "docker compose", and -are implemented as a separate docker container that is run in the -docker compose environment. +The acceptance tests themselves are run using `docker compose`, and are +implemented as a separate docker container that is run in the docker +compose environment. ## Docker compose files -## The various docker compose files +### The various docker compose files + There are in fact multiple docker compose files present in the top level directory, and they have different usecases: + * `docker-compose.yaml`: The main file. Will run a set of tests exercising + most of the components. - * docker-compose.yaml: The most commonly used file. Will - run a set of tests for most of the components. - - * docker-compose.esp.yaml: A test that is simplar to docker-compose.yaml, + * `docker-compose.esp.yaml`: A test that is similar to `docker-compose.yaml`, but also includes the google ESP components. We usually don't run this test since the complications of running ESP for the most part don't outweigh its utility. - * docker-compose.ocs.yaml: tbd - * docker-compose.seagull.yaml: tbd + * `docker-compose.ocs.yaml`: tbd -## Structure of the docker compose files + * `docker-compose.seagull.yaml`: tbd -## Components started by docker compose +### Structure of the docker compose files -... tbd (also a PUML diagram showing call relationships) +### Components started by docker compose +... tbd (also a PUML diagram showing call relationships) ## Prerequisites for running the acceptance tests - - ... tbd +... tbd ## How to run the acceptance tests - ... tbd, but should include: Just running them, running them while developing new tests, how to attach a test being developed to an IDE's debugger. +For the main Docker compose file: + + $ docker-compose up --build --abort-on-container-exit + +To run one of the other Docker compose files, use the `-f` option. Example: + + $ docker-compose -f docker-compose.esp.yaml up --build --abort-on-container-exit + +... tbd, how to run them while developing new tests, how to attach a test +being developed to an IDE's debugger. + +## Running tests that depends on webhooks configured at Stripe + +Currently the "recurring payment" tests depends on webhooks being enabled at Stripe +and the events being proxy forwarded the Prime backend. For enabling proxy forward +of the events the +[stripe/stripe-cli](https://hub.docker.com/r/stripe/stripe-cli) Docker image is used. + +For the tests to work the following two evnironment variables must be set to their +correct value. + + - `STRIPE_API_KEY` + - `STRIPE_ENDPOINT_SECRET` + +For the `STRIPE_API_KEY` variable go to the Stripe console and list the value at +Developer -> API keys -> Secret key. + +To get the correct `STRIPE_ENDPOINT_SECRET` value do as follows: + + $ export STRIPE_API_KEY= + $ docker run --rm -e STRIPE_API_KEY=$STRIPE_API_KEY stripe/stripe-cli listen + Checking for new versions... + + Getting ready... + Ready! Your webhook signing secret is whsec_secretvaluesecretvalue0123456789 (^C to quit) + +Alternatively download the `stripe` command line program from +[https://stripe.com/docs/stripe-cli](https://stripe.com/docs/stripe-cli) and run +the command: + $ stripe listen +(with the `STRIPE_API_KEY` environment variable set). +Set the `STRIPE_ENDPOINT_SECRET` environment variable to the string starting with +"`whsec_`". + $ export STRIPE_ENDPOINT_SECRET=whsec_secretvaluesecretvalue0123456789 +This will cause the tests that depends upon Stripe events to run. +To disiable the tests, set the `STRIPE_ENDPOINT_SECRET` to some dummy value that +don't start with the "`whsec_`" string. diff --git a/acceptance-tests/build.gradle.kts b/acceptance-tests/build.gradle.kts index a86ff690a..2f1ed8b97 100644 --- a/acceptance-tests/build.gradle.kts +++ b/acceptance-tests/build.gradle.kts @@ -35,6 +35,9 @@ dependencies { implementation(kotlin("test-junit")) implementation("io.dropwizard:dropwizard-testing:${Version.dropwizard}") + + implementation("org.junit.jupiter:junit-jupiter-api:${Version.junit5}") + runtimeOnly("org.junit.jupiter:junit-jupiter-engine:${Version.junit5}") } application { diff --git a/acceptance-tests/script/stripe-proxy.sh b/acceptance-tests/script/stripe-proxy.sh new file mode 100755 index 000000000..0f7707268 --- /dev/null +++ b/acceptance-tests/script/stripe-proxy.sh @@ -0,0 +1,20 @@ +#! /bin/sh + +# Only start the Stripe CLI tool in proxy forward mode +# if the STRIPE_ENDPOINT_SECRET environmentvariable is +# set to what looks like an actual secret. +# +# If not set, block until stopped by SIGTERM or similar. + +echo $STRIPE_ENDPOINT_SECRET | grep -q ^whsec_ + +if [ $? -eq 0 ] +then + echo Starting Stripe CLI in proxy forward mode... + stripe listen \ + --forward-to prime:8080/stripe/event \ + --events customer.subscription.updated,invoice.created,invoice.payment_succeeded,invoice.upcoming +else + echo Not starting Stripe CLI. + sleep 2147483647 # block for 2^31-1 seconds +fi diff --git a/acceptance-tests/script/wait.sh b/acceptance-tests/script/wait.sh index 7a13d923f..eb539b4fa 100755 --- a/acceptance-tests/script/wait.sh +++ b/acceptance-tests/script/wait.sh @@ -220,7 +220,18 @@ EOF echo " ... HLR preactivated rofiles loaded into prime" +## +## Creating pub-sub subscriptions for recurring payments tests (plans). +## Have to create one subscription per test, as the tests runs in parallel. +## + +echo "Creating pub-sub subscriptions for recurring payment tests" +curl -X PUT -H "Content-Type: application/json" -d '{"topic":"projects/'${GCP_PROJECT_ID}'/topics/stripe-event","ackDeadlineSeconds":10}' pubsub-emulator:8085/v1/projects/${GCP_PROJECT_ID}/subscriptions/stripe-event-okhttp-purchase-ok-sub +curl -X PUT -H "Content-Type: application/json" -d '{"topic":"projects/'${GCP_PROJECT_ID}'/topics/stripe-event","ackDeadlineSeconds":10}' pubsub-emulator:8085/v1/projects/${GCP_PROJECT_ID}/subscriptions/stripe-event-okhttp-purchase-fail-sub +curl -X PUT -H "Content-Type: application/json" -d '{"topic":"projects/'${GCP_PROJECT_ID}'/topics/stripe-event","ackDeadlineSeconds":10}' pubsub-emulator:8085/v1/projects/${GCP_PROJECT_ID}/subscriptions/stripe-event-jersey-purchase-ok-sub +curl -X PUT -H "Content-Type: application/json" -d '{"topic":"projects/'${GCP_PROJECT_ID}'/topics/stripe-event","ackDeadlineSeconds":10}' pubsub-emulator:8085/v1/projects/${GCP_PROJECT_ID}/subscriptions/stripe-event-jersey-purchase-fail-sub + ## ## Finally run acceptance tests ## -java -cp '/acceptance-tests.jar' org.junit.runner.JUnitCore org.ostelco.at.TestSuite \ No newline at end of file +java -cp '/acceptance-tests.jar' org.junit.runner.JUnitCore org.ostelco.at.TestSuite diff --git a/acceptance-tests/src/main/kotlin/org/ostelco/at/TestSuite.kt b/acceptance-tests/src/main/kotlin/org/ostelco/at/TestSuite.kt index bd38ddb6c..daeda3922 100644 --- a/acceptance-tests/src/main/kotlin/org/ostelco/at/TestSuite.kt +++ b/acceptance-tests/src/main/kotlin/org/ostelco/at/TestSuite.kt @@ -4,7 +4,9 @@ import kotlinx.coroutines.launch import kotlinx.coroutines.runBlocking import org.junit.Test import org.junit.experimental.ParallelComputer +import org.junit.jupiter.api.condition.EnabledIfEnvironmentVariable import org.junit.runner.JUnitCore +import org.ostelco.at.common.StripePayment import org.ostelco.at.common.getLogger import kotlin.test.assertTrue @@ -49,6 +51,43 @@ class TestSuite { } } + /** + * Test of recurring payments requires that: + * 1) The webhook-stripe service docker instance is running. + * 2) The STRIPE_ENDPOINT_SECRET env. variable set. + * If the STRIPE_ENDPOINT_SECRET variable is not set the tests will be skipped. + */ + @Test + @EnabledIfEnvironmentVariable(named = "STRIPE_ENDPOINT_SECRET", matches="whsec_\\S+") + fun `run recurring payment tests`() { + + /** + * TODO: For some reason the '@EnabledIfEnvironmentVariable' annotation + * is not working... + * Remove the check below when this is fixed. + */ + val secret = System.getenv("STRIPE_ENDPOINT_SECRET") ?: "not_set" + + if (!secret.matches(Regex("whsec_\\S+"))) { + logger.info("Skipping the 'recurring payment tests' as the STRIPE_ENDPOINT_SECRET environment variable " + + "is not set or set to a value not matching a secret.") + return + } + + runBlocking { + + launch { + checkResult( + JUnitCore.runClasses( + ParallelComputer(true, true), + org.ostelco.at.okhttp.RenewPlanTest::class.java, + org.ostelco.at.jersey.RenewPlanTest::class.java + ) + ) + } + } + } + private fun checkResult(result: org.junit.runner.Result) { println() @@ -60,4 +99,4 @@ class TestSuite { assertTrue(result.wasSuccessful(), "${result.failureCount} tests failed!") } -} \ No newline at end of file +} diff --git a/acceptance-tests/src/main/kotlin/org/ostelco/at/common/Products.kt b/acceptance-tests/src/main/kotlin/org/ostelco/at/common/Products.kt index 3c55527d2..86180c75c 100644 --- a/acceptance-tests/src/main/kotlin/org/ostelco/at/common/Products.kt +++ b/acceptance-tests/src/main/kotlin/org/ostelco/at/common/Products.kt @@ -14,7 +14,7 @@ fun expectedProducts(): List { ) } -val expectedPlanProduct: Product = Product() +val expectedPlanProductSG: Product = Product() .sku("PLAN_1000SGD_YEAR") .price(Price().amount(1_000_00).currency("SGD")) .properties( @@ -32,6 +32,24 @@ val expectedPlanProduct: Product = Product() ) .presentation(emptyMap()) +val expectedPlanProductUS: Product = Product() + .sku("PLAN_10USD_DAY") + .price(Price().amount(10_00).currency("USD")) + .properties( + mapOf( + "productClass" to "MEMBERSHIP", + "segmentIds" to "country-us" + ) + ) + .payment( + mapOf( + "type" to "SUBSCRIPTION", + "label" to "Daily subscription plan", + "taxRegionId" to "us" + ) + ) + .presentation(emptyMap()) + private val dfs = DecimalFormatSymbols().apply { groupingSeparator = '_' } diff --git a/acceptance-tests/src/main/kotlin/org/ostelco/at/common/StripeEventListener.kt b/acceptance-tests/src/main/kotlin/org/ostelco/at/common/StripeEventListener.kt new file mode 100644 index 000000000..6ca757b1e --- /dev/null +++ b/acceptance-tests/src/main/kotlin/org/ostelco/at/common/StripeEventListener.kt @@ -0,0 +1,167 @@ +package org.ostelco.at.common + +import com.google.api.gax.core.NoCredentialsProvider +import com.google.api.gax.grpc.GrpcTransportChannel +import com.google.api.gax.rpc.FixedTransportChannelProvider +import com.google.cloud.pubsub.v1.AckReplyConsumer +import com.google.cloud.pubsub.v1.MessageReceiver +import com.google.cloud.pubsub.v1.Subscriber +import com.google.gson.Gson +import com.google.protobuf.ByteString +import com.google.pubsub.v1.ProjectSubscriptionName +import com.stripe.model.Event +import com.stripe.model.Invoice +import com.stripe.model.Subscription +import io.grpc.ManagedChannelBuilder +import kotlinx.coroutines.delay +import kotlinx.coroutines.runBlocking +import kotlinx.coroutines.withTimeoutOrNull + +object StripeEventListener { + + private val logger by getLogger() + + val topic = "stripe-event" + + fun waitForSubscriptionPaymentToSucceed(topic: String = StripeEventListener.topic, + subscription: String, + customerId: String, + timeout: Long = 10000L): Boolean = + waitForStripeEvent( + topic = topic, + subscription = subscription, + eventType = "invoice.payment_succeeded", + timeout = timeout, + cmp = { + if (it.type != "invoice.payment_succeeded") + false + else { + val event = it.data.`object` as Invoice + (event.customer == customerId) + .also { + if (it) + logger.info("Received 'invoice.payment_succeeded' event for customer $customerId") + } + } + }) + + fun waitForFailedSubscriptionRenewal(topic: String = StripeEventListener.topic, + subscription: String, + customerId: String, + timeout: Long = 10000L): Boolean = + waitForStripeEvent( + topic = topic, + subscription = subscription, + eventType = "customer.subscription.updated", + timeout = timeout, + cmp = { + if (it.type != "customer.subscription.updated") + false + else { + val event = it.data.`object` as Subscription + (event.status == "past_due" && event.customer == customerId) + .also { + if (it) + logger.info("Received 'customer.subscription.updated' from Stripe for customer $customerId with status 'past_due'") + } + } + }) + + private fun waitForStripeEvent(topic: String, subscription: String, eventType: String, timeout: Long, cmp: (Event) -> Boolean): Boolean { + var status = false + val subscriber = PubSubSubscriber( + topic = topic, + subscription = subscription, + cmp = cmp + ) + + try { + runBlocking { + status = withTimeoutOrNull(timeout) { + subscriber.start() + while (!subscriber.eventSeen()) { + delay(200L) + } + "Done" + } != null + if (!status) + logger.error("Reached timeout limit $timeout while waiting for event $eventType") + } + } finally { + subscriber.stop() + } + + return status + } +} + +/** + * The pubsub setup is for use with emulator only. + */ +class PubSubSubscriber(val topic: String, + val subscription: String, + val cmp: (Event) -> Boolean) { + + private val logger by getLogger() + private val gson = Gson() + + private val hostport: String = System.getenv("PUBSUB_EMULATOR_HOST") + private val project: String = System.getenv("GCP_PROJECT_ID") + + private val subscriptionName = ProjectSubscriptionName.of(project, + subscription) + private val channel = ManagedChannelBuilder.forTarget(hostport) + .usePlaintext() + .build() + private val channelProvider = FixedTransportChannelProvider + .create(GrpcTransportChannel.create(channel)) + private val receiver = MessageReceiver { message, consumer -> + handler(message.data, consumer) + } + + /* For managment. */ + private var subscriber = Subscriber.newBuilder(subscriptionName, receiver) + .setChannelProvider(channelProvider) + .setCredentialsProvider(NoCredentialsProvider.create()) + .build() + + fun start() { + logger.info("ACCEPTANCE TEST PUBSUB: Enabling subscription ${subscription} for project ${project} and topic ${topic}") + + try { + subscriber.startAsync().awaitRunning() + } catch (e: Exception) { + logger.error("ACCEPTANCE TEST PUBSUB: Failed to connect to service: {}", + e.message) + } + } + + fun stop() { + try { + subscriber.stopAsync() + } catch (e: Exception) { + logger.error("ACCEPTANCE TEST PUBSUB: Error disconnecting to service: {}", + e.message) + } + } + + private fun handler(message: ByteString, consumer: AckReplyConsumer) { + try { + gson.fromJson(message.toStringUtf8(), Event::class.java) + .let { + if (cmp(it)) eventSeen = true + } + } catch (e: Exception) { + logger.error("ACCEPTANCE TEST PUBSUB: Failed to decode JSON Stripe event for 'recurring payment' processing: {}", + e.message) + eventSeen = false + } + consumer.ack() + } + + /* Flag. */ + private var eventSeen = false + + /* True on match on expected event. */ + fun eventSeen() = eventSeen +} diff --git a/acceptance-tests/src/main/kotlin/org/ostelco/at/common/StripePayment.kt b/acceptance-tests/src/main/kotlin/org/ostelco/at/common/StripePayment.kt index 2c376fb49..fa9e4b6bf 100644 --- a/acceptance-tests/src/main/kotlin/org/ostelco/at/common/StripePayment.kt +++ b/acceptance-tests/src/main/kotlin/org/ostelco/at/common/StripePayment.kt @@ -4,10 +4,13 @@ import com.stripe.Stripe import com.stripe.model.Customer import com.stripe.model.Source import com.stripe.model.Token +import com.stripe.model.WebhookEndpoint import java.time.Year object StripePayment { + private val logger by getLogger() + fun createPaymentTokenId(): String { // https://stripe.com/docs/api/java#create_card_token @@ -66,6 +69,29 @@ object StripePayment { return source.id } + fun createInsuffientFundsPaymentSourceId(): String { + + // https://stripe.com/docs/api/java#create_source + Stripe.apiKey = System.getenv("STRIPE_API_KEY") + + val sourceMap = mapOf( + "type" to "card", + "card" to mapOf( + "number" to "4000000000000341", + "exp_month" to 12, + "exp_year" to nextYear(), + "cvc" to "314"), + "owner" to mapOf( + "address" to mapOf( + "city" to "Oslo", + "country" to "Norway" + ), + "email" to "me@somewhere.com") + ) + val source = Source.create(sourceMap) + return source.id + } + fun getCardIdForTokenId(tokenId: String) : String { // https://stripe.com/docs/api/java#create_source @@ -105,6 +131,7 @@ object StripePayment { * Obtains the Stripe 'customerId' directly from Stripe. */ fun getStripeCustomerId(customerId: String) : String { + // https://stripe.com/docs/api/java#create_card_token Stripe.apiKey = System.getenv("STRIPE_API_KEY") @@ -121,16 +148,21 @@ object StripePayment { } fun deleteCustomer(customerId: String) { + // https://stripe.com/docs/api/java#create_card_token Stripe.apiKey = System.getenv("STRIPE_API_KEY") + val customers = Customer.list(emptyMap()).data + customers.filter { it.id == customerId } .forEach { it.delete() } } fun deleteAllCustomers() { + // https://stripe.com/docs/api/java#create_card_token Stripe.apiKey = System.getenv("STRIPE_API_KEY") + while (true) { val customers = Customer.list(emptyMap()).data if (customers.isEmpty()) { diff --git a/acceptance-tests/src/main/kotlin/org/ostelco/at/jersey/Tests.kt b/acceptance-tests/src/main/kotlin/org/ostelco/at/jersey/Tests.kt index 3a7df2991..e68b0c226 100644 --- a/acceptance-tests/src/main/kotlin/org/ostelco/at/jersey/Tests.kt +++ b/acceptance-tests/src/main/kotlin/org/ostelco/at/jersey/Tests.kt @@ -2,11 +2,13 @@ package org.ostelco.at.jersey import org.junit.Ignore import org.junit.Test +import org.ostelco.at.common.StripeEventListener import org.ostelco.at.common.StripePayment import org.ostelco.at.common.createCustomer import org.ostelco.at.common.createSubscription import org.ostelco.at.common.enableRegion -import org.ostelco.at.common.expectedPlanProduct +import org.ostelco.at.common.expectedPlanProductSG +import org.ostelco.at.common.expectedPlanProductUS import org.ostelco.at.common.expectedProducts import org.ostelco.at.common.getLogger import org.ostelco.at.common.graphqlGetQuery @@ -298,7 +300,7 @@ class RegionsTest { this.email = email } - assertEquals(2, regionDetailsList.size, "Customer should have 2 regions") + assertEquals(3, regionDetailsList.size, "Customer $email should have 3 regions") var receivedRegion = regionDetailsList.find { it.status == APPROVED } ?: fail("Failed to find an approved region.") val regionCode = receivedRegion.region.id @@ -1943,7 +1945,157 @@ class PlanTest { purchaseRecords.sortBy { it.timestamp } assert(Instant.now().toEpochMilli() - purchaseRecords.last().timestamp < 10_000) { "Missing Purchase Record" } - assertEquals(expectedPlanProduct, purchaseRecords.last().product, "Incorrect 'Product' in purchase record") + assertEquals(expectedPlanProductSG, purchaseRecords.last().product, "Incorrect 'Product' in purchase record") + } finally { + StripePayment.deleteCustomer(customerId = customerId) + } + } +} + +class RenewPlanTest { + + @Test + fun `jersey test - POST purchase plan with trial time`() { + + val email = "purchase-${randomInt()}@test.com" + val sku = "PLAN_10USD_DAY" + var customerId = "" + + try { + customerId = createCustomer(name = "Test Purchase Plan User", email = email).id + enableRegion(email = email, region = "us") + + val sourceId = StripePayment.createPaymentTokenId() + + post { + path = "/products/$sku/purchase" + this.email = email + queryParams = mapOf("sourceId" to sourceId) + } + + Thread.sleep(200) // wait for 200 ms for balance to be updated in db + + var purchaseRecords: PurchaseRecordList = get { + path = "/purchases" + this.email = email + } + purchaseRecords.sortBy { it.timestamp } + + assert(Instant.now().toEpochMilli() - purchaseRecords.last().timestamp < 10_000) { "Missing purchase record" } + + // First record - free product + // Second record - product subscribed to + assert(2 == purchaseRecords.size) { "Got ${purchaseRecords.size} purchase records, expected 2" } + assertEquals(expectedPlanProductUS, purchaseRecords.last().product, "Incorrect 'Product' in purchase record") + + // Actual charge for renewal will first be done after trial time + // expires, which will happen after 4 sec. Waiting a bit longer + // before checking the outcome. + StripeEventListener.waitForSubscriptionPaymentToSucceed( + subscription = "stripe-event-jersey-purchase-ok-sub", + customerId = customerId, + timeout = 30000L) + + Thread.sleep(200) + + purchaseRecords = get { + path = "/purchases" + this.email = email + } + assert(3 == purchaseRecords.size) { "Got ${purchaseRecords.size} purchase records, expected 3 after renewal" } + } finally { + StripePayment.deleteCustomer(customerId = customerId) + } + } + + @Test + fun `jersey test - POST purchase plan with trial time and with insuffient funds payment source`() { + + val email = "purchase-${randomInt()}@test.com" + val sku = "PLAN_10USD_DAY" + var customerId = "" + + try { + customerId = createCustomer(name = "Test Purchase Plan User", email = email).id + enableRegion(email = email, region = "us") + + val sourceId = StripePayment.createPaymentSourceId() + val insuffientFundsSourceId = StripePayment.createInsuffientFundsPaymentSourceId() + val newSourceId = StripePayment.createPaymentSourceId() + + post { + path = "/products/$sku/purchase" + this.email = email + queryParams = mapOf("sourceId" to sourceId) + } + + // Wait for DB to be updated. + Thread.sleep(200) + + var purchaseRecords: PurchaseRecordList = get { + path = "/purchases" + this.email = email + } + purchaseRecords.sortBy { it.timestamp } + + assert(Instant.now().toEpochMilli() - purchaseRecords.last().timestamp < 10_000) { "Missing purchase record" } + + // First record - created when trial time starts (cost is 0). + // Second record - actual charge record. + assert(2 == purchaseRecords.size) { "Got ${purchaseRecords.size} purchase records, expected 2" } + assertEquals(expectedPlanProductUS, purchaseRecords.last().product, "Incorrect 'Product' in purchase record") + + // Switch to a "no funds" card. + // This should cause subscription renewal to fail. + post { + path = "/paymentSources" + this.email = email + queryParams = mapOf("sourceId" to insuffientFundsSourceId) + } + put { + path = "/paymentSources" + this.email = email + queryParams = mapOf("sourceId" to insuffientFundsSourceId) + } + + // Actual charge for renewal will first be done after trial time + // expires, which will happen after 4 sec. Waiting a bit longer + // before checking the outcome. + StripeEventListener.waitForFailedSubscriptionRenewal( + subscription = "stripe-event-jersey-purchase-fail-sub", + customerId = customerId, + timeout = 30000L) + + // Wait for DB to be updated (creation of the 'pending payment' relation. + // (200 ms is too low...) + Thread.sleep(2000) + + purchaseRecords = get { + path = "/purchases" + this.email = email + } + assert(2 == purchaseRecords.size) { "Got ${purchaseRecords.size} purchase records, expected 2 due to renewal failure" } + + // Explicitly renew the subscription with a new card. + post { + path = "/products/$sku/renew" + this.email = email + queryParams = mapOf("sourceId" to newSourceId) + } + + StripeEventListener.waitForSubscriptionPaymentToSucceed( + subscription = "stripe-event-jersey-purchase-fail-sub", + customerId = customerId, + timeout = 30000L) + + // Wait for DB to be updated. + Thread.sleep(200) + + purchaseRecords = get { + path = "/purchases" + this.email = email + } + assert(3 == purchaseRecords.size) { "Got ${purchaseRecords.size} purchase records, expected 3 after renewal completed" } } finally { StripePayment.deleteCustomer(customerId = customerId) } diff --git a/acceptance-tests/src/main/kotlin/org/ostelco/at/okhttp/Tests.kt b/acceptance-tests/src/main/kotlin/org/ostelco/at/okhttp/Tests.kt index 988e7a505..dc1247f63 100644 --- a/acceptance-tests/src/main/kotlin/org/ostelco/at/okhttp/Tests.kt +++ b/acceptance-tests/src/main/kotlin/org/ostelco/at/okhttp/Tests.kt @@ -3,11 +3,13 @@ package org.ostelco.at.okhttp import com.fasterxml.jackson.module.kotlin.jacksonObjectMapper import org.junit.Ignore import org.junit.Test +import org.ostelco.at.common.StripeEventListener import org.ostelco.at.common.StripePayment import org.ostelco.at.common.createCustomer import org.ostelco.at.common.createSubscription import org.ostelco.at.common.enableRegion -import org.ostelco.at.common.expectedPlanProduct +import org.ostelco.at.common.expectedPlanProductSG +import org.ostelco.at.common.expectedPlanProductUS import org.ostelco.at.common.expectedProducts import org.ostelco.at.common.getLogger import org.ostelco.at.common.graphqlGetQuery @@ -994,7 +996,125 @@ class PlanTest { purchaseRecords.sortBy { it.timestamp } assert(Instant.now().toEpochMilli() - purchaseRecords.last().timestamp < 10_000) { "Missing Purchase Record" } - assertEquals(expectedPlanProduct, purchaseRecords.last().product, "Incorrect 'Product' in purchase record") + assertEquals(expectedPlanProductSG, purchaseRecords.last().product, "Incorrect 'Product' in purchase record") + } finally { + StripePayment.deleteCustomer(customerId = customerId) + } + } +} + +class RenewPlanTest { + + @Test + fun `okhttp test - POST purchase plan with trial time`() { + + val email = "purchase-${randomInt()}@test.com" + val sku = "PLAN_10USD_DAY" + var customerId = "" + + try { + customerId = createCustomer(name = "Test Purchase Plan User", email = email).id + enableRegion(email = email, region = "us") + + val client = clientForSubject(subject = email) + val sourceId = StripePayment.createPaymentTokenId() + + client.purchaseProduct(sku, sourceId, false) + + // Wait for DB to be updated. + Thread.sleep(200) + + var purchaseRecords = client.purchaseHistory + purchaseRecords.sortBy { it.timestamp } + + assert(Instant.now().toEpochMilli() - purchaseRecords.last().timestamp < 10_000) { "Missing purchase record" } + + // First record - free product + // Second record - product subscribed to + assert(2 == purchaseRecords.size) { "Got ${purchaseRecords.size} purchase records, expected 2" } + assertEquals(expectedPlanProductUS, purchaseRecords.last().product, "Incorrect 'Product' in purchase record") + + // Actual charge for renewal will first be done after trial time + // expires, which will happen after 4 sec. Waiting a bit longer + // before checking the outcome. + StripeEventListener.waitForSubscriptionPaymentToSucceed( + subscription = "stripe-event-okhttp-purchase-ok-sub", + customerId = customerId, + timeout = 30000L) + + // Wait for DB to be updated. + Thread.sleep(200) + + purchaseRecords = client.purchaseHistory + assert(3 == purchaseRecords.size) { "Got ${purchaseRecords.size} purchase records, expected 3 after renewal" } + } finally { + StripePayment.deleteCustomer(customerId = customerId) + } + } + + @Test + fun `okhttp test - POST purchase plan with trial time and with insuffient funds payment source`() { + + val email = "purchase-${randomInt()}@test.com" + val sku = "PLAN_10USD_DAY" + var customerId = "" + + try { + customerId = createCustomer(name = "Test Purchase Plan User", email = email).id + enableRegion(email = email, region = "us") + + val client = clientForSubject(subject = email) + val sourceId = StripePayment.createPaymentSourceId() + val insuffientFundsSourceId = StripePayment.createInsuffientFundsPaymentSourceId() + val newSourceId = StripePayment.createPaymentSourceId() + + client.purchaseProduct(sku, sourceId, true) + + Thread.sleep(200) // wait for balance to be updated in DB + + var purchaseRecords = client.purchaseHistory + purchaseRecords.sortBy { it.timestamp } + + assert(Instant.now().toEpochMilli() - purchaseRecords.last().timestamp < 10_000) { "Missing purchase record" } + + // First record - created when trial time starts (cost is 0). + // Second record - actual charge record. + assert(2 == purchaseRecords.size) { "Got ${purchaseRecords.size} purchase records, expected 2" } + assertEquals(expectedPlanProductUS, purchaseRecords.last().product, "Incorrect 'Product' in purchase record") + + // Switch to a "no funds" card. + // This should cause subscription renewal to fail. + client.createSource(insuffientFundsSourceId) + client.setDefaultSource(insuffientFundsSourceId) + + // Actual charge for renewal will first be done after trial time + // expires, which will happen after 4 sec. Waiting a bit longer + // before checking the outcome. + StripeEventListener.waitForFailedSubscriptionRenewal( + subscription = "stripe-event-okhttp-purchase-fail-sub", + customerId = customerId, + timeout = 30000L) + + // Wait for DB to be updated (creation of the 'pending payment' relation. + // (200 ms is too low...) + Thread.sleep(2000) + + purchaseRecords = client.purchaseHistory + assert(2 == purchaseRecords.size) { "Got ${purchaseRecords.size} purchase records, expected 2 due to renewal failure" } + + // Explicitly renew the subscription with a new card. + client.renewSubscription(sku, newSourceId, true) + + StripeEventListener.waitForSubscriptionPaymentToSucceed( + subscription = "stripe-event-okhttp-purchase-fail-sub", + customerId = customerId, + timeout = 30000L) + + // Wait for DB to be updated. + Thread.sleep(200) + + purchaseRecords = client.purchaseHistory + assert(3 == purchaseRecords.size) { "Got ${purchaseRecords.size} purchase records, expected 3 after renewal completed" } } finally { StripePayment.deleteCustomer(customerId = customerId) } diff --git a/app-notifier/src/main/kotlin/org/ostelco/prime/appnotifier/FirebaseAppNotifier.kt b/app-notifier/src/main/kotlin/org/ostelco/prime/appnotifier/FirebaseAppNotifier.kt index 4d11c43bb..7826eadc2 100644 --- a/app-notifier/src/main/kotlin/org/ostelco/prime/appnotifier/FirebaseAppNotifier.kt +++ b/app-notifier/src/main/kotlin/org/ostelco/prime/appnotifier/FirebaseAppNotifier.kt @@ -45,6 +45,24 @@ class FirebaseAppNotifier: AppNotifier { body = FCMStrings.JUMIO_IDENTITY_FAILED.s, data = data) } + NotificationType.PAYMENT_METHOD_REQUIRED -> { + logger.info("Notifying customer $customerId of failed payment on renewal of " + + "subscription to product ${data["sku"]} with data $data") + sendMessage(customerId = customerId, + title = FCMStrings.SUBSCRIPTION_RENEWAL_TITLE.s, + body = FCMStrings.SUBSCRIPTION_PAYMENT_METHOD_REQUIRED.s, + data = data) + } + NotificationType.USER_ACTION_REQUIRED -> { + /* TODO: Add support for 3D secure notification. */ + } + NotificationType.SUBSCRIPTION_RENEWAL_UPCOMING -> { + + } + NotificationType.SUBSCRIPTION_RENEWAL_STARTING -> { + /* No notification, as a notification will be sent on either successful + or failed renewal. */ + } } override fun notify(customerId: String, title: String, body: String, data: Map) { @@ -111,4 +129,4 @@ class FirebaseAppNotifier: AppNotifier { addCallback(future, apiFutureCallback, directExecutor()) } -} \ No newline at end of file +} diff --git a/customer-endpoint/src/main/kotlin/org/ostelco/prime/customer/endpoint/resources/ProductsResource.kt b/customer-endpoint/src/main/kotlin/org/ostelco/prime/customer/endpoint/resources/ProductsResource.kt index 7fd1cfc0f..c085b8495 100644 --- a/customer-endpoint/src/main/kotlin/org/ostelco/prime/customer/endpoint/resources/ProductsResource.kt +++ b/customer-endpoint/src/main/kotlin/org/ostelco/prime/customer/endpoint/resources/ProductsResource.kt @@ -54,4 +54,31 @@ class ProductsResource(private val dao: SubscriberDAO) { saveCard = saveCard ?: false) .responseBuilder(Response.Status.CREATED) }.build() + + @POST + @Path("{sku}/renew") + @Produces(MediaType.APPLICATION_JSON) + fun renewPaymentSubscription(@Auth token: AccessTokenPrincipal?, + @NotNull + @PathParam("sku") + sku: String, + @QueryParam("sourceId") + sourceId: String?, + @QueryParam("saveCard") + saveCard: Boolean?): Response = + if (token == null) { + Response.status(Response.Status.UNAUTHORIZED) + } else { + if (sourceId == null) { + dao.renewPaymentSubscription( + identity = token.identity, + sku = sku) + } else { + dao.renewPaymentSubscription( + identity = token.identity, + sku = sku, + sourceId = sourceId, + saveCard = saveCard ?: false) + }.responseBuilder(Response.Status.CREATED) + }.build() } diff --git a/customer-endpoint/src/main/kotlin/org/ostelco/prime/customer/endpoint/store/SubscriberDAO.kt b/customer-endpoint/src/main/kotlin/org/ostelco/prime/customer/endpoint/store/SubscriberDAO.kt index 0ce509958..cfbf1186a 100644 --- a/customer-endpoint/src/main/kotlin/org/ostelco/prime/customer/endpoint/store/SubscriberDAO.kt +++ b/customer-endpoint/src/main/kotlin/org/ostelco/prime/customer/endpoint/store/SubscriberDAO.kt @@ -86,6 +86,10 @@ interface SubscriberDAO { fun purchaseProduct(identity: Identity, sku: String, sourceId: String?, saveCard: Boolean): Either + fun renewPaymentSubscription(identity: Identity, sku: String): Either + + fun renewPaymentSubscription(identity: Identity, sku: String, sourceId: String, saveCard: Boolean): Either + // // Payment // diff --git a/customer-endpoint/src/main/kotlin/org/ostelco/prime/customer/endpoint/store/SubscriberDAOImpl.kt b/customer-endpoint/src/main/kotlin/org/ostelco/prime/customer/endpoint/store/SubscriberDAOImpl.kt index 6bd693eb0..9838e364a 100644 --- a/customer-endpoint/src/main/kotlin/org/ostelco/prime/customer/endpoint/store/SubscriberDAOImpl.kt +++ b/customer-endpoint/src/main/kotlin/org/ostelco/prime/customer/endpoint/store/SubscriberDAOImpl.kt @@ -2,10 +2,12 @@ package org.ostelco.prime.customer.endpoint.store import arrow.core.Either import arrow.core.flatMap +import arrow.core.right import arrow.core.left import org.ostelco.prime.activation.Activation import org.ostelco.prime.apierror.ApiError import org.ostelco.prime.apierror.ApiErrorCode +import org.ostelco.prime.apierror.ApiErrorMapper import org.ostelco.prime.apierror.ApiErrorMapper.mapPaymentErrorToApiError import org.ostelco.prime.apierror.ApiErrorMapper.mapStorageErrorToApiError import org.ostelco.prime.apierror.BadRequestError @@ -348,6 +350,31 @@ class SubscriberDAOImpl : SubscriberDAO { return hasZeroBundle } + // + // Subscription to plans + // + + override fun renewPaymentSubscription(identity: Identity, + sku: String): Either = + storage.renewSubscriptionToPlan(identity, sku) + .mapLeft { error -> + mapStorageErrorToApiError(error.message, ApiErrorCode.FAILED_TO_RENEW_SUBSCRIPTION, + error) + } + + override fun renewPaymentSubscription(identity: Identity, + sku: String, + sourceId: String, + saveCard: Boolean): Either = + storage.renewSubscriptionToPlan(identity, + sku, + sourceId, + saveCard) + .mapLeft { error -> + mapStorageErrorToApiError(error.message, ApiErrorCode.FAILED_TO_RENEW_SUBSCRIPTION, + error) + } + // // Payment // diff --git a/docker-compose.yaml b/docker-compose.yaml index c4b3f3326..275f020d2 100644 --- a/docker-compose.yaml +++ b/docker-compose.yaml @@ -79,6 +79,16 @@ services: ipv4_address: 172.16.238.3 default: + webhook-stripe: + container_name: webhook-stripe + build: + context: acceptance-tests + dockerfile: Dockerfile.stripe + environment: + - STRIPE_API_KEY=${STRIPE_API_KEY} + - STRIPE_ENDPOINT_SECRET=${STRIPE_ENDPOINT_SECRET} + command: ["/proxy.sh"] + acceptance-tests: container_name: acceptance-tests build: acceptance-tests @@ -87,9 +97,11 @@ services: - "prime" command: ["./wait.sh"] environment: + - GCP_PROJECT_ID=${GCP_PROJECT_ID} - PRIME_SOCKET=prime:8080 - OCS_SOCKET=prime:8082 - STRIPE_API_KEY=${STRIPE_API_KEY} + - STRIPE_ENDPOINT_SECRET=${STRIPE_ENDPOINT_SECRET} - GOOGLE_APPLICATION_CREDENTIALS=/secret/prime-service-account.json - PUBSUB_EMULATOR_HOST=pubsub-emulator:8085 networks: diff --git a/model/src/main/kotlin/org/ostelco/prime/model/Entities.kt b/model/src/main/kotlin/org/ostelco/prime/model/Entities.kt index a93b11c7c..a6b3863cb 100644 --- a/model/src/main/kotlin/org/ostelco/prime/model/Entities.kt +++ b/model/src/main/kotlin/org/ostelco/prime/model/Entities.kt @@ -10,6 +10,7 @@ import org.ostelco.prime.model.PaymentProperties.TYPE import org.ostelco.prime.model.ProductProperties.NO_OF_BYTES import org.ostelco.prime.model.ProductProperties.PRODUCT_CLASS import org.ostelco.prime.model.ProductProperties.SEGMENT_IDS +import org.ostelco.prime.model.PurchaseRecordProperties.INVOICE_ID import org.ostelco.prime.model.SimProfileStatus.INSTALLED import java.util.* @@ -212,7 +213,9 @@ enum class VendorScanData(val s: String) { enum class FCMStrings(val s: String) { JUMIO_NOTIFICATION_TITLE("eKYC Status"), JUMIO_IDENTITY_VERIFIED("Successfully verified the identity"), - JUMIO_IDENTITY_FAILED("Failed to verify the identity") + JUMIO_IDENTITY_FAILED("Failed to verify the identity"), + SUBSCRIPTION_RENEWAL_TITLE("Subscription Renewal"), + SUBSCRIPTION_PAYMENT_METHOD_REQUIRED("Subscription renewal failed due to missing or invalid card. Please add a new card.") } data class ApplicationToken( @@ -261,17 +264,15 @@ data class Product( @JsonIgnore get() = sku - // Values from Payment map - - val paymentType: PaymentType? + val paymentLabel: String @Exclude @JsonIgnore - get() = payment[TYPE.s]?.let(PaymentType::valueOf) + get() = payment[LABEL.s] ?: sku - val paymentLabel: String + val paymentType: String @Exclude @JsonIgnore - get() = payment[LABEL.s] ?: sku + get() = payment[TYPE.s] ?: PaymentTypes.ONE_TIME_PAYMENT.s val paymentTaxRegionId: String? @Exclude @@ -310,22 +311,27 @@ enum class ProductClass { } enum class PaymentProperties(val s: String) { - TYPE("type"), LABEL("label"), - TAX_REGION_ID("taxRegionId") + TAX_REGION_ID("taxRegionId"), + TYPE("type") } -enum class PaymentType { - SUBSCRIPTION +enum class PaymentTypes(val s: String) { + ONE_TIME_PAYMENT("ONE_TIME_PAYMENT"), + SUBSCRIPTION("SUBSCRIPTION") } -// Note: The 'name' value becomes the name (sku) of the corresponding product in Stripe. +/* Notes: + - The 'name' value becomes the name (sku) of the corresponding product + in Stripe. + - 'trialPeriod' is in milliseconds. Default is no trial period. */ data class Plan( override val id: String, val stripePlanId: String? = null, val stripeProductId: String? = null, val interval: String, - val intervalCount: Long = 1L) : HasId { + val intervalCount: Long = 1L, + val trialPeriod: Long = 0L) : HasId { companion object } @@ -335,6 +341,9 @@ data class RefundRecord( val reason: String, // possible values are duplicate, fraudulent, and requested_by_customer val timestamp: Long) : HasId +/* TODO! (kmm) To support prorating with subscriptions, the + amount paid values should be taken from the invoice + and not from the product class. */ data class PurchaseRecord( override val id: String, /* 'charge' id. */ val product: Product, @@ -343,9 +352,22 @@ data class PurchaseRecord( /* For storing 'invoice-id' when purchasing a plan. */ val properties: Map = emptyMap()) : HasId { + val chargeId: String + @JsonIgnore + get() = id + + val invoiceId: String? + @Exclude + @JsonIgnore + get() = properties[INVOICE_ID.s] + companion object } +enum class PurchaseRecordProperties(val s: String) { + INVOICE_ID("invoiceId") +} + data class SimEntry( val iccId: String, val status: SimProfileStatus, diff --git a/neo4j-store/src/main/kotlin/org/ostelco/prime/dsl/Syntax.kt b/neo4j-store/src/main/kotlin/org/ostelco/prime/dsl/Syntax.kt index 8247d3321..f5c246c33 100644 --- a/neo4j-store/src/main/kotlin/org/ostelco/prime/dsl/Syntax.kt +++ b/neo4j-store/src/main/kotlin/org/ostelco/prime/dsl/Syntax.kt @@ -19,6 +19,7 @@ import org.ostelco.prime.storage.graph.Neo4jStoreSingleton.exSubscriptionRelatio import org.ostelco.prime.storage.graph.Neo4jStoreSingleton.forPurchaseByRelation import org.ostelco.prime.storage.graph.Neo4jStoreSingleton.forPurchaseOfRelation import org.ostelco.prime.storage.graph.Neo4jStoreSingleton.identifiesRelation +import org.ostelco.prime.storage.graph.Neo4jStoreSingleton.pendingSubscriptionToPlanRelation import org.ostelco.prime.storage.graph.Neo4jStoreSingleton.offerToProductRelation import org.ostelco.prime.storage.graph.Neo4jStoreSingleton.offerToSegmentRelation import org.ostelco.prime.storage.graph.Neo4jStoreSingleton.referredRelation @@ -31,6 +32,7 @@ import org.ostelco.prime.storage.graph.Neo4jStoreSingleton.subscriptionToBundleR import org.ostelco.prime.storage.graph.RelationType import org.ostelco.prime.storage.graph.model.ExCustomer import org.ostelco.prime.storage.graph.model.Identity +import org.ostelco.prime.storage.graph.model.PendingSubscriptionToPlan import org.ostelco.prime.storage.graph.model.Offer import org.ostelco.prime.storage.graph.model.Segment import org.ostelco.prime.storage.graph.model.SimProfile @@ -83,6 +85,7 @@ data class PartialRelationExpression( // (Customer) -[BELONG_TO_REGION]-> (Region) // (SimProfile) -[SIM_PROFILE_FOR_REGION]-> (Region) // (Subscription) -[SUBSCRIPTION_UNDER_SIM_PROFILE]-> (SimProfile) +// (Customer) -[PENDING_SUBSCRIPTION_TO_PLAN]-> (Plan) open class EntityContext(val entityClass: KClass, open val id: String) @@ -125,6 +128,11 @@ data class CustomerContext(override val id: String) : EntityContext(Cu fromId = id, toId = segment.id) + infix fun hasPendingSubscriptionTo(plan: PlanContext) = PartialRelationExpression( + relationType = pendingSubscriptionToPlanRelation, + fromId = id, + toId = plan.id) + infix fun belongsToRegion(region: RegionContext) = RelationExpression( relationType = customerRegionRelation, fromId = id, diff --git a/neo4j-store/src/main/kotlin/org/ostelco/prime/storage/graph/Neo4jStore.kt b/neo4j-store/src/main/kotlin/org/ostelco/prime/storage/graph/Neo4jStore.kt index e8aa8845f..6ba49e791 100644 --- a/neo4j-store/src/main/kotlin/org/ostelco/prime/storage/graph/Neo4jStore.kt +++ b/neo4j-store/src/main/kotlin/org/ostelco/prime/storage/graph/Neo4jStore.kt @@ -58,7 +58,7 @@ import org.ostelco.prime.model.KycType.MY_INFO import org.ostelco.prime.model.KycType.NRIC_FIN import org.ostelco.prime.model.MyInfoApiVersion import org.ostelco.prime.model.MyInfoApiVersion.V3 -import org.ostelco.prime.model.PaymentType.SUBSCRIPTION +import org.ostelco.prime.model.PaymentTypes import org.ostelco.prime.model.Plan import org.ostelco.prime.model.Price import org.ostelco.prime.model.Product @@ -90,8 +90,8 @@ import org.ostelco.prime.paymentprocessor.core.PaymentTransactionInfo import org.ostelco.prime.paymentprocessor.core.PlanAlredyPurchasedError import org.ostelco.prime.paymentprocessor.core.ProductInfo import org.ostelco.prime.paymentprocessor.core.ProfileInfo +import org.ostelco.prime.paymentprocessor.core.SubscriptionPaymentInfo import org.ostelco.prime.paymentprocessor.core.StorePurchaseError -import org.ostelco.prime.paymentprocessor.core.SubscriptionDetailsInfo import org.ostelco.prime.paymentprocessor.core.SubscriptionError import org.ostelco.prime.paymentprocessor.core.UpdatePurchaseError import org.ostelco.prime.securearchive.SecureArchiveService @@ -130,6 +130,7 @@ import org.ostelco.prime.storage.graph.model.Identifies import org.ostelco.prime.storage.graph.model.Identity import org.ostelco.prime.storage.graph.model.Offer import org.ostelco.prime.storage.graph.model.PlanSubscription +import org.ostelco.prime.storage.graph.model.PendingSubscriptionToPlan import org.ostelco.prime.storage.graph.model.Segment import org.ostelco.prime.storage.graph.model.SimProfile import org.ostelco.prime.storage.graph.model.SubscriptionToBundle @@ -196,6 +197,8 @@ enum class Relation( SIM_PROFILE_FOR_REGION(from = SimProfile::class, to = Region::class), // (SimProfile) -[SIM_PROFILE_FOR_REGION]-> (Region) SUBSCRIPTION_UNDER_SIM_PROFILE(from = Subscription::class, to = SimProfile::class), // (Subscription) -[SUBSCRIPTION_UNDER_SIM_PROFILE]-> (SimProfile) + + PENDING_SUBSCRIPTION_TO_PLAN(from = Customer::class, to = Plan::class), // (Customer) -[PENDING_SUBSCRIPTION_TO_PLAN]-> (Plan) } class Neo4jStore : GraphStore by Neo4jStoreSingleton @@ -334,6 +337,13 @@ object Neo4jStoreSingleton : GraphStore { to = simProfileEntity, dataClass = None::class.java) + val pendingSubscriptionToPlanRelation = RelationType( + relation = Relation.PENDING_SUBSCRIPTION_TO_PLAN, + from = customerEntity, + to = planEntity, + dataClass = PendingSubscriptionToPlan::class.java) + private val pendingSubscriptionToPlanRelationStore = UniqueRelationStore(pendingSubscriptionToPlanRelation) + private val onNewCustomerAction: OnNewCustomerAction = config.onNewCustomerAction.getKtsService() private val allowedRegionsService: AllowedRegionsService = config.allowedRegionsService.getKtsService() private val onKycApprovedAction: OnKycApprovedAction = config.onKycApprovedAction.getKtsService() @@ -1249,18 +1259,30 @@ object Neo4jStoreSingleton : GraphStore { } if (product.price.amount > 0) { - val (chargeId, invoiceId) = when (product.paymentType) { - SUBSCRIPTION -> { - val subscriptionDetailsInfo = purchasePlan( + val purchaseRecord = when (product.paymentType) { + PaymentTypes.SUBSCRIPTION.s -> { + val subscriptionPaymentInfo = purchasePlan( customer = customer, sku = product.sku, sourceId = sourceId, saveCard = saveCard, taxRegionId = product.paymentTaxRegionId) .bind() - Pair(subscriptionDetailsInfo.chargeId, subscriptionDetailsInfo.invoiceId) + /* If 'paymentStatus' is set to 'TRIAL_START' then the no actual charge + for the product has been done yet. The actual charge will be done + when the trial time expires. If this payment is successful a new + purchase record will be created. */ + /* TODO! (kmm) 'chargeId' will never be non null on successful purchase. + But should assert this. */ + PurchaseRecord(id = subscriptionPaymentInfo.chargeId ?: UUID.randomUUID().toString(), + product = product, + timestamp = Instant.now().toEpochMilli(), + properties = mapOf( + "invoiceId" to (subscriptionPaymentInfo.invoiceId + ?: UUID.randomUUID().toString()), + "paymentStatus" to subscriptionPaymentInfo.status.name)) } - else -> { + PaymentTypes.ONE_TIME_PAYMENT.s -> { val invoicePaymentInfo = oneTimePurchase( customer = customer, sourceId = sourceId, @@ -1271,14 +1293,24 @@ object Neo4jStoreSingleton : GraphStore { productLabel = product.paymentLabel, transaction = transaction) .bind() - Pair(invoicePaymentInfo.chargeId, invoicePaymentInfo.id) + PurchaseRecord(id = invoicePaymentInfo.chargeId, + product = product, + timestamp = Instant.now().toEpochMilli(), + properties = mapOf( + "invoiceId" to invoicePaymentInfo.id)) + } + else -> { + InvalidRequestError(description = "Product purchase failed due to illegal product: ${product.sku}", + internalError = SystemError(type = "Product", + id = product.sku, + message = "Missing product class in properties of product: ${product.sku}")) + .left() + .bind() } } - val purchaseRecord = PurchaseRecord( - id = chargeId, - product = product, - timestamp = Instant.now().toEpochMilli(), - properties = mapOf("invoiceId" to invoiceId)) + + val chargeId = purchaseRecord.chargeId + val invoiceId = purchaseRecord.invoiceId /* If this step fails, the previously added 'removeInvoice' call added to the transaction will ensure that the invoice will be voided. */ @@ -1316,47 +1348,69 @@ object Neo4jStoreSingleton : GraphStore { } // << END - fun WriteTransaction.applyProduct(customerId: String, product: Product): Either = Either.fx { - when (product.productClass) { - MEMBERSHIP -> { - product.segmentIds.forEach { segmentId -> - assignCustomerToSegment(customerId = customerId, - segmentId = segmentId, + + fun WriteTransaction.applyProduct(customerId: String, product: Product): Either = Either.fx { + /* Apply the effect of product class. */ + when (product.productClass) { + MEMBERSHIP -> { + /* No action. */ + } + SIMPLE_DATA -> { + /* Topup. */ + simpleDataProduct( + customerId = customerId, + sku = product.sku, + bytes = product.noOfBytes, transaction = transaction) .mapLeft { SystemError( - type = "Customer -> Segment", - id = "$customerId -> $segmentId", - message = "Failed to assign Membership", + type = "Customer", + id = product.sku, + message = "Failed to update balance for customer: $customerId", error = it) } .bind() } + else -> { + SystemError( + type = "Product", + id = product.sku, + message = "Missing product class in properties of product: ${product.sku}") + .left() + .bind() + } } - SIMPLE_DATA -> { - /* Topup. */ - simpleDataProduct( - customerId = customerId, - sku = product.sku, - bytes = product.noOfBytes, - transaction = transaction) - .mapLeft { - SystemError( - type = "Customer", - id = product.sku, - message = "Failed to update balance for customer: $customerId", - error = it) - } - .bind() - } - else -> { - SystemError( - type = "Product", - id = product.sku, - message = "Missing product class in properties of product: ${product.sku}").left().bind() + + /* Apply the effect of payment type. */ + when (product.paymentType) { + PaymentTypes.SUBSCRIPTION.s -> { + product.segmentIds.forEach { segmentId -> + assignCustomerToSegment(customerId = customerId, + segmentId = segmentId, + transaction = transaction) + .mapLeft { + SystemError( + type = "Customer -> Segment", + id = "$customerId -> $segmentId", + message = "Failed to assign Membership", + error = it) + } + .bind() + } + } + PaymentTypes.ONE_TIME_PAYMENT.s -> { + /* No action. */ + } + else -> { + SystemError( + type = "Product", + id = product.sku, + message = "No payment type set in payment properties of product: ${product.sku}") + .left() + .bind() + } } } - } private fun fetchOrCreatePaymentProfile(customer: Customer): Either = // Fetch/Create stripe payment profile for the customer. @@ -1375,147 +1429,142 @@ object Neo4jStoreSingleton : GraphStore { sku: String, taxRegionId: String?, sourceId: String?, - saveCard: Boolean): Either { - return Either.fx { + saveCard: Boolean): Either = + Either.fx { - /* Bail out if subscriber tries to buy an already bought plan. - Note: Already verified above that 'customer' (subscriber) exists. */ - get(PurchaseRecord forPurchaseBy (Customer withId customer.id)) - .mapLeft { - StorePurchaseError( - description = "Failed to fetch Purchase Records for Customer", - message = it.message, - internalError = it) - } - .flatMap { purchaseRecords -> - if (purchaseRecords.any { x:PurchaseRecord -> x.product.sku == sku }) { - PlanAlredyPurchasedError("A subscription to plan $sku already exists") - .left() - } else { - Unit.right() + /* Bail out if subscriber tries to buy an already bought plan. + Note: Already verified above that 'customer' (subscriber) exists. */ + get(PurchaseRecord forPurchaseBy (Customer withId customer.id)) + .mapLeft { + StorePurchaseError( + description = "Failed to fetch Purchase Records for Customer", + message = it.message, + internalError = it) } - }.bind() + .flatMap { purchaseRecords -> + if (purchaseRecords.any { x: PurchaseRecord -> x.product.sku == sku }) { + PlanAlredyPurchasedError("A subscription to plan $sku already exists") + .left() + } else { + Unit.right() + } + }.bind() - /* A source must be associated with a payment profile with the payment vendor. - Create the profile if it don't exists. */ - fetchOrCreatePaymentProfile(customer) - .bind() + /* A source must be associated with a payment profile with the payment vendor. + Create the profile if it don't exists. */ + fetchOrCreatePaymentProfile(customer) + .bind() - /* With recurring payments, the payment card (source) must be stored. The - 'saveCard' parameter is therefore ignored. */ - if (!saveCard) { - logger.warn("Ignoring request for deleting payment source after buying plan $sku for " + - "customer ${customer.id} as stored payment source is required when purchasing a plan") - } + /* TODO! (kmm) Move the whole 'add source if not exists' plus 'savecard' logic etc. + into payment-processor. */ + if (sourceId != null) { + val sourceDetails = paymentProcessor.getSavedSources(customer.id) + .mapLeft { + org.ostelco.prime.paymentprocessor.core.NotFoundError("Failed to fetch sources for customer: ${customer.id}", + internalError = it) + }.bind() + if (!sourceDetails.any { sourceDetailsInfo -> sourceDetailsInfo.id == sourceId }) { + paymentProcessor.addSource(customer.id, sourceId) + .bind().id + } + } + + if (sourceId != null) { + val (sourceDetails) = paymentProcessor.getSavedSources(customer.id) + .mapLeft { + org.ostelco.prime.paymentprocessor.core.NotFoundError("Failed to fetch sources for customer: ${customer.id}", + internalError = it) + } + if (!sourceDetails.any { sourceDetailsInfo -> sourceDetailsInfo.id == sourceId }) { + paymentProcessor.addSource(customer.id, sourceId) + .bind().id + } + } - if (sourceId != null) { - val (sourceDetails) = paymentProcessor.getSavedSources(customer.id) + subscribeToPlan( + customerId = customer.id, + planId = sku, + taxRegionId = taxRegionId) .mapLeft { - org.ostelco.prime.paymentprocessor.core.NotFoundError("Failed to fetch sources for customer: ${customer.id}", + AuditLog.error(customerId = customer.id, message = "Failed to subscribe to plan $sku") + SubscriptionError("Failed to subscribe ${customer.id} to plan $sku", internalError = it) } - if (!sourceDetails.any { sourceDetailsInfo -> sourceDetailsInfo.id == sourceId }) { - paymentProcessor.addSource(customer.id, sourceId) - .bind().id - } + .bind() } - subscribeToPlan( - customerId = customer.id, - planId = sku, - taxRegionId = taxRegionId) - .mapLeft { - AuditLog.error(customerId = customer.id, message = "Failed to subscribe to plan $sku") - SubscriptionError("Failed to subscribe ${customer.id} to plan $sku", - internalError = it) - } - .bind() - } - } private fun WriteTransaction.subscribeToPlan( customerId: String, planId: String, - taxRegionId: String?, - trialEnd: Long = 0L): Either { - - return Either.fx { - val (plan) = get(Plan withId planId) - val (profileInfo) = paymentProcessor.getPaymentProfile(customerId) - .mapLeft { - NotFoundError(type = planEntity.name, id = "Failed to subscribe $customerId to ${plan.id}", - error = it) - } - - /* At this point, we have either: - 1) A new subscription to a plan is being created. - 2) An attempt at buying a previously subscribed to plan but which has not been - paid for. - Both are OK. But in order to handle the second case correctly, the previous incomplete - subscription must be removed before we can proceed with creating the new subscription. - - (In the second case there will be a "SUBSCRIBES_TO_PLAN" link between the customer - object and the plan object, but no "PURCHASED" link to the plans "product" object.) - - The motivation for supporting the second case, is that it allows the subscriber to - reattempt to buy a plan using a different payment source. - - Remove existing incomplete subscription if any. */ - get(Plan forCustomer (Customer withId customerId)) - .map { - if (it.any { x -> x.id == planId }) { - removeSubscription(customerId, planId, invoiceNow = true) - } - } + taxRegionId: String?): Either = Either.fx { + val (plan) = get(Plan withId planId) + val (profileInfo) = paymentProcessor.getPaymentProfile(customerId) + .mapLeft { + NotFoundError(type = planEntity.name, id = "Failed to create subscription for $customerId to ${plan.id}", + error = it) + } - /* Lookup in payment backend will fail if no value found for 'stripePlanId'. */ - val planStripeId = plan.stripePlanId ?: SystemError(type = planEntity.name, id = plan.id, - message = "No reference to Stripe plan found in ${plan.id}") - .left() - .bind() + /* Lookup in payment backend will fail if no value found for 'stripePlanId'. */ + val planStripeId = plan.stripePlanId ?: SystemError(type = planEntity.name, id = plan.id, + message = "No reference to Stripe plan found for ${plan.id}") + .left() + .bind() - val subscriptionDetailsInfo = paymentProcessor.createSubscription( - planId = planStripeId, - customerId = profileInfo.id, - trialEnd = trialEnd, - taxRegionId = taxRegionId) - .mapLeft { - NotCreatedError(type = planEntity.name, id = "Failed to subscribe $customerId to ${plan.id}", - error = it) - }.linkReversalActionToTransaction(transaction) { - paymentProcessor.cancelSubscription(it.id) - }.bind() + /* Currently mainly used for simulating recurring subscription in tests. */ + val trialEnd = if (plan.trialPeriod > 0L) + Instant.now().toEpochMilli() + plan.trialPeriod + else + 0L + + val subscriptionPaymentInfo = paymentProcessor.createSubscription( + planId = planStripeId, + customerId = profileInfo.id, + trialEnd = trialEnd, + taxRegionId = taxRegionId) + .mapLeft { + NotCreatedError(type = planEntity.name, id = "Failed to create subscription for customer $customerId to ${plan.id}", + error = it) + }.linkReversalActionToTransaction(transaction) { + paymentProcessor.cancelSubscription(it.id) + }.bind() - /* Dispatch according to the charge result. */ - when (subscriptionDetailsInfo.status) { - PaymentStatus.PAYMENT_SUCCEEDED -> { - } - PaymentStatus.REQUIRES_PAYMENT_METHOD -> { - NotCreatedError(type = "Customer subscription to Plan", - id = "$customerId -> ${plan.id}", - error = InvalidRequestError("Payment method failed") - ).left().bind() - } - PaymentStatus.REQUIRES_ACTION, - PaymentStatus.TRIAL_START -> { - /* No action required. Charge for the subscription will eventually + /* Dispatch according to the charge result. */ + when (subscriptionPaymentInfo.status) { + PaymentStatus.PAYMENT_SUCCEEDED -> { + /* No action required. */ + } + PaymentStatus.REQUIRES_PAYMENT_METHOD -> { + NotCreatedError(type = planEntity.name, id = "$customerId -> ${plan.id}", + error = InvalidRequestError("Payment method failed")) + .left() + .bind() + } + PaymentStatus.REQUIRES_ACTION -> { + NotCreatedError(type = planEntity.name, id = "$customerId -> ${plan.id}", + error = InvalidRequestError("3D Secure currently not supported")) + .left() + .bind() + } + PaymentStatus.TRIAL_START -> { + /* No action required. Charge for the subscription will eventually be reported as a Stripe event. */ - logger.info( - "Pending payment for subscription $planId for customer $customerId (${subscriptionDetailsInfo.status.name})") - } + logger.info( + "Pending payment for subscription $planId for customer $customerId (${subscriptionPaymentInfo.status.name})") } - /* Store information from payment backend for later use. */ - fact { - (Customer withId customerId) subscribesTo (Plan withId planId) using - PlanSubscription( - subscriptionId = subscriptionDetailsInfo.id, - created = subscriptionDetailsInfo.created, - trialEnd = subscriptionDetailsInfo.trialEnd) - }.bind() - - subscriptionDetailsInfo } + + /* Store information from payment backend for later use. */ + fact { + (Customer withId customerId) subscribesTo (Plan withId planId) using + PlanSubscription( + subscriptionId = subscriptionPaymentInfo.id, + created = subscriptionPaymentInfo.created, + trialEnd = subscriptionPaymentInfo.trialEnd) + }.bind() + + subscriptionPaymentInfo } private fun oneTimePurchase( @@ -1574,7 +1623,7 @@ object Neo4jStoreSingleton : GraphStore { }.bind() /* Force immediate payment of the invoice. */ - val (invoicePaymentInfo) = paymentProcessor.payInvoice(invoice.id) + paymentProcessor.payInvoice(invoice.id) .mapLeft { logger.info("Charging for invoice ${invoice.id} for product $sku failed for customer ${customer.id}.") /* Adds failed purchase to customer history. */ @@ -1585,12 +1634,10 @@ object Neo4jStoreSingleton : GraphStore { }.linkReversalActionToTransaction(transaction) { paymentProcessor.refundCharge(it.chargeId) logger.warn(NOTIFY_OPS_MARKER, """ - Refunded customer ${customer.id} for invoice: ${it.id}. - Verify that the invoice has been refunded in Stripe dashboard. - """.trimIndent()) - } - - invoicePaymentInfo + Refunded customer ${customer.id} for invoice: ${it.id}. + Verify that the invoice has been refunded in Stripe dashboard. + """.trimIndent()) + }.bind() } private fun removePaymentSource(saveCard: Boolean, paymentCustomerId: String, sourceId: String) { @@ -1655,7 +1702,7 @@ object Neo4jStoreSingleton : GraphStore { fun WriteTransaction.createPurchaseRecord(customerId: String, purchaseRecord: PurchaseRecord): Either { - val invoiceId = purchaseRecord.properties["invoiceId"] + val invoiceId = purchaseRecord.invoiceId return Either.fx { @@ -2394,7 +2441,7 @@ object Neo4jStoreSingleton : GraphStore { override fun createPlan( plan: Plan, stripeProductName: String, - planProduct: Product): Either = writeTransaction { + product: Product): Either = writeTransaction { Either.fx { if (get(Product withSku plan.id).isRight()) { @@ -2409,7 +2456,7 @@ object Neo4jStoreSingleton : GraphStore { .bind() } - val (productInfo) = paymentProcessor.createProduct(stripeProductName) + val (productInfo) = paymentProcessor.createProduct(plan.id, stripeProductName) .mapLeft { NotCreatedError(type = planEntity.name, id = "Failed to create plan ${plan.id}", error = it) @@ -2418,8 +2465,8 @@ object Neo4jStoreSingleton : GraphStore { } val (planInfo) = paymentProcessor.createPlan( productInfo.id, - planProduct.price.amount, - planProduct.price.currency, + product.price.amount, + product.price.currency, PaymentProcessor.Interval.valueOf(plan.interval.toUpperCase()), plan.intervalCount) .mapLeft { NotCreatedError(type = planEntity.name, id = "Failed to create plan ${plan.id}", @@ -2428,15 +2475,6 @@ object Neo4jStoreSingleton : GraphStore { paymentProcessor.removePlan(it.id) } - /* The associated product to the plan. Note that: - sku - name of the plan - property value 'productClass' is set to "plan" - TODO: Update to new backend model. */ - val product = planProduct.copy( - payment = planProduct.payment + mapOf( - "type" to SUBSCRIPTION.name) - ) - /* Propagates errors from lower layer if any. */ create { product }.bind() create { @@ -2485,13 +2523,10 @@ object Neo4jStoreSingleton : GraphStore { override fun subscribeToPlan( identity: ModelIdentity, - planId: String, - trialEnd: Long): Either = writeTransaction { - + planId: String): Either = writeTransaction { Either.fx { val (customer) = getCustomer(identity = identity) - val (product) = getProduct(identity, planId) subscribeToPlan( @@ -2534,42 +2569,214 @@ object Neo4jStoreSingleton : GraphStore { }.ifFailedThenRollback(transaction) } - override fun purchasedSubscription( - customerId: String, - invoiceId: String, - chargeId: String, - sku: String, - amount: Long, - currency: String): Either = writeTransaction { + override fun renewSubscriptionToPlan(identity: org.ostelco.prime.model.Identity, + sku: String): Either = writeTransaction { + getCustomer(identity = identity) + .flatMap { customer -> + pendingSubscriptionToPlanRelationStore + .get(fromId = customer.id, toId = sku, transaction = transaction) + .flatMap { + paymentProcessor.payInvoice(it.invoiceId) + .mapLeft { + NotFoundError(type = "", id = "") + } + } + .flatMap { + removePendingSubscriptionToPlan(customer.id, sku) + } + .flatMap { + getProduct(identity, sku) /* Return the subscribed to product. */ + } + } + } + + override fun renewSubscriptionToPlan(identity: org.ostelco.prime.model.Identity, + sku: String, + sourceId: String, + saveCard: Boolean): Either = writeTransaction { + getCustomer(identity = identity) + .flatMap { customer -> + pendingSubscriptionToPlanRelationStore + .get(fromId = customer.id, toId = sku, transaction = transaction) + .flatMap { pendingSubscription -> + paymentProcessor.addSource(customer.id, sourceId) + .flatMap { + pendingSubscription.right() + } + .mapLeft { + NotFoundError(type = "", id = "") + } + .finallyDo(transaction) { + if (!saveCard) + paymentProcessor.removeSource(customer.id, sourceId) + .mapLeft { err -> + logger.error("Removing payment source $sourceId failed with error ${err}") + } + } + } + .flatMap { + paymentProcessor.payInvoice(it.invoiceId, sourceId) + .mapLeft { + NotFoundError(type = "", id = "") + } + } + .flatMap { + removePendingSubscriptionToPlan(customer.id, sku) + } + .flatMap { + getProduct(identity, sku) /* Return the subscribed to product. */ + } + } + } + + private fun removePendingSubscriptionToPlan(customerId: String, sku: String): Either = writeTransaction { + write(""" + MATCH (c:${customerEntity.name} {id: '${customerId}'})-[r:${pendingSubscriptionToPlanRelation.name}]->(p:${planEntity.name} {id: '${sku}'}) + DELETE r + """.trimIndent(), transaction) { statementResult -> + Either.cond( + test = statementResult.summary().counters().relationshipsDeleted() > 0, + ifTrue = { + }, + ifFalse = { + NotFoundError(type = "", id = "") + } + ) + } + } + + override fun notifySubscriptionToPlanRenewalUpcoming(customerId: String, + sku: String, + dueDate: Long): Either = readTransaction { + get(Product withSku sku) + .flatMap { product -> + /* Notify the customer about upcoming renewal. */ + appNotifier.notify( + notificationType = NotificationType.SUBSCRIPTION_RENEWAL_UPCOMING, + customerId = customerId, + data = mapOf( + "sku" to sku, + "dueDate" to dueDate + ).plus(product.presentation)) + Unit.right() + } + } + + override fun notifySubscriptionToPlanRenewalStarting(customerId: String, + sku: String, + invoiceId: String, + payInvoiceNow: Boolean): Either = readTransaction { + get(Product withSku sku) + .flatMap { product -> + /* Notify the customer about the subscription renewal. */ + appNotifier.notify( + notificationType = NotificationType.SUBSCRIPTION_RENEWAL_STARTING, + customerId = customerId, + data = mapOf( + "sku" to sku + ).plus(product.presentation)) + product.right() + }.flatMap { product -> + /* Otherwise the charge will be made automatically in about 1 hour. */ + if (payInvoiceNow) + paymentProcessor.payInvoice(invoiceId) + .mapLeft { + logger.error("Payment of invoice ${invoiceId} failed for customer ${customerId}.") + it + } + Unit.right() + } + } + + override fun renewedSubscriptionToPlanSuccessfully(customerId: String, + sku: String, + subscriptionPaymentInfo: SubscriptionPaymentInfo): Either = writeTransaction { Either.fx { - val (product) = get(Product withSku sku) - val (plan) = get(Plan withId sku) + val product = get(Product withSku sku) + .bind() + val plan = get(Plan withId sku) + .bind() + + /* For successful purchases it should never be the case that the 'charge-id' + is not set. */ + val dummyChargeId = UUID.randomUUID().toString() + if (subscriptionPaymentInfo.chargeId == null) { + logger.error("Got no 'charge-id' with successful renewal of subscription " + + "${subscriptionPaymentInfo.id} to product ${sku} for customer ${customerId} " + + "falling back to using the dummy value ${dummyChargeId}") + } + val chargeId = subscriptionPaymentInfo.chargeId ?: dummyChargeId val purchaseRecord = PurchaseRecord( id = chargeId, product = product, timestamp = Instant.now().toEpochMilli(), - properties = mapOf("invoiceId" to invoiceId) + properties = mapOf( + "invoiceId" to (subscriptionPaymentInfo.invoiceId), + "paymentStatus" to subscriptionPaymentInfo.status.name) ) /* Will exit if an existing purchase record matches on 'invoiceId'. */ createPurchaseRecord(customerId, purchaseRecord) .bind() - // FIXME Moving customer to new segments should be done only based on productClass. - /* Offer products to the newly signed up subscriber. */ - product.segmentIds.forEach { segmentId -> - assignCustomerToSegment( - customerId = customerId, - segmentId = segmentId, - transaction = transaction) - .bind() - } - logger.info("Customer $customerId completed payment of invoice $invoiceId for subscription to plan ${plan.id}") - + logger.info( + "Customer $customerId completed payment of invoice ${subscriptionPaymentInfo.invoiceId} " + + "for renewal of subscription to plan ${plan.id}" + ) plan }.ifFailedThenRollback(transaction) } + override fun subscriptionToPlanRenewalFailed(customerId: String, + sku: String, + subscriptionPaymentInfo: SubscriptionPaymentInfo): Either = writeTransaction { + Either.fx { + val product = get(Product withSku sku) + .bind() + val plan = get(Plan withId sku) + .bind() + + /* Check if the renewal entry is already present (can happen on multiple + payment failures on the same subscription). */ + val entryExists = pendingSubscriptionToPlanRelationStore + .get(fromId = customerId, toId = sku, transaction = transaction) + .fold( + { + if (it is NotFoundError) { + false + } else { + it.left().bind() /* Bail out on all other errors. */ + } + }, + { true } + ) + + /* Store 'invoice-id' etc. for the customer to manually completing + the renewal later. */ + if (!entryExists) + fact { + (Customer withId customerId) hasPendingSubscriptionTo (Plan withId plan.id) using + PendingSubscriptionToPlan( + sku = sku, + subscriptionId = subscriptionPaymentInfo.id, + invoiceId = subscriptionPaymentInfo.invoiceId) + }.bind() + + /* Notify the customer about pending renewal. */ + appNotifier.notify( + notificationType = NotificationType.PAYMENT_METHOD_REQUIRED, + customerId = customerId, + data = mapOf( + "sku" to sku + ).plus(product.presentation)) + Unit + }.ifFailedThenRollback(transaction) + } + + // Used in tests for cleanup. + override fun removeProductAndPricePlans(productId: String): Either = + paymentProcessor.removeProductAndPricePlans(productId) + // // For verifying payment transactions // @@ -2915,4 +3122,4 @@ fun Map.copy(key: K, value: V): Map { return mutableMap.toMap() } -fun utcTimeNow() = ZonedDateTime.now(ZoneOffset.UTC).toString() \ No newline at end of file +fun utcTimeNow() = ZonedDateTime.now(ZoneOffset.UTC).toString() diff --git a/neo4j-store/src/main/kotlin/org/ostelco/prime/storage/graph/model/Model.kt b/neo4j-store/src/main/kotlin/org/ostelco/prime/storage/graph/model/Model.kt index 0ae23d368..c4db4921e 100644 --- a/neo4j-store/src/main/kotlin/org/ostelco/prime/storage/graph/model/Model.kt +++ b/neo4j-store/src/main/kotlin/org/ostelco/prime/storage/graph/model/Model.kt @@ -73,3 +73,8 @@ data class ExCustomer( companion object } + +data class PendingSubscriptionToPlan( + val sku: String, + val subscriptionId: String, + val invoiceId: String) diff --git a/neo4j-store/src/main/resources/AcceptanceTestSetup.kts b/neo4j-store/src/main/resources/AcceptanceTestSetup.kts index a77f2fef6..32d861998 100644 --- a/neo4j-store/src/main/resources/AcceptanceTestSetup.kts +++ b/neo4j-store/src/main/resources/AcceptanceTestSetup.kts @@ -4,7 +4,7 @@ import org.ostelco.prime.model.Offer import org.ostelco.prime.model.PaymentProperties.LABEL import org.ostelco.prime.model.PaymentProperties.TAX_REGION_ID import org.ostelco.prime.model.PaymentProperties.TYPE -import org.ostelco.prime.model.PaymentType.SUBSCRIPTION +import org.ostelco.prime.model.PaymentTypes.SUBSCRIPTION import org.ostelco.prime.model.Plan import org.ostelco.prime.model.Price import org.ostelco.prime.model.Product @@ -77,6 +77,11 @@ job { throw Exception(it.message) } +adminStore.removeProductAndPricePlans("PLAN_1000SGD_YEAR") + .mapLeft { + logger.warn(it.message) + } + adminStore.createPlan( plan = Plan( id = "PLAN_1000SGD_YEAR", @@ -89,9 +94,9 @@ adminStore.createPlan( PRODUCT_CLASS.s to MEMBERSHIP.name, SEGMENT_IDS.s to "country-sg"), payment = mapOf( - TYPE.s to SUBSCRIPTION.name, LABEL.s to "Annual subscription plan", - TAX_REGION_ID.s to "sg" + TAX_REGION_ID.s to "sg", + TYPE.s to SUBSCRIPTION.name ) ) ).mapLeft { @@ -126,4 +131,72 @@ adminStore.atomicCreateOffer( ) ).mapLeft { throw Exception(it.message) -} \ No newline at end of file +} + +// For US + +// Plan is created with trial time, which will cause payment to happen as +// a result of Stripe events and not due to a synchronous purchase call. + +job { + create { Region(id = "us", name = "USA") } +}.mapLeft { + throw Exception(it.message) +} + +adminStore.removeProductAndPricePlans("PLAN_10USD_DAY") + .mapLeft { + logger.warn(it.message) + } + +adminStore.createPlan( + plan = Plan( + id = "PLAN_10USD_DAY", + interval = "day", + trialPeriod = 4000L), // 4 sec. trial time + stripeProductName = "Daily subscription plan", + planProduct = Product( + sku = "PLAN_10USD_DAY", + price = Price(amount = 10_00, currency = "USD"), + properties = mapOf( + PRODUCT_CLASS.s to MEMBERSHIP.name, + SEGMENT_IDS.s to "country-us"), + payment = mapOf( + LABEL.s to "Daily subscription plan", + TAX_REGION_ID.s to "us", + TYPE.s to SUBSCRIPTION.name + ) + ) +).mapLeft { + throw Exception(it.message) +} + +adminStore.atomicCreateOffer( + offer = Offer( + id = "plan-offer-us", + products = listOf("PLAN_10USD_DAY") + ), + segments = listOf(Segment(id = "plan-country-us")) +).mapLeft { + throw Exception(it.message) +} + +adminStore.atomicCreateOffer( + offer = Offer(id = "default_offer-us"), + segments = listOf(Segment(id = "country-us")), + products = listOf( + Product(sku = "1GB_5USD", + price = Price(5_00, "USD"), + properties = mapOf( + PRODUCT_CLASS.s to SIMPLE_DATA.name, + NO_OF_BYTES.s to "1_073_741_824" + ), + payment = mapOf( + LABEL.s to "1GB", + TAX_REGION_ID.s to "us" + ) + ) + ) +).mapLeft { + throw Exception(it.message) +} diff --git a/neo4j-store/src/test/kotlin/org/ostelco/prime/storage/graph/Neo4jStoreTest.kt b/neo4j-store/src/test/kotlin/org/ostelco/prime/storage/graph/Neo4jStoreTest.kt index 488ea6c8e..46cc27116 100644 --- a/neo4j-store/src/test/kotlin/org/ostelco/prime/storage/graph/Neo4jStoreTest.kt +++ b/neo4j-store/src/test/kotlin/org/ostelco/prime/storage/graph/Neo4jStoreTest.kt @@ -54,6 +54,7 @@ import org.ostelco.prime.notifications.EmailNotifier import org.ostelco.prime.paymentprocessor.PaymentProcessor import org.ostelco.prime.paymentprocessor.core.InvoiceInfo import org.ostelco.prime.paymentprocessor.core.InvoicePaymentInfo +import org.ostelco.prime.paymentprocessor.core.PaymentStatus import org.ostelco.prime.paymentprocessor.core.ProfileInfo import org.ostelco.prime.sim.SimManager import org.ostelco.prime.storage.NotFoundError @@ -259,7 +260,7 @@ class Neo4jStoreTest { `when`(mockPaymentProcessor.payInvoice( invoiceId = invoiceId) - ).thenReturn(InvoicePaymentInfo(invoiceId, chargeId).right()) + ).thenReturn(InvoicePaymentInfo(invoiceId, PaymentStatus.PAYMENT_SUCCEEDED, chargeId).right()) // prep job { diff --git a/payment-processor/src/integration-test/kotlin/org/ostelco/prime/paymentprocessor/StripePaymentProcessorTest.kt b/payment-processor/src/integration-test/kotlin/org/ostelco/prime/paymentprocessor/StripePaymentProcessorTest.kt index c227578a6..be29cef78 100644 --- a/payment-processor/src/integration-test/kotlin/org/ostelco/prime/paymentprocessor/StripePaymentProcessorTest.kt +++ b/payment-processor/src/integration-test/kotlin/org/ostelco/prime/paymentprocessor/StripePaymentProcessorTest.kt @@ -281,7 +281,7 @@ class StripePaymentProcessorTest { @Test fun createAndRemoveProduct() { - val resultCreateProduct = paymentProcessor.createProduct("TestSku") + val resultCreateProduct = paymentProcessor.createProduct("TEST_SKU","TestSku") assertNotFailure(resultCreateProduct) val resultRemoveProduct = paymentProcessor.removeProduct(resultCreateProduct.fold({ "" }, { it.id })) @@ -294,7 +294,7 @@ class StripePaymentProcessorTest { val resultAddSource = paymentProcessor.addSource(customerId, createPaymentTokenId()) assertNotFailure(resultAddSource) - val resultCreateProduct = paymentProcessor.createProduct("TestSku") + val resultCreateProduct = paymentProcessor.createProduct("TEST_SKU","TestSku") assertNotFailure(resultCreateProduct) val resultCreatePlan = paymentProcessor.createPlan(right(resultCreateProduct).id, 1000, "NOK", PaymentProcessor.Interval.MONTH) diff --git a/payment-processor/src/main/kotlin/org/ostelco/prime/paymentprocessor/StripePaymentProcessor.kt b/payment-processor/src/main/kotlin/org/ostelco/prime/paymentprocessor/StripePaymentProcessor.kt index 550528b01..2f97266a4 100644 --- a/payment-processor/src/main/kotlin/org/ostelco/prime/paymentprocessor/StripePaymentProcessor.kt +++ b/payment-processor/src/main/kotlin/org/ostelco/prime/paymentprocessor/StripePaymentProcessor.kt @@ -38,7 +38,7 @@ import org.ostelco.prime.paymentprocessor.core.ProfileInfo import org.ostelco.prime.paymentprocessor.core.SourceDetailsInfo import org.ostelco.prime.paymentprocessor.core.SourceError import org.ostelco.prime.paymentprocessor.core.SourceInfo -import org.ostelco.prime.paymentprocessor.core.SubscriptionDetailsInfo +import org.ostelco.prime.paymentprocessor.core.SubscriptionPaymentInfo import org.ostelco.prime.paymentprocessor.core.SubscriptionInfo import org.ostelco.prime.paymentprocessor.core.TaxRateInfo import java.math.BigDecimal @@ -177,6 +177,7 @@ class StripePaymentProcessor : PaymentProcessor { override fun createPlan(productId: String, amount: Int, currency: String, interval: PaymentProcessor.Interval, intervalCount: Long): Either = either("Failed to create plan for product $productId amount $amount currency $currency interval ${interval.value}") { val planParams = mapOf( + "id" to productId, "product" to productId, "amount" to amount, "interval" to interval.value, @@ -194,9 +195,21 @@ class StripePaymentProcessor : PaymentProcessor { } } - override fun createProduct(name: String): Either = + override fun removeProductAndPricePlans(productId: String): Either = + either("Failed to remove product ${productId} and associated price plans") { + val params = mapOf( + "product" to productId + ) + Plan.list(params).data.forEach { + it.delete() + } + ProductInfo(Product.retrieve(productId).delete().id) + } + + override fun createProduct(productId: String, name: String): Either = either("Failed to create product with name $name") { val productParams = mapOf( + "id" to productId, "name" to name, "type" to "service") ProductInfo(Product.create(productParams).id) @@ -208,6 +221,35 @@ class StripePaymentProcessor : PaymentProcessor { ProductInfo(product.delete().id) } + override fun addSource(customerId: String, + sourceId: String, + setDefault: Boolean): Either = + either("Failed to add source $sourceId to customer $customerId") { + val customer = Customer.retrieve(customerId) + val sources = customer.sources.data.map { + it.id + } + if (!sources.any { + it == sourceId + }) { + val params = mapOf( + "source" to sourceId, + "metadata" to mapOf("created" to Instant.now()) + ) + customer.sources.create(params).id + } else { + sourceId + }.let { + if (setDefault) { + val params = mapOf( + "default_source" to it + ) + customer.update(params) + } + SourceInfo(it) + } + } + override fun addSource(customerId: String, sourceId: String): Either = either("Failed to add source $sourceId to customer $customerId") { val customer = Customer.retrieve(customerId) @@ -236,10 +278,7 @@ class StripePaymentProcessor : PaymentProcessor { .right() } - /* The 'expand' part will cause an immediate attempt at charging for the - subscription when creating it. For interpreting the result see: - https://stripe.com/docs/billing/subscriptions/payment#signup-3b */ - override fun createSubscription(planId: String, customerId: String, trialEnd: Long, taxRegionId: String?): Either = + override fun createSubscription(planId: String, customerId: String, trialEnd: Long, taxRegionId: String?): Either = either("Failed to subscribe customer $customerId to plan $planId") { val item = mapOf("plan" to planId) val taxRates = getTaxRatesForTaxRegionId(taxRegionId) @@ -247,52 +286,67 @@ class StripePaymentProcessor : PaymentProcessor { { emptyList() }, { it } ) - val subscriptionParams = mapOf( + val trialEndInEpochSeconds = ofEpochMilliToSecond(trialEnd) + val params = mapOf( "customer" to customerId, "items" to mapOf("0" to item), - *(if (trialEnd > Instant.now().epochSecond) - arrayOf("trial_end" to trialEnd.toString()) + *(if (trialEndInEpochSeconds > Instant.now().epochSecond) + arrayOf("trial_end" to trialEndInEpochSeconds.toString()) else arrayOf("expand" to arrayOf("latest_invoice.payment_intent"))), *(if (taxRates.isNotEmpty()) arrayOf("default_tax_rates" to taxRates.map { it.id }) - else arrayOf())) - val subscription = Subscription.create(subscriptionParams) - val status = subscriptionStatus(subscription) - SubscriptionDetailsInfo(id = subscription.id, - status = status.first, - invoiceId = status.second, - chargeId = status.third, - created = Instant.ofEpochSecond(subscription.created).toEpochMilli(), - trialEnd = subscription.trialEnd ?: 0L) + else arrayOf()), + "payment_behavior" to "allow_incomplete", + "prorate" to true) + getSubscriptionPaymentInfo(Subscription.create(params)) } - private fun subscriptionStatus(subscription: Subscription): Triple { + private fun getSubscriptionPaymentInfo(subscription: Subscription): SubscriptionPaymentInfo { val invoice = subscription.latestInvoiceObject val intent = invoice?.paymentIntentObject - return when (subscription.status) { - "active", "incomplete" -> { - if (intent != null) - Triple(when (intent.status) { + return if (subscription.status == "trialing") { + PaymentStatus.TRIAL_START + } else { + require(intent != null) { + "Expected an invoice intent with invoice ${invoice.id} for subscription ${subscription.id} " + + "with status ${subscription.status}" + } + when (subscription.status) { + "active" -> { + when (intent.status) { "succeeded" -> PaymentStatus.PAYMENT_SUCCEEDED + else -> throw IllegalArgumentException("Unexpected intent ${intent.status} for invoice ${invoice.id} " + + "for subscription ${subscription.id} with status ${subscription.status}") + } + } + "incomplete" -> { + when (intent.status) { "requires_payment_method" -> PaymentStatus.REQUIRES_PAYMENT_METHOD "requires_action" -> PaymentStatus.REQUIRES_ACTION - else -> throw RuntimeException( - "Unexpected intent ${intent.status} for Stipe payment invoice ${invoice.id}") - }, invoice.id, invoice.charge) - else { - throw RuntimeException( - "'Intent' absent in response when creating subscription ${subscription.id} with status ${subscription.status}") + else -> throw IllegalArgumentException("Unexpected intent ${intent.status} for invoice ${invoice.id} " + + "for subscription ${subscription.id} with status ${subscription.status}") + } + } + else -> { + throw IllegalArgumentException( + "Got unexpected status ${subscription.status} when creating subscription ${subscription.id}") } } - "trialing" -> { - Triple(PaymentStatus.TRIAL_START, "", "") - } - else -> { - throw RuntimeException( - "Got unexpected status ${subscription.status} when creating subscription ${subscription.id}") - } + }.let { + SubscriptionPaymentInfo(id = subscription.id, + status = it, + invoiceId = if (invoice != null) + invoice.id + else + subscription.latestInvoice, /* No 'invoice' object in case of trials. */ + chargeId = invoice?.charge, + sku = subscription.plan.product, + created = ofEpochSecondToMilli(subscription.created), + currentPeriodStart = ofEpochSecondToMilli(subscription.currentPeriodStart), + currentPeriodEnd = ofEpochSecondToMilli(subscription.currentPeriodEnd), + trialEnd = ofEpochSecondToMilli(subscription.trialEnd ?: 0L)) } } @@ -376,7 +430,8 @@ class StripePaymentProcessor : PaymentProcessor { override fun removeSource(customerId: String, sourceId: String): Either = either("Failed to remove source $sourceId for customerId $customerId") { - val accountInfo = Customer.retrieve(customerId).sources.retrieve(sourceId) + val accountInfo = Customer.retrieve(customerId).sources + .retrieve(sourceId) when (accountInfo) { is Card -> accountInfo.delete() is Source -> accountInfo.detach() @@ -475,10 +530,33 @@ class StripePaymentProcessor : PaymentProcessor { Invoice.create(params) } - override fun payInvoice(invoiceId: String): Either = + /* + TODO: Handle payement fails on subscr. creation/renewal. + + Ref. https://stripe.com/docs/billing/subscriptions/payment#handling-failure + + Missing parts: + - on create-sub or renewal with status "payment-fails" + -- collect invoice-id + -- call 'payInvoice' and expand function to include "payment-intent" + in "expand" parameter + - if payment fails with 402 - probably fine with java as the invoice is always + retrieved first. + + TODO Must have two pay methods, one for subscription renewal and one for all other + or maybe not... + */ + override fun payInvoice(invoiceId: String, sourceId: String?): Either = either("Failed to complete payment of invoice ${invoiceId}") { - val receipt = Invoice.retrieve(invoiceId).pay() - InvoicePaymentInfo(receipt.id, receipt?.charge ?: UUID.randomUUID().toString()) + val params = if (sourceId != null) + mapOf("source" to sourceId) + else + emptyMap() + val receipt = Invoice.retrieve(invoiceId) + .pay(params) + InvoicePaymentInfo(id = receipt.id, + status = PaymentStatus.PAYMENT_SUCCEEDED, + chargeId = receipt.charge ?: UUID.randomUUID().toString()) } override fun removeInvoice(invoiceId: String): Either = @@ -610,4 +688,7 @@ class StripePaymentProcessor : PaymentProcessor { /* Timestamps in Stripe must be in seconds. */ private fun ofEpochMilliToSecond(ts: Long): Long = ts.div(1000L) + + /* Timestamps in Prime must be in milliseconds. */ + private fun ofEpochSecondToMilli(ts: Long): Long = Instant.ofEpochSecond(ts).toEpochMilli() } diff --git a/payment-processor/src/main/kotlin/org/ostelco/prime/paymentprocessor/subscribers/RecurringPaymentStripeEvent.kt b/payment-processor/src/main/kotlin/org/ostelco/prime/paymentprocessor/subscribers/RecurringPaymentStripeEvent.kt index bb8ff941f..3e370c20c 100644 --- a/payment-processor/src/main/kotlin/org/ostelco/prime/paymentprocessor/subscribers/RecurringPaymentStripeEvent.kt +++ b/payment-processor/src/main/kotlin/org/ostelco/prime/paymentprocessor/subscribers/RecurringPaymentStripeEvent.kt @@ -9,10 +9,18 @@ import com.stripe.model.* import org.ostelco.prime.getLogger import org.ostelco.prime.module.getResource import org.ostelco.prime.paymentprocessor.ConfigRegistry +import org.ostelco.prime.paymentprocessor.core.PaymentStatus +import org.ostelco.prime.paymentprocessor.core.SubscriptionPaymentInfo import org.ostelco.prime.pubsub.PubSubSubscriber import org.ostelco.prime.storage.AdminDataSource -import org.ostelco.prime.storage.ValidationError +import java.time.Instant +/** + * Acts on Stripe events related to renewal of subscriptions. + * + * Error cases are handled according to the recommendations at: + * https://stripe.com/docs/billing/subscriptions/payment#building-your-own-handling-for-recurring-charge-failures + */ class RecurringPaymentStripeEvent : PubSubSubscriber( subscription = ConfigRegistry.config.stripeEventRecurringPaymentSubscriptionId, topic = ConfigRegistry.config.stripeEventTopicId, @@ -35,9 +43,10 @@ class RecurringPaymentStripeEvent : PubSubSubscriber( /* Only invoices are of interrest vs. recurring payment (I think). */ when (data) { is Invoice -> invoiceEvent(eventType, data) + is Subscription -> subscriptionEvent(eventType, data) } }.getOrElse { - logger.error("Attempt to log Stripe event {} failed with error message: {}", + logger.error("Processing Stripe event {} failed with message: {}", message.toStringUtf8(), it.message) } consumer.ack() @@ -49,57 +58,146 @@ class RecurringPaymentStripeEvent : PubSubSubscriber( } ) + /* Upcoming renewal and successfully renewed subscriptions. */ private fun invoiceEvent(eventType: String, invoice: Invoice) { - /* Skip invoices not related to subscriptions (recurring payment). */ if (invoice.subscription.isNullOrEmpty()) return + if (invoice.billingReason != "subscription_cycle") + return when (eventType) { - "invoice.payment_succeeded" -> { - /* The first time a subscription is created and the payment is not done immediately, - f.ex. due to 3D secure handlig or because a trial period is used, Stripe will issue - a "dummy" invoice with billing-reason: subscription_update - This check will ensure that only actual (or real) invoices are acted upon. - TODO: Has been checked with trials but it remains to check if this is also the - case with 3D secure payment sources. */ - if (invoice.billingReason == "subscription_cycle") - invoice.lines.data.forEach { - purchasedSubscriptionEvent(invoice.customer, invoice.id, invoice.charge, it.plan) - } - else - logger.debug("Invoice ${invoice.id} successfully paid with billing reason: ${invoice.billingReason}") - } - "invoice.payment_failed" -> { - } + /* Early notification (configurable in Stripe console) of renewal. */ "invoice.upcoming" -> { + invoiceUpcoming(invoice) } + /* Sent right before subscription renewal. */ "invoice.created" -> { + invoiceCreated(invoice) } - // on canceled subsc. F.ex. with expired payment - "invoice.updated" -> { + /* Subscription successfully renewed. */ + "invoice.payment_succeeded" -> { + invoicePaymentSucceeded(invoice) } - "invoice.voided" -> { + } + } + + /* Failed payment on renewal. */ + private fun subscriptionEvent(eventType: String, subscription: Subscription) { + if (eventType != "customer.subscription.updated") + return + if (subscription.status != "past_due") + return + + val invoice = Invoice.retrieve(subscription.latestInvoice) + val intent = PaymentIntent.retrieve(invoice.paymentIntent) + + when (intent.status) { + /* No funds. */ + "requires_payment_method" -> { + requiresPaymentMethod(subscription, invoice) } - "customer.subscription.updated" -> { + /* 3D secure. */ + "requires_action" -> { + requiresUserAction(subscription, invoice) } } } - private fun purchasedSubscriptionEvent(customerId: String, invoiceId: String, chargeId: String, plan: Plan) { - val productId = plan.product - val productDetails = Product.retrieve(productId) - storage.purchasedSubscription(customerId, invoiceId, chargeId, productDetails.name, plan.amount, plan.currency) - .mapLeft { - when (it) { - is ValidationError -> { - /* Ignore as the purchase has already been registered (due to direct - charge being done when purchasing a subscription). */ - } - else -> { - logger.error("Adding subscription purchase report for invoice {} failed with error message: {}", - invoiceId, it.message) - } - } - } + private fun invoiceUpcoming(invoice: Invoice) { + val subscription = Subscription.retrieve(invoice.subscription) + val plan = subscription.plan + + storage.notifySubscriptionToPlanRenewalUpcoming( + customerId = invoice.customer, + sku = plan.product, + dueDate = ofEpochSecondToMilli(invoice.dueDate) + ).mapLeft { + logger.error("Notification to customer ${invoice.customer} of upcoming renewal of subscription ${subscription.id} " + + "to plan ${plan.product} failed with error message ${it.message}") + } + } + + private fun invoiceCreated(invoice: Invoice) { + val subscription = Subscription.retrieve(invoice.subscription) + val plan = subscription.plan + + /* Force immediate payment in acceptance tests. + If false then Stripe will do a charge attempt after 1 hour. */ + val payInvoiceNow = System.getenv("ACCEPTANCE_TESTING") == "true" + + storage.notifySubscriptionToPlanRenewalStarting( + customerId = invoice.customer, + sku = plan.product, + invoiceId = invoice.id, + payInvoiceNow = payInvoiceNow + ).mapLeft { + logger.error("Notification to customer ${invoice.customer} of subscription ${subscription.id} " + + "to plan ${plan.product} beiing renewed failed with error message ${it.message}") + } + } + + private fun invoicePaymentSucceeded(invoice: Invoice) { + if (invoice.status != "paid") { + logger.debug("Invoice ${invoice.id} for customer ${invoice.customer} paid with status ${invoice.status}") + return + } + val subscription = Subscription.retrieve(invoice.subscription) + val plan = subscription.plan + + storage.renewedSubscriptionToPlanSuccessfully( + customerId = invoice.customer, + sku = plan.product, + subscriptionPaymentInfo = buildSubscriptionPaymentInfo( + status = PaymentStatus.PAYMENT_SUCCEEDED, + subscription = subscription, + plan = plan, + invoice = invoice + ) + ).mapLeft { + logger.error("Storing subscription renewal information for plan ${plan.product} for customer ${invoice.customer} " + + "and invoice ${invoice.id} failed with error message: ${it.message}") + } } + + private fun requiresPaymentMethod(subscription: Subscription, invoice: Invoice) { + val plan = subscription.plan + + storage.subscriptionToPlanRenewalFailed( + customerId = subscription.customer, + sku = plan.product, + subscriptionPaymentInfo = buildSubscriptionPaymentInfo( + status = PaymentStatus.REQUIRES_PAYMENT_METHOD, + subscription = subscription, + plan = plan, + invoice = invoice + ) + ).mapLeft { + logger.error("Notification of subscriber ${invoice.customer} of failed payment for subscription ${plan.product}" + + "with invoice ${invoice.id} failed with error message: ${it.message}") + } + } + + /* TODO: (kmm) Add support.*/ + private fun requiresUserAction(subscription: Subscription, invoice: Invoice) { + logger.error("Unexpected request for 'user action' on renewal of subscription ${subscription.id}") + } + + private fun buildSubscriptionPaymentInfo(status: PaymentStatus, + subscription: Subscription, + plan: Plan, + invoice: Invoice) = + SubscriptionPaymentInfo( + id = subscription.id, + sku = plan.product, + status = status, + invoiceId = invoice.id, + chargeId = invoice.charge, + created = ofEpochSecondToMilli(subscription.created), + currentPeriodStart = ofEpochSecondToMilli(subscription.currentPeriodStart), + currentPeriodEnd = ofEpochSecondToMilli(subscription.currentPeriodEnd), + trialEnd = ofEpochSecondToMilli(subscription.trialEnd ?: 0L) + ) + + /* Timestamps in Prime must be in milliseconds. */ + private fun ofEpochSecondToMilli(ts: Long): Long = Instant.ofEpochSecond(ts).toEpochMilli() } diff --git a/prime-modules/src/main/kotlin/org/ostelco/prime/apierror/ApiErrorCodes.kt b/prime-modules/src/main/kotlin/org/ostelco/prime/apierror/ApiErrorCodes.kt index 4739d6da8..ffea659ac 100644 --- a/prime-modules/src/main/kotlin/org/ostelco/prime/apierror/ApiErrorCodes.kt +++ b/prime-modules/src/main/kotlin/org/ostelco/prime/apierror/ApiErrorCodes.kt @@ -66,6 +66,7 @@ enum class ApiErrorCode { FAILED_TO_STORE_PLAN, FAILED_TO_REMOVE_PLAN, ALREADY_SUBSCRIBED_TO_PLAN, + FAILED_TO_RENEW_SUBSCRIPTION, // FAILED_TO_SUBSCRIBE_TO_PLAN, // FAILED_TO_RECORD_PLAN_INVOICE, diff --git a/prime-modules/src/main/kotlin/org/ostelco/prime/appnotifier/AppNotifier.kt b/prime-modules/src/main/kotlin/org/ostelco/prime/appnotifier/AppNotifier.kt index aa7cac82c..4642458db 100644 --- a/prime-modules/src/main/kotlin/org/ostelco/prime/appnotifier/AppNotifier.kt +++ b/prime-modules/src/main/kotlin/org/ostelco/prime/appnotifier/AppNotifier.kt @@ -4,6 +4,10 @@ package org.ostelco.prime.appnotifier enum class NotificationType { JUMIO_VERIFICATION_SUCCEEDED, JUMIO_VERIFICATION_FAILED, + PAYMENT_METHOD_REQUIRED, + USER_ACTION_REQUIRED, + SUBSCRIPTION_RENEWAL_UPCOMING, + SUBSCRIPTION_RENEWAL_STARTING, } interface AppNotifier { diff --git a/prime-modules/src/main/kotlin/org/ostelco/prime/paymentprocessor/PaymentProcessor.kt b/prime-modules/src/main/kotlin/org/ostelco/prime/paymentprocessor/PaymentProcessor.kt index 2756ee057..9dad46c82 100644 --- a/prime-modules/src/main/kotlin/org/ostelco/prime/paymentprocessor/PaymentProcessor.kt +++ b/prime-modules/src/main/kotlin/org/ostelco/prime/paymentprocessor/PaymentProcessor.kt @@ -11,7 +11,7 @@ import org.ostelco.prime.paymentprocessor.core.ProductInfo import org.ostelco.prime.paymentprocessor.core.ProfileInfo import org.ostelco.prime.paymentprocessor.core.SourceDetailsInfo import org.ostelco.prime.paymentprocessor.core.SourceInfo -import org.ostelco.prime.paymentprocessor.core.SubscriptionDetailsInfo +import org.ostelco.prime.paymentprocessor.core.SubscriptionPaymentInfo import org.ostelco.prime.paymentprocessor.core.SubscriptionInfo import org.ostelco.prime.paymentprocessor.core.TaxRateInfo import java.math.BigDecimal @@ -32,6 +32,18 @@ interface PaymentProcessor { */ fun addSource(customerId: String, sourceId: String): Either + /** + * Adds source and sets it as 'default source' if requested. If the source has + * already been added it is not added again. + * @param customerId Customer id + * @param sourceId Stripe source id + * @param setDefault If true then set the 'sourceId' as the default source + * @return The source-info object describing the source + */ + fun addSource(customerId: String, + sourceId: String, + setDefault: Boolean = false): Either + /** * @param customerId: Prime unique identifier for customer * @param email: Contact email address @@ -67,6 +79,14 @@ interface PaymentProcessor { */ fun removePlan(planId: String): Either + /** + * Removes the product identified with 'productId' and all associated + * price plans. + * @param productId The plan product to be removed + * @return Id of the removed plan product + */ + fun removeProductAndPricePlans(productId: String): Either + /** * @param Stripe Plan Id * @param customerId Stripe Customer Id @@ -74,7 +94,7 @@ interface PaymentProcessor { * @param taxRegion An identifier representing the taxes to be applied to a region * @return Stripe SubscriptionId if subscribed */ - fun createSubscription(planId: String, customerId: String, trialEnd: Long = 0L, taxRegionId: String? = null): Either + fun createSubscription(planId: String, customerId: String, trialEnd: Long = 0L, taxRegionId: String? = null): Either /** * @param Stripe Subscription Id @@ -84,10 +104,11 @@ interface PaymentProcessor { fun cancelSubscription(subscriptionId: String, invoiceNow: Boolean = false): Either /** + * @param productId Prime product Id * @param name Prime product name * @return Stripe productId if created */ - fun createProduct(name: String): Either + fun createProduct(productId: String, name: String): Either /** * @param productId Stripe product Id @@ -187,10 +208,13 @@ interface PaymentProcessor { fun createInvoice(customerId: String, amount: Int, currency: String, description: String, taxRegionId: String?, sourceId: String?): Either /** + * Pay an invoice now. If a 'sourceId' is given, then pay the invoice using + * that source. * @param invoiceId ID of the invoice to be paid + * @param sourceId Optionally the payment source to be used * @return ID of the invoice */ - fun payInvoice(invoiceId: String): Either + fun payInvoice(invoiceId: String, sourceId: String? = null): Either /** * @param invoiceId ID of the invoice to be paid @@ -214,4 +238,4 @@ interface PaymentProcessor { * @return payment transactions */ fun getPaymentTransactions(start: Long, end: Long): Either> -} \ No newline at end of file +} diff --git a/prime-modules/src/main/kotlin/org/ostelco/prime/paymentprocessor/core/Model.kt b/prime-modules/src/main/kotlin/org/ostelco/prime/paymentprocessor/core/Model.kt index 34977d802..6ddd3066c 100644 --- a/prime-modules/src/main/kotlin/org/ostelco/prime/paymentprocessor/core/Model.kt +++ b/prime-modules/src/main/kotlin/org/ostelco/prime/paymentprocessor/core/Model.kt @@ -18,18 +18,37 @@ data class ProfileInfo(val id: String) data class SourceInfo(val id: String) -data class SourceDetailsInfo(val id: String, val type: String, val details: Map) +data class SourceDetailsInfo(val id: String, + val type: String, + val details: Map) data class SubscriptionInfo(val id: String) -data class SubscriptionDetailsInfo(val id: String, val status: PaymentStatus, val invoiceId: String, val chargeId: String, val created: Long, val trialEnd: Long = 0L) - -data class TaxRateInfo(val id: String, val percentage: BigDecimal, val displayName: String, val inclusive: Boolean) +data class SubscriptionPaymentInfo(val id: String, /* Stripe subscription id. */ + val sku: String, + val status: PaymentStatus, + val invoiceId: String, /* Not set on plans with trial time. */ + val chargeId: String? = null, /* Only set on successful payment on creation/renewal. */ + val created: Long, + val currentPeriodStart: Long, + val currentPeriodEnd: Long, + val trialEnd: Long = 0L) + +data class TaxRateInfo(val id: String, + val percentage: BigDecimal, + val displayName: String, + val inclusive: Boolean) data class InvoiceItemInfo(val id: String) data class InvoiceInfo(val id: String) -data class InvoicePaymentInfo(val id: String, val chargeId: String) +data class InvoicePaymentInfo(val id: String, + val status: PaymentStatus, + val chargeId: String) -data class PaymentTransactionInfo(val id: String, val amount: Int, val currency: String, val created: Long, val refunded: Boolean) +data class PaymentTransactionInfo(val id: String, + val amount: Int, + val currency: String, + val created: Long, + val refunded: Boolean) diff --git a/prime-modules/src/main/kotlin/org/ostelco/prime/storage/StoreError.kt b/prime-modules/src/main/kotlin/org/ostelco/prime/storage/StoreError.kt index 3c8a6c926..e75973c63 100644 --- a/prime-modules/src/main/kotlin/org/ostelco/prime/storage/StoreError.kt +++ b/prime-modules/src/main/kotlin/org/ostelco/prime/storage/StoreError.kt @@ -7,7 +7,7 @@ sealed class StoreError(val type: String, var message: String, val error: InternalError?) : InternalError() -class NotFoundError(type: String, +class NotFoundError(type: String, id: String, error: InternalError? = null) : StoreError(type = type, diff --git a/prime-modules/src/main/kotlin/org/ostelco/prime/storage/Variants.kt b/prime-modules/src/main/kotlin/org/ostelco/prime/storage/Variants.kt index f54c1ef55..114fa4291 100644 --- a/prime-modules/src/main/kotlin/org/ostelco/prime/storage/Variants.kt +++ b/prime-modules/src/main/kotlin/org/ostelco/prime/storage/Variants.kt @@ -19,6 +19,7 @@ import org.ostelco.prime.model.Subscription import org.ostelco.prime.paymentprocessor.core.PaymentError import org.ostelco.prime.paymentprocessor.core.PaymentTransactionInfo import org.ostelco.prime.paymentprocessor.core.ProductInfo +import org.ostelco.prime.paymentprocessor.core.SubscriptionPaymentInfo import javax.ws.rs.core.MultivaluedMap interface ClientDocumentStore { @@ -194,6 +195,16 @@ interface ClientGraphStore { * Save address and Phone number */ fun saveAddress(identity: Identity, address: String, regionCode: String): Either + + /** + * Renew pending (due to payment issues) subscription to plan. + */ + fun renewSubscriptionToPlan(identity: Identity, sku: String): Either + + fun renewSubscriptionToPlan(identity: Identity, + sku: String, + sourceId: String, + saveCard: Boolean): Either } data class ConsumptionResult(val msisdnAnalyticsId: String, val granted: Long, val balance: Long) @@ -269,10 +280,9 @@ interface AdminGraphStore { * Set up a customer with a subscription to a specific plan. * @param identity - The identity of the customer * @param planId - The name/id of the plan - * @param trialEnd - Epoch timestamp for when the trial period ends * @return Unit value if the subscription was created successfully */ - fun subscribeToPlan(identity: Identity, planId: String, trialEnd: Long = 0): Either + fun subscribeToPlan(identity: Identity, planId: String): Either /** * Remove the subscription to a plan for a specific subscrber. @@ -284,16 +294,56 @@ interface AdminGraphStore { fun unsubscribeFromPlan(identity: Identity, planId: String, invoiceNow: Boolean = true): Either /** - * Adds a purchase record to customer on start of or renewal - * of a subscription. - * @param customerId - The customer that got charged - * @param invoiceId - The reference to the invoice that has been paid - * @param chargeId - The reference to the charge (used on refunds) - * @param sku - The product/plan bought - * @param amount - Cost of the product/plan - * @param currency - Currency used + * Notify customer about upcoming renewal of a subscription. + * @param customerId - The identity of the customer to be notified + * @param sku - Subscription product to be renewed + * @param dueDate - Time when the renewal will happen + */ + fun notifySubscriptionToPlanRenewalUpcoming(customerId: String, + sku: String, + dueDate: Long): Either + + /** + * Notify customer that a renewal of a subscription is starting and + * that a charge will be made. By default the actual charge for the + * renewal will happen in about 1 hour, but an immediate charge can + * be forced by setting the 'payInvoiceNow' flag to true. + * @param customerId - The identity of the customer to be notified. + * @param sku - Subscription product being renewed. + * @param invoiceId - ID of invoice generated for the renewal. + * @param payInvoiceNow - If true pay the invoice immediately. + */ + fun notifySubscriptionToPlanRenewalStarting(customerId: String, + sku: String, + invoiceId: String, + payInvoiceNow: Boolean = false): Either + + /** + * Subscription renewed without issues. + * @param customerId - The identity of the customer + * @param sku - Id of the product/subscription + * @param subscriptionPaymentInfo - Details about the renewed subscription + */ + fun renewedSubscriptionToPlanSuccessfully(customerId: String, + sku: String, + subscriptionPaymentInfo: SubscriptionPaymentInfo): Either + + /** + * Subscription renewal failed either due to issues with payment source or + * because additional actions is required (3D secure). + * @param customerId - The identity of the customer + * @param sku - Id of the product/subscription + * @param subscriptionPaymentInfo - Details about the renewed subscription + */ + fun subscriptionToPlanRenewalFailed(customerId: String, + sku: String, + subscriptionPaymentInfo: SubscriptionPaymentInfo): Either + /** + * Removes a product and associated price plans. Mainly intended for use + * in test for cleanup. + * @param productId - The id of the product to be to be removed. */ - fun purchasedSubscription(customerId: String, invoiceId: String, chargeId: String, sku: String, amount: Long, currency: String): Either + fun removeProductAndPricePlans(productId: String): Either // atomic import of Offer + Product + Segment fun atomicCreateOffer( @@ -359,4 +409,4 @@ interface AdminGraphStore { * @return differences found */ fun checkPaymentTransactions(start: Long, end: Long): Either>> -} \ No newline at end of file +} diff --git a/prime/infra/dev/prime-customer-api.yaml b/prime/infra/dev/prime-customer-api.yaml index ef32e364a..e332c68ba 100644 --- a/prime/infra/dev/prime-customer-api.yaml +++ b/prime/infra/dev/prime-customer-api.yaml @@ -675,6 +675,39 @@ paths: description: "Product not found." security: - firebase: [] + "/products/{sku}/renew": + post: + description: "Renew subscription" + produces: + - application/json + - text/plain + operationId: "renewSubscription" + parameters: + - name: sku + in: path + description: "SKU of the subscription product to be renewed" + required: true + type: string + - name: sourceId + in: query + description: "The stripe-id of the source to be used for this subscription renewal (if empty, use default source)" + required: false + type: string + - name: saveCard + in: query + description: "Whether to save this card as a source for this user (default = false)" + required: false + type: boolean + responses: + 201: + description: "Successfully renewed the subscription" + schema: + $ref: '#/definitions/Product' + 404: + description: "Subscription product not found." + security: + - auth0_jwt: [] + - firebase: [] "/purchases": get: description: "Get list of all purchases."