mirror of
https://github.com/maybe-finance/maybe.git
synced 2025-07-18 20:59:39 +02:00
Billing (#1269)
* Change env SELF_HOSTING_ENABLED to SELF_HOSTED * Initial Stripe implementation * Fix portal link * Use webhook signatures * Migrated to new Stripe gem conventions Also updated resource routing * Added faraday-multipart gem to resolve middleware notice * Merge fix * Merge fix * Temporary upgrade prompt for early access * Lint fix * i18n fixes * Remove catch-all rescue * Update .env.example
This commit is contained in:
parent
41dff228e8
commit
31f3ff6a16
27 changed files with 225 additions and 12 deletions
16
.env.example
16
.env.example
|
@ -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
|
||||
|
@ -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
Gemfile
1
Gemfile
|
@ -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
|
||||
|
|
|
@ -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)
|
||||
|
@ -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)
|
||||
|
@ -506,6 +507,7 @@ DEPENDENCIES
|
|||
simplecov
|
||||
stackprof
|
||||
stimulus-rails
|
||||
stripe
|
||||
tailwindcss-rails
|
||||
turbo-rails
|
||||
tzinfo-data
|
||||
|
|
2
app/controllers/settings/billings_controller.rb
Normal file
2
app/controllers/settings/billings_controller.rb
Normal file
|
@ -0,0 +1,2 @@
|
|||
class Settings::BillingsController < SettingsController
|
||||
end
|
36
app/controllers/subscriptions_controller.rb
Normal file
36
app/controllers/subscriptions_controller.rb
Normal file
|
@ -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
app/controllers/webhooks_controller.rb
Normal file
61
app/controllers/webhooks_controller.rb
Normal file
|
@ -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
app/helpers/settings/billing_helper.rb
Normal file
2
app/helpers/settings/billing_helper.rb
Normal file
|
@ -0,0 +1,2 @@
|
|||
module Settings::BillingHelper
|
||||
end
|
|
@ -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 },
|
||||
|
|
2
app/helpers/subscription_helper.rb
Normal file
2
app/helpers/subscription_helper.rb
Normal file
|
@ -0,0 +1,2 @@
|
|||
module SubscriptionHelper
|
||||
end
|
2
app/helpers/webhooks_helper.rb
Normal file
2
app/helpers/webhooks_helper.rb
Normal file
|
@ -0,0 +1,2 @@
|
|||
module WebhooksHelper
|
||||
end
|
|
@ -127,4 +127,12 @@ class Family < ApplicationRecord
|
|||
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
|
||||
|
|
|
@ -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">
|
||||
|
|
|
@ -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>
|
||||
|
|
16
app/views/settings/billings/show.html.erb
Normal file
16
app/views/settings/billings/show.html.erb
Normal file
|
@ -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
app/views/shared/_subscribe_prompt.html.erb
Normal file
16
app/views/shared/_subscribe_prompt.html.erb
Normal file
|
@ -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>
|
|
@ -29,6 +29,6 @@ module Maybe
|
|||
# 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
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -13,8 +13,11 @@ Rails.application.routes.draw do
|
|||
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
|
||||
|
@ -104,6 +107,9 @@ Rails.application.routes.draw do
|
|||
|
||||
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
|
||||
|
|
7
db/migrate/20241007211438_add_billing_to_families.rb
Normal file
7
db/migrate/20241007211438_add_billing_to_families.rb
Normal file
|
@ -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
db/schema.rb
generated
7
db/schema.rb
generated
|
@ -10,7 +10,7 @@
|
|||
#
|
||||
# It's strongly recommended that you check this file into your version control system.
|
||||
|
||||
ActiveRecord::Schema[7.2].define(version: 2024_10_03_163448) do
|
||||
ActiveRecord::Schema[7.2].define(version: 2024_10_07_211438) do
|
||||
# These are extensions that must be enabled in order to support this database
|
||||
enable_extension "pgcrypto"
|
||||
enable_extension "plpgsql"
|
||||
|
@ -120,7 +120,7 @@ ActiveRecord::Schema[7.2].define(version: 2024_10_03_163448) do
|
|||
t.boolean "is_active", default: true, null: false
|
||||
t.date "last_sync_date"
|
||||
t.uuid "institution_id"
|
||||
t.virtual "classification", type: :string, as: "\nCASE\n WHEN ((accountable_type)::text = ANY ((ARRAY['Loan'::character varying, 'CreditCard'::character varying, 'OtherLiability'::character varying])::text[])) THEN 'liability'::text\n ELSE 'asset'::text\nEND", stored: true
|
||||
t.virtual "classification", type: :string, as: "\nCASE\n WHEN ((accountable_type)::text = ANY (ARRAY[('Loan'::character varying)::text, ('CreditCard'::character varying)::text, ('OtherLiability'::character varying)::text])) THEN 'liability'::text\n ELSE 'asset'::text\nEND", stored: true
|
||||
t.uuid "import_id"
|
||||
t.index ["accountable_type"], name: "index_accounts_on_accountable_type"
|
||||
t.index ["family_id"], name: "index_accounts_on_family_id"
|
||||
|
@ -215,6 +215,9 @@ ActiveRecord::Schema[7.2].define(version: 2024_10_03_163448) do
|
|||
t.string "currency", default: "USD"
|
||||
t.datetime "last_synced_at"
|
||||
t.string "locale", default: "en"
|
||||
t.string "stripe_plan_id"
|
||||
t.string "stripe_customer_id"
|
||||
t.string "stripe_subscription_status", default: "incomplete"
|
||||
end
|
||||
|
||||
create_table "good_job_batches", id: :uuid, default: -> { "gen_random_uuid()" }, force: :cascade do |t|
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -21,7 +21,7 @@ services:
|
|||
name: maybe
|
||||
property: connectionString
|
||||
|
||||
- key: SELF_HOSTING_ENABLED
|
||||
- key: SELF_HOSTED
|
||||
value: true
|
||||
- key: HOSTING_PLATFORM
|
||||
value: render
|
||||
|
|
7
test/controllers/settings/billings_controller_test.rb
Normal file
7
test/controllers/settings/billings_controller_test.rb
Normal file
|
@ -0,0 +1,7 @@
|
|||
require "test_helper"
|
||||
|
||||
class Settings::BillingsControllerTest < ActionDispatch::IntegrationTest
|
||||
# test "the truth" do
|
||||
# assert true
|
||||
# end
|
||||
end
|
7
test/controllers/subscriptions_controller_test.rb
Normal file
7
test/controllers/subscriptions_controller_test.rb
Normal file
|
@ -0,0 +1,7 @@
|
|||
require "test_helper"
|
||||
|
||||
class SubscriptionsControllerTest < ActionDispatch::IntegrationTest
|
||||
# test "the truth" do
|
||||
# assert true
|
||||
# end
|
||||
end
|
7
test/controllers/webhooks_controller_test.rb
Normal file
7
test/controllers/webhooks_controller_test.rb
Normal file
|
@ -0,0 +1,7 @@
|
|||
require "test_helper"
|
||||
|
||||
class WebhooksControllerTest < ActionDispatch::IntegrationTest
|
||||
# test "the truth" do
|
||||
# assert true
|
||||
# end
|
||||
end
|
|
@ -7,7 +7,7 @@ end
|
|||
|
||||
require_relative "../config/environment"
|
||||
|
||||
ENV["SELF_HOSTING_ENABLED"] = "false"
|
||||
ENV["SELF_HOSTED"] = "false"
|
||||
ENV["UPGRADES_ENABLED"] = "false"
|
||||
ENV["RAILS_ENV"] ||= "test"
|
||||
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue