From bea146e612124acf2cb58f5f669de90edfcdeeb9 Mon Sep 17 00:00:00 2001 From: Riccardo Graziosi <31478034+riggraz@users.noreply.github.com> Date: Fri, 3 May 2024 18:11:07 +0200 Subject: [PATCH] Add billing (#329) --- Gemfile | 3 + Gemfile.lock | 2 + app/assets/stylesheets/application.sass.scss | 1 + .../stylesheets/common/_custom_texts.scss | 3 +- app/assets/stylesheets/common/_index.scss | 16 +- .../stylesheets/components/Billing.scss | 122 ++++++++++++ app/controllers/application_controller.rb | 9 + app/controllers/billing_controller.rb | 170 ++++++++++++++++ app/controllers/comments_controller.rb | 1 + app/controllers/follows_controller.rb | 1 + app/controllers/likes_controller.rb | 1 + app/controllers/posts_controller.rb | 1 + app/controllers/tenants_controller.rb | 5 + app/helpers/application_helper.rb | 10 + .../components/Billing/PricingTable.tsx | 90 +++++++++ app/javascript/components/Billing/Return.tsx | 60 ++++++ app/javascript/components/Billing/index.tsx | 186 ++++++++++++++++++ .../TenantSignUp/ConfirmSignUpPage.tsx | 2 +- .../TenantSignUp/TenantSignUpForm.tsx | 10 +- .../components/TenantSignUp/TenantSignUpP.tsx | 4 + .../components/TenantSignUp/index.tsx | 3 + app/javascript/interfaces/ITenantBilling.ts | 21 ++ app/mailers/application_mailer.rb | 12 +- app/mailers/tenant_mailer.rb | 100 ++++++++++ app/models/tenant.rb | 5 + app/models/tenant_billing.rb | 51 +++++ app/models/user.rb | 3 + app/views/billing/index.html.erb | 16 ++ app/views/billing/return.html.erb | 10 + .../layouts/_boards_nav_section.html.erb | 4 +- app/views/layouts/_header.html.erb | 30 ++- .../_no_active_subscription_banner.html.erb | 7 + app/views/layouts/application.html.erb | 4 + app/views/layouts/mailer_no_style.html.erb | 11 ++ .../cancellation_confirmation.html.erb | 19 ++ .../renewal_confirmation.html.erb | 15 ++ .../subscription_confirmation.html.erb | 15 ++ app/views/tenant_mailer/trial_end.html.erb | 28 +++ app/views/tenant_mailer/trial_mid.html.erb | 28 +++ app/views/tenant_mailer/trial_start.html.erb | 28 +++ app/views/tenants/new.html.erb | 1 + app/workflows/create_stripe_customer.rb | 15 ++ config/application.rb | 28 +++ config/environments/development.rb | 3 +- config/environments/production.rb | 3 +- config/initializers/reserved_subdomains.rb | 3 +- config/initializers/stripe.rb | 3 + config/locales/en.yml | 2 + config/routes.rb | 10 + .../20240419160353_create_tenant_billings.rb | 11 ++ ...34215_add_customer_id_to_tenant_billing.rb | 5 + ..._subscription_ends_at_to_tenant_billing.rb | 5 + ...d_slug_and_auth_token_to_tenant_billing.rb | 8 + db/schema.rb | 17 +- db/seeds.rb | 2 + docker-entrypoint.sh | 2 +- lib/tasks/notify_tenants_trial_period.rake | 45 +++++ package.json | 2 + spec/factories/tenant_billings.rb | 5 + .../mailers/previews/tenant_mailer_preview.rb | 33 ++++ spec/models/tenant_billing_spec.rb | 57 ++++++ spec/spec_helper.rb | 2 + yarn.lock | 12 ++ 63 files changed, 1354 insertions(+), 27 deletions(-) create mode 100644 app/assets/stylesheets/components/Billing.scss create mode 100644 app/controllers/billing_controller.rb create mode 100644 app/javascript/components/Billing/PricingTable.tsx create mode 100644 app/javascript/components/Billing/Return.tsx create mode 100644 app/javascript/components/Billing/index.tsx create mode 100644 app/javascript/interfaces/ITenantBilling.ts create mode 100644 app/mailers/tenant_mailer.rb create mode 100644 app/models/tenant_billing.rb create mode 100644 app/views/billing/index.html.erb create mode 100644 app/views/billing/return.html.erb create mode 100644 app/views/layouts/_no_active_subscription_banner.html.erb create mode 100644 app/views/layouts/mailer_no_style.html.erb create mode 100644 app/views/tenant_mailer/cancellation_confirmation.html.erb create mode 100644 app/views/tenant_mailer/renewal_confirmation.html.erb create mode 100644 app/views/tenant_mailer/subscription_confirmation.html.erb create mode 100644 app/views/tenant_mailer/trial_end.html.erb create mode 100644 app/views/tenant_mailer/trial_mid.html.erb create mode 100644 app/views/tenant_mailer/trial_start.html.erb create mode 100644 app/workflows/create_stripe_customer.rb create mode 100644 config/initializers/stripe.rb create mode 100644 db/migrate/20240419160353_create_tenant_billings.rb create mode 100644 db/migrate/20240420134215_add_customer_id_to_tenant_billing.rb create mode 100644 db/migrate/20240420180415_add_subscription_ends_at_to_tenant_billing.rb create mode 100644 db/migrate/20240427140300_add_slug_and_auth_token_to_tenant_billing.rb create mode 100644 lib/tasks/notify_tenants_trial_period.rake create mode 100644 spec/factories/tenant_billings.rb create mode 100644 spec/mailers/previews/tenant_mailer_preview.rb create mode 100644 spec/models/tenant_billing_spec.rb diff --git a/Gemfile b/Gemfile index 7d84fe43..6599d05c 100644 --- a/Gemfile +++ b/Gemfile @@ -47,6 +47,9 @@ gem 'rack-attack', '6.7.0' # Slugs gem 'friendly_id', '5.5.1' +# Billing +gem 'stripe', '11.2.0' + group :development, :test do gem 'byebug', platforms: [:mri, :mingw, :x64_mingw] diff --git a/Gemfile.lock b/Gemfile.lock index e82109f0..d3919152 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -252,6 +252,7 @@ GEM actionpack (>= 5.2) activesupport (>= 5.2) sprockets (>= 3.0.0) + stripe (11.2.0) thor (1.3.1) tilt (2.0.10) timeout (0.4.1) @@ -306,6 +307,7 @@ DEPENDENCIES selenium-webdriver (= 4.1.0) spring (= 2.1.1) spring-watcher-listen (= 2.0.1) + stripe (= 11.2.0) turbolinks (= 5.2.1) tzinfo-data web-console (>= 3.3.0) diff --git a/app/assets/stylesheets/application.sass.scss b/app/assets/stylesheets/application.sass.scss index 9e719d9b..5bf6b003 100644 --- a/app/assets/stylesheets/application.sass.scss +++ b/app/assets/stylesheets/application.sass.scss @@ -20,6 +20,7 @@ @import 'components/LikeButton'; @import 'components/Post'; @import 'components/Roadmap'; +@import 'components/Billing'; /* Site Settings Components */ @import 'components/SiteSettings'; diff --git a/app/assets/stylesheets/common/_custom_texts.scss b/app/assets/stylesheets/common/_custom_texts.scss index ab3689d5..d6ef7701 100644 --- a/app/assets/stylesheets/common/_custom_texts.scss +++ b/app/assets/stylesheets/common/_custom_texts.scss @@ -30,7 +30,8 @@ .smallMutedText { @extend .mutedText, - .m-0; + .mt-1, + .mb-0; font-size: smaller; } diff --git a/app/assets/stylesheets/common/_index.scss b/app/assets/stylesheets/common/_index.scss index 49119752..01e26c25 100644 --- a/app/assets/stylesheets/common/_index.scss +++ b/app/assets/stylesheets/common/_index.scss @@ -236,8 +236,14 @@ body { } .link { + color: var(--astuto-black); + text-decoration: underline; cursor: pointer; - &:hover { text-decoration: underline; } + + &:hover { + color: var(--astuto-black); + text-decoration: underline; + } } .actionLink { @@ -301,4 +307,12 @@ body { a { color: var(--astuto-grey); } a:hover { text-decoration: underline; } +} + +.noActiveSubscriptionBanner { + @extend + .alert, + .alert-warning, + .text-center, + .m-0; } \ No newline at end of file diff --git a/app/assets/stylesheets/components/Billing.scss b/app/assets/stylesheets/components/Billing.scss new file mode 100644 index 00000000..6464d319 --- /dev/null +++ b/app/assets/stylesheets/components/Billing.scss @@ -0,0 +1,122 @@ +.billingContainer { + max-width: 640px; + margin: 0 auto; + + h2 { @extend .mb-4; } + + .billingStatusBadge { + @extend + .badge, + .badgeLight, + .p-2, + .ml-1, + .mr-1; + + width: fit-content; + text-transform: uppercase; + } + + .billingStatusBadgeExpired { + color: white; + background-color: $danger; + } + + .pricingTable { + @extend + .d-flex, + .flex-column, + .mt-4, + .mb-4; + + h3 { text-align: center; } + + .pricingPlansNav { + @extend + .nav, + .nav-pills, + .align-self-center, + .px-2, + .py-1, + .mt-2; + + .yearlyPlanDiscount { + @extend .ml-2; + color: red; + } + + background-color: var(--astuto-grey-light); + border-radius: 0.5rem; + + li.nav-item { + width: 130px; + } + + a { + @extend + .px-3, + .py-1; + + color: var(--astuto-black); + cursor: pointer; + text-align: center; + + &::first-letter { text-transform: uppercase; } + } + + a.nav-link.active { + color: var(--astuto-black); + background-color: white; + box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1); + } + } + + .pricingTableColumn { + @extend + .card, + .my-2, + .p-4; + + width: 100%; + max-width: 400px; + margin: 0 auto; + + .priceContainer { + @extend + .d-flex, + .justify-content-between; + + .price { + .amount { font-size: 36px; } + } + .priceYearly { + @extend .align-self-end; + + .amount { font-size: 26px; } + } + + .price, .priceYearly { + .amount { font-weight: 700; } + .currency { text-transform: uppercase; } + } + } + } + } + + .checkoutContainer { + @extend .mt-4; + + a { display: block; text-align: center; } + + #checkout { @extend .my-2; } + } + + .billingUsefulLinks { + @extend + .d-flex, + .mt-4; + + margin: 0 auto; + + a { @extend .mx-2; } + } +} \ No newline at end of file diff --git a/app/controllers/application_controller.rb b/app/controllers/application_controller.rb index 6cfc7fca..93e1aaa2 100644 --- a/app/controllers/application_controller.rb +++ b/app/controllers/application_controller.rb @@ -35,6 +35,7 @@ class ApplicationController < ActionController::Base # Load tenant data @tenant = Current.tenant_or_raise! @tenant_setting = TenantSetting.first_or_create + @tenant_billing = TenantBilling.first_or_create @boards = Board.select(:id, :name, :slug).order(order: :asc) # Setup locale @@ -48,6 +49,14 @@ class ApplicationController < ActionController::Base .order(created_at: :asc) end + def check_tenant_subscription + return if Current.tenant.tenant_billing.has_active_subscription? + + render json: { + error: 'Your subscription has expired. Please renew it to continue using the service.' + }, status: :forbidden + end + private def user_not_authorized diff --git a/app/controllers/billing_controller.rb b/app/controllers/billing_controller.rb new file mode 100644 index 00000000..71fe1aa5 --- /dev/null +++ b/app/controllers/billing_controller.rb @@ -0,0 +1,170 @@ +require 'stripe' + +class BillingController < ApplicationController + before_action :check_multi_tenancy + before_action :authenticate_owner, only: :request_billing_page + before_action :set_tenant_on_billing_subdomain, only: [:create_checkout_session, :session_status] + skip_before_action :verify_authenticity_token, only: :webhook + + def request_billing_page + tb = Current.tenant.tenant_billing + tenant_id = tb.slug + auth_token = tb.generate_auth_token + + redirect_to billing_url(tenant_id: tenant_id, auth_token: auth_token) + end + + def index + return unless params[:tenant_id] and params[:auth_token] + + tb = TenantBilling.unscoped.find_by(slug: params[:tenant_id]) + Current.tenant = tb.tenant + + if is_current_user_owner? || tb.auth_token == params[:auth_token] + @page_title = t('billing.title') + load_tenant_data_for_billing + + # log in owner on "billing" subdomain + owner = Current.tenant.owner + sign_in owner + tb.invalidate_auth_token + + # get prices from stripe + @prices = Stripe::Price.list({ + lookup_keys: [ + Rails.application.stripe_monthly_lookup_key, + Rails.application.stripe_yearly_lookup_key + ], + active: true + }).data + @prices = @prices.sort_by { |price| price.unit_amount } + else + redirect_to get_url_for(method(:root_url)) + end + end + + def return + return unless params[:tenant_id] + + tb = TenantBilling.unscoped.find_by(slug: params[:tenant_id]) + Current.tenant = tb.tenant + + if is_current_user_owner? + @page_title = t('billing.title') + load_tenant_data_for_billing + else + redirect_to get_url_for(method(:root_url)) + end + end + + def create_checkout_session + session = Stripe::Checkout::Session.create({ + ui_mode: 'embedded', + line_items: [{ + price: params[:price_id], + quantity: 1, + }], + mode: 'subscription', + return_url: "#{billing_return_url}?session_id={CHECKOUT_SESSION_ID}&tenant_id=#{params[:tenant_id]}", + customer: Current.tenant.tenant_billing.customer_id, + }) + + render json: { clientSecret: session.client_secret } + end + + def session_status + session = Stripe::Checkout::Session.retrieve(params[:session_id]) + render json: { status: session.status, session: session } + end + + def webhook + event = nil + + begin + sig_header = request.env['HTTP_STRIPE_SIGNATURE'] + payload = request.body.read + event = Stripe::Webhook.construct_event(payload, sig_header, Rails.application.stripe_endpoint_secret) + rescue JSON::ParserError => e + # Invalid payload + return head :bad_request + rescue Stripe::SignatureVerificationError => e + # Invalid signature + return head :bad_request + end + + if event['type'] == 'invoice.paid' + Current.tenant = get_tenant_from_customer_id(event.data.object.customer) + + monthly_lookup_key = Rails.application.stripe_monthly_lookup_key + yearly_lookup_key = Rails.application.stripe_yearly_lookup_key + + subscription_type = event.data.object.lines.data.last.price.lookup_key + return head :bad_request unless subscription_type == monthly_lookup_key || subscription_type == yearly_lookup_key + + old_subscription_status = Current.tenant.tenant_billing.status + + subscription_duration = subscription_type == monthly_lookup_key ? 1.month : 1.year + Current.tenant.tenant_billing.update!( + status: 'active', + subscription_ends_at: Time.current + subscription_duration + ) + + if old_subscription_status == 'trial' + TenantMailer.subscription_confirmation(tenant: Current.tenant).deliver_later + end + elsif event['type'] == 'customer.subscription.updated' + Current.tenant = get_tenant_from_customer_id(event.data.object.customer) + + if Current.tenant.tenant_billing.status == 'active' || Current.tenant.tenant_billing.status == 'canceled' + has_canceled = event.data.object.cancel_at_period_end + Current.tenant.tenant_billing.update!(status: has_canceled ? 'canceled' : 'active') + + if has_canceled + TenantMailer.cancellation_confirmation(tenant: Current.tenant).deliver_later + else + TenantMailer.renewal_confirmation(tenant: Current.tenant).deliver_later + end + end + end + + return head :ok + end + + private + + def check_multi_tenancy + redirect_to root_path unless Rails.application.multi_tenancy? + end + + def get_tenant_from_customer_id(customer_id) + TenantBilling.unscoped.find_by(customer_id: customer_id).tenant + end + + def is_current_user_owner? + user_signed_in? && current_user.tenant_id == Current.tenant.id && current_user.owner? + end + + def set_tenant_on_billing_subdomain + tb = TenantBilling.unscoped.find_by(slug: params[:tenant_id]) + Current.tenant = tb.tenant + + unless is_current_user_owner? + render json: { + error: t('errors.unauthorized') + }, status: :unauthorized + return + end + end + + def load_tenant_data_for_billing + # needed because ApplicationController#load_tenant_data is not called for this action + @tenant = Current.tenant + @tenant_setting = @tenant.tenant_setting + @tenant_billing = @tenant.tenant_billing + @boards = Board.select(:id, :name, :slug).order(order: :asc) + I18n.locale = @tenant.locale + + # needed because signing out from 'billing' subdomain cause authenticity token error + @disable_sign_out = true + end +end \ No newline at end of file diff --git a/app/controllers/comments_controller.rb b/app/controllers/comments_controller.rb index 015be6ae..3b2b5af9 100644 --- a/app/controllers/comments_controller.rb +++ b/app/controllers/comments_controller.rb @@ -1,5 +1,6 @@ class CommentsController < ApplicationController before_action :authenticate_user!, only: [:create, :update, :destroy] + before_action :check_tenant_subscription, only: [:create, :update, :destroy] def index comments = Comment diff --git a/app/controllers/follows_controller.rb b/app/controllers/follows_controller.rb index 0036f965..30b53630 100644 --- a/app/controllers/follows_controller.rb +++ b/app/controllers/follows_controller.rb @@ -1,5 +1,6 @@ class FollowsController < ApplicationController before_action :authenticate_user!, only: [:create, :destroy] + before_action :check_tenant_subscription, only: [:create, :destroy] def index unless user_signed_in? diff --git a/app/controllers/likes_controller.rb b/app/controllers/likes_controller.rb index f87210f4..a8f9904f 100644 --- a/app/controllers/likes_controller.rb +++ b/app/controllers/likes_controller.rb @@ -1,5 +1,6 @@ class LikesController < ApplicationController before_action :authenticate_user!, only: [:create, :destroy] + before_action :check_tenant_subscription, only: [:create, :destroy] def index likes = Like diff --git a/app/controllers/posts_controller.rb b/app/controllers/posts_controller.rb index 698839a4..89c0f686 100644 --- a/app/controllers/posts_controller.rb +++ b/app/controllers/posts_controller.rb @@ -1,5 +1,6 @@ class PostsController < ApplicationController before_action :authenticate_user!, only: [:create, :update, :destroy] + before_action :check_tenant_subscription, only: [:create, :update, :destroy] def index start_date = params[:start_date] ? Date.parse(params[:start_date]) : Date.parse('1970-01-01') diff --git a/app/controllers/tenants_controller.rb b/app/controllers/tenants_controller.rb index f6c10573..84084f6b 100644 --- a/app/controllers/tenants_controller.rb +++ b/app/controllers/tenants_controller.rb @@ -53,6 +53,11 @@ class TenantsController < ApplicationController CreateWelcomeEntitiesWorkflow.new().run + if is_o_auth_login + CreateStripeCustomer.new().run + TenantMailer.trial_start(tenant: @tenant).deliver_later + end + logger.info { "New tenant registration: #{Current.tenant.inspect}" } render json: @tenant, status: :created diff --git a/app/helpers/application_helper.rb b/app/helpers/application_helper.rb index d8d8977c..325992c2 100644 --- a/app/helpers/application_helper.rb +++ b/app/helpers/application_helper.rb @@ -8,6 +8,16 @@ module ApplicationHelper end end + def authenticate_owner + return if check_user_signed_in == false + + unless current_user.owner? + flash[:alert] = t('errors.not_enough_privileges') + redirect_to root_path + return + end + end + def authenticate_admin return if check_user_signed_in == false diff --git a/app/javascript/components/Billing/PricingTable.tsx b/app/javascript/components/Billing/PricingTable.tsx new file mode 100644 index 00000000..2018021c --- /dev/null +++ b/app/javascript/components/Billing/PricingTable.tsx @@ -0,0 +1,90 @@ +import * as React from 'react'; + +interface Props { + prices: Array; + currentPrice: string; + setCurrentPrice: (priceId: string) => void; + setChosenPrice: (priceId: string) => void; + stripeMonthlyLookupKey: string; + stripeYearlyLookupKey: string; +} + +const PricingTable = ({ + prices, + currentPrice, + setCurrentPrice, + setChosenPrice, + stripeMonthlyLookupKey, + stripeYearlyLookupKey, +}: Props) => { + const monthlyPlanUnitAmount = prices.find(p => p.lookup_key === stripeMonthlyLookupKey).unit_amount; + const yearlyPlanUnitAmount = prices.find(p => p.lookup_key === stripeYearlyLookupKey).unit_amount; + const yearlyPlanDiscount = 1 - yearlyPlanUnitAmount / (monthlyPlanUnitAmount*12) + + return ( +
+

Choose your plan

+ + + { + prices && prices.filter(price => price.id === currentPrice).map((price) => ( +
+

{ price.lookup_key === stripeMonthlyLookupKey ? 'Monthly subscription' : 'Yearly subscription' }

+ +
+

+ {price.unit_amount / 100.0} +   + {price.currency} +  /  + {price.recurring.interval} +

+ + { + price.lookup_key === stripeYearlyLookupKey && +

+ ( + {price.unit_amount / 100.0 / 12} +   + {price.currency} +  /  + month + ) +

+ } +
+ +

+ For most small-medium organizations.
+ Bigger organizations can contact us for a custom plan. +

+ +
    +
  • All features
  • +
  • Unlimited feedback
  • +
  • Unlimited boards
  • +
+ + +
+ )) + } +
+ ); +}; + +export default PricingTable; \ No newline at end of file diff --git a/app/javascript/components/Billing/Return.tsx b/app/javascript/components/Billing/Return.tsx new file mode 100644 index 00000000..109697ae --- /dev/null +++ b/app/javascript/components/Billing/Return.tsx @@ -0,0 +1,60 @@ +import * as React from 'react'; +import { useEffect, useState } from "react"; +import Box from '../common/Box'; +import ActionLink from '../common/ActionLink'; +import { BackIcon } from '../common/Icons'; +import ITenantBilling from '../../interfaces/ITenantBilling'; + +interface Props { + tenantBilling: ITenantBilling; + homeUrl: string; + billingUrl: string; +} + +const Return = ({ tenantBilling, homeUrl, billingUrl }: Props) => { + const [status, setStatus] = useState(null); + const [session, setSession] = useState(null); + + useEffect(() => { + const queryString = window.location.search; + const urlParams = new URLSearchParams(queryString); + const sessionId = urlParams.get('session_id'); + + fetch(`/session_status?session_id=${sessionId}&tenant_id=${tenantBilling.slug}`) + .then(res => res.json()) + .then(data => { + setStatus(data.status); + setSession(data.session); + }); + }, []); + + if (status === 'open') { + return ( + +

Error

+

Unfortunately, there was an error processing your payment. Please try again.

+ + window.location.href = billingUrl} icon={}> + Back to billing + +
+ ) + } + + if (status === 'complete') { + return ( + +

Success

+

Thank you for choosing Astuto! Your subscription will be activated shortly.

+ + window.location.href = homeUrl} icon={}> + Back to home + +
+ ) + } + + return null; +} + +export default Return; \ No newline at end of file diff --git a/app/javascript/components/Billing/index.tsx b/app/javascript/components/Billing/index.tsx new file mode 100644 index 00000000..083ab0fd --- /dev/null +++ b/app/javascript/components/Billing/index.tsx @@ -0,0 +1,186 @@ +import * as React from 'react'; +import I18n from 'i18n-js'; +import { loadStripe } from '@stripe/stripe-js'; + +import ITenantBilling, { TENANT_BILLING_STATUS_ACTIVE, TENANT_BILLING_STATUS_CANCELED, TENANT_BILLING_STATUS_TRIAL } from '../../interfaces/ITenantBilling'; +import Box from '../common/Box'; +import { EmbeddedCheckout, EmbeddedCheckoutProvider } from '@stripe/react-stripe-js'; +import buildRequestHeaders from '../../helpers/buildRequestHeaders'; +import { SmallMutedText } from '../common/CustomTexts'; +import ActionLink from '../common/ActionLink'; +import { BackIcon, LearnMoreIcon } from '../common/Icons'; +import PricingTable from './PricingTable'; + +interface Props { + tenantBilling: ITenantBilling; + prices: Array; + createCheckoutSessionUrl: string; + billingUrl: string; + manageSubscriptionUrl: string; + stripeMonthlyLookupKey: string; + stripeYearlyLookupKey: string; + stripePublicKey: string; + authenticityToken: string; +} + +const Billing = ({ + tenantBilling, + prices, + createCheckoutSessionUrl, + billingUrl, + manageSubscriptionUrl, + stripeMonthlyLookupKey, + stripeYearlyLookupKey, + stripePublicKey, + authenticityToken, +}: Props) => { + const [stripePromise, setStripePromise] = React.useState(null); + const [currentPrice, setCurrentPrice] = React.useState(null); + const [chosenPrice, setChosenPrice] = React.useState(null); + const [showBackLink, setShowBackLink] = React.useState(false); + + React.useEffect(() => { + setStripePromise(loadStripe(stripePublicKey)); + }, []); + + React.useEffect(() => { + if (prices && prices.length > 0) { + setCurrentPrice(prices[0].id); + } + }, [prices]); + + const fetchClientSecret = React.useCallback(() => { + // Create a Checkout Session + return fetch(`${createCheckoutSessionUrl}?price_id=${chosenPrice}&tenant_id=${tenantBilling.slug}`, { + method: "POST", + headers: buildRequestHeaders(authenticityToken), + }) + .then((res) => res.json()) + .then((data) => data.clientSecret); + }, [chosenPrice]); + + React.useEffect(() => { + if (chosenPrice) { + // scroll to checkout + const checkoutElement = document.getElementById('checkout'); + setTimeout(() => { + checkoutElement.scrollIntoView({behavior: 'smooth'}); + }, 300); + + // show back link after 5 seconds + const timer = setTimeout(() => { + setShowBackLink(true); + }, 5000); + + return () => clearTimeout(timer); // cleanup on unmount or when chosenPrice changes + } else { + setShowBackLink(false); // reset state when chosenPrice becomes null + } + }, [chosenPrice]); + + const options = {fetchClientSecret}; + + const currentTime = new Date(); + const trialEndsAt = new Date(tenantBilling.trial_ends_at); + const trialEndsAtFormatted = trialEndsAt.toLocaleString(undefined, {year: 'numeric', month: 'long', day: 'numeric', hour: '2-digit', minute: '2-digit'}); + const subscriptionEndsAt = new Date(tenantBilling.subscription_ends_at); + const subscriptionEndsAtFormatted = subscriptionEndsAt.toLocaleString(undefined, {year: 'numeric', month: 'long', day: 'numeric', hour: '2-digit', minute: '2-digit'}); + + const isExpired = ( + (tenantBilling.status === TENANT_BILLING_STATUS_TRIAL && trialEndsAt < currentTime) || + ((tenantBilling.status === TENANT_BILLING_STATUS_ACTIVE || tenantBilling.status === TENANT_BILLING_STATUS_CANCELED) && subscriptionEndsAt < currentTime) + ); + + return ( + +

{ I18n.t('billing.title') }

+ +

+ {tenantBilling.status} + { isExpired && Expired } +

+ + { + tenantBilling.status === TENANT_BILLING_STATUS_TRIAL && +

Trial {isExpired ? 'expired' : 'expires'} on {trialEndsAtFormatted}

+ } + + { + tenantBilling.status === TENANT_BILLING_STATUS_TRIAL && isExpired && +

Your trial has expired. Please choose a subscription plan to continue using the service.

+ } + + { + tenantBilling.status === TENANT_BILLING_STATUS_ACTIVE && +

Subscription {isExpired ? 'expired' : 'renews'} on {subscriptionEndsAtFormatted}

+ } + + { + tenantBilling.status === TENANT_BILLING_STATUS_ACTIVE && isExpired && +

Your subscription has expired because automatic renewal failed. Please update your payment details by managing your subscription.

+ } + + { + tenantBilling.status === TENANT_BILLING_STATUS_CANCELED && +

Subscription {isExpired ? 'expired' : 'expires'} on {subscriptionEndsAtFormatted}

+ } + + { + (tenantBilling.status === TENANT_BILLING_STATUS_TRIAL) && chosenPrice === null && + + } + + { + chosenPrice && +
+ { showBackLink ? + window.location.href = billingUrl} icon={}> + Choose another plan + + : +
+ } + +
+ + + +
+
+ } + + { + (tenantBilling.status === TENANT_BILLING_STATUS_ACTIVE || tenantBilling.status === TENANT_BILLING_STATUS_CANCELED) && +
+ + + You will be redirected to Stripe, our billing partner. + +
+ } + +
+ window.open('https://astuto.io/terms-of-service', '_blank')} icon={}> + Terms of Service + + window.open('https://astuto.io/privacy-policy', '_blank')} icon={}> + Privacy Policy + +
+
+ ); +} + +export default Billing; \ No newline at end of file diff --git a/app/javascript/components/TenantSignUp/ConfirmSignUpPage.tsx b/app/javascript/components/TenantSignUp/ConfirmSignUpPage.tsx index 5253a419..132686f0 100644 --- a/app/javascript/components/TenantSignUp/ConfirmSignUpPage.tsx +++ b/app/javascript/components/TenantSignUp/ConfirmSignUpPage.tsx @@ -18,7 +18,7 @@ const ConfirmSignUpPage = ({

- Check your email {userEmail} to activate your new feedback space {subdomain}.astuto.io! + Check your email {userEmail} to activate your new feedback space {`${subdomain}.astuto.io`}!

); diff --git a/app/javascript/components/TenantSignUp/TenantSignUpForm.tsx b/app/javascript/components/TenantSignUp/TenantSignUpForm.tsx index 19d18c26..78868509 100644 --- a/app/javascript/components/TenantSignUp/TenantSignUpForm.tsx +++ b/app/javascript/components/TenantSignUp/TenantSignUpForm.tsx @@ -4,7 +4,7 @@ import { SubmitHandler, useForm } from 'react-hook-form'; import Box from '../common/Box'; import Button from '../common/Button'; import Spinner from '../common/Spinner'; -import { DangerText } from '../common/CustomTexts'; +import { DangerText, SmallMutedText } from '../common/CustomTexts'; import { ITenantSignUpTenantForm } from './TenantSignUpP'; import HttpStatus from '../../constants/http_status'; import { getLabel, getValidationMessage } from '../../helpers/formUtils'; @@ -13,12 +13,14 @@ interface Props { isSubmitting: boolean; error: string; handleSignUpSubmit(siteName: string, subdomain: string): void; + trialPeriodDays: number; } const TenantSignUpForm = ({ isSubmitting, error, handleSignUpSubmit, + trialPeriodDays, }: Props) => { const { register, handleSubmit, formState: { errors } } = useForm(); const onSubmit: SubmitHandler = data => { @@ -80,6 +82,12 @@ const TenantSignUpForm = ({ > { isSubmitting ? : 'Create feedback space' } +

+ Your trial starts now and ends in {trialPeriodDays.toString()} days. +

+

+ By clicking "Create", you agree to our Terms of Service and Privacy Policy. +

{ error !== '' && { error } } diff --git a/app/javascript/components/TenantSignUp/TenantSignUpP.tsx b/app/javascript/components/TenantSignUp/TenantSignUpP.tsx index d5289f55..e25c263c 100644 --- a/app/javascript/components/TenantSignUp/TenantSignUpP.tsx +++ b/app/javascript/components/TenantSignUp/TenantSignUpP.tsx @@ -30,6 +30,8 @@ interface Props { pendingTenantImage: string; baseUrl: string; + trialPeriodDays: number; + authenticityToken: string; } @@ -58,6 +60,7 @@ const TenantSignUpP = ({ astutoLogoImage, pendingTenantImage, baseUrl, + trialPeriodDays, authenticityToken }: Props) => { // authMethod is either 'none', 'email' or 'oauth' @@ -124,6 +127,7 @@ const TenantSignUpP = ({ isSubmitting={isSubmitting} error={error} handleSignUpSubmit={handleSignUpSubmit} + trialPeriodDays={trialPeriodDays} /> } diff --git a/app/javascript/components/TenantSignUp/index.tsx b/app/javascript/components/TenantSignUp/index.tsx index 621bb93d..4db57a4d 100644 --- a/app/javascript/components/TenantSignUp/index.tsx +++ b/app/javascript/components/TenantSignUp/index.tsx @@ -15,6 +15,7 @@ interface Props { baseUrl: string; astutoLogoImage: string; pendingTenantImage: string; + trialPeriodDays: number; authenticityToken: string; } @@ -36,6 +37,7 @@ class TenantSignUpRoot extends React.Component { astutoLogoImage, pendingTenantImage, baseUrl, + trialPeriodDays, authenticityToken, } = this.props; @@ -49,6 +51,7 @@ class TenantSignUpRoot extends React.Component { astutoLogoImage={astutoLogoImage} pendingTenantImage={pendingTenantImage} baseUrl={baseUrl} + trialPeriodDays={trialPeriodDays} authenticityToken={authenticityToken} /> diff --git a/app/javascript/interfaces/ITenantBilling.ts b/app/javascript/interfaces/ITenantBilling.ts new file mode 100644 index 00000000..93dc6dd0 --- /dev/null +++ b/app/javascript/interfaces/ITenantBilling.ts @@ -0,0 +1,21 @@ +// Status +export const TENANT_BILLING_STATUS_TRIAL = 'trial'; +export const TENANT_BILLING_STATUS_ACTIVE = 'active'; +export const TENANT_BILLING_STATUS_CANCELED = 'canceled'; +export const TENANT_BILLING_STATUS_PERPETUAL = 'perpetual'; + +export type TenantBillingStatus = + typeof TENANT_BILLING_STATUS_TRIAL | + typeof TENANT_BILLING_STATUS_ACTIVE | + typeof TENANT_BILLING_STATUS_CANCELED | + typeof TENANT_BILLING_STATUS_PERPETUAL; + +interface ITenantBilling { + trial_ends_at: string; + subscription_ends_at: string; + status: TenantBillingStatus; + customer_id: string; + slug: string; +} + +export default ITenantBilling; \ No newline at end of file diff --git a/app/mailers/application_mailer.rb b/app/mailers/application_mailer.rb index 969bd54c..43e7e673 100644 --- a/app/mailers/application_mailer.rb +++ b/app/mailers/application_mailer.rb @@ -3,13 +3,11 @@ class ApplicationMailer < ActionMailer::Base helper :application after_action :set_mail_from_for_multitenancy - - private - def set_mail_from_for_multitenancy - if Rails.application.multi_tenancy? - from = "#{Current.tenant_or_raise!.site_name} <#{ENV.fetch('EMAIL_MAIL_FROM', 'notifications@astuto.io')}>" - mail.from = from - end + def set_mail_from_for_multitenancy + if Rails.application.multi_tenancy? + from = "#{Current.tenant_or_raise!.site_name} <#{ENV.fetch('EMAIL_MAIL_FROM', 'notifications@astuto.io')}>" + mail.from = from end + end end diff --git a/app/mailers/tenant_mailer.rb b/app/mailers/tenant_mailer.rb new file mode 100644 index 00000000..a297d5d1 --- /dev/null +++ b/app/mailers/tenant_mailer.rb @@ -0,0 +1,100 @@ +class TenantMailer < ApplicationMailer + layout :choose_layout + skip_after_action :set_mail_from_for_multitenancy + + def trial_start(tenant:) + @tenant = tenant + Current.tenant = tenant + + mail( + from: email_from_riccardo, + reply_to: email_from_riccardo, + to: tenant.owner.email, + subject: "Welcome to Astuto!", + ) + end + + def trial_mid(tenant:) + return unless Rails.application.multi_tenancy? + + @tenant = tenant + Current.tenant = tenant + + @trial_ends_at = tenant.tenant_billing.trial_ends_at + + mail( + from: email_from_riccardo, + reply_to: email_from_riccardo, + to: tenant.owner.email, + subject: "How is it going?", + ) + end + + def trial_end(tenant:) + return unless Rails.application.multi_tenancy? + + @tenant = tenant + Current.tenant = tenant + + mail( + from: email_from_riccardo, + reply_to: email_from_riccardo, + to: tenant.owner.email, + subject: "Your Astuto trial has ended", + ) + end + + def subscription_confirmation(tenant:) + return unless Rails.application.multi_tenancy? + + @tenant = tenant + Current.tenant = tenant + + mail( + from: email_from_astuto, + to: tenant.owner.email, + subject: "Astuto subscription confirmation" + ) + end + + def cancellation_confirmation(tenant:) + return unless Rails.application.multi_tenancy? + + @tenant = tenant + Current.tenant = tenant + @subscription_ends_at = Current.tenant.tenant_billing.subscription_ends_at + + mail( + from: email_from_astuto, + to: tenant.owner.email, + subject: "Astuto subscription cancellation" + ) + end + + def renewal_confirmation(tenant:) + return unless Rails.application.multi_tenancy? + + @tenant = tenant + Current.tenant = tenant + + mail( + from: email_from_astuto, + to: tenant.owner.email, + subject: "Astuto subscription renewal" + ) + end + + private + + def email_from_riccardo + "Riccardo from Astuto " + end + + def email_from_astuto + "Astuto " + end + + def choose_layout + action_name == 'trial_mid' || action_name == 'trial_end' ? 'mailer_no_style' : 'mailer' + end +end \ No newline at end of file diff --git a/app/models/tenant.rb b/app/models/tenant.rb index b1ee7d4b..bf5b38bf 100644 --- a/app/models/tenant.rb +++ b/app/models/tenant.rb @@ -1,5 +1,6 @@ class Tenant < ApplicationRecord has_one :tenant_setting, dependent: :destroy + has_one :tenant_billing, dependent: :destroy has_many :boards, dependent: :destroy has_many :post_statuses, dependent: :destroy has_many :posts, dependent: :destroy @@ -30,4 +31,8 @@ class Tenant < ApplicationRecord def downcase_subdomain self.subdomain = self.subdomain.downcase end + + def owner + users.find_by(role: 'owner') + end end diff --git a/app/models/tenant_billing.rb b/app/models/tenant_billing.rb new file mode 100644 index 00000000..a51b5048 --- /dev/null +++ b/app/models/tenant_billing.rb @@ -0,0 +1,51 @@ +class TenantBilling < ApplicationRecord + include TenantOwnable + extend FriendlyId + + friendly_id :generate_random_slug, use: :slugged + + belongs_to :tenant + + before_create :set_trial_ends_at + before_create :set_subscription_ends_at + + enum status: [ + :trial, + :active, + :canceled, + :perpetual + ] + + def has_active_subscription? + perpetual? || (active? && subscription_ends_at+1.day > Time.current) || (canceled? && subscription_ends_at > Time.current) || (trial? && trial_ends_at > Time.current) + end + + def generate_auth_token + self.auth_token = SecureRandom.urlsafe_base64 + self.save! + auth_token + end + + def invalidate_auth_token + self.auth_token = nil + self.save! + end + + private + + def set_trial_ends_at + self.trial_ends_at = Time.current + Rails.application.trial_period_days + end + + def set_subscription_ends_at + self.subscription_ends_at = Time.current + end + + def generate_random_slug + loop do + self.slug = SecureRandom.hex(16) + break unless self.class.exists?(slug: slug) + end + slug + end +end diff --git a/app/models/user.rb b/app/models/user.rb index af4ef642..70544ca7 100644 --- a/app/models/user.rb +++ b/app/models/user.rb @@ -48,6 +48,9 @@ class User < ApplicationRecord if tenant.status == "pending" and tenant.users.count == 1 tenant.status = "active" tenant.save + + CreateStripeCustomer.new().run + TenantMailer.trial_start(tenant: tenant).deliver_later end end diff --git a/app/views/billing/index.html.erb b/app/views/billing/index.html.erb new file mode 100644 index 00000000..1db38d27 --- /dev/null +++ b/app/views/billing/index.html.erb @@ -0,0 +1,16 @@ +<%= + react_component( + 'Billing', + { + tenantBilling: @tenant.tenant_billing, + prices: @prices, + createCheckoutSessionUrl: create_checkout_session_url, + billingUrl: get_url_for(method(:request_billing_page_url)), + manageSubscriptionUrl: Rails.application.stripe_manage_subscription_url, + stripeMonthlyLookupKey: Rails.application.stripe_monthly_lookup_key, + stripeYearlyLookupKey: Rails.application.stripe_yearly_lookup_key, + stripePublicKey: Rails.application.stripe_public_key, + authenticityToken: form_authenticity_token + } + ) +%> \ No newline at end of file diff --git a/app/views/billing/return.html.erb b/app/views/billing/return.html.erb new file mode 100644 index 00000000..6a842eaa --- /dev/null +++ b/app/views/billing/return.html.erb @@ -0,0 +1,10 @@ +<%= + react_component( + 'Billing/Return', + { + tenantBilling: @tenant.tenant_billing, + homeUrl: get_url_for(method(:root_url)), + billingUrl: get_url_for(method(:request_billing_page_url)), + } + ) +%> \ No newline at end of file diff --git a/app/views/layouts/_boards_nav_section.html.erb b/app/views/layouts/_boards_nav_section.html.erb index e3b2a0c5..a09c388b 100644 --- a/app/views/layouts/_boards_nav_section.html.erb +++ b/app/views/layouts/_boards_nav_section.html.erb @@ -6,14 +6,14 @@ <% else %> <% boards.each do |board| %> <% end %> <% end %> \ No newline at end of file diff --git a/app/views/layouts/_header.html.erb b/app/views/layouts/_header.html.erb index d390cf10..1a03f680 100644 --- a/app/views/layouts/_header.html.erb +++ b/app/views/layouts/_header.html.erb @@ -1,7 +1,7 @@