Merge pull request #1003 from automatisch/billing-page

feat: redesign billing page
This commit is contained in:
Ömer Faruk Aydın
2023-03-21 00:53:15 +03:00
committed by GitHub
19 changed files with 464 additions and 78 deletions

View File

@@ -39,6 +39,8 @@ type AppConfig = {
smtpPassword: string;
fromEmail: string;
isCloud: boolean;
paddleVendorId: string;
paddleVendorAuthCode: string;
stripeSecretKey: string;
stripeSigningSecret: string;
stripeStarterPriceKey: string;
@@ -111,6 +113,8 @@ const appConfig: AppConfig = {
smtpPassword: process.env.SMTP_PASSWORD,
fromEmail: process.env.FROM_EMAIL,
isCloud: process.env.AUTOMATISCH_CLOUD === 'true',
paddleVendorId: process.env.PADDLE_VENDOR_ID,
paddleVendorAuthCode: process.env.PADDLE_VENDOR_AUTH_CODE,
stripeSecretKey: process.env.STRIPE_SECRET_KEY,
stripeSigningSecret: process.env.STRIPE_SIGNING_SECRET,
stripeStarterPriceKey: process.env.STRIPE_STARTER_PRICE_KEY,

View File

@@ -0,0 +1,18 @@
import { Knex } from 'knex';
import appConfig from '../../config/app';
export async function up(knex: Knex): Promise<void> {
if (!appConfig.isCloud) return;
return knex.schema.table('users', (table) => {
table.date('trial_expiry_date');
});
}
export async function down(knex: Knex): Promise<void> {
if (!appConfig.isCloud) return;
return knex.schema.table('users', (table) => {
table.dropColumn('trial_expiry_date');
});
}

View File

@@ -0,0 +1,10 @@
import appConfig from '../../config/app';
import Billing from '../../helpers/billing/index.ee';
const getPaymentPlans = async () => {
if (!appConfig.isCloud) return;
return Billing.paddlePlans;
};
export default getPaymentPlans;

View File

@@ -12,6 +12,7 @@ import getDynamicData from './queries/get-dynamic-data';
import getDynamicFields from './queries/get-dynamic-fields';
import getCurrentUser from './queries/get-current-user';
import getUsageData from './queries/get-usage-data.ee';
import getPaymentPlans from './queries/get-payment-plans.ee';
import getPaymentPortalUrl from './queries/get-payment-portal-url.ee';
import getAutomatischInfo from './queries/get-automatisch-info';
import healthcheck from './queries/healthcheck';
@@ -31,6 +32,7 @@ const queryResolvers = {
getDynamicFields,
getCurrentUser,
getUsageData,
getPaymentPlans,
getPaymentPortalUrl,
getAutomatischInfo,
healthcheck,

View File

@@ -36,6 +36,7 @@ type Query {
getCurrentUser: User
getUsageData: GetUsageData
getPaymentPortalUrl: GetPaymentPortalUrl
getPaymentPlans: [PaymentPlan]
getAutomatischInfo: GetAutomatischInfo
healthcheck: AppHealth
}
@@ -481,6 +482,13 @@ type GetPaymentPortalUrl {
url: String
}
type PaymentPlan {
name: String
limit: String
price: String
productId: String
}
schema {
query: Query
mutation: Mutation

View File

@@ -4,6 +4,7 @@ import PaymentPlan from '../../models/payment-plan.ee';
import UsageData from '../../models/usage-data.ee';
import appConfig from '../../config/app';
import handleWebhooks from './webhooks.ee';
import paddlePlans from './plans.ee';
const plans = [
{
@@ -95,6 +96,7 @@ const billing = {
handleWebhooks,
stripe,
plans,
paddlePlans,
};
export default billing;

View File

@@ -0,0 +1,16 @@
const plans = [
{
name: '10k - monthly',
limit: '10,000',
price: '€20',
productId: '47384',
},
{
name: '30k - monthly',
limit: '30,000',
price: '€50',
productId: '47419',
},
];
export default plans;

View File

@@ -1,4 +1,6 @@
import { QueryContext, ModelOptions } from 'objection';
import { DateTime } from 'luxon';
import appConfig from '../config/app';
import Base from './base';
import Connection from './connection';
import Flow from './flow';
@@ -17,6 +19,7 @@ class User extends Base {
role: string;
resetPasswordToken: string;
resetPasswordTokenSentAt: string;
trialExpiryDate: string;
connections?: Connection[];
flows?: Flow[];
steps?: Step[];
@@ -133,9 +136,17 @@ class User extends Base {
this.password = await bcrypt.hash(this.password, 10);
}
async startTrialPeriod() {
this.trialExpiryDate = DateTime.now().plus({ days: 30 }).toFormat('D');
}
async $beforeInsert(queryContext: QueryContext) {
await super.$beforeInsert(queryContext);
await this.generateHash();
if (appConfig.isCloud) {
await this.startTrialPeriod();
}
}
async $beforeUpdate(opt: ModelOptions, queryContext: QueryContext) {

View File

@@ -321,6 +321,13 @@ export type IGlobalVariable = {
setActionItem?: (actionItem: IActionItem) => void;
};
export type TPaymentPlan = {
price: string;
name: string;
limit: string;
productId: string;
}
declare module 'axios' {
interface AxiosResponse {
httpError?: IJSONObject;
@@ -335,4 +342,3 @@ export interface IRequest extends Request {
rawBody?: Buffer;
currentUser?: IUser;
}

View File

@@ -1,28 +0,0 @@
import * as React from 'react';
import Typography from '@mui/material/Typography';
import PageTitle from 'components/PageTitle';
import { generateExternalLink } from 'helpers/translation-values';
import usePaymentPortalUrl from 'hooks/usePaymentPortalUrl.ee';
import useFormatMessage from 'hooks/useFormatMessage';
export default function PaymentInformation() {
const paymentPortal = usePaymentPortalUrl();
const formatMessage = useFormatMessage();
return (
<React.Fragment>
<PageTitle
gutterBottom
>
{formatMessage('billingAndUsageSettings.paymentInformation')}
</PageTitle>
<Typography>
{formatMessage(
'billingAndUsageSettings.paymentPortalInformation',
{ link: generateExternalLink(paymentPortal.url) })}
</Typography>
</React.Fragment>
);
}

View File

@@ -0,0 +1,152 @@
import * as React from 'react';
import Box from '@mui/material/Box';
import Button from '@mui/material/Button';
import Card from '@mui/material/Card';
import CardContent from '@mui/material/CardContent';
import Divider from '@mui/material/Divider';
import Grid from '@mui/material/Grid';
import Typography from '@mui/material/Typography';
import Table from '@mui/material/Table';
import TableBody from '@mui/material/TableBody';
import TableCell from '@mui/material/TableCell';
import TableContainer from '@mui/material/TableContainer';
import TableHead from '@mui/material/TableHead';
import TableRow from '@mui/material/TableRow';
import Paper from '@mui/material/Paper';
import LockIcon from '@mui/icons-material/Lock';
import usePaymentPlans from 'hooks/usePaymentPlans.ee';
export default function UpgradeFreeTrial() {
const { plans, loading } = usePaymentPlans();
const [selectedIndex, setSelectedIndex] = React.useState(0);
const selectedPlan = plans?.[selectedIndex];
const updateSelection = (index: number) => setSelectedIndex(index);
if (loading || !plans.length) return null;
return (
<React.Fragment>
<Card sx={{ mb: 3, p: 2 }}>
<CardContent>
<Box sx={{ mb: 1, display: 'flex', justifyContent: 'space-between' }}>
<Typography variant="h6" fontWeight="bold">
Upgrade your free trial
</Typography>
{/* <Chip label="Active" color="success" /> */}
</Box>
<Divider sx={{ mb: 3 }} />
<Grid
container
item
xs={12}
spacing={1}
sx={{ mb: 2 }}
alignItems="stretch"
>
<TableContainer component={Paper}>
<Table aria-label="simple table">
<TableHead
sx={{
backgroundColor: (theme) =>
theme.palette.background.default,
}}
>
<TableRow>
<TableCell sx={{ pt: 0, pb: 2 }}>
<Typography
variant="subtitle1"
sx={{ color: 'text.secondary', mt: 2, fontWeight: 500 }}
>
Monthly Tasks
</Typography>
</TableCell>
<TableCell align="right" sx={{ py: 0, pb: 2 }}>
<Typography
variant="subtitle1"
sx={{ color: 'text.secondary', mt: 2, fontWeight: 500 }}
>
Price
</Typography>
</TableCell>
</TableRow>
</TableHead>
<TableBody>
{plans.map((plan, index) => (
<TableRow
key={plan.productId}
onClick={() => updateSelection(index)}
sx={{
'&:hover': { cursor: 'pointer' },
backgroundColor: selectedIndex === index ? '#f1f3fa' : 'white',
border: selectedIndex === index ? '2px solid #0059f7' : 'none',
}}
>
<TableCell component="th" scope="row" sx={{ py: 2 }}>
<Typography
variant="subtitle2"
sx={{
fontWeight: selectedIndex === index ? 'bold' : 'normal',
}}
>
{plan.limit}
</Typography>
</TableCell>
<TableCell align="right" sx={{ py: 2 }}>
<Typography
variant="subtitle2"
sx={{
fontWeight: selectedIndex === index ? 'bold' : 'normal',
}}
>
{plan.price} / month
</Typography>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</TableContainer>
</Grid>
<Box
sx={{
display: 'flex',
flexDirection: 'column',
alignItems: 'flex-end',
}}
>
<Box sx={{ display: 'flex', flexDirection: 'row' }}>
<Typography
variant="subtitle2"
sx={{
mt: 2,
}}
>
Due today:&nbsp;
</Typography>
<Typography
variant="subtitle2"
sx={{
mt: 2,
fontWeight: 'bold',
}}
>
{selectedPlan.price}
</Typography>
</Box>
<Typography variant="subtitle2" sx={{ fontSize: '12px', mt: 0 }}>
+ VAT if applicable
</Typography>
<Button size="small" variant="contained" sx={{ mt: 2 }}>
<LockIcon fontSize="small" sx={{ mr: 1 }} />
Pay securely via Paddle
</Button>
</Box>
</CardContent>
</Card>
</React.Fragment>
);
}

View File

@@ -1,13 +1,15 @@
import * as React from 'react';
import { DateTime } from 'luxon';
import Paper from '@mui/material/Paper';
import Table from '@mui/material/Table';
import TableBody from '@mui/material/TableBody';
import TableCell from '@mui/material/TableCell';
import TableContainer from '@mui/material/TableContainer';
import TableRow from '@mui/material/TableRow';
import { Link } from 'react-router-dom';
import Box from '@mui/material/Box';
import Button from '@mui/material/Button';
import Card from '@mui/material/Card';
import CardActions from '@mui/material/CardActions';
import CardContent from '@mui/material/CardContent';
import Divider from '@mui/material/Divider';
import Grid from '@mui/material/Grid';
import Typography from '@mui/material/Typography';
import * as URLS from 'config/urls';
import useUsageData from 'hooks/useUsageData.ee';
export default function UsageDataInformation() {
@@ -15,43 +17,143 @@ export default function UsageDataInformation() {
return (
<React.Fragment>
<TableContainer component={Paper}>
<Table>
<TableBody>
<TableRow>
<TableCell component="td" scope="row">
Current plan
</TableCell>
<Card sx={{ mb: 3, p: 2 }}>
<CardContent>
<Box sx={{ mb: 1, display: 'flex', justifyContent: 'space-between' }}>
<Typography variant="h6" fontWeight="bold">
Subscription plan
</Typography>
{/* <Chip label="Active" color="success" /> */}
</Box>
<Divider sx={{ mb: 3 }} />
<Grid
container
item
xs={12}
spacing={1}
sx={{ mb: [2, 2, 8] }}
alignItems="stretch"
>
<Grid item xs={12} md={4}>
<Card
sx={{
height: '100%',
backgroundColor: (theme) => theme.palette.background.default,
}}
>
<CardContent>
<Typography variant="subtitle2" sx={{ pb: 0.5 }}>
Monthly quota
</Typography>
<Typography variant="h6" fontWeight="bold">
Free trial
</Typography>
</CardContent>
<CardActions>
<Button size="small">Upgrade plan</Button>
</CardActions>
</Card>
</Grid>
<TableCell align="right" sx={{ fontWeight: 500 }}>{usageData.name}</TableCell>
</TableRow>
<Grid item xs={12} md={4}>
<Card
sx={{
height: '100%',
backgroundColor: (theme) => theme.palette.background.default,
}}
>
<CardContent>
<Typography variant="subtitle2" sx={{ pb: 0.5 }}>
Next bill amount
</Typography>
<TableRow>
<TableCell component="td" scope="row">
Total allowed task count
</TableCell>
<Typography variant="h6" fontWeight="bold">
---
</Typography>
</CardContent>
<TableCell align="right" sx={{ fontWeight: 500 }}>{usageData.allowedTaskCount}</TableCell>
</TableRow>
<CardActions>
{/* <Button size="small">Update billing info</Button> */}
</CardActions>
</Card>
</Grid>
<TableRow>
<TableCell component="td" scope="row">
Consumed task count
</TableCell>
<Grid item xs={12} md={4}>
<Card
sx={{
height: '100%',
backgroundColor: (theme) => theme.palette.background.default,
}}
>
<CardContent>
<Typography variant="subtitle2" sx={{ pb: 0.5 }}>
Next bill date
</Typography>
<Typography variant="h6" fontWeight="bold">
---
</Typography>
</CardContent>
<TableCell align="right" sx={{ fontWeight: 500 }}>{usageData.consumedTaskCount}</TableCell>
</TableRow>
<CardActions>
{/* <Button disabled size="small">
monthly billing
</Button> */}
</CardActions>
</Card>
</Grid>
</Grid>
<Box>
<Typography variant="h6" fontWeight="bold">
Your usage
</Typography>
<TableRow sx={{ 'td': { border: 0 } }}>
<TableCell component="td" scope="row">
Next billing date
</TableCell>
<Box>
<Typography
variant="subtitle2"
sx={{ color: 'text.secondary', mt: 1 }}
>
Last 30 days total usage
</Typography>
</Box>
<TableCell align="right" sx={{ fontWeight: 500 }}>{usageData.nextResetAt?.toLocaleString(DateTime.DATE_FULL)}</TableCell>
</TableRow>
</TableBody>
</Table>
</TableContainer>
<Divider sx={{ mt: 2 }} />
<Box
sx={{
display: 'flex',
flexDirection: 'row',
justifyContent: 'space-between',
}}
>
<Typography
variant="subtitle2"
sx={{ color: 'text.secondary', mt: 2, fontWeight: 500 }}
>
Tasks
</Typography>
<Typography
variant="subtitle2"
sx={{ color: 'text.secondary', mt: 2, fontWeight: 500 }}
>
12300
</Typography>
</Box>
<Divider sx={{ mt: 2 }} />
</Box>
<Button
component={Link}
to={URLS.SETTINGS_PLAN_UPGRADE}
size="small"
variant="contained"
sx={{ mt: 2 }}
>
Upgrade
</Button>
</CardContent>
</Card>
</React.Fragment>
);
}

View File

@@ -65,9 +65,11 @@ export const SETTINGS = '/settings';
export const SETTINGS_DASHBOARD = SETTINGS;
export const PROFILE = 'profile';
export const BILLING_AND_USAGE = 'billing';
export const PLAN_UPGRADE = 'upgrade';
export const UPDATES = '/updates';
export const SETTINGS_PROFILE = `${SETTINGS}/${PROFILE}`;
export const SETTINGS_BILLING_AND_USAGE = `${SETTINGS}/${BILLING_AND_USAGE}`;
export const SETTINGS_PLAN_UPGRADE = `${SETTINGS_BILLING_AND_USAGE}/${PLAN_UPGRADE}`;
export const DASHBOARD = FLOWS;

View File

@@ -0,0 +1,12 @@
import { gql } from '@apollo/client';
export const GET_PAYMENT_PLANS = gql`
query GetPaymentPlans {
getPaymentPlans {
name
limit
price
productId
}
}
`;

View File

@@ -0,0 +1,18 @@
import { useQuery } from '@apollo/client';
import { TPaymentPlan } from '@automatisch/types';
import { GET_PAYMENT_PLANS } from 'graphql/queries/get-payment-plans.ee';
type UsePaymentPlansReturn = {
plans: TPaymentPlan[];
loading: boolean;
};
export default function usePaymentPlans(): UsePaymentPlansReturn {
const { data, loading } = useQuery(GET_PAYMENT_PLANS);
return {
plans: data?.getPaymentPlans || [],
loading
};
}

View File

@@ -139,5 +139,6 @@
"resetPasswordForm.passwordUpdated": "The password has been updated. Now, you can login.",
"usageAlert.informationText": "Tasks: {consumedTaskCount}/{allowedTaskCount} (Resets {relativeResetDate})",
"usageAlert.viewPlans": "View plans",
"jsonViewer.noDataFound": "We couldn't find anything matching your search"
"jsonViewer.noDataFound": "We couldn't find anything matching your search",
"planUpgrade.title": "Upgrade your plan"
}

View File

@@ -2,9 +2,9 @@ import * as React from 'react';
import { Navigate } from 'react-router-dom';
import Grid from '@mui/material/Grid';
import * as URLS from 'config/urls'
import PaymentInformation from 'components/PaymentInformation/index.ee';
import * as URLS from 'config/urls';
import UsageDataInformation from 'components/UsageDataInformation/index.ee';
import UpgradeFreeTrial from 'components/UpgradeFreeTrial/index.ee';
import PageTitle from 'components/PageTitle';
import Container from 'components/Container';
import useFormatMessage from 'hooks/useFormatMessage';
@@ -16,27 +16,25 @@ function BillingAndUsageSettings() {
// redirect to the initial settings page
if (isCloud === false) {
return (<Navigate to={URLS.SETTINGS} replace={true} />)
return <Navigate to={URLS.SETTINGS} replace={true} />;
}
// render nothing until we know if it's cloud or not
// here, `isCloud` is not `false`, but `undefined`
if (!isCloud) return <React.Fragment />
if (!isCloud) return <React.Fragment />;
return (
<Container sx={{ py: 3, display: 'flex', justifyContent: 'center' }}>
<Grid container item xs={12} sm={9} md={8} lg={6}>
<Grid container item xs={12} sm={9} md={8}>
<Grid item xs={12} sx={{ mb: [2, 5] }}>
<PageTitle>{formatMessage('billingAndUsageSettings.title')}</PageTitle>
<PageTitle>
{formatMessage('billingAndUsageSettings.title')}
</PageTitle>
</Grid>
<Grid item xs={12} sx={{ mb: 6 }}>
<UsageDataInformation />
</Grid>
<Grid item xs={12}>
<PaymentInformation />
</Grid>
</Grid>
</Container>
);

View File

@@ -0,0 +1,42 @@
import * as React from 'react';
import { Navigate } from 'react-router-dom';
import Grid from '@mui/material/Grid';
import * as URLS from 'config/urls';
import UpgradeFreeTrial from 'components/UpgradeFreeTrial/index.ee';
import PageTitle from 'components/PageTitle';
import Container from 'components/Container';
import useFormatMessage from 'hooks/useFormatMessage';
import useCloud from 'hooks/useCloud';
function PlanUpgrade() {
const isCloud = useCloud();
const formatMessage = useFormatMessage();
// redirect to the initial settings page
if (isCloud === false) {
return <Navigate to={URLS.SETTINGS} replace={true} />;
}
// render nothing until we know if it's cloud or not
// here, `isCloud` is not `false`, but `undefined`
if (!isCloud) return <React.Fragment />;
return (
<Container sx={{ py: 3, display: 'flex', justifyContent: 'center' }}>
<Grid container item xs={12} sm={9} md={8}>
<Grid item xs={12} sx={{ mb: [2, 5] }}>
<PageTitle>
{formatMessage('planUpgrade.title')}
</PageTitle>
</Grid>
<Grid item xs={12} sx={{ mb: 6 }}>
<UpgradeFreeTrial />
</Grid>
</Grid>
</Container>
);
}
export default PlanUpgrade;

View File

@@ -2,6 +2,7 @@ import { Route, Navigate } from 'react-router-dom';
import SettingsLayout from 'components/SettingsLayout';
import ProfileSettings from 'pages/ProfileSettings';
import BillingAndUsageSettings from 'pages/BillingAndUsageSettings/index.ee';
import PlanUpgrade from 'pages/PlanUpgrade/index.ee';
import * as URLS from 'config/urls';
@@ -25,6 +26,15 @@ export default (
}
/>
<Route
path={URLS.SETTINGS_PLAN_UPGRADE}
element={
<SettingsLayout>
<PlanUpgrade />
</SettingsLayout>
}
/>
<Route
path={URLS.SETTINGS}
element={<Navigate to={URLS.SETTINGS_PROFILE} replace />}