Skip to content
This repository was archived by the owner on Jul 27, 2025. It is now read-only.
Merged

Billing #1269

Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 12 additions & 4 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -36,8 +36,8 @@ SENTRY_DSN=
# This is useful for controlling who can sign up for your Maybe instance.
REQUIRE_INVITE_CODE=false

# Enables self hosting features
SELF_HOSTING_ENABLED=false
# Enables self hosting features (should be set to true for most folks)
SELF_HOSTED=true

# The hosting platform used to deploy the app (e.g. "render")
# `localhost` (or unset) is used for local development and testing
Expand Down Expand Up @@ -86,11 +86,19 @@ GITHUB_REPO_BRANCH=main
# S3_SECRET_ACCESS_KEY=
# S3_REGION= # defaults to `us-east-1` if not set
# S3_BUCKET=
#
#
# Cloudflare R2
# =============
# ACTIVE_STORAGE_SERVICE=cloudflare
# CLOUDFLARE_ACCOUNT_ID=
# CLOUDFLARE_ACCESS_KEY_ID=
# CLOUDFLARE_SECRET_ACCESS_KEY=
# CLOUDFLARE_BUCKET=
# CLOUDFLARE_BUCKET=

# ======================================================================================================
# Billing Module - responsible for handling billing
# ======================================================================================================
#
STRIPE_PUBLISHABLE_KEY=
STRIPE_SECRET_KEY=
STRIPE_WEBHOOK_SECRET=
1 change: 1 addition & 0 deletions Gemfile
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,7 @@ gem "rails-settings-cached"
gem "tzinfo-data", platforms: %i[windows jruby]
gem "csv"
gem "redcarpet"
gem "stripe"
gem "intercom-rails"

group :development, :test do
Expand Down
4 changes: 3 additions & 1 deletion Gemfile.lock
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
GIT
remote: https://github.com/maybe-finance/lucide-rails.git
revision: 79d989593ee4ac6c50106ec5e4d2bd4ec8f5af87
revision: 272e5fb8418ea458da3995d6abe0ba0ceee9c9f0
specs:
lucide-rails (0.2.0)
railties (>= 4.1.0)
Expand Down Expand Up @@ -414,6 +414,7 @@ GEM
stimulus-rails (1.3.4)
railties (>= 6.0.0)
stringio (3.1.1)
stripe (13.0.0)
tailwindcss-rails (2.7.7)
railties (>= 7.0.0)
tailwindcss-rails (2.7.7-aarch64-linux)
Expand Down Expand Up @@ -506,6 +507,7 @@ DEPENDENCIES
simplecov
stackprof
stimulus-rails
stripe
tailwindcss-rails
turbo-rails
tzinfo-data
Expand Down
2 changes: 2 additions & 0 deletions app/controllers/settings/billings_controller.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
class Settings::BillingsController < SettingsController
end
36 changes: 36 additions & 0 deletions app/controllers/subscriptions_controller.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
class SubscriptionsController < ApplicationController
def new
client = Stripe::StripeClient.new(ENV["STRIPE_SECRET_KEY"])

if Current.family.stripe_customer_id.blank?
customer = client.v1.customers.create(
email: Current.family.primary_user.email,
metadata: { family_id: Current.family.id }
)
Current.family.update(stripe_customer_id: customer.id)
end

session = client.v1.checkout.sessions.create({
customer: Current.family.stripe_customer_id,
line_items: [ {
price: ENV["STRIPE_PLAN_ID"],
quantity: 1
} ],
mode: "subscription",
success_url: settings_billing_url,
cancel_url: settings_billing_url
})

redirect_to session.url, allow_other_host: true, status: :see_other
end

def show
client = Stripe::StripeClient.new(ENV["STRIPE_SECRET_KEY"])

portal_session = client.v1.billing_portal.sessions.create(
customer: Current.family.stripe_customer_id,
return_url: settings_billing_url
)
redirect_to portal_session.url, allow_other_host: true, status: :see_other
end
end
61 changes: 61 additions & 0 deletions app/controllers/webhooks_controller.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,61 @@
class WebhooksController < ApplicationController
skip_before_action :verify_authenticity_token, only: [ :stripe ]
skip_authentication

def stripe
webhook_body = request.body.read
sig_header = request.env["HTTP_STRIPE_SIGNATURE"]
client = Stripe::StripeClient.new(ENV["STRIPE_SECRET_KEY"])

begin
thin_event = client.parse_thin_event(webhook_body, sig_header, ENV["STRIPE_WEBHOOK_SECRET"])

event = client.v1.events.retrieve(thin_event.id)

case event.type
when /^customer\.subscription\./
handle_subscription_event(event)
when "customer.created", "customer.updated", "customer.deleted"
handle_customer_event(event)
else
Rails.logger.info "Unhandled event type: #{event.type}"
end

rescue JSON::ParserError
render json: { error: "Invalid payload" }, status: :bad_request
return
rescue Stripe::SignatureVerificationError
render json: { error: "Invalid signature" }, status: :bad_request
return
end

render json: { received: true }, status: :ok
end

private

def handle_subscription_event(event)
subscription = event.data.object
family = Family.find_by(stripe_customer_id: subscription.customer)

if family
family.update(
stripe_plan_id: subscription.plan.id,
stripe_subscription_status: subscription.status
)
else
Rails.logger.error "Family not found for Stripe customer ID: #{subscription.customer}"
end
end

def handle_customer_event(event)
customer = event.data.object
family = Family.find_by(stripe_customer_id: customer.id)

if family
family.update(stripe_customer_id: customer.id)
else
Rails.logger.error "Family not found for Stripe customer ID: #{customer.id}"
end
end
end
2 changes: 2 additions & 0 deletions app/helpers/settings/billing_helper.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
module Settings::BillingHelper
end
1 change: 1 addition & 0 deletions app/helpers/settings_helper.rb
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ module SettingsHelper
{ name: I18n.t("settings.nav.profile_label"), path: :settings_profile_path },
{ name: I18n.t("settings.nav.preferences_label"), path: :settings_preferences_path },
{ name: I18n.t("settings.nav.self_hosting_label"), path: :settings_hosting_path, condition: :self_hosted? },
{ name: I18n.t("settings.nav.billing_label"), path: :settings_billing_path },
{ name: I18n.t("settings.nav.accounts_label"), path: :accounts_path },
{ name: I18n.t("settings.nav.tags_label"), path: :tags_path },
{ name: I18n.t("settings.nav.categories_label"), path: :categories_path },
Expand Down
2 changes: 2 additions & 0 deletions app/helpers/subscription_helper.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
module SubscriptionHelper
end
2 changes: 2 additions & 0 deletions app/helpers/webhooks_helper.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
module WebhooksHelper
end
8 changes: 8 additions & 0 deletions app/models/family.rb
Original file line number Diff line number Diff line change
Expand Up @@ -127,4 +127,12 @@ def needs_sync?
def synth_usage
self.class.synth_provider&.usage
end

def subscribed?
stripe_subscription_status.present? && stripe_subscription_status == "active"
end

def primary_user
users.order(:created_at).first
end
end
4 changes: 3 additions & 1 deletion app/views/pages/dashboard.html.erb
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,9 @@
</div>
</header>

<% if @accounts.empty? %>
<% if !Current.family.subscribed? && !self_hosted? %>
<%= render "shared/subscribe_prompt" %>
<% elsif @accounts.empty? %>
<%= render "shared/no_account_empty_state" %>
<% else %>
<section class="flex gap-4">
Expand Down
3 changes: 3 additions & 0 deletions app/views/settings/_nav.html.erb
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,9 @@
<%= sidebar_link_to t(".self_hosting_label"), settings_hosting_path, icon: "database" %>
</li>
<% end %>
<li>
<%= sidebar_link_to t(".billing_label"), settings_billing_path, icon: "circle-dollar-sign" %>
</li>
<li>
<%= sidebar_link_to t(".accounts_label"), accounts_path, icon: "layers" %>
</li>
Expand Down
16 changes: 16 additions & 0 deletions app/views/settings/billings/show.html.erb
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
<% content_for :sidebar do %>
<%= render "settings/nav" %>
<% end %>

<div class="space-y-4">
<h1 class="text-gray-900 text-xl font-medium mb-4"><%= t(".page_title") %></h1>
<%= settings_section title: t(".subscription_title"), subtitle: t(".subscription_subtitle") do %>
<% if Current.family.stripe_plan_id.blank? %>
<%= link_to t(".subscribe_button"), new_subscription_path, class: "w-fit flex text-white text-sm font-medium items-center gap-1 bg-gray-900 rounded-lg p-2", data: { turbo: false } %>
<% else %>
<%= link_to t(".manage_subscription_button"), subscription_path, class: "w-fit flex text-white text-sm font-medium items-center gap-1 bg-gray-900 rounded-lg p-2", data: { turbo: false } %>
<% end %>
<% end %>

<%= settings_nav_footer %>
</div>
16 changes: 16 additions & 0 deletions app/views/shared/_subscribe_prompt.html.erb
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
<div class="flex justify-center items-center h-[800px]">
<div class="text-center flex flex-col gap-4 items-center max-w-[300px]">
<%= lucide_icon "circle-fading-arrow-up", class: "w-8 h-8 text-green-500" %>

<div class="space-y-1 text-sm">
<p class="text-gray-900 font-medium"><%= t(".title") %></p>
<p class="text-gray-500"><%= t(".subtitle") %></p>
<p class="text-gray-400 text-xs"><%= t(".guarantee") %></p>
</div>

<%= link_to new_subscription_path, class: "btn btn--primary flex items-center gap-1" do %>
<%= lucide_icon("credit-card", class: "w-5 h-5") %>
<span><%= t(".subscribe") %></span>
<% end %>
</div>
</div>
2 changes: 1 addition & 1 deletion config/application.rb
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,6 @@ class Application < Rails::Application
# TODO: This is here for incremental adoption of localization. This can be removed when all translations are implemented.
config.i18n.fallbacks = true

config.app_mode = (ENV["SELF_HOSTING_ENABLED"] == "true" ? "self_hosted" : "managed").inquiry
config.app_mode = (ENV["SELF_HOSTED"] == "true" ? "self_hosted" : "managed").inquiry
end
end
8 changes: 8 additions & 0 deletions config/locales/views/settings/en.yml
Original file line number Diff line number Diff line change
@@ -1,8 +1,16 @@
---
en:
settings:
billings:
show:
manage_subscription_button: Manage subscription
page_title: Billing
subscribe_button: Subscribe
subscription_subtitle: Manage your subscription and billing details
subscription_title: Manage subscription
nav:
accounts_label: Accounts
billing_label: Billing
categories_label: Categories
feedback_label: Feedback
general_section_title: General
Expand Down
6 changes: 6 additions & 0 deletions config/locales/views/shared/en.yml
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,12 @@ en:
no_account_subtitle: Since no accounts have been added, there's no data to display.
Add your first accounts to start viewing dashboard data.
no_account_title: No accounts yet
subscribe_prompt:
guarantee: We're reasonable people here. If you're not happy or something doesn't
work, we'll gladly refund you.
subscribe: Upgrade your account
subtitle: To continue using Maybe, please subscribe!
title: Upgrade
upgrade_notification:
app_upgraded: The app has been upgraded to %{version}.
dismiss: Dismiss
Expand Down
6 changes: 6 additions & 0 deletions config/routes.rb
Original file line number Diff line number Diff line change
Expand Up @@ -13,8 +13,11 @@
resource :profile, only: %i[show update destroy]
resource :preferences, only: %i[show update]
resource :hosting, only: %i[show update]
resource :billing, only: :show
end

resource :subscription, only: %i[new show]

resources :tags, except: %i[show destroy] do
resources :deletions, only: %i[new create], module: :tag
end
Expand Down Expand Up @@ -104,6 +107,9 @@

resources :currencies, only: %i[show]

# Stripe webhook endpoint
post "webhooks/stripe", to: "webhooks#stripe"

# Reveal health status on /up that returns 200 if the app boots with no exceptions, otherwise 500.
# Can be used by load balancers and uptime monitors to verify that the app is live.
get "up" => "rails/health#show", as: :rails_health_check
Expand Down
7 changes: 7 additions & 0 deletions db/migrate/20241007211438_add_billing_to_families.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
class AddBillingToFamilies < ActiveRecord::Migration[7.2]
def change
add_column :families, :stripe_plan_id, :string
add_column :families, :stripe_customer_id, :string
add_column :families, :stripe_subscription_status, :string, default: "incomplete"
end
end
7 changes: 5 additions & 2 deletions db/schema.rb

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion docker-compose.example.yml
Original file line number Diff line number Diff line change
Expand Up @@ -40,7 +40,7 @@ services:
restart: unless-stopped

environment:
SELF_HOSTING_ENABLED: "true"
SELF_HOSTED: "true"
RAILS_FORCE_SSL: "false"
RAILS_ASSUME_SSL: "false"
GOOD_JOB_EXECUTION_MODE: async
Expand Down
2 changes: 1 addition & 1 deletion render.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,7 @@ services:
name: maybe
property: connectionString

- key: SELF_HOSTING_ENABLED
- key: SELF_HOSTED
value: true
- key: HOSTING_PLATFORM
value: render
Expand Down
7 changes: 7 additions & 0 deletions test/controllers/settings/billings_controller_test.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
require "test_helper"

class Settings::BillingsControllerTest < ActionDispatch::IntegrationTest
# test "the truth" do
# assert true
# end
end
7 changes: 7 additions & 0 deletions test/controllers/subscriptions_controller_test.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
require "test_helper"

class SubscriptionsControllerTest < ActionDispatch::IntegrationTest
# test "the truth" do
# assert true
# end
end
7 changes: 7 additions & 0 deletions test/controllers/webhooks_controller_test.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
require "test_helper"

class WebhooksControllerTest < ActionDispatch::IntegrationTest
# test "the truth" do
# assert true
# end
end
Loading