1
0
Fork 0
mirror of https://github.com/maybe-finance/maybe.git synced 2025-07-19 05:09:38 +02:00
* 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:
Josh Pigford 2024-10-08 14:37:47 -05:00 committed by GitHub
parent 41dff228e8
commit 31f3ff6a16
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
27 changed files with 225 additions and 12 deletions

View file

@ -36,8 +36,8 @@ SENTRY_DSN=
# This is useful for controlling who can sign up for your Maybe instance. # This is useful for controlling who can sign up for your Maybe instance.
REQUIRE_INVITE_CODE=false REQUIRE_INVITE_CODE=false
# Enables self hosting features # Enables self hosting features (should be set to true for most folks)
SELF_HOSTING_ENABLED=false SELF_HOSTED=true
# The hosting platform used to deploy the app (e.g. "render") # The hosting platform used to deploy the app (e.g. "render")
# `localhost` (or unset) is used for local development and testing # `localhost` (or unset) is used for local development and testing
@ -86,11 +86,19 @@ GITHUB_REPO_BRANCH=main
# S3_SECRET_ACCESS_KEY= # S3_SECRET_ACCESS_KEY=
# S3_REGION= # defaults to `us-east-1` if not set # S3_REGION= # defaults to `us-east-1` if not set
# S3_BUCKET= # S3_BUCKET=
# #
# Cloudflare R2 # Cloudflare R2
# ============= # =============
# ACTIVE_STORAGE_SERVICE=cloudflare # ACTIVE_STORAGE_SERVICE=cloudflare
# CLOUDFLARE_ACCOUNT_ID= # CLOUDFLARE_ACCOUNT_ID=
# CLOUDFLARE_ACCESS_KEY_ID= # CLOUDFLARE_ACCESS_KEY_ID=
# CLOUDFLARE_SECRET_ACCESS_KEY= # CLOUDFLARE_SECRET_ACCESS_KEY=
# CLOUDFLARE_BUCKET= # CLOUDFLARE_BUCKET=
# ======================================================================================================
# Billing Module - responsible for handling billing
# ======================================================================================================
#
STRIPE_PUBLISHABLE_KEY=
STRIPE_SECRET_KEY=
STRIPE_WEBHOOK_SECRET=

View file

@ -45,6 +45,7 @@ gem "rails-settings-cached"
gem "tzinfo-data", platforms: %i[windows jruby] gem "tzinfo-data", platforms: %i[windows jruby]
gem "csv" gem "csv"
gem "redcarpet" gem "redcarpet"
gem "stripe"
gem "intercom-rails" gem "intercom-rails"
group :development, :test do group :development, :test do

View file

@ -1,6 +1,6 @@
GIT GIT
remote: https://github.com/maybe-finance/lucide-rails.git remote: https://github.com/maybe-finance/lucide-rails.git
revision: 79d989593ee4ac6c50106ec5e4d2bd4ec8f5af87 revision: 272e5fb8418ea458da3995d6abe0ba0ceee9c9f0
specs: specs:
lucide-rails (0.2.0) lucide-rails (0.2.0)
railties (>= 4.1.0) railties (>= 4.1.0)
@ -414,6 +414,7 @@ GEM
stimulus-rails (1.3.4) stimulus-rails (1.3.4)
railties (>= 6.0.0) railties (>= 6.0.0)
stringio (3.1.1) stringio (3.1.1)
stripe (13.0.0)
tailwindcss-rails (2.7.7) tailwindcss-rails (2.7.7)
railties (>= 7.0.0) railties (>= 7.0.0)
tailwindcss-rails (2.7.7-aarch64-linux) tailwindcss-rails (2.7.7-aarch64-linux)
@ -506,6 +507,7 @@ DEPENDENCIES
simplecov simplecov
stackprof stackprof
stimulus-rails stimulus-rails
stripe
tailwindcss-rails tailwindcss-rails
turbo-rails turbo-rails
tzinfo-data tzinfo-data

View file

@ -0,0 +1,2 @@
class Settings::BillingsController < SettingsController
end

View 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

View 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

View file

@ -0,0 +1,2 @@
module Settings::BillingHelper
end

View file

@ -3,6 +3,7 @@ module SettingsHelper
{ name: I18n.t("settings.nav.profile_label"), path: :settings_profile_path }, { 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.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.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.accounts_label"), path: :accounts_path },
{ name: I18n.t("settings.nav.tags_label"), path: :tags_path }, { name: I18n.t("settings.nav.tags_label"), path: :tags_path },
{ name: I18n.t("settings.nav.categories_label"), path: :categories_path }, { name: I18n.t("settings.nav.categories_label"), path: :categories_path },

View file

@ -0,0 +1,2 @@
module SubscriptionHelper
end

View file

@ -0,0 +1,2 @@
module WebhooksHelper
end

View file

@ -127,4 +127,12 @@ class Family < ApplicationRecord
def synth_usage def synth_usage
self.class.synth_provider&.usage self.class.synth_provider&.usage
end end
def subscribed?
stripe_subscription_status.present? && stripe_subscription_status == "active"
end
def primary_user
users.order(:created_at).first
end
end end

View file

@ -22,7 +22,9 @@
</div> </div>
</header> </header>
<% if @accounts.empty? %> <% if !Current.family.subscribed? && !self_hosted? %>
<%= render "shared/subscribe_prompt" %>
<% elsif @accounts.empty? %>
<%= render "shared/no_account_empty_state" %> <%= render "shared/no_account_empty_state" %>
<% else %> <% else %>
<section class="flex gap-4"> <section class="flex gap-4">

View file

@ -26,6 +26,9 @@
<%= sidebar_link_to t(".self_hosting_label"), settings_hosting_path, icon: "database" %> <%= sidebar_link_to t(".self_hosting_label"), settings_hosting_path, icon: "database" %>
</li> </li>
<% end %> <% end %>
<li>
<%= sidebar_link_to t(".billing_label"), settings_billing_path, icon: "circle-dollar-sign" %>
</li>
<li> <li>
<%= sidebar_link_to t(".accounts_label"), accounts_path, icon: "layers" %> <%= sidebar_link_to t(".accounts_label"), accounts_path, icon: "layers" %>
</li> </li>

View 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>

View 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>

View file

@ -29,6 +29,6 @@ module Maybe
# TODO: This is here for incremental adoption of localization. This can be removed when all translations are implemented. # TODO: This is here for incremental adoption of localization. This can be removed when all translations are implemented.
config.i18n.fallbacks = true 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
end end

View file

@ -1,8 +1,16 @@
--- ---
en: en:
settings: 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: nav:
accounts_label: Accounts accounts_label: Accounts
billing_label: Billing
categories_label: Categories categories_label: Categories
feedback_label: Feedback feedback_label: Feedback
general_section_title: General general_section_title: General

View file

@ -13,6 +13,12 @@ en:
no_account_subtitle: Since no accounts have been added, there's no data to display. no_account_subtitle: Since no accounts have been added, there's no data to display.
Add your first accounts to start viewing dashboard data. Add your first accounts to start viewing dashboard data.
no_account_title: No accounts yet 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: upgrade_notification:
app_upgraded: The app has been upgraded to %{version}. app_upgraded: The app has been upgraded to %{version}.
dismiss: Dismiss dismiss: Dismiss

View file

@ -13,8 +13,11 @@ Rails.application.routes.draw do
resource :profile, only: %i[show update destroy] resource :profile, only: %i[show update destroy]
resource :preferences, only: %i[show update] resource :preferences, only: %i[show update]
resource :hosting, only: %i[show update] resource :hosting, only: %i[show update]
resource :billing, only: :show
end end
resource :subscription, only: %i[new show]
resources :tags, except: %i[show destroy] do resources :tags, except: %i[show destroy] do
resources :deletions, only: %i[new create], module: :tag resources :deletions, only: %i[new create], module: :tag
end end
@ -104,6 +107,9 @@ Rails.application.routes.draw do
resources :currencies, only: %i[show] 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. # 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. # 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 get "up" => "rails/health#show", as: :rails_health_check

View 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
View file

@ -10,7 +10,7 @@
# #
# It's strongly recommended that you check this file into your version control system. # 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 # These are extensions that must be enabled in order to support this database
enable_extension "pgcrypto" enable_extension "pgcrypto"
enable_extension "plpgsql" 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.boolean "is_active", default: true, null: false
t.date "last_sync_date" t.date "last_sync_date"
t.uuid "institution_id" 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.uuid "import_id"
t.index ["accountable_type"], name: "index_accounts_on_accountable_type" t.index ["accountable_type"], name: "index_accounts_on_accountable_type"
t.index ["family_id"], name: "index_accounts_on_family_id" 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.string "currency", default: "USD"
t.datetime "last_synced_at" t.datetime "last_synced_at"
t.string "locale", default: "en" t.string "locale", default: "en"
t.string "stripe_plan_id"
t.string "stripe_customer_id"
t.string "stripe_subscription_status", default: "incomplete"
end end
create_table "good_job_batches", id: :uuid, default: -> { "gen_random_uuid()" }, force: :cascade do |t| create_table "good_job_batches", id: :uuid, default: -> { "gen_random_uuid()" }, force: :cascade do |t|

View file

@ -40,7 +40,7 @@ services:
restart: unless-stopped restart: unless-stopped
environment: environment:
SELF_HOSTING_ENABLED: "true" SELF_HOSTED: "true"
RAILS_FORCE_SSL: "false" RAILS_FORCE_SSL: "false"
RAILS_ASSUME_SSL: "false" RAILS_ASSUME_SSL: "false"
GOOD_JOB_EXECUTION_MODE: async GOOD_JOB_EXECUTION_MODE: async

View file

@ -21,7 +21,7 @@ services:
name: maybe name: maybe
property: connectionString property: connectionString
- key: SELF_HOSTING_ENABLED - key: SELF_HOSTED
value: true value: true
- key: HOSTING_PLATFORM - key: HOSTING_PLATFORM
value: render value: render

View file

@ -0,0 +1,7 @@
require "test_helper"
class Settings::BillingsControllerTest < ActionDispatch::IntegrationTest
# test "the truth" do
# assert true
# end
end

View file

@ -0,0 +1,7 @@
require "test_helper"
class SubscriptionsControllerTest < ActionDispatch::IntegrationTest
# test "the truth" do
# assert true
# end
end

View file

@ -0,0 +1,7 @@
require "test_helper"
class WebhooksControllerTest < ActionDispatch::IntegrationTest
# test "the truth" do
# assert true
# end
end

View file

@ -7,7 +7,7 @@ end
require_relative "../config/environment" require_relative "../config/environment"
ENV["SELF_HOSTING_ENABLED"] = "false" ENV["SELF_HOSTED"] = "false"
ENV["UPGRADES_ENABLED"] = "false" ENV["UPGRADES_ENABLED"] = "false"
ENV["RAILS_ENV"] ||= "test" ENV["RAILS_ENV"] ||= "test"