Compare commits
9 Commits
fix-plaid-
...
v0.18.7
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
6b6b73b77c | ||
|
|
107a6f793b | ||
|
|
67d155759e | ||
|
|
7e2e87256f | ||
|
|
df7790d7c1 | ||
|
|
72128a72c4 | ||
|
|
eb3f23554f | ||
|
|
69ddf43b3e | ||
|
|
249eadaeaa |
@@ -237,4 +237,8 @@ module.exports = {
|
||||
endpoint: process.env.S3_ENDPOINT,
|
||||
bucket: process.env.S3_BUCKET || 'bigcapital-documents',
|
||||
},
|
||||
|
||||
loops: {
|
||||
apiKey: process.env.LOOPS_API_KEY,
|
||||
},
|
||||
};
|
||||
|
||||
@@ -113,6 +113,7 @@ import { UnlinkBankRuleOnDeleteBankRule } from '@/services/Banking/Rules/events/
|
||||
import { DecrementUncategorizedTransactionOnMatching } from '@/services/Banking/Matching/events/DecrementUncategorizedTransactionsOnMatch';
|
||||
import { DecrementUncategorizedTransactionOnExclude } from '@/services/Banking/Exclude/events/DecrementUncategorizedTransactionOnExclude';
|
||||
import { DecrementUncategorizedTransactionOnCategorize } from '@/services/Cashflow/subscribers/DecrementUncategorizedTransactionOnCategorize';
|
||||
import { LoopsEventsSubscriber } from '@/services/Loops/LoopsEventsSubscriber';
|
||||
|
||||
export default () => {
|
||||
return new EventPublisher();
|
||||
@@ -274,5 +275,8 @@ export const susbcribers = () => {
|
||||
|
||||
// Plaid
|
||||
RecognizeSyncedBankTranasctions,
|
||||
|
||||
// Loops
|
||||
LoopsEventsSubscriber
|
||||
];
|
||||
};
|
||||
|
||||
@@ -73,8 +73,6 @@ export class PlaidUpdateTransactions {
|
||||
added.concat(modified),
|
||||
trx
|
||||
);
|
||||
// Sync removed transactions.
|
||||
await this.plaidSync.syncRemoveTransactions(tenantId, removed, trx);
|
||||
// Sync transactions cursor.
|
||||
await this.plaidSync.syncTransactionsCursor(
|
||||
tenantId,
|
||||
|
||||
@@ -37,7 +37,7 @@ export class CreateUncategorizedTransaction {
|
||||
tenantId,
|
||||
async (trx: Knex.Transaction) => {
|
||||
await this.eventPublisher.emitAsync(
|
||||
events.cashflow.onTransactionUncategorizedCreated,
|
||||
events.cashflow.onTransactionUncategorizedCreating,
|
||||
{
|
||||
tenantId,
|
||||
createUncategorizedTransactionDTO,
|
||||
|
||||
51
packages/server/src/services/Loops/LoopsEventsSubscriber.ts
Normal file
51
packages/server/src/services/Loops/LoopsEventsSubscriber.ts
Normal file
@@ -0,0 +1,51 @@
|
||||
import axios from 'axios';
|
||||
import config from '@/config';
|
||||
import { IAuthSignUpVerifiedEventPayload } from '@/interfaces';
|
||||
import events from '@/subscribers/events';
|
||||
import { SystemUser } from '@/system/models';
|
||||
|
||||
export class LoopsEventsSubscriber {
|
||||
/**
|
||||
* Constructor method.
|
||||
*/
|
||||
public attach(bus) {
|
||||
bus.subscribe(
|
||||
events.auth.signUpConfirmed,
|
||||
this.triggerEventOnSignupVerified.bind(this)
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Once the user verified sends the event to the Loops.
|
||||
* @param {IAuthSignUpVerifiedEventPayload} param0
|
||||
*/
|
||||
public async triggerEventOnSignupVerified({
|
||||
email,
|
||||
userId,
|
||||
}: IAuthSignUpVerifiedEventPayload) {
|
||||
// Can't continue since the Loops the api key is not configured.
|
||||
if (!config.loops.apiKey) {
|
||||
return;
|
||||
}
|
||||
const user = await SystemUser.query().findById(userId);
|
||||
|
||||
const options = {
|
||||
method: 'POST',
|
||||
url: 'https://app.loops.so/api/v1/events/send',
|
||||
headers: {
|
||||
Authorization: `Bearer ${config.loops.apiKey}`,
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
data: {
|
||||
email,
|
||||
userId,
|
||||
firstName: user.firstName,
|
||||
lastName: user.lastName,
|
||||
eventName: 'USER_VERIFIED',
|
||||
eventProperties: {},
|
||||
mailingLists: {},
|
||||
},
|
||||
};
|
||||
await axios(options);
|
||||
}
|
||||
}
|
||||
@@ -1,4 +1,3 @@
|
||||
import { getPrice } from '@lemonsqueezy/lemonsqueezy.js';
|
||||
import config from '@/config';
|
||||
import { Inject, Service } from 'typedi';
|
||||
import {
|
||||
@@ -10,7 +9,6 @@ import {
|
||||
} from './utils';
|
||||
import { Plan } from '@/system/models';
|
||||
import { Subscription } from './Subscription';
|
||||
import { isEmpty } from 'lodash';
|
||||
|
||||
@Service()
|
||||
export class LemonSqueezyWebhooks {
|
||||
@@ -18,7 +16,7 @@ export class LemonSqueezyWebhooks {
|
||||
private subscriptionService: Subscription;
|
||||
|
||||
/**
|
||||
* handle the LemonSqueezy webhooks.
|
||||
* Handles the Lemon Squeezy webhooks.
|
||||
* @param {string} rawBody
|
||||
* @param {string} signature
|
||||
* @returns {Promise<void>}
|
||||
@@ -74,7 +72,7 @@ export class LemonSqueezyWebhooks {
|
||||
const variantId = attributes.variant_id as string;
|
||||
|
||||
// We assume that the Plan table is up to date.
|
||||
const plan = await Plan.query().findOne('slug', 'early-adaptor');
|
||||
const plan = await Plan.query().findOne('lemonVariantId', variantId);
|
||||
|
||||
if (!plan) {
|
||||
throw new Error(`Plan with variantId ${variantId} not found.`);
|
||||
@@ -82,26 +80,9 @@ export class LemonSqueezyWebhooks {
|
||||
// Update the subscription in the database.
|
||||
const priceId = attributes.first_subscription_item.price_id;
|
||||
|
||||
// Get the price data from Lemon Squeezy.
|
||||
const priceData = await getPrice(priceId);
|
||||
|
||||
if (priceData.error) {
|
||||
throw new Error(
|
||||
`Failed to get the price data for the subscription ${eventBody.data.id}.`
|
||||
);
|
||||
}
|
||||
const isUsageBased =
|
||||
attributes.first_subscription_item.is_usage_based;
|
||||
const price = isUsageBased
|
||||
? priceData.data?.data.attributes.unit_price_decimal
|
||||
: priceData.data?.data.attributes.unit_price;
|
||||
|
||||
// Create a new subscription of the tenant.
|
||||
if (webhookEvent === 'subscription_created') {
|
||||
await this.subscriptionService.newSubscribtion(
|
||||
tenantId,
|
||||
'early-adaptor'
|
||||
);
|
||||
await this.subscriptionService.newSubscribtion(tenantId, plan.slug);
|
||||
}
|
||||
}
|
||||
} else if (webhookEvent.startsWith('order_')) {
|
||||
|
||||
@@ -40,6 +40,13 @@ export default {
|
||||
baseCurrencyUpdated: 'onOrganizationBaseCurrencyUpdated',
|
||||
},
|
||||
|
||||
/**
|
||||
* User subscription events.
|
||||
*/
|
||||
subscription: {
|
||||
onSubscribed: 'onOrganizationSubscribed',
|
||||
},
|
||||
|
||||
/**
|
||||
* Tenants managment service.
|
||||
*/
|
||||
|
||||
@@ -0,0 +1,11 @@
|
||||
exports.up = function (knex) {
|
||||
return knex.schema.table('subscription_plans', (table) => {
|
||||
table.string('lemon_variant_id').nullable().index();
|
||||
});
|
||||
};
|
||||
|
||||
exports.down = (knex) => {
|
||||
return knex.schema.table('subscription_plans', (table) => {
|
||||
table.dropColumn('lemon_variant_id');
|
||||
});
|
||||
};
|
||||
@@ -0,0 +1,96 @@
|
||||
exports.up = function (knex) {
|
||||
return knex('subscription_plans').insert([
|
||||
// Capital Basic
|
||||
{
|
||||
name: 'Capital Basic (Monthly)',
|
||||
slug: 'capital-basic-monthly',
|
||||
price: 10,
|
||||
active: true,
|
||||
currency: 'USD',
|
||||
invoice_period: 1,
|
||||
invoice_interval: 'month',
|
||||
lemon_variant_id: '446152',
|
||||
// lemon_variant_id: '450016',
|
||||
},
|
||||
{
|
||||
name: 'Capital Basic (Annually)',
|
||||
slug: 'capital-basic-annually',
|
||||
price: 90,
|
||||
active: true,
|
||||
currency: 'USD',
|
||||
invoice_period: 1,
|
||||
invoice_interval: 'year',
|
||||
lemon_variant_id: '446153',
|
||||
// lemon_variant_id: '450018',
|
||||
},
|
||||
|
||||
// # Capital Essential
|
||||
{
|
||||
name: 'Capital Essential (Monthly)',
|
||||
slug: 'capital-essential-monthly',
|
||||
price: 20,
|
||||
active: true,
|
||||
currency: 'USD',
|
||||
invoice_period: 1,
|
||||
invoice_interval: 'month',
|
||||
lemon_variant_id: '446155',
|
||||
// lemon_variant_id: '450028',
|
||||
},
|
||||
{
|
||||
name: 'Capital Essential (Annually)',
|
||||
slug: 'capital-essential-annually',
|
||||
price: 180,
|
||||
active: true,
|
||||
invoice_period: 1,
|
||||
invoice_interval: 'year',
|
||||
lemon_variant_id: '446156',
|
||||
// lemon_variant_id: '450029',
|
||||
},
|
||||
|
||||
// # Capital Plus
|
||||
{
|
||||
name: 'Capital Plus (Monthly)',
|
||||
slug: 'capital-plus-monthly',
|
||||
price: 25,
|
||||
active: true,
|
||||
invoice_period: 1,
|
||||
invoice_interval: 'month',
|
||||
lemon_variant_id: '446165',
|
||||
// lemon_variant_id: '450031',
|
||||
},
|
||||
{
|
||||
name: 'Capital Plus (Annually)',
|
||||
slug: 'capital-plus-annually',
|
||||
price: 228,
|
||||
active: true,
|
||||
invoice_period: 1,
|
||||
invoice_interval: 'year',
|
||||
lemon_variant_id: '446164',
|
||||
// lemon_variant_id: '450032',
|
||||
},
|
||||
|
||||
// # Capital Big
|
||||
{
|
||||
name: 'Capital Big (Monthly)',
|
||||
slug: 'capital-big-monthly',
|
||||
price: 40,
|
||||
active: true,
|
||||
invoice_period: 1,
|
||||
invoice_interval: 'month',
|
||||
lemon_variant_id: '446167',
|
||||
// lemon_variant_id: '450024',
|
||||
},
|
||||
{
|
||||
name: 'Capital Big (Annually)',
|
||||
slug: 'capital-big-annually',
|
||||
price: 360,
|
||||
active: true,
|
||||
invoice_period: 1,
|
||||
invoice_interval: 'year',
|
||||
lemon_variant_id: '446168',
|
||||
// lemon_variant_id: '450025',
|
||||
},
|
||||
]);
|
||||
};
|
||||
|
||||
exports.down = function (knex) {};
|
||||
@@ -23,9 +23,10 @@
|
||||
color: #fff;
|
||||
text-align: center;
|
||||
font-size: 12px;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
.label {
|
||||
font-size: 14px;
|
||||
font-size: 16px;
|
||||
font-weight: 600;
|
||||
color: #2F343C;
|
||||
|
||||
@@ -47,13 +48,31 @@
|
||||
}
|
||||
.price {
|
||||
font-size: 18px;
|
||||
line-height: 1;
|
||||
font-weight: 500;
|
||||
color: #404854;
|
||||
line-height: 1;
|
||||
font-weight: 500;
|
||||
color: #252A31;
|
||||
}
|
||||
|
||||
.pricePer{
|
||||
color: #738091;
|
||||
font-size: 12px;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.featureItem{
|
||||
flex: 1;
|
||||
color: #1C2127;
|
||||
}
|
||||
|
||||
.featurePopover :global .bp4-popover-content{
|
||||
border-radius: 0;
|
||||
}
|
||||
.featurePopoverContent{
|
||||
font-size: 12px
|
||||
}
|
||||
.featurePopoverLabel {
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.4px;
|
||||
font-size: 12px;
|
||||
font-weight: 500;
|
||||
}
|
||||
@@ -1,4 +1,11 @@
|
||||
import { Button, ButtonProps, Intent } from '@blueprintjs/core';
|
||||
import {
|
||||
Button,
|
||||
ButtonProps,
|
||||
Intent,
|
||||
Position,
|
||||
Text,
|
||||
Tooltip,
|
||||
} from '@blueprintjs/core';
|
||||
import clsx from 'classnames';
|
||||
import { Box, Group, Stack } from '../Layout';
|
||||
import styles from './PricingPlan.module.scss';
|
||||
@@ -64,7 +71,7 @@ export interface PricingPriceProps {
|
||||
*/
|
||||
PricingPlan.Price = ({ price, subPrice }: PricingPriceProps) => {
|
||||
return (
|
||||
<Stack spacing={6} className={styles.priceRoot}>
|
||||
<Stack spacing={4} className={styles.priceRoot}>
|
||||
<h4 className={styles.price}>{price}</h4>
|
||||
<span className={styles.pricePer}>{subPrice}</span>
|
||||
</Stack>
|
||||
@@ -101,7 +108,7 @@ export interface PricingFeaturesProps {
|
||||
*/
|
||||
PricingPlan.Features = ({ children }: PricingFeaturesProps) => {
|
||||
return (
|
||||
<Stack spacing={10} className={styles.features}>
|
||||
<Stack spacing={14} className={styles.features}>
|
||||
{children}
|
||||
</Stack>
|
||||
);
|
||||
@@ -109,15 +116,41 @@ PricingPlan.Features = ({ children }: PricingFeaturesProps) => {
|
||||
|
||||
export interface PricingFeatureLineProps {
|
||||
children: React.ReactNode;
|
||||
hintContent?: string;
|
||||
hintLabel?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Displays a single feature line within a list of features.
|
||||
* @param children - The content of the feature line.
|
||||
*/
|
||||
PricingPlan.FeatureLine = ({ children }: PricingFeatureLineProps) => {
|
||||
return (
|
||||
<Group noWrap spacing={12}>
|
||||
PricingPlan.FeatureLine = ({
|
||||
children,
|
||||
hintContent,
|
||||
hintLabel,
|
||||
}: PricingFeatureLineProps) => {
|
||||
return hintContent ? (
|
||||
<Tooltip
|
||||
content={
|
||||
<Stack spacing={5}>
|
||||
{hintLabel && (
|
||||
<Text className={styles.featurePopoverLabel}>{hintLabel}</Text>
|
||||
)}
|
||||
<Text className={styles.featurePopoverContent}>{hintContent}</Text>
|
||||
</Stack>
|
||||
}
|
||||
position={Position.TOP_LEFT}
|
||||
popoverClassName={styles.featurePopover}
|
||||
modifiers={{ offset: { enabled: true, offset: '0,10' } }}
|
||||
minimal
|
||||
>
|
||||
<Group noWrap spacing={8} style={{ cursor: 'help' }}>
|
||||
<CheckCircled height={12} width={12} />
|
||||
<Box className={styles.featureItem}>{children}</Box>
|
||||
</Group>
|
||||
</Tooltip>
|
||||
) : (
|
||||
<Group noWrap spacing={8}>
|
||||
<CheckCircled height={12} width={12} />
|
||||
<Box className={styles.featureItem}>{children}</Box>
|
||||
</Group>
|
||||
|
||||
@@ -1,10 +1,140 @@
|
||||
// @ts-nocheck
|
||||
// Subscription plans.
|
||||
export const plans = [
|
||||
|
||||
];
|
||||
interface SubscriptionPlanFeature {
|
||||
text: string;
|
||||
hint?: string;
|
||||
label?: string;
|
||||
style?: Record<string, string>;
|
||||
}
|
||||
interface SubscriptionPlan {
|
||||
name: string;
|
||||
slug: string;
|
||||
description: string;
|
||||
features: SubscriptionPlanFeature[];
|
||||
featured?: boolean;
|
||||
monthlyPrice: string;
|
||||
monthlyPriceLabel: string;
|
||||
annuallyPrice: string;
|
||||
annuallyPriceLabel: string;
|
||||
monthlyVariantId: string;
|
||||
annuallyVariantId: string;
|
||||
}
|
||||
|
||||
// Payment methods.
|
||||
export const paymentMethods = [
|
||||
|
||||
];
|
||||
export const SubscriptionPlans = [
|
||||
{
|
||||
name: 'Capital Basic',
|
||||
slug: 'capital_basic',
|
||||
description: 'Good for service businesses that just started.',
|
||||
features: [
|
||||
{
|
||||
text: 'Unlimited Sale Invoices',
|
||||
hintLabel: 'Unlimited Sale Invoices',
|
||||
hint: 'Good for service businesses that just started for service businesses that just started',
|
||||
},
|
||||
{ text: 'Unlimated Sale Estimates' },
|
||||
{ text: 'Track GST and VAT' },
|
||||
{ text: 'Connect Banks for Automatic Importing' },
|
||||
{ text: 'Chart of Accounts' },
|
||||
{
|
||||
text: 'Manual Journals',
|
||||
hintLabel: 'Manual Journals',
|
||||
hint: 'Write manual journals entries for financial transactions not automatically captured by the system to adjust financial statements.',
|
||||
},
|
||||
{
|
||||
text: 'Basic Financial Reports & Insights',
|
||||
hint: 'Balance sheet, profit & loss statement, cashflow statement, general ledger, journal sheet, A/P aging summary, A/R aging summary',
|
||||
},
|
||||
{ text: 'Unlimited User Seats' },
|
||||
],
|
||||
monthlyPrice: '$10',
|
||||
monthlyPriceLabel: 'Per month',
|
||||
annuallyPrice: '$7.5',
|
||||
annuallyPriceLabel: 'Per month',
|
||||
monthlyVariantId: '446152',
|
||||
// monthlyVariantId: '450016',
|
||||
annuallyVariantId: '446153',
|
||||
// annuallyVariantId: '450018',
|
||||
},
|
||||
{
|
||||
name: 'Capital Essential',
|
||||
slug: 'capital_plus',
|
||||
description: 'Good for have inventory and want more financial reports.',
|
||||
features: [
|
||||
{ text: 'All Capital Basic features' },
|
||||
{ text: 'Purchase Invoices' },
|
||||
{
|
||||
text: 'Multi Currency Transactions',
|
||||
hintLabel: 'Multi Currency',
|
||||
hint: 'Pay and get paid and do manual journals in any currency with real time exchange rates conversions.',
|
||||
},
|
||||
{
|
||||
text: 'Transactions Locking',
|
||||
hintLabel: 'Transactions Locking',
|
||||
hint: 'Transaction Locking freezes transactions to prevent any additions, modifications, or deletions of transactions recorded during the specified date.',
|
||||
},
|
||||
{
|
||||
text: 'Inventory Tracking',
|
||||
hintLabel: 'Inventory Tracking',
|
||||
hint: 'Track goods in the stock, cost of goods, and get notifications when quantity is low.',
|
||||
},
|
||||
{ text: 'Smart Financial Reports' },
|
||||
{ text: 'Advanced Inventory Reports' },
|
||||
],
|
||||
monthlyPrice: '$20',
|
||||
monthlyPriceLabel: 'Per month',
|
||||
annuallyPrice: '$15',
|
||||
annuallyPriceLabel: 'Per month',
|
||||
// monthlyVariantId: '450028',
|
||||
monthlyVariantId: '446155',
|
||||
// annuallyVariantId: '450029',
|
||||
annuallyVariantId: '446156',
|
||||
},
|
||||
{
|
||||
name: 'Capital Plus',
|
||||
slug: 'essentials',
|
||||
description: 'Good for business want financial and access control.',
|
||||
features: [
|
||||
{ text: 'All Capital Essential features' },
|
||||
{ text: 'Custom User Roles Access' },
|
||||
{ text: 'Vendor Credits' },
|
||||
{
|
||||
text: 'Budgeting',
|
||||
hint: 'Create multiple budgets and compare targets with actuals to understand how your business is performing.',
|
||||
},
|
||||
{ text: 'Analysis Cost Center' },
|
||||
],
|
||||
monthlyPrice: '$25',
|
||||
monthlyPriceLabel: 'Per month',
|
||||
annuallyPrice: '$19',
|
||||
annuallyPriceLabel: 'Per month',
|
||||
featured: true,
|
||||
// monthlyVariantId: '450031',
|
||||
monthlyVariantId: '446165',
|
||||
// annuallyVariantId: '450032',
|
||||
annuallyVariantId: '446164',
|
||||
},
|
||||
{
|
||||
name: 'Capital Big',
|
||||
slug: 'essentials',
|
||||
description: 'Good for businesses have multiple branches.',
|
||||
features: [
|
||||
{ text: 'All Capital Plus features' },
|
||||
{
|
||||
text: 'Multiple Branches',
|
||||
hintLabel: '',
|
||||
hint: 'Track the organization transactions and accounts in multiple branches.',
|
||||
},
|
||||
{
|
||||
text: 'Multiple Warehouses',
|
||||
hintLabel: 'Multiple Warehouses',
|
||||
hint: 'Track the organization inventory in multiple warehouses and transfer goods between them.',
|
||||
},
|
||||
],
|
||||
monthlyPrice: '$40',
|
||||
monthlyPriceLabel: 'Per month',
|
||||
annuallyPrice: '$30',
|
||||
annuallyPriceLabel: 'Per month',
|
||||
// monthlyVariantId: '450024',
|
||||
monthlyVariantId: '446167',
|
||||
// annuallyVariantId: '450025',
|
||||
annuallyVariantId: '446168',
|
||||
},
|
||||
] as SubscriptionPlan[];
|
||||
|
||||
@@ -3,3 +3,7 @@
|
||||
margin: 0 auto;
|
||||
padding: 0 40px;
|
||||
}
|
||||
|
||||
.periodSwitch {
|
||||
margin: 0;
|
||||
}
|
||||
@@ -1,32 +1,65 @@
|
||||
// @ts-nocheck
|
||||
import { AppToaster, Group, T } from '@/components';
|
||||
import { useGetLemonSqueezyCheckout } from '@/hooks/query';
|
||||
import { Intent } from '@blueprintjs/core';
|
||||
import * as R from 'ramda';
|
||||
import { AppToaster } from '@/components';
|
||||
import { useGetLemonSqueezyCheckout } from '@/hooks/query';
|
||||
import { PricingPlan } from '@/components/PricingPlan/PricingPlan';
|
||||
import { SubscriptionPlansPeriod } from '@/store/plans/plans.reducer';
|
||||
import {
|
||||
WithPlansProps,
|
||||
withPlans,
|
||||
} from '@/containers/Subscriptions/withPlans';
|
||||
|
||||
interface SubscriptionPricingFeature {
|
||||
text: string;
|
||||
hint?: string;
|
||||
hintLabel?: string;
|
||||
style?: Record<string, string>;
|
||||
}
|
||||
|
||||
interface SubscriptionPricingProps {
|
||||
slug: string;
|
||||
label: string;
|
||||
description: string;
|
||||
features?: Array<String>;
|
||||
features?: Array<SubscriptionPricingFeature>;
|
||||
featured?: boolean;
|
||||
price: string;
|
||||
pricePeriod: string;
|
||||
monthlyPrice: string;
|
||||
monthlyPriceLabel: string;
|
||||
annuallyPrice: string;
|
||||
annuallyPriceLabel: string;
|
||||
monthlyVariantId?: string;
|
||||
annuallyVariantId?: string;
|
||||
}
|
||||
|
||||
function SubscriptionPricing({
|
||||
featured,
|
||||
interface SubscriptionPricingCombinedProps
|
||||
extends SubscriptionPricingProps,
|
||||
WithPlansProps {}
|
||||
|
||||
function SubscriptionPlanRoot({
|
||||
label,
|
||||
description,
|
||||
featured,
|
||||
features,
|
||||
price,
|
||||
pricePeriod,
|
||||
}: SubscriptionPricingProps) {
|
||||
monthlyPrice,
|
||||
monthlyPriceLabel,
|
||||
annuallyPrice,
|
||||
annuallyPriceLabel,
|
||||
monthlyVariantId,
|
||||
annuallyVariantId,
|
||||
|
||||
// #withPlans
|
||||
plansPeriod,
|
||||
}: SubscriptionPricingCombinedProps) {
|
||||
const { mutateAsync: getLemonCheckout, isLoading } =
|
||||
useGetLemonSqueezyCheckout();
|
||||
|
||||
const handleClick = () => {
|
||||
getLemonCheckout({ variantId: '338516' })
|
||||
const variantId =
|
||||
SubscriptionPlansPeriod.Monthly === plansPeriod
|
||||
? monthlyVariantId
|
||||
: annuallyVariantId;
|
||||
|
||||
getLemonCheckout({ variantId })
|
||||
.then((res) => {
|
||||
const checkoutUrl = res.data.data.attributes.url;
|
||||
window.LemonSqueezy.Url.Open(checkoutUrl);
|
||||
@@ -42,37 +75,34 @@ function SubscriptionPricing({
|
||||
return (
|
||||
<PricingPlan featured={featured}>
|
||||
{featured && <PricingPlan.Featured>Most Popular</PricingPlan.Featured>}
|
||||
|
||||
<PricingPlan.Header label={label} description={description} />
|
||||
<PricingPlan.Price price={price} subPrice={pricePeriod} />
|
||||
|
||||
{plansPeriod === SubscriptionPlansPeriod.Monthly ? (
|
||||
<PricingPlan.Price price={monthlyPrice} subPrice={monthlyPriceLabel} />
|
||||
) : (
|
||||
<PricingPlan.Price
|
||||
price={annuallyPrice}
|
||||
subPrice={annuallyPriceLabel}
|
||||
/>
|
||||
)}
|
||||
<PricingPlan.BuyButton loading={isLoading} onClick={handleClick}>
|
||||
Subscribe
|
||||
</PricingPlan.BuyButton>
|
||||
|
||||
<PricingPlan.Features>
|
||||
{features?.map((feature) => (
|
||||
<PricingPlan.FeatureLine>{feature}</PricingPlan.FeatureLine>
|
||||
<PricingPlan.FeatureLine
|
||||
hintLabel={feature.hintLabel}
|
||||
hintContent={feature.hint}
|
||||
>
|
||||
{feature.text}
|
||||
</PricingPlan.FeatureLine>
|
||||
))}
|
||||
</PricingPlan.Features>
|
||||
</PricingPlan>
|
||||
);
|
||||
}
|
||||
|
||||
export function SubscriptionPlans({ plans }) {
|
||||
return (
|
||||
<Group spacing={18} noWrap align='stretch'>
|
||||
{plans.map((plan, index) => (
|
||||
<SubscriptionPricing
|
||||
key={index}
|
||||
slug={plan.slug}
|
||||
label={plan.name}
|
||||
description={plan.description}
|
||||
features={plan.features}
|
||||
featured={plan.featured}
|
||||
price={plan.price}
|
||||
pricePeriod={plan.pricePeriod}
|
||||
/>
|
||||
))}
|
||||
</Group>
|
||||
);
|
||||
}
|
||||
export const SubscriptionPlan = R.compose(
|
||||
withPlans(({ plansPeriod }) => ({ plansPeriod })),
|
||||
)(SubscriptionPlanRoot);
|
||||
|
||||
@@ -0,0 +1,28 @@
|
||||
import { Group } from '@/components';
|
||||
import { SubscriptionPlan } from './SubscriptionPlan';
|
||||
import { useSubscriptionPlans } from './hooks';
|
||||
|
||||
export function SubscriptionPlans() {
|
||||
const subscriptionPlans = useSubscriptionPlans();
|
||||
|
||||
return (
|
||||
<Group spacing={14} noWrap align="stretch">
|
||||
{subscriptionPlans.map((plan, index) => (
|
||||
<SubscriptionPlan
|
||||
key={index}
|
||||
slug={plan.slug}
|
||||
label={plan.name}
|
||||
description={plan.description}
|
||||
features={plan.features}
|
||||
featured={plan.featured}
|
||||
monthlyPrice={plan.monthlyPrice}
|
||||
monthlyPriceLabel={plan.monthlyPriceLabel}
|
||||
annuallyPrice={plan.annuallyPrice}
|
||||
annuallyPriceLabel={plan.annuallyPriceLabel}
|
||||
monthlyVariantId={plan.monthlyVariantId}
|
||||
annuallyVariantId={plan.annuallyVariantId}
|
||||
/>
|
||||
))}
|
||||
</Group>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
import { ChangeEvent } from 'react';
|
||||
import * as R from 'ramda';
|
||||
import { Intent, Switch, Tag, Text } from '@blueprintjs/core';
|
||||
import { Group } from '@/components';
|
||||
import withSubscriptionPlansActions, {
|
||||
WithSubscriptionPlansActionsProps,
|
||||
} from '@/containers/Subscriptions/withSubscriptionPlansActions';
|
||||
import { SubscriptionPlansPeriod } from '@/store/plans/plans.reducer';
|
||||
import styles from './SetupSubscription.module.scss';
|
||||
|
||||
interface SubscriptionPlansPeriodsSwitchCombinedProps
|
||||
extends WithSubscriptionPlansActionsProps {}
|
||||
|
||||
function SubscriptionPlansPeriodSwitcherRoot({
|
||||
// #withSubscriptionPlansActions
|
||||
changeSubscriptionPlansPeriod,
|
||||
}: SubscriptionPlansPeriodsSwitchCombinedProps) {
|
||||
// Handles the period switch change.
|
||||
const handleSwitchChange = (event: ChangeEvent<HTMLInputElement>) => {
|
||||
changeSubscriptionPlansPeriod(
|
||||
event.currentTarget.checked
|
||||
? SubscriptionPlansPeriod.Annually
|
||||
: SubscriptionPlansPeriod.Monthly,
|
||||
);
|
||||
};
|
||||
return (
|
||||
<Group position={'center'} spacing={10} style={{ marginBottom: '1.2rem' }}>
|
||||
<Text>Pay Monthly</Text>
|
||||
<Switch
|
||||
large
|
||||
onChange={handleSwitchChange}
|
||||
className={styles.periodSwitch}
|
||||
/>
|
||||
<Text>
|
||||
Pay Yearly{' '}
|
||||
<Tag minimal intent={Intent.NONE}>
|
||||
25% Off All Year
|
||||
</Tag>
|
||||
</Text>
|
||||
</Group>
|
||||
);
|
||||
}
|
||||
|
||||
export const SubscriptionPlansPeriodSwitcher = R.compose(
|
||||
withSubscriptionPlansActions,
|
||||
)(SubscriptionPlansPeriodSwitcherRoot);
|
||||
@@ -1,29 +1,21 @@
|
||||
// @ts-nocheck
|
||||
import { Callout } from '@blueprintjs/core';
|
||||
import { SubscriptionPlans } from './SubscriptionPlan';
|
||||
import withPlans from '../../Subscriptions/withPlans';
|
||||
import { compose } from '@/utils';
|
||||
import { SubscriptionPlans } from './SubscriptionPlans';
|
||||
import { SubscriptionPlansPeriodSwitcher } from './SubscriptionPlansPeriodSwitcher';
|
||||
|
||||
/**
|
||||
* Billing plans.
|
||||
*/
|
||||
function SubscriptionPlansSectionRoot({ plans }) {
|
||||
export function SubscriptionPlansSection() {
|
||||
return (
|
||||
<section>
|
||||
<Callout
|
||||
style={{ marginBottom: '1.5rem' }}
|
||||
icon={null}
|
||||
title={'Early Adopter Plan'}
|
||||
>
|
||||
We're looking for 200 early adopters, when you subscribe you'll get the
|
||||
full features and unlimited users for a year regardless of the
|
||||
subscribed plan.
|
||||
<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>
|
||||
<SubscriptionPlans plans={plans} />
|
||||
|
||||
<SubscriptionPlansPeriodSwitcher />
|
||||
<SubscriptionPlans />
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
export const SubscriptionPlansSection = compose(
|
||||
withPlans(({ plans }) => ({ plans })),
|
||||
)(SubscriptionPlansSectionRoot);
|
||||
|
||||
@@ -0,0 +1,5 @@
|
||||
import { SubscriptionPlans } from '@/constants/subscriptionModels';
|
||||
|
||||
export const useSubscriptionPlans = () => {
|
||||
return SubscriptionPlans;
|
||||
};
|
||||
@@ -1,17 +1,35 @@
|
||||
// @ts-nocheck
|
||||
import { connect } from 'react-redux';
|
||||
import { MapStateToProps, connect } from 'react-redux';
|
||||
import {
|
||||
getPlansPeriodSelector,
|
||||
getPlansSelector,
|
||||
} from '@/store/plans/plans.selectors';
|
||||
import { ApplicationState } from '@/store/reducers';
|
||||
|
||||
export default (mapState) => {
|
||||
const mapStateToProps = (state, props) => {
|
||||
export interface WithPlansProps {
|
||||
plans: ReturnType<ReturnType<typeof getPlansSelector>>;
|
||||
plansPeriod: ReturnType<ReturnType<typeof getPlansPeriodSelector>>;
|
||||
}
|
||||
|
||||
type MapState<Props> = (
|
||||
mapped: WithPlansProps,
|
||||
state: ApplicationState,
|
||||
props: Props,
|
||||
) => any;
|
||||
|
||||
export function withPlans<Props>(mapState?: MapState<Props>) {
|
||||
const mapStateToProps: MapStateToProps<
|
||||
WithPlansProps,
|
||||
Props,
|
||||
ApplicationState
|
||||
> = (state, props) => {
|
||||
const getPlans = getPlansSelector();
|
||||
const getPlansPeriod = getPlansPeriodSelector();
|
||||
|
||||
const mapped = {
|
||||
plans: getPlans(state, props),
|
||||
plans: getPlans(state),
|
||||
plansPeriod: getPlansPeriod(state),
|
||||
};
|
||||
return mapState ? mapState(mapped, state, props) : mapped;
|
||||
};
|
||||
return connect(mapStateToProps);
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1,9 +1,22 @@
|
||||
// @ts-nocheck
|
||||
import { connect } from 'react-redux';
|
||||
import { initSubscriptionPlans } from '@/store/plans/plans.actions';
|
||||
import { MapDispatchToProps, connect } from 'react-redux';
|
||||
import {
|
||||
SubscriptionPlansPeriod,
|
||||
changePlansPeriod,
|
||||
initSubscriptionPlans,
|
||||
} from '@/store/plans/plans.reducer';
|
||||
|
||||
export const mapDispatchToProps = (dispatch) => ({
|
||||
export interface WithSubscriptionPlansActionsProps {
|
||||
initSubscriptionPlans: () => void;
|
||||
changeSubscriptionPlansPeriod: (period: SubscriptionPlansPeriod) => void;
|
||||
}
|
||||
|
||||
export const mapDispatchToProps: MapDispatchToProps<
|
||||
WithSubscriptionPlansActionsProps,
|
||||
{}
|
||||
> = (dispatch: any) => ({
|
||||
initSubscriptionPlans: () => dispatch(initSubscriptionPlans()),
|
||||
changeSubscriptionPlansPeriod: (period: SubscriptionPlansPeriod) =>
|
||||
dispatch(changePlansPeriod({ period })),
|
||||
});
|
||||
|
||||
export default connect(null, mapDispatchToProps);
|
||||
export default connect(null, mapDispatchToProps);
|
||||
|
||||
@@ -1,70 +1,46 @@
|
||||
// @ts-nocheck
|
||||
import { createReducer } from '@reduxjs/toolkit';
|
||||
import t from '@/store/types';
|
||||
import { PayloadAction, createSlice } from '@reduxjs/toolkit';
|
||||
import { SubscriptionPlans } from '@/constants/subscriptionModels';
|
||||
|
||||
const getSubscriptionPlans = () => [
|
||||
{
|
||||
name: 'Capital Basic',
|
||||
slug: 'capital_basic',
|
||||
description: 'Good for service businesses that just started.',
|
||||
features: [
|
||||
'Sale Invoices and Estimates',
|
||||
'Tracking Expenses',
|
||||
'Customize Invoice',
|
||||
'Manual Journals',
|
||||
'Bank Reconciliation',
|
||||
'Chart of Accounts',
|
||||
'Taxes',
|
||||
'Basic Financial Reports & Insights',
|
||||
],
|
||||
price: '$29',
|
||||
pricePeriod: 'Per Year',
|
||||
},
|
||||
{
|
||||
name: 'Capital Plus',
|
||||
slug: 'capital_plus',
|
||||
description:
|
||||
'Good for businesses have inventory and want more financial reports.',
|
||||
features: [
|
||||
'All Capital Basic features',
|
||||
'Manage Bills',
|
||||
'Inventory Tracking',
|
||||
'Multi Currencies',
|
||||
'Predefined user roles.',
|
||||
'Transactions locking.',
|
||||
'Smart Financial Reports.',
|
||||
],
|
||||
price: '$29',
|
||||
pricePeriod: 'Per Year',
|
||||
featured: true,
|
||||
},
|
||||
{
|
||||
name: 'Capital Big',
|
||||
slug: 'essentials',
|
||||
description: 'Good for businesses have multiple inventory or branches.',
|
||||
features: [
|
||||
'All Capital Plus features',
|
||||
'Multiple Warehouses',
|
||||
'Multiple Branches',
|
||||
'Invite >= 15 Users',
|
||||
],
|
||||
price: '$29',
|
||||
pricePeriod: 'Per Year',
|
||||
},
|
||||
];
|
||||
export enum SubscriptionPlansPeriod {
|
||||
Monthly = 'monthly',
|
||||
Annually = 'Annually',
|
||||
}
|
||||
|
||||
const initialState = {
|
||||
plans: [],
|
||||
periods: [],
|
||||
};
|
||||
interface StorePlansState {
|
||||
plans: any;
|
||||
plansPeriod: SubscriptionPlansPeriod;
|
||||
}
|
||||
|
||||
export default createReducer(initialState, {
|
||||
/**
|
||||
* Initialize the subscription plans.
|
||||
*/
|
||||
[t.INIT_SUBSCRIPTION_PLANS]: (state) => {
|
||||
const plans = getSubscriptionPlans();
|
||||
export const SubscriptionPlansSlice = createSlice({
|
||||
name: 'plans',
|
||||
initialState: {
|
||||
plans: [],
|
||||
periods: [],
|
||||
plansPeriod: 'monthly',
|
||||
} as StorePlansState,
|
||||
reducers: {
|
||||
/**
|
||||
* Initialize the subscription plans.
|
||||
* @param {StorePlansState} state
|
||||
*/
|
||||
initSubscriptionPlans: (state: StorePlansState) => {
|
||||
const plans = SubscriptionPlans;
|
||||
state.plans = plans;
|
||||
},
|
||||
|
||||
state.plans = plans;
|
||||
/**
|
||||
* Changes the plans period (monthly or annually).
|
||||
* @param {StorePlansState} state
|
||||
* @param {PayloadAction<{ period: SubscriptionPlansPeriod }>} action
|
||||
*/
|
||||
changePlansPeriod: (
|
||||
state: StorePlansState,
|
||||
action: PayloadAction<{ period: SubscriptionPlansPeriod }>,
|
||||
) => {
|
||||
state.plansPeriod = action.payload.period;
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
export const { initSubscriptionPlans, changePlansPeriod } =
|
||||
SubscriptionPlansSlice.actions;
|
||||
|
||||
@@ -2,19 +2,21 @@
|
||||
import { createSelector } from 'reselect';
|
||||
|
||||
const plansSelector = (state) => state.plans.plans;
|
||||
const planSelector = (state, props) => state.plans.plans
|
||||
.find((plan) => plan.slug === props.planSlug);
|
||||
const planSelector = (state, props) =>
|
||||
state.plans.plans.find((plan) => plan.slug === props.planSlug);
|
||||
|
||||
const plansPeriodSelector = (state) => state.plans.plansPeriod;
|
||||
|
||||
// Retrieve manual jounral current page results.
|
||||
export const getPlansSelector = () => createSelector(
|
||||
plansSelector,
|
||||
(plans) => {
|
||||
export const getPlansSelector = () =>
|
||||
createSelector(plansSelector, (plans) => {
|
||||
return plans;
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
// Retrieve plan details.
|
||||
export const getPlanSelector = () => createSelector(
|
||||
planSelector,
|
||||
(plan) => plan,
|
||||
)
|
||||
export const getPlanSelector = () =>
|
||||
createSelector(planSelector, (plan) => plan);
|
||||
|
||||
// Retrieves the plans period (monthly or annually).
|
||||
export const getPlansPeriodSelector = () =>
|
||||
createSelector(plansPeriodSelector, (periods) => periods);
|
||||
|
||||
@@ -32,13 +32,17 @@ import paymentMades from './PaymentMades/paymentMades.reducer';
|
||||
import organizations from './organizations/organizations.reducers';
|
||||
import subscriptions from './subscription/subscription.reducer';
|
||||
import inventoryAdjustments from './inventoryAdjustments/inventoryAdjustment.reducer';
|
||||
import plans from './plans/plans.reducer';
|
||||
import { SubscriptionPlansSlice } from './plans/plans.reducer';
|
||||
import creditNotes from './CreditNote/creditNote.reducer';
|
||||
import vendorCredit from './VendorCredit/VendorCredit.reducer';
|
||||
import warehouseTransfers from './WarehouseTransfer/warehouseTransfer.reducer';
|
||||
import projects from './Project/projects.reducer';
|
||||
import { PlaidSlice } from './banking/banking.reducer';
|
||||
|
||||
export interface ApplicationState {
|
||||
|
||||
}
|
||||
|
||||
const appReducer = combineReducers({
|
||||
authentication,
|
||||
organizations,
|
||||
@@ -69,7 +73,7 @@ const appReducer = combineReducers({
|
||||
paymentReceives,
|
||||
paymentMades,
|
||||
inventoryAdjustments,
|
||||
plans,
|
||||
plans: SubscriptionPlansSlice.reducer,
|
||||
creditNotes,
|
||||
vendorCredit,
|
||||
warehouseTransfers,
|
||||
|
||||
Reference in New Issue
Block a user