How we made additional members possible (and why Studio is not “just a higher SKU”)

Dmytro Bondarchuk|August 17, 2026|14 min read|No comments

A 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:

_id is the memberId referenced 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 / email from the user

  • memberId, memberStatus, memberRole from the membership

  • availableUsers, allowAdditionalUsers, subscriptionPlanTier from 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:

  1. Create members for every existing org user, unique (organizationId, userId). Seed userSlots on the org (included: 1 unless Polar said otherwise).

  2. Copy org-scoped profile off users onto members, then strip those fields from users so auth stays auth.

  3. Rewrite FKs: appointments, history, activities (actor: usermember), blog authors, connected apps (target: usermember).

  4. String vs ObjectId cleanup so Better Auth and the driver agree. (If you ever instanceof ObjectId across two copies of bson, you will skip rows and think the data was already converted. Use _bsontype or $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 + additional
allowAdditionalUsers: boolean;
userSlotGrants: Array<{
polarSubscriptionId: string;
usersAmount: number;
source: "plan" | "addon";
}>;
TypeScript

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;
ts

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:

  1. 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.

  2. 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++.

  3. 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.

  4. Free/Solo must not grow seats. Capacity is gated by product metadata allow_additional_users. Studio’s product has it true. Checkout for packs checks org.allowAdditionalUsers before 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: organizationId
TypeScript

The 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 (createdAt desc), reason downgrade, 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:

  1. Tagged installs: calendar apps → target: "user", everything else → company.

  2. For user-target rows, resolved memberId from (organizationId, userId).

  3. 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:

  1. Org default hours (settings).

  2. Company exceptions - sparse day overrides.

  3. Company holidays - hard close. A member exception cannot reopen a holiday. The test is literally company holiday beats member open hours.

  4. 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 no memberId.

  • 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:

  1. UI knows allowAdditionalUsers and active >= available (session + org).

  2. Pack checkout: list Polar products with metadata.type = users_amount, ensure team customer, Polar Checkout, return to /dashboard/settings/team?seats_purchased=true.

  3. Webhook: identify org from metadata.org or customer.externalId.

  4. Branch on product type → set included slots or upsert addon grant.

  5. recomputeAvailableUsers().

  6. reconcileMembersToSlots().

  7. 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.

bloghacadopolarsubscriptionmulti-user

Comments

No comments yet. Be the first to comment.

Add comment

Contact me

Email

dmytro@bondarchuk.me
© 2026 Dmytro BondarchukCreated usingHacado