feat: wip billing page

This commit is contained in:
Ahmed Bouhuolia
2024-07-28 17:53:51 +02:00
parent 14a9c4ba28
commit 1660df20af
14 changed files with 488 additions and 69 deletions

View File

@@ -6,6 +6,165 @@ export class GetSubscriptionsTransformer extends Transformer {
* @returns {Array}
*/
public includeAttributes = (): string[] => {
return [];
return [
'canceledAtFormatted',
'cancelsAtFormatted',
'trialStartsAtFormatted',
'trialEndsAtFormatted',
'statusFormatted',
'planName',
'planSlug',
'planPrice',
'planPriceCurrency',
'planPriceFormatted',
'planPeriod',
'lemonUrls',
];
};
/**
* Exclude attributes.
* @returns {string[]}
*/
public excludeAttributes = (): string[] => {
return ['id', 'plan'];
};
/**
* Retrieves the canceled at formatted.
* @param subscription
* @returns {string}
*/
public canceledAtFormatted = (subscription) => {
return subscription.canceledAt
? this.formatDate(subscription.canceledAt)
: null;
};
/**
* Retrieves the cancels at formatted.
* @param subscription
* @returns {string}
*/
public cancelsAtFormatted = (subscription) => {
return subscription.cancelsAt
? this.formatDate(subscription.cancelsAt)
: null;
};
/**
* Retrieves the trial starts at formatted date.
* @returns {string}
*/
public trialStartsAtFormatted = (subscription) => {
return subscription.trialStartsAt
? this.formatDate(subscription.trialStartsAt)
: null;
};
/**
* Retrieves the trial ends at formatted date.
* @returns {string}
*/
public trialEndsAtFormatted = (subscription) => {
return subscription.trialEndsAt
? this.formatDate(subscription.trialEndsAt)
: null;
};
/**
* Retrieves the Lemon subscription metadata.
* @param subscription
* @returns
*/
public lemonSubscription = (subscription) => {
return (
this.options.lemonSubscriptions[subscription.lemonSubscriptionId] || null
);
};
/**
* Retrieves the formatted subscription status.
* @param subscription
* @returns {string}
*/
public statusFormatted = (subscription) => {
const pairs = {
canceled: 'Canceled',
active: 'Active',
inactive: 'Inactive',
expired: 'Expired',
on_trial: 'On Trial',
};
return pairs[subscription.status] || '';
};
/**
* Retrieves the subscription plan name.
* @param subscription
* @returns {string}
*/
public planName(subscription) {
return subscription.plan?.name;
}
/**
* Retrieves the subscription plan slug.
* @param subscription
* @returns {string}
*/
public planSlug(subscription) {
return subscription.plan?.slug;
}
/**
* Retrieves the subscription plan price.
* @param subscription
* @returns {number}
*/
public planPrice(subscription) {
return subscription.plan?.price;
}
/**
* Retrieves the subscription plan price currency.
* @param subscription
* @returns {string}
*/
public planPriceCurrency(subscription) {
return subscription.plan?.currency;
}
/**
* Retrieves the subscription plan formatted price.
* @param subscription
* @returns {string}
*/
public planPriceFormatted(subscription) {
return this.formatMoney(subscription.plan?.price, {
currencyCode: subscription.plan?.currency,
precision: 0
});
}
/**
* Retrieves the subscription plan period.
* @param subscription
* @returns {string}
*/
public planPeriod(subscription) {
return subscription?.plan?.period;
}
/**
* Retrieve the subscription Lemon Urls.
* @param subscription
* @returns
*/
public lemonUrls = (subscription) => {
const lemonSusbcription = this.lemonSubscription(subscription);
console.log(lemonSusbcription);
return lemonSusbcription?.data?.attributes?.urls;
};
}

View File

@@ -1,6 +1,6 @@
import { Inject, Service } from 'typedi';
import { EventPublisher } from '@/lib/EventPublisher/EventPublisher';
import events from '@/subscribers/events';
import { Inject, Service } from 'typedi';
import { configureLemonSqueezy } from './utils';
import { PlanSubscription } from '@/system/models';
import { ServiceError } from '@/exceptions';

View File

@@ -1,7 +1,11 @@
import { Inject, Service } from 'typedi';
import { getSubscription } from '@lemonsqueezy/lemonsqueezy.js';
import { PromisePool } from '@supercharge/promise-pool';
import { PlanSubscription } from '@/system/models';
import { TransformerInjectable } from '@/lib/Transformer/TransformerInjectable';
import { GetSubscriptionsTransformer } from './GetSubscriptionsTransformer';
import { configureLemonSqueezy } from './utils';
import { fromPairs } from 'lodash';
@Service()
export default class SubscriptionService {
@@ -13,14 +17,34 @@ export default class SubscriptionService {
* @param {number} tenantId
*/
public async getSubscriptions(tenantId: number) {
const subscriptions = await PlanSubscription.query().where(
'tenant_id',
tenantId
configureLemonSqueezy();
const subscriptions = await PlanSubscription.query()
.where('tenant_id', tenantId)
.withGraphFetched('plan');
const lemonSubscriptionsResult = await PromisePool.withConcurrency(1)
.for(subscriptions)
.process(async (subscription, index, pool) => {
if (subscription.lemonSubscriptionId) {
const res = await getSubscription(subscription.lemonSubscriptionId);
if (res.error) {
return;
}
return [subscription.lemonSubscriptionId, res.data];
}
});
const lemonSubscriptions = fromPairs(
lemonSubscriptionsResult?.results.filter((result) => !!result[1])
);
return this.transformer.transform(
tenantId,
subscriptions,
new GetSubscriptionsTransformer()
new GetSubscriptionsTransformer(),
{
lemonSubscriptions,
}
);
}
}

View File

@@ -0,0 +1,13 @@
exports.up = function (knex) {
return knex.schema.table('subscription_plan_subscriptions', (table) => {
table.dateTime('trial_starts_at').nullable();
table.dateTime('trial_ends_at').nullable();
});
};
exports.down = function (knex) {
return knex.schema.table('subscription_plan_subscriptions', (table) => {
table.dropColumn('trial_starts_at').nullable();
table.dropColumn('trial_ends_at').nullable();
});
};

View File

@@ -5,7 +5,16 @@ import SubscriptionPeriod from '@/services/Subscription/SubscriptionPeriod';
export default class PlanSubscription extends mixin(SystemModel) {
lemonSubscriptionId: number;
canceledAt: Date;
cancelsAt: Date;
trialStartsAt: Date;
trialEndsAt: Date;
endsAt: Date;
startsAt: Date;
/**
* Table name.
*/
@@ -24,7 +33,7 @@ export default class PlanSubscription extends mixin(SystemModel) {
* Defined virtual attributes.
*/
static get virtualAttributes() {
return ['active', 'inactive', 'ended', 'onTrial'];
return ['active', 'inactive', 'ended', 'canceled', 'onTrial', 'status'];
}
/**
@@ -40,7 +49,7 @@ export default class PlanSubscription extends mixin(SystemModel) {
builder.where('trial_ends_at', '>', now);
},
inactiveSubscriptions() {
inactiveSubscriptions(builder) {
builder.modify('endedTrial');
builder.modify('endedPeriod');
},
@@ -100,35 +109,80 @@ export default class PlanSubscription extends mixin(SystemModel) {
}
/**
* Check if subscription is active.
* Check if the subscription is expired.
* Expired mens the user his lost the right to use the product.
* @returns {Boolean}
*/
public expired() {
return this.ended() && !this.onTrial();
}
/**
* Check if paid subscription is active.
* @return {Boolean}
*/
active() {
return !this.ended() || this.onTrial();
public active() {
return (
!this.canceled() && !this.onTrial() && !this.ended() && this.started()
);
}
/**
* Check if subscription is inactive.
* @return {Boolean}
*/
inactive() {
public inactive() {
return !this.active();
}
/**
* Check if subscription period has ended.
* Check if paid subscription period has ended.
* @return {Boolean}
*/
ended() {
public ended() {
return this.endsAt ? moment().isAfter(this.endsAt) : false;
}
/**
* Check if the paid subscription has started.
* @returns {Boolean}
*/
public started() {
return this.startsAt ? moment().isAfter(this.startsAt) : false;
}
/**
* Check if subscription is currently on trial.
* @return {Boolean}
*/
onTrial() {
return this.trailEndsAt ? moment().isAfter(this.trailEndsAt) : false;
public onTrial() {
return this.trialEndsAt ? moment().isBefore(this.trialEndsAt) : false;
}
/**
* Check if the subscription is canceled.
* @returns {boolean}
*/
public canceled() {
return (
this.canceledAt ||
(this.cancelsAt && moment().isAfter(this.cancelsAt)) ||
false
);
}
/**
* Retrieves the subscription status.
* @returns {string}
*/
public status() {
return this.canceled()
? 'canceled'
: this.onTrial()
? 'on_trial'
: this.active()
? 'active'
: 'inactive';
}
/**
@@ -143,7 +197,7 @@ export default class PlanSubscription extends mixin(SystemModel) {
const period = new SubscriptionPeriod(
invoiceInterval,
invoicePeriod,
start,
start
);
const startsAt = period.getStartDate();
@@ -159,7 +213,7 @@ export default class PlanSubscription extends mixin(SystemModel) {
renew(invoiceInterval, invoicePeriod) {
const { startsAt, endsAt } = PlanSubscription.setNewPeriod(
invoiceInterval,
invoicePeriod,
invoicePeriod
);
return this.$query().update({ startsAt, endsAt });
}

View File

@@ -22,6 +22,7 @@ import RefundVendorCreditDetailDrawer from '@/containers/Drawers/RefundVendorCre
import WarehouseTransferDetailDrawer from '@/containers/Drawers/WarehouseTransferDetailDrawer';
import TaxRateDetailsDrawer from '@/containers/TaxRates/drawers/TaxRateDetailsDrawer/TaxRateDetailsDrawer';
import CategorizeTransactionDrawer from '@/containers/CashFlow/CategorizeTransaction/drawers/CategorizeTransactionDrawer/CategorizeTransactionDrawer';
import ChangeSubscriptionPlanDrawer from '@/containers/Subscriptions/drawers/ChangeSubscriptionPlanDrawer/ChangeSubscriptionPlanDrawer';
import { DRAWERS } from '@/constants/drawers';
@@ -63,6 +64,7 @@ export default function DrawersContainer() {
/>
<TaxRateDetailsDrawer name={DRAWERS.TAX_RATE_DETAILS} />
<CategorizeTransactionDrawer name={DRAWERS.CATEGORIZE_TRANSACTION} />
<ChangeSubscriptionPlanDrawer name={DRAWERS.CHANGE_SUBSCARIPTION_PLAN} />
</div>
);
}

View File

@@ -24,4 +24,5 @@ export enum DRAWERS {
WAREHOUSE_TRANSFER_DETAILS = 'warehouse-transfer-detail-drawer',
TAX_RATE_DETAILS = 'tax-rate-detail-drawer',
CATEGORIZE_TRANSACTION = 'categorize-transaction',
CHANGE_SUBSCARIPTION_PLAN = 'change-subscription-plan'
}

View File

@@ -29,6 +29,7 @@ interface SubscriptionPricingProps {
annuallyPriceLabel: string;
monthlyVariantId?: string;
annuallyVariantId?: string;
onSubscribe?: (variantId: number) => void;
}
interface SubscriptionPricingCombinedProps
@@ -46,6 +47,7 @@ function SubscriptionPlanRoot({
annuallyPriceLabel,
monthlyVariantId,
annuallyVariantId,
onSubscribe,
// #withPlans
plansPeriod,
@@ -59,17 +61,19 @@ function SubscriptionPlanRoot({
? monthlyVariantId
: annuallyVariantId;
getLemonCheckout({ variantId })
.then((res) => {
const checkoutUrl = res.data.data.attributes.url;
window.LemonSqueezy.Url.Open(checkoutUrl);
})
.catch(() => {
AppToaster.show({
message: 'Something went wrong!',
intent: Intent.DANGER,
});
});
onSubscribe && onSubscribe(variantId);
// getLemonCheckout({ variantId })
// .then((res) => {
// const checkoutUrl = res.data.data.attributes.url;
// window.LemonSqueezy.Url.Open(checkoutUrl);
// })
// .catch(() => {
// AppToaster.show({
// message: 'Something went wrong!',
// intent: Intent.DANGER,
// });
// });
};
return (

View File

@@ -15,12 +15,17 @@ interface BillingPageBootProps {
}
export function BillingPageBoot({ children }: BillingPageBootProps) {
const { isLoading: isSubscriptionsLoading, data: subscriptions } =
const { isLoading: isSubscriptionsLoading, data: subscriptionsRes } =
useGetSubscriptions();
const mainSubscription = subscriptionsRes?.subscriptions?.find(
(s) => s.slug === 'main',
);
const value = {
isSubscriptionsLoading,
subscriptions,
subscriptions: subscriptionsRes?.subscriptions,
mainSubscription,
};
return <BillingBoot.Provider value={value}>{children}</BillingBoot.Provider>;
}

View File

@@ -1,9 +1,17 @@
// @ts-nocheck
import { Box, Group } from '@/components';
import { Text } from '@blueprintjs/core';
import { Spinner, Text } from '@blueprintjs/core';
import { Subscription } from './BillingSubscription';
import { useBillingPageBoot } from './BillingPageBoot';
import styles from './BillingPageContent.module.scss';
export function BillingPageContent() {
const { isSubscriptionsLoading, subscriptions } = useBillingPageBoot();
if (isSubscriptionsLoading || !subscriptions) {
return <Spinner size={30} />;
}
return (
<Box className={styles.root}>
<Text>

View File

@@ -12,7 +12,7 @@
.title{
margin: 0;
font-size: 20px;
font-size: 18px;
font-weight: 600;
color: #3D4C58;
}
@@ -56,8 +56,4 @@
}
.actions{
margin-top: 16px;
button{
font-size: 15px;
}
}

View File

@@ -4,25 +4,42 @@ import { Box, Group, Stack } from '@/components';
import { Button, Card, Intent, Text } from '@blueprintjs/core';
import withAlertActions from '../Alert/withAlertActions';
import styles from './BillingSubscription.module.scss';
import withDrawerActions from '../Drawer/withDrawerActions';
import { DRAWERS } from '@/constants/drawers';
import { useBillingPageBoot } from './BillingPageBoot';
function SubscriptionRoot({ openAlert }) {
function SubscriptionRoot({ openAlert, openDrawer }) {
const { mainSubscription } = useBillingPageBoot();
// Can't continue if the main subscription is not loaded.
if (!mainSubscription) {
return null;
}
const handleCancelSubBtnClick = () => {
openAlert('cancel-main-subscription');
};
const handleResumeSubBtnClick = () => {
openAlert('resume-main-subscription');
};
const handleUpdatePaymentMethod = () => {};
const handleUpgradeBtnClick = () => {};
const handleUpdatePaymentMethod = () => {
window.LemonSqueezy.Url.Open(
mainSubscription.lemonUrls?.updatePaymentMethod,
);
};
// Handle upgrade button click.
const handleUpgradeBtnClick = () => {
openDrawer(DRAWERS.CHANGE_SUBSCARIPTION_PLAN);
};
return (
<Card className={styles.root}>
<Stack spacing={8}>
<h1 className={styles.title}>Capital Essential</h1>
<Stack spacing={6}>
<h1 className={styles.title}>{mainSubscription.planName}</h1>
<Group spacing={0} className={styles.period}>
<Text className={styles.periodStatus}>Trial</Text>
<Text className={styles.periodStatus}>
{mainSubscription.statusFormatted}
</Text>
<Text className={styles.periodText}>Trial ends in 10 days.</Text>
</Group>
</Stack>
@@ -43,15 +60,29 @@ function SubscriptionRoot({ openAlert }) {
>
Upgrade the Plan
</Button>
<Button
minimal
small
inline
intent={Intent.PRIMARY}
onClick={handleCancelSubBtnClick}
>
Cancel Subscription
</Button>
{mainSubscription.canceled && (
<Button
minimal
small
inline
intent={Intent.PRIMARY}
onClick={handleResumeSubBtnClick}
>
Resume Subscription
</Button>
)}
{!mainSubscription.canceled && (
<Button
minimal
small
inline
intent={Intent.PRIMARY}
onClick={handleCancelSubBtnClick}
>
Cancel Subscription
</Button>
)}
<Button
minimal
small
@@ -65,22 +96,38 @@ function SubscriptionRoot({ openAlert }) {
<Group position={'apart'} style={{ marginTop: 'auto' }}>
<Group spacing={4}>
<Text className={styles.priceAmount}>$10</Text>
<Text className={styles.pricePeriod}>/ mo</Text>
<Text className={styles.priceAmount}>
{mainSubscription.planPriceFormatted}
</Text>
{mainSubscription.planPeriod && (
<Text className={styles.pricePeriod}>
{mainSubscription.planPeriod === 'month'
? 'mo'
: mainSubscription.planPeriod === 'year'
? 'yearly'
: ''}
</Text>
)}
</Group>
<Box>
<Button
intent={Intent.PRIMARY}
onClick={handleResumeSubBtnClick}
className={styles.subscribeButton}
>
Resume Subscription
</Button>
{mainSubscription.canceled && (
<Button
intent={Intent.PRIMARY}
onClick={handleResumeSubBtnClick}
className={styles.subscribeButton}
>
Resume Subscription
</Button>
)}
</Box>
</Group>
</Card>
);
}
export const Subscription = R.compose(withAlertActions)(SubscriptionRoot);
export const Subscription = R.compose(
withAlertActions,
withDrawerActions,
)(SubscriptionRoot);

View File

@@ -0,0 +1,54 @@
// @ts-nocheck
import * as R from 'ramda';
import { Callout, Classes, Intent } from '@blueprintjs/core';
import { AppToaster, Box } from '@/components';
import { SubscriptionPlans } from '@/containers/Setup/SetupSubscription/SubscriptionPlans';
import { SubscriptionPlansPeriodSwitcher } from '@/containers/Setup/SetupSubscription/SubscriptionPlansPeriodSwitcher';
import { useChangeSubscriptionPlan } from '@/hooks/query/subscription';
import withDrawerActions from '@/containers/Drawer/withDrawerActions';
import { DRAWERS } from '@/constants/drawers';
function ChangeSubscriptionPlanContent({ closeDrawer }) {
const { mutateAsync: changeSubscriptionPlan } = useChangeSubscriptionPlan();
// Handle the subscribe button click.
const handleSubscribe = (variantId: number) => {
changeSubscriptionPlan({ variant_id: variantId })
.then(() => {
closeDrawer(DRAWERS.CHANGE_SUBSCARIPTION_PLAN);
AppToaster.show({
intent: Intent.SUCCESS,
message: 'The subscription plan has been changed successfully.',
});
})
.catch(() => {
AppToaster.show({
intent: Intent.DANGER,
message: 'Something went wrong.',
});
});
};
return (
<Box className={Classes.DRAWER_BODY}>
<Box
style={{
maxWidth: 1024,
margin: '0 auto',
padding: '50px 20px 80px',
}}
>
<Callout style={{ marginBottom: '2rem' }} icon={null}>
Simple plans. Simple prices. Only pay for what you really need. All
plans come with award-winning 24/7 customer support. Prices do not
include applicable taxes.
</Callout>
<SubscriptionPlansPeriodSwitcher />
<SubscriptionPlans onSubscribe={handleSubscribe} />
</Box>
</Box>
);
}
export default R.compose(withDrawerActions)(ChangeSubscriptionPlanContent);

View File

@@ -9,6 +9,11 @@ import {
UseQueryResult,
} from 'react-query';
import useApiRequest from '../useRequest';
import { transformToCamelCase } from '@/utils';
const QueryKeys = {
Subscriptions: 'Subscriptions',
};
interface CancelMainSubscriptionValues {}
interface CancelMainSubscriptionResponse {}
@@ -40,6 +45,9 @@ export function useCancelMainSubscription(
(values) =>
apiRequest.post(`/subscription/cancel`, values).then((res) => res.data),
{
onSuccess: () => {
queryClient.invalidateQueries(QueryKeys.Subscriptions);
},
...options,
},
);
@@ -75,6 +83,9 @@ export function useResumeMainSubscription(
(values) =>
apiRequest.post(`/subscription/resume`, values).then((res) => res.data),
{
onSuccess: () => {
queryClient.invalidateQueries(QueryKeys.Subscriptions);
},
...options,
},
);
@@ -105,20 +116,58 @@ export function useChangeSubscriptionPlan(
const apiRequest = useApiRequest();
return useMutation<
ChangeMainSubscriptionPlanValues,
ChangeMainSubscriptionPlanResponse,
Error,
ChangeMainSubscriptionPlanResponse
ChangeMainSubscriptionPlanValues
>(
(values) =>
apiRequest.post(`/subscription/change`, values).then((res) => res.data),
{
onSuccess: () => {
queryClient.invalidateQueries(QueryKeys.Subscriptions);
},
...options,
},
);
}
interface LemonSubscription {
active: boolean;
canceled: string | null;
canceledAt: string | null;
canceledAtFormatted: string | null;
cancelsAt: string | null;
cancelsAtFormatted: string | null;
createdAt: string;
ended: boolean;
endsAt: string | null;
inactive: boolean;
lemonSubscriptionId: string;
lemon_urls: {
updatePaymentMethod: string;
customerPortal: string;
customerPortalUpdateSubscription: string;
};
onTrial: boolean;
planId: number;
planName: string;
planSlug: string;
slug: string;
startsAt: string | null;
status: string;
statusFormatted: string;
tenantId: number;
trialEndsAt: string | null;
trialEndsAtFormatted: string | null;
trialStartsAt: string | null;
trialStartsAtFormatted: string | null;
updatedAt: string;
}
interface GetSubscriptionsQuery {}
interface GetSubscriptionsResponse {}
interface GetSubscriptionsResponse {
subscriptions: Array<LemonSubscription>;
}
/**
* Changese the main subscription of the current organization.
@@ -135,8 +184,11 @@ export function useGetSubscriptions(
const apiRequest = useApiRequest();
return useQuery<GetSubscriptionsQuery, Error, GetSubscriptionsResponse>(
['SUBSCRIPTIONS'],
(values) => apiRequest.get(`/subscription`).then((res) => res.data),
[QueryKeys.Subscriptions],
(values) =>
apiRequest
.get(`/subscription`)
.then((res) => transformToCamelCase(res.data)),
{
...options,
},