In this article, I present a streamlined approach to managing monthly and annual subscription payments within a web application. I recently refined the subscription-related code in my app, Increaser—a Next.js application supported by a Node.js server. Through this journey, I identified several components that could be valuable if you're building your own subscription management system. While the Increaser codebase remains private, I'll showcase essential code snippets here. Additionally, my public RadzionKit repository houses a comprehensive collection of reusable components, hooks, and utilities. While I currently employ Paddle for payment processing in Increaser, the design is adaptable, making it easy to transition to Stripe or other payment platforms.
Starting with the front-end, our primary goal is to ascertain whether a user has permission to access the premium features. In Increaser, this determination hinges on three criteria:
To efficiently evaluate these criteria, we utilize the useIsLikeMember
hook. The result from this hook tells us if a user is eligible to access the premium functionalities. We opted for the name useIsLikeMember
over useIsMember
to more accurately depict the status of users on a free trial. While these users can fully engage with premium features similarly to actual members, they aren't technically labeled as such. As an illustration, in Increaser, while free trial participants can harness all premium tools, they aren't granted entry to our exclusive Telegram group reserved for paying members.
import { useIsPayingUser } from "./useIsPayingUser"
import { useHasFreeTrial } from "./useHasFreeTrial"
export const useIsLikeMember = () => {
const hasFreeTrial = useHasFreeTrial()
const isPayingUser = useIsPayingUser()
return hasFreeTrial || isPayingUser
}
To figure out if a user is on a free trial, we employ the useHasFreeTrial
hook. This hook fetches the timestamp
from the user's state and measures it against the present time. We obtain the current time using the useRhythmicRerender
hook, which prompts a re-render every minute. Both the inner workings of this hook and the convertDuration
utility are available in the RadzionKit repository. This mechanism enables us to dynamically assess a user's trial status, ensuring that the interface precisely showcases their present access rights in real time.
import { useRhythmicRerender } from "@increaser/ui/hooks/useRhythmicRerender"
import { convertDuration } from "@increaser/utils/time/convertDuration"
import { useAssertUserState } from "user/state/UserStateContext"
export const useHasFreeTrial = () => {
const { freeTrialEnd } = useAssertUserState()
const now = useRhythmicRerender(convertDuration(1, "min", "ms"))
return freeTrialEnd && freeTrialEnd > now
}
Within the useIsPayingUser
hook, we discern whether a user is a paying subscriber. We do this by verifying if they possess a lifetime deal or maintain an ongoing subscription.
import { useAssertUserState } from "user/state/UserStateContext"
import { useHasActiveSubscription } from "./useHasActiveSubscription"
export const useIsPayingUser = () => {
const { lifeTimeDeal } = useAssertUserState()
const hasActiveSubscription = useHasActiveSubscription()
return lifeTimeDeal || hasActiveSubscription
}
Within the useHasActiveSubscription
hook, we return false
if the user state lacks a subscription field or if the said subscription is not active.
import { useRhythmicRerender } from "@increaser/ui/hooks/useRhythmicRerender"
import { convertDuration } from "@increaser/utils/time/convertDuration"
import { useAssertUserState } from "user/state/UserStateContext"
import { isActiveSubscription } from "@increaser/entities-utils/subscription/isActiveSubscription"
export const useHasActiveSubscription = () => {
const { subscription } = useAssertUserState()
useRhythmicRerender(convertDuration(1, "min", "ms"))
if (!subscription) return false
return isActiveSubscription(subscription)
}
A pivotal element in subscription management is the endsAt
attribute. For a recurring subscription, this field isn't present. But when a user decides to cancel, we update the endsAt
field, indicating the subscription's expiration date.
import { Subscription } from "@increaser/entities/Subscription"
export const isActiveSubscription = ({
endsAt,
}: Pick<Subscription, "endsAt">) => {
if (!endsAt) return true
return Date.now() < endsAt
}
The foundation of all these hooks is the useAssertUserState
hook, which raises an error when the user state is absent. But, in the context of Increaser, this error never surfaces. The rationale? We've put in measures to ensure the app's content doesn't render until the required user state is available.
import { createContext } from "react"
import { createContextHook } from "@increaser/ui/state/createContextHook"
import { UserStateQuery } from "@increaser/api-interface/client/graphql"
import { QueryApi } from "api/useApi"
interface UserStateContextValue {
state: UserStateQuery["userState"] | null
updateState: (state: Partial<UserStateQuery["userState"]>) => void
pullRemoteState: () => void
isLoading: boolean
lastUpdatedAt: number
updateRemoteState: QueryApi
}
export const UserStateContext = createContext<
UserStateContextValue | undefined
>(undefined)
export const useUserState = createContextHook(
UserStateContext,
"UserStateContext"
)
export const useAssertUserState = () => {
const { state } = useUserState()
if (state === null) {
throw new Error("UserState is not provided")
}
return state
}
The addition of the UserStateOnly
component facilitates controlling content rendering based on the availability of the user state. Think of it as a sentinel. If the authSession
is absent from the local storage, it promptly redirects the user.
import { ComponentWithChildrenProps } from "@increaser/ui/props"
import { useUserState } from "./UserStateContext"
import { useEffect } from "react"
import { useAuthRedirect } from "auth/hooks/useAuthRedirect"
import { useAuthSession } from "auth/hooks/useAuthSession"
export const UserStateOnly = ({ children }: ComponentWithChildrenProps) => {
const { state } = useUserState()
const { toAuthenticationPage } = useAuthRedirect()
const [authSession] = useAuthSession()
useEffect(() => {
if (!authSession) {
toAuthenticationPage()
}
}, [authSession, toAuthenticationPage])
return state ? <>{children}</> : null
}
By enclosing the content of a page within the UserStateOnly
component, you can ensure controlled rendering. For instance, on the Increaser's home page, this setup guarantees that content is visible only once the necessary user state is in place.
export const HomePage: Page = () => {
return (
<FixedWidthContent style={{ display: "flex", flexDirection: "column" }}>
<Head>
<title>🏠 Overview | {productName}</title>
</Head>
<UserStateOnly>
<ErrorBoundary fallback={<ErrorFallbackCard />}>
<HomePageContent />
</ErrorBoundary>
</UserStateOnly>
</FixedWidthContent>
)
}
Upon refreshing the app, content materializes instantly, giving an impression of lightning-fast loading. This swift display is realized by caching the user state in local storage. If you're curious about the nitty-gritty of this implementation, delve into the details in this post.
Here's a tangible use-case of these hooks in action. Imagine a feature reserved solely for members, like initiating a focus session. This function is neatly wrapped inside the MemberOnlyAction
component. This component accepts an action prop, which gets called only if the user aligns with a member profile, and a rendering function that springs a modal loaded with a subscription form for non-members. This setup ensures each user experiences the feature according to their privilege tier.
<MemberOnlyAction
action={() => start({ projectId, duration: focusDuration })}
render={({ action }) => (
<Button kind="reversed" size="l" onClick={action}>
<Text as="div" style={{ wordBreak: "keep-all" }}>
<FocusDurationText emoji={project.emoji} value={focusDuration} />
</Text>
</Button>
)}
/>
The MemberOnlyAction
component is elegantly designed for simplicity. If a user resembles a member, the render function gets invoked with the action prop. Conversely, if they don't, the render function provides a callback to unveil the SubscriptionPrompt
. Augmenting this component, we have the Opener
abstract component sourced from RadzionKit.
import { Opener } from "@increaser/ui/ui/Opener"
import { useIsLikeMember } from "membership/hooks/useIsLikeMember"
import { SubscriptionPrompt } from "membership/subscription/components/SubscriptionPrompt"
type Action = () => void
interface RenderProps {
action: Action
}
interface MemberOnlyActionProps {
action: () => void
render: (props: RenderProps) => JSX.Element
}
export const MemberOnlyAction = ({ action, render }: MemberOnlyActionProps) => {
const isLikeMember = useIsLikeMember()
if (isLikeMember) {
return render({ action })
}
return (
<Opener
renderOpener={({ onOpen }) => render({ action: onOpen })}
renderContent={({ onClose }) => <SubscriptionPrompt onClose={onClose} />}
/>
)
}
The SubscriptionPrompt
component is multistage. It first showcases an offer to the user. Upon acceptance, it progresses to reveal a checkout modal. Instead of leaning on the traditional switch-case architecture, we harness the power of the Match
component from RadzionKit to render varying modals based on each stage. For those keen on crafting a resilient modal component, consider diving into this post.
import { ClosableComponentProps } from "@increaser/ui/props"
import { Button } from "@increaser/ui/ui/buttons/Button"
import { useState } from "react"
import { Modal } from "@increaser/ui/modal"
import { Match } from "@increaser/ui/ui/Match"
import { SubscriptionBillingCycleProvider } from "@increaser/ui/subscription/components/SubscriptionBillingCycleProvider"
import { SubscriptionOffer } from "./SubscriptionOffer"
import { SubscriptionCheckout } from "./SubscriptionCheckout"
type SubscriptionPromptStage = "offer" | "checkout"
export const SubscriptionPrompt = ({ onClose }: ClosableComponentProps) => {
const [stage, setStage] = useState<SubscriptionPromptStage>("offer")
return (
<SubscriptionBillingCycleProvider>
<Match
value={stage}
offer={() => (
<Modal
title="Subscribe to continue"
onClose={onClose}
footer={
<Button
onClick={() => setStage("checkout")}
style={{ width: "100%" }}
kind="reversed"
size="l"
>
Purchase
</Button>
}
>
<SubscriptionOffer />
</Modal>
)}
checkout={() => <SubscriptionCheckout onClose={onClose} />}
/>
</SubscriptionBillingCycleProvider>
)
}
The SubscriptionBillingCycleProvider
is utilized to manage the state of the selected billing period, allowing us to avoid prop-drilling between components. Some developers might regard this provider as excessive, but it resonates with my inclination towards neater components with fewer props.
import { SubscriptionBillingCycle } from "@increaser/entities/Subscription"
import { createContext, useContext, useState } from "react"
import { ComponentWithChildrenProps } from "../../props"
interface BillingCycleContextValue {
value: SubscriptionBillingCycle
setValue: (value: SubscriptionBillingCycle) => void
}
const BillingCycleContext = createContext<BillingCycleContextValue | undefined>(
undefined
)
export const SubscriptionBillingCycleProvider = ({
children,
}: ComponentWithChildrenProps) => {
const [value, setValue] = useState<SubscriptionBillingCycle>("year")
return (
<BillingCycleContext.Provider value={{ value, setValue }}>
{children}
</BillingCycleContext.Provider>
)
}
export const useSubscriptionBillingCycle = () => {
const state = useContext(BillingCycleContext)
if (!state) {
throw new Error(
"useSubscriptionBillingCycle must be used within SubscriptionBillingCycleProvider"
)
}
return [state.value, state.setValue] as const
}
Let's dive into the SubscriptionOffer
component. This component is divided into two main sections: the price-related content, which is shown only after it's loaded, and a list showcasing the benefits of membership.
import { VStack } from "@increaser/ui/ui/Stack"
import { SubscriptionBillingCycleInput } from "@increaser/ui/subscription/components/SubscriptionBillingCycleInput"
import { SubscriptionPrice } from "@increaser/ui/subscription/components/SubscriptionPrice"
import { getAnnualSubscriptionSavings } from "@increaser/entities-utils/subscription/getAnnualSubscriptionSavings"
import { useSubscriptionBillingCycle } from "@increaser/ui/subscription/components/SubscriptionBillingCycleProvider"
import { MembershipBenefits } from "membership/components/MembershipBenefits"
import { SubscriptionPricesQueryDependant } from "@increaser/paddle-ui/components/SubscriptionPricesQueryDependant"
export const SubscriptionOffer = () => {
const [billingCycle, setBillingCycle] = useSubscriptionBillingCycle()
return (
<VStack alignItems="center" gap={20}>
<SubscriptionPricesQueryDependant
success={(prices) => (
<>
<SubscriptionBillingCycleInput
value={billingCycle}
onChange={setBillingCycle}
saving={getAnnualSubscriptionSavings(
prices.year.amount,
prices.month.amount
)}
/>
<SubscriptionPrice
currency={prices.year.currency}
billingCycle={billingCycle}
price={{
month: prices.month.amount,
year: prices.year.amount,
}}
/>
</>
)}
/>
<MembershipBenefits />
</VStack>
)
}
The SubscriptionPricesQueryDependant
component plays a crucial role in loading the prices. This component is a part of the paddle-classic-ui
package within my monorepo, highlighting its specific design for Paddle integration. If I decide to switch to a different payment provider in the future, I can easily create an analogous component inside a corresponding package, like stripe-ui
, ensuring modularity and ease of integration.
import { Text } from "@increaser/ui/ui/Text"
import {
QueryDependant,
QueryDependantProps,
} from "@increaser/ui/query/components/QueryDependant"
import { Center } from "@increaser/ui/ui/Center"
import { Spinner } from "@increaser/ui/ui/Spinner"
import {
SubscriptionPrices,
useSubscriptionPricesQuery,
} from "../hooks/useSubscriptionPricesQuery"
interface SubscriptionPricesQueryDependantProps
extends Pick<QueryDependantProps<SubscriptionPrices>, "success"> {}
export const SubscriptionPricesQueryDependant = ({
success,
}: SubscriptionPricesQueryDependantProps) => {
const query = useSubscriptionPricesQuery()
return (
<QueryDependant
{...query}
error={() => <Text>Failed to load subscription price</Text>}
loading={() => (
<Center>
<Spinner />
</Center>
)}
success={success}
/>
)
}
The QueryDependant
component offers a streamlined solution for rendering content based on the state of a react-query. When data is loading, a centered spinner provides visual feedback. If any issue arises, an informative error message is presented. On successful data retrieval, both the SubscriptionBillingCycleInput
and SubscriptionPrice
components come into view, granting users a selection of subscription options. By introducing this abstraction, the UI becomes dynamically responsive, ensuring users experience content tailored to each stage of the data-fetching journey, enhancing overall user interaction.
import { ReactNode } from "react"
type QueryStatus = "idle" | "error" | "loading" | "success"
export interface QueryDependantProps<T> {
status: QueryStatus
data: T | undefined
error: () => ReactNode
loading: () => ReactNode
success: (data: T) => ReactNode
}
export function QueryDependant<T>({
status,
data,
error,
loading,
success,
}: QueryDependantProps<T>) {
if (status === "error") {
return <>{error()}</>
}
if (status === "loading") {
return <>{loading()}</>
}
if (data) {
return <>{success(data)}</>
}
return null
}
The SubscriptionBillingCycleInput
component harmoniously merges the functionalities of the Switch
and Tag
components. The Switch
component, sourced from RadzionKit, facilitates the alternation between monthly and yearly billing choices. Complementing this, the Tag
component vividly illustrates the percentage of savings a user gains when choosing the yearly billing option. This not only boosts clarity but also nudges users toward the yearly billing cycle, highlighting its financial advantage.
import { useTheme } from "styled-components"
import { InputProps } from "../../props"
import { toPercents } from "@radzionkit/utils/toPercents"
import { HStack } from "../../ui/Stack"
import { Switch } from "../../ui/Switch/Switch"
import { Tag } from "../../ui/Tag"
import { SubscriptionBillingCycle } from "@radzionkit/entities/Subscription"
interface SubscriptionBillingCycleInputProps
extends InputProps<SubscriptionBillingCycle> {
saving: number
}
export const SubscriptionBillingCycleInput = ({
value,
onChange,
saving,
}: SubscriptionBillingCycleInputProps) => {
const { colors } = useTheme()
return (
<HStack alignItems="center" gap={8}>
<Switch
kind="primary"
value={value === "year"}
onChange={(value) => onChange(value ? "year" : "month")}
label="Annual billing"
/>
<Tag $color={colors.success}>save {toPercents(saving, "round")}</Tag>
</HStack>
)
}
The SubscriptionPrice
component is meticulously designed to always present the monthly rate, even when referencing an annual subscription. This approach accentuates the annual option's cost-effectiveness. To maintain transparency and clarity, the total annual price is also displayed—albeit in a subtler font—when users choose the annual subscription. Importantly, the visibility property is employed rather than removing the element from the DOM. This method ensures fluid transitions and a consistent aesthetic when users alternate between billing options. Furthermore, to heighten the visual allure and perceived savings, the annual price is intentionally set such that its monthly equivalent ends in .99. For example, an annual fee of $47.88 breaks down to a neat monthly rate of $3.99.
import { SubscriptionBillingCycle } from "@radzionkit/entities/Subscription"
import { MONTHS_IN_YEAR } from "@radzionkit/utils/time"
import { VStack, HStack } from "../../ui/Stack"
import { HStackSeparatedBy, slashSeparator } from "../../ui/StackSeparatedBy"
import { Text } from "../../ui/Text"
interface SubscriptionPriceProps {
billingCycle: SubscriptionBillingCycle
currency: string
price: Record<SubscriptionBillingCycle, number>
}
const monthsInPeriod: Record<SubscriptionBillingCycle, number> = {
month: 1,
year: MONTHS_IN_YEAR,
}
export const SubscriptionPrice = ({
billingCycle,
currency,
price,
}: SubscriptionPriceProps) => {
return (
<VStack alignItems="center" gap={4}>
<HStack gap={4} alignItems="center">
<Text size={18} as="span" color="regular">
{currency}
</Text>
<HStackSeparatedBy
gap={4}
separator={<Text color="shy">{slashSeparator}</Text>}
>
<Text color="regular" size={32} weight="bold" as="span">
{(price[billingCycle] / monthsInPeriod[billingCycle]).toFixed(2)}
</Text>
<Text size={18} as="span" color="supporting">
mo
</Text>
</HStackSeparatedBy>
</HStack>
<Text
size={14}
color="supporting"
style={{
transition: "none",
visibility: billingCycle === "month" ? "hidden" : "initial",
}}
>
{currency}
{price[billingCycle]} per year
</Text>
</VStack>
)
}
The final segment of the SubscriptionOffer
component is dedicated to the MembershipBenefits
list. This component systematically outlines the various advantages that come with a subscription.
import { VStack } from "@increaser/ui/ui/Stack"
import { MembershipBenefit } from "@increaser/ui/membership/components/MembershipBenefit"
export const MembershipBenefits = () => (
<VStack gap={8}>
<MembershipBenefit benefit="Enhance your focus" />
<MembershipBenefit benefit="Finish work faster" />
<MembershipBenefit benefit="Accelerate your career" />
<MembershipBenefit benefit="Develop positive habits" />
<MembershipBenefit benefit="Boundaries for work-life balance" />
</VStack>
)
The SubscriptionCheckout
component streamlines the user experience through a multi-stage process. Initially, users are presented with the Paddle checkout, embedded within an iframe. After a successful transaction, the component proceeds to fetch the checkout ID, which is crucial for retrieving the subscription ID. In the final stage, the component synchronizes the subscription within the app, marking the conclusion of the process.
import { ClosableComponentProps } from "@increaser/ui/props"
import { useSubscriptionBillingCycle } from "@increaser/ui/subscription/components/SubscriptionBillingCycleProvider"
import { PaddleIFrame } from "@increaser/paddle-classic-ui/components/PaddleIFrame"
import { paddleProductCode } from "@increaser/paddle-classic-ui/paddleProductCode"
import { useAssertUserState } from "user/state/UserStateContext"
import { useState } from "react"
import { SyncSubscription } from "./SyncSubscription"
import { PaddleModal } from "@increaser/paddle-classic-ui/components/PaddleModal"
import { productName } from "@increaser/entities"
import { Flow } from "@increaser/ui/ui/Flow"
import { QuerySubscriptionId } from "@increaser/paddle-classic-ui/components/QuerySubscriptionId"
type SubscriptionCheckoutStep =
| {
id: "paddle"
}
| {
id: "subscriptionId"
checkoutId: string
}
| {
id: "subscription"
subscriptionId: string
}
type StepId = SubscriptionCheckoutStep["id"]
const stepTitle: Record<StepId, string> = {
paddle: `${productName} Subscription`,
subscriptionId: "Syncing Checkout...",
subscription: "Syncing Subscription...",
}
export const SubscriptionCheckout = ({ onClose }: ClosableComponentProps) => {
const [step, setStep] = useState<SubscriptionCheckoutStep>({
id: "paddle",
})
const [billingCycle] = useSubscriptionBillingCycle()
const user = useAssertUserState()
return (
<PaddleModal title={stepTitle[step.id]} onClose={onClose}>
<Flow
step={step}
steps={{
paddle: () => (
<PaddleIFrame
user={user}
onClose={onClose}
product={paddleProductCode[billingCycle]}
onSuccess={(checkoutId) =>
setStep({ id: "subscriptionId", checkoutId })
}
/>
),
subscriptionId: ({ checkoutId }) => (
<QuerySubscriptionId
checkoutId={checkoutId}
onSuccess={(subscriptionId) =>
setStep({ id: "subscription", subscriptionId })
}
/>
),
subscription: ({ subscriptionId }) => (
<SyncSubscription
onFinish={onClose}
subscriptionId={subscriptionId}
/>
),
}}
/>
</PaddleModal>
)
}
The PaddleModal
component acts as a wrapper for the Modal
component, but with a mandatory light theme. This choice stems from a desire for visual consistency with Paddle's default light checkout theme. Even though Paddle offers theme customization, the default light theme typically appears more visually appealing and user-friendly. Therefore, maintaining a uniform light-themed modal for all Paddle interactions ensures a harmonious user experience.
import { ThemeProvider } from "styled-components"
import { Modal, ModalProps } from "@increaser/ui/modal"
import { lightTheme } from "@increaser/ui/ui/theme/lightTheme"
export const PaddleModal = (props: ModalProps) => {
return (
<ThemeProvider theme={lightTheme}>
<Modal placement="top" {...props} />
</ThemeProvider>
)
}
The Flow
component provides a streamlined, adaptable, and intuitive method for overseeing various stages or steps in a user process. Each stage is encapsulated within an object that contains several fields, with the 'id' field being mandatory. Rendering functions are passed a step, which is explicitly typed based on its 'id', enhancing type safety and allowing for precise handling of diverse stages. This setup centralizes the state, offering a cohesive and easily manageable strategy for directing intricate, multi-stage user interactions.
import { ReactNode } from "react"
export interface FlowStep<K extends string> {
id: K
}
type StepHandlers<K extends string, T extends FlowStep<K>> = {
[key in T["id"]]: (step: Extract<T, { id: key }>) => ReactNode
}
type FlowProps<K extends string, T extends FlowStep<K>> = {
step: T
steps: StepHandlers<K, T>
}
export function Flow<K extends string, T extends FlowStep<K>>({
step,
steps,
}: FlowProps<K, T>) {
const id = step.id
const render = steps[id]
return <>{render(step as Extract<T, { id: K }>)}</>
}
The PaddleIFrame
component is crafted to offer a smooth checkout journey. By auto-filling the email and forwarding the user's ID to the Paddle system, the transaction becomes both faster and more straightforward, elevating the user experience. The onSuccess
callback triggers once the checkout concludes, and the onClose
callback gives users a way out, ensuring they maintain control and flexibility throughout the process.
import { useEffect } from "react"
import styled from "styled-components"
import { usePaddleSdk } from "../hooks/usePaddleSdk"
import { User } from "@increaser/entities/User"
interface Props {
onClose: () => void
onSuccess?: (checkoutId: string) => void
user: Pick<User, "email" | "id">
override?: string
product: string | number
}
const Container = styled.div`
position: relative;
`
export const PaddleIFrame = ({
onClose,
override,
product,
user: { email, id },
onSuccess,
}: Props) => {
const className = `checkout-container-${product}`
const { data: paddleSdk } = usePaddleSdk()
useEffect(() => {
if (!paddleSdk) return
paddleSdk.Checkout.open({
method: "inline",
product: Number(product),
allowQuantity: false,
disableLogout: true,
frameTarget: className,
successCallback: ({ checkout: { id } }) => {
onSuccess?.(id)
},
closeCallback: onClose,
frameInitialHeight: 450,
email: email,
passthrough: JSON.stringify({ userId: id }),
override,
frameStyle: "width:100%; background-color: transparent; border: none;",
})
}, [className, onClose, override, paddleSdk, product, email, id])
return <Container className={className} />
}
The QuerySubscriptionId
component retrieves the subscription id based on the checkout id from Paddle. Once this is done, it triggers the onSuccess
callback. Given that we can experience either a loading or an error state, the BlockingQuery
component is rendered to handle these scenarios.
import { useSubscriptionIdQuery } from "../hooks/useSubscriptionIdQuery"
import { useOnQuerySuccess } from "@increaser/ui/query/hooks/useOnQuerySuccess"
import { BlockingQuery } from "@increaser/ui/query/components/BlockingQuery"
interface QuerySubscriptionIdProps {
checkoutId: string
onSuccess: (subscriptionId: string) => void
}
export const QuerySubscriptionId = ({
checkoutId,
onSuccess,
}: QuerySubscriptionIdProps) => {
const query = useSubscriptionIdQuery(checkoutId)
useOnQuerySuccess(query, onSuccess)
return <BlockingQuery error={query.error} />
}
The useOnQuerySuccess
hook is a straightforward wrapper over useEffect
. It triggers the callback as soon as the data becomes available.
import { useEffect } from "react"
import { UseQueryResult } from "react-query"
export const useOnQuerySuccess = <T,>(
{ data }: Pick<UseQueryResult<T>, "data">,
onSuccess: (data: T) => void
) => {
useEffect(() => {
if (data) {
onSuccess(data)
}
}, [data, onSuccess])
}
When faced with only two potential states—loading or error—the BlockingQuery
component comes into play. It displays a prominent spinner during loading. If an error occurs, it presents the error message alongside a support email for further assistance.
import { supportEmail } from "@increaser/entities"
import { CopyText } from "../../ui/CopyText"
import { Spinner } from "../../ui/Spinner"
import { VStack } from "../../ui/Stack"
import { Text } from "../../ui/Text"
import { InfoIcon } from "../../ui/icons/InfoIcon"
interface BlockingQueryProps {
error?: Error | null
}
export const BlockingQuery = ({ error }: BlockingQueryProps) => {
return (
<VStack alignItems="center" gap={20}>
<Text
style={{ display: "flex" }}
color={error ? "alert" : "regular"}
size={80}
>
{error ? <InfoIcon /> : <Spinner />}
</Text>
{error ? (
<>
<Text
color="regular"
style={{ wordBreak: "break-word" }}
centered
height="large"
>
{error.message}
</Text>
<Text centered color="supporting" size={14}>
Nothing helps? Email us at <br />
<CopyText color="regular" as="span" content={supportEmail}>
{supportEmail}
</CopyText>
</Text>
</>
) : (
<Text color="supporting">Please wait</Text>
)}
</VStack>
)
}
The SyncSubscription
component follows a similar pattern to the QuerySubscriptionId
component. However, it queries the API for subscription data. Once the data is retrieved, it synchronizes with the user state and then triggers the onFinish
callback.
import { FinishableComponentProps } from "@increaser/ui/props"
import { useSubscriptionQuery } from "../hooks/useSubscriptionQuery"
import { useOnQuerySuccess } from "@increaser/ui/query/hooks/useOnQuerySuccess"
import { BlockingQuery } from "@increaser/ui/query/components/BlockingQuery"
interface SyncSubscriptionProps extends FinishableComponentProps {
subscriptionId: string
}
export const SyncSubscription = ({
subscriptionId,
onFinish,
}: SyncSubscriptionProps) => {
const query = useSubscriptionQuery(subscriptionId)
useOnQuerySuccess(query, onFinish)
return <BlockingQuery error={query.error} />
}
Increaser utilizes a GraphQL API. For those keen on understanding type generation within a monorepo context, consider reading this post. Shortly, we'll delve into the server-side implementation of the subscription query.
import { graphql } from "@increaser/api-interface/client"
import { SubscriptionQuery } from "@increaser/api-interface/client/graphql"
import { useApi } from "api/useApi"
import { useQuery } from "react-query"
import { useUserState } from "user/state/UserStateContext"
const subscriptionQueryDocument = graphql(`
query subscription($input: SubscriptionInput!) {
subscription(input: $input) {
id
provider
planId
status
nextBilledAt
endsAt
}
}
`)
export const useSubscriptionQuery = (id: string) => {
const { query } = useApi()
const { updateState } = useUserState()
return useQuery<SubscriptionQuery["subscription"], Error>(
["subscription", id],
async () => {
const { subscription } = await query(subscriptionQueryDocument, {
input: { id },
})
updateState({ subscription })
return subscription
},
{}
)
}
After a user purchases a subscription, it's a thoughtful gesture to express gratitude. For this reason, the MembershipConfirmation
component is embedded within the root App component to convey a "Thank you" message.
function MyApp({ Component, pageProps }: MyAppProps) {
// ...
return (
<ThemeProvider>
<GlobalStyle fontFamily={openSans.style.fontFamily} />
<QueryClientProvider client={queryClient}>
<ErrorBoundary fallback={<FullSizeErrorFallback />}>
<UserStateProvider>
<PWAProvider>
<ConditionalUserState
present={() => (
<UserManagerProvider>
<ProjectsProvider>
<HabitsProvider>
<SetsManagerProvider>
<FocusProvider>
<BreakProvider>{component}</BreakProvider>
<MembershipConfirmation />
</FocusProvider>
</SetsManagerProvider>
</HabitsProvider>
</ProjectsProvider>
</UserManagerProvider>
)}
missing={() => <>{component}</>}
/>
</PWAProvider>
</UserStateProvider>
</ErrorBoundary>
</QueryClientProvider>
</ThemeProvider>
)
}
While I have plans to introduce a welcome video in the modal soon, currently it showcases text along with my contact information. To decide when to display this component, we monitor the output from the useIsPayingUser
hook. When the status transitions from false
to true
, the modal appears. Importantly, since we hinge on the isPayingUser
state rather than the subscription
field, this modal will also pop up should a user opt for a lifetime deal, should we offer one in the future.
import { productName } from "@increaser/entities"
import { useBoolean } from "@increaser/ui/hooks/useBoolean"
import { useEffectOnDependencyChange } from "@increaser/ui/hooks/useEffectOnDependencyChange"
import { Modal } from "@increaser/ui/modal"
import { VStack } from "@increaser/ui/ui/Stack"
import { Text } from "@increaser/ui/ui/Text"
import { InlineFounderContacts } from "info/components/InflineFounderContacts"
import { useIsPayingUser } from "membership/hooks/useIsPayingUser"
import { ContinueButton } from "ui/ContinueButton"
export const MembershipConfirmation = () => {
const [isOpen, { set: open, unset: close }] = useBoolean(false)
const isPayingUser = useIsPayingUser()
useEffectOnDependencyChange(() => {
if (isPayingUser) {
open()
}
}, [isPayingUser])
if (!isOpen) return null
return (
<Modal
placement="top"
onClose={close}
title={`Welcome to ${productName}!`}
footer={<ContinueButton onClick={close} />}
>
<VStack gap={20}>
<Text color="regular">
Hey there! Thank you for subscribing to {productName}. I'm Radzion,
and I'm excited for you to embark on this journey to enhanced
productivity and a more balanced lifestyle. If you have any questions
or need assistance, feel free to reach out to me directly—I'm here to
help!
</Text>
<InlineFounderContacts />
</VStack>
</Modal>
)
}
The useEffectOnDependencyChange
is a hook that brings an additional layer of specificity to the standard useEffect
by ensuring that the side effect is only executed when the provided dependencies actually change values.
import { DependencyList, useEffect, useRef } from "react"
export const useEffectOnDependencyChange = (
effect: () => void,
deps: DependencyList
) => {
const prevDeps = useRef(deps)
useEffect(() => {
const hasDepsChanged = !prevDeps.current.every((dep, i) => dep === deps[i])
if (hasDepsChanged) {
effect()
prevDeps.current = deps
}
}, deps)
}
The final step for our front-end process is to display subscription details, enabling users to update billing information or terminate the subscription. This functionality resides in the MembershipOverview
component.
import { MembersTelegram } from "communication/MembersTelegram"
import { VStack } from "@increaser/ui/ui/Stack"
import { FreeTrialStatus } from "../subscription/components/FreeTrialStatus"
import { ManageSubscription } from "../subscription/components/ManageSubscription"
import { ManageLifeTimeDeal } from "./ManageLifeTimeDeal"
import { MembershipOffer } from "./MembershipOffer"
export const MembershipOverview = () => {
return (
<VStack alignItems="start" gap={20}>
<ManageSubscription />
<ManageLifeTimeDeal />
<MembersTelegram />
<FreeTrialStatus />
<MembershipOffer />
</VStack>
)
}
The ManageSubscription
component displays content only when the subscription
field exists and it hasn't expired. If the endsAt
field indicates a future date, we inform the user about the specific end date of their subscription. In cases where there's a payment issue and the status is pastDue
, we prompt the user to update their payment method. Otherwise, when everything is in order, we showcase the next billing date along with buttons to manage the subscription.
import { VStack } from "@increaser/ui/ui/Stack"
import { Text } from "@increaser/ui/ui/Text"
import { useAssertUserState } from "user/state/UserStateContext"
import { ManageSubscriptionActions } from "./ManageSubscriptionActions"
import { mirrorRecord } from "@increaser/utils/mirrorRecord"
import { format } from "date-fns"
import { paddleProductCode } from "@increaser/paddle-classic-ui/paddleProductCode"
const subscriptionDateFormat = "dd MMMM yyyy"
export const ManageSubscription = () => {
const { subscription } = useAssertUserState()
if (!subscription) return null
if (subscription.endsAt) {
if (subscription.endsAt < Date.now()) return null
return (
<Text>
Your subscription ends on{" "}
<Text as="span" weight="bold">
{format(subscription.endsAt, subscriptionDateFormat)}
</Text>
</Text>
)
}
if (subscription.status === "pastDue") {
return (
<VStack gap={8}>
<Text>
Your subscription is past due. Please update your payment method to
continue using the service.
</Text>
<ManageSubscriptionActions />
</VStack>
)
}
const billingCycle =
mirrorRecord(paddleProductCode)[Number(subscription.planId)]
const messages = [
billingCycle
? `Your subscription renews automatically every ${billingCycle}.`
: "Your subscription renews automatically.",
]
if (subscription.nextBilledAt) {
messages.push(
`Next billing date is ${format(
subscription.nextBilledAt,
subscriptionDateFormat
)}.`
)
}
return (
<VStack gap={16}>
<VStack gap={4}>
{messages.map((message) => (
<Text key={message}>{message}</Text>
))}
</VStack>
<ManageSubscriptionActions />
</VStack>
)
}
The ManageSubscriptionActions
component displays the buttons that allow users to update or cancel their subscription. It employs the useManageSubscriptionQuery
hook to retrieve the URLs for the Paddle checkout. The checkout process is managed by the PaddleIFrame
component. Upon completion, the onSuccess
callback is triggered. With the subscription ID at our disposal, we can then synchronize it with the user's state and close the modal.
import { PaddleIFrame } from "@increaser/paddle-classic-ui/components/PaddleIFrame"
import { PaddleModal } from "@increaser/paddle-classic-ui/components/PaddleModal"
import { useManageSubscriptionQuery } from "../hooks/useManageSubscriptionQuery"
import { QueryDependant } from "@increaser/ui/query/components/QueryDependant"
import { getQueryDependantDefaultProps } from "@increaser/ui/query/utils/getQueryDependantDefaultProps"
import { HStack } from "@increaser/ui/ui/Stack"
import { Button } from "@increaser/ui/ui/buttons/Button"
import { shouldBeDefined } from "@increaser/utils/shouldBeDefined"
import { useAssertUserState } from "user/state/UserStateContext"
import { useState } from "react"
import { SyncSubscription } from "./SyncSubscription"
import { Match } from "@increaser/ui/ui/Match"
type ManageSubscriptionAction = "update" | "cancel"
type Stage = ManageSubscriptionAction | "sync"
const stageTitle: Record<Stage, string> = {
update: "Update Subscription",
cancel: "Cancel Subscription",
sync: "Syncing Subscription...",
}
export const ManageSubscriptionActions = () => {
const [stage, setStage] = useState<Stage | null>(null)
const query = useManageSubscriptionQuery()
const user = useAssertUserState()
const { subscription } = user
const { planId } = shouldBeDefined(subscription ?? undefined)
return (
<QueryDependant
{...query}
{...getQueryDependantDefaultProps("subscription management URLs")}
success={({ updateUrl, cancelUrl }) => {
const actionUrl: Record<ManageSubscriptionAction, string> = {
update: updateUrl,
cancel: cancelUrl,
}
const renderActionContent = (action: ManageSubscriptionAction) => (
<PaddleIFrame
user={user}
override={actionUrl[action]}
product={planId}
onClose={() => setStage(null)}
onSuccess={() => {
setStage("sync")
}}
/>
)
return (
<HStack alignItems="center" gap={20}>
<Button onClick={() => setStage("update")} kind="secondary">
Update
</Button>
<Button onClick={() => setStage("cancel")} kind="alert">
Cancel
</Button>
{stage && (
<PaddleModal
title={stageTitle[stage]}
onClose={() => setStage(null)}
>
<Match
value={stage}
update={() => renderActionContent("update")}
cancel={() => renderActionContent("cancel")}
sync={() => (
<SyncSubscription
subscriptionId={subscription!.id}
onFinish={() => setStage(null)}
/>
)}
/>
</PaddleModal>
)}
</HStack>
)
}}
/>
)
}
Having completed the front-end, let's now shift our focus to the server-side. The primary task is to determine the essential data required to represent a 'Subscription' in our database. We needn't concern ourselves with the data format received from the payment provider, as our main focus should be on our internal representation. We can always manage format conversions as needed. For my project, "Increaser," I decided to store:
Paddle Classic
to Paddle
in the future, which is why I've included this. You might not need this field for your application immediately. If required, you can always run a migration later to add it to existing subscriptions.active
or pastDue
. We infer the canceled
state from the endsAt
field.canceled
subscription.active
subscription.export type SubscriptionBillingCycle = "month" | "year"
export type SubscriptionStatus = "active" | "pastDue"
export type SubscriptionProvider = "paddleClassic"
export interface Subscription {
provider: SubscriptionProvider
id: string
planId: string
status: SubscriptionStatus
nextBilledAt?: number
endsAt?: number | null
}
You might be wondering, "What if we need to add another field to our Subscription from the payment provider in the future, and we haven't stored it yet?" This is where the sync command becomes invaluable. Whenever we depend on external systems, it's essential to have a mechanism to synchronize the data. There's always a possibility that issues might arise in our observers or webhooks, necessitating a method to replenish missing data. For the Increaser platform, I've implemented a syncSubscriptions
command. This command takes a plan ID, retrieves all the associated subscriptions, transforms them into our internal Subscription
type, and then updates our database accordingly.
import { getUserByEmail, updateUser } from "@increaser/db/user"
import { PaddleClassicUser } from "../entities"
import { queryPaddle } from "../utils/queryPaddle"
import { getPaddlePlan } from "../utils/getPaddlePlan"
import { toSubscription } from "../utils/toSubscription"
const syncSubscriptions = async (planId: number) => {
console.log(`Syncing subscriptions for plan ${planId}`)
const users = await queryPaddle<PaddleClassicUser[]>("subscription/users", {
plan_id: planId,
results_per_page: 200,
})
console.log(`Found ${users.length} users`)
const plan = await getPaddlePlan(planId)
await Promise.all(
users.map(async (paddleUser) => {
const user = await getUserByEmail(paddleUser.user_email, ["id"])
if (!user) {
throw new Error(`User with email ${paddleUser.user_email} not found`)
}
const subscription = toSubscription(paddleUser, plan)
updateUser(user.id, {
subscription,
})
})
)
}
const planId = Number(process.argv[2])
syncSubscriptions(planId)
Paddle Classic doesn't provide an SDK or type definitions, making direct interactions with their API a bit cumbersome. To streamline this process, I've created a function called queryPaddle
. This function accepts an endpoint and an optional payload. When called, it performs a fetch operation and returns the parsed JSON. The beauty of queryPaddle
is that it abstracts away the need to specify the base url
, content type, and authorization header every time we want to communicate with the Paddle API. This not only makes the code cleaner but also simplifies the querying process.
import { getEnvVar } from "./getEnvVar"
const paddleBaseUrl = "https://vendors.paddle.com/api/2.0"
export const queryPaddle = async <T>(
endpoint: string,
payload: Record<string, any> = {}
): Promise<T> => {
const formData = new URLSearchParams(payload)
const url = [paddleBaseUrl, endpoint].join("/")
const result = await fetch(url, {
method: "POST",
headers: {
"Content-Type": "application/x-www-form-urlencoded",
Authorization: `Bearer ${getEnvVar("PADDLE_API_KEY")}`,
},
body: formData.toString(),
})
const { response } = await result.json()
return response
}
Changes to a subscription don't solely originate from our app. Users might cancel their subscription or make alterations directly in the payment system. As a result, we need a way to stay updated with all such modifications. To achieve this, we "listen" to these changes by using webhooks.
When we receive a webhook, we need to ensure it's one of the events we're interested in. We handle events such as subscription creation, updates, cancellations, and various payment-related alerts. Once we confirm the event type, we process the relevant data, extract the subscription details, and update our database accordingly. This mechanism ensures our system remains in sync with the payment provider, reflecting accurate subscription statuses for our users.
import { isOneOf } from "@increaser/utils/array/isOneOf"
import { PaddleClassicEvent } from "../PaddleClassicEvent"
import { updateUser } from "@increaser/db/user"
import { Subscription } from "@increaser/entities/Subscription"
import { fromPaddleClassicStatus } from "../../paddle-classic/utils/toSubscription"
const paddleClassicSupportedAlerts = [
"subscription_created",
"subscription_updated",
"subscription_cancelled",
"subscription_payment_succeeded",
"subscription_payment_failed",
"subscription_payment_refunded",
] as const
export const handlePaddleClassicEvent = async (event: PaddleClassicEvent) => {
const alertName = isOneOf(event.alert_name, paddleClassicSupportedAlerts)
if (!alertName) {
console.log(
`Received unsupported alert from Paddle Classic: ${event.alert_name}`
)
return
}
console.log(`Processing ${event.alert_name} event from Paddle Classic`)
const { userId } = JSON.parse(event.passthrough)
const subscription: Subscription = {
provider: "paddleClassic",
id: event.subscription_id,
planId: event.subscription_plan_id,
status: fromPaddleClassicStatus(event.status),
nextBilledAt: event.next_bill_date
? new Date(event.next_bill_date).getTime()
: undefined,
endsAt: event.cancellation_effective_date
? new Date(event.cancellation_effective_date).getTime()
: undefined,
}
await updateUser(userId, { subscription })
}
The GraphQL API represents the last integral component of our subscription system. Even though we retrieve the subscription details from our database using the userState
query, it's also essential to incorporate both manageSubscription
and subscription
queries.
enum SubscriptionProvider {
paddleClassic
}
enum SubscriptionStatus {
active
pastDue
}
type Subscription {
provider: SubscriptionProvider!
id: String!
planId: String!
status: SubscriptionStatus!
nextBilledAt: Float
endsAt: Float
}
type ManageSubscription {
updateUrl: String!
cancelUrl: String!
}
input SubscriptionInput {
id: String!
}
type Query {
userState(input: UserStateInput!): UserState!
manageSubscription: ManageSubscription!
subscription(input: SubscriptionInput!): Subscription
}
The manageSubscription
query is crucial because the URLs it returns aren't static and may evolve over time. Instead of hardcoding or caching these URLs, it's more efficient and reliable to query them in real-time when required. This ensures that users always get accurate and functional URLs to manage their subscriptions.
This query essentially acts as a bridge to the subscription/users
endpoint of the Paddle Classic API. Upon invocation, it first verifies the user's identity and checks for their subscription details in the database. Once confirmed, it queries the Paddle Classic API for the specific user's subscription URLs. Finally, it returns the cancellation and update URLs, allowing the user to easily manage their subscription directly from our application.
import { queryPaddle } from "@increaser/paddle-classic/utils/queryPaddle"
import { assertUserId } from "../../../auth/assertUserId"
import { OperationContext } from "../../../gql/OperationContext"
import { QueryResolvers } from "../../../gql/schema"
import { getUserById } from "@increaser/db/user"
import { PaddleClassicUser } from "@increaser/paddle-classic/entities"
export const manageSubscription: QueryResolvers<OperationContext>["manageSubscription"] =
async (_, __, context) => {
const userId = assertUserId(context)
const { subscription } = await getUserById(userId, ["subscription"])
if (!subscription) {
throw new Error("User has no subscription")
}
const [user] = await queryPaddle<PaddleClassicUser[]>(
"subscription/users",
{
subscription_id: subscription.id,
}
)
return {
cancelUrl: user.cancel_url,
updateUrl: user.update_url,
}
}
subscription
QueryThe subscription
query plays a distinct role compared to the userState
query. While the latter simply fetches the subscription data from our database, the former takes an extra, crucial step: it communicates with the payment provider to obtain the latest subscription details, then synchronizes this data with our user records in the database.
The reason behind this dual-action approach is practicality. Webhooks, though effective, can introduce a time lag, possibly leading to outdated or unsynchronized data. For instance, consider a scenario where a user completes a checkout process or decides to cancel their subscription. It's vital for the user experience to immediately reflect these changes. By using the subscription
query, our system ensures that it always displays the most recent subscription status, bridging any potential time gaps between the payment provider's notifications and our system's records.
import { assertUserId } from "../../../auth/assertUserId"
import { OperationContext } from "../../../gql/OperationContext"
import { QueryResolvers } from "../../../gql/schema"
import { updateUser } from "@increaser/db/user"
import { getSubscription } from "@increaser/paddle-classic/utils/getSubscription"
export const subscription: QueryResolvers<OperationContext>["subscription"] =
async (_, { input: { id: subscriptionId } }, context) => {
const userId = assertUserId(context)
const subscription = await getSubscription(subscriptionId)
await updateUser(userId, { subscription })
return subscription
}