mirror of
https://github.com/astuto/astuto.git
synced 2025-12-14 18:57:51 +01:00
Add billing (#329)
This commit is contained in:
committed by
GitHub
parent
fc36c967af
commit
bea146e612
3
Gemfile
3
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]
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -20,6 +20,7 @@
|
||||
@import 'components/LikeButton';
|
||||
@import 'components/Post';
|
||||
@import 'components/Roadmap';
|
||||
@import 'components/Billing';
|
||||
|
||||
/* Site Settings Components */
|
||||
@import 'components/SiteSettings';
|
||||
|
||||
@@ -30,7 +30,8 @@
|
||||
.smallMutedText {
|
||||
@extend
|
||||
.mutedText,
|
||||
.m-0;
|
||||
.mt-1,
|
||||
.mb-0;
|
||||
|
||||
font-size: smaller;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
122
app/assets/stylesheets/components/Billing.scss
Normal file
122
app/assets/stylesheets/components/Billing.scss
Normal file
@@ -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; }
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
170
app/controllers/billing_controller.rb
Normal file
170
app/controllers/billing_controller.rb
Normal file
@@ -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
|
||||
@@ -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
|
||||
|
||||
@@ -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?
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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')
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
90
app/javascript/components/Billing/PricingTable.tsx
Normal file
90
app/javascript/components/Billing/PricingTable.tsx
Normal file
@@ -0,0 +1,90 @@
|
||||
import * as React from 'react';
|
||||
|
||||
interface Props {
|
||||
prices: Array<any>;
|
||||
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 (
|
||||
<div className="pricingTable">
|
||||
<h3>Choose your plan</h3>
|
||||
|
||||
<ul className="pricingPlansNav">
|
||||
{
|
||||
prices && prices.map((price) => (
|
||||
<li key={price.id} className="nav-item">
|
||||
<a className={`nav-link${currentPrice === price.id ? ' active' : ''}`} onClick={() => setCurrentPrice(price.id)}>
|
||||
{price.lookup_key}
|
||||
{
|
||||
price.lookup_key === stripeYearlyLookupKey &&
|
||||
<span className="yearlyPlanDiscount">-{yearlyPlanDiscount * 100}%</span>
|
||||
}
|
||||
</a>
|
||||
</li>
|
||||
))
|
||||
}
|
||||
</ul>
|
||||
{
|
||||
prices && prices.filter(price => price.id === currentPrice).map((price) => (
|
||||
<div key={price.id} className="pricingTableColumn">
|
||||
<h4>{ price.lookup_key === stripeMonthlyLookupKey ? 'Monthly subscription' : 'Yearly subscription' }</h4>
|
||||
|
||||
<div className="priceContainer">
|
||||
<p className="price">
|
||||
<span className="amount">{price.unit_amount / 100.0}</span>
|
||||
|
||||
<span className="currency">{price.currency}</span>
|
||||
/
|
||||
<span className="period">{price.recurring.interval}</span>
|
||||
</p>
|
||||
|
||||
{
|
||||
price.lookup_key === stripeYearlyLookupKey &&
|
||||
<p className="priceYearly">
|
||||
(
|
||||
<span className="amount">{price.unit_amount / 100.0 / 12}</span>
|
||||
|
||||
<span className="currency">{price.currency}</span>
|
||||
/
|
||||
<span className="period">month</span>
|
||||
)
|
||||
</p>
|
||||
}
|
||||
</div>
|
||||
|
||||
<p className="description">
|
||||
For most small-medium organizations.<br />
|
||||
Bigger organizations can <a className="link" href="mailto:info@astuto.io">contact us</a> for a custom plan.
|
||||
</p>
|
||||
|
||||
<ul className="features">
|
||||
<li>All features</li>
|
||||
<li>Unlimited feedback</li>
|
||||
<li>Unlimited boards</li>
|
||||
</ul>
|
||||
|
||||
<button onClick={() => setChosenPrice(price.id)} className="btnPrimary">Subscribe</button>
|
||||
</div>
|
||||
))
|
||||
}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default PricingTable;
|
||||
60
app/javascript/components/Billing/Return.tsx
Normal file
60
app/javascript/components/Billing/Return.tsx
Normal file
@@ -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 (
|
||||
<Box customClass="billingContainer">
|
||||
<h2>Error</h2>
|
||||
<p>Unfortunately, there was an error processing your payment. Please try again.</p>
|
||||
|
||||
<ActionLink onClick={() => window.location.href = billingUrl} icon={<BackIcon />}>
|
||||
Back to billing
|
||||
</ActionLink>
|
||||
</Box>
|
||||
)
|
||||
}
|
||||
|
||||
if (status === 'complete') {
|
||||
return (
|
||||
<Box customClass="billingContainer">
|
||||
<h2>Success</h2>
|
||||
<p>Thank you for choosing Astuto! Your subscription will be activated shortly.</p>
|
||||
|
||||
<ActionLink onClick={() => window.location.href = homeUrl} icon={<BackIcon />}>
|
||||
Back to home
|
||||
</ActionLink>
|
||||
</Box>
|
||||
)
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
export default Return;
|
||||
186
app/javascript/components/Billing/index.tsx
Normal file
186
app/javascript/components/Billing/index.tsx
Normal file
@@ -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<any>;
|
||||
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 (
|
||||
<Box customClass="billingContainer">
|
||||
<h2>{ I18n.t('billing.title') }</h2>
|
||||
|
||||
<p>
|
||||
<span className="billingStatusBadge">{tenantBilling.status}</span>
|
||||
{ isExpired && <span className="billingStatusBadge billingStatusBadgeExpired">Expired</span> }
|
||||
</p>
|
||||
|
||||
{
|
||||
tenantBilling.status === TENANT_BILLING_STATUS_TRIAL &&
|
||||
<p>Trial {isExpired ? 'expired' : 'expires'} on <b>{trialEndsAtFormatted}</b></p>
|
||||
}
|
||||
|
||||
{
|
||||
tenantBilling.status === TENANT_BILLING_STATUS_TRIAL && isExpired &&
|
||||
<p>Your trial has expired. Please choose a subscription plan to continue using the service.</p>
|
||||
}
|
||||
|
||||
{
|
||||
tenantBilling.status === TENANT_BILLING_STATUS_ACTIVE &&
|
||||
<p>Subscription {isExpired ? 'expired' : 'renews'} on {subscriptionEndsAtFormatted}</p>
|
||||
}
|
||||
|
||||
{
|
||||
tenantBilling.status === TENANT_BILLING_STATUS_ACTIVE && isExpired &&
|
||||
<p>Your subscription has expired because automatic renewal failed. Please update your payment details by managing your subscription.</p>
|
||||
}
|
||||
|
||||
{
|
||||
tenantBilling.status === TENANT_BILLING_STATUS_CANCELED &&
|
||||
<p>Subscription {isExpired ? 'expired' : 'expires'} on {subscriptionEndsAtFormatted}</p>
|
||||
}
|
||||
|
||||
{
|
||||
(tenantBilling.status === TENANT_BILLING_STATUS_TRIAL) && chosenPrice === null &&
|
||||
<PricingTable
|
||||
prices={prices}
|
||||
currentPrice={currentPrice}
|
||||
setCurrentPrice={setCurrentPrice}
|
||||
setChosenPrice={setChosenPrice}
|
||||
stripeMonthlyLookupKey={stripeMonthlyLookupKey}
|
||||
stripeYearlyLookupKey={stripeYearlyLookupKey}
|
||||
/>
|
||||
}
|
||||
|
||||
{
|
||||
chosenPrice &&
|
||||
<div className="checkoutContainer">
|
||||
{ showBackLink ?
|
||||
<ActionLink onClick={() => window.location.href = billingUrl} icon={<BackIcon />}>
|
||||
Choose another plan
|
||||
</ActionLink>
|
||||
:
|
||||
<br />
|
||||
}
|
||||
|
||||
<div id="checkout">
|
||||
<EmbeddedCheckoutProvider
|
||||
stripe={stripePromise}
|
||||
options={options}
|
||||
>
|
||||
<EmbeddedCheckout />
|
||||
</EmbeddedCheckoutProvider>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
|
||||
{
|
||||
(tenantBilling.status === TENANT_BILLING_STATUS_ACTIVE || tenantBilling.status === TENANT_BILLING_STATUS_CANCELED) &&
|
||||
<div className="billingAction">
|
||||
<button className="btnPrimary" onClick={() => window.open(manageSubscriptionUrl, '_blank')}>
|
||||
{tenantBilling.status === TENANT_BILLING_STATUS_ACTIVE ? 'Manage subscription' : 'Renew subscription'}
|
||||
</button>
|
||||
<SmallMutedText>
|
||||
You will be redirected to Stripe, our billing partner.
|
||||
</SmallMutedText>
|
||||
</div>
|
||||
}
|
||||
|
||||
<div className="billingUsefulLinks">
|
||||
<ActionLink onClick={() => window.open('https://astuto.io/terms-of-service', '_blank')} icon={<LearnMoreIcon />}>
|
||||
Terms of Service
|
||||
</ActionLink>
|
||||
<ActionLink onClick={() => window.open('https://astuto.io/privacy-policy', '_blank')} icon={<LearnMoreIcon />}>
|
||||
Privacy Policy
|
||||
</ActionLink>
|
||||
</div>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
export default Billing;
|
||||
@@ -18,7 +18,7 @@ const ConfirmSignUpPage = ({
|
||||
<img src={pendingTenantImage} width={64} height={64} style={{margin: '12px auto'}} />
|
||||
|
||||
<p style={{textAlign: 'center'}}>
|
||||
Check your email <b>{userEmail}</b> to activate your new feedback space {subdomain}.astuto.io!
|
||||
Check your email <b>{userEmail}</b> to activate your new feedback space <a href={`https://${subdomain}.astuto.io`} className="link">{`${subdomain}.astuto.io`}</a>!
|
||||
</p>
|
||||
</Box>
|
||||
);
|
||||
|
||||
@@ -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<ITenantSignUpTenantForm>();
|
||||
const onSubmit: SubmitHandler<ITenantSignUpTenantForm> = data => {
|
||||
@@ -80,6 +82,12 @@ const TenantSignUpForm = ({
|
||||
>
|
||||
{ isSubmitting ? <Spinner /> : 'Create feedback space' }
|
||||
</Button>
|
||||
<p className="smallMutedText" style={{textAlign: 'center'}}>
|
||||
Your trial starts now and ends in {trialPeriodDays.toString()} days.
|
||||
</p>
|
||||
<p className="smallMutedText" style={{textAlign: 'center'}}>
|
||||
By clicking "Create", you agree to our <a href="https://astuto.io/terms-of-service" target="_blank" className="link">Terms of Service</a> and <a href="https://astuto.io/privacy-policy" target="_blank" className="link">Privacy Policy</a>.
|
||||
</p>
|
||||
|
||||
{ error !== '' && <DangerText>{ error }</DangerText> }
|
||||
</form>
|
||||
|
||||
@@ -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}
|
||||
/>
|
||||
}
|
||||
|
||||
|
||||
@@ -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<Props> {
|
||||
astutoLogoImage,
|
||||
pendingTenantImage,
|
||||
baseUrl,
|
||||
trialPeriodDays,
|
||||
authenticityToken,
|
||||
} = this.props;
|
||||
|
||||
@@ -49,6 +51,7 @@ class TenantSignUpRoot extends React.Component<Props> {
|
||||
astutoLogoImage={astutoLogoImage}
|
||||
pendingTenantImage={pendingTenantImage}
|
||||
baseUrl={baseUrl}
|
||||
trialPeriodDays={trialPeriodDays}
|
||||
authenticityToken={authenticityToken}
|
||||
/>
|
||||
</Provider>
|
||||
|
||||
21
app/javascript/interfaces/ITenantBilling.ts
Normal file
21
app/javascript/interfaces/ITenantBilling.ts
Normal file
@@ -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;
|
||||
@@ -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
|
||||
|
||||
100
app/mailers/tenant_mailer.rb
Normal file
100
app/mailers/tenant_mailer.rb
Normal file
@@ -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 <info@astuto.io>"
|
||||
end
|
||||
|
||||
def email_from_astuto
|
||||
"Astuto <info@astuto.io>"
|
||||
end
|
||||
|
||||
def choose_layout
|
||||
action_name == 'trial_mid' || action_name == 'trial_end' ? 'mailer_no_style' : 'mailer'
|
||||
end
|
||||
end
|
||||
@@ -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
|
||||
|
||||
51
app/models/tenant_billing.rb
Normal file
51
app/models/tenant_billing.rb
Normal file
@@ -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
|
||||
@@ -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
|
||||
|
||||
|
||||
16
app/views/billing/index.html.erb
Normal file
16
app/views/billing/index.html.erb
Normal file
@@ -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
|
||||
}
|
||||
)
|
||||
%>
|
||||
10
app/views/billing/return.html.erb
Normal file
10
app/views/billing/return.html.erb
Normal file
@@ -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)),
|
||||
}
|
||||
)
|
||||
%>
|
||||
@@ -6,14 +6,14 @@
|
||||
<ul class="dropdown-menu boards-dropdown" aria-labelledby="navbarDropdown">
|
||||
<% boards.each do |board| %>
|
||||
<li>
|
||||
<%= link_to board.name, board_path(board), class: 'dropdown-item' %>
|
||||
<%= link_to board.name, get_url_for(method(:board_url), resource: board), class: 'dropdown-item' %>
|
||||
</li>
|
||||
<% end %>
|
||||
</ul>
|
||||
<% else %>
|
||||
<% boards.each do |board| %>
|
||||
<li class="nav-item<%= board.id == @board.id ? ' active' : '' unless @board.nil? %>">
|
||||
<%= link_to board.name, board_path(board), class: 'nav-link' %>
|
||||
<%= link_to board.name, get_url_for(method(:board_url), resource: board), class: 'nav-link' %>
|
||||
</li>
|
||||
<% end %>
|
||||
<% end %>
|
||||
@@ -1,7 +1,7 @@
|
||||
<nav class="header">
|
||||
<div class="container">
|
||||
<%=
|
||||
link_to root_path, class: 'brand' do
|
||||
link_to get_url_for(method(:root_url)), class: 'brand' do
|
||||
app_name = content_tag :span, @tenant.site_name
|
||||
logo = image_tag(@tenant.site_logo ? @tenant.site_logo : "", class: 'logo', skip_pipeline: true)
|
||||
|
||||
@@ -31,7 +31,7 @@
|
||||
<ul class="boardsNav">
|
||||
<% if @tenant_setting.show_roadmap_in_header %>
|
||||
<li class="nav-item<%= (current_page?(roadmap_path) or (@tenant_setting.root_board_id == 0 and current_page?(root_path))) ? ' active' : '' %>">
|
||||
<%= link_to t('roadmap.title'), roadmap_path, class: 'nav-link' %>
|
||||
<%= link_to t('roadmap.title'), get_url_for(method(:roadmap_url)), class: 'nav-link' %>
|
||||
</li>
|
||||
<% end %>
|
||||
|
||||
@@ -49,21 +49,37 @@
|
||||
<% if current_user.moderator? %>
|
||||
<%=
|
||||
link_to t('header.menu.site_settings'),
|
||||
current_user.admin? ? site_settings_general_path : site_settings_users_path,
|
||||
current_user.admin? ? get_url_for(method(:site_settings_general_url)) : get_url_for(method(:site_settings_users_url)),
|
||||
class: 'dropdown-item'
|
||||
%>
|
||||
|
||||
<% if current_user.owner? and Rails.application.multi_tenancy? %>
|
||||
<%= link_to get_url_for(method(:request_billing_page_url)), class: 'dropdown-item', data: {turbolinks: false} do %>
|
||||
<%= t('billing.title') %>
|
||||
<% if not Current.tenant.tenant_billing.has_active_subscription? %>
|
||||
<span style="color: red; font-size: 18px;">⚠️</span>
|
||||
<% end %>
|
||||
<% end %>
|
||||
<% end %>
|
||||
|
||||
<div class="dropdown-divider"></div>
|
||||
<% end %>
|
||||
|
||||
<%= link_to t('header.menu.profile_settings'), edit_user_registration_path, class: 'dropdown-item' %>
|
||||
<%= link_to t('header.menu.profile_settings'), get_url_for(method(:edit_user_registration_url)), class: 'dropdown-item' %>
|
||||
<div class="dropdown-divider"></div>
|
||||
|
||||
<%= button_to t('header.menu.sign_out'), destroy_user_session_path, method: :delete, class: 'dropdown-item' %>
|
||||
|
||||
<% unless @disable_sign_out %>
|
||||
<%= button_to t('header.menu.sign_out'), get_url_for(method(:destroy_user_session_url)), method: :delete, class: 'dropdown-item' %>
|
||||
<% else %>
|
||||
<small style="font-size: 12px; color: grey; padding: 0.25rem 1.5rem;">
|
||||
Sign out disabled
|
||||
</small>
|
||||
<% end %>
|
||||
</div>
|
||||
</li>
|
||||
<% else %>
|
||||
<li class="nav-item">
|
||||
<%= link_to t('header.log_in'), new_user_session_path, class: 'nav-link' %>
|
||||
<%= link_to t('header.log_in'), get_url_for(method(:new_user_session_url)), class: 'nav-link' %>
|
||||
</li>
|
||||
<% end %>
|
||||
</ul>
|
||||
|
||||
@@ -0,0 +1,7 @@
|
||||
<div class="noActiveSubscriptionBanner">
|
||||
<% if @tenant.tenant_billing.trial? %>
|
||||
<span>Your trial has ended. Please <%= link_to 'subscribe', get_url_for(method(:request_billing_page_url)), data: {turbolinks: false} %> to continue using our services.</span>
|
||||
<% else %>
|
||||
<span>Your subscription has ended. Please <%= link_to 'renew your subscription', get_url_for(method(:request_billing_page_url)), data: {turbolinks: false} %> to continue using our services.</span>
|
||||
<% end %>
|
||||
</div>
|
||||
@@ -18,6 +18,10 @@
|
||||
</head>
|
||||
|
||||
<body>
|
||||
<% if @tenant and not @tenant.tenant_billing.has_active_subscription? and (user_signed_in? and current_user.owner?) %>
|
||||
<%= render 'layouts/no_active_subscription_banner' %>
|
||||
<% end %>
|
||||
|
||||
<% if @tenant %>
|
||||
<%= render 'layouts/header' %>
|
||||
<% end %>
|
||||
|
||||
11
app/views/layouts/mailer_no_style.html.erb
Normal file
11
app/views/layouts/mailer_no_style.html.erb
Normal file
@@ -0,0 +1,11 @@
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<meta http-equiv="Content-Type" content="text/html; charset=utf-8" />
|
||||
</head>
|
||||
<body>
|
||||
<div>
|
||||
<%= yield %>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
19
app/views/tenant_mailer/cancellation_confirmation.html.erb
Normal file
19
app/views/tenant_mailer/cancellation_confirmation.html.erb
Normal file
@@ -0,0 +1,19 @@
|
||||
<p>
|
||||
Hello <%= @tenant.owner.full_name %>!
|
||||
</p>
|
||||
|
||||
<p>
|
||||
We're sorry to see you go. <b>Your subscription has been cancelled and won't be renewed</b>.
|
||||
</p>
|
||||
|
||||
<p>
|
||||
Your current subscription will remain active until <%= @subscription_ends_at.strftime('%B %d, %Y') %>.
|
||||
</p>
|
||||
|
||||
<p>
|
||||
If you have any questions or need help, please don't hesitate to <a href="mailto:info@astuto.io">contact us</a>.
|
||||
</p>
|
||||
|
||||
<p>
|
||||
Have a great day!
|
||||
</p>
|
||||
15
app/views/tenant_mailer/renewal_confirmation.html.erb
Normal file
15
app/views/tenant_mailer/renewal_confirmation.html.erb
Normal file
@@ -0,0 +1,15 @@
|
||||
<p>
|
||||
Hello <%= @tenant.owner.full_name %>!
|
||||
</p>
|
||||
|
||||
<p>
|
||||
<b>You have successfully set your subscription to renew automatically.</b>
|
||||
</p>
|
||||
|
||||
<p>
|
||||
If you have any questions or need help, please don't hesitate to <a href="mailto:info@astuto.io">contact us</a>.
|
||||
</p>
|
||||
|
||||
<p>
|
||||
Have a great day!
|
||||
</p>
|
||||
15
app/views/tenant_mailer/subscription_confirmation.html.erb
Normal file
15
app/views/tenant_mailer/subscription_confirmation.html.erb
Normal file
@@ -0,0 +1,15 @@
|
||||
<p>
|
||||
Hello <%= @tenant.owner.full_name %>!
|
||||
</p>
|
||||
|
||||
<p>
|
||||
Thank you for choosing Astuto. <b>Your subscription is now active</b>.
|
||||
</p>
|
||||
|
||||
<p>
|
||||
If you have any questions or need help, please don't hesitate to <a href="mailto:info@astuto.io">contact us</a>.
|
||||
</p>
|
||||
|
||||
<p>
|
||||
Have a great day!
|
||||
</p>
|
||||
28
app/views/tenant_mailer/trial_end.html.erb
Normal file
28
app/views/tenant_mailer/trial_end.html.erb
Normal file
@@ -0,0 +1,28 @@
|
||||
<p>
|
||||
Hi <%= @tenant.owner.full_name %>,
|
||||
</p>
|
||||
|
||||
<p>
|
||||
I hope you're doing well!
|
||||
</p>
|
||||
|
||||
<p>
|
||||
I'm Riccardo from the Astuto team.
|
||||
</p>
|
||||
|
||||
<p>
|
||||
I wanted to let you know that your <%= (Rails.application.trial_period_days / 1.day).to_s %>-day trial has expired and now your feedback space is in read-only mode.
|
||||
</p>
|
||||
|
||||
<p>
|
||||
If you've enjoyed your trial and want to upgrade to a paid plan, you can do so from the <a href="<%= get_url_for(method(:request_billing_page_url)) %>">billing page</a>.
|
||||
</p>
|
||||
|
||||
<p>
|
||||
However, if there's anything holding you back from upgrading or if you'd like to request more trial days, please don't hesitate to hit reply and let me know. Your feedback is very important and I'm here to help!
|
||||
</p>
|
||||
|
||||
<p>
|
||||
Cheers,<br />
|
||||
Riccardo
|
||||
</p>
|
||||
28
app/views/tenant_mailer/trial_mid.html.erb
Normal file
28
app/views/tenant_mailer/trial_mid.html.erb
Normal file
@@ -0,0 +1,28 @@
|
||||
<p>
|
||||
Hi <%= @tenant.owner.full_name %>,
|
||||
</p>
|
||||
|
||||
<p>
|
||||
I hope you're doing well!
|
||||
</p>
|
||||
|
||||
<p>
|
||||
I'm Riccardo from the Astuto team.
|
||||
</p>
|
||||
|
||||
<p>
|
||||
How's your Astuto trial going? If you have any questions or encounter any issues, please feel free to share them by simply replying to this email.
|
||||
</p>
|
||||
|
||||
<p>
|
||||
Also remember that your trial period will expire on <%= @trial_ends_at.strftime('%B %d, %Y') %>.
|
||||
</p>
|
||||
|
||||
<p>
|
||||
I'd love to hear your feedback.
|
||||
</p>
|
||||
|
||||
<p>
|
||||
Cheers,<br />
|
||||
Riccardo
|
||||
</p>
|
||||
28
app/views/tenant_mailer/trial_start.html.erb
Normal file
28
app/views/tenant_mailer/trial_start.html.erb
Normal file
@@ -0,0 +1,28 @@
|
||||
<p>
|
||||
Hello <%= @tenant.owner.full_name %>,
|
||||
</p>
|
||||
|
||||
<p>
|
||||
Welcome aboard!
|
||||
</p>
|
||||
|
||||
<p>
|
||||
I'm Riccardo from the Astuto team and I'm happy to let you know that <b>your <%= (Rails.application.trial_period_days / 1.day).to_s %>-day trial has started</b>.
|
||||
</p>
|
||||
|
||||
<p>
|
||||
Dive into your new feedback space at <a href="<%= get_url_for(method(:root_url)) %>"><%= get_url_for(method(:root_url)) %></a>.
|
||||
</p>
|
||||
|
||||
<p>
|
||||
To customize your feedback space, just head to <a href="<%= get_url_for(method(:site_settings_general_url)) %>">site settings</a>. For more advanced configurations, such as custom OAuth providers or custom domains, you can refer to the <a href="https://docs.astuto.io/">Astuto documentation</a>.
|
||||
</p>
|
||||
|
||||
<p>
|
||||
Got any questions, thoughts or just want to get in touch? Simply hit reply to this email!
|
||||
</p>
|
||||
|
||||
<p>
|
||||
Cheers,<br />
|
||||
Riccardo
|
||||
</p>
|
||||
@@ -9,6 +9,7 @@
|
||||
baseUrl: Rails.application.base_url,
|
||||
astutoLogoImage: image_url("logo.png"),
|
||||
pendingTenantImage: image_url("pending-tenant.png"),
|
||||
trialPeriodDays: (Rails.application.trial_period_days / 1.day).to_i,
|
||||
authenticityToken: form_authenticity_token
|
||||
}
|
||||
)
|
||||
|
||||
15
app/workflows/create_stripe_customer.rb
Normal file
15
app/workflows/create_stripe_customer.rb
Normal file
@@ -0,0 +1,15 @@
|
||||
require 'stripe'
|
||||
|
||||
class CreateStripeCustomer
|
||||
def run
|
||||
tenant = Current.tenant_or_raise! # check that Current Tenant is set
|
||||
owner = User.find_by(role: 'owner')
|
||||
|
||||
customer = Stripe::Customer.create({
|
||||
email: owner.email,
|
||||
name: owner.full_name,
|
||||
})
|
||||
tb = TenantBilling.first_or_create
|
||||
tb.update!(customer_id: customer.id)
|
||||
end
|
||||
end
|
||||
@@ -33,5 +33,33 @@ module App
|
||||
def posts_per_page
|
||||
15
|
||||
end
|
||||
|
||||
def trial_period_days
|
||||
ENV.key?("TRIAL_PERIOD_DAYS") ? ENV["TRIAL_PERIOD_DAYS"].to_i.days : 7.days
|
||||
end
|
||||
|
||||
def stripe_secret_key
|
||||
ENV["STRIPE_SECRET_KEY"]
|
||||
end
|
||||
|
||||
def stripe_public_key
|
||||
ENV["STRIPE_PUBLIC_KEY"]
|
||||
end
|
||||
|
||||
def stripe_endpoint_secret
|
||||
ENV["STRIPE_ENDPOINT_SECRET"]
|
||||
end
|
||||
|
||||
def stripe_manage_subscription_url
|
||||
ENV["STRIPE_MANAGE_SUBSCRIPTION_URL"]
|
||||
end
|
||||
|
||||
def stripe_monthly_lookup_key
|
||||
ENV["STRIPE_MONTHLY_LOOKUP_KEY"]
|
||||
end
|
||||
|
||||
def stripe_yearly_lookup_key
|
||||
ENV["STRIPE_YEARLY_LOOKUP_KEY"]
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
@@ -58,8 +58,7 @@ Rails.application.configure do
|
||||
end
|
||||
|
||||
config.action_mailer.default_options = {
|
||||
from: ENV.fetch("EMAIL_MAIL_FROM", "Astuto <notifications@astuto.io>"),
|
||||
reply_to: ENV.fetch("EMAIL_MAIL_REPLY_TO", ENV.fetch("EMAIL_MAIL_FROM", "Astuto <notifications@astuto.io>"))
|
||||
reply_to: "noreply@astuto.io"
|
||||
}
|
||||
|
||||
# Store uploaded files on the local file system (see config/storage.yml for options).
|
||||
|
||||
@@ -82,8 +82,7 @@ Rails.application.configure do
|
||||
end
|
||||
|
||||
config.action_mailer.default_options = {
|
||||
from: ENV.fetch("EMAIL_MAIL_FROM", "Astuto <notifications@astuto.io>"),
|
||||
reply_to: ENV.fetch("EMAIL_MAIL_REPLY_TO", ENV.fetch("EMAIL_MAIL_FROM", "Astuto <notifications@astuto.io>"))
|
||||
reply_to: "noreply@astuto.io"
|
||||
}
|
||||
|
||||
# Enable locale fallbacks for I18n (makes lookups for any locale fall back to
|
||||
|
||||
@@ -11,5 +11,6 @@ RESERVED_SUBDOMAINS = [
|
||||
'dashboard',
|
||||
'analytics',
|
||||
'cname',
|
||||
'whatever'
|
||||
'whatever',
|
||||
'billing'
|
||||
]
|
||||
3
config/initializers/stripe.rb
Normal file
3
config/initializers/stripe.rb
Normal file
@@ -0,0 +1,3 @@
|
||||
require 'stripe'
|
||||
|
||||
Stripe.api_key = Rails.application.stripe_secret_key
|
||||
@@ -79,6 +79,8 @@ en:
|
||||
log_in: 'Log in / Sign up'
|
||||
roadmap:
|
||||
title: 'Roadmap'
|
||||
billing:
|
||||
title: 'Billing'
|
||||
blocked_tenant:
|
||||
title: 'This feedback space has been blocked'
|
||||
board:
|
||||
|
||||
@@ -10,6 +10,14 @@ Rails.application.routes.draw do
|
||||
|
||||
resource :tenants, only: [:create]
|
||||
end
|
||||
|
||||
constraints subdomain: 'billing' do
|
||||
get '/billing', to: 'billing#index'
|
||||
get '/billing/return', to: 'billing#return'
|
||||
post '/create_checkout_session', to: 'billing#create_checkout_session'
|
||||
get '/session_status', to: 'billing#session_status'
|
||||
post '/webhook', to: 'billing#webhook'
|
||||
end
|
||||
end
|
||||
|
||||
constraints subdomain: /.*/ do
|
||||
@@ -19,6 +27,8 @@ Rails.application.routes.draw do
|
||||
get '/pending-tenant', to: 'static_pages#pending_tenant'
|
||||
get '/blocked-tenant', to: 'static_pages#blocked_tenant'
|
||||
|
||||
get '/request_billing_page', to: 'billing#request_billing_page'
|
||||
|
||||
devise_for :users, :controllers => {
|
||||
:registrations => 'registrations',
|
||||
:sessions => 'sessions'
|
||||
|
||||
11
db/migrate/20240419160353_create_tenant_billings.rb
Normal file
11
db/migrate/20240419160353_create_tenant_billings.rb
Normal file
@@ -0,0 +1,11 @@
|
||||
class CreateTenantBillings < ActiveRecord::Migration[6.1]
|
||||
def change
|
||||
create_table :tenant_billings do |t|
|
||||
t.references :tenant, null: false, foreign_key: true
|
||||
t.integer :status, null: false, default: 0
|
||||
t.datetime :trial_ends_at
|
||||
|
||||
t.timestamps
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,5 @@
|
||||
class AddCustomerIdToTenantBilling < ActiveRecord::Migration[6.1]
|
||||
def change
|
||||
add_column :tenant_billings, :customer_id, :string
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,5 @@
|
||||
class AddSubscriptionEndsAtToTenantBilling < ActiveRecord::Migration[6.1]
|
||||
def change
|
||||
add_column :tenant_billings, :subscription_ends_at, :datetime
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,8 @@
|
||||
class AddSlugAndAuthTokenToTenantBilling < ActiveRecord::Migration[6.1]
|
||||
def change
|
||||
add_column :tenant_billings, :slug, :string
|
||||
add_column :tenant_billings, :auth_token, :string
|
||||
|
||||
add_index :tenant_billings, :slug, unique: true
|
||||
end
|
||||
end
|
||||
17
db/schema.rb
17
db/schema.rb
@@ -10,7 +10,7 @@
|
||||
#
|
||||
# It's strongly recommended that you check this file into your version control system.
|
||||
|
||||
ActiveRecord::Schema.define(version: 2024_04_04_161306) do
|
||||
ActiveRecord::Schema.define(version: 2024_04_27_140300) do
|
||||
|
||||
# These are extensions that must be enabled in order to support this database
|
||||
enable_extension "plpgsql"
|
||||
@@ -130,6 +130,20 @@ ActiveRecord::Schema.define(version: 2024_04_04_161306) do
|
||||
t.index ["user_id"], name: "index_posts_on_user_id"
|
||||
end
|
||||
|
||||
create_table "tenant_billings", force: :cascade do |t|
|
||||
t.bigint "tenant_id", null: false
|
||||
t.integer "status", default: 0, null: false
|
||||
t.datetime "trial_ends_at"
|
||||
t.datetime "created_at", precision: 6, null: false
|
||||
t.datetime "updated_at", precision: 6, null: false
|
||||
t.string "customer_id"
|
||||
t.datetime "subscription_ends_at"
|
||||
t.string "slug"
|
||||
t.string "auth_token"
|
||||
t.index ["slug"], name: "index_tenant_billings_on_slug", unique: true
|
||||
t.index ["tenant_id"], name: "index_tenant_billings_on_tenant_id"
|
||||
end
|
||||
|
||||
create_table "tenant_default_o_auths", force: :cascade do |t|
|
||||
t.bigint "tenant_id", null: false
|
||||
t.bigint "o_auth_id", null: false
|
||||
@@ -211,6 +225,7 @@ ActiveRecord::Schema.define(version: 2024_04_04_161306) do
|
||||
add_foreign_key "posts", "post_statuses"
|
||||
add_foreign_key "posts", "tenants"
|
||||
add_foreign_key "posts", "users"
|
||||
add_foreign_key "tenant_billings", "tenants"
|
||||
add_foreign_key "tenant_default_o_auths", "o_auths"
|
||||
add_foreign_key "tenant_default_o_auths", "tenants"
|
||||
add_foreign_key "tenant_settings", "tenants"
|
||||
|
||||
@@ -15,6 +15,8 @@ owner = User.create(
|
||||
confirmed_at: Time.zone.now
|
||||
)
|
||||
|
||||
tenant.tenant_billing = TenantBilling.create!(status: 'perpetual')
|
||||
|
||||
CreateWelcomeEntitiesWorkflow.new().run
|
||||
|
||||
# Let the user know how to log in with admin account
|
||||
|
||||
@@ -26,4 +26,4 @@ if [ "$db_version" = "Current version: 0" ]; then
|
||||
else
|
||||
bundle exec rake db:migrate
|
||||
fi
|
||||
echo "Database prepared."
|
||||
echo "Database prepared."
|
||||
|
||||
45
lib/tasks/notify_tenants_trial_period.rake
Normal file
45
lib/tasks/notify_tenants_trial_period.rake
Normal file
@@ -0,0 +1,45 @@
|
||||
require 'rake'
|
||||
|
||||
def get_tenants_to_notify(period)
|
||||
days_before_expiration = period == "mid" ? 4.days : -1.day
|
||||
date_to_check = Date.current + days_before_expiration
|
||||
|
||||
tbs = TenantBilling.unscoped.where(
|
||||
trial_ends_at: date_to_check.beginning_of_day..date_to_check.end_of_day,
|
||||
status: 'trial'
|
||||
)
|
||||
Tenant.where(id: tbs.map(&:tenant_id))
|
||||
end
|
||||
|
||||
task notify_tenants_trial_period: [:environment] do
|
||||
begin
|
||||
# Notify tenants mid trial
|
||||
tenants_mid_trial = get_tenants_to_notify("mid")
|
||||
puts "There are #{tenants_mid_trial.length} tenants to notify for mid trial."
|
||||
|
||||
tenants_mid_trial.each do |tenant|
|
||||
puts "Delivering trial_mid email for #{tenant.site_name}..."
|
||||
TenantMailer.trial_mid(tenant: tenant).deliver_later
|
||||
end
|
||||
|
||||
# Notify tenants end of trial
|
||||
tenants_end_trial = get_tenants_to_notify("end")
|
||||
puts "There are #{tenants_end_trial.length} tenants to notify for end trial."
|
||||
|
||||
tenants_end_trial.each do |tenant|
|
||||
puts "Delivering trial_end email for #{tenant.site_name}..."
|
||||
TenantMailer.trial_end(tenant: tenant).deliver_later
|
||||
end
|
||||
rescue Exception => e
|
||||
error_subject = "Scheduled Task 'notify_tenants_trial_period.rake' Failed"
|
||||
|
||||
res = ActionMailer::Base.mail(
|
||||
from: "errors@astuto.io",
|
||||
to: "info@astuto.io",
|
||||
subject: error_subject,
|
||||
body: "#{e.message}\n\n#{e.backtrace.join("\n")}",
|
||||
).deliver_now
|
||||
|
||||
raise error_subject # raise error so rake task returns non-zero code
|
||||
end
|
||||
end
|
||||
@@ -18,6 +18,8 @@
|
||||
"@babel/preset-env": "7.21.5",
|
||||
"@babel/preset-react": "7.18.6",
|
||||
"@babel/preset-typescript": "7.21.5",
|
||||
"@stripe/react-stripe-js": "2.7.0",
|
||||
"@stripe/stripe-js": "3.3.0",
|
||||
"@types/react": "16.9.2",
|
||||
"@types/react-dom": "16.9.0",
|
||||
"babel-loader": "9.1.2",
|
||||
|
||||
5
spec/factories/tenant_billings.rb
Normal file
5
spec/factories/tenant_billings.rb
Normal file
@@ -0,0 +1,5 @@
|
||||
FactoryBot.define do
|
||||
factory :tenant_billing do
|
||||
tenant
|
||||
end
|
||||
end
|
||||
33
spec/mailers/previews/tenant_mailer_preview.rb
Normal file
33
spec/mailers/previews/tenant_mailer_preview.rb
Normal file
@@ -0,0 +1,33 @@
|
||||
# Preview all emails at http://localhost:3000/rails/mailers/tenant_mailer
|
||||
|
||||
class TenantMailerPreview < ActionMailer::Preview
|
||||
def initialize(params={})
|
||||
super(params)
|
||||
|
||||
Current.tenant = Tenant.first
|
||||
end
|
||||
|
||||
def trial_start
|
||||
TenantMailer.trial_start(tenant: Current.tenant)
|
||||
end
|
||||
|
||||
def trial_mid
|
||||
TenantMailer.trial_mid(tenant: Current.tenant)
|
||||
end
|
||||
|
||||
def trial_end
|
||||
TenantMailer.trial_end(tenant: Current.tenant)
|
||||
end
|
||||
|
||||
def subscription_confirmation
|
||||
TenantMailer.subscription_confirmation(tenant: Current.tenant)
|
||||
end
|
||||
|
||||
def cancellation_confirmation
|
||||
TenantMailer.cancellation_confirmation(tenant: Current.tenant)
|
||||
end
|
||||
|
||||
def renewal_confirmation
|
||||
TenantMailer.renewal_confirmation(tenant: Current.tenant)
|
||||
end
|
||||
end
|
||||
57
spec/models/tenant_billing_spec.rb
Normal file
57
spec/models/tenant_billing_spec.rb
Normal file
@@ -0,0 +1,57 @@
|
||||
require 'rails_helper'
|
||||
|
||||
RSpec.describe TenantBilling, type: :model do
|
||||
let(:tenant_billing) { FactoryBot.build(:tenant_billing) }
|
||||
|
||||
it 'should be valid' do
|
||||
expect(tenant_billing).to be_valid
|
||||
end
|
||||
|
||||
it 'has a status that can be trial, active, canceled or perpetual (default: trial)' do
|
||||
expect(tenant_billing.status).to eq('trial')
|
||||
|
||||
tenant_billing.status = 'active'
|
||||
expect(tenant_billing).to be_valid
|
||||
|
||||
tenant_billing.status = 'canceled'
|
||||
expect(tenant_billing).to be_valid
|
||||
|
||||
tenant_billing.status = 'perpetual'
|
||||
expect(tenant_billing).to be_valid
|
||||
end
|
||||
|
||||
it 'has a trial_ends_at datetime that defaults to TRIAL_PERIOD_DAYS env variable' do
|
||||
tenant_billing.save
|
||||
expect(tenant_billing.trial_ends_at).to be_within(5.seconds).of(Time.current + Rails.application.trial_period_days)
|
||||
end
|
||||
|
||||
it 'has a subscription_ends_at datetime that defaults to current time' do
|
||||
tenant_billing.save
|
||||
expect(tenant_billing.subscription_ends_at).to be_within(5.seconds).of(Time.current)
|
||||
end
|
||||
|
||||
it 'has a has_active_subscription? method that returns true if tenant can access the service' do
|
||||
tenant_billing.status = 'perpetual'
|
||||
expect(tenant_billing.has_active_subscription?).to be_truthy
|
||||
|
||||
tenant_billing.status = 'active'
|
||||
tenant_billing.subscription_ends_at = Time.current + 1.day
|
||||
expect(tenant_billing.has_active_subscription?).to be_truthy
|
||||
|
||||
tenant_billing.subscription_ends_at = Time.current - 1.day - 1.second
|
||||
expect(tenant_billing.has_active_subscription?).to be_falsey
|
||||
|
||||
tenant_billing.status = 'trial'
|
||||
tenant_billing.trial_ends_at = Time.current + 1.day
|
||||
expect(tenant_billing.has_active_subscription?).to be_truthy
|
||||
|
||||
tenant_billing.trial_ends_at = Time.current - 1.day
|
||||
expect(tenant_billing.has_active_subscription?).to be_falsey
|
||||
end
|
||||
|
||||
it 'has a soft expiration of 1 day if in status "active"' do
|
||||
tenant_billing.status = 'active'
|
||||
tenant_billing.subscription_ends_at = Time.current - 23.hours - 59.minutes - 59.seconds
|
||||
expect(tenant_billing.has_active_subscription?).to be_truthy
|
||||
end
|
||||
end
|
||||
@@ -19,6 +19,8 @@ RSpec.configure do |config|
|
||||
# Set tenant before each test
|
||||
config.before(:each) do
|
||||
Current.tenant = FactoryBot.create(:tenant)
|
||||
Current.tenant.tenant_setting = FactoryBot.create(:tenant_setting)
|
||||
Current.tenant.tenant_billing = FactoryBot.create(:tenant_billing)
|
||||
end
|
||||
|
||||
# Compile fresh assets before system specs (needed to get the changes)
|
||||
|
||||
12
yarn.lock
12
yarn.lock
@@ -1148,6 +1148,18 @@
|
||||
"@jridgewell/resolve-uri" "3.1.0"
|
||||
"@jridgewell/sourcemap-codec" "1.4.14"
|
||||
|
||||
"@stripe/react-stripe-js@2.7.0":
|
||||
version "2.7.0"
|
||||
resolved "https://registry.yarnpkg.com/@stripe/react-stripe-js/-/react-stripe-js-2.7.0.tgz#9b01ac0f191dc5d54fa845a98a8f0a6398aafc4b"
|
||||
integrity sha512-kTkIZl2ZleBuDR9c6fDy/s4m33llII8a5al6BDAMSTrfVq/4gSZv3RBO5KS/xvnxS+fDapJ3bKvjD8Lqj+AKdQ==
|
||||
dependencies:
|
||||
prop-types "^15.7.2"
|
||||
|
||||
"@stripe/stripe-js@3.3.0":
|
||||
version "3.3.0"
|
||||
resolved "https://registry.yarnpkg.com/@stripe/stripe-js/-/stripe-js-3.3.0.tgz#6f3a7090fe5a1116a1d7f6ba1205f7252f24537b"
|
||||
integrity sha512-dUgAsko9KoYC1U2TIawHzbkQJzPoApxCc1Qf6/j318d1ArViyh6ROHVYTxnU3RlOQL/utUD9I4/QoyiCowsgrw==
|
||||
|
||||
"@types/eslint-scope@^3.7.3":
|
||||
version "3.7.4"
|
||||
resolved "https://registry.yarnpkg.com/@types/eslint-scope/-/eslint-scope-3.7.4.tgz#37fc1223f0786c39627068a12e94d6e6fc61de16"
|
||||
|
||||
Reference in New Issue
Block a user