Compare commits
100 Commits
fix-plaid-
...
v0.19.1
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f67c63a4fa | ||
|
|
0025dcf8d4 | ||
|
|
81995dc94f | ||
|
|
3fcc70c1d8 | ||
|
|
a986c7a250 | ||
|
|
37e25a8061 | ||
|
|
cfba628465 | ||
|
|
3d200f4d7d | ||
|
|
f21b01b1d6 | ||
|
|
3cbcfac333 | ||
|
|
cc21e1856f | ||
|
|
efd0e1e225 | ||
|
|
521b083ed7 | ||
|
|
a09fe26df7 | ||
|
|
c7a85c4cf8 | ||
|
|
f6350d3d61 | ||
|
|
64c0732e5f | ||
|
|
8e99a31455 | ||
|
|
6d0d0689e1 | ||
|
|
9836129e49 | ||
|
|
86631ea8c3 | ||
|
|
475ccd4903 | ||
|
|
8608144ec1 | ||
|
|
f9cf6d325a | ||
|
|
fc0240c692 | ||
|
|
b84675325f | ||
|
|
647bed5c67 | ||
|
|
00f5bb1d73 | ||
|
|
208800b411 | ||
|
|
5e12a4cea4 | ||
|
|
fdf3e34f1c | ||
|
|
d74337fb94 | ||
|
|
8cab012324 | ||
|
|
940b4f9175 | ||
|
|
5d0dd1fe3f | ||
|
|
ded4e2bb59 | ||
|
|
219e6fb466 | ||
|
|
5ce11f192f | ||
|
|
71e865e9b7 | ||
|
|
590506f183 | ||
|
|
bed281a637 | ||
|
|
47dd767b3a | ||
|
|
8623b69991 | ||
|
|
a1ddc81dac | ||
|
|
832cdacebf | ||
|
|
9f979080b6 | ||
|
|
7f7301b31e | ||
|
|
6affbedef4 | ||
|
|
ba7f32c1bf | ||
|
|
305ce29ebb | ||
|
|
4cd0405078 | ||
|
|
783102449f | ||
|
|
ae617b2e1d | ||
|
|
9090d0a7b2 | ||
|
|
ffc55fa81b | ||
|
|
07c57ed539 | ||
|
|
788150f80d | ||
|
|
c4e77e4e3b | ||
|
|
c09384e49b | ||
|
|
4490c2d4b4 | ||
|
|
e11f1a95f6 | ||
|
|
b91273eee4 | ||
|
|
b5d570417b | ||
|
|
acd3265e35 | ||
|
|
894c899847 | ||
|
|
f6d4ec504f | ||
|
|
1a01461f5d | ||
|
|
f5e18fc1fe | ||
|
|
f64cd32985 | ||
|
|
89552d7ee2 | ||
|
|
4345623ea9 | ||
|
|
f457759e39 | ||
|
|
14d5e82b4a | ||
|
|
333b6f5a4b | ||
|
|
1660df20af | ||
|
|
14a9c4ba28 | ||
|
|
383be111fa | ||
|
|
7720b1cc34 | ||
|
|
db634cbb79 | ||
|
|
53f37f4f48 | ||
|
|
0a7b522b87 | ||
|
|
9e6500ac79 | ||
|
|
b93cb546f4 | ||
|
|
6d17f9cbeb | ||
|
|
998e6de211 | ||
|
|
6fb02f9869 | ||
|
|
449390143d | ||
|
|
51471ed000 | ||
|
|
fe214b1b2d | ||
|
|
6b6b73b77c | ||
|
|
c2815afbe3 | ||
|
|
fa7e6b1fca | ||
|
|
107a6f793b | ||
|
|
67d155759e | ||
|
|
7e2e87256f | ||
|
|
df7790d7c1 | ||
|
|
72128a72c4 | ||
|
|
eb3f23554f | ||
|
|
69ddf43b3e | ||
|
|
249eadaeaa |
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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 });
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 }),
|
||||
];
|
||||
}
|
||||
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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(
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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
|
||||
) => {
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
};
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
};
|
||||
@@ -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');
|
||||
};
|
||||
@@ -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');
|
||||
});
|
||||
};
|
||||
@@ -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);
|
||||
|
||||
@@ -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,
|
||||
];
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -236,6 +236,7 @@ export interface ICashflowTransactionSchema {
|
||||
export interface ICashflowTransactionInput extends ICashflowTransactionSchema {}
|
||||
|
||||
export interface ICategorizeCashflowTransactioDTO {
|
||||
date: Date;
|
||||
creditAccountId: number;
|
||||
referenceNo: string;
|
||||
transactionNumber: string;
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -40,7 +40,7 @@ export interface ILedgerEntry {
|
||||
date: Date | string;
|
||||
|
||||
transactionType: string;
|
||||
transactionSubType: string;
|
||||
transactionSubType?: string;
|
||||
|
||||
transactionId: number;
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -8,4 +8,5 @@ export const s3 = new S3Client({
|
||||
secretAccessKey: config.s3.secretAccessKey,
|
||||
},
|
||||
endpoint: config.s3.endpoint,
|
||||
forcePathStyle: config.s3.forcePathStyle,
|
||||
});
|
||||
|
||||
@@ -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
|
||||
];
|
||||
};
|
||||
|
||||
@@ -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', {});
|
||||
|
||||
@@ -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',
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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.
|
||||
*/
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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[]}
|
||||
|
||||
@@ -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 {
|
||||
);
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -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();
|
||||
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 });
|
||||
|
||||
|
||||
@@ -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
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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(),
|
||||
});
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
});
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
19
packages/server/src/services/Banking/BankAccounts/types.ts
Normal file
19
packages/server/src/services/Banking/BankAccounts/types.ts
Normal 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',
|
||||
};
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
);
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
{
|
||||
|
||||
@@ -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
|
||||
);
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
@@ -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),
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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 = {
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -35,7 +35,8 @@ export class RecognizeSyncedBankTranasctions extends EventSubscriber {
|
||||
runAfterTransaction(trx, async () => {
|
||||
await this.recognizeTranasctionsService.recognizeTransactions(
|
||||
tenantId,
|
||||
batch
|
||||
null,
|
||||
{ batch }
|
||||
);
|
||||
});
|
||||
};
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
);
|
||||
|
||||
@@ -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),
|
||||
}
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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'
|
||||
|
||||
@@ -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
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
export interface RevertRecognizedTransactionsCriteria {
|
||||
batch?: string;
|
||||
accountId?: number;
|
||||
}
|
||||
|
||||
|
||||
export interface RecognizeTransactionsCriteria {
|
||||
batch?: string;
|
||||
accountId?: number;
|
||||
}
|
||||
|
||||
@@ -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';
|
||||
}
|
||||
};
|
||||
};
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
@@ -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);
|
||||
}
|
||||
};
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
};
|
||||
}
|
||||
@@ -62,6 +62,7 @@ export class CreateBankRuleService {
|
||||
await this.eventPublisher.emitAsync(events.bankRules.onCreated, {
|
||||
tenantId,
|
||||
createRuleDTO,
|
||||
bankRule,
|
||||
trx,
|
||||
} as IBankRuleEventCreatedPayload);
|
||||
|
||||
|
||||
@@ -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, {
|
||||
|
||||
@@ -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
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
);
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -37,7 +37,7 @@ export class CreateUncategorizedTransaction {
|
||||
tenantId,
|
||||
async (trx: Knex.Transaction) => {
|
||||
await this.eventPublisher.emitAsync(
|
||||
events.cashflow.onTransactionUncategorizedCreated,
|
||||
events.cashflow.onTransactionUncategorizedCreating,
|
||||
{
|
||||
tenantId,
|
||||
createUncategorizedTransactionDTO,
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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;
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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');
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -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 = (
|
||||
|
||||
@@ -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
|
||||
};
|
||||
};
|
||||
|
||||
|
||||
51
packages/server/src/services/Loops/LoopsEventsSubscriber.ts
Normal file
51
packages/server/src/services/Loops/LoopsEventsSubscriber.ts
Normal file
@@ -0,0 +1,51 @@
|
||||
import axios from 'axios';
|
||||
import config from '@/config';
|
||||
import { IAuthSignUpVerifiedEventPayload } from '@/interfaces';
|
||||
import events from '@/subscribers/events';
|
||||
import { SystemUser } from '@/system/models';
|
||||
|
||||
export class LoopsEventsSubscriber {
|
||||
/**
|
||||
* Constructor method.
|
||||
*/
|
||||
public attach(bus) {
|
||||
bus.subscribe(
|
||||
events.auth.signUpConfirmed,
|
||||
this.triggerEventOnSignupVerified.bind(this)
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Once the user verified sends the event to the Loops.
|
||||
* @param {IAuthSignUpVerifiedEventPayload} param0
|
||||
*/
|
||||
public async triggerEventOnSignupVerified({
|
||||
email,
|
||||
userId,
|
||||
}: IAuthSignUpVerifiedEventPayload) {
|
||||
// Can't continue since the Loops the api key is not configured.
|
||||
if (!config.loops.apiKey) {
|
||||
return;
|
||||
}
|
||||
const user = await SystemUser.query().findById(userId);
|
||||
|
||||
const options = {
|
||||
method: 'POST',
|
||||
url: 'https://app.loops.so/api/v1/events/send',
|
||||
headers: {
|
||||
Authorization: `Bearer ${config.loops.apiKey}`,
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
data: {
|
||||
email,
|
||||
userId,
|
||||
firstName: user.firstName,
|
||||
lastName: user.lastName,
|
||||
eventName: 'USER_VERIFIED',
|
||||
eventProperties: {},
|
||||
mailingLists: {},
|
||||
},
|
||||
};
|
||||
await axios(options);
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
Reference in New Issue
Block a user