Compare commits

...

100 Commits

Author SHA1 Message Date
Ahmed Bouhuolia
f67c63a4fa Merge pull request #569 from bigcapitalhq/fix-edit-bank-rule-recognized
fix: Recognize transactions on editing bank rule
2024-08-08 00:26:18 +02:00
Ahmed Bouhuolia
0025dcf8d4 fix: add recognize jobs 2024-08-08 00:23:47 +02:00
Ahmed Bouhuolia
81995dc94f fix: recognize transactions on editing bank rule 2024-08-08 00:20:17 +02:00
Ahmed Bouhuolia
3fcc70c1d8 Merge pull request #568 from bigcapitalhq/fix-banking-api-query
fix: Banking API account and page query.
2024-08-07 20:17:49 +02:00
Ahmed Bouhuolia
a986c7a250 fix: Banking api account and page query. 2024-08-07 20:08:59 +02:00
allcontributors[bot]
37e25a8061 docs: add mittalsam98 as a contributor for bug (#567)
* docs: update README.md [skip ci]

* docs: update .all-contributorsrc [skip ci]

---------

Co-authored-by: allcontributors[bot] <46447321+allcontributors[bot]@users.noreply.github.com>
2024-08-07 18:58:37 +02:00
Ahmed Bouhuolia
cfba628465 Merge pull request #562 from mittalsam98/fix/wrong-due-amount
fix: the wrong invoice due amouynt.
2024-08-07 18:56:28 +02:00
Ahmed Bouhuolia
3d200f4d7d fix: wrong invoice due amount 2024-08-07 18:52:36 +02:00
Ahmed Bouhuolia
f21b01b1d6 feat: Move billing to preferences (#566)
* feat: move billing to preferences

* chore: remove the commented lines
2024-08-06 12:12:41 +02:00
Ahmed Bouhuolia
3cbcfac333 Merge pull request #565 from bigcapitalhq/fix-edit-bank-rule
fix: Edit bank rule
2024-08-06 00:50:05 +02:00
Ahmed Bouhuolia
cc21e1856f fix: Edit bank rule 2024-08-06 00:48:58 +02:00
Ahmed Bouhuolia
efd0e1e225 Merge pull request #564 from bigcapitalhq/fix-banking-bugs
fix: Banking service bugs
2024-08-05 23:06:03 +02:00
Ahmed Bouhuolia
521b083ed7 fix: retrieve the excluded transactions count 2024-08-05 22:50:58 +02:00
Ahmed Bouhuolia
a09fe26df7 fix: group query key constants in seperate file 2024-08-05 21:36:34 +02:00
Ahmed Bouhuolia
c7a85c4cf8 fix: categorize transactions on recognized transactions table 2024-08-05 21:20:11 +02:00
Ahmed Bouhuolia
f6350d3d61 fix: Should not show the excluded transactions in recognized transactions 2024-08-05 21:11:15 +02:00
Ahmed Bouhuolia
64c0732e5f fix: infinity scrolling of bank account transactions 2024-08-05 20:57:13 +02:00
Ahmed Bouhuolia
8e99a31455 fix: validate exclude and unexclude uncategorized transaction 2024-08-05 15:56:11 +02:00
Ahmed Bouhuolia
6d0d0689e1 Merge pull request #533 from bigcapitalhq/bulk-categorize-bank-transactions
feat: Bulk categorize bank transactions
2024-08-04 22:23:11 +02:00
Ahmed Bouhuolia
9836129e49 Merge branch 'develop' into bulk-categorize-bank-transactions 2024-08-04 22:23:02 +02:00
Ahmed Bouhuolia
86631ea8c3 chore: fix typing 2024-08-04 22:20:31 +02:00
Ahmed Bouhuolia
475ccd4903 Merge pull request #563 from bigcapitalhq/pause-resume-bank-feeds-syncing
feat: pause/resume bank account feeds syncing
2024-08-04 21:47:32 +02:00
Ahmed Bouhuolia
8608144ec1 chore: components description 2024-08-04 21:47:16 +02:00
Ahmed Bouhuolia
f9cf6d325a feat: pause bank account feeds 2024-08-04 21:14:05 +02:00
Ahmed Bouhuolia
fc0240c692 feat: confimation dialog on disconnecting bank account 2024-08-04 19:44:36 +02:00
Ahmed Bouhuolia
b84675325f feat: alert messages of pause.resume bank feeds 2024-08-04 16:05:35 +02:00
Ahmed Bouhuolia
647bed5c67 feat: control the multi-select switch 2024-08-04 15:42:53 +02:00
Ahmed Bouhuolia
00f5bb1d73 fix: decrement uncategorized transactions count 2024-08-04 13:15:20 +02:00
Ahmed Bouhuolia
208800b411 feat: wip pause/resume bank feeds syncing 2024-08-04 11:22:21 +02:00
Ahmed Bouhuolia
5e12a4cea4 feat: pause/resume bank account feeds syncing 2024-08-04 00:36:19 +02:00
Ahmed Bouhuolia
fdf3e34f1c feat: wip uncategorize bank transaction 2024-08-03 23:30:23 +02:00
Ahmed Bouhuolia
d74337fb94 feat: wip multi-select transactions to categorization and matching 2024-08-03 22:01:21 +02:00
Sachin
8cab012324 fix: due Amount on edit page is calculated wrong with "Exclusive of Tax" Invoice mode 2024-08-03 23:56:02 +05:30
Ahmed Bouhuolia
940b4f9175 Merge pull request #553 from oleynikd/attachments
Download attachments (documents) with original filenames
2024-08-02 02:42:52 +02:00
Ahmed Bouhuolia
5d0dd1fe3f Merge branch 'main' into develop 2024-08-01 20:10:01 +02:00
Ahmed Bouhuolia
ded4e2bb59 Merge pull request #560 from bigcapitalhq/fix-onboarding-on-small-screens
fix: Onboarding layout on small screens
2024-08-01 19:53:09 +02:00
Ahmed Bouhuolia
219e6fb466 fix: onboarding page layout on small screens 2024-08-01 19:51:25 +02:00
Ahmed Bouhuolia
5ce11f192f feat: reset the state once closing categorization aside 2024-08-01 14:02:02 +02:00
Ahmed Bouhuolia
71e865e9b7 Merge remote-tracking branch 'refs/remotes/origin/bulk-categorize-bank-transactions' into bulk-categorize-bank-transactions 2024-08-01 13:46:19 +02:00
Ahmed Bouhuolia
590506f183 Merge branch 'develop' into bulk-categorize-bank-transactions 2024-08-01 13:46:03 +02:00
Ahmed Bouhuolia
bed281a637 feat: wip multipe transactions categorization 2024-08-01 13:44:49 +02:00
Ahmed Bouhuolia
47dd767b3a feat: getting matched transactiosn from multi uncategorized transactions 2024-08-01 12:11:54 +02:00
Ahmed Bouhuolia
8623b69991 feat: getting matched transactiosn from multi uncategorized transactions 2024-08-01 12:11:40 +02:00
Denis
a1ddc81dac Fixed double slash in attachments route 2024-07-30 23:54:46 +03:00
Denis
832cdacebf Download attachments with original filenames 2024-07-30 23:48:15 +03:00
Ahmed Bouhuolia
9f979080b6 fix: remove console.log 2024-07-30 21:55:44 +02:00
Ahmed Bouhuolia
7f7301b31e Merge pull request #544 from bigcapitalhq/billing-subscription-page
feat: Billing subscription page
2024-07-30 21:44:55 +02:00
Ahmed Bouhuolia
6affbedef4 feat: description to billing page 2024-07-30 21:43:33 +02:00
Ahmed Bouhuolia
ba7f32c1bf feat: abstract the pricing plans for setup and billing page 2024-07-30 17:47:03 +02:00
allcontributors[bot]
305ce29ebb docs: add oleynikd as a contributor for bug (#551)
* docs: update README.md [skip ci]

* docs: update .all-contributorsrc [skip ci]

---------

Co-authored-by: allcontributors[bot] <46447321+allcontributors[bot]@users.noreply.github.com>
2024-07-30 11:14:12 +02:00
Ahmed Bouhuolia
4cd0405078 fix: quick payment received and payment made form initial values 2024-07-30 11:06:45 +02:00
Ahmed Bouhuolia
783102449f fix: create quick payment received and payment made transactions 2024-07-30 11:06:35 +02:00
Denis
ae617b2e1d Fixed Quick Payment Dialogs
PaymentReceives and BillsPayments Controllers expect 'amount' parameter, but webapp sends 'payment_amount'
2024-07-30 11:06:24 +02:00
Ahmed Bouhuolia
9090d0a7b2 Merge pull request #548 from oleynikd/oleynikd-dev
Fixed Quick Payment Dialogs
2024-07-30 11:04:25 +02:00
Ahmed Bouhuolia
ffc55fa81b fix: quick payment received and payment made form initial values 2024-07-30 11:02:49 +02:00
Ahmed Bouhuolia
07c57ed539 Merge branch 'develop' into billing-subscription-page 2024-07-30 10:03:31 +02:00
Ahmed Bouhuolia
788150f80d Merge pull request #549 from oleynikd/s3-path-style
Added support of path-style S3 endpoints
2024-07-30 00:06:16 +02:00
Ahmed Bouhuolia
c4e77e4e3b fix: create quick payment received and payment made transactions 2024-07-29 23:15:42 +02:00
Denis
c09384e49b Added support of path-style S3 endpoints
This can be very useful when using S3-compatible object storages like MinIO
2024-07-29 23:48:29 +03:00
Denis
4490c2d4b4 Fixed Quick Payment Dialogs
PaymentReceives and BillsPayments Controllers expect 'amount' parameter, but webapp sends 'payment_amount'
2024-07-29 22:49:07 +03:00
Ahmed Bouhuolia
e11f1a95f6 Merge pull request #529 from bigcapitalhq/disconnect-bank-account
feat: Disconnect bank account
2024-07-29 20:18:36 +02:00
Ahmed Bouhuolia
b91273eee4 Merge branch 'develop' into disconnect-bank-account 2024-07-29 20:17:09 +02:00
Ahmed Bouhuolia
b5d570417b fix: add events interfaces of disconnect bank account 2024-07-29 20:10:15 +02:00
Ahmed Bouhuolia
acd3265e35 feat: add migration to is_syncing_owner column in accounts table 2024-07-29 20:01:04 +02:00
Ahmed Bouhuolia
894c899847 feat: improvement in Plaid accounts disconnecting 2024-07-29 19:49:20 +02:00
Ahmed Bouhuolia
f6d4ec504f feat: tweaks in disconnecting bank account 2024-07-29 16:55:50 +02:00
Ahmed Bouhuolia
1a01461f5d feat: delete Plaid item once bank account deleted 2024-07-29 16:20:59 +02:00
Ahmed Bouhuolia
f5e18fc1fe feat: document the Redux mutation methods 2024-07-29 14:03:37 +02:00
Ahmed Bouhuolia
f64cd32985 Merge branch 'develop' into bulk-categorize-bank-transactions 2024-07-29 13:03:35 +02:00
Ahmed Bouhuolia
89552d7ee2 Merge pull request #532 from bigcapitalhq/bulk-exclude-bank-transactions
feat: Bulk exclude bank transactions
2024-07-29 13:01:56 +02:00
Ahmed Bouhuolia
4345623ea9 feat: document functions 2024-07-29 13:00:50 +02:00
Ahmed Bouhuolia
f457759e39 Merge branch 'develop' into bulk-exclude-bank-transactions 2024-07-29 12:00:49 +02:00
Ahmed Bouhuolia
14d5e82b4a fix: style of database checkbox 2024-07-29 12:00:34 +02:00
Ahmed Bouhuolia
333b6f5a4b feat: change subscription plan 2024-07-28 20:52:53 +02:00
Ahmed Bouhuolia
1660df20af feat: wip billing page 2024-07-28 17:53:55 +02:00
Ahmed Bouhuolia
14a9c4ba28 fix: style tweaks in billing page 2024-07-27 21:56:55 +02:00
Ahmed Bouhuolia
383be111fa feat: style the billing page 2024-07-27 21:47:17 +02:00
Ahmed Bouhuolia
7720b1cc34 feat: getting subscription endpoint 2024-07-27 17:39:50 +02:00
Ahmed Bouhuolia
db634cbb79 feat: pause, resume main subscription 2024-07-27 16:55:56 +02:00
Ahmed Bouhuolia
53f37f4f48 Merge pull request #546 from bigcapitalhq/remove-views-tabs
feat: Remove the views tabs bar from all tables
2024-07-25 19:21:50 +02:00
Ahmed Bouhuolia
0a7b522b87 chore: remove unused import 2024-07-25 19:21:16 +02:00
Ahmed Bouhuolia
9e6500ac79 feat: remove the views tabs bar from all tables 2024-07-25 19:17:54 +02:00
Ahmed Bouhuolia
b93cb546f4 Merge pull request #545 from bigcapitalhq/excessed-payments-as-credit
Excessed payments as credit
2024-07-25 18:57:31 +02:00
Ahmed Bouhuolia
6d17f9cbeb feat: record excessed payments as credit 2024-07-25 18:46:24 +02:00
Ahmed Bouhuolia
998e6de211 feat: billing subscription page 2024-07-25 15:21:01 +02:00
Ahmed Bouhuolia
6fb02f9869 feat: bulk categorize and match bank transactions 2024-07-18 19:41:23 +02:00
Ahmed Bouhuolia
449390143d feat: bulk categorizing bank transactions 2024-07-18 17:00:23 +02:00
Ahmed Bouhuolia
51471ed000 feat: exclude bank transactions in bulk 2024-07-17 23:19:59 +02:00
Ahmed Bouhuolia
fe214b1b2d feat: push CHANGELOG 2024-07-17 16:53:47 +02:00
Ahmed Bouhuolia
6b6b73b77c feat: send signup event to Loops (#531)
* feat: send signup event to Loops

* feat: fix
2024-07-17 15:56:05 +02:00
Ahmed Bouhuolia
c2815afbe3 feat: disconnect and update bank account 2024-07-16 17:09:00 +02:00
Ahmed Bouhuolia
fa7e6b1fca feat: disconnect bank account 2024-07-15 23:18:39 +02:00
Ahmed Bouhuolia
107a6f793b Merge pull request #526 from bigcapitalhq/monthly-plans
feat: upgrade the subscription plans
2024-07-14 14:21:57 +02:00
Ahmed Bouhuolia
67d155759e feat: backend the new monthly susbcription plans 2024-07-14 14:19:04 +02:00
Ahmed Bouhuolia
7e2e87256f Merge pull request #527 from bigcapitalhq/fix-sync-removed-transactions
fix: sync the removed bank transactions from the source
2024-07-13 21:56:13 +02:00
Ahmed Bouhuolia
df7790d7c1 fix: sync the removed bank transactions from the source 2024-07-13 21:54:44 +02:00
Ahmed Bouhuolia
72128a72c4 feat: add variant ids to new subscription plans 2024-07-13 19:53:52 +02:00
Ahmed Bouhuolia
eb3f23554f feat: upgrade the subscription plans 2024-07-13 18:19:18 +02:00
Ahmed Bouhuolia
69ddf43b3e fix: duplicated event emitter 2024-07-13 03:23:25 +02:00
Ahmed Bouhuolia
249eadaeaa Merge pull request #525 from bigcapitalhq/fix-plaid-transactions-syncing
fix: Plaid transactions syncing
2024-07-12 23:44:27 +02:00
261 changed files with 6683 additions and 1824 deletions

View File

@@ -132,6 +132,24 @@
"contributions": [
"bug"
]
},
{
"login": "oleynikd",
"name": "Denis",
"avatar_url": "https://avatars.githubusercontent.com/u/3976868?v=4",
"profile": "https://github.com/oleynikd",
"contributions": [
"bug"
]
},
{
"login": "mittalsam98",
"name": "Sachin Mittal",
"avatar_url": "https://avatars.githubusercontent.com/u/42431274?v=4",
"profile": "https://myself.vercel.app/",
"contributions": [
"bug"
]
}
],
"contributorsPerLine": 7,

View File

@@ -2,6 +2,14 @@
All notable changes to Bigcapital server-side will be in this file.
## [v0.18.0] - 10-08-2024
* feat: Bank rules for automated categorization by @abouolia in https://github.com/bigcapitalhq/bigcapital/pull/511
* feat: Categorize & match bank transaction by @abouolia in https://github.com/bigcapitalhq/bigcapital/pull/511
* feat: Reconcile match transactions by @abouolia in https://github.com/bigcapitalhq/bigcapital/pull/522
* fix: Issues in matching transactions by @abouolia in https://github.com/bigcapitalhq/bigcapital/pull/523
* fix: Cashflow transactions types by @abouolia in https://github.com/bigcapitalhq/bigcapital/pull/524
## [v0.17.5] - 17-06-2024
* fix: Balance sheet and P/L nested accounts by @abouolia in https://github.com/bigcapitalhq/bigcapital/pull/501

View File

@@ -126,6 +126,10 @@ Thanks goes to these wonderful people ([emoji key](https://allcontributors.org/d
<td align="center" valign="top" width="14.28%"><a href="http://vederis.id"><img src="https://avatars.githubusercontent.com/u/13505006?v=4?s=100" width="100px;" alt="Vederis Leunardus"/><br /><sub><b>Vederis Leunardus</b></sub></a><br /><a href="https://github.com/bigcapitalhq/bigcapital/commits?author=cloudsbird" title="Code">💻</a></td>
<td align="center" valign="top" width="14.28%"><a href="http://www.pivoten.com"><img src="https://avatars.githubusercontent.com/u/104120598?v=4?s=100" width="100px;" alt="Chris Cantrell"/><br /><sub><b>Chris Cantrell</b></sub></a><br /><a href="https://github.com/bigcapitalhq/bigcapital/issues?q=author%3Accantrell72" title="Bug reports">🐛</a></td>
</tr>
<tr>
<td align="center" valign="top" width="14.28%"><a href="https://github.com/oleynikd"><img src="https://avatars.githubusercontent.com/u/3976868?v=4?s=100" width="100px;" alt="Denis"/><br /><sub><b>Denis</b></sub></a><br /><a href="https://github.com/bigcapitalhq/bigcapital/issues?q=author%3Aoleynikd" title="Bug reports">🐛</a></td>
<td align="center" valign="top" width="14.28%"><a href="https://myself.vercel.app/"><img src="https://avatars.githubusercontent.com/u/42431274?v=4?s=100" width="100px;" alt="Sachin Mittal"/><br /><sub><b>Sachin Mittal</b></sub></a><br /><a href="https://github.com/bigcapitalhq/bigcapital/issues?q=author%3Amittalsam98" title="Bug reports">🐛</a></td>
</tr>
</tbody>
</table>

View File

@@ -250,10 +250,12 @@ export class AttachmentsController extends BaseController {
res: Response,
next: NextFunction
): Promise<Response | void> {
const { tenantId } = req;
const { id: documentKey } = req.params;
try {
const presignedUrl = await this.attachmentsApplication.getPresignedUrl(
tenantId,
documentKey
);
return res.status(200).send({ presignedUrl });

View File

@@ -1,14 +1,18 @@
import { Inject, Service } from 'typedi';
import { NextFunction, Request, Response, Router } from 'express';
import BaseController from '@/api/controllers/BaseController';
import { CashflowApplication } from '@/services/Cashflow/CashflowApplication';
import { GetBankAccountSummary } from '@/services/Banking/BankAccounts/GetBankAccountSummary';
import { BankAccountsApplication } from '@/services/Banking/BankAccounts/BankAccountsApplication';
import { param } from 'express-validator';
@Service()
export class BankAccountsController extends BaseController {
@Inject()
private getBankAccountSummaryService: GetBankAccountSummary;
@Inject()
private bankAccountsApp: BankAccountsApplication;
/**
* Router constructor.
*/
@@ -16,6 +20,27 @@ export class BankAccountsController extends BaseController {
const router = Router();
router.get('/:bankAccountId/meta', this.getBankAccountSummary.bind(this));
router.post(
'/:bankAccountId/disconnect',
this.disconnectBankAccount.bind(this)
);
router.post('/:bankAccountId/update', this.refreshBankAccount.bind(this));
router.post(
'/:bankAccountId/pause_feeds',
[
param('bankAccountId').exists().isNumeric().toInt(),
],
this.validationResult,
this.pauseBankAccountFeeds.bind(this)
);
router.post(
'/:bankAccountId/resume_feeds',
[
param('bankAccountId').exists().isNumeric().toInt(),
],
this.validationResult,
this.resumeBankAccountFeeds.bind(this)
);
return router;
}
@@ -46,4 +71,112 @@ export class BankAccountsController extends BaseController {
next(error);
}
}
/**
* Disonnect the given bank account.
* @param {Request} req
* @param {Response} res
* @param {NextFunction} next
* @returns {Promise<Response|null>}
*/
async disconnectBankAccount(
req: Request<{ bankAccountId: number }>,
res: Response,
next: NextFunction
) {
const { bankAccountId } = req.params;
const { tenantId } = req;
try {
await this.bankAccountsApp.disconnectBankAccount(tenantId, bankAccountId);
return res.status(200).send({
id: bankAccountId,
message: 'The bank account has been disconnected.',
});
} catch (error) {
next(error);
}
}
/**
* Refresh the given bank account.
* @param {Request} req
* @param {Response} res
* @param {NextFunction} next
* @returns {Promise<Response|null>}
*/
async refreshBankAccount(
req: Request<{ bankAccountId: number }>,
res: Response,
next: NextFunction
) {
const { bankAccountId } = req.params;
const { tenantId } = req;
try {
await this.bankAccountsApp.refreshBankAccount(tenantId, bankAccountId);
return res.status(200).send({
id: bankAccountId,
message: 'The bank account has been disconnected.',
});
} catch (error) {
next(error);
}
}
/**
* Resumes the bank account feeds sync.
* @param {Request} req
* @param {Response} res
* @param {NextFunction} next
* @returns {Promise<Response | void>}
*/
async resumeBankAccountFeeds(
req: Request<{ bankAccountId: number }>,
res: Response,
next: NextFunction
) {
const { bankAccountId } = req.params;
const { tenantId } = req;
try {
await this.bankAccountsApp.resumeBankAccount(tenantId, bankAccountId);
return res.status(200).send({
message: 'The bank account feeds syncing has been resumed.',
id: bankAccountId,
});
} catch (error) {
next(error);
}
}
/**
* Pauses the bank account feeds sync.
* @param {Request} req
* @param {Response} res
* @param {NextFunction} next
* @returns {Promise<Response | void>}
*/
async pauseBankAccountFeeds(
req: Request<{ bankAccountId: number }>,
res: Response,
next: NextFunction
) {
const { bankAccountId } = req.params;
const { tenantId } = req;
try {
await this.bankAccountsApp.pauseBankAccount(tenantId, bankAccountId);
return res.status(200).send({
message: 'The bank account feeds syncing has been paused.',
id: bankAccountId,
});
} catch (error) {
next(error);
}
}
}

View File

@@ -1,12 +1,8 @@
import { Inject, Service } from 'typedi';
import { body, param } from 'express-validator';
import { NextFunction, Request, Response, Router } from 'express';
import BaseController from '@/api/controllers/BaseController';
import { MatchBankTransactionsApplication } from '@/services/Banking/Matching/MatchBankTransactionsApplication';
import { body, param } from 'express-validator';
import {
GetMatchedTransactionsFilter,
IMatchTransactionsDTO,
} from '@/services/Banking/Matching/types';
@Service()
export class BankTransactionsMatchingController extends BaseController {
@@ -20,9 +16,17 @@ export class BankTransactionsMatchingController extends BaseController {
const router = Router();
router.post(
'/:transactionId',
'/unmatch/:transactionId',
[param('transactionId').exists()],
this.validationResult,
this.unmatchMatchedBankTransaction.bind(this)
);
router.post(
'/match',
[
param('transactionId').exists(),
body('uncategorizedTransactions').exists().isArray({ min: 1 }),
body('uncategorizedTransactions.*').isNumeric().toInt(),
body('matchedTransactions').isArray({ min: 1 }),
body('matchedTransactions.*.reference_type').exists(),
body('matchedTransactions.*.reference_id').isNumeric().toInt(),
@@ -30,12 +34,6 @@ export class BankTransactionsMatchingController extends BaseController {
this.validationResult,
this.matchBankTransaction.bind(this)
);
router.post(
'/unmatch/:transactionId',
[param('transactionId').exists()],
this.validationResult,
this.unmatchMatchedBankTransaction.bind(this)
);
return router;
}
@@ -50,21 +48,21 @@ export class BankTransactionsMatchingController extends BaseController {
req: Request<{ transactionId: number }>,
res: Response,
next: NextFunction
) {
): Promise<Response | null> {
const { tenantId } = req;
const { transactionId } = req.params;
const matchTransactionDTO = this.matchedBodyData(
req
) as IMatchTransactionsDTO;
const bodyData = this.matchedBodyData(req);
const uncategorizedTransactions = bodyData?.uncategorizedTransactions;
const matchedTransactions = bodyData?.matchedTransactions;
try {
await this.bankTransactionsMatchingApp.matchTransaction(
tenantId,
transactionId,
matchTransactionDTO
uncategorizedTransactions,
matchedTransactions
);
return res.status(200).send({
id: transactionId,
ids: uncategorizedTransactions,
message: 'The bank transaction has been matched.',
});
} catch (error) {

View File

@@ -6,6 +6,7 @@ import { BankingRulesController } from './BankingRulesController';
import { BankTransactionsMatchingController } from './BankTransactionsMatchingController';
import { RecognizedTransactionsController } from './RecognizedTransactionsController';
import { BankAccountsController } from './BankAccountsController';
import { BankingUncategorizedController } from './BankingUncategorizedController';
@Service()
export class BankingController extends BaseController {
@@ -29,6 +30,10 @@ export class BankingController extends BaseController {
'/bank_accounts',
Container.get(BankAccountsController).router()
);
router.use(
'/categorize',
Container.get(BankingUncategorizedController).router()
);
return router;
}
}

View File

@@ -34,16 +34,15 @@ export class BankingRulesController extends BaseController {
body('conditions.*.comparator')
.exists()
.isIn(['equals', 'contains', 'not_contain'])
.default('contain'),
body('conditions.*.value').exists(),
.default('contain')
.trim(),
body('conditions.*.value').exists().trim(),
// Assign
body('assign_category').isString(),
body('assign_account_id').isInt({ min: 0 }),
body('assign_payee').isString().optional({ nullable: true }),
body('assign_memo').isString().optional({ nullable: true }),
body('recognition').isBoolean().toBoolean().optional({ nullable: true }),
];
}

View File

@@ -0,0 +1,57 @@
import { Inject, Service } from 'typedi';
import { NextFunction, Request, Response, Router } from 'express';
import { query } from 'express-validator';
import BaseController from '../BaseController';
import { GetAutofillCategorizeTransaction } from '@/services/Banking/RegonizeTranasctions/GetAutofillCategorizeTransaction';
@Service()
export class BankingUncategorizedController extends BaseController {
@Inject()
private getAutofillCategorizeTransactionService: GetAutofillCategorizeTransaction;
/**
* Router constructor.
*/
router() {
const router = Router();
router.get(
'/autofill',
[
query('uncategorizedTransactionIds').isArray({ min: 1 }),
query('uncategorizedTransactionIds.*').isNumeric().toInt(),
],
this.validationResult,
this.getAutofillCategorizeTransaction.bind(this)
);
return router;
}
/**
* Retrieves the autofill values of the categorize form of the given
* uncategorized transactions.
* @param {Request} req
* @param {Response} res
* @param {NextFunction} next
* @returns {Promise<Response | null>}
*/
public async getAutofillCategorizeTransaction(
req: Request,
res: Response,
next: NextFunction
) {
const { tenantId } = req;
const uncategorizedTransactionIds = req.query.uncategorizedTransactionIds;
try {
const data =
await this.getAutofillCategorizeTransactionService.getAutofillCategorizeTransaction(
tenantId,
uncategorizedTransactionIds
);
return res.status(200).send({ data });
} catch (error) {
next(error);
}
}
}

View File

@@ -1,8 +1,9 @@
import { Inject, Service } from 'typedi';
import { param } from 'express-validator';
import { NextFunction, Request, Response, Router, query } from 'express';
import { body, param, query } from 'express-validator';
import { NextFunction, Request, Response, Router } from 'express';
import BaseController from '../BaseController';
import { ExcludeBankTransactionsApplication } from '@/services/Banking/Exclude/ExcludeBankTransactionsApplication';
import { map, parseInt, trim } from 'lodash';
@Service()
export class ExcludeBankTransactionsController extends BaseController {
@@ -15,9 +16,21 @@ export class ExcludeBankTransactionsController extends BaseController {
public router() {
const router = Router();
router.put(
'/transactions/exclude',
[body('ids').exists()],
this.validationResult,
this.excludeBulkBankTransactions.bind(this)
);
router.put(
'/transactions/unexclude',
[body('ids').exists()],
this.validationResult,
this.unexcludeBulkBankTransactins.bind(this)
);
router.put(
'/transactions/:transactionId/exclude',
[param('transactionId').exists()],
[param('transactionId').exists().toInt()],
this.validationResult,
this.excludeBankTransaction.bind(this)
);
@@ -29,7 +42,11 @@ export class ExcludeBankTransactionsController extends BaseController {
);
router.get(
'/excluded',
[],
[
query('account_id').optional().isNumeric().toInt(),
query('page').optional().isNumeric().toInt(),
query('page_size').optional().isNumeric().toInt(),
],
this.validationResult,
this.getExcludedBankTransactions.bind(this)
);
@@ -94,6 +111,63 @@ export class ExcludeBankTransactionsController extends BaseController {
}
}
/**
* Exclude bank transactions in bulk.
* @param {Request} req
* @param {Response} res
* @param {NextFunction} next
*/
private async excludeBulkBankTransactions(
req: Request,
res: Response,
next: NextFunction
) {
const { tenantId } = req;
const { ids } = this.matchedBodyData(req);
try {
await this.excludeBankTransactionApp.excludeBankTransactions(
tenantId,
ids
);
return res.status(200).send({
message: 'The given bank transactions have been excluded',
ids,
});
} catch (error) {
next(error);
}
}
/**
* Unexclude the given bank transactions in bulk.
* @param {Request} req
* @param {Response} res
* @param {NextFunction} next
* @returns {Promise<Response | null>}
*/
private async unexcludeBulkBankTransactins(
req: Request,
res: Response,
next: NextFunction
): Promise<Response | null> {
const { tenantId } = req;
const { ids } = this.matchedBodyData(req);
try {
await this.excludeBankTransactionApp.unexcludeBankTransactions(
tenantId,
ids
);
return res.status(200).send({
message: 'The given bank transactions have been excluded',
ids,
});
} catch (error) {
next(error);
}
}
/**
* Retrieves the excluded uncategorized bank transactions.
* @param {Request} req
@@ -107,9 +181,8 @@ export class ExcludeBankTransactionsController extends BaseController {
next: NextFunction
): Promise<Response | void> {
const { tenantId } = req;
const filter = this.matchedBodyData(req);
const filter = this.matchedQueryData(req);
console.log('123');
try {
const data =
await this.excludeBankTransactionApp.getExcludedBankTransactions(

View File

@@ -1,5 +1,6 @@
import { Inject, Service } from 'typedi';
import { NextFunction, Request, Response, Router } from 'express';
import { query } from 'express-validator';
import BaseController from '@/api/controllers/BaseController';
import { CashflowApplication } from '@/services/Cashflow/CashflowApplication';
@@ -14,7 +15,16 @@ export class RecognizedTransactionsController extends BaseController {
router() {
const router = Router();
router.get('/', this.getRecognizedTransactions.bind(this));
router.get(
'/',
[
query('page').optional().isNumeric().toInt(),
query('page_size').optional().isNumeric().toInt(),
query('account_id').optional().isNumeric().toInt(),
],
this.validationResult,
this.getRecognizedTransactions.bind(this)
);
router.get(
'/transactions/:uncategorizedTransactionId',
this.getRecognizedTransaction.bind(this)

View File

@@ -1,6 +1,6 @@
import { Service, Inject } from 'typedi';
import { Router, Request, Response, NextFunction } from 'express';
import { param } from 'express-validator';
import { param, query } from 'express-validator';
import BaseController from '../BaseController';
import { ServiceError } from '@/exceptions';
import CheckPolicies from '@/api/middleware/CheckPolicies';
@@ -24,7 +24,12 @@ export default class GetCashflowAccounts extends BaseController {
const router = Router();
router.get(
'/transactions/:transactionId/matches',
'/transactions/matches',
[
query('uncategorizeTransactionsIds').exists().isArray({ min: 1 }),
query('uncategorizeTransactionsIds.*').exists().isNumeric().toInt(),
],
this.validationResult,
this.getMatchedTransactions.bind(this)
);
router.get(
@@ -44,7 +49,7 @@ export default class GetCashflowAccounts extends BaseController {
* @param {NextFunction} next
*/
private getCashflowTransaction = async (
req: Request,
req: Request<{ transactionId: number }>,
res: Response,
next: NextFunction
) => {
@@ -71,19 +76,24 @@ export default class GetCashflowAccounts extends BaseController {
* @param {NextFunction} next
*/
private async getMatchedTransactions(
req: Request<{ transactionId: number }>,
req: Request<
{ transactionId: number },
null,
null,
{ uncategorizeTransactionsIds: Array<number> }
>,
res: Response,
next: NextFunction
) {
const { tenantId } = req;
const { transactionId } = req.params;
const uncategorizeTransactionsIds = req.query.uncategorizeTransactionsIds;
const filter = this.matchedQueryData(req) as GetMatchedTransactionsFilter;
try {
const data =
await this.bankTransactionsMatchingApp.getMatchedTransactions(
tenantId,
transactionId,
uncategorizeTransactionsIds,
filter
);
return res.status(200).send(data);

View File

@@ -1,10 +1,15 @@
import { Service, Inject } from 'typedi';
import { ValidationChain, check, param, query } from 'express-validator';
import { Router, Request, Response, NextFunction } from 'express';
import { omit } from 'lodash';
import BaseController from '../BaseController';
import { ServiceError } from '@/exceptions';
import CheckPolicies from '@/api/middleware/CheckPolicies';
import { AbilitySubject, CashflowAction } from '@/interfaces';
import {
AbilitySubject,
CashflowAction,
ICategorizeCashflowTransactioDTO,
} from '@/interfaces';
import { CashflowApplication } from '@/services/Cashflow/CashflowApplication';
@Service()
@@ -44,7 +49,7 @@ export default class NewCashflowTransactionController extends BaseController {
this.catchServiceErrors
);
router.post(
'/transactions/:id/categorize',
'/transactions/categorize',
this.categorizeCashflowTransactionValidationSchema,
this.validationResult,
this.categorizeCashflowTransaction,
@@ -89,6 +94,7 @@ export default class NewCashflowTransactionController extends BaseController {
*/
public get categorizeCashflowTransactionValidationSchema() {
return [
check('uncategorized_transaction_ids').exists().isArray({ min: 1 }),
check('date').exists().isISO8601().toDate(),
check('credit_account_id').exists().isInt().toInt(),
check('transaction_number').optional(),
@@ -161,7 +167,7 @@ export default class NewCashflowTransactionController extends BaseController {
* @param {NextFunction} next
*/
private revertCategorizedCashflowTransaction = async (
req: Request,
req: Request<{ id: number }>,
res: Response,
next: NextFunction
) => {
@@ -191,14 +197,19 @@ export default class NewCashflowTransactionController extends BaseController {
next: NextFunction
) => {
const { tenantId } = req;
const { id: cashflowTransactionId } = req.params;
const cashflowTransaction = this.matchedBodyData(req);
const matchedObject = this.matchedBodyData(req);
const categorizeDTO = omit(matchedObject, [
'uncategorizedTransactionIds',
]) as ICategorizeCashflowTransactioDTO;
const uncategorizedTransactionIds =
matchedObject.uncategorizedTransactionIds;
try {
await this.cashflowApplication.categorizeTransaction(
tenantId,
cashflowTransactionId,
cashflowTransaction
uncategorizedTransactionIds,
categorizeDTO
);
return res.status(200).send({
message: 'The cashflow transaction has been created successfully.',
@@ -269,7 +280,7 @@ export default class NewCashflowTransactionController extends BaseController {
* @param {NextFunction} next
*/
public getUncategorizedCashflowTransactions = async (
req: Request,
req: Request<{ id: number }>,
res: Response,
next: NextFunction
) => {

View File

@@ -111,6 +111,7 @@ export default class BillsPayments extends BaseController {
check('vendor_id').exists().isNumeric().toInt(),
check('exchange_rate').optional().isFloat({ gt: 0 }).toFloat(),
check('amount').exists().isNumeric().toFloat(),
check('payment_account_id').exists().isNumeric().toInt(),
check('payment_number').optional({ nullable: true }).trim().escape(),
check('payment_date').exists(),
@@ -118,7 +119,7 @@ export default class BillsPayments extends BaseController {
check('reference').optional().trim().escape(),
check('branch_id').optional({ nullable: true }).isNumeric().toInt(),
check('entries').exists().isArray({ min: 1 }),
check('entries').exists().isArray(),
check('entries.*.index').optional().isNumeric().toInt(),
check('entries.*.bill_id').exists().isNumeric().toInt(),
check('entries.*.payment_amount').exists().isNumeric().toFloat(),

View File

@@ -150,6 +150,7 @@ export default class PaymentReceivesController extends BaseController {
check('customer_id').exists().isNumeric().toInt(),
check('exchange_rate').optional().isFloat({ gt: 0 }).toFloat(),
check('amount').exists().isNumeric().toFloat(),
check('payment_date').exists(),
check('reference_no').optional(),
check('deposit_account_id').exists().isNumeric().toInt(),
@@ -158,8 +159,7 @@ export default class PaymentReceivesController extends BaseController {
check('branch_id').optional({ nullable: true }).isNumeric().toInt(),
check('entries').isArray({ min: 1 }),
check('entries').isArray({}),
check('entries.*.id').optional({ nullable: true }).isNumeric().toInt(),
check('entries.*.index').optional().isNumeric().toInt(),
check('entries.*.invoice_id').exists().isNumeric().toInt(),

View File

@@ -8,6 +8,7 @@ import SubscriptionService from '@/services/Subscription/SubscriptionService';
import asyncMiddleware from '@/api/middleware/asyncMiddleware';
import BaseController from '../BaseController';
import { LemonSqueezyService } from '@/services/Subscription/LemonSqueezyService';
import { SubscriptionApplication } from '@/services/Subscription/SubscriptionApplication';
@Service()
export class SubscriptionController extends BaseController {
@@ -17,6 +18,9 @@ export class SubscriptionController extends BaseController {
@Inject()
private lemonSqueezyService: LemonSqueezyService;
@Inject()
private subscriptionApp: SubscriptionApplication;
/**
* Router constructor.
*/
@@ -33,6 +37,14 @@ export class SubscriptionController extends BaseController {
this.validationResult,
this.getCheckoutUrl.bind(this)
);
router.post('/cancel', asyncMiddleware(this.cancelSubscription.bind(this)));
router.post('/resume', asyncMiddleware(this.resumeSubscription.bind(this)));
router.post(
'/change',
[body('variant_id').exists().trim()],
this.validationResult,
asyncMiddleware(this.changeSubscriptionPlan.bind(this))
);
router.get('/', asyncMiddleware(this.getSubscriptions.bind(this)));
return router;
@@ -85,4 +97,84 @@ export class SubscriptionController extends BaseController {
next(error);
}
}
/**
* Cancels the subscription of the current organization.
* @param {Request} req
* @param {Response} res
* @param {NextFunction} next
* @returns {Promise<Response|null>}
*/
private async cancelSubscription(
req: Request,
res: Response,
next: NextFunction
) {
const { tenantId } = req;
try {
await this.subscriptionApp.cancelSubscription(tenantId, '455610');
return res.status(200).send({
status: 200,
message: 'The organization subscription has been canceled.',
});
} catch (error) {
next(error);
}
}
/**
* Resumes the subscription of the current organization.
* @param {Request} req
* @param {Response} res
* @param {NextFunction} next
* @returns {Promise<Response | null>}
*/
private async resumeSubscription(
req: Request,
res: Response,
next: NextFunction
) {
const { tenantId } = req;
try {
await this.subscriptionApp.resumeSubscription(tenantId);
return res.status(200).send({
status: 200,
message: 'The organization subscription has been resumed.',
});
} catch (error) {
next(error);
}
}
/**
* Changes the main subscription plan of the current organization.
* @param {Request} req
* @param {Response} res
* @param {NextFunction} next
* @returns {Promise<Response | null>}
*/
public async changeSubscriptionPlan(
req: Request,
res: Response,
next: NextFunction
) {
const { tenantId } = req;
const body = this.matchedBodyData(req);
try {
await this.subscriptionApp.changeSubscriptionPlan(
tenantId,
body.variantId
);
return res.status(200).send({
message: 'The subscription plan has been changed.',
});
} catch (error) {
next(error);
}
}
}

View File

@@ -236,5 +236,13 @@ module.exports = {
secretAccessKey: process.env.S3_SECRET_ACCESS_KEY,
endpoint: process.env.S3_ENDPOINT,
bucket: process.env.S3_BUCKET || 'bigcapital-documents',
forcePathStyle: parseBoolean(
defaultTo(process.env.S3_FORCE_PATH_STYLE, false),
false
),
},
loops: {
apiKey: process.env.LOOPS_API_KEY,
},
};

View File

@@ -0,0 +1,11 @@
exports.up = function (knex) {
return knex.schema.table('accounts', (table) => {
table.string('plaid_item_id').nullable();
});
};
exports.down = function (knex) {
return knex.schema.table('accounts', (table) => {
table.dropColumn('plaid_item_id');
});
};

View File

@@ -0,0 +1,19 @@
exports.up = function (knex) {
return knex.schema
.table('accounts', (table) => {
table
.boolean('is_syncing_owner')
.defaultTo(false)
.after('is_feeds_active');
})
.then(() => {
return knex('accounts')
.whereNotNull('plaid_item_id')
.orWhereNotNull('plaid_account_id')
.update('is_syncing_owner', true);
});
};
exports.down = function (knex) {
table.dropColumn('is_syncing_owner');
};

View File

@@ -0,0 +1,11 @@
exports.up = function (knex) {
return knex.schema.table('plaid_items', (table) => {
table.datetime('paused_at');
});
};
exports.down = function (knex) {
return knex.schema.table('plaid_items', (table) => {
table.dropColumn('paused_at');
});
};

View File

@@ -12,8 +12,7 @@ export default class SeedAccounts extends TenantSeeder {
description: this.i18n.__(account.description),
currencyCode: this.tenant.metadata.baseCurrency,
seededAt: new Date(),
})
);
}));
return knex('accounts').then(async () => {
// Inserts seed entries.
return knex('accounts').insert(data);

View File

@@ -9,6 +9,28 @@ export const TaxPayableAccount = {
predefined: 1,
};
export const UnearnedRevenueAccount = {
name: 'Unearned Revenue',
slug: 'unearned-revenue',
account_type: 'other-current-liability',
parent_account_id: null,
code: '50005',
active: true,
index: 1,
predefined: true,
};
export const PrepardExpenses = {
name: 'Prepaid Expenses',
slug: 'prepaid-expenses',
account_type: 'other-current-asset',
parent_account_id: null,
code: '100010',
active: true,
index: 1,
predefined: true,
};
export default [
{
name: 'Bank Account',
@@ -323,4 +345,6 @@ export default [
index: 1,
predefined: 0,
},
UnearnedRevenueAccount,
PrepardExpenses,
];

View File

@@ -15,6 +15,7 @@ export interface IAccountDTO {
export interface IAccountCreateDTO extends IAccountDTO {
currencyCode?: string;
plaidAccountId?: string;
plaidItemId?: string;
}
export interface IAccountEditDTO extends IAccountDTO {}
@@ -37,6 +38,8 @@ export interface IAccount {
accountNormal: string;
accountParentType: string;
bankBalance: string;
plaidItemId: number | null
lastFeedsUpdatedAt: Date;
}
export enum AccountNormal {

View File

@@ -236,6 +236,7 @@ export interface ICashflowTransactionSchema {
export interface ICashflowTransactionInput extends ICashflowTransactionSchema {}
export interface ICategorizeCashflowTransactioDTO {
date: Date;
creditAccountId: number;
referenceNo: string;
transactionNumber: string;

View File

@@ -130,20 +130,23 @@ export interface ICommandCashflowDeletedPayload {
export interface ICashflowTransactionCategorizedPayload {
tenantId: number;
uncategorizedTransaction: any;
uncategorizedTransactions: Array<IUncategorizedCashflowTransaction>;
cashflowTransaction: ICashflowTransaction;
oldUncategorizedTransactions: Array<IUncategorizedCashflowTransaction>;
categorizeDTO: any;
trx: Knex.Transaction;
}
export interface ICashflowTransactionUncategorizingPayload {
tenantId: number;
uncategorizedTransaction: IUncategorizedCashflowTransaction;
uncategorizedTransactionId: number;
oldUncategorizedTransactions: Array<IUncategorizedCashflowTransaction>;
trx: Knex.Transaction;
}
export interface ICashflowTransactionUncategorizedPayload {
tenantId: number;
uncategorizedTransaction: IUncategorizedCashflowTransaction;
oldUncategorizedTransaction: IUncategorizedCashflowTransaction;
uncategorizedTransactionId: number;
uncategorizedTransactions: Array<IUncategorizedCashflowTransaction>;
oldUncategorizedTransactions: Array<IUncategorizedCashflowTransaction>;
trx: Knex.Transaction;
}

View File

@@ -40,7 +40,7 @@ export interface ILedgerEntry {
date: Date | string;
transactionType: string;
transactionSubType: string;
transactionSubType?: string;
transactionId: number;

View File

@@ -1,69 +1,12 @@
import { forEach } from 'lodash';
import { Configuration, PlaidApi, PlaidEnvironments } from 'plaid';
import { createPlaidApiEvent } from './PlaidApiEventsDBSync';
import config from '@/config';
const OPTIONS = { clientApp: 'Plaid-Pattern' };
// We want to log requests to / responses from the Plaid API (via the Plaid client), as this data
// can be useful for troubleshooting.
/**
* Logging function for Plaid client methods that use an access_token as an argument. Associates
* the Plaid API event log entry with the item and user the request is for.
*
* @param {string} clientMethod the name of the Plaid client method called.
* @param {Array} clientMethodArgs the arguments passed to the Plaid client method.
* @param {Object} response the response from the Plaid client.
*/
const defaultLogger = async (clientMethod, clientMethodArgs, response) => {
const accessToken = clientMethodArgs[0].access_token;
// const { id: itemId, user_id: userId } = await retrieveItemByPlaidAccessToken(
// accessToken
// );
// await createPlaidApiEvent(1, 1, clientMethod, clientMethodArgs, response);
// console.log(response);
};
/**
* Logging function for Plaid client methods that do not use access_token as an argument. These
* Plaid API event log entries will not be associated with an item or user.
*
* @param {string} clientMethod the name of the Plaid client method called.
* @param {Array} clientMethodArgs the arguments passed to the Plaid client method.
* @param {Object} response the response from the Plaid client.
*/
const noAccessTokenLogger = async (
clientMethod,
clientMethodArgs,
response
) => {
// console.log(response);
// await createPlaidApiEvent(
// undefined,
// undefined,
// clientMethod,
// clientMethodArgs,
// response
// );
};
// Plaid client methods used in this app, mapped to their appropriate logging functions.
const clientMethodLoggingFns = {
accountsGet: defaultLogger,
institutionsGet: noAccessTokenLogger,
institutionsGetById: noAccessTokenLogger,
itemPublicTokenExchange: noAccessTokenLogger,
itemRemove: defaultLogger,
linkTokenCreate: noAccessTokenLogger,
transactionsSync: defaultLogger,
sandboxItemResetLogin: defaultLogger,
};
// Wrapper for the Plaid client. This allows us to easily log data for all Plaid client requests.
export class PlaidClientWrapper {
constructor() {
private static instance: PlaidClientWrapper;
private client: PlaidApi;
private constructor() {
// Initialize the Plaid client.
const configuration = new Configuration({
basePath: PlaidEnvironments[config.plaid.env],
@@ -75,26 +18,13 @@ export class PlaidClientWrapper {
},
},
});
this.client = new PlaidApi(configuration);
// Wrap the Plaid client methods to add a logging function.
forEach(clientMethodLoggingFns, (logFn, method) => {
this[method] = this.createWrappedClientMethod(method, logFn);
});
}
// Allows us to log API request data for troubleshooting purposes.
createWrappedClientMethod(clientMethod, log) {
return async (...args) => {
try {
const res = await this.client[clientMethod](...args);
await log(clientMethod, args, res);
return res;
} catch (err) {
await log(clientMethod, args, err?.response?.data);
throw err;
}
};
public static getClient(): PlaidApi {
if (!PlaidClientWrapper.instance) {
PlaidClientWrapper.instance = new PlaidClientWrapper();
}
return PlaidClientWrapper.instance.client;
}
}

View File

@@ -8,4 +8,5 @@ export const s3 = new S3Client({
secretAccessKey: config.s3.secretAccessKey,
},
endpoint: config.s3.endpoint,
forcePathStyle: config.s3.forcePathStyle,
});

View File

@@ -113,6 +113,8 @@ import { UnlinkBankRuleOnDeleteBankRule } from '@/services/Banking/Rules/events/
import { DecrementUncategorizedTransactionOnMatching } from '@/services/Banking/Matching/events/DecrementUncategorizedTransactionsOnMatch';
import { DecrementUncategorizedTransactionOnExclude } from '@/services/Banking/Exclude/events/DecrementUncategorizedTransactionOnExclude';
import { DecrementUncategorizedTransactionOnCategorize } from '@/services/Cashflow/subscribers/DecrementUncategorizedTransactionOnCategorize';
import { DisconnectPlaidItemOnAccountDeleted } from '@/services/Banking/BankAccounts/events/DisconnectPlaidItemOnAccountDeleted';
import { LoopsEventsSubscriber } from '@/services/Loops/LoopsEventsSubscriber';
export default () => {
return new EventPublisher();
@@ -274,5 +276,9 @@ export const susbcribers = () => {
// Plaid
RecognizeSyncedBankTranasctions,
DisconnectPlaidItemOnAccountDeleted,
// Loops
LoopsEventsSubscriber
];
};

View File

@@ -13,7 +13,9 @@ import { PaymentReceiveMailNotificationJob } from '@/services/Sales/PaymentRecei
import { PlaidFetchTransactionsJob } from '@/services/Banking/Plaid/PlaidFetchTransactionsJob';
import { ImportDeleteExpiredFilesJobs } from '@/services/Import/jobs/ImportDeleteExpiredFilesJob';
import { SendVerifyMailJob } from '@/services/Authentication/jobs/SendVerifyMailJob';
import { RegonizeTransactionsJob } from '@/services/Banking/RegonizeTranasctions/RecognizeTransactionsJob';
import { ReregonizeTransactionsJob } from '@/services/Banking/RegonizeTranasctions/jobs/RerecognizeTransactionsJob';
import { RegonizeTransactionsJob } from '@/services/Banking/RegonizeTranasctions/jobs/RecognizeTransactionsJob';
import { RevertRegonizeTransactionsJob } from '@/services/Banking/RegonizeTranasctions/jobs/RevertRecognizedTransactionsJob';
export default ({ agenda }: { agenda: Agenda }) => {
new ResetPasswordMailJob(agenda);
@@ -31,6 +33,8 @@ export default ({ agenda }: { agenda: Agenda }) => {
new ImportDeleteExpiredFilesJobs(agenda);
new SendVerifyMailJob(agenda);
new RegonizeTransactionsJob(agenda);
new ReregonizeTransactionsJob(agenda);
new RevertRegonizeTransactionsJob(agenda);
agenda.start().then(() => {
agenda.every('1 hours', 'delete-expired-imported-files', {});

View File

@@ -197,6 +197,7 @@ export default class Account extends mixin(TenantModel, [
const ExpenseEntry = require('models/ExpenseCategory');
const ItemEntry = require('models/ItemEntry');
const UncategorizedTransaction = require('models/UncategorizedCashflowTransaction');
const PlaidItem = require('models/PlaidItem');
return {
/**
@@ -321,6 +322,18 @@ export default class Account extends mixin(TenantModel, [
query.where('categorized', false);
},
},
/**
* Account model may belongs to a Plaid item.
*/
plaidItem: {
relation: Model.BelongsToOneRelation,
modelClass: PlaidItem.default,
join: {
from: 'accounts.plaidItemId',
to: 'plaid_items.plaidItemId',
},
},
};
}

View File

@@ -525,9 +525,9 @@ export default class Bill extends mixin(TenantModel, [
return notFoundBillsIds;
}
static changePaymentAmount(billId, amount) {
static changePaymentAmount(billId, amount, trx) {
const changeMethod = amount > 0 ? 'increment' : 'decrement';
return this.query()
return this.query(trx)
.where('id', billId)
[changeMethod]('payment_amount', Math.abs(amount));
}

View File

@@ -1,6 +1,8 @@
import TenantModel from 'models/TenantModel';
export default class PlaidItem extends TenantModel {
pausedAt: Date;
/**
* Table name.
*/
@@ -21,4 +23,19 @@ export default class PlaidItem extends TenantModel {
static get relationMappings() {
return {};
}
/**
* Virtual attributes.
*/
static get virtualAttributes() {
return ['isPaused'];
}
/**
* Detarmines whether the Plaid item feeds syncing is paused.
* @return {boolean}
*/
get isPaused() {
return !!this.pausedAt;
}
}

View File

@@ -20,6 +20,7 @@ export default class UncategorizedCashflowTransaction extends mixin(
description!: string;
plaidTransactionId!: string;
recognizedTransactionId!: number;
excludedAt: Date;
/**
* Table name.
@@ -31,7 +32,7 @@ export default class UncategorizedCashflowTransaction extends mixin(
/**
* Timestamps columns.
*/
static get timestamps() {
get timestamps() {
return ['createdAt', 'updatedAt'];
}
@@ -45,6 +46,7 @@ export default class UncategorizedCashflowTransaction extends mixin(
'isDepositTransaction',
'isWithdrawalTransaction',
'isRecognized',
'isExcluded'
];
}
@@ -89,6 +91,14 @@ export default class UncategorizedCashflowTransaction extends mixin(
return !!this.recognizedTransactionId;
}
/**
* Detarmines whether the transaction is excluded.
* @returns {boolean}
*/
public get isExcluded(): boolean {
return !!this.excludedAt;
}
/**
* Model modifiers.
*/

View File

@@ -2,7 +2,12 @@ import { Account } from 'models';
import TenantRepository from '@/repositories/TenantRepository';
import { IAccount } from '@/interfaces';
import { Knex } from 'knex';
import { TaxPayableAccount } from '@/database/seeds/data/accounts';
import {
PrepardExpenses,
TaxPayableAccount,
UnearnedRevenueAccount,
} from '@/database/seeds/data/accounts';
import { TenantMetadata } from '@/system/models';
export default class AccountRepository extends TenantRepository {
/**
@@ -179,4 +184,67 @@ export default class AccountRepository extends TenantRepository {
}
return result;
};
/**
* Finds or creates the unearned revenue.
* @param {Record<string, string>} extraAttrs
* @param {Knex.Transaction} trx
* @returns
*/
public async findOrCreateUnearnedRevenue(
extraAttrs: Record<string, string> = {},
trx?: Knex.Transaction
) {
// Retrieves the given tenant metadata.
const tenantMeta = await TenantMetadata.query().findOne({
tenantId: this.tenantId,
});
const _extraAttrs = {
currencyCode: tenantMeta.baseCurrency,
...extraAttrs,
};
let result = await this.model
.query(trx)
.findOne({ slug: UnearnedRevenueAccount.slug, ..._extraAttrs });
if (!result) {
result = await this.model.query(trx).insertAndFetch({
...UnearnedRevenueAccount,
..._extraAttrs,
});
}
return result;
}
/**
* Finds or creates the prepard expenses account.
* @param {Record<string, string>} extraAttrs
* @param {Knex.Transaction} trx
* @returns
*/
public async findOrCreatePrepardExpenses(
extraAttrs: Record<string, string> = {},
trx?: Knex.Transaction
) {
// Retrieves the given tenant metadata.
const tenantMeta = await TenantMetadata.query().findOne({
tenantId: this.tenantId,
});
const _extraAttrs = {
currencyCode: tenantMeta.baseCurrency,
...extraAttrs,
};
let result = await this.model
.query(trx)
.findOne({ slug: PrepardExpenses.slug, ..._extraAttrs });
if (!result) {
result = await this.model.query(trx).insertAndFetch({
...PrepardExpenses,
..._extraAttrs,
});
}
return result;
}
}

View File

@@ -4,12 +4,17 @@ import CachableRepository from './CachableRepository';
export default class TenantRepository extends CachableRepository {
repositoryName: string;
tenantId: number;
/**
* Constructor method.
* @param {number} tenantId
* @param {number} tenantId
*/
constructor(knex, cache, i18n) {
super(knex, cache, i18n);
}
}
setTenantId(tenantId: number) {
this.tenantId = tenantId;
}
}

View File

@@ -13,7 +13,21 @@ export class AccountTransformer extends Transformer {
* @returns {Array}
*/
public includeAttributes = (): string[] => {
return ['formattedAmount', 'flattenName', 'bankBalanceFormatted'];
return [
'formattedAmount',
'flattenName',
'bankBalanceFormatted',
'lastFeedsUpdatedAtFormatted',
'isFeedsPaused',
];
};
/**
* Exclude attributes.
* @returns {string[]}
*/
public excludeAttributes = (): string[] => {
return ['plaidItem'];
};
/**
@@ -52,6 +66,24 @@ export class AccountTransformer extends Transformer {
});
};
/**
* Retrieves the formatted last feeds update at.
* @param {IAccount} account
* @returns {string}
*/
protected lastFeedsUpdatedAtFormatted = (account: IAccount): string => {
return this.formatDate(account.lastFeedsUpdatedAt);
};
/**
* Detarmines whether the bank account connection is paused.
* @param account
* @returns {boolean}
*/
protected isFeedsPaused = (account: any): boolean => {
return account.plaidItem?.isPaused || false;
};
/**
* Transformes the accounts collection to flat or nested array.
* @param {IAccount[]}

View File

@@ -96,6 +96,11 @@ export class CreateAccount {
...createAccountDTO,
slug: kebabCase(createAccountDTO.name),
currencyCode: createAccountDTO.currencyCode || baseCurrency,
// Mark the account is Plaid owner since Plaid item/account is defined on creating.
isSyncingOwner: Boolean(
createAccountDTO.plaidAccountId || createAccountDTO.plaidItemId
),
};
};
@@ -117,12 +122,7 @@ export class CreateAccount {
const tenantMeta = await TenantMetadata.query().findOne({ tenantId });
// Authorize the account creation.
await this.authorize(
tenantId,
accountDTO,
tenantMeta.baseCurrency,
params
);
await this.authorize(tenantId, accountDTO, tenantMeta.baseCurrency, params);
// Transformes the DTO to model.
const accountInputModel = this.transformDTOToModel(
accountDTO,
@@ -157,4 +157,3 @@ export class CreateAccount {
);
};
}

View File

@@ -25,7 +25,10 @@ export class GetAccount {
const { accountRepository } = this.tenancy.repositories(tenantId);
// Find the given account or throw not found error.
const account = await Account.query().findById(accountId).throwIfNotFound();
const account = await Account.query()
.findById(accountId)
.withGraphFetched('plaidItem')
.throwIfNotFound();
const accountsGraph = await accountRepository.getDependencyGraph();

View File

@@ -96,10 +96,11 @@ export class AttachmentsApplication {
/**
* Retrieves the presigned url of the given attachment key.
* @param {number} tenantId
* @param {string} key
* @returns {Promise<string>}
*/
public getPresignedUrl(key: string): Promise<string> {
return this.getPresignedUrlService.getPresignedUrl(key);
public getPresignedUrl(tenantId: number, key: string): Promise<string> {
return this.getPresignedUrlService.getPresignedUrl(tenantId, key);
}
}

View File

@@ -1,20 +1,34 @@
import { Service } from 'typedi';
import { Inject, Service } from 'typedi';
import { GetObjectCommand } from '@aws-sdk/client-s3';
import { getSignedUrl } from '@aws-sdk/s3-request-presigner';
import { s3 } from '@/lib/S3/S3';
import config from '@/config';
import HasTenancyService from '../Tenancy/TenancyService';
@Service()
export class getAttachmentPresignedUrl {
@Inject()
private tenancy: HasTenancyService;
/**
* Retrieves the presigned url of the given attachment key.
* Retrieves the presigned url of the given attachment key with the original filename.
* @param {number} tenantId
* @param {string} key
* @returns {Promise<string?>}
* @returns {string}
*/
async getPresignedUrl(key: string) {
async getPresignedUrl(tenantId: number, key: string) {
const { Document } = this.tenancy.models(tenantId);
const foundDocument = await Document.query().findOne({ key });
let ResponseContentDisposition = 'attachment';
if (foundDocument && foundDocument.originName) {
ResponseContentDisposition += `; filename="${foundDocument.originName}"`;
}
const command = new GetObjectCommand({
Bucket: config.s3.bucket,
Key: key,
ResponseContentDisposition,
});
const signedUrl = await getSignedUrl(s3, command, { expiresIn: 300 });

View File

@@ -0,0 +1,72 @@
import { Inject, Service } from 'typedi';
import { DisconnectBankAccount } from './DisconnectBankAccount';
import { RefreshBankAccountService } from './RefreshBankAccount';
import { PauseBankAccountFeeds } from './PauseBankAccountFeeds';
import { ResumeBankAccountFeeds } from './ResumeBankAccountFeeds';
@Service()
export class BankAccountsApplication {
@Inject()
private disconnectBankAccountService: DisconnectBankAccount;
@Inject()
private refreshBankAccountService: RefreshBankAccountService;
@Inject()
private resumeBankAccountFeedsService: ResumeBankAccountFeeds;
@Inject()
private pauseBankAccountFeedsService: PauseBankAccountFeeds;
/**
* Disconnects the given bank account.
* @param {number} tenantId
* @param {number} bankAccountId
* @returns {Promise<void>}
*/
async disconnectBankAccount(tenantId: number, bankAccountId: number) {
return this.disconnectBankAccountService.disconnectBankAccount(
tenantId,
bankAccountId
);
}
/**
* Refresh the bank transactions of the given bank account.
* @param {number} tenantId
* @param {number} bankAccountId
* @returns {Promise<void>}
*/
async refreshBankAccount(tenantId: number, bankAccountId: number) {
return this.refreshBankAccountService.refreshBankAccount(
tenantId,
bankAccountId
);
}
/**
* Pauses the feeds sync of the given bank account.
* @param {number} tenantId
* @param {number} bankAccountId
* @returns {Promise<void>}
*/
async pauseBankAccount(tenantId: number, bankAccountId: number) {
return this.pauseBankAccountFeedsService.pauseBankAccountFeeds(
tenantId,
bankAccountId
);
}
/**
* Resumes the feeds sync of the given bank account.
* @param {number} tenantId
* @param {number} bankAccountId
* @returns {Promise<void>}
*/
async resumeBankAccount(tenantId: number, bankAccountId: number) {
return this.resumeBankAccountFeedsService.resumeBankAccountFeeds(
tenantId,
bankAccountId
);
}
}

View File

@@ -0,0 +1,78 @@
import { Knex } from 'knex';
import { Inject, Service } from 'typedi';
import { ServiceError } from '@/exceptions';
import { EventPublisher } from '@/lib/EventPublisher/EventPublisher';
import { PlaidClientWrapper } from '@/lib/Plaid';
import HasTenancyService from '@/services/Tenancy/TenancyService';
import UnitOfWork from '@/services/UnitOfWork';
import events from '@/subscribers/events';
import {
ERRORS,
IBankAccountDisconnectedEventPayload,
IBankAccountDisconnectingEventPayload,
} from './types';
import { ACCOUNT_TYPE } from '@/data/AccountTypes';
@Service()
export class DisconnectBankAccount {
@Inject()
private tenancy: HasTenancyService;
@Inject()
private eventPublisher: EventPublisher;
@Inject()
private uow: UnitOfWork;
/**
* Disconnects the given bank account.
* @param {number} tenantId
* @param {number} bankAccountId
* @returns {Promise<void>}
*/
public async disconnectBankAccount(tenantId: number, bankAccountId: number) {
const { Account, PlaidItem } = this.tenancy.models(tenantId);
// Retrieve the bank account or throw not found error.
const account = await Account.query()
.findById(bankAccountId)
.whereIn('account_type', [ACCOUNT_TYPE.CASH, ACCOUNT_TYPE.BANK])
.withGraphFetched('plaidItem')
.throwIfNotFound();
const oldPlaidItem = account.plaidItem;
if (!oldPlaidItem) {
throw new ServiceError(ERRORS.BANK_ACCOUNT_NOT_CONNECTED);
}
const plaidInstance = PlaidClientWrapper.getClient();
return this.uow.withTransaction(tenantId, async (trx: Knex.Transaction) => {
// Triggers `onBankAccountDisconnecting` event.
await this.eventPublisher.emitAsync(events.bankAccount.onDisconnecting, {
tenantId,
bankAccountId,
} as IBankAccountDisconnectingEventPayload);
// Remove the Plaid item from the system.
await PlaidItem.query(trx).findById(account.plaidItemId).delete();
// Remove the plaid item association to the bank account.
await Account.query(trx).findById(bankAccountId).patch({
plaidAccountId: null,
plaidItemId: null,
isFeedsActive: false,
});
// Remove the Plaid item.
await plaidInstance.itemRemove({
access_token: oldPlaidItem.plaidAccessToken,
});
// Triggers `onBankAccountDisconnected` event.
await this.eventPublisher.emitAsync(events.bankAccount.onDisconnected, {
tenantId,
bankAccountId,
trx,
} as IBankAccountDisconnectedEventPayload);
});
}
}

View File

@@ -1,6 +1,7 @@
import { Inject, Service } from 'typedi';
import { initialize } from 'objection';
import HasTenancyService from '@/services/Tenancy/TenancyService';
import { UncategorizedTransactionTransformer } from '@/services/Cashflow/UncategorizedTransactionTransformer';
@Service()
export class GetBankAccountSummary {
@@ -31,17 +32,21 @@ export class GetBankAccountSummary {
.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 UncategorizedCashflowTransaction.query().onBuild((q) => {
// Include just the given account.
q.where('accountId', bankAccountId);
// Only the not excluded.
q.modify('notExcluded');
// Only the not categorized.
q.modify('notCategorized');
commonQuery(q);
// Only the not matched bank transactions.
q.withGraphJoined('matchedBankTransactions');
@@ -52,25 +57,40 @@ export class GetBankAccountSummary {
q.first();
});
// Retrieves the recognized transactions count of the given bank account.
const recognizedTransactionsCount = await RecognizedBankTransaction.query()
.whereExists(
UncategorizedCashflowTransaction.query().where(
'accountId',
bankAccountId
)
)
.count('id as total')
.first();
// Retrives the recognized transactions count.
const recognizedTransactionsCount =
await UncategorizedCashflowTransaction.query().onBuild((q) => {
commonQuery(q);
q.withGraphJoined('recognizedTransaction');
q.whereNotNull('recognizedTransaction.id');
// Count the results.
q.count('uncategorized_cashflow_transactions.id as total');
q.first();
});
const excludedTransactionsCount =
await UncategorizedCashflowTransaction.query().onBuild((q) => {
q.where('accountId', bankAccountId);
q.modify('excluded');
// 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;
return {
name: bankAccount.name,
totalUncategorizedTransactions,
totalRecognizedTransactions,
totalExcludedTransactions,
};
}
}

View File

@@ -0,0 +1,44 @@
import { Inject, Service } from 'typedi';
import { Knex } from 'knex';
import HasTenancyService from '@/services/Tenancy/TenancyService';
import UnitOfWork from '@/services/UnitOfWork';
import { ServiceError } from '@/exceptions';
import { ERRORS } from './types';
@Service()
export class PauseBankAccountFeeds {
@Inject()
private tenancy: HasTenancyService;
@Inject()
private uow: UnitOfWork;
/**
* Pauses the bankfeed syncing of the given bank account.
* @param {number} tenantId
* @param {number} bankAccountId
* @returns {Promise<void>}
*/
public async pauseBankAccountFeeds(tenantId: number, bankAccountId: number) {
const { Account, PlaidItem } = this.tenancy.models(tenantId);
const oldAccount = await Account.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(tenantId, async (trx: Knex.Transaction) => {
await PlaidItem.query(trx).findById(oldAccount.plaidItem.id).patch({
pausedAt: new Date(),
});
});
}
}

View File

@@ -0,0 +1,36 @@
import { Inject, Service } from 'typedi';
import { ServiceError } from '@/exceptions';
import { PlaidClientWrapper } from '@/lib/Plaid';
import HasTenancyService from '@/services/Tenancy/TenancyService';
import { ERRORS } from './types';
@Service()
export class RefreshBankAccountService {
@Inject()
private tenancy: HasTenancyService;
/**
* Asks Plaid to trigger syncing the given bank account.
* @param {number} tenantId
* @param {number} bankAccountId
* @returns {Promise<void>}
*/
public async refreshBankAccount(tenantId: number, bankAccountId: number) {
const { Account } = this.tenancy.models(tenantId);
const bankAccount = await Account.query()
.findById(bankAccountId)
.withGraphFetched('plaidItem')
.throwIfNotFound();
// Can't continue if the given account is not linked with Plaid item.
if (!bankAccount.plaidItem) {
throw new ServiceError(ERRORS.BANK_ACCOUNT_NOT_CONNECTED);
}
const plaidInstance = PlaidClientWrapper.getClient();
await plaidInstance.transactionsRefresh({
access_token: bankAccount.plaidItem.plaidAccessToken,
});
}
}

View File

@@ -0,0 +1,43 @@
import { Inject, Service } from 'typedi';
import { Knex } from 'knex';
import HasTenancyService from '@/services/Tenancy/TenancyService';
import UnitOfWork from '@/services/UnitOfWork';
import { ServiceError } from '@/exceptions';
import { ERRORS } from './types';
@Service()
export class ResumeBankAccountFeeds {
@Inject()
private tenancy: HasTenancyService;
@Inject()
private uow: UnitOfWork;
/**
* Resumes the bank feeds syncing of the bank account.
* @param {number} tenantId
* @param {number} bankAccountId
* @returns {Promise<void>}
*/
public async resumeBankAccountFeeds(tenantId: number, bankAccountId: number) {
const { Account, PlaidItem } = this.tenancy.models(tenantId);
const oldAccount = await Account.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(tenantId, async (trx: Knex.Transaction) => {
await PlaidItem.query(trx).findById(oldAccount.plaidItem.id).patch({
pausedAt: null,
});
});
}
}

View File

@@ -0,0 +1,63 @@
import { Inject, Service } from 'typedi';
import { IAccountEventDeletedPayload } from '@/interfaces';
import { PlaidClientWrapper } from '@/lib/Plaid';
import HasTenancyService from '@/services/Tenancy/TenancyService';
import events from '@/subscribers/events';
@Service()
export class DisconnectPlaidItemOnAccountDeleted {
@Inject()
private tenancy: HasTenancyService;
/**
* Constructor method.
*/
public attach(bus) {
bus.subscribe(
events.accounts.onDeleted,
this.handleDisconnectPlaidItemOnAccountDelete.bind(this)
);
}
/**
* Deletes Plaid item from the system and Plaid once the account deleted.
* @param {IAccountEventDeletedPayload} payload
* @returns {Promise<void>}
*/
private async handleDisconnectPlaidItemOnAccountDelete({
tenantId,
oldAccount,
trx,
}: IAccountEventDeletedPayload) {
const { PlaidItem, Account } = this.tenancy.models(tenantId);
// Can't continue if the deleted account is not linked to Plaid item.
if (!oldAccount.plaidItemId) return;
// Retrieves the Plaid item that associated to the deleted account.
const oldPlaidItem = await PlaidItem.query(trx).findOne(
'plaidItemId',
oldAccount.plaidItemId
);
// Unlink the Plaid item from all account before deleting it.
await Account.query(trx)
.where('plaidItemId', oldAccount.plaidItemId)
.patch({
plaidAccountId: null,
plaidItemId: null,
});
// Remove the Plaid item from the system.
await PlaidItem.query(trx)
.findOne('plaidItemId', oldAccount.plaidItemId)
.delete();
if (oldPlaidItem) {
const plaidInstance = PlaidClientWrapper.getClient();
// Remove the Plaid item.
await plaidInstance.itemRemove({
access_token: oldPlaidItem.plaidAccessToken,
});
}
}
}

View File

@@ -0,0 +1,19 @@
import { Knex } from 'knex';
export interface IBankAccountDisconnectingEventPayload {
tenantId: number;
bankAccountId: number;
trx: Knex.Transaction;
}
export interface IBankAccountDisconnectedEventPayload {
tenantId: number;
bankAccountId: number;
trx: Knex.Transaction;
}
export const ERRORS = {
BANK_ACCOUNT_NOT_CONNECTED: 'BANK_ACCOUNT_NOT_CONNECTED',
BANK_ACCOUNT_FEEDS_ALREADY_PAUSED: 'BANK_ACCOUNT_FEEDS_ALREADY_PAUSED',
BANK_ACCOUNT_FEEDS_ALREADY_RESUMED: 'BANK_ACCOUNT_FEEDS_ALREADY_RESUMED',
};

View File

@@ -1,7 +1,11 @@
import { Knex } from 'knex';
import { Inject, Service } from 'typedi';
import HasTenancyService from '@/services/Tenancy/TenancyService';
import UnitOfWork from '@/services/UnitOfWork';
import { Inject, Service } from 'typedi';
import { validateTransactionNotCategorized } from './utils';
import {
validateTransactionNotCategorized,
validateTransactionNotExcluded,
} from './utils';
import { EventPublisher } from '@/lib/EventPublisher/EventPublisher';
import events from '@/subscribers/events';
import {
@@ -37,9 +41,13 @@ export class ExcludeBankTransaction {
.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(tenantId, async (trx) => {
return this.uow.withTransaction(tenantId, async (trx: Knex.Transaction) => {
await this.eventPublisher.emitAsync(events.bankTransactions.onExcluding, {
tenantId,
uncategorizedTransactionId,

View File

@@ -0,0 +1,32 @@
import { Inject, Service } from 'typedi';
import PromisePool from '@supercharge/promise-pool';
import { castArray, uniq } from 'lodash';
import { ExcludeBankTransaction } from './ExcludeBankTransaction';
@Service()
export class ExcludeBankTransactions {
@Inject()
private excludeBankTransaction: ExcludeBankTransaction;
/**
* Exclude bank transactions in bulk.
* @param {number} tenantId
* @param {number} bankTransactionIds
* @returns {Promise<void>}
*/
public async excludeBankTransactions(
tenantId: number,
bankTransactionIds: Array<number> | number
) {
const _bankTransactionIds = uniq(castArray(bankTransactionIds));
await PromisePool.withConcurrency(1)
.for(_bankTransactionIds)
.process((bankTransactionId: number) => {
return this.excludeBankTransaction.excludeBankTransaction(
tenantId,
bankTransactionId
);
});
}
}

View File

@@ -3,6 +3,8 @@ import { ExcludeBankTransaction } from './ExcludeBankTransaction';
import { UnexcludeBankTransaction } from './UnexcludeBankTransaction';
import { GetExcludedBankTransactionsService } from './GetExcludedBankTransactions';
import { ExcludedBankTransactionsQuery } from './_types';
import { UnexcludeBankTransactions } from './UnexcludeBankTransactions';
import { ExcludeBankTransactions } from './ExcludeBankTransactions';
@Service()
export class ExcludeBankTransactionsApplication {
@@ -15,6 +17,12 @@ export class ExcludeBankTransactionsApplication {
@Inject()
private getExcludedBankTransactionsService: GetExcludedBankTransactionsService;
@Inject()
private excludeBankTransactionsService: ExcludeBankTransactions;
@Inject()
private unexcludeBankTransactionsService: UnexcludeBankTransactions;
/**
* Marks a bank transaction as excluded.
* @param {number} tenantId - The ID of the tenant.
@@ -56,4 +64,36 @@ export class ExcludeBankTransactionsApplication {
filter
);
}
/**
* Exclude the given bank transactions in bulk.
* @param {number} tenantId
* @param {Array<number> | number} bankTransactionIds
* @returns {Promise<void>}
*/
public excludeBankTransactions(
tenantId: number,
bankTransactionIds: Array<number> | number
): Promise<void> {
return this.excludeBankTransactionsService.excludeBankTransactions(
tenantId,
bankTransactionIds
);
}
/**
* Exclude the given bank transactions in bulk.
* @param {number} tenantId
* @param {Array<number> | number} bankTransactionIds
* @returns {Promise<void>}
*/
public unexcludeBankTransactions(
tenantId: number,
bankTransactionIds: Array<number> | number
): Promise<void> {
return this.unexcludeBankTransactionsService.unexcludeBankTransactions(
tenantId,
bankTransactionIds
);
}
}

View File

@@ -1,7 +1,11 @@
import { Knex } from 'knex';
import { Inject, Service } from 'typedi';
import HasTenancyService from '@/services/Tenancy/TenancyService';
import UnitOfWork from '@/services/UnitOfWork';
import { Inject, Service } from 'typedi';
import { validateTransactionNotCategorized } from './utils';
import {
validateTransactionNotCategorized,
validateTransactionShouldBeExcluded,
} from './utils';
import { EventPublisher } from '@/lib/EventPublisher/EventPublisher';
import events from '@/subscribers/events';
import {
@@ -37,9 +41,13 @@ export class UnexcludeBankTransaction {
.findById(uncategorizedTransactionId)
.throwIfNotFound();
// Validate the transaction should be excludded.
validateTransactionShouldBeExcluded(oldUncategorizedTransaction);
// Validate the transaction shouldn't be categorized.
validateTransactionNotCategorized(oldUncategorizedTransaction);
return this.uow.withTransaction(tenantId, async (trx) => {
return this.uow.withTransaction(tenantId, async (trx: Knex.Transaction) => {
await this.eventPublisher.emitAsync(
events.bankTransactions.onUnexcluding,
{

View File

@@ -0,0 +1,31 @@
import { Inject, Service } from 'typedi';
import PromisePool from '@supercharge/promise-pool';
import { UnexcludeBankTransaction } from './UnexcludeBankTransaction';
import { castArray, uniq } from 'lodash';
@Service()
export class UnexcludeBankTransactions {
@Inject()
private unexcludeBankTransaction: UnexcludeBankTransaction;
/**
* Unexclude bank transactions in bulk.
* @param {number} tenantId
* @param {number} bankTransactionIds
*/
public async unexcludeBankTransactions(
tenantId: number,
bankTransactionIds: Array<number> | number
) {
const _bankTransactionIds = uniq(castArray(bankTransactionIds));
await PromisePool.withConcurrency(1)
.for(_bankTransactionIds)
.process((bankTransactionId: number) => {
return this.unexcludeBankTransaction.unexcludeBankTransaction(
tenantId,
bankTransactionId
);
});
}
}

View File

@@ -3,6 +3,8 @@ import UncategorizedCashflowTransaction from '@/models/UncategorizedCashflowTran
const ERRORS = {
TRANSACTION_ALREADY_CATEGORIZED: 'TRANSACTION_ALREADY_CATEGORIZED',
TRANSACTION_ALREADY_EXCLUDED: 'TRANSACTION_ALREADY_EXCLUDED',
TRANSACTION_NOT_EXCLUDED: 'TRANSACTION_NOT_EXCLUDED',
};
export const validateTransactionNotCategorized = (
@@ -12,3 +14,19 @@ export const validateTransactionNotCategorized = (
throw new ServiceError(ERRORS.TRANSACTION_ALREADY_CATEGORIZED);
}
};
export const validateTransactionNotExcluded = (
transaction: UncategorizedCashflowTransaction
) => {
if (transaction.isExcluded) {
throw new ServiceError(ERRORS.TRANSACTION_ALREADY_EXCLUDED);
}
};
export const validateTransactionShouldBeExcluded = (
transaction: UncategorizedCashflowTransaction
) => {
if (!transaction.isExcluded) {
throw new ServiceError(ERRORS.TRANSACTION_NOT_EXCLUDED);
}
};

View File

@@ -1,6 +1,7 @@
import { Inject, Service } from 'typedi';
import * as R from 'ramda';
import moment from 'moment';
import { first, sumBy } from 'lodash';
import { PromisePool } from '@supercharge/promise-pool';
import { GetMatchedTransactionsFilter, MatchedTransactionsPOJO } from './types';
import { GetMatchedTransactionsByExpenses } from './GetMatchedTransactionsByExpenses';
@@ -47,21 +48,24 @@ export class GetMatchedTransactions {
/**
* Retrieves the matched transactions.
* @param {number} tenantId -
* @param {Array<number>} uncategorizedTransactionIds - Uncategorized transactions ids.
* @param {GetMatchedTransactionsFilter} filter -
* @returns {Promise<MatchedTransactionsPOJO>}
*/
public async getMatchedTransactions(
tenantId: number,
uncategorizedTransactionId: number,
uncategorizedTransactionIds: Array<number>,
filter: GetMatchedTransactionsFilter
): Promise<MatchedTransactionsPOJO> {
const { UncategorizedCashflowTransaction } = this.tenancy.models(tenantId);
const uncategorizedTransaction =
const uncategorizedTransactions =
await UncategorizedCashflowTransaction.query()
.findById(uncategorizedTransactionId)
.whereIn('id', uncategorizedTransactionIds)
.throwIfNotFound();
const totalPending = Math.abs(sumBy(uncategorizedTransactions, 'amount'));
const filtered = filter.transactionType
? this.registered.filter((item) => item.type === filter.transactionType)
: this.registered;
@@ -71,14 +75,14 @@ export class GetMatchedTransactions {
.process(async ({ type, service }) => {
return service.getMatchedTransactions(tenantId, filter);
});
const { perfectMatches, possibleMatches } = this.groupMatchedResults(
uncategorizedTransaction,
uncategorizedTransactions,
matchedTransactions
);
return {
perfectMatches,
possibleMatches,
totalPending,
};
}
@@ -90,20 +94,20 @@ export class GetMatchedTransactions {
* @returns {MatchedTransactionsPOJO}
*/
private groupMatchedResults(
uncategorizedTransaction,
uncategorizedTransactions: Array<any>,
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(
uncategorizedTransaction,
results
);
const closestResullts = sortClosestMatchTransactions(amount, date, results);
const perfectMatches = R.filter(
(match) =>
match.amount === uncategorizedTransaction.amount &&
moment(match.date).isSame(uncategorizedTransaction.date, 'day'),
match.amount === amount && moment(match.date).isSame(date, 'day'),
closestResullts
);
const possibleMatches = R.difference(closestResullts, perfectMatches);

View File

@@ -7,6 +7,7 @@ import {
MatchedTransactionsPOJO,
} from './types';
import { Inject, Service } from 'typedi';
import PromisePool from '@supercharge/promise-pool';
export abstract class GetMatchedTransactionsByType {
@Inject()
@@ -43,24 +44,28 @@ export abstract class GetMatchedTransactionsByType {
}
/**
*
* Creates the common matched transaction.
* @param {number} tenantId
* @param {number} uncategorizedTransactionId
* @param {Array<number>} uncategorizedTransactionIds
* @param {IMatchTransactionDTO} matchTransactionDTO
* @param {Knex.Transaction} trx
*/
public async createMatchedTransaction(
tenantId: number,
uncategorizedTransactionId: number,
uncategorizedTransactionIds: Array<number>,
matchTransactionDTO: IMatchTransactionDTO,
trx?: Knex.Transaction
) {
const { MatchedBankTransaction } = this.tenancy.models(tenantId);
await MatchedBankTransaction.query(trx).insert({
uncategorizedTransactionId,
referenceType: matchTransactionDTO.referenceType,
referenceId: matchTransactionDTO.referenceId,
});
await PromisePool.withConcurrency(2)
.for(uncategorizedTransactionIds)
.process(async (uncategorizedTransactionId) => {
await MatchedBankTransaction.query(trx).insert({
uncategorizedTransactionId,
referenceType: matchTransactionDTO.referenceType,
referenceId: matchTransactionDTO.referenceId,
});
});
}
}

View File

@@ -2,7 +2,7 @@ import { Inject, Service } from 'typedi';
import { GetMatchedTransactions } from './GetMatchedTransactions';
import { MatchBankTransactions } from './MatchTransactions';
import { UnmatchMatchedBankTransaction } from './UnmatchMatchedTransaction';
import { GetMatchedTransactionsFilter, IMatchTransactionsDTO } from './types';
import { GetMatchedTransactionsFilter, IMatchTransactionDTO } from './types';
@Service()
export class MatchBankTransactionsApplication {
@@ -23,12 +23,12 @@ export class MatchBankTransactionsApplication {
*/
public getMatchedTransactions(
tenantId: number,
uncategorizedTransactionId: number,
uncategorizedTransactionsIds: Array<number>,
filter: GetMatchedTransactionsFilter
) {
return this.getMatchedTransactionsService.getMatchedTransactions(
tenantId,
uncategorizedTransactionId,
uncategorizedTransactionsIds,
filter
);
}
@@ -42,13 +42,13 @@ export class MatchBankTransactionsApplication {
*/
public matchTransaction(
tenantId: number,
uncategorizedTransactionId: number,
matchTransactionsDTO: IMatchTransactionsDTO
uncategorizedTransactionId: number | Array<number>,
matchedTransactions: Array<IMatchTransactionDTO>
): Promise<void> {
return this.matchTransactionService.matchTransaction(
tenantId,
uncategorizedTransactionId,
matchTransactionsDTO
matchedTransactions
);
}

View File

@@ -1,4 +1,4 @@
import { isEmpty } from 'lodash';
import { castArray } from 'lodash';
import { Knex } from 'knex';
import { Inject, Service } from 'typedi';
import { PromisePool } from '@supercharge/promise-pool';
@@ -10,11 +10,16 @@ import {
ERRORS,
IBankTransactionMatchedEventPayload,
IBankTransactionMatchingEventPayload,
IMatchTransactionsDTO,
IMatchTransactionDTO,
} from './types';
import { MatchTransactionsTypes } from './MatchTransactionsTypes';
import { ServiceError } from '@/exceptions';
import { sumMatchTranasctions } from './_utils';
import {
sumMatchTranasctions,
sumUncategorizedTransactions,
validateUncategorizedTransactionsExcluded,
validateUncategorizedTransactionsNotMatched,
} from './_utils';
@Service()
export class MatchBankTransactions {
@@ -39,27 +44,25 @@ export class MatchBankTransactions {
*/
async validate(
tenantId: number,
uncategorizedTransactionId: number,
matchTransactionsDTO: IMatchTransactionsDTO
uncategorizedTransactionId: number | Array<number>,
matchedTransactions: Array<IMatchTransactionDTO>
) {
const { UncategorizedCashflowTransaction } = this.tenancy.models(tenantId);
const { matchedTransactions } = matchTransactionsDTO;
const uncategorizedTransactionIds = castArray(uncategorizedTransactionId);
// Validates the uncategorized transaction existance.
const uncategorizedTransaction =
const uncategorizedTransactions =
await UncategorizedCashflowTransaction.query()
.findById(uncategorizedTransactionId)
.whereIn('id', uncategorizedTransactionIds)
.withGraphFetched('matchedBankTransactions')
.throwIfNotFound();
// Validates the uncategorized transaction is not already matched.
if (!isEmpty(uncategorizedTransaction.matchedBankTransactions)) {
throw new ServiceError(ERRORS.TRANSACTION_ALREADY_MATCHED);
}
validateUncategorizedTransactionsNotMatched(uncategorizedTransactions);
// Validate the uncategorized transaction is not excluded.
if (uncategorizedTransaction.excluded) {
throw new ServiceError(ERRORS.CANNOT_MATCH_EXCLUDED_TRANSACTION);
}
validateUncategorizedTransactionsExcluded(uncategorizedTransactions);
// Validates the given matched transaction.
const validateMatchedTransaction = async (matchedTransaction) => {
const getMatchedTransactionsService =
@@ -94,9 +97,12 @@ export class MatchBankTransactions {
const totalMatchedTranasctions = sumMatchTranasctions(
validatationResult.results
);
const totalUncategorizedTransactions = sumUncategorizedTransactions(
uncategorizedTransactions
);
// Validates the total given matching transcations whether is not equal
// uncategorized transaction amount.
if (totalMatchedTranasctions !== uncategorizedTransaction.amount) {
if (totalUncategorizedTransactions !== totalMatchedTranasctions) {
throw new ServiceError(ERRORS.TOTAL_MATCHING_TRANSACTIONS_INVALID);
}
}
@@ -109,23 +115,23 @@ export class MatchBankTransactions {
*/
public async matchTransaction(
tenantId: number,
uncategorizedTransactionId: number,
matchTransactionsDTO: IMatchTransactionsDTO
uncategorizedTransactionId: number | Array<number>,
matchedTransactions: Array<IMatchTransactionDTO>
): Promise<void> {
const { matchedTransactions } = matchTransactionsDTO;
const uncategorizedTransactionIds = castArray(uncategorizedTransactionId);
// Validates the given matching transactions DTO.
await this.validate(
tenantId,
uncategorizedTransactionId,
matchTransactionsDTO
uncategorizedTransactionIds,
matchedTransactions
);
return this.uow.withTransaction(tenantId, async (trx: Knex.Transaction) => {
// Triggers the event `onBankTransactionMatching`.
await this.eventPublisher.emitAsync(events.bankMatch.onMatching, {
tenantId,
uncategorizedTransactionId,
matchTransactionsDTO,
uncategorizedTransactionIds,
matchedTransactions,
trx,
} as IBankTransactionMatchingEventPayload);
@@ -139,17 +145,16 @@ export class MatchBankTransactions {
);
await getMatchedTransactionsService.createMatchedTransaction(
tenantId,
uncategorizedTransactionId,
uncategorizedTransactionIds,
matchedTransaction,
trx
);
});
// Triggers the event `onBankTransactionMatched`.
await this.eventPublisher.emitAsync(events.bankMatch.onMatched, {
tenantId,
uncategorizedTransactionId,
matchTransactionsDTO,
uncategorizedTransactionIds,
matchedTransactions,
trx,
} as IBankTransactionMatchedEventPayload);
});

View File

@@ -1,22 +1,23 @@
import moment from 'moment';
import * as R from 'ramda';
import UncategorizedCashflowTransaction from '@/models/UncategorizedCashflowTransaction';
import { MatchedTransactionPOJO } from './types';
import { ERRORS, MatchedTransactionPOJO } from './types';
import { isEmpty, sumBy } from 'lodash';
import { ServiceError } from '@/exceptions';
export const sortClosestMatchTransactions = (
uncategorizedTransaction: UncategorizedCashflowTransaction,
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 - uncategorizedTransaction.amount)
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(uncategorizedTransaction.date), 'days')
)
Math.abs(moment(match.date).diff(moment(date), 'days'))
),
])(matches);
};
@@ -29,3 +30,36 @@ export const sumMatchTranasctions = (transactions: Array<any>) => {
0
);
};
export const sumUncategorizedTransactions = (
uncategorizedTransactions: Array<any>
) => {
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),
});
}
};

View File

@@ -5,6 +5,7 @@ import {
IBankTransactionUnmatchedEventPayload,
} from '../types';
import HasTenancyService from '@/services/Tenancy/TenancyService';
import PromisePool from '@supercharge/promise-pool';
@Service()
export class DecrementUncategorizedTransactionOnMatching {
@@ -30,18 +31,24 @@ export class DecrementUncategorizedTransactionOnMatching {
*/
public async decrementUnCategorizedTransactionsOnMatching({
tenantId,
uncategorizedTransactionId,
uncategorizedTransactionIds,
trx,
}: IBankTransactionMatchedEventPayload) {
const { UncategorizedCashflowTransaction, Account } =
this.tenancy.models(tenantId);
const transaction = await UncategorizedCashflowTransaction.query().findById(
uncategorizedTransactionId
);
await Account.query(trx)
.findById(transaction.accountId)
.decrement('uncategorizedTransactions', 1);
const uncategorizedTransactions =
await UncategorizedCashflowTransaction.query().whereIn(
'id',
uncategorizedTransactionIds
);
await PromisePool.withConcurrency(1)
.for(uncategorizedTransactions)
.process(async (transaction) => {
await Account.query(trx)
.findById(transaction.accountId)
.decrement('uncategorizedTransactions', 1);
});
}
/**

View File

@@ -2,15 +2,15 @@ import { Knex } from 'knex';
export interface IBankTransactionMatchingEventPayload {
tenantId: number;
uncategorizedTransactionId: number;
matchTransactionsDTO: IMatchTransactionsDTO;
uncategorizedTransactionIds: Array<number>;
matchedTransactions: Array<IMatchTransactionDTO>;
trx?: Knex.Transaction;
}
export interface IBankTransactionMatchedEventPayload {
tenantId: number;
uncategorizedTransactionId: number;
matchTransactionsDTO: IMatchTransactionsDTO;
uncategorizedTransactionIds: Array<number>;
matchedTransactions: Array<IMatchTransactionDTO>;
trx?: Knex.Transaction;
}
@@ -32,6 +32,7 @@ export interface IMatchTransactionDTO {
}
export interface IMatchTransactionsDTO {
uncategorizedTransactionIds: Array<number>;
matchedTransactions: Array<IMatchTransactionDTO>;
}
@@ -57,6 +58,7 @@ export interface MatchedTransactionPOJO {
export type MatchedTransactionsPOJO = {
perfectMatches: Array<MatchedTransactionPOJO>;
possibleMatches: Array<MatchedTransactionPOJO>;
totalPending: number;
};
export const ERRORS = {

View File

@@ -28,7 +28,7 @@ export class PlaidItemService {
const { PlaidItem } = this.tenancy.models(tenantId);
const { publicToken, institutionId } = itemDTO;
const plaidInstance = new PlaidClientWrapper();
const plaidInstance = PlaidClientWrapper.getClient();
// Exchange the public token for a private access token and store with the item.
const response = await plaidInstance.itemPublicTokenExchange({

View File

@@ -26,7 +26,7 @@ export class PlaidLinkTokenService {
webhook: config.plaid.linkWebhook,
access_token: accessToken,
};
const plaidInstance = new PlaidClientWrapper();
const plaidInstance = PlaidClientWrapper.getClient();
const createResponse = await plaidInstance.linkTokenCreate(linkTokenParams);
return createResponse.data;

View File

@@ -2,6 +2,11 @@ import * as R from 'ramda';
import { Inject, Service } from 'typedi';
import bluebird from 'bluebird';
import { entries, groupBy } from 'lodash';
import {
AccountBase as PlaidAccountBase,
Item as PlaidItem,
Institution as PlaidInstitution,
} from 'plaid';
import { CreateAccount } from '@/services/Accounts/CreateAccount';
import {
IAccountCreateDTO,
@@ -53,6 +58,7 @@ export class PlaidSyncDb {
trx?: Knex.Transaction
) {
const { Account } = this.tenancy.models(tenantId);
const plaidAccount = await Account.query().findOne(
'plaidAccountId',
createBankAccountDTO.plaidAccountId
@@ -77,13 +83,15 @@ export class PlaidSyncDb {
*/
public async syncBankAccounts(
tenantId: number,
plaidAccounts: PlaidAccount[],
institution: any,
plaidAccounts: PlaidAccountBase[],
institution: PlaidInstitution,
item: PlaidItem,
trx?: Knex.Transaction
): Promise<void> {
const transformToPlaidAccounts =
transformPlaidAccountToCreateAccount(institution);
const transformToPlaidAccounts = transformPlaidAccountToCreateAccount(
item,
institution
);
const accountCreateDTOs = R.map(transformToPlaidAccounts)(plaidAccounts);
await bluebird.map(

View File

@@ -53,7 +53,7 @@ export class PlaidUpdateTransactions {
await this.fetchTransactionUpdates(tenantId, plaidItemId);
const request = { access_token: accessToken };
const plaidInstance = new PlaidClientWrapper();
const plaidInstance = PlaidClientWrapper.getClient();
const {
data: { accounts, item },
} = await plaidInstance.accountsGet(request);
@@ -66,15 +66,19 @@ export class PlaidUpdateTransactions {
country_codes: ['US', 'UK'],
});
// Sync bank accounts.
await this.plaidSync.syncBankAccounts(tenantId, accounts, institution, trx);
await this.plaidSync.syncBankAccounts(
tenantId,
accounts,
institution,
item,
trx
);
// Sync bank account transactions.
await this.plaidSync.syncAccountsTransactions(
tenantId,
added.concat(modified),
trx
);
// Sync removed transactions.
await this.plaidSync.syncRemoveTransactions(tenantId, removed, trx);
// Sync transactions cursor.
await this.plaidSync.syncTransactionsCursor(
tenantId,
@@ -143,7 +147,7 @@ export class PlaidUpdateTransactions {
cursor: cursor,
count: batchSize,
};
const plaidInstance = new PlaidClientWrapper();
const plaidInstance = PlaidClientWrapper.getClient();
const response = await plaidInstance.transactionsSync(request);
const data = response.data;
// Add this page of results

View File

@@ -1,11 +1,15 @@
import { Inject, Service } from 'typedi';
import { PlaidUpdateTransactions } from './PlaidUpdateTransactions';
import HasTenancyService from '@/services/Tenancy/TenancyService';
@Service()
export class PlaidWebooks {
@Inject()
private updateTransactionsService: PlaidUpdateTransactions;
@Inject()
private tenancy: HasTenancyService;
/**
* Listens to Plaid webhooks
* @param {number} tenantId - Tenant Id.
@@ -61,7 +65,7 @@ export class PlaidWebooks {
plaidItemId: string
): void {
console.log(
`WEBHOOK: TRANSACTIONS: ${webhookCode}: Plaid_item_id ${plaidItemId}: ${additionalInfo}`
`PLAID WEBHOOK: TRANSACTIONS: ${webhookCode}: Plaid_item_id ${plaidItemId}: ${additionalInfo}`
);
}
@@ -78,8 +82,21 @@ export class PlaidWebooks {
plaidItemId: string,
webhookCode: string
): Promise<void> {
const { PlaidItem } = this.tenancy.models(tenantId);
const plaidItem = await PlaidItem.query()
.findById(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(

View File

@@ -35,7 +35,8 @@ export class RecognizeSyncedBankTranasctions extends EventSubscriber {
runAfterTransaction(trx, async () => {
await this.recognizeTranasctionsService.recognizeTransactions(
tenantId,
batch
null,
{ batch }
);
});
};

View File

@@ -1,18 +1,28 @@
import * as R from 'ramda';
import {
Item as PlaidItem,
Institution as PlaidInstitution,
AccountBase as PlaidAccount,
} from 'plaid';
import {
CreateUncategorizedTransactionDTO,
IAccountCreateDTO,
PlaidAccount,
PlaidTransaction,
} from '@/interfaces';
/**
* Transformes the Plaid account to create cashflow account DTO.
* @param {PlaidAccount} plaidAccount
* @param {PlaidItem} item -
* @param {PlaidInstitution} institution -
* @param {PlaidAccount} plaidAccount -
* @returns {IAccountCreateDTO}
*/
export const transformPlaidAccountToCreateAccount = R.curry(
(institution: any, plaidAccount: PlaidAccount): IAccountCreateDTO => {
(
item: PlaidItem,
institution: PlaidInstitution,
plaidAccount: PlaidAccount
): IAccountCreateDTO => {
return {
name: `${institution.name} - ${plaidAccount.name}`,
code: '',
@@ -20,9 +30,10 @@ export const transformPlaidAccountToCreateAccount = R.curry(
currencyCode: plaidAccount.balances.iso_currency_code,
accountType: 'cash',
active: true,
plaidAccountId: plaidAccount.account_id,
bankBalance: plaidAccount.balances.current,
accountMask: plaidAccount.mask,
plaidAccountId: plaidAccount.account_id,
plaidItemId: item.item_id,
};
}
);

View File

@@ -0,0 +1,45 @@
import { Inject, Service } from 'typedi';
import { castArray, first, uniq } from 'lodash';
import HasTenancyService from '@/services/Tenancy/TenancyService';
import { TransformerInjectable } from '@/lib/Transformer/TransformerInjectable';
import { GetAutofillCategorizeTransctionTransformer } from './GetAutofillCategorizeTransactionTransformer';
@Service()
export class GetAutofillCategorizeTransaction {
@Inject()
private tenancy: HasTenancyService;
@Inject()
private transformer: TransformerInjectable;
/**
* Retrieves the autofill values of categorize transactions form.
* @param {number} tenantId - Tenant id.
* @param {Array<number> | number} uncategorizeTransactionsId - Uncategorized transactions ids.
*/
public async getAutofillCategorizeTransaction(
tenantId: number,
uncategorizeTransactionsId: Array<number> | number
) {
const { UncategorizedCashflowTransaction } = this.tenancy.models(tenantId);
const uncategorizeTransactionsIds = uniq(
castArray(uncategorizeTransactionsId)
);
const uncategorizedTransactions =
await UncategorizedCashflowTransaction.query()
.whereIn('id', uncategorizeTransactionsIds)
.withGraphFetched('recognizedTransaction.assignAccount')
.withGraphFetched('recognizedTransaction.bankRule')
.throwIfNotFound();
return this.transformer.transform(
tenantId,
{},
new GetAutofillCategorizeTransctionTransformer(),
{
uncategorizedTransactions,
firstUncategorizedTransaction: first(uncategorizedTransactions),
}
);
}
}

View File

@@ -0,0 +1,176 @@
import { Transformer } from '@/lib/Transformer/Transformer';
import { sumBy } from 'lodash';
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
);
}
}

View File

@@ -1,11 +1,13 @@
import { Knex } from 'knex';
import { Inject, Service } from 'typedi';
import { castArray, isEmpty } from 'lodash';
import UncategorizedCashflowTransaction from '@/models/UncategorizedCashflowTransaction';
import HasTenancyService from '@/services/Tenancy/TenancyService';
import { transformToMapBy } from '@/utils';
import { Inject, Service } from 'typedi';
import { PromisePool } from '@supercharge/promise-pool';
import { BankRule } from '@/models/BankRule';
import { bankRulesMatchTransaction } from './_utils';
import { RecognizeTransactionsCriteria } from './_types';
@Service()
export class RecognizeTranasctionsService {
@@ -48,24 +50,42 @@ export class RecognizeTranasctionsService {
/**
* Regonized the uncategorized transactions.
* @param {number} tenantId -
* @param {number|Array<number>} ruleId - The target rule id/ids.
* @param {RecognizeTransactionsCriteria}
* @param {Knex.Transaction} trx -
*/
public async recognizeTransactions(
tenantId: number,
batch: string = '',
ruleId?: number | Array<number>,
transactionsCriteria?: RecognizeTransactionsCriteria,
trx?: Knex.Transaction
) {
const { UncategorizedCashflowTransaction, BankRule } =
this.tenancy.models(tenantId);
const uncategorizedTranasctions =
await UncategorizedCashflowTransaction.query().onBuild((query) => {
query.where('recognized_transaction_id', null);
query.where('categorized', false);
await UncategorizedCashflowTransaction.query(trx).onBuild((query) => {
query.modify('notRecognized');
query.modify('notCategorized');
if (batch) query.where('batch', batch);
// 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 BankRule.query().withGraphFetched('conditions');
const bankRules = await BankRule.query(trx).onBuild((q) => {
const rulesIds = castArray(ruleId);
if (!isEmpty(rulesIds)) {
q.whereIn('id', rulesIds);
}
q.withGraphFetched('conditions');
});
const bankRulesByAccountId = transformToMapBy(
bankRules,
'applyIfAccountId'

View File

@@ -0,0 +1,72 @@
import { Inject, Service } from 'typedi';
import { castArray } from 'lodash';
import { Knex } from 'knex';
import HasTenancyService from '@/services/Tenancy/TenancyService';
import UnitOfWork from '@/services/UnitOfWork';
import { RevertRecognizedTransactionsCriteria } from './_types';
@Service()
export class RevertRecognizedTransactions {
@Inject()
private tenancy: HasTenancyService;
@Inject()
private uow: UnitOfWork;
/**
* Revert and unlinks the recognized transactions based on the given bank rule
* and transactions criteria..
* @param {number} tenantId - Tenant id.
* @param {number|Array<number>} bankRuleId - Bank rule id.
* @param {RevertRecognizedTransactionsCriteria} transactionsCriteria -
* @param {Knex.Transaction} trx - Knex transaction.
* @returns {Promise<void>}
*/
public async revertRecognizedTransactions(
tenantId: number,
ruleId?: number | Array<number>,
transactionsCriteria?: RevertRecognizedTransactionsCriteria,
trx?: Knex.Transaction
): Promise<void> {
const { UncategorizedCashflowTransaction, RecognizedBankTransaction } =
this.tenancy.models(tenantId);
const rulesIds = castArray(ruleId);
return this.uow.withTransaction(
tenantId,
async (trx: Knex.Transaction) => {
// Retrieves all the recognized transactions of the banbk rule.
const uncategorizedTransactions =
await UncategorizedCashflowTransaction.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 UncategorizedCashflowTransaction.query(trx)
.whereIn('id', uncategorizedTransactionIds)
.patch({
recognizedTransactionId: null,
});
// Delete the recognized bank transactions that assocaited to bank rule.
await RecognizedBankTransaction.query(trx)
.whereIn('uncategorizedTransactionId', uncategorizedTransactionIds)
.delete();
},
trx
);
}
}

View File

@@ -0,0 +1,11 @@
export interface RevertRecognizedTransactionsCriteria {
batch?: string;
accountId?: number;
}
export interface RecognizeTransactionsCriteria {
batch?: string;
accountId?: number;
}

View File

@@ -1,3 +1,4 @@
import { lowerCase } from 'lodash';
import UncategorizedCashflowTransaction from '@/models/UncategorizedCashflowTransaction';
import {
BankRuleApplyIfTransactionType,
@@ -51,12 +52,15 @@ const matchNumberCondition = (
const matchTextCondition = (
transaction: UncategorizedCashflowTransaction,
condition: IBankRuleCondition
) => {
): boolean => {
switch (condition.comparator) {
case BankRuleConditionComparator.Equals:
return transaction[condition.field] === condition.value;
case BankRuleConditionComparator.Contains:
return transaction[condition.field]?.includes(condition.value.toString());
const fieldValue = lowerCase(transaction[condition.field]);
const conditionValue = lowerCase(condition.value);
return fieldValue.includes(conditionValue);
case BankRuleConditionComparator.NotContain:
return !transaction[condition.field]?.includes(
condition.value.toString()
@@ -101,4 +105,4 @@ const determineFieldType = (field: string): string => {
default:
return 'unknown';
}
};
};

View File

@@ -41,14 +41,10 @@ export class TriggerRecognizedTransactions {
*/
private async recognizedTransactionsOnRuleCreated({
tenantId,
createRuleDTO,
bankRule,
}: IBankRuleEventCreatedPayload) {
const payload = { tenantId };
const payload = { tenantId, ruleId: bankRule.id };
// Cannot run recognition if the option is not enabled.
if (createRuleDTO.recognition) {
return;
}
await this.agenda.now('recognize-uncategorized-transactions-job', payload);
}
@@ -59,14 +55,14 @@ export class TriggerRecognizedTransactions {
private async recognizedTransactionsOnRuleEdited({
tenantId,
editRuleDTO,
ruleId,
}: IBankRuleEventEditedPayload) {
const payload = { tenantId };
const payload = { tenantId, ruleId };
// Cannot run recognition if the option is not enabled.
if (!editRuleDTO.recognition) {
return;
}
await this.agenda.now('recognize-uncategorized-transactions-job', payload);
await this.agenda.now(
'rerecognize-uncategorized-transactions-job',
payload
);
}
/**
@@ -75,9 +71,13 @@ export class TriggerRecognizedTransactions {
*/
private async recognizedTransactionsOnRuleDeleted({
tenantId,
ruleId,
}: IBankRuleEventDeletedPayload) {
const payload = { tenantId };
await this.agenda.now('recognize-uncategorized-transactions-job', payload);
const payload = { tenantId, ruleId };
await this.agenda.now(
'revert-recognized-uncategorized-transactions-job',
payload
);
}
/**
@@ -91,7 +91,7 @@ export class TriggerRecognizedTransactions {
}: IImportFileCommitedEventPayload) {
const importFile = await Import.query().findOne({ importId });
const batch = importFile.paramsParsed.batch;
const payload = { tenantId, batch };
const payload = { tenantId, transactionsCriteria: { batch } };
await this.agenda.now('recognize-uncategorized-transactions-job', payload);
}

View File

@@ -1,5 +1,5 @@
import Container, { Service } from 'typedi';
import { RecognizeTranasctionsService } from './RecognizeTranasctionsService';
import { RecognizeTranasctionsService } from '../RecognizeTranasctionsService';
@Service()
export class RegonizeTransactionsJob {
@@ -18,11 +18,15 @@ export class RegonizeTransactionsJob {
* Triggers sending invoice mail.
*/
private handler = async (job, done: Function) => {
const { tenantId, batch } = job.attrs.data;
const { tenantId, ruleId, transactionsCriteria } = job.attrs.data;
const regonizeTransactions = Container.get(RecognizeTranasctionsService);
try {
await regonizeTransactions.recognizeTransactions(tenantId, batch);
await regonizeTransactions.recognizeTransactions(
tenantId,
ruleId,
transactionsCriteria
);
done();
} catch (error) {
console.log(error);

View File

@@ -0,0 +1,45 @@
import Container, { Service } from 'typedi';
import { RecognizeTranasctionsService } from '../RecognizeTranasctionsService';
import { RevertRecognizedTransactions } from '../RevertRecognizedTransactions';
@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);
}
};
}

View File

@@ -0,0 +1,38 @@
import Container, { Service } from 'typedi';
import { RevertRecognizedTransactions } from '../RevertRecognizedTransactions';
@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);
}
};
}

View File

@@ -62,6 +62,7 @@ export class CreateBankRuleService {
await this.eventPublisher.emitAsync(events.bankRules.onCreated, {
tenantId,
createRuleDTO,
bankRule,
trx,
} as IBankRuleEventCreatedPayload);

View File

@@ -64,9 +64,10 @@ export class EditBankRuleService {
} as IBankRuleEventEditingPayload);
// Updates the given bank rule.
await BankRule.query(trx)
.findById(ruleId)
.patch({ ...tranformDTO });
await BankRule.query(trx).upsertGraphAndFetch({
...tranformDTO,
id: ruleId,
});
// Triggers `onBankRuleEdited` event.
await this.eventPublisher.emitAsync(events.bankRules.onEdited, {

View File

@@ -1,54 +0,0 @@
import { Inject, Service } from 'typedi';
import { Knex } from 'knex';
import HasTenancyService from '@/services/Tenancy/TenancyService';
import UnitOfWork from '@/services/UnitOfWork';
@Service()
export class UnlinkBankRuleRecognizedTransactions {
@Inject()
private tenancy: HasTenancyService;
@Inject()
private uow: UnitOfWork;
/**
* Unlinks the given bank rule out of recognized transactions.
* @param {number} tenantId - Tenant id.
* @param {number} bankRuleId - Bank rule id.
* @param {Knex.Transaction} trx - Knex transaction.
* @returns {Promise<void>}
*/
public async unlinkBankRuleOutRecognizedTransactions(
tenantId: number,
bankRuleId: number,
trx?: Knex.Transaction
): Promise<void> {
const { UncategorizedCashflowTransaction, RecognizedBankTransaction } =
this.tenancy.models(tenantId);
return this.uow.withTransaction(
tenantId,
async (trx: Knex.Transaction) => {
// Retrieves all the recognized transactions of the banbk rule.
const recognizedTransactions = await RecognizedBankTransaction.query(
trx
).where('bankRuleId', bankRuleId);
const uncategorizedTransactionIds = recognizedTransactions.map(
(r) => r.uncategorizedTransactionId
);
// Unlink the recongized transactions out of uncategorized transactions.
await UncategorizedCashflowTransaction.query(trx)
.whereIn('id', uncategorizedTransactionIds)
.patch({
recognizedTransactionId: null,
});
// Delete the recognized bank transactions that assocaited to bank rule.
await RecognizedBankTransaction.query(trx)
.where({ bankRuleId })
.delete();
},
trx
);
}
}

View File

@@ -1,12 +1,12 @@
import { Inject, Service } from 'typedi';
import events from '@/subscribers/events';
import { UnlinkBankRuleRecognizedTransactions } from '../UnlinkBankRuleRecognizedTransactions';
import { IBankRuleEventDeletingPayload } from '../types';
import { RevertRecognizedTransactions } from '../../RegonizeTranasctions/RevertRecognizedTransactions';
@Service()
export class UnlinkBankRuleOnDeleteBankRule {
@Inject()
private unlinkBankRule: UnlinkBankRuleRecognizedTransactions;
private revertRecognizedTransactionsService: RevertRecognizedTransactions;
/**
* Constructor method.
@@ -26,7 +26,7 @@ export class UnlinkBankRuleOnDeleteBankRule {
tenantId,
ruleId,
}: IBankRuleEventDeletingPayload) {
await this.unlinkBankRule.unlinkBankRuleOutRecognizedTransactions(
await this.revertRecognizedTransactionsService.revertRecognizedTransactions(
tenantId,
ruleId
);

View File

@@ -16,7 +16,7 @@ export interface IBankRuleCondition {
id?: number;
field: BankRuleConditionField;
comparator: BankRuleConditionComparator;
value: number;
value: string;
}
export enum BankRuleConditionType {
@@ -30,6 +30,7 @@ export enum BankRuleApplyIfTransactionType {
}
export interface IBankRule {
id?: number;
name: string;
order?: number;
applyIfAccountId: number;
@@ -71,8 +72,6 @@ export interface IBankRuleCommonDTO {
assignAccountId: number;
assignPayee?: string;
assignMemo?: string;
recognition?: boolean;
}
export interface ICreateBankRuleDTO extends IBankRuleCommonDTO {}
@@ -86,6 +85,7 @@ export interface IBankRuleEventCreatingPayload {
export interface IBankRuleEventCreatedPayload {
tenantId: number;
createRuleDTO: ICreateBankRuleDTO;
bankRule: IBankRule;
trx?: Knex.Transaction;
}

View File

@@ -164,12 +164,12 @@ export class CashflowApplication {
*/
public categorizeTransaction(
tenantId: number,
cashflowTransactionId: number,
uncategorizeTransactionIds: Array<number>,
categorizeDTO: ICategorizeCashflowTransactioDTO
) {
return this.categorizeTransactionService.categorize(
tenantId,
cashflowTransactionId,
uncategorizeTransactionIds,
categorizeDTO
);
}

View File

@@ -1,4 +1,6 @@
import { Inject, Service } from 'typedi';
import { castArray } from 'lodash';
import { Knex } from 'knex';
import HasTenancyService from '../Tenancy/TenancyService';
import events from '@/subscribers/events';
import { EventPublisher } from '@/lib/EventPublisher/EventPublisher';
@@ -8,12 +10,12 @@ import {
ICashflowTransactionUncategorizingPayload,
ICategorizeCashflowTransactioDTO,
} from '@/interfaces';
import { Knex } from 'knex';
import { transformCategorizeTransToCashflow } from './utils';
import {
transformCategorizeTransToCashflow,
validateUncategorizedTransactionsNotExcluded,
} from './utils';
import { CommandCashflowValidator } from './CommandCasflowValidator';
import NewCashflowTransactionService from './NewCashflowTransactionService';
import { ServiceError } from '@/exceptions';
import { ERRORS } from './constants';
@Service()
export class CategorizeCashflowTransaction {
@@ -39,27 +41,29 @@ export class CategorizeCashflowTransaction {
*/
public async categorize(
tenantId: number,
uncategorizedTransactionId: number,
uncategorizedTransactionId: number | Array<number>,
categorizeDTO: ICategorizeCashflowTransactioDTO
) {
const { UncategorizedCashflowTransaction } = this.tenancy.models(tenantId);
const uncategorizedTransactionIds = castArray(uncategorizedTransactionId);
// Retrieves the uncategorized transaction or throw an error.
const transaction = await UncategorizedCashflowTransaction.query()
.findById(uncategorizedTransactionId)
.throwIfNotFound();
const oldUncategorizedTransactions =
await UncategorizedCashflowTransaction.query()
.whereIn('id', uncategorizedTransactionIds)
.throwIfNotFound();
// Validate cannot categorize excluded transaction.
if (transaction.excluded) {
throw new ServiceError(ERRORS.CANNOT_CATEGORIZE_EXCLUDED_TRANSACTION);
}
// Validates the transaction shouldn't be categorized before.
this.commandValidators.validateTransactionShouldNotCategorized(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(
transaction,
oldUncategorizedTransactions,
categorizeDTO.transactionType
);
// Edits the cashflow transaction under UOW env.
@@ -69,12 +73,13 @@ export class CategorizeCashflowTransaction {
events.cashflow.onTransactionCategorizing,
{
tenantId,
oldUncategorizedTransactions,
trx,
} as ICashflowTransactionUncategorizingPayload
);
// Transformes the categorize DTO to the cashflow transaction.
const cashflowTransactionDTO = transformCategorizeTransToCashflow(
transaction,
oldUncategorizedTransactions,
categorizeDTO
);
// Creates a new cashflow transaction.
@@ -83,15 +88,20 @@ export class CategorizeCashflowTransaction {
tenantId,
cashflowTransactionDTO
);
// Updates the uncategorized transaction as categorized.
const uncategorizedTransaction =
await UncategorizedCashflowTransaction.query(trx).patchAndFetchById(
uncategorizedTransactionId,
{
categorized: true,
categorizeRefType: 'CashflowTransaction',
categorizeRefId: cashflowTransaction.id,
}
await UncategorizedCashflowTransaction.query(trx)
.whereIn('id', uncategorizedTransactionIds)
.patch({
categorized: true,
categorizeRefType: 'CashflowTransaction',
categorizeRefId: cashflowTransaction.id,
});
// Fetch the new updated uncategorized transactions.
const uncategorizedTransactions =
await UncategorizedCashflowTransaction.query(trx).whereIn(
'id',
uncategorizedTransactionIds
);
// Triggers `onCashflowTransactionCategorized` event.
await this.eventPublisher.emitAsync(
@@ -99,7 +109,8 @@ export class CategorizeCashflowTransaction {
{
tenantId,
cashflowTransaction,
uncategorizedTransaction,
uncategorizedTransactions,
oldUncategorizedTransactions,
categorizeDTO,
trx,
} as ICashflowTransactionCategorizedPayload

View File

@@ -1,5 +1,5 @@
import { Service } from 'typedi';
import { includes, camelCase, upperFirst } from 'lodash';
import { includes, camelCase, upperFirst, sumBy } from 'lodash';
import { IAccount, IUncategorizedCashflowTransaction } from '@/interfaces';
import { getCashflowTransactionType } from './utils';
import { ServiceError } from '@/exceptions';
@@ -68,11 +68,15 @@ export class CommandCashflowValidator {
* Validate the given transcation shouldn't be categorized.
* @param {CashflowTransaction} cashflowTransaction
*/
public validateTransactionShouldNotCategorized(
cashflowTransaction: CashflowTransaction
public validateTransactionsShouldNotCategorized(
cashflowTransactions: Array<IUncategorizedCashflowTransaction>
) {
if (cashflowTransaction.uncategorize) {
throw new ServiceError(ERRORS.TRANSACTION_ALREADY_CATEGORIZED);
const categorized = cashflowTransactions.filter((t) => t.categorized);
if (categorized?.length > 0) {
throw new ServiceError(ERRORS.TRANSACTION_ALREADY_CATEGORIZED, '', {
ids: categorized.map((t) => t.id),
});
}
}
@@ -83,17 +87,19 @@ export class CommandCashflowValidator {
* @throws {ServiceError(ERRORS.UNCATEGORIZED_TRANSACTION_TYPE_INVALID)}
*/
public validateUncategorizeTransactionType(
uncategorizeTransaction: IUncategorizedCashflowTransaction,
uncategorizeTransactions: Array<IUncategorizedCashflowTransaction>,
transactionType: string
) {
const amount = sumBy(uncategorizeTransactions, 'amount');
const isDepositTransaction = amount > 0;
const isWithdrawalTransaction = amount <= 0;
const type = getCashflowTransactionType(
upperFirst(camelCase(transactionType)) as CASHFLOW_TRANSACTION_TYPE
transactionType as CASHFLOW_TRANSACTION_TYPE
);
if (
(type.direction === CASHFLOW_DIRECTION.IN &&
uncategorizeTransaction.isDepositTransaction) ||
(type.direction === CASHFLOW_DIRECTION.OUT &&
uncategorizeTransaction.isWithdrawalTransaction)
(type.direction === CASHFLOW_DIRECTION.IN && isDepositTransaction) ||
(type.direction === CASHFLOW_DIRECTION.OUT && isWithdrawalTransaction)
) {
return;
}

View File

@@ -37,7 +37,7 @@ export class CreateUncategorizedTransaction {
tenantId,
async (trx: Knex.Transaction) => {
await this.eventPublisher.emitAsync(
events.cashflow.onTransactionUncategorizedCreated,
events.cashflow.onTransactionUncategorizedCreating,
{
tenantId,
createUncategorizedTransactionDTO,

View File

@@ -34,6 +34,7 @@ export class GetRecognizedTransactionsService {
q.withGraphFetched('recognizedTransaction.assignAccount');
q.withGraphFetched('recognizedTransaction.bankRule');
q.whereNotNull('recognizedTransactionId');
q.modify('notExcluded');
if (_filter.accountId) {
q.where('accountId', _filter.accountId);

View File

@@ -8,6 +8,7 @@ import {
ICashflowTransactionUncategorizedPayload,
ICashflowTransactionUncategorizingPayload,
} from '@/interfaces';
import { validateTransactionShouldBeCategorized } from './utils';
@Service()
export class UncategorizeCashflowTransaction {
@@ -24,11 +25,12 @@ export class UncategorizeCashflowTransaction {
* Uncategorizes the given cashflow transaction.
* @param {number} tenantId
* @param {number} cashflowTransactionId
* @returns {Promise<Array<number>>}
*/
public async uncategorize(
tenantId: number,
uncategorizedTransactionId: number
) {
): Promise<Array<number>> {
const { UncategorizedCashflowTransaction } = this.tenancy.models(tenantId);
const oldUncategorizedTransaction =
@@ -36,6 +38,22 @@ export class UncategorizeCashflowTransaction {
.findById(uncategorizedTransactionId)
.throwIfNotFound();
validateTransactionShouldBeCategorized(oldUncategorizedTransaction);
const associatedUncategorizedTransactions =
await UncategorizedCashflowTransaction.query()
.where('categorizeRefId', oldUncategorizedTransaction.categorizeRefId)
.where(
'categorizeRefType',
oldUncategorizedTransaction.categorizeRefType
);
const oldUncategorizedTransactions = [
oldUncategorizedTransaction,
...associatedUncategorizedTransactions,
];
const oldUncategoirzedTransactionsIds = oldUncategorizedTransactions.map(
(t) => t.id
);
// Updates the transaction under UOW.
return this.uow.withTransaction(tenantId, async (trx: Knex.Transaction) => {
// Triggers `onTransactionUncategorizing` event.
@@ -43,30 +61,36 @@ export class UncategorizeCashflowTransaction {
events.cashflow.onTransactionUncategorizing,
{
tenantId,
uncategorizedTransactionId,
oldUncategorizedTransactions,
trx,
} as ICashflowTransactionUncategorizingPayload
);
// Removes the ref relation with the related transaction.
const uncategorizedTransaction =
await UncategorizedCashflowTransaction.query(trx).updateAndFetchById(
uncategorizedTransactionId,
{
categorized: false,
categorizeRefId: null,
categorizeRefType: null,
}
await UncategorizedCashflowTransaction.query(trx)
.whereIn('id', oldUncategoirzedTransactionsIds)
.patch({
categorized: false,
categorizeRefId: null,
categorizeRefType: null,
});
const uncategorizedTransactions =
await UncategorizedCashflowTransaction.query(trx).whereIn(
'id',
oldUncategoirzedTransactionsIds
);
// Triggers `onTransactionUncategorized` event.
await this.eventPublisher.emitAsync(
events.cashflow.onTransactionUncategorized,
{
tenantId,
uncategorizedTransaction,
oldUncategorizedTransaction,
uncategorizedTransactionId,
uncategorizedTransactions,
oldUncategorizedTransactions,
trx,
} as ICashflowTransactionUncategorizedPayload
);
return uncategorizedTransaction;
return oldUncategoirzedTransactionsIds;
});
}
}

View File

@@ -16,7 +16,9 @@ export const ERRORS = {
CANNOT_DELETE_TRANSACTION_CONVERTED_FROM_UNCATEGORIZED:
'CANNOT_DELETE_TRANSACTION_CONVERTED_FROM_UNCATEGORIZED',
CANNOT_CATEGORIZE_EXCLUDED_TRANSACTION: 'CANNOT_CATEGORIZE_EXCLUDED_TRANSACTION'
CANNOT_CATEGORIZE_EXCLUDED_TRANSACTION: 'CANNOT_CATEGORIZE_EXCLUDED_TRANSACTION',
TRANSACTION_NOT_CATEGORIZED: 'TRANSACTION_NOT_CATEGORIZED'
};
export enum CASHFLOW_DIRECTION {

View File

@@ -5,6 +5,7 @@ import {
ICashflowTransactionCategorizedPayload,
ICashflowTransactionUncategorizedPayload,
} from '@/interfaces';
import PromisePool from '@supercharge/promise-pool';
@Service()
export class DecrementUncategorizedTransactionOnCategorize {
@@ -34,13 +35,18 @@ export class DecrementUncategorizedTransactionOnCategorize {
*/
public async decrementUnCategorizedTransactionsOnCategorized({
tenantId,
uncategorizedTransaction,
uncategorizedTransactions,
trx
}: ICashflowTransactionCategorizedPayload) {
const { Account } = this.tenancy.models(tenantId);
await Account.query()
.findById(uncategorizedTransaction.accountId)
.decrement('uncategorizedTransactions', 1);
await PromisePool.withConcurrency(1)
.for(uncategorizedTransactions)
.process(async (uncategorizedTransaction) => {
await Account.query(trx)
.findById(uncategorizedTransaction.accountId)
.decrement('uncategorizedTransactions', 1);
});
}
/**
@@ -49,13 +55,18 @@ export class DecrementUncategorizedTransactionOnCategorize {
*/
public async incrementUnCategorizedTransactionsOnUncategorized({
tenantId,
uncategorizedTransaction,
uncategorizedTransactions,
trx
}: ICashflowTransactionUncategorizedPayload) {
const { Account } = this.tenancy.models(tenantId);
await Account.query()
.findById(uncategorizedTransaction.accountId)
.increment('uncategorizedTransactions', 1);
await PromisePool.withConcurrency(1)
.for(uncategorizedTransactions)
.process(async (uncategorizedTransaction) => {
await Account.query(trx)
.findById(uncategorizedTransaction.accountId)
.increment('uncategorizedTransactions', 1);
});
}
/**

View File

@@ -1,8 +1,10 @@
import { Inject, Service } from 'typedi';
import { PromisePool } from '@supercharge/promise-pool';
import events from '@/subscribers/events';
import { ICashflowTransactionUncategorizedPayload } from '@/interfaces';
import { DeleteCashflowTransaction } from '../DeleteCashflowTransactionService';
import HasTenancyService from '@/services/Tenancy/TenancyService';
import { ServiceError } from '@/exceptions';
@Service()
export class DeleteCashflowTransactionOnUncategorize {
@@ -25,18 +27,27 @@ export class DeleteCashflowTransactionOnUncategorize {
*/
public async deleteCashflowTransactionOnUncategorize({
tenantId,
oldUncategorizedTransaction,
oldUncategorizedTransactions,
trx,
}: ICashflowTransactionUncategorizedPayload) {
// Deletes the cashflow transaction.
if (
oldUncategorizedTransaction.categorizeRefType === 'CashflowTransaction'
) {
await this.deleteCashflowTransactionService.deleteCashflowTransaction(
tenantId,
const _oldUncategorizedTransactions = oldUncategorizedTransactions.filter(
(transaction) => transaction.categorizeRefType === 'CashflowTransaction'
);
oldUncategorizedTransaction.categorizeRefId
);
// Deletes the cashflow transaction.
if (_oldUncategorizedTransactions.length > 0) {
const result = await PromisePool.withConcurrency(1)
.for(_oldUncategorizedTransactions)
.process(async (oldUncategorizedTransaction) => {
await this.deleteCashflowTransactionService.deleteCashflowTransaction(
tenantId,
oldUncategorizedTransaction.categorizeRefId,
trx
);
});
if (result.errors.length > 0) {
throw new ServiceError('SOMETHING_WRONG');
}
}
}
}

View File

@@ -1,7 +1,8 @@
import { upperFirst, camelCase } from 'lodash';
import { upperFirst, camelCase, first, sum, sumBy } from 'lodash';
import {
CASHFLOW_TRANSACTION_TYPE,
CASHFLOW_TRANSACTION_TYPE_META,
ERRORS,
ICashflowTransactionTypeMeta,
} from './constants';
import {
@@ -9,6 +10,8 @@ import {
ICategorizeCashflowTransactioDTO,
IUncategorizedCashflowTransaction,
} from '@/interfaces';
import { UncategorizeCashflowTransaction } from './UncategorizeCashflowTransaction';
import { ServiceError } from '@/exceptions';
/**
* Ensures the given transaction type to transformed to appropriate format.
@@ -27,7 +30,9 @@ export const transformCashflowTransactionType = (type) => {
export function getCashflowTransactionType(
transactionType: CASHFLOW_TRANSACTION_TYPE
): ICashflowTransactionTypeMeta {
return CASHFLOW_TRANSACTION_TYPE_META[transactionType];
const _transactionType = transformCashflowTransactionType(transactionType);
return CASHFLOW_TRANSACTION_TYPE_META[_transactionType];
}
/**
@@ -46,22 +51,46 @@ export const getCashflowAccountTransactionsTypes = () => {
* @returns {ICashflowNewCommandDTO}
*/
export const transformCategorizeTransToCashflow = (
uncategorizeModel: IUncategorizedCashflowTransaction,
uncategorizeTransactions: Array<IUncategorizedCashflowTransaction>,
categorizeDTO: ICategorizeCashflowTransactioDTO
): ICashflowNewCommandDTO => {
const uncategorizeTransaction = first(uncategorizeTransactions);
const amount = sumBy(uncategorizeTransactions, 'amount');
const amountAbs = Math.abs(amount);
return {
date: uncategorizeModel.date,
referenceNo: categorizeDTO.referenceNo || uncategorizeModel.referenceNo,
description: categorizeDTO.description || uncategorizeModel.description,
cashflowAccountId: uncategorizeModel.accountId,
date: categorizeDTO.date,
referenceNo: categorizeDTO.referenceNo,
description: categorizeDTO.description,
cashflowAccountId: uncategorizeTransaction.accountId,
creditAccountId: categorizeDTO.creditAccountId,
exchangeRate: categorizeDTO.exchangeRate || 1,
currencyCode: uncategorizeModel.currencyCode,
amount: uncategorizeModel.amount,
currencyCode: categorizeDTO.currencyCode,
amount: amountAbs,
transactionNumber: categorizeDTO.transactionNumber,
transactionType: categorizeDTO.transactionType,
uncategorizedTransactionId: uncategorizeModel.id,
branchId: categorizeDTO?.branchId,
publish: true,
};
};
export const validateUncategorizedTransactionsNotExcluded = (
transactions: Array<UncategorizeCashflowTransaction>
) => {
const excluded = transactions.filter((tran) => tran.excluded);
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);
}
};

View File

@@ -45,9 +45,9 @@ export class CustomersApplication {
/**
* Creates a new customer.
* @param {number} tenantId
* @param {ICustomerNewDTO} customerDTO
* @param {ISystemUser} authorizedUser
* @param {number} tenantId
* @param {ICustomerNewDTO} customerDTO
* @param {ISystemUser} authorizedUser
* @returns {Promise<ICustomer>}
*/
public createCustomer = (tenantId: number, customerDTO: ICustomerNewDTO) => {
@@ -56,9 +56,9 @@ export class CustomersApplication {
/**
* Edits details of the given customer.
* @param {number} tenantId
* @param {number} customerId
* @param {ICustomerEditDTO} customerDTO
* @param {number} tenantId
* @param {number} customerId
* @param {ICustomerEditDTO} customerDTO
* @return {Promise<ICustomer>}
*/
public editCustomer = (
@@ -75,9 +75,9 @@ export class CustomersApplication {
/**
* Deletes the given customer and associated transactions.
* @param {number} tenantId
* @param {number} customerId
* @param {ISystemUser} authorizedUser
* @param {number} tenantId
* @param {number} customerId
* @param {ISystemUser} authorizedUser
* @returns {Promise<void>}
*/
public deleteCustomer = (
@@ -94,9 +94,9 @@ export class CustomersApplication {
/**
* Changes the opening balance of the given customer.
* @param {number} tenantId
* @param {number} customerId
* @param {Date|string} openingBalanceEditDTO
* @param {number} tenantId
* @param {number} customerId
* @param {Date|string} openingBalanceEditDTO
* @returns {Promise<ICustomer>}
*/
public editOpeningBalance = (

View File

@@ -1,7 +1,8 @@
import { Inject, Service } from 'typedi';
import { IFeatureAllItem, ISystemUser } from '@/interfaces';
import { FeaturesManager } from '@/services/Features/FeaturesManager';
import HasTenancyService from '@/services/Tenancy/TenancyService';
import { Inject, Service } from 'typedi';
import config from '@/config';
interface IRoleAbility {
subject: string;
@@ -11,15 +12,16 @@ interface IRoleAbility {
interface IDashboardBootMeta {
abilities: IRoleAbility[];
features: IFeatureAllItem[];
isBigcapitalCloud: boolean;
}
@Service()
export default class DashboardService {
@Inject()
tenancy: HasTenancyService;
private tenancy: HasTenancyService;
@Inject()
featuresManager: FeaturesManager;
private featuresManager: FeaturesManager;
/**
* Retrieve dashboard meta.
@@ -39,6 +41,7 @@ export default class DashboardService {
return {
abilities,
features,
isBigcapitalCloud: config.hostedOnBigcapitalCloud
};
};

View File

@@ -0,0 +1,51 @@
import axios from 'axios';
import config from '@/config';
import { IAuthSignUpVerifiedEventPayload } from '@/interfaces';
import events from '@/subscribers/events';
import { SystemUser } from '@/system/models';
export class LoopsEventsSubscriber {
/**
* Constructor method.
*/
public attach(bus) {
bus.subscribe(
events.auth.signUpConfirmed,
this.triggerEventOnSignupVerified.bind(this)
);
}
/**
* Once the user verified sends the event to the Loops.
* @param {IAuthSignUpVerifiedEventPayload} param0
*/
public async triggerEventOnSignupVerified({
email,
userId,
}: IAuthSignUpVerifiedEventPayload) {
// Can't continue since the Loops the api key is not configured.
if (!config.loops.apiKey) {
return;
}
const user = await SystemUser.query().findById(userId);
const options = {
method: 'POST',
url: 'https://app.loops.so/api/v1/events/send',
headers: {
Authorization: `Bearer ${config.loops.apiKey}`,
'Content-Type': 'application/json',
},
data: {
email,
userId,
firstName: user.firstName,
lastName: user.lastName,
eventName: 'USER_VERIFIED',
eventProperties: {},
mailingLists: {},
},
};
await axios(options);
}
}

View File

@@ -4,6 +4,7 @@ import { omit, sumBy } from 'lodash';
import { IBillPayment, IBillPaymentDTO, IVendor } from '@/interfaces';
import { BranchTransactionDTOTransform } from '@/services/Branches/Integrations/BranchTransactionDTOTransform';
import { formatDateFields } from '@/utils';
import HasTenancyService from '@/services/Tenancy/TenancyService';
@Service()
export class CommandBillPaymentDTOTransformer {
@@ -23,11 +24,14 @@ export class CommandBillPaymentDTOTransformer {
vendor: IVendor,
oldBillPayment?: IBillPayment
): Promise<IBillPayment> {
const amount =
billPaymentDTO.amount ?? sumBy(billPaymentDTO.entries, 'paymentAmount');
const initialDTO = {
...formatDateFields(omit(billPaymentDTO, ['attachments']), [
'paymentDate',
]),
amount: sumBy(billPaymentDTO.entries, 'paymentAmount'),
amount,
currencyCode: vendor.currencyCode,
exchangeRate: billPaymentDTO.exchangeRate || 1,
entries: billPaymentDTO.entries,

View File

@@ -36,7 +36,9 @@ export class PaymentReceiveDTOTransformer {
paymentReceiveDTO: IPaymentReceiveCreateDTO | IPaymentReceiveEditDTO,
oldPaymentReceive?: IPaymentReceive
): Promise<IPaymentReceive> {
const paymentAmount = sumBy(paymentReceiveDTO.entries, 'paymentAmount');
const amount =
paymentReceiveDTO.amount ??
sumBy(paymentReceiveDTO.entries, 'paymentAmount');
// Retreive the next invoice number.
const autoNextNumber =
@@ -54,7 +56,7 @@ export class PaymentReceiveDTOTransformer {
...formatDateFields(omit(paymentReceiveDTO, ['entries', 'attachments']), [
'paymentDate',
]),
amount: paymentAmount,
amount,
currencyCode: customer.currencyCode,
...(paymentReceiveNo ? { paymentReceiveNo } : {}),
exchangeRate: paymentReceiveDTO.exchangeRate || 1,

View File

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

Some files were not shown because too many files have changed in this diff Show More