-
Notifications
You must be signed in to change notification settings - Fork 2
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.
|
|
||
| #### 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.
|
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 |
| 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 |
|
|
||
| **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.
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.
Totally aligned with the proposed approach and implementation.
Strong opinion however on the way we should solve the business vs individual customer in the code using polymorphic entities. I suggest you take a look :)
| **Feature 2**: Update logic to handle both individual and business customers. | ||
| 2. **Update Event Model**. We should validate that the event model has a business_customer_id if the customer is on multiple businesses. | ||
| 3. **Update billing entities logic**: we should make sure that the person who is updating the subscription/order/etc. has permissions to do it. | ||
| 4. **Checkout Flow Updates**: if we are purchasing for a business, we should create 2 customers, one for the business, and another one for the 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.
Could we skip the individual customer creation and only create it if/when they claim their seat?
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.
The problem happens with "Workflow A: that is someone selling themes". In this case, they would only have a checkout link with seat based billing enabled.
In that case, we need to create 2 customers:
- Business customer: that holds everything
- At least a member (therefore a customer): that can manage the business cusotmer
| customer_id: UUID # Usage actor (always individual) | ||
| billing_customer_id: UUID # Billing payer (individual OR business) | ||
|
|
||
| # Relationships with explicit naming |
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.
Excellent callout!
Yopi
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.
Nice update Petru!
I think this makes sense.
Just to clarify, I think this is the case but I was confused by this earlier as well. We will now get an try per business and per user in the customers table. So we will end up with e.g.
- ACME (type business)
- ACME employee Alice (type individual)
- ACME employee Bob (type individual)
| 2. **Billing Transfer**: Allow transfer of subscriptions to another billing manager | ||
| 3. **Clear Attribution**: Easily know who is the billing customer of an event | ||
|
|
||
| ### Ideal Workflows |
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 clear, good idea to write this down!
| customer_id: UUID # Usage actor (always individual) | ||
| billing_customer_id: UUID # Billing payer (individual OR business) |
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.
How will this work on the ingestion API? Will you need to specify both?
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'm not in love with this naming. I'm also not sure if it solves the problem of proper attribution? E.g scenario:
- Customer A is a member of Business A with Subscription 1
- Customer A is also a member of Business B with Subscription 2
- Customer A is also a member and user within Business B for Subscription 3 (same product as Subscription 2)
We want to know which subscription to incur the usage charges for, right? By only specifying Customer A and Business B, we still don't know whether to attribute it to Subscription 2 or 3.
Therefore, I believe it's inevitable to require subscription_id and as a consequence whether that's sufficient here, e.g customer_id (required) and subscription_id from which we can also infer and even automatically decorate the business. Just to keep the number of IDs and reference passed to a minimum for improved DX combined with avoiding the mental challenge of distinguishing customer_id vs. billing_customer_id
Otherwise, I think calling it business_customer_id might be better. Keeping the naming and customer.type the same?
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 still have this dilemma, but I would say it's a minor thing. We have three options:
Option 1: Require only if the organization has seat-based billing enabled
This way we will require it for ALL event ingestions as customerId can be both an individual or business
- + Merchants who switch will get a warning that they need to modify the integration to send billing_customer_id
- ~ The API will have the same behaviour of failing for all customers if not specified
- - On B2C only customers will always be the same.
Option 2: require it only if customer is a member of an business
- + For B2C customers will work out of the box
- ~ Merchant needs to know if the customer is B2B and then send the business_id
Option 3: Polar tries to assing the correct billing_customer_id
- ~ On all B2C only customers this will work, on B2B should also work as customer are usually on on a single business. This will only fail in 2 cases:
- If the customer is a B2C and B2B
- If the customer is 2 B2B business.
Both cases should be rare. The problem is that merchant will get 4XX on those requests.
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.
@birkjernstrom I'm in favor of the subscription id, there is only 1 problem. What happens if the customer doesn't have any subscription?
Nowadays we can ingest events without any products attached to the customer.
| - **Simpler mental model**: Everything is a customer - individuals and businesses | ||
| - **Easier migration**: Just add `type` field and Member table, no entity splitting required | ||
|
|
||
| **Key Decision Rationale**: Option 2 scores lower (62/81 vs 66/81), we're prioritizing architectural simplicity over developer experience. Having a single Customer entity is conceptually simpler and avoids the complexity of splitting billing from usage across two entities. The developer experience trade-offs (type filtering) are manageable with proper tooling. We can migrate to BillingAccount if this became a need. |
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.
When we say developer experience here we're only referring to our own internally, I believe? Think that's important to align on and clarify.
Because the DX for our merchants should be 💯
|
Nice update Petru! I think we're closing in. I have three concerns remaining:
The latter point worries me the most. I think it would be helpful to see this fully from an integration perspective in terms of a documentation example, i.e what calls they would make, expected responses, what they would store and use in future calls. |
Fixes polarsource/polar#7712