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; smtpPassword: string;
fromEmail: string; fromEmail: string;
isCloud: boolean; isCloud: boolean;
paddleVendorId: string;
paddleVendorAuthCode: string;
stripeSecretKey: string; stripeSecretKey: string;
stripeSigningSecret: string; stripeSigningSecret: string;
stripeStarterPriceKey: string; stripeStarterPriceKey: string;
@@ -111,6 +113,8 @@ const appConfig: AppConfig = {
smtpPassword: process.env.SMTP_PASSWORD, smtpPassword: process.env.SMTP_PASSWORD,
fromEmail: process.env.FROM_EMAIL, fromEmail: process.env.FROM_EMAIL,
isCloud: process.env.AUTOMATISCH_CLOUD === 'true', 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, stripeSecretKey: process.env.STRIPE_SECRET_KEY,
stripeSigningSecret: process.env.STRIPE_SIGNING_SECRET, stripeSigningSecret: process.env.STRIPE_SIGNING_SECRET,
stripeStarterPriceKey: process.env.STRIPE_STARTER_PRICE_KEY, 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 getDynamicFields from './queries/get-dynamic-fields';
import getCurrentUser from './queries/get-current-user'; import getCurrentUser from './queries/get-current-user';
import getUsageData from './queries/get-usage-data.ee'; 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 getPaymentPortalUrl from './queries/get-payment-portal-url.ee';
import getAutomatischInfo from './queries/get-automatisch-info'; import getAutomatischInfo from './queries/get-automatisch-info';
import healthcheck from './queries/healthcheck'; import healthcheck from './queries/healthcheck';
@@ -31,6 +32,7 @@ const queryResolvers = {
getDynamicFields, getDynamicFields,
getCurrentUser, getCurrentUser,
getUsageData, getUsageData,
getPaymentPlans,
getPaymentPortalUrl, getPaymentPortalUrl,
getAutomatischInfo, getAutomatischInfo,
healthcheck, healthcheck,

View File

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

View File

@@ -4,6 +4,7 @@ import PaymentPlan from '../../models/payment-plan.ee';
import UsageData from '../../models/usage-data.ee'; import UsageData from '../../models/usage-data.ee';
import appConfig from '../../config/app'; import appConfig from '../../config/app';
import handleWebhooks from './webhooks.ee'; import handleWebhooks from './webhooks.ee';
import paddlePlans from './plans.ee';
const plans = [ const plans = [
{ {
@@ -95,6 +96,7 @@ const billing = {
handleWebhooks, handleWebhooks,
stripe, stripe,
plans, plans,
paddlePlans,
}; };
export default billing; 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 { QueryContext, ModelOptions } from 'objection';
import { DateTime } from 'luxon';
import appConfig from '../config/app';
import Base from './base'; import Base from './base';
import Connection from './connection'; import Connection from './connection';
import Flow from './flow'; import Flow from './flow';
@@ -17,6 +19,7 @@ class User extends Base {
role: string; role: string;
resetPasswordToken: string; resetPasswordToken: string;
resetPasswordTokenSentAt: string; resetPasswordTokenSentAt: string;
trialExpiryDate: string;
connections?: Connection[]; connections?: Connection[];
flows?: Flow[]; flows?: Flow[];
steps?: Step[]; steps?: Step[];
@@ -133,9 +136,17 @@ class User extends Base {
this.password = await bcrypt.hash(this.password, 10); this.password = await bcrypt.hash(this.password, 10);
} }
async startTrialPeriod() {
this.trialExpiryDate = DateTime.now().plus({ days: 30 }).toFormat('D');
}
async $beforeInsert(queryContext: QueryContext) { async $beforeInsert(queryContext: QueryContext) {
await super.$beforeInsert(queryContext); await super.$beforeInsert(queryContext);
await this.generateHash(); await this.generateHash();
if (appConfig.isCloud) {
await this.startTrialPeriod();
}
} }
async $beforeUpdate(opt: ModelOptions, queryContext: QueryContext) { async $beforeUpdate(opt: ModelOptions, queryContext: QueryContext) {

View File

@@ -321,6 +321,13 @@ export type IGlobalVariable = {
setActionItem?: (actionItem: IActionItem) => void; setActionItem?: (actionItem: IActionItem) => void;
}; };
export type TPaymentPlan = {
price: string;
name: string;
limit: string;
productId: string;
}
declare module 'axios' { declare module 'axios' {
interface AxiosResponse { interface AxiosResponse {
httpError?: IJSONObject; httpError?: IJSONObject;
@@ -335,4 +342,3 @@ export interface IRequest extends Request {
rawBody?: Buffer; rawBody?: Buffer;
currentUser?: IUser; 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 * as React from 'react';
import { DateTime } from 'luxon'; import { Link } from 'react-router-dom';
import Box from '@mui/material/Box';
import Paper from '@mui/material/Paper'; import Button from '@mui/material/Button';
import Table from '@mui/material/Table'; import Card from '@mui/material/Card';
import TableBody from '@mui/material/TableBody'; import CardActions from '@mui/material/CardActions';
import TableCell from '@mui/material/TableCell'; import CardContent from '@mui/material/CardContent';
import TableContainer from '@mui/material/TableContainer'; import Divider from '@mui/material/Divider';
import TableRow from '@mui/material/TableRow'; import Grid from '@mui/material/Grid';
import Typography from '@mui/material/Typography';
import * as URLS from 'config/urls';
import useUsageData from 'hooks/useUsageData.ee'; import useUsageData from 'hooks/useUsageData.ee';
export default function UsageDataInformation() { export default function UsageDataInformation() {
@@ -15,43 +17,143 @@ export default function UsageDataInformation() {
return ( return (
<React.Fragment> <React.Fragment>
<TableContainer component={Paper}> <Card sx={{ mb: 3, p: 2 }}>
<Table> <CardContent>
<TableBody> <Box sx={{ mb: 1, display: 'flex', justifyContent: 'space-between' }}>
<TableRow> <Typography variant="h6" fontWeight="bold">
<TableCell component="td" scope="row"> Subscription plan
Current plan </Typography>
</TableCell> {/* <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> <Grid item xs={12} md={4}>
</TableRow> <Card
sx={{
height: '100%',
backgroundColor: (theme) => theme.palette.background.default,
}}
>
<CardContent>
<Typography variant="subtitle2" sx={{ pb: 0.5 }}>
Next bill amount
</Typography>
<TableRow> <Typography variant="h6" fontWeight="bold">
<TableCell component="td" scope="row"> ---
Total allowed task count </Typography>
</TableCell> </CardContent>
<TableCell align="right" sx={{ fontWeight: 500 }}>{usageData.allowedTaskCount}</TableCell> <CardActions>
</TableRow> {/* <Button size="small">Update billing info</Button> */}
</CardActions>
</Card>
</Grid>
<TableRow> <Grid item xs={12} md={4}>
<TableCell component="td" scope="row"> <Card
Consumed task count sx={{
</TableCell> 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> <CardActions>
</TableRow> {/* <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 } }}> <Box>
<TableCell component="td" scope="row"> <Typography
Next billing date variant="subtitle2"
</TableCell> 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> <Divider sx={{ mt: 2 }} />
</TableRow>
</TableBody> <Box
</Table> sx={{
</TableContainer> 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> </React.Fragment>
); );
} }

View File

@@ -65,9 +65,11 @@ export const SETTINGS = '/settings';
export const SETTINGS_DASHBOARD = SETTINGS; export const SETTINGS_DASHBOARD = SETTINGS;
export const PROFILE = 'profile'; export const PROFILE = 'profile';
export const BILLING_AND_USAGE = 'billing'; export const BILLING_AND_USAGE = 'billing';
export const PLAN_UPGRADE = 'upgrade';
export const UPDATES = '/updates'; export const UPDATES = '/updates';
export const SETTINGS_PROFILE = `${SETTINGS}/${PROFILE}`; export const SETTINGS_PROFILE = `${SETTINGS}/${PROFILE}`;
export const SETTINGS_BILLING_AND_USAGE = `${SETTINGS}/${BILLING_AND_USAGE}`; 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; 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.", "resetPasswordForm.passwordUpdated": "The password has been updated. Now, you can login.",
"usageAlert.informationText": "Tasks: {consumedTaskCount}/{allowedTaskCount} (Resets {relativeResetDate})", "usageAlert.informationText": "Tasks: {consumedTaskCount}/{allowedTaskCount} (Resets {relativeResetDate})",
"usageAlert.viewPlans": "View plans", "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 { Navigate } from 'react-router-dom';
import Grid from '@mui/material/Grid'; import Grid from '@mui/material/Grid';
import * as URLS from 'config/urls' import * as URLS from 'config/urls';
import PaymentInformation from 'components/PaymentInformation/index.ee';
import UsageDataInformation from 'components/UsageDataInformation/index.ee'; import UsageDataInformation from 'components/UsageDataInformation/index.ee';
import UpgradeFreeTrial from 'components/UpgradeFreeTrial/index.ee';
import PageTitle from 'components/PageTitle'; import PageTitle from 'components/PageTitle';
import Container from 'components/Container'; import Container from 'components/Container';
import useFormatMessage from 'hooks/useFormatMessage'; import useFormatMessage from 'hooks/useFormatMessage';
@@ -16,27 +16,25 @@ function BillingAndUsageSettings() {
// redirect to the initial settings page // redirect to the initial settings page
if (isCloud === false) { 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 // render nothing until we know if it's cloud or not
// here, `isCloud` is not `false`, but `undefined` // here, `isCloud` is not `false`, but `undefined`
if (!isCloud) return <React.Fragment /> if (!isCloud) return <React.Fragment />;
return ( return (
<Container sx={{ py: 3, display: 'flex', justifyContent: 'center' }}> <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] }}> <Grid item xs={12} sx={{ mb: [2, 5] }}>
<PageTitle>{formatMessage('billingAndUsageSettings.title')}</PageTitle> <PageTitle>
{formatMessage('billingAndUsageSettings.title')}
</PageTitle>
</Grid> </Grid>
<Grid item xs={12} sx={{ mb: 6 }}> <Grid item xs={12} sx={{ mb: 6 }}>
<UsageDataInformation /> <UsageDataInformation />
</Grid> </Grid>
<Grid item xs={12}>
<PaymentInformation />
</Grid>
</Grid> </Grid>
</Container> </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 SettingsLayout from 'components/SettingsLayout';
import ProfileSettings from 'pages/ProfileSettings'; import ProfileSettings from 'pages/ProfileSettings';
import BillingAndUsageSettings from 'pages/BillingAndUsageSettings/index.ee'; import BillingAndUsageSettings from 'pages/BillingAndUsageSettings/index.ee';
import PlanUpgrade from 'pages/PlanUpgrade/index.ee';
import * as URLS from 'config/urls'; import * as URLS from 'config/urls';
@@ -25,6 +26,15 @@ export default (
} }
/> />
<Route
path={URLS.SETTINGS_PLAN_UPGRADE}
element={
<SettingsLayout>
<PlanUpgrade />
</SettingsLayout>
}
/>
<Route <Route
path={URLS.SETTINGS} path={URLS.SETTINGS}
element={<Navigate to={URLS.SETTINGS_PROFILE} replace />} element={<Navigate to={URLS.SETTINGS_PROFILE} replace />}