From 1869ba216f025b2ad664a818ea02e26a7875ad5a Mon Sep 17 00:00:00 2001 From: Ahmed Bouhuolia Date: Sun, 5 Jan 2025 16:26:23 +0200 Subject: [PATCH] refactor: banking services to Nestjs --- packages/server-nest/package.json | 7 +- packages/server-nest/src/models/Model.ts | 23 +- .../modules/Accounts/models/Account.model.ts | 26 +- .../server-nest/src/modules/App/App.module.ts | 5 + .../modules/BankRules/BankRules.controller.ts | 46 +++ .../src/modules/BankRules/BankRules.module.ts | 34 +++ .../modules/BankRules/BankRulesApplication.ts | 67 +++++ .../commands/CreateBankRule.service.ts | 61 ++++ .../commands/DeleteBankRule.service.ts | 59 ++++ .../commands/DeleteBankRules.service.ts | 28 ++ .../commands/EditBankRule.service.ts | 72 +++++ .../events/UnlinkBankRuleOnDeleteBankRule.ts | 23 ++ .../src/modules/BankRules/models/BankRule.ts | 74 +++++ .../BankRules/models/BankRuleCondition.ts | 30 ++ .../BankRules/queries/GetBankRule.service.ts | 30 ++ .../queries/GetBankRuleTransformer.ts | 11 + .../BankRules/queries/GetBankRules.service.ts | 30 ++ .../queries/GetBankRulesTransformer.ts | 49 ++++ .../src/modules/BankRules/types.ts | 124 +++++++++ .../BankAccounts.controller.ts | 27 ++ .../BankingAccounts/BankAccounts.module.ts | 24 ++ .../BankAccountsApplication.service.ts | 57 ++++ .../commands/DisconnectBankAccount.service.ts | 75 +++++ .../commands/PauseBankAccountFeeds.service.ts | 47 ++++ .../commands/RefreshBankAccount.service.ts | 35 +++ .../ResumeBankAccountFeeds.service.ts | 46 +++ .../queries/GetBankAccountSummary.ts | 107 +++++++ ...ategorizedTransactionsOnAccountDeleting.ts | 55 ++++ .../DisconnectPlaidItemOnAccountDeleted.ts | 57 ++++ .../types/BankAccounts.types.ts | 17 ++ .../commands/CategorizeCashflowTransaction.ts | 110 ++++++++ .../CategorizeTransactionAsExpense.ts | 77 ++++++ .../CreateUncategorizedTransaction.service.ts | 61 ++++ ...UncategorizeCashflowTransaction.service.ts | 98 +++++++ ...egorizeCashflowTransactionsBulk.service.ts | 32 +++ .../UncategorizedTransaction.transformer.ts | 145 ++++++++++ .../UncategorizedTransactionsImportable.ts | 99 +++++++ .../types/BankingCategorize.types.ts | 63 +++++ .../BankingMatching/BankingMatching.module.ts | 39 +++ .../BankingMatchingApplication.ts | 65 +++++ .../src/modules/BankingMatching/_utils.ts | 64 +++++ .../commands/MatchTransactions.ts | 149 ++++++++++ .../commands/MatchTransactionsTypes.ts | 63 +++++ .../MatchTransactionsTypesRegistry.ts | 50 ++++ .../UnmatchMatchedTransaction.service.ts | 46 +++ .../ValidateTransactionsMatched.service.ts | 34 +++ ...crementUncategorizedTransactionsOnMatch.ts | 64 +++++ .../ValidateMatchingOnCashflowDelete.ts | 28 ++ .../events/ValidateMatchingOnExpenseDelete.ts | 28 ++ .../ValidateMatchingOnManualJournalDelete.ts | 28 ++ .../ValidateMatchingOnPaymentMadeDelete.ts | 28 ++ ...ValidateMatchingOnPaymentReceivedDelete.ts | 28 ++ .../models/MatchedBankTransaction.ts | 36 +++ .../GetMatchedTransactionBillsTransformer.ts | 131 +++++++++ ...etMatchedTransactionCashflowTransformer.ts | 142 ++++++++++ ...etMatchedTransactionExpensesTransformer.ts | 142 ++++++++++ ...etMatchedTransactionInvoicesTransformer.ts | 138 +++++++++ ...hedTransactionManualJournalsTransformer.ts | 149 ++++++++++ .../queries/GetMatchedTransactions.service.ts | 107 +++++++ .../GetMatchedTransactionsByBills.service.ts | 139 ++++++++++ .../GetMatchedTransactionsByCashflow.ts | 77 ++++++ .../GetMatchedTransactionsByExpenses.ts | 74 +++++ ...etMatchedTransactionsByInvoices.service.ts | 132 +++++++++ ...hedTransactionsByManualJournals.service.ts | 75 +++++ .../queries/GetMatchedTransactionsByType.ts | 66 +++++ .../src/modules/BankingMatching/types.ts | 73 +++++ .../BankingPlaid/BankingPlaid.module.ts | 20 ++ .../modules/BankingPlaid/PlaidApplication.ts | 50 ++++ .../BankingPlaid/PlaidFetchTransactionsJob.ts | 43 +++ .../PlaidWebhookTenantBootMiddleware.ts | 32 +++ .../modules/BankingPlaid/command/PlaidItem.ts | 63 +++++ .../BankingPlaid/command/PlaidSyncDB.ts | 245 ++++++++++++++++ .../command/PlaidUpdateTransactions.ts | 148 ++++++++++ .../BankingPlaid/command/PlaidWebhooks.ts | 152 ++++++++++ .../models/PlaidItem.ts} | 5 + .../BankingPlaid/models/SystemPlaidItem.ts | 49 ++++ .../queries/GetPlaidLinkToken.service.ts | 41 +++ ...dateTransactionsOnItemCreatedSubscriber.ts | 22 ++ ...ognizeSyncedBankTransactions.subscriber.ts | 30 ++ .../BankingPlaid/types/BankingPlaid.types.ts | 32 +++ .../src/modules/BankingPlaid/utils.ts | 85 ++++++ .../BankingTransactionsRegonize.module.ts | 26 ++ .../BankingTranasctionsRegonize/_types.ts | 11 + .../BankingTranasctionsRegonize/_utils.ts | 116 ++++++++ .../commands/RecognizeTranasctions.service.ts | 129 +++++++++ .../RevertRecognizedTransactions.service.ts | 70 +++++ .../events/TriggerRecognizedTransactions.ts | 83 ++++++ .../jobs/RecognizeTransactionsJob.ts | 36 +++ .../jobs/RerecognizeTransactionsJob.ts | 45 +++ .../jobs/RevertRecognizedTransactionsJob.ts | 38 +++ .../models/RecognizedBankTransaction.ts | 79 ++++++ ...etAutofillCategorizeTransaction.service.ts | 42 +++ ...utofillCategorizeTransactionTransformer.ts | 176 ++++++++++++ .../BankingTransactions.module.ts | 35 +++ .../BankingTransactionsApplication.service.ts | 58 ++++ .../BankTransactionAutoIncrement.service.ts | 26 ++ .../commands/BankTransactionGL.ts | 102 +++++++ .../commands/BankTransactionGLEntries.ts | 54 ++++ .../CommandCasflowValidator.service.ts | 109 ++++++++ .../commands/CreateBankTransaction.service.ts | 168 +++++++++++ .../DeleteCashflowTransaction.service.ts | 84 ++++++ ...PendingUncategorizedTransaction.service.ts | 67 +++++ ...teDeleteBankAccountTransactions.service.ts | 27 ++ .../modules/BankingTransactions/constants.ts | 149 ++++++++++ .../models/BankTransaction.ts | 231 ++++++++++++++++ .../models/BankTransactionLine.ts | 46 +++ .../models/UncategorizedBankTransaction.ts | 242 ++++++++++++++++ .../queries/BankAccountTransformer.ts | 62 +++++ .../queries/BankTransactionTransformer.ts | 55 ++++ .../queries/BankTransactionsTransformer.ts | 70 +++++ .../queries/GetBankAccounts.service.ts | 61 ++++ .../queries/GetBankTransaction.service.ts | 51 ++++ ...etPendingBankAccountTransaction.service.ts | 48 ++++ ...endingBankAccountTransactionTransformer.ts | 72 +++++ .../GetRecognizedTransaction.service.ts | 37 +++ .../GetRecognizedTransactionTransformer.ts | 261 ++++++++++++++++++ .../queries/GetRecongizedTransactions.ts | 67 +++++ ...GetUncategorizedBankTransaction.service.ts | 32 +++ .../queries/GetUncategorizedTransactions.ts | 73 +++++ .../CashflowTransactionSubscriber.ts | 60 ++++ .../CashflowWithAccountSubscriber.ts | 26 ++ ...entUncategorizedTransactionOnCategorize.ts | 88 ++++++ ...DeleteCashflowTransactionOnUncategorize.ts | 35 +++ .../PreventDeleteTransactionsOnDelete.ts | 42 +++ .../types/BankingTransactions.types.ts | 122 ++++++++ .../src/modules/BankingTransactions/utils.ts | 124 +++++++++ .../BankingTransactionsExclude.controller.ts | 53 ++++ .../BankingTransactionsExclude.module.ts | 23 ++ .../ExcludeBankTransactionsApplication.ts | 77 ++++++ .../ExcludeBankTransaction.service.ts | 63 +++++ .../ExcludeBankTransactions.service.ts | 30 ++ .../UnexcludeBankTransaction.service.ts | 64 +++++ .../UnexcludeBankTransactions.service.ts | 29 ++ .../commands/utils.ts | 32 +++ .../queries/GetExcludedBankTransactions.ts | 60 ++++ ...rementUncategorizedTransactionOnExclude.ts | 56 ++++ .../types/BankTransactionsExclude.types.ts | 30 ++ .../BankingTransactionsExclude/utils.ts | 31 +++ .../src/modules/Bills/models/Bill.ts | 226 +++++++-------- .../src/modules/Import/Importable.ts | 3 +- .../src/modules/Import/ImportableResources.ts | 2 +- .../src/modules/Import/interfaces.ts | 3 +- .../src/modules/Items/Item.controller.ts | 5 +- .../src/modules/Plaid/Plaid.module.ts | 29 ++ .../SystemModels/SystemModels.module.ts | 7 +- .../Tenancy/TenancyModels/Tenancy.module.ts | 17 +- .../src/utils/transform-to-map-by.ts | 5 + packages/server-nest/test/items.e2e-spec.ts | 113 ++++++-- .../src/models/RecognizedBankTransaction.ts | 7 + pnpm-lock.yaml | 18 +- 150 files changed, 9698 insertions(+), 163 deletions(-) create mode 100644 packages/server-nest/src/modules/BankRules/BankRules.controller.ts create mode 100644 packages/server-nest/src/modules/BankRules/BankRules.module.ts create mode 100644 packages/server-nest/src/modules/BankRules/BankRulesApplication.ts create mode 100644 packages/server-nest/src/modules/BankRules/commands/CreateBankRule.service.ts create mode 100644 packages/server-nest/src/modules/BankRules/commands/DeleteBankRule.service.ts create mode 100644 packages/server-nest/src/modules/BankRules/commands/DeleteBankRules.service.ts create mode 100644 packages/server-nest/src/modules/BankRules/commands/EditBankRule.service.ts create mode 100644 packages/server-nest/src/modules/BankRules/events/UnlinkBankRuleOnDeleteBankRule.ts create mode 100644 packages/server-nest/src/modules/BankRules/models/BankRule.ts create mode 100644 packages/server-nest/src/modules/BankRules/models/BankRuleCondition.ts create mode 100644 packages/server-nest/src/modules/BankRules/queries/GetBankRule.service.ts create mode 100644 packages/server-nest/src/modules/BankRules/queries/GetBankRuleTransformer.ts create mode 100644 packages/server-nest/src/modules/BankRules/queries/GetBankRules.service.ts create mode 100644 packages/server-nest/src/modules/BankRules/queries/GetBankRulesTransformer.ts create mode 100644 packages/server-nest/src/modules/BankRules/types.ts create mode 100644 packages/server-nest/src/modules/BankingAccounts/BankAccounts.controller.ts create mode 100644 packages/server-nest/src/modules/BankingAccounts/BankAccounts.module.ts create mode 100644 packages/server-nest/src/modules/BankingAccounts/BankAccountsApplication.service.ts create mode 100644 packages/server-nest/src/modules/BankingAccounts/commands/DisconnectBankAccount.service.ts create mode 100644 packages/server-nest/src/modules/BankingAccounts/commands/PauseBankAccountFeeds.service.ts create mode 100644 packages/server-nest/src/modules/BankingAccounts/commands/RefreshBankAccount.service.ts create mode 100644 packages/server-nest/src/modules/BankingAccounts/commands/ResumeBankAccountFeeds.service.ts create mode 100644 packages/server-nest/src/modules/BankingAccounts/queries/GetBankAccountSummary.ts create mode 100644 packages/server-nest/src/modules/BankingAccounts/subscribers/DeleteUncategorizedTransactionsOnAccountDeleting.ts create mode 100644 packages/server-nest/src/modules/BankingAccounts/subscribers/DisconnectPlaidItemOnAccountDeleted.ts create mode 100644 packages/server-nest/src/modules/BankingAccounts/types/BankAccounts.types.ts create mode 100644 packages/server-nest/src/modules/BankingCategorize/commands/CategorizeCashflowTransaction.ts create mode 100644 packages/server-nest/src/modules/BankingCategorize/commands/CategorizeTransactionAsExpense.ts create mode 100644 packages/server-nest/src/modules/BankingCategorize/commands/CreateUncategorizedTransaction.service.ts create mode 100644 packages/server-nest/src/modules/BankingCategorize/commands/UncategorizeCashflowTransaction.service.ts create mode 100644 packages/server-nest/src/modules/BankingCategorize/commands/UncategorizeCashflowTransactionsBulk.service.ts create mode 100644 packages/server-nest/src/modules/BankingCategorize/commands/UncategorizedTransaction.transformer.ts create mode 100644 packages/server-nest/src/modules/BankingCategorize/commands/UncategorizedTransactionsImportable.ts create mode 100644 packages/server-nest/src/modules/BankingCategorize/types/BankingCategorize.types.ts create mode 100644 packages/server-nest/src/modules/BankingMatching/BankingMatching.module.ts create mode 100644 packages/server-nest/src/modules/BankingMatching/BankingMatchingApplication.ts create mode 100644 packages/server-nest/src/modules/BankingMatching/_utils.ts create mode 100644 packages/server-nest/src/modules/BankingMatching/commands/MatchTransactions.ts create mode 100644 packages/server-nest/src/modules/BankingMatching/commands/MatchTransactionsTypes.ts create mode 100644 packages/server-nest/src/modules/BankingMatching/commands/MatchTransactionsTypesRegistry.ts create mode 100644 packages/server-nest/src/modules/BankingMatching/commands/UnmatchMatchedTransaction.service.ts create mode 100644 packages/server-nest/src/modules/BankingMatching/commands/ValidateTransactionsMatched.service.ts create mode 100644 packages/server-nest/src/modules/BankingMatching/events/DecrementUncategorizedTransactionsOnMatch.ts create mode 100644 packages/server-nest/src/modules/BankingMatching/events/ValidateMatchingOnCashflowDelete.ts create mode 100644 packages/server-nest/src/modules/BankingMatching/events/ValidateMatchingOnExpenseDelete.ts create mode 100644 packages/server-nest/src/modules/BankingMatching/events/ValidateMatchingOnManualJournalDelete.ts create mode 100644 packages/server-nest/src/modules/BankingMatching/events/ValidateMatchingOnPaymentMadeDelete.ts create mode 100644 packages/server-nest/src/modules/BankingMatching/events/ValidateMatchingOnPaymentReceivedDelete.ts create mode 100644 packages/server-nest/src/modules/BankingMatching/models/MatchedBankTransaction.ts create mode 100644 packages/server-nest/src/modules/BankingMatching/queries/GetMatchedTransactionBillsTransformer.ts create mode 100644 packages/server-nest/src/modules/BankingMatching/queries/GetMatchedTransactionCashflowTransformer.ts create mode 100644 packages/server-nest/src/modules/BankingMatching/queries/GetMatchedTransactionExpensesTransformer.ts create mode 100644 packages/server-nest/src/modules/BankingMatching/queries/GetMatchedTransactionInvoicesTransformer.ts create mode 100644 packages/server-nest/src/modules/BankingMatching/queries/GetMatchedTransactionManualJournalsTransformer.ts create mode 100644 packages/server-nest/src/modules/BankingMatching/queries/GetMatchedTransactions.service.ts create mode 100644 packages/server-nest/src/modules/BankingMatching/queries/GetMatchedTransactionsByBills.service.ts create mode 100644 packages/server-nest/src/modules/BankingMatching/queries/GetMatchedTransactionsByCashflow.ts create mode 100644 packages/server-nest/src/modules/BankingMatching/queries/GetMatchedTransactionsByExpenses.ts create mode 100644 packages/server-nest/src/modules/BankingMatching/queries/GetMatchedTransactionsByInvoices.service.ts create mode 100644 packages/server-nest/src/modules/BankingMatching/queries/GetMatchedTransactionsByManualJournals.service.ts create mode 100644 packages/server-nest/src/modules/BankingMatching/queries/GetMatchedTransactionsByType.ts create mode 100644 packages/server-nest/src/modules/BankingMatching/types.ts create mode 100644 packages/server-nest/src/modules/BankingPlaid/BankingPlaid.module.ts create mode 100644 packages/server-nest/src/modules/BankingPlaid/PlaidApplication.ts create mode 100644 packages/server-nest/src/modules/BankingPlaid/PlaidFetchTransactionsJob.ts create mode 100644 packages/server-nest/src/modules/BankingPlaid/PlaidWebhookTenantBootMiddleware.ts create mode 100644 packages/server-nest/src/modules/BankingPlaid/command/PlaidItem.ts create mode 100644 packages/server-nest/src/modules/BankingPlaid/command/PlaidSyncDB.ts create mode 100644 packages/server-nest/src/modules/BankingPlaid/command/PlaidUpdateTransactions.ts create mode 100644 packages/server-nest/src/modules/BankingPlaid/command/PlaidWebhooks.ts rename packages/server-nest/src/modules/{Banking/models/PlaidItem.model.ts => BankingPlaid/models/PlaidItem.ts} (83%) create mode 100644 packages/server-nest/src/modules/BankingPlaid/models/SystemPlaidItem.ts create mode 100644 packages/server-nest/src/modules/BankingPlaid/queries/GetPlaidLinkToken.service.ts create mode 100644 packages/server-nest/src/modules/BankingPlaid/subscribers/PlaidUpdateTransactionsOnItemCreatedSubscriber.ts create mode 100644 packages/server-nest/src/modules/BankingPlaid/subscribers/RecognizeSyncedBankTransactions.subscriber.ts create mode 100644 packages/server-nest/src/modules/BankingPlaid/types/BankingPlaid.types.ts create mode 100644 packages/server-nest/src/modules/BankingPlaid/utils.ts create mode 100644 packages/server-nest/src/modules/BankingTranasctionsRegonize/BankingTransactionsRegonize.module.ts create mode 100644 packages/server-nest/src/modules/BankingTranasctionsRegonize/_types.ts create mode 100644 packages/server-nest/src/modules/BankingTranasctionsRegonize/_utils.ts create mode 100644 packages/server-nest/src/modules/BankingTranasctionsRegonize/commands/RecognizeTranasctions.service.ts create mode 100644 packages/server-nest/src/modules/BankingTranasctionsRegonize/commands/RevertRecognizedTransactions.service.ts create mode 100644 packages/server-nest/src/modules/BankingTranasctionsRegonize/events/TriggerRecognizedTransactions.ts create mode 100644 packages/server-nest/src/modules/BankingTranasctionsRegonize/jobs/RecognizeTransactionsJob.ts create mode 100644 packages/server-nest/src/modules/BankingTranasctionsRegonize/jobs/RerecognizeTransactionsJob.ts create mode 100644 packages/server-nest/src/modules/BankingTranasctionsRegonize/jobs/RevertRecognizedTransactionsJob.ts create mode 100644 packages/server-nest/src/modules/BankingTranasctionsRegonize/models/RecognizedBankTransaction.ts create mode 100644 packages/server-nest/src/modules/BankingTranasctionsRegonize/queries/GetAutofillCategorizeTransaction.service.ts create mode 100644 packages/server-nest/src/modules/BankingTranasctionsRegonize/queries/GetAutofillCategorizeTransactionTransformer.ts create mode 100644 packages/server-nest/src/modules/BankingTransactions/BankingTransactions.module.ts create mode 100644 packages/server-nest/src/modules/BankingTransactions/BankingTransactionsApplication.service.ts create mode 100644 packages/server-nest/src/modules/BankingTransactions/commands/BankTransactionAutoIncrement.service.ts create mode 100644 packages/server-nest/src/modules/BankingTransactions/commands/BankTransactionGL.ts create mode 100644 packages/server-nest/src/modules/BankingTransactions/commands/BankTransactionGLEntries.ts create mode 100644 packages/server-nest/src/modules/BankingTransactions/commands/CommandCasflowValidator.service.ts create mode 100644 packages/server-nest/src/modules/BankingTransactions/commands/CreateBankTransaction.service.ts create mode 100644 packages/server-nest/src/modules/BankingTransactions/commands/DeleteCashflowTransaction.service.ts create mode 100644 packages/server-nest/src/modules/BankingTransactions/commands/RemovePendingUncategorizedTransaction.service.ts create mode 100644 packages/server-nest/src/modules/BankingTransactions/commands/ValidateDeleteBankAccountTransactions.service.ts create mode 100644 packages/server-nest/src/modules/BankingTransactions/constants.ts create mode 100644 packages/server-nest/src/modules/BankingTransactions/models/BankTransaction.ts create mode 100644 packages/server-nest/src/modules/BankingTransactions/models/BankTransactionLine.ts create mode 100644 packages/server-nest/src/modules/BankingTransactions/models/UncategorizedBankTransaction.ts create mode 100644 packages/server-nest/src/modules/BankingTransactions/queries/BankAccountTransformer.ts create mode 100644 packages/server-nest/src/modules/BankingTransactions/queries/BankTransactionTransformer.ts create mode 100644 packages/server-nest/src/modules/BankingTransactions/queries/BankTransactionsTransformer.ts create mode 100644 packages/server-nest/src/modules/BankingTransactions/queries/GetBankAccounts.service.ts create mode 100644 packages/server-nest/src/modules/BankingTransactions/queries/GetBankTransaction.service.ts create mode 100644 packages/server-nest/src/modules/BankingTransactions/queries/GetPendingBankAccountTransaction.service.ts create mode 100644 packages/server-nest/src/modules/BankingTransactions/queries/GetPendingBankAccountTransactionTransformer.ts create mode 100644 packages/server-nest/src/modules/BankingTransactions/queries/GetRecognizedTransaction.service.ts create mode 100644 packages/server-nest/src/modules/BankingTransactions/queries/GetRecognizedTransactionTransformer.ts create mode 100644 packages/server-nest/src/modules/BankingTransactions/queries/GetRecongizedTransactions.ts create mode 100644 packages/server-nest/src/modules/BankingTransactions/queries/GetUncategorizedBankTransaction.service.ts create mode 100644 packages/server-nest/src/modules/BankingTransactions/queries/GetUncategorizedTransactions.ts create mode 100644 packages/server-nest/src/modules/BankingTransactions/subscribers/CashflowTransactionSubscriber.ts create mode 100644 packages/server-nest/src/modules/BankingTransactions/subscribers/CashflowWithAccountSubscriber.ts create mode 100644 packages/server-nest/src/modules/BankingTransactions/subscribers/DecrementUncategorizedTransactionOnCategorize.ts create mode 100644 packages/server-nest/src/modules/BankingTransactions/subscribers/DeleteCashflowTransactionOnUncategorize.ts create mode 100644 packages/server-nest/src/modules/BankingTransactions/subscribers/PreventDeleteTransactionsOnDelete.ts create mode 100644 packages/server-nest/src/modules/BankingTransactions/types/BankingTransactions.types.ts create mode 100644 packages/server-nest/src/modules/BankingTransactions/utils.ts create mode 100644 packages/server-nest/src/modules/BankingTransactionsExclude/BankingTransactionsExclude.controller.ts create mode 100644 packages/server-nest/src/modules/BankingTransactionsExclude/BankingTransactionsExclude.module.ts create mode 100644 packages/server-nest/src/modules/BankingTransactionsExclude/ExcludeBankTransactionsApplication.ts create mode 100644 packages/server-nest/src/modules/BankingTransactionsExclude/commands/ExcludeBankTransaction.service.ts create mode 100644 packages/server-nest/src/modules/BankingTransactionsExclude/commands/ExcludeBankTransactions.service.ts create mode 100644 packages/server-nest/src/modules/BankingTransactionsExclude/commands/UnexcludeBankTransaction.service.ts create mode 100644 packages/server-nest/src/modules/BankingTransactionsExclude/commands/UnexcludeBankTransactions.service.ts create mode 100644 packages/server-nest/src/modules/BankingTransactionsExclude/commands/utils.ts create mode 100644 packages/server-nest/src/modules/BankingTransactionsExclude/queries/GetExcludedBankTransactions.ts create mode 100644 packages/server-nest/src/modules/BankingTransactionsExclude/subscribers/DecrementUncategorizedTransactionOnExclude.ts create mode 100644 packages/server-nest/src/modules/BankingTransactionsExclude/types/BankTransactionsExclude.types.ts create mode 100644 packages/server-nest/src/modules/BankingTransactionsExclude/utils.ts create mode 100644 packages/server-nest/src/modules/Plaid/Plaid.module.ts create mode 100644 packages/server-nest/src/utils/transform-to-map-by.ts diff --git a/packages/server-nest/package.json b/packages/server-nest/package.json index 438b0cc01..182763618 100644 --- a/packages/server-nest/package.json +++ b/packages/server-nest/package.json @@ -72,7 +72,11 @@ "rxjs": "^7.8.1", "serialize-interceptor": "^1.1.7", "strategy": "^1.1.1", - "zod": "^3.23.8" + "zod": "^3.23.8", + "plaid": "^10.3.0", + "@supercharge/promise-pool": "^3.2.0", + "yup": "^0.28.1", + "uniqid": "^5.2.0" }, "devDependencies": { "@nestjs/cli": "^10.0.0", @@ -82,6 +86,7 @@ "@types/jest": "^29.5.2", "@types/node": "^20.3.1", "@types/supertest": "^6.0.0", + "@types/yup": "^0.29.13", "@typescript-eslint/eslint-plugin": "^8.0.0", "@typescript-eslint/parser": "^8.0.0", "eslint": "^9.0.0", diff --git a/packages/server-nest/src/models/Model.ts b/packages/server-nest/src/models/Model.ts index 9551179f6..815dd246e 100644 --- a/packages/server-nest/src/models/Model.ts +++ b/packages/server-nest/src/models/Model.ts @@ -1,6 +1,25 @@ -import { Model } from 'objection'; +import { Constructor, Model, QueryBuilderType, TransactionOrKnex } from 'objection'; export class BaseModel extends Model { public readonly id: number; public readonly tableName: string; -} \ No newline at end of file + + static get QueryBuilder() { + return PaginationQueryBuilder; + } +} + +class PaginationQueryBuilder extends Model.QueryBuilder { + pagination(page: number, pageSize: number) { + return super.page(page, pageSize).runAfter(({ results, total }) => { + return { + results, + pagination: { + total, + page: page + 1, + pageSize, + }, + }; + }); + } +} diff --git a/packages/server-nest/src/modules/Accounts/models/Account.model.ts b/packages/server-nest/src/modules/Accounts/models/Account.model.ts index 75834b3c4..103e5cd1b 100644 --- a/packages/server-nest/src/modules/Accounts/models/Account.model.ts +++ b/packages/server-nest/src/modules/Accounts/models/Account.model.ts @@ -12,6 +12,7 @@ import { TenantModel } from '@/modules/System/models/TenantModel'; // import { ModelSettings } from '@/modules/Settings/ModelSettings'; import { AccountTypesUtils } from '@/libs/accounts-utils/AccountTypesUtils'; import { Model } from 'objection'; +import { PlaidItem } from '@/modules/BankingPlaid/models/PlaidItem'; // import AccountSettings from './Account.Settings'; // import { DEFAULT_VIEWS } from '@/modules/Accounts/constants'; // import { buildFilterQuery, buildSortColumnQuery } from '@/lib/ViewRolesBuilder'; @@ -25,17 +26,20 @@ import { Model } from 'objection'; // ]) { export class Account extends TenantModel { - name: string; - slug: string; - code: string; - index: number; - accountType: string; - predefined: boolean; - currencyCode: string; - active: boolean; - bankBalance: number; - lastFeedsUpdatedAt: string | null; - amount: number; + public name!: string; + public slug!: string; + public code!: string; + public index!: number; + public accountType!: string; + public predefined!: boolean; + public currencyCode!: string; + public active!: boolean; + public bankBalance!: number; + public lastFeedsUpdatedAt!: string | null; + public amount!: number; + public plaidItemId!: number; + + public plaidItem!: PlaidItem; /** * Table name. diff --git a/packages/server-nest/src/modules/App/App.module.ts b/packages/server-nest/src/modules/App/App.module.ts index f95c76ef5..c1e851896 100644 --- a/packages/server-nest/src/modules/App/App.module.ts +++ b/packages/server-nest/src/modules/App/App.module.ts @@ -54,6 +54,8 @@ import { CreditNoteRefundsModule } from '../CreditNoteRefunds/CreditNoteRefunds. import { BillPaymentsModule } from '../BillPayments/BillPayments.module'; import { PaymentsReceivedModule } from '../PaymentReceived/PaymentsReceived.module'; import { LedgerModule } from '../Ledger/Ledger.module'; +import { BankRulesModule } from '../BankRules/BankRules.module'; +import { BankAccountsModule } from '../BankingAccounts/BankAccounts.module'; @Module({ imports: [ @@ -132,6 +134,9 @@ import { LedgerModule } from '../Ledger/Ledger.module'; BillPaymentsModule, PaymentsReceivedModule, LedgerModule, + + BankAccountsModule, + BankRulesModule, ], controllers: [AppController], providers: [ diff --git a/packages/server-nest/src/modules/BankRules/BankRules.controller.ts b/packages/server-nest/src/modules/BankRules/BankRules.controller.ts new file mode 100644 index 000000000..1979f19f2 --- /dev/null +++ b/packages/server-nest/src/modules/BankRules/BankRules.controller.ts @@ -0,0 +1,46 @@ +import { + Body, + Controller, + Delete, + Get, + Param, + Post, + Put, +} from '@nestjs/common'; +import { BankRulesApplication } from './BankRulesApplication'; +import { ICreateBankRuleDTO, IEditBankRuleDTO } from './types'; + +@Controller('banking/rules') +export class BankRulesController { + constructor(private readonly bankRulesApplication: BankRulesApplication) {} + + @Post() + async createBankRule( + @Body() createRuleDTO: ICreateBankRuleDTO, + ): Promise { + return this.bankRulesApplication.createBankRule(createRuleDTO); + } + + @Put(':id') + async editBankRule( + @Param('id') ruleId: number, + @Body() editRuleDTO: IEditBankRuleDTO, + ): Promise { + return this.bankRulesApplication.editBankRule(ruleId, editRuleDTO); + } + + @Delete(':id') + async deleteBankRule(@Param('id') ruleId: number): Promise { + return this.bankRulesApplication.deleteBankRule(ruleId); + } + + @Get(':id') + async getBankRule(@Param('id') ruleId: number): Promise { + return this.bankRulesApplication.getBankRule(ruleId); + } + + @Get() + async getBankRules(): Promise { + return this.bankRulesApplication.getBankRules(); + } +} diff --git a/packages/server-nest/src/modules/BankRules/BankRules.module.ts b/packages/server-nest/src/modules/BankRules/BankRules.module.ts new file mode 100644 index 000000000..444685ec4 --- /dev/null +++ b/packages/server-nest/src/modules/BankRules/BankRules.module.ts @@ -0,0 +1,34 @@ +import { Module } from '@nestjs/common'; +import { CreateBankRuleService } from './commands/CreateBankRule.service'; +import { EditBankRuleService } from './commands/EditBankRule.service'; +import { DeleteBankRuleService } from './commands/DeleteBankRule.service'; +import { GetBankRulesService } from './queries/GetBankRules.service'; +import { GetBankRuleService } from './queries/GetBankRule.service'; +import { BankRulesApplication } from './BankRulesApplication'; +import { RegisterTenancyModel } from '../Tenancy/TenancyModels/Tenancy.module'; +import { BankRuleCondition } from './models/BankRuleCondition'; +import { BankRule } from './models/BankRule'; +import { BankRulesController } from './BankRules.controller'; +import { UnlinkBankRuleOnDeleteBankRuleSubscriber } from './events/UnlinkBankRuleOnDeleteBankRule'; + +const models = [ + RegisterTenancyModel(BankRule), + RegisterTenancyModel(BankRuleCondition), +]; + +@Module({ + controllers: [BankRulesController], + imports: [], + providers: [ + ...models, + CreateBankRuleService, + EditBankRuleService, + DeleteBankRuleService, + GetBankRuleService, + GetBankRulesService, + BankRulesApplication, + UnlinkBankRuleOnDeleteBankRuleSubscriber + ], + exports: [...models], +}) +export class BankRulesModule {} diff --git a/packages/server-nest/src/modules/BankRules/BankRulesApplication.ts b/packages/server-nest/src/modules/BankRules/BankRulesApplication.ts new file mode 100644 index 000000000..5db9fa678 --- /dev/null +++ b/packages/server-nest/src/modules/BankRules/BankRulesApplication.ts @@ -0,0 +1,67 @@ +import { Injectable } from '@nestjs/common'; +import { CreateBankRuleService } from './commands/CreateBankRule.service'; +import { DeleteBankRuleService } from './commands/DeleteBankRule.service'; +import { EditBankRuleService } from './commands/EditBankRule.service'; +import { GetBankRuleService } from './queries/GetBankRule.service'; +import { GetBankRulesService } from './queries/GetBankRules.service'; +import { ICreateBankRuleDTO, IEditBankRuleDTO } from './types'; + +@Injectable() +export class BankRulesApplication { + constructor( + private readonly createBankRuleService: CreateBankRuleService, + private readonly editBankRuleService: EditBankRuleService, + private readonly deleteBankRuleService: DeleteBankRuleService, + private readonly getBankRuleService: GetBankRuleService, + private readonly getBankRulesService: GetBankRulesService, + ) {} + + /** + * Creates new bank rule. + * @param {ICreateBankRuleDTO} createRuleDTO - Bank rule data. + * @returns {Promise} + */ + public createBankRule(createRuleDTO: ICreateBankRuleDTO): Promise { + return this.createBankRuleService.createBankRule(createRuleDTO); + } + + /** + * Edits the given bank rule. + * @param {number} ruleId - Bank rule identifier. + * @param {IEditBankRuleDTO} editRuleDTO - Bank rule data. + * @returns {Promise} + */ + public editBankRule( + ruleId: number, + editRuleDTO: IEditBankRuleDTO, + ): Promise { + return this.editBankRuleService.editBankRule(ruleId, editRuleDTO); + } + + /** + * Deletes the given bank rule. + * @param {number} ruleId - Bank rule identifier. + * @returns {Promise} + */ + public deleteBankRule(ruleId: number): Promise { + return this.deleteBankRuleService.deleteBankRule(ruleId); + } + + /** + * Retrieves the given bank rule. + * @param {number} ruleId - Bank rule identifier. + * @returns {Promise} + */ + public getBankRule(ruleId: number): Promise { + return this.getBankRuleService.getBankRule(ruleId); + } + + /** + * Retrieves the bank rules of the given account. + * @param {number} accountId - Bank account identifier. + * @returns {Promise} + */ + public getBankRules(): Promise { + return this.getBankRulesService.getBankRules(); + } +} diff --git a/packages/server-nest/src/modules/BankRules/commands/CreateBankRule.service.ts b/packages/server-nest/src/modules/BankRules/commands/CreateBankRule.service.ts new file mode 100644 index 000000000..04adcbd83 --- /dev/null +++ b/packages/server-nest/src/modules/BankRules/commands/CreateBankRule.service.ts @@ -0,0 +1,61 @@ +import { Knex } from 'knex'; +import { Inject, Injectable } from '@nestjs/common'; +import { + IBankRuleEventCreatedPayload, + IBankRuleEventCreatingPayload, + ICreateBankRuleDTO, +} from '../types'; +import { UnitOfWork } from '../../Tenancy/TenancyDB/UnitOfWork.service'; +import { EventEmitter2 } from '@nestjs/event-emitter'; +import { events } from '@/common/events/events'; +import { BankRule } from '../models/BankRule'; + +@Injectable() +export class CreateBankRuleService { + constructor( + private readonly uow: UnitOfWork, + private readonly eventPublisher: EventEmitter2, + + @Inject(BankRule.name) private readonly bankRuleModel: typeof BankRule, + ) {} + + /** + * Transforms the DTO to model. + * @param {ICreateBankRuleDTO} createDTO + * @returns + */ + private transformDTO(createDTO: ICreateBankRuleDTO): Partial { + return { + ...createDTO, + }; + } + + /** + * Creates a new bank rule. + * @param {ICreateBankRuleDTO} createRuleDTO + * @returns {Promise} + */ + public async createBankRule(createRuleDTO: ICreateBankRuleDTO): Promise { + const transformDTO = this.transformDTO(createRuleDTO); + + await this.uow.withTransaction(async (trx: Knex.Transaction) => { + // Triggers `onBankRuleCreating` event. + await this.eventPublisher.emitAsync(events.bankRules.onCreating, { + createRuleDTO, + trx, + } as IBankRuleEventCreatingPayload); + + const bankRule = await this.bankRuleModel.query(trx).upsertGraph({ + ...transformDTO, + }); + // Triggers `onBankRuleCreated` event. + await this.eventPublisher.emitAsync(events.bankRules.onCreated, { + createRuleDTO, + bankRule, + trx, + } as IBankRuleEventCreatedPayload); + + return bankRule; + }); + } +} diff --git a/packages/server-nest/src/modules/BankRules/commands/DeleteBankRule.service.ts b/packages/server-nest/src/modules/BankRules/commands/DeleteBankRule.service.ts new file mode 100644 index 000000000..da880ac2f --- /dev/null +++ b/packages/server-nest/src/modules/BankRules/commands/DeleteBankRule.service.ts @@ -0,0 +1,59 @@ +import { Knex } from 'knex'; +import { Inject, Injectable } from '@nestjs/common'; +import { + IBankRuleEventDeletedPayload, + IBankRuleEventDeletingPayload, +} from '../types'; +import { BankRule } from '../models/BankRule'; +import { BankRuleCondition } from '../models/BankRuleCondition'; +import { UnitOfWork } from '@/modules/Tenancy/TenancyDB/UnitOfWork.service'; +import { EventEmitter2 } from '@nestjs/event-emitter'; +import { events } from '@/common/events/events'; + +@Injectable() +export class DeleteBankRuleService { + constructor( + @Inject(BankRule.name) private bankRuleModel: typeof BankRule, + @Inject(BankRuleCondition.name) + private bankRuleConditionModel: typeof BankRuleCondition, + + private readonly uow: UnitOfWork, + private readonly eventPublisher: EventEmitter2, + ) {} + + /** + * Deletes the given bank rule. + * @param {number} ruleId + * @returns {Promise} + */ + public async deleteBankRule( + ruleId: number, + trx?: Knex.Transaction, + ): Promise { + const oldBankRule = await this.bankRuleModel + .query() + .findById(ruleId) + .throwIfNotFound(); + + return this.uow.withTransaction(async (trx: Knex.Transaction) => { + // Triggers `onBankRuleDeleting` event. + await this.eventPublisher.emitAsync(events.bankRules.onDeleting, { + oldBankRule, + ruleId, + trx, + } as IBankRuleEventDeletingPayload); + + await this.bankRuleConditionModel + .query(trx) + .where('ruleId', ruleId) + .delete(); + await this.bankRuleModel.query(trx).findById(ruleId).delete(); + + // Triggers `onBankRuleDeleted` event. + await this.eventPublisher.emitAsync(events.bankRules.onDeleted, { + ruleId, + trx, + } as IBankRuleEventDeletedPayload); + }, trx); + } +} diff --git a/packages/server-nest/src/modules/BankRules/commands/DeleteBankRules.service.ts b/packages/server-nest/src/modules/BankRules/commands/DeleteBankRules.service.ts new file mode 100644 index 000000000..2a1be48f2 --- /dev/null +++ b/packages/server-nest/src/modules/BankRules/commands/DeleteBankRules.service.ts @@ -0,0 +1,28 @@ +import { Knex } from 'knex'; +import { PromisePool } from '@supercharge/promise-pool'; +import { castArray, uniq } from 'lodash'; +import { DeleteBankRuleService } from './DeleteBankRule.service'; +import { Injectable } from '@nestjs/common'; + +@Injectable() +export class DeleteBankRulesService { + constructor(private readonly deleteBankRuleService: DeleteBankRuleService) {} + + /** + * Delete bank rules. + * @param {number | Array} bankRuleId - The bank rule id or ids. + * @param {Knex.Transaction} trx - The transaction. + */ + async deleteBankRules( + bankRuleId: number | Array, + trx?: Knex.Transaction, + ) { + const bankRulesIds = uniq(castArray(bankRuleId)); + + const results = await PromisePool.withConcurrency(1) + .for(bankRulesIds) + .process(async (bankRuleId: number) => { + await this.deleteBankRuleService.deleteBankRule(bankRuleId, trx); + }); + } +} diff --git a/packages/server-nest/src/modules/BankRules/commands/EditBankRule.service.ts b/packages/server-nest/src/modules/BankRules/commands/EditBankRule.service.ts new file mode 100644 index 000000000..880b01ddc --- /dev/null +++ b/packages/server-nest/src/modules/BankRules/commands/EditBankRule.service.ts @@ -0,0 +1,72 @@ +import { Inject, Injectable } from '@nestjs/common'; +import { + IBankRuleEventEditedPayload, + IBankRuleEventEditingPayload, + IEditBankRuleDTO, +} from '../types'; +import { BankRule } from '../models/BankRule'; +import { EventEmitter2 } from '@nestjs/event-emitter'; +import { UnitOfWork } from '@/modules/Tenancy/TenancyDB/UnitOfWork.service'; +import { events } from '@/common/events/events'; + +@Injectable() +export class EditBankRuleService { + constructor( + private readonly uow: UnitOfWork, + private readonly eventPublisher: EventEmitter2, + + @Inject(BankRule.name) private bankRuleModel: typeof BankRule, + ) {} + + /** + * + * @param createDTO + * @returns + */ + private transformDTO(createDTO: IEditBankRuleDTO): Partial { + return { + ...createDTO, + }; + } + + /** + * Edits the given bank rule. + * @param {number} ruleId - + * @param {IEditBankRuleDTO} editBankDTO + */ + public async editBankRule( + ruleId: number, + editRuleDTO: IEditBankRuleDTO + ) { + const oldBankRule = await this.bankRuleModel.query() + .findById(ruleId) + .withGraphFetched('conditions') + .throwIfNotFound(); + + const tranformDTO = this.transformDTO(editRuleDTO); + + return this.uow.withTransaction(async (trx) => { + // Triggers `onBankRuleEditing` event. + await this.eventPublisher.emitAsync(events.bankRules.onEditing, { + oldBankRule, + ruleId, + editRuleDTO, + trx, + } as IBankRuleEventEditingPayload); + + // Updates the given bank rule. + const bankRule = await this.bankRuleModel.query(trx).upsertGraphAndFetch({ + ...tranformDTO, + id: ruleId, + }); + + // Triggers `onBankRuleEdited` event. + await this.eventPublisher.emitAsync(events.bankRules.onEdited, { + oldBankRule, + bankRule, + editRuleDTO, + trx, + } as IBankRuleEventEditedPayload); + }); + } +} diff --git a/packages/server-nest/src/modules/BankRules/events/UnlinkBankRuleOnDeleteBankRule.ts b/packages/server-nest/src/modules/BankRules/events/UnlinkBankRuleOnDeleteBankRule.ts new file mode 100644 index 000000000..d9c4b2c01 --- /dev/null +++ b/packages/server-nest/src/modules/BankRules/events/UnlinkBankRuleOnDeleteBankRule.ts @@ -0,0 +1,23 @@ +import { IBankRuleEventDeletingPayload } from '../types'; +import { Injectable } from '@nestjs/common'; +import { RevertRecognizedTransactionsService } from '@/modules/BankingTranasctionsRegonize/commands/RevertRecognizedTransactions.service'; +import { events } from '@/common/events/events'; +import { OnEvent } from '@nestjs/event-emitter'; + +@Injectable() +export class UnlinkBankRuleOnDeleteBankRuleSubscriber { + private revertRecognizedTransactionsService: RevertRecognizedTransactionsService; + + /** + * Unlinks the bank rule out of recognized transactions. + * @param {IBankRuleEventDeletingPayload} payload - + */ + @OnEvent(events.bankRules.onDeleting) + public async unlinkBankRuleOutRecognizedTransactionsOnRuleDeleting({ + oldBankRule, + }: IBankRuleEventDeletingPayload) { + await this.revertRecognizedTransactionsService.revertRecognizedTransactions( + oldBankRule.id + ); + } +} diff --git a/packages/server-nest/src/modules/BankRules/models/BankRule.ts b/packages/server-nest/src/modules/BankRules/models/BankRule.ts new file mode 100644 index 000000000..d86c6183a --- /dev/null +++ b/packages/server-nest/src/modules/BankRules/models/BankRule.ts @@ -0,0 +1,74 @@ +import { BaseModel } from '@/models/Model'; +import { Model } from 'objection'; +import { BankRuleCondition } from './BankRuleCondition'; +import { BankRuleAssignCategory, BankRuleConditionType } from '../types'; + +export class BankRule extends BaseModel { + public id!: number; + public name!: string; + public order!: number; + public applyIfAccountId!: number; + public applyIfTransactionType!: string; + public assignCategory!: BankRuleAssignCategory; + public assignAccountId!: number; + public assignPayee!: string; + public assignMemo!: string; + public conditionsType!: BankRuleConditionType; + + conditions!: BankRuleCondition[]; + + /** + * Table name + */ + static get tableName() { + return 'bank_rules'; + } + + /** + * Timestamps columns. + */ + get timestamps() { + return ['created_at', 'updated_at']; + } + + /** + * Virtual attributes. + */ + static get virtualAttributes() { + return []; + } + + /** + * Relationship mapping. + */ + static get relationMappings() { + const { BankRuleCondition } = require('./BankRuleCondition'); + const { Account } = require('../../Accounts/models/Account.model'); + + return { + /** + * Sale invoice associated entries. + */ + conditions: { + relation: Model.HasManyRelation, + modelClass: BankRuleCondition, + join: { + from: 'bank_rules.id', + to: 'bank_rule_conditions.ruleId', + }, + }, + + /** + * Bank rule may associated to the assign account. + */ + assignAccount: { + relation: Model.BelongsToOneRelation, + modelClass: Account, + join: { + from: 'bank_rules.assignAccountId', + to: 'accounts.id', + }, + }, + }; + } +} diff --git a/packages/server-nest/src/modules/BankRules/models/BankRuleCondition.ts b/packages/server-nest/src/modules/BankRules/models/BankRuleCondition.ts new file mode 100644 index 000000000..1852cb3c0 --- /dev/null +++ b/packages/server-nest/src/modules/BankRules/models/BankRuleCondition.ts @@ -0,0 +1,30 @@ +import { BaseModel } from '@/models/Model'; + +export class BankRuleCondition extends BaseModel { + public id!: number; + public bankRuleId!: number; + public field!: string; + public comparator!: string; + public value!: string; + + /** + * Table name. + */ + static get tableName() { + return 'bank_rule_conditions'; + } + + /** + * Timestamps columns. + */ + get timestamps() { + return []; + } + + /** + * Virtual attributes. + */ + static get virtualAttributes() { + return []; + } +} diff --git a/packages/server-nest/src/modules/BankRules/queries/GetBankRule.service.ts b/packages/server-nest/src/modules/BankRules/queries/GetBankRule.service.ts new file mode 100644 index 000000000..f31344833 --- /dev/null +++ b/packages/server-nest/src/modules/BankRules/queries/GetBankRule.service.ts @@ -0,0 +1,30 @@ +import { Inject, Injectable } from '@nestjs/common'; +import { GetBankRuleTransformer } from './GetBankRuleTransformer'; +import { TransformerInjectable } from '../../Transformer/TransformerInjectable.service'; +import { BankRule } from '../models/BankRule'; +import { GetBankRulesTransformer } from './GetBankRulesTransformer'; + +@Injectable() +export class GetBankRuleService { + constructor( + @Inject(BankRule.name) private bankRuleModel: typeof BankRule, + private transformer: TransformerInjectable, + ) {} + + /** + * Retrieves the bank rule. + * @param {number} ruleId + * @returns {Promise} + */ + async getBankRule(ruleId: number): Promise { + const bankRule = await this.bankRuleModel + .query() + .findById(ruleId) + .withGraphFetched('conditions'); + + return this.transformer.transform( + bankRule, + new GetBankRulesTransformer() + ); + } +} diff --git a/packages/server-nest/src/modules/BankRules/queries/GetBankRuleTransformer.ts b/packages/server-nest/src/modules/BankRules/queries/GetBankRuleTransformer.ts new file mode 100644 index 000000000..b01979ecb --- /dev/null +++ b/packages/server-nest/src/modules/BankRules/queries/GetBankRuleTransformer.ts @@ -0,0 +1,11 @@ +import { Transformer } from "@/modules/Transformer/Transformer"; + +export class GetBankRuleTransformer extends Transformer { + /** + * Include these attributes to sale invoice object. + * @returns {Array} + */ + public includeAttributes = (): string[] => { + return []; + }; +} diff --git a/packages/server-nest/src/modules/BankRules/queries/GetBankRules.service.ts b/packages/server-nest/src/modules/BankRules/queries/GetBankRules.service.ts new file mode 100644 index 000000000..2dbec718a --- /dev/null +++ b/packages/server-nest/src/modules/BankRules/queries/GetBankRules.service.ts @@ -0,0 +1,30 @@ +import { Inject, Injectable } from '@nestjs/common'; +import { GetBankRulesTransformer } from './GetBankRulesTransformer'; +import { BankRule } from '../models/BankRule'; +import { TransformerInjectable } from '@/modules/Transformer/TransformerInjectable.service'; + +@Injectable() +export class GetBankRulesService { + constructor( + private transformer: TransformerInjectable, + + @Inject(BankRule.name) + private bankRuleModel: typeof BankRule, + ) {} + + /** + * Retrieves the bank rules of the given account. + * @returns {Promise} + */ + public async getBankRules(): Promise { + const bankRule = await this.bankRuleModel + .query() + .withGraphFetched('conditions') + .withGraphFetched('assignAccount'); + + return this.transformer.transform( + bankRule, + new GetBankRulesTransformer() + ); + } +} diff --git a/packages/server-nest/src/modules/BankRules/queries/GetBankRulesTransformer.ts b/packages/server-nest/src/modules/BankRules/queries/GetBankRulesTransformer.ts new file mode 100644 index 000000000..0a26317c7 --- /dev/null +++ b/packages/server-nest/src/modules/BankRules/queries/GetBankRulesTransformer.ts @@ -0,0 +1,49 @@ +import { Transformer } from '@/modules/Transformer/Transformer'; +import { getCashflowTransactionFormattedType } from '../../BankingTransactions/utils'; + +export class GetBankRulesTransformer extends Transformer { + /** + * Include these attributes to sale invoice object. + * @returns {Array} + */ + public includeAttributes = (): string[] => { + return [ + 'assignAccountName', + 'assignCategoryFormatted', + 'conditionsFormatted', + ]; + }; + + /** + * Get the assign account name. + * @param bankRule + * @returns {string} + */ + protected assignAccountName(bankRule: any) { + return bankRule.assignAccount.name; + } + + /** + * Assigned category formatted. + * @returns {string} + */ + protected assignCategoryFormatted(bankRule: any) { + return getCashflowTransactionFormattedType(bankRule.assignCategory); + } + + /** + * Get the bank rule formatted conditions. + * @param bankRule + * @returns {string} + */ + protected conditionsFormatted(bankRule: any) { + return bankRule.conditions + .map((condition) => { + const field = + condition.field.charAt(0).toUpperCase() + condition.field.slice(1); + + return `${field} ${condition.comparator} ${condition.value}`; + }) + .join(bankRule.conditionsType === 'and' ? ' and ' : ' or '); + } +} diff --git a/packages/server-nest/src/modules/BankRules/types.ts b/packages/server-nest/src/modules/BankRules/types.ts new file mode 100644 index 000000000..e3de2219b --- /dev/null +++ b/packages/server-nest/src/modules/BankRules/types.ts @@ -0,0 +1,124 @@ +import { Knex } from 'knex'; +import { BankRule } from './models/BankRule'; + +export enum BankRuleConditionField { + Amount = 'amount', + Description = 'description', + Payee = 'payee', +} + +export enum BankRuleConditionComparator { + Contains = 'contains', + Equals = 'equals', + Equal = 'equal', + NotContain = 'not_contain', + Bigger = 'bigger', + BiggerOrEqual = 'bigger_or_equal', + Smaller = 'smaller', + SmallerOrEqual = 'smaller_or_equal', +} + +export interface IBankRuleCondition { + id?: number; + field: BankRuleConditionField; + comparator: BankRuleConditionComparator; + value: string; +} + +export enum BankRuleConditionType { + Or = 'or', + And = 'and', +} + +export enum BankRuleApplyIfTransactionType { + Deposit = 'deposit', + Withdrawal = 'withdrawal', +} + +// export interface BankRule { +// id?: number; +// name: string; +// order?: number; +// applyIfAccountId: number; +// applyIfTransactionType: BankRuleApplyIfTransactionType; + +// conditionsType: BankRuleConditionType; +// conditions: IBankRuleCondition[]; + +// assignCategory: BankRuleAssignCategory; +// assignAccountId: number; +// assignPayee?: string; +// assignMemo?: string; +// } + +export enum BankRuleAssignCategory { + InterestIncome = 'InterestIncome', + OtherIncome = 'OtherIncome', + Deposit = 'Deposit', + Expense = 'Expense', + OwnerDrawings = 'OwnerDrawings', +} + +export interface IBankRuleConditionDTO { + id?: number; + field: string; + comparator: + | 'contains' + | 'equals' + | 'not_contains' + | 'equal' + | 'bigger' + | 'bigger_or_equal' + | 'smaller' + | 'smaller_or_equal'; + value: number; +} + +export interface IBankRuleCommonDTO { + name: string; + order?: number; + applyIfAccountId: number; + applyIfTransactionType: string; + + conditions: IBankRuleConditionDTO[]; + + assignCategory: BankRuleAssignCategory; + assignAccountId: number; + assignPayee?: string; + assignMemo?: string; +} + +export interface ICreateBankRuleDTO extends IBankRuleCommonDTO {} +export interface IEditBankRuleDTO extends IBankRuleCommonDTO {} + +export interface IBankRuleEventCreatingPayload { + createRuleDTO: ICreateBankRuleDTO; + trx?: Knex.Transaction; +} +export interface IBankRuleEventCreatedPayload { + createRuleDTO: ICreateBankRuleDTO; + bankRule: BankRule; + trx?: Knex.Transaction; +} + +export interface IBankRuleEventEditingPayload { + ruleId: number; + oldBankRule: any; + editRuleDTO: IEditBankRuleDTO; + trx?: Knex.Transaction; +} +export interface IBankRuleEventEditedPayload { + oldBankRule: BankRule; + bankRule: BankRule; + editRuleDTO: IEditBankRuleDTO; + trx?: Knex.Transaction; +} + +export interface IBankRuleEventDeletingPayload { + oldBankRule: any; + trx?: Knex.Transaction; +} +export interface IBankRuleEventDeletedPayload { + ruleId: number; + trx?: Knex.Transaction; +} diff --git a/packages/server-nest/src/modules/BankingAccounts/BankAccounts.controller.ts b/packages/server-nest/src/modules/BankingAccounts/BankAccounts.controller.ts new file mode 100644 index 000000000..3ec53d450 --- /dev/null +++ b/packages/server-nest/src/modules/BankingAccounts/BankAccounts.controller.ts @@ -0,0 +1,27 @@ +import { Controller, Param, Post } from '@nestjs/common'; +import { BankAccountsApplication } from './BankAccountsApplication.service'; + +@Controller('banking/accounts') +export class BankAccountsController { + constructor(private bankAccountsApplication: BankAccountsApplication) {} + + @Post(':id/disconnect') + async disconnectBankAccount(@Param('id') bankAccountId: number) { + return this.bankAccountsApplication.disconnectBankAccount(bankAccountId); + } + + @Post(':id/refresh') + async refreshBankAccount(@Param('id') bankAccountId: number) { + return this.bankAccountsApplication.refreshBankAccount(bankAccountId); + } + + @Post(':id/pause') + async pauseBankAccount(@Param('id') bankAccountId: number) { + return this.bankAccountsApplication.pauseBankAccount(bankAccountId); + } + + @Post(':id/resume') + async resumeBankAccount(@Param('id') bankAccountId: number) { + return this.bankAccountsApplication.resumeBankAccount(bankAccountId); + } +} diff --git a/packages/server-nest/src/modules/BankingAccounts/BankAccounts.module.ts b/packages/server-nest/src/modules/BankingAccounts/BankAccounts.module.ts new file mode 100644 index 000000000..d434e1361 --- /dev/null +++ b/packages/server-nest/src/modules/BankingAccounts/BankAccounts.module.ts @@ -0,0 +1,24 @@ +import { Module } from '@nestjs/common'; +import { BankAccountsApplication } from './BankAccountsApplication.service'; +import { DisconnectBankAccountService } from './commands/DisconnectBankAccount.service'; +import { RefreshBankAccountService } from './commands/RefreshBankAccount.service'; +import { ResumeBankAccountFeedsService } from './commands/ResumeBankAccountFeeds.service'; +import { PauseBankAccountFeeds } from './commands/PauseBankAccountFeeds.service'; +import { DeleteUncategorizedTransactionsOnAccountDeleting } from './subscribers/DeleteUncategorizedTransactionsOnAccountDeleting'; +import { DisconnectPlaidItemOnAccountDeleted } from './subscribers/DisconnectPlaidItemOnAccountDeleted'; +import { BankAccountsController } from './BankAccounts.controller'; + +@Module({ + imports: [ + DisconnectBankAccountService, + RefreshBankAccountService, + ResumeBankAccountFeedsService, + PauseBankAccountFeeds, + DeleteUncategorizedTransactionsOnAccountDeleting, + DisconnectPlaidItemOnAccountDeleted, + ], + providers: [BankAccountsApplication], + exports: [BankAccountsApplication], + controllers: [BankAccountsController], +}) +export class BankAccountsModule {} diff --git a/packages/server-nest/src/modules/BankingAccounts/BankAccountsApplication.service.ts b/packages/server-nest/src/modules/BankingAccounts/BankAccountsApplication.service.ts new file mode 100644 index 000000000..c7e1a757a --- /dev/null +++ b/packages/server-nest/src/modules/BankingAccounts/BankAccountsApplication.service.ts @@ -0,0 +1,57 @@ +import { Injectable } from '@nestjs/common'; +import { DisconnectBankAccountService } from './commands/DisconnectBankAccount.service'; +import { RefreshBankAccountService } from './commands/RefreshBankAccount.service'; +import { ResumeBankAccountFeedsService } from './commands/ResumeBankAccountFeeds.service'; +import { PauseBankAccountFeeds } from './commands/PauseBankAccountFeeds.service'; + +@Injectable() +export class BankAccountsApplication { + constructor( + private disconnectBankAccountService: DisconnectBankAccountService, + private readonly refreshBankAccountService: RefreshBankAccountService, + private readonly resumeBankAccountFeedsService: ResumeBankAccountFeedsService, + private readonly pauseBankAccountFeedsService: PauseBankAccountFeeds, + ) {} + + /** + * Disconnects the given bank account. + * @param {number} bankAccountId - Bank account identifier. + * @returns {Promise} + */ + async disconnectBankAccount(bankAccountId: number) { + return this.disconnectBankAccountService.disconnectBankAccount( + bankAccountId, + ); + } + + /** + * Refresh the bank transactions of the given bank account. + * @param {number} bankAccountId - Bank account identifier. + * @returns {Promise} + */ + async refreshBankAccount(bankAccountId: number) { + return this.refreshBankAccountService.refreshBankAccount(bankAccountId); + } + + /** + * Pauses the feeds sync of the given bank account. + * @param {number} bankAccountId - Bank account identifier. + * @returns {Promise} + */ + async pauseBankAccount(bankAccountId: number) { + return this.pauseBankAccountFeedsService.pauseBankAccountFeeds( + bankAccountId, + ); + } + + /** + * Resumes the feeds sync of the given bank account. + * @param {number} bankAccountId - Bank account identifier. + * @returns {Promise} + */ + async resumeBankAccount(bankAccountId: number) { + return this.resumeBankAccountFeedsService.resumeBankAccountFeeds( + bankAccountId, + ); + } +} diff --git a/packages/server-nest/src/modules/BankingAccounts/commands/DisconnectBankAccount.service.ts b/packages/server-nest/src/modules/BankingAccounts/commands/DisconnectBankAccount.service.ts new file mode 100644 index 000000000..ac63c73a5 --- /dev/null +++ b/packages/server-nest/src/modules/BankingAccounts/commands/DisconnectBankAccount.service.ts @@ -0,0 +1,75 @@ +import { Knex } from 'knex'; +import { Inject, Injectable } from '@nestjs/common'; +import { PlaidApi } from 'plaid'; +import { + ERRORS, + IBankAccountDisconnectedEventPayload, + IBankAccountDisconnectingEventPayload, +} from '../types/BankAccounts.types'; +import { ACCOUNT_TYPE } from '@/constants/accounts'; +import { UnitOfWork } from '@/modules/Tenancy/TenancyDB/UnitOfWork.service'; +import { Account } from '@/modules/Accounts/models/Account.model'; +import { PlaidItem } from '@/modules/BankingPlaid/models/PlaidItem'; +import { ServiceError } from '@/modules/Items/ServiceError'; +import { events } from '@/common/events/events'; +import { EventEmitter2 } from '@nestjs/event-emitter'; + +import { PLAID_CLIENT } from '@/modules/Plaid/Plaid.module'; + +@Injectable() +export class DisconnectBankAccountService { + constructor( + private eventPublisher: EventEmitter2, + private uow: UnitOfWork, + + @Inject(Account.name) private accountModel: typeof Account, + @Inject(PlaidItem.name) private plaidItemModel: typeof PlaidItem, + @Inject(PLAID_CLIENT) private plaidClient: PlaidApi, + ) {} + + /** + * Disconnects the given bank account. + * @param {number} bankAccountId + * @returns {Promise} + */ + public async disconnectBankAccount(bankAccountId: number) { + // Retrieve the bank account or throw not found error. + const account = await this.accountModel + .query() + .findById(bankAccountId) + .whereIn('account_type', [ACCOUNT_TYPE.CASH, ACCOUNT_TYPE.BANK]) + .withGraphFetched('plaidItem') + .throwIfNotFound(); + + const oldPlaidItem = account.plaidItem; + + if (!oldPlaidItem) { + throw new ServiceError(ERRORS.BANK_ACCOUNT_NOT_CONNECTED); + } + return this.uow.withTransaction(async (trx: Knex.Transaction) => { + // Triggers `onBankAccountDisconnecting` event. + await this.eventPublisher.emitAsync(events.bankAccount.onDisconnecting, { + bankAccountId, + } as IBankAccountDisconnectingEventPayload); + + // Remove the Plaid item from the system. + await this.plaidItemModel.query(trx).findById(account.plaidItemId).delete(); + + // Remove the plaid item association to the bank account. + await this.accountModel.query(trx).findById(bankAccountId).patch({ + plaidAccountId: null, + plaidItemId: null, + isFeedsActive: false, + }); + // Remove the Plaid item. + await this.plaidClient.itemRemove({ + access_token: oldPlaidItem.plaidAccessToken, + }); + // Triggers `onBankAccountDisconnected` event. + await this.eventPublisher.emitAsync(events.bankAccount.onDisconnected, { + bankAccountId, + trx, + } as IBankAccountDisconnectedEventPayload); + }); + } +} diff --git a/packages/server-nest/src/modules/BankingAccounts/commands/PauseBankAccountFeeds.service.ts b/packages/server-nest/src/modules/BankingAccounts/commands/PauseBankAccountFeeds.service.ts new file mode 100644 index 000000000..45867bf64 --- /dev/null +++ b/packages/server-nest/src/modules/BankingAccounts/commands/PauseBankAccountFeeds.service.ts @@ -0,0 +1,47 @@ +import { Inject, Injectable } from '@nestjs/common'; +import { Knex } from 'knex'; +import { ERRORS } from '../types/BankAccounts.types'; +import { ServiceError } from '@/modules/Items/ServiceError'; +import { Account } from '@/modules/Accounts/models/Account.model'; +import { UnitOfWork } from '@/modules/Tenancy/TenancyDB/UnitOfWork.service'; +import { PlaidItem } from '@/modules/BankingPlaid/models/PlaidItem'; + +@Injectable() +export class PauseBankAccountFeeds { + constructor( + @Inject(Account.name) private accountModel: typeof Account, + @Inject(PlaidItem.name) private plaidItemModel: typeof PlaidItem, + + private uow: UnitOfWork, + ) {} + + /** + * Pauses the bankfeed syncing of the given bank account. + * @param {number} bankAccountId + * @returns {Promise} + */ + public async pauseBankAccountFeeds(bankAccountId: number) { + const oldAccount = await this.accountModel + .query() + .findById(bankAccountId) + .withGraphFetched('plaidItem') + .throwIfNotFound(); + + // Can't continue if the bank account is not connected. + if (!oldAccount.plaidItem) { + throw new ServiceError(ERRORS.BANK_ACCOUNT_NOT_CONNECTED); + } + // Cannot continue if the bank account feeds is already paused. + if (oldAccount.plaidItem.isPaused) { + throw new ServiceError(ERRORS.BANK_ACCOUNT_FEEDS_ALREADY_PAUSED); + } + return this.uow.withTransaction(async (trx: Knex.Transaction) => { + await this.plaidItemModel + .query(trx) + .findById(oldAccount.plaidItem.id) + .patch({ + pausedAt: new Date(), + }); + }); + } +} diff --git a/packages/server-nest/src/modules/BankingAccounts/commands/RefreshBankAccount.service.ts b/packages/server-nest/src/modules/BankingAccounts/commands/RefreshBankAccount.service.ts new file mode 100644 index 000000000..4bf063707 --- /dev/null +++ b/packages/server-nest/src/modules/BankingAccounts/commands/RefreshBankAccount.service.ts @@ -0,0 +1,35 @@ +import { PlaidApi } from 'plaid'; +import { Inject, Injectable } from '@nestjs/common'; +import { Account } from '@/modules/Accounts/models/Account.model'; +import { ServiceError } from '@/modules/Items/ServiceError'; +import { PLAID_CLIENT } from '@/modules/Plaid/Plaid.module'; +import { ERRORS } from '../types/BankAccounts.types'; + +@Injectable() +export class RefreshBankAccountService { + constructor( + @Inject(PLAID_CLIENT) private plaidClient: PlaidApi, + @Inject(Account.name) private readonly accountModel: typeof Account, + ) {} + + /** + * Asks Plaid to trigger syncing the given bank account. + * @param {number} bankAccountId - Bank account identifier. + * @returns {Promise} + */ + public async refreshBankAccount(bankAccountId: number) { + const bankAccount = await this.accountModel + .query() + .findById(bankAccountId) + .withGraphFetched('plaidItem') + .throwIfNotFound(); + + // Can't continue if the given account is not linked with Plaid item. + if (!bankAccount.plaidItem) { + throw new ServiceError(ERRORS.BANK_ACCOUNT_NOT_CONNECTED); + } + await this.plaidClient.transactionsRefresh({ + access_token: bankAccount.plaidItem.plaidAccessToken, + }); + } +} diff --git a/packages/server-nest/src/modules/BankingAccounts/commands/ResumeBankAccountFeeds.service.ts b/packages/server-nest/src/modules/BankingAccounts/commands/ResumeBankAccountFeeds.service.ts new file mode 100644 index 000000000..8009dd8f4 --- /dev/null +++ b/packages/server-nest/src/modules/BankingAccounts/commands/ResumeBankAccountFeeds.service.ts @@ -0,0 +1,46 @@ +import { Inject, Injectable } from '@nestjs/common'; +import { Knex } from 'knex'; +import { ERRORS } from '../types/BankAccounts.types'; +import { UnitOfWork } from '@/modules/Tenancy/TenancyDB/UnitOfWork.service'; +import { PlaidItem } from '@/modules/BankingPlaid/models/PlaidItem'; +import { Account } from '@/modules/Accounts/models/Account.model'; +import { ServiceError } from '@/modules/Items/ServiceError'; + +@Injectable() +export class ResumeBankAccountFeedsService { + constructor( + @Inject(Account.name) private accountModel: typeof Account, + @Inject(PlaidItem.name) private plaidItemModel: typeof PlaidItem, + + private uow: UnitOfWork, + ) {} + + /** + * Resumes the bank feeds syncing of the bank account. + * @param {number} bankAccountId + * @returns {Promise} + */ + public async resumeBankAccountFeeds(bankAccountId: number) { + const oldAccount = await this.accountModel + .query() + .findById(bankAccountId) + .withGraphFetched('plaidItem'); + + // Can't continue if the bank account is not connected. + if (!oldAccount.plaidItem) { + throw new ServiceError(ERRORS.BANK_ACCOUNT_NOT_CONNECTED); + } + // Cannot continue if the bank account feeds is already paused. + if (!oldAccount.plaidItem.isPaused) { + throw new ServiceError(ERRORS.BANK_ACCOUNT_FEEDS_ALREADY_RESUMED); + } + return this.uow.withTransaction(async (trx: Knex.Transaction) => { + await this.plaidItemModel + .query(trx) + .findById(oldAccount.plaidItem.id) + .patch({ + pausedAt: null, + }); + }); + } +} diff --git a/packages/server-nest/src/modules/BankingAccounts/queries/GetBankAccountSummary.ts b/packages/server-nest/src/modules/BankingAccounts/queries/GetBankAccountSummary.ts new file mode 100644 index 000000000..14e0605df --- /dev/null +++ b/packages/server-nest/src/modules/BankingAccounts/queries/GetBankAccountSummary.ts @@ -0,0 +1,107 @@ +import { Inject, Injectable } from '@nestjs/common'; +import { Account } from '@/modules/Accounts/models/Account.model'; +import { UncategorizedBankTransaction } from '@/modules/BankingTransactions/models/UncategorizedBankTransaction'; + +@Injectable() +export class GetBankAccountSummary { + constructor( + @Inject(Account.name) + private readonly accountModel: typeof Account, + + @Inject(UncategorizedBankTransaction.name) + private readonly uncategorizedBankTransactionModel: typeof UncategorizedBankTransaction, + ) {} + + /** + * Retrieves the bank account meta summary + * @param {number} bankAccountId - The bank account id. + * @returns {Promise} + */ + public async getBankAccountSummary(bankAccountId: number) { + const bankAccount = await this.accountModel + .query() + .findById(bankAccountId) + .throwIfNotFound(); + + const commonQuery = (q) => { + // Include just the given account. + q.where('accountId', bankAccountId); + + // Only the not excluded. + q.modify('notExcluded'); + + // Only the not categorized. + q.modify('notCategorized'); + }; + + // Retrieves the uncategorized transactions count of the given bank account. + const uncategorizedTranasctionsCount = + await this.uncategorizedBankTransactionModel.query().onBuild((q) => { + commonQuery(q); + + // Only the not matched bank transactions. + q.withGraphJoined('matchedBankTransactions'); + q.whereNull('matchedBankTransactions.id'); + + // Exclude the pending transactions. + q.modify('notPending'); + + // Count the results. + q.count('uncategorized_cashflow_transactions.id as total'); + q.first(); + }); + + // Retrives the recognized transactions count. + const recognizedTransactionsCount = + await this.uncategorizedBankTransactionModel.query().onBuild((q) => { + commonQuery(q); + + q.withGraphJoined('recognizedTransaction'); + q.whereNotNull('recognizedTransaction.id'); + + // Exclude the pending transactions. + q.modify('notPending'); + + // Count the results. + q.count('uncategorized_cashflow_transactions.id as total'); + q.first(); + }); + // Retrieves excluded transactions count. + const excludedTransactionsCount = + await this.uncategorizedBankTransactionModel.query().onBuild((q) => { + q.where('accountId', bankAccountId); + q.modify('excluded'); + + // Exclude the pending transactions. + q.modify('notPending'); + + // Count the results. + q.count('uncategorized_cashflow_transactions.id as total'); + q.first(); + }); + // Retrieves the pending transactions count. + const pendingTransactionsCount = + await this.uncategorizedBankTransactionModel.query().onBuild((q) => { + q.where('accountId', bankAccountId); + q.modify('pending'); + + // Count the results. + q.count('uncategorized_cashflow_transactions.id as total'); + q.first(); + }); + + const totalUncategorizedTransactions = + uncategorizedTranasctionsCount?.total || 0; + const totalRecognizedTransactions = recognizedTransactionsCount?.total || 0; + const totalExcludedTransactions = excludedTransactionsCount?.total || 0; + const totalPendingTransactions = pendingTransactionsCount?.total || 0; + + return { + name: bankAccount.name, + totalUncategorizedTransactions, + totalRecognizedTransactions, + totalExcludedTransactions, + totalPendingTransactions, + }; + } +} diff --git a/packages/server-nest/src/modules/BankingAccounts/subscribers/DeleteUncategorizedTransactionsOnAccountDeleting.ts b/packages/server-nest/src/modules/BankingAccounts/subscribers/DeleteUncategorizedTransactionsOnAccountDeleting.ts new file mode 100644 index 000000000..c4ec04fa9 --- /dev/null +++ b/packages/server-nest/src/modules/BankingAccounts/subscribers/DeleteUncategorizedTransactionsOnAccountDeleting.ts @@ -0,0 +1,55 @@ +import { Inject, Injectable } from '@nestjs/common'; +import { OnEvent } from '@nestjs/event-emitter'; +import { events } from '@/common/events/events'; +import { IAccountEventDeletePayload } from '@/interfaces/Account'; +import { RevertRecognizedTransactionsService } from '@/modules/BankingTranasctionsRegonize/commands/RevertRecognizedTransactions.service'; +import { UncategorizedBankTransaction } from '@/modules/BankingTransactions/models/UncategorizedBankTransaction'; +import { DeleteBankRulesService } from '@/modules/BankRules/commands/DeleteBankRules.service'; +import { BankRule } from '@/modules/BankRules/models/BankRule'; + +@Injectable() +export class DeleteUncategorizedTransactionsOnAccountDeleting { + constructor( + private readonly deleteBankRules: DeleteBankRulesService, + private readonly revertRecognizedTransactins: RevertRecognizedTransactionsService, + + @Inject(BankRule.name) private bankRuleModel: typeof BankRule, + @Inject(UncategorizedBankTransaction.name) + private uncategorizedCashflowTransactionModel: typeof UncategorizedBankTransaction, + ) {} + + /** + * Handles revert the recognized transactions and delete all the bank rules + * associated to the deleted bank account. + * @param {IAccountEventDeletePayload} + */ + @OnEvent(events.accounts.onDelete) + public async handleDeleteBankRulesOnAccountDeleting({ + oldAccount, + trx, + }: IAccountEventDeletePayload) { + const foundAssociatedRules = await this.bankRuleModel.query(trx).where( + 'applyIfAccountId', + oldAccount.id, + ); + const foundAssociatedRulesIds = foundAssociatedRules.map((rule) => rule.id); + + // Revert the recognized transactions of the given bank rules. + await this.revertRecognizedTransactins.revertRecognizedTransactions( + foundAssociatedRulesIds, + null, + trx, + ); + // Delete the associated uncategorized transactions. + await this.uncategorizedCashflowTransactionModel + .query(trx) + .where('accountId', oldAccount.id) + .delete(); + + // Delete the given bank rules. + await this.deleteBankRules.deleteBankRules( + foundAssociatedRulesIds, + trx, + ); + } +} diff --git a/packages/server-nest/src/modules/BankingAccounts/subscribers/DisconnectPlaidItemOnAccountDeleted.ts b/packages/server-nest/src/modules/BankingAccounts/subscribers/DisconnectPlaidItemOnAccountDeleted.ts new file mode 100644 index 000000000..7c4bcf1e7 --- /dev/null +++ b/packages/server-nest/src/modules/BankingAccounts/subscribers/DisconnectPlaidItemOnAccountDeleted.ts @@ -0,0 +1,57 @@ +import { OnEvent } from '@nestjs/event-emitter'; +import { Inject, Injectable } from '@nestjs/common'; +import { IAccountEventDeletedPayload } from '@/interfaces/Account'; +import { events } from '@/common/events/events'; +import { PlaidItem } from '@/modules/BankingPlaid/models/PlaidItem'; +import { Account } from '@/modules/Accounts/models/Account.model'; +import { PlaidApi } from 'plaid'; +import { PLAID_CLIENT } from '@/modules/Plaid/Plaid.module'; + +@Injectable() +export class DisconnectPlaidItemOnAccountDeleted { + constructor( + @Inject(PLAID_CLIENT) private plaidClient: PlaidApi, + @Inject(PlaidItem.name) private plaidItemModel: typeof PlaidItem, + @Inject(Account.name) private accountModel: typeof Account, + ) {} + /** + * Deletes Plaid item from the system and Plaid once the account deleted. + * @param {IAccountEventDeletedPayload} payload + * @returns {Promise} + */ + @OnEvent(events.accounts.onDeleted) + public async handleDisconnectPlaidItemOnAccountDelete({ + tenantId, + oldAccount, + trx, + }: IAccountEventDeletedPayload) { + // 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 this.plaidItemModel + .query(trx) + .findOne('plaidItemId', oldAccount.plaidItemId); + // Unlink the Plaid item from all account before deleting it. + await this.accountModel + .query(trx) + .where('plaidItemId', oldAccount.plaidItemId) + .patch({ + plaidAccountId: null, + plaidItemId: null, + }); + // Remove the Plaid item from the system. + await this.plaidItemModel + .query(trx) + .findOne('plaidItemId', oldAccount.plaidItemId) + .delete(); + + // Remove Plaid item once the transaction resolve. + if (oldPlaidItem) { + // Remove the Plaid item. + await this.plaidClient.itemRemove({ + access_token: oldPlaidItem.plaidAccessToken, + }); + } + } +} diff --git a/packages/server-nest/src/modules/BankingAccounts/types/BankAccounts.types.ts b/packages/server-nest/src/modules/BankingAccounts/types/BankAccounts.types.ts new file mode 100644 index 000000000..951150d11 --- /dev/null +++ b/packages/server-nest/src/modules/BankingAccounts/types/BankAccounts.types.ts @@ -0,0 +1,17 @@ +import { Knex } from 'knex'; + +export interface IBankAccountDisconnectingEventPayload { + bankAccountId: number; + trx: Knex.Transaction; +} + +export interface IBankAccountDisconnectedEventPayload { + bankAccountId: number; + trx: Knex.Transaction; +} + +export const ERRORS = { + BANK_ACCOUNT_NOT_CONNECTED: 'BANK_ACCOUNT_NOT_CONNECTED', + BANK_ACCOUNT_FEEDS_ALREADY_PAUSED: 'BANK_ACCOUNT_FEEDS_ALREADY_PAUSED', + BANK_ACCOUNT_FEEDS_ALREADY_RESUMED: 'BANK_ACCOUNT_FEEDS_ALREADY_RESUMED', +}; diff --git a/packages/server-nest/src/modules/BankingCategorize/commands/CategorizeCashflowTransaction.ts b/packages/server-nest/src/modules/BankingCategorize/commands/CategorizeCashflowTransaction.ts new file mode 100644 index 000000000..cc564c27d --- /dev/null +++ b/packages/server-nest/src/modules/BankingCategorize/commands/CategorizeCashflowTransaction.ts @@ -0,0 +1,110 @@ +import { castArray } from 'lodash'; +import { EventEmitter2 } from '@nestjs/event-emitter'; +import { Inject, Injectable } from '@nestjs/common'; +import { Knex } from 'knex'; +import { + ICashflowTransactionCategorizedPayload, + ICashflowTransactionUncategorizingPayload, + ICategorizeCashflowTransactioDTO, +} from '../types/BankingCategorize.types'; +import { + transformCategorizeTransToCashflow, + validateUncategorizedTransactionsNotExcluded, +} from '../../BankingTransactions/utils'; +import { CommandBankTransactionValidator } from '../../BankingTransactions/commands/CommandCasflowValidator.service'; +import { CreateBankTransactionService } from '../../BankingTransactions/commands/CreateBankTransaction.service'; +import { UnitOfWork } from '@/modules/Tenancy/TenancyDB/UnitOfWork.service'; +import { UncategorizedBankTransaction } from '@/modules/BankingTransactions/models/UncategorizedBankTransaction'; +import { events } from '@/common/events/events'; + +@Injectable() +export class CategorizeCashflowTransaction { + constructor( + private readonly eventPublisher: EventEmitter2, + private readonly uow: UnitOfWork, + private readonly commandValidators: CommandBankTransactionValidator, + private readonly createBankTransaction: CreateBankTransactionService, + + @Inject(UncategorizedBankTransaction.name) + private readonly uncategorizedBankTransactionModel: typeof UncategorizedBankTransaction, + ) {} + + /** + * Categorize the given cashflow transaction. + * @param {ICategorizeCashflowTransactioDTO} categorizeDTO - Categorize DTO. + */ + public async categorize( + uncategorizedTransactionId: number | Array, + categorizeDTO: ICategorizeCashflowTransactioDTO, + ) { + const uncategorizedTransactionIds = castArray(uncategorizedTransactionId); + + // Retrieves the uncategorized transaction or throw an error. + const oldUncategorizedTransactions = + await this.uncategorizedBankTransactionModel.query() + .whereIn('id', uncategorizedTransactionIds) + .throwIfNotFound(); + + // Validate cannot categorize excluded transaction. + validateUncategorizedTransactionsNotExcluded(oldUncategorizedTransactions); + + // Validates the transaction shouldn't be categorized before. + this.commandValidators.validateTransactionsShouldNotCategorized( + oldUncategorizedTransactions, + ); + // Validate the uncateogirzed transaction if it's deposit the transaction direction + // should `IN` and the same thing if it's withdrawal the direction should be OUT. + this.commandValidators.validateUncategorizeTransactionType( + oldUncategorizedTransactions, + categorizeDTO.transactionType, + ); + // Edits the cashflow transaction under UOW env. + return this.uow.withTransaction(async (trx: Knex.Transaction) => { + // Triggers `onTransactionCategorizing` event. + await this.eventPublisher.emitAsync( + events.cashflow.onTransactionCategorizing, + { + // tenantId, + oldUncategorizedTransactions, + trx, + } as ICashflowTransactionUncategorizingPayload, + ); + // Transformes the categorize DTO to the cashflow transaction. + const cashflowTransactionDTO = transformCategorizeTransToCashflow( + oldUncategorizedTransactions, + categorizeDTO, + ); + // Creates a new cashflow transaction. + const cashflowTransaction = + await this.createBankTransaction.newCashflowTransaction( + cashflowTransactionDTO, + ); + + // Updates the uncategorized transaction as categorized. + await this.uncategorizedBankTransactionModel.query(trx) + .whereIn('id', uncategorizedTransactionIds) + .patch({ + categorized: true, + categorizeRefType: 'CashflowTransaction', + categorizeRefId: cashflowTransaction.id, + }); + // Fetch the new updated uncategorized transactions. + const uncategorizedTransactions = + await this.uncategorizedBankTransactionModel.query(trx).whereIn( + 'id', + uncategorizedTransactionIds, + ); + // Triggers `onCashflowTransactionCategorized` event. + await this.eventPublisher.emitAsync( + events.cashflow.onTransactionCategorized, + { + cashflowTransaction, + uncategorizedTransactions, + oldUncategorizedTransactions, + categorizeDTO, + trx, + } as ICashflowTransactionCategorizedPayload, + ); + }); + } +} diff --git a/packages/server-nest/src/modules/BankingCategorize/commands/CategorizeTransactionAsExpense.ts b/packages/server-nest/src/modules/BankingCategorize/commands/CategorizeTransactionAsExpense.ts new file mode 100644 index 000000000..8f3b9fd1f --- /dev/null +++ b/packages/server-nest/src/modules/BankingCategorize/commands/CategorizeTransactionAsExpense.ts @@ -0,0 +1,77 @@ +import { + CategorizeTransactionAsExpenseDTO, + ICashflowTransactionCategorizedPayload, +} from '@/interfaces'; +import { Knex } from 'knex'; +import { EventEmitter2 } from '@nestjs/event-emitter'; +import { UncategorizedBankTransaction } from '@/modules/BankingTransactions/models/UncategorizedBankTransaction'; +import { Expense } from '@/modules/Expenses/models/Expense.model'; +import { BankTransaction } from '@/modules/BankingTransactions/models/BankTransaction'; +import { CreateExpense } from '@/modules/Expenses/commands/CreateExpense.service'; +import { Inject } from '@nestjs/common'; +import { UnitOfWork } from '@/modules/Tenancy/TenancyDB/UnitOfWork.service'; +import { Injectable } from '@nestjs/common'; +import { events } from '@/common/events/events'; +import { ICashflowTransactionUncategorizedPayload } from '../types/BankingCategorize.types'; + +@Injectable() +export class CategorizeTransactionAsExpense { + constructor( + private readonly uow: UnitOfWork, + private readonly eventPublisher: EventEmitter2, + private readonly createExpenseService: CreateExpense, + + @Inject(UncategorizedBankTransaction.name) + private readonly uncategorizedBankTransactionModel: typeof UncategorizedBankTransaction, + + @Inject(Expense.name) + private readonly expenseModel: typeof Expense, + + @Inject(BankTransaction.name) + private readonly bankTransactionModel: typeof BankTransaction, + ) {} + + /** + * Categorize the transaction as expense transaction. + * @param {number} cashflowTransactionId + * @param {CategorizeTransactionAsExpenseDTO} transactionDTO + */ + public async categorize( + cashflowTransactionId: number, + transactionDTO: CategorizeTransactionAsExpenseDTO, + ) { + const transaction = await this.bankTransactionModel + .query() + .findById(cashflowTransactionId) + .throwIfNotFound(); + + return this.uow.withTransaction(async (trx: Knex.Transaction) => { + // Triggers `onTransactionUncategorizing` event. + await this.eventPublisher.emitAsync( + events.cashflow.onTransactionCategorizingAsExpense, + { + trx, + } as ICashflowTransactionCategorizedPayload, + ); + // Creates a new expense transaction. + const expenseTransaction = await this.createExpenseService.newExpense({}); + + // Updates the item on the storage and fetches the updated once. + const cashflowTransaction = await this.bankTransactionModel + .query(trx) + .patchAndFetchById(cashflowTransactionId, { + categorizeRefType: 'Expense', + categorizeRefId: expenseTransaction.id, + uncategorized: true, + }); + // Triggers `onTransactionUncategorized` event. + await this.eventPublisher.emitAsync( + events.cashflow.onTransactionCategorizedAsExpense, + { + cashflowTransaction, + trx, + } as ICashflowTransactionUncategorizedPayload, + ); + }); + } +} diff --git a/packages/server-nest/src/modules/BankingCategorize/commands/CreateUncategorizedTransaction.service.ts b/packages/server-nest/src/modules/BankingCategorize/commands/CreateUncategorizedTransaction.service.ts new file mode 100644 index 000000000..b8afc38a5 --- /dev/null +++ b/packages/server-nest/src/modules/BankingCategorize/commands/CreateUncategorizedTransaction.service.ts @@ -0,0 +1,61 @@ +import { Knex } from 'knex'; +import { + CreateUncategorizedTransactionDTO, + IUncategorizedTransactionCreatedEventPayload, + IUncategorizedTransactionCreatingEventPayload, +} from '../types/BankingCategorize.types'; +import { Inject, Injectable } from '@nestjs/common'; +import { EventEmitter2 } from '@nestjs/event-emitter'; +import { UncategorizedBankTransaction } from '../../BankingTransactions/models/UncategorizedBankTransaction'; +import { UnitOfWork } from '@/modules/Tenancy/TenancyDB/UnitOfWork.service'; +import { events } from '@/common/events/events'; + +@Injectable() +export class CreateUncategorizedTransactionService { + constructor( + private readonly uow: UnitOfWork, + private readonly eventPublisher: EventEmitter2, + + @Inject(UncategorizedBankTransaction.name) + private readonly uncategorizedBankTransaction: typeof UncategorizedBankTransaction, + ) {} + + /** + * Creates an uncategorized cashflow transaction. + * @param {CreateUncategorizedTransactionDTO} createDTO - Create uncategorized transaction DTO. + * @param {Knex.Transaction} trx - Knex transaction. + * @returns {Promise} + */ + public create( + createUncategorizedTransactionDTO: CreateUncategorizedTransactionDTO, + trx?: Knex.Transaction + ) { + return this.uow.withTransaction( + async (trx: Knex.Transaction) => { + await this.eventPublisher.emitAsync( + events.cashflow.onTransactionUncategorizedCreating, + { + createUncategorizedTransactionDTO, + trx, + } as IUncategorizedTransactionCreatingEventPayload + ); + + const uncategorizedTransaction = + await this.uncategorizedBankTransaction.query(trx).insertAndFetch({ + ...createUncategorizedTransactionDTO, + }); + + await this.eventPublisher.emitAsync( + events.cashflow.onTransactionUncategorizedCreated, + { + uncategorizedTransaction, + createUncategorizedTransactionDTO, + trx, + } as IUncategorizedTransactionCreatedEventPayload + ); + return uncategorizedTransaction; + }, + trx + ); + } +} diff --git a/packages/server-nest/src/modules/BankingCategorize/commands/UncategorizeCashflowTransaction.service.ts b/packages/server-nest/src/modules/BankingCategorize/commands/UncategorizeCashflowTransaction.service.ts new file mode 100644 index 000000000..3b4a25ee6 --- /dev/null +++ b/packages/server-nest/src/modules/BankingCategorize/commands/UncategorizeCashflowTransaction.service.ts @@ -0,0 +1,98 @@ +import { Knex } from 'knex'; +import { + ICashflowTransactionUncategorizedPayload, + ICashflowTransactionUncategorizingPayload, +} from '../types/BankingCategorize.types'; +import { validateTransactionShouldBeCategorized } from '../../BankingTransactions/utils'; +import { Inject, Injectable } from '@nestjs/common'; +import { EventEmitter2 } from '@nestjs/event-emitter'; +import { UnitOfWork } from '@/modules/Tenancy/TenancyDB/UnitOfWork.service'; +import { events } from '@/common/events/events'; +import { UncategorizedBankTransaction } from '../../BankingTransactions/models/UncategorizedBankTransaction'; + +@Injectable() +export class UncategorizeCashflowTransactionService { + constructor( + private readonly eventPublisher: EventEmitter2, + private readonly uow: UnitOfWork, + + @Inject(UncategorizedBankTransaction.name) + private readonly uncategorizedBankTransactionModel: typeof UncategorizedBankTransaction, + ) {} + + /** + * Uncategorizes the given cashflow transaction. + * @param {number} cashflowTransactionId - The id of the cashflow transaction to be uncategorized. + * @returns {Promise>} + */ + public async uncategorize( + uncategorizedTransactionId: number, + ): Promise> { + const oldMainUncategorizedTransaction = + await this.uncategorizedBankTransactionModel + .query() + .findById(uncategorizedTransactionId) + .throwIfNotFound(); + + validateTransactionShouldBeCategorized(oldMainUncategorizedTransaction); + + const associatedUncategorizedTransactions = + await this.uncategorizedBankTransactionModel + .query() + .where( + 'categorizeRefId', + oldMainUncategorizedTransaction.categorizeRefId, + ) + .where( + 'categorizeRefType', + oldMainUncategorizedTransaction.categorizeRefType, + ) + // Exclude the main transaction. + .whereNot('id', uncategorizedTransactionId); + + const oldUncategorizedTransactions = [ + oldMainUncategorizedTransaction, + ...associatedUncategorizedTransactions, + ]; + const oldUncategoirzedTransactionsIds = oldUncategorizedTransactions.map( + (t) => t.id, + ); + // Updates the transaction under UOW. + return this.uow.withTransaction(async (trx: Knex.Transaction) => { + // Triggers `onTransactionUncategorizing` event. + await this.eventPublisher.emitAsync( + events.cashflow.onTransactionUncategorizing, + { + uncategorizedTransactionId, + oldUncategorizedTransactions, + trx, + } as ICashflowTransactionUncategorizingPayload, + ); + // Removes the ref relation with the related transaction. + await this.uncategorizedBankTransactionModel + .query(trx) + .whereIn('id', oldUncategoirzedTransactionsIds) + .patch({ + categorized: false, + categorizeRefId: null, + categorizeRefType: null, + }); + const uncategorizedTransactions = + await this.uncategorizedBankTransactionModel + .query(trx) + .whereIn('id', oldUncategoirzedTransactionsIds); + // Triggers `onTransactionUncategorized` event. + await this.eventPublisher.emitAsync( + events.cashflow.onTransactionUncategorized, + { + uncategorizedTransactionId, + oldMainUncategorizedTransaction, + uncategorizedTransactions, + oldUncategorizedTransactions, + trx, + } as ICashflowTransactionUncategorizedPayload, + ); + return oldUncategoirzedTransactionsIds; + }); + } +} diff --git a/packages/server-nest/src/modules/BankingCategorize/commands/UncategorizeCashflowTransactionsBulk.service.ts b/packages/server-nest/src/modules/BankingCategorize/commands/UncategorizeCashflowTransactionsBulk.service.ts new file mode 100644 index 000000000..e92011eed --- /dev/null +++ b/packages/server-nest/src/modules/BankingCategorize/commands/UncategorizeCashflowTransactionsBulk.service.ts @@ -0,0 +1,32 @@ +import { castArray } from 'lodash'; +import { PromisePool } from '@supercharge/promise-pool'; +import { Injectable } from '@nestjs/common'; +import { UncategorizeCashflowTransactionService } from './UncategorizeCashflowTransaction.service'; + +@Injectable() +export class UncategorizeCashflowTransactionsBulk { + constructor( + private readonly uncategorizeTransactionService: UncategorizeCashflowTransactionService + ) {} + + /** + * Uncategorize the given bank transactions in bulk. + * @param {number} tenantId + * @param {number} uncategorizedTransactionId + */ + public async uncategorizeBulk( + uncategorizedTransactionId: number | Array + ) { + const uncategorizedTransactionIds = castArray(uncategorizedTransactionId); + + const result = await PromisePool.withConcurrency(MIGRATION_CONCURRENCY) + .for(uncategorizedTransactionIds) + .process(async (_uncategorizedTransactionId: number, index, pool) => { + await this.uncategorizeTransactionService.uncategorize( + _uncategorizedTransactionId + ); + }); + } +} + +const MIGRATION_CONCURRENCY = 1; diff --git a/packages/server-nest/src/modules/BankingCategorize/commands/UncategorizedTransaction.transformer.ts b/packages/server-nest/src/modules/BankingCategorize/commands/UncategorizedTransaction.transformer.ts new file mode 100644 index 000000000..b7134c2a5 --- /dev/null +++ b/packages/server-nest/src/modules/BankingCategorize/commands/UncategorizedTransaction.transformer.ts @@ -0,0 +1,145 @@ +import { Transformer } from "../../Transformer/Transformer"; + +export class UncategorizedTransactionTransformer extends Transformer { + /** + * Include these attributes to sale invoice object. + * @returns {string[]} + */ + public includeAttributes = (): string[] => { + return [ + 'formattedAmount', + 'formattedDate', + 'formattedDepositAmount', + 'formattedWithdrawalAmount', + + 'assignedAccountId', + 'assignedAccountName', + 'assignedAccountCode', + 'assignedPayee', + 'assignedMemo', + 'assignedCategory', + 'assignedCategoryFormatted', + ]; + }; + + /** + * Exclude all attributes. + * @returns {Array} + */ + public excludeAttributes = (): string[] => { + return ['recognizedTransaction']; + }; + + /** + * Formattes the transaction date. + * @param transaction + * @returns {string} + */ + public formattedDate(transaction) { + return this.formatDate(transaction.date); + } + + /** + * Formatted amount. + * @param transaction + * @returns {string} + */ + public formattedAmount(transaction) { + return this.formatNumber(transaction.amount, { + currencyCode: transaction.currencyCode, + }); + } + + /** + * Formatted deposit amount. + * @param transaction + * @returns {string} + */ + protected formattedDepositAmount(transaction) { + if (transaction.isDepositTransaction) { + return this.formatNumber(transaction.deposit, { + currencyCode: transaction.currencyCode, + }); + } + return ''; + } + + /** + * Formatted withdrawal amount. + * @param transaction + * @returns {string} + */ + protected formattedWithdrawalAmount(transaction) { + if (transaction.isWithdrawalTransaction) { + return this.formatNumber(transaction.withdrawal, { + currencyCode: transaction.currencyCode, + }); + } + return ''; + } + + // -------------------------------------------------------- + // # Recgonized transaction + // -------------------------------------------------------- + /** + * Get the assigned account ID of the transaction. + * @param {object} transaction + * @returns {number} + */ + public assignedAccountId(transaction: any): number { + return transaction.recognizedTransaction?.assignedAccountId; + } + + /** + * Get the assigned account name of the transaction. + * @param {object} transaction + * @returns {string} + */ + public assignedAccountName(transaction: any): string { + return transaction.recognizedTransaction?.assignAccount?.name; + } + + /** + * Get the assigned account code of the transaction. + * @param {object} transaction + * @returns {string} + */ + public assignedAccountCode(transaction: any): string { + return transaction.recognizedTransaction?.assignAccount?.code; + } + + /** + * Get the assigned payee of the transaction. + * @param {object} transaction + * @returns {string} + */ + public getAssignedPayee(transaction: any): string { + return transaction.recognizedTransaction?.assignedPayee; + } + + /** + * Get the assigned memo of the transaction. + * @param {object} transaction + * @returns {string} + */ + public assignedMemo(transaction: any): string { + return transaction.recognizedTransaction?.assignedMemo; + } + + /** + * Get the assigned category of the transaction. + * @param {object} transaction + * @returns {string} + */ + public assignedCategory(transaction: any): string { + return transaction.recognizedTransaction?.assignedCategory; + } + + /** + * Get the assigned formatted category. + * @returns {string} + */ + public assignedCategoryFormatted() { + return 'Other Income'; + } +} diff --git a/packages/server-nest/src/modules/BankingCategorize/commands/UncategorizedTransactionsImportable.ts b/packages/server-nest/src/modules/BankingCategorize/commands/UncategorizedTransactionsImportable.ts new file mode 100644 index 000000000..b912fbe85 --- /dev/null +++ b/packages/server-nest/src/modules/BankingCategorize/commands/UncategorizedTransactionsImportable.ts @@ -0,0 +1,99 @@ +import { Inject, Injectable } from '@nestjs/common'; +import { Knex } from 'knex'; +import * as yup from 'yup'; +import uniqid from 'uniqid'; +import { Importable } from '../../Import/Importable'; +import { CreateUncategorizedTransactionService } from './CreateUncategorizedTransaction.service'; +import { ImportableContext } from '../../Import/interfaces'; +import { BankTransactionsSampleData } from '../../BankingTransactions/constants'; +import { Account } from '@/modules/Accounts/models/Account.model'; +import { CreateUncategorizedTransactionDTO } from '../types/BankingCategorize.types'; + +@Injectable() +export class UncategorizedTransactionsImportable extends Importable { + constructor( + private readonly createUncategorizedTransaction: CreateUncategorizedTransactionService, + + @Inject(Account.name) + private readonly accountModel: typeof Account, + ) { + super(); + } + + /** + * Passing the sheet DTO to create uncategorized transaction. + * @param {CreateUncategorizedTransactionDTO,} createDTO + * @param {Knex.Transaction} trx + */ + public async importable( + createDTO: CreateUncategorizedTransactionDTO, + trx?: Knex.Transaction, + ) { + return this.createUncategorizedTransaction.create(createDTO, trx); + } + + /** + * Transformes the DTO before validating and importing. + * @param {CreateUncategorizedTransactionDTO} createDTO + * @param {ImportableContext} context + * @returns {CreateUncategorizedTransactionDTO} + */ + public transform( + createDTO: CreateUncategorizedTransactionDTO, + context?: ImportableContext, + ): CreateUncategorizedTransactionDTO { + return { + ...createDTO, + accountId: context.import.paramsParsed.accountId, + batch: context.import.paramsParsed.batch, + }; + } + + /** + * Sample data used to download sample sheet. + * @returns {Record[]} + */ + public sampleData(): Record[] { + return BankTransactionsSampleData; + } + + // ------------------ + // # Params + // ------------------ + /** + * Params validation schema. + * @returns {ValidationSchema[]} + */ + public paramsValidationSchema() { + return yup.object().shape({ + accountId: yup.number().required(), + }); + } + + /** + * Validates the params existance asyncly. + * @param {number} tenantId - + * @param {Record} params - + */ + public async validateParams(params: Record): Promise { + if (params.accountId) { + await this.accountModel + .query() + .findById(params.accountId) + .throwIfNotFound({}); + } + } + + /** + * Transforms the import params before storing them. + * @param {Record} parmas + */ + public transformParams(parmas: Record) { + const batch = uniqid(); + + return { + ...parmas, + batch, + }; + } +} diff --git a/packages/server-nest/src/modules/BankingCategorize/types/BankingCategorize.types.ts b/packages/server-nest/src/modules/BankingCategorize/types/BankingCategorize.types.ts new file mode 100644 index 000000000..03dc7b210 --- /dev/null +++ b/packages/server-nest/src/modules/BankingCategorize/types/BankingCategorize.types.ts @@ -0,0 +1,63 @@ +import { UncategorizedBankTransaction } from "@/modules/BankingTransactions/models/UncategorizedBankTransaction"; +import { Knex } from "knex"; + +export interface ICashflowTransactionCategorizedPayload { + uncategorizedTransactions: Array; + cashflowTransaction: UncategorizedBankTransaction; + oldUncategorizedTransactions: Array; + categorizeDTO: any; + trx: Knex.Transaction; +} + +export interface ICashflowTransactionUncategorizingPayload { + uncategorizedTransactionId: number; + oldUncategorizedTransactions: Array; + trx: Knex.Transaction; +} + +export interface ICashflowTransactionUncategorizedPayload { + uncategorizedTransactionId: number; + uncategorizedTransactions: Array; + oldMainUncategorizedTransaction: UncategorizedBankTransaction; + oldUncategorizedTransactions: Array; + trx: Knex.Transaction; +} + +export interface ICategorizeCashflowTransactioDTO { + date: Date; + creditAccountId: number; + referenceNo: string; + transactionNumber: string; + transactionType: string; + exchangeRate: number; + description: string; + branchId: number; +} + + +export interface IUncategorizedTransactionCreatingEventPayload { + tenantId: number; + createUncategorizedTransactionDTO: CreateUncategorizedTransactionDTO; + trx: Knex.Transaction; +} + +export interface IUncategorizedTransactionCreatedEventPayload { + tenantId: number; + uncategorizedTransaction: any; + createUncategorizedTransactionDTO: CreateUncategorizedTransactionDTO; + trx: Knex.Transaction; +} + +export interface CreateUncategorizedTransactionDTO { + date: Date | string; + accountId: number; + amount: number; + currencyCode: string; + payee?: string; + description?: string; + referenceNo?: string | null; + plaidTransactionId?: string | null; + pending?: boolean; + pendingPlaidTransactionId?: string | null; + batch?: string; +} diff --git a/packages/server-nest/src/modules/BankingMatching/BankingMatching.module.ts b/packages/server-nest/src/modules/BankingMatching/BankingMatching.module.ts new file mode 100644 index 000000000..1e1573282 --- /dev/null +++ b/packages/server-nest/src/modules/BankingMatching/BankingMatching.module.ts @@ -0,0 +1,39 @@ +import { Module } from '@nestjs/common'; +import { MatchedBankTransaction } from './models/MatchedBankTransaction'; +import { RegisterTenancyModel } from '../Tenancy/TenancyModels/Tenancy.module'; +import { BankingMatchingApplication } from './BankingMatchingApplication'; +import { GetMatchedTransactions } from './queries/GetMatchedTransactions.service'; +import { UnmatchMatchedBankTransaction } from './commands/UnmatchMatchedTransaction.service'; +import { GetMatchedTransactionsByBills } from './queries/GetMatchedTransactionsByBills.service'; +import { GetMatchedTransactionsByCashflow } from './queries/GetMatchedTransactionsByCashflow'; +import { GetMatchedTransactionsByExpenses } from './queries/GetMatchedTransactionsByExpenses'; +import { GetMatchedTransactionsByInvoices } from './queries/GetMatchedTransactionsByInvoices.service'; +import { ValidateMatchingOnExpenseDeleteSubscriber } from './events/ValidateMatchingOnExpenseDelete'; +import { ValidateMatchingOnPaymentReceivedDeleteSubscriber } from './events/ValidateMatchingOnPaymentReceivedDelete'; +import { DecrementUncategorizedTransactionOnMatchingSubscriber } from './events/DecrementUncategorizedTransactionsOnMatch'; +import { ValidateMatchingOnPaymentMadeDeleteSubscriber } from './events/ValidateMatchingOnPaymentMadeDelete'; +import { ValidateMatchingOnManualJournalDeleteSubscriber } from './events/ValidateMatchingOnManualJournalDelete'; +import { ValidateMatchingOnCashflowDeleteSubscriber } from './events/ValidateMatchingOnCashflowDelete'; + +const models = [RegisterTenancyModel(MatchedBankTransaction)]; + +@Module({ + providers: [ + ...models, + GetMatchedTransactionsByBills, + GetMatchedTransactionsByCashflow, + GetMatchedTransactionsByExpenses, + GetMatchedTransactionsByInvoices, + BankingMatchingApplication, + GetMatchedTransactions, + UnmatchMatchedBankTransaction, + ValidateMatchingOnExpenseDeleteSubscriber, + ValidateMatchingOnPaymentReceivedDeleteSubscriber, + DecrementUncategorizedTransactionOnMatchingSubscriber, + ValidateMatchingOnPaymentMadeDeleteSubscriber, + ValidateMatchingOnManualJournalDeleteSubscriber, + ValidateMatchingOnCashflowDeleteSubscriber + ], + exports: [...models], +}) +export class BankingMatchingModule {} diff --git a/packages/server-nest/src/modules/BankingMatching/BankingMatchingApplication.ts b/packages/server-nest/src/modules/BankingMatching/BankingMatchingApplication.ts new file mode 100644 index 000000000..363968ddf --- /dev/null +++ b/packages/server-nest/src/modules/BankingMatching/BankingMatchingApplication.ts @@ -0,0 +1,65 @@ +import { Injectable } from '@nestjs/common'; +import { GetMatchedTransactions } from './queries/GetMatchedTransactions.service'; +import { MatchBankTransactions } from './commands/MatchTransactions'; +import { UnmatchMatchedBankTransaction } from './commands/UnmatchMatchedTransaction.service'; +import { GetMatchedTransactionsFilter, IMatchTransactionDTO } from './types'; + +@Injectable() +export class BankingMatchingApplication { + constructor( + private readonly getMatchedTransactionsService: GetMatchedTransactions, + private readonly matchTransactionService: MatchBankTransactions, + private readonly unmatchMatchedTransactionService: UnmatchMatchedBankTransaction + ) {} + + /** + * Retrieves the matched transactions. + * @param {Array} uncategorizedTransactionsIds - Uncategorized transactions ids. + * @param {GetMatchedTransactionsFilter} filter - + * @returns {Promise} + */ + public getMatchedTransactions( + uncategorizedTransactionsIds: Array, + filter: GetMatchedTransactionsFilter + ) { + return this.getMatchedTransactionsService.getMatchedTransactions( + uncategorizedTransactionsIds, + filter + ); + } + + /** + * Matches the given uncategorized transaction with the given system transaction. + * @param {number} tenantId + * @param {number} uncategorizedTransactionId + * @param {IMatchTransactionDTO} matchTransactionsDTO + * @returns {Promise} + */ + public matchTransaction( + tenantId: number, + uncategorizedTransactionId: number | Array, + matchedTransactions: Array + ): Promise { + return this.matchTransactionService.matchTransaction( + tenantId, + uncategorizedTransactionId, + matchedTransactions + ); + } + + /** + * Unmatch the given matched transaction. + * @param {number} tenantId + * @param {number} uncategorizedTransactionId + * @returns {Promise} + */ + public unmatchMatchedTransaction( + tenantId: number, + uncategorizedTransactionId: number + ) { + return this.unmatchMatchedTransactionService.unmatchMatchedTransaction( + tenantId, + uncategorizedTransactionId + ); + } +} diff --git a/packages/server-nest/src/modules/BankingMatching/_utils.ts b/packages/server-nest/src/modules/BankingMatching/_utils.ts new file mode 100644 index 000000000..65563de39 --- /dev/null +++ b/packages/server-nest/src/modules/BankingMatching/_utils.ts @@ -0,0 +1,64 @@ +import moment from 'moment'; +import * as R from 'ramda'; +import { isEmpty, sumBy } from 'lodash'; +import { ERRORS, MatchedTransactionPOJO } from './types'; +import { ServiceError } from '../Items/ServiceError'; + +export const sortClosestMatchTransactions = ( + amount: number, + date: Date, + matches: MatchedTransactionPOJO[] +) => { + return R.sortWith([ + // Sort by amount difference (closest to uncategorized transaction amount first) + R.ascend((match: MatchedTransactionPOJO) => + Math.abs(match.amount - amount) + ), + // Sort by date difference (closest to uncategorized transaction date first) + R.ascend((match: MatchedTransactionPOJO) => + Math.abs(moment(match.date).diff(moment(date), 'days')) + ), + ])(matches); +}; + +export const sumMatchTranasctions = (transactions: Array) => { + return transactions.reduce( + (total, item) => + total + + (item.transactionNormal === 'debit' ? 1 : -1) * parseFloat(item.amount), + 0 + ); +}; + +export const sumUncategorizedTransactions = ( + uncategorizedTransactions: Array +) => { + return sumBy(uncategorizedTransactions, 'amount'); +}; + +export const validateUncategorizedTransactionsNotMatched = ( + uncategorizedTransactions: any +) => { + const matchedTransactions = uncategorizedTransactions.filter( + (trans) => !isEmpty(trans.matchedBankTransactions) + ); + // + if (matchedTransactions.length > 0) { + throw new ServiceError(ERRORS.TRANSACTION_ALREADY_MATCHED, '', { + matchedTransactionsIds: matchedTransactions?.map((m) => m.id), + }); + } +}; + +export const validateUncategorizedTransactionsExcluded = ( + uncategorizedTransactions: any +) => { + const excludedTransactions = uncategorizedTransactions.filter( + (trans) => trans.excluded + ); + if (excludedTransactions.length > 0) { + throw new ServiceError(ERRORS.CANNOT_MATCH_EXCLUDED_TRANSACTION, '', { + excludedTransactionsIds: excludedTransactions.map((e) => e.id), + }); + } +}; diff --git a/packages/server-nest/src/modules/BankingMatching/commands/MatchTransactions.ts b/packages/server-nest/src/modules/BankingMatching/commands/MatchTransactions.ts new file mode 100644 index 000000000..63466bdad --- /dev/null +++ b/packages/server-nest/src/modules/BankingMatching/commands/MatchTransactions.ts @@ -0,0 +1,149 @@ +import { castArray } from 'lodash'; +import { Knex } from 'knex'; +import { Inject, Injectable } from '@nestjs/common'; +import { PromisePool } from '@supercharge/promise-pool'; +import { + ERRORS, + IBankTransactionMatchedEventPayload, + IBankTransactionMatchingEventPayload, + IMatchTransactionDTO, +} from '../types'; +import { MatchTransactionsTypes } from './MatchTransactionsTypes'; +import { + sumMatchTranasctions, + sumUncategorizedTransactions, + validateUncategorizedTransactionsExcluded, + validateUncategorizedTransactionsNotMatched, +} from '../_utils'; +import { EventEmitter2 } from '@nestjs/event-emitter'; +import { UnitOfWork } from '@/modules/Tenancy/TenancyDB/UnitOfWork.service'; +import { ServiceError } from '@/modules/Items/ServiceError'; +import { UncategorizedBankTransaction } from '@/modules/BankingTransactions/models/UncategorizedBankTransaction'; +import { events } from '@/common/events/events'; + +@Injectable() +export class MatchBankTransactions { + constructor( + private readonly uow: UnitOfWork, + private readonly eventPublisher: EventEmitter2, + private readonly matchedBankTransactions: MatchTransactionsTypes, + + @Inject(UncategorizedBankTransaction.name) + private readonly uncategorizedBankTransactionModel: typeof UncategorizedBankTransaction, + ) {} + + /** + * Validates the match bank transactions DTO. + * @param {number} uncategorizedTransactionId - Uncategorized transaction id. + * @param {IMatchTransactionsDTO} matchTransactionsDTO - Match transactions DTO. + * @returns {Promise} + */ + async validate( + uncategorizedTransactionId: number | Array, + matchedTransactions: Array, + ) { + const uncategorizedTransactionIds = castArray(uncategorizedTransactionId); + + // Validates the uncategorized transaction existance. + const uncategorizedTransactions = + await this.uncategorizedBankTransactionModel + .query() + .whereIn('id', uncategorizedTransactionIds) + .withGraphFetched('matchedBankTransactions') + .throwIfNotFound(); + + // Validates the uncategorized transaction is not already matched. + validateUncategorizedTransactionsNotMatched(uncategorizedTransactions); + + // Validate the uncategorized transaction is not excluded. + validateUncategorizedTransactionsExcluded(uncategorizedTransactions); + + // Validates the given matched transaction. + const validateMatchedTransaction = async (matchedTransaction) => { + const getMatchedTransactionsService = + this.matchedBankTransactions.registry.get( + matchedTransaction.referenceType, + ); + if (!getMatchedTransactionsService) { + throw new ServiceError( + ERRORS.RESOURCE_TYPE_MATCHING_TRANSACTION_INVALID, + ); + } + const foundMatchedTransaction = + await getMatchedTransactionsService.getMatchedTransaction( + matchedTransaction.referenceId, + ); + if (!foundMatchedTransaction) { + throw new ServiceError(ERRORS.RESOURCE_ID_MATCHING_TRANSACTION_INVALID); + } + return foundMatchedTransaction; + }; + // Matches the given transactions under promise pool concurrency controlling. + const validatationResult = await PromisePool.withConcurrency(10) + .for(matchedTransactions) + .process(validateMatchedTransaction); + + if (validatationResult.errors?.length > 0) { + const error = validatationResult.errors.map((er) => er.raw)[0]; + throw new ServiceError(error); + } + // Calculate the total given matching transactions. + const totalMatchedTranasctions = sumMatchTranasctions( + validatationResult.results, + ); + const totalUncategorizedTransactions = sumUncategorizedTransactions( + uncategorizedTransactions, + ); + // Validates the total given matching transcations whether is not equal + // uncategorized transaction amount. + if (totalUncategorizedTransactions !== totalMatchedTranasctions) { + throw new ServiceError(ERRORS.TOTAL_MATCHING_TRANSACTIONS_INVALID); + } + } + + /** + * Matches the given uncategorized transaction to the given references. + * @param {number} tenantId + * @param {number} uncategorizedTransactionId + * @returns {Promise} + */ + public async matchTransaction( + tenantId: number, + uncategorizedTransactionId: number | Array, + matchedTransactions: Array, + ): Promise { + const uncategorizedTransactionIds = castArray(uncategorizedTransactionId); + + // Validates the given matching transactions DTO. + await this.validate(uncategorizedTransactionIds, matchedTransactions); + return this.uow.withTransaction(async (trx: Knex.Transaction) => { + // Triggers the event `onBankTransactionMatching`. + await this.eventPublisher.emitAsync(events.bankMatch.onMatching, { + uncategorizedTransactionIds, + matchedTransactions, + trx, + } as IBankTransactionMatchingEventPayload); + + // Matches the given transactions under promise pool concurrency controlling. + await PromisePool.withConcurrency(10) + .for(matchedTransactions) + .process(async (matchedTransaction) => { + const getMatchedTransactionsService = + this.matchedBankTransactions.registry.get( + matchedTransaction.referenceType, + ); + await getMatchedTransactionsService.createMatchedTransaction( + uncategorizedTransactionIds, + matchedTransaction, + trx, + ); + }); + // Triggers the event `onBankTransactionMatched`. + await this.eventPublisher.emitAsync(events.bankMatch.onMatched, { + uncategorizedTransactionIds, + matchedTransactions, + trx, + } as IBankTransactionMatchedEventPayload); + }); + } +} diff --git a/packages/server-nest/src/modules/BankingMatching/commands/MatchTransactionsTypes.ts b/packages/server-nest/src/modules/BankingMatching/commands/MatchTransactionsTypes.ts new file mode 100644 index 000000000..73acedaaf --- /dev/null +++ b/packages/server-nest/src/modules/BankingMatching/commands/MatchTransactionsTypes.ts @@ -0,0 +1,63 @@ +import { GetMatchedTransactionsByExpenses } from '../queries/GetMatchedTransactionsByExpenses'; +import { GetMatchedTransactionsByBills } from '../queries/GetMatchedTransactionsByBills.service'; +import { GetMatchedTransactionsByManualJournals } from '../queries/GetMatchedTransactionsByManualJournals.service'; +import { MatchTransactionsTypesRegistry } from './MatchTransactionsTypesRegistry'; +import { GetMatchedTransactionsByInvoices } from '../queries/GetMatchedTransactionsByInvoices.service'; +import { GetMatchedTransactionCashflowTransformer } from '../queries/GetMatchedTransactionCashflowTransformer'; +import { GetMatchedTransactionsByCashflow } from '../queries/GetMatchedTransactionsByCashflow'; +import { Injectable } from '@nestjs/common'; + +@Injectable() +export class MatchTransactionsTypes { + private static registry: MatchTransactionsTypesRegistry; + + /** + * Consttuctor method. + */ + constructor() { + this.boot(); + } + + get registered() { + return [ + { type: 'SaleInvoice', service: GetMatchedTransactionsByInvoices }, + { type: 'Bill', service: GetMatchedTransactionsByBills }, + { type: 'Expense', service: GetMatchedTransactionsByExpenses }, + { + type: 'ManualJournal', + service: GetMatchedTransactionsByManualJournals, + }, + { + type: 'CashflowTransaction', + service: GetMatchedTransactionsByCashflow, + }, + ]; + } + + /** + * Importable instances. + */ + private types = []; + + /** + * + */ + public get registry() { + return MatchTransactionsTypes.registry; + } + + /** + * Boots all the registered importables. + */ + public boot() { + if (!MatchTransactionsTypes.registry) { + const instance = MatchTransactionsTypesRegistry.getInstance(); + + this.registered.forEach((registered) => { + const serviceInstanace = Container.get(registered.service); + instance.register(registered.type, serviceInstanace); + }); + MatchTransactionsTypes.registry = instance; + } + } +} diff --git a/packages/server-nest/src/modules/BankingMatching/commands/MatchTransactionsTypesRegistry.ts b/packages/server-nest/src/modules/BankingMatching/commands/MatchTransactionsTypesRegistry.ts new file mode 100644 index 000000000..e27016b44 --- /dev/null +++ b/packages/server-nest/src/modules/BankingMatching/commands/MatchTransactionsTypesRegistry.ts @@ -0,0 +1,50 @@ +import { camelCase, upperFirst } from 'lodash'; +import { GetMatchedTransactionsByType } from '../queries/GetMatchedTransactionsByType'; + +export class MatchTransactionsTypesRegistry { + private static instance: MatchTransactionsTypesRegistry; + private importables: Record; + + constructor() { + this.importables = {}; + } + + /** + * Gets singleton instance of registry. + * @returns {MatchTransactionsTypesRegistry} + */ + public static getInstance(): MatchTransactionsTypesRegistry { + if (!MatchTransactionsTypesRegistry.instance) { + MatchTransactionsTypesRegistry.instance = + new MatchTransactionsTypesRegistry(); + } + return MatchTransactionsTypesRegistry.instance; + } + + /** + * Registers the given importable service. + * @param {string} resource + * @param {GetMatchedTransactionsByType} importable + */ + public register( + resource: string, + importable: GetMatchedTransactionsByType + ): void { + const _resource = this.sanitizeResourceName(resource); + this.importables[_resource] = importable; + } + + /** + * Retrieves the importable service instance of the given resource name. + * @param {string} name + * @returns {GetMatchedTransactionsByType} + */ + public get(name: string): GetMatchedTransactionsByType { + const _name = this.sanitizeResourceName(name); + return this.importables[_name]; + } + + private sanitizeResourceName(resource: string) { + return upperFirst(camelCase(resource)); + } +} diff --git a/packages/server-nest/src/modules/BankingMatching/commands/UnmatchMatchedTransaction.service.ts b/packages/server-nest/src/modules/BankingMatching/commands/UnmatchMatchedTransaction.service.ts new file mode 100644 index 000000000..7068587ed --- /dev/null +++ b/packages/server-nest/src/modules/BankingMatching/commands/UnmatchMatchedTransaction.service.ts @@ -0,0 +1,46 @@ +import { Inject, Injectable } from '@nestjs/common'; +import { IBankTransactionUnmatchingEventPayload } from '../types'; +import { EventEmitter2 } from '@nestjs/event-emitter'; +import { UnitOfWork } from '../../Tenancy/TenancyDB/UnitOfWork.service'; +import { events } from '@/common/events/events'; +import { MatchedBankTransaction } from '../models/MatchedBankTransaction'; + +@Injectable() +export class UnmatchMatchedBankTransaction { + constructor( + private readonly uow: UnitOfWork, + private readonly eventPublisher: EventEmitter2, + + @Inject(MatchedBankTransaction.name) + private readonly matchedBankTransactionModel: typeof MatchedBankTransaction, + ) {} + + /** + * Unmatch the matched the given uncategorized bank transaction. + * @param {number} uncategorizedTransactionId - Uncategorized transaction id. + * @returns {Promise} + */ + public unmatchMatchedTransaction( + tenantId: number, + uncategorizedTransactionId: number, + ): Promise { + return this.uow.withTransaction(async (trx) => { + await this.eventPublisher.emitAsync(events.bankMatch.onUnmatching, { + tenantId, + uncategorizedTransactionId, + trx, + } as IBankTransactionUnmatchingEventPayload); + + await this.matchedBankTransactionModel + .query(trx) + .where('uncategorizedTransactionId', uncategorizedTransactionId) + .delete(); + + await this.eventPublisher.emitAsync(events.bankMatch.onUnmatched, { + tenantId, + uncategorizedTransactionId, + trx, + } as IBankTransactionUnmatchingEventPayload); + }); + } +} diff --git a/packages/server-nest/src/modules/BankingMatching/commands/ValidateTransactionsMatched.service.ts b/packages/server-nest/src/modules/BankingMatching/commands/ValidateTransactionsMatched.service.ts new file mode 100644 index 000000000..2c2f00606 --- /dev/null +++ b/packages/server-nest/src/modules/BankingMatching/commands/ValidateTransactionsMatched.service.ts @@ -0,0 +1,34 @@ +import { Knex } from 'knex'; +import { ERRORS } from '../types'; +import { Inject, Injectable } from '@nestjs/common'; +import { ServiceError } from '@/modules/Items/ServiceError'; +import { MatchedBankTransaction } from '../models/MatchedBankTransaction'; + +@Injectable() +export class ValidateTransactionMatched { + constructor( + @Inject(MatchedBankTransaction.name) + private readonly matchedBankTransactionModel: typeof MatchedBankTransaction, + ) {} + + /** + * Validate the given transaction whether is matched with bank transactions. + * @param {string} referenceType - Transaction reference type. + * @param {number} referenceId - Transaction reference id. + * @returns {Promise} + */ + public async validateTransactionNoMatchLinking( + referenceType: string, + referenceId: number, + trx?: Knex.Transaction + ) { + const foundMatchedTransaction = + await this.matchedBankTransactionModel.query(trx).findOne({ + referenceType, + referenceId, + }); + if (foundMatchedTransaction) { + throw new ServiceError(ERRORS.CANNOT_DELETE_TRANSACTION_MATCHED); + } + } +} diff --git a/packages/server-nest/src/modules/BankingMatching/events/DecrementUncategorizedTransactionsOnMatch.ts b/packages/server-nest/src/modules/BankingMatching/events/DecrementUncategorizedTransactionsOnMatch.ts new file mode 100644 index 000000000..8c5a9977c --- /dev/null +++ b/packages/server-nest/src/modules/BankingMatching/events/DecrementUncategorizedTransactionsOnMatch.ts @@ -0,0 +1,64 @@ +import { + IBankTransactionMatchedEventPayload, + IBankTransactionUnmatchedEventPayload, +} from '../types'; +import PromisePool from '@supercharge/promise-pool'; +import { OnEvent } from '@nestjs/event-emitter'; +import { Account } from '@/modules/Accounts/models/Account.model'; +import { Inject, Injectable } from '@nestjs/common'; +import { UncategorizedBankTransaction } from '@/modules/BankingTransactions/models/UncategorizedBankTransaction'; +import { events } from '@/common/events/events'; + +@Injectable() +export class DecrementUncategorizedTransactionOnMatchingSubscriber { + constructor( + @Inject(Account.name) + private readonly accountModel: typeof Account, + + @Inject(UncategorizedBankTransaction.name) + private readonly uncategorizedBankTransactionModel: typeof UncategorizedBankTransaction, + ) {} + + /** + * Validates the cashflow transaction whether matched with bank transaction on deleting. + * @param {IManualJournalDeletingPayload} + */ + @OnEvent(events.bankMatch.onMatched) + public async decrementUnCategorizedTransactionsOnMatching({ + uncategorizedTransactionIds, + trx, + }: IBankTransactionMatchedEventPayload) { + const uncategorizedTransactions = + await this.uncategorizedBankTransactionModel.query().whereIn( + 'id', + uncategorizedTransactionIds + ); + await PromisePool.withConcurrency(1) + .for(uncategorizedTransactions) + .process(async (transaction) => { + await this.accountModel + .query(trx) + .findById(transaction.accountId) + .decrement('uncategorizedTransactions', 1); + }); + } + + /** + * Validates the cashflow transaction whether matched with bank transaction on deleting. + * @param {IManualJournalDeletingPayload} + */ + @OnEvent(events.bankMatch.onUnmatched) + public async incrementUnCategorizedTransactionsOnUnmatching({ + uncategorizedTransactionId, + trx, + }: IBankTransactionUnmatchedEventPayload) { + const transaction = + await this.uncategorizedBankTransactionModel.query().findById( + uncategorizedTransactionId + ); + await this.accountModel + .query(trx) + .findById(transaction.accountId) + .increment('uncategorizedTransactions', 1); + } +} diff --git a/packages/server-nest/src/modules/BankingMatching/events/ValidateMatchingOnCashflowDelete.ts b/packages/server-nest/src/modules/BankingMatching/events/ValidateMatchingOnCashflowDelete.ts new file mode 100644 index 000000000..9a497bbca --- /dev/null +++ b/packages/server-nest/src/modules/BankingMatching/events/ValidateMatchingOnCashflowDelete.ts @@ -0,0 +1,28 @@ +import { OnEvent } from '@nestjs/event-emitter'; +import { Injectable } from '@nestjs/common'; +import { events } from '@/common/events/events'; +import { ValidateTransactionMatched } from '../commands/ValidateTransactionsMatched.service'; +import { ICommandCashflowDeletingPayload } from '@/modules/BankingTransactions/types/BankingTransactions.types'; + +@Injectable() +export class ValidateMatchingOnCashflowDeleteSubscriber { + constructor( + private readonly validateNoMatchingLinkedService: ValidateTransactionMatched, + ) {} + + /** + * Validates the cashflow transaction whether matched with bank transaction on deleting. + * @param {IManualJournalDeletingPayload} + */ + @OnEvent(events.cashflow.onTransactionDeleting) + public async validateMatchingOnCashflowDeleting({ + oldCashflowTransaction, + trx, + }: ICommandCashflowDeletingPayload) { + await this.validateNoMatchingLinkedService.validateTransactionNoMatchLinking( + 'CashflowTransaction', + oldCashflowTransaction.id, + trx, + ); + } +} diff --git a/packages/server-nest/src/modules/BankingMatching/events/ValidateMatchingOnExpenseDelete.ts b/packages/server-nest/src/modules/BankingMatching/events/ValidateMatchingOnExpenseDelete.ts new file mode 100644 index 000000000..71bb80bc2 --- /dev/null +++ b/packages/server-nest/src/modules/BankingMatching/events/ValidateMatchingOnExpenseDelete.ts @@ -0,0 +1,28 @@ +import { Injectable } from '@nestjs/common'; +import { OnEvent } from '@nestjs/event-emitter'; +import { ValidateTransactionMatched } from '../commands/ValidateTransactionsMatched.service'; +import { IExpenseEventDeletePayload } from '@/modules/Expenses/interfaces/Expenses.interface'; +import { events } from '@/common/events/events'; + +@Injectable() +export class ValidateMatchingOnExpenseDeleteSubscriber { + constructor( + private readonly validateNoMatchingLinkedService: ValidateTransactionMatched, + ) {} + + /** + * Validates the expense transaction whether matched with bank transaction on deleting. + * @param {IExpenseEventDeletePayload} + */ + @OnEvent(events.expenses.onDeleting) + public async validateMatchingOnExpenseDeleting({ + oldExpense, + trx, + }: IExpenseEventDeletePayload) { + await this.validateNoMatchingLinkedService.validateTransactionNoMatchLinking( + 'Expense', + oldExpense.id, + trx, + ); + } +} diff --git a/packages/server-nest/src/modules/BankingMatching/events/ValidateMatchingOnManualJournalDelete.ts b/packages/server-nest/src/modules/BankingMatching/events/ValidateMatchingOnManualJournalDelete.ts new file mode 100644 index 000000000..23127da71 --- /dev/null +++ b/packages/server-nest/src/modules/BankingMatching/events/ValidateMatchingOnManualJournalDelete.ts @@ -0,0 +1,28 @@ +import { Injectable } from '@nestjs/common'; +import { ValidateTransactionMatched } from '../commands/ValidateTransactionsMatched.service'; +import { OnEvent } from '@nestjs/event-emitter'; +import { IManualJournalDeletingPayload } from '@/modules/ManualJournals/types/ManualJournals.types'; +import { events } from '@/common/events/events'; + +@Injectable() +export class ValidateMatchingOnManualJournalDeleteSubscriber { + constructor( + private readonly validateNoMatchingLinkedService: ValidateTransactionMatched, + ) {} + + /** + * Validates the manual journal transaction whether matched with bank transaction on deleting. + * @param {IManualJournalDeletingPayload} + */ + @OnEvent(events.manualJournals.onDeleting) + public async validateMatchingOnManualJournalDeleting({ + oldManualJournal, + trx, + }: IManualJournalDeletingPayload) { + await this.validateNoMatchingLinkedService.validateTransactionNoMatchLinking( + 'ManualJournal', + oldManualJournal.id, + trx, + ); + } +} diff --git a/packages/server-nest/src/modules/BankingMatching/events/ValidateMatchingOnPaymentMadeDelete.ts b/packages/server-nest/src/modules/BankingMatching/events/ValidateMatchingOnPaymentMadeDelete.ts new file mode 100644 index 000000000..a8ab857b4 --- /dev/null +++ b/packages/server-nest/src/modules/BankingMatching/events/ValidateMatchingOnPaymentMadeDelete.ts @@ -0,0 +1,28 @@ +import { Injectable } from '@nestjs/common'; +import { OnEvent } from '@nestjs/event-emitter'; +import { ValidateTransactionMatched } from '../commands/ValidateTransactionsMatched.service'; +import { IBillPaymentEventDeletedPayload } from '@/modules/BillPayments/types/BillPayments.types'; +import { events } from '@/common/events/events'; + +@Injectable() +export class ValidateMatchingOnPaymentMadeDeleteSubscriber { + constructor( + private readonly validateNoMatchingLinkedService: ValidateTransactionMatched, + ) {} + + /** + * Validates the payment made transaction whether matched with bank transaction on deleting. + * @param {IPaymentReceivedDeletedPayload} + */ + @OnEvent(events.billPayment.onDeleting) + public async validateMatchingOnPaymentMadeDeleting({ + oldBillPayment, + trx, + }: IBillPaymentEventDeletedPayload) { + await this.validateNoMatchingLinkedService.validateTransactionNoMatchLinking( + 'PaymentMade', + oldBillPayment.id, + trx, + ); + } +} diff --git a/packages/server-nest/src/modules/BankingMatching/events/ValidateMatchingOnPaymentReceivedDelete.ts b/packages/server-nest/src/modules/BankingMatching/events/ValidateMatchingOnPaymentReceivedDelete.ts new file mode 100644 index 000000000..8a34d5fbf --- /dev/null +++ b/packages/server-nest/src/modules/BankingMatching/events/ValidateMatchingOnPaymentReceivedDelete.ts @@ -0,0 +1,28 @@ +import { Injectable } from '@nestjs/common'; +import { ValidateTransactionMatched } from '../commands/ValidateTransactionsMatched.service'; +import { OnEvent } from '@nestjs/event-emitter'; +import { events } from '@/common/events/events'; +import { IPaymentReceivedDeletedPayload } from '@/modules/PaymentReceived/types/PaymentReceived.types'; + +@Injectable() +export class ValidateMatchingOnPaymentReceivedDeleteSubscriber { + constructor( + private readonly validateNoMatchingLinkedService: ValidateTransactionMatched, + ) {} + + /** + * Validates the payment received transaction whether matched with bank transaction on deleting. + * @param {IPaymentReceivedDeletedPayload} + */ + @OnEvent(events.paymentReceive.onDeleting) + public async validateMatchingOnPaymentReceivedDeleting({ + oldPaymentReceive, + trx, + }: IPaymentReceivedDeletedPayload) { + await this.validateNoMatchingLinkedService.validateTransactionNoMatchLinking( + 'PaymentReceive', + oldPaymentReceive.id, + trx, + ); + } +} diff --git a/packages/server-nest/src/modules/BankingMatching/models/MatchedBankTransaction.ts b/packages/server-nest/src/modules/BankingMatching/models/MatchedBankTransaction.ts new file mode 100644 index 000000000..fc2eddb36 --- /dev/null +++ b/packages/server-nest/src/modules/BankingMatching/models/MatchedBankTransaction.ts @@ -0,0 +1,36 @@ +import { BaseModel } from '@/models/Model'; + +export class MatchedBankTransaction extends BaseModel { + public referenceId!: number; + public referenceType!: string; + public uncategorizedTransactionId!: number; + + + /** + * Table name. + */ + static get tableName() { + return 'matched_bank_transactions'; + } + + /** + * Timestamps columns. + */ + get timestamps() { + return ['createdAt', 'updatedAt']; + } + + /** + * Virtual attributes. + */ + static get virtualAttributes() { + return []; + } + + /** + * Relationship mapping. + */ + static get relationMappings() { + return {}; + } +} diff --git a/packages/server-nest/src/modules/BankingMatching/queries/GetMatchedTransactionBillsTransformer.ts b/packages/server-nest/src/modules/BankingMatching/queries/GetMatchedTransactionBillsTransformer.ts new file mode 100644 index 000000000..3f4969196 --- /dev/null +++ b/packages/server-nest/src/modules/BankingMatching/queries/GetMatchedTransactionBillsTransformer.ts @@ -0,0 +1,131 @@ +import { Transformer } from "@/modules/Transformer/Transformer"; + +export class GetMatchedTransactionBillsTransformer extends Transformer { + /** + * Include these attributes to sale credit note object. + * @returns {Array} + */ + public includeAttributes = (): string[] => { + return [ + 'referenceNo', + 'amount', + 'amountFormatted', + 'transactionNo', + 'date', + 'dateFormatted', + 'transactionId', + 'transactionNo', + 'transactionType', + 'transsactionTypeFormatted', + 'transactionNormal', + 'referenceId', + 'referenceType', + ]; + }; + + /** + * Exclude all attributes. + * @returns {Array} + */ + public excludeAttributes = (): string[] => { + return ['*']; + }; + + /** + * Retrieve the reference number of the bill. + * @param {Object} bill - The bill object. + * @returns {string} + */ + protected referenceNo(bill) { + return bill.referenceNo; + } + + /** + * Retrieve the amount of the bill. + * @param {Object} bill - The bill object. + * @returns {number} + */ + protected amount(bill) { + return bill.amount; + } + + /** + * Retrieve the formatted amount of the bill. + * @param {Object} bill - The bill object. + * @returns {string} + */ + protected amountFormatted(bill) { + return this.formatNumber(bill.amount, { + currencyCode: bill.currencyCode, + money: true, + }); + } + + /** + * Retrieve the date of the bill. + * @param {Object} bill - The bill object. + * @returns {string} + */ + protected date(bill) { + return bill.billDate; + } + + /** + * Retrieve the formatted date of the bill. + * @param {Object} bill - The bill object. + * @returns {string} + */ + protected dateFormatted(bill) { + return this.formatDate(bill.billDate); + } + + /** + * Retrieve the transcation id of the bill. + * @param {Object} bill - The bill object. + * @returns {number} + */ + protected transactionId(bill) { + return bill.id; + } + + /** + * Retrieve the manual journal transaction type. + * @returns {string} + */ + protected transactionType() { + return 'Bill'; + } + + /** + * Retrieves the manual journal formatted transaction type. + * @returns {string} + */ + protected transsactionTypeFormatted() { + return 'Bill'; + } + + /** + * Retrieves the bill transaction normal (debit or credit). + * @returns {string} + */ + protected transactionNormal() { + return 'credit'; + } + + /** + * Retrieve the match transaction reference id. + * @param bill + * @returns {number} + */ + protected referenceId(bill) { + return bill.id; + } + + /** + * Retrieve the match transaction referenece type. + * @returns {string} + */ + protected referenceType() { + return 'Bill'; + } +} diff --git a/packages/server-nest/src/modules/BankingMatching/queries/GetMatchedTransactionCashflowTransformer.ts b/packages/server-nest/src/modules/BankingMatching/queries/GetMatchedTransactionCashflowTransformer.ts new file mode 100644 index 000000000..0204d3536 --- /dev/null +++ b/packages/server-nest/src/modules/BankingMatching/queries/GetMatchedTransactionCashflowTransformer.ts @@ -0,0 +1,142 @@ +import { Transformer } from "@/modules/Transformer/Transformer"; + +export class GetMatchedTransactionCashflowTransformer extends Transformer { + /** + * Include these attributes to sale credit note object. + * @returns {Array} + */ + public includeAttributes = (): string[] => { + return [ + 'referenceNo', + 'amount', + 'amountFormatted', + 'transactionNo', + 'date', + 'dateFormatted', + 'transactionId', + 'transactionNo', + 'transactionType', + 'transsactionTypeFormatted', + 'transactionNormal', + 'referenceId', + 'referenceType', + ]; + }; + + /** + * Exclude all attributes. + * @returns {Array} + */ + public excludeAttributes = (): string[] => { + return ['*']; + }; + + /** + * Retrieve the invoice reference number. + * @returns {string} + */ + protected referenceNo(invoice) { + return invoice.referenceNo; + } + + /** + * Retrieve the transaction amount. + * @param transaction + * @returns {number} + */ + protected amount(transaction) { + return transaction.amount; + } + + /** + * Retrieve the transaction formatted amount. + * @param transaction + * @returns {string} + */ + protected amountFormatted(transaction) { + return this.formatNumber(transaction.amount, { + currencyCode: transaction.currencyCode, + money: true, + }); + } + + /** + * Retrieve the date of the invoice. + * @param invoice + * @returns {Date} + */ + protected date(transaction) { + return transaction.date; + } + + /** + * Format the date of the invoice. + * @param invoice + * @returns {string} + */ + protected dateFormatted(transaction) { + return this.formatDate(transaction.date); + } + + /** + * Retrieve the transaction ID of the invoice. + * @param invoice + * @returns {number} + */ + protected transactionId(transaction) { + return transaction.id; + } + + /** + * Retrieve the invoice transaction number. + * @param invoice + * @returns {string} + */ + protected transactionNo(transaction) { + return transaction.transactionNumber; + } + + /** + * Retrieve the invoice transaction type. + * @param invoice + * @returns {String} + */ + protected transactionType(transaction) { + return transaction.transactionType; + } + + /** + * Retrieve the invoice formatted transaction type. + * @param invoice + * @returns {string} + */ + protected transsactionTypeFormatted(transaction) { + return transaction.transactionTypeFormatted; + } + + /** + * Retrieve the cashflow transaction normal (credit or debit). + * @param transaction + * @returns {string} + */ + protected transactionNormal(transaction) { + return transaction.isCashCredit ? 'credit' : 'debit'; + } + + /** + * Retrieves the cashflow transaction reference id. + * @param transaction + * @returns {number} + */ + protected referenceId(transaction) { + return transaction.id; + } + + /** + * Retrieves the cashflow transaction reference type. + * @returns {string} + */ + protected referenceType() { + return 'CashflowTransaction'; + } +} diff --git a/packages/server-nest/src/modules/BankingMatching/queries/GetMatchedTransactionExpensesTransformer.ts b/packages/server-nest/src/modules/BankingMatching/queries/GetMatchedTransactionExpensesTransformer.ts new file mode 100644 index 000000000..cda810dd0 --- /dev/null +++ b/packages/server-nest/src/modules/BankingMatching/queries/GetMatchedTransactionExpensesTransformer.ts @@ -0,0 +1,142 @@ +import { Transformer } from "@/modules/Transformer/Transformer"; + +export class GetMatchedTransactionExpensesTransformer extends Transformer { + /** + * Include these attributes to sale credit note object. + * @returns {Array} + */ + public includeAttributes = (): string[] => { + return [ + 'referenceNo', + 'amount', + 'amountFormatted', + 'transactionNo', + 'date', + 'dateFormatted', + 'transactionId', + 'transactionNo', + 'transactionType', + 'transsactionTypeFormatted', + 'transactionNormal', + 'referenceType', + 'referenceId', + ]; + }; + + /** + * Exclude all attributes. + * @returns {Array} + */ + public excludeAttributes = (): string[] => { + return ['*']; + }; + + /** + * Retrieves the expense reference number. + * @param expense + * @returns {string} + */ + protected referenceNo(expense) { + return expense.referenceNo; + } + + /** + * Retrieves the expense amount. + * @param expense + * @returns {number} + */ + protected amount(expense) { + return expense.totalAmount; + } + + /** + * Formats the amount of the expense. + * @param expense + * @returns {string} + */ + protected amountFormatted(expense) { + return this.formatNumber(expense.totalAmount, { + currencyCode: expense.currencyCode, + money: true, + }); + } + + /** + * Retrieves the date of the expense. + * @param expense + * @returns {Date} + */ + protected date(expense) { + return expense.paymentDate; + } + + /** + * Formats the date of the expense. + * @param expense + * @returns {string} + */ + protected dateFormatted(expense) { + return this.formatDate(expense.paymentDate); + } + + /** + * Retrieves the transaction ID of the expense. + * @param expense + * @returns {number} + */ + protected transactionId(expense) { + return expense.id; + } + + /** + * Retrieves the expense transaction number. + * @param expense + * @returns {string} + */ + protected transactionNo(expense) { + return expense.expenseNo; + } + + /** + * Retrieves the expense transaction type. + * @param expense + * @returns {String} + */ + protected transactionType() { + return 'Expense'; + } + + /** + * Retrieves the formatted transaction type of the expense. + * @param expense + * @returns {string} + */ + protected transsactionTypeFormatted() { + return 'Expense'; + } + + /** + * Retrieve the expense transaction normal (credit or debit). + * @returns {string} + */ + protected transactionNormal() { + return 'credit'; + } + + /** + * Retrieve the transaction reference type. + * @returns {string} + */ + protected referenceType() { + return 'Expense'; + } + + /** + * Retrieve the transaction reference id. + * @param transaction + * @returns {number} + */ + protected referenceId(transaction) { + return transaction.id; + } +} diff --git a/packages/server-nest/src/modules/BankingMatching/queries/GetMatchedTransactionInvoicesTransformer.ts b/packages/server-nest/src/modules/BankingMatching/queries/GetMatchedTransactionInvoicesTransformer.ts new file mode 100644 index 000000000..eb376bfc6 --- /dev/null +++ b/packages/server-nest/src/modules/BankingMatching/queries/GetMatchedTransactionInvoicesTransformer.ts @@ -0,0 +1,138 @@ +import { Transformer } from "@/modules/Transformer/Transformer"; + +export class GetMatchedTransactionInvoicesTransformer extends Transformer { + /** + * Include these attributes to sale credit note object. + * @returns {Array} + */ + public includeAttributes = (): string[] => { + return [ + 'referenceNo', + 'amount', + 'amountFormatted', + 'transactionNo', + 'date', + 'dateFormatted', + 'transactionId', + 'transactionNo', + 'transactionType', + 'transsactionTypeFormatted', + 'transactionNormal', + 'referenceType', + 'referenceId' + ]; + }; + + /** + * Exclude all attributes. + * @returns {Array} + */ + public excludeAttributes = (): string[] => { + return ['*']; + }; + + /** + * Retrieve the invoice reference number. + * @returns {string} + */ + protected referenceNo(invoice) { + return invoice.referenceNo; + } + + /** + * Retrieve the invoice amount. + * @param invoice + * @returns {number} + */ + protected amount(invoice) { + return invoice.dueAmount; + } + /** + * Format the amount of the invoice. + * @param invoice + * @returns {string} + */ + protected amountFormatted(invoice) { + return this.formatNumber(invoice.dueAmount, { + currencyCode: invoice.currencyCode, + money: true, + }); + } + + /** + * Retrieve the date of the invoice. + * @param invoice + * @returns {Date} + */ + protected date(invoice) { + return invoice.invoiceDate; + } + + /** + * Format the date of the invoice. + * @param invoice + * @returns {string} + */ + protected dateFormatted(invoice) { + return this.formatDate(invoice.invoiceDate); + } + + /** + * Retrieve the transaction ID of the invoice. + * @param invoice + * @returns {number} + */ + protected transactionId(invoice) { + return invoice.id; + } + /** + * Retrieve the invoice transaction number. + * @param invoice + * @returns {string} + */ + protected transactionNo(invoice) { + return invoice.invoiceNo; + } + + /** + * Retrieve the invoice transaction type. + * @param invoice + * @returns {String} + */ + protected transactionType(invoice) { + return 'SaleInvoice'; + } + + /** + * Retrieve the invoice formatted transaction type. + * @param invoice + * @returns {string} + */ + protected transsactionTypeFormatted(invoice) { + return 'Sale invoice'; + } + + /** + * Retrieve the transaction normal of invoice (credit or debit). + * @returns {string} + */ + protected transactionNormal() { + return 'debit'; + } + + /** + * Retrieve the transaction reference type. + * @returns {string} + */ protected referenceType() { + return 'SaleInvoice'; + } + + /** + * Retrieve the transaction reference id. + * @param transaction + * @returns {number} + */ + protected referenceId(transaction) { + return transaction.id; + } +} diff --git a/packages/server-nest/src/modules/BankingMatching/queries/GetMatchedTransactionManualJournalsTransformer.ts b/packages/server-nest/src/modules/BankingMatching/queries/GetMatchedTransactionManualJournalsTransformer.ts new file mode 100644 index 000000000..280351850 --- /dev/null +++ b/packages/server-nest/src/modules/BankingMatching/queries/GetMatchedTransactionManualJournalsTransformer.ts @@ -0,0 +1,149 @@ +import { sumBy } from 'lodash'; +import { AccountNormal } from '@/interfaces/Account'; +import { Transformer } from '@/modules/Transformer/Transformer'; + +export class GetMatchedTransactionManualJournalsTransformer extends Transformer { + /** + * Include these attributes to sale credit note object. + * @returns {Array} + */ + public includeAttributes = (): string[] => { + return [ + 'referenceNo', + 'amount', + 'amountFormatted', + 'transactionNo', + 'date', + 'dateFormatted', + 'transactionId', + 'transactionNo', + 'transactionType', + 'transsactionTypeFormatted', + 'transactionNormal', + 'referenceType', + 'referenceId', + ]; + }; + + /** + * Exclude all attributes. + * @returns {Array} + */ + public excludeAttributes = (): string[] => { + return ['*']; + }; + + /** + * Retrieves the manual journal reference no. + * @param manualJournal + * @returns {string} + */ + protected referenceNo(manualJournal) { + return manualJournal.referenceNo; + } + + protected total(manualJournal) { + const credit = sumBy(manualJournal?.entries, 'credit'); + const debit = sumBy(manualJournal?.entries, 'debit'); + + return debit - credit; + } + + /** + * Retrieves the manual journal amount. + * @param manualJournal + * @returns {number} + */ + protected amount(manualJournal) { + return Math.abs(this.total(manualJournal)); + } + + /** + * Retrieves the manual journal formatted amount. + * @param manualJournal + * @returns {string} + */ + protected amountFormatted(manualJournal) { + return this.formatNumber(manualJournal.amount, { + currencyCode: manualJournal.currencyCode, + money: true, + }); + } + + /** + * Retreives the manual journal date. + * @param manualJournal + * @returns {Date} + */ + protected date(manualJournal) { + return manualJournal.date; + } + + /** + * Retrieves the manual journal formatted date. + * @param manualJournal + * @returns {string} + */ + protected dateFormatted(manualJournal) { + return this.formatDate(manualJournal.date); + } + + /** + * Retrieve the manual journal transaction id. + * @returns {number} + */ + protected transactionId(manualJournal) { + return manualJournal.id; + } + + /** + * Retrieve the manual journal transaction number. + * @param manualJournal + */ + protected transactionNo(manualJournal) { + return manualJournal.journalNumber; + } + + /** + * Retrieve the manual journal transaction type. + * @returns {string} + */ + protected transactionType() { + return 'ManualJournal'; + } + + /** + * Retrieves the manual journal formatted transaction type. + * @returns {string} + */ + protected transsactionTypeFormatted() { + return 'Manual Journal'; + } + + /** + * Retrieve the manual journal transaction normal (credit or debit). + * @returns {string} + */ + protected transactionNormal(transaction) { + const amount = this.total(transaction); + + return amount >= 0 ? AccountNormal.DEBIT : AccountNormal.CREDIT; + } + + /** + * Retrieve the manual journal reference type. + * @returns {string} + */ + protected referenceType() { + return 'ManualJournal'; + } + + /** + * Retrieves the manual journal reference id. + * @param transaction + * @returns {number} + */ + protected referenceId(transaction) { + return transaction.id; + } +} diff --git a/packages/server-nest/src/modules/BankingMatching/queries/GetMatchedTransactions.service.ts b/packages/server-nest/src/modules/BankingMatching/queries/GetMatchedTransactions.service.ts new file mode 100644 index 000000000..b806fe97e --- /dev/null +++ b/packages/server-nest/src/modules/BankingMatching/queries/GetMatchedTransactions.service.ts @@ -0,0 +1,107 @@ +import * as R from 'ramda'; +import moment from 'moment'; +import { first, sumBy } from 'lodash'; +import { PromisePool } from '@supercharge/promise-pool'; +import { Inject, Injectable } from '@nestjs/common'; +import { GetMatchedTransactionsFilter, MatchedTransactionsPOJO } from '../types'; +import { GetMatchedTransactionsByExpenses } from './GetMatchedTransactionsByExpenses'; +import { GetMatchedTransactionsByBills } from './GetMatchedTransactionsByBills.service'; +import { GetMatchedTransactionsByManualJournals } from './GetMatchedTransactionsByManualJournals.service'; +import { GetMatchedTransactionsByCashflow } from './GetMatchedTransactionsByCashflow'; +import { GetMatchedTransactionsByInvoices } from './GetMatchedTransactionsByInvoices.service'; +import { UncategorizedBankTransaction } from '@/modules/BankingTransactions/models/UncategorizedBankTransaction'; +import { sortClosestMatchTransactions } from '../_utils'; + +@Injectable() +export class GetMatchedTransactions { + constructor( + private readonly getMatchedInvoicesService: GetMatchedTransactionsByInvoices, + private readonly getMatchedBillsService: GetMatchedTransactionsByBills, + private readonly getMatchedManualJournalService: GetMatchedTransactionsByManualJournals, + private readonly getMatchedExpensesService: GetMatchedTransactionsByExpenses, + private readonly getMatchedCashflowService: GetMatchedTransactionsByCashflow, + + @Inject(UncategorizedBankTransaction.name) + private readonly uncategorizedBankTransactionModel: typeof UncategorizedBankTransaction, + ) {} + + /** + * Registered matched transactions types. + */ + get registered() { + return [ + { type: 'SaleInvoice', service: this.getMatchedInvoicesService }, + { type: 'Bill', service: this.getMatchedBillsService }, + { type: 'Expense', service: this.getMatchedExpensesService }, + { type: 'ManualJournal', service: this.getMatchedManualJournalService }, + { type: 'Cashflow', service: this.getMatchedCashflowService }, + ]; + } + + /** + * Retrieves the matched transactions. + * @param {Array} uncategorizedTransactionIds - Uncategorized transactions ids. + * @param {GetMatchedTransactionsFilter} filter - + * @returns {Promise} + */ + public async getMatchedTransactions( + uncategorizedTransactionIds: Array, + filter: GetMatchedTransactionsFilter + ): Promise { + const uncategorizedTransactions = + await this.uncategorizedBankTransactionModel.query() + .whereIn('id', uncategorizedTransactionIds) + .throwIfNotFound(); + + const totalPending = sumBy(uncategorizedTransactions, 'amount'); + + const filtered = filter.transactionType + ? this.registered.filter((item) => item.type === filter.transactionType) + : this.registered; + + const matchedTransactions = await PromisePool.withConcurrency(2) + .for(filtered) + .process(async ({ type, service }) => { + return service.getMatchedTransactions(filter); + }); + const { perfectMatches, possibleMatches } = this.groupMatchedResults( + uncategorizedTransactions, + matchedTransactions + ); + return { + perfectMatches, + possibleMatches, + totalPending, + }; + } + + /** + * Groups the given results for getting perfect and possible matches + * based on the given uncategorized transaction. + * @param uncategorizedTransaction + * @param matchedTransactions + * @returns {MatchedTransactionsPOJO} + */ + private groupMatchedResults( + uncategorizedTransactions: Array, + matchedTransactions + ): MatchedTransactionsPOJO { + const results = R.compose(R.flatten)(matchedTransactions?.results); + + const firstUncategorized = first(uncategorizedTransactions); + const amount = sumBy(uncategorizedTransactions, 'amount'); + const date = firstUncategorized.date; + + // Sort the results based on amount, date, and transaction type + const closestResullts = sortClosestMatchTransactions(amount, date, results); + const perfectMatches = R.filter( + (match) => + match.amount === amount && moment(match.date).isSame(date, 'day'), + closestResullts + ); + const possibleMatches = R.difference(closestResullts, perfectMatches); + const totalPending = sumBy(uncategorizedTransactions, 'amount'); + + return { perfectMatches, possibleMatches, totalPending }; + } +} diff --git a/packages/server-nest/src/modules/BankingMatching/queries/GetMatchedTransactionsByBills.service.ts b/packages/server-nest/src/modules/BankingMatching/queries/GetMatchedTransactionsByBills.service.ts new file mode 100644 index 000000000..5b81ffd63 --- /dev/null +++ b/packages/server-nest/src/modules/BankingMatching/queries/GetMatchedTransactionsByBills.service.ts @@ -0,0 +1,139 @@ +import { initialize } from 'objection'; +import { Inject, Injectable } from '@nestjs/common'; +import { Knex } from 'knex'; +import { first } from 'lodash'; +import { GetMatchedTransactionBillsTransformer } from './GetMatchedTransactionBillsTransformer'; +import { + GetMatchedTransactionsFilter, + IMatchTransactionDTO, + MatchedTransactionPOJO, +} from '../types'; +import { GetMatchedTransactionsByType } from './GetMatchedTransactionsByType'; +import { CreateBillPaymentService } from '@/modules/BillPayments/commands/CreateBillPayment.service'; +import { TransformerInjectable } from '@/modules/Transformer/TransformerInjectable.service'; +import { IBillPaymentDTO } from '@/modules/BillPayments/types/BillPayments.types'; +import { Bill } from '@/modules/Bills/models/Bill'; +import { UncategorizedBankTransaction } from '@/modules/BankingTransactions/models/UncategorizedBankTransaction'; + +@Injectable() +export class GetMatchedTransactionsByBills extends GetMatchedTransactionsByType { + constructor( + private readonly createPaymentMadeService: CreateBillPaymentService, + private readonly transformer: TransformerInjectable, + + @Inject(Bill.name) + private readonly billModel: typeof Bill, + + @Inject(UncategorizedBankTransaction.name) + private readonly uncategorizedBankTransactionModel: typeof UncategorizedBankTransaction, + ) { + super(); + } + + /** + * Retrieves the matched transactions. + * @param {number} tenantId - + * @param {GetMatchedTransactionsFilter} filter - + */ + public async getMatchedTransactions( + filter: GetMatchedTransactionsFilter, + ) { + // Retrieves the bill matches. + const bills = await Bill.query().onBuild((q) => { + q.withGraphJoined('matchedBankTransaction'); + q.whereNull('matchedBankTransaction.id'); + q.modify('published'); + + if (filter.fromDate) { + q.where('billDate', '>=', filter.fromDate); + } + if (filter.toDate) { + q.where('billDate', '<=', filter.toDate); + } + q.orderBy('billDate', 'DESC'); + }); + + return this.transformer.transform( + bills, + new GetMatchedTransactionBillsTransformer(), + ); + } + + /** + * Retrieves the given bill matched transaction. + * @param {number} tenantId + * @param {number} transactionId + * @returns {Promise} + */ + public async getMatchedTransaction( + transactionId: number, + ): Promise { + const bill = await this.billModel + .query() + .findById(transactionId) + .throwIfNotFound(); + + return this.transformer.transform( + bill, + new GetMatchedTransactionBillsTransformer(), + ); + } + + /** + * Creates the common matched transaction. + * @param {number} tenantId + * @param {Array} uncategorizedTransactionIds + * @param {IMatchTransactionDTO} matchTransactionDTO + * @param {Knex.Transaction} trx + */ + public async createMatchedTransaction( + uncategorizedTransactionIds: Array, + matchTransactionDTO: IMatchTransactionDTO, + trx?: Knex.Transaction, + ): Promise { + await super.createMatchedTransaction( + uncategorizedTransactionIds, + matchTransactionDTO, + trx, + ); + const uncategorizedTransactionId = first(uncategorizedTransactionIds); + const uncategorizedTransaction = + await this.uncategorizedBankTransactionModel + .query(trx) + .findById(uncategorizedTransactionId) + .throwIfNotFound(); + + const bill = await this.billModel + .query(trx) + .findById(matchTransactionDTO.referenceId) + .throwIfNotFound(); + + const createPaymentMadeDTO: IBillPaymentDTO = { + vendorId: bill.vendorId, + paymentAccountId: uncategorizedTransaction.accountId, + paymentDate: uncategorizedTransaction.date, + exchangeRate: 1, + entries: [ + { + paymentAmount: bill.dueAmount, + billId: bill.id, + }, + ], + branchId: bill.branchId, + }; + // Create a new bill payment associated to the matched bill. + const billPayment = await this.createPaymentMadeService.createBillPayment( + createPaymentMadeDTO, + trx, + ); + // Link the create bill payment with matched transaction. + await super.createMatchedTransaction( + uncategorizedTransactionIds, + { + referenceType: 'BillPayment', + referenceId: billPayment.id, + }, + trx, + ); + } +} diff --git a/packages/server-nest/src/modules/BankingMatching/queries/GetMatchedTransactionsByCashflow.ts b/packages/server-nest/src/modules/BankingMatching/queries/GetMatchedTransactionsByCashflow.ts new file mode 100644 index 000000000..496fb1df3 --- /dev/null +++ b/packages/server-nest/src/modules/BankingMatching/queries/GetMatchedTransactionsByCashflow.ts @@ -0,0 +1,77 @@ +import { GetMatchedTransactionsByType } from './GetMatchedTransactionsByType'; +import { GetMatchedTransactionCashflowTransformer } from './GetMatchedTransactionCashflowTransformer'; +import { GetMatchedTransactionsFilter } from '../types'; +import { BankTransaction } from '@/modules/BankingTransactions/models/BankTransaction'; +import { TransformerInjectable } from '@/modules/Transformer/TransformerInjectable.service'; +import { Inject, Injectable } from '@nestjs/common'; + +@Injectable() +export class GetMatchedTransactionsByCashflow extends GetMatchedTransactionsByType { + constructor( + private readonly transformer: TransformerInjectable, + + @Inject(BankTransaction.name) + private readonly bankTransactionModel: typeof BankTransaction, + ) { + super(); + } + + /** + * Retrieve the matched transactions of cash flow. + * @param {number} tenantId + * @param {GetMatchedTransactionsFilter} filter + * @returns + */ + async getMatchedTransactions( + filter: Omit, + ) { + const transactions = await this.bankTransactionModel + .query() + .onBuild((q) => { + // Not matched to bank transaction. + q.withGraphJoined('matchedBankTransaction'); + q.whereNull('matchedBankTransaction.id'); + + // Not categorized. + q.modify('notCategorized'); + + // Published. + q.modify('published'); + + if (filter.fromDate) { + q.where('date', '>=', filter.fromDate); + } + if (filter.toDate) { + q.where('date', '<=', filter.toDate); + } + q.orderBy('date', 'DESC'); + }); + + return this.transformer.transform( + transactions, + new GetMatchedTransactionCashflowTransformer(), + ); + } + + /** + * Retrieves the matched transaction of cash flow. + * @param {number} tenantId + * @param {number} transactionId + * @returns + */ + async getMatchedTransaction(transactionId: number) { + const transactions = await this.bankTransactionModel + .query() + .findById(transactionId) + .withGraphJoined('matchedBankTransaction') + .whereNull('matchedBankTransaction.id') + .modify('notCategorized') + .modify('published') + .throwIfNotFound(); + + return this.transformer.transform( + transactions, + new GetMatchedTransactionCashflowTransformer(), + ); + } +} diff --git a/packages/server-nest/src/modules/BankingMatching/queries/GetMatchedTransactionsByExpenses.ts b/packages/server-nest/src/modules/BankingMatching/queries/GetMatchedTransactionsByExpenses.ts new file mode 100644 index 000000000..331df5922 --- /dev/null +++ b/packages/server-nest/src/modules/BankingMatching/queries/GetMatchedTransactionsByExpenses.ts @@ -0,0 +1,74 @@ +import { Inject, Injectable } from '@nestjs/common'; +import { GetMatchedTransactionsFilter, MatchedTransactionPOJO } from '../types'; +import { GetMatchedTransactionsByType } from './GetMatchedTransactionsByType'; +import { GetMatchedTransactionExpensesTransformer } from './GetMatchedTransactionExpensesTransformer'; +import { TransformerInjectable } from '@/modules/Transformer/TransformerInjectable.service'; +import { Expense } from '@/modules/Expenses/models/Expense.model'; + +@Injectable() +export class GetMatchedTransactionsByExpenses extends GetMatchedTransactionsByType { + constructor( + protected readonly transformer: TransformerInjectable, + + @Inject(Expense.name) + protected readonly expenseModel: typeof Expense, + ) { + super(); + } + + /** + * Retrieves the matched transactions of expenses. + * @param {number} tenantId + * @param {GetMatchedTransactionsFilter} filter + * @returns + */ + async getMatchedTransactions(filter: GetMatchedTransactionsFilter) { + // Retrieve the expense matches. + const expenses = await this.expenseModel.query().onBuild((query) => { + // Filter out the not matched to bank transactions. + query.withGraphJoined('matchedBankTransaction'); + query.whereNull('matchedBankTransaction.id'); + + // Filter the published onyl + query.modify('filterByPublished'); + + if (filter.fromDate) { + query.where('paymentDate', '>=', filter.fromDate); + } + if (filter.toDate) { + query.where('paymentDate', '<=', filter.toDate); + } + if (filter.minAmount) { + query.where('totalAmount', '>=', filter.minAmount); + } + if (filter.maxAmount) { + query.where('totalAmount', '<=', filter.maxAmount); + } + query.orderBy('paymentDate', 'DESC'); + }); + return this.transformer.transform( + expenses, + new GetMatchedTransactionExpensesTransformer(), + ); + } + + /** + * Retrieves the given matched expense transaction. + * @param {number} tenantId + * @param {number} transactionId + * @returns {GetMatchedTransactionExpensesTransformer-} + */ + public async getMatchedTransaction( + transactionId: number, + ): Promise { + const expense = await this.expenseModel + .query() + .findById(transactionId) + .throwIfNotFound(); + + return this.transformer.transform( + expense, + new GetMatchedTransactionExpensesTransformer(), + ); + } +} diff --git a/packages/server-nest/src/modules/BankingMatching/queries/GetMatchedTransactionsByInvoices.service.ts b/packages/server-nest/src/modules/BankingMatching/queries/GetMatchedTransactionsByInvoices.service.ts new file mode 100644 index 000000000..33450c5d2 --- /dev/null +++ b/packages/server-nest/src/modules/BankingMatching/queries/GetMatchedTransactionsByInvoices.service.ts @@ -0,0 +1,132 @@ +import { Knex } from 'knex'; +import { first } from 'lodash'; +import { GetMatchedTransactionInvoicesTransformer } from './GetMatchedTransactionInvoicesTransformer'; +import { + GetMatchedTransactionsFilter, + IMatchTransactionDTO, + MatchedTransactionPOJO, + MatchedTransactionsPOJO, +} from '../types'; +import { GetMatchedTransactionsByType } from './GetMatchedTransactionsByType'; +import { CreatePaymentReceivedService } from '@/modules/PaymentReceived/commands/CreatePaymentReceived.serivce'; +import { Inject, Injectable } from '@nestjs/common'; +import { SaleInvoice } from '@/modules/SaleInvoices/models/SaleInvoice'; +import { TransformerInjectable } from '@/modules/Transformer/TransformerInjectable.service'; +import { UncategorizedBankTransaction } from '@/modules/BankingTransactions/models/UncategorizedBankTransaction'; +import { IPaymentReceivedCreateDTO } from '@/modules/PaymentReceived/types/PaymentReceived.types'; + +@Injectable() +export class GetMatchedTransactionsByInvoices extends GetMatchedTransactionsByType { + constructor( + private readonly transformer: TransformerInjectable, + private readonly createPaymentReceivedService: CreatePaymentReceivedService, + + @Inject(SaleInvoice.name) + private readonly saleInvoiceModel: typeof SaleInvoice, + + @Inject(UncategorizedBankTransaction.name) + private readonly uncategorizedBankTransactionModel: typeof UncategorizedBankTransaction, + ) { + super(); + } + + /** + * Retrieves the matched transactions. + * @param {GetMatchedTransactionsFilter} filter - + * @returns {Promise} + */ + public async getMatchedTransactions( + filter: GetMatchedTransactionsFilter + ): Promise { + // Retrieve the invoices that not matched, unpaid. + const invoices = await this.saleInvoiceModel.query().onBuild((q) => { + q.withGraphJoined('matchedBankTransaction'); + q.whereNull('matchedBankTransaction.id'); + q.modify('unpaid'); + q.modify('published'); + + if (filter.fromDate) { + q.where('invoiceDate', '>=', filter.fromDate); + } + if (filter.toDate) { + q.where('invoiceDate', '<=', filter.toDate); + } + q.orderBy('invoiceDate', 'DESC'); + }); + + return this.transformer.transform( + invoices, + new GetMatchedTransactionInvoicesTransformer() + ); + } + + /** + * Retrieves the matched transaction. + * @param {number} tenantId + * @param {number} transactionId + * @returns {Promise} + */ + public async getMatchedTransaction( + transactionId: number + ): Promise { + const invoice = await this.saleInvoiceModel.query().findById(transactionId); + + return this.transformer.transform( + invoice, + new GetMatchedTransactionInvoicesTransformer() + ); + } + + /** + * Creates the common matched transaction. + * @param {number} tenantId + * @param {Array} uncategorizedTransactionIds + * @param {IMatchTransactionDTO} matchTransactionDTO + * @param {Knex.Transaction} trx + */ + public async createMatchedTransaction( + uncategorizedTransactionIds: Array, + matchTransactionDTO: IMatchTransactionDTO, + trx?: Knex.Transaction + ) { + await super.createMatchedTransaction( + uncategorizedTransactionIds, + matchTransactionDTO, + trx + ); + const uncategorizedTransactionId = first(uncategorizedTransactionIds); + const uncategorizedTransaction = + await this.uncategorizedBankTransactionModel.query(trx) + .findById(uncategorizedTransactionId) + .throwIfNotFound(); + + const invoice = await SaleInvoice.query(trx) + .findById(matchTransactionDTO.referenceId) + .throwIfNotFound(); + + const createPaymentReceivedDTO: IPaymentReceivedCreateDTO = { + customerId: invoice.customerId, + paymentDate: uncategorizedTransaction.date, + amount: invoice.dueAmount, + depositAccountId: uncategorizedTransaction.accountId, + entries: [ + { + index: 1, + invoiceId: invoice.id, + paymentAmount: invoice.dueAmount, + }, + ], + branchId: invoice.branchId, + }; + // Create a payment received associated to the matched invoice. + const paymentReceived = await this.createPaymentReceivedService.createPaymentReceived( + createPaymentReceivedDTO, + trx + ); + // Link the create payment received with matched invoice transaction. + await super.createMatchedTransaction(uncategorizedTransactionIds, { + referenceType: 'PaymentReceive', + referenceId: paymentReceived.id, + }, trx) + } +} diff --git a/packages/server-nest/src/modules/BankingMatching/queries/GetMatchedTransactionsByManualJournals.service.ts b/packages/server-nest/src/modules/BankingMatching/queries/GetMatchedTransactionsByManualJournals.service.ts new file mode 100644 index 000000000..fbc16b1a4 --- /dev/null +++ b/packages/server-nest/src/modules/BankingMatching/queries/GetMatchedTransactionsByManualJournals.service.ts @@ -0,0 +1,75 @@ +import { Inject, Injectable } from '@nestjs/common'; +import { GetMatchedTransactionManualJournalsTransformer } from './GetMatchedTransactionManualJournalsTransformer'; +import { GetMatchedTransactionsByType } from './GetMatchedTransactionsByType'; +import { GetMatchedTransactionsFilter } from '../types'; +import { ManualJournal } from '@/modules/ManualJournals/models/ManualJournal'; +import { TransformerInjectable } from '@/modules/Transformer/TransformerInjectable.service'; + +@Injectable() +export class GetMatchedTransactionsByManualJournals extends GetMatchedTransactionsByType { + constructor( + private readonly transformer: TransformerInjectable, + + @Inject(ManualJournal.name) + private readonly manualJournalModel: typeof ManualJournal, + ) { + super(); + } + + /** + * Retrieve the matched transactions of manual journals. + * @param {number} tenantId + * @param {GetMatchedTransactionsFilter} filter + * @returns + */ + async getMatchedTransactions( + filter: Omit, + ) { + // @todo: get the account id from the filter + const accountId = 1000; + + const manualJournals = await this.manualJournalModel.query().onBuild((query) => { + query.withGraphJoined('matchedBankTransaction'); + query.whereNull('matchedBankTransaction.id'); + + query.withGraphJoined('entries'); + query.where('entries.accountId', accountId); + query.modify('filterByPublished'); + + if (filter.fromDate) { + query.where('date', '>=', filter.fromDate); + } + if (filter.toDate) { + query.where('date', '<=', filter.toDate); + } + if (filter.minAmount) { + query.where('amount', '>=', filter.minAmount); + } + if (filter.maxAmount) { + query.where('amount', '<=', filter.maxAmount); + } + }); + return this.transformer.transform( + manualJournals, + new GetMatchedTransactionManualJournalsTransformer(), + ); + } + + /** + * Retrieves the matched transaction of manual journals. + * @param {number} tenantId + * @param {number} transactionId + * @returns + */ + public async getMatchedTransaction(transactionId: number) { + const manualJournal = await this.manualJournalModel.query() + .findById(transactionId) + .whereNotExists(ManualJournal.relatedQuery('matchedBankTransaction')) + .throwIfNotFound(); + + return this.transformer.transform( + manualJournal, + new GetMatchedTransactionManualJournalsTransformer(), + ); + } +} diff --git a/packages/server-nest/src/modules/BankingMatching/queries/GetMatchedTransactionsByType.ts b/packages/server-nest/src/modules/BankingMatching/queries/GetMatchedTransactionsByType.ts new file mode 100644 index 000000000..7ca1c7c8e --- /dev/null +++ b/packages/server-nest/src/modules/BankingMatching/queries/GetMatchedTransactionsByType.ts @@ -0,0 +1,66 @@ +import { Knex } from 'knex'; +import { + GetMatchedTransactionsFilter, + IMatchTransactionDTO, + MatchedTransactionPOJO, + MatchedTransactionsPOJO, +} from '../types'; +import PromisePool from '@supercharge/promise-pool'; +import { MatchedBankTransaction } from '../models/MatchedBankTransaction'; +import { Inject } from '@nestjs/common'; + +export abstract class GetMatchedTransactionsByType { + @Inject(MatchedBankTransaction.name) + private readonly matchedBankTransactionModel: typeof MatchedBankTransaction; + + /** + * Retrieves the matched transactions. + * @param {number} tenantId - + * @param {GetMatchedTransactionsFilter} filter - + * @returns {Promise} + */ + public async getMatchedTransactions( + filter: GetMatchedTransactionsFilter + ): Promise { + throw new Error( + 'The `getMatchedTransactions` method is not defined for the transaction type.' + ); + } + + /** + * Retrieves the matched transaction details. + * @param {number} tenantId - + * @param {number} transactionId - + * @returns {Promise} + */ + public async getMatchedTransaction( + transactionId: number + ): Promise { + throw new Error( + 'The `getMatchedTransaction` method is not defined for the transaction type.' + ); + } + + /** + * Creates the common matched transaction. + * @param {number} tenantId + * @param {Array} uncategorizedTransactionIds + * @param {IMatchTransactionDTO} matchTransactionDTO + * @param {Knex.Transaction} trx + */ + public async createMatchedTransaction( + uncategorizedTransactionIds: Array, + matchTransactionDTO: IMatchTransactionDTO, + trx?: Knex.Transaction + ) { + await PromisePool.withConcurrency(2) + .for(uncategorizedTransactionIds) + .process(async (uncategorizedTransactionId) => { + await this.matchedBankTransactionModel.query(trx).insert({ + uncategorizedTransactionId, + referenceType: matchTransactionDTO.referenceType, + referenceId: matchTransactionDTO.referenceId, + }); + }); + } +} diff --git a/packages/server-nest/src/modules/BankingMatching/types.ts b/packages/server-nest/src/modules/BankingMatching/types.ts new file mode 100644 index 000000000..1146c3798 --- /dev/null +++ b/packages/server-nest/src/modules/BankingMatching/types.ts @@ -0,0 +1,73 @@ +import { Knex } from 'knex'; + +export interface IBankTransactionMatchingEventPayload { + tenantId: number; + uncategorizedTransactionIds: Array; + matchedTransactions: Array; + trx?: Knex.Transaction; +} + +export interface IBankTransactionMatchedEventPayload { + // tenantId: number; + uncategorizedTransactionIds: Array; + matchedTransactions: Array; + trx?: Knex.Transaction; +} + +export interface IBankTransactionUnmatchingEventPayload { + // tenantId: number; + uncategorizedTransactionId: number; + trx?: Knex.Transaction; +} + +export interface IBankTransactionUnmatchedEventPayload { + // tenantId: number; + uncategorizedTransactionId: number; + trx?: Knex.Transaction; +} + +export interface IMatchTransactionDTO { + referenceType: string; + referenceId: number; +} + +export interface IMatchTransactionsDTO { + uncategorizedTransactionIds: Array; + matchedTransactions: Array; +} + +export interface GetMatchedTransactionsFilter { + fromDate: string; + toDate: string; + minAmount: number; + maxAmount: number; + transactionType: string; +} + +export interface MatchedTransactionPOJO { + amount: number; + amountFormatted: string; + date: string; + dateFormatted: string; + referenceNo: string; + transactionNo: string; + transactionId: number; + transactionType: string; +} + +export type MatchedTransactionsPOJO = { + perfectMatches: Array; + possibleMatches: Array; + totalPending: number; +}; + +export const ERRORS = { + RESOURCE_TYPE_MATCHING_TRANSACTION_INVALID: + 'RESOURCE_TYPE_MATCHING_TRANSACTION_INVALID', + RESOURCE_ID_MATCHING_TRANSACTION_INVALID: + 'RESOURCE_ID_MATCHING_TRANSACTION_INVALID', + TOTAL_MATCHING_TRANSACTIONS_INVALID: 'TOTAL_MATCHING_TRANSACTIONS_INVALID', + TRANSACTION_ALREADY_MATCHED: 'TRANSACTION_ALREADY_MATCHED', + CANNOT_MATCH_EXCLUDED_TRANSACTION: 'CANNOT_MATCH_EXCLUDED_TRANSACTION', + CANNOT_DELETE_TRANSACTION_MATCHED: 'CANNOT_DELETE_TRANSACTION_MATCHED', +}; diff --git a/packages/server-nest/src/modules/BankingPlaid/BankingPlaid.module.ts b/packages/server-nest/src/modules/BankingPlaid/BankingPlaid.module.ts new file mode 100644 index 000000000..18f88b591 --- /dev/null +++ b/packages/server-nest/src/modules/BankingPlaid/BankingPlaid.module.ts @@ -0,0 +1,20 @@ +import { Module } from "@nestjs/common"; +import { PlaidUpdateTransactionsOnItemCreatedSubscriber } from "./subscribers/PlaidUpdateTransactionsOnItemCreatedSubscriber"; +import { PlaidUpdateTransactions } from "./command/PlaidUpdateTransactions"; +import { PlaidSyncDb } from "./command/PlaidSyncDB"; +import { PlaidWebooks } from "./command/PlaidWebhooks"; +import { PlaidLinkTokenService } from "./queries/GetPlaidLinkToken.service"; +import { PlaidApplication } from "./PlaidApplication"; + + +@Module({ + providers: [ + PlaidUpdateTransactions, + PlaidSyncDb, + PlaidWebooks, + PlaidLinkTokenService, + PlaidApplication, + PlaidUpdateTransactionsOnItemCreatedSubscriber, + ], +}) +export class BankingPlaidModule {} \ No newline at end of file diff --git a/packages/server-nest/src/modules/BankingPlaid/PlaidApplication.ts b/packages/server-nest/src/modules/BankingPlaid/PlaidApplication.ts new file mode 100644 index 000000000..77be991a2 --- /dev/null +++ b/packages/server-nest/src/modules/BankingPlaid/PlaidApplication.ts @@ -0,0 +1,50 @@ +import { PlaidLinkTokenService } from './queries/GetPlaidLinkToken.service'; +import { PlaidItemService } from './command/PlaidItem'; +import { PlaidWebooks } from './command/PlaidWebhooks'; +import { Injectable } from '@nestjs/common'; +import { PlaidItemDTO } from './types/BankingPlaid.types'; + +@Injectable() +export class PlaidApplication { + constructor( + private readonly getLinkTokenService: PlaidLinkTokenService, + private readonly plaidItemService: PlaidItemService, + private readonly plaidWebhooks: PlaidWebooks, + ) {} + + /** + * Retrieves the Plaid link token. + * @returns {Promise} + */ + public getLinkToken() { + return this.getLinkTokenService.getLinkToken(); + } + + /** + * Exchanges the Plaid access token. + * @param {PlaidItemDTO} itemDTO + * @returns + */ + public exchangeToken(itemDTO: PlaidItemDTO): Promise { + return this.plaidItemService.item(itemDTO); + } + + /** + * Listens to Plaid webhooks + * @param {string} plaidItemId - Plaid item id. + * @param {string} webhookType - Webhook type. + * @param {string} webhookCode - Webhook code. + * @returns {Promise} + */ + public webhooks( + plaidItemId: string, + webhookType: string, + webhookCode: string, + ): Promise { + return this.plaidWebhooks.webhooks( + plaidItemId, + webhookType, + webhookCode, + ); + } +} diff --git a/packages/server-nest/src/modules/BankingPlaid/PlaidFetchTransactionsJob.ts b/packages/server-nest/src/modules/BankingPlaid/PlaidFetchTransactionsJob.ts new file mode 100644 index 000000000..970ed9452 --- /dev/null +++ b/packages/server-nest/src/modules/BankingPlaid/PlaidFetchTransactionsJob.ts @@ -0,0 +1,43 @@ +// import Container, { Service } from 'typedi'; +// import { PlaidUpdateTransactions } from './PlaidUpdateTransactions'; +// import { IPlaidItemCreatedEventPayload } from '@/interfaces'; + +// @Service() +// export class PlaidFetchTransactionsJob { +// /** +// * Constructor method. +// */ +// constructor(agenda) { +// agenda.define( +// 'plaid-update-account-transactions', +// { priority: 'high', concurrency: 2 }, +// this.handler +// ); +// } + +// /** +// * Triggers the function. +// */ +// private handler = async (job, done: Function) => { +// const { tenantId, plaidItemId } = job.attrs +// .data as IPlaidItemCreatedEventPayload; + +// const plaidFetchTransactionsService = Container.get( +// PlaidUpdateTransactions +// ); +// const io = Container.get('socket'); + +// try { +// await plaidFetchTransactionsService.updateTransactions( +// tenantId, +// plaidItemId +// ); +// // Notify the frontend to reflect the new transactions changes. +// io.emit('NEW_TRANSACTIONS_DATA', { plaidItemId }); +// done(); +// } catch (error) { +// console.log(error); +// done(error); +// } +// }; +// } diff --git a/packages/server-nest/src/modules/BankingPlaid/PlaidWebhookTenantBootMiddleware.ts b/packages/server-nest/src/modules/BankingPlaid/PlaidWebhookTenantBootMiddleware.ts new file mode 100644 index 000000000..49a3e4d35 --- /dev/null +++ b/packages/server-nest/src/modules/BankingPlaid/PlaidWebhookTenantBootMiddleware.ts @@ -0,0 +1,32 @@ +// import { Request, Response, NextFunction } from 'express'; +// import { SystemPlaidItem, Tenant } from '@/system/models'; +// import tenantDependencyInjection from '@/api/middleware/TenantDependencyInjection'; + +// export const PlaidWebhookTenantBootMiddleware = async ( +// req: Request, +// res: Response, +// next: NextFunction +// ) => { +// const { item_id: plaidItemId } = req.body; +// const plaidItem = await SystemPlaidItem.query().findOne({ plaidItemId }); + +// const notFoundOrganization = () => { +// return res.boom.unauthorized('Organization identication not found.', { +// errors: [{ type: 'ORGANIZATION.ID.NOT.FOUND', code: 100 }], +// }); +// }; +// // In case the given organization not found. +// if (!plaidItem) { +// return notFoundOrganization(); +// } +// const tenant = await Tenant.query() +// .findById(plaidItem.tenantId) +// .withGraphFetched('metadata'); + +// // When the given organization id not found on the system storage. +// if (!tenant) { +// return notFoundOrganization(); +// } +// tenantDependencyInjection(req, tenant); +// next(); +// }; diff --git a/packages/server-nest/src/modules/BankingPlaid/command/PlaidItem.ts b/packages/server-nest/src/modules/BankingPlaid/command/PlaidItem.ts new file mode 100644 index 000000000..9471aed8a --- /dev/null +++ b/packages/server-nest/src/modules/BankingPlaid/command/PlaidItem.ts @@ -0,0 +1,63 @@ +import { Inject, Injectable } from '@nestjs/common'; +import { PlaidItem } from '../models/PlaidItem'; +import { PlaidApi } from 'plaid'; +import { PLAID_CLIENT } from '../../Plaid/Plaid.module'; +import { EventEmitter2 } from '@nestjs/event-emitter'; +import { events } from '@/common/events/events'; +import { SystemPlaidItem } from '../models/SystemPlaidItem'; +import { TenancyContext } from '@/modules/Tenancy/TenancyContext.service'; +import { PlaidItemDTO } from '../types/BankingPlaid.types'; + +@Injectable() +export class PlaidItemService { + constructor( + private readonly eventEmitter: EventEmitter2, + private readonly tenancyContext: TenancyContext, + + @Inject(SystemPlaidItem.name) + private readonly systemPlaidItemModel: typeof SystemPlaidItem, + + @Inject(PlaidItem.name) + private readonly plaidItemModel: typeof PlaidItem, + + @Inject(PLAID_CLIENT) + private readonly plaidClient: PlaidApi, + ) {} + + /** + * Exchanges the public token to get access token and item id and then creates + * a new Plaid item. + * @param {PlaidItemDTO} itemDTO - Plaid item data transfer object. + * @returns {Promise} + */ + public async item(itemDTO: PlaidItemDTO): Promise { + const { publicToken, institutionId } = itemDTO; + + const tenant = await this.tenancyContext.getTenant(); + const tenantId = tenant.id; + + // Exchange the public token for a private access token and store with the item. + const response = await this.plaidClient.itemPublicTokenExchange({ + public_token: publicToken, + }); + const plaidAccessToken = response.data.access_token; + const plaidItemId = response.data.item_id; + + // Store the Plaid item metadata on tenant scope. + const plaidItem = await this.plaidItemModel.query().insertAndFetch({ + tenantId, + plaidAccessToken, + plaidItemId, + plaidInstitutionId: institutionId, + }); + // Stores the Plaid item id on system scope. + await this.systemPlaidItemModel.query().insert({ tenantId, plaidItemId }); + + // Triggers `onPlaidItemCreated` event. + await this.eventEmitter.emitAsync(events.plaid.onItemCreated, { + plaidAccessToken, + plaidItemId, + plaidInstitutionId: institutionId, + } as IPlaidItemCreatedEventPayload); + } +} diff --git a/packages/server-nest/src/modules/BankingPlaid/command/PlaidSyncDB.ts b/packages/server-nest/src/modules/BankingPlaid/command/PlaidSyncDB.ts new file mode 100644 index 000000000..50fa0f19c --- /dev/null +++ b/packages/server-nest/src/modules/BankingPlaid/command/PlaidSyncDB.ts @@ -0,0 +1,245 @@ +import * as R from 'ramda'; +import bluebird from 'bluebird'; +import { entries, groupBy } from 'lodash'; +import { + AccountBase as PlaidAccountBase, + Item as PlaidItem, + Institution as PlaidInstitution, + Transaction as PlaidTransaction, +} from 'plaid'; +import { + transformPlaidAccountToCreateAccount, + transformPlaidTrxsToCashflowCreate, +} from '../utils'; +import { Knex } from 'knex'; +import uniqid from 'uniqid'; +import { EventEmitter2 } from '@nestjs/event-emitter'; +import { RemovePendingUncategorizedTransaction } from '../../BankingTransactions/commands/RemovePendingUncategorizedTransaction.service'; +import { CreateAccountService } from '../../Accounts/CreateAccount.service'; +import { Account } from '../../Accounts/models/Account.model'; +import { events } from '@/common/events/events'; +import { PlaidItem as PlaidItemModel } from '../models/PlaidItem'; +import { IAccountCreateDTO } from '@/interfaces/Account'; +import { IPlaidTransactionsSyncedEventPayload } from '../types/BankingPlaid.types'; +import { UncategorizedBankTransaction } from '../../BankingTransactions/models/UncategorizedBankTransaction'; +import { Inject, Injectable } from '@nestjs/common'; +import { CreateUncategorizedTransactionService } from '@/modules/BankingCategorize/commands/CreateUncategorizedTransaction.service'; + +const CONCURRENCY_ASYNC = 10; + +@Injectable() +export class PlaidSyncDb { + constructor( + private readonly createAccountService: CreateAccountService, + private readonly createUncategorizedTransaction: CreateUncategorizedTransactionService, + private readonly removePendingTransaction: RemovePendingUncategorizedTransaction, + private readonly eventPublisher: EventEmitter2, + + @Inject(Account.name) + private readonly accountModel: typeof Account, + + @Inject(PlaidItemModel.name) + private readonly plaidItemModel: typeof PlaidItemModel, + + @Inject(UncategorizedBankTransaction.name) + private readonly uncategorizedBankTransactionModel: typeof UncategorizedBankTransaction, + ) {} + + /** + * Syncs the Plaid bank account. + * @param {number} tenantId + * @param {IAccountCreateDTO} createBankAccountDTO + * @param {Knex.Transaction} trx + * @returns {Promise} + */ + public async syncBankAccount( + createBankAccountDTO: IAccountCreateDTO, + trx?: Knex.Transaction, + ) { + const plaidAccount = await this.accountModel + .query(trx) + .findOne('plaidAccountId', createBankAccountDTO.plaidAccountId); + // Can't continue if the Plaid account is already created. + if (plaidAccount) { + return; + } + await this.createAccountService.createAccount(createBankAccountDTO, trx, { + ignoreUniqueName: true, + }); + } + + /** + * Syncs the plaid accounts to the system accounts. + * @param {number} tenantId Tenant ID. + * @param {PlaidAccount[]} plaidAccounts + * @returns {Promise} + */ + public async syncBankAccounts( + plaidAccounts: PlaidAccountBase[], + institution: PlaidInstitution, + item: PlaidItem, + trx?: Knex.Transaction, + ): Promise { + const transformToPlaidAccounts = transformPlaidAccountToCreateAccount( + item, + institution, + ); + const accountCreateDTOs = R.map(transformToPlaidAccounts)(plaidAccounts); + + await bluebird.map( + accountCreateDTOs, + (createAccountDTO: any) => this.syncBankAccount(createAccountDTO, trx), + { concurrency: CONCURRENCY_ASYNC }, + ); + } + + /** + * Synsc the Plaid transactions to the system GL entries. + * @param {number} tenantId - Tenant ID. + * @param {number} plaidAccountId - Plaid account ID. + * @param {PlaidTransaction[]} plaidTranasctions - Plaid transactions + * @return {Promise} + */ + public async syncAccountTranactions( + plaidAccountId: number, + plaidTranasctions: PlaidTransaction[], + trx?: Knex.Transaction, + ): Promise { + const batch = uniqid(); + const cashflowAccount = await this.accountModel + .query(trx) + .findOne({ plaidAccountId }) + .throwIfNotFound(); + + // Transformes the Plaid transactions to cashflow create DTOs. + const transformTransaction = transformPlaidTrxsToCashflowCreate( + cashflowAccount.id, + ); + const uncategorizedTransDTOs = + R.map(transformTransaction)(plaidTranasctions); + + // Creating account transaction queue. + await bluebird.map( + uncategorizedTransDTOs, + (uncategoriedDTO) => + this.createUncategorizedTransaction.create( + { ...uncategoriedDTO, batch }, + trx, + ), + { concurrency: 1 }, + ); + // Triggers `onPlaidTransactionsSynced` event. + await this.eventPublisher.emitAsync(events.plaid.onTransactionsSynced, { + plaidAccountId, + batch, + } as IPlaidTransactionsSyncedEventPayload); + } + + /** + * Syncs the accounts transactions in paraller under controlled concurrency. + * @param {number} tenantId + * @param {PlaidTransaction[]} plaidTransactions + * @return {Promise} + */ + public async syncAccountsTransactions( + plaidAccountsTransactions: PlaidTransaction[], + trx?: Knex.Transaction, + ): Promise { + const groupedTrnsxByAccountId = entries( + groupBy(plaidAccountsTransactions, 'account_id'), + ); + await bluebird.map( + groupedTrnsxByAccountId, + ([plaidAccountId, plaidTransactions]: [number, PlaidTransaction[]]) => { + return this.syncAccountTranactions( + plaidAccountId, + plaidTransactions, + trx, + ); + }, + { concurrency: CONCURRENCY_ASYNC }, + ); + } + + /** + * Syncs the removed Plaid transactions ids from the cashflow system transactions. + * @param {string[]} plaidTransactionsIds - Plaid Transactions IDs. + */ + public async syncRemoveTransactions( + plaidTransactionsIds: string[], + trx?: Knex.Transaction, + ) { + const uncategorizedTransactions = + await this.uncategorizedBankTransactionModel + .query(trx) + .whereIn('plaidTransactionId', plaidTransactionsIds); + const uncategorizedTransactionsIds = uncategorizedTransactions.map( + (trans) => trans.id, + ); + await bluebird.map( + uncategorizedTransactionsIds, + (uncategorizedTransactionId: number) => + this.removePendingTransaction.removePendingTransaction( + uncategorizedTransactionId, + trx, + ), + { concurrency: CONCURRENCY_ASYNC }, + ); + } + + /** + * Syncs the Plaid item last transaction cursor. + * @param {number} tenantId - Tenant ID. + * @param {string} itemId - Plaid item ID. + * @param {string} lastCursor - Last transaction cursor. + * @return {Promise} + */ + public async syncTransactionsCursor( + plaidItemId: string, + lastCursor: string, + trx?: Knex.Transaction, + ): Promise { + await this.plaidItemModel + .query(trx) + .findOne({ plaidItemId }) + .patch({ lastCursor }); + } + + /** + * Updates the last feeds updated at of the given Plaid accounts ids. + * @param {number} tenantId + * @param {string[]} plaidAccountIds + * @return {Promise} + */ + public async updateLastFeedsUpdatedAt( + plaidAccountIds: string[], + trx?: Knex.Transaction, + ): Promise { + await this.accountModel + .query(trx) + .whereIn('plaid_account_id', plaidAccountIds) + .patch({ + lastFeedsUpdatedAt: new Date(), + }); + } + + /** + * Updates the accounts feed active status of the given Plaid accounts ids. + * @param {number} tenantId + * @param {number[]} plaidAccountIds + * @param {boolean} isFeedsActive + * @returns {Promise} + */ + public async updateAccountsFeedsActive( + plaidAccountIds: string[], + isFeedsActive: boolean = true, + trx?: Knex.Transaction, + ): Promise { + await this.accountModel + .query(trx) + .whereIn('plaid_account_id', plaidAccountIds) + .patch({ + isFeedsActive, + }); + } +} diff --git a/packages/server-nest/src/modules/BankingPlaid/command/PlaidUpdateTransactions.ts b/packages/server-nest/src/modules/BankingPlaid/command/PlaidUpdateTransactions.ts new file mode 100644 index 000000000..754fe65fc --- /dev/null +++ b/packages/server-nest/src/modules/BankingPlaid/command/PlaidUpdateTransactions.ts @@ -0,0 +1,148 @@ +import { Knex } from 'knex'; +import { PlaidSyncDb } from './PlaidSyncDB'; +import { PlaidFetchedTransactionsUpdates } from '../types/BankingPlaid.types'; +import { PlaidItem } from '../models/PlaidItem'; +import { Inject, Injectable } from '@nestjs/common'; +import { UnitOfWork } from '@/modules/Tenancy/TenancyDB/UnitOfWork.service'; +import { + CountryCode, + PlaidApi, + Transaction as PlaidTransaction, + RemovedTransaction, +} from 'plaid'; +import { PLAID_CLIENT } from '@/modules/Plaid/Plaid.module'; + +@Injectable() +export class PlaidUpdateTransactions { + constructor( + private readonly plaidItemModel: typeof PlaidItem, + private readonly plaidSync: PlaidSyncDb, + private readonly uow: UnitOfWork, + + @Inject(PLAID_CLIENT) + private readonly plaidClient: PlaidApi, + ) {} + + /** + * Handles sync the Plaid item to Bigcaptial under UOW. + * @param {number} tenantId - Tenant id. + * @param {number} plaidItemId - Plaid item id. + * @returns {Promise<{ addedCount: number; modifiedCount: number; removedCount: number; }>} + */ + public async updateTransactions(plaidItemId: string) { + return this.uow.withTransaction((trx: Knex.Transaction) => { + return this.updateTransactionsWork(plaidItemId, trx); + }); + } + + /** + * Handles the fetching and storing the following: + * - New, modified, or removed transactions. + * - New bank accounts. + * - Last accounts feeds updated at. + * - Turn on the accounts feed flag. + * @param {number} tenantId - Tenant ID. + * @param {string} plaidItemId - The Plaid ID for the item. + * @returns {Promise<{ addedCount: number; modifiedCount: number; removedCount: number; }>} + */ + public async updateTransactionsWork( + plaidItemId: string, + trx?: Knex.Transaction, + ): Promise<{ + addedCount: number; + modifiedCount: number; + removedCount: number; + }> { + // Fetch new transactions from plaid api. + const { added, modified, removed, cursor, accessToken } = + await this.fetchTransactionUpdates(plaidItemId); + + const request = { access_token: accessToken }; + const { + data: { accounts, item }, + } = await this.plaidClient.accountsGet(request); + + const plaidAccountsIds = accounts.map((a) => a.account_id); + const { + data: { institution }, + } = await this.plaidClient.institutionsGetById({ + institution_id: item.institution_id, + country_codes: [CountryCode.Us, CountryCode.Gb], + }); + // Sync bank accounts. + await this.plaidSync.syncBankAccounts(accounts, institution, item, trx); + // Sync removed transactions. + await this.plaidSync.syncRemoveTransactions( + removed?.map((r) => r.transaction_id), + trx, + ); + // Sync bank account transactions. + await this.plaidSync.syncAccountsTransactions(added.concat(modified), trx); + // Sync transactions cursor. + await this.plaidSync.syncTransactionsCursor(plaidItemId, cursor, trx); + // Update the last feeds updated at of the updated accounts. + await this.plaidSync.updateLastFeedsUpdatedAt(plaidAccountsIds, trx); + // Turn on the accounts feeds flag. + await this.plaidSync.updateAccountsFeedsActive(plaidAccountsIds, true, trx); + + return { + addedCount: added.length, + modifiedCount: modified.length, + removedCount: removed.length, + }; + } + + /** + * Fetches transactions from the `Plaid API` for a given item. + * @param {number} tenantId - Tenant ID. + * @param {string} plaidItemId - The Plaid ID for the item. + * @returns {Promise} + */ + private async fetchTransactionUpdates( + plaidItemId: string, + ): Promise { + // the transactions endpoint is paginated, so we may need to hit it multiple times to + // retrieve all available transactions. + const plaidItem = await this.plaidItemModel + .query() + .findOne('plaidItemId', plaidItemId); + if (!plaidItem) { + throw new Error('The given Plaid item id is not found.'); + } + const { plaidAccessToken, lastCursor } = plaidItem; + let cursor = lastCursor; + + // New transaction updates since "cursor" + let added: PlaidTransaction[] = []; + let modified: PlaidTransaction[] = []; + // Removed transaction ids + let removed: RemovedTransaction[] = []; + let hasMore = true; + + const batchSize = 100; + try { + // Iterate through each page of new transaction updates for item + /* eslint-disable no-await-in-loop */ + while (hasMore) { + const request = { + access_token: plaidAccessToken, + cursor: cursor, + count: batchSize, + }; + const response = await this.plaidClient.transactionsSync(request); + const data = response.data; + // Add this page of results + added = added.concat(data.added); + modified = modified.concat(data.modified); + removed = removed.concat(data.removed); + hasMore = data.has_more; + // Update cursor to the next cursor + cursor = data.next_cursor; + } + } catch (err) { + console.error(`Error fetching transactions: ${err.message}`); + cursor = lastCursor; + } + return { added, modified, removed, cursor, accessToken: plaidAccessToken }; + } +} diff --git a/packages/server-nest/src/modules/BankingPlaid/command/PlaidWebhooks.ts b/packages/server-nest/src/modules/BankingPlaid/command/PlaidWebhooks.ts new file mode 100644 index 000000000..9746e0ced --- /dev/null +++ b/packages/server-nest/src/modules/BankingPlaid/command/PlaidWebhooks.ts @@ -0,0 +1,152 @@ +import { PlaidItem } from '../models/PlaidItem'; +import { PlaidUpdateTransactions } from './PlaidUpdateTransactions'; +import { Inject, Injectable } from '@nestjs/common'; + +@Injectable() +export class PlaidWebooks { + constructor( + private readonly updateTransactionsService: PlaidUpdateTransactions, + + @Inject(PlaidItem.name) + private readonly plaidItemModel: typeof PlaidItem, + ) {} + + /** + * Listens to Plaid webhooks + * @param {string} webhookType - Webhook type. + * @param {string} plaidItemId - Plaid item Id. + * @param {string} webhookCode - webhook code. + */ + public async webhooks( + plaidItemId: string, + webhookType: string, + webhookCode: string, + ): Promise { + const _webhookType = webhookType.toLowerCase(); + + // There are five types of webhooks: AUTH, TRANSACTIONS, ITEM, INCOME, and ASSETS. + // @TODO implement handling for remaining webhook types. + const webhookHandlerMap = { + transactions: this.handleTransactionsWebooks.bind(this), + item: this.itemsHandler.bind(this), + }; + const webhookHandler = + webhookHandlerMap[_webhookType] || this.unhandledWebhook; + + await webhookHandler(plaidItemId, webhookCode); + } + + /** + * Handles all unhandled/not yet implemented webhook events. + * @param {string} webhookType - Webhook type. + * @param {string} webhookCode - Webhook code. + * @param {string} plaidItemId - Plaid item id. + */ + private async unhandledWebhook( + webhookType: string, + webhookCode: string, + plaidItemId: string, + ): Promise { + console.log( + `UNHANDLED ${webhookType} WEBHOOK: ${webhookCode}: Plaid item id ${plaidItemId}: unhandled webhook type received.`, + ); + } + + /** + * Logs to console and emits to socket + * @param {string} additionalInfo - Additional info. + * @param {string} webhookCode - Webhook code. + * @param {string} plaidItemId - Plaid item id. + */ + private serverLogAndEmitSocket( + additionalInfo: string, + webhookCode: string, + plaidItemId: string, + ): void { + console.log( + `PLAID WEBHOOK: TRANSACTIONS: ${webhookCode}: Plaid_item_id ${plaidItemId}: ${additionalInfo}`, + ); + } + + /** + * Handles all transaction webhook events. The transaction webhook notifies + * you that a single item has new transactions available. + * @param {string} plaidItemId - Plaid item id. + * @param {string} webhookCode - Webhook code. + * @returns {Promise} + */ + public async handleTransactionsWebooks( + tenantId: number, + plaidItemId: string, + webhookCode: string, + ): Promise { + const plaidItem = await this.plaidItemModel + .query() + .findOne({ plaidItemId }) + .throwIfNotFound(); + + switch (webhookCode) { + case 'SYNC_UPDATES_AVAILABLE': { + if (plaidItem.isPaused) { + this.serverLogAndEmitSocket( + 'Plaid item syncing is paused.', + webhookCode, + plaidItemId, + ); + return; + } + // Fired when new transactions data becomes available. + const { addedCount, modifiedCount, removedCount } = + await this.updateTransactionsService.updateTransactions(plaidItemId); + + this.serverLogAndEmitSocket( + `Transactions: ${addedCount} added, ${modifiedCount} modified, ${removedCount} removed`, + webhookCode, + plaidItemId, + ); + break; + } + case 'DEFAULT_UPDATE': + case 'INITIAL_UPDATE': + case 'HISTORICAL_UPDATE': + /* ignore - not needed if using sync endpoint + webhook */ + break; + default: + this.serverLogAndEmitSocket( + `unhandled webhook type received.`, + webhookCode, + plaidItemId, + ); + } + } + + /** + * Handles all Item webhook events. + * @param {number} tenantId - Tenant ID + * @param {string} webhookCode - The webhook code + * @param {string} plaidItemId - The Plaid ID for the item + * @returns {Promise} + */ + public async itemsHandler( + plaidItemId: string, + webhookCode: string, + ): Promise { + switch (webhookCode) { + case 'WEBHOOK_UPDATE_ACKNOWLEDGED': + this.serverLogAndEmitSocket('is updated', plaidItemId, error); + break; + case 'ERROR': { + break; + } + case 'PENDING_EXPIRATION': { + break; + } + default: + this.serverLogAndEmitSocket( + 'unhandled webhook type received.', + webhookCode, + plaidItemId, + ); + } + } +} diff --git a/packages/server-nest/src/modules/Banking/models/PlaidItem.model.ts b/packages/server-nest/src/modules/BankingPlaid/models/PlaidItem.ts similarity index 83% rename from packages/server-nest/src/modules/Banking/models/PlaidItem.model.ts rename to packages/server-nest/src/modules/BankingPlaid/models/PlaidItem.ts index ac3600d7a..aaae7b702 100644 --- a/packages/server-nest/src/modules/Banking/models/PlaidItem.model.ts +++ b/packages/server-nest/src/modules/BankingPlaid/models/PlaidItem.ts @@ -2,6 +2,11 @@ import { BaseModel } from '@/models/Model'; export class PlaidItem extends BaseModel { pausedAt: Date; + plaidAccessToken: string; + lastCursor?: string; + tenantId: number; + plaidItemId: string; + plaidInstitutionId: string; /** * Table name. diff --git a/packages/server-nest/src/modules/BankingPlaid/models/SystemPlaidItem.ts b/packages/server-nest/src/modules/BankingPlaid/models/SystemPlaidItem.ts new file mode 100644 index 000000000..640a4c5cc --- /dev/null +++ b/packages/server-nest/src/modules/BankingPlaid/models/SystemPlaidItem.ts @@ -0,0 +1,49 @@ +import { BaseModel } from '@/models/Model'; +import { Model } from 'objection'; + +export class SystemPlaidItem extends BaseModel { + tenantId: number; + plaidItemId: string; + + /** + * Table name. + */ + static get tableName() { + return 'plaid_items'; + } + + /** + * Timestamps columns. + */ + get timestamps() { + return ['createdAt', 'updatedAt']; + } + + /** + * Virtual attributes. + */ + static get virtualAttributes() { + return []; + } + + /** + * Relationship mapping. + */ + static get relationMappings() { + const Tenant = require('system/models/Tenant'); + + return { + /** + * System user may belongs to tenant model. + */ + tenant: { + relation: Model.BelongsToOneRelation, + modelClass: Tenant.default, + join: { + from: 'users.tenantId', + to: 'tenants.id', + }, + }, + }; + } +} diff --git a/packages/server-nest/src/modules/BankingPlaid/queries/GetPlaidLinkToken.service.ts b/packages/server-nest/src/modules/BankingPlaid/queries/GetPlaidLinkToken.service.ts new file mode 100644 index 000000000..f0f6beefa --- /dev/null +++ b/packages/server-nest/src/modules/BankingPlaid/queries/GetPlaidLinkToken.service.ts @@ -0,0 +1,41 @@ +import { Inject, Injectable } from '@nestjs/common'; +import { ConfigService } from '@nestjs/config'; +import { PLAID_CLIENT } from '@/modules/Plaid/Plaid.module'; +import { CountryCode, PlaidApi, Products } from 'plaid'; + +@Injectable() +export class PlaidLinkTokenService { + constructor( + public readonly configService: ConfigService, + + @Inject(PLAID_CLIENT) + private readonly plaidClient: PlaidApi, + ) {} + + /** + * Retrieves the plaid link token. + * @param {number} tenantId + * @returns + */ + public async getLinkToken() { + const accessToken = null; + + // Must include transactions in order to receive transactions webhooks + const linkTokenParams = { + user: { + // This should correspond to a unique id for the current user. + client_user_id: 'uniqueId' + 1, + }, + client_name: 'Pattern', + products: [Products.Transactions], + country_codes: [CountryCode.Us], + language: 'en', + webhook: this.configService.get('plaid.linkWebhook'), + access_token: accessToken, + }; + const createResponse = + await this.plaidClient.linkTokenCreate(linkTokenParams); + + return createResponse.data; + } +} diff --git a/packages/server-nest/src/modules/BankingPlaid/subscribers/PlaidUpdateTransactionsOnItemCreatedSubscriber.ts b/packages/server-nest/src/modules/BankingPlaid/subscribers/PlaidUpdateTransactionsOnItemCreatedSubscriber.ts new file mode 100644 index 000000000..3d6dda20c --- /dev/null +++ b/packages/server-nest/src/modules/BankingPlaid/subscribers/PlaidUpdateTransactionsOnItemCreatedSubscriber.ts @@ -0,0 +1,22 @@ +import { events } from '@/common/events/events'; +import { Injectable } from '@nestjs/common'; +import { OnEvent } from '@nestjs/event-emitter'; +import { IPlaidItemCreatedEventPayload } from '../types/BankingPlaid.types'; + +@Injectable() +export class PlaidUpdateTransactionsOnItemCreatedSubscriber { + /** + * Updates the Plaid item transactions + * @param {IPlaidItemCreatedEventPayload} payload - Event payload. + */ + @OnEvent(events.plaid.onItemCreated) + public async handleUpdateTransactionsOnItemCreated({ + tenantId, + plaidItemId, + plaidAccessToken, + plaidInstitutionId, + }: IPlaidItemCreatedEventPayload) { + const payload = { tenantId, plaidItemId }; + // await this.agenda.now('plaid-update-account-transactions', payload); + }; +} diff --git a/packages/server-nest/src/modules/BankingPlaid/subscribers/RecognizeSyncedBankTransactions.subscriber.ts b/packages/server-nest/src/modules/BankingPlaid/subscribers/RecognizeSyncedBankTransactions.subscriber.ts new file mode 100644 index 000000000..4f7f99abb --- /dev/null +++ b/packages/server-nest/src/modules/BankingPlaid/subscribers/RecognizeSyncedBankTransactions.subscriber.ts @@ -0,0 +1,30 @@ +import { Injectable } from '@nestjs/common'; +import { OnEvent } from '@nestjs/event-emitter'; +import { events } from '@/common/events/events'; +import { RecognizeTranasctionsService } from '@/modules/BankingTranasctionsRegonize/commands/RecognizeTranasctions.service'; +import { runAfterTransaction } from '@/modules/Tenancy/TenancyDB/TransactionsHooks'; +import { IPlaidTransactionsSyncedEventPayload } from '../types/BankingPlaid.types'; + +@Injectable() +export class RecognizeSyncedBankTranasctionsSubscriber { + constructor( + private readonly recognizeTranasctionsService: RecognizeTranasctionsService, + ) {} + + /** + * Updates the Plaid item transactions + * @param {IPlaidItemCreatedEventPayload} payload - Event payload. + */ + @OnEvent(events.plaid.onTransactionsSynced) + public async handleRecognizeSyncedBankTransactions({ + batch, + trx, + }: IPlaidTransactionsSyncedEventPayload) { + runAfterTransaction(trx, async () => { + await this.recognizeTranasctionsService.recognizeTransactions( + null, + { batch } + ); + }); + }; +} diff --git a/packages/server-nest/src/modules/BankingPlaid/types/BankingPlaid.types.ts b/packages/server-nest/src/modules/BankingPlaid/types/BankingPlaid.types.ts new file mode 100644 index 000000000..fe3943fca --- /dev/null +++ b/packages/server-nest/src/modules/BankingPlaid/types/BankingPlaid.types.ts @@ -0,0 +1,32 @@ +import { Knex } from "knex"; +import { RemovedTransaction, Transaction } from "plaid"; + +export interface IPlaidTransactionsSyncedEventPayload { + // tenantId: number; + plaidAccountId: number; + batch: string; + trx?: Knex.Transaction +} + +export interface PlaidItemDTO { + publicToken: string; + institutionId: string; +} + + +export interface PlaidFetchedTransactionsUpdates { + added: Transaction[]; + modified: Transaction[]; + removed: RemovedTransaction[]; + accessToken: string; + cursor: string; +} + + + +export interface IPlaidItemCreatedEventPayload { + tenantId: number; + plaidAccessToken: string; + plaidItemId: string; + plaidInstitutionId: string; +} diff --git a/packages/server-nest/src/modules/BankingPlaid/utils.ts b/packages/server-nest/src/modules/BankingPlaid/utils.ts new file mode 100644 index 000000000..12b6addf2 --- /dev/null +++ b/packages/server-nest/src/modules/BankingPlaid/utils.ts @@ -0,0 +1,85 @@ +import * as R from 'ramda'; +import { + Item as PlaidItem, + Institution as PlaidInstitution, + AccountBase as PlaidAccount, + TransactionBase as PlaidTransactionBase, + AccountType as PlaidAccountType, +} from 'plaid'; +import { ACCOUNT_TYPE } from '@/constants/accounts'; +import { IAccountCreateDTO } from '@/interfaces/Account'; +import { CreateUncategorizedTransactionDTO } from '../BankingCategorize/types/BankingCategorize.types'; + +/** + * Retrieves the system account type from the given Plaid account type. + * @param {PlaidAccountType} plaidAccountType + * @returns {string} + */ +const getAccountTypeFromPlaidAccountType = ( + plaidAccountType: PlaidAccountType +) => { + if (plaidAccountType === PlaidAccountType.Credit) { + return ACCOUNT_TYPE.CREDIT_CARD; + } + return ACCOUNT_TYPE.BANK; +}; + +/** + * Transformes the Plaid account to create cashflow account DTO. + * @param {PlaidItem} item - Plaid item. + * @param {PlaidInstitution} institution - Plaid institution. + * @param {PlaidAccount} plaidAccount - Plaid account. + * @returns {IAccountCreateDTO} + */ +export const transformPlaidAccountToCreateAccount = R.curry( + ( + item: PlaidItem, + institution: PlaidInstitution, + plaidAccount: PlaidAccount + ): IAccountCreateDTO => { + return { + name: `${institution.name} - ${plaidAccount.name}`, + code: '', + description: plaidAccount.official_name, + currencyCode: plaidAccount.balances.iso_currency_code, + accountType: getAccountTypeFromPlaidAccountType(plaidAccount.type), + active: true, + bankBalance: plaidAccount.balances.current, + accountMask: plaidAccount.mask, + plaidAccountId: plaidAccount.account_id, + plaidItemId: item.item_id, + }; + } +); + +/** + * Transformes the plaid transaction to cashflow create DTO. + * @param {number} cashflowAccountId - Cashflow account ID. + * @param {number} creditAccountId - Credit account ID. + * @param {PlaidTransaction} plaidTranasction - Plaid transaction. + * @returns {CreateUncategorizedTransactionDTO} + */ +export const transformPlaidTrxsToCashflowCreate = R.curry( + ( + cashflowAccountId: number, + plaidTranasction: PlaidTransactionBase + ): CreateUncategorizedTransactionDTO => { + return { + date: plaidTranasction.date, + + // Plaid: Positive values when money moves out of the account; negative values + // when money moves in. For example, debit card purchases are positive; + // credit card payments, direct deposits, and refunds are negative. + amount: -1 * plaidTranasction.amount, + + description: plaidTranasction.name, + payee: plaidTranasction.payment_meta?.payee, + currencyCode: plaidTranasction.iso_currency_code, + accountId: cashflowAccountId, + referenceNo: plaidTranasction.payment_meta?.reference_number, + plaidTransactionId: plaidTranasction.transaction_id, + pending: plaidTranasction.pending, + pendingPlaidTransactionId: plaidTranasction.pending_transaction_id, + }; + } +); diff --git a/packages/server-nest/src/modules/BankingTranasctionsRegonize/BankingTransactionsRegonize.module.ts b/packages/server-nest/src/modules/BankingTranasctionsRegonize/BankingTransactionsRegonize.module.ts new file mode 100644 index 000000000..8b4688a6c --- /dev/null +++ b/packages/server-nest/src/modules/BankingTranasctionsRegonize/BankingTransactionsRegonize.module.ts @@ -0,0 +1,26 @@ +import { Module } from '@nestjs/common'; +import { RegisterTenancyModel } from '../Tenancy/TenancyModels/Tenancy.module'; +import { RecognizedBankTransaction } from './models/RecognizedBankTransaction'; +import { GetAutofillCategorizeTransactionService } from './queries/GetAutofillCategorizeTransaction.service'; +import { RevertRecognizedTransactionsService } from './commands/RevertRecognizedTransactions.service'; +import { RecognizeTranasctionsService } from './commands/RecognizeTranasctions.service'; +import { TriggerRecognizedTransactionsSubscriber } from './events/TriggerRecognizedTransactions'; + +const models = [RegisterTenancyModel(RecognizedBankTransaction)]; + +@Module({ + providers: [ + ...models, + GetAutofillCategorizeTransactionService, + RevertRecognizedTransactionsService, + RecognizeTranasctionsService, + TriggerRecognizedTransactionsSubscriber, + ], + exports: [ + ...models, + GetAutofillCategorizeTransactionService, + RevertRecognizedTransactionsService, + RecognizeTranasctionsService, + ], +}) +export class BankingTransactionsRegonizeModule {} diff --git a/packages/server-nest/src/modules/BankingTranasctionsRegonize/_types.ts b/packages/server-nest/src/modules/BankingTranasctionsRegonize/_types.ts new file mode 100644 index 000000000..92f394b0a --- /dev/null +++ b/packages/server-nest/src/modules/BankingTranasctionsRegonize/_types.ts @@ -0,0 +1,11 @@ +export interface RevertRecognizedTransactionsCriteria { + batch?: string; + accountId?: number; +} + + +export interface RecognizeTransactionsCriteria { + batch?: string; + accountId?: number; +} + diff --git a/packages/server-nest/src/modules/BankingTranasctionsRegonize/_utils.ts b/packages/server-nest/src/modules/BankingTranasctionsRegonize/_utils.ts new file mode 100644 index 000000000..24d159dd2 --- /dev/null +++ b/packages/server-nest/src/modules/BankingTranasctionsRegonize/_utils.ts @@ -0,0 +1,116 @@ +import { lowerCase } from 'lodash'; +import { UncategorizedBankTransaction } from '../BankingTransactions/models/UncategorizedBankTransaction'; +import { BankRuleApplyIfTransactionType, BankRuleConditionComparator, BankRuleConditionType, IBankRuleCondition } from '../BankRules/types'; +import { BankRule } from '../BankRules/models/BankRule'; +import { BankRuleCondition } from '../BankRules/models/BankRuleCondition'; + +const conditionsMatch = ( + transaction: UncategorizedBankTransaction, + conditions: BankRuleCondition[], + conditionsType: BankRuleConditionType = BankRuleConditionType.And +) => { + const method = + conditionsType === BankRuleConditionType.And ? 'every' : 'some'; + + return conditions[method]((condition) => { + switch (determineFieldType(condition.field)) { + case 'number': + return matchNumberCondition(transaction, condition); + case 'text': + return matchTextCondition(transaction, condition); + default: + return false; + } + }); +}; + +const matchNumberCondition = ( + transaction: UncategorizedBankTransaction, + condition: BankRuleCondition +) => { + const conditionValue = parseFloat(condition.value); + const transactionAmount = + condition.field === 'amount' + ? Math.abs(transaction[condition.field]) + : (transaction[condition.field] as unknown as number); + + switch (condition.comparator) { + case BankRuleConditionComparator.Equals: + case BankRuleConditionComparator.Equal: + return transactionAmount === conditionValue; + + case BankRuleConditionComparator.BiggerOrEqual: + return transactionAmount >= conditionValue; + + case BankRuleConditionComparator.Bigger: + return transactionAmount > conditionValue; + + case BankRuleConditionComparator.Smaller: + return transactionAmount < conditionValue; + + case BankRuleConditionComparator.SmallerOrEqual: + return transactionAmount <= conditionValue; + + default: + return false; + } +}; + +const matchTextCondition = ( + transaction: UncategorizedBankTransaction, + condition: BankRuleCondition +): boolean => { + const transactionValue = transaction[condition.field] as string; + + switch (condition.comparator) { + case BankRuleConditionComparator.Equals: + case BankRuleConditionComparator.Equal: + return transactionValue === condition.value; + case BankRuleConditionComparator.Contains: + const fieldValue = lowerCase(transactionValue); + const conditionValue = lowerCase(condition.value); + + return fieldValue.includes(conditionValue); + case BankRuleConditionComparator.NotContain: + return !transactionValue?.includes(condition.value.toString()); + default: + return false; + } +}; + +const matchTransactionType = ( + bankRule: BankRule, + transaction: UncategorizedBankTransaction +): boolean => { + return ( + (transaction.isDepositTransaction && + bankRule.applyIfTransactionType === + BankRuleApplyIfTransactionType.Deposit) || + (transaction.isWithdrawalTransaction && + bankRule.applyIfTransactionType === + BankRuleApplyIfTransactionType.Withdrawal) + ); +}; + +export const bankRulesMatchTransaction = ( + transaction: UncategorizedBankTransaction, + bankRules: BankRule[] +) => { + return bankRules.find((rule) => { + return ( + matchTransactionType(rule, transaction) && + conditionsMatch(transaction, rule.conditions, rule.conditionsType) + ); + }); +}; + +const determineFieldType = (field: string): string => { + switch (field) { + case 'amount': + return 'number'; + case 'description': + case 'payee': + default: + return 'text'; + } +}; diff --git a/packages/server-nest/src/modules/BankingTranasctionsRegonize/commands/RecognizeTranasctions.service.ts b/packages/server-nest/src/modules/BankingTranasctionsRegonize/commands/RecognizeTranasctions.service.ts new file mode 100644 index 000000000..bec128bfa --- /dev/null +++ b/packages/server-nest/src/modules/BankingTranasctionsRegonize/commands/RecognizeTranasctions.service.ts @@ -0,0 +1,129 @@ +import { Knex } from 'knex'; +import { Inject, Injectable } from '@nestjs/common'; +import { castArray, isEmpty } from 'lodash'; +import { PromisePool } from '@supercharge/promise-pool'; +import { bankRulesMatchTransaction } from '../_utils'; +import { RecognizeTransactionsCriteria } from '../_types'; +import { BankRule } from '@/modules/BankRules/models/BankRule'; +import { RecognizedBankTransaction } from '../models/RecognizedBankTransaction'; +import { UncategorizedBankTransaction } from '@/modules/BankingTransactions/models/UncategorizedBankTransaction'; +import { transformToMapBy } from '@/utils/transform-to-map-by'; + +@Injectable() +export class RecognizeTranasctionsService { + constructor( + @Inject(UncategorizedBankTransaction.name) + private readonly uncategorizedCashflowTransactionModel: typeof UncategorizedBankTransaction, + + @Inject(RecognizedBankTransaction.name) + private readonly recognizedBankTransactionModel: typeof RecognizedBankTransaction, + + @Inject(BankRule.name) + private readonly bankRuleModel: typeof BankRule, + ) {} + + /** + * Marks the uncategorized transaction as recognized from the given bank rule. + * @param {BankRule} bankRule - + * @param {UncategorizedCashflowTransaction} transaction - + * @param {Knex.Transaction} trx - + */ + private async markBankRuleAsRecognized( + bankRule: BankRule, + transaction: UncategorizedBankTransaction, + trx?: Knex.Transaction, + ) { + const recognizedTransaction = await this.recognizedBankTransactionModel + .query(trx) + .insert({ + bankRuleId: bankRule.id, + uncategorizedTransactionId: transaction.id, + assignedCategory: bankRule.assignCategory, + assignedAccountId: bankRule.assignAccountId, + assignedPayee: bankRule.assignPayee, + assignedMemo: bankRule.assignMemo, + }); + await this.uncategorizedCashflowTransactionModel + .query(trx) + .findById(transaction.id) + .patch({ + recognizedTransactionId: recognizedTransaction.id, + }); + } + + /** + * Regonized the uncategorized transactions. + * @param {number|Array} ruleId - The target rule id/ids. + * @param {RecognizeTransactionsCriteria} + * @param {Knex.Transaction} trx - + */ + public async recognizeTransactions( + ruleId?: number | Array, + transactionsCriteria?: RecognizeTransactionsCriteria, + trx?: Knex.Transaction, + ) { + const uncategorizedTranasctions = + await this.uncategorizedCashflowTransactionModel + .query(trx) + .onBuild((query) => { + query.modify('notRecognized'); + query.modify('notCategorized'); + + // Filter the transactions based on the given criteria. + if (transactionsCriteria?.batch) { + query.where('batch', transactionsCriteria.batch); + } + if (transactionsCriteria?.accountId) { + query.where('accountId', transactionsCriteria.accountId); + } + }); + + const bankRules = await this.bankRuleModel.query(trx).onBuild((q) => { + const rulesIds = !isEmpty(ruleId) ? castArray(ruleId) : []; + + if (rulesIds?.length > 0) { + q.whereIn('id', rulesIds); + } + q.withGraphFetched('conditions'); + }); + const bankRulesByAccountId = transformToMapBy( + bankRules, + 'applyIfAccountId', + ); + // Try to recognize the transaction. + const regonizeTransaction = async ( + transaction: UncategorizedBankTransaction, + ) => { + const allAccountsBankRules = bankRulesByAccountId.get(`null`); + const accountBankRules = bankRulesByAccountId.get( + `${transaction.accountId}`, + ); + const recognizedBankRule = bankRulesMatchTransaction( + transaction, + accountBankRules, + ); + if (recognizedBankRule) { + await this.markBankRuleAsRecognized( + recognizedBankRule, + transaction, + trx, + ); + } + }; + const result = await PromisePool.withConcurrency(MIGRATION_CONCURRENCY) + .for(uncategorizedTranasctions) + .process((transaction: UncategorizedBankTransaction, index, pool) => { + return regonizeTransaction(transaction); + }); + } + + /** + * + * @param {number} uncategorizedTransaction + */ + public async regonizeTransaction( + uncategorizedTransaction: UncategorizedBankTransaction, + ) {} +} + +const MIGRATION_CONCURRENCY = 10; diff --git a/packages/server-nest/src/modules/BankingTranasctionsRegonize/commands/RevertRecognizedTransactions.service.ts b/packages/server-nest/src/modules/BankingTranasctionsRegonize/commands/RevertRecognizedTransactions.service.ts new file mode 100644 index 000000000..b322dad49 --- /dev/null +++ b/packages/server-nest/src/modules/BankingTranasctionsRegonize/commands/RevertRecognizedTransactions.service.ts @@ -0,0 +1,70 @@ +import { castArray } from 'lodash'; +import { Knex } from 'knex'; +import { Inject, Injectable } from '@nestjs/common'; +import { RevertRecognizedTransactionsCriteria } from '../_types'; +import { RecognizedBankTransaction } from '../models/RecognizedBankTransaction'; +import { UncategorizedBankTransaction } from '@/modules/BankingTransactions/models/UncategorizedBankTransaction'; +import { UnitOfWork } from '@/modules/Tenancy/TenancyDB/UnitOfWork.service'; + +@Injectable() +export class RevertRecognizedTransactionsService { + constructor( + private readonly uow: UnitOfWork, + + @Inject(RecognizedBankTransaction.name) + private readonly recognizedBankTransactionModel: typeof RecognizedBankTransaction, + + @Inject(UncategorizedBankTransaction.name) + private readonly uncategorizedBankTransactionModel: typeof UncategorizedBankTransaction, + ) {} + + /** + * Revert and unlinks the recognized transactions based on the given bank rule + * and transactions criteria.. + * @param {number|Array} bankRuleId - Bank rule id. + * @param {RevertRecognizedTransactionsCriteria} transactionsCriteria - + * @param {Knex.Transaction} trx - Knex transaction. + * @returns {Promise} + */ + public async revertRecognizedTransactions( + ruleId?: number | Array, + transactionsCriteria?: RevertRecognizedTransactionsCriteria, + trx?: Knex.Transaction, + ): Promise { + const rulesIds = castArray(ruleId); + + return this.uow.withTransaction(async (trx: Knex.Transaction) => { + // Retrieves all the recognized transactions of the banbk rule. + const uncategorizedTransactions = + await this.uncategorizedBankTransactionModel.query(trx).onBuild((q) => { + q.withGraphJoined('recognizedTransaction'); + q.whereNotNull('recognizedTransaction.id'); + + if (rulesIds.length > 0) { + q.whereIn('recognizedTransaction.bankRuleId', rulesIds); + } + if (transactionsCriteria?.accountId) { + q.where('accountId', transactionsCriteria.accountId); + } + if (transactionsCriteria?.batch) { + q.where('batch', transactionsCriteria.batch); + } + }); + const uncategorizedTransactionIds = uncategorizedTransactions.map( + (r) => r.id, + ); + // Unlink the recongized transactions out of uncategorized transactions. + await this.uncategorizedBankTransactionModel + .query(trx) + .whereIn('id', uncategorizedTransactionIds) + .patch({ + recognizedTransactionId: null, + }); + // Delete the recognized bank transactions that assocaited to bank rule. + await this.recognizedBankTransactionModel + .query(trx) + .whereIn('uncategorizedTransactionId', uncategorizedTransactionIds) + .delete(); + }, trx); + } +} diff --git a/packages/server-nest/src/modules/BankingTranasctionsRegonize/events/TriggerRecognizedTransactions.ts b/packages/server-nest/src/modules/BankingTranasctionsRegonize/events/TriggerRecognizedTransactions.ts new file mode 100644 index 000000000..c74480b5e --- /dev/null +++ b/packages/server-nest/src/modules/BankingTranasctionsRegonize/events/TriggerRecognizedTransactions.ts @@ -0,0 +1,83 @@ +import { isEqual, omit } from 'lodash'; +import { Injectable } from '@nestjs/common'; +import { OnEvent } from '@nestjs/event-emitter'; +import { events } from '@/common/events/events'; +import { IBankRuleEventCreatedPayload, IBankRuleEventDeletedPayload, IBankRuleEventEditedPayload } from '@/modules/BankRules/types'; + +@Injectable() +export class TriggerRecognizedTransactionsSubscriber { + /** + * Triggers the recognize uncategorized transactions job on rule created. + * @param {IBankRuleEventCreatedPayload} payload - + */ + @OnEvent(events.bankRules.onCreated) + private async recognizedTransactionsOnRuleCreated({ + bankRule, + }: IBankRuleEventCreatedPayload) { + const payload = { ruleId: bankRule.id }; + + // await this.agenda.now('recognize-uncategorized-transactions-job', payload); + } + + /** + * Triggers the recognize uncategorized transactions job on rule edited. + * @param {IBankRuleEventEditedPayload} payload - + */ + @OnEvent(events.bankRules.onEdited) + private async recognizedTransactionsOnRuleEdited({ + editRuleDTO, + oldBankRule, + bankRule, + }: IBankRuleEventEditedPayload) { + const payload = { ruleId: bankRule.id }; + + // Cannot continue if the new and old bank rule values are the same, + // after excluding `createdAt` and `updatedAt` dates. + if ( + isEqual( + omit(bankRule, ['createdAt', 'updatedAt']), + omit(oldBankRule, ['createdAt', 'updatedAt']) + ) + ) { + return; + } + // await this.agenda.now( + // 'rerecognize-uncategorized-transactions-job', + // payload + // ); + } + + /** + * Triggers the recognize uncategorized transactions job on rule deleted. + * @param {IBankRuleEventDeletedPayload} payload - + */ + @OnEvent(events.bankRules.onDeleted) + private async recognizedTransactionsOnRuleDeleted({ + ruleId, + }: IBankRuleEventDeletedPayload) { + const payload = { ruleId }; + + // await this.agenda.now( + // 'revert-recognized-uncategorized-transactions-job', + // payload + // ); + } + + /** + * Triggers the recognize bank transactions once the imported file commit. + * @param {IImportFileCommitedEventPayload} payload - + */ + @OnEvent(events.import.onImportCommitted) + private async triggerRecognizeTransactionsOnImportCommitted({ + importId, + }: IImportFileCommitedEventPayload) { + const importFile = await Import.query().findOne({ importId }); + const batch = importFile.paramsParsed.batch; + const payload = { transactionsCriteria: { batch } }; + + // Cannot continue if the imported resource is not bank account transactions. + if (importFile.resource !== 'UncategorizedCashflowTransaction') return; + + // await this.agenda.now('recognize-uncategorized-transactions-job', payload); + } +} diff --git a/packages/server-nest/src/modules/BankingTranasctionsRegonize/jobs/RecognizeTransactionsJob.ts b/packages/server-nest/src/modules/BankingTranasctionsRegonize/jobs/RecognizeTransactionsJob.ts new file mode 100644 index 000000000..3363f49c7 --- /dev/null +++ b/packages/server-nest/src/modules/BankingTranasctionsRegonize/jobs/RecognizeTransactionsJob.ts @@ -0,0 +1,36 @@ +// import Container, { Service } from 'typedi'; +// import { RecognizeTranasctionsService } from '../commands/RecognizeTranasctions.service'; + +// @Service() +// export class RegonizeTransactionsJob { +// /** +// * Constructor method. +// */ +// constructor(agenda) { +// agenda.define( +// 'recognize-uncategorized-transactions-job', +// { priority: 'high', concurrency: 2 }, +// this.handler +// ); +// } + +// /** +// * Triggers sending invoice mail. +// */ +// private handler = async (job, done: Function) => { +// const { tenantId, ruleId, transactionsCriteria } = job.attrs.data; +// const regonizeTransactions = Container.get(RecognizeTranasctionsService); + +// try { +// await regonizeTransactions.recognizeTransactions( +// tenantId, +// ruleId, +// transactionsCriteria +// ); +// done(); +// } catch (error) { +// console.log(error); +// done(error); +// } +// }; +// } diff --git a/packages/server-nest/src/modules/BankingTranasctionsRegonize/jobs/RerecognizeTransactionsJob.ts b/packages/server-nest/src/modules/BankingTranasctionsRegonize/jobs/RerecognizeTransactionsJob.ts new file mode 100644 index 000000000..0a7489ea4 --- /dev/null +++ b/packages/server-nest/src/modules/BankingTranasctionsRegonize/jobs/RerecognizeTransactionsJob.ts @@ -0,0 +1,45 @@ +// import Container, { Service } from 'typedi'; +// import { RecognizeTranasctionsService } from '../commands/RecognizeTranasctions.service'; +// import { RevertRecognizedTransactions } from '../commands/RevertRecognizedTransactions.service'; + +// @Service() +// export class ReregonizeTransactionsJob { +// /** +// * Constructor method. +// */ +// constructor(agenda) { +// agenda.define( +// 'rerecognize-uncategorized-transactions-job', +// { priority: 'high', concurrency: 2 }, +// this.handler +// ); +// } + +// /** +// * Triggers sending invoice mail. +// */ +// private handler = async (job, done: Function) => { +// const { tenantId, ruleId, transactionsCriteria } = job.attrs.data; +// const regonizeTransactions = Container.get(RecognizeTranasctionsService); +// const revertRegonizedTransactions = Container.get( +// RevertRecognizedTransactions +// ); + +// try { +// await revertRegonizedTransactions.revertRecognizedTransactions( +// tenantId, +// ruleId, +// transactionsCriteria +// ); +// await regonizeTransactions.recognizeTransactions( +// tenantId, +// ruleId, +// transactionsCriteria +// ); +// done(); +// } catch (error) { +// console.log(error); +// done(error); +// } +// }; +// } diff --git a/packages/server-nest/src/modules/BankingTranasctionsRegonize/jobs/RevertRecognizedTransactionsJob.ts b/packages/server-nest/src/modules/BankingTranasctionsRegonize/jobs/RevertRecognizedTransactionsJob.ts new file mode 100644 index 000000000..6a24844b2 --- /dev/null +++ b/packages/server-nest/src/modules/BankingTranasctionsRegonize/jobs/RevertRecognizedTransactionsJob.ts @@ -0,0 +1,38 @@ +// import Container, { Service } from 'typedi'; +// import { RevertRecognizedTransactions } from '../commands/RevertRecognizedTransactions.service'; + +// @Service() +// export class RevertRegonizeTransactionsJob { +// /** +// * Constructor method. +// */ +// constructor(agenda) { +// agenda.define( +// 'revert-recognized-uncategorized-transactions-job', +// { priority: 'high', concurrency: 2 }, +// this.handler +// ); +// } + +// /** +// * Triggers sending invoice mail. +// */ +// private handler = async (job, done: Function) => { +// const { tenantId, ruleId, transactionsCriteria } = job.attrs.data; +// const revertRegonizedTransactions = Container.get( +// RevertRecognizedTransactions +// ); + +// try { +// await revertRegonizedTransactions.revertRecognizedTransactions( +// tenantId, +// ruleId, +// transactionsCriteria +// ); +// done(); +// } catch (error) { +// console.log(error); +// done(error); +// } +// }; +// } diff --git a/packages/server-nest/src/modules/BankingTranasctionsRegonize/models/RecognizedBankTransaction.ts b/packages/server-nest/src/modules/BankingTranasctionsRegonize/models/RecognizedBankTransaction.ts new file mode 100644 index 000000000..308cdbe51 --- /dev/null +++ b/packages/server-nest/src/modules/BankingTranasctionsRegonize/models/RecognizedBankTransaction.ts @@ -0,0 +1,79 @@ +import { BaseModel } from '@/models/Model'; +import { Model } from 'objection'; + +export class RecognizedBankTransaction extends BaseModel { + public bankRuleId!: number; + public uncategorizedTransactionId!: number; + public assignedCategory!: string; + public assignedAccountId!: number; + public assignedPayee!: string; + public assignedMemo!: string; + + /** + * Table name. + */ + static get tableName() { + return 'recognized_bank_transactions'; + } + + /** + * Timestamps columns. + */ + get timestamps() { + return []; + } + + /** + * Virtual attributes. + */ + static get virtualAttributes() { + return []; + } + + /** + * Relationship mapping. + */ + static get relationMappings() { + const UncategorizedCashflowTransaction = require('./UncategorizedCashflowTransaction'); + const Account = require('./Account'); + const { BankRule } = require('./BankRule'); + + return { + /** + * Recognized bank transaction may belongs to uncategorized transactions. + */ + uncategorizedTransactions: { + relation: Model.HasManyRelation, + modelClass: UncategorizedCashflowTransaction.default, + join: { + from: 'recognized_bank_transactions.uncategorizedTransactionId', + to: 'uncategorized_cashflow_transactions.id', + }, + }, + + /** + * Recognized bank transaction may belongs to assign account. + */ + assignAccount: { + relation: Model.BelongsToOneRelation, + modelClass: Account.default, + join: { + from: 'recognized_bank_transactions.assignedAccountId', + to: 'accounts.id', + }, + }, + + /** + * Recognized bank transaction may belongs to bank rule. + */ + bankRule: { + relation: Model.BelongsToOneRelation, + modelClass: BankRule, + join: { + from: 'recognized_bank_transactions.bankRuleId', + to: 'bank_rules.id', + }, + }, + }; + } +} diff --git a/packages/server-nest/src/modules/BankingTranasctionsRegonize/queries/GetAutofillCategorizeTransaction.service.ts b/packages/server-nest/src/modules/BankingTranasctionsRegonize/queries/GetAutofillCategorizeTransaction.service.ts new file mode 100644 index 000000000..03ae30db7 --- /dev/null +++ b/packages/server-nest/src/modules/BankingTranasctionsRegonize/queries/GetAutofillCategorizeTransaction.service.ts @@ -0,0 +1,42 @@ +import { Inject, Injectable } from '@nestjs/common'; +import { castArray, first, uniq } from 'lodash'; +import { GetAutofillCategorizeTransctionTransformer } from './GetAutofillCategorizeTransactionTransformer'; +import { UncategorizedBankTransaction } from '@/modules/BankingTransactions/models/UncategorizedBankTransaction'; +import { TransformerInjectable } from '@/modules/Transformer/TransformerInjectable.service'; + +@Injectable() +export class GetAutofillCategorizeTransactionService { + constructor( + private readonly transformer: TransformerInjectable, + + @Inject(UncategorizedBankTransaction.name) + private readonly uncategorizedBankTransactionModel: typeof UncategorizedBankTransaction, + ) {} + + /** + * Retrieves the autofill values of categorize transactions form. + * @param {Array | number} uncategorizeTransactionsId - Uncategorized transactions ids. + */ + public async getAutofillCategorizeTransaction( + uncategorizeTransactionsId: Array | number + ) { + const uncategorizeTransactionsIds = uniq( + castArray(uncategorizeTransactionsId) + ); + const uncategorizedTransactions = + await this.uncategorizedBankTransactionModel.query() + .whereIn('id', uncategorizeTransactionsIds) + .withGraphFetched('recognizedTransaction.assignAccount') + .withGraphFetched('recognizedTransaction.bankRule') + .throwIfNotFound(); + + return this.transformer.transform( + {}, + new GetAutofillCategorizeTransctionTransformer(), + { + uncategorizedTransactions, + firstUncategorizedTransaction: first(uncategorizedTransactions), + } + ); + } +} diff --git a/packages/server-nest/src/modules/BankingTranasctionsRegonize/queries/GetAutofillCategorizeTransactionTransformer.ts b/packages/server-nest/src/modules/BankingTranasctionsRegonize/queries/GetAutofillCategorizeTransactionTransformer.ts new file mode 100644 index 000000000..c6418d536 --- /dev/null +++ b/packages/server-nest/src/modules/BankingTranasctionsRegonize/queries/GetAutofillCategorizeTransactionTransformer.ts @@ -0,0 +1,176 @@ +import { sumBy } from 'lodash'; +import { Transformer } from '@/modules/Transformer/Transformer'; + +export class GetAutofillCategorizeTransctionTransformer extends Transformer { + /** + * Included attributes to the object. + * @returns {Array} + */ + public includeAttributes = (): string[] => { + return [ + 'amount', + 'formattedAmount', + 'isRecognized', + 'date', + 'formattedDate', + 'creditAccountId', + 'debitAccountId', + 'referenceNo', + 'transactionType', + 'recognizedByRuleId', + 'recognizedByRuleName', + 'isWithdrawalTransaction', + 'isDepositTransaction', + ]; + }; + + /** + * Detarmines whether the transaction is recognized. + * @returns {boolean} + */ + public isRecognized() { + return !!this.options.firstUncategorizedTransaction?.recognizedTransaction; + } + + /** + * Retrieves the total amount of uncategorized transactions. + * @returns {number} + */ + public amount() { + return sumBy(this.options.uncategorizedTransactions, 'amount'); + } + + /** + * Retrieves the formatted total amount of uncategorized transactions. + * @returns {string} + */ + public formattedAmount() { + return this.formatNumber(this.amount(), { + currencyCode: 'USD', + money: true, + }); + } + + /** + * Detarmines whether the transaction is deposit. + * @returns {boolean} + */ + public isDepositTransaction() { + const amount = this.amount(); + + return amount > 0; + } + + /** + * Detarmines whether the transaction is withdrawal. + * @returns {boolean} + */ + public isWithdrawalTransaction() { + const amount = this.amount(); + + return amount < 0; + } + + /** + * + * @param {string} + */ + public date() { + return this.options.firstUncategorizedTransaction?.date || null; + } + + /** + * Retrieves the formatted date of uncategorized transaction. + * @returns {string} + */ + public formattedDate() { + return this.formatDate(this.date()); + } + + /** + * + * @param {string} + */ + public referenceNo() { + return this.options.firstUncategorizedTransaction?.referenceNo || null; + } + + /** + * + * @returns {number} + */ + public creditAccountId() { + return ( + this.options.firstUncategorizedTransaction?.recognizedTransaction + ?.assignedAccountId || null + ); + } + + /** + * + * @returns {number} + */ + public debitAccountId() { + return this.options.firstUncategorizedTransaction?.accountId || null; + } + + /** + * Retrieves the assigned category of recognized transaction, if is not recognized + * returns the default transaction type depends on the transaction normal. + * @returns {string} + */ + public transactionType() { + const assignedCategory = + this.options.firstUncategorizedTransaction?.recognizedTransaction + ?.assignedCategory; + + return ( + assignedCategory || + (this.isDepositTransaction() ? 'other_income' : 'other_expense') + ); + } + + /** + * + * @returns {string} + */ + public payee() { + return ( + this.options.firstUncategorizedTransaction?.recognizedTransaction + ?.assignedPayee || null + ); + } + + /** + * + * @returns {string} + */ + public memo() { + return ( + this.options.firstUncategorizedTransaction?.recognizedTransaction + ?.assignedMemo || null + ); + } + + /** + * Retrieves the rule id the transaction recongized by. + * @returns {string} + */ + public recognizedByRuleId() { + return ( + this.options.firstUncategorizedTransaction?.recognizedTransaction + ?.bankRuleId || null + ); + } + + /** + * Retrieves the rule name the transaction recongized by. + * @returns {string} + */ + public recognizedByRuleName() { + return ( + this.options.firstUncategorizedTransaction?.recognizedTransaction + ?.bankRule?.name || null + ); + } +} diff --git a/packages/server-nest/src/modules/BankingTransactions/BankingTransactions.module.ts b/packages/server-nest/src/modules/BankingTransactions/BankingTransactions.module.ts new file mode 100644 index 000000000..ee89d278f --- /dev/null +++ b/packages/server-nest/src/modules/BankingTransactions/BankingTransactions.module.ts @@ -0,0 +1,35 @@ +import { Module } from '@nestjs/common'; +import { RegisterTenancyModel } from '../Tenancy/TenancyModels/Tenancy.module'; +import { UncategorizedBankTransaction } from './models/UncategorizedBankTransaction'; +import { BankTransactionLine } from './models/BankTransactionLine'; +import { BankTransaction } from './models/BankTransaction'; +import { BankTransactionAutoIncrement } from './commands/BankTransactionAutoIncrement.service'; +import BankingTransactionGLEntriesSubscriber from './subscribers/CashflowTransactionSubscriber'; +import { DecrementUncategorizedTransactionOnCategorizeSubscriber } from './subscribers/DecrementUncategorizedTransactionOnCategorize'; +import { DeleteCashflowTransactionOnUncategorizeSubscriber } from './subscribers/DeleteCashflowTransactionOnUncategorize'; +import { PreventDeleteTransactionOnDeleteSubscriber } from './subscribers/PreventDeleteTransactionsOnDelete'; +import { ValidateDeleteBankAccountTransactions } from './commands/ValidateDeleteBankAccountTransactions.service'; +import { BankTransactionGLEntriesService } from './commands/BankTransactionGLEntries'; +import { BankingTransactionsApplication } from './BankingTransactionsApplication.service'; + +const models = [ + RegisterTenancyModel(UncategorizedBankTransaction), + RegisterTenancyModel(BankTransaction), + RegisterTenancyModel(BankTransactionLine), +]; + +@Module({ + exports: [ + BankTransactionAutoIncrement, + BankTransactionGLEntriesService, + ValidateDeleteBankAccountTransactions, + BankingTransactionGLEntriesSubscriber, + DecrementUncategorizedTransactionOnCategorizeSubscriber, + DeleteCashflowTransactionOnUncategorizeSubscriber, + PreventDeleteTransactionOnDeleteSubscriber, + BankingTransactionsApplication, + ...models, + ], + providers: [...models], +}) +export class BankingTransactionsModule {} diff --git a/packages/server-nest/src/modules/BankingTransactions/BankingTransactionsApplication.service.ts b/packages/server-nest/src/modules/BankingTransactions/BankingTransactionsApplication.service.ts new file mode 100644 index 000000000..bb4fce4d9 --- /dev/null +++ b/packages/server-nest/src/modules/BankingTransactions/BankingTransactionsApplication.service.ts @@ -0,0 +1,58 @@ +import { Knex } from 'knex'; +import { DeleteCashflowTransaction } from './commands/DeleteCashflowTransaction.service'; +import { CreateBankTransactionService } from './commands/CreateBankTransaction.service'; +import { GetBankTransactionService } from './queries/GetBankTransaction.service'; +import { ICashflowNewCommandDTO } from './types/BankingTransactions.types'; +import { Injectable } from '@nestjs/common'; + +@Injectable() +export class BankingTransactionsApplication { + constructor( + private readonly createTransactionService: CreateBankTransactionService, + private readonly deleteTransactionService: DeleteCashflowTransaction, + private readonly getCashflowTransactionService: GetBankTransactionService, + // private readonly getCashflowAccountsService: GetBankingAccountsServic, + ) {} + + /** + * Creates a new cashflow transaction. + * @param {ICashflowNewCommandDTO} transactionDTO + * @returns + */ + public createTransaction(transactionDTO: ICashflowNewCommandDTO) { + return this.createTransactionService.newCashflowTransaction(transactionDTO); + } + + /** + * Deletes the given cashflow transaction. + * @param {number} cashflowTransactionId - Cashflow transaction id. + * @returns {Promise<{ oldCashflowTransaction: ICashflowTransaction }>} + */ + public deleteTransaction(cashflowTransactionId: number) { + return this.deleteTransactionService.deleteCashflowTransaction( + cashflowTransactionId, + ); + } + + /** + * Retrieves specific cashflow transaction. + * @param {number} cashflowTransactionId + * @returns + */ + public getTransaction(cashflowTransactionId: number) { + return this.getCashflowTransactionService.getBankTransaction( + cashflowTransactionId, + ); + } + + /** + * Retrieves the cashflow accounts. + * @param {ICashflowAccountsFilter} filterDTO + * @returns + */ + public getCashflowAccounts( + // filterDTO: ICashflowAccountsFilter, + ) { + // return this.getCashflowAccountsService.getCashflowAccounts(filterDTO); + } +} diff --git a/packages/server-nest/src/modules/BankingTransactions/commands/BankTransactionAutoIncrement.service.ts b/packages/server-nest/src/modules/BankingTransactions/commands/BankTransactionAutoIncrement.service.ts new file mode 100644 index 000000000..7ea43ac8f --- /dev/null +++ b/packages/server-nest/src/modules/BankingTransactions/commands/BankTransactionAutoIncrement.service.ts @@ -0,0 +1,26 @@ +import { Injectable } from '@nestjs/common'; +import { AutoIncrementOrdersService } from '../../AutoIncrementOrders/AutoIncrementOrders.service'; + +@Injectable() +export class BankTransactionAutoIncrement { + constructor( + private readonly autoIncrementOrdersService: AutoIncrementOrdersService, + ) {} + + /** + * Retrieve the next unique invoice number. + * @return {string} + */ + public getNextTransactionNumber = (): string => { + return this.autoIncrementOrdersService.getNextTransactionNumber('cashflow'); + }; + + /** + * Increment the invoice next number. + */ + public incrementNextTransactionNumber = () => { + return this.autoIncrementOrdersService.incrementSettingsNextNumber( + 'cashflow', + ); + }; +} diff --git a/packages/server-nest/src/modules/BankingTransactions/commands/BankTransactionGL.ts b/packages/server-nest/src/modules/BankingTransactions/commands/BankTransactionGL.ts new file mode 100644 index 000000000..bdd1c03e1 --- /dev/null +++ b/packages/server-nest/src/modules/BankingTransactions/commands/BankTransactionGL.ts @@ -0,0 +1,102 @@ +import { ILedgerEntry } from '@/modules/Ledger/types/Ledger.types'; +import { BankTransaction } from '../models/BankTransaction'; +import { transformCashflowTransactionType } from '../utils'; +import { Ledger } from '@/modules/Ledger/Ledger'; + +export class BankTransactionGL { + private bankTransactionModel: BankTransaction; + /** + * @param {BankTransaction} bankTransactionModel - The bank transaction model. + */ + constructor(bankTransactionModel: BankTransaction) { + this.bankTransactionModel = bankTransactionModel; + } + + /** + * Retrieves the common entry of cashflow transaction. + * @returns {Partial} + */ + private get commonEntry() { + const { entries, ...transaction } = this.bankTransactionModel; + + return { + date: this.bankTransactionModel.date, + currencyCode: this.bankTransactionModel.currencyCode, + exchangeRate: this.bankTransactionModel.exchangeRate, + + transactionType: 'CashflowTransaction', + transactionId: this.bankTransactionModel.id, + transactionNumber: this.bankTransactionModel.transactionNumber, + transactionSubType: transformCashflowTransactionType( + this.bankTransactionModel.transactionType, + ), + referenceNumber: this.bankTransactionModel.referenceNo, + + note: this.bankTransactionModel.description, + + branchId: this.bankTransactionModel.branchId, + userId: this.bankTransactionModel.userId, + }; + } + + /** + * Retrieves the cashflow debit GL entry. + * @returns {ILedgerEntry} + */ + private get cashflowDebitGLEntry(): ILedgerEntry { + const commonEntry = this.commonEntry; + + return { + ...commonEntry, + accountId: this.bankTransactionModel.cashflowAccountId, + credit: this.bankTransactionModel.isCashCredit + ? this.bankTransactionModel.localAmount + : 0, + debit: this.bankTransactionModel.isCashDebit + ? this.bankTransactionModel.localAmount + : 0, + accountNormal: this.bankTransactionModel?.cashflowAccount?.accountNormal, + index: 1, + }; + } + + /** + * Retrieves the cashflow credit GL entry. + * @returns {ILedgerEntry} + */ + private get cashflowCreditGLEntry(): ILedgerEntry { + return { + ...this.commonEntry, + credit: this.bankTransactionModel.isCashDebit + ? this.bankTransactionModel.localAmount + : 0, + debit: this.bankTransactionModel.isCashCredit + ? this.bankTransactionModel.localAmount + : 0, + accountId: this.bankTransactionModel.creditAccountId, + accountNormal: this.bankTransactionModel.creditAccount.accountNormal, + index: 2, + }; + } + + /** + * Retrieves the cashflow transaction GL entry. + * @returns {ILedgerEntry[]} + */ + private getJournalEntries(): ILedgerEntry[] { + const debitEntry = this.cashflowDebitGLEntry; + const creditEntry = this.cashflowCreditGLEntry; + + return [debitEntry, creditEntry]; + } + + /** + * Retrieves the cashflow GL ledger. + * @returns {Ledger} + */ + public getCashflowLedger() { + const entries = this.getJournalEntries(); + + return new Ledger(entries); + } +} diff --git a/packages/server-nest/src/modules/BankingTransactions/commands/BankTransactionGLEntries.ts b/packages/server-nest/src/modules/BankingTransactions/commands/BankTransactionGLEntries.ts new file mode 100644 index 000000000..880d1aadf --- /dev/null +++ b/packages/server-nest/src/modules/BankingTransactions/commands/BankTransactionGLEntries.ts @@ -0,0 +1,54 @@ +import { Knex } from 'knex'; +import { Inject, Injectable } from '@nestjs/common'; +import { LedgerStorageService } from '@/modules/Ledger/LedgerStorage.service'; +import { BankTransaction } from '../models/BankTransaction'; +import { BankTransactionGL } from './BankTransactionGL'; + +@Injectable() +export class BankTransactionGLEntriesService { + constructor( + private readonly ledgerStorage: LedgerStorageService, + + @Inject(BankTransaction.name) + private readonly bankTransactionModel: typeof BankTransaction, + ) {} + + /** + * Write the journal entries of the given cashflow transaction. + * @param {number} tenantId + * @param {ICashflowTransaction} cashflowTransaction + * @return {Promise} + */ + public writeJournalEntries = async ( + cashflowTransactionId: number, + trx?: Knex.Transaction, + ): Promise => { + // Retrieves the cashflow transactions with associated entries. + const transaction = await this.bankTransactionModel + .query(trx) + .findById(cashflowTransactionId) + .withGraphFetched('cashflowAccount') + .withGraphFetched('creditAccount'); + + // Retrieves the cashflow transaction ledger. + const ledger = new BankTransactionGL(transaction).getCashflowLedger(); + + await this.ledgerStorage.commit(ledger, trx); + }; + + /** + * Delete the journal entries. + * @param {number} cashflowTransactionId - Cashflow transaction id. + * @return {Promise} + */ + public revertJournalEntries = async ( + cashflowTransactionId: number, + trx?: Knex.Transaction, + ): Promise => { + await this.ledgerStorage.deleteByReference( + cashflowTransactionId, + 'CashflowTransaction', + trx, + ); + }; +} diff --git a/packages/server-nest/src/modules/BankingTransactions/commands/CommandCasflowValidator.service.ts b/packages/server-nest/src/modules/BankingTransactions/commands/CommandCasflowValidator.service.ts new file mode 100644 index 000000000..e7d6e2e57 --- /dev/null +++ b/packages/server-nest/src/modules/BankingTransactions/commands/CommandCasflowValidator.service.ts @@ -0,0 +1,109 @@ +import { includes, camelCase, upperFirst, sumBy } from 'lodash'; +import { getCashflowTransactionType } from '../utils'; +import { + CASHFLOW_DIRECTION, + CASHFLOW_TRANSACTION_TYPE, + ERRORS, +} from '../constants'; +import { Account } from '@/modules/Accounts/models/Account.model'; +import { Injectable } from '@nestjs/common'; +import { ServiceError } from '@/modules/Items/ServiceError'; +import { BankTransaction } from '../models/BankTransaction'; +import { UncategorizedBankTransaction } from '../models/UncategorizedBankTransaction'; + +@Injectable() +export class CommandBankTransactionValidator { + /** + * Validates the lines accounts type should be cash or bank account. + * @param {Account} accounts - + */ + public validateCreditAccountWithCashflowType = ( + creditAccount: Account, + cashflowTransactionType: CASHFLOW_TRANSACTION_TYPE + ): void => { + const transactionTypeMeta = getCashflowTransactionType( + cashflowTransactionType + ); + const noneCashflowAccount = !includes( + transactionTypeMeta.creditType, + creditAccount.accountType + ); + if (noneCashflowAccount) { + throw new ServiceError(ERRORS.CREDIT_ACCOUNTS_HAS_INVALID_TYPE); + } + }; + + /** + * Validates the cashflow transaction type. + * @param {string} transactionType + * @returns {string} + */ + public validateCashflowTransactionType = (transactionType: string) => { + const transformedType = upperFirst( + camelCase(transactionType) + ) as CASHFLOW_TRANSACTION_TYPE; + + // Retrieve the given transaction type meta. + const transactionTypeMeta = getCashflowTransactionType(transformedType); + + // Throw service error in case not the found the given transaction type. + if (!transactionTypeMeta) { + throw new ServiceError(ERRORS.CASHFLOW_TRANSACTION_TYPE_INVALID); + } + return transformedType; + }; + + /** + * Validate the given transaction should be categorized. + * @param {CashflowTransaction} cashflowTransaction + */ + public validateTransactionShouldCategorized( + cashflowTransaction: BankTransaction + ) { + if (!cashflowTransaction.uncategorize) { + throw new ServiceError(ERRORS.TRANSACTION_ALREADY_CATEGORIZED); + } + } + + /** + * Validate the given transcation shouldn't be categorized. + * @param {CashflowTransaction} cashflowTransaction + */ + public validateTransactionsShouldNotCategorized( + cashflowTransactions: Array + ) { + const categorized = cashflowTransactions.filter((t) => t.categorized); + + if (categorized?.length > 0) { + throw new ServiceError(ERRORS.TRANSACTION_ALREADY_CATEGORIZED, '', { + ids: categorized.map((t) => t.id), + }); + } + } + + /** + * Validate the uncategorize transaction type. + * @param {uncategorizeTransaction} + * @param {string} transactionType + * @throws {ServiceError(ERRORS.UNCATEGORIZED_TRANSACTION_TYPE_INVALID)} + */ + public validateUncategorizeTransactionType( + uncategorizeTransactions: Array, + transactionType: string + ) { + const amount = sumBy(uncategorizeTransactions, 'amount'); + const isDepositTransaction = amount > 0; + const isWithdrawalTransaction = amount <= 0; + + const type = getCashflowTransactionType( + transactionType as CASHFLOW_TRANSACTION_TYPE + ); + if ( + (type.direction === CASHFLOW_DIRECTION.IN && isDepositTransaction) || + (type.direction === CASHFLOW_DIRECTION.OUT && isWithdrawalTransaction) + ) { + return; + } + throw new ServiceError(ERRORS.UNCATEGORIZED_TRANSACTION_TYPE_INVALID); + } +} diff --git a/packages/server-nest/src/modules/BankingTransactions/commands/CreateBankTransaction.service.ts b/packages/server-nest/src/modules/BankingTransactions/commands/CreateBankTransaction.service.ts new file mode 100644 index 000000000..b10fc4571 --- /dev/null +++ b/packages/server-nest/src/modules/BankingTransactions/commands/CreateBankTransaction.service.ts @@ -0,0 +1,168 @@ +import { Inject, Injectable } from '@nestjs/common'; +import { pick } from 'lodash'; +import { Knex } from 'knex'; +import * as R from 'ramda'; +import { CASHFLOW_TRANSACTION_TYPE } from '../constants'; +import { transformCashflowTransactionType } from '../utils'; +import { CommandBankTransactionValidator } from './CommandCasflowValidator.service'; +import { BankTransactionAutoIncrement } from './BankTransactionAutoIncrement.service'; +import { UnitOfWork } from '@/modules/Tenancy/TenancyDB/UnitOfWork.service'; +import { EventEmitter2 } from '@nestjs/event-emitter'; +import { BranchTransactionDTOTransformer } from '@/modules/Branches/integrations/BranchTransactionDTOTransform'; +import { events } from '@/common/events/events'; +import { Account } from '@/modules/Accounts/models/Account.model'; +import { BankTransaction } from '../models/BankTransaction'; +import { + ICashflowNewCommandDTO, + ICommandCashflowCreatedPayload, + ICommandCashflowCreatingPayload, +} from '../types/BankingTransactions.types'; + +@Injectable() +export class CreateBankTransactionService { + constructor( + private validator: CommandBankTransactionValidator, + private uow: UnitOfWork, + private eventPublisher: EventEmitter2, + private autoIncrement: BankTransactionAutoIncrement, + private branchDTOTransform: BranchTransactionDTOTransformer, + + @Inject(BankTransaction.name) + private bankTransactionModel: typeof BankTransaction, + + @Inject(Account.name) + private accountModel: typeof Account, + ) {} + + /** + * Authorize the cashflow creating transaction. + * @param {ICashflowNewCommandDTO} newCashflowTransactionDTO + */ + public authorize = async ( + newCashflowTransactionDTO: ICashflowNewCommandDTO, + creditAccount: Account, + ) => { + const transactionType = transformCashflowTransactionType( + newCashflowTransactionDTO.transactionType, + ); + // Validates the cashflow transaction type. + this.validator.validateCashflowTransactionType(transactionType); + + // Retrieve accounts of the cashflow lines object. + this.validator.validateCreditAccountWithCashflowType( + creditAccount, + transactionType as CASHFLOW_TRANSACTION_TYPE, + ); + }; + + /** + * Transformes owner contribution DTO to cashflow transaction. + * @param {ICashflowNewCommandDTO} newCashflowTransactionDTO - New transaction DTO. + * @returns {ICashflowTransactionInput} - Cashflow transaction object. + */ + private transformCashflowTransactionDTO = ( + newCashflowTransactionDTO: ICashflowNewCommandDTO, + cashflowAccount: Account, + userId: number, + ): BankTransaction => { + const amount = newCashflowTransactionDTO.amount; + + const fromDTO = pick(newCashflowTransactionDTO, [ + 'date', + 'referenceNo', + 'description', + 'transactionType', + 'exchangeRate', + 'cashflowAccountId', + 'creditAccountId', + 'branchId', + 'plaidTransactionId', + 'uncategorizedTransactionId', + ]); + // Retreive the next invoice number. + const autoNextNumber = this.autoIncrement.getNextTransactionNumber(); + + // Retrieve the transaction number. + const transactionNumber = + newCashflowTransactionDTO.transactionNumber || autoNextNumber; + + const initialDTO = { + amount, + ...fromDTO, + transactionNumber, + currencyCode: cashflowAccount.currencyCode, + exchangeRate: fromDTO?.exchangeRate || 1, + transactionType: transformCashflowTransactionType( + fromDTO.transactionType, + ), + userId, + ...(newCashflowTransactionDTO.publish + ? { + publishedAt: new Date(), + } + : {}), + }; + return R.compose(this.branchDTOTransform.transformDTO)( + initialDTO, + ) as BankTransaction; + }; + + /** + * Owner contribution money in. + * @param {ICashflowOwnerContributionDTO} ownerContributionDTO + * @param {number} userId - User id. + * @returns {Promise} + */ + public newCashflowTransaction = async ( + newTransactionDTO: ICashflowNewCommandDTO, + userId?: number, + ): Promise => { + // Retrieves the cashflow account or throw not found error. + const cashflowAccount = await this.accountModel + .query() + .findById(newTransactionDTO.cashflowAccountId) + .throwIfNotFound(); + + // Retrieves the credit account or throw not found error. + const creditAccount = await this.accountModel + .query() + .findById(newTransactionDTO.creditAccountId) + .throwIfNotFound(); + + // Authorize before creating cashflow transaction. + await this.authorize(newTransactionDTO, creditAccount); + + // Transformes owner contribution DTO to cashflow transaction. + const cashflowTransactionObj = this.transformCashflowTransactionDTO( + newTransactionDTO, + cashflowAccount, + userId, + ); + // Creates a new cashflow transaction under UOW envirement. + return this.uow.withTransaction(async (trx: Knex.Transaction) => { + // Triggers `onCashflowTransactionCreate` event. + await this.eventPublisher.emitAsync( + events.cashflow.onTransactionCreating, + { + trx, + newTransactionDTO, + } as ICommandCashflowCreatingPayload, + ); + // Inserts cashflow owner contribution transaction. + const cashflowTransaction = await this.bankTransactionModel + .query(trx) + .upsertGraph(cashflowTransactionObj); + + // Triggers `onCashflowTransactionCreated` event. + await this.eventPublisher.emitAsync( + events.cashflow.onTransactionCreated, + { + newTransactionDTO, + cashflowTransaction, + trx, + } as ICommandCashflowCreatedPayload, + ); + return cashflowTransaction; + }); + }; +} diff --git a/packages/server-nest/src/modules/BankingTransactions/commands/DeleteCashflowTransaction.service.ts b/packages/server-nest/src/modules/BankingTransactions/commands/DeleteCashflowTransaction.service.ts new file mode 100644 index 000000000..69e9cf3da --- /dev/null +++ b/packages/server-nest/src/modules/BankingTransactions/commands/DeleteCashflowTransaction.service.ts @@ -0,0 +1,84 @@ +import { Knex } from 'knex'; +import { ERRORS } from '../constants'; +import { EventEmitter2 } from '@nestjs/event-emitter'; +import { Inject, Injectable } from '@nestjs/common'; +import { BankTransaction } from '../models/BankTransaction'; +import { BankTransactionLine } from '../models/BankTransactionLine'; +import { UnitOfWork } from '@/modules/Tenancy/TenancyDB/UnitOfWork.service'; +import { ServiceError } from '@/modules/Items/ServiceError'; +import { events } from '@/common/events/events'; +import { + ICommandCashflowDeletedPayload, + ICommandCashflowDeletingPayload, +} from '../types/BankingTransactions.types'; + +@Injectable() +export class DeleteCashflowTransaction { + constructor( + private readonly uow: UnitOfWork, + private readonly eventEmitter: EventEmitter2, + + @Inject(BankTransaction.name) + private readonly bankTransaction: typeof BankTransaction, + + @Inject(BankTransactionLine.name) + private readonly bankTransactionLine: typeof BankTransactionLine, + ) {} + + /** + * Deletes the cashflow transaction with associated journal entries. + * @param {number} tenantId - + * @param {number} userId - User id. + */ + public deleteCashflowTransaction = async ( + cashflowTransactionId: number, + trx?: Knex.Transaction, + ): Promise => { + // Retrieve the cashflow transaction. + const oldCashflowTransaction = await this.bankTransaction + .query() + .findById(cashflowTransactionId); + // Throw not found error if the given transaction id not found. + this.throwErrorIfTransactionNotFound(oldCashflowTransaction); + + // Starting database transaction. + return this.uow.withTransaction(async (trx: Knex.Transaction) => { + // Triggers `onCashflowTransactionDelete` event. + await this.eventEmitter.emitAsync(events.cashflow.onTransactionDeleting, { + trx, + oldCashflowTransaction, + } as ICommandCashflowDeletingPayload); + + // Delete cashflow transaction associated lines first. + await this.bankTransactionLine + .query(trx) + .where('cashflow_transaction_id', cashflowTransactionId) + .delete(); + + // Delete cashflow transaction. + await this.bankTransaction + .query(trx) + .findById(cashflowTransactionId) + .delete(); + + // Triggers `onCashflowTransactionDeleted` event. + await this.eventEmitter.emitAsync(events.cashflow.onTransactionDeleted, { + trx, + cashflowTransactionId, + oldCashflowTransaction, + } as ICommandCashflowDeletedPayload); + + return oldCashflowTransaction; + }, trx); + }; + + /** + * Throw not found error if the given transaction id not found. + * @param transaction + */ + private throwErrorIfTransactionNotFound(transaction) { + if (!transaction) { + throw new ServiceError(ERRORS.CASHFLOW_TRANSACTION_NOT_FOUND); + } + } +} diff --git a/packages/server-nest/src/modules/BankingTransactions/commands/RemovePendingUncategorizedTransaction.service.ts b/packages/server-nest/src/modules/BankingTransactions/commands/RemovePendingUncategorizedTransaction.service.ts new file mode 100644 index 000000000..60ee0d318 --- /dev/null +++ b/packages/server-nest/src/modules/BankingTransactions/commands/RemovePendingUncategorizedTransaction.service.ts @@ -0,0 +1,67 @@ +import { Knex } from 'knex'; +import { ERRORS } from '../constants'; +import { + IPendingTransactionRemovedEventPayload, + IPendingTransactionRemovingEventPayload, +} from '../types/BankingTransactions.types'; +import { Inject, Injectable } from '@nestjs/common'; +import { EventEmitter2 } from '@nestjs/event-emitter'; +import { UncategorizedBankTransaction } from '../models/UncategorizedBankTransaction'; +import { ServiceError } from '@/modules/Items/ServiceError'; +import { UnitOfWork } from '@/modules/Tenancy/TenancyDB/UnitOfWork.service'; +import { events } from '@/common/events/events'; + +@Injectable() +export class RemovePendingUncategorizedTransaction { + constructor( + private readonly eventPublisher: EventEmitter2, + private readonly uow: UnitOfWork, + + @Inject(UncategorizedBankTransaction.name) + private readonly uncategorizedBankTransaction: typeof UncategorizedBankTransaction, + ) {} + + /** + * REmoves the pending uncategorized transaction. + * @param {number} uncategorizedTransactionId - + * @param {Knex.Transaction} trx - + * @returns {Promise} + */ + public async removePendingTransaction( + uncategorizedTransactionId: number, + trx?: Knex.Transaction, + ): Promise { + const pendingTransaction = await this.uncategorizedBankTransaction + .query(trx) + .findById(uncategorizedTransactionId) + .throwIfNotFound(); + + if (!pendingTransaction.isPending) { + throw new ServiceError(ERRORS.TRANSACTION_NOT_PENDING); + } + return this.uow.withTransaction(async (trx: Knex.Transaction) => { + await this.eventPublisher.emitAsync( + events.bankTransactions.onPendingRemoving, + { + uncategorizedTransactionId, + pendingTransaction, + trx, + } as IPendingTransactionRemovingEventPayload, + ); + // Removes the pending uncategorized transaction. + await this.uncategorizedBankTransaction + .query(trx) + .findById(uncategorizedTransactionId) + .delete(); + + await this.eventPublisher.emitAsync( + events.bankTransactions.onPendingRemoved, + { + uncategorizedTransactionId, + pendingTransaction, + trx, + } as IPendingTransactionRemovedEventPayload, + ); + }); + } +} diff --git a/packages/server-nest/src/modules/BankingTransactions/commands/ValidateDeleteBankAccountTransactions.service.ts b/packages/server-nest/src/modules/BankingTransactions/commands/ValidateDeleteBankAccountTransactions.service.ts new file mode 100644 index 000000000..aebd97a6e --- /dev/null +++ b/packages/server-nest/src/modules/BankingTransactions/commands/ValidateDeleteBankAccountTransactions.service.ts @@ -0,0 +1,27 @@ +import { Inject, Injectable } from '@nestjs/common'; +import { ERRORS } from '../constants'; +import { ServiceError } from '../../Items/ServiceError'; +import { BankTransactionLine } from '../models/BankTransactionLine'; + +@Injectable() +export class ValidateDeleteBankAccountTransactions { + constructor( + @Inject(BankTransactionLine.name) + private readonly bankTransactionLineModel: typeof BankTransactionLine, + ) {} + + /** + * Validate the account has no associated cashflow transactions. + * @param {number} accountId + */ + public validateAccountHasNoCashflowEntries = async (accountId: number) => { + const associatedLines = await this.bankTransactionLineModel + .query() + .where('creditAccountId', accountId) + .orWhere('cashflowAccountId', accountId); + + if (associatedLines.length > 0) { + throw new ServiceError(ERRORS.ACCOUNT_HAS_ASSOCIATED_TRANSACTIONS); + } + }; +} diff --git a/packages/server-nest/src/modules/BankingTransactions/constants.ts b/packages/server-nest/src/modules/BankingTransactions/constants.ts new file mode 100644 index 000000000..4a80ca034 --- /dev/null +++ b/packages/server-nest/src/modules/BankingTransactions/constants.ts @@ -0,0 +1,149 @@ +import { ACCOUNT_TYPE } from "@/constants/accounts"; + + +export const ERRORS = { + CASHFLOW_TRANSACTION_TYPE_INVALID: 'CASHFLOW_TRANSACTION_TYPE_INVALID', + CASHFLOW_ACCOUNTS_HAS_INVALID_TYPE: 'CASHFLOW_ACCOUNTS_HAS_INVALID_TYPE', + CASHFLOW_TRANSACTION_NOT_FOUND: 'CASHFLOW_TRANSACTION_NOT_FOUND', + CASHFLOW_ACCOUNTS_IDS_NOT_FOUND: 'CASHFLOW_ACCOUNTS_IDS_NOT_FOUND', + CREDIT_ACCOUNTS_IDS_NOT_FOUND: 'CREDIT_ACCOUNTS_IDS_NOT_FOUND', + CREDIT_ACCOUNTS_HAS_INVALID_TYPE: 'CREDIT_ACCOUNTS_HAS_INVALID_TYPE', + ACCOUNT_ID_HAS_INVALID_TYPE: 'ACCOUNT_ID_HAS_INVALID_TYPE', + ACCOUNT_HAS_ASSOCIATED_TRANSACTIONS: 'account_has_associated_transactions', + TRANSACTION_ALREADY_CATEGORIZED: 'TRANSACTION_ALREADY_CATEGORIZED', + TRANSACTION_ALREADY_UNCATEGORIZED: 'TRANSACTION_ALREADY_UNCATEGORIZED', + UNCATEGORIZED_TRANSACTION_TYPE_INVALID: + 'UNCATEGORIZED_TRANSACTION_TYPE_INVALID', + CANNOT_DELETE_TRANSACTION_CONVERTED_FROM_UNCATEGORIZED: + 'CANNOT_DELETE_TRANSACTION_CONVERTED_FROM_UNCATEGORIZED', + CANNOT_CATEGORIZE_EXCLUDED_TRANSACTION: + 'CANNOT_CATEGORIZE_EXCLUDED_TRANSACTION', + TRANSACTION_NOT_CATEGORIZED: 'TRANSACTION_NOT_CATEGORIZED', + TRANSACTION_NOT_PENDING: 'TRANSACTION_NOT_PENDING', +}; + +export enum CASHFLOW_DIRECTION { + IN = 'In', + OUT = 'Out', +} + +export enum CASHFLOW_TRANSACTION_TYPE { + ONWERS_DRAWING = 'OwnerDrawing', + OWNER_CONTRIBUTION = 'OwnerContribution', + OTHER_INCOME = 'OtherIncome', + TRANSFER_FROM_ACCOUNT = 'TransferFromAccount', + TRANSFER_TO_ACCOUNT = 'TransferToAccount', + OTHER_EXPENSE = 'OtherExpense', +} + +export const CASHFLOW_TRANSACTION_TYPE_META = { + [`${CASHFLOW_TRANSACTION_TYPE.ONWERS_DRAWING}`]: { + type: 'OwnerDrawing', + direction: CASHFLOW_DIRECTION.OUT, + creditType: [ACCOUNT_TYPE.EQUITY], + }, + [`${CASHFLOW_TRANSACTION_TYPE.OWNER_CONTRIBUTION}`]: { + type: 'OwnerContribution', + direction: CASHFLOW_DIRECTION.IN, + creditType: [ACCOUNT_TYPE.EQUITY], + }, + [`${CASHFLOW_TRANSACTION_TYPE.OTHER_INCOME}`]: { + type: 'OtherIncome', + direction: CASHFLOW_DIRECTION.IN, + creditType: [ACCOUNT_TYPE.INCOME, ACCOUNT_TYPE.OTHER_INCOME], + }, + [`${CASHFLOW_TRANSACTION_TYPE.TRANSFER_FROM_ACCOUNT}`]: { + type: 'TransferFromAccount', + direction: CASHFLOW_DIRECTION.IN, + creditType: [ + ACCOUNT_TYPE.CASH, + ACCOUNT_TYPE.BANK, + ACCOUNT_TYPE.CREDIT_CARD, + ], + }, + [`${CASHFLOW_TRANSACTION_TYPE.TRANSFER_TO_ACCOUNT}`]: { + type: 'TransferToAccount', + direction: CASHFLOW_DIRECTION.OUT, + creditType: [ + ACCOUNT_TYPE.CASH, + ACCOUNT_TYPE.BANK, + ACCOUNT_TYPE.CREDIT_CARD, + ], + }, + [`${CASHFLOW_TRANSACTION_TYPE.OTHER_EXPENSE}`]: { + type: 'OtherExpense', + direction: CASHFLOW_DIRECTION.OUT, + creditType: [ + ACCOUNT_TYPE.EXPENSE, + ACCOUNT_TYPE.OTHER_EXPENSE, + ACCOUNT_TYPE.COST_OF_GOODS_SOLD, + ], + }, +}; + +export interface ICashflowTransactionTypeMeta { + type: string; + direction: CASHFLOW_DIRECTION; + creditType: string[]; +} + +export const BankTransactionsSampleData = [ + { + Amount: '6,410.19', + Date: '2024-03-26', + Payee: 'MacGyver and Sons', + 'Reference No.': 'REF-1', + Description: 'Commodi quo labore.', + }, + { + Amount: '8,914.17', + Date: '2024-01-05', + Payee: 'Eichmann - Bergnaum', + 'Reference No.': 'REF-1', + Description: 'Quia enim et.', + }, + { + Amount: '6,200.88', + Date: '2024-02-17', + Payee: 'Luettgen, Mraz and Legros', + 'Reference No.': 'REF-1', + Description: 'Occaecati consequuntur cum impedit illo.', + }, +]; + + +export const CashflowTransactionTypes = { + OtherIncome: 'Other income', + OtherExpense: 'Other expense', + OwnerDrawing: 'Owner drawing', + OwnerContribution: 'Owner contribution', + TransferToAccount: 'Transfer to account', + TransferFromAccount: 'Transfer from account', +}; + +export const TransactionTypes = { + SaleInvoice: 'Sale invoice', + SaleReceipt: 'Sale receipt', + PaymentReceive: 'Payment received', + Bill: 'Bill', + BillPayment: 'Payment made', + VendorOpeningBalance: 'Vendor opening balance', + CustomerOpeningBalance: 'Customer opening balance', + InventoryAdjustment: 'Inventory adjustment', + ManualJournal: 'Manual journal', + Journal: 'Manual journal', + Expense: 'Expense', + OwnerContribution: 'Owner contribution', + TransferToAccount: 'Transfer to account', + TransferFromAccount: 'Transfer from account', + OtherIncome: 'Other income', + OtherExpense: 'Other expense', + OwnerDrawing: 'Owner drawing', + InvoiceWriteOff: 'Invoice write-off', + CreditNote: 'transaction_type.credit_note', + VendorCredit: 'transaction_type.vendor_credit', + RefundCreditNote: 'transaction_type.refund_credit_note', + RefundVendorCredit: 'transaction_type.refund_vendor_credit', + LandedCost: 'transaction_type.landed_cost', + CashflowTransaction: CashflowTransactionTypes, +}; diff --git a/packages/server-nest/src/modules/BankingTransactions/models/BankTransaction.ts b/packages/server-nest/src/modules/BankingTransactions/models/BankTransaction.ts new file mode 100644 index 000000000..49a080514 --- /dev/null +++ b/packages/server-nest/src/modules/BankingTransactions/models/BankTransaction.ts @@ -0,0 +1,231 @@ +/* eslint-disable global-require */ +import { Model } from 'objection'; +// import { +// getCashflowAccountTransactionsTypes, +// getCashflowTransactionType, +// } from '@/services/Cashflow/utils'; +// import { CASHFLOW_DIRECTION } from '@/services/Cashflow/constants'; +// import { getCashflowTransactionFormattedType } from '@/utils/transactions-types'; +import { BaseModel } from '@/models/Model'; +import { getCashflowTransactionType } from '../utils'; +import { CASHFLOW_DIRECTION } from '../constants'; +import { BankTransactionLine } from './BankTransactionLine'; +import { Account } from '@/modules/Accounts/models/Account.model'; + +export class BankTransaction extends BaseModel { + transactionType: string; + amount: number; + exchangeRate: number; + uncategorize: boolean; + uncategorizedTransaction!: boolean; + currencyCode: string; + date: Date; + transactionNumber: string; + referenceNo: string; + description: string; + + cashflowAccountId: number; + creditAccountId: number; + + branchId: number; + userId: number; + + entries: BankTransactionLine[]; + cashflowAccount: Account; + creditAccount: Account; + + uncategorizedTransactionId: number; + + /** + * Table name. + * @returns {string} + */ + static get tableName() { + return 'cashflow_transactions'; + } + + /** + * Timestamps columns. + * @returns {Array} + */ + static get timestamps() { + return ['createdAt', 'updatedAt']; + } + + /** + * Virtual attributes. + * @returns {Array} + */ + static get virtualAttributes() { + return [ + 'localAmount', + 'transactionTypeFormatted', + 'isPublished', + 'typeMeta', + 'isCashCredit', + 'isCashDebit', + ]; + } + + /** + * Retrieves the local amount of cashflow transaction. + * @returns {number} + */ + get localAmount() { + return this.amount * this.exchangeRate; + } + + /** + * Detarmines whether the cashflow transaction is published. + * @return {boolean} + */ + get isPublished() { + return !!this.publishedAt; + } + + /** + * Transaction type formatted. + * @returns {string} + */ + // get transactionTypeFormatted() { + // return getCashflowTransactionFormattedType(this.transactionType); + // } + + get typeMeta() { + return getCashflowTransactionType(this.transactionType); + } + + /** + * Detarmines whether the cashflow transaction cash credit type. + * @returns {boolean} + */ + get isCashCredit() { + return this.typeMeta?.direction === CASHFLOW_DIRECTION.OUT; + } + + /** + * Detarmines whether the cashflow transaction cash debit type. + * @returns {boolean} + */ + get isCashDebit() { + return this.typeMeta?.direction === CASHFLOW_DIRECTION.IN; + } + + /** + * Detarmines whether the transaction imported from uncategorized transaction. + * @returns {boolean} + */ + get isCategroizedTranasction() { + return !!this.uncategorizedTransaction; + } + + /** + * Model modifiers. + */ + static get modifiers() { + return { + /** + * Filter the published transactions. + */ + published(query) { + query.whereNot('published_at', null); + }, + + /** + * Filter the not categorized transactions. + */ + notCategorized(query) { + query.whereNull('cashflowTransactions.uncategorizedTransactionId'); + }, + + /** + * Filter the categorized transactions. + */ + categorized(query) { + query.whereNotNull('cashflowTransactions.uncategorizedTransactionId'); + }, + }; + } + + /** + * Relationship mapping. + */ + static get relationMappings() { + const CashflowTransactionLine = require('models/CashflowTransactionLine'); + const AccountTransaction = require('models/AccountTransaction'); + const Account = require('models/Account'); + const { MatchedBankTransaction } = require('models/MatchedBankTransaction'); + + return { + /** + * Cashflow transaction entries. + */ + entries: { + relation: Model.HasManyRelation, + modelClass: CashflowTransactionLine.default, + join: { + from: 'cashflow_transactions.id', + to: 'cashflow_transaction_lines.cashflowTransactionId', + }, + filter: (query) => { + query.orderBy('index', 'ASC'); + }, + }, + + /** + * Cashflow transaction has associated account transactions. + */ + transactions: { + relation: Model.HasManyRelation, + modelClass: AccountTransaction.default, + join: { + from: 'cashflow_transactions.id', + to: 'accounts_transactions.referenceId', + }, + filter(builder) { + builder.where('reference_type', 'CashflowTransaction'); + }, + }, + + /** + * Cashflow transaction may has associated cashflow account. + */ + cashflowAccount: { + relation: Model.BelongsToOneRelation, + modelClass: Account.default, + join: { + from: 'cashflow_transactions.cashflowAccountId', + to: 'accounts.id', + }, + }, + + /** + * Cashflow transcation may has associated to credit account. + */ + creditAccount: { + relation: Model.BelongsToOneRelation, + modelClass: Account.default, + join: { + from: 'cashflow_transactions.creditAccountId', + to: 'accounts.id', + }, + }, + + /** + * Cashflow transaction may belongs to matched bank transaction. + */ + matchedBankTransaction: { + relation: Model.HasManyRelation, + modelClass: MatchedBankTransaction, + join: { + from: 'cashflow_transactions.id', + to: 'matched_bank_transactions.referenceId', + }, + filter: (query) => { + const referenceTypes = getCashflowAccountTransactionsTypes(); + query.whereIn('reference_type', referenceTypes); + }, + }, + }; + } +} diff --git a/packages/server-nest/src/modules/BankingTransactions/models/BankTransactionLine.ts b/packages/server-nest/src/modules/BankingTransactions/models/BankTransactionLine.ts new file mode 100644 index 000000000..d1538f361 --- /dev/null +++ b/packages/server-nest/src/modules/BankingTransactions/models/BankTransactionLine.ts @@ -0,0 +1,46 @@ +/* eslint-disable global-require */ +import { Model } from 'objection'; +// import TenantModel from 'models/TenantModel'; +import { BaseModel } from '@/models/Model'; + +export class BankTransactionLine extends BaseModel{ + /** + * Table name. + */ + static get tableName() { + return 'cashflow_transaction_lines'; + } + + /** + * Timestamps columns. + */ + static get timestamps() { + return ['createdAt', 'updatedAt']; + } + + /** + * Relationship mapping. + */ + static get relationMappings() { + const Account = require('models/Account'); + + return { + cashflowAccount: { + relation: Model.BelongsToOneRelation, + modelClass: Account.default, + join: { + from: 'cashflow_transaction_lines.cashflowAccountId', + to: 'accounts.id', + }, + }, + creditAccount: { + relation: Model.BelongsToOneRelation, + modelClass: Account.default, + join: { + from: 'cashflow_transaction_lines.creditAccountId', + to: 'accounts.id', + }, + }, + }; + } +} diff --git a/packages/server-nest/src/modules/BankingTransactions/models/UncategorizedBankTransaction.ts b/packages/server-nest/src/modules/BankingTransactions/models/UncategorizedBankTransaction.ts new file mode 100644 index 000000000..73fdcee63 --- /dev/null +++ b/packages/server-nest/src/modules/BankingTransactions/models/UncategorizedBankTransaction.ts @@ -0,0 +1,242 @@ +/* eslint-disable global-require */ +import moment from 'moment'; +import { Model } from 'objection'; +// import TenantModel from 'models/TenantModel'; +// import ModelSettings from './ModelSetting'; +// import UncategorizedCashflowTransactionMeta from './UncategorizedCashflowTransaction.meta'; +import { BaseModel } from '@/models/Model'; + +export class UncategorizedBankTransaction extends BaseModel { + amount!: number; + date!: Date | string; + categorized!: boolean; + accountId!: number; + referenceNo!: string; + payee!: string; + description!: string; + plaidTransactionId!: string; + recognizedTransactionId!: number; + excludedAt: Date; + pending: boolean; + + categorizeRefId!: number; + categorizeRefType!: string; + + /** + * Table name. + */ + static get tableName() { + return 'uncategorized_cashflow_transactions'; + } + + /** + * Timestamps columns. + */ + get timestamps() { + return ['createdAt', 'updatedAt']; + } + + /** + * Virtual attributes. + */ + static get virtualAttributes() { + return [ + 'withdrawal', + 'deposit', + 'isDepositTransaction', + 'isWithdrawalTransaction', + 'isRecognized', + 'isExcluded', + 'isPending', + ]; + } + + // static get meta() { + // return UncategorizedCashflowTransactionMeta; + // } + + /** + * Retrieves the withdrawal amount. + * @returns {number} + */ + public get withdrawal() { + return this.amount < 0 ? Math.abs(this.amount) : 0; + } + + /** + * Retrieves the deposit amount. + * @returns {number} + */ + public get deposit(): number { + return this.amount > 0 ? Math.abs(this.amount) : 0; + } + + /** + * Detarmines whether the transaction is deposit transaction. + */ + public get isDepositTransaction(): boolean { + return 0 < this.deposit; + } + + /** + * Detarmines whether the transaction is withdrawal transaction. + */ + public get isWithdrawalTransaction(): boolean { + return 0 < this.withdrawal; + } + + /** + * Detarmines whether the transaction is recognized. + */ + public get isRecognized(): boolean { + return !!this.recognizedTransactionId; + } + + /** + * Detarmines whether the transaction is excluded. + * @returns {boolean} + */ + public get isExcluded(): boolean { + return !!this.excludedAt; + } + + /** + * Detarmines whether the transaction is pending. + * @returns {boolean} + */ + public get isPending(): boolean { + return !!this.pending; + } + + /** + * Model modifiers. + */ + static get modifiers() { + return { + /** + * Filters the not excluded transactions. + */ + notExcluded(query) { + query.whereNull('excluded_at'); + }, + + /** + * Filters the excluded transactions. + */ + excluded(query) { + query.whereNotNull('excluded_at'); + }, + + /** + * Filter out the recognized transactions. + * @param query + */ + recognized(query) { + query.whereNotNull('recognizedTransactionId'); + }, + + /** + * Filter out the not recognized transactions. + * @param query + */ + notRecognized(query) { + query.whereNull('recognizedTransactionId'); + }, + + categorized(query) { + query.whereNotNull('categorizeRefType'); + query.whereNotNull('categorizeRefId'); + }, + + notCategorized(query) { + query.whereNull('categorizeRefType'); + query.whereNull('categorizeRefId'); + }, + + /** + * Filters the not pending transactions. + */ + notPending(query) { + query.where('pending', false); + }, + + /** + * Filters the pending transactions. + */ + pending(query) { + query.where('pending', true); + }, + + minAmount(query, minAmount) { + query.where('amount', '>=', minAmount); + }, + + maxAmount(query, maxAmount) { + query.where('amount', '<=', maxAmount); + }, + + toDate(query, toDate) { + const dateFormat = 'YYYY-MM-DD'; + const _toDate = moment(toDate).endOf('day').format(dateFormat); + + query.where('date', '<=', _toDate); + }, + + fromDate(query, fromDate) { + const dateFormat = 'YYYY-MM-DD'; + const _fromDate = moment(fromDate).startOf('day').format(dateFormat); + + query.where('date', '>=', _fromDate); + }, + }; + } + + /** + * Relationship mapping. + */ + static get relationMappings() { + const Account = require('models/Account'); + const { + RecognizedBankTransaction, + } = require('models/RecognizedBankTransaction'); + const { MatchedBankTransaction } = require('models/MatchedBankTransaction'); + + return { + /** + * Transaction may has associated to account. + */ + account: { + relation: Model.BelongsToOneRelation, + modelClass: Account.default, + join: { + from: 'uncategorized_cashflow_transactions.accountId', + to: 'accounts.id', + }, + }, + + /** + * Transaction may has association to recognized transaction. + */ + recognizedTransaction: { + relation: Model.HasOneRelation, + modelClass: RecognizedBankTransaction, + join: { + from: 'uncategorized_cashflow_transactions.recognizedTransactionId', + to: 'recognized_bank_transactions.id', + }, + }, + + /** + * Uncategorized transaction may has association to matched transaction. + */ + matchedBankTransactions: { + relation: Model.HasManyRelation, + modelClass: MatchedBankTransaction, + join: { + from: 'uncategorized_cashflow_transactions.id', + to: 'matched_bank_transactions.uncategorizedTransactionId', + }, + }, + }; + } +} diff --git a/packages/server-nest/src/modules/BankingTransactions/queries/BankAccountTransformer.ts b/packages/server-nest/src/modules/BankingTransactions/queries/BankAccountTransformer.ts new file mode 100644 index 000000000..f645a2d73 --- /dev/null +++ b/packages/server-nest/src/modules/BankingTransactions/queries/BankAccountTransformer.ts @@ -0,0 +1,62 @@ +import { Account } from '../../Accounts/models/Account.model'; +import { Transformer } from '../../Transformer/Transformer'; + +export class CashflowAccountTransformer extends Transformer { + /** + * Include these attributes to sale invoice object. + * @returns {string[]} + */ + public includeAttributes = (): string[] => { + return [ + 'formattedAmount', + 'lastFeedsUpdatedAt', + 'lastFeedsUpdatedAtFormatted', + 'lastFeedsUpdatedFromNow', + ]; + }; + + /** + * Exclude these attributes to sale invoice object. + * @returns {string[]} + */ + public excludeAttributes = (): string[] => { + return [ + 'predefined', + 'index', + 'accountRootType', + 'accountTypeLabel', + 'accountParentType', + 'isBalanceSheetAccount', + 'isPlSheet', + ]; + }; + + /** + * Retrieve formatted account amount. + * @param {IAccount} invoice + * @returns {string} + */ + protected formattedAmount = (account: Account): string => { + return this.formatNumber(account.amount, { + currencyCode: account.currencyCode, + }); + }; + + /** + * Retrieves the last feeds update at formatted date. + * @param {IAccount} account + * @returns {string} + */ + protected lastFeedsUpdatedAtFormatted(account: Account): string { + return this.formatDate(account.lastFeedsUpdatedAt); + } + + /** + * Retrieves the last feeds updated from now. + * @param {IAccount} account + * @returns {string} + */ + protected lastFeedsUpdatedFromNow(account: Account): string { + return this.formatDateFromNow(account.lastFeedsUpdatedAt); + } +} diff --git a/packages/server-nest/src/modules/BankingTransactions/queries/BankTransactionTransformer.ts b/packages/server-nest/src/modules/BankingTransactions/queries/BankTransactionTransformer.ts new file mode 100644 index 000000000..592174953 --- /dev/null +++ b/packages/server-nest/src/modules/BankingTransactions/queries/BankTransactionTransformer.ts @@ -0,0 +1,55 @@ +import { Transformer } from '../../Transformer/Transformer'; + +export class BankTransactionTransformer extends Transformer { + /** + * Include these attributes to sale invoice object. + * @returns {string[]} + */ + public includeAttributes = (): string[] => { + return [ + 'formattedAmount', + 'transactionTypeFormatted', + 'formattedDate', + 'formattedCreatedAt', + ]; + }; + + /** + * Formatted amount. + * @param {} transaction + * @returns {string} + */ + protected formattedAmount = (transaction) => { + return this.formatNumber(transaction.amount, { + currencyCode: transaction.currencyCode, + excerptZero: true, + }); + }; + + /** + * Formatted transaction type. + * @param transaction + * @returns {string} + */ + protected transactionTypeFormatted = (transaction) => { + return this.context.i18n.t(transaction.transactionTypeFormatted); + }; + + /** + * Retrieve the formatted transaction date. + * @param invoice + * @returns {string} + */ + protected formattedDate = (invoice): string => { + return this.formatDate(invoice.date); + }; + + /** + * Retrieve the formatted created at date. + * @param invoice + * @returns {string} + */ + protected formattedCreatedAt = (invoice): string => { + return this.formatDate(invoice.createdAt); + }; +} diff --git a/packages/server-nest/src/modules/BankingTransactions/queries/BankTransactionsTransformer.ts b/packages/server-nest/src/modules/BankingTransactions/queries/BankTransactionsTransformer.ts new file mode 100644 index 000000000..dcf0b3056 --- /dev/null +++ b/packages/server-nest/src/modules/BankingTransactions/queries/BankTransactionsTransformer.ts @@ -0,0 +1,70 @@ +import { Transformer } from "@/modules/Transformer/Transformer"; + +export class BankTransactionsTransformer extends Transformer { + /** + * Include these attributes to sale invoice object. + * @returns {string[]} + */ + public includeAttributes = (): string[] => { + return ['deposit', 'withdrawal', 'formattedDeposit', 'formattedWithdrawal']; + }; + + /** + * Exclude these attributes. + * @returns {string[]} + */ + public excludeAttributes = (): string[] => { + return [ + 'credit', + 'debit', + 'index', + 'index_group', + 'item_id', + 'item_quantity', + 'contact_type', + 'contact_id', + ]; + }; + + /** + * Deposit amount attribute. + * @param transaction + * @returns + */ + protected deposit = (transaction) => { + return transaction.debit; + }; + + /** + * Withdrawal amount attribute. + * @param transaction + * @returns + */ + protected withdrawal = (transaction) => { + return transaction.credit; + }; + + /** + * Formatted withdrawal amount. + * @param transaction + * @returns + */ + protected formattedWithdrawal = (transaction) => { + return this.formatNumber(transaction.credit, { + currencyCode: transaction.currencyCode, + excerptZero: true, + }); + }; + + /** + * Formatted deposit account. + * @param transaction + * @returns + */ + protected formattedDeposit = (transaction) => { + return this.formatNumber(transaction.debit, { + currencyCode: transaction.currencyCode, + excerptZero: true, + }); + }; +} diff --git a/packages/server-nest/src/modules/BankingTransactions/queries/GetBankAccounts.service.ts b/packages/server-nest/src/modules/BankingTransactions/queries/GetBankAccounts.service.ts new file mode 100644 index 000000000..40f934d59 --- /dev/null +++ b/packages/server-nest/src/modules/BankingTransactions/queries/GetBankAccounts.service.ts @@ -0,0 +1,61 @@ +// import { Service, Inject } from 'typedi'; +// import { ICashflowAccount, ICashflowAccountsFilter } from '@/interfaces'; +// import { CashflowAccountTransformer } from './queries/BankAccountTransformer'; +// import TenancyService from '@/services/Tenancy/TenancyService'; +// import DynamicListingService from '@/services/DynamicListing/DynamicListService'; +// import { TransformerInjectable } from '@/lib/Transformer/TransformerInjectable'; +// import { ACCOUNT_TYPE } from '@/data/AccountTypes'; + +// @Service() +// export default class GetCashflowAccountsService { +// @Inject() +// private tenancy: TenancyService; + +// @Inject() +// private dynamicListService: DynamicListingService; + +// @Inject() +// private transformer: TransformerInjectable; + +// /** +// * Retrieve the cash flow accounts. +// * @param {number} tenantId - Tenant id. +// * @param {ICashflowAccountsFilter} filterDTO - Filter DTO. +// * @returns {ICashflowAccount[]} +// */ +// public async getCashflowAccounts( +// tenantId: number, +// filterDTO: ICashflowAccountsFilter +// ): Promise<{ cashflowAccounts: ICashflowAccount[] }> { +// const { CashflowAccount } = this.tenancy.models(tenantId); + +// // Parsees accounts list filter DTO. +// const filter = this.dynamicListService.parseStringifiedFilter(filterDTO); + +// // Dynamic list service. +// const dynamicList = await this.dynamicListService.dynamicList( +// tenantId, +// CashflowAccount, +// filter +// ); +// // Retrieve accounts model based on the given query. +// const accounts = await CashflowAccount.query().onBuild((builder) => { +// dynamicList.buildQuery()(builder); + +// builder.whereIn('account_type', [ +// ACCOUNT_TYPE.BANK, +// ACCOUNT_TYPE.CASH, +// ACCOUNT_TYPE.CREDIT_CARD, +// ]); +// builder.modify('inactiveMode', filter.inactiveMode); +// }); +// // Retrieves the transformed accounts. +// const transformed = await this.transformer.transform( +// tenantId, +// accounts, +// new CashflowAccountTransformer() +// ); + +// return transformed; +// } +// } diff --git a/packages/server-nest/src/modules/BankingTransactions/queries/GetBankTransaction.service.ts b/packages/server-nest/src/modules/BankingTransactions/queries/GetBankTransaction.service.ts new file mode 100644 index 000000000..1e5c637ff --- /dev/null +++ b/packages/server-nest/src/modules/BankingTransactions/queries/GetBankTransaction.service.ts @@ -0,0 +1,51 @@ +import { Inject, Injectable } from '@nestjs/common'; +import { ERRORS } from '../constants'; +import { BankTransaction } from '../models/BankTransaction'; +import { TransformerInjectable } from '@/modules/Transformer/TransformerInjectable.service'; +import { ServiceError } from '@/modules/Items/ServiceError'; +import { BankTransactionTransformer } from './BankTransactionTransformer'; + +@Injectable() +export class GetBankTransactionService { + constructor( + @Inject(BankTransaction.name) + private readonly bankTransactionModel: typeof BankTransaction, + private readonly transformer: TransformerInjectable, + ) {} + + /** + * Retrieve the given cashflow transaction. + * @param {number} cashflowTransactionId + * @returns + */ + public async getBankTransaction(cashflowTransactionId: number) { + const cashflowTransaction = await this.bankTransactionModel + .query() + .findById(cashflowTransactionId) + .withGraphFetched('entries.cashflowAccount') + .withGraphFetched('entries.creditAccount') + .withGraphFetched('transactions.account') + .orderBy('date', 'DESC') + .throwIfNotFound(); + + this.throwErrorCashflowTransactionNotFound(cashflowTransaction); + + // Transforms the cashflow transaction model to POJO. + return this.transformer.transform( + cashflowTransaction, + new BankTransactionTransformer(), + ); + } + + /** + * Throw not found error if the given cashflow is undefined. + * @param {BankTransaction} bankTransaction - Bank transaction. + */ + private throwErrorCashflowTransactionNotFound( + bankTransaction: BankTransaction, + ) { + if (!bankTransaction) { + throw new ServiceError(ERRORS.CASHFLOW_TRANSACTION_NOT_FOUND); + } + } +} diff --git a/packages/server-nest/src/modules/BankingTransactions/queries/GetPendingBankAccountTransaction.service.ts b/packages/server-nest/src/modules/BankingTransactions/queries/GetPendingBankAccountTransaction.service.ts new file mode 100644 index 000000000..5da6b2cc4 --- /dev/null +++ b/packages/server-nest/src/modules/BankingTransactions/queries/GetPendingBankAccountTransaction.service.ts @@ -0,0 +1,48 @@ +import { Inject, Injectable } from '@nestjs/common'; +import { GetPendingBankAccountTransactionTransformer } from './GetPendingBankAccountTransactionTransformer'; +import { UncategorizedBankTransaction } from '../models/UncategorizedBankTransaction'; +import { TransformerInjectable } from '@/modules/Transformer/TransformerInjectable.service'; + +@Injectable() +export class GetPendingBankAccountTransactions { + constructor( + private readonly transformerService: TransformerInjectable, + + @Inject(UncategorizedBankTransaction.name) + private readonly uncategorizedBankTransactionModel: typeof UncategorizedBankTransaction + ) {} + + /** + * Retrieves the given bank accounts pending transaction. + * @param {GetPendingTransactionsQuery} filter - Pending transactions query. + */ + async getPendingTransactions(filter?: GetPendingTransactionsQuery) { + const _filter = { + page: 1, + pageSize: 20, + ...filter, + }; + const { results, pagination } = + await this.uncategorizedBankTransactionModel.query() + .onBuild((q) => { + q.modify('pending'); + + if (_filter?.accountId) { + q.where('accountId', _filter.accountId); + } + }) + .pagination(_filter.page - 1, _filter.pageSize); + + const data = await this.transformerService.transform( + results, + new GetPendingBankAccountTransactionTransformer() + ); + return { data, pagination }; + } +} + +interface GetPendingTransactionsQuery { + page?: number; + pageSize?: number; + accountId?: number; +} diff --git a/packages/server-nest/src/modules/BankingTransactions/queries/GetPendingBankAccountTransactionTransformer.ts b/packages/server-nest/src/modules/BankingTransactions/queries/GetPendingBankAccountTransactionTransformer.ts new file mode 100644 index 000000000..ff54dcb32 --- /dev/null +++ b/packages/server-nest/src/modules/BankingTransactions/queries/GetPendingBankAccountTransactionTransformer.ts @@ -0,0 +1,72 @@ +import { Transformer } from '@/modules/Transformer/Transformer'; + +export class GetPendingBankAccountTransactionTransformer extends Transformer { + /** + * Include these attributes to sale invoice object. + * @returns {string[]} + */ + public includeAttributes = (): string[] => { + return [ + 'formattedAmount', + 'formattedDate', + 'formattedDepositAmount', + 'formattedWithdrawalAmount', + ]; + }; + + /** + * Exclude all attributes. + * @returns {Array} + */ + public excludeAttributes = (): string[] => { + return []; + }; + + /** + * Formattes the transaction date. + * @param transaction + * @returns {string} + */ + public formattedDate(transaction) { + return this.formatDate(transaction.date); + } + + /** + * Formatted amount. + * @param transaction + * @returns {string} + */ + public formattedAmount(transaction) { + return this.formatNumber(transaction.amount, { + currencyCode: transaction.currencyCode, + }); + } + + /** + * Formatted deposit amount. + * @param transaction + * @returns {string} + */ + protected formattedDepositAmount(transaction) { + if (transaction.isDepositTransaction) { + return this.formatNumber(transaction.deposit, { + currencyCode: transaction.currencyCode, + }); + } + return ''; + } + + /** + * Formatted withdrawal amount. + * @param transaction + * @returns {string} + */ + protected formattedWithdrawalAmount(transaction) { + if (transaction.isWithdrawalTransaction) { + return this.formatNumber(transaction.withdrawal, { + currencyCode: transaction.currencyCode, + }); + } + return ''; + } +} diff --git a/packages/server-nest/src/modules/BankingTransactions/queries/GetRecognizedTransaction.service.ts b/packages/server-nest/src/modules/BankingTransactions/queries/GetRecognizedTransaction.service.ts new file mode 100644 index 000000000..35f7368db --- /dev/null +++ b/packages/server-nest/src/modules/BankingTransactions/queries/GetRecognizedTransaction.service.ts @@ -0,0 +1,37 @@ +import { Inject, Injectable } from '@nestjs/common'; +import { GetRecognizedTransactionTransformer } from './GetRecognizedTransactionTransformer'; +import { TransformerInjectable } from '@/modules/Transformer/TransformerInjectable.service'; +import { UncategorizedBankTransaction } from '../models/UncategorizedBankTransaction'; + +@Injectable() +export class GetRecognizedTransactionService { + constructor( + private readonly transformer: TransformerInjectable, + + @Inject(UncategorizedBankTransaction.name) + private readonly uncategorizedBankTransactionModel: typeof UncategorizedBankTransaction + ) {} + + /** + * Retrieves the recognized transaction of the given uncategorized transaction. + * @param {number} tenantId + * @param {number} uncategorizedTransactionId + */ + public async getRecognizedTransaction( + uncategorizedTransactionId: number + ) { + const uncategorizedTransaction = + await this.uncategorizedBankTransactionModel.query() + .findById(uncategorizedTransactionId) + .withGraphFetched('matchedBankTransactions') + .withGraphFetched('recognizedTransaction.assignAccount') + .withGraphFetched('recognizedTransaction.bankRule') + .withGraphFetched('account') + .throwIfNotFound(); + + return this.transformer.transform( + uncategorizedTransaction, + new GetRecognizedTransactionTransformer() + ); + } +} diff --git a/packages/server-nest/src/modules/BankingTransactions/queries/GetRecognizedTransactionTransformer.ts b/packages/server-nest/src/modules/BankingTransactions/queries/GetRecognizedTransactionTransformer.ts new file mode 100644 index 000000000..a85b8a7d6 --- /dev/null +++ b/packages/server-nest/src/modules/BankingTransactions/queries/GetRecognizedTransactionTransformer.ts @@ -0,0 +1,261 @@ +import { Transformer } from "@/modules/Transformer/Transformer"; + +export class GetRecognizedTransactionTransformer extends Transformer { + /** + * Include these attributes to sale credit note object. + * @returns {Array} + */ + public includeAttributes = (): string[] => { + return [ + 'uncategorizedTransactionId', + 'referenceNo', + 'description', + 'payee', + 'amount', + 'formattedAmount', + 'date', + 'formattedDate', + 'assignedAccountId', + 'assignedAccountName', + 'assignedAccountCode', + 'assignedPayee', + 'assignedMemo', + 'assignedCategory', + 'assignedCategoryFormatted', + 'withdrawal', + 'deposit', + 'isDepositTransaction', + 'isWithdrawalTransaction', + 'formattedDepositAmount', + 'formattedWithdrawalAmount', + 'bankRuleId', + 'bankRuleName', + ]; + }; + + /** + * Exclude all attributes. + * @returns {Array} + */ + public excludeAttributes = (): string[] => { + return ['*']; + }; + + /** + * Get the uncategorized transaction id. + * @param transaction + * @returns {number} + */ + public uncategorizedTransactionId = (transaction): number => { + return transaction.id; + } + + /** + * Get the reference number of the transaction. + * @param {object} transaction + * @returns {string} + */ + public referenceNo(transaction: any): string { + return transaction.referenceNo; + } + + /** + * Get the description of the transaction. + * @param {object} transaction + * @returns {string} + */ + public description(transaction: any): string { + return transaction.description; + } + + /** + * Get the payee of the transaction. + * @param {object} transaction + * @returns {string} + */ + public payee(transaction: any): string { + return transaction.payee; + } + + /** + * Get the amount of the transaction. + * @param {object} transaction + * @returns {number} + */ + public amount(transaction: any): number { + return transaction.amount; + } + + /** + * Get the formatted amount of the transaction. + * @param {object} transaction + * @returns {string} + */ + public formattedAmount(transaction: any): string { + return this.formatNumber(transaction.formattedAmount, { + money: true, + }); + } + + /** + * Get the date of the transaction. + * @param {object} transaction + * @returns {string} + */ + public date(transaction: any): string { + return transaction.date; + } + + /** + * Get the formatted date of the transaction. + * @param {object} transaction + * @returns {string} + */ + public formattedDate(transaction: any): string { + return this.formatDate(transaction.date); + } + + /** + * Get the assigned account ID of the transaction. + * @param {object} transaction + * @returns {number} + */ + public assignedAccountId(transaction: any): number { + return transaction.recognizedTransaction.assignedAccountId; + } + + /** + * Get the assigned account name of the transaction. + * @param {object} transaction + * @returns {string} + */ + public assignedAccountName(transaction: any): string { + return transaction.recognizedTransaction.assignAccount.name; + } + + /** + * Get the assigned account code of the transaction. + * @param {object} transaction + * @returns {string} + */ + public assignedAccountCode(transaction: any): string { + return transaction.recognizedTransaction.assignAccount.code; + } + + /** + * Get the assigned payee of the transaction. + * @param {object} transaction + * @returns {string} + */ + public getAssignedPayee(transaction: any): string { + return transaction.recognizedTransaction.assignedPayee; + } + + /** + * Get the assigned memo of the transaction. + * @param {object} transaction + * @returns {string} + */ + public assignedMemo(transaction: any): string { + return transaction.recognizedTransaction.assignedMemo; + } + + /** + * Get the assigned category of the transaction. + * @param {object} transaction + * @returns {string} + */ + public assignedCategory(transaction: any): string { + return transaction.recognizedTransaction.assignedCategory; + } + + /** + * + * @returns {string} + */ + public assignedCategoryFormatted() { + return 'Other Income' + } + + /** + * Check if the transaction is a withdrawal. + * @param {object} transaction + * @returns {boolean} + */ + public isWithdrawal(transaction: any): boolean { + return transaction.withdrawal; + } + + /** + * Check if the transaction is a deposit. + * @param {object} transaction + * @returns {boolean} + */ + public isDeposit(transaction: any): boolean { + return transaction.deposit; + } + + /** + * Check if the transaction is a deposit transaction. + * @param {object} transaction + * @returns {boolean} + */ + public isDepositTransaction(transaction: any): boolean { + return transaction.isDepositTransaction; + } + + /** + * Check if the transaction is a withdrawal transaction. + * @param {object} transaction + * @returns {boolean} + */ + public isWithdrawalTransaction(transaction: any): boolean { + return transaction.isWithdrawalTransaction; + } + + /** + * Get formatted deposit amount. + * @param {any} transaction + * @returns {string} + */ + protected formattedDepositAmount(transaction) { + if (transaction.isDepositTransaction) { + return this.formatNumber(transaction.deposit, { + currencyCode: transaction.currencyCode, + }); + } + return ''; + } + + /** + * Get formatted withdrawal amount. + * @param transaction + * @returns {string} + */ + protected formattedWithdrawalAmount(transaction) { + if (transaction.isWithdrawalTransaction) { + return this.formatNumber(transaction.withdrawal, { + currencyCode: transaction.currencyCode, + }); + } + return ''; + } + + /** + * Get the transaction bank rule id. + * @param transaction + * @returns {string} + */ + protected bankRuleId(transaction) { + return transaction.recognizedTransaction.bankRuleId; + } + + /** + * Get the transaction bank rule name. + * @param transaction + * @returns {string} + */ + protected bankRuleName(transaction) { + return transaction.recognizedTransaction.bankRule.name; + } +} diff --git a/packages/server-nest/src/modules/BankingTransactions/queries/GetRecongizedTransactions.ts b/packages/server-nest/src/modules/BankingTransactions/queries/GetRecongizedTransactions.ts new file mode 100644 index 000000000..94e1c5d9a --- /dev/null +++ b/packages/server-nest/src/modules/BankingTransactions/queries/GetRecongizedTransactions.ts @@ -0,0 +1,67 @@ +import { Inject, Injectable } from '@nestjs/common'; +import { GetRecognizedTransactionTransformer } from './GetRecognizedTransactionTransformer'; +import { UncategorizedBankTransaction } from '../models/UncategorizedBankTransaction'; +import { TransformerInjectable } from '@/modules/Transformer/TransformerInjectable.service'; +import { IGetRecognizedTransactionsQuery } from '../types/BankingTransactions.types'; + +@Injectable() +export class GetRecognizedTransactionsService { + constructor( + private readonly transformer: TransformerInjectable, + + @Inject(UncategorizedBankTransaction.name) + private readonly uncategorizedBankTransactionModel: typeof UncategorizedBankTransaction, + ) {} + + /** + * Retrieves the recognized transactions of the given account. + * @param {number} tenantId + * @param {IGetRecognizedTransactionsQuery} filter - + */ + async getRecognizedTranactions(filter?: IGetRecognizedTransactionsQuery) { + const _query = { + page: 1, + pageSize: 20, + ...filter, + }; + const { results, pagination } = + await this.uncategorizedBankTransactionModel.query() + .onBuild((q) => { + q.withGraphFetched('recognizedTransaction.assignAccount'); + q.withGraphFetched('recognizedTransaction.bankRule'); + q.whereNotNull('recognizedTransactionId'); + + // Exclude the excluded transactions. + q.modify('notExcluded'); + + // Exclude the pending transactions. + q.modify('notPending'); + + if (_query.accountId) { + q.where('accountId', _query.accountId); + } + if (_query.minDate) { + q.modify('fromDate', _query.minDate); + } + if (_query.maxDate) { + q.modify('toDate', _query.maxDate); + } + if (_query.minAmount) { + q.modify('minAmount', _query.minAmount); + } + if (_query.maxAmount) { + q.modify('maxAmount', _query.maxAmount); + } + if (_query.accountId) { + q.where('accountId', _query.accountId); + } + }) + .pagination(_query.page - 1, _query.pageSize); + + const data = await this.transformer.transform( + results, + new GetRecognizedTransactionTransformer(), + ); + return { data, pagination }; + } +} diff --git a/packages/server-nest/src/modules/BankingTransactions/queries/GetUncategorizedBankTransaction.service.ts b/packages/server-nest/src/modules/BankingTransactions/queries/GetUncategorizedBankTransaction.service.ts new file mode 100644 index 000000000..a9059c435 --- /dev/null +++ b/packages/server-nest/src/modules/BankingTransactions/queries/GetUncategorizedBankTransaction.service.ts @@ -0,0 +1,32 @@ +import { TransformerInjectable } from '@/modules/Transformer/TransformerInjectable.service'; +import { Inject, Injectable } from '@nestjs/common'; +import { UncategorizedBankTransaction } from '../models/UncategorizedBankTransaction'; +import { UncategorizedTransactionTransformer } from '../../BankingCategorize/commands/UncategorizedTransaction.transformer'; + +@Injectable() +export class GetUncategorizedBankTransactionService { + constructor( + private readonly transformer: TransformerInjectable, + + @Inject(UncategorizedBankTransaction.name) + private readonly uncategorizedBankTransaction: typeof UncategorizedBankTransaction, + ) {} + + /** + * Retrieves specific uncategorized cashflow transaction. + * @param {number} tenantId - Tenant id. + * @param {number} uncategorizedTransactionId - Uncategorized transaction id. + */ + public async getTransaction(uncategorizedTransactionId: number) { + const transaction = await this.uncategorizedBankTransaction + .query() + .findById(uncategorizedTransactionId) + .withGraphFetched('account') + .throwIfNotFound(); + + return this.transformer.transform( + transaction, + new UncategorizedTransactionTransformer(), + ); + } +} diff --git a/packages/server-nest/src/modules/BankingTransactions/queries/GetUncategorizedTransactions.ts b/packages/server-nest/src/modules/BankingTransactions/queries/GetUncategorizedTransactions.ts new file mode 100644 index 000000000..af5909057 --- /dev/null +++ b/packages/server-nest/src/modules/BankingTransactions/queries/GetUncategorizedTransactions.ts @@ -0,0 +1,73 @@ +import { TransformerInjectable } from '@/modules/Transformer/TransformerInjectable.service'; +import { initialize } from 'objection'; +import { UncategorizedBankTransaction } from '../models/UncategorizedBankTransaction'; +import { Inject, Injectable } from '@nestjs/common'; +import { UncategorizedTransactionTransformer } from '../../BankingCategorize/commands/UncategorizedTransaction.transformer'; + +@Injectable() +export class GetUncategorizedTransactions { + constructor( + private readonly transformer: TransformerInjectable, + + @Inject(UncategorizedBankTransaction.name) + private readonly uncategorizedBankTransactionModel: typeof UncategorizedBankTransaction, + ) {} + + /** + * Retrieves the uncategorized cashflow transactions. + * @param {number} tenantId - Tenant id. + * @param {number} accountId - Account Id. + */ + public async getTransactions( + accountId: number, + query: IGetUncategorizedTransactionsQuery + ) { + // Parsed query with default values. + const _query = { + page: 1, + pageSize: 20, + ...query, + }; + + const { results, pagination } = + await this.uncategorizedBankTransactionModel.query() + .onBuild((q) => { + q.where('accountId', accountId); + q.where('categorized', false); + + q.modify('notExcluded'); + q.modify('notPending'); + + q.withGraphFetched('account'); + q.withGraphFetched('recognizedTransaction.assignAccount'); + + q.withGraphJoined('matchedBankTransactions'); + + q.whereNull('matchedBankTransactions.id'); + q.orderBy('date', 'DESC'); + + if (_query.minDate) { + q.modify('fromDate', _query.minDate); + } + if (_query.maxDate) { + q.modify('toDate', _query.maxDate); + } + if (_query.minAmount) { + q.modify('minAmount', _query.minAmount); + } + if (_query.maxAmount) { + q.modify('maxAmount', _query.maxAmount); + } + }) + .pagination(_query.page - 1, _query.pageSize); + + const data = await this.transformer.transform( + results, + new UncategorizedTransactionTransformer() + ); + return { + data, + pagination, + }; + } +} diff --git a/packages/server-nest/src/modules/BankingTransactions/subscribers/CashflowTransactionSubscriber.ts b/packages/server-nest/src/modules/BankingTransactions/subscribers/CashflowTransactionSubscriber.ts new file mode 100644 index 000000000..dd470e808 --- /dev/null +++ b/packages/server-nest/src/modules/BankingTransactions/subscribers/CashflowTransactionSubscriber.ts @@ -0,0 +1,60 @@ +import { Injectable } from '@nestjs/common'; +import { OnEvent } from '@nestjs/event-emitter'; +import { BankTransactionAutoIncrement } from '../commands/BankTransactionAutoIncrement.service'; +import { BankTransactionGLEntriesService } from '../commands/BankTransactionGLEntries'; +import { events } from '@/common/events/events'; +import { ICommandCashflowCreatedPayload, ICommandCashflowDeletedPayload } from '../types/BankingTransactions.types'; + +@Injectable() +export default class BankingTransactionGLEntriesSubscriber { + /** + * @param {BankTransactionGLEntriesService} bankTransactionGLEntries - Bank transaction GL entries service. + * @param {BankTransactionAutoIncrement} cashflowTransactionAutoIncrement - Cashflow transaction auto increment service. + */ + constructor( + private readonly bankTransactionGLEntries: BankTransactionGLEntriesService, + private readonly cashflowTransactionAutoIncrement: BankTransactionAutoIncrement, + ) {} + + /** + * Writes the journal entries once the cashflow transaction create. + * @param {ICommandCashflowCreatedPayload} payload - + */ + @OnEvent(events.cashflow.onTransactionCreated) + public async writeJournalEntriesOnceTransactionCreated({ + cashflowTransaction, + trx, + }: ICommandCashflowCreatedPayload) { + // Can't write GL entries if the transaction not published yet. + if (!cashflowTransaction.isPublished) return; + + await this.bankTransactionGLEntries.writeJournalEntries( + cashflowTransaction.id, + trx, + ); + } + + /** + * Increment the cashflow transaction number once the transaction created. + * @param {ICommandCashflowCreatedPayload} payload - + */ + @OnEvent(events.cashflow.onTransactionCreated) + public async incrementTransactionNumberOnceTransactionCreated({}: ICommandCashflowCreatedPayload) { + this.cashflowTransactionAutoIncrement.incrementNextTransactionNumber(); + } + + /** + * Deletes the GL entries once the cashflow transaction deleted. + * @param {ICommandCashflowDeletedPayload} payload - + */ + @OnEvent(events.cashflow.onTransactionDeleted) + public async revertGLEntriesOnceTransactionDeleted({ + cashflowTransactionId, + trx, + }: ICommandCashflowDeletedPayload) { + await this.bankTransactionGLEntries.revertJournalEntries( + cashflowTransactionId, + trx, + ); + }; +} diff --git a/packages/server-nest/src/modules/BankingTransactions/subscribers/CashflowWithAccountSubscriber.ts b/packages/server-nest/src/modules/BankingTransactions/subscribers/CashflowWithAccountSubscriber.ts new file mode 100644 index 000000000..ebc152685 --- /dev/null +++ b/packages/server-nest/src/modules/BankingTransactions/subscribers/CashflowWithAccountSubscriber.ts @@ -0,0 +1,26 @@ +import { Injectable } from '@nestjs/common'; +import { events } from '@/common/events/events'; + +import { ValidateDeleteBankAccountTransactions } from '../commands/ValidateDeleteBankAccountTransactions.service'; +import { OnEvent } from '@nestjs/event-emitter'; +import { IAccountEventDeletePayload } from '@/interfaces/Account'; + +@Injectable() +export class CashflowWithAccountSubscriber { + constructor( + private readonly validateDeleteBankAccount: ValidateDeleteBankAccountTransactions, + ) {} + + /** + * Validate chart account has no associated cashflow transactions on delete. + * @param {IAccountEventDeletePayload} payload - + */ + @OnEvent(events.accounts.onDelete) + public async validateAccountHasNoCashflowTransactionsOnDelete({ + oldAccount, + }: IAccountEventDeletePayload) { + await this.validateDeleteBankAccount.validateAccountHasNoCashflowEntries( + oldAccount.id + ); + }; +} diff --git a/packages/server-nest/src/modules/BankingTransactions/subscribers/DecrementUncategorizedTransactionOnCategorize.ts b/packages/server-nest/src/modules/BankingTransactions/subscribers/DecrementUncategorizedTransactionOnCategorize.ts new file mode 100644 index 000000000..149c16bff --- /dev/null +++ b/packages/server-nest/src/modules/BankingTransactions/subscribers/DecrementUncategorizedTransactionOnCategorize.ts @@ -0,0 +1,88 @@ +import PromisePool from '@supercharge/promise-pool'; +import { + ICashflowTransactionCategorizedPayload, + ICashflowTransactionUncategorizedPayload, +} from '../types/BankingTransactions.types'; +import { OnEvent } from '@nestjs/event-emitter'; +import { Inject, Injectable } from '@nestjs/common'; +import { events } from '@/common/events/events'; +import { Account } from '@/modules/Accounts/models/Account.model'; +import { UncategorizedBankTransaction } from '../models/UncategorizedBankTransaction'; + +@Injectable() +export class DecrementUncategorizedTransactionOnCategorizeSubscriber { + constructor( + @Inject(Account.name) + private readonly accountModel: typeof Account, + ) {} + + /** + * Decrement the uncategoirzed transactions on the account once categorizing. + * @param {ICashflowTransactionCategorizedPayload} + */ + @OnEvent(events.cashflow.onTransactionCategorized) + public async decrementUnCategorizedTransactionsOnCategorized({ + uncategorizedTransactions, + trx, + }: ICashflowTransactionCategorizedPayload) { + await PromisePool.withConcurrency(1) + .for(uncategorizedTransactions) + .process( + async (uncategorizedTransaction: UncategorizedBankTransaction) => { + // Cannot continue if the transaction is still pending. + if (uncategorizedTransaction.isPending) { + return; + } + await this.accountModel + .query(trx) + .findById(uncategorizedTransaction.accountId) + .decrement('uncategorizedTransactions', 1); + }, + ); + } + + /** + * Increment the uncategorized transaction on the given account on uncategorizing. + * @param {IManualJournalDeletingPayload} + */ + @OnEvent(events.cashflow.onTransactionUncategorized) + public async incrementUnCategorizedTransactionsOnUncategorized({ + uncategorizedTransactions, + trx, + }: ICashflowTransactionUncategorizedPayload) { + await PromisePool.withConcurrency(1) + .for(uncategorizedTransactions) + .process( + async (uncategorizedTransaction: UncategorizedBankTransaction) => { + // Cannot continue if the transaction is still pending. + if (uncategorizedTransaction.isPending) { + return; + } + await this.accountModel + .query(trx) + .findById(uncategorizedTransaction.accountId) + .increment('uncategorizedTransactions', 1); + }, + ); + } + + /** + * Increments uncategorized transactions count once creating a new transaction. + * @param {ICommandCashflowCreatedPayload} payload - + */ + @OnEvent(events.cashflow.onTransactionUncategorizedCreated) + public async incrementUncategoirzedTransactionsOnCreated({ + uncategorizedTransaction, + trx, + }: any) { + if (!uncategorizedTransaction.accountId) return; + + // Cannot continue if the transaction is still pending. + if (uncategorizedTransaction.isPending) return; + + await this.accountModel + .query(trx) + .findById(uncategorizedTransaction.accountId) + .increment('uncategorizedTransactions', 1); + } +} diff --git a/packages/server-nest/src/modules/BankingTransactions/subscribers/DeleteCashflowTransactionOnUncategorize.ts b/packages/server-nest/src/modules/BankingTransactions/subscribers/DeleteCashflowTransactionOnUncategorize.ts new file mode 100644 index 000000000..c5b8d37d0 --- /dev/null +++ b/packages/server-nest/src/modules/BankingTransactions/subscribers/DeleteCashflowTransactionOnUncategorize.ts @@ -0,0 +1,35 @@ +import { Inject, Injectable } from '@nestjs/common'; +import { OnEvent } from '@nestjs/event-emitter'; +import { PromisePool } from '@supercharge/promise-pool'; +import { DeleteCashflowTransaction } from '../commands/DeleteCashflowTransaction.service'; +import { events } from '@/common/events/events'; +import { ICashflowTransactionUncategorizedPayload } from '@/modules/BankingCategorize/types/BankingCategorize.types'; + +@Injectable() +export class DeleteCashflowTransactionOnUncategorizeSubscriber { + constructor( + private readonly deleteCashflowTransactionService: DeleteCashflowTransaction, + ) {} + + /** + * Deletes the cashflow transaction once uncategorize the bank transaction. + * @param {ICashflowTransactionUncategorizedPayload} payload + */ + @OnEvent(events.cashflow.onTransactionUncategorized) + public async deleteCashflowTransactionOnUncategorize({ + oldMainUncategorizedTransaction, + trx, + }: ICashflowTransactionUncategorizedPayload) { + // Cannot continue if the main transaction does not reference to cashflow type. + if ( + oldMainUncategorizedTransaction.categorizeRefType !== + 'CashflowTransaction' + ) { + return; + } + await this.deleteCashflowTransactionService.deleteCashflowTransaction( + oldMainUncategorizedTransaction.categorizeRefId, + trx + ); + } +} diff --git a/packages/server-nest/src/modules/BankingTransactions/subscribers/PreventDeleteTransactionsOnDelete.ts b/packages/server-nest/src/modules/BankingTransactions/subscribers/PreventDeleteTransactionsOnDelete.ts new file mode 100644 index 000000000..7ab0e064e --- /dev/null +++ b/packages/server-nest/src/modules/BankingTransactions/subscribers/PreventDeleteTransactionsOnDelete.ts @@ -0,0 +1,42 @@ +import { events } from '@/common/events/events'; +import { ERRORS } from '../constants'; +import { OnEvent } from '@nestjs/event-emitter'; +import { ServiceError } from '@/modules/Items/ServiceError'; +import { Inject, Injectable } from '@nestjs/common'; +import { UncategorizedBankTransaction } from '../models/UncategorizedBankTransaction'; +import { ICommandCashflowDeletingPayload } from '../types/BankingTransactions.types'; + +@Injectable() +export class PreventDeleteTransactionOnDeleteSubscriber { + constructor( + @Inject(UncategorizedBankTransaction.name) + private readonly uncategorizedBankTransactionModel: typeof UncategorizedBankTransaction, + ) {} + + /** + * Prevent delete cashflow transaction has converted from uncategorized transaction. + * @param {ICommandCashflowDeletingPayload} payload + */ + @OnEvent(events.cashflow.onTransactionDeleting) + public async preventDeleteCashflowTransactionHasUncategorizedTransaction({ + oldCashflowTransaction, + trx, + }: ICommandCashflowDeletingPayload) { + if (oldCashflowTransaction.uncategorizedTransactionId) { + const foundTransactions = await this.uncategorizedBankTransactionModel + .query(trx) + .where({ + categorized: true, + categorizeRefId: oldCashflowTransaction.id, + categorizeRefType: 'CashflowTransaction', + }); + // Throw the error if the cashflow transaction still linked to uncategorized transaction. + if (foundTransactions.length > 0) { + throw new ServiceError( + ERRORS.CANNOT_DELETE_TRANSACTION_CONVERTED_FROM_UNCATEGORIZED, + 'Cannot delete cashflow transaction converted from uncategorized transaction.', + ); + } + } + } +} diff --git a/packages/server-nest/src/modules/BankingTransactions/types/BankingTransactions.types.ts b/packages/server-nest/src/modules/BankingTransactions/types/BankingTransactions.types.ts new file mode 100644 index 000000000..1e7f8145b --- /dev/null +++ b/packages/server-nest/src/modules/BankingTransactions/types/BankingTransactions.types.ts @@ -0,0 +1,122 @@ +import { Knex } from "knex"; +import { UncategorizedBankTransaction } from "../models/UncategorizedBankTransaction"; +import { BankTransaction } from "../models/BankTransaction"; + +export interface IPendingTransactionRemovingEventPayload { + uncategorizedTransactionId: number; + pendingTransaction: UncategorizedBankTransaction; + trx?: Knex.Transaction; +} + +export interface IPendingTransactionRemovedEventPayload { + uncategorizedTransactionId: number; + pendingTransaction: UncategorizedBankTransaction; + trx?: Knex.Transaction; +} + +export interface IGetRecognizedTransactionsQuery { + page?: number; + pageSize?: number; + accountId?: number; + minDate?: Date; + maxDate?: Date; + minAmount?: number; + maxAmount?: number; +} + +export interface ICashflowCommandDTO { + date: Date; + + transactionNumber: string; + referenceNo: string; + transactionType: string; + description: string; + + amount: number; + exchangeRate: number; + currencyCode: string; + + creditAccountId: number; + cashflowAccountId: number; + + publish: boolean; + branchId?: number; + plaidTransactionId?: string; +} + +export interface ICashflowNewCommandDTO extends ICashflowCommandDTO { + plaidAccountId?: string; + uncategorizedTransactionId?: number; +} + +export enum CashflowDirection { + IN = 'in', + OUT = 'out', +} + +export interface ICommandCashflowCreatingPayload { + trx: Knex.Transaction; + newTransactionDTO: ICashflowNewCommandDTO; +} + +export interface ICommandCashflowCreatedPayload { + newTransactionDTO: ICashflowNewCommandDTO; + cashflowTransaction: BankTransaction; + trx: Knex.Transaction; +} + +export interface ICommandCashflowDeletingPayload { + oldCashflowTransaction: BankTransaction; + trx: Knex.Transaction; +} + +export interface ICommandCashflowDeletedPayload { + cashflowTransactionId: number; + oldCashflowTransaction: BankTransaction; + trx: Knex.Transaction; +} + +export interface ICashflowTransactionCategorizedPayload { + uncategorizedTransactions: Array; + cashflowTransaction: BankTransaction; + oldUncategorizedTransactions: Array; + categorizeDTO: any; + trx: Knex.Transaction; +} + +export interface ICashflowTransactionUncategorizingPayload { + uncategorizedTransactionId: number; + oldUncategorizedTransactions: Array; + trx: Knex.Transaction; +} + +export interface ICashflowTransactionUncategorizedPayload { + uncategorizedTransactionId: number; + uncategorizedTransactions: Array; + oldMainUncategorizedTransaction: UncategorizedBankTransaction; + oldUncategorizedTransactions: Array; + trx: Knex.Transaction; +} + +export enum CashflowAction { + Create = 'Create', + Delete = 'Delete', + View = 'View', +} + +export interface CategorizeTransactionAsExpenseDTO { + expenseAccountId: number; + exchangeRate: number; + referenceNo: string; + description: string; + branchId?: number; +} + +export interface IGetUncategorizedTransactionsQuery { + page?: number; + pageSize?: number; + minDate?: Date; + maxDate?: Date; + minAmount?: number; + maxAmount?: number; +} diff --git a/packages/server-nest/src/modules/BankingTransactions/utils.ts b/packages/server-nest/src/modules/BankingTransactions/utils.ts new file mode 100644 index 000000000..89c113303 --- /dev/null +++ b/packages/server-nest/src/modules/BankingTransactions/utils.ts @@ -0,0 +1,124 @@ +import { upperFirst, camelCase, first, sumBy, isObject } from 'lodash'; +import { + CASHFLOW_TRANSACTION_TYPE, + CASHFLOW_TRANSACTION_TYPE_META, + CashflowTransactionTypes, + ERRORS, + ICashflowTransactionTypeMeta, + TransactionTypes, +} from './constants'; +import { ICashflowNewCommandDTO } from './types/BankingTransactions.types'; +import { UncategorizedBankTransaction } from './models/UncategorizedBankTransaction'; +import { ServiceError } from '../Items/ServiceError'; + +/** + * Ensures the given transaction type to transformed to appropriate format. + * @param {string} type + * @returns {string} + */ +export const transformCashflowTransactionType = (type) => { + return upperFirst(camelCase(type)); +}; + +/** + * Retrieve the cashflow transaction type meta. + * @param {CASHFLOW_TRANSACTION_TYPE} transactionType + * @returns {ICashflowTransactionTypeMeta} + */ +export function getCashflowTransactionType( + transactionType: CASHFLOW_TRANSACTION_TYPE, +): ICashflowTransactionTypeMeta { + const _transactionType = transformCashflowTransactionType(transactionType); + + return CASHFLOW_TRANSACTION_TYPE_META[_transactionType]; +} + +/** + * Retrieve cashflow accounts transactions types + * @returns {string} + */ +export const getCashflowAccountTransactionsTypes = () => { + return Object.values(CASHFLOW_TRANSACTION_TYPE_META).map((meta) => meta.type); +}; + +/** + * Tranasformes the given uncategorized transaction and categorized DTO + * to cashflow create DTO. + * @param {IUncategorizedCashflowTransaction} uncategorizeModel + * @param {ICategorizeCashflowTransactioDTO} categorizeDTO + * @returns {ICashflowNewCommandDTO} + */ +export const transformCategorizeTransToCashflow = ( + uncategorizeTransactions: Array, + categorizeDTO: ICategorizeBankTransactionDTO, +): ICashflowNewCommandDTO => { + const uncategorizeTransaction = first(uncategorizeTransactions); + const amount = sumBy(uncategorizeTransactions, 'amount'); + const amountAbs = Math.abs(amount); + + return { + date: categorizeDTO.date, + referenceNo: categorizeDTO.referenceNo, + description: categorizeDTO.description, + cashflowAccountId: uncategorizeTransaction.accountId, + creditAccountId: categorizeDTO.creditAccountId, + exchangeRate: categorizeDTO.exchangeRate || 1, + currencyCode: categorizeDTO.currencyCode, + amount: amountAbs, + transactionNumber: categorizeDTO.transactionNumber, + transactionType: categorizeDTO.transactionType, + branchId: categorizeDTO?.branchId, + publish: true, + }; +}; + +export const validateUncategorizedTransactionsNotExcluded = ( + transactions: Array, +) => { + const excluded = transactions.filter((tran) => tran.isExcluded); + + if (excluded?.length > 0) { + throw new ServiceError(ERRORS.CANNOT_CATEGORIZE_EXCLUDED_TRANSACTION, '', { + ids: excluded.map((t) => t.id), + }); + } +}; + +export const validateTransactionShouldBeCategorized = ( + uncategorizedTransaction: any, +) => { + if (!uncategorizedTransaction.categorized) { + throw new ServiceError(ERRORS.TRANSACTION_NOT_CATEGORIZED); + } +}; + +/** + * Retrieves the formatted type of account transaction. + * @param {string} referenceType + * @param {string} transactionType + * @returns {string} + */ +export const getTransactionTypeLabel = ( + referenceType: string, + transactionType?: string, +) => { + const _referenceType = upperFirst(camelCase(referenceType)); + const _transactionType = upperFirst(camelCase(transactionType)); + + return isObject(TransactionTypes[_referenceType]) + ? TransactionTypes[_referenceType][_transactionType] + : TransactionTypes[_referenceType] || null; +}; + +/** + * Retrieves the formatted type of cashflow transaction. + * @param {string} transactionType + * @returns {string¿} + */ +export const getCashflowTransactionFormattedType = ( + transactionType: string, +) => { + const _transactionType = upperFirst(camelCase(transactionType)); + + return CashflowTransactionTypes[_transactionType] || null; +}; diff --git a/packages/server-nest/src/modules/BankingTransactionsExclude/BankingTransactionsExclude.controller.ts b/packages/server-nest/src/modules/BankingTransactionsExclude/BankingTransactionsExclude.controller.ts new file mode 100644 index 000000000..fc3b04b63 --- /dev/null +++ b/packages/server-nest/src/modules/BankingTransactionsExclude/BankingTransactionsExclude.controller.ts @@ -0,0 +1,53 @@ +import { + Body, + Controller, + Delete, + Get, + Param, + Post, + Query, +} from '@nestjs/common'; +import { ExcludeBankTransactionsApplication } from './ExcludeBankTransactionsApplication'; +import { ExcludedBankTransactionsQuery } from './types/BankTransactionsExclude.types'; + +@Controller('banking/transactions') +export class BankingTransactionsExcludeController { + constructor( + private readonly excludeBankTransactionsApplication: ExcludeBankTransactionsApplication, + ) {} + + @Get() + public getExcludedBankTransactions( + @Query() query: ExcludedBankTransactionsQuery, + ) { + return this.excludeBankTransactionsApplication.getExcludedBankTransactions( + query, + ); + } + + @Post(':id/exclude') + public excludeBankTransaction(@Param('id') id: string) { + return this.excludeBankTransactionsApplication.excludeBankTransaction( + Number(id), + ); + } + + @Delete(':id/exclude') + public unexcludeBankTransaction(@Param('id') id: string) { + return this.excludeBankTransactionsApplication.unexcludeBankTransaction( + Number(id), + ); + } + + @Post('bulk/exclude') + public excludeBankTransactions(@Body('ids') ids: number[]) { + return this.excludeBankTransactionsApplication.excludeBankTransactions(ids); + } + + @Delete('bulk/exclude') + public unexcludeBankTransactions(@Body('ids') ids: number[]) { + return this.excludeBankTransactionsApplication.unexcludeBankTransactions( + ids, + ); + } +} diff --git a/packages/server-nest/src/modules/BankingTransactionsExclude/BankingTransactionsExclude.module.ts b/packages/server-nest/src/modules/BankingTransactionsExclude/BankingTransactionsExclude.module.ts new file mode 100644 index 000000000..4cde2b708 --- /dev/null +++ b/packages/server-nest/src/modules/BankingTransactionsExclude/BankingTransactionsExclude.module.ts @@ -0,0 +1,23 @@ +import { Module } from '@nestjs/common'; +import { ExcludeBankTransactionsApplication } from './ExcludeBankTransactionsApplication'; +import { ExcludeBankTransactionService } from './commands/ExcludeBankTransaction.service'; +import { UnexcludeBankTransactionService } from './commands/UnexcludeBankTransaction.service'; +import { GetExcludedBankTransactionsService } from './queries/GetExcludedBankTransactions'; +import { ExcludeBankTransactionsService } from './commands/ExcludeBankTransactions.service'; +import { UnexcludeBankTransactionsService } from './commands/UnexcludeBankTransactions.service'; +import { DecrementUncategorizedTransactionOnExclude } from './subscribers/DecrementUncategorizedTransactionOnExclude'; +import { BankingTransactionsExcludeController } from './BankingTransactionsExclude.controller'; + +@Module({ + providers: [ + ExcludeBankTransactionsApplication, + ExcludeBankTransactionService, + UnexcludeBankTransactionService, + GetExcludedBankTransactionsService, + ExcludeBankTransactionsService, + UnexcludeBankTransactionsService, + DecrementUncategorizedTransactionOnExclude + ], + controllers: [BankingTransactionsExcludeController], +}) +export class BankingTransactionsExcludeModule {} diff --git a/packages/server-nest/src/modules/BankingTransactionsExclude/ExcludeBankTransactionsApplication.ts b/packages/server-nest/src/modules/BankingTransactionsExclude/ExcludeBankTransactionsApplication.ts new file mode 100644 index 000000000..b0aa1fd6b --- /dev/null +++ b/packages/server-nest/src/modules/BankingTransactionsExclude/ExcludeBankTransactionsApplication.ts @@ -0,0 +1,77 @@ +import { ExcludeBankTransactionService } from './commands/ExcludeBankTransaction.service'; +import { UnexcludeBankTransactionService } from './commands/UnexcludeBankTransaction.service'; +import { GetExcludedBankTransactionsService } from './queries/GetExcludedBankTransactions'; +import { ExcludedBankTransactionsQuery } from './types/BankTransactionsExclude.types'; +import { UnexcludeBankTransactionsService } from './commands/UnexcludeBankTransactions.service'; +import { ExcludeBankTransactionsService } from './commands/ExcludeBankTransactions.service'; +import { Injectable } from '@nestjs/common'; + +@Injectable() +export class ExcludeBankTransactionsApplication { + constructor( + private readonly excludeBankTransactionService: ExcludeBankTransactionService, + private readonly unexcludeBankTransactionService: UnexcludeBankTransactionService, + private readonly getExcludedBankTransactionsService: GetExcludedBankTransactionsService, + private readonly excludeBankTransactionsService: ExcludeBankTransactionsService, + private readonly unexcludeBankTransactionsService: UnexcludeBankTransactionsService, + ) {} + + /** + * Marks a bank transaction as excluded. + * @param {number} bankTransactionId - The ID of the bank transaction to exclude. + * @returns {Promise} + */ + public excludeBankTransaction(bankTransactionId: number) { + return this.excludeBankTransactionService.excludeBankTransaction( + bankTransactionId, + ); + } + + /** + * Marks a bank transaction as not excluded. + * @param {number} bankTransactionId - The ID of the bank transaction to exclude. + * @returns {Promise} + */ + public unexcludeBankTransaction(bankTransactionId: number) { + return this.unexcludeBankTransactionService.unexcludeBankTransaction( + bankTransactionId, + ); + } + + /** + * Retrieves the excluded bank transactions. + * @param {ExcludedBankTransactionsQuery} filter + * @returns {} + */ + public getExcludedBankTransactions(filter: ExcludedBankTransactionsQuery) { + return this.getExcludedBankTransactionsService.getExcludedBankTransactions( + filter, + ); + } + + /** + * Exclude the given bank transactions in bulk. + * @param {Array | number} bankTransactionIds + * @returns {Promise} + */ + public excludeBankTransactions( + bankTransactionIds: Array | number, + ): Promise { + return this.excludeBankTransactionsService.excludeBankTransactions( + bankTransactionIds, + ); + } + + /** + * Exclude the given bank transactions in bulk. + * @param {Array | number} bankTransactionIds + * @returns {Promise} + */ + public unexcludeBankTransactions( + bankTransactionIds: Array | number, + ): Promise { + return this.unexcludeBankTransactionsService.unexcludeBankTransactions( + bankTransactionIds, + ); + } +} diff --git a/packages/server-nest/src/modules/BankingTransactionsExclude/commands/ExcludeBankTransaction.service.ts b/packages/server-nest/src/modules/BankingTransactionsExclude/commands/ExcludeBankTransaction.service.ts new file mode 100644 index 000000000..7c3621fa0 --- /dev/null +++ b/packages/server-nest/src/modules/BankingTransactionsExclude/commands/ExcludeBankTransaction.service.ts @@ -0,0 +1,63 @@ +import { Knex } from 'knex'; +import { Inject, Injectable } from '@nestjs/common'; +import { UncategorizedBankTransaction } from '@/modules/BankingTransactions/models/UncategorizedBankTransaction'; +import { + validateTransactionNotCategorized, + validateTransactionNotExcluded, +} from './utils'; +import { + IBankTransactionUnexcludedEventPayload, + IBankTransactionUnexcludingEventPayload, +} from '../types/BankTransactionsExclude.types'; +import { UnitOfWork } from '@/modules/Tenancy/TenancyDB/UnitOfWork.service'; +import { EventEmitter2 } from '@nestjs/event-emitter'; +import { events } from '@/common/events/events'; + +@Injectable() +export class ExcludeBankTransactionService { + constructor( + @Inject(UncategorizedBankTransaction.name) + private uncategorizedBankTransactionModel: typeof UncategorizedBankTransaction, + + private uow: UnitOfWork, + private eventEmitter: EventEmitter2, + ) {} + + /** + * Marks the given bank transaction as excluded. + * @param {number} uncategorizedTransactionId - Uncategorized bank transaction identifier. + * @returns {Promise} + */ + public async excludeBankTransaction(uncategorizedTransactionId: number) { + const oldUncategorizedTransaction = + await this.uncategorizedBankTransactionModel + .query() + .findById(uncategorizedTransactionId) + .throwIfNotFound(); + + // Validate the transaction shouldn't be excluded. + validateTransactionNotExcluded(oldUncategorizedTransaction); + + // Validate the transaction shouldn't be categorized. + validateTransactionNotCategorized(oldUncategorizedTransaction); + + return this.uow.withTransaction(async (trx: Knex.Transaction) => { + await this.eventEmitter.emitAsync(events.bankTransactions.onExcluding, { + uncategorizedTransactionId, + trx, + } as IBankTransactionUnexcludingEventPayload); + + await this.uncategorizedBankTransactionModel + .query(trx) + .findById(uncategorizedTransactionId) + .patch({ + excludedAt: new Date(), + }); + + await this.eventEmitter.emitAsync(events.bankTransactions.onExcluded, { + uncategorizedTransactionId, + trx, + } as IBankTransactionUnexcludedEventPayload); + }); + } +} diff --git a/packages/server-nest/src/modules/BankingTransactionsExclude/commands/ExcludeBankTransactions.service.ts b/packages/server-nest/src/modules/BankingTransactionsExclude/commands/ExcludeBankTransactions.service.ts new file mode 100644 index 000000000..e9d88da1f --- /dev/null +++ b/packages/server-nest/src/modules/BankingTransactionsExclude/commands/ExcludeBankTransactions.service.ts @@ -0,0 +1,30 @@ +import PromisePool from '@supercharge/promise-pool'; +import { castArray, uniq } from 'lodash'; +import { ExcludeBankTransactionService } from './ExcludeBankTransaction.service'; +import { Injectable } from '@nestjs/common'; + +@Injectable() +export class ExcludeBankTransactionsService { + constructor( + private readonly excludeBankTransaction: ExcludeBankTransactionService, + ) {} + + /** + * Exclude bank transactions in bulk. + * @param {Array | number} bankTransactionIds - The IDs of the bank transactions to exclude. + * @returns {Promise} + */ + public async excludeBankTransactions( + bankTransactionIds: Array | number, + ) { + const _bankTransactionIds = uniq(castArray(bankTransactionIds)); + + await PromisePool.withConcurrency(1) + .for(_bankTransactionIds) + .process((bankTransactionId: number) => { + return this.excludeBankTransaction.excludeBankTransaction( + bankTransactionId, + ); + }); + } +} diff --git a/packages/server-nest/src/modules/BankingTransactionsExclude/commands/UnexcludeBankTransaction.service.ts b/packages/server-nest/src/modules/BankingTransactionsExclude/commands/UnexcludeBankTransaction.service.ts new file mode 100644 index 000000000..c78e83704 --- /dev/null +++ b/packages/server-nest/src/modules/BankingTransactionsExclude/commands/UnexcludeBankTransaction.service.ts @@ -0,0 +1,64 @@ +import { Knex } from 'knex'; +import { + validateTransactionNotCategorized, + validateTransactionShouldBeExcluded, +} from './utils'; +import { + IBankTransactionExcludedEventPayload, + IBankTransactionExcludingEventPayload, +} from '../types/BankTransactionsExclude.types'; +import { EventEmitter2 } from '@nestjs/event-emitter'; +import { Inject, Injectable } from '@nestjs/common'; +import { UncategorizedBankTransaction } from '@/modules/BankingTransactions/models/UncategorizedBankTransaction'; +import { UnitOfWork } from '@/modules/Tenancy/TenancyDB/UnitOfWork.service'; +import { events } from '@/common/events/events'; + +@Injectable() +export class UnexcludeBankTransactionService { + constructor( + private readonly eventEmitter: EventEmitter2, + private readonly uow: UnitOfWork, + + @Inject(UncategorizedBankTransaction.name) + private readonly uncategorizedBankTransactionModel: typeof UncategorizedBankTransaction, + ) {} + + /** + * Marks the given bank transaction as excluded. + * @param {number} tenantId + * @param {number} bankTransactionId + * @returns {Promise} + */ + public async unexcludeBankTransaction( + uncategorizedTransactionId: number, + ): Promise { + const oldUncategorizedTransaction = + await this.uncategorizedBankTransactionModel + .query() + .findById(uncategorizedTransactionId) + .throwIfNotFound(); + + // Validate the transaction should be excludded. + validateTransactionShouldBeExcluded(oldUncategorizedTransaction); + + // Validate the transaction shouldn't be categorized. + validateTransactionNotCategorized(oldUncategorizedTransaction); + + return this.uow.withTransaction(async (trx: Knex.Transaction) => { + await this.eventEmitter.emitAsync(events.bankTransactions.onUnexcluding, { + uncategorizedTransactionId, + } as IBankTransactionExcludingEventPayload); + + await this.uncategorizedBankTransactionModel + .query(trx) + .findById(uncategorizedTransactionId) + .patch({ + excludedAt: null, + }); + + await this.eventEmitter.emitAsync(events.bankTransactions.onUnexcluded, { + uncategorizedTransactionId, + } as IBankTransactionExcludedEventPayload); + }); + } +} diff --git a/packages/server-nest/src/modules/BankingTransactionsExclude/commands/UnexcludeBankTransactions.service.ts b/packages/server-nest/src/modules/BankingTransactionsExclude/commands/UnexcludeBankTransactions.service.ts new file mode 100644 index 000000000..357f7e485 --- /dev/null +++ b/packages/server-nest/src/modules/BankingTransactionsExclude/commands/UnexcludeBankTransactions.service.ts @@ -0,0 +1,29 @@ +import { Injectable } from '@nestjs/common'; +import { PromisePool } from '@supercharge/promise-pool'; +import { castArray, uniq } from 'lodash'; +import { UnexcludeBankTransactionService } from './UnexcludeBankTransaction.service'; + +@Injectable() +export class UnexcludeBankTransactionsService { + constructor( + private readonly unexcludeBankTransaction: UnexcludeBankTransactionService, + ) {} + + /** + * Unexclude bank transactions in bulk. + * @param {Array | number} bankTransactionIds - The IDs of the bank transactions to unexclude. + */ + public async unexcludeBankTransactions( + bankTransactionIds: Array | number + ) { + const _bankTransactionIds = uniq(castArray(bankTransactionIds)); + + await PromisePool.withConcurrency(1) + .for(_bankTransactionIds) + .process((bankTransactionId: number) => { + return this.unexcludeBankTransaction.unexcludeBankTransaction( + bankTransactionId + ); + }); + } +} diff --git a/packages/server-nest/src/modules/BankingTransactionsExclude/commands/utils.ts b/packages/server-nest/src/modules/BankingTransactionsExclude/commands/utils.ts new file mode 100644 index 000000000..67fe62869 --- /dev/null +++ b/packages/server-nest/src/modules/BankingTransactionsExclude/commands/utils.ts @@ -0,0 +1,32 @@ +import { UncategorizedBankTransaction } from "@/modules/BankingTransactions/models/UncategorizedBankTransaction"; +import { ServiceError } from "@/modules/Items/ServiceError"; + +const ERRORS = { + TRANSACTION_ALREADY_CATEGORIZED: 'TRANSACTION_ALREADY_CATEGORIZED', + TRANSACTION_ALREADY_EXCLUDED: 'TRANSACTION_ALREADY_EXCLUDED', + TRANSACTION_NOT_EXCLUDED: 'TRANSACTION_NOT_EXCLUDED', +}; + +export const validateTransactionNotCategorized = ( + transaction: UncategorizedBankTransaction +) => { + if (transaction.categorized) { + throw new ServiceError(ERRORS.TRANSACTION_ALREADY_CATEGORIZED); + } +}; + +export const validateTransactionNotExcluded = ( + transaction: UncategorizedBankTransaction +) => { + if (transaction.isExcluded) { + throw new ServiceError(ERRORS.TRANSACTION_ALREADY_EXCLUDED); + } +}; + +export const validateTransactionShouldBeExcluded = ( + transaction: UncategorizedBankTransaction +) => { + if (!transaction.isExcluded) { + throw new ServiceError(ERRORS.TRANSACTION_NOT_EXCLUDED); + } +}; diff --git a/packages/server-nest/src/modules/BankingTransactionsExclude/queries/GetExcludedBankTransactions.ts b/packages/server-nest/src/modules/BankingTransactionsExclude/queries/GetExcludedBankTransactions.ts new file mode 100644 index 000000000..b67b29e7b --- /dev/null +++ b/packages/server-nest/src/modules/BankingTransactionsExclude/queries/GetExcludedBankTransactions.ts @@ -0,0 +1,60 @@ +import { Inject, Injectable } from '@nestjs/common'; +import { TransformerInjectable } from '@/modules/Transformer/TransformerInjectable.service'; +import { ExcludedBankTransactionsQuery } from '../types/BankTransactionsExclude.types'; +import { UncategorizedTransactionTransformer } from '@/modules/BankingCategorize/commands/UncategorizedTransaction.transformer'; +import { UncategorizedBankTransaction } from '@/modules/BankingTransactions/models/UncategorizedBankTransaction'; + +@Injectable() +export class GetExcludedBankTransactionsService { + constructor( + private readonly transformer: TransformerInjectable, + + @Inject(UncategorizedBankTransaction.name) + private readonly uncategorizedBankTransaction: typeof UncategorizedBankTransaction, + ) {} + + /** + * Retrieves the excluded uncategorized bank transactions. + * @param {ExcludedBankTransactionsQuery} filter + * @returns + */ + public async getExcludedBankTransactions( + filter: ExcludedBankTransactionsQuery, + ) { + // Parsed query with default values. + const _query = { + page: 1, + pageSize: 20, + ...filter, + }; + const { results, pagination } = await this.uncategorizedBankTransaction + .query() + .onBuild((q) => { + q.modify('excluded'); + q.orderBy('date', 'DESC'); + + if (_query.accountId) { + q.where('account_id', _query.accountId); + } + if (_query.minDate) { + q.modify('fromDate', _query.minDate); + } + if (_query.maxDate) { + q.modify('toDate', _query.maxDate); + } + if (_query.minAmount) { + q.modify('minAmount', _query.minAmount); + } + if (_query.maxAmount) { + q.modify('maxAmount', _query.maxAmount); + } + }) + .pagination(_query.page - 1, _query.pageSize); + + const data = await this.transformer.transform( + results, + new UncategorizedTransactionTransformer(), + ); + return { data, pagination }; + } +} diff --git a/packages/server-nest/src/modules/BankingTransactionsExclude/subscribers/DecrementUncategorizedTransactionOnExclude.ts b/packages/server-nest/src/modules/BankingTransactionsExclude/subscribers/DecrementUncategorizedTransactionOnExclude.ts new file mode 100644 index 000000000..be1c731e3 --- /dev/null +++ b/packages/server-nest/src/modules/BankingTransactionsExclude/subscribers/DecrementUncategorizedTransactionOnExclude.ts @@ -0,0 +1,56 @@ +import { OnEvent } from '@nestjs/event-emitter'; +import { Inject, Injectable } from '@nestjs/common'; +import { + IBankTransactionExcludedEventPayload, + IBankTransactionUnexcludedEventPayload, +} from '../types/BankTransactionsExclude.types'; +import { Account } from '@/modules/Accounts/models/Account.model'; +import { UncategorizedBankTransaction } from '@/modules/BankingTransactions/models/UncategorizedBankTransaction'; +import { events } from '@/common/events/events'; + +@Injectable() +export class DecrementUncategorizedTransactionOnExclude { + constructor( + @Inject(Account.name) + private readonly account: typeof Account, + + @Inject(UncategorizedBankTransaction.name) + private readonly uncategorizedBankTransaction: typeof UncategorizedBankTransaction, + ) {} + + /** + * Validates the cashflow transaction whether matched with bank transaction on deleting. + * @param {IManualJournalDeletingPayload} + */ + @OnEvent(events.bankTransactions.onExcluded) + public async decrementUnCategorizedTransactionsOnExclude({ + uncategorizedTransactionId, + trx, + }: IBankTransactionExcludedEventPayload) { + const transaction = await this.uncategorizedBankTransaction.query( + trx + ).findById(uncategorizedTransactionId); + + await this.account.query(trx) + .findById(transaction.accountId) + .decrement('uncategorizedTransactions', 1); + } + + /** + * Validates the cashflow transaction whether matched with bank transaction on deleting. + * @param {IManualJournalDeletingPayload} + */ + @OnEvent(events.bankTransactions.onUnexcluded) + public async incrementUnCategorizedTransactionsOnUnexclude({ + uncategorizedTransactionId, + trx, + }: IBankTransactionUnexcludedEventPayload) { + const transaction = await this.uncategorizedBankTransaction.query().findById( + uncategorizedTransactionId + ); + // + await this.account.query(trx) + .findById(transaction.accountId) + .increment('uncategorizedTransactions', 1); + } +} diff --git a/packages/server-nest/src/modules/BankingTransactionsExclude/types/BankTransactionsExclude.types.ts b/packages/server-nest/src/modules/BankingTransactionsExclude/types/BankTransactionsExclude.types.ts new file mode 100644 index 000000000..4bd23b98a --- /dev/null +++ b/packages/server-nest/src/modules/BankingTransactionsExclude/types/BankTransactionsExclude.types.ts @@ -0,0 +1,30 @@ +import { Knex } from "knex"; + +export interface ExcludedBankTransactionsQuery { + page?: number; + pageSize?: number; + accountId?: number; + minDate?: Date; + maxDate?: Date; + minAmount?: number; + maxAmount?: number; +} + +export interface IBankTransactionUnexcludingEventPayload { + uncategorizedTransactionId: number; + trx?: Knex.Transaction +} + +export interface IBankTransactionUnexcludedEventPayload { + uncategorizedTransactionId: number; + trx?: Knex.Transaction +} + +export interface IBankTransactionExcludingEventPayload { + uncategorizedTransactionId: number; + trx?: Knex.Transaction +} +export interface IBankTransactionExcludedEventPayload { + uncategorizedTransactionId: number; + trx?: Knex.Transaction +} diff --git a/packages/server-nest/src/modules/BankingTransactionsExclude/utils.ts b/packages/server-nest/src/modules/BankingTransactionsExclude/utils.ts new file mode 100644 index 000000000..f07be91ec --- /dev/null +++ b/packages/server-nest/src/modules/BankingTransactionsExclude/utils.ts @@ -0,0 +1,31 @@ +import { UncategorizedBankTransaction } from '../BankingTransactions/models/UncategorizedBankTransaction'; +import { ServiceError } from '../Items/ServiceError'; + +const ERRORS = { TRANSACTION_ALREADY_CATEGORIZED: 'TRANSACTION_ALREADY_CATEGORIZED', + TRANSACTION_ALREADY_EXCLUDED: 'TRANSACTION_ALREADY_EXCLUDED', + TRANSACTION_NOT_EXCLUDED: 'TRANSACTION_NOT_EXCLUDED', +}; + +export const validateTransactionNotCategorized = ( + transaction: UncategorizedBankTransaction, +) => { + if (transaction.categorized) { + throw new ServiceError(ERRORS.TRANSACTION_ALREADY_CATEGORIZED); + } +}; + +export const validateTransactionNotExcluded = ( + transaction: UncategorizedBankTransaction, +) => { + if (transaction.isExcluded) { + throw new ServiceError(ERRORS.TRANSACTION_ALREADY_EXCLUDED); + } +}; + +export const validateTransactionShouldBeExcluded = ( + transaction: UncategorizedBankTransaction, +) => { + if (!transaction.isExcluded) { + throw new ServiceError(ERRORS.TRANSACTION_NOT_EXCLUDED); + } +}; diff --git a/packages/server-nest/src/modules/Bills/models/Bill.ts b/packages/server-nest/src/modules/Bills/models/Bill.ts index 01793f558..882622da8 100644 --- a/packages/server-nest/src/modules/Bills/models/Bill.ts +++ b/packages/server-nest/src/modules/Bills/models/Bill.ts @@ -470,125 +470,129 @@ export class Bill extends BaseModel { /** * Relationship mapping. */ - // static get relationMappings() { - // const Vendor = require('models/Vendor'); - // const ItemEntry = require('models/ItemEntry'); - // const BillLandedCost = require('models/BillLandedCost'); - // const Branch = require('models/Branch'); - // const Warehouse = require('models/Warehouse'); - // const TaxRateTransaction = require('models/TaxRateTransaction'); - // const Document = require('models/Document'); - // const { MatchedBankTransaction } = require('models/MatchedBankTransaction'); + static get relationMappings() { + const { Vendor } = require('../../Vendors/models/Vendor'); + const { + ItemEntry, + } = require('../../TransactionItemEntry/models/ItemEntry'); + const { + BillLandedCost, + } = require('../../BillLandedCosts/models/BillLandedCost'); + const { Branch } = require('../../Branches/models/Branch.model'); + const { Warehouse } = require('../../Warehouses/models/Warehouse.model'); + const { TaxRateModel } = require('../../TaxRates/models/TaxRate.model'); + const { Document } = require('../../ChromiumlyTenancy/models/Document'); + // const { MatchedBankTransaction } = require('models/MatchedBankTransaction'); - // return { - // vendor: { - // relation: Model.BelongsToOneRelation, - // modelClass: Vendor.default, - // join: { - // from: 'bills.vendorId', - // to: 'contacts.id', - // }, - // filter(query) { - // query.where('contact_service', 'vendor'); - // }, - // }, + return { + vendor: { + relation: Model.BelongsToOneRelation, + modelClass: Vendor, + join: { + from: 'bills.vendorId', + to: 'contacts.id', + }, + filter(query) { + query.where('contact_service', 'vendor'); + }, + }, - // entries: { - // relation: Model.HasManyRelation, - // modelClass: ItemEntry.default, - // join: { - // from: 'bills.id', - // to: 'items_entries.referenceId', - // }, - // filter(builder) { - // builder.where('reference_type', 'Bill'); - // builder.orderBy('index', 'ASC'); - // }, - // }, + entries: { + relation: Model.HasManyRelation, + modelClass: ItemEntry, + join: { + from: 'bills.id', + to: 'items_entries.referenceId', + }, + filter(builder) { + builder.where('reference_type', 'Bill'); + builder.orderBy('index', 'ASC'); + }, + }, - // locatedLandedCosts: { - // relation: Model.HasManyRelation, - // modelClass: BillLandedCost.default, - // join: { - // from: 'bills.id', - // to: 'bill_located_costs.billId', - // }, - // }, + locatedLandedCosts: { + relation: Model.HasManyRelation, + modelClass: BillLandedCost, + join: { + from: 'bills.id', + to: 'bill_located_costs.billId', + }, + }, - // /** - // * Bill may belongs to associated branch. - // */ - // branch: { - // relation: Model.BelongsToOneRelation, - // modelClass: Branch.default, - // join: { - // from: 'bills.branchId', - // to: 'branches.id', - // }, - // }, + /** + * Bill may belongs to associated branch. + */ + branch: { + relation: Model.BelongsToOneRelation, + modelClass: Branch, + join: { + from: 'bills.branchId', + to: 'branches.id', + }, + }, - // /** - // * Bill may has associated warehouse. - // */ - // warehouse: { - // relation: Model.BelongsToOneRelation, - // modelClass: Warehouse.default, - // join: { - // from: 'bills.warehouseId', - // to: 'warehouses.id', - // }, - // }, + /** + * Bill may has associated warehouse. + */ + warehouse: { + relation: Model.BelongsToOneRelation, + modelClass: Warehouse, + join: { + from: 'bills.warehouseId', + to: 'warehouses.id', + }, + }, - // /** - // * Bill may has associated tax rate transactions. - // */ - // taxes: { - // relation: Model.HasManyRelation, - // modelClass: TaxRateTransaction.default, - // join: { - // from: 'bills.id', - // to: 'tax_rate_transactions.referenceId', - // }, - // filter(builder) { - // builder.where('reference_type', 'Bill'); - // }, - // }, + /** + * Bill may has associated tax rate transactions. + */ + taxes: { + relation: Model.HasManyRelation, + modelClass: TaxRateModel, + join: { + from: 'bills.id', + to: 'tax_rate_transactions.referenceId', + }, + filter(builder) { + builder.where('reference_type', 'Bill'); + }, + }, - // /** - // * Bill may has many attached attachments. - // */ - // attachments: { - // relation: Model.ManyToManyRelation, - // modelClass: Document.default, - // join: { - // from: 'bills.id', - // through: { - // from: 'document_links.modelId', - // to: 'document_links.documentId', - // }, - // to: 'documents.id', - // }, - // filter(query) { - // query.where('model_ref', 'Bill'); - // }, - // }, + /** + * Bill may has many attached attachments. + */ + attachments: { + relation: Model.ManyToManyRelation, + modelClass: Document, + join: { + from: 'bills.id', + through: { + from: 'document_links.modelId', + to: 'document_links.documentId', + }, + to: 'documents.id', + }, + filter(query) { + query.where('model_ref', 'Bill'); + }, + }, - // /** - // * Bill may belongs to matched bank transaction. - // */ - // matchedBankTransaction: { - // relation: Model.HasManyRelation, - // modelClass: MatchedBankTransaction, - // join: { - // from: 'bills.id', - // to: 'matched_bank_transactions.referenceId', - // }, - // filter(query) { - // query.where('reference_type', 'Bill'); - // }, - // }, - // }; - // } + /** + * Bill may belongs to matched bank transaction. + */ + // matchedBankTransaction: { + // relation: Model.HasManyRelation, + // modelClass: MatchedBankTransaction, + // join: { + // from: 'bills.id', + // to: 'matched_bank_transactions.referenceId', + // }, + // filter(query) { + // query.where('reference_type', 'Bill'); + // }, + // }, + }; + } /** * Retrieve the not found bills ids as array that associated to the given vendor. diff --git a/packages/server-nest/src/modules/Import/Importable.ts b/packages/server-nest/src/modules/Import/Importable.ts index 9cb82b56c..7a6c4d9e8 100644 --- a/packages/server-nest/src/modules/Import/Importable.ts +++ b/packages/server-nest/src/modules/Import/Importable.ts @@ -9,7 +9,7 @@ export abstract class Importable { * @param {any} createDTO * @param {Knex.Transaction} trx */ - public importable(tenantId: number, createDTO: any, trx?: Knex.Transaction) { + public importable(createDTO: any, trx?: Knex.Transaction) { throw new Error( 'The `importable` function is not defined in service importable.' ); @@ -58,7 +58,6 @@ export abstract class Importable { * @returns {Promise} - True means passed and false failed. */ public async validateParams( - tenantId: number, params: Record ): Promise {} diff --git a/packages/server-nest/src/modules/Import/ImportableResources.ts b/packages/server-nest/src/modules/Import/ImportableResources.ts index 8635ebd29..b6b61423b 100644 --- a/packages/server-nest/src/modules/Import/ImportableResources.ts +++ b/packages/server-nest/src/modules/Import/ImportableResources.ts @@ -1,7 +1,7 @@ import Container, { Service } from 'typedi'; import { AccountsImportable } from '../Accounts/AccountsImportable'; import { ImportableRegistry } from './ImportableRegistry'; -import { UncategorizedTransactionsImportable } from '../Cashflow/UncategorizedTransactionsImportable'; +import { UncategorizedTransactionsImportable } from '../BankingCategorize/commands/UncategorizedTransactionsImportable'; import { CustomersImportable } from '../Contacts/Customers/CustomersImportable'; import { VendorsImportable } from '../Contacts/Vendors/VendorsImportable'; import { ItemsImportable } from '../Items/ItemsImportable'; diff --git a/packages/server-nest/src/modules/Import/interfaces.ts b/packages/server-nest/src/modules/Import/interfaces.ts index 65d53ff59..5fb89f3fe 100644 --- a/packages/server-nest/src/modules/Import/interfaces.ts +++ b/packages/server-nest/src/modules/Import/interfaces.ts @@ -1,5 +1,4 @@ -import { IModelMetaField, IModelMetaField2 } from '@/interfaces'; -import Import from '@/models/Import'; +import { IModelMetaField2 } from "@/interfaces/Model"; export interface ImportMappingAttr { from: string; diff --git a/packages/server-nest/src/modules/Items/Item.controller.ts b/packages/server-nest/src/modules/Items/Item.controller.ts index 6abc6a214..f8b876850 100644 --- a/packages/server-nest/src/modules/Items/Item.controller.ts +++ b/packages/server-nest/src/modules/Items/Item.controller.ts @@ -8,6 +8,7 @@ import { UseGuards, Patch, Get, + Put, } from '@nestjs/common'; import { ZodValidationPipe } from '@/common/pipes/ZodValidation.pipe'; import { createItemSchema } from './Item.schema'; @@ -38,7 +39,7 @@ export class ItemsController extends TenantController { * @param editItemDto - The item DTO. * @returns The updated item id. */ - @Post(':id') + @Put(':id') @UsePipes(new ZodValidationPipe(createItemSchema)) async editItem( @Param('id') id: string, @@ -75,6 +76,8 @@ export class ItemsController extends TenantController { */ @Patch(':id/inactivate') async inactivateItem(@Param('id') id: string): Promise { + console.log(id, 'XXXXXX'); + const itemId = parseInt(id, 10); return this.itemsApplication.inactivateItem(itemId); } diff --git a/packages/server-nest/src/modules/Plaid/Plaid.module.ts b/packages/server-nest/src/modules/Plaid/Plaid.module.ts new file mode 100644 index 000000000..0ebc49514 --- /dev/null +++ b/packages/server-nest/src/modules/Plaid/Plaid.module.ts @@ -0,0 +1,29 @@ +import { Module } from '@nestjs/common'; +import { ConfigService } from '@nestjs/config'; +import { Configuration, PlaidApi, PlaidEnvironments } from 'plaid'; + +export const PLAID_CLIENT = 'PLAID_CLIENT'; + +@Module({ + providers: [ + { + provide: PLAID_CLIENT, + useFactory: (configService: ConfigService) => { + const configuration = new Configuration({ + basePath: PlaidEnvironments[configService.get('plaid.env')], + baseOptions: { + headers: { + 'PLAID-CLIENT-ID': configService.get('plaid.clientId'), + 'PLAID-SECRET': configService.get('plaid.secret'), + 'Plaid-Version': '2020-09-14', + }, + }, + }); + return new PlaidApi(configuration); + }, + inject: [ConfigService], + }, + ], + exports: [PLAID_CLIENT], +}) +export class PlaidModule {} diff --git a/packages/server-nest/src/modules/System/SystemModels/SystemModels.module.ts b/packages/server-nest/src/modules/System/SystemModels/SystemModels.module.ts index 1a55ebbe3..5bfe42bfa 100644 --- a/packages/server-nest/src/modules/System/SystemModels/SystemModels.module.ts +++ b/packages/server-nest/src/modules/System/SystemModels/SystemModels.module.ts @@ -17,6 +17,11 @@ const modelProviders = models.map((model) => { }; }); +export const InjectSystemModel = (model: typeof Model) => ({ + value: model, + provide: model.name, +}); + const providers = [ ...modelProviders, { @@ -25,7 +30,7 @@ const providers = [ useFactory: async (systemKnex: Knex) => { Model.knex(systemKnex); }, - } + }, ]; @Global() diff --git a/packages/server-nest/src/modules/Tenancy/TenancyModels/Tenancy.module.ts b/packages/server-nest/src/modules/Tenancy/TenancyModels/Tenancy.module.ts index 0b373f933..cadeff046 100644 --- a/packages/server-nest/src/modules/Tenancy/TenancyModels/Tenancy.module.ts +++ b/packages/server-nest/src/modules/Tenancy/TenancyModels/Tenancy.module.ts @@ -38,6 +38,8 @@ import { RefundCreditNote } from '@/modules/CreditNoteRefunds/models/RefundCredi import { VendorCredit } from '@/modules/VendorCredit/models/VendorCredit'; import { RefundVendorCredit } from '@/modules/VendorCreditsRefund/models/RefundVendorCredit'; import { PaymentReceived } from '@/modules/PaymentReceived/models/PaymentReceived'; +import { BaseModel } from '@/models/Model'; +import { Model } from 'objection'; const models = [ Item, @@ -79,16 +81,23 @@ const models = [ PaymentReceivedEntry ]; -const modelProviders = models.map((model) => { +/** + * Decorator factory that registers a model with the tenancy system. + * @param model The model class to register + */ +export function RegisterTenancyModel(model: typeof Model) { return { provide: model.name, - inject: [TENANCY_DB_CONNECTION], + inject: [TENANCY_DB_CONNECTION], scope: Scope.REQUEST, useFactory: async (tenantKnex: Knex) => { return model.bindKnex(tenantKnex); - }, + } }; -}); +} + +// Register all models using the decorator +const modelProviders = models.map((model) => RegisterTenancyModel(model)); @Global() @Module({ diff --git a/packages/server-nest/src/utils/transform-to-map-by.ts b/packages/server-nest/src/utils/transform-to-map-by.ts new file mode 100644 index 000000000..39fb3edda --- /dev/null +++ b/packages/server-nest/src/utils/transform-to-map-by.ts @@ -0,0 +1,5 @@ +import { groupBy } from 'lodash'; + +export const transformToMapBy = (collection, key) => { + return new Map(Object.entries(groupBy(collection, key))); +}; diff --git a/packages/server-nest/test/items.e2e-spec.ts b/packages/server-nest/test/items.e2e-spec.ts index 185037f85..d76cc9d4a 100644 --- a/packages/server-nest/test/items.e2e-spec.ts +++ b/packages/server-nest/test/items.e2e-spec.ts @@ -2,42 +2,39 @@ import * as request from 'supertest'; import { faker } from '@faker-js/faker'; import { app } from './init-app-test'; +const makeItemRequest = () => ({ + name: faker.commerce.productName(), + type: 'service', +}); + describe('Items (e2e)', () => { it('/items (POST)', () => { return request(app.getHttpServer()) .post('/items') .set('organization-id', '4064541lv40nhca') - .send({ - name: faker.commerce.productName(), - type: 'service', - }) + .send(makeItemRequest()) .expect(201); }); - it('/items/:id (POST)', async () => { - const item = { - name: faker.commerce.productName(), - type: 'service', - }; - return request(app.getHttpServer()) + it('/items/:id (PUT)', async () => { + const response = await request(app.getHttpServer()) .post('/items') .set('organization-id', '4064541lv40nhca') - .send({ - name: faker.commerce.productName(), - type: 'service', - }) - .expect(201); + .send(makeItemRequest()); + const itemId = response.body.id; + + return request(app.getHttpServer()) + .put(`/items/${itemId}`) + .set('organization-id', '4064541lv40nhca') + .send(makeItemRequest()); }); it('/items/:id/inactivate (PATCH)', async () => { const response = await request(app.getHttpServer()) .post('/items') .set('organization-id', '4064541lv40nhca') - .send({ - name: faker.commerce.productName(), - type: 'service', - }); - const itemId = response.body.id; + .send(makeItemRequest()); + const itemId = response.text; return request(app.getHttpServer()) .patch(`/items/${itemId}/inactivate`) @@ -49,15 +46,81 @@ describe('Items (e2e)', () => { const response = await request(app.getHttpServer()) .post('/items') .set('organization-id', '4064541lv40nhca') - .send({ - name: faker.commerce.productName(), - type: 'service', - }); - const itemId = response.body.id; + .send(makeItemRequest()); + const itemId = response.text; return request(app.getHttpServer()) .patch(`/items/${itemId}/activate`) .set('organization-id', '4064541lv40nhca') .expect(200); }); + + it('/items/:id (DELETE)', async () => { + const response = await request(app.getHttpServer()) + .post('/items') + .set('organization-id', '4064541lv40nhca') + .send(makeItemRequest()); + const itemId = response.text; + + return request(app.getHttpServer()) + .delete(`/items/${itemId}`) + .set('organization-id', '4064541lv40nhca') + .expect(200); + }); + + it('/items/:id/invoices (GET)', async () => { + const response = await request(app.getHttpServer()) + .post('/items') + .set('organization-id', '4064541lv40nhca') + .send(makeItemRequest()); + + const itemId = response.text; + + return request(app.getHttpServer()) + .get(`/items/${itemId}/invoices`) + .set('organization-id', '4064541lv40nhca') + .expect(200); + }); + + it('/items/:id/bills (GET)', async () => { + const response = await request(app.getHttpServer()) + .post('/items') + .set('organization-id', '4064541lv40nhca') + .send(makeItemRequest()); + + const itemId = response.text; + + return request(app.getHttpServer()) + .get(`/items/${itemId}/bills`) + .set('organization-id', '4064541lv40nhca') + .expect(200); + }); + + it('/items/:id/estimates (GET)', async () => { + const response = await request(app.getHttpServer()) + .post('/items') + .set('organization-id', '4064541lv40nhca') + .send(makeItemRequest()); + + const itemId = response.text; + + return request(app.getHttpServer()) + .get(`/items/${itemId}/estimates`) + .set('organization-id', '4064541lv40nhca') + .expect(200); + }); + + it('/items/:id/receipts (GET)', async () => { + const response = await request(app.getHttpServer()) + .post('/items') + .set('organization-id', '4064541lv40nhca') + .send(makeItemRequest()); + + const itemId = response.text; + + return request(app.getHttpServer()) + .get(`/items/${itemId}/receipts`) + .set('organization-id', '4064541lv40nhca') + .expect(200); + }); }); diff --git a/packages/server/src/models/RecognizedBankTransaction.ts b/packages/server/src/models/RecognizedBankTransaction.ts index 32798445e..3fcc8c48d 100644 --- a/packages/server/src/models/RecognizedBankTransaction.ts +++ b/packages/server/src/models/RecognizedBankTransaction.ts @@ -2,6 +2,13 @@ import TenantModel from 'models/TenantModel'; import { Model } from 'objection'; export class RecognizedBankTransaction extends TenantModel { + public bankRuleId!: number; + public uncategorizedTransactionId!: number; + public assignedCategory!: string; + public assignedAccountId!: number; + public assignedPayee!: string; + public assignedMemo!: string; + /** * Table name. */ diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 47b900af3..03dd11e4d 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -529,6 +529,9 @@ importers: '@nestjs/throttler': specifier: ^6.2.1 version: 6.2.1(@nestjs/common@10.4.7)(@nestjs/core@10.4.7)(reflect-metadata@0.2.2) + '@supercharge/promise-pool': + specifier: ^3.2.0 + version: 3.2.0 '@types/passport-local': specifier: ^1.0.38 version: 1.0.38 @@ -616,6 +619,9 @@ importers: passport-local: specifier: ^1.0.0 version: 1.0.0 + plaid: + specifier: ^10.3.0 + version: 10.9.0 pug: specifier: ^3.0.2 version: 3.0.2 @@ -637,9 +643,15 @@ importers: strategy: specifier: ^1.1.1 version: 1.1.1 + uniqid: + specifier: ^5.2.0 + version: 5.4.0 uuid: specifier: ^10.0.0 version: 10.0.0 + yup: + specifier: ^0.28.1 + version: 0.28.5 zod: specifier: ^3.23.8 version: 3.23.8 @@ -665,6 +677,9 @@ importers: '@types/supertest': specifier: ^6.0.0 version: 6.0.2 + '@types/yup': + specifier: ^0.29.13 + version: 0.29.14 '@typescript-eslint/eslint-plugin': specifier: ^8.0.0 version: 8.11.0(@typescript-eslint/parser@8.11.0)(eslint@9.13.0)(typescript@5.6.3) @@ -2751,7 +2766,7 @@ packages: '@babel/core': ^7.0.0-0 dependencies: '@babel/core': 7.24.5 - '@babel/helper-plugin-utils': 7.25.9 + '@babel/helper-plugin-utils': 7.24.5 '@babel/helper-skip-transparent-expression-wrappers': 7.22.5 '@babel/plugin-syntax-optional-chaining': 7.8.3(@babel/core@7.24.5) dev: false @@ -12620,7 +12635,6 @@ packages: /@types/yup@0.29.14: resolution: {integrity: sha512-Ynb/CjHhE/Xp/4bhHmQC4U1Ox+I2OpfRYF3dnNgQqn1cHa6LK3H1wJMNPT02tSVZA6FYuXE2ITORfbnb6zBCSA==} - dev: false /@typescript-eslint/eslint-plugin@2.34.0(@typescript-eslint/parser@2.34.0)(eslint@8.57.0)(typescript@4.9.5): resolution: {integrity: sha512-4zY3Z88rEE99+CNvTbXSyovv2z9PNOVffTWD2W8QF5s2prBQtwN2zadqERcrHpcR7O/+KMI3fcTAmUUhK/iQcQ==}