Add billing (#329)

This commit is contained in:
Riccardo Graziosi
2024-05-03 18:11:07 +02:00
committed by GitHub
parent fc36c967af
commit bea146e612
63 changed files with 1354 additions and 27 deletions

View File

@@ -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]

View File

@@ -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)

View File

@@ -20,6 +20,7 @@
@import 'components/LikeButton';
@import 'components/Post';
@import 'components/Roadmap';
@import 'components/Billing';
/* Site Settings Components */
@import 'components/SiteSettings';

View File

@@ -30,7 +30,8 @@
.smallMutedText {
@extend
.mutedText,
.m-0;
.mt-1,
.mb-0;
font-size: smaller;
}

View File

@@ -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;
}

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

View File

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

View 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

View File

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

View File

@@ -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?

View File

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

View File

@@ -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')

View File

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

View File

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

View 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>
&nbsp;
<span className="currency">{price.currency}</span>
&nbsp;/&nbsp;
<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>
&nbsp;
<span className="currency">{price.currency}</span>
&nbsp;/&nbsp;
<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;

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

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

View File

@@ -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>
);

View File

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

View File

@@ -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}
/>
}

View File

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

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

View File

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

View 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

View File

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

View 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

View File

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

View 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
}
)
%>

View 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)),
}
)
%>

View File

@@ -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 %>

View File

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

View File

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

View File

@@ -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 %>

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

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

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

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

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

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

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

View File

@@ -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
}
)

View 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

View File

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

View File

@@ -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).

View File

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

View File

@@ -11,5 +11,6 @@ RESERVED_SUBDOMAINS = [
'dashboard',
'analytics',
'cname',
'whatever'
'whatever',
'billing'
]

View File

@@ -0,0 +1,3 @@
require 'stripe'
Stripe.api_key = Rails.application.stripe_secret_key

View File

@@ -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:

View File

@@ -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'

View 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

View File

@@ -0,0 +1,5 @@
class AddCustomerIdToTenantBilling < ActiveRecord::Migration[6.1]
def change
add_column :tenant_billings, :customer_id, :string
end
end

View File

@@ -0,0 +1,5 @@
class AddSubscriptionEndsAtToTenantBilling < ActiveRecord::Migration[6.1]
def change
add_column :tenant_billings, :subscription_ends_at, :datetime
end
end

View File

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

View File

@@ -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"

View File

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

View File

@@ -26,4 +26,4 @@ if [ "$db_version" = "Current version: 0" ]; then
else
bundle exec rake db:migrate
fi
echo "Database prepared."
echo "Database prepared."

View 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

View File

@@ -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",

View File

@@ -0,0 +1,5 @@
FactoryBot.define do
factory :tenant_billing do
tenant
end
end

View 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

View 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

View File

@@ -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)

View File

@@ -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"