diff --git a/.all-contributorsrc b/.all-contributorsrc index d978835f1..bd2ec7d4d 100644 --- a/.all-contributorsrc +++ b/.all-contributorsrc @@ -132,6 +132,15 @@ "contributions": [ "bug" ] + }, + { + "login": "oleynikd", + "name": "Denis", + "avatar_url": "https://avatars.githubusercontent.com/u/3976868?v=4", + "profile": "https://github.com/oleynikd", + "contributions": [ + "bug" + ] } ], "contributorsPerLine": 7, diff --git a/README.md b/README.md index b35701043..b9c4cbfd7 100644 --- a/README.md +++ b/README.md @@ -126,6 +126,9 @@ Thanks goes to these wonderful people ([emoji key](https://allcontributors.org/d Vederis Leunardus
Vederis Leunardus

💻 Chris Cantrell
Chris Cantrell

🐛 + + Denis
Denis

🐛 + diff --git a/packages/server/src/api/controllers/Banking/BankAccountsController.ts b/packages/server/src/api/controllers/Banking/BankAccountsController.ts index 4b062768f..f337c0b38 100644 --- a/packages/server/src/api/controllers/Banking/BankAccountsController.ts +++ b/packages/server/src/api/controllers/Banking/BankAccountsController.ts @@ -3,12 +3,16 @@ import { NextFunction, Request, Response, Router } from 'express'; import BaseController from '@/api/controllers/BaseController'; import { CashflowApplication } from '@/services/Cashflow/CashflowApplication'; import { GetBankAccountSummary } from '@/services/Banking/BankAccounts/GetBankAccountSummary'; +import { BankAccountsApplication } from '@/services/Banking/BankAccounts/BankAccountsApplication'; @Service() export class BankAccountsController extends BaseController { @Inject() private getBankAccountSummaryService: GetBankAccountSummary; + @Inject() + private bankAccountsApp: BankAccountsApplication; + /** * Router constructor. */ @@ -16,6 +20,11 @@ export class BankAccountsController extends BaseController { const router = Router(); router.get('/:bankAccountId/meta', this.getBankAccountSummary.bind(this)); + router.post( + '/:bankAccountId/disconnect', + this.disconnectBankAccount.bind(this) + ); + router.post('/:bankAccountId/update', this.refreshBankAccount.bind(this)); return router; } @@ -46,4 +55,58 @@ export class BankAccountsController extends BaseController { next(error); } } + + /** + * Disonnect the given bank account. + * @param {Request} req + * @param {Response} res + * @param {NextFunction} next + * @returns {Promise} + */ + async disconnectBankAccount( + req: Request<{ bankAccountId: number }>, + res: Response, + next: NextFunction + ) { + const { bankAccountId } = req.params; + const { tenantId } = req; + + try { + await this.bankAccountsApp.disconnectBankAccount(tenantId, bankAccountId); + + return res.status(200).send({ + id: bankAccountId, + message: 'The bank account has been disconnected.', + }); + } catch (error) { + next(error); + } + } + + /** + * Refresh the given bank account. + * @param {Request} req + * @param {Response} res + * @param {NextFunction} next + * @returns {Promise} + */ + async refreshBankAccount( + req: Request<{ bankAccountId: number }>, + res: Response, + next: NextFunction + ) { + const { bankAccountId } = req.params; + const { tenantId } = req; + + try { + await this.bankAccountsApp.refreshBankAccount(tenantId, bankAccountId); + + return res.status(200).send({ + id: bankAccountId, + message: 'The bank account has been disconnected.', + }); + } catch (error) { + next(error); + } + } } diff --git a/packages/server/src/api/controllers/Subscription/SubscriptionController.ts b/packages/server/src/api/controllers/Subscription/SubscriptionController.ts index 7f394a666..d84e4c2c2 100644 --- a/packages/server/src/api/controllers/Subscription/SubscriptionController.ts +++ b/packages/server/src/api/controllers/Subscription/SubscriptionController.ts @@ -8,6 +8,7 @@ import SubscriptionService from '@/services/Subscription/SubscriptionService'; import asyncMiddleware from '@/api/middleware/asyncMiddleware'; import BaseController from '../BaseController'; import { LemonSqueezyService } from '@/services/Subscription/LemonSqueezyService'; +import { SubscriptionApplication } from '@/services/Subscription/SubscriptionApplication'; @Service() export class SubscriptionController extends BaseController { @@ -17,6 +18,9 @@ export class SubscriptionController extends BaseController { @Inject() private lemonSqueezyService: LemonSqueezyService; + @Inject() + private subscriptionApp: SubscriptionApplication; + /** * Router constructor. */ @@ -33,6 +37,14 @@ export class SubscriptionController extends BaseController { this.validationResult, this.getCheckoutUrl.bind(this) ); + router.post('/cancel', asyncMiddleware(this.cancelSubscription.bind(this))); + router.post('/resume', asyncMiddleware(this.resumeSubscription.bind(this))); + router.post( + '/change', + [body('variant_id').exists().trim()], + this.validationResult, + asyncMiddleware(this.changeSubscriptionPlan.bind(this)) + ); router.get('/', asyncMiddleware(this.getSubscriptions.bind(this))); return router; @@ -85,4 +97,84 @@ export class SubscriptionController extends BaseController { next(error); } } + + /** + * Cancels the subscription of the current organization. + * @param {Request} req + * @param {Response} res + * @param {NextFunction} next + * @returns {Promise} + */ + private async cancelSubscription( + req: Request, + res: Response, + next: NextFunction + ) { + const { tenantId } = req; + + try { + await this.subscriptionApp.cancelSubscription(tenantId, '455610'); + + return res.status(200).send({ + status: 200, + message: 'The organization subscription has been canceled.', + }); + } catch (error) { + next(error); + } + } + + /** + * Resumes the subscription of the current organization. + * @param {Request} req + * @param {Response} res + * @param {NextFunction} next + * @returns {Promise} + */ + private async resumeSubscription( + req: Request, + res: Response, + next: NextFunction + ) { + const { tenantId } = req; + + try { + await this.subscriptionApp.resumeSubscription(tenantId); + + return res.status(200).send({ + status: 200, + message: 'The organization subscription has been resumed.', + }); + } catch (error) { + next(error); + } + } + + /** + * Changes the main subscription plan of the current organization. + * @param {Request} req + * @param {Response} res + * @param {NextFunction} next + * @returns {Promise} + */ + public async changeSubscriptionPlan( + req: Request, + res: Response, + next: NextFunction + ) { + const { tenantId } = req; + const body = this.matchedBodyData(req); + + try { + await this.subscriptionApp.changeSubscriptionPlan( + tenantId, + body.variantId + ); + return res.status(200).send({ + message: 'The subscription plan has been changed.', + }); + } catch (error) { + next(error); + } + } } diff --git a/packages/server/src/config/index.ts b/packages/server/src/config/index.ts index b8a862343..a7ef51e93 100644 --- a/packages/server/src/config/index.ts +++ b/packages/server/src/config/index.ts @@ -236,6 +236,10 @@ module.exports = { secretAccessKey: process.env.S3_SECRET_ACCESS_KEY, endpoint: process.env.S3_ENDPOINT, bucket: process.env.S3_BUCKET || 'bigcapital-documents', + forcePathStyle: parseBoolean( + defaultTo(process.env.S3_FORCE_PATH_STYLE, false), + false + ), }, loops: { diff --git a/packages/server/src/database/migrations/20240716114732_add_plaid_item_id_to_accounts_table.js b/packages/server/src/database/migrations/20240716114732_add_plaid_item_id_to_accounts_table.js new file mode 100644 index 000000000..ce084dca8 --- /dev/null +++ b/packages/server/src/database/migrations/20240716114732_add_plaid_item_id_to_accounts_table.js @@ -0,0 +1,11 @@ +exports.up = function (knex) { + return knex.schema.table('accounts', (table) => { + table.string('plaid_item_id').nullable(); + }); +}; + +exports.down = function (knex) { + return knex.schema.table('accounts', (table) => { + table.dropColumn('plaid_item_id'); + }); +}; diff --git a/packages/server/src/database/migrations/20240729172403_add_is_syncing_owner_to_accounts_table.js b/packages/server/src/database/migrations/20240729172403_add_is_syncing_owner_to_accounts_table.js new file mode 100644 index 000000000..f65eb3ca0 --- /dev/null +++ b/packages/server/src/database/migrations/20240729172403_add_is_syncing_owner_to_accounts_table.js @@ -0,0 +1,19 @@ +exports.up = function (knex) { + return knex.schema + .table('accounts', (table) => { + table + .boolean('is_syncing_owner') + .defaultTo(false) + .after('is_feeds_active'); + }) + .then(() => { + return knex('accounts') + .whereNotNull('plaid_item_id') + .orWhereNotNull('plaid_account_id') + .update('is_syncing_owner', true); + }); +}; + +exports.down = function (knex) { + table.dropColumn('is_syncing_owner'); +}; diff --git a/packages/server/src/interfaces/Account.ts b/packages/server/src/interfaces/Account.ts index b1a880b80..03c002c7b 100644 --- a/packages/server/src/interfaces/Account.ts +++ b/packages/server/src/interfaces/Account.ts @@ -15,6 +15,7 @@ export interface IAccountDTO { export interface IAccountCreateDTO extends IAccountDTO { currencyCode?: string; plaidAccountId?: string; + plaidItemId?: string; } export interface IAccountEditDTO extends IAccountDTO {} @@ -37,6 +38,8 @@ export interface IAccount { accountNormal: string; accountParentType: string; bankBalance: string; + plaidItemId: number | null + lastFeedsUpdatedAt: Date; } export enum AccountNormal { diff --git a/packages/server/src/lib/Plaid/Plaid.ts b/packages/server/src/lib/Plaid/Plaid.ts index 532d3cfe8..05875a27c 100644 --- a/packages/server/src/lib/Plaid/Plaid.ts +++ b/packages/server/src/lib/Plaid/Plaid.ts @@ -1,69 +1,12 @@ -import { forEach } from 'lodash'; import { Configuration, PlaidApi, PlaidEnvironments } from 'plaid'; -import { createPlaidApiEvent } from './PlaidApiEventsDBSync'; import config from '@/config'; -const OPTIONS = { clientApp: 'Plaid-Pattern' }; - -// We want to log requests to / responses from the Plaid API (via the Plaid client), as this data -// can be useful for troubleshooting. - -/** - * Logging function for Plaid client methods that use an access_token as an argument. Associates - * the Plaid API event log entry with the item and user the request is for. - * - * @param {string} clientMethod the name of the Plaid client method called. - * @param {Array} clientMethodArgs the arguments passed to the Plaid client method. - * @param {Object} response the response from the Plaid client. - */ -const defaultLogger = async (clientMethod, clientMethodArgs, response) => { - const accessToken = clientMethodArgs[0].access_token; - // const { id: itemId, user_id: userId } = await retrieveItemByPlaidAccessToken( - // accessToken - // ); - // await createPlaidApiEvent(1, 1, clientMethod, clientMethodArgs, response); - - // console.log(response); -}; - -/** - * Logging function for Plaid client methods that do not use access_token as an argument. These - * Plaid API event log entries will not be associated with an item or user. - * - * @param {string} clientMethod the name of the Plaid client method called. - * @param {Array} clientMethodArgs the arguments passed to the Plaid client method. - * @param {Object} response the response from the Plaid client. - */ -const noAccessTokenLogger = async ( - clientMethod, - clientMethodArgs, - response -) => { - // console.log(response); - - // await createPlaidApiEvent( - // undefined, - // undefined, - // clientMethod, - // clientMethodArgs, - // response - // ); -}; - -// Plaid client methods used in this app, mapped to their appropriate logging functions. -const clientMethodLoggingFns = { - accountsGet: defaultLogger, - institutionsGet: noAccessTokenLogger, - institutionsGetById: noAccessTokenLogger, - itemPublicTokenExchange: noAccessTokenLogger, - itemRemove: defaultLogger, - linkTokenCreate: noAccessTokenLogger, - transactionsSync: defaultLogger, - sandboxItemResetLogin: defaultLogger, -}; // Wrapper for the Plaid client. This allows us to easily log data for all Plaid client requests. export class PlaidClientWrapper { - constructor() { + private static instance: PlaidClientWrapper; + private client: PlaidApi; + + private constructor() { // Initialize the Plaid client. const configuration = new Configuration({ basePath: PlaidEnvironments[config.plaid.env], @@ -75,26 +18,13 @@ export class PlaidClientWrapper { }, }, }); - this.client = new PlaidApi(configuration); - - // Wrap the Plaid client methods to add a logging function. - forEach(clientMethodLoggingFns, (logFn, method) => { - this[method] = this.createWrappedClientMethod(method, logFn); - }); } - // Allows us to log API request data for troubleshooting purposes. - createWrappedClientMethod(clientMethod, log) { - return async (...args) => { - try { - const res = await this.client[clientMethod](...args); - await log(clientMethod, args, res); - return res; - } catch (err) { - await log(clientMethod, args, err?.response?.data); - throw err; - } - }; + public static getClient(): PlaidApi { + if (!PlaidClientWrapper.instance) { + PlaidClientWrapper.instance = new PlaidClientWrapper(); + } + return PlaidClientWrapper.instance.client; } } diff --git a/packages/server/src/lib/S3/S3.ts b/packages/server/src/lib/S3/S3.ts index 2b81a50df..96567e238 100644 --- a/packages/server/src/lib/S3/S3.ts +++ b/packages/server/src/lib/S3/S3.ts @@ -8,4 +8,5 @@ export const s3 = new S3Client({ secretAccessKey: config.s3.secretAccessKey, }, endpoint: config.s3.endpoint, + forcePathStyle: config.s3.forcePathStyle, }); diff --git a/packages/server/src/loaders/eventEmitter.ts b/packages/server/src/loaders/eventEmitter.ts index 9e2ae276e..f303b4527 100644 --- a/packages/server/src/loaders/eventEmitter.ts +++ b/packages/server/src/loaders/eventEmitter.ts @@ -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 { DisconnectPlaidItemOnAccountDeleted } from '@/services/Banking/BankAccounts/events/DisconnectPlaidItemOnAccountDeleted'; import { LoopsEventsSubscriber } from '@/services/Loops/LoopsEventsSubscriber'; export default () => { @@ -275,6 +276,7 @@ export const susbcribers = () => { // Plaid RecognizeSyncedBankTranasctions, + DisconnectPlaidItemOnAccountDeleted, // Loops LoopsEventsSubscriber diff --git a/packages/server/src/models/Account.ts b/packages/server/src/models/Account.ts index 7e0d8d6e4..d9972251a 100644 --- a/packages/server/src/models/Account.ts +++ b/packages/server/src/models/Account.ts @@ -197,6 +197,7 @@ export default class Account extends mixin(TenantModel, [ const ExpenseEntry = require('models/ExpenseCategory'); const ItemEntry = require('models/ItemEntry'); const UncategorizedTransaction = require('models/UncategorizedCashflowTransaction'); + const PlaidItem = require('models/PlaidItem'); return { /** @@ -321,6 +322,18 @@ export default class Account extends mixin(TenantModel, [ query.where('categorized', false); }, }, + + /** + * Account model may belongs to a Plaid item. + */ + plaidItem: { + relation: Model.BelongsToOneRelation, + modelClass: PlaidItem.default, + join: { + from: 'accounts.plaidItemId', + to: 'plaid_items.plaidItemId', + }, + }, }; } diff --git a/packages/server/src/services/Accounts/AccountTransform.ts b/packages/server/src/services/Accounts/AccountTransform.ts index cb58a9be9..28f3b74a5 100644 --- a/packages/server/src/services/Accounts/AccountTransform.ts +++ b/packages/server/src/services/Accounts/AccountTransform.ts @@ -13,7 +13,12 @@ export class AccountTransformer extends Transformer { * @returns {Array} */ public includeAttributes = (): string[] => { - return ['formattedAmount', 'flattenName', 'bankBalanceFormatted']; + return [ + 'formattedAmount', + 'flattenName', + 'bankBalanceFormatted', + 'lastFeedsUpdatedAtFormatted', + ]; }; /** @@ -52,6 +57,15 @@ export class AccountTransformer extends Transformer { }); }; + /** + * Retrieves the formatted last feeds update at. + * @param {IAccount} account + * @returns {string} + */ + protected lastFeedsUpdatedAtFormatted = (account: IAccount): string => { + return this.formatDate(account.lastFeedsUpdatedAt); + }; + /** * Transformes the accounts collection to flat or nested array. * @param {IAccount[]} diff --git a/packages/server/src/services/Accounts/CreateAccount.ts b/packages/server/src/services/Accounts/CreateAccount.ts index da80d3af4..27ecbf580 100644 --- a/packages/server/src/services/Accounts/CreateAccount.ts +++ b/packages/server/src/services/Accounts/CreateAccount.ts @@ -96,6 +96,11 @@ export class CreateAccount { ...createAccountDTO, slug: kebabCase(createAccountDTO.name), currencyCode: createAccountDTO.currencyCode || baseCurrency, + + // Mark the account is Plaid owner since Plaid item/account is defined on creating. + isSyncingOwner: Boolean( + createAccountDTO.plaidAccountId || createAccountDTO.plaidItemId + ), }; }; @@ -117,12 +122,7 @@ export class CreateAccount { const tenantMeta = await TenantMetadata.query().findOne({ tenantId }); // Authorize the account creation. - await this.authorize( - tenantId, - accountDTO, - tenantMeta.baseCurrency, - params - ); + await this.authorize(tenantId, accountDTO, tenantMeta.baseCurrency, params); // Transformes the DTO to model. const accountInputModel = this.transformDTOToModel( accountDTO, @@ -157,4 +157,3 @@ export class CreateAccount { ); }; } - diff --git a/packages/server/src/services/Banking/BankAccounts/BankAccountsApplication.tsx b/packages/server/src/services/Banking/BankAccounts/BankAccountsApplication.tsx new file mode 100644 index 000000000..51c12106e --- /dev/null +++ b/packages/server/src/services/Banking/BankAccounts/BankAccountsApplication.tsx @@ -0,0 +1,38 @@ +import { Inject, Service } from 'typedi'; +import { DisconnectBankAccount } from './DisconnectBankAccount'; +import { RefreshBankAccountService } from './RefreshBankAccount'; + +@Service() +export class BankAccountsApplication { + @Inject() + private disconnectBankAccountService: DisconnectBankAccount; + + @Inject() + private refreshBankAccountService: RefreshBankAccountService; + + /** + * Disconnects the given bank account. + * @param {number} tenantId + * @param {number} bankAccountId + * @returns {Promise} + */ + async disconnectBankAccount(tenantId: number, bankAccountId: number) { + return this.disconnectBankAccountService.disconnectBankAccount( + tenantId, + bankAccountId + ); + } + + /** + * Refresh the bank transactions of the given bank account. + * @param {number} tenantId + * @param {number} bankAccountId + * @returns {Promise} + */ + async refreshBankAccount(tenantId: number, bankAccountId: number) { + return this.refreshBankAccountService.refreshBankAccount( + tenantId, + bankAccountId + ); + } +} diff --git a/packages/server/src/services/Banking/BankAccounts/DisconnectBankAccount.tsx b/packages/server/src/services/Banking/BankAccounts/DisconnectBankAccount.tsx new file mode 100644 index 000000000..fe43ef1e2 --- /dev/null +++ b/packages/server/src/services/Banking/BankAccounts/DisconnectBankAccount.tsx @@ -0,0 +1,78 @@ +import { Knex } from 'knex'; +import { Inject, Service } from 'typedi'; +import { ServiceError } from '@/exceptions'; +import { EventPublisher } from '@/lib/EventPublisher/EventPublisher'; +import { PlaidClientWrapper } from '@/lib/Plaid'; +import HasTenancyService from '@/services/Tenancy/TenancyService'; +import UnitOfWork from '@/services/UnitOfWork'; +import events from '@/subscribers/events'; +import { + ERRORS, + IBankAccountDisconnectedEventPayload, + IBankAccountDisconnectingEventPayload, +} from './types'; +import { ACCOUNT_TYPE } from '@/data/AccountTypes'; + +@Service() +export class DisconnectBankAccount { + @Inject() + private tenancy: HasTenancyService; + + @Inject() + private eventPublisher: EventPublisher; + + @Inject() + private uow: UnitOfWork; + + /** + * Disconnects the given bank account. + * @param {number} tenantId + * @param {number} bankAccountId + * @returns {Promise} + */ + public async disconnectBankAccount(tenantId: number, bankAccountId: number) { + const { Account, PlaidItem } = this.tenancy.models(tenantId); + + // Retrieve the bank account or throw not found error. + const account = await Account.query() + .findById(bankAccountId) + .whereIn('account_type', [ACCOUNT_TYPE.CASH, ACCOUNT_TYPE.BANK]) + .withGraphFetched('plaidItem') + .throwIfNotFound(); + + const oldPlaidItem = account.plaidItem; + + if (!oldPlaidItem) { + throw new ServiceError(ERRORS.BANK_ACCOUNT_NOT_CONNECTED); + } + const plaidInstance = PlaidClientWrapper.getClient(); + + return this.uow.withTransaction(tenantId, async (trx: Knex.Transaction) => { + // Triggers `onBankAccountDisconnecting` event. + await this.eventPublisher.emitAsync(events.bankAccount.onDisconnecting, { + tenantId, + bankAccountId, + } as IBankAccountDisconnectingEventPayload); + + // Remove the Plaid item from the system. + await PlaidItem.query(trx).findById(account.plaidItemId).delete(); + + // Remove the plaid item association to the bank account. + await Account.query(trx).findById(bankAccountId).patch({ + plaidAccountId: null, + plaidItemId: null, + isFeedsActive: false, + }); + // Remove the Plaid item. + await plaidInstance.itemRemove({ + access_token: oldPlaidItem.plaidAccessToken, + }); + // Triggers `onBankAccountDisconnected` event. + await this.eventPublisher.emitAsync(events.bankAccount.onDisconnected, { + tenantId, + bankAccountId, + trx, + } as IBankAccountDisconnectedEventPayload); + }); + } +} diff --git a/packages/server/src/services/Banking/BankAccounts/RefreshBankAccount.tsx b/packages/server/src/services/Banking/BankAccounts/RefreshBankAccount.tsx new file mode 100644 index 000000000..8efa5845d --- /dev/null +++ b/packages/server/src/services/Banking/BankAccounts/RefreshBankAccount.tsx @@ -0,0 +1,36 @@ +import { Inject, Service } from 'typedi'; +import { ServiceError } from '@/exceptions'; +import { PlaidClientWrapper } from '@/lib/Plaid'; +import HasTenancyService from '@/services/Tenancy/TenancyService'; +import { ERRORS } from './types'; + +@Service() +export class RefreshBankAccountService { + @Inject() + private tenancy: HasTenancyService; + + /** + * Asks Plaid to trigger syncing the given bank account. + * @param {number} tenantId + * @param {number} bankAccountId + * @returns {Promise} + */ + public async refreshBankAccount(tenantId: number, bankAccountId: number) { + const { Account } = this.tenancy.models(tenantId); + + const bankAccount = await Account.query() + .findById(bankAccountId) + .withGraphFetched('plaidItem') + .throwIfNotFound(); + + // Can't continue if the given account is not linked with Plaid item. + if (!bankAccount.plaidItem) { + throw new ServiceError(ERRORS.BANK_ACCOUNT_NOT_CONNECTED); + } + const plaidInstance = PlaidClientWrapper.getClient(); + + await plaidInstance.transactionsRefresh({ + access_token: bankAccount.plaidItem.plaidAccessToken, + }); + } +} diff --git a/packages/server/src/services/Banking/BankAccounts/events/DisconnectPlaidItemOnAccountDeleted.ts b/packages/server/src/services/Banking/BankAccounts/events/DisconnectPlaidItemOnAccountDeleted.ts new file mode 100644 index 000000000..89f02da57 --- /dev/null +++ b/packages/server/src/services/Banking/BankAccounts/events/DisconnectPlaidItemOnAccountDeleted.ts @@ -0,0 +1,63 @@ +import { Inject, Service } from 'typedi'; +import { IAccountEventDeletedPayload } from '@/interfaces'; +import { PlaidClientWrapper } from '@/lib/Plaid'; +import HasTenancyService from '@/services/Tenancy/TenancyService'; +import events from '@/subscribers/events'; + +@Service() +export class DisconnectPlaidItemOnAccountDeleted { + @Inject() + private tenancy: HasTenancyService; + + /** + * Constructor method. + */ + public attach(bus) { + bus.subscribe( + events.accounts.onDeleted, + this.handleDisconnectPlaidItemOnAccountDelete.bind(this) + ); + } + + /** + * Deletes Plaid item from the system and Plaid once the account deleted. + * @param {IAccountEventDeletedPayload} payload + * @returns {Promise} + */ + private async handleDisconnectPlaidItemOnAccountDelete({ + tenantId, + oldAccount, + trx, + }: IAccountEventDeletedPayload) { + const { PlaidItem, Account } = this.tenancy.models(tenantId); + + // Can't continue if the deleted account is not linked to Plaid item. + if (!oldAccount.plaidItemId) return; + + // Retrieves the Plaid item that associated to the deleted account. + const oldPlaidItem = await PlaidItem.query(trx).findOne( + 'plaidItemId', + oldAccount.plaidItemId + ); + // Unlink the Plaid item from all account before deleting it. + await Account.query(trx) + .where('plaidItemId', oldAccount.plaidItemId) + .patch({ + plaidAccountId: null, + plaidItemId: null, + }); + // Remove the Plaid item from the system. + await PlaidItem.query(trx) + .findOne('plaidItemId', oldAccount.plaidItemId) + .delete(); + + if (oldPlaidItem) { + const plaidInstance = PlaidClientWrapper.getClient(); + + // Remove the Plaid item. + await plaidInstance.itemRemove({ + access_token: oldPlaidItem.plaidAccessToken, + }); + } + } +} diff --git a/packages/server/src/services/Banking/BankAccounts/types.ts b/packages/server/src/services/Banking/BankAccounts/types.ts new file mode 100644 index 000000000..d3198cc5c --- /dev/null +++ b/packages/server/src/services/Banking/BankAccounts/types.ts @@ -0,0 +1,17 @@ +import { Knex } from 'knex'; + +export interface IBankAccountDisconnectingEventPayload { + tenantId: number; + bankAccountId: number; + trx: Knex.Transaction; +} + +export interface IBankAccountDisconnectedEventPayload { + tenantId: number; + bankAccountId: number; + trx: Knex.Transaction; +} + +export const ERRORS = { + BANK_ACCOUNT_NOT_CONNECTED: 'BANK_ACCOUNT_NOT_CONNECTED', +}; diff --git a/packages/server/src/services/Banking/Plaid/PlaidItem.ts b/packages/server/src/services/Banking/Plaid/PlaidItem.ts index 9e83202f9..138d523c6 100644 --- a/packages/server/src/services/Banking/Plaid/PlaidItem.ts +++ b/packages/server/src/services/Banking/Plaid/PlaidItem.ts @@ -28,7 +28,7 @@ export class PlaidItemService { const { PlaidItem } = this.tenancy.models(tenantId); const { publicToken, institutionId } = itemDTO; - const plaidInstance = new PlaidClientWrapper(); + const plaidInstance = PlaidClientWrapper.getClient(); // Exchange the public token for a private access token and store with the item. const response = await plaidInstance.itemPublicTokenExchange({ diff --git a/packages/server/src/services/Banking/Plaid/PlaidLinkToken.ts b/packages/server/src/services/Banking/Plaid/PlaidLinkToken.ts index 89203df72..003181505 100644 --- a/packages/server/src/services/Banking/Plaid/PlaidLinkToken.ts +++ b/packages/server/src/services/Banking/Plaid/PlaidLinkToken.ts @@ -26,7 +26,7 @@ export class PlaidLinkTokenService { webhook: config.plaid.linkWebhook, access_token: accessToken, }; - const plaidInstance = new PlaidClientWrapper(); + const plaidInstance = PlaidClientWrapper.getClient(); const createResponse = await plaidInstance.linkTokenCreate(linkTokenParams); return createResponse.data; diff --git a/packages/server/src/services/Banking/Plaid/PlaidSyncDB.ts b/packages/server/src/services/Banking/Plaid/PlaidSyncDB.ts index 40f30af17..aed2fc945 100644 --- a/packages/server/src/services/Banking/Plaid/PlaidSyncDB.ts +++ b/packages/server/src/services/Banking/Plaid/PlaidSyncDB.ts @@ -2,6 +2,11 @@ import * as R from 'ramda'; import { Inject, Service } from 'typedi'; import bluebird from 'bluebird'; import { entries, groupBy } from 'lodash'; +import { + AccountBase as PlaidAccountBase, + Item as PlaidItem, + Institution as PlaidInstitution, +} from 'plaid'; import { CreateAccount } from '@/services/Accounts/CreateAccount'; import { IAccountCreateDTO, @@ -53,6 +58,7 @@ export class PlaidSyncDb { trx?: Knex.Transaction ) { const { Account } = this.tenancy.models(tenantId); + const plaidAccount = await Account.query().findOne( 'plaidAccountId', createBankAccountDTO.plaidAccountId @@ -77,13 +83,15 @@ export class PlaidSyncDb { */ public async syncBankAccounts( tenantId: number, - plaidAccounts: PlaidAccount[], - institution: any, + plaidAccounts: PlaidAccountBase[], + institution: PlaidInstitution, + item: PlaidItem, trx?: Knex.Transaction ): Promise { - const transformToPlaidAccounts = - transformPlaidAccountToCreateAccount(institution); - + const transformToPlaidAccounts = transformPlaidAccountToCreateAccount( + item, + institution + ); const accountCreateDTOs = R.map(transformToPlaidAccounts)(plaidAccounts); await bluebird.map( diff --git a/packages/server/src/services/Banking/Plaid/PlaidUpdateTransactions.ts b/packages/server/src/services/Banking/Plaid/PlaidUpdateTransactions.ts index 0b8bb232e..3265cc2ba 100644 --- a/packages/server/src/services/Banking/Plaid/PlaidUpdateTransactions.ts +++ b/packages/server/src/services/Banking/Plaid/PlaidUpdateTransactions.ts @@ -53,7 +53,7 @@ export class PlaidUpdateTransactions { await this.fetchTransactionUpdates(tenantId, plaidItemId); const request = { access_token: accessToken }; - const plaidInstance = new PlaidClientWrapper(); + const plaidInstance = PlaidClientWrapper.getClient(); const { data: { accounts, item }, } = await plaidInstance.accountsGet(request); @@ -66,7 +66,13 @@ export class PlaidUpdateTransactions { country_codes: ['US', 'UK'], }); // Sync bank accounts. - await this.plaidSync.syncBankAccounts(tenantId, accounts, institution, trx); + await this.plaidSync.syncBankAccounts( + tenantId, + accounts, + institution, + item, + trx + ); // Sync bank account transactions. await this.plaidSync.syncAccountsTransactions( tenantId, @@ -141,7 +147,7 @@ export class PlaidUpdateTransactions { cursor: cursor, count: batchSize, }; - const plaidInstance = new PlaidClientWrapper(); + const plaidInstance = PlaidClientWrapper.getClient(); const response = await plaidInstance.transactionsSync(request); const data = response.data; // Add this page of results diff --git a/packages/server/src/services/Banking/Plaid/utils.ts b/packages/server/src/services/Banking/Plaid/utils.ts index 243f9449b..395b4346f 100644 --- a/packages/server/src/services/Banking/Plaid/utils.ts +++ b/packages/server/src/services/Banking/Plaid/utils.ts @@ -1,18 +1,28 @@ import * as R from 'ramda'; +import { + Item as PlaidItem, + Institution as PlaidInstitution, + AccountBase as PlaidAccount, +} from 'plaid'; import { CreateUncategorizedTransactionDTO, IAccountCreateDTO, - PlaidAccount, PlaidTransaction, } from '@/interfaces'; /** * Transformes the Plaid account to create cashflow account DTO. - * @param {PlaidAccount} plaidAccount + * @param {PlaidItem} item - + * @param {PlaidInstitution} institution - + * @param {PlaidAccount} plaidAccount - * @returns {IAccountCreateDTO} */ export const transformPlaidAccountToCreateAccount = R.curry( - (institution: any, plaidAccount: PlaidAccount): IAccountCreateDTO => { + ( + item: PlaidItem, + institution: PlaidInstitution, + plaidAccount: PlaidAccount + ): IAccountCreateDTO => { return { name: `${institution.name} - ${plaidAccount.name}`, code: '', @@ -20,9 +30,10 @@ export const transformPlaidAccountToCreateAccount = R.curry( currencyCode: plaidAccount.balances.iso_currency_code, accountType: 'cash', active: true, - plaidAccountId: plaidAccount.account_id, bankBalance: plaidAccount.balances.current, accountMask: plaidAccount.mask, + plaidAccountId: plaidAccount.account_id, + plaidItemId: item.item_id, }; } ); diff --git a/packages/server/src/services/Dashboard/DashboardService.ts b/packages/server/src/services/Dashboard/DashboardService.ts index 81c24a026..e8411b1bf 100644 --- a/packages/server/src/services/Dashboard/DashboardService.ts +++ b/packages/server/src/services/Dashboard/DashboardService.ts @@ -1,7 +1,8 @@ +import { Inject, Service } from 'typedi'; import { IFeatureAllItem, ISystemUser } from '@/interfaces'; import { FeaturesManager } from '@/services/Features/FeaturesManager'; import HasTenancyService from '@/services/Tenancy/TenancyService'; -import { Inject, Service } from 'typedi'; +import config from '@/config'; interface IRoleAbility { subject: string; @@ -11,15 +12,16 @@ interface IRoleAbility { interface IDashboardBootMeta { abilities: IRoleAbility[]; features: IFeatureAllItem[]; + isBigcapitalCloud: boolean; } @Service() export default class DashboardService { @Inject() - tenancy: HasTenancyService; + private tenancy: HasTenancyService; @Inject() - featuresManager: FeaturesManager; + private featuresManager: FeaturesManager; /** * Retrieve dashboard meta. @@ -39,6 +41,7 @@ export default class DashboardService { return { abilities, features, + isBigcapitalCloud: config.hostedOnBigcapitalCloud }; }; diff --git a/packages/server/src/services/Subscription/GetSubscriptionsTransformer.ts b/packages/server/src/services/Subscription/GetSubscriptionsTransformer.ts new file mode 100644 index 000000000..28e267d89 --- /dev/null +++ b/packages/server/src/services/Subscription/GetSubscriptionsTransformer.ts @@ -0,0 +1,168 @@ +import { Transformer } from '@/lib/Transformer/Transformer'; + +export class GetSubscriptionsTransformer extends Transformer { + /** + * Include these attributes to sale invoice object. + * @returns {Array} + */ + public includeAttributes = (): string[] => { + return [ + 'canceledAtFormatted', + 'endsAtFormatted', + '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 ends at date formatted. + * @param subscription + * @returns {string} + */ + public endsAtFormatted = (subscription) => { + return subscription.cancelsAt + ? this.formatDate(subscription.endsAt) + : 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); + return lemonSusbcription?.data?.attributes?.urls; + }; +} diff --git a/packages/server/src/services/Subscription/LemonCancelSubscription.ts b/packages/server/src/services/Subscription/LemonCancelSubscription.ts new file mode 100644 index 000000000..ef8441198 --- /dev/null +++ b/packages/server/src/services/Subscription/LemonCancelSubscription.ts @@ -0,0 +1,47 @@ +import { Inject, Service } from 'typedi'; +import { cancelSubscription } from '@lemonsqueezy/lemonsqueezy.js'; +import { configureLemonSqueezy } from './utils'; +import { PlanSubscription } from '@/system/models'; +import { ServiceError } from '@/exceptions'; +import { EventPublisher } from '@/lib/EventPublisher/EventPublisher'; +import events from '@/subscribers/events'; +import { ERRORS, IOrganizationSubscriptionCanceled } from './types'; + +@Service() +export class LemonCancelSubscription { + @Inject() + private eventPublisher: EventPublisher; + + /** + * Cancels the subscription of the given tenant. + * @param {number} tenantId + * @param {number} subscriptionId + * @returns {Promise} + */ + public async cancelSubscription(tenantId: number) { + configureLemonSqueezy(); + + const subscription = await PlanSubscription.query().findOne({ + tenantId, + slug: 'main', + }); + if (!subscription) { + throw new ServiceError(ERRORS.SUBSCRIPTION_ID_NOT_ASSOCIATED_TO_TENANT); + } + const lemonSusbcriptionId = subscription.lemonSubscriptionId; + const subscriptionId = subscription.id; + const cancelledSub = await cancelSubscription(lemonSusbcriptionId); + + if (cancelledSub.error) { + throw new Error(cancelledSub.error.message); + } + await PlanSubscription.query().findById(subscriptionId).patch({ + canceledAt: new Date(), + }); + // Triggers `onSubscriptionCanceled` event. + await this.eventPublisher.emitAsync( + events.subscription.onSubscriptionCanceled, + { tenantId, subscriptionId } as IOrganizationSubscriptionCanceled + ); + } +} diff --git a/packages/server/src/services/Subscription/LemonChangeSubscriptionPlan.ts b/packages/server/src/services/Subscription/LemonChangeSubscriptionPlan.ts new file mode 100644 index 000000000..9be404601 --- /dev/null +++ b/packages/server/src/services/Subscription/LemonChangeSubscriptionPlan.ts @@ -0,0 +1,47 @@ +import { Inject, Service } from 'typedi'; +import { updateSubscription } from '@lemonsqueezy/lemonsqueezy.js'; +import { EventPublisher } from '@/lib/EventPublisher/EventPublisher'; +import { ServiceError } from '@/exceptions'; +import { PlanSubscription } from '@/system/models'; +import { configureLemonSqueezy } from './utils'; +import events from '@/subscribers/events'; +import { IOrganizationSubscriptionChanged } from './types'; + +@Service() +export class LemonChangeSubscriptionPlan { + @Inject() + private eventPublisher: EventPublisher; + + /** + * Changes the given organization subscription plan. + * @param {number} tenantId - Tenant id. + * @param {number} newVariantId - New variant id. + * @returns {Promise} + */ + public async changeSubscriptionPlan(tenantId: number, newVariantId: number) { + configureLemonSqueezy(); + + const subscription = await PlanSubscription.query().findOne({ + tenantId, + slug: 'main', + }); + const lemonSubscriptionId = subscription.lemonSubscriptionId; + + // Send request to Lemon Squeezy to change the subscription. + const updatedSub = await updateSubscription(lemonSubscriptionId, { + variantId: newVariantId, + }); + if (updatedSub.error) { + throw new ServiceError('SOMETHING_WENT_WRONG'); + } + // Triggers `onSubscriptionPlanChanged` event. + await this.eventPublisher.emitAsync( + events.subscription.onSubscriptionPlanChanged, + { + tenantId, + lemonSubscriptionId, + newVariantId, + } as IOrganizationSubscriptionChanged + ); + } +} diff --git a/packages/server/src/services/Subscription/LemonResumeSubscription.ts b/packages/server/src/services/Subscription/LemonResumeSubscription.ts new file mode 100644 index 000000000..cd0ee0d2e --- /dev/null +++ b/packages/server/src/services/Subscription/LemonResumeSubscription.ts @@ -0,0 +1,48 @@ +import { Inject, Service } from 'typedi'; +import { EventPublisher } from '@/lib/EventPublisher/EventPublisher'; +import events from '@/subscribers/events'; +import { configureLemonSqueezy } from './utils'; +import { PlanSubscription } from '@/system/models'; +import { ServiceError } from '@/exceptions'; +import { ERRORS, IOrganizationSubscriptionResumed } from './types'; +import { updateSubscription } from '@lemonsqueezy/lemonsqueezy.js'; + +@Service() +export class LemonResumeSubscription { + @Inject() + private eventPublisher: EventPublisher; + + /** + * Resumes the main subscription of the given tenant. + * @param {number} tenantId - + * @returns {Promise} + */ + public async resumeSubscription(tenantId: number) { + configureLemonSqueezy(); + + const subscription = await PlanSubscription.query().findOne({ + tenantId, + slug: 'main', + }); + if (!subscription) { + throw new ServiceError(ERRORS.SUBSCRIPTION_ID_NOT_ASSOCIATED_TO_TENANT); + } + const subscriptionId = subscription.id; + const lemonSubscriptionId = subscription.lemonSubscriptionId; + const returnedSub = await updateSubscription(lemonSubscriptionId, { + cancelled: false, + }); + if (returnedSub.error) { + throw new ServiceError(''); + } + // Update the subscription of the organization. + await PlanSubscription.query().findById(subscriptionId).patch({ + canceledAt: null, + }); + // Triggers `onSubscriptionCanceled` event. + await this.eventPublisher.emitAsync( + events.subscription.onSubscriptionResumed, + { tenantId, subscriptionId } as IOrganizationSubscriptionResumed + ); + } +} diff --git a/packages/server/src/services/Subscription/SubscriptionApplication.ts b/packages/server/src/services/Subscription/SubscriptionApplication.ts new file mode 100644 index 000000000..c7f97569a --- /dev/null +++ b/packages/server/src/services/Subscription/SubscriptionApplication.ts @@ -0,0 +1,48 @@ +import { Inject, Service } from 'typedi'; +import { LemonCancelSubscription } from './LemonCancelSubscription'; +import { LemonChangeSubscriptionPlan } from './LemonChangeSubscriptionPlan'; +import { LemonResumeSubscription } from './LemonResumeSubscription'; + +@Service() +export class SubscriptionApplication { + @Inject() + private cancelSubscriptionService: LemonCancelSubscription; + + @Inject() + private resumeSubscriptionService: LemonResumeSubscription; + + @Inject() + private changeSubscriptionPlanService: LemonChangeSubscriptionPlan; + + /** + * Cancels the subscription of the given tenant. + * @param {number} tenantId + * @param {string} id + * @returns {Promise} + */ + public cancelSubscription(tenantId: number, id: string) { + return this.cancelSubscriptionService.cancelSubscription(tenantId, id); + } + + /** + * Resumes the subscription of the given tenant. + * @param {number} tenantId + * @returns {Promise} + */ + public resumeSubscription(tenantId: number) { + return this.resumeSubscriptionService.resumeSubscription(tenantId); + } + + /** + * Changes the given organization subscription plan. + * @param {number} tenantId + * @param {number} newVariantId + * @returns {Promise} + */ + public changeSubscriptionPlan(tenantId: number, newVariantId: number) { + return this.changeSubscriptionPlanService.changeSubscriptionPlan( + tenantId, + newVariantId + ); + } +} diff --git a/packages/server/src/services/Subscription/SubscriptionService.ts b/packages/server/src/services/Subscription/SubscriptionService.ts index 8e70c55d8..a61714af3 100644 --- a/packages/server/src/services/Subscription/SubscriptionService.ts +++ b/packages/server/src/services/Subscription/SubscriptionService.ts @@ -1,17 +1,50 @@ -import { Service } from 'typedi'; +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 { + @Inject() + private transformer: TransformerInjectable; + /** * Retrieve all subscription of the given tenant. * @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(), + { + lemonSubscriptions, + } ); - return subscriptions; } } diff --git a/packages/server/src/services/Subscription/types.ts b/packages/server/src/services/Subscription/types.ts new file mode 100644 index 000000000..c506b634f --- /dev/null +++ b/packages/server/src/services/Subscription/types.ts @@ -0,0 +1,20 @@ +export const ERRORS = { + SUBSCRIPTION_ID_NOT_ASSOCIATED_TO_TENANT: + 'SUBSCRIPTION_ID_NOT_ASSOCIATED_TO_TENANT', +}; + +export interface IOrganizationSubscriptionChanged { + tenantId: number; + lemonSubscriptionId: string; + newVariantId: number; +} + +export interface IOrganizationSubscriptionCanceled { + tenantId: number; + subscriptionId: string; +} + +export interface IOrganizationSubscriptionResumed { + tenantId: number; + subscriptionId: number; +} diff --git a/packages/server/src/subscribers/events.ts b/packages/server/src/subscribers/events.ts index e90aeb309..c72a2e7d3 100644 --- a/packages/server/src/subscribers/events.ts +++ b/packages/server/src/subscribers/events.ts @@ -41,9 +41,12 @@ export default { }, /** - * User subscription events. + * Organization subscription. */ subscription: { + onSubscriptionCanceled: 'onSubscriptionCanceled', + onSubscriptionResumed: 'onSubscriptionResumed', + onSubscriptionPlanChanged: 'onSubscriptionPlanChanged', onSubscribed: 'onOrganizationSubscribed', }, @@ -658,6 +661,11 @@ export default { onUnexcluded: 'onBankTransactionUnexcluded', }, + bankAccount: { + onDisconnecting: 'onBankAccountDisconnecting', + onDisconnected: 'onBankAccountDisconnected', + }, + // Import files. import: { onImportCommitted: 'onImportFileCommitted', diff --git a/packages/server/src/system/migrations/20240727094214_add_lemon_subscription_id_to_subscriptions_table.js b/packages/server/src/system/migrations/20240727094214_add_lemon_subscription_id_to_subscriptions_table.js new file mode 100644 index 000000000..29907345a --- /dev/null +++ b/packages/server/src/system/migrations/20240727094214_add_lemon_subscription_id_to_subscriptions_table.js @@ -0,0 +1,11 @@ +exports.up = function (knex) { + return knex.schema.table('subscription_plan_subscriptions', (table) => { + table.string('lemon_subscription_id').nullable(); + }); +}; + +exports.down = function (knex) { + return knex.schema.table('subscription_plan_subscriptions', (table) => { + table.dropColumn('lemon_subscription_id'); + }); +}; diff --git a/packages/server/src/system/migrations/20240728123419_add_trial_columns_to_subscription_table.js b/packages/server/src/system/migrations/20240728123419_add_trial_columns_to_subscription_table.js new file mode 100644 index 000000000..b8addd516 --- /dev/null +++ b/packages/server/src/system/migrations/20240728123419_add_trial_columns_to_subscription_table.js @@ -0,0 +1,13 @@ +exports.up = function (knex) { + return knex.schema.table('subscription_plan_subscriptions', (table) => { + table.dateTime('trial_ends_at').nullable(); + table.dropColumn('cancels_at'); + }); +}; + +exports.down = function (knex) { + return knex.schema.table('subscription_plan_subscriptions', (table) => { + table.dropColumn('trial_ends_at').nullable(); + table.dateTime('cancels_at').nullable(); + }); +}; diff --git a/packages/server/src/system/models/Subscriptions/PlanSubscription.ts b/packages/server/src/system/models/Subscriptions/PlanSubscription.ts index d77ee6418..d7c988d8f 100644 --- a/packages/server/src/system/models/Subscriptions/PlanSubscription.ts +++ b/packages/server/src/system/models/Subscriptions/PlanSubscription.ts @@ -4,6 +4,15 @@ import moment from 'moment'; import SubscriptionPeriod from '@/services/Subscription/SubscriptionPeriod'; export default class PlanSubscription extends mixin(SystemModel) { + public lemonSubscriptionId: number; + + public endsAt: Date; + public startsAt: Date; + + public canceledAt: Date; + + public trialEndsAt: Date; + /** * Table name. */ @@ -22,7 +31,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']; } /** @@ -38,7 +47,7 @@ export default class PlanSubscription extends mixin(SystemModel) { builder.where('trial_ends_at', '>', now); }, - inactiveSubscriptions() { + inactiveSubscriptions(builder) { builder.modify('endedTrial'); builder.modify('endedPeriod'); }, @@ -98,35 +107,65 @@ export default class PlanSubscription extends mixin(SystemModel) { } /** - * Check if subscription is active. + * Check if the subscription is active. * @return {Boolean} */ - active() { - return !this.ended() || this.onTrial(); + public active() { + return this.onTrial() || !this.ended(); } /** - * Check if subscription is inactive. + * Check if the 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; + } + + /** + * Retrieves the subscription status. + * @returns {string} + */ + public status() { + return this.canceled() + ? 'canceled' + : this.onTrial() + ? 'on_trial' + : this.active() + ? 'active' + : 'inactive'; } /** @@ -141,7 +180,7 @@ export default class PlanSubscription extends mixin(SystemModel) { const period = new SubscriptionPeriod( invoiceInterval, invoicePeriod, - start, + start ); const startsAt = period.getStartDate(); @@ -157,7 +196,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 }); } diff --git a/packages/webapp/src/components/AppToaster/index.tsx b/packages/webapp/src/components/AppToaster/index.tsx index 57dc43d2f..85cdb9b6f 100644 --- a/packages/webapp/src/components/AppToaster/index.tsx +++ b/packages/webapp/src/components/AppToaster/index.tsx @@ -2,6 +2,6 @@ import { Position, Toaster, Intent } from '@blueprintjs/core'; export const AppToaster = Toaster.create({ - position: Position.RIGHT_BOTTOM, + position: Position.TOP, intent: Intent.WARNING, }); diff --git a/packages/webapp/src/components/Dashboard/TopbarUser.tsx b/packages/webapp/src/components/Dashboard/TopbarUser.tsx index 889bd3426..3cf8bb03c 100644 --- a/packages/webapp/src/components/Dashboard/TopbarUser.tsx +++ b/packages/webapp/src/components/Dashboard/TopbarUser.tsx @@ -15,7 +15,7 @@ import { useAuthActions } from '@/hooks/state'; import withDialogActions from '@/containers/Dialog/withDialogActions'; -import { useAuthenticatedAccount } from '@/hooks/query'; +import { useAuthenticatedAccount, useDashboardMeta } from '@/hooks/query'; import { firstLettersArgs, compose } from '@/utils'; /** @@ -31,6 +31,9 @@ function DashboardTopbarUser({ // Retrieve authenticated user information. const { data: user } = useAuthenticatedAccount(); + const { data: dashboardMeta } = useDashboardMeta({ + keepPreviousData: true, + }); const onClickLogout = () => { setLogout(); }; @@ -58,6 +61,12 @@ function DashboardTopbarUser({ } /> + {dashboardMeta.is_bigcapital_cloud && ( + history.push('/billing')} + /> + )} } onClick={onKeyboardShortcut} @@ -79,6 +88,4 @@ function DashboardTopbarUser({ ); } -export default compose( - withDialogActions, -)(DashboardTopbarUser); +export default compose(withDialogActions)(DashboardTopbarUser); diff --git a/packages/webapp/src/components/DrawersContainer.tsx b/packages/webapp/src/components/DrawersContainer.tsx index af3c97525..cf9451d1c 100644 --- a/packages/webapp/src/components/DrawersContainer.tsx +++ b/packages/webapp/src/components/DrawersContainer.tsx @@ -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() { /> + ); } diff --git a/packages/webapp/src/constants/drawers.ts b/packages/webapp/src/constants/drawers.ts index 2dc3e92e9..c4e477352 100644 --- a/packages/webapp/src/constants/drawers.ts +++ b/packages/webapp/src/constants/drawers.ts @@ -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' } diff --git a/packages/webapp/src/containers/AlertsContainer/registered.tsx b/packages/webapp/src/containers/AlertsContainer/registered.tsx index d1257cc1b..40724c66c 100644 --- a/packages/webapp/src/containers/AlertsContainer/registered.tsx +++ b/packages/webapp/src/containers/AlertsContainer/registered.tsx @@ -27,6 +27,7 @@ import ProjectAlerts from '@/containers/Projects/containers/ProjectAlerts'; import TaxRatesAlerts from '@/containers/TaxRates/alerts'; import { CashflowAlerts } from '../CashFlow/CashflowAlerts'; import { BankRulesAlerts } from '../Banking/Rules/RulesList/BankRulesAlerts'; +import { SubscriptionAlerts } from '../Subscriptions/alerts/alerts'; export default [ ...AccountsAlerts, @@ -56,5 +57,6 @@ export default [ ...ProjectAlerts, ...TaxRatesAlerts, ...CashflowAlerts, - ...BankRulesAlerts + ...BankRulesAlerts, + ...SubscriptionAlerts ]; diff --git a/packages/webapp/src/containers/CashFlow/AccountTransactions/AccountTransactionsActionsBar.tsx b/packages/webapp/src/containers/CashFlow/AccountTransactions/AccountTransactionsActionsBar.tsx index 2228452af..13160984b 100644 --- a/packages/webapp/src/containers/CashFlow/AccountTransactions/AccountTransactionsActionsBar.tsx +++ b/packages/webapp/src/containers/CashFlow/AccountTransactions/AccountTransactionsActionsBar.tsx @@ -14,14 +14,17 @@ import { Intent, Switch, Tooltip, + MenuDivider, } from '@blueprintjs/core'; import { useHistory } from 'react-router-dom'; +import { isEmpty } from 'lodash'; import { Icon, DashboardActionsBar, DashboardRowsHeightButton, FormattedMessage as T, AppToaster, + If, } from '@/components'; import { CashFlowMenuItems } from './utils'; @@ -37,13 +40,14 @@ import withSettings from '@/containers/Settings/withSettings'; import withSettingsActions from '@/containers/Settings/withSettingsActions'; import { compose } from '@/utils'; -import { withBanking } from '../withBanking'; -import { isEmpty } from 'lodash'; import { + useDisconnectBankAccount, + useUpdateBankAccount, useExcludeUncategorizedTransactions, useUnexcludeUncategorizedTransactions, } from '@/hooks/query/bank-rules'; import { withBankingActions } from '../withBankingActions'; +import { withBanking } from '../withBanking'; function AccountTransactionsActionsBar({ // #withDialogActions @@ -63,15 +67,21 @@ function AccountTransactionsActionsBar({ enableMultipleCategorization, }) { const history = useHistory(); - const { accountId } = useAccountTransactionsContext(); + const { accountId, currentAccount } = useAccountTransactionsContext(); // Refresh cashflow infinity transactions hook. const { refresh } = useRefreshCashflowTransactionsInfinity(); + const { mutateAsync: disconnectBankAccount } = useDisconnectBankAccount(); + const { mutateAsync: updateBankAccount } = useUpdateBankAccount(); + // Retrieves the money in/out buttons options. const addMoneyInOptions = useMemo(() => getAddMoneyInOptions(), []); const addMoneyOutOptions = useMemo(() => getAddMoneyOutOptions(), []); + const isFeedsActive = !!currentAccount.is_feeds_active; + const isSyncingOwner = currentAccount.is_syncing_owner; + // Handle table row size change. const handleTableRowSizeChange = (size) => { addSetting('cashflowTransactions', 'tableSize', size); @@ -100,6 +110,39 @@ function AccountTransactionsActionsBar({ const handleBankRulesClick = () => { history.push(`/bank-rules?accountId=${accountId}`); }; + + // Handles the bank account disconnect click. + const handleDisconnectClick = () => { + disconnectBankAccount({ bankAccountId: accountId }) + .then(() => { + AppToaster.show({ + message: 'The bank account has been disconnected.', + intent: Intent.SUCCESS, + }); + }) + .catch((error) => { + AppToaster.show({ + message: 'Something went wrong.', + intent: Intent.DANGER, + }); + }); + }; + // handles the bank update button click. + const handleBankUpdateClick = () => { + updateBankAccount({ bankAccountId: accountId }) + .then(() => { + AppToaster.show({ + message: 'The transactions of the bank account has been updated.', + intent: Intent.SUCCESS, + }); + }) + .catch(() => { + AppToaster.show({ + message: 'Something went wrong.', + intent: Intent.DANGER, + }); + }); + }; // Handle the refresh button click. const handleRefreshBtnClick = () => { refresh(); @@ -200,6 +243,24 @@ function AccountTransactionsActionsBar({ /> + + + + + {mainSubscription.canceled && ( + + )} + {!mainSubscription.canceled && ( + + )} + + + + + + + {mainSubscription.planPriceFormatted} + + + {mainSubscription.planPeriod && ( + + {mainSubscription.planPeriod === 'month' + ? 'mo' + : mainSubscription.planPeriod === 'year' + ? 'yearly' + : ''} + + )} + + + + {mainSubscription.canceled && ( + + )} + + + + ); +} + +export const Subscription = R.compose( + withAlertActions, + withDrawerActions, +)(SubscriptionRoot); + +function SubscriptionStatusText({ subscription }) { + const text = getSubscriptionStatusText(subscription); + + if (!text) return null; + + return {text}; +} diff --git a/packages/webapp/src/containers/Subscriptions/BillingTab.tsx b/packages/webapp/src/containers/Subscriptions/BillingTab.tsx deleted file mode 100644 index e4235d14c..000000000 --- a/packages/webapp/src/containers/Subscriptions/BillingTab.tsx +++ /dev/null @@ -1,7 +0,0 @@ -// @ts-nocheck -import React from 'react'; -import BillingPlansForm from './BillingPlansForm'; - -export default function BillingTab() { - return (); -} \ No newline at end of file diff --git a/packages/webapp/src/containers/Subscriptions/LicenseTab.tsx b/packages/webapp/src/containers/Subscriptions/LicenseTab.tsx deleted file mode 100644 index e069ffbf9..000000000 --- a/packages/webapp/src/containers/Subscriptions/LicenseTab.tsx +++ /dev/null @@ -1,42 +0,0 @@ -// @ts-nocheck -import React from 'react'; -import { Intent, Button } from '@blueprintjs/core'; -import { useFormikContext } from 'formik'; -import { FormattedMessage as T } from '@/components'; -import { compose } from '@/utils'; - -import withDialogActions from '@/containers/Dialog/withDialogActions'; - -/** - * Payment via license code tab. - */ -function LicenseTab({ openDialog }) { - const { submitForm, values } = useFormikContext(); - - const handleSubmitBtnClick = () => { - submitForm().then(() => { - openDialog('payment-via-voucher', { ...values }); - }); - }; - - return ( -
-

- -

-

- -

- - -
- ); -} - -export default compose(withDialogActions)(LicenseTab); diff --git a/packages/webapp/src/containers/Subscriptions/SubscriptionTabs.tsx b/packages/webapp/src/containers/Subscriptions/SubscriptionTabs.tsx deleted file mode 100644 index 3f2ec6a05..000000000 --- a/packages/webapp/src/containers/Subscriptions/SubscriptionTabs.tsx +++ /dev/null @@ -1,47 +0,0 @@ -// @ts-nocheck -import React from 'react'; -import intl from 'react-intl-universal'; -import { Tabs, Tab } from '@blueprintjs/core'; -import BillingTab from './BillingTab'; -import LicenseTab from './LicenseTab'; - -/** - * Master billing tabs. - */ -export const MasterBillingTabs = ({ formik }) => { - return ( -
- - } - /> - - -
- ); -}; - -/** - * Payment methods tabs. - */ -export const PaymentMethodTabs = ({ formik }) => { - return ( -
- - } - /> - - - -
- ); -}; diff --git a/packages/webapp/src/containers/Subscriptions/_utils.ts b/packages/webapp/src/containers/Subscriptions/_utils.ts new file mode 100644 index 000000000..fba7a568e --- /dev/null +++ b/packages/webapp/src/containers/Subscriptions/_utils.ts @@ -0,0 +1,17 @@ +// @ts-nocheck +export const getSubscriptionStatusText = (subscription) => { + if (subscription.status === 'on_trial') { + return subscription.onTrial + ? `Trials ends in ${subscription.trialEndsAtFormatted}` + : `Trial ended ${subscription.trialEndsAtFormatted}`; + } else if (subscription.status === 'active') { + return subscription.endsAtFormatted + ? `Renews in ${subscription.endsAtFormatted}` + : 'Lifetime subscription'; + } else if (subscription.status === 'canceled') { + return subscription.ended + ? `Expires ${subscription.endsAtFormatted}` + : `Expired ${subscription.endsAtFormatted}`; + } + return ''; +}; diff --git a/packages/webapp/src/containers/Subscriptions/alerts/CancelMainSubscriptionAlert.tsx b/packages/webapp/src/containers/Subscriptions/alerts/CancelMainSubscriptionAlert.tsx new file mode 100644 index 000000000..ca0c6a905 --- /dev/null +++ b/packages/webapp/src/containers/Subscriptions/alerts/CancelMainSubscriptionAlert.tsx @@ -0,0 +1,81 @@ +// @ts-nocheck +import React from 'react'; +import * as R from 'ramda'; +import { Intent, Alert } from '@blueprintjs/core'; +import { AppToaster, FormattedMessage as T } from '@/components'; + +import withAlertStoreConnect from '@/containers/Alert/withAlertStoreConnect'; +import withAlertActions from '@/containers/Alert/withAlertActions'; + +import { useCancelMainSubscription } from '@/hooks/query/subscription'; + +/** + * Cancel Unlocking partial transactions alerts. + */ +function CancelMainSubscriptionAlert({ + name, + + // #withAlertStoreConnect + isOpen, + payload: { module }, + + // #withAlertActions + closeAlert, +}) { + const { mutateAsync: cancelSubscription, isLoading } = + useCancelMainSubscription(); + + // Handle cancel. + const handleCancel = () => { + closeAlert(name); + }; + // Handle confirm. + const handleConfirm = () => { + const values = { + module: module, + }; + cancelSubscription() + .then(() => { + AppToaster.show({ + message: 'The subscription has been canceled.', + intent: Intent.SUCCESS, + }); + }) + .catch( + ({ + response: { + data: { errors }, + }, + }) => {}, + ) + .finally(() => { + closeAlert(name); + }); + }; + + return ( + } + confirmButtonText={'Cancel Subscription'} + intent={Intent.DANGER} + isOpen={isOpen} + onCancel={handleCancel} + onConfirm={handleConfirm} + loading={isLoading} + > +

+ The subscription for this organization will end. +

+ +

+ It will no longer be accessible to you or any other users. Make sure any + data has already been exported. +

+
+ ); +} + +export default R.compose( + withAlertStoreConnect(), + withAlertActions, +)(CancelMainSubscriptionAlert); diff --git a/packages/webapp/src/containers/Subscriptions/alerts/ResumeMainSubscriptionAlert.tsx b/packages/webapp/src/containers/Subscriptions/alerts/ResumeMainSubscriptionAlert.tsx new file mode 100644 index 000000000..c8d8d0734 --- /dev/null +++ b/packages/webapp/src/containers/Subscriptions/alerts/ResumeMainSubscriptionAlert.tsx @@ -0,0 +1,79 @@ +// @ts-nocheck +import React from 'react'; +import * as R from 'ramda'; +import { Intent, Alert } from '@blueprintjs/core'; +import { AppToaster, FormattedMessage as T } from '@/components'; + +import withAlertStoreConnect from '@/containers/Alert/withAlertStoreConnect'; +import withAlertActions from '@/containers/Alert/withAlertActions'; +import { useResumeMainSubscription } from '@/hooks/query/subscription'; + +/** + * Resume Unlocking partial transactions alerts. + */ +function ResumeMainSubscriptionAlert({ + name, + + // #withAlertStoreConnect + isOpen, + payload: { module }, + + // #withAlertActions + closeAlert, +}) { + const { mutateAsync: resumeSubscription, isLoading } = + useResumeMainSubscription(); + + // Handle cancel. + const handleCancel = () => { + closeAlert(name); + }; + // Handle confirm. + const handleConfirm = () => { + const values = { + module: module, + }; + resumeSubscription() + .then(() => { + AppToaster.show({ + message: 'The subscription has been resumed.', + intent: Intent.SUCCESS, + }); + }) + .catch( + ({ + response: { + data: { errors }, + }, + }) => {}, + ) + .finally(() => { + closeAlert(name); + }); + }; + + return ( + } + confirmButtonText={'Resume Subscription'} + intent={Intent.DANGER} + isOpen={isOpen} + onCancel={handleCancel} + onConfirm={handleConfirm} + loading={isLoading} + > +

+ The subscription for this organization will resume. + +

+ Are you sure want to resume the subscription of this organization? +

+

+
+ ); +} + +export default R.compose( + withAlertStoreConnect(), + withAlertActions, +)(ResumeMainSubscriptionAlert); diff --git a/packages/webapp/src/containers/Subscriptions/alerts/alerts.ts b/packages/webapp/src/containers/Subscriptions/alerts/alerts.ts new file mode 100644 index 000000000..94939a56a --- /dev/null +++ b/packages/webapp/src/containers/Subscriptions/alerts/alerts.ts @@ -0,0 +1,23 @@ +// @ts-nocheck +import React from 'react'; + +const CancelMainSubscriptionAlert = React.lazy( + () => import('./CancelMainSubscriptionAlert'), +); +const ResumeMainSubscriptionAlert = React.lazy( + () => import('./ResumeMainSubscriptionAlert'), +); + +/** + * Subscription alert. + */ +export const SubscriptionAlerts = [ + { + name: 'cancel-main-subscription', + component: CancelMainSubscriptionAlert, + }, + { + name: 'resume-main-subscription', + component: ResumeMainSubscriptionAlert, + }, +]; diff --git a/packages/webapp/src/containers/Setup/SetupSubscription/SubscriptionPlan.tsx b/packages/webapp/src/containers/Subscriptions/component/SubscriptionPlan.tsx similarity index 69% rename from packages/webapp/src/containers/Setup/SetupSubscription/SubscriptionPlan.tsx rename to packages/webapp/src/containers/Subscriptions/component/SubscriptionPlan.tsx index 4ebb88d5f..e23780e14 100644 --- a/packages/webapp/src/containers/Setup/SetupSubscription/SubscriptionPlan.tsx +++ b/packages/webapp/src/containers/Subscriptions/component/SubscriptionPlan.tsx @@ -1,14 +1,12 @@ // @ts-nocheck -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'; +import { ButtonProps } from '@blueprintjs/core'; interface SubscriptionPricingFeature { text: string; @@ -27,8 +25,8 @@ interface SubscriptionPricingProps { monthlyPriceLabel: string; annuallyPrice: string; annuallyPriceLabel: string; - monthlyVariantId?: string; - annuallyVariantId?: string; + onSubscribe?: (variantId: number) => void; + subscribeButtonProps?: Optional; } interface SubscriptionPricingCombinedProps @@ -44,32 +42,14 @@ function SubscriptionPlanRoot({ monthlyPriceLabel, annuallyPrice, annuallyPriceLabel, - monthlyVariantId, - annuallyVariantId, + onSubscribe, + subscribeButtonProps, // #withPlans plansPeriod, }: SubscriptionPricingCombinedProps) { - const { mutateAsync: getLemonCheckout, isLoading } = - useGetLemonSqueezyCheckout(); - const handleClick = () => { - const variantId = - SubscriptionPlansPeriod.Monthly === plansPeriod - ? 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(); }; return ( @@ -85,7 +65,7 @@ function SubscriptionPlanRoot({ subPrice={annuallyPriceLabel} /> )} - + Subscribe diff --git a/packages/webapp/src/containers/Subscriptions/component/withSubscriptionPlanMapper.tsx b/packages/webapp/src/containers/Subscriptions/component/withSubscriptionPlanMapper.tsx new file mode 100644 index 000000000..b0cd58938 --- /dev/null +++ b/packages/webapp/src/containers/Subscriptions/component/withSubscriptionPlanMapper.tsx @@ -0,0 +1,52 @@ +// @ts-nocheck +import React from 'react'; + + +interface WithSubscriptionPlanProps { + plan: any; + onSubscribe?: (variantId: number) => void; +} + +interface MappedSubscriptionPlanProps { + slug: string; + label: string; + description: string; + features: any[]; + featured: boolean; + monthlyPrice: string; + monthlyPriceLabel: string; + annuallyPrice: string; + annuallyPriceLabel: string; + monthlyVariantId: number; + annuallyVariantId: number; + onSubscribe?: (variantId: number) => void; +} + +export const withSubscriptionPlanMapper = < + P extends MappedSubscriptionPlanProps, +>( + WrappedComponent: React.ComponentType

, +) => { + return function WithSubscriptionPlanMapper( + props: WithSubscriptionPlanProps & + Omit, + ) { + const { plan, onSubscribe, ...restProps } = props; + + const mappedProps: MappedSubscriptionPlanProps = { + 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, + onSubscribe, + }; + return ; + }; +}; diff --git a/packages/webapp/src/containers/Subscriptions/drawers/ChangeSubscriptionPlanDrawer/ChangeSubscriptionPlanContent.tsx b/packages/webapp/src/containers/Subscriptions/drawers/ChangeSubscriptionPlanDrawer/ChangeSubscriptionPlanContent.tsx new file mode 100644 index 000000000..85d0361e8 --- /dev/null +++ b/packages/webapp/src/containers/Subscriptions/drawers/ChangeSubscriptionPlanDrawer/ChangeSubscriptionPlanContent.tsx @@ -0,0 +1,29 @@ +// @ts-nocheck +import * as R from 'ramda'; +import { Callout, Classes } from '@blueprintjs/core'; +import { Box } from '@/components'; +import { SubscriptionPlansPeriodSwitcher } from '@/containers/Setup/SetupSubscription/SubscriptionPlansPeriodSwitcher'; +import { ChangeSubscriptionPlans } from './ChangeSubscriptionPlans'; + +export default function ChangeSubscriptionPlanContent() { + return ( + + + + 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. + + + + + + + ); +} diff --git a/packages/webapp/src/containers/Subscriptions/drawers/ChangeSubscriptionPlanDrawer/ChangeSubscriptionPlanDrawer.tsx b/packages/webapp/src/containers/Subscriptions/drawers/ChangeSubscriptionPlanDrawer/ChangeSubscriptionPlanDrawer.tsx new file mode 100644 index 000000000..a8aaf60ad --- /dev/null +++ b/packages/webapp/src/containers/Subscriptions/drawers/ChangeSubscriptionPlanDrawer/ChangeSubscriptionPlanDrawer.tsx @@ -0,0 +1,39 @@ +// @ts-nocheck +import React, { lazy } from 'react'; +import * as R from 'ramda'; +import { Drawer, DrawerHeaderContent, DrawerSuspense } from '@/components'; +import withDrawers from '@/containers/Drawer/withDrawers'; +import { Position } from '@blueprintjs/core'; +import { DRAWERS } from '@/constants/drawers'; + +const ChangeSubscriptionPlanContent = lazy( + () => import('./ChangeSubscriptionPlanContent'), +); + +/** + * Account drawer. + */ +function ChangeSubscriptionPlanDrawer({ + name, + // #withDrawer + isOpen, +}) { + return ( + + + + + + + ); +} + +export default R.compose(withDrawers())(ChangeSubscriptionPlanDrawer); diff --git a/packages/webapp/src/containers/Subscriptions/drawers/ChangeSubscriptionPlanDrawer/ChangeSubscriptionPlans.tsx b/packages/webapp/src/containers/Subscriptions/drawers/ChangeSubscriptionPlanDrawer/ChangeSubscriptionPlans.tsx new file mode 100644 index 000000000..1edc6d7cf --- /dev/null +++ b/packages/webapp/src/containers/Subscriptions/drawers/ChangeSubscriptionPlanDrawer/ChangeSubscriptionPlans.tsx @@ -0,0 +1,72 @@ +// @ts-nocheck +import * as R from 'ramda'; +import { Intent } from '@blueprintjs/core'; +import { AppToaster, Group } from '@/components'; +import { SubscriptionPlan } from '../../component/SubscriptionPlan'; +import { SubscriptionPlansPeriod } from '@/store/plans/plans.reducer'; +import { useSubscriptionPlans } from '@/hooks/constants/useSubscriptionPlans'; +import { useChangeSubscriptionPlan } from '@/hooks/query/subscription'; +import { withSubscriptionPlanMapper } from '../../component/withSubscriptionPlanMapper'; +import { withPlans } from '../../withPlans'; +import withDrawerActions from '@/containers/Drawer/withDrawerActions'; +import { DRAWERS } from '@/constants/drawers'; + +export function ChangeSubscriptionPlans() { + const subscriptionPlans = useSubscriptionPlans(); + + return ( + + {subscriptionPlans.map((plan, index) => ( + + ))} + + ); +} + +export const SubscriptionPlanMapped = R.compose( + withSubscriptionPlanMapper, + withDrawerActions, + withPlans(({ plansPeriod }) => ({ plansPeriod })), +)( + ({ + openDrawer, + closeDrawer, + monthlyVariantId, + annuallyVariantId, + plansPeriod, + ...props + }) => { + const { mutateAsync: changeSubscriptionPlan, isLoading } = + useChangeSubscriptionPlan(); + + // Handles the subscribe button click. + const handleSubscribe = () => { + const variantId = + plansPeriod === SubscriptionPlansPeriod.Monthly + ? monthlyVariantId + : annuallyVariantId; + + changeSubscriptionPlan({ variant_id: variantId }) + .then(() => { + closeDrawer(DRAWERS.CHANGE_SUBSCARIPTION_PLAN); + AppToaster.show({ + message: 'The subscription plan has been changed.', + intent: Intent.SUCCESS, + }); + }) + .catch((error) => { + AppToaster.show({ + message: 'Something went wrong.', + intent: Intent.DANGER, + }); + }); + }; + return ( + + ); + }, +); diff --git a/packages/webapp/src/containers/Subscriptions/drawers/ChangeSubscriptionPlanDrawer/index.ts b/packages/webapp/src/containers/Subscriptions/drawers/ChangeSubscriptionPlanDrawer/index.ts new file mode 100644 index 000000000..4af1d02b2 --- /dev/null +++ b/packages/webapp/src/containers/Subscriptions/drawers/ChangeSubscriptionPlanDrawer/index.ts @@ -0,0 +1 @@ +export * as default from './ChangeSubscriptionPlanDrawer'; \ No newline at end of file diff --git a/packages/webapp/src/containers/Subscriptions/utils.tsx b/packages/webapp/src/containers/Subscriptions/utils.tsx deleted file mode 100644 index 041234fc9..000000000 --- a/packages/webapp/src/containers/Subscriptions/utils.tsx +++ /dev/null @@ -1,9 +0,0 @@ -// @ts-nocheck -import * as Yup from 'yup'; - -export const getBillingFormValidationSchema = () => - Yup.object().shape({ - plan_slug: Yup.string().required(), - period: Yup.string().required(), - license_code: Yup.string().trim(), - }); diff --git a/packages/webapp/src/hooks/constants/useSubscriptionPlans.tsx b/packages/webapp/src/hooks/constants/useSubscriptionPlans.tsx new file mode 100644 index 000000000..3beb2a34b --- /dev/null +++ b/packages/webapp/src/hooks/constants/useSubscriptionPlans.tsx @@ -0,0 +1,5 @@ +import { SubscriptionPlans } from '@/constants/subscriptionModels'; + +export const useSubscriptionPlans = () => { + return SubscriptionPlans; +}; diff --git a/packages/webapp/src/hooks/query/bank-rules.ts b/packages/webapp/src/hooks/query/bank-rules.ts index e6098a37e..1774d2ea8 100644 --- a/packages/webapp/src/hooks/query/bank-rules.ts +++ b/packages/webapp/src/hooks/query/bank-rules.ts @@ -61,6 +61,76 @@ export function useCreateBankRule( ); } +interface DisconnectBankAccountRes {} +interface DisconnectBankAccountValues { + bankAccountId: number; +} + +/** + * Disconnects the given bank account. + * @param {UseMutationOptions} options + * @returns {UseMutationResult} + */ +export function useDisconnectBankAccount( + options?: UseMutationOptions< + DisconnectBankAccountRes, + Error, + DisconnectBankAccountValues + >, +): UseMutationResult< + DisconnectBankAccountRes, + Error, + DisconnectBankAccountValues +> { + const queryClient = useQueryClient(); + const apiRequest = useApiRequest(); + + return useMutation< + DisconnectBankAccountRes, + Error, + DisconnectBankAccountValues + >( + ({ bankAccountId }) => + apiRequest.post(`/banking/bank_accounts/${bankAccountId}/disconnect`), + { + ...options, + onSuccess: (res, values) => { + queryClient.invalidateQueries([t.ACCOUNT, values.bankAccountId]); + }, + }, + ); +} + +interface UpdateBankAccountRes {} +interface UpdateBankAccountValues { + bankAccountId: number; +} + +/** + * Update the bank transactions of the bank account. + * @param {UseMutationOptions} + * @returns {UseMutationResult} + */ +export function useUpdateBankAccount( + options?: UseMutationOptions< + UpdateBankAccountRes, + Error, + UpdateBankAccountValues + >, +): UseMutationResult { + const queryClient = useQueryClient(); + const apiRequest = useApiRequest(); + + return useMutation( + ({ bankAccountId }) => + apiRequest.post(`/banking/bank_accounts/${bankAccountId}/update`), + { + ...options, + onSuccess: () => {}, + }, + ); +} + interface EditBankRuleValues { id: number; value: any; diff --git a/packages/webapp/src/hooks/query/subscription.tsx b/packages/webapp/src/hooks/query/subscription.tsx new file mode 100644 index 000000000..d3d0ebc4e --- /dev/null +++ b/packages/webapp/src/hooks/query/subscription.tsx @@ -0,0 +1,196 @@ +// @ts-nocheck +import { + useMutation, + UseMutationOptions, + UseMutationResult, + useQuery, + useQueryClient, + UseQueryOptions, + UseQueryResult, +} from 'react-query'; +import useApiRequest from '../useRequest'; +import { transformToCamelCase } from '@/utils'; + +const QueryKeys = { + Subscriptions: 'Subscriptions', +}; + +interface CancelMainSubscriptionValues {} +interface CancelMainSubscriptionResponse {} + +/** + * Cancels the main subscription of the current organization. + * @param {UseMutationOptions} options - + * @returns {UseMutationResult}TCHES + */ +export function useCancelMainSubscription( + options?: UseMutationOptions< + CancelMainSubscriptionValues, + Error, + CancelMainSubscriptionResponse + >, +): UseMutationResult< + CancelMainSubscriptionValues, + Error, + CancelMainSubscriptionResponse +> { + const queryClient = useQueryClient(); + const apiRequest = useApiRequest(); + + return useMutation< + CancelMainSubscriptionValues, + Error, + CancelMainSubscriptionResponse + >( + (values) => + apiRequest.post(`/subscription/cancel`, values).then((res) => res.data), + { + onSuccess: () => { + queryClient.invalidateQueries(QueryKeys.Subscriptions); + }, + ...options, + }, + ); +} + +interface ResumeMainSubscriptionValues {} +interface ResumeMainSubscriptionResponse {} + +/** + * Resumes the main subscription of the current organization. + * @param {UseMutationOptions} options - + * @returns {UseMutationResult}TCHES + */ +export function useResumeMainSubscription( + options?: UseMutationOptions< + ResumeMainSubscriptionValues, + Error, + ResumeMainSubscriptionResponse + >, +): UseMutationResult< + ResumeMainSubscriptionValues, + Error, + ResumeMainSubscriptionResponse +> { + const queryClient = useQueryClient(); + const apiRequest = useApiRequest(); + + return useMutation< + ResumeMainSubscriptionValues, + Error, + ResumeMainSubscriptionResponse + >( + (values) => + apiRequest.post(`/subscription/resume`, values).then((res) => res.data), + { + onSuccess: () => { + queryClient.invalidateQueries(QueryKeys.Subscriptions); + }, + ...options, + }, + ); +} + +interface ChangeMainSubscriptionPlanValues { + variant_id: string; +} +interface ChangeMainSubscriptionPlanResponse {} + +/** + * Changese the main subscription of the current organization. + * @param {UseMutationOptions} options - + * @returns {UseMutationResult} + */ +export function useChangeSubscriptionPlan( + options?: UseMutationOptions< + ChangeMainSubscriptionPlanValues, + Error, + ChangeMainSubscriptionPlanResponse + >, +): UseMutationResult< + ChangeMainSubscriptionPlanValues, + Error, + ChangeMainSubscriptionPlanResponse +> { + const queryClient = useQueryClient(); + const apiRequest = useApiRequest(); + + return useMutation< + ChangeMainSubscriptionPlanResponse, + Error, + 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 { + subscriptions: Array; +} + +/** + * Changese the main subscription of the current organization. + * @param {UseMutationOptions} options - + * @returns {UseMutationResult} + */ +export function useGetSubscriptions( + options?: UseQueryOptions< + GetSubscriptionsQuery, + Error, + GetSubscriptionsResponse + >, +): UseQueryResult { + const apiRequest = useApiRequest(); + + return useQuery( + [QueryKeys.Subscriptions], + (values) => + apiRequest + .get(`/subscription`) + .then((res) => transformToCamelCase(res.data)), + { + ...options, + }, + ); +} diff --git a/packages/webapp/src/routes/dashboard.tsx b/packages/webapp/src/routes/dashboard.tsx index b1b4cb1d4..aaedb5853 100644 --- a/packages/webapp/src/routes/dashboard.tsx +++ b/packages/webapp/src/routes/dashboard.tsx @@ -1231,6 +1231,13 @@ export const getDashboardRoutes = () => [ breadcrumb: 'Bank Rules', subscriptionActive: [SUBSCRIPTION_TYPE.MAIN], }, + { + path: '/billing', + component: lazy(() => import('@/containers/Subscriptions/BillingPage')), + pageTitle: 'Billing', + breadcrumb: 'Billing', + subscriptionActive: [SUBSCRIPTION_TYPE.MAIN], + }, // Homepage { path: `/`, diff --git a/packages/webapp/src/static/json/icons.tsx b/packages/webapp/src/static/json/icons.tsx index 2e3d19ed7..4149a8c70 100644 --- a/packages/webapp/src/static/json/icons.tsx +++ b/packages/webapp/src/static/json/icons.tsx @@ -635,4 +635,11 @@ export default { ], viewBox: '0 0 16 16', }, + + feed: { + path: [ + 'M1.99,11.99c-1.1,0-2,0.9-2,2s0.9,2,2,2s2-0.9,2-2S3.1,11.99,1.99,11.99zM2.99,7.99c-0.55,0-1,0.45-1,1s0.45,1,1,1c1.66,0,3,1.34,3,3c0,0.55,0.45,1,1,1s1-0.45,1-1C7.99,10.23,5.75,7.99,2.99,7.99zM2.99,3.99c-0.55,0-1,0.45-1,1s0.45,1,1,1c3.87,0,7,3.13,7,7c0,0.55,0.45,1,1,1s1-0.45,1-1C11.99,8.02,7.96,3.99,2.99,3.99zM2.99-0.01c-0.55,0-1,0.45-1,1s0.45,1,1,1c6.08,0,11,4.92,11,11c0,0.55,0.45,1,1,1s1-0.45,1-1C15.99,5.81,10.17-0.01,2.99-0.01z', + ], + viewBox: '0 0 16 16', + }, };