feat(nestjs): migrate to NestJS

This commit is contained in:
Ahmed Bouhuolia
2025-04-07 11:51:24 +02:00
parent f068218a16
commit 55fcc908ef
3779 changed files with 631 additions and 195332 deletions

View File

@@ -0,0 +1,130 @@
import { Transformer } from '../Transformer/Transformer';
import { Account } from './models/Account.model';
import { flatToNestedArray } from '@/utils/flat-to-nested-array';
import { assocDepthLevelToObjectTree } from '@/utils/assoc-depth-level-to-object-tree';
import { nestedArrayToFlatten } from '@/utils/nested-array-to-flatten';
import { IAccountsStructureType } from './Accounts.types';
export class AccountTransformer extends Transformer {
/**
* Include these attributes to sale invoice object.
* @returns {Array}
*/
public includeAttributes = (): string[] => {
return [
'accountTypeLabel',
'accountNormalFormatted',
'formattedAmount',
'flattenName',
'bankBalanceFormatted',
'lastFeedsUpdatedAtFormatted',
'isFeedsPaused',
];
};
/**
* Exclude attributes.
* @returns {string[]}
*/
public excludeAttributes = (): string[] => {
return ['plaidItem'];
};
/**
* Retrieves the flatten name with all dependants accounts names.
* @param {IAccount} account -
* @returns {string}
*/
public flattenName = (account: Account): string => {
const parentDependantsIds = this.options.accountsGraph.dependantsOf(
account.id,
);
const prefixAccounts = parentDependantsIds.map((dependId) => {
const node = this.options.accountsGraph.getNodeData(dependId);
return `${node.name}: `;
});
return `${prefixAccounts}${account.name}`;
};
/**
* Retrieve formatted account amount.
* @param {IAccount} invoice
* @returns {string}
*/
protected formattedAmount = (account: Account): string => {
return this.formatNumber(account.amount, {
currencyCode: account.currencyCode,
});
};
/**
* Retrieves the formatted bank balance.
* @param {Account} account
* @returns {string}
*/
protected bankBalanceFormatted = (account: Account): string => {
return this.formatNumber(account.bankBalance, {
currencyCode: account.currencyCode,
});
};
/**
* Retrieves the formatted last feeds update at.
* @param {IAccount} account
* @returns {string}
*/
protected lastFeedsUpdatedAtFormatted = (account: Account): string => {
return account.lastFeedsUpdatedAt
? this.formatDate(account.lastFeedsUpdatedAt)
: '';
};
/**
* Detarmines whether the bank account connection is paused.
* @param account
* @returns {boolean}
*/
protected isFeedsPaused = (account: Account): boolean => {
// return account.plaidItem?.isPaused || false;
return false;
};
/**
* Retrieves formatted account type label.
* @returns {string}
*/
protected accountTypeLabel = (account: Account): string => {
return this.context.i18n.t(account.accountTypeLabel);
};
/**
* Retrieves formatted account normal.
* @returns {string}
*/
protected accountNormalFormatted = (account: Account): string => {
return this.context.i18n.t(account.accountNormalFormatted);
};
/**
* Transformes the accounts collection to flat or nested array.
* @param {IAccount[]}
* @returns {IAccount[]}
*/
protected postCollectionTransform = (accounts: Account[]) => {
// Transfom the flatten to accounts tree.
const transformed = flatToNestedArray(accounts, {
id: 'id',
parentId: 'parentAccountId',
});
// Associate `accountLevel` attr to indicate object depth.
const transformed2 = assocDepthLevelToObjectTree(
transformed,
1,
'accountLevel',
);
return this.options.structure === IAccountsStructureType.Flat
? nestedArrayToFlatten(transformed2)
: transformed2;
};
}

View File

@@ -0,0 +1,124 @@
import { Transformer } from '../Transformer/Transformer';
import { AccountTransaction } from './models/AccountTransaction.model';
export class AccountTransactionTransformer extends Transformer {
/**
* Include these attributes to sale invoice object.
* @returns {Array}
*/
public includeAttributes = (): string[] => {
return [
'date',
'formattedDate',
'transactionType',
'transactionId',
'transactionTypeFormatted',
'credit',
'debit',
'formattedCredit',
'formattedDebit',
'fcCredit',
'fcDebit',
'formattedFcCredit',
'formattedFcDebit',
];
};
/**
* Exclude all attributes of the model.
* @returns {Array<string>}
*/
public excludeAttributes = (): string[] => {
return ['*'];
};
/**
* Retrieves the formatted date.
* @returns {string}
*/
public formattedDate(transaction: AccountTransaction) {
return this.formatDate(transaction.date);
}
/**
* Retrieves the formatted transaction type.
* @returns {string}
*/
public transactionTypeFormatted(transaction: AccountTransaction) {
return this.context.i18n.t(transaction.referenceTypeFormatted);
}
/**
* Retrieves the tranasction type.
* @returns {string}
*/
public transactionType(transaction: AccountTransaction) {
return transaction.referenceType;
}
/**
* Retrieves the transaction id.
* @returns {number}
*/
public transactionId(transaction: AccountTransaction) {
return transaction.referenceId;
}
/**
* Retrieves the credit amount.
* @returns {string}
*/
protected formattedCredit(transaction: AccountTransaction) {
return this.formatMoney(transaction.credit, {
excerptZero: true,
});
}
/**
* Retrieves the credit amount.
* @returns {string}
*/
protected formattedDebit(transaction: AccountTransaction) {
return this.formatMoney(transaction.debit, {
excerptZero: true,
});
}
/**
* Retrieves the foreign credit amount.
* @returns {number}
*/
protected fcCredit(transaction: AccountTransaction) {
return transaction.credit * transaction.exchangeRate;
}
/**
* Retrieves the foreign debit amount.
* @returns {number}
*/
protected fcDebit(transaction: AccountTransaction) {
return transaction.debit * transaction.exchangeRate;
}
/**
* Retrieves the formatted foreign credit amount.
* @returns {string}
*/
protected formattedFcCredit(transaction: AccountTransaction) {
return this.formatMoney(this.fcCredit(transaction), {
currencyCode: transaction.currencyCode,
excerptZero: true,
});
}
/**
* Retrieves the formatted foreign debit amount.
* @returns {string}
*/
protected formattedFcDebit(transaction: AccountTransaction) {
return this.formatMoney(this.fcDebit(transaction), {
currencyCode: transaction.currencyCode,
excerptZero: true,
});
}
}

View File

@@ -0,0 +1,637 @@
export const OtherExpensesAccount = {
name: 'Other Expenses',
slug: 'other-expenses',
accountType: 'other-expense',
code: '40011',
description: '',
active: true,
index: 1,
predefined: true,
};
export const TaxPayableAccount = {
name: 'Tax Payable',
slug: 'tax-payable',
accountType: 'tax-payable',
code: '20006',
description: '',
active: true,
index: 1,
predefined: true,
};
export const UnearnedRevenueAccount = {
name: 'Unearned Revenue',
slug: 'unearned-revenue',
accountType: 'other-current-liability',
parentAccountId: null,
code: '50005',
active: true,
index: 1,
predefined: true,
};
export const PrepardExpenses = {
name: 'Prepaid Expenses',
slug: 'prepaid-expenses',
accountType: 'other-current-asset',
parentAccountId: null,
code: '100010',
active: true,
index: 1,
predefined: true,
};
export const StripeClearingAccount = {
name: 'Stripe Clearing',
slug: 'stripe-clearing',
accountType: 'other-current-asset',
parentAccountId: null,
code: '100020',
active: true,
index: 1,
predefined: true,
};
export const DiscountExpenseAccount = {
name: 'Discount',
slug: 'discount',
accountType: 'other-income',
code: '40008',
active: true,
index: 1,
predefined: true,
};
export const PurchaseDiscountAccount = {
name: 'Purchase Discount',
slug: 'purchase-discount',
accountType: 'other-expense',
code: '40009',
active: true,
index: 1,
predefined: true,
};
export const OtherChargesAccount = {
name: 'Other Charges',
slug: 'other-charges',
accountType: 'other-income',
code: '40010',
active: true,
index: 1,
predefined: true,
};
export const SeedAccounts = [
{
name: 'Bank Account',
slug: 'bank-account',
accountType: 'bank',
code: '10001',
description: '',
active: 1,
index: 1,
predefined: 1,
},
{
name: 'Saving Bank Account',
slug: 'saving-bank-account',
account_type: 'bank',
code: '10002',
description: '',
active: 1,
index: 1,
predefined: 0,
},
{
name: 'Undeposited Funds',
slug: 'undeposited-funds',
account_type: 'cash',
code: '10003',
description: '',
active: 1,
index: 1,
predefined: 1,
},
{
name: 'Petty Cash',
slug: 'petty-cash',
account_type: 'cash',
code: '10004',
description: '',
active: 1,
index: 1,
predefined: 1,
},
{
name: 'Computer Equipment',
slug: 'computer-equipment',
code: '10005',
account_type: 'fixed-asset',
predefined: 0,
parent_account_id: null,
index: 1,
active: 1,
description: '',
},
{
name: 'Office Equipment',
slug: 'office-equipment',
code: '10006',
account_type: 'fixed-asset',
predefined: 0,
parent_account_id: null,
index: 1,
active: 1,
description: '',
},
{
name: 'Accounts Receivable (A/R)',
slug: 'accounts-receivable',
account_type: 'accounts-receivable',
code: '10007',
description: '',
active: 1,
index: 1,
predefined: 1,
},
{
name: 'Inventory Asset',
slug: 'inventory-asset',
code: '10008',
account_type: 'inventory',
predefined: 1,
parent_account_id: null,
index: 1,
active: 1,
description:
'An account that holds valuation of products or goods that available for sale.',
},
// Libilities
{
name: 'Accounts Payable (A/P)',
slug: 'accounts-payable',
account_type: 'accounts-payable',
parent_account_id: null,
code: '20001',
description: '',
active: 1,
index: 1,
predefined: 1,
},
{
name: 'Owner A Drawings',
slug: 'owner-drawings',
account_type: 'other-current-liability',
parent_account_id: null,
code: '20002',
description: 'Withdrawals by the owners.',
active: 1,
index: 1,
predefined: 0,
},
{
name: 'Loan',
slug: 'owner-drawings',
account_type: 'other-current-liability',
code: '20003',
description: 'Money that has been borrowed from a creditor.',
active: 1,
index: 1,
predefined: 0,
},
{
name: 'Opening Balance Liabilities',
slug: 'opening-balance-liabilities',
account_type: 'other-current-liability',
code: '20004',
description:
'This account will hold the difference in the debits and credits entered during the opening balance..',
active: 1,
index: 1,
predefined: 0,
},
{
name: 'Revenue Received in Advance',
slug: 'revenue-received-in-advance',
account_type: 'other-current-liability',
parent_account_id: null,
code: '20005',
description: 'When customers pay in advance for products/services.',
active: 1,
index: 1,
predefined: 0,
},
TaxPayableAccount,
// Equity
{
name: 'Retained Earnings',
slug: 'retained-earnings',
account_type: 'equity',
code: '30001',
description:
'Retained earnings tracks net income from previous fiscal years.',
active: 1,
index: 1,
predefined: 1,
},
{
name: 'Opening Balance Equity',
slug: 'opening-balance-equity',
account_type: 'equity',
code: '30002',
description:
'When you enter opening balances to the accounts, the amounts enter in Opening balance equity. This ensures that you have a correct trial balance sheet for your company, without even specific the second credit or debit entry.',
active: 1,
index: 1,
predefined: 1,
},
{
name: "Owner's Equity",
slug: 'owner-equity',
account_type: 'equity',
code: '30003',
description: '',
active: 1,
index: 1,
predefined: 1,
},
{
name: `Drawings`,
slug: 'drawings',
account_type: 'equity',
code: '30003',
description:
'Goods purchased with the intention of selling these to customers',
active: 1,
index: 1,
predefined: 1,
},
// Expenses
OtherExpensesAccount,
{
name: 'Other Expenses',
slug: 'other-expenses',
account_type: 'other-expense',
parent_account_id: null,
code: '40001',
description: '',
active: 1,
index: 1,
predefined: 1,
},
{
name: 'Cost of Goods Sold',
slug: 'cost-of-goods-sold',
account_type: 'cost-of-goods-sold',
parent_account_id: null,
code: '40002',
description: 'Tracks the direct cost of the goods sold.',
active: 1,
index: 1,
predefined: 1,
},
{
name: 'Office expenses',
slug: 'office-expenses',
account_type: 'expense',
parent_account_id: null,
code: '40003',
description: '',
active: 1,
index: 1,
predefined: 0,
},
{
name: 'Rent',
slug: 'rent',
account_type: 'expense',
parent_account_id: null,
code: '40004',
description: '',
active: 1,
index: 1,
predefined: 0,
},
{
name: 'Exchange Gain or Loss',
slug: 'exchange-grain-loss',
account_type: 'other-expense',
parent_account_id: null,
code: '40005',
description: 'Tracks the gain and losses of the exchange differences.',
active: 1,
index: 1,
predefined: 1,
},
{
name: 'Bank Fees and Charges',
slug: 'bank-fees-and-charges',
account_type: 'expense',
parent_account_id: null,
code: '40006',
description:
'Any bank fees levied is recorded into the bank fees and charges account. A bank account maintenance fee, transaction charges, a late payment fee are some examples.',
active: 1,
index: 1,
predefined: 0,
},
{
name: 'Depreciation Expense',
slug: 'depreciation-expense',
account_type: 'expense',
parent_account_id: null,
code: '40007',
description: '',
active: 1,
index: 1,
predefined: 0,
},
// Income
{
name: 'Sales of Product Income',
slug: 'sales-of-product-income',
account_type: 'income',
predefined: 1,
parent_account_id: null,
code: '50001',
index: 1,
active: 1,
description: '',
},
{
name: 'Sales of Service Income',
slug: 'sales-of-service-income',
account_type: 'income',
predefined: 0,
parent_account_id: null,
code: '50002',
index: 1,
active: 1,
description: '',
},
{
name: 'Uncategorized Income',
slug: 'uncategorized-income',
account_type: 'income',
parent_account_id: null,
code: '50003',
description: '',
active: 1,
index: 1,
predefined: 1,
},
{
name: 'Other Income',
slug: 'other-income',
account_type: 'other-income',
parent_account_id: null,
code: '50004',
description:
'The income activities are not associated to the core business.',
active: 1,
index: 1,
predefined: 0,
},
UnearnedRevenueAccount,
PrepardExpenses,
DiscountExpenseAccount,
PurchaseDiscountAccount,
OtherChargesAccount,
];
export const ACCOUNT_TYPE = {
CASH: 'cash',
BANK: 'bank',
ACCOUNTS_RECEIVABLE: 'accounts-receivable',
INVENTORY: 'inventory',
OTHER_CURRENT_ASSET: 'other-current-asset',
FIXED_ASSET: 'fixed-asset',
NON_CURRENT_ASSET: 'none-current-asset',
ACCOUNTS_PAYABLE: 'accounts-payable',
CREDIT_CARD: 'credit-card',
TAX_PAYABLE: 'tax-payable',
OTHER_CURRENT_LIABILITY: 'other-current-liability',
LOGN_TERM_LIABILITY: 'long-term-liability',
NON_CURRENT_LIABILITY: 'non-current-liability',
EQUITY: 'equity',
INCOME: 'income',
OTHER_INCOME: 'other-income',
COST_OF_GOODS_SOLD: 'cost-of-goods-sold',
EXPENSE: 'expense',
OTHER_EXPENSE: 'other-expense',
};
export const ACCOUNT_PARENT_TYPE = {
CURRENT_ASSET: 'current-asset',
FIXED_ASSET: 'fixed-asset',
NON_CURRENT_ASSET: 'non-current-asset',
CURRENT_LIABILITY: 'current-liability',
LOGN_TERM_LIABILITY: 'long-term-liability',
NON_CURRENT_LIABILITY: 'non-current-liability',
EQUITY: 'equity',
EXPENSE: 'expense',
INCOME: 'income',
};
export const ACCOUNT_ROOT_TYPE = {
ASSET: 'asset',
LIABILITY: 'liability',
EQUITY: 'equity',
EXPENSE: 'expense',
INCOME: 'income',
};
export const ACCOUNT_NORMAL = {
CREDIT: 'credit',
DEBIT: 'debit',
};
export const ACCOUNT_TYPES = [
{
label: 'Cash',
key: ACCOUNT_TYPE.CASH,
normal: ACCOUNT_NORMAL.DEBIT,
parentType: ACCOUNT_PARENT_TYPE.CURRENT_ASSET,
rootType: ACCOUNT_ROOT_TYPE.ASSET,
multiCurrency: true,
balanceSheet: true,
incomeSheet: false,
},
{
label: 'Bank',
key: ACCOUNT_TYPE.BANK,
normal: ACCOUNT_NORMAL.DEBIT,
parentType: ACCOUNT_PARENT_TYPE.CURRENT_ASSET,
rootType: ACCOUNT_ROOT_TYPE.ASSET,
multiCurrency: true,
balanceSheet: true,
incomeSheet: false,
},
{
label: 'Accounts Receivable',
key: ACCOUNT_TYPE.ACCOUNTS_RECEIVABLE,
normal: ACCOUNT_NORMAL.DEBIT,
rootType: ACCOUNT_ROOT_TYPE.ASSET,
parentType: ACCOUNT_PARENT_TYPE.CURRENT_ASSET,
balanceSheet: true,
incomeSheet: false,
},
{
label: 'Inventory',
key: ACCOUNT_TYPE.INVENTORY,
normal: ACCOUNT_NORMAL.DEBIT,
rootType: ACCOUNT_ROOT_TYPE.ASSET,
parentType: ACCOUNT_PARENT_TYPE.CURRENT_ASSET,
balanceSheet: true,
incomeSheet: false,
},
{
label: 'Other Current Asset',
key: ACCOUNT_TYPE.OTHER_CURRENT_ASSET,
normal: ACCOUNT_NORMAL.DEBIT,
rootType: ACCOUNT_ROOT_TYPE.ASSET,
parentType: ACCOUNT_PARENT_TYPE.CURRENT_ASSET,
balanceSheet: true,
incomeSheet: false,
},
{
label: 'Fixed Asset',
key: ACCOUNT_TYPE.FIXED_ASSET,
normal: ACCOUNT_NORMAL.DEBIT,
rootType: ACCOUNT_ROOT_TYPE.ASSET,
parentType: ACCOUNT_PARENT_TYPE.FIXED_ASSET,
balanceSheet: true,
incomeSheet: false,
},
{
label: 'Non-Current Asset',
key: ACCOUNT_TYPE.NON_CURRENT_ASSET,
normal: ACCOUNT_NORMAL.DEBIT,
rootType: ACCOUNT_ROOT_TYPE.ASSET,
parentType: ACCOUNT_PARENT_TYPE.FIXED_ASSET,
balanceSheet: true,
incomeSheet: false,
},
{
label: 'Accounts Payable',
key: ACCOUNT_TYPE.ACCOUNTS_PAYABLE,
normal: ACCOUNT_NORMAL.CREDIT,
rootType: ACCOUNT_ROOT_TYPE.LIABILITY,
parentType: ACCOUNT_PARENT_TYPE.CURRENT_LIABILITY,
balanceSheet: true,
incomeSheet: false,
},
{
label: 'Credit Card',
key: ACCOUNT_TYPE.CREDIT_CARD,
normal: ACCOUNT_NORMAL.CREDIT,
rootType: ACCOUNT_ROOT_TYPE.LIABILITY,
parentType: ACCOUNT_PARENT_TYPE.CURRENT_LIABILITY,
multiCurrency: true,
balanceSheet: true,
incomeSheet: false,
},
{
label: 'Tax Payable',
key: ACCOUNT_TYPE.TAX_PAYABLE,
normal: ACCOUNT_NORMAL.CREDIT,
rootType: ACCOUNT_ROOT_TYPE.LIABILITY,
parentType: ACCOUNT_PARENT_TYPE.CURRENT_LIABILITY,
balanceSheet: true,
incomeSheet: false,
},
{
label: 'Other Current Liability',
key: ACCOUNT_TYPE.OTHER_CURRENT_LIABILITY,
normal: ACCOUNT_NORMAL.CREDIT,
rootType: ACCOUNT_ROOT_TYPE.LIABILITY,
parentType: ACCOUNT_PARENT_TYPE.CURRENT_LIABILITY,
balanceSheet: false,
incomeSheet: true,
},
{
label: 'Long Term Liability',
key: ACCOUNT_TYPE.LOGN_TERM_LIABILITY,
normal: ACCOUNT_NORMAL.CREDIT,
rootType: ACCOUNT_ROOT_TYPE.LIABILITY,
parentType: ACCOUNT_PARENT_TYPE.LOGN_TERM_LIABILITY,
balanceSheet: false,
incomeSheet: true,
},
{
label: 'Non-Current Liability',
key: ACCOUNT_TYPE.NON_CURRENT_LIABILITY,
normal: ACCOUNT_NORMAL.CREDIT,
rootType: ACCOUNT_ROOT_TYPE.LIABILITY,
parentType: ACCOUNT_PARENT_TYPE.NON_CURRENT_LIABILITY,
balanceSheet: false,
incomeSheet: true,
},
{
label: 'Equity',
key: ACCOUNT_TYPE.EQUITY,
normal: ACCOUNT_NORMAL.CREDIT,
rootType: ACCOUNT_ROOT_TYPE.EQUITY,
parentType: ACCOUNT_PARENT_TYPE.EQUITY,
balanceSheet: true,
incomeSheet: false,
},
{
label: 'Income',
key: ACCOUNT_TYPE.INCOME,
normal: ACCOUNT_NORMAL.CREDIT,
rootType: ACCOUNT_ROOT_TYPE.INCOME,
parentType: ACCOUNT_PARENT_TYPE.INCOME,
balanceSheet: false,
incomeSheet: true,
},
{
label: 'Other Income',
key: ACCOUNT_TYPE.OTHER_INCOME,
normal: ACCOUNT_NORMAL.CREDIT,
rootType: ACCOUNT_ROOT_TYPE.INCOME,
parentType: ACCOUNT_PARENT_TYPE.INCOME,
balanceSheet: false,
incomeSheet: true,
},
{
label: 'Cost of Goods Sold',
key: ACCOUNT_TYPE.COST_OF_GOODS_SOLD,
normal: ACCOUNT_NORMAL.DEBIT,
rootType: ACCOUNT_ROOT_TYPE.EXPENSE,
parentType: ACCOUNT_PARENT_TYPE.EXPENSE,
balanceSheet: false,
incomeSheet: true,
},
{
label: 'Expense',
key: ACCOUNT_TYPE.EXPENSE,
normal: ACCOUNT_NORMAL.DEBIT,
rootType: ACCOUNT_ROOT_TYPE.EXPENSE,
parentType: ACCOUNT_PARENT_TYPE.EXPENSE,
balanceSheet: false,
incomeSheet: true,
},
{
label: 'Other Expense',
key: ACCOUNT_TYPE.OTHER_EXPENSE,
normal: ACCOUNT_NORMAL.DEBIT,
rootType: ACCOUNT_ROOT_TYPE.EXPENSE,
parentType: ACCOUNT_PARENT_TYPE.EXPENSE,
balanceSheet: false,
incomeSheet: true,
},
];
export const getAccountsSupportsMultiCurrency = () => {
return ACCOUNT_TYPES.filter((account) => account.multiCurrency);
};

View File

@@ -0,0 +1,149 @@
import {
Controller,
Post,
Body,
Param,
Delete,
Get,
Query,
ParseIntPipe,
} from '@nestjs/common';
import { AccountsApplication } from './AccountsApplication.service';
import { CreateAccountDTO } from './CreateAccount.dto';
import { EditAccountDTO } from './EditAccount.dto';
import { IAccountsFilter, IAccountsTransactionsFilter } from './Accounts.types';
import { ApiOperation, ApiParam, ApiResponse, ApiTags } from '@nestjs/swagger';
@Controller('accounts')
@ApiTags('accounts')
export class AccountsController {
constructor(private readonly accountsApplication: AccountsApplication) {}
@Post()
@ApiOperation({ summary: 'Create an account' })
@ApiResponse({
status: 200,
description: 'The account has been successfully created.',
})
async createAccount(@Body() accountDTO: CreateAccountDTO) {
return this.accountsApplication.createAccount(accountDTO);
}
@Post(':id')
@ApiOperation({ summary: 'Edit the given account.' })
@ApiResponse({
status: 200,
description: 'The account has been successfully updated.',
})
@ApiResponse({ status: 404, description: 'The account not found.' })
@ApiParam({
name: 'id',
required: true,
type: Number,
description: 'The account id',
})
async editAccount(
@Param('id', ParseIntPipe) id: number,
@Body() accountDTO: EditAccountDTO,
) {
return this.accountsApplication.editAccount(id, accountDTO);
}
@Delete(':id')
@ApiOperation({ summary: 'Delete the given account.' })
@ApiResponse({
status: 200,
description: 'The account has been successfully deleted.',
})
@ApiResponse({ status: 404, description: 'The account not found.' })
@ApiParam({
name: 'id',
required: true,
type: Number,
description: 'The account id',
})
async deleteAccount(@Param('id', ParseIntPipe) id: number) {
return this.accountsApplication.deleteAccount(id);
}
@Post(':id/activate')
@ApiOperation({ summary: 'Activate the given account.' })
@ApiResponse({
status: 200,
description: 'The account has been successfully activated.',
})
@ApiResponse({ status: 404, description: 'The account not found.' })
@ApiParam({
name: 'id',
required: true,
type: Number,
description: 'The account id',
})
async activateAccount(@Param('id', ParseIntPipe) id: number) {
return this.accountsApplication.activateAccount(id);
}
@Post(':id/inactivate')
@ApiOperation({ summary: 'Inactivate the given account.' })
@ApiResponse({
status: 200,
description: 'The account has been successfully inactivated.',
})
@ApiResponse({ status: 404, description: 'The account not found.' })
@ApiParam({
name: 'id',
required: true,
type: Number,
description: 'The account id',
})
async inactivateAccount(@Param('id', ParseIntPipe) id: number) {
return this.accountsApplication.inactivateAccount(id);
}
@Get('types')
@ApiOperation({ summary: 'Retrieves the account types.' })
@ApiResponse({
status: 200,
description: 'The account types have been successfully retrieved.',
})
async getAccountTypes() {
return this.accountsApplication.getAccountTypes();
}
@Get('transactions')
@ApiOperation({ summary: 'Retrieves the account transactions.' })
@ApiResponse({
status: 200,
description: 'The account transactions have been successfully retrieved.',
})
async getAccountTransactions(@Query() filter: IAccountsTransactionsFilter) {
return this.accountsApplication.getAccountsTransactions(filter);
}
@Get(':id')
@ApiOperation({ summary: 'Retrieves the account details.' })
@ApiResponse({
status: 200,
description: 'The account details have been successfully retrieved.',
})
@ApiResponse({ status: 404, description: 'The account not found.' })
@ApiParam({
name: 'id',
required: true,
type: Number,
description: 'The account id',
})
async getAccount(@Param('id', ParseIntPipe) id: number) {
return this.accountsApplication.getAccount(id);
}
@Get()
@ApiOperation({ summary: 'Retrieves the accounts.' })
@ApiResponse({
status: 200,
description: 'The accounts have been successfully retrieved.',
})
async getAccounts(@Query() filter: IAccountsFilter) {
return this.accountsApplication.getAccounts(filter);
}
}

View File

@@ -0,0 +1,44 @@
import { Module } from '@nestjs/common';
import { TenancyDatabaseModule } from '../Tenancy/TenancyDB/TenancyDB.module';
import { AccountsController } from './Accounts.controller';
import { AccountsApplication } from './AccountsApplication.service';
import { CreateAccountService } from './CreateAccount.service';
import { TenancyContext } from '../Tenancy/TenancyContext.service';
import { CommandAccountValidators } from './CommandAccountValidators.service';
import { AccountRepository } from './repositories/Account.repository';
import { EditAccount } from './EditAccount.service';
import { DeleteAccount } from './DeleteAccount.service';
import { GetAccount } from './GetAccount.service';
import { TransformerInjectable } from '../Transformer/TransformerInjectable.service';
import { ActivateAccount } from './ActivateAccount.service';
import { GetAccountTypesService } from './GetAccountTypes.service';
import { GetAccountTransactionsService } from './GetAccountTransactions.service';
import { RegisterTenancyModel } from '../Tenancy/TenancyModels/Tenancy.module';
import { BankAccount } from '../BankingTransactions/models/BankAccount';
import { GetAccountsService } from './GetAccounts.service';
import { DynamicListModule } from '../DynamicListing/DynamicList.module';
// import { GetAccountsService } from './GetAccounts.service';
const models = [RegisterTenancyModel(BankAccount)];
@Module({
imports: [TenancyDatabaseModule, DynamicListModule, ...models],
controllers: [AccountsController],
providers: [
AccountsApplication,
CreateAccountService,
TenancyContext,
CommandAccountValidators,
AccountRepository,
EditAccount,
DeleteAccount,
GetAccount,
TransformerInjectable,
ActivateAccount,
GetAccountTypesService,
GetAccountTransactionsService,
GetAccountsService,
],
exports: [AccountRepository, CreateAccountService, ...models],
})
export class AccountsModule {}

View File

@@ -0,0 +1,92 @@
import { Knex } from 'knex';
import { Account } from './models/Account.model';
// import { IDynamicListFilterDTO } from '@/interfaces/DynamicFilter';
export enum AccountNormal {
DEBIT = 'debit',
CREDIT = 'credit',
}
export interface IAccountsTransactionsFilter {
accountId?: number;
limit?: number;
}
export enum IAccountsStructureType {
Tree = 'tree',
Flat = 'flat',
}
// export interface IAccountsFilter extends IDynamicListFilterDTO {
// }
export interface IAccountsFilter {
onlyInactive: boolean;
structure?: IAccountsStructureType;
}
export interface IAccountType {
label: string;
key: string;
normal: string;
rootType: string;
childType: string;
balanceSheet: boolean;
incomeSheet: boolean;
}
export interface IAccountsTypesService {
getAccountsTypes(): Promise<IAccountType>;
}
export interface IAccountEventCreatingPayload {
accountDTO: any;
trx: Knex.Transaction;
}
export interface IAccountEventCreatedPayload {
account: Account;
accountId: number;
trx: Knex.Transaction;
}
export interface IAccountEventEditedPayload {
account: Account;
oldAccount: Account;
trx: Knex.Transaction;
}
export interface IAccountEventDeletedPayload {
accountId: number;
oldAccount: Account;
trx: Knex.Transaction;
}
export interface IAccountEventDeletePayload {
trx: Knex.Transaction;
oldAccount: Account;
}
export interface IAccountEventActivatedPayload {
accountId: number;
trx: Knex.Transaction;
}
export enum AccountAction {
CREATE = 'Create',
EDIT = 'Edit',
DELETE = 'Delete',
VIEW = 'View',
TransactionsLocking = 'TransactionsLocking',
}
export enum TaxRateAction {
CREATE = 'Create',
EDIT = 'Edit',
DELETE = 'Delete',
VIEW = 'View',
}
export interface CreateAccountParams {
ignoreUniqueName: boolean;
}
export interface IGetAccountTransactionPOJO {}

View File

@@ -0,0 +1,133 @@
import { Knex } from 'knex';
import { Injectable } from '@nestjs/common';
import { CreateAccountService } from './CreateAccount.service';
import { DeleteAccount } from './DeleteAccount.service';
import { EditAccount } from './EditAccount.service';
import { CreateAccountDTO } from './CreateAccount.dto';
import { Account } from './models/Account.model';
import { EditAccountDTO } from './EditAccount.dto';
import { GetAccount } from './GetAccount.service';
import { ActivateAccount } from './ActivateAccount.service';
import { GetAccountTypesService } from './GetAccountTypes.service';
import { GetAccountTransactionsService } from './GetAccountTransactions.service';
import {
IAccountsFilter,
IAccountsTransactionsFilter,
IGetAccountTransactionPOJO,
} from './Accounts.types';
import { GetAccountsService } from './GetAccounts.service';
import { IFilterMeta } from '@/interfaces/Model';
@Injectable()
export class AccountsApplication {
/**
* @param {CreateAccountService} createAccountService - The create account service.
* @param {EditAccount} editAccountService - The edit account service.
* @param {DeleteAccount} deleteAccountService - The delete account service.
* @param {ActivateAccount} activateAccountService - The activate account service.
* @param {GetAccountTypesService} getAccountTypesService - The get account types service.
* @param {GetAccount} getAccountService - The get account service.
* @param {GetAccountTransactionsService} getAccountTransactionsService - The get account transactions service.
* @param {GetAccountsService} getAccountsService - The get accounts service.
*/
constructor(
private readonly createAccountService: CreateAccountService,
private readonly editAccountService: EditAccount,
private readonly deleteAccountService: DeleteAccount,
private readonly activateAccountService: ActivateAccount,
private readonly getAccountTypesService: GetAccountTypesService,
private readonly getAccountService: GetAccount,
private readonly getAccountTransactionsService: GetAccountTransactionsService,
private readonly getAccountsService: GetAccountsService,
) {}
/**
* Creates a new account.
* @param {number} tenantId
* @param {IAccountCreateDTO} accountDTO
* @returns {Promise<IAccount>}
*/
public createAccount = (
accountDTO: CreateAccountDTO,
trx?: Knex.Transaction,
): Promise<Account> => {
return this.createAccountService.createAccount(accountDTO, trx);
};
/**
* Deletes the given account.
* @param {number} tenantId
* @param {number} accountId
* @returns {Promise<void>}
*/
public deleteAccount = (accountId: number) => {
return this.deleteAccountService.deleteAccount(accountId);
};
/**
* Edits the given account.
* @param {number} tenantId
* @param {number} accountId
* @param {IAccountEditDTO} accountDTO
* @returns
*/
public editAccount = (accountId: number, accountDTO: EditAccountDTO) => {
return this.editAccountService.editAccount(accountId, accountDTO);
};
/**
* Activate the given account.
* @param {number} accountId - Account id.
*/
public activateAccount = (accountId: number) => {
return this.activateAccountService.activateAccount(accountId, true);
};
/**
* Inactivate the given account.
* @param {number} accountId - Account id.
*/
public inactivateAccount = (accountId: number) => {
return this.activateAccountService.activateAccount(accountId, false);
};
/**
* Retrieves the account details.
* @param {number} tenantId - Tenant id.
* @param {number} accountId - Account id.
* @returns {Promise<IAccount>}
*/
public getAccount = (accountId: number) => {
return this.getAccountService.getAccount(accountId);
};
/**
* Retrieves all account types.
* @returns {Promise<IAccountType[]>}
*/
public getAccountTypes = () => {
return this.getAccountTypesService.getAccountsTypes();
};
/**
* Retrieves the accounts list.
* @param {IAccountsFilter} filterDTO - Filter DTO.
* @returns {Promise<{ accounts: IAccountResponse[]; filterMeta: IFilterMeta }>}
*/
public getAccounts = (
filterDTO: IAccountsFilter,
): Promise<{ accounts: Account[]; filterMeta: IFilterMeta }> => {
return this.getAccountsService.getAccountsList(filterDTO);
};
/**
* Retrieves the given account transactions.
* @param {IAccountsTransactionsFilter} filter
* @returns {Promise<IGetAccountTransactionPOJO[]>}
*/
public getAccountsTransactions = (
filter: IAccountsTransactionsFilter,
): Promise<IGetAccountTransactionPOJO[]> => {
return this.getAccountTransactionsService.getAccountsTransactions(filter);
};
}

View File

@@ -0,0 +1,29 @@
import { AccountsApplication } from './AccountsApplication.service';
import { Exportable } from '../Export/Exportable';
import { EXPORT_SIZE_LIMIT } from '../Export/constants';
import { IAccountsFilter, IAccountsStructureType } from './Accounts.types';
export class AccountsExportable extends Exportable {
constructor(private readonly accountsApplication: AccountsApplication) {
super();
}
/**
* Retrieves the accounts data to exportable sheet.
*/
public exportable(query: IAccountsFilter) {
const parsedQuery = {
sortOrder: 'desc',
columnSortBy: 'created_at',
inactiveMode: false,
...query,
structure: IAccountsStructureType.Flat,
pageSize: EXPORT_SIZE_LIMIT,
page: 1,
} as IAccountsFilter;
return this.accountsApplication
.getAccounts(parsedQuery)
.then((output) => output.accounts);
}
}

View File

@@ -0,0 +1,50 @@
export const AccountsSampleData = [
{
'Account Name': 'Utilities Expense',
'Account Code': 9000,
Type: 'Expense',
Description: 'Omnis voluptatum consequatur.',
Active: 'T',
'Currency Code': '',
},
{
'Account Name': 'Unearned Revenue',
'Account Code': 9010,
Type: 'Long Term Liability',
Description: 'Autem odit voluptas nihil unde.',
Active: 'T',
'Currency Code': '',
},
{
'Account Name': 'Long-Term Debt',
'Account Code': 9020,
Type: 'Long Term Liability',
Description: 'In voluptas cumque exercitationem.',
Active: 'T',
'Currency Code': '',
},
{
'Account Name': 'Salaries and Wages Expense',
'Account Code': 9030,
Type: 'Expense',
Description: 'Assumenda aspernatur soluta aliquid perspiciatis quasi.',
Active: 'T',
'Currency Code': '',
},
{
'Account Name': 'Rental Income',
'Account Code': 9040,
Type: 'Income',
Description: 'Omnis possimus amet occaecati inventore.',
Active: 'T',
'Currency Code': '',
},
{
'Account Name': 'Paypal',
'Account Code': 9050,
Type: 'Bank',
Description: 'In voluptas cumque exercitationem.',
Active: 'T',
'Currency Code': '',
},
];

View File

@@ -0,0 +1,45 @@
// import { Inject, Service } from 'typedi';
// import { Knex } from 'knex';
// import { IAccountCreateDTO } from '@/interfaces';
// import { CreateAccount } from './CreateAccount.service';
// import { Importable } from '../Import/Importable';
// import { AccountsSampleData } from './AccountsImportable.SampleData';
// @Service()
// export class AccountsImportable extends Importable {
// @Inject()
// private createAccountService: CreateAccount;
// /**
// * Importing to account service.
// * @param {number} tenantId
// * @param {IAccountCreateDTO} createAccountDTO
// * @returns
// */
// public importable(
// tenantId: number,
// createAccountDTO: IAccountCreateDTO,
// trx?: Knex.Transaction
// ) {
// return this.createAccountService.createAccount(
// tenantId,
// createAccountDTO,
// trx
// );
// }
// /**
// * Concurrrency controlling of the importing process.
// * @returns {number}
// */
// public get concurrency() {
// return 1;
// }
// /**
// * Retrieves the sample data that used to download accounts sample sheet.
// */
// public sampleData(): any[] {
// return AccountsSampleData;
// }
// }

View File

@@ -0,0 +1,60 @@
import { Inject, Injectable } from '@nestjs/common';
import { Knex } from 'knex';
import { IAccountEventActivatedPayload } from './Accounts.types';
import { Account } from './models/Account.model';
import { AccountRepository } from './repositories/Account.repository';
import { UnitOfWork } from '../Tenancy/TenancyDB/UnitOfWork.service';
import { EventEmitter2 } from '@nestjs/event-emitter';
import { events } from '@/common/events/events';
import { TenantModelProxy } from '../System/models/TenantBaseModel';
@Injectable()
export class ActivateAccount {
/**
* @param {EventEmitter2} eventEmitter - The event emitter.
* @param {UnitOfWork} uow - The unit of work.
* @param {AccountRepository} accountRepository - The account repository.
* @param {TenantModelProxy<typeof Account>} accountModel - The account model.
*/
constructor(
private readonly eventEmitter: EventEmitter2,
private readonly uow: UnitOfWork,
private readonly accountRepository: AccountRepository,
@Inject(Account.name)
private readonly accountModel: TenantModelProxy<typeof Account>,
) {}
/**
* Activates/Inactivates the given account.
* @param {number} accountId - The account id.
* @param {boolean} activate - Activate or inactivate the account.
*/
public activateAccount = async (accountId: number, activate?: boolean) => {
// Retrieve the given account or throw not found error.
const oldAccount = await this.accountModel()
.query()
.findById(accountId)
.throwIfNotFound();
// Get all children accounts.
const accountsGraph = await this.accountRepository.getDependencyGraph();
const dependenciesAccounts = accountsGraph.dependenciesOf(accountId);
const patchAccountsIds = [...dependenciesAccounts, accountId];
// Activate account and associated transactions under unit-of-work environment.
return this.uow.withTransaction(async (trx: Knex.Transaction) => {
// Activate and inactivate the given accounts ids.
activate
? await this.accountRepository.activateByIds(patchAccountsIds, trx)
: await this.accountRepository.inactivateByIds(patchAccountsIds, trx);
// Triggers `onAccountActivated` event.
this.eventEmitter.emitAsync(events.accounts.onActivated, {
accountId,
trx,
} as IAccountEventActivatedPayload);
});
};
}

View File

@@ -0,0 +1,224 @@
// @ts-nocheck
import { Inject, Injectable, Scope } from '@nestjs/common';
// import { IAccountDTO, IAccount, IAccountCreateDTO } from './Accounts.types';
// import AccountTypesUtils from '@/lib/AccountTypes';
import { ServiceError } from '../Items/ServiceError';
import { ERRORS, MAX_ACCOUNTS_CHART_DEPTH } from './constants';
import { Account } from './models/Account.model';
import { AccountRepository } from './repositories/Account.repository';
import { AccountTypesUtils } from './utils/AccountType.utils';
import { CreateAccountDTO } from './CreateAccount.dto';
import { EditAccountDTO } from './EditAccount.dto';
import { TenantModelProxy } from '../System/models/TenantBaseModel';
@Injectable({ scope: Scope.REQUEST })
export class CommandAccountValidators {
constructor(
@Inject(Account.name)
private readonly accountModel: TenantModelProxy<typeof Account>,
private readonly accountRepository: AccountRepository,
) {}
/**
* Throws error if the account was prefined.
* @param {Account} account
*/
public throwErrorIfAccountPredefined(account: Account) {
if (account.predefined) {
throw new ServiceError(ERRORS.ACCOUNT_PREDEFINED);
}
}
/**
* Diff account type between new and old account, throw service error
* if they have different account type.
* @param {Account|CreateAccountDTO|EditAccountDTO} oldAccount
* @param {Account|CreateAccountDTO|EditAccountDTO} newAccount
*/
public async isAccountTypeChangedOrThrowError(
oldAccount: Account | CreateAccountDTO | EditAccountDTO,
newAccount: Account | CreateAccountDTO | EditAccountDTO,
) {
if (oldAccount.accountType !== newAccount.accountType) {
throw new ServiceError(ERRORS.ACCOUNT_TYPE_NOT_ALLOWED_TO_CHANGE);
}
}
/**
* Retrieve account type or throws service error.
* @param {string} accountTypeKey -
* @return {IAccountType}
*/
public getAccountTypeOrThrowError(accountTypeKey: string) {
const accountType = AccountTypesUtils.getType(accountTypeKey);
if (!accountType) {
throw new ServiceError(ERRORS.ACCOUNT_TYPE_NOT_FOUND);
}
return accountType;
}
/**
* Retrieve parent account or throw service error.
* @param {number} accountId - Account id.
* @param {number} notAccountId - Ignore the account id.
*/
public async getParentAccountOrThrowError(
accountId: number,
notAccountId?: number,
) {
const parentAccount = await this.accountModel()
.query()
.findById(accountId)
.onBuild((query) => {
if (notAccountId) {
query.whereNot('id', notAccountId);
}
});
if (!parentAccount) {
throw new ServiceError(ERRORS.PARENT_ACCOUNT_NOT_FOUND);
}
return parentAccount;
}
/**
* Throws error if the account type was not unique on the storage.
* @param {string} accountCode - Account code.
* @param {number} notAccountId - Ignore the account id.
*/
public async isAccountCodeUniqueOrThrowError(
accountCode: string,
notAccountId?: number,
) {
const account = await this.accountModel()
.query()
.where('code', accountCode)
.onBuild((query) => {
if (notAccountId) {
query.whereNot('id', notAccountId);
}
});
if (account.length > 0) {
throw new ServiceError(
ERRORS.ACCOUNT_CODE_NOT_UNIQUE,
'Account code is not unique.',
);
}
}
/**
* Validates the account name uniquiness.
* @param {string} accountName - Account name.
* @param {number} notAccountId - Ignore the account id.
*/
public async validateAccountNameUniquiness(
accountName: string,
notAccountId?: number,
) {
const foundAccount = await this.accountModel
.query()
.findOne('name', accountName)
.onBuild((query) => {
if (notAccountId) {
query.whereNot('id', notAccountId);
}
});
if (foundAccount) {
throw new ServiceError(
ERRORS.ACCOUNT_NAME_NOT_UNIQUE,
'Account name is not unique.',
);
}
}
/**
* Validates the given account type supports multi-currency.
* @param {CreateAccountDTO | EditAccountDTO} accountDTO -
*/
public validateAccountTypeSupportCurrency = (
accountDTO: CreateAccountDTO | EditAccountDTO,
baseCurrency: string,
) => {
// Can't continue to validate the type has multi-currency feature
// if the given currency equals the base currency or not assigned.
if (accountDTO.currencyCode === baseCurrency || !accountDTO.currencyCode) {
return;
}
const meta = AccountTypesUtils.getType(accountDTO.accountType);
// Throw error if the account type does not support multi-currency.
if (!meta?.multiCurrency) {
throw new ServiceError(ERRORS.ACCOUNT_TYPE_NOT_SUPPORTS_MULTI_CURRENCY);
}
};
/**
* Validates the account DTO currency code whether equals the currency code of
* parent account.
* @param {CreateAccountDTO | EditAccountDTO} accountDTO
* @param {Account} parentAccount
* @param {string} baseCurrency -
* @throws {ServiceError(ERRORS.ACCOUNT_CURRENCY_NOT_SAME_PARENT_ACCOUNT)}
*/
public validateCurrentSameParentAccount = (
accountDTO: CreateAccountDTO | EditAccountDTO,
parentAccount: Account,
baseCurrency: string,
) => {
// If the account DTO currency not assigned and the parent account has no base currency.
if (
!accountDTO.currencyCode &&
parentAccount.currencyCode !== baseCurrency
) {
throw new ServiceError(ERRORS.ACCOUNT_CURRENCY_NOT_SAME_PARENT_ACCOUNT);
}
// If the account DTO is assigned and not equals the currency code of parent account.
if (
accountDTO.currencyCode &&
parentAccount.currencyCode !== accountDTO.currencyCode
) {
throw new ServiceError(ERRORS.ACCOUNT_CURRENCY_NOT_SAME_PARENT_ACCOUNT);
}
};
/**
* Throws service error if parent account has different type.
* @param {IAccountDTO} accountDTO
* @param {IAccount} parentAccount
*/
public throwErrorIfParentHasDiffType(
accountDTO: CreateAccountDTO | EditAccountDTO,
parentAccount: Account,
) {
if (accountDTO.accountType !== parentAccount.accountType) {
throw new ServiceError(ERRORS.PARENT_ACCOUNT_HAS_DIFFERENT_TYPE);
}
}
/**
* Retrieve account of throw service error in case account not found.
* @param {number} accountId
* @return {IAccount}
*/
public async getAccountOrThrowError(accountId: number) {
const account = await this.accountRepository.findOneById(accountId);
if (!account) {
throw new ServiceError(ERRORS.ACCOUNT_NOT_FOUND);
}
return account;
}
/**
* Validates the max depth level of accounts chart.
* @param {number} parentAccountId - Parent account id.
*/
public async validateMaxParentAccountDepthLevels(parentAccountId: number) {
const accountsGraph = await this.accountRepository.getDependencyGraph();
const parentDependantsIds = accountsGraph.dependantsOf(parentAccountId);
if (parentDependantsIds.length >= MAX_ACCOUNTS_CHART_DEPTH) {
throw new ServiceError(ERRORS.PARENT_ACCOUNT_EXCEEDED_THE_DEPTH_LEVEL);
}
}
}

View File

@@ -0,0 +1,103 @@
import { ApiProperty } from '@nestjs/swagger';
import {
IsString,
IsOptional,
IsInt,
MinLength,
MaxLength,
IsBoolean,
} from 'class-validator';
export class CreateAccountDTO {
@IsString()
@MinLength(3)
@MaxLength(255) // Assuming DATATYPES_LENGTH.STRING is 255
@ApiProperty({
description: 'Account name',
example: 'Cash Account',
minLength: 3,
maxLength: 255,
})
name: string;
@IsOptional()
@IsString()
@MinLength(3)
@MaxLength(6)
@ApiProperty({
description: 'Account code',
example: 'CA001',
required: false,
minLength: 3,
maxLength: 6,
})
code?: string;
@IsOptional()
@IsString()
@ApiProperty({
description: 'Currency code for the account',
example: 'USD',
required: false,
})
currencyCode?: string;
@IsString()
@MinLength(3)
@MaxLength(255)
@ApiProperty({
description: 'Type of account',
example: 'asset',
minLength: 3,
maxLength: 255,
})
accountType: string;
@IsOptional()
@IsString()
@MaxLength(65535)
@ApiProperty({
description: 'Account description',
example: 'Main cash account for daily operations',
required: false,
maxLength: 65535,
})
description?: string;
@IsOptional()
@IsInt()
@ApiProperty({
description: 'ID of the parent account',
example: 1,
required: false,
})
parentAccountId?: number;
@IsOptional()
@IsBoolean()
@ApiProperty({
description: 'Whether the account is active',
example: true,
required: false,
default: true,
})
active?: boolean;
@IsOptional()
@IsString()
@ApiProperty({
description: 'Plaid account ID for syncing',
example: 'plaid_account_123456',
required: false,
})
plaidAccountId?: string;
@IsOptional()
@IsString()
@ApiProperty({
description: 'Plaid item ID for syncing',
example: 'plaid_item_123456',
required: false,
})
plaidItemId?: string;
}

View File

@@ -0,0 +1,147 @@
import { Inject, Injectable } from '@nestjs/common';
import { kebabCase } from 'lodash';
import { Knex } from 'knex';
import { EventEmitter2 } from '@nestjs/event-emitter';
import {
IAccountEventCreatingPayload,
CreateAccountParams,
IAccountEventCreatedPayload,
} from './Accounts.types';
import { CommandAccountValidators } from './CommandAccountValidators.service';
import { Account } from './models/Account.model';
import { UnitOfWork } from '../Tenancy/TenancyDB/UnitOfWork.service';
import { TenancyContext } from '../Tenancy/TenancyContext.service';
import { events } from '@/common/events/events';
import { CreateAccountDTO } from './CreateAccount.dto';
import { PartialModelObject } from 'objection';
import { TenantModelProxy } from '../System/models/TenantBaseModel';
@Injectable()
export class CreateAccountService {
/**
* @param {TenantModelProxy<typeof Account>} accountModel - The account model proxy.
* @param {EventEmitter2} eventEmitter - The event emitter.
* @param {UnitOfWork} uow - The unit of work.
* @param {CommandAccountValidators} validator - The command account validators.
* @param {TenancyContext} tenancyContext - The tenancy context.
*/
constructor(
@Inject(Account.name)
private readonly accountModel: TenantModelProxy<typeof Account>,
private readonly eventEmitter: EventEmitter2,
private readonly uow: UnitOfWork,
private readonly validator: CommandAccountValidators,
private readonly tenancyContext: TenancyContext,
) {}
/**
* Authorize the account creation.
* @param {CreateAccountDTO} accountDTO
*/
private authorize = async (
accountDTO: CreateAccountDTO,
baseCurrency: string,
params?: CreateAccountParams,
) => {
// Validate account name uniquiness.
if (!params.ignoreUniqueName) {
await this.validator.validateAccountNameUniquiness(accountDTO.name);
}
// Validate the account code uniquiness.
if (accountDTO.code) {
await this.validator.isAccountCodeUniqueOrThrowError(accountDTO.code);
}
// Retrieve the account type meta or throw service error if not found.
this.validator.getAccountTypeOrThrowError(accountDTO.accountType);
// Ingore the parent account validation if not presented.
if (accountDTO.parentAccountId) {
const parentAccount = await this.validator.getParentAccountOrThrowError(
accountDTO.parentAccountId,
);
this.validator.throwErrorIfParentHasDiffType(accountDTO, parentAccount);
// Inherit active status from parent account.
accountDTO.active = parentAccount.active;
// Validate should currency code be the same currency of parent account.
this.validator.validateCurrentSameParentAccount(
accountDTO,
parentAccount,
baseCurrency,
);
// Validates the max depth level of accounts chart.
await this.validator.validateMaxParentAccountDepthLevels(
accountDTO.parentAccountId,
);
}
// Validates the given account type supports the multi-currency.
this.validator.validateAccountTypeSupportCurrency(accountDTO, baseCurrency);
};
/**
* Transformes the create account DTO to input model.
* @param {IAccountCreateDTO} createAccountDTO
*/
private transformDTOToModel = (
createAccountDTO: CreateAccountDTO,
baseCurrency: string,
): PartialModelObject<Account> => {
return {
...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,
),
};
};
/**
* Creates a new account on the storage.
* @param {IAccountCreateDTO} accountDTO
* @returns {Promise<IAccount>}
*/
public createAccount = async (
accountDTO: CreateAccountDTO,
trx?: Knex.Transaction,
params: CreateAccountParams = { ignoreUniqueName: false },
): Promise<Account> => {
// Retrieves the given tenant metadata.
const tenant = await this.tenancyContext.getTenant(true);
// Authorize the account creation.
await this.authorize(accountDTO, tenant.metadata.baseCurrency, params);
// Transformes the DTO to model.
const accountInputModel = this.transformDTOToModel(
accountDTO,
tenant.metadata.baseCurrency,
);
// Creates a new account with associated transactions under unit-of-work envirement.
return this.uow.withTransaction(async (trx: Knex.Transaction) => {
// Triggers `onAccountCreating` event.
await this.eventEmitter.emitAsync(events.accounts.onCreating, {
accountDTO,
trx,
} as IAccountEventCreatingPayload);
// Inserts account to the storage.
const account = await this.accountModel()
.query()
.insert({
...accountInputModel,
});
// Triggers `onAccountCreated` event.
await this.eventEmitter.emitAsync(events.accounts.onCreated, {
account,
accountId: account.id,
trx,
} as IAccountEventCreatedPayload);
return account;
}, trx);
};
}

View File

@@ -0,0 +1,83 @@
import { Knex } from 'knex';
import { Inject, Injectable } from '@nestjs/common';
// import { IAccountEventDeletedPayload } from '@/interfaces';
import { CommandAccountValidators } from './CommandAccountValidators.service';
import { Account } from './models/Account.model';
import { EventEmitter2 } from '@nestjs/event-emitter';
import { UnitOfWork } from '../Tenancy/TenancyDB/UnitOfWork.service';
import { events } from '@/common/events/events';
import { IAccountEventDeletedPayload } from './Accounts.types';
import { TenantModelProxy } from '../System/models/TenantBaseModel';
@Injectable()
export class DeleteAccount {
constructor(
@Inject(Account.name)
private accountModel: TenantModelProxy<typeof Account>,
private eventEmitter: EventEmitter2,
private uow: UnitOfWork,
private validator: CommandAccountValidators,
) {}
/**
* Authorize account delete.
* @param {number} accountId - Account id.
*/
private authorize = async (accountId: number, oldAccount: Account) => {
// Throw error if the account was predefined.
this.validator.throwErrorIfAccountPredefined(oldAccount);
};
/**
* Unlink the given parent account with children accounts.
* @param {number|number[]} parentAccountId -
*/
private async unassociateChildrenAccountsFromParent(
parentAccountId: number | number[],
trx?: Knex.Transaction,
) {
const accountsIds = Array.isArray(parentAccountId)
? parentAccountId
: [parentAccountId];
await this.accountModel()
.query(trx)
.whereIn('parent_account_id', accountsIds)
.patch({ parentAccountId: null });
}
/**
* Deletes the account from the storage.
* @param {number} accountId
*/
public deleteAccount = async (accountId: number): Promise<void> => {
// Retrieve account or not found service error.
const oldAccount = await this.accountModel().query().findById(accountId);
// Authorize before delete account.
await this.authorize(accountId, oldAccount);
// Deletes the account and associated transactions under UOW environment.
return this.uow.withTransaction(async (trx: Knex.Transaction) => {
// Triggers `onAccountDelete` event.
await this.eventEmitter.emitAsync(events.accounts.onDelete, {
trx,
oldAccount,
} as IAccountEventDeletedPayload);
// Unlink the parent account from children accounts.
await this.unassociateChildrenAccountsFromParent(accountId, trx);
// Deletes account by the given id.
await this.accountModel().query(trx).deleteById(accountId);
// Triggers `onAccountDeleted` event.
await this.eventEmitter.emitAsync(events.accounts.onDeleted, {
accountId,
oldAccount,
trx,
} as IAccountEventDeletedPayload);
});
};
}

View File

@@ -0,0 +1,54 @@
import { ApiProperty } from '@nestjs/swagger';
import {
IsString,
IsOptional,
IsInt,
MinLength,
MaxLength,
} from 'class-validator';
export class EditAccountDTO {
@IsString()
@MinLength(3)
@MaxLength(255)
@ApiProperty({
description: 'The name of the account',
example: 'Bank Account',
})
name: string;
@IsOptional()
@IsString()
@MinLength(3)
@MaxLength(6)
@ApiProperty({
description: 'The code of the account',
example: '123456',
})
code?: string;
@IsString()
@MinLength(3)
@MaxLength(255)
@ApiProperty({
description: 'The type of the account',
example: 'Bank Account',
})
accountType: string;
@IsOptional()
@IsString()
@ApiProperty({
description: 'The description of the account',
example: 'This is a description',
})
description?: string;
@IsOptional()
@IsInt()
@ApiProperty({
description: 'The parent account ID of the account',
example: 1,
})
parentAccountId?: number;
}

View File

@@ -0,0 +1,101 @@
import { Inject, Injectable } from '@nestjs/common';
import { Knex } from 'knex';
import { CommandAccountValidators } from './CommandAccountValidators.service';
import { Account } from './models/Account.model';
import { EventEmitter2 } from '@nestjs/event-emitter';
import { UnitOfWork } from '../Tenancy/TenancyDB/UnitOfWork.service';
import { events } from '@/common/events/events';
import { EditAccountDTO } from './EditAccount.dto';
import { TenantModelProxy } from '../System/models/TenantBaseModel';
@Injectable()
export class EditAccount {
constructor(
private readonly eventEmitter: EventEmitter2,
private readonly uow: UnitOfWork,
private readonly validator: CommandAccountValidators,
@Inject(Account.name)
private readonly accountModel: TenantModelProxy<typeof Account>,
) {}
/**
* Authorize the account editing.
* @param {number} accountId
* @param {IAccountEditDTO} accountDTO
* @param {IAccount} oldAccount -
*/
private authorize = async (
accountId: number,
accountDTO: EditAccountDTO,
oldAccount: Account,
) => {
// Validate account name uniquiness.
await this.validator.validateAccountNameUniquiness(
accountDTO.name,
accountId,
);
// Validate the account type should be not mutated.
await this.validator.isAccountTypeChangedOrThrowError(
oldAccount,
accountDTO,
);
// Validate the account code not exists on the storage.
if (accountDTO.code && accountDTO.code !== oldAccount.code) {
await this.validator.isAccountCodeUniqueOrThrowError(
accountDTO.code,
oldAccount.id,
);
}
// Retrieve the parent account of throw not found service error.
if (accountDTO.parentAccountId) {
const parentAccount = await this.validator.getParentAccountOrThrowError(
accountDTO.parentAccountId,
oldAccount.id,
);
this.validator.throwErrorIfParentHasDiffType(accountDTO, parentAccount);
}
};
/**
* Edits details of the given account.
* @param {number} accountId
* @param {IAccountDTO} accountDTO
*/
public async editAccount(
accountId: number,
accountDTO: EditAccountDTO,
): Promise<Account> {
// Retrieve the old account or throw not found service error.
const oldAccount = await this.accountModel()
.query()
.findById(accountId)
.throwIfNotFound();
// Authorize the account editing.
await this.authorize(accountId, accountDTO, oldAccount);
// Edits account and associated transactions under unit-of-work environment.
return this.uow.withTransaction(async (trx: Knex.Transaction) => {
// Triggers `onAccountEditing` event.
await this.eventEmitter.emitAsync(events.accounts.onEditing, {
oldAccount,
accountDTO,
});
// Update the account on the storage.
const account = await this.accountModel()
.query(trx)
.findById(accountId)
.updateAndFetch({ ...accountDTO });
// Triggers `onAccountEdited` event.
// await this.eventEmitter.emitAsync(events.accounts.onEdited, {
// account,
// oldAccount,
// trx,
// } as IAccountEventEditedPayload);
return account;
});
}
}

View File

@@ -0,0 +1,47 @@
import { Inject, Injectable } from '@nestjs/common';
import { AccountTransformer } from './Account.transformer';
import { Account } from './models/Account.model';
import { AccountRepository } from './repositories/Account.repository';
import { TransformerInjectable } from '../Transformer/TransformerInjectable.service';
import { EventEmitter2 } from '@nestjs/event-emitter';
import { events } from '@/common/events/events';
import { TenantModelProxy } from '../System/models/TenantBaseModel';
@Injectable()
export class GetAccount {
constructor(
@Inject(Account.name)
private readonly accountModel: TenantModelProxy<typeof Account>,
private readonly accountRepository: AccountRepository,
private readonly transformer: TransformerInjectable,
private readonly eventEmitter: EventEmitter2,
) {}
/**
* Retrieve the given account details.
* @param {number} accountId
*/
public getAccount = async (accountId: number) => {
// Find the given account or throw not found error.
const account = await this.accountModel()
.query()
.findById(accountId)
.withGraphFetched('plaidItem')
.throwIfNotFound();
const accountsGraph = await this.accountRepository.getDependencyGraph();
// Transforms the account model to POJO.
const transformed = await this.transformer.transform(
account,
new AccountTransformer(),
{ accountsGraph },
);
const eventPayload = { accountId };
// Triggers `onAccountViewed` event.
await this.eventEmitter.emitAsync(events.accounts.onViewed, eventPayload);
return transformed;
};
}

View File

@@ -0,0 +1,55 @@
import {
IAccountsTransactionsFilter,
IGetAccountTransactionPOJO,
} from './Accounts.types';
import { AccountTransactionTransformer } from './AccountTransaction.transformer';
import { AccountTransaction } from './models/AccountTransaction.model';
import { Account } from './models/Account.model';
import { Inject, Injectable } from '@nestjs/common';
import { TransformerInjectable } from '../Transformer/TransformerInjectable.service';
import { TenantModelProxy } from '../System/models/TenantBaseModel';
@Injectable()
export class GetAccountTransactionsService {
constructor(
private readonly transformer: TransformerInjectable,
@Inject(AccountTransaction.name)
private readonly accountTransaction: TenantModelProxy<
typeof AccountTransaction
>,
@Inject(Account.name)
private readonly account: TenantModelProxy<typeof Account>,
) {}
/**
* Retrieve the accounts transactions.
* @param {IAccountsTransactionsFilter} filter -
*/
public getAccountsTransactions = async (
filter: IAccountsTransactionsFilter,
): Promise<IGetAccountTransactionPOJO[]> => {
// Retrieve the given account or throw not found error.
if (filter.accountId) {
await this.account().query().findById(filter.accountId).throwIfNotFound();
}
const transactions = await this.accountTransaction()
.query()
.onBuild((query) => {
query.orderBy('date', 'DESC');
if (filter.accountId) {
query.where('account_id', filter.accountId);
}
query.withGraphFetched('account');
query.withGraphFetched('contact');
query.limit(filter.limit || 50);
});
// Transform the account transaction.
return this.transformer.transform(
transactions,
new AccountTransactionTransformer(),
);
};
}

View File

@@ -0,0 +1,17 @@
// import { IAccountType } from './Accounts.types';
import { Injectable } from '@nestjs/common';
import { AccountTypesUtils } from './utils/AccountType.utils';
@Injectable()
export class GetAccountTypesService {
/**
* Retrieve all accounts types.
* @param {number} tenantId -
* @return {IAccountType}
*/
public getAccountsTypes() {
const accountTypes = AccountTypesUtils.getList();
return accountTypes;
}
}

View File

@@ -0,0 +1,69 @@
import { Inject, Injectable } from '@nestjs/common';
import * as R from 'ramda';
import { IAccountsFilter } from './Accounts.types';
import { DynamicListService } from '../DynamicListing/DynamicList.service';
import { AccountTransformer } from './Account.transformer';
import { TransformerInjectable } from '../Transformer/TransformerInjectable.service';
import { Account } from './models/Account.model';
import { AccountRepository } from './repositories/Account.repository';
import { IFilterMeta } from '@/interfaces/Model';
import { TenantModelProxy } from '../System/models/TenantBaseModel';
@Injectable()
export class GetAccountsService {
constructor(
private readonly dynamicListService: DynamicListService,
private readonly transformerService: TransformerInjectable,
private readonly accountRepository: AccountRepository,
@Inject(Account.name)
private readonly accountModel: TenantModelProxy<typeof Account>,
) {}
/**
* Retrieve accounts datatable list.
* @param {IAccountsFilter} accountsFilter
* @returns {Promise<{ accounts: IAccountResponse[]; filterMeta: IFilterMeta }>}
*/
public async getAccountsList(
filterDTO: IAccountsFilter,
): Promise<{ accounts: Account[]; filterMeta: IFilterMeta }> {
// Parses the stringified filter roles.
const filter = this.parseListFilterDTO(filterDTO);
// Dynamic list service.
const dynamicList = await this.dynamicListService.dynamicList(
this.accountModel(),
filter,
);
// Retrieve accounts model based on the given query.
const accounts = await this.accountModel()
.query()
.onBuild((builder) => {
dynamicList.buildQuery()(builder);
builder.modify('inactiveMode', filter.inactiveMode);
});
const accountsGraph = await this.accountRepository.getDependencyGraph();
// Retrieves the transformed accounts collection.
const transformedAccounts = await this.transformerService.transform(
accounts,
new AccountTransformer(),
{ accountsGraph, structure: filterDTO.structure },
);
return {
accounts: transformedAccounts,
filterMeta: dynamicList.getResponseMeta(),
};
}
/**
* Parsees accounts list filter DTO.
* @param filterDTO
* @returns
*/
private parseListFilterDTO(filterDTO) {
return R.compose(this.dynamicListService.parseStringifiedFilter)(filterDTO);
}
}

View File

@@ -0,0 +1,22 @@
// import { Inject, Service } from 'typedi';
// import HasTenancyService from '@/services/Tenancy/TenancyService';
// @Service()
// export class MutateBaseCurrencyAccounts {
// @Inject()
// tenancy: HasTenancyService;
// /**
// * Mutates the all accounts or the organziation.
// * @param {number} tenantId
// * @param {string} currencyCode
// */
// public mutateAllAccountsCurrency = async (
// tenantId: number,
// currencyCode: string
// ) => {
// const { Account } = this.tenancy.models(tenantId);
// await Account.query().update({ currencyCode });
// };
// }

View File

@@ -0,0 +1,103 @@
export const ERRORS = {
ACCOUNT_NOT_FOUND: 'account_not_found',
ACCOUNT_TYPE_NOT_FOUND: 'account_type_not_found',
PARENT_ACCOUNT_NOT_FOUND: 'parent_account_not_found',
ACCOUNT_CODE_NOT_UNIQUE: 'account_code_not_unique',
ACCOUNT_NAME_NOT_UNIQUE: 'account_name_not_unqiue',
PARENT_ACCOUNT_HAS_DIFFERENT_TYPE: 'parent_has_different_type',
ACCOUNT_TYPE_NOT_ALLOWED_TO_CHANGE: 'account_type_not_allowed_to_changed',
ACCOUNT_PREDEFINED: 'account_predefined',
ACCOUNT_HAS_ASSOCIATED_TRANSACTIONS: 'account_has_associated_transactions',
PREDEFINED_ACCOUNTS: 'predefined_accounts',
ACCOUNTS_HAVE_TRANSACTIONS: 'accounts_have_transactions',
CLOSE_ACCOUNT_AND_TO_ACCOUNT_NOT_SAME_TYPE:
'close_account_and_to_account_not_same_type',
ACCOUNTS_NOT_FOUND: 'accounts_not_found',
ACCOUNT_TYPE_NOT_SUPPORTS_MULTI_CURRENCY:
'ACCOUNT_TYPE_NOT_SUPPORTS_MULTI_CURRENCY',
ACCOUNT_CURRENCY_NOT_SAME_PARENT_ACCOUNT:
'ACCOUNT_CURRENCY_NOT_SAME_PARENT_ACCOUNT',
PARENT_ACCOUNT_EXCEEDED_THE_DEPTH_LEVEL:
'PARENT_ACCOUNT_EXCEEDED_THE_DEPTH_LEVEL',
};
// Default views columns.
export const DEFAULT_VIEW_COLUMNS = [
{ key: 'name', label: 'Account name' },
{ key: 'code', label: 'Account code' },
{ key: 'account_type_label', label: 'Account type' },
{ key: 'account_normal', label: 'Account normal' },
{ key: 'amount', label: 'Balance' },
{ key: 'currencyCode', label: 'Currency' },
];
export const MAX_ACCOUNTS_CHART_DEPTH = 5;
// Accounts default views.
export const DEFAULT_VIEWS = [
{
name: 'Assets',
slug: 'assets',
rolesLogicExpression: '1',
roles: [
{ index: 1, fieldKey: 'root_type', comparator: 'equals', value: 'asset' },
],
columns: DEFAULT_VIEW_COLUMNS,
},
{
name: 'Liabilities',
slug: 'liabilities',
rolesLogicExpression: '1',
roles: [
{
fieldKey: 'root_type',
index: 1,
comparator: 'equals',
value: 'liability',
},
],
columns: DEFAULT_VIEW_COLUMNS,
},
{
name: 'Equity',
slug: 'equity',
rolesLogicExpression: '1',
roles: [
{
fieldKey: 'root_type',
index: 1,
comparator: 'equals',
value: 'equity',
},
],
columns: DEFAULT_VIEW_COLUMNS,
},
{
name: 'Income',
slug: 'income',
rolesLogicExpression: '1',
roles: [
{
fieldKey: 'root_type',
index: 1,
comparator: 'equals',
value: 'income',
},
],
columns: DEFAULT_VIEW_COLUMNS,
},
{
name: 'Expenses',
slug: 'expenses',
rolesLogicExpression: '1',
roles: [
{
fieldKey: 'root_type',
index: 1,
comparator: 'equals',
value: 'expense',
},
],
columns: DEFAULT_VIEW_COLUMNS,
},
];

View File

@@ -0,0 +1,458 @@
/* eslint-disable global-require */
// import { mixin, Model } from 'objection';
import { castArray } from 'lodash';
import DependencyGraph from '@/libs/dependency-graph';
import {
ACCOUNT_TYPES,
getAccountsSupportsMultiCurrency,
} from '@/constants/accounts';
import { TenantModel } from '@/modules/System/models/TenantModel';
// import { SearchableModel } from '@/modules/Search/SearchableMdel';
// import { CustomViewBaseModel } from '@/modules/CustomViews/CustomViewBaseModel';
// import { ModelSettings } from '@/modules/Settings/ModelSettings';
import { AccountTypesUtils } from '@/libs/accounts-utils/AccountTypesUtils';
import { Model } from 'objection';
import { PlaidItem } from '@/modules/BankingPlaid/models/PlaidItem';
import { TenantBaseModel } from '@/modules/System/models/TenantBaseModel';
import { flatToNestedArray } from '@/utils/flat-to-nested-array';
// import AccountSettings from './Account.Settings';
// import { DEFAULT_VIEWS } from '@/modules/Accounts/constants';
// import { buildFilterQuery, buildSortColumnQuery } from '@/lib/ViewRolesBuilder';
// import { flatToNestedArray } from 'utils';
export class Account extends TenantBaseModel {
public name!: string;
public slug!: string;
public code!: string;
public index!: number;
public accountType!: string;
public parentAccountId!: number | null;
public predefined!: boolean;
public currencyCode!: string;
public active!: boolean;
public bankBalance!: number;
public lastFeedsUpdatedAt!: string | Date | null;
public amount!: number;
public plaidItemId!: string;
public plaidAccountId!: string | null;
public isFeedsActive!: boolean;
public isSyncingOwner!: boolean;
public plaidItem!: PlaidItem;
/**
* Table name.
*/
static get tableName() {
return 'accounts';
}
/**
* Timestamps columns.
*/
static get timestamps() {
return ['createdAt', 'updatedAt'];
}
/**
* Virtual attributes.
*/
static get virtualAttributes() {
return [
'accountTypeLabel',
'accountParentType',
'accountRootType',
'accountNormal',
'accountNormalFormatted',
'isBalanceSheetAccount',
'isPLSheet',
];
}
/**
* Account normal.
*/
get accountNormal(): string {
return AccountTypesUtils.getType(this.accountType, 'normal');
}
get accountNormalFormatted(): string {
const paris = {
credit: 'Credit',
debit: 'Debit',
};
return paris[this.accountNormal] || '';
}
/**
* Retrieve account type label.
*/
get accountTypeLabel(): string {
return AccountTypesUtils.getType(this.accountType, 'label');
}
/**
* Retrieve account parent type.
*/
get accountParentType(): string {
return AccountTypesUtils.getType(this.accountType, 'parentType');
}
/**
* Retrieve account root type.
*/
get accountRootType(): string {
return AccountTypesUtils.getType(this.accountType, 'rootType');
}
/**
* Retrieve whether the account is balance sheet account.
*/
get isBalanceSheetAccount(): boolean {
return this.isBalanceSheet();
}
/**
* Retrieve whether the account is profit/loss sheet account.
*/
get isPLSheet(): boolean {
return this.isProfitLossSheet();
}
/**
* Allows to mark model as resourceable to viewable and filterable.
*/
static get resourceable() {
return true;
}
/**
* Model modifiers.
*/
static get modifiers() {
const TABLE_NAME = Account.tableName;
return {
/**
* Inactive/Active mode.
*/
inactiveMode(query, active = false) {
query.where('accounts.active', !active);
},
filterAccounts(query, accountIds) {
if (accountIds.length > 0) {
query.whereIn(`${TABLE_NAME}.id`, accountIds);
}
},
filterAccountTypes(query, typesIds) {
if (typesIds.length > 0) {
query.whereIn('account_types.account_type_id', typesIds);
}
},
viewRolesBuilder(query, conditionals, expression) {
// buildFilterQuery(Account.tableName, conditionals, expression)(query);
},
sortColumnBuilder(query, columnKey, direction) {
// buildSortColumnQuery(Account.tableName, columnKey, direction)(query);
},
/**
* Filter by root type.
*/
filterByRootType(query, rootType) {
const filterTypes = ACCOUNT_TYPES.filter(
(accountType) => accountType.rootType === rootType,
).map((accountType) => accountType.key);
query.whereIn('account_type', filterTypes);
},
/**
* Filter by account normal
*/
filterByAccountNormal(query, accountNormal) {
const filterTypes = ACCOUNT_TYPES.filter(
(accountType) => accountType.normal === accountNormal,
).map((accountType) => accountType.key);
query.whereIn('account_type', filterTypes);
},
/**
* Finds account by the given slug.
* @param {*} query
* @param {*} slug
*/
findBySlug(query, slug) {
query.where('slug', slug).first();
},
/**
*
* @param {*} query
* @param {*} baseCyrrency
*/
preventMutateBaseCurrency(query) {
const accountsTypes = getAccountsSupportsMultiCurrency();
const accountsTypesKeys = accountsTypes.map((type) => type.key);
query
.whereIn('accountType', accountsTypesKeys)
.where('seededAt', null)
.first();
},
};
}
/**
* Relationship mapping.
*/
static get relationMappings() {
const { AccountTransaction } = require('./AccountTransaction.model');
const { Item } = require('../../Items/models/Item');
// const InventoryAdjustment = require('models/InventoryAdjustment');
// const ManualJournalEntry = require('models/ManualJournalEntry');
// const Expense = require('models/Expense');
// const ExpenseEntry = require('models/ExpenseCategory');
// const ItemEntry = require('models/ItemEntry');
// const UncategorizedTransaction = require('models/UncategorizedCashflowTransaction');
const { PlaidItem } = require('../../BankingPlaid/models/PlaidItem');
return {
/**
* Account model may has many transactions.
*/
transactions: {
relation: Model.HasManyRelation,
modelClass: AccountTransaction,
join: {
from: 'accounts.id',
to: 'accounts_transactions.accountId',
},
},
/**
* Account may has many items as cost account.
*/
itemsCostAccount: {
relation: Model.HasManyRelation,
modelClass: Item,
join: {
from: 'accounts.id',
to: 'items.costAccountId',
},
},
/**
* Account may has many items as sell account.
*/
itemsSellAccount: {
relation: Model.HasManyRelation,
modelClass: Item,
join: {
from: 'accounts.id',
to: 'items.sellAccountId',
},
},
// /**
// *
// */
// inventoryAdjustments: {
// relation: Model.HasManyRelation,
// modelClass: InventoryAdjustment.default,
// join: {
// from: 'accounts.id',
// to: 'inventory_adjustments.adjustmentAccountId',
// },
// },
// /**
// *
// */
// manualJournalEntries: {
// relation: Model.HasManyRelation,
// modelClass: ManualJournalEntry.default,
// join: {
// from: 'accounts.id',
// to: 'manual_journals_entries.accountId',
// },
// },
// /**
// *
// */
// expensePayments: {
// relation: Model.HasManyRelation,
// modelClass: Expense.default,
// join: {
// from: 'accounts.id',
// to: 'expenses_transactions.paymentAccountId',
// },
// },
// /**
// *
// */
// expenseEntries: {
// relation: Model.HasManyRelation,
// modelClass: ExpenseEntry.default,
// join: {
// from: 'accounts.id',
// to: 'expense_transaction_categories.expenseAccountId',
// },
// },
// /**
// *
// */
// entriesCostAccount: {
// relation: Model.HasManyRelation,
// modelClass: ItemEntry.default,
// join: {
// from: 'accounts.id',
// to: 'items_entries.costAccountId',
// },
// },
// /**
// *
// */
// entriesSellAccount: {
// relation: Model.HasManyRelation,
// modelClass: ItemEntry.default,
// join: {
// from: 'accounts.id',
// to: 'items_entries.sellAccountId',
// },
// },
// /**
// * Associated uncategorized transactions.
// */
// uncategorizedTransactions: {
// relation: Model.HasManyRelation,
// modelClass: UncategorizedTransaction.default,
// join: {
// from: 'accounts.id',
// to: 'uncategorized_cashflow_transactions.accountId',
// },
// filter: (query) => {
// query.where('categorized', false);
// },
// },
/**
* Account model may belongs to a Plaid item.
*/
plaidItem: {
relation: Model.BelongsToOneRelation,
modelClass: PlaidItem,
join: {
from: 'accounts.plaidItemId',
to: 'plaid_items.plaidItemId',
},
},
};
}
/**
* Detarmines whether the given type equals the account type.
* @param {string} accountType
* @return {boolean}
*/
isAccountType(accountType) {
const types = castArray(accountType);
return types.indexOf(this.accountType) !== -1;
}
/**
* Detarmines whether the given root type equals the account type.
* @param {string} rootType
* @return {boolean}
*/
isRootType(rootType) {
return AccountTypesUtils.isRootTypeEqualsKey(this.accountType, rootType);
}
/**
* Detarmine whether the given parent type equals the account type.
* @param {string} parentType
* @return {boolean}
*/
isParentType(parentType) {
return AccountTypesUtils.isParentTypeEqualsKey(
this.accountType,
parentType,
);
}
/**
* Detarmines whether the account is balance sheet account.
* @return {boolean}
*/
isBalanceSheet() {
return AccountTypesUtils.isTypeBalanceSheet(this.accountType);
}
/**
* Detarmines whether the account is profit/loss account.
* @return {boolean}
*/
isProfitLossSheet() {
return AccountTypesUtils.isTypePLSheet(this.accountType);
}
/**
* Detarmines whether the account is income statement account
* @return {boolean}
*/
isIncomeSheet() {
return this.isProfitLossSheet();
}
/**
* Converts flatten accounts list to nested array.
* @param {Array} accounts
* @param {Object} options
*/
static toNestedArray(accounts, options = { children: 'children' }) {
return flatToNestedArray(accounts, {
id: 'id',
parentId: 'parentAccountId',
});
}
/**
* Transformes the accounts list to depenedency graph structure.
* @param {IAccount[]} accounts
*/
static toDependencyGraph(accounts) {
return DependencyGraph.fromArray(accounts, {
itemId: 'id',
parentItemId: 'parentAccountId',
});
}
/**
* Model settings.
*/
// static get meta() {
// return AccountSettings;
// }
/**
* Retrieve the default custom views, roles and columns.
*/
// static get defaultViews() {
// return DEFAULT_VIEWS;
// }
/**
* Model search roles.
*/
static get searchRoles() {
return [
{ condition: 'or', fieldKey: 'name', comparator: 'contains' },
{ condition: 'or', fieldKey: 'code', comparator: 'like' },
];
}
/**
* Prevents mutate base currency since the model is not empty.
*/
static get preventMutateBaseCurrency() {
return true;
}
}

View File

@@ -0,0 +1,268 @@
import { Model, raw } from 'objection';
import * as moment from 'moment';
import { unitOfTime } from 'moment';
import { isEmpty, castArray } from 'lodash';
import { BaseModel } from '@/models/Model';
import { Account } from './Account.model';
// import { getTransactionTypeLabel } from '@/utils/transactions-types';
export class AccountTransaction extends BaseModel {
public readonly referenceType: string;
public readonly referenceId: number;
public readonly accountId: number;
public readonly contactId: number;
public readonly contactType: string;
public readonly credit: number;
public readonly debit: number;
public readonly exchangeRate: number;
public readonly taxRate: number;
public readonly date: Date | string;
public readonly transactionType: string;
public readonly currencyCode: string;
public readonly referenceTypeFormatted: string;
public readonly transactionNumber!: string;
public readonly referenceNumber!: string;
public readonly note!: string;
public readonly index!: number;
public readonly indexGroup!: number;
public readonly taxRateId!: number;
public readonly branchId!: number;
public readonly userId!: number;
public readonly itemId!: number;
public readonly projectId!: number;
public readonly account: Account;
/**
* Table name
*/
static get tableName() {
return 'accounts_transactions';
}
/**
* Timestamps columns.
*/
get timestamps() {
return ['createdAt'];
}
/**
* Virtual attributes.
*/
static get virtualAttributes() {
return ['referenceTypeFormatted', 'creditLocal', 'debitLocal'];
}
/**
* Retrieves the credit amount in base currency.
* @return {number}
*/
get creditLocal() {
return this.credit * this.exchangeRate;
}
/**
* Retrieves the debit amount in base currency.
* @return {number}
*/
get debitLocal() {
return this.debit * this.exchangeRate;
}
// /**
// * Retrieve formatted reference type.
// * @return {string}
// */
// get referenceTypeFormatted() {
// return getTransactionTypeLabel(this.referenceType, this.transactionType);
// }
/**
* Model modifiers.
*/
static get modifiers() {
return {
/**
* Filters accounts by the given ids.
* @param {Query} query
* @param {number[]} accountsIds
*/
filterAccounts(query, accountsIds) {
if (Array.isArray(accountsIds) && accountsIds.length > 0) {
query.whereIn('account_id', accountsIds);
}
},
/**
* Filters the transaction types.
* @param {Query} query
* @param {string[]} types
*/
filterTransactionTypes(query, types) {
if (Array.isArray(types) && types.length > 0) {
query.whereIn('reference_type', types);
} else if (typeof types === 'string') {
query.where('reference_type', types);
}
},
/**
* Filters the date range.
* @param {Query} query
* @param {moment.MomentInput} startDate
* @param {moment.MomentInput} endDate
* @param {unitOfTime.StartOf} type
*/
filterDateRange(
query,
startDate: moment.MomentInput,
endDate: moment.MomentInput,
type: unitOfTime.StartOf = 'day',
) {
const dateFormat = 'YYYY-MM-DD';
const fromDate = moment(startDate).startOf(type).format(dateFormat);
const toDate = moment(endDate).endOf(type).format(dateFormat);
if (startDate) {
query.where('date', '>=', fromDate);
}
if (endDate) {
query.where('date', '<=', toDate);
}
},
/**
* Filters the amount range.
* @param {Query} query
* @param {number} fromAmount
* @param {number} toAmount
*/
filterAmountRange(query, fromAmount, toAmount) {
if (fromAmount) {
query.andWhere((q) => {
q.where('credit', '>=', fromAmount);
q.orWhere('debit', '>=', fromAmount);
});
}
if (toAmount) {
query.andWhere((q) => {
q.where('credit', '<=', toAmount);
q.orWhere('debit', '<=', toAmount);
});
}
},
sumationCreditDebit(query) {
query.select(['accountId']);
query.sum('credit as credit');
query.sum('debit as debit');
query.groupBy('account_id');
},
filterContactType(query, contactType) {
query.where('contact_type', contactType);
},
filterContactIds(query, contactIds) {
query.whereIn('contact_id', contactIds);
},
openingBalance(query, fromDate) {
query.modify('filterDateRange', null, fromDate);
query.modify('sumationCreditDebit');
},
closingBalance(query, toDate) {
query.modify('filterDateRange', null, toDate);
query.modify('sumationCreditDebit');
},
contactsOpeningBalance(
query,
openingDate,
receivableAccounts,
customersIds,
) {
// Filter by date.
query.modify('filterDateRange', null, openingDate);
// Filter by customers.
query.whereNot('contactId', null);
query.whereIn('accountId', castArray(receivableAccounts));
if (!isEmpty(customersIds)) {
query.whereIn('contactId', castArray(customersIds));
}
// Group by the contact transactions.
query.groupBy('contactId');
query.sum('credit as credit');
query.sum('debit as debit');
query.select('contactId');
},
creditDebitSummation(query) {
query.sum('credit as credit');
query.sum('debit as debit');
},
groupByDateFormat(query, groupType = 'month') {
const groupBy = {
day: '%Y-%m-%d',
month: '%Y-%m',
year: '%Y',
};
const dateFormat = groupBy[groupType];
query.select(raw(`DATE_FORMAT(DATE, '${dateFormat}')`).as('date'));
query.groupByRaw(`DATE_FORMAT(DATE, '${dateFormat}')`);
},
filterByBranches(query, branchesIds) {
const formattedBranchesIds = castArray(branchesIds);
query.whereIn('branchId', formattedBranchesIds);
},
filterByProjects(query, projectsIds) {
const formattedProjectsIds = castArray(projectsIds);
query.whereIn('projectId', formattedProjectsIds);
},
filterByReference(query, referenceId: number, referenceType: string) {
query.where('reference_id', referenceId);
query.where('reference_type', referenceType);
},
};
}
/**
* Relationship mapping.
*/
static get relationMappings() {
const { Account } = require('./Account.model');
const { Contact } = require('../../Contacts/models/Contact');
return {
account: {
relation: Model.BelongsToOneRelation,
modelClass: Account,
join: {
from: 'accounts_transactions.accountId',
to: 'accounts.id',
},
},
contact: {
relation: Model.BelongsToOneRelation,
modelClass: Contact,
join: {
from: 'accounts_transactions.contactId',
to: 'contacts.id',
},
},
};
}
/**
* Prevents mutate base currency since the model is not empty.
*/
static get preventMutateBaseCurrency() {
return true;
}
}

View File

@@ -0,0 +1,390 @@
import { Knex } from 'knex';
import { Inject, Injectable, Scope } from '@nestjs/common';
import { TenantRepository } from '@/common/repository/TenantRepository';
import { TENANCY_DB_CONNECTION } from '@/modules/Tenancy/TenancyDB/TenancyDB.constants';
import { Account } from '../models/Account.model';
import { I18nService } from 'nestjs-i18n';
import { TenancyContext } from '@/modules/Tenancy/TenancyContext.service';
import {
DiscountExpenseAccount,
OtherChargesAccount,
OtherExpensesAccount,
PrepardExpenses,
PurchaseDiscountAccount,
StripeClearingAccount,
TaxPayableAccount,
UnearnedRevenueAccount,
} from '../Accounts.constants';
@Injectable({ scope: Scope.REQUEST })
export class AccountRepository extends TenantRepository {
constructor(
private readonly i18n: I18nService,
private readonly tenancyContext: TenancyContext,
@Inject(TENANCY_DB_CONNECTION)
private readonly tenantDBKnex: () => Knex,
) {
super();
}
/**
* Gets the repository's model.
*/
get model(): typeof Account {
return Account.bindKnex(this.tenantDBKnex());
}
/**
* Retrieve accounts dependency graph.
* @param {string} withRelation
* @param {Knex.Transaction} trx
* @returns {}
*/
public async getDependencyGraph(
withRelation?: string,
trx?: Knex.Transaction,
) {
const accounts = await this.all(withRelation, trx);
return this.model.toDependencyGraph(accounts);
}
/**
* Retrieve account by slug.
* @param {string} slug
* @return {Promise<IAccount>}
*/
public findBySlug(slug: string) {
return this.findOne({ slug });
}
// /**
// * Changes account balance.
// * @param {number} accountId
// * @param {number} amount
// * @return {Promise<void>}
// */
// async balanceChange(accountId: number, amount: number): Promise<void> {
// const method: string = amount < 0 ? 'decrement' : 'increment';
// await this.model.query().where('id', accountId)[method]('amount', amount);
// this.flushCache();
// }
/**
* Activate user by the given id.
* @param {number} userId - User id.
* @return {Promise<void>}
*/
activateById(userId: number): Promise<number> {
return super.update({ active: 1 }, { id: userId });
}
/**
* Inactivate user by the given id.
* @param {number} userId - User id.
* @return {Promise<void>}
*/
inactivateById(userId: number): Promise<number> {
return super.update({ active: 0 }, { id: userId });
}
/**
* Activate user by the given id.
* @param {number} userId - User id.
* @return {Promise<void>}
*/
async activateByIds(userIds: number[], trx): Promise<number> {
const results = await this.model
.query(trx)
.whereIn('id', userIds)
.patch({ active: true });
return results;
}
/**
* Inactivate user by the given id.
* @param {number} userId - User id.
* @return {Promise<void>}
*/
async inactivateByIds(userIds: number[], trx): Promise<number> {
const results = await this.model
.query(trx)
.whereIn('id', userIds)
.patch({ active: false });
return results;
}
/**
*
* @param {string} currencyCode
* @param extraAttrs
* @param trx
* @returns
*/
findOrCreateAccountReceivable = async (
currencyCode: string = '',
extraAttrs = {},
trx?: Knex.Transaction,
) => {
let result = await this.model
.query(trx)
.onBuild((query) => {
if (currencyCode) {
query.where('currencyCode', currencyCode);
}
query.where('accountType', 'accounts-receivable');
})
.first();
if (!result) {
result = await this.model.query(trx).insertAndFetch({
name: this.i18n.t('account.accounts_receivable.currency', {
args: { currency: currencyCode },
}),
accountType: 'accounts-receivable',
currencyCode,
active: true,
...extraAttrs,
});
}
return result;
};
/**
* Find or create tax payable account.
* @param {Record<string, string>}extraAttrs
* @param {Knex.Transaction} trx
* @returns
*/
async findOrCreateTaxPayable(
extraAttrs: Record<string, string> = {},
trx?: Knex.Transaction,
) {
let result = await this.model
.query(trx)
.findOne({ slug: TaxPayableAccount.slug, ...extraAttrs });
if (!result) {
result = await this.model.query(trx).insertAndFetch({
...TaxPayableAccount,
...extraAttrs,
});
}
return result;
}
findOrCreateAccountsPayable = async (
currencyCode: string = '',
extraAttrs = {},
trx?: Knex.Transaction,
) => {
let result = await this.model
.query(trx)
.onBuild((query) => {
if (currencyCode) {
query.where('currencyCode', currencyCode);
}
query.where('accountType', 'accounts-payable');
})
.first();
if (!result) {
result = await this.model.query(trx).insertAndFetch({
name: this.i18n.t('account.accounts_payable.currency', {
args: { currency: currencyCode },
}),
accountType: 'accounts-payable',
currencyCode,
active: true,
...extraAttrs,
});
}
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,
) {
const tenantMeta = await this.tenancyContext.getTenantMetadata();
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,
) {
const tenantMeta = await this.tenancyContext.getTenantMetadata();
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;
}
/**
* Finds or creates the stripe clearing account.
* @param {Record<string, string>} extraAttrs
* @param {Knex.Transaction} trx
* @returns
*/
public async findOrCreateStripeClearing(
extraAttrs: Record<string, string> = {},
trx?: Knex.Transaction,
) {
const tenantMeta = await this.tenancyContext.getTenantMetadata();
const _extraAttrs = {
currencyCode: tenantMeta.baseCurrency,
...extraAttrs,
};
let result = await this.model
.query(trx)
.findOne({ slug: StripeClearingAccount.slug, ..._extraAttrs });
if (!result) {
result = await this.model.query(trx).insertAndFetch({
...StripeClearingAccount,
..._extraAttrs,
});
}
return result;
}
/**
* Finds or creates the discount expense account.
* @param {Record<string, string>} extraAttrs
* @param {Knex.Transaction} trx
* @returns
*/
public async findOrCreateDiscountAccount(
extraAttrs: Record<string, string> = {},
trx?: Knex.Transaction,
) {
const tenantMeta = await this.tenancyContext.getTenantMetadata();
const _extraAttrs = {
currencyCode: tenantMeta.baseCurrency,
...extraAttrs,
};
let result = await this.model
.query(trx)
.findOne({ slug: DiscountExpenseAccount.slug, ..._extraAttrs });
if (!result) {
result = await this.model.query(trx).insertAndFetch({
...DiscountExpenseAccount,
..._extraAttrs,
});
}
return result;
}
public async findOrCreatePurchaseDiscountAccount(
extraAttrs: Record<string, string> = {},
trx?: Knex.Transaction,
) {
const tenantMeta = await this.tenancyContext.getTenantMetadata();
const _extraAttrs = {
currencyCode: tenantMeta.baseCurrency,
...extraAttrs,
};
let result = await this.model
.query(trx)
.findOne({ slug: PurchaseDiscountAccount.slug, ..._extraAttrs });
if (!result) {
result = await this.model.query(trx).insertAndFetch({
...PurchaseDiscountAccount,
..._extraAttrs,
});
}
return result;
}
public async findOrCreateOtherChargesAccount(
extraAttrs: Record<string, string> = {},
trx?: Knex.Transaction,
) {
const tenantMeta = await this.tenancyContext.getTenantMetadata();
const _extraAttrs = {
currencyCode: tenantMeta.baseCurrency,
...extraAttrs,
};
let result = await this.model
.query(trx)
.findOne({ slug: OtherChargesAccount.slug, ..._extraAttrs });
if (!result) {
result = await this.model.query(trx).insertAndFetch({
...OtherChargesAccount,
..._extraAttrs,
});
}
return result;
}
public async findOrCreateOtherExpensesAccount(
extraAttrs: Record<string, string> = {},
trx?: Knex.Transaction,
) {
const tenantMeta = await this.tenancyContext.getTenantMetadata();
const _extraAttrs = {
currencyCode: tenantMeta.baseCurrency,
...extraAttrs,
};
let result = await this.model
.query(trx)
.findOne({ slug: OtherExpensesAccount.slug, ..._extraAttrs });
if (!result) {
result = await this.model.query(trx).insertAndFetch({
...OtherExpensesAccount,
..._extraAttrs,
});
}
return result;
}
}

View File

@@ -0,0 +1,34 @@
// import { Service, Inject } from 'typedi';
// import events from '@/subscribers/events';
// import { MutateBaseCurrencyAccounts } from '../MutateBaseCurrencyAccounts';
// @Service()
// export class MutateBaseCurrencyAccountsSubscriber {
// @Inject()
// public mutateBaseCurrencyAccounts: MutateBaseCurrencyAccounts;
// /**
// * Attaches the events with handles.
// * @param bus
// */
// attach(bus) {
// bus.subscribe(
// events.organization.baseCurrencyUpdated,
// this.updateAccountsCurrencyOnBaseCurrencyMutated
// );
// }
// /**
// * Updates the all accounts currency once the base currency
// * of the organization is mutated.
// */
// private updateAccountsCurrencyOnBaseCurrencyMutated = async ({
// tenantId,
// organizationDTO,
// }) => {
// await this.mutateBaseCurrencyAccounts.mutateAllAccountsCurrency(
// tenantId,
// organizationDTO.baseCurrency
// );
// };
// }

View File

@@ -0,0 +1,101 @@
import { get } from 'lodash';
import { ACCOUNT_TYPES } from '../Accounts.constants';
export class AccountTypesUtils {
/**
* Retrieve account types list.
*/
static getList() {
return ACCOUNT_TYPES;
}
/**
* Retrieve accounts types by the given root type.
* @param {string} rootType -
* @return {string}
*/
static getTypesByRootType(rootType: string) {
return ACCOUNT_TYPES.filter((type) => type.rootType === rootType);
}
/**
* Retrieve account type by the given account type key.
* @param {string} key
* @param {string} accessor
*/
static getType(key: string, accessor?: string) {
const type = ACCOUNT_TYPES.find((type) => type.key === key);
if (accessor) {
return get(type, accessor);
}
return type;
}
/**
* Retrieve accounts types by the parent account type.
* @param {string} parentType
*/
static getTypesByParentType(parentType: string) {
return ACCOUNT_TYPES.filter((type) => type.parentType === parentType);
}
/**
* Retrieve accounts types by the given account normal.
* @param {string} normal
*/
static getTypesByNormal(normal: string) {
return ACCOUNT_TYPES.filter((type) => type.normal === normal);
}
/**
* Detarmines whether the root type equals the account type.
* @param {string} key
* @param {string} rootType
*/
static isRootTypeEqualsKey(key: string, rootType: string): boolean {
return ACCOUNT_TYPES.some((type) => {
const isType = type.key === key;
const isRootType = type.rootType === rootType;
return isType && isRootType;
});
}
/**
* Detarmines whether the parent account type equals the account type key.
* @param {string} key - Account type key.
* @param {string} parentType - Account parent type.
*/
static isParentTypeEqualsKey(key: string, parentType: string): boolean {
return ACCOUNT_TYPES.some((type) => {
const isType = type.key === key;
const isParentType = type.parentType === parentType;
return isType && isParentType;
});
}
/**
* Detarmines whether account type has balance sheet.
* @param {string} key - Account type key.
*
*/
static isTypeBalanceSheet(key: string): boolean {
return ACCOUNT_TYPES.some((type) => {
const isType = type.key === key;
return isType && type.balanceSheet;
});
}
/**
* Detarmines whether account type has profit/loss sheet.
* @param {string} key - Account type key.
*/
static isTypePLSheet(key: string): boolean {
return ACCOUNT_TYPES.some((type) => {
const isType = type.key === key;
return isType && type.incomeSheet;
});
}
}

View File

@@ -0,0 +1,22 @@
import { Test, TestingModule } from '@nestjs/testing';
import { AppController } from './App.controller';
import { AppService } from './App.service';
describe('AppController', () => {
let appController: AppController;
beforeEach(async () => {
const app: TestingModule = await Test.createTestingModule({
controllers: [AppController],
providers: [AppService],
}).compile();
appController = app.get<AppController>(AppController);
});
describe('root', () => {
it('should return "Hello World!"', () => {
expect(appController.getHello()).toBe('Hello World!');
});
});
});

View File

@@ -0,0 +1,12 @@
import { Controller, Get } from '@nestjs/common';
import { AppService } from './App.service';
@Controller()
export class AppController {
constructor(private readonly appService: AppService) {}
@Get()
getHello(): string {
return this.appService.getHello();
}
}

View File

@@ -0,0 +1,220 @@
import { MiddlewareConsumer, Module, RequestMethod } from '@nestjs/common';
import { ConfigModule, ConfigService } from '@nestjs/config';
import { EventEmitterModule } from '@nestjs/event-emitter';
import { APP_INTERCEPTOR } from '@nestjs/core';
import { join } from 'path';
import { RedisModule } from '@liaoliaots/nestjs-redis';
import {
AcceptLanguageResolver,
CookieResolver,
HeaderResolver,
I18nModule,
QueryResolver,
} from 'nestjs-i18n';
import { BullModule } from '@nestjs/bullmq';
import { PassportModule } from '@nestjs/passport';
import { ClsModule, ClsService } from 'nestjs-cls';
import { AppController } from './App.controller';
import { AppService } from './App.service';
import { ItemsModule } from '../Items/items.module';
import { config } from '../../common/config';
import { SystemDatabaseModule } from '../System/SystemDB/SystemDB.module';
import { SystemModelsModule } from '../System/SystemModels/SystemModels.module';
import { TenancyDatabaseModule } from '../Tenancy/TenancyDB/TenancyDB.module';
import { TenancyModelsModule } from '../Tenancy/TenancyModels/Tenancy.module';
import { LoggerMiddleware } from '@/middleware/logger.middleware';
import { ExcludeNullInterceptor } from '@/interceptors/ExcludeNull.interceptor';
import { UserIpInterceptor } from '@/interceptors/user-ip.interceptor';
import { TransformerModule } from '../Transformer/Transformer.module';
import { AccountsModule } from '../Accounts/Accounts.module';
import { ExpensesModule } from '../Expenses/Expenses.module';
import { ItemCategoryModule } from '../ItemCategories/ItemCategory.module';
import { TaxRatesModule } from '../TaxRates/TaxRate.module';
import { PdfTemplatesModule } from '../PdfTemplate/PdfTemplates.module';
import { BranchesModule } from '../Branches/Branches.module';
import { WarehousesModule } from '../Warehouses/Warehouses.module';
import { SerializeInterceptor } from '@/common/interceptors/serialize.interceptor';
import { ChromiumlyTenancyModule } from '../ChromiumlyTenancy/ChromiumlyTenancy.module';
import { CustomersModule } from '../Customers/Customers.module';
import { VendorsModule } from '../Vendors/Vendors.module';
import { SaleEstimatesModule } from '../SaleEstimates/SaleEstimates.module';
import { BillsModule } from '../Bills/Bills.module';
import { SaleInvoicesModule } from '../SaleInvoices/SaleInvoices.module';
import { SaleReceiptsModule } from '../SaleReceipts/SaleReceipts.module';
import { ManualJournalsModule } from '../ManualJournals/ManualJournals.module';
import { CreditNotesModule } from '../CreditNotes/CreditNotes.module';
import { VendorCreditsModule } from '../VendorCredit/VendorCredits.module';
import { VendorCreditApplyBillsModule } from '../VendorCreditsApplyBills/VendorCreditApplyBills.module';
import { VendorCreditsRefundModule } from '../VendorCreditsRefund/VendorCreditsRefund.module';
import { CreditNoteRefundsModule } from '../CreditNoteRefunds/CreditNoteRefunds.module';
import { BillPaymentsModule } from '../BillPayments/BillPayments.module';
import { PaymentsReceivedModule } from '../PaymentReceived/PaymentsReceived.module';
import { LedgerModule } from '../Ledger/Ledger.module';
import { BankRulesModule } from '../BankRules/BankRules.module';
import { BankAccountsModule } from '../BankingAccounts/BankAccounts.module';
import { BankingTransactionsExcludeModule } from '../BankingTransactionsExclude/BankingTransactionsExclude.module';
import { BankingTransactionsRegonizeModule } from '../BankingTranasctionsRegonize/BankingTransactionsRegonize.module';
import { BankingMatchingModule } from '../BankingMatching/BankingMatching.module';
import { BankingTransactionsModule } from '../BankingTransactions/BankingTransactions.module';
import { TransactionsLockingModule } from '../TransactionsLocking/TransactionsLocking.module';
import { SettingsModule } from '../Settings/Settings.module';
import { InventoryAdjustmentsModule } from '../InventoryAdjutments/InventoryAdjustments.module';
import { PostHogModule } from '../EventsTracker/postHog.module';
import { EventTrackerModule } from '../EventsTracker/EventTracker.module';
import { MailModule } from '../Mail/Mail.module';
import { FinancialStatementsModule } from '../FinancialStatements/FinancialStatements.module';
import { StripePaymentModule } from '../StripePayment/StripePayment.module';
import { FeaturesModule } from '../Features/Features.module';
import { InventoryCostModule } from '../InventoryCost/InventoryCost.module';
import { WarehousesTransfersModule } from '../WarehousesTransfers/WarehouseTransfers.module';
import { DashboardModule } from '../Dashboard/Dashboard.module';
import { PaymentLinksModule } from '../PaymentLinks/PaymentLinks.module';
import { RolesModule } from '../Roles/Roles.module';
import { SubscriptionModule } from '../Subscription/Subscription.module';
import { OrganizationModule } from '../Organization/Organization.module';
import { TenantDBManagerModule } from '../TenantDBManager/TenantDBManager.module';
import { PaymentServicesModule } from '../PaymentServices/PaymentServices.module';
import { AuthModule } from '../Auth/Auth.module';
import { TenancyModule } from '../Tenancy/Tenancy.module';
import { LoopsModule } from '../Loops/Loops.module';
import { AttachmentsModule } from '../Attachments/Attachment.module';
import { S3Module } from '../S3/S3.module';
@Module({
imports: [
ConfigModule.forRoot({
envFilePath: '.env',
load: config,
isGlobal: true,
}),
SystemDatabaseModule,
SystemModelsModule,
EventEmitterModule.forRoot(),
I18nModule.forRootAsync({
useFactory: () => ({
fallbackLanguage: 'en',
loaderOptions: {
path: join(__dirname, '/../../i18n/'),
watch: true,
},
}),
resolvers: [
new QueryResolver(),
new HeaderResolver(),
new CookieResolver(),
AcceptLanguageResolver,
],
}),
PassportModule,
BullModule.forRootAsync({
imports: [ConfigModule],
useFactory: async (configService: ConfigService) => ({
connection: {
host: configService.get('QUEUE_HOST'),
port: configService.get('QUEUE_PORT'),
},
}),
inject: [ConfigService],
}),
ClsModule.forRoot({
global: true,
middleware: {
mount: true,
setup: (cls: ClsService, req: Request, res: Response) => {
cls.set('organizationId', req.headers['organization-id']);
},
generateId: true,
saveReq: true,
},
}),
RedisModule.forRootAsync({
imports: [ConfigModule],
useFactory: (configService: ConfigService) => ({
config: {
host: configService.get('redis.host') || 'localhost',
port: configService.get('redis.port') || 6379,
},
}),
inject: [ConfigService],
}),
TenancyDatabaseModule,
TenancyModelsModule,
TenancyModule,
ChromiumlyTenancyModule,
TransformerModule,
MailModule,
AuthModule,
ItemsModule,
ItemCategoryModule,
AccountsModule,
ExpensesModule,
TaxRatesModule,
PdfTemplatesModule,
BranchesModule,
WarehousesModule,
WarehousesTransfersModule,
CustomersModule,
VendorsModule,
SaleInvoicesModule,
SaleEstimatesModule,
SaleReceiptsModule,
BillsModule,
ManualJournalsModule,
CreditNotesModule,
VendorCreditsModule,
VendorCreditApplyBillsModule,
VendorCreditsRefundModule,
CreditNoteRefundsModule,
BillPaymentsModule,
PaymentsReceivedModule,
LedgerModule,
BankAccountsModule,
BankRulesModule,
BankingTransactionsModule,
BankingTransactionsExcludeModule,
BankingTransactionsRegonizeModule,
BankingMatchingModule,
TransactionsLockingModule,
SettingsModule,
FeaturesModule,
InventoryAdjustmentsModule,
InventoryCostModule,
PostHogModule,
EventTrackerModule,
FinancialStatementsModule,
StripePaymentModule,
DashboardModule,
PaymentLinksModule,
RolesModule,
SubscriptionModule,
OrganizationModule,
TenantDBManagerModule,
PaymentServicesModule,
LoopsModule,
AttachmentsModule,
S3Module
],
controllers: [AppController],
providers: [
{
provide: APP_INTERCEPTOR,
useClass: SerializeInterceptor,
},
{
provide: APP_INTERCEPTOR,
useClass: UserIpInterceptor,
},
{
provide: APP_INTERCEPTOR,
useClass: ExcludeNullInterceptor,
},
AppService,
],
})
export class AppModule {
configure(consumer: MiddlewareConsumer) {
consumer
.apply(LoggerMiddleware)
.forRoutes({ path: '*', method: RequestMethod.ALL });
}
}

View File

@@ -0,0 +1,10 @@
import { Injectable } from '@nestjs/common';
@Injectable()
export class AppService {
constructor() {}
getHello(): string {
return '';
}
}

View File

@@ -0,0 +1,80 @@
import { Module } from "@nestjs/common";
import * as multerS3 from 'multer-s3';
import { S3_CLIENT, S3Module } from "../S3/S3.module";
import { DeleteAttachment } from "./DeleteAttachment";
import { GetAttachment } from "./GetAttachment";
import { getAttachmentPresignedUrl } from "./GetAttachmentPresignedUrl";
import { LinkAttachment } from "./LinkAttachment";
import { UnlinkAttachment } from "./UnlinkAttachment";
import { ValidateAttachments } from "./ValidateAttachments";
import { AttachmentsOnBillPayments } from "./events/AttachmentsOnPaymentsMade";
import { AttachmentsOnBills } from "./events/AttachmentsOnBills";
import { AttachmentsOnCreditNote } from "./events/AttachmentsOnCreditNote";
import { AttachmentsOnExpenses } from "./events/AttachmentsOnExpenses";
import { AttachmentsOnPaymentsReceived } from "./events/AttachmentsOnPaymentsReceived";
import { AttachmentsOnManualJournals } from "./events/AttachmentsOnManualJournals";
import { AttachmentsOnVendorCredits } from "./events/AttachmentsOnVendorCredits";
import { AttachmentsOnSaleInvoiceCreated } from "./events/AttachmentsOnSaleInvoice";
import { AttachmentsController } from "./Attachments.controller";
import { RegisterTenancyModel } from "../Tenancy/TenancyModels/Tenancy.module";
import { DocumentModel } from "./models/Document.model";
import { DocumentLinkModel } from "./models/DocumentLink.model";
import { AttachmentsApplication } from "./AttachmentsApplication";
import { UploadDocument } from "./UploadDocument";
import { AttachmentUploadPipeline } from "./S3UploadPipeline";
import { MULTER_MODULE_OPTIONS } from "@/common/constants/files.constants";
import { ConfigService } from "@nestjs/config";
import { S3Client } from "@aws-sdk/client-s3";
const models = [
RegisterTenancyModel(DocumentModel),
RegisterTenancyModel(DocumentLinkModel),
];
@Module({
imports: [S3Module, ...models],
controllers: [AttachmentsController],
providers: [
DeleteAttachment,
GetAttachment,
getAttachmentPresignedUrl,
LinkAttachment,
UnlinkAttachment,
ValidateAttachments,
AttachmentsOnBillPayments,
AttachmentsOnBills,
AttachmentsOnCreditNote,
AttachmentsOnExpenses,
AttachmentsOnPaymentsReceived,
AttachmentsOnManualJournals,
AttachmentsOnVendorCredits,
AttachmentsOnSaleInvoiceCreated,
AttachmentsApplication,
UploadDocument,
AttachmentUploadPipeline,
{
provide: MULTER_MODULE_OPTIONS,
inject: [ConfigService, S3_CLIENT],
useFactory: (configService: ConfigService, s3: S3Client) => ({
storage: multerS3({
s3,
bucket: configService.get('s3.bucket'),
contentType: multerS3.AUTO_CONTENT_TYPE,
metadata: function (req, file, cb) {
cb(null, { fieldName: file.fieldname });
},
key: function (req, file, cb) {
cb(null, Date.now().toString());
},
acl: function(req, file, cb) {
// Conditionally set file to public or private based on isPublic flag
const aclValue = true ? 'public-read' : 'private';
// Set ACL based on the isPublic flag
cb(null, aclValue);
}
}),
})
}
]
})
export class AttachmentsModule {}

View File

@@ -0,0 +1,19 @@
import { Transformer } from "../Transformer/Transformer";
export class AttachmentTransformer extends Transformer {
/**
* Exclude attributes.
* @returns {string[]}
*/
public excludeAttributes = (): string[] => {
return ['id', 'createdAt'];
};
/**
* Includeded attributes.
* @returns {string[]}
*/
public includeAttributes = (): string[] => {
return [];
};
}

View File

@@ -0,0 +1,19 @@
import { Transformer } from "../Transformer/Transformer";
export class AttachmentTransformer extends Transformer{
/**
* Exclude attributes.
* @returns {string[]}
*/
public excludeAttributes = (): string[] => {
return ['id', 'createdAt'];
};
/**
* Includeded attributes.
* @returns {string[]}
*/
public includeAttributes = (): string[] => {
return [];
};
}

View File

@@ -0,0 +1,202 @@
import mime from 'mime-types';
import { Response, NextFunction, Request } from 'express';
import {
ApiBody,
ApiConsumes,
ApiOperation,
ApiParam,
ApiResponse,
ApiTags,
} from '@nestjs/swagger';
import {
Body,
Controller,
Delete,
Get,
Param,
Post,
Res,
UnauthorizedException,
UploadedFile,
UseInterceptors,
} from '@nestjs/common';
import {
LinkAttachmentDto,
UnlinkAttachmentDto,
UploadAttachmentDto,
} from './dtos/Attachment.dto';
import { AttachmentsApplication } from './AttachmentsApplication';
import { AttachmentUploadPipeline } from './S3UploadPipeline';
import { FileInterceptor } from '@/common/interceptors/file.interceptor';
import { ConfigService } from '@nestjs/config';
@ApiTags('Attachments')
@Controller('/attachments')
export class AttachmentsController {
/**
* @param {AttachmentsApplication} attachmentsApplication - Attachments application.
* @param uploadPipelineService
*/
constructor(
private readonly attachmentsApplication: AttachmentsApplication,
private readonly uploadPipelineService: AttachmentUploadPipeline,
private readonly configService: ConfigService,
) {}
/**
* Uploads the attachments to S3 and store the file metadata to DB.
*/
@Post()
@UseInterceptors(FileInterceptor('file'))
@ApiConsumes('multipart/form-data')
@ApiOperation({ summary: 'Upload attachment to S3' })
@ApiBody({ description: 'Upload attachment', type: UploadAttachmentDto })
@ApiResponse({
status: 200,
description: 'The document has been uploaded successfully',
})
@ApiResponse({
status: 401,
description: 'Unauthorized - File upload failed',
})
async uploadAttachment(
@UploadedFile() file: Express.Multer.File,
res: Response,
next: NextFunction,
): Promise<Response | void> {
if (!file) {
throw new UnauthorizedException({
errorType: 'FILE_UPLOAD_FAILED',
message: 'Now file uploaded.',
});
}
const data = await this.attachmentsApplication.upload(file);
return res.status(200).send({
status: 200,
message: 'The document has uploaded successfully.',
data,
});
}
/**
* Retrieves the given attachment key.
*/
@Get('/:id')
@ApiOperation({ summary: 'Get attachment by ID' })
@ApiParam({ name: 'id', description: 'Attachment ID' })
@ApiResponse({ status: 200, description: 'Returns the attachment file' })
async getAttachment(
@Res() res: Response,
@Param('id') documentId: string,
): Promise<Response | void> {
const data = await this.attachmentsApplication.get(documentId);
const byte = await data.Body.transformToByteArray();
const extension = mime.extension(data.ContentType);
const buffer = Buffer.from(byte);
res.set('Content-Disposition', `filename="${documentId}.${extension}"`);
res.set('Content-Type', data.ContentType);
res.send(buffer);
}
/**
* Deletes the given document key.
*/
@Delete('/:id')
@ApiOperation({ summary: 'Delete attachment by ID' })
@ApiParam({ name: 'id', description: 'Attachment ID' })
@ApiResponse({
status: 200,
description: 'The document has been deleted successfully',
})
async deleteAttachment(
@Res() res: Response,
@Param('id') documentId: string,
): Promise<Response | void> {
await this.attachmentsApplication.delete(documentId);
return res.status(200).send({
status: 200,
message: 'The document has been delete successfully.',
});
}
/**
* Links the given document key.
*/
@Post('/:id/link')
@ApiOperation({ summary: 'Link attachment to a model' })
@ApiParam({ name: 'id', description: 'Attachment ID' })
@ApiBody({ type: LinkAttachmentDto })
@ApiResponse({
status: 200,
description: 'The document has been linked successfully',
})
async linkDocument(
@Body() linkDocumentDto: LinkAttachmentDto,
@Param('id') documentId: string,
@Res() res: Response,
): Promise<Response | void> {
await this.attachmentsApplication.link(
documentId,
linkDocumentDto.modelRef,
linkDocumentDto.modelId,
);
return res.status(200).send({
status: 200,
message: 'The document has been linked successfully.',
});
}
/**
* Links the given document key.
*/
@Post('/:id/unlink')
@ApiOperation({ summary: 'Unlink attachment from a model' })
@ApiParam({ name: 'id', description: 'Attachment ID' })
@ApiBody({ type: UnlinkAttachmentDto })
@ApiResponse({
status: 200,
description: 'The document has been unlinked successfully',
})
async unlinkDocument(
@Body() unlinkDto: UnlinkAttachmentDto,
@Param('id') documentId: string,
@Res() res: Response,
): Promise<Response | void> {
await this.attachmentsApplication.link(
documentId,
unlinkDto.modelRef,
unlinkDto.modelId,
);
return res.status(200).send({
status: 200,
message: 'The document has been linked successfully.',
});
}
/**
* Retreives the presigned url of the given attachment key.
*/
@Get('/:id/presigned-url')
@ApiOperation({ summary: 'Get presigned URL for attachment' })
@ApiParam({ name: 'id', description: 'Attachment ID' })
@ApiResponse({
status: 200,
description: 'Returns the presigned URL for the attachment',
})
async getAttachmentPresignedUrl(
@Param('id') documentKey: string,
res: Response,
next: NextFunction,
): Promise<Response | void> {
const presignedUrl =
await this.attachmentsApplication.getPresignedUrl(documentKey);
return res.status(200).send({ presignedUrl });
}
}

View File

@@ -0,0 +1,3 @@
export interface AttachmentLinkDTO {
key: string;
}

View File

@@ -0,0 +1,76 @@
import { Injectable } from '@nestjs/common';
import { UploadDocument } from './UploadDocument';
import { DeleteAttachment } from './DeleteAttachment';
import { GetAttachment } from './GetAttachment';
import { LinkAttachment } from './LinkAttachment';
import { UnlinkAttachment } from './UnlinkAttachment';
import { getAttachmentPresignedUrl } from './GetAttachmentPresignedUrl';
@Injectable()
export class AttachmentsApplication {
constructor(
private readonly uploadDocumentService: UploadDocument,
private readonly deleteDocumentService: DeleteAttachment,
private readonly getDocumentService: GetAttachment,
private readonly linkDocumentService: LinkAttachment,
private readonly unlinkDocumentService: UnlinkAttachment,
private readonly getPresignedUrlService: getAttachmentPresignedUrl,
) {}
/**
* Saves the metadata of uploaded document to S3 on database.
* @param {} file
* @returns {Promise<Document>}
*/
public upload(file: any) {
return this.uploadDocumentService.upload(file);
}
/**
* Deletes the give file attachment file key.
* @param {string} documentKey
* @returns {Promise<void>}
*/
public delete(documentKey: string) {
return this.deleteDocumentService.delete(documentKey);
}
/**
* Retrieves the document data.
* @param {string} documentKey
*/
public get(documentKey: string) {
return this.getDocumentService.getAttachment(documentKey);
}
/**
* Links the given document to resource model.
* @param {string} filekey
* @param {string} modelRef
* @param {number} modelId
* @returns
*/
public link(filekey: string, modelRef: string, modelId: number) {
return this.linkDocumentService.link(filekey, modelRef, modelId);
}
/**
* Unlinks the given document from resource model.
* @param {string} filekey
* @param {string} modelRef
* @param {number} modelId
* @returns
*/
public unlink(filekey: string, modelRef: string, modelId: number) {
return this.unlinkDocumentService.unlink(filekey, modelRef, modelId);
}
/**
* Retrieves the presigned url of the given attachment key.
* @param {string} key
* @returns {Promise<string>}
*/
public getPresignedUrl(key: string): Promise<string> {
return this.getPresignedUrlService.getPresignedUrl(key);
}
}

View File

@@ -0,0 +1,56 @@
import { Inject, Injectable } from '@nestjs/common';
import { DeleteObjectCommand, S3Client } from '@aws-sdk/client-s3';
import { Knex } from 'knex';
import { ConfigService } from '@nestjs/config';
import { UnitOfWork } from '../Tenancy/TenancyDB/UnitOfWork.service';
import { S3_CLIENT } from '../S3/S3.module';
import { DocumentModel } from './models/Document.model';
import { TenantModelProxy } from '../System/models/TenantBaseModel';
import { DocumentLinkModel } from './models/DocumentLink.model';
@Injectable()
export class DeleteAttachment {
constructor(
private readonly uow: UnitOfWork,
private readonly configService: ConfigService,
@Inject(S3_CLIENT)
private readonly s3Client: S3Client,
@Inject(DocumentModel.name)
private readonly documentModel: TenantModelProxy<typeof DocumentModel>,
@Inject(DocumentLinkModel.name)
private readonly documentLinkModel: TenantModelProxy<
typeof DocumentLinkModel
>,
) {}
/**
* Deletes the give file attachment file key.
* @param {string} filekey
*/
async delete(filekey: string): Promise<void> {
const params = {
Bucket: this.configService.get('s3.bucket'),
Key: filekey,
};
await this.s3Client.send(new DeleteObjectCommand(params));
const foundDocument = await this.documentModel()
.query()
.findOne('key', filekey)
.throwIfNotFound();
await this.uow.withTransaction(async (trx: Knex.Transaction) => {
// Delete all document links
await this.documentLinkModel()
.query(trx)
.where('documentId', foundDocument.id)
.delete();
// Delete thedocument.
await this.documentModel().query(trx).findById(foundDocument.id).delete();
});
}
}

View File

@@ -0,0 +1,28 @@
import { GetObjectCommand, S3Client } from '@aws-sdk/client-s3';
import { Inject, Injectable } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import { S3_CLIENT } from '../S3/S3.module';
@Injectable()
export class GetAttachment {
constructor(
private readonly configService: ConfigService,
@Inject(S3_CLIENT)
private readonly s3: S3Client,
) {}
/**
* Retrieves data of the given document key.
* @param {string} filekey
*/
async getAttachment(filekey: string) {
const params = {
Bucket: this.configService.get('s3.bucket'),
Key: filekey,
};
const data = await this.s3.send(new GetObjectCommand(params));
return data;
}
}

View File

@@ -0,0 +1,43 @@
import { GetObjectCommand, S3Client } from '@aws-sdk/client-s3';
import { getSignedUrl } from '@aws-sdk/s3-request-presigner';
import { Inject, Injectable } from '@nestjs/common';
import { TenantModelProxy } from '../System/models/TenantBaseModel';
import { DocumentModel } from './models/Document.model';
import { ConfigService } from '@nestjs/config';
import { S3_CLIENT } from '../S3/S3.module';
@Injectable()
export class getAttachmentPresignedUrl {
constructor(
private readonly configService: ConfigService,
@Inject(DocumentModel.name)
private readonly documentModel: TenantModelProxy<typeof DocumentModel>,
@Inject(S3_CLIENT)
private readonly s3Client: S3Client,
) {}
/**
* Retrieves the presigned url of the given attachment key with the original filename.
* @param {string} key -
* @returns {string}
*/
async getPresignedUrl(key: string) {
const foundDocument = await this.documentModel().query().findOne({ key });
const config = this.configService.get('s3');
let ResponseContentDisposition = 'attachment';
if (foundDocument && foundDocument.originName) {
ResponseContentDisposition += `; filename="${foundDocument.originName}"`;
}
const command = new GetObjectCommand({
Bucket: config.bucket,
Key: key,
ResponseContentDisposition,
});
const signedUrl = await getSignedUrl(this.s3Client, command, { expiresIn: 300 });
return signedUrl;
}
}

View File

@@ -0,0 +1,93 @@
import { ModuleRef } from '@nestjs/core';
import bluebird from 'bluebird';
import { Knex } from 'knex';
import {
validateLinkModelEntryExists,
validateLinkModelExists,
} from './_utils';
import { ERRORS } from './constants';
import { TenantModelProxy } from '../System/models/TenantBaseModel';
import { DocumentLink } from '../ChromiumlyTenancy/models/DocumentLink';
import { Inject, Injectable } from '@nestjs/common';
import { ServiceError } from '../Items/ServiceError';
import { getAttachableModelsMap } from './decorators/InjectAttachable.decorator';
import { DocumentModel } from './models/Document.model';
import { SaleInvoice } from '../SaleInvoices/models/SaleInvoice';
@Injectable()
export class LinkAttachment {
constructor(
private moduleRef: ModuleRef,
@Inject(DocumentLink.name)
private readonly documentLinkModel: TenantModelProxy<typeof DocumentLink>,
@Inject(DocumentModel.name)
private readonly documentModel: TenantModelProxy<typeof DocumentModel>,
) {}
/**
* Links the given file key to the given model type and id.
* @param {string} filekey - File key.
* @param {string} modelRef - Model reference.
* @param {number} modelId - Model id.
* @returns {Promise<void>}
*/
async link(
filekey: string,
modelRef: string,
modelId: number,
trx?: Knex.Transaction,
) {
const attachmentsAttachableModels = getAttachableModelsMap();
const attachableModel = attachmentsAttachableModels.get(modelRef);
validateLinkModelExists(attachableModel);
const LinkModel = this.moduleRef.get(modelRef, { strict: false });
const foundFile = await this.documentModel()
.query(trx)
.findOne('key', filekey)
.throwIfNotFound();
const foundLinkModel = await LinkModel().query(trx).findById(modelId);
validateLinkModelEntryExists(foundLinkModel);
const foundLinks = await this.documentLinkModel().query(trx)
.where('modelRef', modelRef)
.where('modelId', modelId)
.where('documentId', foundFile.id);
if (foundLinks.length > 0) {
throw new ServiceError(ERRORS.DOCUMENT_LINK_ALREADY_LINKED);
}
await this.documentLinkModel().query(trx).insert({
modelRef,
modelId,
documentId: foundFile.id,
});
}
/**
* Links the given file keys to the given model type and id.
* @param {string[]} filekeys - File keys.
* @param {string} modelRef - Model reference.
* @param {number} modelId - Model id.
* @param {Knex.Transaction} trx - Knex transaction.
* @returns {Promise<void>}
*/
async bulkLink(
filekeys: string[],
modelRef: string,
modelId: number,
trx?: Knex.Transaction,
) {
return bluebird.each(filekeys, async (fieldKey: string) => {
try {
await this.link(fieldKey, modelRef, modelId, trx);
} catch {
// Ignore catching exceptions in bulk action.
}
});
}
}

View File

@@ -0,0 +1,36 @@
import { NextFunction, Request, Response } from 'express';
import { ConfigService } from '@nestjs/config';
import { Injectable } from '@nestjs/common';
@Injectable()
export class AttachmentUploadPipeline {
constructor(
private readonly configService: ConfigService
) {}
/**
* Middleware to ensure that S3 configuration is properly set before proceeding.
* This function checks if the necessary S3 configuration keys are present and throws an error if any are missing.
* @param req The HTTP request object.
* @param res The HTTP response object.
* @param next The callback to pass control to the next middleware function.
*/
public validateS3Configured(req: Request, res: Response, next: NextFunction) {
const config = this.configService.get('s3');
if (
!config.region ||
!config.accessKeyId ||
!config.secretAccessKey
) {
const missingKeys = [];
if (!config.region) missingKeys.push('region');
if (!config.accessKeyId) missingKeys.push('accessKeyId');
if (!config.secretAccessKey) missingKeys.push('secretAccessKey');
const missing = missingKeys.join(', ');
throw new Error(`S3 configuration error: Missing ${missing}`);
}
next();
}
}

View File

@@ -0,0 +1,131 @@
import bluebird from 'bluebird';
import { difference } from 'lodash';
import {
validateLinkModelEntryExists,
validateLinkModelExists,
} from './_utils';
import { Knex } from 'knex';
import { Inject, Injectable } from '@nestjs/common';
import { DocumentLinkModel } from './models/DocumentLink.model';
import { TenantModelProxy } from '../System/models/TenantBaseModel';
import { DocumentModel } from './models/Document.model';
import { getAttachableModelsMap } from './decorators/InjectAttachable.decorator';
import { ModuleRef } from '@nestjs/core';
@Injectable()
export class UnlinkAttachment {
constructor(
private moduleRef: ModuleRef,
@Inject(DocumentModel.name)
private readonly documentModel: TenantModelProxy<typeof DocumentModel>,
@Inject(DocumentLinkModel.name)
private readonly documentLinkModel: TenantModelProxy<
typeof DocumentLinkModel
>,
) {}
/**
* Unlink the attachments from the model entry.
* @param {string} filekey - File key.
* @param {string} modelRef - Model reference.
* @param {number} modelId - Model id.
*/
async unlink(
filekey: string,
modelRef: string,
modelId: number,
trx?: Knex.Transaction,
): Promise<void> {
const attachmentsAttachableModels = getAttachableModelsMap();
const attachableModel = attachmentsAttachableModels.get(modelRef);
validateLinkModelExists(attachableModel);
const LinkModel = this.moduleRef.get(modelRef, { strict: false });
const foundLinkModel = await LinkModel.query(trx).findById(modelId);
validateLinkModelEntryExists(foundLinkModel);
const document = await this.documentModel().query(trx).findOne('key', filekey);
// Delete the document link.
await this.documentLinkModel().query(trx)
.where('modelRef', modelRef)
.where('modelId', modelId)
.where('documentId', document.id)
.delete();
}
/**
* Bulk unlink the attachments from the model entry.
* @param {string} fieldkey
* @param {string} modelRef
* @param {number} modelId
* @returns {Promise<void>}
*/
async bulkUnlink(
filekeys: string[],
modelRef: string,
modelId: number,
trx?: Knex.Transaction,
): Promise<void> {
await bluebird.each(filekeys, (fieldKey: string) => {
try {
this.unlink(fieldKey, modelRef, modelId, trx);
} catch {
// Ignore catching exceptions on bulk action.
}
});
}
/**
* Unlink all the unpresented keys of the given model type and id.
* @param {number} tenantId
* @param {string[]} presentedKeys
* @param {string} modelRef
* @param {number} modelId
* @param {Knex.Transaction} trx
*/
async unlinkUnpresentedKeys(
presentedKeys: string[],
modelRef: string,
modelId: number,
trx?: Knex.Transaction,
): Promise<void> {
const modelLinks = await this.documentLinkModel()
.query(trx)
.where('modelRef', modelRef)
.where('modelId', modelId)
.withGraphFetched('document');
const modelLinkKeys = modelLinks.map((link) => link.document.key);
const unpresentedKeys = difference(modelLinkKeys, presentedKeys);
await this.bulkUnlink(unpresentedKeys, modelRef, modelId, trx);
}
/**
* Unlink all attachments of the given model type and id.
* @param {string} modelRef
* @param {number} modelId
* @param {Knex.Transaction} trx
* @returns {Promise<void>}
*/
async unlinkAllModelKeys(
modelRef: string,
modelId: number,
trx?: Knex.Transaction,
): Promise<void> {
// Get all the keys of the modelRef and modelId.
const modelLinks = await this.documentLinkModel()
.query(trx)
.where('modelRef', modelRef)
.where('modelId', modelId)
.withGraphFetched('document');
const modelLinkKeys = modelLinks.map((link) => link.document.key);
await this.bulkUnlink(modelLinkKeys, modelRef, modelId, trx);
}
}

View File

@@ -0,0 +1,28 @@
import { Inject, Injectable } from '@nestjs/common';
import { TenancyDatabaseModule } from '../Tenancy/TenancyDB/TenancyDB.module';
import { TenantModelProxy } from '../System/models/TenantBaseModel';
import { DocumentModel } from './models/Document.model';
@Injectable()
export class UploadDocument {
constructor(
@Inject(DocumentModel.name)
private readonly documentModel: TenantModelProxy<typeof DocumentModel>,
) {}
/**
* Inserts the document metadata.
* @param {number} tenantId
* @param {} file
* @returns {}
*/
async upload(file: any) {
const insertedDocument = await this.documentModel().query().insert({
key: file.key,
mimeType: file.mimetype,
size: file.size,
originName: file.originalname,
});
return insertedDocument;
}
}

View File

@@ -0,0 +1,29 @@
import { castArray, difference } from 'lodash';
import { Inject, Injectable } from '@nestjs/common';
import { TenantModelProxy } from '../System/models/TenantBaseModel';
import { DocumentModel } from './models/Document.model';
import { ServiceError } from '../Items/ServiceError';
@Injectable()
export class ValidateAttachments {
constructor(
@Inject(DocumentModel.name)
private readonly documentModel: TenantModelProxy<typeof DocumentModel>,
) {}
/**
* Validates the given file keys existance.
* @param {string|string[]} key
*/
async validate(key: string | string[]) {
const keys = castArray(key);
const documents = await this.documentModel().query().whereIn('key', key);
const documentKeys = documents.map((document) => document.key);
const notFoundKeys = difference(keys, documentKeys);
if (notFoundKeys.length > 0) {
throw new ServiceError('DOCUMENT_KEYS_INVALID');
}
}
}

View File

@@ -0,0 +1,14 @@
import { ServiceError } from '../Items/ServiceError';
import { ERRORS } from './constants';
export const validateLinkModelExists = (LinkModel) => {
if (!LinkModel) {
throw new ServiceError(ERRORS.DOCUMENT_LINK_REF_INVALID);
}
};
export const validateLinkModelEntryExists = (foundLinkModel) => {
if (!foundLinkModel) {
throw new ServiceError(ERRORS.DOCUMENT_LINK_ID_INVALID);
}
};

View File

@@ -0,0 +1,5 @@
export enum ERRORS {
DOCUMENT_LINK_REF_INVALID = 'DOCUMENT_LINK_REF_INVALID',
DOCUMENT_LINK_ID_INVALID = 'DOCUMENT_LINK_ID_INVALID',
DOCUMENT_LINK_ALREADY_LINKED = 'DOCUMENT_LINK_ALREADY_LINKED'
}

View File

@@ -0,0 +1,23 @@
import { Injectable } from '@nestjs/common';
// Array to store attachable model names
const attachableModelsMap: Map<string, boolean> = new Map();
/**
* Decorator that marks a class as attachable and adds its name to the attachable models array.
* This is used to track which models can have attachments.
*/
export function InjectAttachable() {
return function (target: any) {
// Add the model name to the attachable models array
attachableModelsMap.set(target.name, true);
};
}
/**
* Get all attachable model names
*/
export function getAttachableModelsMap() {
return attachableModelsMap;
}

View File

@@ -0,0 +1,33 @@
import { ApiProperty } from "@nestjs/swagger";
import { IsNotEmpty, IsString } from "class-validator";
export class AttachmentLinkDto {
@IsString()
@IsNotEmpty()
key: string;
}
export class UnlinkAttachmentDto {
@IsNotEmpty()
modelRef: string;
@IsNotEmpty()
modelId: number;
}
export class LinkAttachmentDto {
@IsNotEmpty()
modelRef: string;
@IsNotEmpty()
modelId: number;
}
export class UploadAttachmentDto {
@ApiProperty({ type: 'string', format: 'binary' })
file: any;
}

View File

@@ -0,0 +1,117 @@
import { isEmpty } from 'lodash';
import {
IBIllEventDeletedPayload,
IBillCreatedPayload,
IBillCreatingPayload,
IBillEditedPayload,
} from '@/modules/Bills/Bills.types';
import { ValidateAttachments } from '../ValidateAttachments';
import { OnEvent } from '@nestjs/event-emitter';
import { Injectable } from '@nestjs/common';
import { UnlinkAttachment } from '../UnlinkAttachment';
import { LinkAttachment } from '../LinkAttachment';
import { events } from '@/common/events/events';
@Injectable()
export class AttachmentsOnBills {
/**
* @param {LinkAttachment} linkAttachmentService
* @param {UnlinkAttachment} unlinkAttachmentService
* @param {ValidateAttachments} validateDocuments
*/
constructor(
private readonly linkAttachmentService: LinkAttachment,
private readonly unlinkAttachmentService: UnlinkAttachment,
private readonly validateDocuments: ValidateAttachments,
) {}
/**
* Validates the attachment keys on creating bill.
* @param {ISaleInvoiceCreatingPaylaod}
* @returns {Promise<void>}
*/
@OnEvent(events.bill.onCreating)
async validateAttachmentsOnBillCreate({
billDTO,
}: IBillCreatingPayload): Promise<void> {
if (isEmpty(billDTO.attachments)) {
return;
}
const documentKeys = billDTO?.attachments?.map((a) => a.key);
await this.validateDocuments.validate(documentKeys);
}
/**
* Handles linking the attachments of the created bill.
* @param {ISaleInvoiceCreatedPayload}
* @returns {Promise<void>}
*/
@OnEvent(events.bill.onCreated)
async handleAttachmentsOnBillCreated({
bill,
billDTO,
trx,
}: IBillCreatedPayload): Promise<void> {
if (isEmpty(billDTO.attachments)) return;
const keys = billDTO.attachments?.map((attachment) => attachment.key);
await this.linkAttachmentService.bulkLink(keys, 'Bill', bill.id, trx);
}
/**
* Handles unlinking all the unpresented keys of the edited bill.
* @param {IBillEditedPayload}
*/
@OnEvent(events.bill.onEdited)
async handleUnlinkUnpresentedKeysOnBillEdited({
billDTO,
bill,
trx,
}: IBillEditedPayload) {
const keys = billDTO.attachments?.map((attachment) => attachment.key);
await this.unlinkAttachmentService.unlinkUnpresentedKeys(
keys,
'Bill',
bill.id,
trx,
);
}
/**
* Handles linking all the presented keys of the edited bill.
* @param {ISaleInvoiceEditedPayload}
* @returns {Promise<void>}
*/
@OnEvent(events.bill.onEdited)
async handleLinkPresentedKeysOnBillEdited({
billDTO,
oldBill,
trx,
}: IBillEditedPayload) {
if (isEmpty(billDTO.attachments)) return;
const keys = billDTO.attachments?.map((attachment) => attachment.key);
await this.linkAttachmentService.bulkLink(keys, 'Bill', oldBill.id, trx);
}
/**
* Unlink all attachments once the bill deleted.
* @param {ISaleInvoiceDeletedPayload}
* @returns {Promise<void>}
*/
@OnEvent(events.bill.onDeleting)
async handleUnlinkAttachmentsOnBillDeleted({
oldBill,
trx,
}: IBIllEventDeletedPayload) {
await this.unlinkAttachmentService.unlinkAllModelKeys(
'Bill',
oldBill.id,
trx,
);
}
}

View File

@@ -0,0 +1,129 @@
import { isEmpty } from 'lodash';
import {
ICreditNoteCreatedPayload,
ICreditNoteCreatingPayload,
ICreditNoteDeletingPayload,
ICreditNoteEditedPayload,
} from '@/modules/CreditNotes/types/CreditNotes.types';
import { ValidateAttachments } from '../ValidateAttachments';
import { Injectable } from '@nestjs/common';
import { LinkAttachment } from '../LinkAttachment';
import { UnlinkAttachment } from '../UnlinkAttachment';
import { OnEvent } from '@nestjs/event-emitter';
import { events } from '@/common/events/events';
@Injectable()
export class AttachmentsOnCreditNote {
/**
* @param {LinkAttachment} linkAttachmentService -
* @param {UnlinkAttachment} unlinkAttachmentService -
* @param {ValidateAttachments} validateDocuments -
*/
constructor(
private readonly linkAttachmentService: LinkAttachment,
private readonly unlinkAttachmentService: UnlinkAttachment,
private readonly validateDocuments: ValidateAttachments,
) {}
/**
* Validates the attachment keys on creating credit note.
* @param {ICreditNoteCreatingPayload}
* @returns {Promise<void>}
*/
@OnEvent(events.creditNote.onCreating)
async validateAttachmentsOnCreditNoteCreate({
creditNoteDTO,
}: ICreditNoteCreatingPayload): Promise<void> {
if (isEmpty(creditNoteDTO.attachments)) {
return;
}
const documentKeys = creditNoteDTO?.attachments?.map((a) => a.key);
await this.validateDocuments.validate(documentKeys);
}
/**
* Handles linking the attachments of the created credit note.
* @param {ICreditNoteCreatedPayload}
* @returns {Promise<void>}
*/
@OnEvent(events.creditNote.onCreated)
async handleAttachmentsOnCreditNoteCreated({
creditNote,
creditNoteDTO,
trx,
}: ICreditNoteCreatedPayload): Promise<void> {
if (isEmpty(creditNoteDTO.attachments)) return;
const keys = creditNoteDTO.attachments?.map((attachment) => attachment.key);
await this.linkAttachmentService.bulkLink(
keys,
'CreditNote',
creditNote.id,
trx,
);
}
/**
* Handles unlinking all the unpresented keys of the edited credit note.
* @param {ICreditNoteEditedPayload}
*/
@OnEvent(events.creditNote.onEdited)
async handleUnlinkUnpresentedKeysOnCreditNoteEdited({
creditNoteEditDTO,
oldCreditNote,
trx,
}: ICreditNoteEditedPayload) {
const keys = creditNoteEditDTO.attachments?.map(
(attachment) => attachment.key,
);
await this.unlinkAttachmentService.unlinkUnpresentedKeys(
keys,
'CreditNote',
oldCreditNote.id,
trx,
);
}
/**
* Handles linking all the presented keys of the edited credit note.
* @param {ICreditNoteEditedPayload}
* @returns {Promise<void>}
*/
@OnEvent(events.creditNote.onEdited)
async handleLinkPresentedKeysOnCreditNoteEdited({
creditNoteEditDTO,
oldCreditNote,
trx,
}: ICreditNoteEditedPayload) {
if (isEmpty(creditNoteEditDTO.attachments)) return;
const keys = creditNoteEditDTO.attachments?.map(
(attachment) => attachment.key,
);
await this.linkAttachmentService.bulkLink(
keys,
'CreditNote',
oldCreditNote.id,
trx,
);
}
/**
* Unlink all attachments once the credit note deleted.
* @param {ICreditNoteDeletingPayload}
* @returns {Promise<void>}
*/
@OnEvent(events.creditNote.onDeleting)
async handleUnlinkAttachmentsOnCreditNoteDeleted({
oldCreditNote,
trx,
}: ICreditNoteDeletingPayload) {
await this.unlinkAttachmentService.unlinkAllModelKeys(
'CreditNote',
oldCreditNote.id,
trx,
);
}
}

View File

@@ -0,0 +1,120 @@
import { isEmpty } from 'lodash';
import { OnEvent } from '@nestjs/event-emitter';
import { Injectable } from '@nestjs/common';
import {
IExpenseCreatedPayload,
IExpenseCreatingPayload,
IExpenseDeletingPayload,
IExpenseEventEditPayload,
} from '@/modules/Expenses/Expenses.types';
import { ValidateAttachments } from '../ValidateAttachments';
import { UnlinkAttachment } from '../UnlinkAttachment';
import { LinkAttachment } from '../LinkAttachment';
import { events } from '@/common/events/events';
@Injectable()
export class AttachmentsOnExpenses {
constructor(
private readonly linkAttachmentService: LinkAttachment,
private readonly unlinkAttachmentService: UnlinkAttachment,
private readonly validateDocuments: ValidateAttachments,
) {}
/**
* Validates the attachment keys on creating expense.
* @param {ISaleInvoiceCreatingPaylaod}
* @returns {Promise<void>}
*/
@OnEvent(events.expenses.onCreating)
async validateAttachmentsOnExpenseCreate({
expenseDTO,
}: IExpenseCreatingPayload): Promise<void> {
if (isEmpty(expenseDTO.attachments)) {
return;
}
const documentKeys = expenseDTO?.attachments?.map((a) => a.key);
await this.validateDocuments.validate(documentKeys);
}
/**
* Handles linking the attachments of the created expense.
* @param {ISaleInvoiceCreatedPayload}
* @returns {Promise<void>}
*/
@OnEvent(events.expenses.onCreated)
async handleAttachmentsOnExpenseCreated({
expenseDTO,
expense,
trx,
}: IExpenseCreatedPayload): Promise<void> {
if (isEmpty(expenseDTO.attachments)) return;
const keys = expenseDTO.attachments?.map((attachment) => attachment.key);
await this.linkAttachmentService.bulkLink(
keys,
'Expense',
expense.id,
trx,
);
}
/**
* Handles unlinking all the unpresented keys of the edited expense.
* @param {ISaleInvoiceEditedPayload}
*/
@OnEvent(events.expenses.onEdited)
async handleUnlinkUnpresentedKeysOnExpenseEdited({
expenseDTO,
expense,
trx,
}: IExpenseEventEditPayload) {
const keys = expenseDTO.attachments?.map((attachment) => attachment.key);
await this.unlinkAttachmentService.unlinkUnpresentedKeys(
keys,
'Expense',
expense.id,
trx,
);
}
/**
* Handles linking all the presented keys of the edited expense.
* @param {ISaleInvoiceEditedPayload}
* @returns {Promise<void>}
*/
@OnEvent(events.expenses.onEdited)
async handleLinkPresentedKeysOnExpenseEdited({
expenseDTO,
oldExpense,
trx,
}: IExpenseEventEditPayload) {
if (isEmpty(expenseDTO.attachments)) return;
const keys = expenseDTO.attachments?.map((attachment) => attachment.key);
await this.linkAttachmentService.bulkLink(
keys,
'Expense',
oldExpense.id,
trx,
);
}
/**
* Unlink all attachments once the expense deleted.
* @param {ISaleInvoiceDeletedPayload}
* @returns {Promise<void>}
*/
@OnEvent(events.expenses.onDeleting)
async handleUnlinkAttachmentsOnExpenseDeleted({
oldExpense,
trx,
}: IExpenseDeletingPayload) {
await this.unlinkAttachmentService.unlinkAllModelKeys(
'Expense',
oldExpense.id,
trx,
);
}
}

View File

@@ -0,0 +1,125 @@
import { isEmpty } from 'lodash';
import {
IManualJournalCreatingPayload,
IManualJournalEventCreatedPayload,
IManualJournalEventDeletedPayload,
IManualJournalEventEditedPayload,
} from '@/modules/ManualJournals/types/ManualJournals.types';
import { ValidateAttachments } from '../ValidateAttachments';
import { OnEvent } from '@nestjs/event-emitter';
import { UnlinkAttachment } from '../UnlinkAttachment';
import { LinkAttachment } from '../LinkAttachment';
import { Injectable } from '@nestjs/common';
import { events } from '@/common/events/events';
@Injectable()
export class AttachmentsOnManualJournals {
constructor(
private readonly linkAttachmentService: LinkAttachment,
private readonly unlinkAttachmentService: UnlinkAttachment,
private readonly validateDocuments: ValidateAttachments,
) {}
/**
* Validates the attachment keys on creating manual journal.
* @param {IManualJournalCreatingPayload}
* @returns {Promise<void>}
*/
@OnEvent(events.manualJournals.onCreating)
async validateAttachmentsOnManualJournalCreate({
manualJournalDTO,
}: IManualJournalCreatingPayload): Promise<void> {
if (isEmpty(manualJournalDTO.attachments)) {
return;
}
const documentKeys = manualJournalDTO?.attachments?.map((a) => a.key);
await this.validateDocuments.validate(documentKeys);
}
/**
* Handles linking the attachments of the created manual journal.
* @param {IManualJournalEventCreatedPayload}
* @returns {Promise<void>}
*/
@OnEvent(events.manualJournals.onCreated)
async handleAttachmentsOnManualJournalCreated({
manualJournalDTO,
manualJournal,
trx,
}: IManualJournalEventCreatedPayload): Promise<void> {
if (isEmpty(manualJournalDTO.attachments)) return;
const keys = manualJournalDTO.attachments?.map(
(attachment) => attachment.key,
);
await this.linkAttachmentService.bulkLink(
keys,
'ManualJournal',
manualJournal.id,
trx,
);
}
/**
* Handles unlinking all the unpresented keys of the edited manual journal.
* @param {ISaleInvoiceEditedPayload}
*/
@OnEvent(events.manualJournals.onEdited)
async handleUnlinkUnpresentedKeysOnManualJournalEdited({
manualJournalDTO,
manualJournal,
trx,
}: IManualJournalEventEditedPayload) {
const keys = manualJournalDTO.attachments?.map(
(attachment) => attachment.key,
);
await this.unlinkAttachmentService.unlinkUnpresentedKeys(
keys,
'SaleInvoice',
manualJournal.id,
trx,
);
}
/**
* Handles linking all the presented keys of the edited manual journal.
* @param {ISaleInvoiceEditedPayload}
* @returns {Promise<void>}
*/
@OnEvent(events.manualJournals.onEdited)
async handleLinkPresentedKeysOnManualJournalEdited({
manualJournalDTO,
oldManualJournal,
trx,
}: IManualJournalEventEditedPayload) {
if (isEmpty(manualJournalDTO.attachments)) return;
const keys = manualJournalDTO.attachments?.map(
(attachment) => attachment.key,
);
await this.linkAttachmentService.bulkLink(
keys,
'ManualJournal',
oldManualJournal.id,
trx,
);
}
/**
* Unlink all attachments once the manual journal deleted.
* @param {ISaleInvoiceDeletedPayload}
* @returns {Promise<void>}
*/
@OnEvent(events.manualJournals.onDeleting)
async handleUnlinkAttachmentsOnManualJournalDeleted({
oldManualJournal,
trx,
}: IManualJournalEventDeletedPayload) {
await this.unlinkAttachmentService.unlinkAllModelKeys(
'SaleInvoice',
oldManualJournal.id,
trx,
);
}
}

View File

@@ -0,0 +1,125 @@
import { isEmpty } from 'lodash';
import {
IBillPaymentCreatingPayload,
IBillPaymentDeletingPayload,
IBillPaymentEventCreatedPayload,
IBillPaymentEventEditedPayload,
} from '@/modules/BillPayments/types/BillPayments.types';
import { ValidateAttachments } from '../ValidateAttachments';
import { Injectable } from '@nestjs/common';
import { LinkAttachment } from '../LinkAttachment';
import { UnlinkAttachment } from '../UnlinkAttachment';
import { events } from '@/common/events/events';
import { OnEvent } from '@nestjs/event-emitter';
@Injectable()
export class AttachmentsOnBillPayments {
constructor(
private readonly linkAttachmentService: LinkAttachment,
private readonly unlinkAttachmentService: UnlinkAttachment,
private readonly validateDocuments: ValidateAttachments,
) {}
/**
* Validates the attachment keys on creating bill payment.
* @param {IBillPaymentCreatingPayload}
* @returns {Promise<void>}
*/
@OnEvent(events.billPayment.onCreating)
async validateAttachmentsOnBillPaymentCreate({
billPaymentDTO,
}: IBillPaymentCreatingPayload): Promise<void> {
if (isEmpty(billPaymentDTO.attachments)) {
return;
}
const documentKeys = billPaymentDTO?.attachments?.map((a) => a.key);
await this.validateDocuments.validate(documentKeys);
}
/**
* Handles linking the attachments of the created bill payment.
* @param {IBillPaymentEventCreatedPayload}
* @returns {Promise<void>}
*/
@OnEvent(events.billPayment.onCreated)
async handleAttachmentsOnBillPaymentCreated({
billPaymentDTO,
billPayment,
trx,
}: IBillPaymentEventCreatedPayload): Promise<void> {
if (isEmpty(billPaymentDTO.attachments)) return;
const keys = billPaymentDTO.attachments?.map(
(attachment) => attachment.key,
);
await this.linkAttachmentService.bulkLink(
keys,
'BillPayment',
billPayment.id,
trx,
);
}
/**
* Handles unlinking all the unpresented keys of the edited bill payment.
* @param {IBillPaymentEventEditedPayload}
*/
@OnEvent(events.billPayment.onEdited)
async handleUnlinkUnpresentedKeysOnBillPaymentEdited({
billPaymentDTO,
oldBillPayment,
trx,
}: IBillPaymentEventEditedPayload) {
const keys = billPaymentDTO.attachments?.map(
(attachment) => attachment.key,
);
await this.unlinkAttachmentService.unlinkUnpresentedKeys(
keys,
'BillPayment',
oldBillPayment.id,
trx,
);
}
/**
* Handles linking all the presented keys of the edited bill payment.
* @param {IBillPaymentEventEditedPayload}
* @returns {Promise<void>}
*/
@OnEvent(events.billPayment.onEdited)
async handleLinkPresentedKeysOnBillPaymentEdited({
billPaymentDTO,
oldBillPayment,
trx,
}: IBillPaymentEventEditedPayload) {
if (isEmpty(billPaymentDTO.attachments)) return;
const keys = billPaymentDTO.attachments?.map(
(attachment) => attachment.key,
);
await this.linkAttachmentService.bulkLink(
keys,
'BillPayment',
oldBillPayment.id,
trx,
);
}
/**
* Unlink all attachments once the bill payment deleted.
* @param {IBillPaymentDeletingPayload}
* @returns {Promise<void>}
*/
@OnEvent(events.billPayment.onDeleting)
async handleUnlinkAttachmentsOnBillPaymentDeleted({
oldBillPayment,
trx,
}: IBillPaymentDeletingPayload) {
await this.unlinkAttachmentService.unlinkAllModelKeys(
'BillPayment',
oldBillPayment.id,
trx,
);
}
}

View File

@@ -0,0 +1,125 @@
import { isEmpty } from 'lodash';
import {
IPaymentReceivedCreatedPayload,
IPaymentReceivedCreatingPayload,
IPaymentReceivedDeletingPayload,
IPaymentReceivedEditedPayload,
} from '@/modules/PaymentReceived/types/PaymentReceived.types';
import { ValidateAttachments } from '../ValidateAttachments';
import { OnEvent } from '@nestjs/event-emitter';
import { Injectable } from '@nestjs/common';
import { UnlinkAttachment } from '../UnlinkAttachment';
import { LinkAttachment } from '../LinkAttachment';
import { events } from '@/common/events/events';
@Injectable()
export class AttachmentsOnPaymentsReceived {
constructor(
private readonly linkAttachmentService: LinkAttachment,
private readonly unlinkAttachmentService: UnlinkAttachment,
private readonly validateDocuments: ValidateAttachments,
) {}
/**
* Validates the attachment keys on creating payment.
* @param {IPaymentReceivedCreatingPayload}
* @returns {Promise<void>}
*/
@OnEvent(events.paymentReceive.onCreating)
async validateAttachmentsOnPaymentCreate({
paymentReceiveDTO,
}: IPaymentReceivedCreatingPayload): Promise<void> {
if (isEmpty(paymentReceiveDTO.attachments)) {
return;
}
const documentKeys = paymentReceiveDTO?.attachments?.map((a) => a.key);
await this.validateDocuments.validate(documentKeys);
}
/**
* Handles linking the attachments of the created payment.
* @param {IPaymentReceivedCreatedPayload}
* @returns {Promise<void>}
*/
@OnEvent(events.paymentReceive.onCreated)
async handleAttachmentsOnPaymentCreated({
paymentReceiveDTO,
paymentReceive,
trx,
}: IPaymentReceivedCreatedPayload): Promise<void> {
if (isEmpty(paymentReceiveDTO.attachments)) return;
const keys = paymentReceiveDTO.attachments?.map(
(attachment) => attachment.key,
);
await this.linkAttachmentService.bulkLink(
keys,
'PaymentReceive',
paymentReceive.id,
trx,
);
}
/**
* Handles unlinking all the unpresented keys of the edited payment.
* @param {IPaymentReceivedEditedPayload}
*/
@OnEvent(events.paymentReceive.onEdited)
private async handleUnlinkUnpresentedKeysOnPaymentEdited({
paymentReceiveDTO,
oldPaymentReceive,
trx,
}: IPaymentReceivedEditedPayload) {
const keys = paymentReceiveDTO.attachments?.map(
(attachment) => attachment.key,
);
await this.unlinkAttachmentService.unlinkUnpresentedKeys(
keys,
'PaymentReceive',
oldPaymentReceive.id,
trx,
);
}
/**
* Handles linking all the presented keys of the edited payment.
* @param {IPaymentReceivedEditedPayload}
* @returns {Promise<void>}
*/
@OnEvent(events.paymentReceive.onEdited)
async handleLinkPresentedKeysOnPaymentEdited({
paymentReceiveDTO,
oldPaymentReceive,
trx,
}: IPaymentReceivedEditedPayload) {
if (isEmpty(paymentReceiveDTO.attachments)) return;
const keys = paymentReceiveDTO.attachments?.map(
(attachment) => attachment.key,
);
await this.linkAttachmentService.bulkLink(
keys,
'PaymentReceive',
oldPaymentReceive.id,
trx,
);
}
/**
* Unlink all attachments once the payment deleted.
* @param {ISaleInvoiceDeletedPayload}
* @returns {Promise<void>}
*/
@OnEvent(events.paymentReceive.onDeleting)
async handleUnlinkAttachmentsOnPaymentDelete({
oldPaymentReceive,
trx,
}: IPaymentReceivedDeletingPayload) {
await this.unlinkAttachmentService.unlinkAllModelKeys(
'PaymentReceive',
oldPaymentReceive.id,
trx,
);
}
}

View File

@@ -0,0 +1,123 @@
import { isEmpty } from 'lodash';
import {
ISaleEstimateCreatedPayload,
ISaleEstimateCreatingPayload,
ISaleEstimateDeletingPayload,
ISaleEstimateEditedPayload,
} from '@/modules/SaleEstimates/types/SaleEstimates.types';
import { ValidateAttachments } from '../ValidateAttachments';
import { Injectable } from '@nestjs/common';
import { UnlinkAttachment } from '../UnlinkAttachment';
import { LinkAttachment } from '../LinkAttachment';
import { OnEvent } from '@nestjs/event-emitter';
import { events } from '@/common/events/events';
@Injectable()
export class AttachmentsOnSaleEstimates {
constructor(
private readonly linkAttachmentService: LinkAttachment,
private readonly unlinkAttachmentService: UnlinkAttachment,
private readonly validateDocuments: ValidateAttachments,
) {}
/**
* Validates the attachment keys on creating sale estimate.
* @param {ISaleEstimateCreatingPayload}
* @returns {Promise<void>}
*/
@OnEvent(events.saleEstimate.onCreating)
async validateAttachmentsOnSaleEstimateCreated({
estimateDTO,
}: ISaleEstimateCreatingPayload): Promise<void> {
if (isEmpty(estimateDTO.attachments)) {
return;
}
const documentKeys = estimateDTO?.attachments?.map((a) => a.key);
await this.validateDocuments.validate(documentKeys);
}
/**
* Handles linking the attachments of the created sale estimate.
* @param {ISaleEstimateCreatedPayload}
* @returns {Promise<void>}
*/
@OnEvent(events.saleEstimate.onCreated)
async handleAttachmentsOnSaleEstimateCreated({
saleEstimateDTO,
saleEstimate,
trx,
}: ISaleEstimateCreatedPayload): Promise<void> {
if (isEmpty(saleEstimateDTO.attachments)) return;
const keys = saleEstimateDTO.attachments?.map(
(attachment) => attachment.key,
);
await this.linkAttachmentService.bulkLink(
keys,
'SaleEstimate',
saleEstimate.id,
trx,
);
}
/**
* Handles unlinking all the unpresented keys of the edited sale estimate.
* @param {ISaleEstimateEditedPayload}
*/
@OnEvent(events.saleEstimate.onEdited)
async handleUnlinkUnpresentedKeysOnSaleEstimateEdited({
estimateDTO,
oldSaleEstimate,
trx,
}: ISaleEstimateEditedPayload) {
const keys = estimateDTO.attachments?.map((attachment) => attachment.key);
await this.unlinkAttachmentService.unlinkUnpresentedKeys(
keys,
'SaleEstimate',
oldSaleEstimate.id,
trx,
);
}
/**
* Handles linking all the presented keys of the edited sale estimate.
* @param {ISaleEstimateEditedPayload}
* @returns {Promise<void>}
*/
@OnEvent(events.saleEstimate.onEdited)
async handleLinkPresentedKeysOnSaleEstimateEdited({
estimateDTO,
oldSaleEstimate,
trx,
}: ISaleEstimateEditedPayload) {
if (isEmpty(estimateDTO.attachments)) return;
const keys = estimateDTO.attachments?.map((attachment) => attachment.key);
await this.linkAttachmentService.bulkLink(
keys,
'SaleEstimate',
oldSaleEstimate.id,
trx,
);
}
/**
* Unlink all attachments once the estimate deleted.
* @param {ISaleEstimateDeletingPayload}
* @returns {Promise<void>}
*/
@OnEvent(events.saleEstimate.onDeleting)
async handleUnlinkAttachmentsOnSaleEstimateDelete({
oldSaleEstimate,
trx,
}: ISaleEstimateDeletingPayload) {
await this.unlinkAttachmentService.unlinkAllModelKeys(
'SaleEstimate',
oldSaleEstimate.id,
trx,
);
}
}

View File

@@ -0,0 +1,127 @@
import { Injectable } from '@nestjs/common';
import { isEmpty } from 'lodash';
import { OnEvent } from '@nestjs/event-emitter';
import {
ISaleInvoiceCreatedPayload,
ISaleInvoiceCreatingPaylaod,
ISaleInvoiceDeletingPayload,
ISaleInvoiceEditedPayload,
} from '@/modules/SaleInvoices/SaleInvoice.types';
import { ValidateAttachments } from '../ValidateAttachments';
import { LinkAttachment } from '../LinkAttachment';
import { UnlinkAttachment } from '../UnlinkAttachment';
import { events } from '@/common/events/events';
@Injectable()
export class AttachmentsOnSaleInvoiceCreated {
constructor(
private readonly linkAttachmentService: LinkAttachment,
private readonly unlinkAttachmentService: UnlinkAttachment,
private readonly validateDocuments: ValidateAttachments,
) {}
/**
* Validates the attachment keys on creating sale invoice.
* @param {ISaleInvoiceCreatingPaylaod}
* @returns {Promise<void>}
*/
@OnEvent(events.saleInvoice.onCreating)
async validateAttachmentsOnSaleInvoiceCreate({
saleInvoiceDTO,
}: ISaleInvoiceCreatingPaylaod): Promise<void> {
if (isEmpty(saleInvoiceDTO.attachments)) {
return;
}
const documentKeys = saleInvoiceDTO?.attachments?.map((a) => a.key);
await this.validateDocuments.validate(documentKeys);
}
/**
* Handles linking the attachments of the created sale invoice.
* @param {ISaleInvoiceCreatedPayload}
* @returns {Promise<void>}
*/
@OnEvent(events.saleInvoice.onCreated)
async handleAttachmentsOnSaleInvoiceCreated({
saleInvoiceDTO,
saleInvoice,
trx,
}: ISaleInvoiceCreatedPayload): Promise<void> {
if (isEmpty(saleInvoiceDTO.attachments)) return;
const keys = saleInvoiceDTO.attachments?.map(
(attachment) => attachment.key,
);
await this.linkAttachmentService.bulkLink(
keys,
'SaleInvoice',
saleInvoice.id,
trx,
);
}
/**
* Handles unlinking all the unpresented keys of the edited sale invoice.
* @param {ISaleInvoiceEditedPayload}
*/
@OnEvent(events.saleInvoice.onEdited)
async handleUnlinkUnpresentedKeysOnInvoiceEdited({
saleInvoiceDTO,
saleInvoice,
trx,
}: ISaleInvoiceEditedPayload) {
// if (isEmpty(saleInvoiceDTO.attachments)) return;
const keys = saleInvoiceDTO.attachments?.map(
(attachment) => attachment.key,
);
await this.unlinkAttachmentService.unlinkUnpresentedKeys(
keys,
'SaleInvoice',
saleInvoice.id,
trx,
);
}
/**
* Handles linking all the presented keys of the edited sale invoice.
* @param {ISaleInvoiceEditedPayload}
* @returns {Promise<void>}
*/
@OnEvent(events.saleInvoice.onEdited)
async handleLinkPresentedKeysOnInvoiceEdited({
saleInvoiceDTO,
oldSaleInvoice,
trx,
}: ISaleInvoiceEditedPayload) {
if (isEmpty(saleInvoiceDTO.attachments)) return;
const keys = saleInvoiceDTO.attachments?.map(
(attachment) => attachment.key,
);
await this.linkAttachmentService.bulkLink(
keys,
'SaleInvoice',
oldSaleInvoice.id,
trx,
);
}
/**
* Unlink all attachments once the invoice deleted.
* @param {ISaleInvoiceDeletedPayload}
* @returns {Promise<void>}
*/
@OnEvent(events.saleInvoice.onDeleting)
async handleUnlinkAttachmentsOnInvoiceDeleted({
oldSaleInvoice,
trx,
}: ISaleInvoiceDeletingPayload) {
await this.unlinkAttachmentService.unlinkAllModelKeys(
'SaleInvoice',
oldSaleInvoice.id,
trx,
);
}
}

View File

@@ -0,0 +1,125 @@
import { isEmpty } from 'lodash';
import { Injectable } from '@nestjs/common';
import { OnEvent } from '@nestjs/event-emitter';
import {
ISaleReceiptCreatedPayload,
ISaleReceiptCreatingPayload,
ISaleReceiptDeletingPayload,
ISaleReceiptEditedPayload,
} from '../../SaleReceipts/types/SaleReceipts.types';
import { ValidateAttachments } from '../ValidateAttachments';
import { LinkAttachment } from '../LinkAttachment';
import { UnlinkAttachment } from '../UnlinkAttachment';
import { events } from '@/common/events/events';
@Injectable()
export class AttachmentsOnSaleReceipt {
constructor(
private readonly linkAttachmentService: LinkAttachment,
private readonly unlinkAttachmentService: UnlinkAttachment,
private readonly validateDocuments: ValidateAttachments,
) {}
/**
* Validates the attachment keys on creating sale receipt.
* @param {ISaleReceiptCreatingPayload}
* @returns {Promise<void>}
*/
@OnEvent(events.saleReceipt.onCreating)
async validateAttachmentsOnSaleInvoiceCreate({
saleReceiptDTO,
}: ISaleReceiptCreatingPayload): Promise<void> {
if (isEmpty(saleReceiptDTO.attachments)) {
return;
}
const documentKeys = saleReceiptDTO?.attachments?.map((a) => a.key);
await this.validateDocuments.validate(documentKeys);
}
/**
* Handles linking the attachments of the created sale receipt.
* @param {ISaleReceiptCreatedPayload}
* @returns {Promise<void>}
*/
@OnEvent(events.saleReceipt.onCreated)
async handleAttachmentsOnSaleInvoiceCreated({
saleReceiptDTO,
saleReceipt,
trx,
}: ISaleReceiptCreatedPayload): Promise<void> {
if (isEmpty(saleReceiptDTO.attachments)) return;
const keys = saleReceiptDTO.attachments?.map(
(attachment) => attachment.key,
);
await this.linkAttachmentService.bulkLink(
keys,
'SaleReceipt',
saleReceipt.id,
trx,
);
}
/**
* Handles unlinking all the unpresented keys of the edited sale receipt.
* @param {ISaleReceiptEditedPayload}
*/
@OnEvent(events.saleReceipt.onEdited)
async handleUnlinkUnpresentedKeysOnInvoiceEdited({
saleReceiptDTO,
saleReceipt,
trx,
}: ISaleReceiptEditedPayload) {
const keys = saleReceiptDTO.attachments?.map(
(attachment) => attachment.key,
);
await this.unlinkAttachmentService.unlinkUnpresentedKeys(
keys,
'SaleReceipt',
saleReceipt.id,
trx,
);
}
/**
* Handles linking all the presented keys of the edited sale receipt.
* @param {ISaleReceiptEditedPayload}
* @returns {Promise<void>}
*/
@OnEvent(events.saleReceipt.onEdited)
async handleLinkPresentedKeysOnInvoiceEdited({
saleReceiptDTO,
oldSaleReceipt,
trx,
}: ISaleReceiptEditedPayload) {
if (isEmpty(saleReceiptDTO.attachments)) return;
const keys = saleReceiptDTO.attachments?.map(
(attachment) => attachment.key,
);
await this.linkAttachmentService.bulkLink(
keys,
'SaleReceipt',
oldSaleReceipt.id,
trx,
);
}
/**
* Unlink all attachments once the receipt deleted.
* @param {ISaleReceiptDeletingPayload}
* @returns {Promise<void>}
*/
@OnEvent(events.saleReceipt.onDeleting)
async handleUnlinkAttachmentsOnReceiptDeleted({
oldSaleReceipt,
trx,
}: ISaleReceiptDeletingPayload) {
await this.unlinkAttachmentService.unlinkAllModelKeys(
'SaleReceipt',
oldSaleReceipt.id,
trx,
);
}
}

View File

@@ -0,0 +1,125 @@
import { isEmpty } from 'lodash';
import {
IVendorCreditCreatedPayload,
IVendorCreditCreatingPayload,
IVendorCreditDeletingPayload,
IVendorCreditEditedPayload,
} from '../../VendorCredit/types/VendorCredit.types';
import { ValidateAttachments } from '../ValidateAttachments';
import { OnEvent } from '@nestjs/event-emitter';
import { Injectable } from '@nestjs/common';
import { UnlinkAttachment } from '../UnlinkAttachment';
import { LinkAttachment } from '../LinkAttachment';
import { events } from '@/common/events/events';
@Injectable()
export class AttachmentsOnVendorCredits {
constructor(
private readonly linkAttachmentService: LinkAttachment,
private readonly unlinkAttachmentService: UnlinkAttachment,
private readonly validateDocuments: ValidateAttachments,
) {}
/**
* Validates the attachment keys on creating vendor credit.
* @param {IVendorCreditCreatingPayload}
* @returns {Promise<void>}
*/
@OnEvent(events.vendorCredit.onCreating)
async validateAttachmentsOnVendorCreditCreate({
vendorCreditCreateDTO,
}: IVendorCreditCreatingPayload): Promise<void> {
if (isEmpty(vendorCreditCreateDTO.attachments)) {
return;
}
const documentKeys = vendorCreditCreateDTO?.attachments?.map((a) => a.key);
await this.validateDocuments.validate(documentKeys);
}
/**
* Handles linking the attachments of the created vendor credit.
* @param {IVendorCreditCreatedPayload}
* @returns {Promise<void>}
*/
@OnEvent(events.vendorCredit.onCreated)
async handleAttachmentsOnVendorCreditCreated({
vendorCreditCreateDTO,
vendorCredit,
trx,
}: IVendorCreditCreatedPayload): Promise<void> {
if (isEmpty(vendorCreditCreateDTO.attachments)) return;
const keys = vendorCreditCreateDTO.attachments?.map(
(attachment) => attachment.key,
);
await this.linkAttachmentService.bulkLink(
keys,
'VendorCredit',
vendorCredit.id,
trx,
);
}
/**
* Handles unlinking all the unpresented keys of the edited vendor credit.
* @param {IVendorCreditEditedPayload}
*/
@OnEvent(events.vendorCredit.onEdited)
async handleUnlinkUnpresentedKeysOnVendorCreditEdited({
vendorCreditDTO,
oldVendorCredit,
trx,
}: IVendorCreditEditedPayload) {
const keys = vendorCreditDTO.attachments?.map(
(attachment) => attachment.key,
);
await this.unlinkAttachmentService.unlinkUnpresentedKeys(
keys,
'VendorCredit',
oldVendorCredit.id,
trx,
);
}
/**
* Handles linking all the presented keys of the edited vendor credit.
* @param {IVendorCreditEditedPayload}
* @returns {Promise<void>}
*/
@OnEvent(events.vendorCredit.onEdited)
async handleLinkPresentedKeysOnVendorCreditEdited({
vendorCreditDTO,
oldVendorCredit,
trx,
}: IVendorCreditEditedPayload) {
if (isEmpty(vendorCreditDTO.attachments)) return;
const keys = vendorCreditDTO.attachments?.map(
(attachment) => attachment.key,
);
await this.linkAttachmentService.bulkLink(
keys,
'VendorCredit',
oldVendorCredit.id,
trx,
);
}
/**
* Unlink all attachments once the vendor credit deleted.
* @param {IVendorCreditDeletingPayload}
* @returns {Promise<void>}
*/
@OnEvent(events.vendorCredit.onDeleting)
async handleUnlinkAttachmentsOnVendorCreditDeleted({
oldVendorCredit,
trx,
}: IVendorCreditDeletingPayload) {
await this.unlinkAttachmentService.unlinkAllModelKeys(
'VendorCredit',
oldVendorCredit.id,
trx,
);
}
}

View File

@@ -0,0 +1,22 @@
import { TenantBaseModel } from '@/modules/System/models/TenantBaseModel';
export class DocumentModel extends TenantBaseModel {
originName!: string;
size!: number;
mimeType!: string;
key!: string;
/**
* Table name
*/
static get tableName() {
return 'documents';
}
/**
* Model timestamps.
*/
get timestamps() {
return ['createdAt', 'updatedAt'];
}
}

View File

@@ -0,0 +1,41 @@
import { TenantBaseModel } from '@/modules/System/models/TenantBaseModel';
import { Model, mixin } from 'objection';
import { DocumentModel } from './Document.model';
export class DocumentLinkModel extends TenantBaseModel {
document!: DocumentModel;
/**
* Table name
*/
static get tableName() {
return 'document_links';
}
/**
* Model timestamps.
*/
get timestamps() {
return ['createdAt', 'updatedAt'];
}
/**
* Relationship mapping.
*/
static get relationMappings() {
const { DocumentModel } = require('./Document.model');
return {
/**
* Sale invoice associated entries.
*/
document: {
relation: Model.HasOneRelation,
modelClass: DocumentModel,
join: {
from: 'document_links.documentId',
to: 'documents.id',
},
},
};
}
}

View File

@@ -0,0 +1,10 @@
import path from 'path';
// import config from '@/config';
export const getUploadedObjectUri = (objectKey: string) => {
return '';
// return new URL(
// path.join(config.s3.bucket, objectKey),
// config.s3.endpoint
// ).toString();
};

View File

@@ -0,0 +1,28 @@
export const jwtConstants = {
secret:
'DO NOT USE THIS VALUE. INSTEAD, CREATE A COMPLEX SECRET AND KEEP IT SAFE OUTSIDE OF THE SOURCE CODE.',
};
export const ERRORS = {
INVALID_DETAILS: 'INVALID_DETAILS',
USER_INACTIVE: 'USER_INACTIVE',
EMAIL_NOT_FOUND: 'EMAIL_NOT_FOUND',
TOKEN_INVALID: 'TOKEN_INVALID',
USER_NOT_FOUND: 'USER_NOT_FOUND',
TOKEN_EXPIRED: 'TOKEN_EXPIRED',
PHONE_NUMBER_EXISTS: 'PHONE_NUMBER_EXISTS',
EMAIL_EXISTS: 'EMAIL_EXISTS',
SIGNUP_RESTRICTED_NOT_ALLOWED: 'SIGNUP_RESTRICTED_NOT_ALLOWED',
SIGNUP_RESTRICTED: 'SIGNUP_RESTRICTED',
SIGNUP_CONFIRM_TOKEN_INVALID: 'SIGNUP_CONFIRM_TOKEN_INVALID',
USER_ALREADY_VERIFIED: 'USER_ALREADY_VERIFIED',
};
export const IS_PUBLIC_ROUTE = 'isPublic';
export const SendResetPasswordMailQueue = 'SendResetPasswordMailQueue';
export const SendResetPasswordMailJob = 'SendResetPasswordMailJob';
export const SendSignupVerificationMailQueue =
'SendSignupVerificationMailQueue';
export const SendSignupVerificationMailJob = 'SendSignupVerificationMailJob';

View File

@@ -0,0 +1,96 @@
// @ts-nocheck
import {
Body,
Controller,
Get,
Param,
Post,
Request,
UseGuards,
} from '@nestjs/common';
import { ApiTags, ApiOperation, ApiBody, ApiParam } from '@nestjs/swagger';
import { JwtAuthGuard, PublicRoute } from './guards/jwt.guard';
import { AuthenticationApplication } from './AuthApplication.sevice';
import { AuthSignupDto } from './dtos/AuthSignup.dto';
import { AuthSigninDto } from './dtos/AuthSignin.dto';
import { LocalAuthGuard } from './guards/local.guard';
import { JwtService } from '@nestjs/jwt';
import { AuthSigninService } from './commands/AuthSignin.service';
@Controller('/auth')
@ApiTags('Auth')
@PublicRoute()
export class AuthController {
constructor(
private readonly authApp: AuthenticationApplication,
private readonly authSignin: AuthSigninService,
) {}
@Post('/signin')
@UseGuards(LocalAuthGuard)
@ApiOperation({ summary: 'Sign in a user' })
@ApiBody({ type: AuthSigninDto })
signin(@Request() req: Request, @Body() signinDto: AuthSigninDto) {
const { user } = req;
return { access_token: this.authSignin.signToken(user) };
}
@Post('/signup')
@ApiOperation({ summary: 'Sign up a new user' })
@ApiBody({ type: AuthSignupDto })
signup(@Request() req: Request, @Body() signupDto: AuthSignupDto) {
return this.authApp.signUp(signupDto);
}
@Post('/signup/confirm')
@ApiOperation({ summary: 'Confirm user signup' })
@ApiBody({
schema: {
type: 'object',
properties: {
email: { type: 'string', example: 'user@example.com' },
token: { type: 'string', example: 'confirmation-token' },
},
},
})
signupConfirm(@Body('email') email: string, @Body('token') token: string) {
return this.authApp.signUpConfirm(email, token);
}
@Post('/send_reset_password')
@ApiOperation({ summary: 'Send reset password email' })
@ApiBody({
schema: {
type: 'object',
properties: {
email: { type: 'string', example: 'user@example.com' },
},
},
})
sendResetPassword(@Body('email') email: string) {
return this.authApp.sendResetPassword(email);
}
@Post('/reset_password/:token')
@ApiOperation({ summary: 'Reset password using token' })
@ApiParam({ name: 'token', description: 'Reset password token' })
@ApiBody({
schema: {
type: 'object',
properties: {
password: { type: 'string', example: 'new-password' },
},
},
})
resetPassword(
@Param('token') token: string,
@Body('password') password: string,
) {
return this.authApp.resetPassword(token, password);
}
@Get('/meta')
meta() {
return this.authApp.getAuthMeta();
}
}

View File

@@ -0,0 +1,74 @@
import { ModelObject } from 'objection';
import { SystemUser } from '../System/models/SystemUser';
import { TenantModel } from '../System/models/TenantModel';
import { AuthSignupDto } from './dtos/AuthSignup.dto';
export interface JwtPayload {
sub: string;
iat: number;
exp: number;
}
export interface IAuthSignedInEventPayload {}
export interface IAuthSigningInEventPayload {}
export interface IAuthSignInPOJO {}
export interface IAuthSigningInEventPayload {
email: string;
password: string;
user: ModelObject<SystemUser>;
}
export interface IAuthSignedInEventPayload {
email: string;
password: string;
user: ModelObject<SystemUser>;
}
export interface IAuthSigningUpEventPayload {
signupDTO: AuthSignupDto;
}
export interface IAuthSignedUpEventPayload {
signupDTO: AuthSignupDto;
tenant: TenantModel;
user: SystemUser;
}
export interface IAuthSignInPOJO {
user: ModelObject<SystemUser>;
token: string;
tenant: ModelObject<TenantModel>;
}
export interface IAuthResetedPasswordEventPayload {
user: SystemUser;
token: string;
password: string;
}
export interface IAuthSendingResetPassword {
user: SystemUser;
token: string;
}
export interface IAuthSendedResetPassword {
user: SystemUser;
token: string;
}
export interface IAuthGetMetaPOJO {
signupDisabled: boolean;
}
export interface IAuthSignUpVerifingEventPayload {
email: string;
verifyToken: string;
userId: number;
}
export interface IAuthSignUpVerifiedEventPayload {
email: string;
verifyToken: string;
userId: number;
}

View File

@@ -0,0 +1,75 @@
import { Module } from '@nestjs/common';
import { AuthController } from './Auth.controller';
import { JwtModule } from '@nestjs/jwt';
import { JwtStrategy } from './strategies/Jwt.strategy';
import { AuthenticationApplication } from './AuthApplication.sevice';
import { AuthSendResetPasswordService } from './commands/AuthSendResetPassword.service';
import { AuthResetPasswordService } from './commands/AuthResetPassword.service';
import { AuthSignupConfirmResendService } from './commands/AuthSignupConfirmResend.service';
import { AuthSignupConfirmService } from './commands/AuthSignupConfirm.service';
import { AuthSignupService } from './commands/AuthSignup.service';
import { AuthSigninService } from './commands/AuthSignin.service';
import { PasswordReset } from './models/PasswordReset';
import { TenantDBManagerModule } from '../TenantDBManager/TenantDBManager.module';
import { AuthenticationMailMesssages } from './AuthMailMessages.esrvice';
import { LocalStrategy } from './strategies/Local.strategy';
import { PassportModule } from '@nestjs/passport';
import { APP_GUARD } from '@nestjs/core';
import { JwtAuthGuard } from './guards/jwt.guard';
import { AuthMailSubscriber } from './Subscribers/AuthMail.subscriber';
import { BullModule } from '@nestjs/bullmq';
import {
SendResetPasswordMailQueue,
SendSignupVerificationMailQueue,
} from './Auth.constants';
import { SendResetPasswordMailProcessor } from './processors/SendResetPasswordMail.processor';
import { SendSignupVerificationMailProcessor } from './processors/SendSignupVerificationMail.processor';
import { MailModule } from '../Mail/Mail.module';
import { ConfigService } from '@nestjs/config';
import { InjectSystemModel } from '../System/SystemModels/SystemModels.module';
import { GetAuthMetaService } from './queries/GetAuthMeta.service';
const models = [InjectSystemModel(PasswordReset)];
@Module({
controllers: [AuthController],
imports: [
MailModule,
PassportModule.register({ defaultStrategy: 'jwt' }),
JwtModule.registerAsync({
inject: [ConfigService],
useFactory: (configService: ConfigService) => ({
secret: configService.get('jwt.secret'),
signOptions: { expiresIn: '1d', algorithm: 'HS384' },
verifyOptions: { algorithms: ['HS384'] },
}),
}),
TenantDBManagerModule,
BullModule.registerQueue({ name: SendResetPasswordMailQueue }),
BullModule.registerQueue({ name: SendSignupVerificationMailQueue }),
],
exports: [...models],
providers: [
...models,
LocalStrategy,
JwtStrategy,
AuthenticationApplication,
AuthSendResetPasswordService,
AuthResetPasswordService,
AuthSignupConfirmResendService,
AuthSignupConfirmService,
AuthSignupService,
AuthSigninService,
AuthenticationMailMesssages,
SendResetPasswordMailProcessor,
SendSignupVerificationMailProcessor,
GetAuthMetaService,
{
provide: APP_GUARD,
useClass: JwtAuthGuard,
},
AuthMailSubscriber,
],
})
export class AuthModule {}

View File

@@ -0,0 +1,10 @@
import * as bcrypt from 'bcrypt';
export const hashPassword = (password: string): Promise<string> =>
new Promise((resolve) => {
bcrypt.genSalt(10, (error, salt) => {
bcrypt.hash(password, salt, (err, hash: string) => {
resolve(hash);
});
});
});

View File

@@ -0,0 +1,86 @@
import { Injectable } from '@nestjs/common';
import { AuthSigninService } from './commands/AuthSignin.service';
import { AuthSignupService } from './commands/AuthSignup.service';
import { AuthSignupConfirmService } from './commands/AuthSignupConfirm.service';
import { AuthSignupConfirmResendService } from './commands/AuthSignupConfirmResend.service';
import { AuthSigninDto } from './dtos/AuthSignin.dto';
import { AuthSignupDto } from './dtos/AuthSignup.dto';
import { AuthSendResetPasswordService } from './commands/AuthSendResetPassword.service';
import { AuthResetPasswordService } from './commands/AuthResetPassword.service';
import { GetAuthMetaService } from './queries/GetAuthMeta.service';
@Injectable()
export class AuthenticationApplication {
constructor(
private readonly authSigninService: AuthSigninService,
private readonly authSignupService: AuthSignupService,
private readonly authSignupConfirmService: AuthSignupConfirmService,
private readonly authSignUpConfirmResendService: AuthSignupConfirmResendService,
private readonly authResetPasswordService: AuthResetPasswordService,
private readonly authSendResetPasswordService: AuthSendResetPasswordService,
private readonly authGetMeta: GetAuthMetaService,
) {}
/**
* Signin and generates JWT token.
* @param {string} email - Email address.
* @param {string} password - Password.
*/
public async signIn(email: string, password: string) {
return this.authSigninService.signin(email, password);
}
/**
* Signup a new user.
* @param {IRegisterDTO} signupDTO
*/
public async signUp(signupDto: AuthSignupDto) {
return this.authSignupService.signUp(signupDto);
}
/**
* Verifying the provided user's email after signin-up.
* @param {string} email - User email.
* @param {string} token - Verification token.
* @returns {Promise<SystemUser>}
*/
public async signUpConfirm(email: string, token: string) {
return this.authSignupConfirmService.signupConfirm(email, token);
}
/**
* Re-sends the confirmation email of the given system user.
* @param {number} userId - System user id.
* @returns {Promise<void>}
*/
public async signUpConfirmResend(userId: number) {
return this.authSignUpConfirmResendService.signUpConfirmResend(userId);
}
/**
* Generates and retrieve password reset token for the given user email.
* @param {string} email
* @return {<Promise<IPasswordReset>}
*/
public async sendResetPassword(email: string) {
return this.authSendResetPasswordService.sendResetPassword(email);
}
/**
* Resets a user password from given token.
* @param {string} token - Password reset token.
* @param {string} password - New Password.
* @return {Promise<void>}
*/
public async resetPassword(token: string, password: string) {
return this.authResetPasswordService.resetPassword(token, password);
}
/**
* Retrieves the authentication meta for SPA.
* @returns {Promise<IAuthGetMetaPOJO>}
*/
public async getAuthMeta() {
return this.authGetMeta.getAuthMeta();
}
}

View File

@@ -0,0 +1,82 @@
import { Injectable } from '@nestjs/common';
import * as path from 'path';
import { SystemUser } from '../System/models/SystemUser';
import { ModelObject } from 'objection';
import { ConfigService } from '@nestjs/config';
import { Mail } from '../Mail/Mail';
import { MailTransporter } from '../Mail/MailTransporter.service';
@Injectable()
export class AuthenticationMailMesssages {
constructor(
private readonly configService: ConfigService,
private readonly mailTransporter: MailTransporter,
) {}
/**
* Sends reset password message.
* @param {ISystemUser} user - The system user.
* @param {string} token - Reset password token.
* @returns {Mail}
*/
resetPasswordMessage(user: ModelObject<SystemUser>, token: string) {
const baseURL = this.configService.get('baseURL');
return new Mail()
.setSubject('Bigcapital - Password Reset')
.setView('mail/ResetPassword.html')
.setTo(user.email)
.setAttachments([
{
filename: 'bigcapital.png',
path: path.join(global.__static_dirname, `/images/bigcapital.png`),
cid: 'bigcapital_logo',
},
])
.setData({
resetPasswordUrl: `${baseURL}/auth/reset_password/${token}`,
first_name: user.firstName,
last_name: user.lastName,
});
}
sendResetPasswordMail(user: ModelObject<SystemUser>, token: string) {
const mail = this.resetPasswordMessage(user, token);
return this.mailTransporter.send(mail);
}
/**
* Sends signup verification mail.
* @param {string} email - Email address
* @param {string} fullName - User name.
* @param {string} token - Verification token.
* @returns {Mail}
*/
signupVerificationMail(email: string, fullName: string, token: string) {
const baseURL = this.configService.get('baseURL');
const verifyUrl = `${baseURL}/auth/email_confirmation?token=${token}&email=${email}`;
return new Mail()
.setSubject('Bigcapital - Verify your email')
.setView('mail/SignupVerifyEmail.html')
.setTo(email)
.setAttachments([
{
filename: 'bigcapital.png',
path: path.join(global.__static_dirname, `/images/bigcapital.png`),
cid: 'bigcapital_logo',
},
])
.setData({ verifyUrl, fullName });
}
sendSignupVerificationMail(email: string, fullName: string, token: string) {
const mail = this.signupVerificationMail(
email,
fullName,
token,
);
return this.mailTransporter.send(mail);
}
}

View File

@@ -0,0 +1,88 @@
import { ConfigService } from '@nestjs/config';
import { Inject, Injectable } from '@nestjs/common';
import { SystemUser } from '@/modules/System/models/SystemUser';
import { PasswordReset } from '../models/PasswordReset';
import { ServiceError } from '@/modules/Items/ServiceError';
import { ERRORS } from '../Auth.constants';
import { hashPassword } from '../Auth.utils';
import { EventEmitter2 } from '@nestjs/event-emitter';
import { events } from '@/common/events/events';
import { IAuthResetedPasswordEventPayload } from '../Auth.interfaces';
@Injectable()
export class AuthResetPasswordService {
/**
* @param {ConfigService} configService - Config service.
* @param {EventEmitter2} eventEmitter - Event emitter.
* @param {typeof SystemUser} systemUserModel
* @param {typeof PasswordReset} passwordResetModel - Reset password model.
*/
constructor(
private readonly configService: ConfigService,
private readonly eventEmitter: EventEmitter2,
@Inject(SystemUser.name)
private readonly systemUserModel: typeof SystemUser,
@Inject(PasswordReset.name)
private readonly passwordResetModel: typeof PasswordReset,
) {}
/**
* Resets a user password from given token.
* @param {string} token - Password reset token.
* @param {string} password - New Password.
* @return {Promise<void>}
*/
public async resetPassword(token: string, password: string): Promise<void> {
// Finds the password reset token.
const tokenModel = await this.passwordResetModel
.query()
.findOne('token', token);
// In case the password reset token not found throw token invalid error..
if (!tokenModel) {
throw new ServiceError(ERRORS.TOKEN_INVALID);
}
const resetPasswordSeconds = this.configService.get('resetPasswordSeconds');
// Different between tokne creation datetime and current time.
if (moment().diff(tokenModel.createdAt, 'seconds') > resetPasswordSeconds) {
// Deletes the expired token by expired token email.
await this.deletePasswordResetToken(tokenModel.email);
throw new ServiceError(ERRORS.TOKEN_EXPIRED);
}
const user = await this.systemUserModel
.query()
.findOne({ email: tokenModel.email });
if (!user) {
throw new ServiceError(ERRORS.USER_NOT_FOUND);
}
const hashedPassword = await hashPassword(password);
await this.systemUserModel
.query()
.findById(user.id)
.update({ password: hashedPassword });
// Deletes the used token.
await this.deletePasswordResetToken(tokenModel.email);
// Triggers `onResetPassword` event.
await this.eventEmitter.emitAsync(events.auth.resetPassword, {
user,
token,
password,
} as IAuthResetedPasswordEventPayload);
}
/**
* Deletes the password reset token by the given email.
* @param {string} email
* @returns {Promise}
*/
private async deletePasswordResetToken(email: string) {
return PasswordReset.query().where('email', email).delete();
}
}

View File

@@ -0,0 +1,70 @@
import { Inject, Injectable } from '@nestjs/common';
import * as uniqid from 'uniqid';
import {
IAuthSendedResetPassword,
IAuthSendingResetPassword,
} from '../Auth.interfaces';
import { EventEmitter2 } from '@nestjs/event-emitter';
import { PasswordReset } from '../models/PasswordReset';
import { SystemUser } from '@/modules/System/models/SystemUser';
import { events } from '@/common/events/events';
@Injectable()
export class AuthSendResetPasswordService {
/**
* @param {EventEmitter2} eventPublisher - Event emitter.
* @param {typeof PasswordReset} resetPasswordModel - Password reset model.
* @param {typeof SystemUser} systemUserModel - System user model.
*/
constructor(
private readonly eventPublisher: EventEmitter2,
@Inject(PasswordReset.name)
private readonly resetPasswordModel: typeof PasswordReset,
@Inject(SystemUser.name)
private readonly systemUserModel: typeof SystemUser,
) {}
/**
* Sends the given email reset password email.
* @param {string} email - Email address.
*/
async sendResetPassword(email: string): Promise<void> {
const user = await this.systemUserModel
.query()
.findOne({ email })
.throwIfNotFound();
const token: string = uniqid();
// Triggers sending reset password event.
await this.eventPublisher.emitAsync(events.auth.sendingResetPassword, {
user,
token,
} as IAuthSendingResetPassword);
// Delete all stored tokens of reset password that associate to the give email.
this.deletePasswordResetToken(email);
// Creates a new password reset row with unique token.
const passwordReset = await this.resetPasswordModel.query().insert({
email,
token,
});
// Triggers sent reset password event.
await this.eventPublisher.emitAsync(events.auth.sendResetPassword, {
user,
token,
} as IAuthSendedResetPassword);
}
/**
* Deletes the password reset token by the given email.
* @param {string} email
* @returns {Promise}
*/
private async deletePasswordResetToken(email: string) {
return this.resetPasswordModel.query().where('email', email).delete();
}
}

View File

@@ -0,0 +1,87 @@
import { ClsService } from 'nestjs-cls';
import { Inject, Injectable, UnauthorizedException } from '@nestjs/common';
import { JwtService } from '@nestjs/jwt';
import { SystemUser } from '@/modules/System/models/SystemUser';
import { ModelObject } from 'objection';
import { JwtPayload } from '../Auth.interfaces';
@Injectable()
export class AuthSigninService {
constructor(
@Inject(SystemUser.name)
private readonly systemUserModel: typeof SystemUser,
private readonly jwtService: JwtService,
private readonly clsService: ClsService,
) {}
/**
* Validates the given email and password.
* @param {string} email - Signin email address.
* @param {string} password - Signin password.
* @returns {Promise<ModelObject<SystemUser>>}
*/
async signin(
email: string,
password: string,
): Promise<ModelObject<SystemUser>> {
let user: SystemUser;
try {
user = await this.systemUserModel
.query()
.findOne({ email })
.throwIfNotFound();
} catch (err) {
throw new UnauthorizedException(
`There isn't any user with email: ${email}`,
);
}
if (!(await user.checkPassword(password))) {
throw new UnauthorizedException(
`Wrong password for user with email: ${email}`,
);
}
if (!user.verified) {
throw new UnauthorizedException(
`The user is not verified yet, check out your mail inbox.`
);
}
return user;
}
/**
* Verifies the given jwt payload.
* @param {JwtPayload} payload
* @returns {Promise<any>}
*/
async verifyPayload(payload: JwtPayload): Promise<any> {
let user: SystemUser;
try {
user = await this.systemUserModel
.query()
.findOne({ email: payload.sub })
.throwIfNotFound();
this.clsService.set('tenantId', user.tenantId);
this.clsService.set('userId', user.id);
} catch (error) {
throw new UnauthorizedException(
`There isn't any user with email: ${payload.sub}`,
);
}
return payload;
}
/**
*
* @param {SystemUser} user
* @returns {string}
*/
signToken(user: SystemUser): string {
const payload = {
sub: user.email,
};
return this.jwtService.sign(payload);
}
}

View File

@@ -0,0 +1,130 @@
import * as crypto from 'crypto';
import * as moment from 'moment';
import { events } from '@/common/events/events';
import { ServiceError } from '@/modules/Items/ServiceError';
import { SystemUser } from '@/modules/System/models/SystemUser';
import { TenantsManagerService } from '@/modules/TenantDBManager/TenantsManager';
import { Inject, Injectable } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import { EventEmitter2 } from '@nestjs/event-emitter';
import { isEmpty } from 'class-validator';
import { AuthSignupDto } from '../dtos/AuthSignup.dto';
import {
IAuthSignedUpEventPayload,
IAuthSigningUpEventPayload,
} from '../Auth.interfaces';
import { defaultTo } from 'ramda';
import { ERRORS } from '../Auth.constants';
import { hashPassword } from '../Auth.utils';
@Injectable()
export class AuthSignupService {
/**
* @param {ConfigService} configService - Config service
* @param {EventEmitter2} eventEmitter - Event emitter
* @param {TenantsManagerService} tenantsManager - Tenants manager
* @param {typeof SystemUser} systemUserModel - System user model
*/
constructor(
private readonly configService: ConfigService,
private readonly eventEmitter: EventEmitter2,
private readonly tenantsManager: TenantsManagerService,
@Inject(SystemUser.name)
private readonly systemUserModel: typeof SystemUser,
) {}
/**
* Registers a new tenant with user from user input.
* @param {AuthSignupDto} signupDTO
*/
public async signUp(signupDTO: AuthSignupDto) {
// Validates the signup disable restrictions.
await this.validateSignupRestrictions(signupDTO.email);
// Validates the given email uniqiness.
await this.validateEmailUniqiness(signupDTO.email);
const hashedPassword = await hashPassword(signupDTO.password);
const signupConfirmation = this.configService.get('signupConfirmation');
const verifyTokenCrypto = crypto.randomBytes(64).toString('hex');
const verifiedEnabed = defaultTo(signupConfirmation.enabled, false);
const verifyToken = verifiedEnabed ? verifyTokenCrypto : '';
const verified = !verifiedEnabed;
const inviteAcceptedAt = moment().format('YYYY-MM-DD');
// Triggers signin up event.
await this.eventEmitter.emitAsync(events.auth.signingUp, {
signupDTO,
} as IAuthSigningUpEventPayload);
const tenant = await this.tenantsManager.createTenant();
const user = await this.systemUserModel.query().insert({
...signupDTO,
verifyToken,
verified,
active: true,
password: hashedPassword,
tenantId: tenant.id,
inviteAcceptedAt,
});
// Triggers signed up event.
await this.eventEmitter.emitAsync(events.auth.signUp, {
signupDTO,
tenant,
user,
} as IAuthSignedUpEventPayload);
return {
userId: user.id,
tenantId: user.tenantId,
organizationId: tenant.organizationId,
};
}
/**
* Validates email uniqiness on the storage.
* @param {string} email - Email address
*/
private async validateEmailUniqiness(email: string) {
const isEmailExists = await this.systemUserModel.query().findOne({ email });
if (isEmailExists) {
throw new ServiceError(ERRORS.EMAIL_EXISTS);
}
}
/**
* Validate sign-up disable restrictions.
* @param {string} email - Signup email address
*/
private async validateSignupRestrictions(email: string) {
const signupRestrictions = this.configService.get('signupRestrictions');
// Can't continue if the signup is not disabled.
if (!signupRestrictions.disabled) return;
// Validate the allowed email addresses and domains.
if (
!isEmpty(signupRestrictions.allowedEmails) ||
!isEmpty(signupRestrictions.allowedDomains)
) {
const emailDomain = email.split('@').pop();
const isAllowedEmail =
signupRestrictions.allowedEmails.indexOf(email) !== -1;
const isAllowedDomain = signupRestrictions.allowedDomains.some(
(domain) => emailDomain === domain,
);
if (!isAllowedEmail && !isAllowedDomain) {
throw new ServiceError(ERRORS.SIGNUP_RESTRICTED_NOT_ALLOWED);
}
// Throw error if the signup is disabled with no exceptions.
} else {
throw new ServiceError(ERRORS.SIGNUP_RESTRICTED);
}
}
}

View File

@@ -0,0 +1,62 @@
import { ServiceError } from '@/modules/Items/ServiceError';
import { SystemUser } from '@/modules/System/models/SystemUser';
import { Inject, Injectable } from '@nestjs/common';
import { ERRORS } from '../Auth.constants';
import { EventEmitter2 } from '@nestjs/event-emitter';
import {
IAuthSignUpVerifiedEventPayload,
IAuthSignUpVerifingEventPayload,
} from '../Auth.interfaces';
import { events } from '@/common/events/events';
@Injectable()
export class AuthSignupConfirmService {
constructor(
private readonly eventPublisher: EventEmitter2,
@Inject(SystemUser.name)
private readonly systemUserModel: typeof SystemUser,
) {}
/**
* Verifies the provided user's email after signing-up.
* @throws {ServiceErrors}
* @param {IRegisterDTO} signupDTO
* @returns {Promise<ISystemUser>}
*/
public async signupConfirm(
email: string,
verifyToken: string,
): Promise<SystemUser> {
const foundUser = await this.systemUserModel
.query()
.findOne({ email, verifyToken });
if (!foundUser) {
throw new ServiceError(ERRORS.SIGNUP_CONFIRM_TOKEN_INVALID);
}
const userId = foundUser.id;
// Triggers `signUpConfirming` event.
await this.eventPublisher.emitAsync(events.auth.signUpConfirming, {
email,
verifyToken,
userId,
} as IAuthSignUpVerifingEventPayload);
const updatedUser = await this.systemUserModel
.query()
.patchAndFetchById(foundUser.id, {
verified: true,
verifyToken: '',
});
// Triggers `signUpConfirmed` event.
await this.eventPublisher.emitAsync(events.auth.signUpConfirmed, {
email,
verifyToken,
userId,
} as IAuthSignUpVerifiedEventPayload);
return updatedUser as SystemUser;
}
}

View File

@@ -0,0 +1,5 @@
export class AuthSignupConfirmResendService {
signUpConfirmResend(userId: number) {
return;
}
}

View File

@@ -0,0 +1,11 @@
import { IsNotEmpty, IsString } from 'class-validator';
export class AuthSigninDto {
@IsNotEmpty()
@IsString()
password: string;
@IsNotEmpty()
@IsString()
email: string;
}

View File

@@ -0,0 +1,20 @@
import { IsEmail, IsNotEmpty, IsString } from 'class-validator';
export class AuthSignupDto {
@IsNotEmpty()
@IsString()
firstName: string;
@IsNotEmpty()
@IsString()
lastName: string;
@IsNotEmpty()
@IsString()
@IsEmail()
email: string;
@IsNotEmpty()
@IsString()
password: string;
}

View File

@@ -0,0 +1,5 @@
import { Injectable, ExecutionContext } from '@nestjs/common';
import { AuthGuard } from '@nestjs/passport';
@Injectable()
export class LocalAuthGuard extends AuthGuard('local') {}

View File

@@ -0,0 +1,28 @@
import { ExecutionContext, Injectable, SetMetadata } from '@nestjs/common';
import { Reflector } from '@nestjs/core';
import { AuthGuard } from '@nestjs/passport';
import { ClsService } from 'nestjs-cls';
import { IS_PUBLIC_ROUTE } from '../Auth.constants';
export const PublicRoute = () => SetMetadata(IS_PUBLIC_ROUTE, true);
@Injectable()
export class JwtAuthGuard extends AuthGuard('jwt') {
constructor(
private reflector: Reflector,
private readonly cls: ClsService,
) {
super();
}
canActivate(context: ExecutionContext) {
const isPublic = this.reflector.getAllAndOverride<boolean>(
IS_PUBLIC_ROUTE,
[context.getHandler(), context.getClass()],
);
if (isPublic) {
return true;
}
return super.canActivate(context);
}
}

View File

@@ -0,0 +1,21 @@
import { SystemModel } from '@/modules/System/models/SystemModel';
export class PasswordReset extends SystemModel {
readonly email: string;
readonly token: string;
readonly createdAt: Date;
/**
* Table name
*/
static get tableName() {
return 'password_resets';
}
/**
* Timestamps columns.
*/
get timestamps() {
return ['createdAt'];
}
}

View File

@@ -0,0 +1,42 @@
import { Processor, WorkerHost } from '@nestjs/bullmq';
import { Scope } from '@nestjs/common';
import {
SendResetPasswordMailJob,
SendResetPasswordMailQueue,
} from '../Auth.constants';
import { Process } from '@nestjs/bull';
import { Job } from 'bullmq';
import { AuthenticationMailMesssages } from '../AuthMailMessages.esrvice';
import { MailTransporter } from '@/modules/Mail/MailTransporter.service';
import { ModelObject } from 'objection';
import { SystemUser } from '@/modules/System/models/SystemUser';
@Processor({
name: SendResetPasswordMailQueue,
scope: Scope.REQUEST,
})
export class SendResetPasswordMailProcessor extends WorkerHost {
constructor(
private readonly authMailMesssages: AuthenticationMailMesssages,
private readonly mailTransporter: MailTransporter,
) {
super();
}
@Process(SendResetPasswordMailJob)
async process(job: Job<SendResetPasswordMailJobPayload>) {
try {
await this.authMailMesssages.sendResetPasswordMail(
job.data.user,
job.data.token,
);
} catch (error) {
console.log('Error occured during send reset password mail', error);
}
}
}
export interface SendResetPasswordMailJobPayload {
user: ModelObject<SystemUser>;
token: string;
}

View File

@@ -0,0 +1,42 @@
import { Scope } from '@nestjs/common';
import { Job } from 'bullmq';
import { Process } from '@nestjs/bull';
import { Processor, WorkerHost } from '@nestjs/bullmq';
import {
SendSignupVerificationMailJob,
SendSignupVerificationMailQueue,
} from '../Auth.constants';
import { MailTransporter } from '@/modules/Mail/MailTransporter.service';
import { AuthenticationMailMesssages } from '../AuthMailMessages.esrvice';
@Processor({
name: SendSignupVerificationMailQueue,
scope: Scope.REQUEST,
})
export class SendSignupVerificationMailProcessor extends WorkerHost {
constructor(
private readonly authMailMesssages: AuthenticationMailMesssages,
private readonly mailTransporter: MailTransporter,
) {
super();
}
@Process(SendSignupVerificationMailJob)
async process(job: Job<SendSignupVerificationMailJobPayload>) {
try {
await this.authMailMesssages.sendSignupVerificationMail(
job.data.email,
job.data.fullName,
job.data.token,
);
} catch (error) {
console.log('Error occured during send signup verification mail', error);
}
}
}
export interface SendSignupVerificationMailJobPayload {
email: string;
fullName: string;
token: string;
}

View File

@@ -0,0 +1,22 @@
import { Injectable } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import { IAuthGetMetaPOJO } from '../Auth.interfaces';
@Injectable()
export class GetAuthMetaService {
constructor(
private readonly configService: ConfigService,
) {
}
/**
* Retrieves the authentication meta for SPA.
* @returns {Promise<IAuthGetMetaPOJO>}
*/
public async getAuthMeta(): Promise<IAuthGetMetaPOJO> {
return {
signupDisabled: this.configService.get('signupRestrictions.disabled'),
};
}
}

View File

@@ -0,0 +1,24 @@
import { Injectable } from '@nestjs/common';
import { PassportStrategy } from '@nestjs/passport';
import { ExtractJwt, Strategy } from 'passport-jwt';
import { AuthSigninService } from '../commands/AuthSignin.service';
import { JwtPayload } from '../Auth.interfaces';
import { ConfigService } from '@nestjs/config';
@Injectable()
export class JwtStrategy extends PassportStrategy(Strategy, 'jwt') {
constructor(
private readonly authSigninService: AuthSigninService,
private readonly configService: ConfigService,
) {
super({
jwtFromRequest: ExtractJwt.fromAuthHeaderAsBearerToken(),
ignoreExpiration: false,
secretOrKey: configService.get('jwt.secret'),
});
}
validate(payload: JwtPayload) {
return this.authSigninService.verifyPayload(payload);
}
}

View File

@@ -0,0 +1,21 @@
import { Injectable } from '@nestjs/common';
import { PassportStrategy } from '@nestjs/passport';
import { Strategy } from 'passport-local';
import { AuthSigninService } from '../commands/AuthSignin.service';
import { ModelObject } from 'objection';
import { SystemUser } from '../../System/models/SystemUser';
@Injectable()
export class LocalStrategy extends PassportStrategy(Strategy, 'local') {
constructor(private readonly authSigninService: AuthSigninService) {
super({
usernameField: 'email',
passReqToCallback: false,
session: false,
});
}
validate(email: string, password: string): Promise<ModelObject<SystemUser>> {
return this.authSigninService.signin(email, password);
}
}

View File

@@ -0,0 +1,68 @@
import { Injectable } from '@nestjs/common';
import { events } from '@/common/events/events';
import { OnEvent } from '@nestjs/event-emitter';
import {
IAuthSendedResetPassword,
IAuthSignedUpEventPayload,
} from '../Auth.interfaces';
import { Queue } from 'bullmq';
import { InjectQueue } from '@nestjs/bullmq';
import { SendResetPasswordMailJobPayload } from '../processors/SendResetPasswordMail.processor';
import {
SendResetPasswordMailJob,
SendResetPasswordMailQueue,
SendSignupVerificationMailJob,
SendSignupVerificationMailQueue,
} from '../Auth.constants';
import { SendSignupVerificationMailJobPayload } from '../processors/SendSignupVerificationMail.processor';
@Injectable()
export class AuthMailSubscriber {
constructor(
@InjectQueue(SendResetPasswordMailQueue)
private readonly sendResetPasswordMailQueue: Queue,
@InjectQueue(SendSignupVerificationMailQueue)
private readonly sendSignupVerificationMailQueue: Queue,
) {}
/**
* @param {IAuthSignedUpEventPayload} payload
*/
@OnEvent(events.auth.signUp)
async handleSignupSendVerificationMail(payload: IAuthSignedUpEventPayload) {
try {
const job = await this.sendSignupVerificationMailQueue.add(
SendSignupVerificationMailJob,
{
email: payload.user.email,
fullName: payload.user.firstName,
token: payload.user.verifyToken,
} as SendSignupVerificationMailJobPayload,
{
delay: 0,
},
);
console.log(job);
} catch (error) {
console.log(error);
}
}
/**
* @param {IAuthSendedResetPassword} payload
*/
@OnEvent(events.auth.sendResetPassword)
async handleSendResetPasswordMail(payload: IAuthSendedResetPassword) {
await this.sendResetPasswordMailQueue.add(
SendResetPasswordMailJob,
{
user: payload.user,
token: payload.token,
} as SendResetPasswordMailJobPayload,
{
delay: 0,
},
);
}
}

View File

@@ -0,0 +1,12 @@
import { Module } from '@nestjs/common';
import { TransformerInjectable } from '../Transformer/TransformerInjectable.service';
import { TenancyDatabaseModule } from '../Tenancy/TenancyDB/TenancyDB.module';
import { AutoIncrementOrdersService } from './AutoIncrementOrders.service';
@Module({
imports: [TenancyDatabaseModule],
controllers: [],
providers: [AutoIncrementOrdersService],
exports: [AutoIncrementOrdersService],
})
export class AutoIncrementOrdersModule {}

View File

@@ -0,0 +1,72 @@
import { Inject, Injectable } from '@nestjs/common';
import { SettingsStore } from '../Settings/SettingsStore';
import { SETTINGS_PROVIDER } from '../Settings/Settings.types';
import { transactionIncrement } from '@/utils/transaction-increment';
/**
* Auto increment orders service.
*/
@Injectable()
export class AutoIncrementOrdersService {
constructor(
@Inject(SETTINGS_PROVIDER)
private readonly settingsStore: () => SettingsStore,
) {}
/**
* Check if the auto increment is enabled for the given settings group.
* @param {string} settingsGroup - Settings group.
* @returns {Promise<boolean>}
*/
public autoIncrementEnabled = async (
settingsGroup: string,
): Promise<boolean> => {
const settingsStore = await this.settingsStore();
const group = settingsGroup;
// Settings service transaction number and prefix.
return settingsStore.get({ group, key: 'auto_increment' }, false);
};
/**
* Retrieve the next service transaction number.
* @param {string} settingsGroup
* @param {Function} getMaxTransactionNo
* @return {Promise<string>}
*/
async getNextTransactionNumber(group: string): Promise<string> {
const settingsStore = await this.settingsStore();
// Settings service transaction number and prefix.
const autoIncrement = await this.autoIncrementEnabled(group);
const settingNo = settingsStore.get({ group, key: 'next_number' }, '');
const settingPrefix = settingsStore.get(
{ group, key: 'number_prefix' },
'',
);
return autoIncrement ? `${settingPrefix}${settingNo}` : '';
}
/**
* Increment setting next number.
* @param {string} orderGroup - Order group.
* @param {string} orderNumber -Order number.
*/
async incrementSettingsNextNumber(group: string) {
const settingsStore = await this.settingsStore();
const settingNo = settingsStore.get({ group, key: 'next_number' });
const autoIncrement = settingsStore.get({ group, key: 'auto_increment' });
// // Can't continue if the auto-increment of the service was disabled.
if (!autoIncrement) {
return;
}
settingsStore.set(
{ group, key: 'next_number' },
transactionIncrement(settingNo),
);
await settingsStore.save();
}
}

View File

@@ -0,0 +1,55 @@
import { ApiOperation, ApiTags } from '@nestjs/swagger';
import {
Body,
Controller,
Delete,
Get,
Param,
Post,
Put,
} from '@nestjs/common';
import { BankRulesApplication } from './BankRulesApplication';
import { BankRule } from './models/BankRule';
import { CreateBankRuleDto } from './dtos/BankRule.dto';
import { EditBankRuleDto } from './dtos/BankRule.dto';
@Controller('banking/rules')
@ApiTags('bank-rules')
export class BankRulesController {
constructor(private readonly bankRulesApplication: BankRulesApplication) {}
@Post()
@ApiOperation({ summary: 'Create a new bank rule.' })
async createBankRule(
@Body() createRuleDTO: CreateBankRuleDto,
): Promise<BankRule> {
return this.bankRulesApplication.createBankRule(createRuleDTO);
}
@Put(':id')
@ApiOperation({ summary: 'Edit the given bank rule.' })
async editBankRule(
@Param('id') ruleId: number,
@Body() editRuleDTO: EditBankRuleDto,
): Promise<void> {
return this.bankRulesApplication.editBankRule(ruleId, editRuleDTO);
}
@Delete(':id')
@ApiOperation({ summary: 'Delete the given bank rule.' })
async deleteBankRule(@Param('id') ruleId: number): Promise<void> {
return this.bankRulesApplication.deleteBankRule(ruleId);
}
@Get(':id')
@ApiOperation({ summary: 'Retrieves the bank rule details.' })
async getBankRule(@Param('id') ruleId: number): Promise<any> {
return this.bankRulesApplication.getBankRule(ruleId);
}
@Get()
@ApiOperation({ summary: 'Retrieves the bank rules.' })
async getBankRules(): Promise<any> {
return this.bankRulesApplication.getBankRules();
}
}

View File

@@ -0,0 +1,36 @@
import { forwardRef, Module } from '@nestjs/common';
import { CreateBankRuleService } from './commands/CreateBankRule.service';
import { EditBankRuleService } from './commands/EditBankRule.service';
import { DeleteBankRuleService } from './commands/DeleteBankRule.service';
import { GetBankRulesService } from './queries/GetBankRules.service';
import { GetBankRuleService } from './queries/GetBankRule.service';
import { BankRulesApplication } from './BankRulesApplication';
import { RegisterTenancyModel } from '../Tenancy/TenancyModels/Tenancy.module';
import { BankRuleCondition } from './models/BankRuleCondition';
import { BankRule } from './models/BankRule';
import { BankRulesController } from './BankRules.controller';
import { UnlinkBankRuleOnDeleteBankRuleSubscriber } from './events/UnlinkBankRuleOnDeleteBankRule';
import { DeleteBankRulesService } from './commands/DeleteBankRules.service';
import { BankingTransactionsRegonizeModule } from '../BankingTranasctionsRegonize/BankingTransactionsRegonize.module';
const models = [
RegisterTenancyModel(BankRule),
RegisterTenancyModel(BankRuleCondition),
];
@Module({
controllers: [BankRulesController],
imports: [forwardRef(() => BankingTransactionsRegonizeModule), ...models],
providers: [
CreateBankRuleService,
EditBankRuleService,
DeleteBankRuleService,
DeleteBankRulesService,
GetBankRuleService,
GetBankRulesService,
BankRulesApplication,
UnlinkBankRuleOnDeleteBankRuleSubscriber,
],
exports: [...models, DeleteBankRuleService, DeleteBankRulesService],
})
export class BankRulesModule {}

View File

@@ -0,0 +1,70 @@
import { Injectable } from '@nestjs/common';
import { CreateBankRuleService } from './commands/CreateBankRule.service';
import { DeleteBankRuleService } from './commands/DeleteBankRule.service';
import { EditBankRuleService } from './commands/EditBankRule.service';
import { GetBankRuleService } from './queries/GetBankRule.service';
import { GetBankRulesService } from './queries/GetBankRules.service';
import { BankRule } from './models/BankRule';
import { CreateBankRuleDto, EditBankRuleDto } from './dtos/BankRule.dto';
@Injectable()
export class BankRulesApplication {
constructor(
private readonly createBankRuleService: CreateBankRuleService,
private readonly editBankRuleService: EditBankRuleService,
private readonly deleteBankRuleService: DeleteBankRuleService,
private readonly getBankRuleService: GetBankRuleService,
private readonly getBankRulesService: GetBankRulesService,
) {}
/**
* Creates new bank rule.
* @param {ICreateBankRuleDTO} createRuleDTO - Bank rule data.
* @returns {Promise<void>}
*/
public createBankRule(
createRuleDTO: CreateBankRuleDto,
): Promise<BankRule> {
return this.createBankRuleService.createBankRule(createRuleDTO);
}
/**
* Edits the given bank rule.
* @param {number} ruleId - Bank rule identifier.
* @param {EditBankRuleDto} editRuleDTO - Bank rule data.
* @returns {Promise<void>}
*/
public editBankRule(
ruleId: number,
editRuleDTO: EditBankRuleDto,
): Promise<void> {
return this.editBankRuleService.editBankRule(ruleId, editRuleDTO);
}
/**
* Deletes the given bank rule.
* @param {number} ruleId - Bank rule identifier.
* @returns {Promise<void>}
*/
public deleteBankRule(ruleId: number): Promise<void> {
return this.deleteBankRuleService.deleteBankRule(ruleId);
}
/**
* Retrieves the given bank rule.
* @param {number} ruleId - Bank rule identifier.
* @returns {Promise<any>}
*/
public getBankRule(ruleId: number): Promise<any> {
return this.getBankRuleService.getBankRule(ruleId);
}
/**
* Retrieves the bank rules of the given account.
* @param {number} accountId - Bank account identifier.
* @returns {Promise<any>}
*/
public getBankRules(): Promise<any> {
return this.getBankRulesService.getBankRules();
}
}

View File

@@ -0,0 +1,67 @@
import { ModelObject } from 'objection';
import { Knex } from 'knex';
import { Inject, Injectable } from '@nestjs/common';
import { EventEmitter2 } from '@nestjs/event-emitter';
import {
IBankRuleEventCreatedPayload,
IBankRuleEventCreatingPayload,
} from '../types';
import { UnitOfWork } from '../../Tenancy/TenancyDB/UnitOfWork.service';
import { events } from '@/common/events/events';
import { BankRule } from '../models/BankRule';
import { CreateBankRuleDto } from '../dtos/BankRule.dto';
import { TenantModelProxy } from '@/modules/System/models/TenantBaseModel';
@Injectable()
export class CreateBankRuleService {
constructor(
private readonly uow: UnitOfWork,
private readonly eventPublisher: EventEmitter2,
@Inject(BankRule.name)
private readonly bankRuleModel: TenantModelProxy<typeof BankRule>,
) {}
/**
* Transforms the DTO to model.
* @param {ICreateBankRuleDTO} createDTO
*/
private transformDTO(createDTO: CreateBankRuleDto): ModelObject<BankRule> {
return {
...createDTO,
} as ModelObject<BankRule>;
}
/**
* Creates a new bank rule.
* @param {ICreateBankRuleDTO} createRuleDTO
* @returns {Promise<BankRule>}
*/
public async createBankRule(
createRuleDTO: CreateBankRuleDto,
): Promise<BankRule> {
const transformDTO = this.transformDTO(createRuleDTO);
return this.uow.withTransaction(async (trx: Knex.Transaction) => {
// Triggers `onBankRuleCreating` event.
await this.eventPublisher.emitAsync(events.bankRules.onCreating, {
createRuleDTO,
trx,
} as IBankRuleEventCreatingPayload);
const bankRule = await this.bankRuleModel()
.query(trx)
.upsertGraphAndFetch({
...transformDTO,
});
// Triggers `onBankRuleCreated` event.
await this.eventPublisher.emitAsync(events.bankRules.onCreated, {
createRuleDTO,
bankRule,
trx,
} as IBankRuleEventCreatedPayload);
return bankRule;
});
}
}

View File

@@ -0,0 +1,63 @@
import { Knex } from 'knex';
import { Inject, Injectable } from '@nestjs/common';
import {
IBankRuleEventDeletedPayload,
IBankRuleEventDeletingPayload,
} from '../types';
import { BankRule } from '../models/BankRule';
import { BankRuleCondition } from '../models/BankRuleCondition';
import { UnitOfWork } from '@/modules/Tenancy/TenancyDB/UnitOfWork.service';
import { EventEmitter2 } from '@nestjs/event-emitter';
import { events } from '@/common/events/events';
import { TenantModelProxy } from '@/modules/System/models/TenantBaseModel';
@Injectable()
export class DeleteBankRuleService {
constructor(
@Inject(BankRule.name)
private bankRuleModel: TenantModelProxy<typeof BankRule>,
@Inject(BankRuleCondition.name)
private bankRuleConditionModel: TenantModelProxy<typeof BankRuleCondition>,
private readonly uow: UnitOfWork,
private readonly eventPublisher: EventEmitter2,
) {}
/**
* Deletes the given bank rule.
* @param {number} ruleId
* @returns {Promise<void>}
*/
public async deleteBankRule(
ruleId: number,
trx?: Knex.Transaction,
): Promise<void> {
const oldBankRule = await this.bankRuleModel()
.query()
.findById(ruleId)
.throwIfNotFound();
return this.uow.withTransaction(async (trx: Knex.Transaction) => {
// Triggers `onBankRuleDeleting` event.
await this.eventPublisher.emitAsync(events.bankRules.onDeleting, {
oldBankRule,
ruleId,
trx,
} as IBankRuleEventDeletingPayload);
await this.bankRuleConditionModel()
.query(trx)
.where('ruleId', ruleId)
.delete();
await this.bankRuleModel().query(trx).findById(ruleId).delete();
// Triggers `onBankRuleDeleted` event.
await this.eventPublisher.emitAsync(events.bankRules.onDeleted, {
ruleId,
trx,
} as IBankRuleEventDeletedPayload);
}, trx);
}
}

View File

@@ -0,0 +1,28 @@
import { Knex } from 'knex';
import { PromisePool } from '@supercharge/promise-pool';
import { castArray, uniq } from 'lodash';
import { DeleteBankRuleService } from './DeleteBankRule.service';
import { Injectable } from '@nestjs/common';
@Injectable()
export class DeleteBankRulesService {
constructor(private readonly deleteBankRuleService: DeleteBankRuleService) {}
/**
* Delete bank rules.
* @param {number | Array<number>} bankRuleId - The bank rule id or ids.
* @param {Knex.Transaction} trx - The transaction.
*/
async deleteBankRules(
bankRuleId: number | Array<number>,
trx?: Knex.Transaction,
) {
const bankRulesIds = uniq(castArray(bankRuleId));
const results = await PromisePool.withConcurrency(1)
.for(bankRulesIds)
.process(async (bankRuleId: number) => {
await this.deleteBankRuleService.deleteBankRule(bankRuleId, trx);
});
}
}

View File

@@ -0,0 +1,75 @@
import { Inject, Injectable } from '@nestjs/common';
import {
IBankRuleEventEditedPayload,
IBankRuleEventEditingPayload,
IEditBankRuleDTO,
} from '../types';
import { BankRule } from '../models/BankRule';
import { EventEmitter2 } from '@nestjs/event-emitter';
import { UnitOfWork } from '@/modules/Tenancy/TenancyDB/UnitOfWork.service';
import { events } from '@/common/events/events';
import { TenantModelProxy } from '@/modules/System/models/TenantBaseModel';
import { EditBankRuleDto } from '../dtos/BankRule.dto';
import { ModelObject } from 'objection';
@Injectable()
export class EditBankRuleService {
constructor(
private readonly uow: UnitOfWork,
private readonly eventPublisher: EventEmitter2,
@Inject(BankRule.name)
private bankRuleModel: TenantModelProxy<typeof BankRule>,
) {}
/**
*
* @param createDTO
* @returns
*/
private transformDTO(createDTO: EditBankRuleDto): ModelObject<BankRule> {
return {
...createDTO,
} as ModelObject<BankRule>;
}
/**
* Edits the given bank rule.
* @param {number} ruleId -
* @param {IEditBankRuleDTO} editBankDTO
*/
public async editBankRule(ruleId: number, editRuleDTO: EditBankRuleDto) {
const oldBankRule = await this.bankRuleModel()
.query()
.findById(ruleId)
.withGraphFetched('conditions')
.throwIfNotFound();
const tranformDTO = this.transformDTO(editRuleDTO);
return this.uow.withTransaction(async (trx) => {
// Triggers `onBankRuleEditing` event.
await this.eventPublisher.emitAsync(events.bankRules.onEditing, {
oldBankRule,
ruleId,
editRuleDTO,
trx,
} as IBankRuleEventEditingPayload);
// Updates the given bank rule.
const bankRule = await this.bankRuleModel()
.query(trx)
.upsertGraphAndFetch({
...tranformDTO,
id: ruleId,
});
// Triggers `onBankRuleEdited` event.
await this.eventPublisher.emitAsync(events.bankRules.onEdited, {
oldBankRule,
bankRule,
editRuleDTO,
trx,
} as IBankRuleEventEditedPayload);
});
}
}

View File

@@ -0,0 +1,122 @@
import { Type } from 'class-transformer';
import {
IsString,
IsInt,
Min,
IsOptional,
IsIn,
IsArray,
ValidateNested,
ArrayMinSize,
IsNotEmpty,
} from 'class-validator';
import { BankRuleComparator } from '../types';
import { ApiProperty } from '@nestjs/swagger';
class BankRuleConditionDto {
@IsNotEmpty()
@IsIn(['description', 'amount'])
field: string;
@IsNotEmpty()
@IsIn([
'equals',
'equal',
'contains',
'not_contain',
'bigger',
'bigger_or_equal',
'smaller',
'smaller_or_equal',
])
comparator: BankRuleComparator = 'contains';
@IsNotEmpty()
value: string;
}
export class CommandBankRuleDto {
@IsString()
@IsNotEmpty()
@ApiProperty({
description: 'The name of the bank rule',
example: 'Monthly Salary',
})
name: string;
@IsInt()
@Min(0)
@ApiProperty({
description: 'The order of the bank rule',
example: 1,
})
order: number;
@IsOptional()
@IsInt()
@Min(0)
@ApiProperty({
description: 'The account ID to apply the rule if',
example: 1,
})
applyIfAccountId?: number;
@IsIn(['deposit', 'withdrawal'])
@ApiProperty({
description: 'The transaction type to apply the rule if',
example: 'deposit',
})
applyIfTransactionType: 'deposit' | 'withdrawal';
@IsString()
@IsIn(['and', 'or'])
@ApiProperty({
description: 'The conditions type to apply the rule if',
example: 'and',
})
conditionsType: 'and' | 'or' = 'and';
@IsArray()
@ArrayMinSize(1)
@ValidateNested({ each: true })
@Type(() => BankRuleConditionDto)
@ApiProperty({
description: 'The conditions to apply the rule if',
example: [{ field: 'description', comparator: 'contains', value: 'Salary' }],
})
conditions: BankRuleConditionDto[];
@IsString()
@ApiProperty({
description: 'The category to assign the rule if',
example: 'Income:Salary',
})
assignCategory: string;
@IsInt()
@Min(0)
@ApiProperty({
description: 'The account ID to assign the rule if',
example: 1,
})
assignAccountId: number;
@IsOptional()
@IsString()
@ApiProperty({
description: 'The payee to assign the rule if',
example: 'Employer Inc.',
})
assignPayee?: string;
@IsOptional()
@IsString()
@ApiProperty({
description: 'The memo to assign the rule if',
example: 'Monthly Salary',
})
assignMemo?: string;
}
export class CreateBankRuleDto extends CommandBankRuleDto {}
export class EditBankRuleDto extends CommandBankRuleDto {}

View File

@@ -0,0 +1,25 @@
import { IBankRuleEventDeletingPayload } from '../types';
import { Injectable } from '@nestjs/common';
import { RevertRecognizedTransactionsService } from '@/modules/BankingTranasctionsRegonize/commands/RevertRecognizedTransactions.service';
import { events } from '@/common/events/events';
import { OnEvent } from '@nestjs/event-emitter';
@Injectable()
export class UnlinkBankRuleOnDeleteBankRuleSubscriber {
constructor(
private readonly revertRecognizedTransactionsService: RevertRecognizedTransactionsService,
) {}
/**
* Unlinks the bank rule out of recognized transactions.
* @param {IBankRuleEventDeletingPayload} payload -
*/
@OnEvent(events.bankRules.onDeleting)
public async unlinkBankRuleOutRecognizedTransactionsOnRuleDeleting({
oldBankRule,
}: IBankRuleEventDeletingPayload) {
await this.revertRecognizedTransactionsService.revertRecognizedTransactions(
oldBankRule.id,
);
}
}

View File

@@ -0,0 +1,74 @@
import { BaseModel } from '@/models/Model';
import { Model } from 'objection';
import { BankRuleCondition } from './BankRuleCondition';
import { BankRuleAssignCategory, BankRuleConditionType } from '../types';
export class BankRule extends BaseModel {
public readonly id!: number;
public readonly name!: string;
public readonly order!: number;
public readonly applyIfAccountId!: number;
public readonly applyIfTransactionType!: string;
public readonly assignCategory!: BankRuleAssignCategory;
public readonly assignAccountId!: number;
public readonly assignPayee!: string;
public readonly assignMemo!: string;
public readonly conditionsType!: BankRuleConditionType;
public readonly conditions!: BankRuleCondition[];
/**
* Table name
*/
static get tableName() {
return 'bank_rules';
}
/**
* Timestamps columns.
*/
static get timestamps() {
return ['created_at', 'updated_at'];
}
/**
* Virtual attributes.
*/
static get virtualAttributes() {
return [];
}
/**
* Relationship mapping.
*/
static get relationMappings() {
const { BankRuleCondition } = require('./BankRuleCondition');
const { Account } = require('../../Accounts/models/Account.model');
return {
/**
* Sale invoice associated entries.
*/
conditions: {
relation: Model.HasManyRelation,
modelClass: BankRuleCondition,
join: {
from: 'bank_rules.id',
to: 'bank_rule_conditions.ruleId',
},
},
/**
* Bank rule may associated to the assign account.
*/
assignAccount: {
relation: Model.BelongsToOneRelation,
modelClass: Account,
join: {
from: 'bank_rules.assignAccountId',
to: 'accounts.id',
},
},
};
}
}

View File

@@ -0,0 +1,31 @@
import { BaseModel } from '@/models/Model';
import { BankRuleComparator } from '../types';
export class BankRuleCondition extends BaseModel {
public id!: number;
public bankRuleId!: number;
public field!: string;
public comparator!: BankRuleComparator;
public value!: string;
/**
* Table name.
*/
static get tableName() {
return 'bank_rule_conditions';
}
/**
* Timestamps columns.
*/
get timestamps() {
return [];
}
/**
* Virtual attributes.
*/
static get virtualAttributes() {
return [];
}
}

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