Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Adds handling of recurring payment #914

Open
wants to merge 55 commits into
base: develop
Choose a base branch
from

Conversation

kmmatwork
Copy link
Collaborator

@kmmatwork kmmatwork commented Nov 4, 2019

No description provided.

@kmmatwork kmmatwork force-pushed the feature/payment-recurring branch from b8d70dc to dceb366 Compare November 4, 2019 10:28
@@ -0,0 +1,18 @@
FROM azul/zulu-openjdk:13
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Today, I have bumped this version to 13.0.1 which is already merged to develop.

* skipped altogether.
*/
@Test
@EnabledIfEnvironmentVariable(named = "NGROK_AUTH_TOKEN", matches="\\S+")
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Do you want these tests to be run on CI? Should we add this ENV var to CI setup?

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yes, but we will first have to create an account for CI with ngrok.io first. And maybe consider a baslc or pro subscription? Being able to use "custom subdomain" would simplify the setup a bit...

@@ -297,26 +291,26 @@ enum class ProductProperties(val s: String) {

enum class ProductClass {
SIMPLE_DATA,
SUBSCRIPTION,
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Subscription is not a Product class. Instead, it is a payment type.
(Product Class = Membership + Payment type = Subscription) => Our Membership project which we have removed.
(Product Class = Simple Data + Payment type = Subscription) => Product which will topup x bytes evey y period.
(Product Class = Data with Validity (coming soon) + Payment type = Subscription) => We will have this.

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Maybe it could be an idea to rewrite the "payment paths" in the code to only consider the payment[type] field and not consider the productClass field at all? And maybe make the payment[type] field mandatory?

Copy link
Collaborator Author

@kmmatwork kmmatwork Nov 4, 2019

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Don't know yet how straight forward this will be though. Will have to check first...

price = Price(amount = 10_00, currency = "USD"),
properties = mapOf(
PRODUCT_CLASS.s to SUBSCRIPTION.name,
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Similar to another comment, product class should be MEMBERSHIP.
Payment type is SUBSCRIPTION.
If no payment type is set, then the default value is ONE TIME PAYMENT.

PRODUCT_CLASS.s to SUBSCRIPTION.name,
SEGMENT_IDS.s to "country-us"),
payment = mapOf(
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The payment type = subscription has to be set in payment properties.

Comment on lines 38 to 50
.properties(
mapOf(
"productClass" to "SUBSCRIPTION",
"segmentIds" to "country-us"
)
)
.payment(
mapOf(
"type" to "SUBSCRIPTION",
"label" to "Daily subscription plan",
"taxRegionId" to "us"
)
)
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Here, the payment type is set correctly to "SUBSCRIPTION", but the product class should be MEMBERSHIP.

@POST
@Path("{sku}/renew")
@Produces(MediaType.APPLICATION_JSON)
fun renewSubscription(@Auth token: AccessTokenPrincipal?,
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Just the word Subscription means mobile Subscription (with MSISDN as ID). Better to call this PaymentSubscription or PlanSubscription to distinguish it from "Mobile" Subscription.

NotificationType.SUBSCRIPTION_RENEWAL_STARTING -> {
/* No notification, as a notification will be sent on either successful
or failed renewal. */
}
}
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This API is a bit messed up. enum NotificationType can have fields such as title and body which is then directly used instead of using when cases. Also, if FCMStrings is specific to Firebase, they should not be in generic API interfaces - prime-modules.

The simplest way to design it is to think of AppNotifier as an interface whereas FirebaseAppNotifier as one of the many possible implementations of AppNotifier.
We should be able to replace whole FCM with some other implementation with just adding and removing dependency in the gradle build file and no need of any code change.

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Comment on lines 534 to 538
val params = mapOf(
*(if (sourceId != null)
arrayOf("source" to sourceId)
else arrayOf())
)
Copy link
Member

@vihangpatil vihangpatil Nov 4, 2019

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Outer if-else with mapOf inside is more readable.
Also, ?. is more compact way to check nulls.

##

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
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is so much repeated code that it now arguably would make sense to factor this out into a function. Or not. Your call.

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Will try to move the curl call into a function.

subscription: String,
customerId: String,
timeout: Long = 10000L): Boolean =
waitForStripeEvent(
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This code has very convoluted control flow. Consider breaking it up into smaller pieces with descriptive names.

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Will try to move the cmp function out out.

delay(200L)
}
"Done"
} != null
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This makes it very obscure what we're checking for. Please consider making a local function with a descriptive name. Code should preferably be trivial to read, and this isn't.

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Basically cut and paste from https://kotlinlang.org/docs/reference/coroutines/cancellation-and-timeouts.html at the bottom of the page.

),
"email" to "[email protected]")
)
val source = Source.create(sourceMap)
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I would have preferred calling this paymentSource, just to make it blindingly obvious to casual readers (such as myself, in the role of reviewer) what this value is.

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Good point!

queryParams = mapOf("sourceId" to sourceId)
}

Thread.sleep(200) // wait for 200 ms for balance to be updated in db
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is brittle. Is there a less brittle way to wait for the database?

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Not sure. The same wait statement is present in all acceptance tests that involves updates to neo4j. Will add a TODO and a Notion tech dept task.

// expires, which will happen after 4 sec. Waiting a bit longer
// before checking the outcome.
StripeEventListener.waitForSubscriptionPaymentToSucceed(
subscription = "stripe-event-jersey-purchase-ok-sub",
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

What does this magic string mean? Consider putting it into a variable with a descriptive name.

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Name of a subscription to the "stripe-event" pub-sub topic. We need one such subscription for each "recurring payment" acceptance test. The waitForSubscriptionPaymentToSucceed method is there to ensure that we don't wait too long for the expected events.

Not sure if it make sense use a variable for the subscription name. But some more comments about what is going on could be an idea.

customerId = createCustomer(name = "Test Purchase Plan User", email = email).id
enableRegion(email = email, region = "us")

val client = clientForSubject(subject = email)
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

There is a lot of repeated code between this and the previous test. Consider factoring that code into separate methods or perhaps even running the code in a a "setUp" method as part off fixture setup for the test suite.

Repeated code obscures the intent and implementation of the actual test.

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Will try to restructure a bit.

// 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(
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Repeated code

// Explicitly renew the subscription with a new card.
client.renewSubscription(sku, newSourceId, true)

StripeEventListener.waitForSubscriptionPaymentToSucceed(
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Repeated code

sourceId: String?,
@QueryParam("saveCard")
saveCard: Boolean?): Response =
if (token == null) {
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Can authorisation check be delegated to a filter so that it does not show up in the business logic part of the resource implementation? Technically it obviously can, but the question is obviously if it makes sense in this particular situation. If the "token == null" must be there, add a comment describing why this couldn't be delegated to a filter, of if it can, at the very least add a "TODO" indicating that it should.

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Agree with this. I think the same pattern is used in all client facing APIs, and an update should probably fix it for all of them. Will leave a TODO and a Notion tech dept task for fixing this.

if (sourceId == null) {
dao.renewPaymentSubscription(
identity = token.identity,
sku = sku)
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

In this case the saveCard parameter is discarded? If it's non-null that should indicate an error, or it should be used.

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Will fix.

dao.renewPaymentSubscription(
identity = token.identity,
sku = sku,
sourceId = sourceId,
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

What is the default value of sourceId? If it's null, then perhaps only the "else" branch is necessary and the code can be made more compact without losing generality?

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yes, this API needs some rethinking.

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,
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Add a comment indicating what the times should be interpreted as? Is it seconds since epoch? If so, say so.

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Internally timestamps are always in milliseconds since epoch.

description: "Successfully renewed the subscription"
schema:
$ref: '#/definitions/Product'
404:
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Wasn't there a 402 in there somewhere also?

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)
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Please wrap if in parens. I get confused by this kind of implicitly bounded values. Be explicit.

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Agree with that.


try {
runBlocking {
status = withTimeoutOrNull(timeout) {
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

If this is just a busy-wait loop, then say so in a comment. If not then explain what's going on.

}
})

fun waitForFailedSubscriptionRenewal(topic: String = StripeEventListener.topic,
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The control flow of this method is very obscure. Please make it simpler to read by breaking it up into smaller methods with descriptive names rather than one big open-coded method with a very complex internal life.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
None yet
Development

Successfully merging this pull request may close these issues.

3 participants