diff --git a/.env.example b/.env.example index aef24fa9..69617304 100644 --- a/.env.example +++ b/.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= \ No newline at end of file +# CLOUDFLARE_BUCKET= + +# ====================================================================================================== +# Billing Module - responsible for handling billing +# ====================================================================================================== +# +STRIPE_PUBLISHABLE_KEY= +STRIPE_SECRET_KEY= +STRIPE_WEBHOOK_SECRET= \ No newline at end of file diff --git a/Gemfile b/Gemfile index c794bf70..cccaae5d 100644 --- a/Gemfile +++ b/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 diff --git a/Gemfile.lock b/Gemfile.lock index c7f8e025..3ff95a37 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -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 diff --git a/app/controllers/settings/billings_controller.rb b/app/controllers/settings/billings_controller.rb new file mode 100644 index 00000000..d6dc4053 --- /dev/null +++ b/app/controllers/settings/billings_controller.rb @@ -0,0 +1,2 @@ +class Settings::BillingsController < SettingsController +end diff --git a/app/controllers/subscriptions_controller.rb b/app/controllers/subscriptions_controller.rb new file mode 100644 index 00000000..198e1b6c --- /dev/null +++ b/app/controllers/subscriptions_controller.rb @@ -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 diff --git a/app/controllers/webhooks_controller.rb b/app/controllers/webhooks_controller.rb new file mode 100644 index 00000000..23e431f7 --- /dev/null +++ b/app/controllers/webhooks_controller.rb @@ -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 diff --git a/app/helpers/settings/billing_helper.rb b/app/helpers/settings/billing_helper.rb new file mode 100644 index 00000000..8de033a5 --- /dev/null +++ b/app/helpers/settings/billing_helper.rb @@ -0,0 +1,2 @@ +module Settings::BillingHelper +end diff --git a/app/helpers/settings_helper.rb b/app/helpers/settings_helper.rb index 3f4be356..f127905d 100644 --- a/app/helpers/settings_helper.rb +++ b/app/helpers/settings_helper.rb @@ -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 }, diff --git a/app/helpers/subscription_helper.rb b/app/helpers/subscription_helper.rb new file mode 100644 index 00000000..bac8d5a9 --- /dev/null +++ b/app/helpers/subscription_helper.rb @@ -0,0 +1,2 @@ +module SubscriptionHelper +end diff --git a/app/helpers/webhooks_helper.rb b/app/helpers/webhooks_helper.rb new file mode 100644 index 00000000..3fa66567 --- /dev/null +++ b/app/helpers/webhooks_helper.rb @@ -0,0 +1,2 @@ +module WebhooksHelper +end diff --git a/app/models/family.rb b/app/models/family.rb index 70f93bb6..24da7ddd 100644 --- a/app/models/family.rb +++ b/app/models/family.rb @@ -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 diff --git a/app/views/pages/dashboard.html.erb b/app/views/pages/dashboard.html.erb index 72bdc2bd..37b21147 100644 --- a/app/views/pages/dashboard.html.erb +++ b/app/views/pages/dashboard.html.erb @@ -22,7 +22,9 @@ - <% if @accounts.empty? %> + <% if !Current.family.subscribed? && !self_hosted? %> + <%= render "shared/subscribe_prompt" %> + <% elsif @accounts.empty? %> <%= render "shared/no_account_empty_state" %> <% else %>
diff --git a/app/views/settings/_nav.html.erb b/app/views/settings/_nav.html.erb index dad0759e..cd321c1b 100644 --- a/app/views/settings/_nav.html.erb +++ b/app/views/settings/_nav.html.erb @@ -26,6 +26,9 @@ <%= sidebar_link_to t(".self_hosting_label"), settings_hosting_path, icon: "database" %> <% end %> +
  • + <%= sidebar_link_to t(".billing_label"), settings_billing_path, icon: "circle-dollar-sign" %> +
  • <%= sidebar_link_to t(".accounts_label"), accounts_path, icon: "layers" %>
  • diff --git a/app/views/settings/billings/show.html.erb b/app/views/settings/billings/show.html.erb new file mode 100644 index 00000000..93de6099 --- /dev/null +++ b/app/views/settings/billings/show.html.erb @@ -0,0 +1,16 @@ +<% content_for :sidebar do %> + <%= render "settings/nav" %> +<% end %> + +
    +

    <%= t(".page_title") %>

    + <%= 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 %> +
    diff --git a/app/views/shared/_subscribe_prompt.html.erb b/app/views/shared/_subscribe_prompt.html.erb new file mode 100644 index 00000000..35315af4 --- /dev/null +++ b/app/views/shared/_subscribe_prompt.html.erb @@ -0,0 +1,16 @@ +
    +
    + <%= lucide_icon "circle-fading-arrow-up", class: "w-8 h-8 text-green-500" %> + +
    +

    <%= t(".title") %>

    +

    <%= t(".subtitle") %>

    +

    <%= t(".guarantee") %>

    +
    + + <%= link_to new_subscription_path, class: "btn btn--primary flex items-center gap-1" do %> + <%= lucide_icon("credit-card", class: "w-5 h-5") %> + <%= t(".subscribe") %> + <% end %> +
    +
    diff --git a/config/application.rb b/config/application.rb index 55737438..17caf1f9 100644 --- a/config/application.rb +++ b/config/application.rb @@ -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 diff --git a/config/locales/views/settings/en.yml b/config/locales/views/settings/en.yml index 83f067ff..8bf5b77b 100644 --- a/config/locales/views/settings/en.yml +++ b/config/locales/views/settings/en.yml @@ -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 diff --git a/config/locales/views/shared/en.yml b/config/locales/views/shared/en.yml index 80f4fc3d..89ad83bc 100644 --- a/config/locales/views/shared/en.yml +++ b/config/locales/views/shared/en.yml @@ -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 diff --git a/config/routes.rb b/config/routes.rb index 1bd35e42..42b91592 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -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 diff --git a/db/migrate/20241007211438_add_billing_to_families.rb b/db/migrate/20241007211438_add_billing_to_families.rb new file mode 100644 index 00000000..3b8a0840 --- /dev/null +++ b/db/migrate/20241007211438_add_billing_to_families.rb @@ -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 diff --git a/db/schema.rb b/db/schema.rb index 7e6ef394..502ddb48 100644 --- a/db/schema.rb +++ b/db/schema.rb @@ -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| diff --git a/docker-compose.example.yml b/docker-compose.example.yml index ad5220b2..683d35de 100644 --- a/docker-compose.example.yml +++ b/docker-compose.example.yml @@ -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 diff --git a/render.yaml b/render.yaml index 1c404712..69b298ff 100644 --- a/render.yaml +++ b/render.yaml @@ -21,7 +21,7 @@ services: name: maybe property: connectionString - - key: SELF_HOSTING_ENABLED + - key: SELF_HOSTED value: true - key: HOSTING_PLATFORM value: render diff --git a/test/controllers/settings/billings_controller_test.rb b/test/controllers/settings/billings_controller_test.rb new file mode 100644 index 00000000..579bf769 --- /dev/null +++ b/test/controllers/settings/billings_controller_test.rb @@ -0,0 +1,7 @@ +require "test_helper" + +class Settings::BillingsControllerTest < ActionDispatch::IntegrationTest + # test "the truth" do + # assert true + # end +end diff --git a/test/controllers/subscriptions_controller_test.rb b/test/controllers/subscriptions_controller_test.rb new file mode 100644 index 00000000..3fda28d4 --- /dev/null +++ b/test/controllers/subscriptions_controller_test.rb @@ -0,0 +1,7 @@ +require "test_helper" + +class SubscriptionsControllerTest < ActionDispatch::IntegrationTest + # test "the truth" do + # assert true + # end +end diff --git a/test/controllers/webhooks_controller_test.rb b/test/controllers/webhooks_controller_test.rb new file mode 100644 index 00000000..ae20adba --- /dev/null +++ b/test/controllers/webhooks_controller_test.rb @@ -0,0 +1,7 @@ +require "test_helper" + +class WebhooksControllerTest < ActionDispatch::IntegrationTest + # test "the truth" do + # assert true + # end +end diff --git a/test/test_helper.rb b/test/test_helper.rb index f17655f7..b3bacfa8 100644 --- a/test/test_helper.rb +++ b/test/test_helper.rb @@ -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"