API-Version
Make sure to use the latest API-version (as in this blog post), which can be configured in stripe dashboard from here:
Otherwise the event json structure will be different, and you may run into trouble like java.util.NoSuchElementException: No value present
when your code execute:
fullEvent.dataObjectDeserializer.`object`.get()
Recall that .get()
method usually means it is of type Optional
in java
.
Testing Webhook
Install Stripe CLI
To install Stripe CLI we can follow the official instruction. For mac it is as simple as running:
brew install stripe/stripe-cli/stripe
Connect to Your Stripe Account
After installing CLI, we execute:
stripe login --api-key sk_test_51PffJDR... stripe listen --forward-to http://localhost:8080/stripe-test/webhook
Cont'd From Previous Stripe Basic Post
From to
We have introduced a basic stripe event: the checkout.session.completed
event, and how to get the latest customized ID
from the event from this post.
As time goes by, we find that the checkout.session.completed
event only applies to new subscription, it does not work with subscription changes (like upgrade, downgrade, cancel, etc).
Therefore for unifying everything we shift our focus to customer.subscription.updated
event.
Handle Delayed Billing Events
Doubled Events Emission upon Downgrading and Deleting Subscription
-
When downgrading and canelling subscription, we would like the event only to take place at the end of billing period.
-
Unfortunately, a
customer.subscription.updated
event will be emitted immediately once after we make changes (an almost identical event will be fired again at the end of billing period), we need to study how to distinguish them and ignore the immediately fired one.
How to Ignore Immediately Triggered Subscription Update Event
-
For every
subscription.updated
event there is a fieldpreviousAttributes
that indicates what is being changed in the subscription, in kotlin we invokeval fullEvent = Event.retrieve(event.id) fullEvent.data.previousAttributes
to get a
Map
of previous values object. -
When
Cancel
/Downgrade
occurs, thelatest_invoice
will get updated (and it will not be there at the end of billing period). Therefore we can define a booleanval isBillingUpdate = (fullEvent.data.previousAttributes != null) && fullEvent.data.previousAttributes.containsKey("latest_invoice") && fullEvent.data.previousAttributes["latest_invoice"] != null
to distinguish two
subscription.update
events.
Subscribe, Upgrade, Downgrade, Cancel
Strategy
Quantity Adjustment for Subscriptions
The actual implementation of add
, upgrade
, downgrade
and cancel
operations are all controlled by adjusting quantities of the products with appropriate setters
setting proration behaviour and billing cylces:
-
Subscribe
the quantity from 0 to 1 -
Upgrade
,Downgrade
the quantity of the old product by 1, and that of the new product. -
Cancel
simply the quantity of target product by 1.
Proration Period and Billing Cycle
We model a subscription plan as a stripe product in Stripe world, which must live within a Stripe Subscription.
This product is configured to have recurring price and therefore become a subscription (in common sense), thus
namely, a stripe subsciprtion can contain a list of ordinary subscriptions.
For Subscribe
and Upgrade
, the billing action should be immediate, and that of Downgrade
and Cancel
should be delayed until the end of billing period.
In stripe the "immediate" and "delayed" billing actions are controlled by
- Proration Behaviour and
- Billing Anchor
Immediate Action: ProrationBehavior.ALWAYS_INVOICE, Delayed Action: (ProrationBehavior.NONE, BillingCycleAnchor.UNCHANGED)
For example, suppose a user have subscribed an upgraded plan for month and downgraded for month right before the next billing period, then
will be charged at start of the next period.
Check Existing Active Subscription
-
This step is crucial, we always check whether a customer has active subscription in order to determine if we need to create a checkout page for customer.
-
If a subscription already exists, we instead provide a confirmation dialog in the frontend since the customer don't need to provide the payment information again.
fun getActiveSubscriptionOfCustomer(stripCustomerId: String): Subscription? { val param = SubscriptionListParams.builder() .setCustomer(stripCustomerId) .setStatus(SubscriptionListParams.Status.ACTIVE) .addExpand("data.items") val subscriptions = Subscription.list(param.build()) return subscriptions.data.firstOrNull() }
New Subscription and Metadata
Handle Checkout Session and Add Metadata to Subscriptions
1@Service 2class StripeService( 3 ...injected dependencies 4) { 5 fun createCheckoutSession( 6 orderId: String, 7 stripCustomerId: String, 8 priceId: String, 9 productName: String, 10 numOfPersons: Int? 11 ): String {
At this point we need priceId
instead of productId
since each product has a list of price
's, we should not directly work with product
(to make room for discounted price later).
12 // https://docs.stripe.com/api/checkout/sessions/create 13 val sessionParam = SessionCreateParams.builder() 14 .setSuccessUrl("$purchasePageURL/success?productName=$productName") 15 .setCancelUrl("$purchasePageURL/failed?orderId=$orderId")
Here we handle the display of failed and success cases in our frontend.
16 .addLineItem( 17 SessionCreateParams.LineItem.builder() 18 .setPrice(priceId) 19 .setQuantity((numOfPersons ?: 1).toLong()) 20 .build() 21 ) 22 .setMode(SessionCreateParams.Mode.SUBSCRIPTION)
Now we set our product to bill users periodically.
Finally let's attach an orderId
so that later we can trace back the latest transaction that leads to the quantity change:
23 .setCustomer(stripCustomerId) 24 .setSavedPaymentMethodOptions( 25 SessionCreateParams.SavedPaymentMethodOptions.builder() 26 .setPaymentMethodSave(SessionCreateParams.SavedPaymentMethodOptions.PaymentMethodSave.ENABLED) 27 .build() 28 ) 29 .setSubscriptionData( 30 SubscriptionData.builder() 31 .putMetadata("orderId", orderId) 32 .putMetadata("createdAt", System.currentTimeMillis().toString()) 33 .build() 34 ) 35 .build() 36 val session = Session.create(sessionParam) 37 val sessionId = session.id 38 logger.info { "Session Created" } 39 return sessionId 40 } 41}
Metadata Analysis of Event
Note that we have put metadata
in the previous code block, the orderId
represents the identifier of row in our Order
table that contains a complete list of order information.
1"data": { 2 "object": { 3 "id": "si_QqZbSMBKfEkewL", 4 "object": "subscription", 5 "metadata": { 6 "createdAt": "1726306800814", 7 "orderId": "0191efe7-5072-950e-2e48-0ea96226db41" 8 }, 9 "items": { 10 "object": "list", 11 "data": [ 12 { 13 "object": "subscription_item", 14 "metadata": {}, 15 "price": { 16 "id": "price_1PyQMKErCGxv56Y5GQPupsch", 17 ... 18 }, 19 "quantity": 1, 20 "subscription": "sub_1PysSfErCGxv56Y5PoaUzxMR", 21 ... 22 },
When creating checkout session, It is unfortunate that the builder
SessionCreateParams.LineItem.builder()
does not provide a setMetadata
setter (i.e., we can't put metadata
to lineItem
at this stage), therefore the orderId
lying in the subscription
object (when we create checkout session) is exactly also the orderId
of the first object without metadata
in items
.
Next suppose we purchase another subscribed item, then this time we have an existing subscription to which we add a "product" (with recurring price), resulting in:
23 { 24 "object": "subscription_item", 25 "metadata": { 26 "createdAt": "1726306839585", 27 "orderId": "0191efe7-e703-85ff-5952-219554642eef" 28 }, 29 "price": { 30 "id": "price_1PyQOrErCGxv56Y5EHhAQcMA", 31 ... 32 }, 33 "quantity": 7, 34 "subscription": "sub_1PysSfErCGxv56Y5PoaUzxMR", 35 ... 36 } 37 ], 38 ... 39 } 40 } 41}
Here comes the importance of createdAt
, customer.subscription.updated
contains a list of items with the same interface, the orderId
with the latest createdAt
will be the latest order identifier we want.
Update Existing Subscription
Subscription Item
- Special Enum for Subscription Item Inited Event. Since adding a subscription item (with 0 quantity) is also a subscription update event. We introduce the following enum:
enum class MetadataOperation(val code: String) { INIT_SUBSCRIPTION_ITEM("INIT_SUBSCRITION_ITEM") }
-
Helper Function:
getSubItemFromSubscriptionAndPriceId
.-
It is helpful to find the
SubscriptionItem
of the targetpriceId
within anSubscription
since:-
We need to deal with the number of subscribed items
-
Operation within the same subscription scope can ensure all products are billed within the same period and enable stripe to calculate the prorated cost for us.
-
-
For example, a subscription plan can have number because we model some of our plans as a sharable asset assignable to "team member" inside our system.
-
We then assign this enum into our
metadata
private fun getSubItemFromSubscriptionAndPriceId(subscription: Subscription, priceId: String): SubscriptionItem { val subItem = subscription.items.data.find { it -> it.price.id == priceId } if (subItem != null) { return subItem } else { val itemParams = SubscriptionUpdateParams.Item.builder() .setPrice(priceId) .putMetadata("createdAt", System.currentTimeMillis().toString()) .putMetadata("operation", SubscriptionChangeEvent.MetadataOperation.INIT_SUBSCRIPTION_ITEM.code) .setQuantity(0L) .build() val updateParams = SubscriptionUpdateParams.builder() .addItem(itemParams) .build() val updatedSubscription = subscription.update(updateParams) return updatedSubscription.items.data.find { it -> it.price.id == priceId }!! } }
so that later we can ignore this
init-item-event
by using the boolean:class SubscriptionChangeEvent { enum class MetadataOperation(val code: String) { INIT_SUBSCRIPTION_ITEM("INIT_SUBSCRITION_ITEM") } data class Metadata(val orderId: String, val createdAt: String?, val operation: String?) data class Data(val subscription: String, val metadata: Metadata) data class Items(val data: List<Data>) data class DataObject(val items: Items, val metadata: Metadata) } val fullEvent = Event.retrieve(event.id) val subscriptionUpdatedEventDataObject = Gson().fromJson( fullEvent.dataObjectDeserializer.`object`.get().toJson(), SubscriptionChangeEvent.DataObject::class.java ) val lastUpdatedItem = subscriptionUpdatedEventDataObject.items.data.sortedByDescending { it.metadata.createdAt ?: "0" }.firstOrNull() val isInitItem = lastUpdatedItem?.metadata?.operation == SubscriptionChangeEvent.MetadataOperation.INIT_SUBSCRIPTION_ITEM.code
-
Subscribe Additional Product
We update the active subscription by simply setting a new item into it.
Since metadata
is like a persistent record, it will confuse our system if we don't manually remove it (by .putMetadata("operation", "")
). We need to ensure the erasion of operation
field for any subsequent update:
1fun addSubscriptionItemsByPriceId( 2 fromPriceId: String, 3 quantityIncrement: Long, 4 activeSubscription: Subscription, 5 orderId: String, 6) { 7 val targetSubscriptionItem = getSubItemFromSubscriptionAndPriceId(activeSubscription, fromPriceId) 8 val newQuantity = targetSubscriptionItem.quantity + quantityIncrement 9 val subItemUpdate = SubscriptionUpdateParams.Item.builder() 10 .setId(targetSubscriptionItem.id) 11 .setQuantity(newQuantity) 12 .putMetadata("orderId", orderId) 13 .putMetadata("createdAt", System.currentTimeMillis().toString()) 14 .putMetadata("operation", "") 15 .build() 16 17 val updateParams = SubscriptionUpdateParams.builder() 18 .addItem(subItemUpdate) 19 .setProrationBehavior(ProrationBehavior.ALWAYS_INVOICE) 20 .build() 21 22 activeSubscription.update(updateParams) 23}
Note that the payment should be immediate, ProrationBehavior.ALWAYS_INVOICE
makes sure the charging from the existing payment method is directly triggered on any update.
Upgrade and Immediate Downgrade
Both upgrade and downgrade represent a switch between items in a zero-sum
fashion:
1fun switchSubscriptionItemsByPriceId( 2 fromPriceId: String, 3 targetPriceId: String, 4 activeSubscription: Subscription, 5 orderId: String, 6 isImmediate: Boolean = true, 7) { 8 val fromSubscriptionItem = getSubItemFromSubscriptionAndPriceId(activeSubscription, fromPriceId) 9 val targetSubscriptionItem = getSubItemFromSubscriptionAndPriceId(activeSubscription, targetPriceId) 10 val updatedSub = Subscription.retrieve(activeSubscription.id) 11 val currTimestamp = System.currentTimeMillis() 12 13 val fromSubItemParams = SubscriptionUpdateParams.Item.builder() 14 .setId(fromSubscriptionItem.id!!) 15 .setQuantity(fromSubscriptionItem.quantity - 1) 16 .putMetadata("orderId", orderId) 17 .putMetadata("createdAt", currTimestamp.toString()) 18 .putMetadata("operation", "") 19 .build() 20 21 val toSubItemParams = SubscriptionUpdateParams.Item.builder() 22 .setId(targetSubscriptionItem.id!!) 23 .setQuantity(targetSubscriptionItem.quantity + 1) 24 .putMetadata("orderId", orderId) 25 .putMetadata("createdAt", currTimestamp.toString()) 26 .putMetadata("operation", "") 27 .build() 28 29 val updateSubParamsPrebuild = SubscriptionUpdateParams.builder() 30 .addAllItem(listOf(fromSubItemParams, toSubItemParams))
For upgrade isImmediate
should be set to true
:
31 if (isImmediate) { 32 updateSubParamsPrebuild 33 .setProrationBehavior(ProrationBehavior.ALWAYS_INVOICE) 34 }
so that the billing is immediate with invoice being dispatched immediately.
On the other hand, if our action is an immediate downgrade, we set isImmediate
to false
to get non-immediate billing action:
35 else { 36 updateSubParamsPrebuild 37 .setProrationBehavior(ProrationBehavior.NONE) 38 .setBillingCycleAnchor(SubscriptionUpdateParams.BillingCycleAnchor.UNCHANGED) 39 } 40 updatedSub.update(updateSubParamsPrebuild.build()) 41}
Subscription Schedules for Scheduled Downgrade/Unsubscription
-
Another case for downgrade is to delay the downgrade request until the start of the next billing period.
-
This makes perfect sense in case of unsubscription (as a kind of downgrade) which should only takes effect at the end of billing period because customer has already paid for the service.
Now we demonstrate an example of scheduling the changes of a product quantity:
Objective. We create a function
scheduleAmountChangeByPriceId
which schedules a change of the amount of a product that takes effect at the start of next billing period.
Let's define an helper function and our schedule function:
1private fun getExistingSchedule(customerId: String, activeSubscription: Subscription): SubscriptionSchedule? { 2 val listParams = SubscriptionScheduleListParams.builder() 3 .setCustomer(customerId) 4 .build() 5 val existingSchedules = SubscriptionSchedule.list(listParams) 6 val existingSchedule = existingSchedules.data 7 .filter { 8 it.subscription == activeSubscription.id 9 }.sortedByDescending { 10 it.created 11 }.firstOrNull() 12 return existingSchedule 13} 14
15fun scheduleAmountChangeByPriceId( 16 increment: Int, 17 priceId: String, 18 customerId: String, 19 activeSubscription: Subscription, 20): SubscriptionSchedule { 21 val currentPeriodEnd = activeSubscription.currentPeriodEnd 22 val existingSchedule = getExistingSchedule(customerId, activeSubscription) 23 val targetItem = getSubItemFromSubAndPriceId(activeSubscription, priceId) 24 25 val schedule = if (existingSchedule != null) { 26 existingSchedule 27 } else { 28 val params = SubscriptionScheduleCreateParams.builder() 29 .setFromSubscription(activeSubscription.id) 30 .build() 31 SubscriptionSchedule.create(params) 32 }
Let's pause and explain the strategy here. What I learned from a conversation:
Therefore let's create a new list of phases and update it:
33 val updateParamsBuilder = SubscriptionScheduleUpdateParams.builder() 34 .setEndBehavior(SubscriptionScheduleUpdateParams.EndBehavior.RELEASE) 35 36 schedule.phases.forEach { phase -> 37 val phaseBuilder = SubscriptionScheduleUpdateParams.Phase.builder() 38 phase.startDate?.let { phaseBuilder.setStartDate(it) } 39 phase.endDate?.let { phaseBuilder.setEndDate(it) } 40 phase.items.forEach { item -> 41 phaseBuilder.addItem( 42 SubscriptionScheduleUpdateParams.Phase.Item.builder() 43 .setPrice(item.price) 44 .apply { 45 if (phase.endDate > currentPeriodEnd && item.price == priceId) { 46 setQuantity(item.quantity + increment) 47 } else { 48 setQuantity(item.quantity) 49 } 50 } 51 .build() 52 ) 53 } 54 updateParamsBuilder.addPhase(phaseBuilder.build()) 55 }
Finally let's handle an edge case here:
-
A new subscription has no schedule, and;
-
When
existingSchedule == null
, we created a new schedule in line 25. -
By default any new schedule will have a phase describing the current items in the latest subscription period.
-
Since our latest schedule have at most
phase.endDate == currentPeriodEnd
, line 46 cannot be reached in this case. -
Subsequently we manually add a phase in the next billing period so that we actually have an schedule for which
phase.endDate > currentPeriodEnd
:56 if (existingSchedule == null) { 57 val phaseBuilder = SubscriptionScheduleUpdateParams.Phase.builder() 58 .setStartDate(currentPeriodEnd) 59 .addItem( 60 SubscriptionScheduleUpdateParams.Phase.Item.builder() 61 .setPrice(priceId) 62 .setQuantity(targetItem.quantity + increment) 63 .build() 64 ) 65 updateParamsBuilder.addPhase(phaseBuilder.build()) 66 } 67 68 return schedule.update(updateParamsBuilder.build()) 69}
Undo a Schedule
We simply call
fun scheduleAmountChangeByPriceId( increment: Int, priceId: String, customerId: String, activeSubscription: Subscription, ): SubscriptionSchedule
with the compensating increment
in opposite sign.
Alternatively, one can save the scheduleId
in the database so that when we click undo
in the frontend, the backend execute the following:
SubscriptionSchedule.retrieve(scheduleId).cancel()
For me doing the opposite via scheduleAmountChangeByPriceId
did the job.
Renewal of Existing Subscribed Items
At the end of billing period, yet another subscription updated event is emitted which simply changes (as can be found in previous values object):
current_period_start
current_period_end
latest_invoice
We can trigger the renewal logic in our database by using the boolean:
val prevValues = fullEvent.data.previousAttributes val isBillingPeriodUpdate = (prevValues != null) && prevValues.containsKey("latest_invoice") && prevValues.containsKey("current_period_start") && prevValues.containsKey("current_period_end")
Of course we can also handle the cancel/downgrade logic at the same time. In my case:
fun handleEndOfBillingPeriod( prevStartDate: Double, prevEndDate: Double, newStartDate: Double, newEndDate: Double, planOwnerUserEmail: String, subscriptionId: String, ) { val subscription = Subscription.retrieve(subscriptionId) subscription.items.data.forEach { val priceId = it.price.id val product = stripeproductDao.fetchByStripepriceid(priceId).firstOrNull() ?: throw Exception("stripe product cannot be found") val seatDomains = seatRepository.fetchActiveSeatsByUserEmail(planOwnerUserEmail, product.type!!) seatDomains.forEach { seatDomain -> val iscancelScheduled = seatDomain.seat?.cancelscheduled ?: false val isdowngradeScheduled = seatDomain.getPersonalSeataData()?.downgradescheduled ?: false when { iscancelScheduled -> { seatDomain.inactivate() seatDomain.inactivateCounter() } isdowngradeScheduled -> { val seat = seatDomain.seat if (seat?.type === QuotaSeattype.PERSONAL_POWERFUL_BILLIE) { seatDomain.inactivate() seatDomain.inactivateCounter() } } else -> { seatDomain.getLatestActiveCounter()?.let { val isOldCounterToRenew = it.startdate < prevEndDate if (isOldCounterToRenew) { seatDomain.inactivateCounter() seatDomain.addNewUsageCounter( newStartDate, newEndDate ) } } } } seatDomain.save() } } }
Test Clock
Why?
In stripe testclocks are mutually isolated worlds, we need to associate each of stripe test users with a testclock in order to view the changes for like 1 month later.
This is a must-have feature for testing delayed subscription actions like unsubscription, downgrading subscription and also testing the billing behaviour from Stripe.
Test Clock Subscription
In Stripe when a customer is associated with a testclock, then all of his/her subscription will be isolated within each testclock as follows:
-
List of testclocks:
-
Subscription within testclock:
Test Clock Manipulation
Create a Test Clock and a Test Customer
1fun createTestClockCustomer(emailAddress: String): CreateTestClockCustomerReturn { 2 val testClock = createTestClock() 3 val customerParams = CustomerCreateParams.builder() 4 .setEmail(emailAddress) 5 .setTestClock(testClock.id) 6 .build() 7 val customer = Customer.create(customerParams)
Up to this point we are done with creating a user with testclock.
Assign Payment Method
Next the following is optional which is only useful for writing test cases.
Remark. Since we will have no UI finishing the checkout session, that means we need to finish the session by code, but that amounts to the need to create subscription via program in code-based test-cases (and we need payment method for getting invoice).
8 val paymentMethodParams = PaymentMethodCreateParams.builder() 9 .setType(PaymentMethodCreateParams.Type.CARD) 10 .putExtraParam("card[token]", "tok_mastercard") 11 .build() 12 val paymentMethod = PaymentMethod.create(paymentMethodParams) 13 14 val attachParams = PaymentMethodAttachParams.builder().setCustomer(customer.id).build() 15 paymentMethod.attach(attachParams) 16 customer.update(CustomerCreateParams.builder() 17 .setInvoiceSettings(CustomerCreateParams.InvoiceSettings 18 .builder() 19 .setDefaultPaymentMethod(paymentMethod.id) 20 .build() 21 ).build().toMap() 22 ) 23 return CreateTestClockCustomerReturn(stripeCusotomerId = customer.id, 24 testClockId = testClock.id)
Advance The Test Clock
-
If we are implementing a frontend to let internal users advance the time, we could write:
data class AdvanceTestclockRequestDto(val testclockId: String) suspend fun advanceTestclockByMonth(advanceTestclockRequestDto: AdvanceTestclockRequestDto) = coroutineScope { val (testclockId) = advanceTestclockRequestDto val testClock = TestClock.retrieve(testclockId) val testclockTimeMillis = testClock.frozenTime * 1000 testClock?.let { withContext(Dispatchers.IO) { var now = DateTime(testclockTimeMillis) now = now.plusDays(31) now = now.plusHours(1) it.advance(TestClockAdvanceParams.builder().setFrozenTime((now.millis / 1000)).build()) delay(2500) now = now.plusHours(1) it.advance(TestClockAdvanceParams.builder().setFrozenTime((now.millis / 1000)).build()) } } }
-
If a test user has access to the stripe account, they can directly advance it here: