blog post thumbnail
SHARE
Reading Time: 5 minutes
  • This post is intended for implementing a recurring billing system for Shopify; if you're looking for an OOB alternative, check Mantle (it's dope). I did this code before Mantle was as good as it is right now, but it's still generically useful (if there's no mantle in your niche)
  • It's better if you have already implemented a billing system and, more importantly, if you understand XState and the Actor Model (I want to have the same PTSD as my readers 👉👈)
  • Check this article on a large screen because you're going to eat a lot of stately chart.s

Understanding the problematic

Implementing a billing system sucks; you've to manage a lot of edge cases like this smart guy says:

What happens if you want to handle:

image 1
(it's not; it's delicious pls trust me)
  • Discount?
  • Upgrade/Downgrade?
  • What happens during app uninstall?
  • Jobs, can you check if the subscription is still active?
  • Remember how it works when you return to this random codebase 1 month later?
  • Making things evolve easily
  • Centralizing your logic
  • Removing all boring side effects?
  • Sort of composition?
  • Using the same mental model front/back?
  • Even managing the Front? (bonus 🥸🥸🥸)

Answer: Finite State Machines + Actor Model

Definitions

Entities

Here are the entities we'll use for managing our billing system

Recurring Application Charge (RAC)

It represents a recurring charge (gg wp), which is linked to an appPlan and maybe an appDiscount

It could be active or not; only one RAC can be active, BUT a shop can have many RACs (pending, canceled etc.)

TypeScript
export const RecurringApplicationCharge = z
  .object({
    id: z.string(),
    name: z.string(), // refer to AppPlan.name
    price: z.number().int().min(0),
    appDiscountId: z.string().uuid().nullable(),
    createdReason: CreateDeleteRacReasonEnum,
    billingOn: zDate.nullable(),
    status: RacStatusEnum,
    activatedOn: zDate.nullable(),
    returnUrl: z.string(),
    test: z.boolean(),
    cancelledOn: zDate.nullable(),
    cancelledReason: CreateDeleteRacReasonEnum.nullable(),
    isCancellationProrated: z.boolean().nullable(),
    trialDays: z.number().int().min(0),
    trialEndsOn: zDate.nullable(),
    shopifyShopId: z.string(),
    apiClientId: z.string(),
    currency: z.enum(["USD", "AUD", "EUR", "INR", "SGD", "GBP", "CAD"]),
  })
  .merge(BaseShopifyEntitiesKeys)

appPlan

Representing the plans with intervals available in the app

TypeScript
export const AppPlan = z.object({
  id: z.string().uuid(),
  name: z.string(),
  slug: z.string(),
  trialDays: z.number().int(),
  interval: z.enum(["EVERY_30_DAYS", "ANNUAL", "LIFETIME"]),
  price: z.number().int(),
  i18nFeatures: z.record(z.string(), z.array(z.string())),
  description: z.record(z.string(), z.string()),
  currencyCode: z.enum(["USD", "AUD", "EUR", "INR", "SGD", "GBP", "CAD"]),
  updatedAt: zDate,
  createdAt: zDate,
  activeSizeChartsLimit: z.number().int().min(-1),
  includedShopifyPlans: z.array(FullPlanTypes),
  testToProdGracePeriodDays: z.number().int().min(0),
  features: z.record(
    z.string(),
    z.object({
      enabled: z.boolean(),
    }),
  ),
  type: z.enum(["common", "fallback", "custom"]),
  watermarkStatus: WatermarkStatus,
})

appDiscount

Represent a discount, can be allowed to N plans

TypeScript
export const AppDiscount = z.object({
  id: z.string(),
  allowedPlanSlugs: z.array(z.string()),
  description: z.string(),
  discountCode: z.string(),
  discountUsageLimit: z.number().min(-1),
  discountUsed: z.number().min(0),
  discountValue: z.number().min(0),
  discountType: z.enum(["percentage", "fixed_amount"]),
  durationLimitInterval: z.number().min(-1),
  createdAt: zDate,
  updatedAt: zDate,
})

Relations

image
Implementing an effective billing system using Finite State Machines 5

Processing the Subscription

Initializing the subscription creation

The idea is to reach the shopify paywall before approving the billing in a safe condition:

We can first simplify the machine:

You can click on simulate and on the current state to test the workflow. Also, check the state's description because it can explain what it does.

So we have, again you can check the description on stately :

  1. Validating, which checks if the shop can initialize the charge
  2. creatingSubscription which does a call to Shopify to initialize the payment
  3. If it's done => success; otherwise error

It's pseudo-machine that describes the key steps of initializing the subscription, with the key steps written down.

Let's add a bit more complexity here and create the initCreateSubscriptionMachine (take some time to check it; there's a description for each state)

I'm not commenting since it should be descriptive (I mean, that's the goal 🥸)

Of course, it takes time to get used to Stately‘s representation, there's arrows everywhere + it looks bloated, but even non-techies can UNDERSTAND this BEAUTY with time.

So far, so good. We have the first step, and we generated this appSubscription and we get the callbackUrl to validate the payment

So now the user is redirected here. We have the RAC persisted etc. etc cool, nice THANKS MOM

CleanShot 2024 11 08 at 20.38.01@2x
Implementing an effective billing system using Finite State Machines 6

Let's go for the second part when the user clicks on approve (remember this for later)

Managing the billing confirmation

We get back the webhook app_subscriptions/update who'll trigger the confirmSubscriptionMutation:

It's pretty sequential, yes. We retrieve the RAC from Shopify, persist, and sync. There's just one thing here, confirmSubscriptionMutation invoke a syncShopAppPlanMachine in syncShopAppPlanToRemote state that does a synchronization to Shopify (updating a billing metafield to remove the watermark)

🚨 So it's a machine invoking a machine 👉 an actor invoking an actor AND that's the power of XState

In this case, It induces a sort of composability because we only invoke it (it's like fire/forget), but it's more than that

Updating back to Shopify

We retrieve data and update a shopify metafield accordingly, using a machine again syncShopAppPlanMachine

Liquid
{% liquid
    assign billingConfigRaw = app.metafields.rsc-app.billing_config.value
    assign billingConfig = billingConfigRaw | split: ","
    assign appPlanSlug = billingConfig | where: "appPlanSlug" | first | split: ":" | last
    assign watermarkStatus = billingConfig | where: "watermarkStatus" | first | split: ":" | last
    assign activeSizeChartsLimit = billingConfig | where: "activeSizeChartsLimit" | first | split: ":" | last | plus: 0
%}
image 2
By updating this metafield based on the plan, we remove the watermark on the liquid part

Combining the full workflow

Now that we have divided each step, we can merge back into a single machine based on the previous machines to create kind of a stateful workflow.

  • Waiting wait until an event is fired
  • on event billing.create.init we save the subscription on the context and transit to InitAppSubscription
  • InitAppSubscription fire the first machine we talked about initCreateSubscriptionMachine
  • on event subscription.create.confirm (which is when a user approves) we trigger confirmSubscriptionMutationMachine

And so, finally, we have the full workflow 😎:

By splitting key parts into machines (init, confirm, pushing back to Shopify), we created a whole composable, upgradeable, and more importantly, READABLE (yes, I swear you're going to be used to this Stately madness) set of machines ready to use for different intents.

Bonus: Managing the app Frontend with an FSM using the inspector

Let's continue with one other bloated schema, which is managing the front plan page with FSM; thanks to Stately Inspector, you can see the Sequence Diagram of each event triggered

Related machine:

OF COURSE, this machine can be tested with Model Based Testing based on the definition itself! 😎

Conclusion

We created a declarative and centralized workflow to manage a simple billing system with XState and composability in mind.

Again, getting used to XState Stately takes time, but it's worth the price, especially if you work as a team.

There are advantages in clarity and minimizing your mental load using XState.

There are also cons, like sometimes coding itself, the shape of the machine can be overwhelming (for instance, you should use a guard while a pattern matcher could've been more straightforward, or just if/else). That's the price of being able to represent a workflow; you should follow common rules.


November 8, 2024 - 10:13 pm

Do you like my work?

Let's make a wonderful
websiteexperienceappstrategy together!

Get in touch!