Compare commits

...

58 Commits

Author SHA1 Message Date
Ahmed Bouhuolia
8f30c86f5f fizx: remove debug console.log 2024-07-30 21:54:55 +02:00
Ahmed Bouhuolia
6affbedef4 feat: description to billing page 2024-07-30 21:43:33 +02:00
Ahmed Bouhuolia
ba7f32c1bf feat: abstract the pricing plans for setup and billing page 2024-07-30 17:47:03 +02:00
Ahmed Bouhuolia
07c57ed539 Merge branch 'develop' into billing-subscription-page 2024-07-30 10:03:31 +02:00
Ahmed Bouhuolia
788150f80d Merge pull request #549 from oleynikd/s3-path-style
Added support of path-style S3 endpoints
2024-07-30 00:06:16 +02:00
Denis
c09384e49b Added support of path-style S3 endpoints
This can be very useful when using S3-compatible object storages like MinIO
2024-07-29 23:48:29 +03:00
Ahmed Bouhuolia
e11f1a95f6 Merge pull request #529 from bigcapitalhq/disconnect-bank-account
feat: Disconnect bank account
2024-07-29 20:18:36 +02:00
Ahmed Bouhuolia
b91273eee4 Merge branch 'develop' into disconnect-bank-account 2024-07-29 20:17:09 +02:00
Ahmed Bouhuolia
b5d570417b fix: add events interfaces of disconnect bank account 2024-07-29 20:10:15 +02:00
Ahmed Bouhuolia
acd3265e35 feat: add migration to is_syncing_owner column in accounts table 2024-07-29 20:01:04 +02:00
Ahmed Bouhuolia
894c899847 feat: improvement in Plaid accounts disconnecting 2024-07-29 19:49:20 +02:00
Ahmed Bouhuolia
f6d4ec504f feat: tweaks in disconnecting bank account 2024-07-29 16:55:50 +02:00
Ahmed Bouhuolia
1a01461f5d feat: delete Plaid item once bank account deleted 2024-07-29 16:20:59 +02:00
Ahmed Bouhuolia
89552d7ee2 Merge pull request #532 from bigcapitalhq/bulk-exclude-bank-transactions
feat: Bulk exclude bank transactions
2024-07-29 13:01:56 +02:00
Ahmed Bouhuolia
4345623ea9 feat: document functions 2024-07-29 13:00:50 +02:00
Ahmed Bouhuolia
f457759e39 Merge branch 'develop' into bulk-exclude-bank-transactions 2024-07-29 12:00:49 +02:00
Ahmed Bouhuolia
14d5e82b4a fix: style of database checkbox 2024-07-29 12:00:34 +02:00
Ahmed Bouhuolia
333b6f5a4b feat: change subscription plan 2024-07-28 20:52:53 +02:00
Ahmed Bouhuolia
1660df20af feat: wip billing page 2024-07-28 17:53:55 +02:00
Ahmed Bouhuolia
14a9c4ba28 fix: style tweaks in billing page 2024-07-27 21:56:55 +02:00
Ahmed Bouhuolia
383be111fa feat: style the billing page 2024-07-27 21:47:17 +02:00
Ahmed Bouhuolia
7720b1cc34 feat: getting subscription endpoint 2024-07-27 17:39:50 +02:00
Ahmed Bouhuolia
db634cbb79 feat: pause, resume main subscription 2024-07-27 16:55:56 +02:00
Ahmed Bouhuolia
53f37f4f48 Merge pull request #546 from bigcapitalhq/remove-views-tabs
feat: Remove the views tabs bar from all tables
2024-07-25 19:21:50 +02:00
Ahmed Bouhuolia
0a7b522b87 chore: remove unused import 2024-07-25 19:21:16 +02:00
Ahmed Bouhuolia
9e6500ac79 feat: remove the views tabs bar from all tables 2024-07-25 19:17:54 +02:00
Ahmed Bouhuolia
b93cb546f4 Merge pull request #545 from bigcapitalhq/excessed-payments-as-credit
Excessed payments as credit
2024-07-25 18:57:31 +02:00
Ahmed Bouhuolia
6d17f9cbeb feat: record excessed payments as credit 2024-07-25 18:46:24 +02:00
Ahmed Bouhuolia
998e6de211 feat: billing subscription page 2024-07-25 15:21:01 +02:00
Ahmed Bouhuolia
51471ed000 feat: exclude bank transactions in bulk 2024-07-17 23:19:59 +02:00
Ahmed Bouhuolia
fe214b1b2d feat: push CHANGELOG 2024-07-17 16:53:47 +02:00
Ahmed Bouhuolia
6b6b73b77c feat: send signup event to Loops (#531)
* feat: send signup event to Loops

* feat: fix
2024-07-17 15:56:05 +02:00
Ahmed Bouhuolia
c2815afbe3 feat: disconnect and update bank account 2024-07-16 17:09:00 +02:00
Ahmed Bouhuolia
fa7e6b1fca feat: disconnect bank account 2024-07-15 23:18:39 +02:00
Ahmed Bouhuolia
107a6f793b Merge pull request #526 from bigcapitalhq/monthly-plans
feat: upgrade the subscription plans
2024-07-14 14:21:57 +02:00
Ahmed Bouhuolia
67d155759e feat: backend the new monthly susbcription plans 2024-07-14 14:19:04 +02:00
Ahmed Bouhuolia
7e2e87256f Merge pull request #527 from bigcapitalhq/fix-sync-removed-transactions
fix: sync the removed bank transactions from the source
2024-07-13 21:56:13 +02:00
Ahmed Bouhuolia
df7790d7c1 fix: sync the removed bank transactions from the source 2024-07-13 21:54:44 +02:00
Ahmed Bouhuolia
72128a72c4 feat: add variant ids to new subscription plans 2024-07-13 19:53:52 +02:00
Ahmed Bouhuolia
eb3f23554f feat: upgrade the subscription plans 2024-07-13 18:19:18 +02:00
Ahmed Bouhuolia
69ddf43b3e fix: duplicated event emitter 2024-07-13 03:23:25 +02:00
Ahmed Bouhuolia
249eadaeaa Merge pull request #525 from bigcapitalhq/fix-plaid-transactions-syncing
fix: Plaid transactions syncing
2024-07-12 23:44:27 +02:00
Ahmed Bouhuolia
59168bc691 fix: Plaid transactions syncing 2024-07-12 23:43:20 +02:00
Ahmed Bouhuolia
81b26c6f13 fix(hotfix): uniqid import 2024-07-12 20:15:28 +02:00
Ahmed Bouhuolia
da435d85d9 Merge pull request #524 from bigcapitalhq/fix-cashflow-transactions-type
fix: Cashflow transactions types
2024-07-09 14:57:43 +02:00
Ahmed Bouhuolia
533006b90e fix: Cashflow transactions types 2024-07-09 14:47:30 +02:00
Ahmed Bouhuolia
d096e49d45 Merge pull request #523 from bigcapitalhq/matching-transactions-fixes
fix: Matching transactions bugs
2024-07-08 22:18:12 +02:00
Ahmed Bouhuolia
73acdb6240 fix: add bank rule categories 2024-07-08 21:48:16 +02:00
Ahmed Bouhuolia
38d4122d11 fix: matching transactions bugs 2024-07-08 19:37:11 +02:00
Ahmed Bouhuolia
24a77c81b3 fix: unexpected char in cashflow transactions report 2024-07-08 15:25:28 +02:00
Ahmed Bouhuolia
7f41b4280e fix: the database migration schema 2024-07-08 15:18:58 +02:00
Ahmed Bouhuolia
aa89653967 Merge pull request #522 from bigcapitalhq/reconcile-match-transactionss
Reconcile match transactionss
2024-07-07 23:52:30 +02:00
Ahmed Bouhuolia
b80bc95fa5 fix: increment/decrement uncategorized transactions on excluding 2024-07-07 23:35:26 +02:00
Ahmed Bouhuolia
9a5befbee7 fix: bank transactions report 2024-07-07 22:11:57 +02:00
Ahmed Bouhuolia
b7487f19d3 fix: improvements to bank matching transactions 2024-07-06 19:10:07 +02:00
Ahmed Bouhuolia
cd9039fe16 fix(server): match transactions query 2024-07-06 16:10:34 +02:00
Ahmed Bouhuolia
87f60f7461 feat: cashflow transaction matching 2024-07-04 22:44:20 +02:00
Ahmed Bouhuolia
202179ec0b feat: reconcile matching transactions 2024-07-04 19:21:05 +02:00
242 changed files with 6349 additions and 1573 deletions

View File

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

View File

@@ -3,12 +3,16 @@ import { NextFunction, Request, Response, Router } from 'express';
import BaseController from '@/api/controllers/BaseController';
import { CashflowApplication } from '@/services/Cashflow/CashflowApplication';
import { GetBankAccountSummary } from '@/services/Banking/BankAccounts/GetBankAccountSummary';
import { BankAccountsApplication } from '@/services/Banking/BankAccounts/BankAccountsApplication';
@Service()
export class BankAccountsController extends BaseController {
@Inject()
private getBankAccountSummaryService: GetBankAccountSummary;
@Inject()
private bankAccountsApp: BankAccountsApplication;
/**
* Router constructor.
*/
@@ -16,6 +20,11 @@ export class BankAccountsController extends BaseController {
const router = Router();
router.get('/:bankAccountId/meta', this.getBankAccountSummary.bind(this));
router.post(
'/:bankAccountId/disconnect',
this.disconnectBankAccount.bind(this)
);
router.post('/:bankAccountId/update', this.refreshBankAccount.bind(this));
return router;
}
@@ -46,4 +55,58 @@ export class BankAccountsController extends BaseController {
next(error);
}
}
/**
* Disonnect the given bank account.
* @param {Request} req
* @param {Response} res
* @param {NextFunction} next
* @returns {Promise<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);
}
}
}

View File

@@ -38,15 +38,7 @@ export class BankingRulesController extends BaseController {
body('conditions.*.value').exists(),
// Assign
body('assign_category')
.isString()
.isIn([
'interest_income',
'other_income',
'deposit',
'expense',
'owner_drawings',
]),
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 }),

View File

@@ -1,8 +1,9 @@
import { Inject, Service } from 'typedi';
import { param } from 'express-validator';
import { NextFunction, Request, Response, Router, query } from 'express';
import { body, param, query } from 'express-validator';
import { NextFunction, Request, Response, Router } from 'express';
import BaseController from '../BaseController';
import { ExcludeBankTransactionsApplication } from '@/services/Banking/Exclude/ExcludeBankTransactionsApplication';
import { map, parseInt, trim } from 'lodash';
@Service()
export class ExcludeBankTransactionsController extends BaseController {
@@ -15,9 +16,21 @@ export class ExcludeBankTransactionsController extends BaseController {
public router() {
const router = Router();
router.put(
'/transactions/exclude',
[body('ids').exists()],
this.validationResult,
this.excludeBulkBankTransactions.bind(this)
);
router.put(
'/transactions/unexclude',
[body('ids').exists()],
this.validationResult,
this.unexcludeBulkBankTransactins.bind(this)
);
router.put(
'/transactions/:transactionId/exclude',
[param('transactionId').exists()],
[param('transactionId').exists().toInt()],
this.validationResult,
this.excludeBankTransaction.bind(this)
);
@@ -94,6 +107,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
@@ -109,7 +179,6 @@ export class ExcludeBankTransactionsController extends BaseController {
const { tenantId } = req;
const filter = this.matchedBodyData(req);
console.log('123');
try {
const data =
await this.excludeBankTransactionApp.getExcludedBankTransactions(

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,7 +1,16 @@
export const CashflowTransactionTypes = {
OtherIncome: 'Other income',
OtherExpense: 'Other expense',
OwnerDrawing: 'Owner drawing',
OwnerContribution: 'Owner contribution',
TransferToAccount: 'Transfer to account',
TransferFromAccount: 'Transfer from account',
};
export const TransactionTypes = {
SaleInvoice: 'Sale invoice',
SaleReceipt: 'Sale receipt',
PaymentReceive: 'Payment receive',
PaymentReceive: 'Payment received',
Bill: 'Bill',
BillPayment: 'Payment made',
VendorOpeningBalance: 'Vendor opening balance',
@@ -17,12 +26,10 @@ export const TransactionTypes = {
OtherExpense: 'Other expense',
OwnerDrawing: 'Owner drawing',
InvoiceWriteOff: 'Invoice write-off',
CreditNote: 'transaction_type.credit_note',
VendorCredit: 'transaction_type.vendor_credit',
RefundCreditNote: 'transaction_type.refund_credit_note',
RefundVendorCredit: 'transaction_type.refund_vendor_credit',
LandedCost: 'transaction_type.landed_cost',
CashflowTransaction: CashflowTransactionTypes,
};

View File

@@ -5,7 +5,8 @@ exports.up = function (knex) {
.integer('uncategorized_transaction_id')
.unsigned()
.references('id')
.inTable('uncategorized_cashflow_transactions');
.inTable('uncategorized_cashflow_transactions')
.withKeyName('recognizedBankTransactionsUncategorizedTransIdForeign');
table
.integer('bank_rule_id')
.unsigned()

View File

@@ -1,6 +1,11 @@
exports.up = function (knex) {
return knex.schema.table('uncategorized_cashflow_transactions', (table) => {
table.integer('recognized_transaction_id').unsigned();
table
.integer('recognized_transaction_id')
.unsigned()
.references('id')
.inTable('recognized_bank_transactions')
.withKeyName('uncategorizedCashflowTransRecognizedTranIdForeign');
});
};

View File

@@ -1,7 +1,11 @@
exports.up = function (knex) {
return knex.schema.createTable('matched_bank_transactions', (table) => {
table.increments('id');
table.integer('uncategorized_transaction_id').unsigned();
table
.integer('uncategorized_transaction_id')
.unsigned()
.references('id')
.inTable('uncategorized_cashflow_transactions');
table.string('reference_type');
table.integer('reference_id').unsigned();
table.decimal('amount');

View File

@@ -0,0 +1,60 @@
exports.up = function (knex) {
return knex('accounts_transactions')
.whereIn('referenceType', [
'OtherIncome',
'OtherExpense',
'OwnerDrawing',
'OwnerContribution',
'TransferToAccount',
'TransferFromAccount',
])
.update({
transactionType: knex.raw(`
CASE
WHEN REFERENCE_TYPE = 'OtherIncome' THEN 'OtherIncome'
WHEN REFERENCE_TYPE = 'OtherExpense' THEN 'OtherExpense'
WHEN REFERENCE_TYPE = 'OwnerDrawing' THEN 'OwnerDrawing'
WHEN REFERENCE_TYPE = 'OwnerContribution' THEN 'OwnerContribution'
WHEN REFERENCE_TYPE = 'TransferToAccount' THEN 'TransferToAccount'
WHEN REFERENCE_TYPE = 'TransferFromAccount' THEN 'TransferFromAccount'
END
`),
referenceType: knex.raw(`
CASE
WHEN REFERENCE_TYPE IN ('OtherIncome', 'OtherExpense', 'OwnerDrawing', 'OwnerContribution', 'TransferToAccount', 'TransferFromAccount') THEN 'CashflowTransaction'
ELSE REFERENCE_TYPE
END
`),
});
};
exports.down = function (knex) {
return knex('accounts_transactions')
.whereIn('transactionType', [
'OtherIncome',
'OtherExpense',
'OwnerDrawing',
'OwnerContribution',
'TransferToAccount',
'TransferFromAccount',
])
.update({
referenceType: knex.raw(`
CASE
WHEN TRANSACTION_TYPE = 'OtherIncome' THEN 'OtherIncome'
WHEN TRANSACTION_TYPE = 'OtherExpense' THEN 'OtherExpense'
WHEN TRANSACTION_TYPE = 'OwnerDrawing' THEN 'OwnerDrawing'
WHEN TRANSACTION_TYPE = 'OwnerContribution' THEN 'OwnerContribution'
WHEN TRANSACTION_TYPE = 'TransferToAccount' THEN 'TransferToAccount'
WHEN TRANSACTION_TYPE = 'TransferFromAccount' THEN 'TransferFromAccount'
ELSE REFERENCE_TYPE
END
`),
transactionType: knex.raw(`
CASE
WHEN TRANSACTION_TYPE IN ('OtherIncome', 'OtherExpense', 'OwnerDrawing', 'OwnerContribution', 'TransferToAccount', 'TransferFromAccount') THEN NULL
ELSE TRANSACTION_TYPE
END
`),
});
};

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,3 +1,4 @@
import { Knex } from 'knex';
import {
IFinancialSheetCommonMeta,
INumberFormatQuery,
@@ -257,7 +258,6 @@ export interface IUncategorizedCashflowTransaction {
categorized: boolean;
}
export interface CreateUncategorizedTransactionDTO {
date: Date | string;
accountId: number;
@@ -269,3 +269,16 @@ export interface CreateUncategorizedTransactionDTO {
plaidTransactionId?: string | null;
batch?: string;
}
export interface IUncategorizedTransactionCreatingEventPayload {
tenantId: number;
createUncategorizedTransactionDTO: CreateUncategorizedTransactionDTO;
trx: Knex.Transaction;
}
export interface IUncategorizedTransactionCreatedEventPayload {
tenantId: number;
uncategorizedTransaction: any;
createUncategorizedTransactionDTO: CreateUncategorizedTransactionDTO;
trx: Knex.Transaction;
}

View File

@@ -130,8 +130,9 @@ export interface ICommandCashflowDeletedPayload {
export interface ICashflowTransactionCategorizedPayload {
tenantId: number;
cashflowTransactionId: number;
uncategorizedTransaction: any;
cashflowTransaction: ICashflowTransaction;
categorizeDTO: any;
trx: Knex.Transaction;
}
export interface ICashflowTransactionUncategorizingPayload {

View File

@@ -29,4 +29,9 @@ export interface ICashflowAccountTransaction {
date: Date;
formattedDate: string;
status: string;
formattedStatus: string;
uncategorizedTransactionId: number;
}

View File

@@ -0,0 +1,8 @@
import { ImportFilePreviewPOJO } from "@/services/Import/interfaces";
export interface IImportFileCommitedEventPayload {
tenantId: number;
importId: number;
meta: ImportFilePreviewPOJO;
}

View File

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

View File

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

View File

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

View File

@@ -110,6 +110,11 @@ import { ValidateMatchingOnPaymentMadeDelete } from '@/services/Banking/Matching
import { ValidateMatchingOnCashflowDelete } from '@/services/Banking/Matching/events/ValidateMatchingOnCashflowDelete';
import { RecognizeSyncedBankTranasctions } from '@/services/Banking/Plaid/subscribers/RecognizeSyncedBankTransactions';
import { UnlinkBankRuleOnDeleteBankRule } from '@/services/Banking/Rules/events/UnlinkBankRuleOnDeleteBankRule';
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();
@@ -258,6 +263,9 @@ export const susbcribers = () => {
// Bank Rules
TriggerRecognizedTransactions,
UnlinkBankRuleOnDeleteBankRule,
DecrementUncategorizedTransactionOnMatching,
DecrementUncategorizedTransactionOnExclude,
DecrementUncategorizedTransactionOnCategorize,
// Validate matching
ValidateMatchingOnCashflowDelete,
@@ -266,7 +274,11 @@ export const susbcribers = () => {
ValidateMatchingOnPaymentReceivedDelete,
ValidateMatchingOnPaymentMadeDelete,
// Plaid
// Plaid
RecognizeSyncedBankTranasctions,
DisconnectPlaidItemOnAccountDeleted,
// Loops
LoopsEventsSubscriber
];
};

View File

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

View File

@@ -10,6 +10,7 @@ export default class AccountTransaction extends TenantModel {
debit: number;
exchangeRate: number;
taxRate: number;
transactionType: string;
/**
* Table name
@@ -53,7 +54,7 @@ export default class AccountTransaction extends TenantModel {
* @return {string}
*/
get referenceTypeFormatted() {
return getTransactionTypeLabel(this.referenceType);
return getTransactionTypeLabel(this.referenceType, this.transactionType);
}
/**

View File

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

View File

@@ -5,9 +5,9 @@ import {
getCashflowAccountTransactionsTypes,
getCashflowTransactionType,
} from '@/services/Cashflow/utils';
import AccountTransaction from './AccountTransaction';
import { CASHFLOW_DIRECTION } from '@/services/Cashflow/constants';
import { getTransactionTypeLabel } from '@/utils/transactions-types';
import { getCashflowTransactionFormattedType } from '@/utils/transactions-types';
export default class CashflowTransaction extends TenantModel {
transactionType: string;
amount: number;
@@ -64,7 +64,7 @@ export default class CashflowTransaction extends TenantModel {
* @returns {string}
*/
get transactionTypeFormatted() {
return getTransactionTypeLabel(this.transactionType);
return getCashflowTransactionFormattedType(this.transactionType);
}
get typeMeta() {
@@ -95,6 +95,34 @@ export default class CashflowTransaction extends TenantModel {
return !!this.uncategorizedTransaction;
}
/**
* Model modifiers.
*/
static get modifiers() {
return {
/**
* Filter the published transactions.
*/
published(query) {
query.whereNot('published_at', null);
},
/**
* Filter the not categorized transactions.
*/
notCategorized(query) {
query.whereNull('cashflowTransactions.uncategorizedTransactionId');
},
/**
* Filter the categorized transactions.
*/
categorized(query) {
query.whereNotNull('cashflowTransactions.uncategorizedTransactionId');
},
};
}
/**
* Relationship mapping.
*/
@@ -131,8 +159,7 @@ export default class CashflowTransaction extends TenantModel {
to: 'accounts_transactions.referenceId',
},
filter(builder) {
const referenceTypes = getCashflowAccountTransactionsTypes();
builder.whereIn('reference_type', referenceTypes);
builder.where('reference_type', 'CashflowTransaction');
},
},

View File

@@ -105,8 +105,34 @@ export default class UncategorizedCashflowTransaction extends mixin(
* Filters the excluded transactions.
*/
excluded(query) {
query.whereNotNull('excluded_at')
}
query.whereNotNull('excluded_at');
},
/**
* Filter out the recognized transactions.
* @param query
*/
recognized(query) {
query.whereNotNull('recognizedTransactionId');
},
/**
* Filter out the not recognized transactions.
* @param query
*/
notRecognized(query) {
query.whereNull('recognizedTransactionId');
},
categorized(query) {
query.whereNotNull('categorizeRefType');
query.whereNotNull('categorizeRefId');
},
notCategorized(query) {
query.whereNull('categorizeRefType');
query.whereNull('categorizeRefId');
},
};
}
@@ -158,56 +184,4 @@ export default class UncategorizedCashflowTransaction extends mixin(
},
};
}
/**
* Updates the count of uncategorized transactions for the associated account
* based on the specified operation.
* @param {QueryContext} queryContext - The query context for the transaction.
* @param {boolean} increment - Indicates whether to increment or decrement the count.
*/
private async updateUncategorizedTransactionCount(
queryContext: QueryContext,
increment: boolean,
amount: number = 1
) {
const operation = increment ? 'increment' : 'decrement';
await Account.query(queryContext.transaction)
.findById(this.accountId)
[operation]('uncategorized_transactions', amount);
}
/**
* Runs after insert.
* @param {QueryContext} queryContext
*/
public async $afterInsert(queryContext) {
await super.$afterInsert(queryContext);
await this.updateUncategorizedTransactionCount(queryContext, true);
}
/**
* Runs after update.
* @param {ModelOptions} opt
* @param {QueryContext} queryContext
*/
public async $afterUpdate(
opt: ModelOptions,
queryContext: QueryContext
): Promise<any> {
await super.$afterUpdate(opt, queryContext);
if (this.id && this.categorized) {
await this.updateUncategorizedTransactionCount(queryContext, false);
}
}
/**
* Runs after delete.
* @param {QueryContext} queryContext
*/
public async $afterDelete(queryContext: QueryContext) {
await super.$afterDelete(queryContext);
await this.updateUncategorizedTransactionCount(queryContext, false);
}
}

View File

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

View File

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

View File

@@ -19,6 +19,8 @@ export const transformLedgerEntryToTransaction = (
referenceId: entry.transactionId,
transactionNumber: entry.transactionNumber,
transactionType: entry.transactionSubType,
referenceNumber: entry.referenceNumber,
note: entry.note,

View File

@@ -13,7 +13,12 @@ export class AccountTransformer extends Transformer {
* @returns {Array}
*/
public includeAttributes = (): string[] => {
return ['formattedAmount', 'flattenName', 'bankBalanceFormatted'];
return [
'formattedAmount',
'flattenName',
'bankBalanceFormatted',
'lastFeedsUpdatedAtFormatted',
];
};
/**
@@ -52,6 +57,15 @@ export class AccountTransformer extends Transformer {
});
};
/**
* Retrieves the formatted last feeds update at.
* @param {IAccount} account
* @returns {string}
*/
protected lastFeedsUpdatedAtFormatted = (account: IAccount): string => {
return this.formatDate(account.lastFeedsUpdatedAt);
};
/**
* Transformes the accounts collection to flat or nested array.
* @param {IAccount[]}

View File

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

View File

@@ -0,0 +1,38 @@
import { Inject, Service } from 'typedi';
import { DisconnectBankAccount } from './DisconnectBankAccount';
import { RefreshBankAccountService } from './RefreshBankAccount';
@Service()
export class BankAccountsApplication {
@Inject()
private disconnectBankAccountService: DisconnectBankAccount;
@Inject()
private refreshBankAccountService: RefreshBankAccountService;
/**
* Disconnects the given bank account.
* @param {number} tenantId
* @param {number} bankAccountId
* @returns {Promise<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
);
}
}

View File

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

View File

@@ -1,6 +1,6 @@
import HasTenancyService from '@/services/Tenancy/TenancyService';
import { Server } from 'socket.io';
import { Inject, Service } from 'typedi';
import { initialize } from 'objection';
import HasTenancyService from '@/services/Tenancy/TenancyService';
@Service()
export class GetBankAccountSummary {
@@ -14,22 +14,43 @@ export class GetBankAccountSummary {
* @returns
*/
public async getBankAccountSummary(tenantId: number, bankAccountId: number) {
const knex = this.tenancy.knex(tenantId);
const {
Account,
UncategorizedCashflowTransaction,
RecognizedBankTransaction,
MatchedBankTransaction,
} = this.tenancy.models(tenantId);
await initialize(knex, [
UncategorizedCashflowTransaction,
RecognizedBankTransaction,
MatchedBankTransaction,
]);
const bankAccount = await Account.query()
.findById(bankAccountId)
.throwIfNotFound();
// Retrieves the uncategorized transactions count of the given bank account.
const uncategorizedTranasctionsCount =
await UncategorizedCashflowTransaction.query()
.where('accountId', bankAccountId)
.count('id as total')
.first();
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');
// Only the not matched bank transactions.
q.withGraphJoined('matchedBankTransactions');
q.whereNull('matchedBankTransactions.id');
// Count the results.
q.count('uncategorized_cashflow_transactions.id as total');
q.first();
});
// Retrieves the recognized transactions count of the given bank account.
const recognizedTransactionsCount = await RecognizedBankTransaction.query()
@@ -43,8 +64,8 @@ export class GetBankAccountSummary {
.first();
const totalUncategorizedTransactions =
uncategorizedTranasctionsCount?.total;
const totalRecognizedTransactions = recognizedTransactionsCount?.total;
uncategorizedTranasctionsCount?.total || 0;
const totalRecognizedTransactions = recognizedTransactionsCount?.total || 0;
return {
name: bankAccount.name,

View File

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

View File

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

View File

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

View File

@@ -2,6 +2,12 @@ import HasTenancyService from '@/services/Tenancy/TenancyService';
import UnitOfWork from '@/services/UnitOfWork';
import { Inject, Service } from 'typedi';
import { validateTransactionNotCategorized } from './utils';
import { EventPublisher } from '@/lib/EventPublisher/EventPublisher';
import events from '@/subscribers/events';
import {
IBankTransactionUnexcludedEventPayload,
IBankTransactionUnexcludingEventPayload,
} from './_types';
@Service()
export class ExcludeBankTransaction {
@@ -11,6 +17,9 @@ export class ExcludeBankTransaction {
@Inject()
private uow: UnitOfWork;
@Inject()
private eventPublisher: EventPublisher;
/**
* Marks the given bank transaction as excluded.
* @param {number} tenantId
@@ -31,11 +40,23 @@ export class ExcludeBankTransaction {
validateTransactionNotCategorized(oldUncategorizedTransaction);
return this.uow.withTransaction(tenantId, async (trx) => {
await this.eventPublisher.emitAsync(events.bankTransactions.onExcluding, {
tenantId,
uncategorizedTransactionId,
trx,
} as IBankTransactionUnexcludingEventPayload);
await UncategorizedCashflowTransaction.query(trx)
.findById(uncategorizedTransactionId)
.patch({
excludedAt: new Date(),
});
await this.eventPublisher.emitAsync(events.bankTransactions.onExcluded, {
tenantId,
uncategorizedTransactionId,
trx,
} as IBankTransactionUnexcludedEventPayload);
});
}
}

View File

@@ -0,0 +1,32 @@
import { Inject, Service } from 'typedi';
import PromisePool from '@supercharge/promise-pool';
import { castArray } 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 = castArray(bankTransactionIds);
await PromisePool.withConcurrency(1)
.for(_bankTransactionIds)
.process((bankTransactionId: number) => {
return this.excludeBankTransaction.excludeBankTransaction(
tenantId,
bankTransactionId
);
});
}
}

View File

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

View File

@@ -2,6 +2,12 @@ import HasTenancyService from '@/services/Tenancy/TenancyService';
import UnitOfWork from '@/services/UnitOfWork';
import { Inject, Service } from 'typedi';
import { validateTransactionNotCategorized } from './utils';
import { EventPublisher } from '@/lib/EventPublisher/EventPublisher';
import events from '@/subscribers/events';
import {
IBankTransactionExcludedEventPayload,
IBankTransactionExcludingEventPayload,
} from './_types';
@Service()
export class UnexcludeBankTransaction {
@@ -11,6 +17,9 @@ export class UnexcludeBankTransaction {
@Inject()
private uow: UnitOfWork;
@Inject()
private eventPublisher: EventPublisher;
/**
* Marks the given bank transaction as excluded.
* @param {number} tenantId
@@ -20,7 +29,7 @@ export class UnexcludeBankTransaction {
public async unexcludeBankTransaction(
tenantId: number,
uncategorizedTransactionId: number
) {
): Promise<void> {
const { UncategorizedCashflowTransaction } = this.tenancy.models(tenantId);
const oldUncategorizedTransaction =
@@ -31,11 +40,27 @@ export class UnexcludeBankTransaction {
validateTransactionNotCategorized(oldUncategorizedTransaction);
return this.uow.withTransaction(tenantId, async (trx) => {
await this.eventPublisher.emitAsync(
events.bankTransactions.onUnexcluding,
{
tenantId,
uncategorizedTransactionId,
} as IBankTransactionExcludingEventPayload
);
await UncategorizedCashflowTransaction.query(trx)
.findById(uncategorizedTransactionId)
.patch({
excludedAt: null,
});
await this.eventPublisher.emitAsync(
events.bankTransactions.onUnexcluded,
{
tenantId,
uncategorizedTransactionId,
} as IBankTransactionExcludedEventPayload
);
});
}
}

View File

@@ -0,0 +1,31 @@
import { Inject, Service } from 'typedi';
import PromisePool from '@supercharge/promise-pool';
import { UnexcludeBankTransaction } from './UnexcludeBankTransaction';
import { castArray } 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 = castArray(bankTransactionIds);
await PromisePool.withConcurrency(1)
.for(_bankTransactionIds)
.process((bankTransactionId: number) => {
return this.unexcludeBankTransaction.unexcludeBankTransaction(
tenantId,
bankTransactionId
);
});
}
}

View File

@@ -1,6 +1,30 @@
import { Knex } from "knex";
export interface ExcludedBankTransactionsQuery {
page?: number;
pageSize?: number;
accountId?: number;
}
}
export interface IBankTransactionUnexcludingEventPayload {
tenantId: number;
uncategorizedTransactionId: number;
trx?: Knex.Transaction
}
export interface IBankTransactionUnexcludedEventPayload {
tenantId: number;
uncategorizedTransactionId: number;
trx?: Knex.Transaction
}
export interface IBankTransactionExcludingEventPayload {
tenantId: number;
uncategorizedTransactionId: number;
trx?: Knex.Transaction
}
export interface IBankTransactionExcludedEventPayload {
tenantId: number;
uncategorizedTransactionId: number;
trx?: Knex.Transaction
}

View File

@@ -0,0 +1,68 @@
import { Inject, Service } from 'typedi';
import events from '@/subscribers/events';
import HasTenancyService from '@/services/Tenancy/TenancyService';
import {
IBankTransactionExcludedEventPayload,
IBankTransactionUnexcludedEventPayload,
} from '../_types';
@Service()
export class DecrementUncategorizedTransactionOnExclude {
@Inject()
private tenancy: HasTenancyService;
/**
* Constructor method.
*/
public attach(bus) {
bus.subscribe(
events.bankTransactions.onExcluded,
this.decrementUnCategorizedTransactionsOnExclude.bind(this)
);
bus.subscribe(
events.bankTransactions.onUnexcluded,
this.incrementUnCategorizedTransactionsOnUnexclude.bind(this)
);
}
/**
* Validates the cashflow transaction whether matched with bank transaction on deleting.
* @param {IManualJournalDeletingPayload}
*/
public async decrementUnCategorizedTransactionsOnExclude({
tenantId,
uncategorizedTransactionId,
trx,
}: IBankTransactionExcludedEventPayload) {
const { UncategorizedCashflowTransaction, Account } =
this.tenancy.models(tenantId);
const transaction = await UncategorizedCashflowTransaction.query(
trx
).findById(uncategorizedTransactionId);
await Account.query(trx)
.findById(transaction.accountId)
.decrement('uncategorizedTransactions', 1);
}
/**
* Validates the cashflow transaction whether matched with bank transaction on deleting.
* @param {IManualJournalDeletingPayload}
*/
public async incrementUnCategorizedTransactionsOnUnexclude({
tenantId,
uncategorizedTransactionId,
trx,
}: IBankTransactionUnexcludedEventPayload) {
const { UncategorizedCashflowTransaction, Account } =
this.tenancy.models(tenantId);
const transaction = await UncategorizedCashflowTransaction.query().findById(
uncategorizedTransactionId
);
//
await Account.query(trx)
.findById(transaction.accountId)
.increment('uncategorizedTransactions', 1);
}
}

View File

@@ -17,6 +17,9 @@ export class GetMatchedTransactionBillsTransformer extends Transformer {
'transactionNo',
'transactionType',
'transsactionTypeFormatted',
'transactionNormal',
'referenceId',
'referenceType',
];
};
@@ -100,4 +103,29 @@ export class GetMatchedTransactionBillsTransformer extends Transformer {
protected transsactionTypeFormatted() {
return 'Bill';
}
/**
* Retrieves the bill transaction normal (debit or credit).
* @returns {string}
*/
protected transactionNormal() {
return 'credit';
}
/**
* Retrieve the match transaction reference id.
* @param bill
* @returns {number}
*/
protected referenceId(bill) {
return bill.id;
}
/**
* Retrieve the match transaction referenece type.
* @returns {string}
*/
protected referenceType() {
return 'Bill';
}
}

View File

@@ -0,0 +1,142 @@
import { Transformer } from '@/lib/Transformer/Transformer';
export class GetMatchedTransactionCashflowTransformer extends Transformer {
/**
* Include these attributes to sale credit note object.
* @returns {Array}
*/
public includeAttributes = (): string[] => {
return [
'referenceNo',
'amount',
'amountFormatted',
'transactionNo',
'date',
'dateFormatted',
'transactionId',
'transactionNo',
'transactionType',
'transsactionTypeFormatted',
'transactionNormal',
'referenceId',
'referenceType',
];
};
/**
* Exclude all attributes.
* @returns {Array<string>}
*/
public excludeAttributes = (): string[] => {
return ['*'];
};
/**
* Retrieve the invoice reference number.
* @returns {string}
*/
protected referenceNo(invoice) {
return invoice.referenceNo;
}
/**
* Retrieve the transaction amount.
* @param transaction
* @returns {number}
*/
protected amount(transaction) {
return transaction.amount;
}
/**
* Retrieve the transaction formatted amount.
* @param transaction
* @returns {string}
*/
protected amountFormatted(transaction) {
return this.formatNumber(transaction.amount, {
currencyCode: transaction.currencyCode,
money: true,
});
}
/**
* Retrieve the date of the invoice.
* @param invoice
* @returns {Date}
*/
protected date(transaction) {
return transaction.date;
}
/**
* Format the date of the invoice.
* @param invoice
* @returns {string}
*/
protected dateFormatted(transaction) {
return this.formatDate(transaction.date);
}
/**
* Retrieve the transaction ID of the invoice.
* @param invoice
* @returns {number}
*/
protected transactionId(transaction) {
return transaction.id;
}
/**
* Retrieve the invoice transaction number.
* @param invoice
* @returns {string}
*/
protected transactionNo(transaction) {
return transaction.transactionNumber;
}
/**
* Retrieve the invoice transaction type.
* @param invoice
* @returns {String}
*/
protected transactionType(transaction) {
return transaction.transactionType;
}
/**
* Retrieve the invoice formatted transaction type.
* @param invoice
* @returns {string}
*/
protected transsactionTypeFormatted(transaction) {
return transaction.transactionTypeFormatted;
}
/**
* Retrieve the cashflow transaction normal (credit or debit).
* @param transaction
* @returns {string}
*/
protected transactionNormal(transaction) {
return transaction.isCashCredit ? 'credit' : 'debit';
}
/**
* Retrieves the cashflow transaction reference id.
* @param transaction
* @returns {number}
*/
protected referenceId(transaction) {
return transaction.id;
}
/**
* Retrieves the cashflow transaction reference type.
* @returns {string}
*/
protected referenceType() {
return 'CashflowTransaction';
}
}

View File

@@ -17,6 +17,9 @@ export class GetMatchedTransactionExpensesTransformer extends Transformer {
'transactionNo',
'transactionType',
'transsactionTypeFormatted',
'transactionNormal',
'referenceType',
'referenceId',
];
};
@@ -111,4 +114,29 @@ export class GetMatchedTransactionExpensesTransformer extends Transformer {
protected transsactionTypeFormatted() {
return 'Expense';
}
/**
* Retrieve the expense transaction normal (credit or debit).
* @returns {string}
*/
protected transactionNormal() {
return 'credit';
}
/**
* Retrieve the transaction reference type.
* @returns {string}
*/
protected referenceType() {
return 'Expense';
}
/**
* Retrieve the transaction reference id.
* @param transaction
* @returns {number}
*/
protected referenceId(transaction) {
return transaction.id;
}
}

View File

@@ -17,6 +17,9 @@ export class GetMatchedTransactionInvoicesTransformer extends Transformer {
'transactionNo',
'transactionType',
'transsactionTypeFormatted',
'transactionNormal',
'referenceType',
'referenceId'
];
};
@@ -49,7 +52,7 @@ export class GetMatchedTransactionInvoicesTransformer extends Transformer {
* @param invoice
* @returns {string}
*/
protected formatAmount(invoice) {
protected amountFormatted(invoice) {
return this.formatNumber(invoice.dueAmount, {
currencyCode: invoice.currencyCode,
money: true,
@@ -79,7 +82,7 @@ export class GetMatchedTransactionInvoicesTransformer extends Transformer {
* @param invoice
* @returns {number}
*/
protected getTransactionId(invoice) {
protected transactionId(invoice) {
return invoice.id;
}
/**
@@ -108,4 +111,28 @@ export class GetMatchedTransactionInvoicesTransformer extends Transformer {
protected transsactionTypeFormatted(invoice) {
return 'Sale invoice';
}
/**
* Retrieve the transaction normal of invoice (credit or debit).
* @returns {string}
*/
protected transactionNormal() {
return 'debit';
}
/**
* Retrieve the transaction reference type.
* @returns {string}
*/ protected referenceType() {
return 'SaleInvoice';
}
/**
* Retrieve the transaction reference id.
* @param transaction
* @returns {number}
*/
protected referenceId(transaction) {
return transaction.id;
}
}

View File

@@ -1,4 +1,6 @@
import { sumBy } from 'lodash';
import { Transformer } from '@/lib/Transformer/Transformer';
import { AccountNormal } from '@/interfaces';
export class GetMatchedTransactionManualJournalsTransformer extends Transformer {
/**
@@ -17,6 +19,9 @@ export class GetMatchedTransactionManualJournalsTransformer extends Transformer
'transactionNo',
'transactionType',
'transsactionTypeFormatted',
'transactionNormal',
'referenceType',
'referenceId',
];
};
@@ -37,13 +42,20 @@ export class GetMatchedTransactionManualJournalsTransformer extends Transformer
return manualJournal.referenceNo;
}
protected total(manualJournal) {
const credit = sumBy(manualJournal?.entries, 'credit');
const debit = sumBy(manualJournal?.entries, 'debit');
return debit - credit;
}
/**
* Retrieves the manual journal amount.
* @param manualJournal
* @returns {number}
*/
protected amount(manualJournal) {
return manualJournal.amount;
return Math.abs(this.total(manualJournal));
}
/**
@@ -107,5 +119,31 @@ export class GetMatchedTransactionManualJournalsTransformer extends Transformer
protected transsactionTypeFormatted() {
return 'Manual Journal';
}
}
/**
* Retrieve the manual journal transaction normal (credit or debit).
* @returns {string}
*/
protected transactionNormal(transaction) {
const amount = this.total(transaction);
return amount >= 0 ? AccountNormal.DEBIT : AccountNormal.CREDIT;
}
/**
* Retrieve the manual journal reference type.
* @returns {string}
*/
protected referenceType() {
return 'ManualJournal';
}
/**
* Retrieves the manual journal reference id.
* @param transaction
* @returns {number}
*/
protected referenceId(transaction) {
return transaction.id;
}
}

View File

@@ -8,6 +8,8 @@ import { GetMatchedTransactionsByBills } from './GetMatchedTransactionsByBills';
import { GetMatchedTransactionsByManualJournals } from './GetMatchedTransactionsByManualJournals';
import HasTenancyService from '@/services/Tenancy/TenancyService';
import { sortClosestMatchTransactions } from './_utils';
import { GetMatchedTransactionsByCashflow } from './GetMatchedTransactionsByCashflow';
import { GetMatchedTransactionsByInvoices } from './GetMatchedTransactionsByInvoices';
@Service()
export class GetMatchedTransactions {
@@ -15,7 +17,7 @@ export class GetMatchedTransactions {
private tenancy: HasTenancyService;
@Inject()
private getMatchedInvoicesService: GetMatchedTransactionsByExpenses;
private getMatchedInvoicesService: GetMatchedTransactionsByInvoices;
@Inject()
private getMatchedBillsService: GetMatchedTransactionsByBills;
@@ -26,6 +28,9 @@ export class GetMatchedTransactions {
@Inject()
private getMatchedExpensesService: GetMatchedTransactionsByExpenses;
@Inject()
private getMatchedCashflowService: GetMatchedTransactionsByCashflow;
/**
* Registered matched transactions types.
*/
@@ -35,6 +40,7 @@ export class GetMatchedTransactions {
{ type: 'Bill', service: this.getMatchedBillsService },
{ type: 'Expense', service: this.getMatchedExpensesService },
{ type: 'ManualJournal', service: this.getMatchedManualJournalService },
{ type: 'Cashflow', service: this.getMatchedCashflowService },
];
}

View File

@@ -1,4 +1,5 @@
import { Inject, Service } from 'typedi';
import { initialize } from 'objection';
import { TransformerInjectable } from '@/lib/Transformer/TransformerInjectable';
import { GetMatchedTransactionBillsTransformer } from './GetMatchedTransactionBillsTransformer';
import { GetMatchedTransactionsFilter, MatchedTransactionPOJO } from './types';
@@ -22,10 +23,25 @@ export class GetMatchedTransactionsByBills extends GetMatchedTransactionsByType
tenantId: number,
filter: GetMatchedTransactionsFilter
) {
const { Bill } = this.tenancy.models(tenantId);
const { Bill, MatchedBankTransaction } = this.tenancy.models(tenantId);
const knex = this.tenancy.knex(tenantId);
// Initialize the models metadata.
await initialize(knex, [Bill, MatchedBankTransaction]);
// Retrieves the bill matches.
const bills = await Bill.query().onBuild((q) => {
q.whereNotExists(Bill.relatedQuery('matchedBankTransaction'));
q.withGraphJoined('matchedBankTransaction');
q.whereNull('matchedBankTransaction.id');
q.modify('published');
if (filter.fromDate) {
q.where('billDate', '>=', filter.fromDate);
}
if (filter.toDate) {
q.where('billDate', '<=', filter.toDate);
}
q.orderBy('billDate', 'DESC');
});
return this.transformer.transform(

View File

@@ -0,0 +1,85 @@
import { Inject, Service } from 'typedi';
import { initialize } from 'objection';
import { TransformerInjectable } from '@/lib/Transformer/TransformerInjectable';
import { GetMatchedTransactionsByType } from './GetMatchedTransactionsByType';
import { GetMatchedTransactionCashflowTransformer } from './GetMatchedTransactionCashflowTransformer';
import { GetMatchedTransactionsFilter } from './types';
@Service()
export class GetMatchedTransactionsByCashflow extends GetMatchedTransactionsByType {
@Inject()
private transformer: TransformerInjectable;
/**
* Retrieve the matched transactions of cash flow.
* @param {number} tenantId
* @param {GetMatchedTransactionsFilter} filter
* @returns
*/
async getMatchedTransactions(
tenantId: number,
filter: Omit<GetMatchedTransactionsFilter, 'transactionType'>
) {
const { CashflowTransaction, MatchedBankTransaction } =
this.tenancy.models(tenantId);
const knex = this.tenancy.knex(tenantId);
// Initialize the ORM models metadata.
await initialize(knex, [CashflowTransaction, MatchedBankTransaction]);
const transactions = await CashflowTransaction.query().onBuild((q) => {
// Not matched to bank transaction.
q.withGraphJoined('matchedBankTransaction');
q.whereNull('matchedBankTransaction.id');
// Not categorized.
q.modify('notCategorized');
// Published.
q.modify('published');
if (filter.fromDate) {
q.where('date', '>=', filter.fromDate);
}
if (filter.toDate) {
q.where('date', '<=', filter.toDate);
}
q.orderBy('date', 'DESC');
});
return this.transformer.transform(
tenantId,
transactions,
new GetMatchedTransactionCashflowTransformer()
);
}
/**
* Retrieves the matched transaction of cash flow.
* @param {number} tenantId
* @param {number} transactionId
* @returns
*/
async getMatchedTransaction(tenantId: number, transactionId: number) {
const { CashflowTransaction, MatchedBankTransaction } =
this.tenancy.models(tenantId);
const knex = this.tenancy.knex(tenantId);
// Initialize the ORM models metadata.
await initialize(knex, [CashflowTransaction, MatchedBankTransaction]);
const transactions = await CashflowTransaction.query()
.findById(transactionId)
.withGraphJoined('matchedBankTransaction')
.whereNull('matchedBankTransaction.id')
.modify('notCategorized')
.modify('published')
.throwIfNotFound();
return this.transformer.transform(
tenantId,
transactions,
new GetMatchedTransactionCashflowTransformer()
);
}
}

View File

@@ -1,4 +1,5 @@
import { Inject, Service } from 'typedi';
import { initialize } from 'objection';
import { GetMatchedTransactionsFilter, MatchedTransactionPOJO } from './types';
import { TransformerInjectable } from '@/lib/Transformer/TransformerInjectable';
import HasTenancyService from '@/services/Tenancy/TenancyService';
@@ -23,22 +24,34 @@ export class GetMatchedTransactionsByExpenses extends GetMatchedTransactionsByTy
tenantId: number,
filter: GetMatchedTransactionsFilter
) {
const { Expense } = this.tenancy.models(tenantId);
const { Expense, MatchedBankTransaction } = this.tenancy.models(tenantId);
const knex = this.tenancy.knex(tenantId);
// Initialize the models metadata.
await initialize(knex, [Expense, MatchedBankTransaction]);
// Retrieve the expense matches.
const expenses = await Expense.query().onBuild((query) => {
query.whereNotExists(Expense.relatedQuery('matchedBankTransaction'));
// Filter out the not matched to bank transactions.
query.withGraphJoined('matchedBankTransaction');
query.whereNull('matchedBankTransaction.id');
// Filter the published onyl
query.modify('filterByPublished');
if (filter.fromDate) {
query.where('payment_date', '>=', filter.fromDate);
query.where('paymentDate', '>=', filter.fromDate);
}
if (filter.toDate) {
query.where('payment_date', '<=', filter.toDate);
query.where('paymentDate', '<=', filter.toDate);
}
if (filter.minAmount) {
query.where('total_amount', '>=', filter.minAmount);
query.where('totalAmount', '>=', filter.minAmount);
}
if (filter.maxAmount) {
query.where('total_amount', '<=', filter.maxAmount);
query.where('totalAmount', '<=', filter.maxAmount);
}
query.orderBy('paymentDate', 'DESC');
});
return this.transformer.transform(
tenantId,

View File

@@ -1,3 +1,5 @@
import { Inject, Service } from 'typedi';
import { initialize } from 'objection';
import { TransformerInjectable } from '@/lib/Transformer/TransformerInjectable';
import { GetMatchedTransactionInvoicesTransformer } from './GetMatchedTransactionInvoicesTransformer';
import {
@@ -6,7 +8,6 @@ import {
MatchedTransactionsPOJO,
} from './types';
import HasTenancyService from '@/services/Tenancy/TenancyService';
import { Inject, Service } from 'typedi';
import { GetMatchedTransactionsByType } from './GetMatchedTransactionsByType';
@Service()
@@ -27,10 +28,27 @@ export class GetMatchedTransactionsByInvoices extends GetMatchedTransactionsByTy
tenantId: number,
filter: GetMatchedTransactionsFilter
): Promise<MatchedTransactionsPOJO> {
const { SaleInvoice } = this.tenancy.models(tenantId);
const { SaleInvoice, MatchedBankTransaction } =
this.tenancy.models(tenantId);
const knex = this.tenancy.knex(tenantId);
// Initialize the models metadata.
await initialize(knex, [SaleInvoice, MatchedBankTransaction]);
// Retrieve the invoices that not matched, unpaid.
const invoices = await SaleInvoice.query().onBuild((q) => {
q.whereNotExists(SaleInvoice.relatedQuery('matchedBankTransaction'));
q.withGraphJoined('matchedBankTransaction');
q.whereNull('matchedBankTransaction.id');
q.modify('unpaid');
q.modify('published');
if (filter.fromDate) {
q.where('invoiceDate', '>=', filter.fromDate);
}
if (filter.toDate) {
q.where('invoiceDate', '<=', filter.toDate);
}
q.orderBy('invoiceDate', 'DESC');
});
return this.transformer.transform(

View File

@@ -1,4 +1,5 @@
import { Inject, Service } from 'typedi';
import { initialize } from 'objection';
import { TransformerInjectable } from '@/lib/Transformer/TransformerInjectable';
import { GetMatchedTransactionManualJournalsTransformer } from './GetMatchedTransactionManualJournalsTransformer';
import { GetMatchedTransactionsByType } from './GetMatchedTransactionsByType';
@@ -19,12 +20,26 @@ export class GetMatchedTransactionsByManualJournals extends GetMatchedTransactio
tenantId: number,
filter: Omit<GetMatchedTransactionsFilter, 'transactionType'>
) {
const { ManualJournal } = this.tenancy.models(tenantId);
const { ManualJournal, ManualJournalEntry, MatchedBankTransaction } =
this.tenancy.models(tenantId);
const knex = this.tenancy.knex(tenantId);
await initialize(knex, [
ManualJournal,
ManualJournalEntry,
MatchedBankTransaction,
]);
const accountId = 1000;
const manualJournals = await ManualJournal.query().onBuild((query) => {
query.whereNotExists(
ManualJournal.relatedQuery('matchedBankTransaction')
);
query.withGraphJoined('matchedBankTransaction');
query.whereNull('matchedBankTransaction.id');
query.withGraphJoined('entries');
query.where('entries.accountId', accountId);
query.modify('filterByPublished');
if (filter.fromDate) {
query.where('date', '>=', filter.fromDate);
}

View File

@@ -1,4 +1,4 @@
import { isEmpty, sumBy } from 'lodash';
import { isEmpty } from 'lodash';
import { Knex } from 'knex';
import { Inject, Service } from 'typedi';
import { PromisePool } from '@supercharge/promise-pool';
@@ -14,6 +14,7 @@ import {
} from './types';
import { MatchTransactionsTypes } from './MatchTransactionsTypes';
import { ServiceError } from '@/exceptions';
import { sumMatchTranasctions } from './_utils';
@Service()
export class MatchBankTransactions {
@@ -90,9 +91,8 @@ export class MatchBankTransactions {
throw new ServiceError(error);
}
// Calculate the total given matching transactions.
const totalMatchedTranasctions = sumBy(
validatationResult.results,
'amount'
const totalMatchedTranasctions = sumMatchTranasctions(
validatationResult.results
);
// Validates the total given matching transcations whether is not equal
// uncategorized transaction amount.

View File

@@ -4,6 +4,8 @@ import { GetMatchedTransactionsByBills } from './GetMatchedTransactionsByBills';
import { GetMatchedTransactionsByManualJournals } from './GetMatchedTransactionsByManualJournals';
import { MatchTransactionsTypesRegistry } from './MatchTransactionsTypesRegistry';
import { GetMatchedTransactionsByInvoices } from './GetMatchedTransactionsByInvoices';
import { GetMatchedTransactionCashflowTransformer } from './GetMatchedTransactionCashflowTransformer';
import { GetMatchedTransactionsByCashflow } from './GetMatchedTransactionsByCashflow';
@Service()
export class MatchTransactionsTypes {
@@ -25,6 +27,10 @@ export class MatchTransactionsTypes {
type: 'ManualJournal',
service: GetMatchedTransactionsByManualJournals,
},
{
type: 'CashflowTransaction',
service: GetMatchedTransactionsByCashflow,
},
];
}

View File

@@ -31,6 +31,7 @@ export class UnmatchMatchedBankTransaction {
return this.uow.withTransaction(tenantId, async (trx) => {
await this.eventPublisher.emitAsync(events.bankMatch.onUnmatching, {
tenantId,
uncategorizedTransactionId,
trx,
} as IBankTransactionUnmatchingEventPayload);
@@ -40,6 +41,7 @@ export class UnmatchMatchedBankTransaction {
await this.eventPublisher.emitAsync(events.bankMatch.onUnmatched, {
tenantId,
uncategorizedTransactionId,
trx,
} as IBankTransactionUnmatchingEventPayload);
});

View File

@@ -1,6 +1,7 @@
import { Knex } from 'knex';
import { Inject, Service } from 'typedi';
import { ServiceError } from '@/exceptions';
import HasTenancyService from '@/services/Tenancy/TenancyService';
import { Inject, Service } from 'typedi';
import { ERRORS } from './types';
@Service()
@@ -18,12 +19,13 @@ export class ValidateTransactionMatched {
public async validateTransactionNoMatchLinking(
tenantId: number,
referenceType: string,
referenceId: number
referenceId: number,
trx?: Knex.Transaction
) {
const { MatchedBankTransaction } = this.tenancy.models(tenantId);
const foundMatchedTransaction =
await MatchedBankTransaction.query().findOne({
await MatchedBankTransaction.query(trx).findOne({
referenceType,
referenceId,
});

View File

@@ -20,3 +20,12 @@ export const sortClosestMatchTransactions = (
),
])(matches);
};
export const sumMatchTranasctions = (transactions: Array<any>) => {
return transactions.reduce(
(total, item) =>
total +
(item.transactionNormal === 'debit' ? 1 : -1) * parseFloat(item.amount),
0
);
};

View File

@@ -0,0 +1,66 @@
import { Inject, Service } from 'typedi';
import events from '@/subscribers/events';
import {
IBankTransactionMatchedEventPayload,
IBankTransactionUnmatchedEventPayload,
} from '../types';
import HasTenancyService from '@/services/Tenancy/TenancyService';
@Service()
export class DecrementUncategorizedTransactionOnMatching {
@Inject()
private tenancy: HasTenancyService;
/**
* Constructor method.
*/
public attach(bus) {
bus.subscribe(
events.bankMatch.onMatched,
this.decrementUnCategorizedTransactionsOnMatching.bind(this)
);
bus.subscribe(
events.bankMatch.onUnmatched,
this.incrementUnCategorizedTransactionsOnUnmatching.bind(this)
);
}
/**
* Validates the cashflow transaction whether matched with bank transaction on deleting.
* @param {IManualJournalDeletingPayload}
*/
public async decrementUnCategorizedTransactionsOnMatching({
tenantId,
uncategorizedTransactionId,
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);
}
/**
* Validates the cashflow transaction whether matched with bank transaction on deleting.
* @param {IManualJournalDeletingPayload}
*/
public async incrementUnCategorizedTransactionsOnUnmatching({
tenantId,
uncategorizedTransactionId,
trx,
}: IBankTransactionUnmatchedEventPayload) {
const { UncategorizedCashflowTransaction, Account } =
this.tenancy.models(tenantId);
const transaction = await UncategorizedCashflowTransaction.query().findById(
uncategorizedTransactionId
);
await Account.query(trx)
.findById(transaction.accountId)
.increment('uncategorizedTransactions', 1);
}
}

View File

@@ -1,5 +1,5 @@
import { Inject, Service } from 'typedi';
import { IManualJournalDeletingPayload } from '@/interfaces';
import { ICommandCashflowDeletingPayload, IManualJournalDeletingPayload } from '@/interfaces';
import events from '@/subscribers/events';
import { ValidateTransactionMatched } from '../ValidateTransactionsMatched';
@@ -24,13 +24,14 @@ export class ValidateMatchingOnCashflowDelete {
*/
public async validateMatchingOnCashflowDeleting({
tenantId,
oldManualJournal,
oldCashflowTransaction,
trx,
}: IManualJournalDeletingPayload) {
}: ICommandCashflowDeletingPayload) {
await this.validateNoMatchingLinkedService.validateTransactionNoMatchLinking(
tenantId,
'ManualJournal',
oldManualJournal.id
'CashflowTransaction',
oldCashflowTransaction.id,
trx
);
}
}

View File

@@ -30,7 +30,8 @@ export class ValidateMatchingOnExpenseDelete {
await this.validateNoMatchingLinkedService.validateTransactionNoMatchLinking(
tenantId,
'Expense',
oldExpense.id
oldExpense.id,
trx
);
}
}

View File

@@ -30,7 +30,8 @@ export class ValidateMatchingOnManualJournalDelete {
await this.validateNoMatchingLinkedService.validateTransactionNoMatchLinking(
tenantId,
'ManualJournal',
oldManualJournal.id
oldManualJournal.id,
trx
);
}
}

View File

@@ -33,7 +33,8 @@ export class ValidateMatchingOnPaymentMadeDelete {
await this.validateNoMatchingLinkedService.validateTransactionNoMatchLinking(
tenantId,
'PaymentMade',
oldBillPayment.id
oldBillPayment.id,
trx
);
}
}

View File

@@ -30,7 +30,8 @@ export class ValidateMatchingOnPaymentReceivedDelete {
await this.validateNoMatchingLinkedService.validateTransactionNoMatchLinking(
tenantId,
'PaymentReceive',
oldPaymentReceive.id
oldPaymentReceive.id,
trx
);
}
}

View File

@@ -16,10 +16,14 @@ export interface IBankTransactionMatchedEventPayload {
export interface IBankTransactionUnmatchingEventPayload {
tenantId: number;
uncategorizedTransactionId: number;
trx?: Knex.Transaction;
}
export interface IBankTransactionUnmatchedEventPayload {
tenantId: number;
uncategorizedTransactionId: number;
trx?: Knex.Transaction;
}
export interface IMatchTransactionDTO {

View File

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

View File

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

View File

@@ -2,6 +2,11 @@ import * as R from 'ramda';
import { Inject, Service } from 'typedi';
import bluebird from 'bluebird';
import { entries, groupBy } from 'lodash';
import {
AccountBase as PlaidAccountBase,
Item as PlaidItem,
Institution as PlaidInstitution,
} from 'plaid';
import { CreateAccount } from '@/services/Accounts/CreateAccount';
import {
IAccountCreateDTO,
@@ -17,7 +22,7 @@ import { DeleteCashflowTransaction } from '@/services/Cashflow/DeleteCashflowTra
import HasTenancyService from '@/services/Tenancy/TenancyService';
import { CashflowApplication } from '@/services/Cashflow/CashflowApplication';
import { Knex } from 'knex';
import { uniqid } from 'uniqid';
import uniqid from 'uniqid';
import { EventPublisher } from '@/lib/EventPublisher/EventPublisher';
import events from '@/subscribers/events';
@@ -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(
@@ -148,7 +156,6 @@ export class PlaidSyncDb {
*/
public async syncAccountsTransactions(
tenantId: number,
batchNo: string,
plaidAccountsTransactions: PlaidTransaction[],
trx?: Knex.Transaction
): Promise<void> {
@@ -161,7 +168,6 @@ export class PlaidSyncDb {
return this.syncAccountTranactions(
tenantId,
plaidAccountId,
batchNo,
plaidTransactions,
trx
);

View File

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

View File

@@ -1,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,
};
}
);
@@ -37,7 +48,6 @@ export const transformPlaidAccountToCreateAccount = R.curry(
export const transformPlaidTrxsToCashflowCreate = R.curry(
(
cashflowAccountId: number,
creditAccountId: number,
plaidTranasction: PlaidTransaction
): CreateUncategorizedTransactionDTO => {
return {

View File

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

View File

@@ -5,6 +5,8 @@ import {
IBankRuleEventDeletedPayload,
IBankRuleEventEditedPayload,
} from '../../Rules/types';
import { IImportFileCommitedEventPayload } from '@/interfaces/Import';
import { Import } from '@/system/models';
@Service()
export class TriggerRecognizedTransactions {
@@ -27,6 +29,10 @@ export class TriggerRecognizedTransactions {
events.bankRules.onDeleted,
this.recognizedTransactionsOnRuleDeleted.bind(this)
);
bus.subscribe(
events.import.onImportCommitted,
this.triggerRecognizeTransactionsOnImportCommitted.bind(this)
);
}
/**
@@ -73,4 +79,20 @@ export class TriggerRecognizedTransactions {
const payload = { tenantId };
await this.agenda.now('recognize-uncategorized-transactions-job', payload);
}
/**
* Triggers the recognize bank transactions once the imported file commit.
* @param {IImportFileCommitedEventPayload} payload -
*/
private async triggerRecognizeTransactionsOnImportCommitted({
tenantId,
importId,
meta,
}: IImportFileCommitedEventPayload) {
const importFile = await Import.query().findOne({ importId });
const batch = importFile.paramsParsed.batch;
const payload = { tenantId, batch };
await this.agenda.now('recognize-uncategorized-transactions-job', payload);
}
}

View File

@@ -1,6 +1,5 @@
import { upperFirst, camelCase } from 'lodash';
import { Transformer } from '@/lib/Transformer/Transformer';
import { getTransactionTypeLabel } from '@/utils/transactions-types';
import { getCashflowTransactionFormattedType } from '@/utils/transactions-types';
export class GetBankRulesTransformer extends Transformer {
/**
@@ -29,8 +28,7 @@ export class GetBankRulesTransformer extends Transformer {
* @returns {string}
*/
protected assignCategoryFormatted(bankRule: any) {
const assignCategory = upperFirst(camelCase(bankRule.assignCategory));
return getTransactionTypeLabel(assignCategory);
return getCashflowTransactionFormattedType(bankRule.assignCategory);
}
/**

View File

@@ -34,11 +34,12 @@ export default class CashflowTransactionJournalEntries {
currencyCode: transaction.currencyCode,
exchangeRate: transaction.exchangeRate,
transactionType: transformCashflowTransactionType(
transaction.transactionType
),
transactionType: 'CashflowTransaction',
transactionId: transaction.id,
transactionNumber: transaction.transactionNumber,
transactionSubType: transformCashflowTransactionType(
transaction.transactionType
),
referenceNumber: transaction.referenceNo,
note: transaction.description,
@@ -161,12 +162,10 @@ export default class CashflowTransactionJournalEntries {
cashflowTransactionId: number,
trx?: Knex.Transaction
): Promise<void> => {
const transactionTypes = getCashflowAccountTransactionsTypes();
await this.ledgerStorage.deleteByReference(
tenantId,
cashflowTransactionId,
transactionTypes,
'CashflowTransaction',
trx
);
};

View File

@@ -84,20 +84,23 @@ export class CategorizeCashflowTransaction {
cashflowTransactionDTO
);
// Updates the uncategorized transaction as categorized.
await UncategorizedCashflowTransaction.query(trx).patchAndFetchById(
uncategorizedTransactionId,
{
categorized: true,
categorizeRefType: 'CashflowTransaction',
categorizeRefId: cashflowTransaction.id,
}
);
const uncategorizedTransaction =
await UncategorizedCashflowTransaction.query(trx).patchAndFetchById(
uncategorizedTransactionId,
{
categorized: true,
categorizeRefType: 'CashflowTransaction',
categorizeRefId: cashflowTransaction.id,
}
);
// Triggers `onCashflowTransactionCategorized` event.
await this.eventPublisher.emitAsync(
events.cashflow.onTransactionCategorized,
{
tenantId,
// cashflowTransaction,
cashflowTransaction,
uncategorizedTransaction,
categorizeDTO,
trx,
} as ICashflowTransactionCategorizedPayload
);

View File

@@ -2,7 +2,13 @@ import { Knex } from 'knex';
import { Inject, Service } from 'typedi';
import HasTenancyService from '../Tenancy/TenancyService';
import UnitOfWork from '../UnitOfWork';
import { CreateUncategorizedTransactionDTO } from '@/interfaces';
import { EventPublisher } from '@/lib/EventPublisher/EventPublisher';
import events from '@/subscribers/events';
import {
CreateUncategorizedTransactionDTO,
IUncategorizedTransactionCreatedEventPayload,
IUncategorizedTransactionCreatingEventPayload,
} from '@/interfaces';
@Service()
export class CreateUncategorizedTransaction {
@@ -12,6 +18,9 @@ export class CreateUncategorizedTransaction {
@Inject()
private uow: UnitOfWork;
@Inject()
private eventPublisher: EventPublisher;
/**
* Creates an uncategorized cashflow transaction.
* @param {number} tenantId
@@ -19,7 +28,7 @@ export class CreateUncategorizedTransaction {
*/
public create(
tenantId: number,
createDTO: CreateUncategorizedTransactionDTO,
createUncategorizedTransactionDTO: CreateUncategorizedTransactionDTO,
trx?: Knex.Transaction
) {
const { UncategorizedCashflowTransaction } = this.tenancy.models(tenantId);
@@ -27,12 +36,30 @@ export class CreateUncategorizedTransaction {
return this.uow.withTransaction(
tenantId,
async (trx: Knex.Transaction) => {
const transaction = await UncategorizedCashflowTransaction.query(
trx
).insertAndFetch({
...createDTO,
});
return transaction;
await this.eventPublisher.emitAsync(
events.cashflow.onTransactionUncategorizedCreating,
{
tenantId,
createUncategorizedTransactionDTO,
trx,
} as IUncategorizedTransactionCreatingEventPayload
);
const uncategorizedTransaction =
await UncategorizedCashflowTransaction.query(trx).insertAndFetch({
...createUncategorizedTransactionDTO,
});
await this.eventPublisher.emitAsync(
events.cashflow.onTransactionUncategorizedCreated,
{
tenantId,
uncategorizedTransaction,
createUncategorizedTransactionDTO,
trx,
} as IUncategorizedTransactionCreatedEventPayload
);
return uncategorizedTransaction;
},
trx
);

View File

@@ -101,6 +101,7 @@ export default class NewCashflowTransactionService {
...fromDTO,
transactionNumber,
currencyCode: cashflowAccount.currencyCode,
exchangeRate: fromDTO?.exchangeRate || 1,
transactionType: transformCashflowTransactionType(
fromDTO.transactionType
),

View File

@@ -1,6 +1,7 @@
import { Inject, Service } from 'typedi';
import { Knex } from 'knex';
import * as yup from 'yup';
import uniqid from 'uniqid';
import { Importable } from '../Import/Importable';
import { CreateUncategorizedTransaction } from './CreateUncategorizedTransaction';
import { CreateUncategorizedTransactionDTO } from '@/interfaces';
@@ -15,6 +16,7 @@ export class UncategorizedTransactionsImportable extends Importable {
@Inject()
private tenancy: HasTenancyService;
/**
* Passing the sheet DTO to create uncategorized transaction.
* @param {number} tenantId
@@ -43,6 +45,7 @@ export class UncategorizedTransactionsImportable extends Importable {
return {
...createDTO,
accountId: context.import.paramsParsed.accountId,
batch: context.import.paramsParsed.batch,
};
}
@@ -54,6 +57,9 @@ export class UncategorizedTransactionsImportable extends Importable {
return BankTransactionsSampleData;
}
// ------------------
// # Params
// ------------------
/**
* Params validation schema.
* @returns {ValidationSchema[]}
@@ -79,4 +85,17 @@ export class UncategorizedTransactionsImportable extends Importable {
await Account.query().findById(params.accountId).throwIfNotFound({});
}
}
/**
* Transformes the import params before storing them.
* @param {Record<string, any>} parmas
*/
public transformParams(parmas: Record<string, any>) {
const batch = uniqid();
return {
...parmas,
batch,
};
}
}

View File

@@ -0,0 +1,78 @@
import { Inject, Service } from 'typedi';
import events from '@/subscribers/events';
import HasTenancyService from '@/services/Tenancy/TenancyService';
import {
ICashflowTransactionCategorizedPayload,
ICashflowTransactionUncategorizedPayload,
} from '@/interfaces';
@Service()
export class DecrementUncategorizedTransactionOnCategorize {
@Inject()
private tenancy: HasTenancyService;
/**
* Constructor method.
*/
public attach(bus) {
bus.subscribe(
events.cashflow.onTransactionCategorized,
this.decrementUnCategorizedTransactionsOnCategorized.bind(this)
);
bus.subscribe(
events.cashflow.onTransactionUncategorized,
this.incrementUnCategorizedTransactionsOnUncategorized.bind(this)
);
bus.subscribe(
events.cashflow.onTransactionUncategorizedCreated,
this.incrementUncategoirzedTransactionsOnCreated.bind(this)
);
}
/**
* Decrement the uncategoirzed transactions on the account once categorizing.
* @param {ICashflowTransactionCategorizedPayload}
*/
public async decrementUnCategorizedTransactionsOnCategorized({
tenantId,
uncategorizedTransaction,
}: ICashflowTransactionCategorizedPayload) {
const { Account } = this.tenancy.models(tenantId);
await Account.query()
.findById(uncategorizedTransaction.accountId)
.decrement('uncategorizedTransactions', 1);
}
/**
* Increment the uncategorized transaction on the given account on uncategorizing.
* @param {IManualJournalDeletingPayload}
*/
public async incrementUnCategorizedTransactionsOnUncategorized({
tenantId,
uncategorizedTransaction,
}: ICashflowTransactionUncategorizedPayload) {
const { Account } = this.tenancy.models(tenantId);
await Account.query()
.findById(uncategorizedTransaction.accountId)
.increment('uncategorizedTransactions', 1);
}
/**
* Increments uncategorized transactions count once creating a new transaction.
* @param {ICommandCashflowCreatedPayload} payload -
*/
public async incrementUncategoirzedTransactionsOnCreated({
tenantId,
uncategorizedTransaction,
trx,
}: any) {
const { Account } = this.tenancy.models(tenantId);
if (!uncategorizedTransaction.accountId) return;
await Account.query(trx)
.findById(uncategorizedTransaction.accountId)
.increment('uncategorizedTransactions', 1);
}
}

View File

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

View File

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

View File

@@ -1,20 +1,20 @@
import R from 'ramda';
import moment from 'moment';
import { first, isEmpty } from 'lodash';
import {
ICashflowAccountTransaction,
ICashflowAccountTransactionsQuery,
INumberFormatQuery,
} from '@/interfaces';
import FinancialSheet from '../FinancialSheet';
import { runningAmount } from 'utils';
import { CashflowAccountTransactionsRepo } from './CashflowAccountTransactionsRepo';
import { BankTransactionStatus } from './constants';
import { formatBankTransactionsStatus } from './utils';
export default class CashflowAccountTransactionReport extends FinancialSheet {
private transactions: any;
private openingBalance: number;
export class CashflowAccountTransactionReport extends FinancialSheet {
private runningBalance: any;
private numberFormat: INumberFormatQuery;
private baseCurrency: string;
private query: ICashflowAccountTransactionsQuery;
private repo: CashflowAccountTransactionsRepo;
/**
* Constructor method.
@@ -23,19 +23,61 @@ export default class CashflowAccountTransactionReport extends FinancialSheet {
* @param {ICashflowAccountTransactionsQuery} query -
*/
constructor(
transactions,
openingBalance: number,
repo: CashflowAccountTransactionsRepo,
query: ICashflowAccountTransactionsQuery
) {
super();
this.transactions = transactions;
this.openingBalance = openingBalance;
this.runningBalance = runningAmount(this.openingBalance);
this.repo = repo;
this.query = query;
this.numberFormat = query.numberFormat;
this.baseCurrency = 'USD';
this.runningBalance = runningAmount(this.repo.openingBalance);
}
/**
* Retrieves the transaction status.
* @param {} transaction
* @returns {BankTransactionStatus}
*/
private getTransactionStatus(transaction: any): BankTransactionStatus {
const categorizedTrans = this.repo.uncategorizedTransactionsMapByRef.get(
`${transaction.referenceType}-${transaction.referenceId}`
);
const matchedTrans = this.repo.matchedBankTransactionsMapByRef.get(
`${transaction.referenceType}-${transaction.referenceId}`
);
if (!isEmpty(categorizedTrans)) {
return BankTransactionStatus.Categorized;
} else if (!isEmpty(matchedTrans)) {
return BankTransactionStatus.Matched;
} else {
return BankTransactionStatus.Manual;
}
}
/**
* Retrieves the uncategoized transaction id from the given transaction.
* @param transaction
* @returns {number|null}
*/
private getUncategorizedTransId(transaction: any): number {
// The given transaction would be categorized, matched or not, so we'd take a look at
// the categorized transaction first to get the id if not exist, then should look at the matched
// transaction if not exist too, so the given transaction has no uncategorized transaction id.
const categorizedTrans = this.repo.uncategorizedTransactionsMapByRef.get(
`${transaction.referenceType}-${transaction.referenceId}`
);
const matchedTrans = this.repo.matchedBankTransactionsMapByRef.get(
`${transaction.referenceType}-${transaction.referenceId}`
);
// Relation between the transaction and matching always been one-to-one.
const firstCategorizedTrans = first(categorizedTrans);
const firstMatchedTrans = first(matchedTrans);
return (
firstCategorizedTrans?.id ||
firstMatchedTrans?.uncategorizedTransactionId ||
null
);
}
/**
@@ -44,6 +86,10 @@ export default class CashflowAccountTransactionReport extends FinancialSheet {
* @returns {ICashflowAccountTransaction}
*/
private transactionNode = (transaction: any): ICashflowAccountTransaction => {
const status = this.getTransactionStatus(transaction);
const uncategorizedTransactionId =
this.getUncategorizedTransId(transaction);
return {
date: transaction.date,
formattedDate: moment(transaction.date).format('YYYY-MM-DD'),
@@ -67,6 +113,9 @@ export default class CashflowAccountTransactionReport extends FinancialSheet {
balance: 0,
formattedBalance: '',
status,
formattedStatus: formatBankTransactionsStatus(status),
uncategorizedTransactionId,
};
};
@@ -146,6 +195,6 @@ export default class CashflowAccountTransactionReport extends FinancialSheet {
* @returns {ICashflowAccountTransaction[]}
*/
public reportData(): ICashflowAccountTransaction[] {
return this.transactionsNode(this.transactions);
return this.transactionsNode(this.repo.transactions);
}
}

View File

@@ -1,30 +1,59 @@
import { Service, Inject } from 'typedi';
import HasTenancyService from '@/services/Tenancy/TenancyService';
import { ICashflowAccountTransactionsQuery, IPaginationMeta } from '@/interfaces';
import * as R from 'ramda';
import { ICashflowAccountTransactionsQuery } from '@/interfaces';
import {
groupMatchedBankTransactions,
groupUncategorizedTransactions,
} from './utils';
@Service()
export default class CashflowAccountTransactionsRepo {
@Inject()
private tenancy: HasTenancyService;
export class CashflowAccountTransactionsRepo {
private models: any;
public query: ICashflowAccountTransactionsQuery;
public transactions: any;
public uncategorizedTransactions: any;
public uncategorizedTransactionsMapByRef: Map<string, any>;
public matchedBankTransactions: any;
public matchedBankTransactionsMapByRef: Map<string, any>;
public pagination: any;
public openingBalance: any;
/**
* Constructor method.
* @param {any} models
* @param {ICashflowAccountTransactionsQuery} query
*/
constructor(models: any, query: ICashflowAccountTransactionsQuery) {
this.models = models;
this.query = query;
}
/**
* Async initalize the resources.
*/
async asyncInit() {
await this.initCashflowAccountTransactions();
await this.initCashflowAccountOpeningBalance();
await this.initCategorizedTransactions();
await this.initMatchedTransactions();
}
/**
* Retrieve the cashflow account transactions.
* @param {number} tenantId -
* @param {ICashflowAccountTransactionsQuery} query -
*/
async getCashflowAccountTransactions(
tenantId: number,
query: ICashflowAccountTransactionsQuery
) {
const { AccountTransaction } = this.tenancy.models(tenantId);
async initCashflowAccountTransactions() {
const { AccountTransaction } = this.models;
return AccountTransaction.query()
.where('account_id', query.accountId)
const { results, pagination } = await AccountTransaction.query()
.where('account_id', this.query.accountId)
.orderBy([
{ column: 'date', order: 'desc' },
{ column: 'created_at', order: 'desc' },
])
.pagination(query.page - 1, query.pageSize);
.pagination(this.query.page - 1, this.query.pageSize);
this.transactions = results;
this.pagination = pagination;
}
/**
@@ -34,22 +63,18 @@ export default class CashflowAccountTransactionsRepo {
* @param {IPaginationMeta} pagination
* @return {Promise<number>}
*/
async getCashflowAccountOpeningBalance(
tenantId: number,
accountId: number,
pagination: IPaginationMeta
): Promise<number> {
const { AccountTransaction } = this.tenancy.models(tenantId);
async initCashflowAccountOpeningBalance(): Promise<void> {
const { AccountTransaction } = this.models;
// Retrieve the opening balance of credit and debit balances.
const openingBalancesSubquery = AccountTransaction.query()
.where('account_id', accountId)
.where('account_id', this.query.accountId)
.orderBy([
{ column: 'date', order: 'desc' },
{ column: 'created_at', order: 'desc' },
])
.limit(pagination.total)
.offset(pagination.pageSize * (pagination.page - 1));
.limit(this.pagination.total)
.offset(this.pagination.pageSize * (this.pagination.page - 1));
// Sumation of credit and debit balance.
const openingBalances = await AccountTransaction.query()
@@ -60,6 +85,43 @@ export default class CashflowAccountTransactionsRepo {
const openingBalance = openingBalances.debit - openingBalances.credit;
return openingBalance;
this.openingBalance = openingBalance;
}
/**
* Initialize the uncategorized transactions of the bank account.
*/
async initCategorizedTransactions() {
const { UncategorizedCashflowTransaction } = this.models;
const refs = this.transactions.map((t) => [t.referenceType, t.referenceId]);
const uncategorizedTransactions =
await UncategorizedCashflowTransaction.query().whereIn(
['categorizeRefType', 'categorizeRefId'],
refs
);
this.uncategorizedTransactions = uncategorizedTransactions;
this.uncategorizedTransactionsMapByRef = groupUncategorizedTransactions(
uncategorizedTransactions
);
}
/**
* Initialize the matched bank transactions of the bank account.
*/
async initMatchedTransactions(): Promise<void> {
const { MatchedBankTransaction } = this.models;
const refs = this.transactions.map((t) => [t.referenceType, t.referenceId]);
const matchedBankTransactions =
await MatchedBankTransaction.query().whereIn(
['referenceType', 'referenceId'],
refs
);
this.matchedBankTransactions = matchedBankTransactions;
this.matchedBankTransactionsMapByRef = groupMatchedBankTransactions(
matchedBankTransactions
);
}
}

View File

@@ -1,26 +1,16 @@
import { Service, Inject } from 'typedi';
import { includes } from 'lodash';
import * as qim from 'qim';
import { ICashflowAccountTransactionsQuery, IAccount } from '@/interfaces';
import TenancyService from '@/services/Tenancy/TenancyService';
import FinancialSheet from '../FinancialSheet';
import CashflowAccountTransactionsRepo from './CashflowAccountTransactionsRepo';
import CashflowAccountTransactionsReport from './CashflowAccountTransactions';
import { ACCOUNT_TYPE } from '@/data/AccountTypes';
import { ServiceError } from '@/exceptions';
import { ERRORS } from './constants';
import { CashflowAccountTransactionReport } from './CashflowAccountTransactions';
import I18nService from '@/services/I18n/I18nService';
import { CashflowAccountTransactionsRepo } from './CashflowAccountTransactionsRepo';
@Service()
export default class CashflowAccountTransactionsService extends FinancialSheet {
@Inject()
tenancy: TenancyService;
@Inject()
cashflowTransactionsRepo: CashflowAccountTransactionsRepo;
@Inject()
i18nService: I18nService;
private tenancy: TenancyService;
/**
* Defaults balance sheet filter query.
@@ -50,59 +40,24 @@ export default class CashflowAccountTransactionsService extends FinancialSheet {
tenantId: number,
query: ICashflowAccountTransactionsQuery
) {
const { Account } = this.tenancy.models(tenantId);
const models = this.tenancy.models(tenantId);
const parsedQuery = { ...this.defaultQuery, ...query };
// Retrieve the given account or throw not found service error.
const account = await Account.query().findById(parsedQuery.accountId);
// Validates the cashflow account type.
this.validateCashflowAccountType(account);
// Retrieve the cashflow account transactions.
const { results: transactions, pagination } =
await this.cashflowTransactionsRepo.getCashflowAccountTransactions(
tenantId,
parsedQuery
);
// Retrieve the cashflow account opening balance.
const openingBalance =
await this.cashflowTransactionsRepo.getCashflowAccountOpeningBalance(
tenantId,
parsedQuery.accountId,
pagination
);
// Retrieve the computed report.
const report = new CashflowAccountTransactionsReport(
transactions,
openingBalance,
// Initalize the bank transactions report repository.
const cashflowTransactionsRepo = new CashflowAccountTransactionsRepo(
models,
parsedQuery
);
const reportTranasctions = report.reportData();
await cashflowTransactionsRepo.asyncInit();
return {
transactions: this.i18nService.i18nApply(
[[qim.$each, 'formattedTransactionType']],
reportTranasctions,
tenantId
),
pagination,
};
}
// Retrieve the computed report.
const report = new CashflowAccountTransactionReport(
cashflowTransactionsRepo,
parsedQuery
);
const transactions = report.reportData();
const pagination = cashflowTransactionsRepo.pagination;
/**
* Validates the cashflow account type.
* @param {IAccount} account -
*/
private validateCashflowAccountType(account: IAccount) {
const cashflowTypes = [
ACCOUNT_TYPE.CASH,
ACCOUNT_TYPE.CREDIT_CARD,
ACCOUNT_TYPE.BANK,
];
if (!includes(cashflowTypes, account.accountType)) {
throw new ServiceError(ERRORS.ACCOUNT_ID_HAS_INVALID_TYPE);
}
return { transactions, pagination };
}
}

View File

@@ -1,3 +1,9 @@
export const ERRORS = {
ACCOUNT_ID_HAS_INVALID_TYPE: 'ACCOUNT_ID_HAS_INVALID_TYPE',
};
export enum BankTransactionStatus {
Categorized = 'categorized',
Matched = 'matched',
Manual = 'manual',
}

View File

@@ -0,0 +1,40 @@
import * as R from 'ramda';
export const groupUncategorizedTransactions = (
uncategorizedTransactions: any
): Map<string, any> => {
return new Map(
R.toPairs(
R.groupBy(
(transaction) =>
`${transaction.categorizeRefType}-${transaction.categorizeRefId}`,
uncategorizedTransactions
)
)
);
};
export const groupMatchedBankTransactions = (
uncategorizedTransactions: any
): Map<string, any> => {
return new Map(
R.toPairs(
R.groupBy(
(transaction) =>
`${transaction.referenceType}-${transaction.referenceId}`,
uncategorizedTransactions
)
)
);
};
export const formatBankTransactionsStatus = (status) => {
switch (status) {
case 'categorized':
return 'Categorized';
case 'matched':
return 'Matched';
case 'manual':
return 'Manual';
}
};

View File

@@ -15,14 +15,10 @@ import { ServiceError } from '@/exceptions';
import { getUniqueImportableValue, trimObject } from './_utils';
import { ImportableResources } from './ImportableResources';
import ResourceService from '../Resource/ResourceService';
import HasTenancyService from '../Tenancy/TenancyService';
import { Import } from '@/system/models';
@Service()
export class ImportFileCommon {
@Inject()
private tenancy: HasTenancyService;
@Inject()
private importFileValidator: ImportFileDataValidator;

View File

@@ -0,0 +1,47 @@
import { Inject, Service } from 'typedi';
import HasTenancyService from '../Tenancy/TenancyService';
import { ImportFilePreviewPOJO } from './interfaces';
import { ImportFileProcess } from './ImportFileProcess';
import { EventPublisher } from '@/lib/EventPublisher/EventPublisher';
import events from '@/subscribers/events';
import { IImportFileCommitedEventPayload } from '@/interfaces/Import';
@Service()
export class ImportFileProcessCommit {
@Inject()
private tenancy: HasTenancyService;
@Inject()
private importFile: ImportFileProcess;
@Inject()
private eventPublisher: EventPublisher;
/**
* Commits the imported file.
* @param {number} tenantId
* @param {number} importId
* @returns {Promise<ImportFilePreviewPOJO>}
*/
public async commit(
tenantId: number,
importId: number
): Promise<ImportFilePreviewPOJO> {
const knex = this.tenancy.knex(tenantId);
const trx = await knex.transaction({ isolationLevel: 'read uncommitted' });
const meta = await this.importFile.import(tenantId, importId, trx);
// Commit the successed transaction.
await trx.commit();
// Triggers `onImportFileCommitted` event.
await this.eventPublisher.emitAsync(events.import.onImportCommitted, {
meta,
importId,
tenantId,
} as IImportFileCommitedEventPayload);
return meta;
}
}

View File

@@ -6,6 +6,7 @@ import { ImportFileProcess } from './ImportFileProcess';
import { ImportFilePreview } from './ImportFilePreview';
import { ImportSampleService } from './ImportSample';
import { ImportFileMeta } from './ImportFileMeta';
import { ImportFileProcessCommit } from './ImportFileProcessCommit';
@Inject()
export class ImportResourceApplication {
@@ -27,6 +28,9 @@ export class ImportResourceApplication {
@Inject()
private importMetaService: ImportFileMeta;
@Inject()
private importProcessCommit: ImportFileProcessCommit;
/**
* Reads the imported file and stores the import file meta under unqiue id.
* @param {number} tenantId -
@@ -74,12 +78,12 @@ export class ImportResourceApplication {
* @returns {Promise<ImportFilePreviewPOJO>}
*/
public async process(tenantId: number, importId: number) {
return this.importProcessService.import(tenantId, importId);
return this.importProcessCommit.commit(tenantId, importId);
}
/**
* Retrieves the import meta of the given import id.
* @param {number} tenantId -
* @param {number} tenantId -
* @param {string} importId - Import id.
* @returns {}
*/

View File

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

View File

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

View File

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

View File

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

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