Merge pull request #1780 from automatisch/AUT-686

refactor: rewrite useBillingAndUsageData with useSubscription and useUserTrial
This commit is contained in:
Ali BARIN
2024-04-08 15:22:55 +02:00
committed by GitHub
14 changed files with 185 additions and 321 deletions

View File

@@ -1,101 +0,0 @@
import { DateTime } from 'luxon';
import Billing from '../../helpers/billing/index.ee.js';
import ExecutionStep from '../../models/execution-step.js';
const getBillingAndUsage = async (_parent, _params, context) => {
const persistedSubscription = await context.currentUser.$relatedQuery(
'currentSubscription'
);
const subscription = persistedSubscription
? paidSubscription(persistedSubscription)
: freeTrialSubscription();
return {
subscription,
usage: {
task: executionStepCount(context),
},
};
};
const paidSubscription = (subscription) => {
const currentPlan = Billing.paddlePlans.find(
(plan) => plan.productId === subscription.paddlePlanId
);
return {
status: subscription.status,
monthlyQuota: {
title: currentPlan.limit,
action: {
type: 'link',
text: 'Cancel plan',
src: subscription.cancelUrl,
},
},
nextBillAmount: {
title: subscription.nextBillAmount
? '€' + subscription.nextBillAmount
: '---',
action: {
type: 'link',
text: 'Update payment method',
src: subscription.updateUrl,
},
},
nextBillDate: {
title: subscription.nextBillDate ? subscription.nextBillDate : '---',
action: {
type: 'text',
text: '(monthly payment)',
},
},
};
};
const freeTrialSubscription = () => {
return {
status: null,
monthlyQuota: {
title: 'Free Trial',
action: {
type: 'link',
text: 'Upgrade plan',
src: '/settings/billing/upgrade',
},
},
nextBillAmount: {
title: '---',
action: null,
},
nextBillDate: {
title: '---',
action: null,
},
};
};
const executionIds = async (context) => {
return (
await context.currentUser
.$relatedQuery('executions')
.select('executions.id')
).map((execution) => execution.id);
};
const executionStepCount = async (context) => {
const executionStepCount = await ExecutionStep.query()
.whereIn('execution_id', await executionIds(context))
.andWhere(
'created_at',
'>=',
DateTime.now().minus({ days: 30 }).toISODate()
)
.count()
.first();
return executionStepCount.count;
};
export default getBillingAndUsage;

View File

@@ -1,5 +1,4 @@
import getAppAuthClient from './queries/get-app-auth-client.ee.js'; import getAppAuthClient from './queries/get-app-auth-client.ee.js';
import getBillingAndUsage from './queries/get-billing-and-usage.ee.js';
import getConnectedApps from './queries/get-connected-apps.js'; import getConnectedApps from './queries/get-connected-apps.js';
import getDynamicData from './queries/get-dynamic-data.js'; import getDynamicData from './queries/get-dynamic-data.js';
import getStepWithTestExecutions from './queries/get-step-with-test-executions.js'; import getStepWithTestExecutions from './queries/get-step-with-test-executions.js';
@@ -7,7 +6,6 @@ import testConnection from './queries/test-connection.js';
const queryResolvers = { const queryResolvers = {
getAppAuthClient, getAppAuthClient,
getBillingAndUsage,
getConnectedApps, getConnectedApps,
getDynamicData, getDynamicData,
getStepWithTestExecutions, getStepWithTestExecutions,

View File

@@ -8,7 +8,6 @@ type Query {
key: String! key: String!
parameters: JSONObject parameters: JSONObject
): JSONObject ): JSONObject
getBillingAndUsage: GetBillingAndUsage
} }
type Mutation { type Mutation {
@@ -560,43 +559,6 @@ type License {
verified: Boolean verified: Boolean
} }
type GetBillingAndUsage {
subscription: Subscription
usage: Usage
}
type MonthlyQuota {
title: String
action: BillingCardAction
}
type NextBillAmount {
title: String
action: BillingCardAction
}
type NextBillDate {
title: String
action: BillingCardAction
}
type BillingCardAction {
type: String
text: String
src: String
}
type Subscription {
status: String
monthlyQuota: MonthlyQuota
nextBillAmount: NextBillAmount
nextBillDate: NextBillDate
}
type Usage {
task: Int
}
type Permission { type Permission {
id: String id: String
action: String action: String

View File

@@ -2,6 +2,7 @@ import * as React from 'react';
import { useLocation } from 'react-router-dom'; import { useLocation } from 'react-router-dom';
import Alert from '@mui/material/Alert'; import Alert from '@mui/material/Alert';
import Typography from '@mui/material/Typography'; import Typography from '@mui/material/Typography';
import useFormatMessage from 'hooks/useFormatMessage'; import useFormatMessage from 'hooks/useFormatMessage';
export default function CheckoutCompletedAlert() { export default function CheckoutCompletedAlert() {
@@ -9,7 +10,9 @@ export default function CheckoutCompletedAlert() {
const location = useLocation(); const location = useLocation();
const state = location.state; const state = location.state;
const checkoutCompleted = state?.checkoutCompleted; const checkoutCompleted = state?.checkoutCompleted;
if (!checkoutCompleted) return <React.Fragment />; if (!checkoutCompleted) return <React.Fragment />;
return ( return (
<Alert <Alert
severity="success" severity="success"

View File

@@ -3,11 +3,21 @@ import Alert from '@mui/material/Alert';
import Typography from '@mui/material/Typography'; import Typography from '@mui/material/Typography';
import useSubscription from 'hooks/useSubscription.ee'; import useSubscription from 'hooks/useSubscription.ee';
import useFormatMessage from 'hooks/useFormatMessage';
import { DateTime } from 'luxon';
import useUserTrial from 'hooks/useUserTrial.ee';
export default function SubscriptionCancelledAlert() { export default function SubscriptionCancelledAlert() {
const formatMessage = useFormatMessage();
const subscription = useSubscription(); const subscription = useSubscription();
const trial = useUserTrial();
if (!subscription) return <React.Fragment />; if (subscription?.data?.status === 'active' || trial.hasTrial)
return <React.Fragment />;
const cancellationEffectiveDateObject = DateTime.fromISO(
subscription?.data?.cancellationEffectiveDate,
);
return ( return (
<Alert <Alert
@@ -18,7 +28,9 @@ export default function SubscriptionCancelledAlert() {
}} }}
> >
<Typography variant="subtitle2" sx={{ lineHeight: 1.5 }}> <Typography variant="subtitle2" sx={{ lineHeight: 1.5 }}>
{subscription.message} {formatMessage('subscriptionCancelledAlert.text', {
date: cancellationEffectiveDateObject.toFormat('DDD'),
})}
</Typography> </Typography>
</Alert> </Alert>
); );

View File

@@ -8,7 +8,7 @@ import useUserTrial from 'hooks/useUserTrial.ee';
export default function TrialStatusBadge() { export default function TrialStatusBadge() {
const data = useUserTrial(); const data = useUserTrial();
if (!data) return <React.Fragment />; if (!data.hasTrial) return <React.Fragment />;
const { message, status } = data; const { message, status } = data;

View File

@@ -10,15 +10,23 @@ import CardContent from '@mui/material/CardContent';
import Divider from '@mui/material/Divider'; import Divider from '@mui/material/Divider';
import Grid from '@mui/material/Grid'; import Grid from '@mui/material/Grid';
import Typography from '@mui/material/Typography'; import Typography from '@mui/material/Typography';
import TrialOverAlert from 'components/TrialOverAlert/index.ee'; import TrialOverAlert from 'components/TrialOverAlert/index.ee';
import SubscriptionCancelledAlert from 'components/SubscriptionCancelledAlert/index.ee'; import SubscriptionCancelledAlert from 'components/SubscriptionCancelledAlert/index.ee';
import CheckoutCompletedAlert from 'components/CheckoutCompletedAlert/index.ee'; import CheckoutCompletedAlert from 'components/CheckoutCompletedAlert/index.ee';
import * as URLS from 'config/urls'; import * as URLS from 'config/urls';
import useBillingAndUsageData from 'hooks/useBillingAndUsageData.ee';
import useFormatMessage from 'hooks/useFormatMessage'; import useFormatMessage from 'hooks/useFormatMessage';
import usePlanAndUsage from 'hooks/usePlanAndUsage';
import useSubscription from 'hooks/useSubscription.ee';
import useUserTrial from 'hooks/useUserTrial.ee';
import { useQueryClient } from '@tanstack/react-query';
import useCurrentUser from 'hooks/useCurrentUser';
const capitalize = (str) => str[0].toUpperCase() + str.slice(1, str.length); const capitalize = (str) => str[0].toUpperCase() + str.slice(1, str.length);
function BillingCard(props) { function BillingCard(props) {
const { name, title = '', action } = props; const { name, title = '', action, text } = props;
return ( return (
<Card <Card
sx={{ sx={{
@@ -40,42 +48,94 @@ function BillingCard(props) {
</CardContent> </CardContent>
<CardActions> <CardActions>
<Action action={action} /> <Action action={action} text={text} />
</CardActions> </CardActions>
</Card> </Card>
); );
} }
function Action(props) { function Action(props) {
const { action } = props; const { action, text } = props;
if (!action) return <React.Fragment />; if (!action) return <React.Fragment />;
const { text, type } = action;
if (type === 'link') { if (action.startsWith('http')) {
if (action.src.startsWith('http')) {
return (
<Button size="small" href={action.src} target="_blank">
{text}
</Button>
);
} else {
return (
<Button size="small" component={Link} to={action.src}>
{text}
</Button>
);
}
}
if (type === 'text') {
return ( return (
<Typography variant="subtitle2" pb={1}> <Button size="small" href={action} target="_blank">
{text} {text}
</Typography> </Button>
);
} else if (action.startsWith('/')) {
return (
<Button size="small" component={Link} to={action}>
{text}
</Button>
); );
} }
return <React.Fragment />;
return (
<Typography variant="subtitle2" pb={1}>
{text}
</Typography>
);
} }
export default function UsageDataInformation() { export default function UsageDataInformation() {
const formatMessage = useFormatMessage(); const formatMessage = useFormatMessage();
const billingAndUsageData = useBillingAndUsageData(); const queryClient = useQueryClient();
const { data } = usePlanAndUsage();
const planAndUsage = data?.data;
const { data: currentUser } = useCurrentUser();
const currentUserId = currentUser?.data?.id;
const trial = useUserTrial();
const subscriptionData = useSubscription();
const subscription = subscriptionData?.data;
let billingInfo;
React.useEffect(() => {
queryClient.invalidateQueries({
queryKey: ['planAndUsage', currentUserId],
});
}, [subscription, queryClient, currentUserId]);
if (trial.hasTrial) {
billingInfo = {
monthlyQuota: {
title: formatMessage('usageDataInformation.freeTrial'),
action: URLS.SETTINGS_PLAN_UPGRADE,
text: 'Upgrade plan',
},
nextBillAmount: {
title: '---',
action: null,
text: null,
},
nextBillDate: {
title: '---',
action: null,
text: null,
},
};
} else {
billingInfo = {
monthlyQuota: {
title: planAndUsage?.plan?.limit,
action: subscription?.cancelUrl,
text: formatMessage('usageDataInformation.cancelPlan'),
},
nextBillAmount: {
title: `${subscription?.nextBillAmount}`,
action: subscription?.updateUrl,
text: formatMessage('usageDataInformation.updatePaymentMethod'),
},
nextBillDate: {
title: subscription?.nextBillDate,
action: formatMessage('usageDataInformation.monthlyPayment'),
text: formatMessage('usageDataInformation.monthlyPayment'),
},
};
}
return ( return (
<React.Fragment> <React.Fragment>
<Stack sx={{ width: '100%', mb: 2 }} spacing={2}> <Stack sx={{ width: '100%', mb: 2 }} spacing={2}>
@@ -92,11 +152,8 @@ export default function UsageDataInformation() {
{formatMessage('usageDataInformation.subscriptionPlan')} {formatMessage('usageDataInformation.subscriptionPlan')}
</Typography> </Typography>
{billingAndUsageData?.subscription?.status && ( {subscription?.status && (
<Chip <Chip label={capitalize(subscription?.status)} color="success" />
label={capitalize(billingAndUsageData?.subscription?.status)}
color="success"
/>
)} )}
</Box> </Box>
@@ -113,26 +170,27 @@ export default function UsageDataInformation() {
<Grid item xs={12} md={4}> <Grid item xs={12} md={4}>
<BillingCard <BillingCard
name={formatMessage('usageDataInformation.monthlyQuota')} name={formatMessage('usageDataInformation.monthlyQuota')}
title={billingAndUsageData?.subscription?.monthlyQuota.title} title={billingInfo.monthlyQuota.title}
action={billingAndUsageData?.subscription?.monthlyQuota.action} action={billingInfo.monthlyQuota.action}
text={billingInfo.monthlyQuota.text}
/> />
</Grid> </Grid>
<Grid item xs={12} md={4}> <Grid item xs={12} md={4}>
<BillingCard <BillingCard
name={formatMessage('usageDataInformation.nextBillAmount')} name={formatMessage('usageDataInformation.nextBillAmount')}
title={billingAndUsageData?.subscription?.nextBillAmount.title} title={billingInfo.nextBillAmount.title}
action={ action={billingInfo.nextBillAmount.action}
billingAndUsageData?.subscription?.nextBillAmount.action text={billingInfo.nextBillAmount.text}
}
/> />
</Grid> </Grid>
<Grid item xs={12} md={4}> <Grid item xs={12} md={4}>
<BillingCard <BillingCard
name={formatMessage('usageDataInformation.nextBillDate')} name={formatMessage('usageDataInformation.nextBillDate')}
title={billingAndUsageData?.subscription?.nextBillDate.title} title={billingInfo.nextBillDate.title}
action={billingAndUsageData?.subscription?.nextBillDate.action} action={billingInfo.nextBillDate.action}
text={billingInfo.nextBillDate.text}
/> />
</Grid> </Grid>
</Grid> </Grid>
@@ -171,7 +229,7 @@ export default function UsageDataInformation() {
variant="subtitle2" variant="subtitle2"
sx={{ color: 'text.secondary', mt: 2, fontWeight: 500 }} sx={{ color: 'text.secondary', mt: 2, fontWeight: 500 }}
> >
{billingAndUsageData?.usage.task} {planAndUsage?.usage.task}
</Typography> </Typography>
</Box> </Box>
@@ -179,7 +237,7 @@ export default function UsageDataInformation() {
</Box> </Box>
{/* free plan has `null` status so that we can show the upgrade button */} {/* free plan has `null` status so that we can show the upgrade button */}
{billingAndUsageData?.subscription?.status === null && ( {subscription?.status === undefined && (
<Button <Button
component={Link} component={Link}
to={URLS.SETTINGS_PLAN_UPGRADE} to={URLS.SETTINGS_PLAN_UPGRADE}

View File

@@ -4,7 +4,7 @@ import { useNavigate } from 'react-router-dom';
import * as URLS from 'config/urls'; import * as URLS from 'config/urls';
import useCloud from 'hooks/useCloud'; import useCloud from 'hooks/useCloud';
import usePaddleInfo from 'hooks/usePaddleInfo.ee'; import usePaddleInfo from 'hooks/usePaddleInfo.ee';
import apolloClient from 'graphql/client'; import { useQueryClient } from '@tanstack/react-query';
export const PaddleContext = React.createContext({ export const PaddleContext = React.createContext({
loaded: false, loaded: false,
@@ -17,6 +17,7 @@ export const PaddleProvider = (props) => {
const { data } = usePaddleInfo(); const { data } = usePaddleInfo();
const sandbox = data?.data?.sandbox; const sandbox = data?.data?.sandbox;
const vendorId = data?.data?.vendorId; const vendorId = data?.data?.vendorId;
const queryClient = useQueryClient();
const [loaded, setLoaded] = React.useState(false); const [loaded, setLoaded] = React.useState(false);
@@ -29,8 +30,12 @@ export const PaddleProvider = (props) => {
if (completed) { if (completed) {
// Paddle has side effects in the background, // Paddle has side effects in the background,
// so we need to refetch the relevant queries // so we need to refetch the relevant queries
await apolloClient.refetchQueries({ await queryClient.refetchQueries({
include: ['GetTrialStatus', 'GetBillingAndUsage'], queryKey: ['userTrial'],
});
await queryClient.refetchQueries({
queryKey: ['subscription'],
}); });
navigate(URLS.SETTINGS_BILLING_AND_USAGE, { navigate(URLS.SETTINGS_BILLING_AND_USAGE, {
@@ -39,7 +44,7 @@ export const PaddleProvider = (props) => {
} }
} }
}, },
[navigate], [navigate, queryClient],
); );
const value = React.useMemo(() => { const value = React.useMemo(() => {

View File

@@ -1,37 +0,0 @@
import { gql } from '@apollo/client';
export const GET_BILLING_AND_USAGE = gql`
query GetBillingAndUsage {
getBillingAndUsage {
subscription {
status
monthlyQuota {
title
action {
type
text
src
}
}
nextBillDate {
title
action {
type
text
src
}
}
nextBillAmount {
title
action {
type
text
src
}
}
}
usage {
task
}
}
}
`;

View File

@@ -1,52 +0,0 @@
import * as React from 'react';
import { useQuery } from '@apollo/client';
import { useLocation } from 'react-router-dom';
import { DateTime } from 'luxon';
import { GET_BILLING_AND_USAGE } from 'graphql/queries/get-billing-and-usage.ee';
function transform(billingAndUsageData) {
const nextBillDate = billingAndUsageData.subscription.nextBillDate;
const nextBillDateTitle = nextBillDate.title;
const nextBillDateTitleDateObject = DateTime.fromMillis(
Number(nextBillDateTitle),
);
const formattedNextBillDateTitle = nextBillDateTitleDateObject.isValid
? nextBillDateTitleDateObject.toFormat('LLL dd, yyyy')
: nextBillDateTitle;
return {
...billingAndUsageData,
subscription: {
...billingAndUsageData.subscription,
nextBillDate: {
...billingAndUsageData.subscription.nextBillDate,
title: formattedNextBillDateTitle,
},
},
};
}
export default function useBillingAndUsageData() {
const location = useLocation();
const state = location.state;
const { data, loading, startPolling, stopPolling } = useQuery(
GET_BILLING_AND_USAGE,
);
const checkoutCompleted = state?.checkoutCompleted;
const hasSubscription = !!data?.getBillingAndUsage?.subscription?.status;
React.useEffect(
function pollDataUntilSubscriptionIsCreated() {
if (checkoutCompleted && !hasSubscription) {
startPolling(1000);
}
},
[checkoutCompleted, hasSubscription, startPolling],
);
React.useEffect(
function stopPollingWhenSubscriptionIsCreated() {
if (checkoutCompleted && hasSubscription) {
stopPolling();
}
},
[checkoutCompleted, hasSubscription, stopPolling],
);
if (loading) return null;
return transform(data.getBillingAndUsage);
}

View File

@@ -0,0 +1,18 @@
import { useQuery } from '@tanstack/react-query';
import api from 'helpers/api';
export default function usePlanAndUsage(userId) {
const query = useQuery({
queryKey: ['planAndUsage', userId],
queryFn: async ({ signal }) => {
const { data } = await api.get(`/v1/users/${userId}/plan-and-usage`, {
signal,
});
return data;
},
});
return query;
}

View File

@@ -1,13 +1,30 @@
import { useQuery } from '@tanstack/react-query'; import { useQuery } from '@tanstack/react-query';
import { useLocation } from 'react-router-dom';
import { DateTime } from 'luxon'; import { DateTime } from 'luxon';
import * as React from 'react';
import useFormatMessage from './useFormatMessage';
import api from 'helpers/api'; import api from 'helpers/api';
export default function useSubscription() { function transform(subscription) {
const formatMessage = useFormatMessage(); const nextBillDate = subscription?.nextBillDate;
const nextBillDateTitleDateObject = DateTime.fromISO(nextBillDate);
const formattedNextBillDateTitle = nextBillDateTitleDateObject.isValid
? nextBillDateTitleDateObject.toFormat('LLL dd, yyyy')
: nextBillDate;
const { data, isLoading: isSubscriptionLoading } = useQuery({ return {
...subscription,
nextBillDate: formattedNextBillDateTitle,
};
}
export default function useSubscription() {
const location = useLocation();
const state = location.state;
const checkoutCompleted = state?.checkoutCompleted;
const [isPolling, setIsPolling] = React.useState(false);
const { data } = useQuery({
queryKey: ['subscription'], queryKey: ['subscription'],
queryFn: async ({ signal }) => { queryFn: async ({ signal }) => {
const { data } = await api.get(`/v1/users/me/subscription`, { const { data } = await api.get(`/v1/users/me/subscription`, {
@@ -16,23 +33,23 @@ export default function useSubscription() {
return data; return data;
}, },
refetchInterval: isPolling ? 1000 : false,
}); });
const subscription = data?.data; const subscription = data?.data;
const cancellationEffectiveDate = subscription?.cancellationEffectiveDate; const hasSubscription = subscription?.status === 'active';
const hasCancelled = !!cancellationEffectiveDate; React.useEffect(
function pollDataUntilSubscriptionIsCreated() {
if (isSubscriptionLoading || !hasCancelled) return null; if (checkoutCompleted) {
setIsPolling(!hasSubscription);
const cancellationEffectiveDateObject = DateTime.fromISO( }
cancellationEffectiveDate, },
[checkoutCompleted, hasSubscription],
); );
return { return {
message: formatMessage('subscriptionCancelledAlert.text', { data: transform(subscription),
date: cancellationEffectiveDateObject.toFormat('DDD'),
}),
cancellationEffectiveDate: cancellationEffectiveDateObject,
}; };
} }

View File

@@ -1,10 +1,8 @@
import * as React from 'react';
import { useLocation } from 'react-router-dom';
import { DateTime } from 'luxon'; import { DateTime } from 'luxon';
import { useQuery } from '@tanstack/react-query';
import useFormatMessage from './useFormatMessage'; import useFormatMessage from './useFormatMessage';
import api from 'helpers/api'; import api from 'helpers/api';
import { useQuery } from '@tanstack/react-query';
function getDiffInDays(date) { function getDiffInDays(date) {
const today = DateTime.now().startOf('day'); const today = DateTime.now().startOf('day');
@@ -13,6 +11,7 @@ function getDiffInDays(date) {
return roundedDiffInDays; return roundedDiffInDays;
} }
function getFeedbackPayload(date) { function getFeedbackPayload(date) {
const diffInDays = getDiffInDays(date); const diffInDays = getDiffInDays(date);
@@ -41,12 +40,8 @@ function getFeedbackPayload(date) {
} }
export default function useUserTrial() { export default function useUserTrial() {
const formatMessage = useFormatMessage(); const formatMessage = useFormatMessage();
const location = useLocation();
const state = location.state;
const checkoutCompleted = state?.checkoutCompleted;
const [isPolling, setIsPolling] = React.useState(false);
const { data, isLoading: isUserTrialLoading } = useQuery({ const { data } = useQuery({
queryKey: ['userTrial'], queryKey: ['userTrial'],
queryFn: async ({ signal }) => { queryFn: async ({ signal }) => {
const { data } = await api.get('/v1/users/me/trial', { const { data } = await api.get('/v1/users/me/trial', {
@@ -55,32 +50,12 @@ export default function useUserTrial() {
return data; return data;
}, },
refetchInterval: isPolling ? 1000 : false,
}); });
const userTrial = data?.data; const userTrial = data?.data;
const hasTrial = userTrial?.inTrial; const hasTrial = userTrial?.inTrial;
React.useEffect(
function pollDataUntilTrialEnds() {
if (checkoutCompleted && hasTrial) {
setIsPolling(true);
}
},
[checkoutCompleted, hasTrial, setIsPolling],
);
React.useEffect(
function stopPollingWhenTrialEnds() {
if (checkoutCompleted && !hasTrial) {
setIsPolling(false);
}
},
[checkoutCompleted, hasTrial, setIsPolling],
);
if (isUserTrialLoading || !hasTrial) return null;
const expireAt = DateTime.fromISO(userTrial?.expireAt).startOf('day'); const expireAt = DateTime.fromISO(userTrial?.expireAt).startOf('day');
const { translationEntryId, translationEntryValues, status, over } = const { translationEntryId, translationEntryValues, status, over } =
@@ -91,5 +66,6 @@ export default function useUserTrial() {
expireAt, expireAt,
over, over,
status, status,
hasTrial,
}; };
} }

View File

@@ -165,6 +165,11 @@
"usageDataInformation.yourUsageDescription": "Last 30 days total usage", "usageDataInformation.yourUsageDescription": "Last 30 days total usage",
"usageDataInformation.yourUsageTasks": "Tasks", "usageDataInformation.yourUsageTasks": "Tasks",
"usageDataInformation.upgrade": "Upgrade", "usageDataInformation.upgrade": "Upgrade",
"usageDataInformation.freeTrial": "Free trial",
"usageDataInformation.cancelPlan": "Cancel plan",
"usageDataInformation.updatePaymentMethod": "Update payment method",
"usageDataInformation.monthlyPayment": "(monthly payment)",
"usageDataInformation.upgradePlan": "Upgrade plan",
"invoices.invoices": "Invoices", "invoices.invoices": "Invoices",
"invoices.date": "Date", "invoices.date": "Date",
"invoices.amount": "Amount", "invoices.amount": "Amount",