mirror of
https://github.com/astuto/astuto.git
synced 2025-12-16 03:37:56 +01:00
Add billing (#329)
This commit is contained in:
committed by
GitHub
parent
fc36c967af
commit
bea146e612
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;
|
||||
Reference in New Issue
Block a user