0%
September 15, 2024

Stripe Technical Detail for Add, Upgrade, Downgrade, and Cancel Subscrpition

kotlin

stripe

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 field previousAttributes that indicates what is being changed in the subscription, in kotlin we invoke

    val fullEvent = Event.retrieve(event.id)
    fullEvent.data.previousAttributes

    to get a Map of previous values object.

  • When Cancel/Downgrade occurs, the latest_invoice will get updated (and it will not be there at the end of billing period). Therefore we can define a boolean

    val 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 target priceId within an Subscription 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: