Modelling Recurring Bookings: Subscription State vs Appointment State
How to model a membership booking flow so pause, skip and reschedule do not corrupt your appointment data
Most booking flows are modelled as a single table of appointments, and that model holds up right until somebody buys a plan instead of a slot. The moment a customer can pause, skip or reschedule a recurring visit, one table starts encoding two different lifecycles, and the bugs that follow are the confusing kind: a cancelled visit that keeps billing, a paused plan that keeps generating slots, a reschedule that silently deletes a paid-for month.
This is a modelling problem, not a UI problem. Here is the shape that works, the state machine underneath it, and the schema that makes the tiers readable to search engines and AI assistants.
Why one table breaks
The naive model looks reasonable. An appointment row carries a customer, a service, a datetime and a status, and recurrence gets bolted on as recurring: true plus an interval.
The trouble is that "cancelled" now means two incompatible things. Cancelling one Tuesday in November is not the same event as cancelling the commercial relationship, but a single status column cannot tell you which happened. Neither can your reporting, which is how a shop ends up with a churn number that does not reconcile against revenue.
The same collision hits every other transition. Pausing a plan should stop future occurrences without touching the two visits already scheduled. Rescheduling one visit should not extend the billing period. Skipping a month may or may not credit the customer, and that is a business rule that has to live somewhere explicit.
Two entities, two lifecycles
Split it. A subscription is a billing relationship. An appointment is a scheduled occurrence that belongs to one. They reference each other and they change state independently.
type SubscriptionStatus =
| 'active'
| 'paused'
| 'past_due'
| 'cancelled' // no future occurrences, history retained
| 'pending' // checkout started, payment not confirmed
type Tier = 'small' | 'midsize' | 'fullsize' | 'fleet'
interface Subscription {
id: string
customerId: string
tier: Tier
vehicleIds: string[] // fleet accounts hold many
intervalDays: number // 30, 60, 90
status: SubscriptionStatus
currentPeriodEnd: string // ISO, drives billing not scheduling
pausedUntil?: string
skipCreditCount: number // skipped visits owed back
}
type AppointmentStatus =
| 'scheduled'
| 'confirmed'
| 'completed'
| 'skipped' // member moved it, plan unaffected
| 'no_show'
| 'voided' // parent subscription cancelled
interface Appointment {
id: string
subscriptionId?: string // absent for one-off retail bookings
customerId: string
vehicleId: string
startsAt: string
durationMinutes: number
status: AppointmentStatus
rescheduledFrom?: string // preserves the original slot
}
Two details in there matter more than they look. subscriptionId is optional, which is what lets member visits and walk-in retail bookings share one calendar rather than two systems fighting over the same bay. And rescheduledFrom keeps the original slot as data instead of overwriting it, so you can answer "how often do members move their visit" later without having destroyed the evidence.
The state table
This is the transition matrix worth agreeing on before any UI gets built. Every row here is a bug we have seen shipped as an undefined case.
| Event | Subscription becomes | Existing scheduled appointments | Billing effect |
|---|---|---|---|
| Checkout completes | pending to active |
First occurrence created | Period starts |
| Member skips one visit | unchanged (active) |
That row to skipped, skipCreditCount + 1 |
None |
| Member reschedules one visit | unchanged (active) |
New row, old row keeps rescheduledFrom |
None |
| Member pauses | paused, pausedUntil set |
Kept as scheduled, none generated after | Paused at period end |
| Payment fails | past_due |
Kept, flagged | Retry window |
| Dunning exhausted | cancelled |
Future rows to voided |
Stops |
| Member cancels | cancelled at period end |
Rows inside the paid period stay | Stops at period end |
| Shop cancels a visit | unchanged (active) |
That row to skipped, credit owed |
None |
The row that gets skipped in almost every first implementation is the last one. When the shop cancels, the member is owed something, and if that is not modelled the credit gets handled by a phone call and a discount code, which is fine at three members and unusable at forty.
Generating occurrences: materialise or compute?
Two options. Compute occurrences on the fly from intervalDays, or materialise appointment rows ahead of time.
Computing is tempting because it keeps the data small, but it fails on the first exception. A member who moves one visit two weeks later has permanently broken the arithmetic, so every read now needs an override table, which is just materialised rows with extra steps.
Materialise, but only a short horizon. We generate the next two occurrences per active subscription and top up on completion. That keeps the calendar honest, keeps capacity planning real, and keeps the cost of a cancellation bounded to voiding two rows rather than two years of them.
Run the top-up as an idempotent job keyed on (subscriptionId, startsAt). Retries are guaranteed in this kind of system, and a duplicate booking is a worse failure than a missing one.
What the UI has to expose
Every subscription state needs a visible surface, or support absorbs it.
Pause, skip and reschedule all have to be self-serve. This is a retention feature rather than a convenience: industry subscription data from Recurly's 2026 report, across 2,200 businesses and 76 million subscribers, puts churn reduction from annual commitment at 51 percent, and the mechanism behind numbers like that is friction removal, not loyalty. If moving one visit requires a conversation, some share of members cancel instead of asking.
Source: Recurly, State of Subscriptions 2026
past_due deserves a real state in the interface too. A silent failed payment reads as a cancelled service to the customer and as active revenue to the shop, and the two only reconcile when someone arrives to an unbooked bay.
We build Framer sites and booking systems for automotive shops, so this comes up constantly with detailers moving from one-off jobs to maintenance plans. The build always ends up spending more time on these four states than on the checkout itself.
Making the tiers machine readable
The pricing page for a plan is usually three columns of prose, which is invisible to anything parsing structured data. Emit the tiers as offers with a recurring price specification instead.
{
"@context": "https://schema.org",
"@type": "Service",
"serviceType": "Auto detailing maintenance membership",
"provider": { "@type": "AutoDetailing", "name": "Example Detailing" },
"areaServed": { "@type": "City", "name": "Houston" },
"offers": [
{
"@type": "Offer",
"name": "Mid-size Monthly Maintenance",
"priceSpecification": {
"@type": "UnitPriceSpecification",
"price": "125.00",
"priceCurrency": "USD",
"billingIncrement": 1,
"unitCode": "MON",
"billingDuration": 1
},
"eligibleCustomerType": "https://schema.org/Consumer"
}
]
}
UnitPriceSpecification with unitCode: "MON" is the part that communicates "per month" rather than "one hundred and twenty five dollars, once". Without it a crawler reads a plan as a one-time product, which is exactly the wrong summary for an assistant to hand a user asking what a monthly plan costs.
Published market tiers sit around 100, 125 and 150 dollars by vehicle size for US consumer plans, which is a useful sanity check when modelling test data.
Source: Car Detox membership plans
Implementation order
If you are building this from nothing, this sequence avoids the most rework:
Model
SubscriptionandAppointmentas separate entities from the first commit. Retrofitting the split after launch means migrating live billing data, which is the one migration nobody wants.Write the state table for your own business rules before the UI. Fill in every cell, including the awkward ones about credits.
Build the recurring checkout and the
pendingtoactivetransition. Nothing else can be tested until a subscription can exist.Materialise a two-occurrence horizon with an idempotent top-up job.
Ship pause, skip and reschedule in the same release as the checkout. Launching without them means every exception arrives as a support message.
Add the offer schema last, once prices are stable.
The interesting engineering here is not the payment integration. It is accepting that a booking and a billing relationship are two objects with two lifecycles, and that every ambiguity you leave between them eventually shows up as a customer who was charged for a visit that no longer exists.
Written by the team at Xenon Builds. We build Framer sites and booking systems for automotive shops across the US, which means we spend a lot of time on conversion, page speed and form UX for a very specific kind of small business.

