Table of Contents
- 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:
Implementing proper billing and upgrade/downgrade flows is still some of the hardest parts of building a SaaS product.
— Shayan (@ImSh4yy) July 11, 2024
There's so much to consider and so many edge cases to handle.
What happens if you want to handle:
- 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.)
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
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
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
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 :
- Validating, which checks if the shop can initialize the charge
creatingSubscription
which does a call to Shopify to initialize the payment- If it's done => success; otherwise error
It's a 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
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
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
%}
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 toInitAppSubscription
InitAppSubscription
fire the first machine we talked aboutinitCreateSubscriptionMachine
- on event
subscription.create.confirm
(which is when a user approves) we triggerconfirmSubscriptionMutationMachine
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