5
11
Fork
You've already forked tomato
1

Sign up page for new organizations #46

Merged
edwardloveall merged 1 commit from el-onboarding into main 2025年11月17日 16:52:25 +01:00

In 2026, Rootable wants to start on-boarding paying customers. As an under resourced staff, we don't want our capacity to limit how many organizations we can onboard. We want folks to be able to sign up for a 30-day trial or sign up to use the service.

We decided that we wouldn't tackle payments in this change. Programatically handling payments adds a ton of complexity. We don't have lots of users, and the users we expect to sign up are whole orgs, not individual users, so the volume is unlikely to be super high initially.

Tech details

 ┌────────────┐ ┌──────────────┐
 │ trial ├─┬─►│pricing tier 1│
 ┌─►│subscription│ │ └──────────────┘
┌────────────┐ │ └────────────┘ │
│organization├──┤ ┌────────────┐ │
└────────────┘ │ │ paid ├─┘
 └─►│subscription│
 └────────────┘

Organizations can now have a pricing tier via a subscription. The subscription has an expiration date and is marked as trial or paid. It points to one of the existing pricing tiers. Pricing tiers can be selectable meaning they show up on the sign up form (or are hidden when selectable: false).

Some ideas behind this model are:

  • We can change pricing tiers overtime without invalidating subscriptions. Adding new tiers and hiding previous ones doesn't interrupt usage.
  • trial or paid allows us to show a "x days remaining on trial" banner 30 days out. Since we offer monthly and yearly subscriptions, we would have no way to differentiate between a paid, monthly subscription and a trial. trial vs paid lets us make that distinction.
  • Checking if an org has a valid subscription can be done by seeing if an org have any subscriptions with an expires_at in the future.
  • Checking to see if their trial expires soon can be done by seeing if an org's most-future, expiring subscription has is less than 30 days out.

Actually signing up

The sign up form is found at /sign_up. We don't link to it anywhere, yet. We'll just be passing it out manually.

When organizations sign up, they also choose an email/pass. That's used to create a user that is an organizational admin of the newly created org.

Finally, it sends us an email when someone signs up.

Expiring subscriptions

When an organizations trial or paid subscription runs out, any page they try to visit sends them to a bare page telling them they need to reach out to us to re-subscribe.

Same thing for any volunteers of that org. However, if any of the volunteer's orgs have an expired subscription they will be redirected. Ideally we'd just redirect them if they tried to access data from an organization with the expired subscription and let them access other data as usual, but that's out of scope for now. We also don't expect any volunteers to be part of multiple orgs at the moment.

The way we block people is we've nested all the admin controllers under OrganizationScopeController which checks the organization's subscription and redirects if it's expired. The plain OrganizationsController is also nested here, but it gets the current organization via the id param instead of organization_id. This difference hints to me that the controller should actually be only accessible to operations users and the current show action should be something like OrganizationDashboardController. Out of scope for now.

Default values

One thing I learned while making this is that default parameters need to use a proc if they are dynamic. I ran into this with subscriptions.expires_at because we want it to default to 30.days.from_now.

Default values can be static or dynamic. It's not well documented, but if you use a proc the value is recalculated every time a model is built (if no other value exists).

This is critical when using a date because otherwise whatever value is set when the class is loaded will be used. So the date will be frozen at load time, not run time. So for a default value of a date, that date will remain static even if it's set to something dynamic like 30.days.from_now. Over time, this date will get closer and closer and even move into the past if left long enough.

You can test this by creating new instances of the model in the rails console with the proc and with a static value.

In 2026, Rootable wants to start on-boarding paying customers. As an under resourced staff, we don't want our capacity to limit how many organizations we can onboard. We want folks to be able to sign up for a 30-day trial or sign up to use the service. We decided that we wouldn't tackle payments in this change. Programatically handling payments adds a ton of complexity. We don't have lots of users, and the users we expect to sign up are whole orgs, not individual users, so the volume is unlikely to be super high initially. ## Tech details ``` ┌────────────┐ ┌──────────────┐ │ trial ├─┬─►│pricing tier 1│ ┌─►│subscription│ │ └──────────────┘ ┌────────────┐ │ └────────────┘ │ │organization├──┤ ┌────────────┐ │ └────────────┘ │ │ paid ├─┘ └─►│subscription│ └────────────┘ ``` Organizations can now have a pricing tier via a subscription. The subscription has an expiration date and is marked as `trial` or `paid`. It points to one of the existing pricing tiers. Pricing tiers can be `selectable` meaning they show up on the sign up form (or are hidden when `selectable: false`). Some ideas behind this model are: * We can change pricing tiers overtime without invalidating subscriptions. Adding new tiers and hiding previous ones doesn't interrupt usage. * `trial` or `paid` allows us to show a "x days remaining on trial" banner 30 days out. Since we offer monthly and yearly subscriptions, we would have no way to differentiate between a paid, monthly subscription and a trial. `trial` vs `paid` lets us make that distinction. * Checking if an org has a valid subscription can be done by seeing if an org have any subscriptions with an `expires_at` in the future. * Checking to see if their trial expires soon can be done by seeing if an org's most-future, expiring subscription has is less than 30 days out. ## Actually signing up The sign up form is found at `/sign_up`. We don't link to it anywhere, yet. We'll just be passing it out manually. When organizations sign up, they also choose an email/pass. That's used to create a user that is an organizational admin of the newly created org. Finally, it sends us an email when someone signs up. ## Expiring subscriptions When an organizations trial or paid subscription runs out, any page they try to visit sends them to a bare page telling them they need to reach out to us to re-subscribe. Same thing for any volunteers of that org. However, if _any_ of the volunteer's orgs have an expired subscription they will be redirected. Ideally we'd just redirect them if they tried to access data from an organization with the expired subscription and let them access other data as usual, but that's out of scope for now. We also don't expect any volunteers to be part of multiple orgs at the moment. The way we block people is we've nested all the admin controllers under `OrganizationScopeController` which checks the organization's subscription and redirects if it's expired. The plain `OrganizationsController` is also nested here, but it gets the current organization via the `id` param instead of `organization_id`. This difference hints to me that the controller should actually be only accessible to operations users and the current `show` action should be something like `OrganizationDashboardController`. Out of scope for now. ## Default values One thing I learned while making this is that default parameters need to use a proc if they are dynamic. I ran into this with `subscriptions.expires_at` because we want it to default to `30.days.from_now`. Default values can be static or dynamic. It's not well documented, but if you use a proc the value is recalculated every time a model is built (if no other value exists). This is critical when using a date because otherwise whatever value is set when the class is loaded will be used. So the date will be frozen at load time, not run time. So for a default value of a date, that date will remain static even if it's set to something dynamic like 30.days.from_now. Over time, this date will get closer and closer and even move into the past if left long enough. You can test this by creating new instances of the model in the rails console with the proc and with a static value.
edwardloveall force-pushed el-onboarding from 1969dd2efc
Some checks failed
Setup Successful
Jest Successful
Static Analysis Successful
RSpec Failed
to 3c9d61f5e1
Some checks failed
Setup Successful
Jest Successful
Static Analysis Successful
RSpec Failed
2025年11月12日 22:19:44 +01:00
Compare
thetizzo left a comment
Copy link

This looks great overall!

I like the flexibility you get with this data model, I think it's the right choice.

I left some comments and questions on a few things.

This looks great overall! I like the flexibility you get with this data model, I think it's the right choice. I left some comments and questions on a few things.
@ -0,0 +16,4 @@
ApplicationMailer
.with(organization: @organization)
.organization_onboarded
.deliver_now
First-time contributor
Copy link

I'm curious if this email should be sent async? If this fails for whatever reason then it seems like the user will be prevented from logging in?

I'm curious if this email should be sent async? If this fails for whatever reason then it seems like the user will be prevented from logging in?
Author
Owner
Copy link

Ideally, yes! However we don't have an async anything right now (as far as I can see) so we can't actually use that.

Ideally, yes! However we don't have an async anything right now (as far as I can see) so we can't actually use that.
@ -0,0 +5,4 @@
before_action :ensure_valid_subscription
def set_organization
@organization = Organization
First-time contributor
Copy link

This might be a candidate for something like Current Attributes

This might be a candidate for something like [Current Attributes](https://api.rubyonrails.org/classes/ActiveSupport/CurrentAttributes.html)
Author
Owner
Copy link

Oh cool, TIL. I'm also out of time on this PR unless there's something totally broken, unfortunately 😕, and there are a bunch of places that refer to @organization throughout that would have to change. I put it on my refactor list for investment time, though.

Oh cool, TIL. I'm also out of time on this PR unless there's something totally broken, unfortunately 😕, and there are a bunch of places that refer to `@organization` throughout that would have to change. I put it on my refactor list for investment time, though.
@ -0,0 +11,4 @@
end
def ensure_valid_subscription
if !@organization.active_subscription?
First-time contributor
Copy link

You might want to also check for @organization presence in this if as well. If for some reason set_organization doesn't find an org then this will throw a no method on nil error instead of redirecting but you probably just want to redirect in that case? Or maybe redirect somewhere else?

You might want to also check for `@organization` presence in this `if` as well. If for some reason `set_organization` doesn't find an org then this will throw a no method on nil error instead of redirecting but you probably just want to redirect in that case? Or maybe redirect somewhere else?
@ -25,0 +25,4 @@
@organization = Organization.new(
address: Address.new,
subscriptions: [Subscription.new]
)
First-time contributor
Copy link

If an organization is created through this path, it won't have a user like it does through the onboarding path. Is that expected?

If an organization is created through this path, it won't have a user like it does through the onboarding path. Is that expected?
Author
Owner
Copy link

Yeah, good question. I think: yes. If it's created this way, it's some admin creating the org for someone else. They would then go to the new org's user page and invite a user. Adding a new user here could be an enhancement, but not something we need to do right now.

Yeah, good question. I think: yes. If it's created this way, it's some admin creating the org for someone else. They would then go to the new org's user page and invite a user. Adding a new user here could be an enhancement, but not something we need to do right now.
@ -12,2 +14,4 @@
has_one :address, as: :addressable, dependent: :destroy
accepts_nested_attributes_for :address, allow_destroy: true
accepts_nested_attributes_for :subscriptions
accepts_nested_attributes_for :users
First-time contributor
Copy link

I think accepts_nested_attributes_for is my least favorite Rails feature but your implementation of it looks clean and straight forward so I'm not going to fight it 😄 😂

I think `accepts_nested_attributes_for` is my least favorite Rails feature but your implementation of it looks clean and straight forward so I'm not going to fight it 😄 😂
Author
Owner
Copy link

Hahahaha. Yup, it's a beast and I definitely struggled with it. I'm curious what your go-to strategy is for creating associated objects, if you have one. I've never found anything perfect. I feel like there's something with form objects but every time I try one (including in draft versions of this PR) I run into problems.

Hahahaha. Yup, it's a beast and I definitely struggled with it. I'm curious what your go-to strategy is for creating associated objects, if you have one. I've never found anything perfect. I feel like there's something with form objects but every time I try one (including in draft versions of this PR) I run into problems.
config/routes.rb Outdated
@ -23,6 +23,7 @@ Rails.application.routes.draw do
resources :shift_event_comparisons, only: %i[index]
resources :shift_occurrence_comparisons, only: %(show)
resources :users, only: %i[show update]
resources :onboardings, only: %i[new create]
First-time contributor
Copy link

This feels like a nitpick but it looks like there will 2 routes to OnboardingsController#new action, this one and the sign_up route. Just wanted to call this out in case that wasn't intentional.

This feels like a nitpick but it looks like there will 2 routes to `OnboardingsController#new` action, this one and the sign_up route. Just wanted to call this out in case that wasn't intentional.
Author
Owner
Copy link

Sharp eye. I mostly do the ReSTful thing by default, then augment with named routes—like a shortcut. I suppose that could create some problems if there are too many paths to the same place, but I can't actually think of a vulnerability, or usability problem. Let me know if you think of something that's worth addressing.

Sharp eye. I mostly do the ReSTful thing by default, then augment with named routes—like a shortcut. I suppose that could create some problems if there are too many paths to the same place, but I can't actually think of a vulnerability, or usability problem. Let me know if you think of something that's worth addressing.
edwardloveall force-pushed el-onboarding from 3c9d61f5e1
Some checks failed
Setup Successful
Jest Successful
Static Analysis Successful
RSpec Failed
to 6f6042b766
Some checks failed
Setup Successful
Jest Successful
Static Analysis Successful
RSpec Failed
2025年11月17日 15:36:30 +01:00
Compare
edwardloveall force-pushed el-onboarding from 6f6042b766
Some checks failed
Setup Successful
Jest Successful
Static Analysis Successful
RSpec Failed
to 73da9392d5
Some checks failed
Setup Successful
Jest Successful
Static Analysis Successful
RSpec Failed
2025年11月17日 15:39:27 +01:00
Compare
Author
Owner
Copy link

Failing tests are flaky and pass locally

Failing tests are flaky and pass locally
edwardloveall deleted branch el-onboarding 2025年11月17日 16:52:26 +01:00
Sign in to join this conversation.
No reviewers
Milestone
Clear milestone
No items
No milestone
Projects
Clear projects
No items
No project
Assignees
Clear assignees
No assignees
2 participants
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set.

Reference
rootable/tomato!46
Reference in a new issue
rootable/tomato
No description provided.
Delete branch "el-onboarding"

Deleting a branch is permanent. Although the deleted branch may continue to exist for a short time before it actually gets removed, it CANNOT be undone in most cases. Continue?