-
Notifications
You must be signed in to change notification settings - Fork 1
feat: first draft of business entity #14
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Conversation
|
Great write-up, thank you! The problem statement & tenets are a good definition of what we're trying to fix. I have been thinking about this myself and I believe I figured out a more backwards-compatible option. The impact throughout the app will be most limited if we can consider ACME & Lolo the Up until we introduced seat-based billing, Polar was designed with a implicit 1:1 relationship between the customer (i.e. the entity who pays for a transaction) and the benefit recipient (i.e. the entity who gets granted the benefits from said transaction). With seat-based billing, this relationship is no longer 1:1 but 1:many, causing the issues you aptly pointed out. I'm wondering whether we can flip the script on option #6. With that, I mean instead of adding the business umbrella layer "above" multiple customers, keep the customers as-is and add a member layer "below" a single customer. This way, we can design this This way, everything is still tracked under the If a product/subscription is not seat-based, the We could then either make this a one-to-one relationship (a member can belong to only one customer), or we immediately go all the way and introduce a many-to-many table that could also hold metadata (like authorization / role / …) on the relationship between the member and the customer. |
frankie567
left a comment
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Very nice write-up!
If I were to choose an option, I would go for Option 1, even if it has the lowest score. It feels like the most natural one while allowing for maximum flexibility. I'm not a big fan of Option 6 with synthetic business, since it'll add lot of burden for a vast majority of users who don't care about those features...
...which brings me my main point 😄 Do we really want to go that far now? Yes, it was asked by one big potential customer. But as I mention in one of my comment, I'm not sure multi-tenants with single authentication layer will be super common. Who does that in the industry today, apart behemoth like Slack?
My point is that in most SaaS businesses, you have one organization with its set of customers. If you as an individual are part of several organizations, you'll likely have several accounts with different emails and logout/login.
All in all, I'm not sure it's worth the hassle.
| customer_id: UUID | ||
|
|
||
| # NEW - for business-owned subscriptions | ||
| business_id: UUID | None |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
What happens if they omit the business_id? Should we reject the event or invoice it "best effort" like we do today?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
If the customer has a seat based product with usage-based billing, I would reject it.
|
|
||
| #### Tenets | ||
| 1. ✅ Billing accuracy: events are attributed to a single customer | ||
| 2. ❌ Backward compatibility: customers that enabled seat-based billing will receive null customerIds in the API responses for some entities. |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Could we detail how seat-based billing will look like in that context? In particular CustomerSeat?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
CustomerSeats will work as it is now. BusinessCustomer it's only used to keep track of billing managers and other roles that we will have. Maybe BusinessManager is a better name.
| #### Tenets | ||
| 1. ✅ Billing accuracy: events are attributed to a single customer | ||
| 2. ❌ Backward compatibility: customers that enabled seat-based billing will receive null customerIds in the API responses for some entities. | ||
| 3. ✅ Customer experience: individual customers and business customers can have a tailored experience based on their needs. |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
We'll have an issue to tackle then with the customer portal. Since a customer with an email can belong to different teams (businesses) with different subscriptions, we'll need to know which team they want to log into. Meaning:
- From the API: require the merchant to pass
business_idto generate the portal link - From a customer-flow: have a team selector after successful email authentication
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Or we can display all data across all businesses, similar to when a user has multiple subscriptions.
| #### Tenets | ||
| Same as option 1, but with: | ||
| 1. ✅ Billing accuracy: events are attributed to a single customer | ||
| 2. **🟡 Backward compatibility**: we will always return the customerId in the response, but now it can be a businessId or a customer itself. But business ids will not be available under `/v1/customers/{customerId}`. This will affect only orgs that enabled seat-based pricing. |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Another problem too: existing customer ID will change (because they'll now be BillingCustomer ID); unless we do trickery during migration.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I was thinking of doing trickery with migrations and having id == customerId 🙈
| 4. 🟡 Merchant Developer experience: merchant will treat all the customers the same way. But a first migration is needed. | ||
| 5. 🟡 Operational Flexibility: this maps to WorkOS and allows all features requested by our customers. | ||
| 6. ❌ Performance: one extra joined load on every customer query | ||
| 7. **✅ Polar developer experience:** we need to be aware of the branching and boundaries of what is a Customer is less defined. |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
That's a 🟡, if we want to be consistent
|
Hey @pieterbeulque, Thanks for the new suggestion. Before adding it, I have a couple of doubts.
I think the semantics looks like this:
And here is the table with tenets.
Let me know if I misunderstood something. |
|
Thanks Petru! I realize my initial explanation wasn't clear enough - the terminology is definitely confusing. Let me try again with clearer definitions. I'm proposing to keep the current So, not: but: Basically, the core of my proposal is to consider ACME/Lolo as first-class customers in our system so that we can preserve our entire billing infrastructure and while solving seat-based as an isolated feature, instead of having this change become a major refactor. My proposal simply formalizes that ACME is the customer, not a new entity type above customers. This is both semantically accurate (ACME is literally our customer) and architecturally simpler. Just making sure that we're entirely clear on that distinction. To address your other questions:
Why I think this approach deserves consideration: the billing entity (Customer) remains unchanged, so all payment, invoice, subscription logic stays intact, making seat-based an opt-in feature layer, not a fundamental restructure. No need to retrofit all existing customers into a Business model, and to me it's semantically clear: ACME is our customer. Their employees are members/users of ACME's subscription, not direct customers of Slack. |
|
This is really well formulated and thought out Petru! I am a bit inclined to either Option 1 or what Pieter is suggesting. In an ideal world this change becomes negligible or a no-op for customers who are B2C today, and don't really care about seats or selling to businesses. Whereas for the B2B customers it doesn't add too much complexity. Both of these might fall under the "Customer experience"-tenet. This is what I like about option 1, in that it's only for the business-type customers (events) that you need to specify it, and all else just continues to work as it is. For your suggestion Pieter are you thinking the same as in Option 1 when it comes to specifying the individual who is tracked by an event (or a meter if we would want to have per customer+member based limits in the future)? Sidenote: |
|
@Yopi — Yes, regarding event tracking, in my proposal it would work out of the box to ingest events for customers (i.e. the ACME) with the current (Not to go into the details that that should then probably be |
|
|
||
| #### Tenets | ||
| 1. ✅ Billing accuracy: events are attributed to a single customer | ||
| 2. 🟡 Backward compatibility: customers that enabled seat-based billing will receive null customerIds in the API responses for some entities. |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Why is that? Wouldn't a business always at least have one customer tied to it (the one who set up the subscription/order)?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
With this particular proposal not anymore. Here all billing entities, Order, Subscription, PaymentMehtod, Refund, etc. will have a customerId OR businessId set.
This is because we want billing entities attached to business, so when a member changes we don't need to do any transfer of data.
| 1. ✅ Billing accuracy: events are attributed to a single customer | ||
| 2. 🟡 Backward compatibility: customers that enabled seat-based billing will receive null customerIds in the API responses for some entities. | ||
| 3. ✅ Customer experience: individual customers and business customers can have a tailored experience based on their needs. | ||
| 4. ❌ Merchant Developer experience: the merchant will need to do branching depending if the entities and customer is a business or an individual customer. |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
For organizations with this model, I think the branching is actually helpful. E.g Better Auth, WorkOS and others have the business / customer model (although different names) and carries such IDs in their sessions. So passing them forward 1:1 for our named properties in the API feels easier vs. other potential solutions.
I worry more around the notion of moving orders and subscriptions to businesses. Since that would be a breaking change, e.g someone who starts to sell a seat-based product from previously only having standard SaaS tiers could now receive webhooks for orders/subscriptions with a potential null for customer_id?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
yep, if merchant enable seat based billing they will receive null on customer_id and a value on business_id.
There is proposal 6 where this doesn't happen
|
Fantastic write-up Petru! At first, I was in favour of Option 1 – provided timeline/scope would not be too brutal (see prioritization below). Since it maps 1:1 to how such models are reflected elsewhere, e.g Better Auth or WorkOS (different terms) etc. Combined with optionally & incrementally introducing complexity as needed, i.e However, I'm against that it branches core resources like
Overall, I started second guessing having multiple explicit entities on our end. For Auth providers or multi-tenant applications, it makes total sense given more distinct requirements for expanded data models surrounding them and ancillary tables with relationships to them each respectively. However, for us, they're both quite slim and arguably desirably so for a SaaS integration to simply map internal IDs to billing resources on our end. Additionally, it adds unnecessary depth of filtration or ingestion. Since an organization can have multiple subscriptions we would have three parameters to consider in an ingestion:
Combined with terminology and settings on our end, e.g "Allow multiple subscriptions per customer". Could easily become a setting both for "business" and "customers". In short: By introducing 2 entities, we risk multiplying everything over time. So I started seeing more value in an approach like Option 5 focused on retaining one entity. But I have an alternative proposal for it and inspired by Pieter's proposal although it's flattened. Nested CustomersInspired by events and our introduction of class Member(RecordModel):
customer_id: UUID
role: MemberRole
class Customer(RecordModel):
# existing attributes
members: list[CustomerMember]
type: 'individual' | 'business' # potentially not neededPros:
Cons:
Re: Prioritization |
| 3. ✅ **Customer experience**: Clear separation - Customer = payer, Member = user | ||
| 4. 🟡 **Merchant Developer experience**: | ||
| - ✅ Non-seat-based: No changes | ||
| - ❌ Seat-based: Need to track customer_id as team or individuals |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
What do you mean with this?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Let me rephrase this, on the Customers api the merchant should be aware if the customer is a "business" or an individual member
|
|
||
| **Key Points**: | ||
| 1. **CustomerSeat is NOT replaced** - Member is an additive layer that extends CustomerSeat with role management | ||
| 2. **Every Member must link to individual Customer** - Required for authentication and benefits |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I 100% understand where you're coming from on this thanks to your previous comments, but I still have to warm up to the idea of circling back the members into the customers table.
I've been doing a lot of metrics & grouping and segmenting things per customer lately for cost analytics, so I may be over-indexing on the importance of the split between paying vs product-using entities.
It'd be great for cost analytics knowing that if you query by customer_id it's always ACME & Lolo without having to filter out the Alice's for each query. Especially since you don't know on the customer itself if it's a paying entity or a member entity, you'd have to do a double join to be able to filter these out?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
if that's the problem, I think we can add a "type" that is either: individual, business, or member.
We may still have problems with individuals that are members also 🤔
Something like this should be exposed in the API so merchants can decide it on the fly, but at the beggining I was thinking of computing it but maybe it's worth having it stored in the DB.
| customer_id: UUID # Who pays (unchanged) | ||
|
|
||
| # NEW - explicit user attribution for seat-based scenarios | ||
| member_id: UUID | None # Who used (optional) |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
This is the main thing I don't understand with this approach. Currently, we push our users to use external_customer_id, which will likely be the customer who triggers the event, not the one who is paying (they don't care about that).
So it looks to me we're flipping the script a bit + require them to track an opaque Polar-generated ID: member_id, which is not ideal in terms of DX.
Could we consider something like this instead:
(external_)customer_id: UUID # Who used
# NEW - explicit billing attribution for seat-based scenarios
(external_)billing_customer_id: UUID | None # Who pays
Fixes polarsource/polar#7712