From fa7e6b1fcab7990427a30db8b6eb0e55d01a6300 Mon Sep 17 00:00:00 2001 From: Ahmed Bouhuolia Date: Mon, 15 Jul 2024 23:18:39 +0200 Subject: [PATCH 01/22] feat: disconnect bank account --- .../Banking/BankAccountsController.ts | 35 +++++++++ .../BankAccounts/BankAccountsApplication.tsx | 21 ++++++ .../BankAccounts/DisconnectBankAccount.tsx | 72 +++++++++++++++++++ packages/server/src/subscribers/events.ts | 5 ++ .../AccountTransactionsActionsBar.tsx | 29 ++++++++ packages/webapp/src/hooks/query/bank-rules.ts | 21 +++++- 6 files changed, 182 insertions(+), 1 deletion(-) create mode 100644 packages/server/src/services/Banking/BankAccounts/BankAccountsApplication.tsx create mode 100644 packages/server/src/services/Banking/BankAccounts/DisconnectBankAccount.tsx diff --git a/packages/server/src/api/controllers/Banking/BankAccountsController.ts b/packages/server/src/api/controllers/Banking/BankAccountsController.ts index 4b062768f..c876ba604 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,10 @@ export class BankAccountsController extends BaseController { const router = Router(); router.get('/:bankAccountId/meta', this.getBankAccountSummary.bind(this)); + router.post( + '/:bankAccountId/disconnect', + this.discountBankAccount.bind(this) + ); return router; } @@ -46,4 +54,31 @@ 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); + } + } } 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..c375030ef --- /dev/null +++ b/packages/server/src/services/Banking/BankAccounts/BankAccountsApplication.tsx @@ -0,0 +1,21 @@ +import { Inject, Service } from 'typedi'; +import { DisconnectBankAccount } from './DisconnectBankAccount'; + +@Service() +export class BankAccountsApplication { + @Inject() + private disconnectBankAccountService: DisconnectBankAccount; + + /** + * 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 + ); + } +} 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..4f9e6106e --- /dev/null +++ b/packages/server/src/services/Banking/BankAccounts/DisconnectBankAccount.tsx @@ -0,0 +1,72 @@ +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'; + +@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} + */ + async disconnectBankAccount(tenantId: number, bankAccountId: number) { + const { Account, PlaidItem } = this.tenancy.models(tenantId); + + const account = await Account.query() + .findById(bankAccountId) + .where('type', ['bank']) + .throwIfNotFound(); + + const plaidItem = await PlaidItem.query().findById(account.plaidAccountId); + + if (!plaidItem) { + throw new ServiceError(ERRORS.BANK_ACCOUNT_NOT_CONNECTED); + } + const request = { + accessToken: plaidItem.plaidAccessToken, + }; + const plaidInstance = new PlaidClientWrapper(); + + // + return this.uow.withTransaction(tenantId, async (trx: Knex.Transaction) => { + await this.eventPublisher.emitAsync(events.bankAccount.onDisconnecting, { + tenantId, + bankAccountId, + }); + // Remove the Plaid item. + const data = await plaidInstance.itemRemove(request); + + // Remove the Plaid item from the system. + await PlaidItem.query().findById(account.plaidAccountId).delete(); + + // Remove the plaid item association to the bank account. + await Account.query().findById(bankAccountId).patch({ + plaidAccountId: null, + isFeedsActive: false, + }); + await this.eventPublisher.emitAsync(events.bankAccount.onDisconnected, { + tenantId, + bankAccountId, + }); + }); + } +} + +const ERRORS = { + BANK_ACCOUNT_NOT_CONNECTED: 'BANK_ACCOUNT_NOT_CONNECTED', +}; diff --git a/packages/server/src/subscribers/events.ts b/packages/server/src/subscribers/events.ts index 711dbce35..b64e398c3 100644 --- a/packages/server/src/subscribers/events.ts +++ b/packages/server/src/subscribers/events.ts @@ -651,6 +651,11 @@ export default { onUnexcluded: 'onBankTransactionUnexcluded', }, + bankAccount: { + onDisconnecting: 'onBankAccountDisconnecting', + onDisconnected: 'onBankAccountDisconnected', + }, + // Import files. import: { onImportCommitted: 'onImportFileCommitted', diff --git a/packages/webapp/src/containers/CashFlow/AccountTransactions/AccountTransactionsActionsBar.tsx b/packages/webapp/src/containers/CashFlow/AccountTransactions/AccountTransactionsActionsBar.tsx index b5db2eb78..850b3fa37 100644 --- a/packages/webapp/src/containers/CashFlow/AccountTransactions/AccountTransactionsActionsBar.tsx +++ b/packages/webapp/src/containers/CashFlow/AccountTransactions/AccountTransactionsActionsBar.tsx @@ -11,6 +11,7 @@ import { MenuItem, PopoverInteractionKind, Position, + Intent, } from '@blueprintjs/core'; import { useHistory } from 'react-router-dom'; import { @@ -18,6 +19,7 @@ import { DashboardActionsBar, DashboardRowsHeightButton, FormattedMessage as T, + AppToaster, } from '@/components'; import { CashFlowMenuItems } from './utils'; @@ -33,6 +35,7 @@ import withSettings from '@/containers/Settings/withSettings'; import withSettingsActions from '@/containers/Settings/withSettingsActions'; import { compose } from '@/utils'; +import { useDisconnectBankAccount } from '@/hooks/query/bank-rules'; function AccountTransactionsActionsBar({ // #withDialogActions @@ -50,6 +53,8 @@ function AccountTransactionsActionsBar({ // Refresh cashflow infinity transactions hook. const { refresh } = useRefreshCashflowTransactionsInfinity(); + const { mutateAsync: disconnectBankAccount } = useDisconnectBankAccount(); + // Retrieves the money in/out buttons options. const addMoneyInOptions = useMemo(() => getAddMoneyInOptions(), []); const addMoneyOutOptions = useMemo(() => getAddMoneyOutOptions(), []); @@ -82,6 +87,26 @@ function AccountTransactionsActionsBar({ const handleBankRulesClick = () => { history.push(`/bank-rules?accountId=${accountId}`); }; + + const isConnected = true; + + // Handles the bank account disconnect click. + const handleDisconnectClick = () => { + disconnectBankAccount(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, + }); + }); + }; + // Handle the refresh button click. const handleRefreshBtnClick = () => { refresh(); @@ -142,6 +167,10 @@ function AccountTransactionsActionsBar({ content={ + + {isConnected && ( + + )} } > diff --git a/packages/webapp/src/hooks/query/bank-rules.ts b/packages/webapp/src/hooks/query/bank-rules.ts index 77a770ff4..a49e4a5b0 100644 --- a/packages/webapp/src/hooks/query/bank-rules.ts +++ b/packages/webapp/src/hooks/query/bank-rules.ts @@ -1,4 +1,3 @@ -// @ts-nocheck import { QueryClient, UseMutationOptions, @@ -61,6 +60,26 @@ export function useCreateBankRule( ); } +interface DisconnectBankAccountRes {} + +export function useDisconnectBankAccount( + options?: UseMutationOptions, +) { + const queryClient = useQueryClient(); + const apiRequest = useApiRequest(); + + return useMutation( + (bankAccountId: number) => + apiRequest + .post(`/banking/bank_accounts/${bankAccountId}`) + .then((res) => res.data), + { + ...options, + onSuccess: () => {}, + }, + ); +} + interface EditBankRuleValues { id: number; value: any; From c2815afbe39b15670fde1d51609828737fec200f Mon Sep 17 00:00:00 2001 From: Ahmed Bouhuolia Date: Tue, 16 Jul 2024 17:09:00 +0200 Subject: [PATCH 02/22] feat: disconnect and update bank account --- .../Banking/BankAccountsController.ts | 30 ++++++++- ...732_add_plaid_item_id_to_accounts_table.js | 11 ++++ packages/server/src/lib/Plaid/Plaid.ts | 1 + packages/server/src/models/Account.ts | 13 ++++ .../BankAccounts/BankAccountsApplication.tsx | 17 +++++ .../BankAccounts/DisconnectBankAccount.tsx | 34 +++++----- .../BankAccounts/RefreshBankAccount.tsx | 39 ++++++++++++ .../services/Banking/BankAccounts/types.ts | 17 +++++ .../AccountTransactionsActionsBar.tsx | 43 ++++++++++++- packages/webapp/src/hooks/query/bank-rules.ts | 63 ++++++++++++++++--- packages/webapp/src/static/json/icons.tsx | 7 +++ 11 files changed, 246 insertions(+), 29 deletions(-) create mode 100644 packages/server/src/database/migrations/20240716114732_add_plaid_item_id_to_accounts_table.js create mode 100644 packages/server/src/services/Banking/BankAccounts/RefreshBankAccount.tsx create mode 100644 packages/server/src/services/Banking/BankAccounts/types.ts diff --git a/packages/server/src/api/controllers/Banking/BankAccountsController.ts b/packages/server/src/api/controllers/Banking/BankAccountsController.ts index c876ba604..f337c0b38 100644 --- a/packages/server/src/api/controllers/Banking/BankAccountsController.ts +++ b/packages/server/src/api/controllers/Banking/BankAccountsController.ts @@ -22,8 +22,9 @@ export class BankAccountsController extends BaseController { router.get('/:bankAccountId/meta', this.getBankAccountSummary.bind(this)); router.post( '/:bankAccountId/disconnect', - this.discountBankAccount.bind(this) + this.disconnectBankAccount.bind(this) ); + router.post('/:bankAccountId/update', this.refreshBankAccount.bind(this)); return router; } @@ -81,4 +82,31 @@ export class BankAccountsController extends BaseController { 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/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/lib/Plaid/Plaid.ts b/packages/server/src/lib/Plaid/Plaid.ts index 532d3cfe8..04cc71888 100644 --- a/packages/server/src/lib/Plaid/Plaid.ts +++ b/packages/server/src/lib/Plaid/Plaid.ts @@ -52,6 +52,7 @@ const noAccessTokenLogger = async ( // Plaid client methods used in this app, mapped to their appropriate logging functions. const clientMethodLoggingFns = { + transactionsRefresh: defaultLogger, accountsGet: defaultLogger, institutionsGet: noAccessTokenLogger, institutionsGetById: noAccessTokenLogger, diff --git a/packages/server/src/models/Account.ts b/packages/server/src/models/Account.ts index 7e0d8d6e4..79100d6d6 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.id', + }, + }, }; } diff --git a/packages/server/src/services/Banking/BankAccounts/BankAccountsApplication.tsx b/packages/server/src/services/Banking/BankAccounts/BankAccountsApplication.tsx index c375030ef..51c12106e 100644 --- a/packages/server/src/services/Banking/BankAccounts/BankAccountsApplication.tsx +++ b/packages/server/src/services/Banking/BankAccounts/BankAccountsApplication.tsx @@ -1,11 +1,15 @@ 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 @@ -18,4 +22,17 @@ export class BankAccountsApplication { 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 index 4f9e6106e..d562b31fc 100644 --- a/packages/server/src/services/Banking/BankAccounts/DisconnectBankAccount.tsx +++ b/packages/server/src/services/Banking/BankAccounts/DisconnectBankAccount.tsx @@ -6,6 +6,7 @@ import { PlaidClientWrapper } from '@/lib/Plaid'; import HasTenancyService from '@/services/Tenancy/TenancyService'; import UnitOfWork from '@/services/UnitOfWork'; import events from '@/subscribers/events'; +import { ERRORS } from './types'; @Service() export class DisconnectBankAccount { @@ -20,45 +21,46 @@ export class DisconnectBankAccount { /** * Disconnects the given bank account. - * @param {number} tenantId - * @param {number} bankAccountId + * @param {number} tenantId + * @param {number} bankAccountId * @returns {Promise} */ 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) - .where('type', ['bank']) + .whereIn('account_type', ['bank', 'cash']) .throwIfNotFound(); - const plaidItem = await PlaidItem.query().findById(account.plaidAccountId); + const oldPlaidItem = await PlaidItem.query().findById(account.plaidItemId); - if (!plaidItem) { + if (!oldPlaidItem) { throw new ServiceError(ERRORS.BANK_ACCOUNT_NOT_CONNECTED); } - const request = { - accessToken: plaidItem.plaidAccessToken, - }; const plaidInstance = new PlaidClientWrapper(); - // return this.uow.withTransaction(tenantId, async (trx: Knex.Transaction) => { + // Triggers `onBankAccountDisconnecting` event. await this.eventPublisher.emitAsync(events.bankAccount.onDisconnecting, { tenantId, bankAccountId, }); - // Remove the Plaid item. - const data = await plaidInstance.itemRemove(request); - // Remove the Plaid item from the system. - await PlaidItem.query().findById(account.plaidAccountId).delete(); + await PlaidItem.query(trx).findById(account.plaidItemId).delete(); // Remove the plaid item association to the bank account. - await Account.query().findById(bankAccountId).patch({ + await Account.query(trx).findById(bankAccountId).patch({ plaidAccountId: null, + plaidItemId: null, isFeedsActive: false, }); + // Remove the Plaid item. + const data = await plaidInstance.itemRemove({ + access_token: oldPlaidItem.plaidAccessToken, + }); + // Triggers `onBankAccountDisconnected` event. await this.eventPublisher.emitAsync(events.bankAccount.onDisconnected, { tenantId, bankAccountId, @@ -66,7 +68,3 @@ export class DisconnectBankAccount { }); } } - -const ERRORS = { - BANK_ACCOUNT_NOT_CONNECTED: 'BANK_ACCOUNT_NOT_CONNECTED', -}; 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..814d00c75 --- /dev/null +++ b/packages/server/src/services/Banking/BankAccounts/RefreshBankAccount.tsx @@ -0,0 +1,39 @@ +import { ServiceError } from '@/exceptions'; +import { PlaidClientWrapper } from '@/lib/Plaid'; +import HasTenancyService from '@/services/Tenancy/TenancyService'; +import UnitOfWork from '@/services/UnitOfWork'; +import { Inject } from 'typedi'; +export class RefreshBankAccountService { + @Inject() + private tenancy: HasTenancyService; + + @Inject() + private uow: UnitOfWork; + + /** + * + * @param {number} tenantId + * @param {number} bankAccountId + */ + public async refreshBankAccount(tenantId: number, bankAccountId: number) { + const { Account } = this.tenancy.models(tenantId); + + const bankAccount = await Account.query() + .findById(bankAccountId) + .withGraphFetched('plaidItem') + .throwIfNotFound(); + + if (!bankAccount.plaidItem) { + throw new ServiceError(''); + } + const plaidInstance = new PlaidClientWrapper(); + + const data = await plaidInstance.transactionsRefresh({ + access_token: bankAccount.plaidItem.plaidAccessToken, + }); + await Account.query().findById(bankAccountId).patch({ + isFeedsActive: true, + lastFeedsUpdatedAt: new Date(), + }); + } +} 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/webapp/src/containers/CashFlow/AccountTransactions/AccountTransactionsActionsBar.tsx b/packages/webapp/src/containers/CashFlow/AccountTransactions/AccountTransactionsActionsBar.tsx index 850b3fa37..e9635524c 100644 --- a/packages/webapp/src/containers/CashFlow/AccountTransactions/AccountTransactionsActionsBar.tsx +++ b/packages/webapp/src/containers/CashFlow/AccountTransactions/AccountTransactionsActionsBar.tsx @@ -12,6 +12,8 @@ import { PopoverInteractionKind, Position, Intent, + Tooltip, + MenuDivider, } from '@blueprintjs/core'; import { useHistory } from 'react-router-dom'; import { @@ -35,7 +37,10 @@ import withSettings from '@/containers/Settings/withSettings'; import withSettingsActions from '@/containers/Settings/withSettingsActions'; import { compose } from '@/utils'; -import { useDisconnectBankAccount } from '@/hooks/query/bank-rules'; +import { + useDisconnectBankAccount, + useUpdateBankAccount, +} from '@/hooks/query/bank-rules'; function AccountTransactionsActionsBar({ // #withDialogActions @@ -54,6 +59,7 @@ function AccountTransactionsActionsBar({ const { refresh } = useRefreshCashflowTransactionsInfinity(); const { mutateAsync: disconnectBankAccount } = useDisconnectBankAccount(); + const { mutateAsync: updateBankAccount } = useUpdateBankAccount(); // Retrieves the money in/out buttons options. const addMoneyInOptions = useMemo(() => getAddMoneyInOptions(), []); @@ -92,7 +98,7 @@ function AccountTransactionsActionsBar({ // Handles the bank account disconnect click. const handleDisconnectClick = () => { - disconnectBankAccount(accountId) + disconnectBankAccount({ bankAccountId: accountId }) .then(() => { AppToaster.show({ message: 'The bank account has been disconnected.', @@ -106,7 +112,22 @@ function AccountTransactionsActionsBar({ }); }); }; - + // 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(); @@ -154,6 +175,18 @@ function AccountTransactionsActionsBar({ onChange={handleTableRowSizeChange} /> + + + - - ); -} - -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.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(), - }); From db634cbb79899b32fd0de093a62d4a85c4584e41 Mon Sep 17 00:00:00 2001 From: Ahmed Bouhuolia Date: Sat, 27 Jul 2024 16:55:56 +0200 Subject: [PATCH 04/22] feat: pause, resume main subscription --- .../Subscription/SubscriptionController.ts | 92 ++++++++++++++ .../Subscription/LemonCancelSubscription.ts | 47 +++++++ .../LemonChangeSubscriptionPlan.ts | 47 +++++++ .../Subscription/LemonResumeSubscription.ts | 48 ++++++++ .../Subscription/SubscriptionApplication.ts | 48 ++++++++ .../server/src/services/Subscription/types.ts | 20 +++ packages/server/src/subscribers/events.ts | 9 ++ ..._subscription_id_to_subscriptions_table.js | 11 ++ .../models/Subscriptions/PlanSubscription.ts | 2 + .../containers/AlertsContainer/registered.tsx | 4 +- .../containers/Subscriptions/BillingPage.tsx | 24 ++++ .../Subscriptions/BillingPageBoot.tsx | 3 + .../alerts/CancelMainSubscriptionAlert.tsx | 74 +++++++++++ .../alerts/ResumeMainSubscriptionAlert.tsx | 73 +++++++++++ .../containers/Subscriptions/alerts/alerts.ts | 23 ++++ .../webapp/src/hooks/query/subscription.tsx | 115 ++++++++++++++++++ packages/webapp/src/routes/dashboard.tsx | 7 ++ 17 files changed, 646 insertions(+), 1 deletion(-) create mode 100644 packages/server/src/services/Subscription/LemonCancelSubscription.ts create mode 100644 packages/server/src/services/Subscription/LemonChangeSubscriptionPlan.ts create mode 100644 packages/server/src/services/Subscription/LemonResumeSubscription.ts create mode 100644 packages/server/src/services/Subscription/SubscriptionApplication.ts create mode 100644 packages/server/src/services/Subscription/types.ts create mode 100644 packages/server/src/system/migrations/20240727094214_add_lemon_subscription_id_to_subscriptions_table.js create mode 100644 packages/webapp/src/containers/Subscriptions/BillingPage.tsx create mode 100644 packages/webapp/src/containers/Subscriptions/BillingPageBoot.tsx create mode 100644 packages/webapp/src/containers/Subscriptions/alerts/CancelMainSubscriptionAlert.tsx create mode 100644 packages/webapp/src/containers/Subscriptions/alerts/ResumeMainSubscriptionAlert.tsx create mode 100644 packages/webapp/src/containers/Subscriptions/alerts/alerts.ts create mode 100644 packages/webapp/src/hooks/query/subscription.tsx 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/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..e6628cc0c --- /dev/null +++ b/packages/server/src/services/Subscription/LemonResumeSubscription.ts @@ -0,0 +1,48 @@ +import { EventPublisher } from '@/lib/EventPublisher/EventPublisher'; +import events from '@/subscribers/events'; +import { Inject, Service } from 'typedi'; +import { configureLemonSqueezy } from './utils'; +import { PlanSubscription } from '@/system/models'; +import { ServiceError } from '@/exceptions'; +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/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 711dbce35..78fca991e 100644 --- a/packages/server/src/subscribers/events.ts +++ b/packages/server/src/subscribers/events.ts @@ -40,6 +40,15 @@ export default { baseCurrencyUpdated: 'onOrganizationBaseCurrencyUpdated', }, + /** + * Organization subscription. + */ + subscription: { + onSubscriptionCanceled: 'onSubscriptionCanceled', + onSubscriptionResumed: 'onSubscriptionResumed', + onSubscriptionPlanChanged: 'onSubscriptionPlanChanged', + }, + /** * Tenants managment service. */ 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/models/Subscriptions/PlanSubscription.ts b/packages/server/src/system/models/Subscriptions/PlanSubscription.ts index d77ee6418..c3e63530c 100644 --- a/packages/server/src/system/models/Subscriptions/PlanSubscription.ts +++ b/packages/server/src/system/models/Subscriptions/PlanSubscription.ts @@ -4,6 +4,8 @@ import moment from 'moment'; import SubscriptionPeriod from '@/services/Subscription/SubscriptionPeriod'; export default class PlanSubscription extends mixin(SystemModel) { + lemonSubscriptionId: number; + /** * Table name. */ 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/Subscriptions/BillingPage.tsx b/packages/webapp/src/containers/Subscriptions/BillingPage.tsx new file mode 100644 index 000000000..e1f0c07b5 --- /dev/null +++ b/packages/webapp/src/containers/Subscriptions/BillingPage.tsx @@ -0,0 +1,24 @@ +// @ts-nocheck +import * as R from 'ramda'; +import { Button } from '@blueprintjs/core'; +import withAlertActions from '../Alert/withAlertActions'; + +function BillingPageRoot({ openAlert }) { + const handleCancelSubBtnClick = () => { + openAlert('cancel-main-subscription'); + }; + const handleResumeSubBtnClick = () => { + openAlert('resume-main-subscription'); + }; + const handleUpdatePaymentMethod = () => {}; + + return ( +

+ + + +

+ ); +} + +export default R.compose(withAlertActions)(BillingPageRoot); diff --git a/packages/webapp/src/containers/Subscriptions/BillingPageBoot.tsx b/packages/webapp/src/containers/Subscriptions/BillingPageBoot.tsx new file mode 100644 index 000000000..93d8d7b1b --- /dev/null +++ b/packages/webapp/src/containers/Subscriptions/BillingPageBoot.tsx @@ -0,0 +1,3 @@ +export function BillingPageBoot() { + return null; +} 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..9e2b5979c --- /dev/null +++ b/packages/webapp/src/containers/Subscriptions/alerts/CancelMainSubscriptionAlert.tsx @@ -0,0 +1,74 @@ +// @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 cancel.', + 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} + > +

asdfsadf asdf asdfdsaf

+
+ ); +} + +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..33149e752 --- /dev/null +++ b/packages/webapp/src/containers/Subscriptions/alerts/ResumeMainSubscriptionAlert.tsx @@ -0,0 +1,73 @@ +// @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} + > +

asdfsadf asdf asdfdsaf

+
+ ); +} + +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/hooks/query/subscription.tsx b/packages/webapp/src/hooks/query/subscription.tsx new file mode 100644 index 000000000..58dbe81ee --- /dev/null +++ b/packages/webapp/src/hooks/query/subscription.tsx @@ -0,0 +1,115 @@ +// @ts-nocheck +import { + useMutation, + UseMutationOptions, + UseMutationResult, + useQueryClient, +} from 'react-query'; +import useApiRequest from '../useRequest'; + +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), + { + ...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), + { + ...options, + }, + ); +} + +interface ChangeMainSubscriptionPlanValues { + variantId: 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< + ChangeMainSubscriptionPlanValues, + Error, + ChangeMainSubscriptionPlanResponse + >( + (values) => + apiRequest.post(`/subscription/change`, values).then((res) => 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: `/`, From 7720b1cc3425a3d2c7f273f8f8c00e8111856d21 Mon Sep 17 00:00:00 2001 From: Ahmed Bouhuolia Date: Sat, 27 Jul 2024 17:39:50 +0200 Subject: [PATCH 05/22] feat: getting subscription endpoint --- .../GetSubscriptionsTransformer.ts | 11 +++++++ .../Subscription/SubscriptionService.ts | 13 ++++++-- .../containers/Subscriptions/BillingPage.tsx | 13 +++++--- .../Subscriptions/BillingPageBoot.tsx | 29 +++++++++++++++-- .../webapp/src/hooks/query/subscription.tsx | 31 ++++++++++++++++++- 5 files changed, 87 insertions(+), 10 deletions(-) create mode 100644 packages/server/src/services/Subscription/GetSubscriptionsTransformer.ts diff --git a/packages/server/src/services/Subscription/GetSubscriptionsTransformer.ts b/packages/server/src/services/Subscription/GetSubscriptionsTransformer.ts new file mode 100644 index 000000000..edc7d5dc0 --- /dev/null +++ b/packages/server/src/services/Subscription/GetSubscriptionsTransformer.ts @@ -0,0 +1,11 @@ +import { Transformer } from '@/lib/Transformer/Transformer'; + +export class GetSubscriptionsTransformer extends Transformer { + /** + * Include these attributes to sale invoice object. + * @returns {Array} + */ + public includeAttributes = (): string[] => { + return []; + }; +} diff --git a/packages/server/src/services/Subscription/SubscriptionService.ts b/packages/server/src/services/Subscription/SubscriptionService.ts index 8e70c55d8..de3b1db93 100644 --- a/packages/server/src/services/Subscription/SubscriptionService.ts +++ b/packages/server/src/services/Subscription/SubscriptionService.ts @@ -1,8 +1,13 @@ -import { Service } from 'typedi'; +import { Inject, Service } from 'typedi'; import { PlanSubscription } from '@/system/models'; +import { TransformerInjectable } from '@/lib/Transformer/TransformerInjectable'; +import { GetSubscriptionsTransformer } from './GetSubscriptionsTransformer'; @Service() export default class SubscriptionService { + @Inject() + private transformer: TransformerInjectable; + /** * Retrieve all subscription of the given tenant. * @param {number} tenantId @@ -12,6 +17,10 @@ export default class SubscriptionService { 'tenant_id', tenantId ); - return subscriptions; + return this.transformer.transform( + tenantId, + subscriptions, + new GetSubscriptionsTransformer() + ); } } diff --git a/packages/webapp/src/containers/Subscriptions/BillingPage.tsx b/packages/webapp/src/containers/Subscriptions/BillingPage.tsx index e1f0c07b5..b2d77462a 100644 --- a/packages/webapp/src/containers/Subscriptions/BillingPage.tsx +++ b/packages/webapp/src/containers/Subscriptions/BillingPage.tsx @@ -2,6 +2,7 @@ import * as R from 'ramda'; import { Button } from '@blueprintjs/core'; import withAlertActions from '../Alert/withAlertActions'; +import { BillingPageBoot } from './BillingPageBoot'; function BillingPageRoot({ openAlert }) { const handleCancelSubBtnClick = () => { @@ -13,11 +14,13 @@ function BillingPageRoot({ openAlert }) { const handleUpdatePaymentMethod = () => {}; return ( -

- - - -

+ +

+ + + +

+
); } diff --git a/packages/webapp/src/containers/Subscriptions/BillingPageBoot.tsx b/packages/webapp/src/containers/Subscriptions/BillingPageBoot.tsx index 93d8d7b1b..7cebf9759 100644 --- a/packages/webapp/src/containers/Subscriptions/BillingPageBoot.tsx +++ b/packages/webapp/src/containers/Subscriptions/BillingPageBoot.tsx @@ -1,3 +1,28 @@ -export function BillingPageBoot() { - return null; +import React, { createContext } from 'react'; +import { useGetSubscriptions } from '@/hooks/query/subscription'; + +interface BillingBootContextValues { + isSubscriptionsLoading: boolean; + subscriptions: any; } + +const BillingBoot = createContext( + {} as BillingBootContextValues, +); + +interface BillingPageBootProps { + children: React.ReactNode; +} + +export function BillingPageBoot({ children }: BillingPageBootProps) { + const { isLoading: isSubscriptionsLoading, data: subscriptions } = + useGetSubscriptions(); + + const value = { + isSubscriptionsLoading, + subscriptions, + }; + return {children}; +} + +export const useBillingPageBoot = () => React.useContext(BillingBoot); diff --git a/packages/webapp/src/hooks/query/subscription.tsx b/packages/webapp/src/hooks/query/subscription.tsx index 58dbe81ee..050913c7c 100644 --- a/packages/webapp/src/hooks/query/subscription.tsx +++ b/packages/webapp/src/hooks/query/subscription.tsx @@ -1,9 +1,12 @@ -// @ts-nocheck +// @ts-ignore import { useMutation, UseMutationOptions, UseMutationResult, + useQuery, useQueryClient, + UseQueryOptions, + UseQueryResult, } from 'react-query'; import useApiRequest from '../useRequest'; @@ -113,3 +116,29 @@ export function useChangeSubscriptionPlan( }, ); } + +interface GetSubscriptionsQuery {} +interface GetSubscriptionsResponse {} + +/** + * 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( + ['SUBSCRIPTIONS'], + (values) => apiRequest.get(`/subscription`).then((res) => res.data), + { + ...options, + }, + ); +} From 383be111fa789eeb9976f06f5c3114986a897226 Mon Sep 17 00:00:00 2001 From: Ahmed Bouhuolia Date: Sat, 27 Jul 2024 21:47:17 +0200 Subject: [PATCH 06/22] feat: style the billing page --- .../src/components/Dashboard/TopbarUser.tsx | 5 +- .../containers/Subscriptions/BillingPage.tsx | 22 ++--- .../BillingPageContent.module.scss | 8 ++ .../Subscriptions/BillingPageContent.tsx | 20 +++++ .../BillingSubscription.module.scss | 64 ++++++++++++++ .../Subscriptions/BillingSubscription.tsx | 86 +++++++++++++++++++ .../webapp/src/hooks/query/subscription.tsx | 2 +- 7 files changed, 188 insertions(+), 19 deletions(-) create mode 100644 packages/webapp/src/containers/Subscriptions/BillingPageContent.module.scss create mode 100644 packages/webapp/src/containers/Subscriptions/BillingPageContent.tsx create mode 100644 packages/webapp/src/containers/Subscriptions/BillingSubscription.module.scss create mode 100644 packages/webapp/src/containers/Subscriptions/BillingSubscription.tsx diff --git a/packages/webapp/src/components/Dashboard/TopbarUser.tsx b/packages/webapp/src/components/Dashboard/TopbarUser.tsx index 889bd3426..b20401868 100644 --- a/packages/webapp/src/components/Dashboard/TopbarUser.tsx +++ b/packages/webapp/src/components/Dashboard/TopbarUser.tsx @@ -58,6 +58,7 @@ function DashboardTopbarUser({ } /> + history.push('/billing')} /> } onClick={onKeyboardShortcut} @@ -79,6 +80,4 @@ function DashboardTopbarUser({ ); } -export default compose( - withDialogActions, -)(DashboardTopbarUser); +export default compose(withDialogActions)(DashboardTopbarUser); diff --git a/packages/webapp/src/containers/Subscriptions/BillingPage.tsx b/packages/webapp/src/containers/Subscriptions/BillingPage.tsx index b2d77462a..29979db3b 100644 --- a/packages/webapp/src/containers/Subscriptions/BillingPage.tsx +++ b/packages/webapp/src/containers/Subscriptions/BillingPage.tsx @@ -3,24 +3,16 @@ import * as R from 'ramda'; import { Button } from '@blueprintjs/core'; import withAlertActions from '../Alert/withAlertActions'; import { BillingPageBoot } from './BillingPageBoot'; +import { BillingPageContent } from './BillingPageContent'; +import { DashboardInsider } from '@/components'; function BillingPageRoot({ openAlert }) { - const handleCancelSubBtnClick = () => { - openAlert('cancel-main-subscription'); - }; - const handleResumeSubBtnClick = () => { - openAlert('resume-main-subscription'); - }; - const handleUpdatePaymentMethod = () => {}; - return ( - -

- - - -

-
+ + + + + ); } diff --git a/packages/webapp/src/containers/Subscriptions/BillingPageContent.module.scss b/packages/webapp/src/containers/Subscriptions/BillingPageContent.module.scss new file mode 100644 index 000000000..23c7abb89 --- /dev/null +++ b/packages/webapp/src/containers/Subscriptions/BillingPageContent.module.scss @@ -0,0 +1,8 @@ +.root { + display: flex; + flex-direction: column; + padding: 32px 40px; + min-width: 800px; + max-width: 900px; + width: 75%; +} \ No newline at end of file diff --git a/packages/webapp/src/containers/Subscriptions/BillingPageContent.tsx b/packages/webapp/src/containers/Subscriptions/BillingPageContent.tsx new file mode 100644 index 000000000..bea0ec377 --- /dev/null +++ b/packages/webapp/src/containers/Subscriptions/BillingPageContent.tsx @@ -0,0 +1,20 @@ +import { Box, Group } from '@/components'; +import { Text } from '@blueprintjs/core'; +import { Subscription } from './BillingSubscription'; +import styles from './BillingPageContent.module.scss'; + +export function BillingPageContent() { + return ( + + + Transactions locking has the ability to lock all organization + transactions so users canโ€™t edit, delete or create new transactions + during the past period. + + + + + + + ); +} diff --git a/packages/webapp/src/containers/Subscriptions/BillingSubscription.module.scss b/packages/webapp/src/containers/Subscriptions/BillingSubscription.module.scss new file mode 100644 index 000000000..dd5c5bad1 --- /dev/null +++ b/packages/webapp/src/containers/Subscriptions/BillingSubscription.module.scss @@ -0,0 +1,64 @@ + +.root { + width: 450px; + background: #fff; + border-radius: 5px; + box-shadow: 0 -8px 0 0px #BFCCD6, rgb(0 8 36 / 9%) 0px 4px 20px -5px; + border: 1px solid #C4D2D7; + min-height: 420px; + display: flex; + flex-direction: column; +} + +.title{ + margin: 0; + font-size: 20px; + font-weight: 600; + color: #3D4C58; +} + +.description { + font-size: 15px; + line-height: 1.5; + color: #394B59; + margin-top: 14px; +} + +.period { + div + div { + &::before{ + content: " โ€ข "; + text-align: center; + margin-right: 3px; + color: #999; + margin-left: 6px; + } + } +} +.periodStatus{ + text-transform: uppercase; + color: #A82A2A; + font-weight: 500; +} +.periodText{ + color: #AF6161; +} +.priceAmount { + font-size: 24px; + font-weight: 500; +} +.pricePeriod { + color: #8F99A8; +} +.subscribeButton{ + border-radius: 32px; + padding-left: 16px; + padding-right: 16px; +} +.actions{ + margin-top: 20px; + + button{ + font-size: 15px; + } +} \ No newline at end of file diff --git a/packages/webapp/src/containers/Subscriptions/BillingSubscription.tsx b/packages/webapp/src/containers/Subscriptions/BillingSubscription.tsx new file mode 100644 index 000000000..0bef787a1 --- /dev/null +++ b/packages/webapp/src/containers/Subscriptions/BillingSubscription.tsx @@ -0,0 +1,86 @@ +// @ts-nocheck +import * as R from 'ramda'; +import { Box, Group, Stack } from '@/components'; +import { Button, Card, Intent, Text } from '@blueprintjs/core'; +import withAlertActions from '../Alert/withAlertActions'; +import styles from './BillingSubscription.module.scss'; + +function SubscriptionRoot({ openAlert }) { + const handleCancelSubBtnClick = () => { + openAlert('cancel-main-subscription'); + }; + const handleResumeSubBtnClick = () => { + openAlert('resume-main-subscription'); + }; + const handleUpdatePaymentMethod = () => {}; + + const handleUpgradeBtnClick = () => {}; + + return ( + + +

Capital Essential

+ + + Trial + Trial ends in 10 days. + +
+ + + Transactions locking has the ability to lock all organization + transactions so users canโ€™t edit, delete or create new transactions + during the past period. + + + + + + + + + + + $10 + / mo + + + + + + +
+ ); +} + +export const Subscription = R.compose(withAlertActions)(SubscriptionRoot); diff --git a/packages/webapp/src/hooks/query/subscription.tsx b/packages/webapp/src/hooks/query/subscription.tsx index 050913c7c..c9bbeec4e 100644 --- a/packages/webapp/src/hooks/query/subscription.tsx +++ b/packages/webapp/src/hooks/query/subscription.tsx @@ -1,4 +1,4 @@ -// @ts-ignore +// @ts-nocheck import { useMutation, UseMutationOptions, From 14a9c4ba28e63027aecbd39c4e00909177218f05 Mon Sep 17 00:00:00 2001 From: Ahmed Bouhuolia Date: Sat, 27 Jul 2024 21:56:55 +0200 Subject: [PATCH 07/22] fix: style tweaks in billing page --- .../containers/Subscriptions/BillingSubscription.module.scss | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/packages/webapp/src/containers/Subscriptions/BillingSubscription.module.scss b/packages/webapp/src/containers/Subscriptions/BillingSubscription.module.scss index dd5c5bad1..7f218431d 100644 --- a/packages/webapp/src/containers/Subscriptions/BillingSubscription.module.scss +++ b/packages/webapp/src/containers/Subscriptions/BillingSubscription.module.scss @@ -18,7 +18,6 @@ } .description { - font-size: 15px; line-height: 1.5; color: #394B59; margin-top: 14px; @@ -56,7 +55,7 @@ padding-right: 16px; } .actions{ - margin-top: 20px; + margin-top: 16px; button{ font-size: 15px; From 1660df20af9ca85ad6fd5623b4deb83d29680dbb Mon Sep 17 00:00:00 2001 From: Ahmed Bouhuolia Date: Sun, 28 Jul 2024 17:53:51 +0200 Subject: [PATCH 08/22] feat: wip billing page --- .../GetSubscriptionsTransformer.ts | 161 +++++++++++++++++- .../Subscription/LemonResumeSubscription.ts | 2 +- .../Subscription/SubscriptionService.ts | 32 +++- ...add_trial_columns_to_subscription_table.js | 13 ++ .../models/Subscriptions/PlanSubscription.ts | 80 +++++++-- .../src/components/DrawersContainer.tsx | 2 + packages/webapp/src/constants/drawers.ts | 1 + .../SetupSubscription/SubscriptionPlan.tsx | 26 +-- .../Subscriptions/BillingPageBoot.tsx | 9 +- .../Subscriptions/BillingPageContent.tsx | 10 +- .../BillingSubscription.module.scss | 6 +- .../Subscriptions/BillingSubscription.tsx | 99 ++++++++--- .../ChangeSubscriptionPlanContent.tsx | 54 ++++++ .../webapp/src/hooks/query/subscription.tsx | 62 ++++++- 14 files changed, 488 insertions(+), 69 deletions(-) create mode 100644 packages/server/src/system/migrations/20240728123419_add_trial_columns_to_subscription_table.js create mode 100644 packages/webapp/src/containers/Subscriptions/drawers/ChangeSubscriptionPlanDrawer/ChangeSubscriptionPlanContent.tsx diff --git a/packages/server/src/services/Subscription/GetSubscriptionsTransformer.ts b/packages/server/src/services/Subscription/GetSubscriptionsTransformer.ts index edc7d5dc0..194b41f78 100644 --- a/packages/server/src/services/Subscription/GetSubscriptionsTransformer.ts +++ b/packages/server/src/services/Subscription/GetSubscriptionsTransformer.ts @@ -6,6 +6,165 @@ export class GetSubscriptionsTransformer extends Transformer { * @returns {Array} */ public includeAttributes = (): string[] => { - return []; + return [ + 'canceledAtFormatted', + 'cancelsAtFormatted', + 'trialStartsAtFormatted', + 'trialEndsAtFormatted', + 'statusFormatted', + 'planName', + 'planSlug', + 'planPrice', + 'planPriceCurrency', + 'planPriceFormatted', + 'planPeriod', + 'lemonUrls', + ]; + }; + + /** + * Exclude attributes. + * @returns {string[]} + */ + public excludeAttributes = (): string[] => { + return ['id', 'plan']; + }; + + /** + * Retrieves the canceled at formatted. + * @param subscription + * @returns {string} + */ + public canceledAtFormatted = (subscription) => { + return subscription.canceledAt + ? this.formatDate(subscription.canceledAt) + : null; + }; + + /** + * Retrieves the cancels at formatted. + * @param subscription + * @returns {string} + */ + public cancelsAtFormatted = (subscription) => { + return subscription.cancelsAt + ? this.formatDate(subscription.cancelsAt) + : null; + }; + + /** + * Retrieves the trial starts at formatted date. + * @returns {string} + */ + public trialStartsAtFormatted = (subscription) => { + return subscription.trialStartsAt + ? this.formatDate(subscription.trialStartsAt) + : null; + }; + + /** + * Retrieves the trial ends at formatted date. + * @returns {string} + */ + public trialEndsAtFormatted = (subscription) => { + return subscription.trialEndsAt + ? this.formatDate(subscription.trialEndsAt) + : null; + }; + + /** + * Retrieves the Lemon subscription metadata. + * @param subscription + * @returns + */ + public lemonSubscription = (subscription) => { + return ( + this.options.lemonSubscriptions[subscription.lemonSubscriptionId] || null + ); + }; + + /** + * Retrieves the formatted subscription status. + * @param subscription + * @returns {string} + */ + public statusFormatted = (subscription) => { + const pairs = { + canceled: 'Canceled', + active: 'Active', + inactive: 'Inactive', + expired: 'Expired', + on_trial: 'On Trial', + }; + return pairs[subscription.status] || ''; + }; + + /** + * Retrieves the subscription plan name. + * @param subscription + * @returns {string} + */ + public planName(subscription) { + return subscription.plan?.name; + } + + /** + * Retrieves the subscription plan slug. + * @param subscription + * @returns {string} + */ + public planSlug(subscription) { + return subscription.plan?.slug; + } + + /** + * Retrieves the subscription plan price. + * @param subscription + * @returns {number} + */ + public planPrice(subscription) { + return subscription.plan?.price; + } + + /** + * Retrieves the subscription plan price currency. + * @param subscription + * @returns {string} + */ + public planPriceCurrency(subscription) { + return subscription.plan?.currency; + } + + /** + * Retrieves the subscription plan formatted price. + * @param subscription + * @returns {string} + */ + public planPriceFormatted(subscription) { + return this.formatMoney(subscription.plan?.price, { + currencyCode: subscription.plan?.currency, + precision: 0 + }); + } + + /** + * Retrieves the subscription plan period. + * @param subscription + * @returns {string} + */ + public planPeriod(subscription) { + return subscription?.plan?.period; + } + + /** + * Retrieve the subscription Lemon Urls. + * @param subscription + * @returns + */ + public lemonUrls = (subscription) => { + const lemonSusbcription = this.lemonSubscription(subscription); + console.log(lemonSusbcription); + + return lemonSusbcription?.data?.attributes?.urls; }; } diff --git a/packages/server/src/services/Subscription/LemonResumeSubscription.ts b/packages/server/src/services/Subscription/LemonResumeSubscription.ts index e6628cc0c..cd0ee0d2e 100644 --- a/packages/server/src/services/Subscription/LemonResumeSubscription.ts +++ b/packages/server/src/services/Subscription/LemonResumeSubscription.ts @@ -1,6 +1,6 @@ +import { Inject, Service } from 'typedi'; import { EventPublisher } from '@/lib/EventPublisher/EventPublisher'; import events from '@/subscribers/events'; -import { Inject, Service } from 'typedi'; import { configureLemonSqueezy } from './utils'; import { PlanSubscription } from '@/system/models'; import { ServiceError } from '@/exceptions'; diff --git a/packages/server/src/services/Subscription/SubscriptionService.ts b/packages/server/src/services/Subscription/SubscriptionService.ts index de3b1db93..a61714af3 100644 --- a/packages/server/src/services/Subscription/SubscriptionService.ts +++ b/packages/server/src/services/Subscription/SubscriptionService.ts @@ -1,7 +1,11 @@ import { Inject, Service } from 'typedi'; +import { getSubscription } from '@lemonsqueezy/lemonsqueezy.js'; +import { PromisePool } from '@supercharge/promise-pool'; import { PlanSubscription } from '@/system/models'; import { TransformerInjectable } from '@/lib/Transformer/TransformerInjectable'; import { GetSubscriptionsTransformer } from './GetSubscriptionsTransformer'; +import { configureLemonSqueezy } from './utils'; +import { fromPairs } from 'lodash'; @Service() export default class SubscriptionService { @@ -13,14 +17,34 @@ export default class SubscriptionService { * @param {number} tenantId */ public async getSubscriptions(tenantId: number) { - const subscriptions = await PlanSubscription.query().where( - 'tenant_id', - tenantId + configureLemonSqueezy(); + + const subscriptions = await PlanSubscription.query() + .where('tenant_id', tenantId) + .withGraphFetched('plan'); + + const lemonSubscriptionsResult = await PromisePool.withConcurrency(1) + .for(subscriptions) + .process(async (subscription, index, pool) => { + if (subscription.lemonSubscriptionId) { + const res = await getSubscription(subscription.lemonSubscriptionId); + + if (res.error) { + return; + } + return [subscription.lemonSubscriptionId, res.data]; + } + }); + const lemonSubscriptions = fromPairs( + lemonSubscriptionsResult?.results.filter((result) => !!result[1]) ); return this.transformer.transform( tenantId, subscriptions, - new GetSubscriptionsTransformer() + new GetSubscriptionsTransformer(), + { + lemonSubscriptions, + } ); } } 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..1843b120a --- /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_starts_at').nullable(); + table.dateTime('trial_ends_at').nullable(); + }); +}; + +exports.down = function (knex) { + return knex.schema.table('subscription_plan_subscriptions', (table) => { + table.dropColumn('trial_starts_at').nullable(); + table.dropColumn('trial_ends_at').nullable(); + }); +}; diff --git a/packages/server/src/system/models/Subscriptions/PlanSubscription.ts b/packages/server/src/system/models/Subscriptions/PlanSubscription.ts index c3e63530c..3ae1c1fac 100644 --- a/packages/server/src/system/models/Subscriptions/PlanSubscription.ts +++ b/packages/server/src/system/models/Subscriptions/PlanSubscription.ts @@ -5,7 +5,16 @@ import SubscriptionPeriod from '@/services/Subscription/SubscriptionPeriod'; export default class PlanSubscription extends mixin(SystemModel) { lemonSubscriptionId: number; - + + canceledAt: Date; + cancelsAt: Date; + + trialStartsAt: Date; + trialEndsAt: Date; + + endsAt: Date; + startsAt: Date; + /** * Table name. */ @@ -24,7 +33,7 @@ export default class PlanSubscription extends mixin(SystemModel) { * Defined virtual attributes. */ static get virtualAttributes() { - return ['active', 'inactive', 'ended', 'onTrial']; + return ['active', 'inactive', 'ended', 'canceled', 'onTrial', 'status']; } /** @@ -40,7 +49,7 @@ export default class PlanSubscription extends mixin(SystemModel) { builder.where('trial_ends_at', '>', now); }, - inactiveSubscriptions() { + inactiveSubscriptions(builder) { builder.modify('endedTrial'); builder.modify('endedPeriod'); }, @@ -100,35 +109,80 @@ export default class PlanSubscription extends mixin(SystemModel) { } /** - * Check if subscription is active. + * Check if the subscription is expired. + * Expired mens the user his lost the right to use the product. + * @returns {Boolean} + */ + public expired() { + return this.ended() && !this.onTrial(); + } + + /** + * Check if paid subscription is active. * @return {Boolean} */ - active() { - return !this.ended() || this.onTrial(); + public active() { + return ( + !this.canceled() && !this.onTrial() && !this.ended() && this.started() + ); } /** * Check if subscription is inactive. * @return {Boolean} */ - inactive() { + public inactive() { return !this.active(); } /** - * Check if subscription period has ended. + * Check if paid subscription period has ended. * @return {Boolean} */ - ended() { + public ended() { return this.endsAt ? moment().isAfter(this.endsAt) : false; } + /** + * Check if the paid subscription has started. + * @returns {Boolean} + */ + public started() { + return this.startsAt ? moment().isAfter(this.startsAt) : false; + } + /** * Check if subscription is currently on trial. * @return {Boolean} */ - onTrial() { - return this.trailEndsAt ? moment().isAfter(this.trailEndsAt) : false; + public onTrial() { + return this.trialEndsAt ? moment().isBefore(this.trialEndsAt) : false; + } + + /** + * Check if the subscription is canceled. + * @returns {boolean} + */ + public canceled() { + return ( + this.canceledAt || + (this.cancelsAt && moment().isAfter(this.cancelsAt)) || + false + ); + } + + /** + * Retrieves the subscription status. + * @returns {string} + */ + public status() { + return this.canceled() + ? 'canceled' + : this.onTrial() + ? 'on_trial' + : this.active() + ? 'active' + : 'inactive'; } /** @@ -143,7 +197,7 @@ export default class PlanSubscription extends mixin(SystemModel) { const period = new SubscriptionPeriod( invoiceInterval, invoicePeriod, - start, + start ); const startsAt = period.getStartDate(); @@ -159,7 +213,7 @@ export default class PlanSubscription extends mixin(SystemModel) { renew(invoiceInterval, invoicePeriod) { const { startsAt, endsAt } = PlanSubscription.setNewPeriod( invoiceInterval, - invoicePeriod, + invoicePeriod ); return this.$query().update({ startsAt, endsAt }); } 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/Setup/SetupSubscription/SubscriptionPlan.tsx b/packages/webapp/src/containers/Setup/SetupSubscription/SubscriptionPlan.tsx index 4ebb88d5f..e4463ad70 100644 --- a/packages/webapp/src/containers/Setup/SetupSubscription/SubscriptionPlan.tsx +++ b/packages/webapp/src/containers/Setup/SetupSubscription/SubscriptionPlan.tsx @@ -29,6 +29,7 @@ interface SubscriptionPricingProps { annuallyPriceLabel: string; monthlyVariantId?: string; annuallyVariantId?: string; + onSubscribe?: (variantId: number) => void; } interface SubscriptionPricingCombinedProps @@ -46,6 +47,7 @@ function SubscriptionPlanRoot({ annuallyPriceLabel, monthlyVariantId, annuallyVariantId, + onSubscribe, // #withPlans plansPeriod, @@ -59,17 +61,19 @@ function SubscriptionPlanRoot({ ? monthlyVariantId : annuallyVariantId; - getLemonCheckout({ variantId }) - .then((res) => { - const checkoutUrl = res.data.data.attributes.url; - window.LemonSqueezy.Url.Open(checkoutUrl); - }) - .catch(() => { - AppToaster.show({ - message: 'Something went wrong!', - intent: Intent.DANGER, - }); - }); + onSubscribe && onSubscribe(variantId); + + // getLemonCheckout({ variantId }) + // .then((res) => { + // const checkoutUrl = res.data.data.attributes.url; + // window.LemonSqueezy.Url.Open(checkoutUrl); + // }) + // .catch(() => { + // AppToaster.show({ + // message: 'Something went wrong!', + // intent: Intent.DANGER, + // }); + // }); }; return ( diff --git a/packages/webapp/src/containers/Subscriptions/BillingPageBoot.tsx b/packages/webapp/src/containers/Subscriptions/BillingPageBoot.tsx index 7cebf9759..06bb3513e 100644 --- a/packages/webapp/src/containers/Subscriptions/BillingPageBoot.tsx +++ b/packages/webapp/src/containers/Subscriptions/BillingPageBoot.tsx @@ -15,12 +15,17 @@ interface BillingPageBootProps { } export function BillingPageBoot({ children }: BillingPageBootProps) { - const { isLoading: isSubscriptionsLoading, data: subscriptions } = + const { isLoading: isSubscriptionsLoading, data: subscriptionsRes } = useGetSubscriptions(); + const mainSubscription = subscriptionsRes?.subscriptions?.find( + (s) => s.slug === 'main', + ); + const value = { isSubscriptionsLoading, - subscriptions, + subscriptions: subscriptionsRes?.subscriptions, + mainSubscription, }; return {children}; } diff --git a/packages/webapp/src/containers/Subscriptions/BillingPageContent.tsx b/packages/webapp/src/containers/Subscriptions/BillingPageContent.tsx index bea0ec377..dee9d0159 100644 --- a/packages/webapp/src/containers/Subscriptions/BillingPageContent.tsx +++ b/packages/webapp/src/containers/Subscriptions/BillingPageContent.tsx @@ -1,9 +1,17 @@ +// @ts-nocheck import { Box, Group } from '@/components'; -import { Text } from '@blueprintjs/core'; +import { Spinner, Text } from '@blueprintjs/core'; import { Subscription } from './BillingSubscription'; +import { useBillingPageBoot } from './BillingPageBoot'; import styles from './BillingPageContent.module.scss'; export function BillingPageContent() { + const { isSubscriptionsLoading, subscriptions } = useBillingPageBoot(); + + if (isSubscriptionsLoading || !subscriptions) { + return ; + } + return ( diff --git a/packages/webapp/src/containers/Subscriptions/BillingSubscription.module.scss b/packages/webapp/src/containers/Subscriptions/BillingSubscription.module.scss index 7f218431d..f99f30d8e 100644 --- a/packages/webapp/src/containers/Subscriptions/BillingSubscription.module.scss +++ b/packages/webapp/src/containers/Subscriptions/BillingSubscription.module.scss @@ -12,7 +12,7 @@ .title{ margin: 0; - font-size: 20px; + font-size: 18px; font-weight: 600; color: #3D4C58; } @@ -56,8 +56,4 @@ } .actions{ margin-top: 16px; - - button{ - font-size: 15px; - } } \ No newline at end of file diff --git a/packages/webapp/src/containers/Subscriptions/BillingSubscription.tsx b/packages/webapp/src/containers/Subscriptions/BillingSubscription.tsx index 0bef787a1..aff31eecb 100644 --- a/packages/webapp/src/containers/Subscriptions/BillingSubscription.tsx +++ b/packages/webapp/src/containers/Subscriptions/BillingSubscription.tsx @@ -4,25 +4,42 @@ import { Box, Group, Stack } from '@/components'; import { Button, Card, Intent, Text } from '@blueprintjs/core'; import withAlertActions from '../Alert/withAlertActions'; import styles from './BillingSubscription.module.scss'; +import withDrawerActions from '../Drawer/withDrawerActions'; +import { DRAWERS } from '@/constants/drawers'; +import { useBillingPageBoot } from './BillingPageBoot'; -function SubscriptionRoot({ openAlert }) { +function SubscriptionRoot({ openAlert, openDrawer }) { + const { mainSubscription } = useBillingPageBoot(); + + // Can't continue if the main subscription is not loaded. + if (!mainSubscription) { + return null; + } const handleCancelSubBtnClick = () => { openAlert('cancel-main-subscription'); }; const handleResumeSubBtnClick = () => { openAlert('resume-main-subscription'); }; - const handleUpdatePaymentMethod = () => {}; - - const handleUpgradeBtnClick = () => {}; + const handleUpdatePaymentMethod = () => { + window.LemonSqueezy.Url.Open( + mainSubscription.lemonUrls?.updatePaymentMethod, + ); + }; + // Handle upgrade button click. + const handleUpgradeBtnClick = () => { + openDrawer(DRAWERS.CHANGE_SUBSCARIPTION_PLAN); + }; return ( - -

Capital Essential

+ +

{mainSubscription.planName}

- Trial + + {mainSubscription.statusFormatted} + Trial ends in 10 days.
@@ -43,15 +60,29 @@ function SubscriptionRoot({ openAlert }) { > Upgrade the Plan - + + {mainSubscription.canceled && ( + + )} + {!mainSubscription.canceled && ( + + )} + {mainSubscription.canceled && ( + + )}
); } -export const Subscription = R.compose(withAlertActions)(SubscriptionRoot); +export const Subscription = R.compose( + withAlertActions, + withDrawerActions, +)(SubscriptionRoot); 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..7c99e17e8 --- /dev/null +++ b/packages/webapp/src/containers/Subscriptions/drawers/ChangeSubscriptionPlanDrawer/ChangeSubscriptionPlanContent.tsx @@ -0,0 +1,54 @@ +// @ts-nocheck +import * as R from 'ramda'; +import { Callout, Classes, Intent } from '@blueprintjs/core'; +import { AppToaster, Box } from '@/components'; +import { SubscriptionPlans } from '@/containers/Setup/SetupSubscription/SubscriptionPlans'; +import { SubscriptionPlansPeriodSwitcher } from '@/containers/Setup/SetupSubscription/SubscriptionPlansPeriodSwitcher'; +import { useChangeSubscriptionPlan } from '@/hooks/query/subscription'; +import withDrawerActions from '@/containers/Drawer/withDrawerActions'; +import { DRAWERS } from '@/constants/drawers'; + +function ChangeSubscriptionPlanContent({ closeDrawer }) { + const { mutateAsync: changeSubscriptionPlan } = useChangeSubscriptionPlan(); + + // Handle the subscribe button click. + const handleSubscribe = (variantId: number) => { + changeSubscriptionPlan({ variant_id: variantId }) + .then(() => { + closeDrawer(DRAWERS.CHANGE_SUBSCARIPTION_PLAN); + AppToaster.show({ + intent: Intent.SUCCESS, + message: 'The subscription plan has been changed successfully.', + }); + }) + .catch(() => { + AppToaster.show({ + intent: Intent.DANGER, + message: 'Something went wrong.', + }); + }); + }; + + return ( + + + + 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. + + + + + + + ); +} + +export default R.compose(withDrawerActions)(ChangeSubscriptionPlanContent); diff --git a/packages/webapp/src/hooks/query/subscription.tsx b/packages/webapp/src/hooks/query/subscription.tsx index c9bbeec4e..9050aca1d 100644 --- a/packages/webapp/src/hooks/query/subscription.tsx +++ b/packages/webapp/src/hooks/query/subscription.tsx @@ -9,6 +9,11 @@ import { UseQueryResult, } from 'react-query'; import useApiRequest from '../useRequest'; +import { transformToCamelCase } from '@/utils'; + +const QueryKeys = { + Subscriptions: 'Subscriptions', +}; interface CancelMainSubscriptionValues {} interface CancelMainSubscriptionResponse {} @@ -40,6 +45,9 @@ export function useCancelMainSubscription( (values) => apiRequest.post(`/subscription/cancel`, values).then((res) => res.data), { + onSuccess: () => { + queryClient.invalidateQueries(QueryKeys.Subscriptions); + }, ...options, }, ); @@ -75,6 +83,9 @@ export function useResumeMainSubscription( (values) => apiRequest.post(`/subscription/resume`, values).then((res) => res.data), { + onSuccess: () => { + queryClient.invalidateQueries(QueryKeys.Subscriptions); + }, ...options, }, ); @@ -105,20 +116,58 @@ export function useChangeSubscriptionPlan( const apiRequest = useApiRequest(); return useMutation< - ChangeMainSubscriptionPlanValues, + ChangeMainSubscriptionPlanResponse, Error, - ChangeMainSubscriptionPlanResponse + ChangeMainSubscriptionPlanValues >( (values) => apiRequest.post(`/subscription/change`, values).then((res) => res.data), { + onSuccess: () => { + queryClient.invalidateQueries(QueryKeys.Subscriptions); + }, ...options, }, ); } +interface LemonSubscription { + active: boolean; + canceled: string | null; + canceledAt: string | null; + canceledAtFormatted: string | null; + cancelsAt: string | null; + cancelsAtFormatted: string | null; + createdAt: string; + ended: boolean; + endsAt: string | null; + inactive: boolean; + lemonSubscriptionId: string; + lemon_urls: { + updatePaymentMethod: string; + customerPortal: string; + customerPortalUpdateSubscription: string; + }; + onTrial: boolean; + planId: number; + planName: string; + planSlug: string; + slug: string; + startsAt: string | null; + status: string; + statusFormatted: string; + tenantId: number; + trialEndsAt: string | null; + trialEndsAtFormatted: string | null; + trialStartsAt: string | null; + trialStartsAtFormatted: string | null; + updatedAt: string; +} + interface GetSubscriptionsQuery {} -interface GetSubscriptionsResponse {} +interface GetSubscriptionsResponse { + subscriptions: Array; +} /** * Changese the main subscription of the current organization. @@ -135,8 +184,11 @@ export function useGetSubscriptions( const apiRequest = useApiRequest(); return useQuery( - ['SUBSCRIPTIONS'], - (values) => apiRequest.get(`/subscription`).then((res) => res.data), + [QueryKeys.Subscriptions], + (values) => + apiRequest + .get(`/subscription`) + .then((res) => transformToCamelCase(res.data)), { ...options, }, From 333b6f5a4bb3cad8cb712025442fb1ecbde53088 Mon Sep 17 00:00:00 2001 From: Ahmed Bouhuolia Date: Sun, 28 Jul 2024 20:52:53 +0200 Subject: [PATCH 09/22] feat: change subscription plan --- .../SetupSubscription/SubscriptionPlans.tsx | 15 +++++-- .../ChangeSubscriptionPlanDrawer.tsx | 39 +++++++++++++++++++ .../ChangeSubscriptionPlanDrawer/index.ts | 1 + 3 files changed, 52 insertions(+), 3 deletions(-) create mode 100644 packages/webapp/src/containers/Subscriptions/drawers/ChangeSubscriptionPlanDrawer/ChangeSubscriptionPlanDrawer.tsx create mode 100644 packages/webapp/src/containers/Subscriptions/drawers/ChangeSubscriptionPlanDrawer/index.ts diff --git a/packages/webapp/src/containers/Setup/SetupSubscription/SubscriptionPlans.tsx b/packages/webapp/src/containers/Setup/SetupSubscription/SubscriptionPlans.tsx index 7fa489f0e..9b71ab9ce 100644 --- a/packages/webapp/src/containers/Setup/SetupSubscription/SubscriptionPlans.tsx +++ b/packages/webapp/src/containers/Setup/SetupSubscription/SubscriptionPlans.tsx @@ -1,12 +1,20 @@ -import { Group } from '@/components'; +import { Group, GroupProps } from '@/components'; import { SubscriptionPlan } from './SubscriptionPlan'; import { useSubscriptionPlans } from './hooks'; -export function SubscriptionPlans() { +interface SubscriptionPlansProps { + wrapProps?: GroupProps; + onSubscribe?: (variantId: number) => void; +} + +export function SubscriptionPlans({ + wrapProps, + onSubscribe +}: SubscriptionPlansProps) { const subscriptionPlans = useSubscriptionPlans(); return ( - + {subscriptionPlans.map((plan, index) => ( ))} 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/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 From 1a01461f5db31e4fc724243f6a384afbf78cd872 Mon Sep 17 00:00:00 2001 From: Ahmed Bouhuolia Date: Mon, 29 Jul 2024 16:20:59 +0200 Subject: [PATCH 10/22] feat: delete Plaid item once bank account deleted --- packages/server/src/interfaces/Account.ts | 1 + packages/server/src/loaders/eventEmitter.ts | 2 + .../BankAccounts/DisconnectBankAccount.tsx | 4 +- .../DisconnectPlaidItemOnAccountDeleted.ts | 59 +++++++++++++++++++ 4 files changed, 64 insertions(+), 2 deletions(-) create mode 100644 packages/server/src/services/Banking/BankAccounts/events/DisconnectPlaidItemOnAccountDeleted.ts diff --git a/packages/server/src/interfaces/Account.ts b/packages/server/src/interfaces/Account.ts index b1a880b80..29c75a44e 100644 --- a/packages/server/src/interfaces/Account.ts +++ b/packages/server/src/interfaces/Account.ts @@ -37,6 +37,7 @@ export interface IAccount { accountNormal: string; accountParentType: string; bankBalance: string; + plaidItemId: number | null } export enum AccountNormal { diff --git a/packages/server/src/loaders/eventEmitter.ts b/packages/server/src/loaders/eventEmitter.ts index 8595bed55..9d7e24905 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'; export default () => { return new EventPublisher(); @@ -274,5 +275,6 @@ export const susbcribers = () => { // Plaid RecognizeSyncedBankTranasctions, + DisconnectPlaidItemOnAccountDeleted, ]; }; diff --git a/packages/server/src/services/Banking/BankAccounts/DisconnectBankAccount.tsx b/packages/server/src/services/Banking/BankAccounts/DisconnectBankAccount.tsx index d562b31fc..3169e47ac 100644 --- a/packages/server/src/services/Banking/BankAccounts/DisconnectBankAccount.tsx +++ b/packages/server/src/services/Banking/BankAccounts/DisconnectBankAccount.tsx @@ -25,7 +25,7 @@ export class DisconnectBankAccount { * @param {number} bankAccountId * @returns {Promise} */ - async disconnectBankAccount(tenantId: number, bankAccountId: number) { + public async disconnectBankAccount(tenantId: number, bankAccountId: number) { const { Account, PlaidItem } = this.tenancy.models(tenantId); // Retrieve the bank account or throw not found error. @@ -57,7 +57,7 @@ export class DisconnectBankAccount { isFeedsActive: false, }); // Remove the Plaid item. - const data = await plaidInstance.itemRemove({ + await plaidInstance.itemRemove({ access_token: oldPlaidItem.plaidAccessToken, }); // Triggers `onBankAccountDisconnected` event. 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..958fa0bb3 --- /dev/null +++ b/packages/server/src/services/Banking/BankAccounts/events/DisconnectPlaidItemOnAccountDeleted.ts @@ -0,0 +1,59 @@ +import { IAccountEventDeletedPayload } from '@/interfaces'; +import { PlaidClientWrapper } from '@/lib/Plaid'; +import HasTenancyService from '@/services/Tenancy/TenancyService'; +import events from '@/subscribers/events'; +import { Inject, Service } from 'typedi'; + +@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).findById( + oldAccount.plaidItemId + ); + // Unlink the Plaid item from all account before deleting it. + await Account.query(trx) + .where('plaidItemId', oldAccount.plaidItemId) + .patch({ + plaidItemId: null, + }); + // Remove the Plaid item from the system. + await PlaidItem.query(trx).findById(oldAccount.plaidItemId).delete(); + + if (oldPlaidItem) { + const plaidInstance = new PlaidClientWrapper(); + + // Remove the Plaid item. + await plaidInstance.itemRemove({ + access_token: oldPlaidItem.plaidAccessToken, + }); + } + } +} From f6d4ec504f9e52cb38039e31efb1ae142b68e905 Mon Sep 17 00:00:00 2001 From: Ahmed Bouhuolia Date: Mon, 29 Jul 2024 16:55:50 +0200 Subject: [PATCH 11/22] feat: tweaks in disconnecting bank account --- .../BankAccounts/DisconnectBankAccount.tsx | 3 ++- .../BankAccounts/RefreshBankAccount.tsx | 21 +++++++-------- .../DisconnectPlaidItemOnAccountDeleted.ts | 2 +- .../AccountTransactionsActionsBar.tsx | 26 ++++++++++++------- 4 files changed, 28 insertions(+), 24 deletions(-) diff --git a/packages/server/src/services/Banking/BankAccounts/DisconnectBankAccount.tsx b/packages/server/src/services/Banking/BankAccounts/DisconnectBankAccount.tsx index 3169e47ac..f04b2f90a 100644 --- a/packages/server/src/services/Banking/BankAccounts/DisconnectBankAccount.tsx +++ b/packages/server/src/services/Banking/BankAccounts/DisconnectBankAccount.tsx @@ -7,6 +7,7 @@ import HasTenancyService from '@/services/Tenancy/TenancyService'; import UnitOfWork from '@/services/UnitOfWork'; import events from '@/subscribers/events'; import { ERRORS } from './types'; +import { ACCOUNT_TYPE } from '@/data/AccountTypes'; @Service() export class DisconnectBankAccount { @@ -31,7 +32,7 @@ export class DisconnectBankAccount { // Retrieve the bank account or throw not found error. const account = await Account.query() .findById(bankAccountId) - .whereIn('account_type', ['bank', 'cash']) + .whereIn('account_type', [ACCOUNT_TYPE.CASH, ACCOUNT_TYPE.BANK]) .throwIfNotFound(); const oldPlaidItem = await PlaidItem.query().findById(account.plaidItemId); diff --git a/packages/server/src/services/Banking/BankAccounts/RefreshBankAccount.tsx b/packages/server/src/services/Banking/BankAccounts/RefreshBankAccount.tsx index 814d00c75..282ce06fd 100644 --- a/packages/server/src/services/Banking/BankAccounts/RefreshBankAccount.tsx +++ b/packages/server/src/services/Banking/BankAccounts/RefreshBankAccount.tsx @@ -1,19 +1,19 @@ +import { Inject, Service } from 'typedi'; import { ServiceError } from '@/exceptions'; import { PlaidClientWrapper } from '@/lib/Plaid'; import HasTenancyService from '@/services/Tenancy/TenancyService'; -import UnitOfWork from '@/services/UnitOfWork'; -import { Inject } from 'typedi'; +import { ERRORS } from './types'; + +@Service() export class RefreshBankAccountService { @Inject() private tenancy: HasTenancyService; - @Inject() - private uow: UnitOfWork; - /** - * + * 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); @@ -23,17 +23,14 @@ export class RefreshBankAccountService { .withGraphFetched('plaidItem') .throwIfNotFound(); + // Can't continue if the given account is not linked with Plaid item. if (!bankAccount.plaidItem) { - throw new ServiceError(''); + throw new ServiceError(ERRORS.BANK_ACCOUNT_NOT_CONNECTED); } const plaidInstance = new PlaidClientWrapper(); - const data = await plaidInstance.transactionsRefresh({ + await plaidInstance.transactionsRefresh({ access_token: bankAccount.plaidItem.plaidAccessToken, }); - await Account.query().findById(bankAccountId).patch({ - isFeedsActive: true, - lastFeedsUpdatedAt: new Date(), - }); } } diff --git a/packages/server/src/services/Banking/BankAccounts/events/DisconnectPlaidItemOnAccountDeleted.ts b/packages/server/src/services/Banking/BankAccounts/events/DisconnectPlaidItemOnAccountDeleted.ts index 958fa0bb3..16d19e222 100644 --- a/packages/server/src/services/Banking/BankAccounts/events/DisconnectPlaidItemOnAccountDeleted.ts +++ b/packages/server/src/services/Banking/BankAccounts/events/DisconnectPlaidItemOnAccountDeleted.ts @@ -1,8 +1,8 @@ +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'; -import { Inject, Service } from 'typedi'; @Service() export class DisconnectPlaidItemOnAccountDeleted { diff --git a/packages/webapp/src/containers/CashFlow/AccountTransactions/AccountTransactionsActionsBar.tsx b/packages/webapp/src/containers/CashFlow/AccountTransactions/AccountTransactionsActionsBar.tsx index e9635524c..e6c3a6a3c 100644 --- a/packages/webapp/src/containers/CashFlow/AccountTransactions/AccountTransactionsActionsBar.tsx +++ b/packages/webapp/src/containers/CashFlow/AccountTransactions/AccountTransactionsActionsBar.tsx @@ -53,7 +53,7 @@ function AccountTransactionsActionsBar({ addSetting, }) { const history = useHistory(); - const { accountId } = useAccountTransactionsContext(); + const { accountId, currentAccount } = useAccountTransactionsContext(); // Refresh cashflow infinity transactions hook. const { refresh } = useRefreshCashflowTransactionsInfinity(); @@ -65,6 +65,8 @@ function AccountTransactionsActionsBar({ const addMoneyInOptions = useMemo(() => getAddMoneyInOptions(), []); const addMoneyOutOptions = useMemo(() => getAddMoneyOutOptions(), []); + const isFeedsActive = !!currentAccount.is_feeds_active; + // Handle table row size change. const handleTableRowSizeChange = (size) => { addSetting('cashflowTransactions', 'tableSize', size); @@ -94,8 +96,6 @@ function AccountTransactionsActionsBar({ history.push(`/bank-rules?accountId=${accountId}`); }; - const isConnected = true; - // Handles the bank account disconnect click. const handleDisconnectClick = () => { disconnectBankAccount({ bankAccountId: accountId }) @@ -177,14 +177,18 @@ function AccountTransactionsActionsBar({