How we made additional members possible (and why Studio is not “just a higher SKU”)
Dmytro Bondarchuk|August 17, 2026|14 min read|No commentsA technical post about splitting auth from membership, treating seats as a ledger, and teaching Polar about packs of humans. Timelish is now Hacado; this is the part that actually had to be built.
When we renamed Timelish to Hacado, the public story was the name. The engineering story was Studio: a salon with five stylists, not a freelancer with a prettier checkout.
That sounds like a billing change. It was not. A “team plan” that still stores “the user” on every appointment is a lie. We had to decide what a member is, what a seat is, and what happens when Polar says you have fewer of them than people currently logged in.
This is how we did it.
The old model: the person was the organization
Timelish grew up as a one-calendar product. A users document carried organizationId, role, profile fields, even calendar sources. Appointments, activity logs, connected apps, blog authors - they all pointed at userId.
That is fine until the second person shows up.
Then you hit every classic trap:
The bookable person and the login are the same row. Fire someone and you either delete history or leave a ghost login.
Profile is global. The same human cannot later belong to two studios with different names and hours.
Capacity is “how many user documents we created,” which is a terrible billing primitive.
Auth migrations (Better Auth, ObjectId vs string ids) fight business migrations because they share a collection.
Studio is not “unlock a users table.” Studio is “a workspace has N bookable people, paid for independently of who has a password.”
So the first decision was: stop using userId as the staff foreign key.
Decision 1: Member is the staff record, User is the login
We split the world in two.
User | Member | |
|---|---|---|
Lives in | Better Auth users | members (Better Auth org membership + our fields) |
Means | Identity: email, password, verification | “This person works here” |
Id | userId | memberId (members._id) |
Scoped to | The human | The organization |
Holds | Auth only | Role, display name, phone, bio, image, calendar sources, meeting-app choice, active/inactive |
The comment in the type is the whole design:
_idis thememberIdreferenced by appointments, services, apps, etc. Profile fields are org-scoped (a user may belong to multiple orgs later).
A member can be active or inactive. Inactive is not delete. Reasons are removed (someone clicked remove) or downgrade (billing took the seat back). Appointments keep memberId either way, so the calendar does not rewrite history.
The session is a join. Better Auth’s custom session carries both:
id/emailfrom the usermemberId,memberStatus,memberRolefrom the membershipavailableUsers,allowAdditionalUsers,subscriptionPlanTierfrom the org
Apps that used to receive “the current user” now get the actor and the org. memberId is what booking, gift cards, and notifications stamp on events.
The migration was the tax
You cannot add a members collection and call it Studio. Every place that meant “staff” had to move.
Rough order:
Create
membersfor every existing org user, unique(organizationId, userId). SeeduserSlotson the org (included: 1unless Polar said otherwise).Copy org-scoped profile off
usersontomembers, then strip those fields fromusersso auth stays auth.Rewrite FKs: appointments, history, activities (
actor: user→member), blog authors, connected apps (target: user→member).String vs ObjectId cleanup so Better Auth and the driver agree. (If you ever
instanceof ObjectIdacross two copies ofbson, you will skip rows and think the data was already converted. Use_bsontypeor$type: "objectId". We learned that the hard way.)
After that, “invite a teammate” is: create a membership, optionally a user, consume a slot. It is not “insert another god-document.”
Decision 2: Seats are a ledger, not users.length
Billing cannot be “count the members collection.” Polar is the source of money. Mongo is the source of capacity. We keep them in sync with a small ledger on the organization:
userSlots: { included: number; additional: number;}availableUsers: number; // included + additionalallowAdditionalUsers: boolean;userSlotGrants: Array<{ polarSubscriptionId: string; usersAmount: number; source: "plan" | "addon";}>;Included comes from the base plan product. Solo is 1. Studio’s Polar product metadata says users_amount: 5.
Additional is the sum of addon grants - extra packs you bought.
availableUsers is always recomputed from grants, not incremented ad hoc. Webhooks are retryable; a running counter is how you double-sell seats.
canInviteMoreMembers() is then boring, which is what you want:
activeMemberCount < availableUsers;If Polar is down, we still have the last computed capacity. If Mongo is wrong, Polar still charges. The webhook is the repair.
Decision 3: Extra seats are separate Polar products, not a quantity field
We could have put quantity on the Studio subscription (“5 included, set quantity to 8”). We did not.
Reasons:
Polar already models products and subscriptions. Packs of +1 / +3 / +5 seats are just more products. We did not want to invent a usage meter for humans the way we did for SMS.
Packs can discount. The purchase UI ranks offers by price-per-seat and shows savings vs the most expensive pack. That is merchandising, not a
quantity++.Canceling extra seats should not touch the Studio subscription. If seats were a line item on the same sub, a portal change gets scary. A second subscription can die on its own.
Free/Solo must not grow seats. Capacity is gated by product metadata
allow_additional_users. Studio’s product has it true. Checkout for packs checksorg.allowAdditionalUsersbefore creating a Polar session. The button is not enough; the server says no.
Convention on Polar products:
metadata.type | Meaning |
|---|---|
subscription | Free / Solo / Studio. Also carries users_amount and allow_additional_users. |
users_amount | Recurring seat pack. users_amount is how many extra humans this SKU adds. |
Checkout for packs sends:
metadata: { org: organizationId, kind: "user_seats" }externalCustomerId: organizationIdThe org id is the customer. Polar’s customer is type: "team" with externalId = organizationId and the owner nested underneath. Seats belong to the studio, not to whoever happened to click “Buy.”
Do not overwrite the plan subscription id
This was the easy bug.
When an addon subscription webhook arrives, it looks like a subscription. If you blindly $set polarSubscriptionId, Studio becomes “3 extra seats” and the real plan disappears.
persistPolarSubscriptionToOrganization only writes polarSubscriptionId / product / status when metadata.type === "subscription". Addon payloads only upsert a grant.
order.paid for users_amount products does the same grant upsert, then reconciles members. Polar can fire subscription and order events; both paths are idempotent on polarSubscriptionId.
Decision 4: When seats shrink, deactivate - don’t delete, don’t block the webhook
reconcileMembersToSlots runs after every grant change.
If
active.length > availableUsers: deactivate the newest non-owners first (createdAtdesc), reasondowngrade,force: true.If there is slack: reactivate the oldest members who were parked for
downgrade(FIFO). People you removed by hand stay removed.
Owner is never deactivated. The last seat is the person who pays.
force: true matters. Manual remove refuses if that member still has upcoming appointments. A billing downgrade cannot wait for the calendar to drain - Polar already stopped charging. We still keep the rows; they just cannot log in (invalidateUserSessions on deactivate). Upcoming appointments stay on that memberId so the owner can reassign.
When seats come back, we email the owner about reactivations. Sessions are invalidated whenever entitlements change (availableUsers, plan tier, allowAdditionalUsers) so the next request sees the new cap instead of a cached “you have 5.”
Decision 5: A member’s calendar is just their calendar apps
We kept saying “personal calendar” internally. That name is misleading. There is no second calendar type.
Google Calendar, Outlook, CalDAV, ICS, busy-events, Zoom - those installs are owned by a member. The connected-app row has target: "member" and memberId. Busy time for Anna’s bookable hours comes from Anna’s connected calendars, listed on members.calendarSources as { appId } pointers. Not from the user document. Not from a studio-wide Google account we pretend is hers.
What is a Studio-only switch is allowStaffCalendarSources. Coordinators and up can always attach calendar sources (schedule:manageCalendarSources). Staff-role members only can if that flag is on. canUseMemberCalendarSources is the same split when we actually query busy time: non-staff always, staff only when the org allows it.
So:
A seat is whether this human exists as an active member.
A member calendar is OAuth (or busy-events) installed on that member, then opted into
calendarSources.The Studio flag is only “may staff calendars affect the bookable grid?” You can have five seats and one shared company hours template. You can let staff connect Google without buying a sixth seat. Billing does not smuggle this into
users_amount.
SMS is the other example: Polar meters credits; seats are grants. Humans are not a meter. You do not ingest “member-minutes.” You sell packs.
App ownership: company vs member
Once members exist, “who owns this install?” has to be a catalog fact, not a guess from userId on the document.
Every app declares target:
target | Uniqueness | Who it is for |
|---|---|---|
company | One install per org ( dontAllowMultiple looks at any row) | Waitlist, payments, weekly schedule, blog, Stripe, SMTP |
member | One install per member (same app name on another memberId is allowed) | Google Calendar, Outlook, CalDAV, busy-events, Zoom, member SMS |
That is install ownership. It is independent of scope usage. Outlook is the mixed case: target: "member" because OAuth is per person, but it also offers mail-send, which is a company-usage scope. Staff connecting Outlook do not get to become the org mail sender. filterInstallDefaultScopesForUser drops company-usage scopes unless the actor can install company apps.
Connected-app documents used to be “probably the owner’s.” The migration:
Tagged installs: calendar apps →
target: "user", everything else →company.For user-target rows, resolved
memberIdfrom(organizationId, userId).Renamed leftover
target: "user"to"member"when we stopped calling staff “users.”
Access follows the same split (canAccessConnectedApp):
Company apps: anyone who may use apps.
Member apps: you see your own; owner/admin can see others except they cannot open the organization owner’s personal apps. That is deliberate. The owner’s Google is not a shared inbox.
Uninstall is stricter than use: staff can run waitlist without being able to delete it (useCompany required to uninstall a company app).
The installed-apps UI is two lists: company, and “mine” (memberId === session.memberId). The store’s “already installed” quota uses the same rule, so Anna connecting Google does not grey out the button for Ben.
Busy-events stayed member-targeted. We stamped missing memberId with the org owner so old blocks still attach to a person, then unique-indexed (organizationId, appId, memberId, week).
Without this, Studio would have been five logins sharing one Google token.
Weekly schedule: one company app, layered exceptions
Hours were the other “one person” leftover. The old weekly-schedule app stored a dense week: every weekday, every shift, one document per week in weekly-schedules. Empty day meant “closed for the whole studio.” There was no way for Anna to work Tuesday evening if the template said closed, except by rewriting the studio week.
We did not make weekly schedule a member-targeted app. There is still one install (target: "company", dontAllowMultiple: true). What changed is the data: sparse exceptions in weekly-schedule-exceptions, each with scope: "company" | "member" and optional memberId.
Resolve order for a given member and date:
Org default hours (settings).
Company exceptions - sparse day overrides.
Company holidays - hard close. A member exception cannot reopen a holiday. The test is literally
company holiday beats member open hours.Member exceptions - that staff member’s overrides. Member hours do beat company open hours. Empty company hours are not holidays; a member may still work that day.
Core ScheduleService never sees those layers. The app resolves, then returns a day schedule. Core only merges “what the schedule app said” onto the org default.
Storage is sparse: we persist only days that differ from the parent layer. Repeat is one document (repeatEveryWeeks, repeatUntil, excludeWeeks). A one-off week beats a series. Newer series win on overlap.
The migration from dense weeks:
Empty day →
holidays(old meaning: cleared = closed for everyone).Non-empty day →
days[weekDay] = shifts.Every converted row is
scope: "company"with nomemberId.Old collection renamed
_to_remove_weekly-schedules.
The UI is a scope selector: Company (all members) vs a staff member. Same app, same week picker, different exception slice. Company holidays are edited only in company scope (set-company-holidays). Member scope cannot punch through a studio closure.
That is the schedule analogue of app targets: one product, two ownership layers, so a floor can have studio hours and still let one stylist stay late.
What the request path looks like
Invite / purchase, compressed:
UI knows
allowAdditionalUsersandactive >= available(session + org).Pack checkout: list Polar products with
metadata.type = users_amount, ensure team customer, Polar Checkout, return to/dashboard/settings/team?seats_purchased=true.Webhook: identify org from
metadata.orgorcustomer.externalId.Branch on product type → set included slots or upsert addon grant.
recomputeAvailableUsers().reconcileMembersToSlots().Invalidate org sessions (and hostname cache if the plan also dropped custom domain, which Free does).
None of that lives in the page component. The page just counts. Polar is allowed to be late; reconcile is allowed to run twice.
Tradeoffs I would still defend
Metadata instead of a first-class Polar “seats” API. We own the schema (type, users_amount, allow_additional_users). Polar stays a catalog + checkout + webhooks. The cost is discipline: every new product in the dashboard must be tagged or it silently does not grant seats.
Separate subscriptions for packs. More objects in Polar’s portal. Clearer cancel. Stacking two packs is just two grants.
Deactivate on downgrade instead of hard-blocking Polar. Money already moved. We park people and keep memberId stable. The messy case is “this stylist is fully booked next week and you just dropped a seat.” Forced deactivate plus upcoming appointments on an inactive member is the honest state. Reassign is an ops problem, not a webhook timeout.
Weekly schedule stays a company install. Member hours are exception rows, not five copies of the app. Holidays stay a studio-level lock. The cost is a resolver that has to know scope instead of “load this user’s week document.”
Member ids as strings in app code. Better Auth and a pile of historical documents disagree about ObjectId. After the conversion migrations, _id on members is a string hex. New code treats it as a string. instanceof ObjectId is banned in this neighborhood.
What Studio actually is, in one sentence
Studio is a Polar product that sets users_amount to 5 and allow_additional_users to true, on top of a data model where the bookable person is a member, capacity is a grant ledger, extra humans are their own recurring products, calendar OAuth belongs to that member, and hours are company exceptions with optional member overlays.
The rename to Hacado did not require that. Five people on one floor did.
If you are about to bolt “team” onto a single-user SaaS: split identity from membership first, then invent a seat number Polar can change without rewriting your appointments collection. The checkout button is the last 5%.
Come say the new name
Start at hacado.com. If you already use Timelish, you are already on Hacado - same bookings, same clients. Switch to Solo or Studio whenever you’re ready.
About Hacado
Hacado (formerly Timelish) helps people and small businesses who sell their time. You get a booking website that looks like you, a calendar that stays up to date, payments, reminders, and - when you need it - a way for a whole team to share the books.
Comments
No comments yet. Be the first to comment.