Compare commits

..

10 Commits

Author SHA1 Message Date
Ahmed Bouhuolia
d0e227ff28 feat: disable auto applying credit payments 2024-07-25 11:52:40 +02:00
Ahmed Bouhuolia
b590d2cb03 fix: excess dialog 2024-07-25 11:01:26 +02:00
Ahmed Bouhuolia
daf1cd38c0 feat: advanced payments 2024-07-25 01:40:48 +02:00
Ahmed Bouhuolia
3e2997d745 feat: logic of excess amount confirmation 2024-07-24 22:33:26 +02:00
Ahmed Bouhuolia
f3af3843dd feat: wip prepard expenses from vendors 2024-07-24 18:57:51 +02:00
Ahmed Bouhuolia
b68d180785 feat: prepard expenses of payment made transactions 2024-07-24 02:18:32 +02:00
Ahmed Bouhuolia
341d47cc7b feat: excess payment alert 2024-07-23 18:54:08 +02:00
Ahmed Bouhuolia
5c3a371e8a feat: wip advanced payment 2024-07-23 15:02:39 +02:00
Ahmed Bouhuolia
1141991e44 feat: advanced payments 2024-07-23 13:52:25 +02:00
Ahmed Bouhuolia
8cd3a6c48d feat: advanced payments 2024-07-22 20:40:15 +02:00
95 changed files with 1440 additions and 1462 deletions

View File

@@ -132,15 +132,6 @@
"contributions": [
"bug"
]
},
{
"login": "oleynikd",
"name": "Denis",
"avatar_url": "https://avatars.githubusercontent.com/u/3976868?v=4",
"profile": "https://github.com/oleynikd",
"contributions": [
"bug"
]
}
],
"contributorsPerLine": 7,

View File

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

View File

@@ -3,16 +3,12 @@ import { NextFunction, Request, Response, Router } from 'express';
import BaseController from '@/api/controllers/BaseController';
import { CashflowApplication } from '@/services/Cashflow/CashflowApplication';
import { GetBankAccountSummary } from '@/services/Banking/BankAccounts/GetBankAccountSummary';
import { BankAccountsApplication } from '@/services/Banking/BankAccounts/BankAccountsApplication';
@Service()
export class BankAccountsController extends BaseController {
@Inject()
private getBankAccountSummaryService: GetBankAccountSummary;
@Inject()
private bankAccountsApp: BankAccountsApplication;
/**
* Router constructor.
*/
@@ -20,11 +16,6 @@ export class BankAccountsController extends BaseController {
const router = Router();
router.get('/:bankAccountId/meta', this.getBankAccountSummary.bind(this));
router.post(
'/:bankAccountId/disconnect',
this.disconnectBankAccount.bind(this)
);
router.post('/:bankAccountId/update', this.refreshBankAccount.bind(this));
return router;
}
@@ -55,58 +46,4 @@ export class BankAccountsController extends BaseController {
next(error);
}
}
/**
* Disonnect the given bank account.
* @param {Request} req
* @param {Response} res
* @param {NextFunction} next
* @returns {Promise<Response|null>}
*/
async disconnectBankAccount(
req: Request<{ bankAccountId: number }>,
res: Response,
next: NextFunction
) {
const { bankAccountId } = req.params;
const { tenantId } = req;
try {
await this.bankAccountsApp.disconnectBankAccount(tenantId, bankAccountId);
return res.status(200).send({
id: bankAccountId,
message: 'The bank account has been disconnected.',
});
} catch (error) {
next(error);
}
}
/**
* Refresh the given bank account.
* @param {Request} req
* @param {Response} res
* @param {NextFunction} next
* @returns {Promise<Response|null>}
*/
async refreshBankAccount(
req: Request<{ bankAccountId: number }>,
res: Response,
next: NextFunction
) {
const { bankAccountId } = req.params;
const { tenantId } = req;
try {
await this.bankAccountsApp.refreshBankAccount(tenantId, bankAccountId);
return res.status(200).send({
id: bankAccountId,
message: 'The bank account has been disconnected.',
});
} catch (error) {
next(error);
}
}
}

View File

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

View File

@@ -126,6 +126,8 @@ export default class BillsPayments extends BaseController {
check('attachments').isArray().optional(),
check('attachments.*.key').exists().isString(),
check('prepard_expenses_account_id').optional().isNumeric().toInt(),
];
}

View File

@@ -150,8 +150,9 @@ export default class PaymentReceivesController extends BaseController {
check('customer_id').exists().isNumeric().toInt(),
check('exchange_rate').optional().isFloat({ gt: 0 }).toFloat(),
check('amount').exists().isNumeric().toFloat(),
check('payment_date').exists(),
check('amount').exists().isNumeric().toFloat(),
check('reference_no').optional(),
check('deposit_account_id').exists().isNumeric().toInt(),
check('payment_receive_no').optional({ nullable: true }).trim().escape(),
@@ -159,7 +160,8 @@ export default class PaymentReceivesController extends BaseController {
check('branch_id').optional({ nullable: true }).isNumeric().toInt(),
check('entries').isArray({}),
check('entries').isArray(),
check('entries.*.id').optional({ nullable: true }).isNumeric().toInt(),
check('entries.*.index').optional().isNumeric().toInt(),
check('entries.*.invoice_id').exists().isNumeric().toInt(),
@@ -167,6 +169,11 @@ export default class PaymentReceivesController extends BaseController {
check('attachments').isArray().optional(),
check('attachments.*.key').exists().isString(),
check('unearned_revenue_account_id')
.optional({ nullable: true })
.isNumeric()
.toInt(),
];
}

View File

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

View File

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

View File

@@ -0,0 +1,17 @@
exports.up = function (knex) {
return knex.schema.table('payment_receives', (table) => {
table.decimal('applied_amount', 13, 3).defaultTo(0);
table
.integer('unearned_revenue_account_id')
.unsigned()
.references('id')
.inTable('accounts');
});
};
exports.down = function (knex) {
return knex.schema.table('payment_receives', (table) => {
table.dropColumn('applied_amount');
table.dropColumn('unearned_revenue_account_id');
});
};

View File

@@ -0,0 +1,17 @@
exports.up = function (knex) {
return knex.schema.table('bills_payments', (table) => {
table.decimal('applied_amount', 13, 3).defaultTo(0);
table
.integer('prepard_expenses_account_id')
.unsigned()
.references('id')
.inTable('accounts');
});
};
exports.down = function (knex) {
return knex.schema.table('bills_payments', (table) => {
table.dropColumn('applied_amount');
table.dropColumn('prepard_expenses_account_id');
});
};

View File

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

View File

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

View File

@@ -166,3 +166,10 @@ export interface IBillOpenedPayload {
oldBill: IBill;
tenantId: number;
}
export interface IBillPrepardExpensesAppliedEventPayload {
tenantId: number;
billId: number;
trx?: Knex.Transaction;
}

View File

@@ -29,6 +29,9 @@ export interface IBillPayment {
localAmount?: number;
branchId?: number;
prepardExpensesAccountId?: number;
isPrepardExpense: boolean;
}
export interface IBillPaymentEntryDTO {
@@ -38,6 +41,7 @@ export interface IBillPaymentEntryDTO {
export interface IBillPaymentDTO {
vendorId: number;
amount: number;
paymentAccountId: number;
paymentNumber?: string;
paymentDate: Date;
@@ -47,6 +51,7 @@ export interface IBillPaymentDTO {
entries: IBillPaymentEntryDTO[];
branchId?: number;
attachments?: AttachmentLinkDTO[];
prepardExpensesAccountId?: number;
}
export interface IBillReceivePageEntry {
@@ -119,3 +124,11 @@ export enum IPaymentMadeAction {
Delete = 'Delete',
View = 'View',
}
export interface IPaymentPrepardExpensesAppliedEventPayload {
tenantId: number;
billPaymentId: number;
billId: number;
appliedAmount: number;
trx?: Knex.Transaction;
}

View File

@@ -25,8 +25,13 @@ export interface IPaymentReceive {
updatedAt: Date;
localAmount?: number;
branchId?: number;
unearnedRevenueAccountId?: number;
}
export interface IPaymentReceiveCreateDTO {
interface IPaymentReceivedCommonDTO {
unearnedRevenueAccountId?: number;
}
export interface IPaymentReceiveCreateDTO extends IPaymentReceivedCommonDTO {
customerId: number;
paymentDate: Date;
amount: number;
@@ -41,7 +46,7 @@ export interface IPaymentReceiveCreateDTO {
attachments?: AttachmentLinkDTO[];
}
export interface IPaymentReceiveEditDTO {
export interface IPaymentReceiveEditDTO extends IPaymentReceivedCommonDTO {
customerId: number;
paymentDate: Date;
amount: number;
@@ -184,3 +189,11 @@ export interface PaymentReceiveMailPresendEvent {
paymentReceiveId: number;
messageOptions: PaymentReceiveMailOptsDTO;
}
export interface PaymentReceiveUnearnedRevenueAppliedEventPayload {
tenantId: number;
paymentReceiveId: number;
saleInvoiceId: number;
appliedAmount: number;
trx?: Knex.Transaction;
}

View File

@@ -216,3 +216,9 @@ export interface ISaleInvoiceMailSent {
saleInvoiceId: number;
messageOptions: SendInvoiceMailDTO;
}
export interface SaleInvoiceAppliedUnearnedRevenueOnCreatedEventPayload {
tenantId: number;
saleInvoiceId: number;
trx?: Knex.Transaction;
}

View File

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

View File

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

View File

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

View File

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

View File

@@ -11,6 +11,8 @@ export default class BillPayment extends mixin(TenantModel, [
CustomViewBaseModel,
ModelSearchable,
]) {
prepardExpensesAccountId: number;
/**
* Table name
*/
@@ -47,6 +49,14 @@ export default class BillPayment extends mixin(TenantModel, [
return BillPaymentSettings;
}
/**
* Detarmines whether the payment is prepard expense.
* @returns {boolean}
*/
get isPrepardExpense() {
return !!this.prepardExpensesAccountId;
}
/**
* Relationship mapping.
*/

View File

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

View File

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

View File

@@ -1,38 +0,0 @@
import { Inject, Service } from 'typedi';
import { DisconnectBankAccount } from './DisconnectBankAccount';
import { RefreshBankAccountService } from './RefreshBankAccount';
@Service()
export class BankAccountsApplication {
@Inject()
private disconnectBankAccountService: DisconnectBankAccount;
@Inject()
private refreshBankAccountService: RefreshBankAccountService;
/**
* Disconnects the given bank account.
* @param {number} tenantId
* @param {number} bankAccountId
* @returns {Promise<void>}
*/
async disconnectBankAccount(tenantId: number, bankAccountId: number) {
return this.disconnectBankAccountService.disconnectBankAccount(
tenantId,
bankAccountId
);
}
/**
* Refresh the bank transactions of the given bank account.
* @param {number} tenantId
* @param {number} bankAccountId
* @returns {Promise<void>}
*/
async refreshBankAccount(tenantId: number, bankAccountId: number) {
return this.refreshBankAccountService.refreshBankAccount(
tenantId,
bankAccountId
);
}
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -38,4 +38,24 @@ export default class ContactTransfromer extends Transformer {
? this.formatDate(contact.openingBalanceAt)
: '';
};
/**
* Retrieves the unused credit balance.
* @param {IContact} contact
* @returns {number}
*/
protected unusedCredit = (contact: IContact): number => {
return contact.balance > 0 ? 0 : Math.abs(contact.balance);
};
/**
* Retrieves the formatted unused credit balance.
* @param {IContact} contact
* @returns {string}
*/
protected formattedUnusedCredit = (contact: IContact): string => {
const unusedCredit = this.unusedCredit(contact);
return formatNumber(unusedCredit, { currencyCode: contact.currencyCode });
};
}

View File

@@ -12,6 +12,8 @@ export default class CustomerTransfromer extends ContactTransfromer {
'formattedOpeningBalanceAt',
'customerType',
'formattedCustomerType',
'unusedCredit',
'formattedUnusedCredit',
];
};

View File

@@ -1,4 +1,3 @@
import { Service } from 'typedi';
import ContactTransfromer from '../ContactTransformer';
export default class VendorTransfromer extends ContactTransfromer {
@@ -10,7 +9,9 @@ export default class VendorTransfromer extends ContactTransfromer {
return [
'formattedBalance',
'formattedOpeningBalance',
'formattedOpeningBalanceAt'
'formattedOpeningBalanceAt',
'unusedCredit',
'formattedUnusedCredit',
];
};
}

View File

@@ -21,7 +21,6 @@ export const DEFAULT_VIEWS = [
},
];
export const ERRORS = {
OPENING_BALANCE_DATE_REQUIRED: 'OPENING_BALANCE_DATE_REQUIRED',
CONTACT_ALREADY_INACTIVE: 'CONTACT_ALREADY_INACTIVE',

View File

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

View File

@@ -1,8 +1,14 @@
import moment from 'moment';
import { sumBy } from 'lodash';
import { sumBy, chain } from 'lodash';
import { Service, Inject } from 'typedi';
import { Knex } from 'knex';
import { AccountNormal, IBillPayment, ILedgerEntry } from '@/interfaces';
import {
AccountNormal,
IBillPayment,
IBillPaymentEntry,
ILedger,
ILedgerEntry,
} from '@/interfaces';
import Ledger from '@/services/Accounting/Ledger';
import LedgerStorageService from '@/services/Accounting/LedgerStorageService';
import HasTenancyService from '@/services/Tenancy/TenancyService';
@@ -21,6 +27,7 @@ export class BillPaymentGLEntries {
* @param {number} tenantId
* @param {number} billPaymentId
* @param {Knex.Transaction} trx
* @returns {Promise<void>}
*/
public writePaymentGLEntries = async (
tenantId: number,
@@ -65,6 +72,7 @@ export class BillPaymentGLEntries {
* @param {number} tenantId
* @param {number} billPaymentId
* @param {Knex.Transaction} trx
* @returns {Promise<void>}
*/
public rewritePaymentGLEntries = async (
tenantId: number,
@@ -102,7 +110,7 @@ export class BillPaymentGLEntries {
* @param {IBillPayment} billPayment
* @returns {}
*/
private getPaymentCommonEntry = (billPayment: IBillPayment) => {
private getPaymentCommonEntry = (billPayment: IBillPayment): ILedgerEntry => {
const formattedDate = moment(billPayment.paymentDate).format('YYYY-MM-DD');
return {
@@ -127,7 +135,7 @@ export class BillPaymentGLEntries {
/**
* Calculates the payment total exchange gain/loss.
* @param {IBillPayment} paymentReceive - Payment receive with entries.
* @param {IBillPayment} paymentReceive - Payment receive with entries.
* @returns {number}
*/
private getPaymentExGainOrLoss = (billPayment: IBillPayment): number => {
@@ -141,10 +149,10 @@ export class BillPaymentGLEntries {
/**
* Retrieves the payment exchange gain/loss entries.
* @param {IBillPayment} billPayment -
* @param {number} APAccountId -
* @param {number} gainLossAccountId -
* @param {string} baseCurrency -
* @param {IBillPayment} billPayment -
* @param {number} APAccountId -
* @param {number} gainLossAccountId -
* @param {string} baseCurrency -
* @returns {ILedgerEntry[]}
*/
private getPaymentExGainOrLossEntries = (
@@ -186,7 +194,7 @@ export class BillPaymentGLEntries {
/**
* Retrieves the payment deposit GL entry.
* @param {IBillPayment} billPayment
* @param {IBillPayment} billPayment
* @returns {ILedgerEntry}
*/
private getPaymentGLEntry = (billPayment: IBillPayment): ILedgerEntry => {
@@ -198,6 +206,7 @@ export class BillPaymentGLEntries {
accountId: billPayment.paymentAccountId,
accountNormal: AccountNormal.DEBIT,
index: 2,
indexGroup: 10,
};
};
@@ -226,8 +235,8 @@ export class BillPaymentGLEntries {
/**
* Retrieves the payment GL entries.
* @param {IBillPayment} billPayment
* @param {number} APAccountId
* @param {IBillPayment} billPayment
* @param {number} APAccountId
* @returns {ILedgerEntry[]}
*/
private getPaymentGLEntries = (
@@ -254,10 +263,53 @@ export class BillPaymentGLEntries {
return [paymentEntry, payableEntry, ...exGainLossEntries];
};
/**
*
* BEFORE APPLYING TO PAYMENT TO BILLS.
* -----------------------------------------
* - Cash/Bank - Credit.
* - Prepard Expenses - Debit
*
* AFTER APPLYING BILLS TO PAYMENT.
* -----------------------------------------
* - Prepard Expenses - Credit
* - A/P - Debit
*
* @param {number} APAccountId - A/P account id.
* @param {IBillPayment} billPayment
*/
private getPrepardExpenseGLEntries = (
APAccountId: number,
billPayment: IBillPayment
) => {
const prepardExpenseEntry = this.getPrepardExpenseEntry(billPayment);
const withdrawalEntry = this.getPaymentGLEntry(billPayment);
const paymentLinesEntries = chain(billPayment.entries)
.map((billPaymentEntry) => {
const APEntry = this.getAccountPayablePaymentLineEntry(
APAccountId,
billPayment,
billPaymentEntry
);
const creditPrepardExpenseEntry = this.getCreditPrepardExpenseEntry(
billPayment,
billPaymentEntry
);
return [creditPrepardExpenseEntry, APEntry];
})
.flatten()
.value();
const prepardExpenseEntries = [prepardExpenseEntry, withdrawalEntry];
const combinedEntries = [...prepardExpenseEntries, ...paymentLinesEntries];
return combinedEntries;
};
/**
* Retrieves the bill payment ledger.
* @param {IBillPayment} billPayment
* @param {number} APAccountId
* @param {IBillPayment} billPayment
* @param {number} APAccountId
* @returns {Ledger}
*/
private getBillPaymentLedger = (
@@ -266,12 +318,79 @@ export class BillPaymentGLEntries {
gainLossAccountId: number,
baseCurrency: string
): Ledger => {
const entries = this.getPaymentGLEntries(
billPayment,
APAccountId,
gainLossAccountId,
baseCurrency
);
const entries = billPayment.isPrepardExpense
? this.getPrepardExpenseGLEntries(APAccountId, billPayment)
: this.getPaymentGLEntries(
billPayment,
APAccountId,
gainLossAccountId,
baseCurrency
);
return new Ledger(entries);
};
/**
* Retrieves the prepard expense GL entry.
* @param {IBillPayment} billPayment
* @returns {ILedgerEntry}
*/
private getPrepardExpenseEntry = (
billPayment: IBillPayment
): ILedgerEntry => {
const commonJournal = this.getPaymentCommonEntry(billPayment);
return {
...commonJournal,
debit: billPayment.localAmount,
accountId: billPayment.prepardExpensesAccountId,
accountNormal: AccountNormal.DEBIT,
indexGroup: 10,
index: 1,
};
};
/**
* Retrieves the GL entries of credit prepard expense for the give payment line.
* @param {IBillPayment} billPayment
* @param {IBillPaymentEntry} billPaymentEntry
* @returns {ILedgerEntry}
*/
private getCreditPrepardExpenseEntry = (
billPayment: IBillPayment,
billPaymentEntry: IBillPaymentEntry
) => {
const commonJournal = this.getPaymentCommonEntry(billPayment);
return {
...commonJournal,
credit: billPaymentEntry.paymentAmount,
accountId: billPayment.prepardExpensesAccountId,
accountNormal: AccountNormal.DEBIT,
index: 2,
indexGroup: 20,
};
};
/**
* Retrieves the A/P debit of the payment line.
* @param {number} APAccountId
* @param {IBillPayment} billPayment
* @param {IBillPaymentEntry} billPaymentEntry
* @returns {ILedgerEntry}
*/
private getAccountPayablePaymentLineEntry = (
APAccountId: number,
billPayment: IBillPayment,
billPaymentEntry: IBillPaymentEntry
): ILedgerEntry => {
const commonJournal = this.getPaymentCommonEntry(billPayment);
return {
...commonJournal,
debit: billPaymentEntry.paymentAmount,
accountId: APAccountId,
index: 1,
indexGroup: 20,
};
};
}

View File

@@ -17,6 +17,10 @@ export class PaymentWriteGLEntriesSubscriber {
*/
public attach(bus) {
bus.subscribe(events.billPayment.onCreated, this.handleWriteJournalEntries);
bus.subscribe(
events.billPayment.onPrepardExpensesApplied,
this.handleWritePrepardExpenseGLEntries
);
bus.subscribe(
events.billPayment.onEdited,
this.handleRewriteJournalEntriesOncePaymentEdited
@@ -28,7 +32,8 @@ export class PaymentWriteGLEntriesSubscriber {
}
/**
* Handle bill payment writing journal entries once created.
* Handles bill payment writing journal entries once created.
* @param {IBillPaymentEventCreatedPayload} payload -
*/
private handleWriteJournalEntries = async ({
tenantId,
@@ -44,6 +49,22 @@ export class PaymentWriteGLEntriesSubscriber {
);
};
/**
* Handles rewrite prepard expense GL entries once the bill payment applying to bills.
* @param {IBillPaymentEventCreatedPayload} payload -
*/
private handleWritePrepardExpenseGLEntries = async ({
tenantId,
billPaymentId,
trx,
}: IBillPaymentEventCreatedPayload) => {
await this.billPaymentGLEntries.rewritePaymentGLEntries(
tenantId,
billPaymentId,
trx
);
};
/**
* Handle bill payment re-writing journal entries once the payment transaction be edited.
*/

View File

@@ -11,6 +11,9 @@ export class CommandBillPaymentDTOTransformer {
@Inject()
private branchDTOTransform: BranchTransactionDTOTransform;
@Inject()
private tenancy: HasTenancyService;
/**
* Transforms create/edit DTO to model.
* @param {number} tenantId
@@ -24,17 +27,27 @@ export class CommandBillPaymentDTOTransformer {
vendor: IVendor,
oldBillPayment?: IBillPayment
): Promise<IBillPayment> {
const amount =
billPaymentDTO.amount ?? sumBy(billPaymentDTO.entries, 'paymentAmount');
const { accountRepository } = this.tenancy.repositories(tenantId);
const appliedAmount = sumBy(billPaymentDTO.entries, 'paymentAmount');
const hasPrepardExpenses = appliedAmount < billPaymentDTO.amount;
const prepardExpensesAccount = hasPrepardExpenses
? await accountRepository.findOrCreatePrepardExpenses()
: null;
const prepardExpensesAccountId =
hasPrepardExpenses && prepardExpensesAccount
? billPaymentDTO.prepardExpensesAccountId ?? prepardExpensesAccount?.id
: billPaymentDTO.prepardExpensesAccountId;
const initialDTO = {
...formatDateFields(omit(billPaymentDTO, ['attachments']), [
'paymentDate',
]),
amount,
appliedAmount,
currencyCode: vendor.currencyCode,
exchangeRate: billPaymentDTO.exchangeRate || 1,
entries: billPaymentDTO.entries,
prepardExpensesAccountId,
};
return R.compose(
this.branchDTOTransform.transformDTO<IBillPayment>(tenantId)

View File

@@ -0,0 +1,117 @@
import { Knex } from 'knex';
import { Inject, Service } from 'typedi';
import PromisePool, { ProcessHandler } from '@supercharge/promise-pool';
import HasTenancyService from '@/services/Tenancy/TenancyService';
import {
IBillPayment,
IBillPrepardExpensesAppliedEventPayload,
IPaymentPrepardExpensesAppliedEventPayload,
} from '@/interfaces';
import { EventPublisher } from '@/lib/EventPublisher/EventPublisher';
import events from '@/subscribers/events';
@Service()
export class AutoApplyPrepardExpenses {
@Inject()
private tenancy: HasTenancyService;
@Inject()
private eventPublisher: EventPublisher;
/**
* Auto apply prepard expenses to the given bill.
* @param {number} tenantId
* @param {number} billId
* @returns {Promise<void>}
*/
async autoApplyPrepardExpensesToBill(
tenantId: number,
billId: number,
trx?: Knex.Transaction
): Promise<void> {
const { BillPayment, Bill } = this.tenancy.models(tenantId);
const bill = await Bill.query(trx).findById(billId).throwIfNotFound();
const unappliedPayments = await BillPayment.query(trx)
.where('vendorId', bill.vendorId)
.whereRaw('amount - applied_amount > 0')
.whereNotNull('prepardExpensesAccountId');
let unappliedAmount = bill.total;
let appliedTotalAmount = 0; // Total applied amount after applying.
const precessHandler: ProcessHandler<IBillPayment, void> = async (
unappliedPayment: IBillPayment,
index: number,
pool
) => {
const appliedAmount = Math.min(unappliedAmount, unappliedPayment.amount);
unappliedAmount = unappliedAmount - appliedAmount;
appliedTotalAmount += appliedAmount;
// Stop applying once the unapplied amount reach zero or less.
if (appliedAmount <= 0) {
pool.stop();
return;
}
await this.applyBillToPaymentMade(
tenantId,
unappliedPayment.id,
bill.id,
appliedAmount,
trx
);
};
await PromisePool.withConcurrency(1)
.for(unappliedPayments)
.process(precessHandler);
// Increase the paid amount of the purchase invoice.
await Bill.changePaymentAmount(billId, appliedTotalAmount, trx);
// Triggers `onBillPrepardExpensesApplied` event.
await this.eventPublisher.emitAsync(events.bill.onPrepardExpensesApplied, {
tenantId,
billId,
trx,
} as IBillPrepardExpensesAppliedEventPayload);
}
/**
* Apply the given bill to payment made transaction.
* @param {number} tenantId
* @param {number} billPaymentId
* @param {number} billId
* @param {number} appliedAmount
* @param {Knex.Transaction} trx
*/
public applyBillToPaymentMade = async (
tenantId: number,
billPaymentId: number,
billId: number,
appliedAmount: number,
trx?: Knex.Transaction
) => {
const { BillPaymentEntry, BillPayment } = this.tenancy.models(tenantId);
await BillPaymentEntry.query(trx).insert({
billPaymentId,
billId,
paymentAmount: appliedAmount,
});
await BillPayment.query(trx).increment('appliedAmount', appliedAmount);
// Triggers `onBillPaymentPrepardExpensesApplied` event.
await this.eventPublisher.emitAsync(
events.billPayment.onPrepardExpensesApplied,
{
tenantId,
billPaymentId,
billId,
appliedAmount,
trx,
} as IPaymentPrepardExpensesAppliedEventPayload
);
};
}

View File

@@ -0,0 +1,36 @@
import { Inject, Service } from 'typedi';
import { AutoApplyPrepardExpenses } from '../AutoApplyPrepardExpenses';
import events from '@/subscribers/events';
import { IBillCreatedPayload } from '@/interfaces';
@Service()
export class AutoApplyPrepardExpensesOnBillCreated {
@Inject()
private autoApplyPrepardExpenses: AutoApplyPrepardExpenses;
/**
* Constructor method.
*/
public attach(bus) {
bus.subscribe(
events.bill.onCreated,
this.handleAutoApplyPrepardExpensesOnBillCreated.bind(this)
);
}
/**
* Handles the auto apply prepard expenses on bill created.
* @param {IBillCreatedPayload} payload -
*/
private async handleAutoApplyPrepardExpensesOnBillCreated({
tenantId,
billId,
trx,
}: IBillCreatedPayload) {
await this.autoApplyPrepardExpenses.autoApplyPrepardExpensesToBill(
tenantId,
billId,
trx
);
}
}

View File

@@ -0,0 +1,126 @@
import { Inject, Service } from 'typedi';
import { Knex } from 'knex';
import PromisePool, { ProcessHandler } from '@supercharge/promise-pool';
import HasTenancyService from '@/services/Tenancy/TenancyService';
import { EventPublisher } from '@/lib/EventPublisher/EventPublisher';
import events from '@/subscribers/events';
import {
IPaymentReceive,
PaymentReceiveUnearnedRevenueAppliedEventPayload,
SaleInvoiceAppliedUnearnedRevenueOnCreatedEventPayload,
} from '@/interfaces';
@Service()
export class AutoApplyUnearnedRevenue {
@Inject()
private tenancy: HasTenancyService;
@Inject()
private eventPublisher: EventPublisher;
/**
* Auto apply invoice to advanced payment received transactions.
* @param {number} tenantId
* @param {number} invoiceId
* @returns {Promise<void>}
*/
public async autoApplyUnearnedRevenueToInvoice(
tenantId: number,
saleInvoiceId: number,
trx?: Knex.Transaction
): Promise<void> {
const { PaymentReceive, SaleInvoice } = this.tenancy.models(tenantId);
const invoice = await SaleInvoice.query(trx)
.findById(saleInvoiceId)
.throwIfNotFound();
const unappliedPayments = await PaymentReceive.query(trx)
.where('customerId', invoice.customerId)
.whereRaw('amount - applied_amount > 0')
.whereNotNull('unearnedRevenueAccountId');
let unappliedAmount = invoice.total;
let appliedTotalAmount = 0; // Total applied amount after applying.
const processHandler: ProcessHandler<
IPaymentReceive,
Promise<void>
> = async (unappliedPayment: IPaymentReceive, index: number, pool) => {
const appliedAmount = Math.min(unappliedAmount, unappliedPayment.amount);
unappliedAmount = unappliedAmount - appliedAmount;
appliedTotalAmount += appliedAmount;
// Stop applying once the unapplied amount reache zero or less.
if (appliedAmount <= 0) {
pool.stop();
return;
}
await this.applyInvoiceToPaymentReceived(
tenantId,
unappliedPayment.id,
invoice.id,
appliedAmount,
trx
);
};
await PromisePool.withConcurrency(1)
.for(unappliedPayments)
.process(processHandler);
// Increase the paid amount of the sale invoice.
await SaleInvoice.changePaymentAmount(
saleInvoiceId,
appliedTotalAmount,
trx
);
// Triggers event `onSaleInvoiceUnearnedRevenue`.
await this.eventPublisher.emitAsync(
events.saleInvoice.onUnearnedRevenueApplied,
{
tenantId,
saleInvoiceId,
trx,
} as SaleInvoiceAppliedUnearnedRevenueOnCreatedEventPayload
);
}
/**
* Apply the given invoice to payment received transaction.
* @param {number} tenantId
* @param {number} paymentReceivedId
* @param {number} invoiceId
* @param {number} appliedAmount
* @param {Knex.Transaction} trx
* @returns {Promise<void>}
*/
public applyInvoiceToPaymentReceived = async (
tenantId: number,
paymentReceiveId: number,
invoiceId: number,
appliedAmount: number,
trx?: Knex.Transaction
): Promise<void> => {
const { PaymentReceiveEntry, PaymentReceive } =
this.tenancy.models(tenantId);
await PaymentReceiveEntry.query(trx).insert({
paymentReceiveId,
invoiceId,
paymentAmount: appliedAmount,
});
await PaymentReceive.query(trx).increment('appliedAmount', appliedAmount);
// Triggers the event `onPaymentReceivedUnearnedRevenue`.
await this.eventPublisher.emitAsync(
events.paymentReceive.onUnearnedRevenueApplied,
{
tenantId,
paymentReceiveId,
saleInvoiceId: invoiceId,
appliedAmount,
trx,
} as PaymentReceiveUnearnedRevenueAppliedEventPayload
);
};
}

View File

@@ -11,6 +11,7 @@ import { PaymentReceiveValidators } from './PaymentReceiveValidators';
import { PaymentReceiveIncrement } from './PaymentReceiveIncrement';
import { BranchTransactionDTOTransform } from '@/services/Branches/Integrations/BranchTransactionDTOTransform';
import { formatDateFields } from '@/utils';
import HasTenancyService from '@/services/Tenancy/TenancyService';
@Service()
export class PaymentReceiveDTOTransformer {
@@ -23,6 +24,9 @@ export class PaymentReceiveDTOTransformer {
@Inject()
private branchDTOTransform: BranchTransactionDTOTransform;
@Inject()
private tenancy: HasTenancyService;
/**
* Transformes the create payment receive DTO to model object.
* @param {number} tenantId
@@ -36,9 +40,8 @@ export class PaymentReceiveDTOTransformer {
paymentReceiveDTO: IPaymentReceiveCreateDTO | IPaymentReceiveEditDTO,
oldPaymentReceive?: IPaymentReceive
): Promise<IPaymentReceive> {
const amount =
paymentReceiveDTO.amount ??
sumBy(paymentReceiveDTO.entries, 'paymentAmount');
const { accountRepository } = this.tenancy.repositories(tenantId);
const appliedAmount = sumBy(paymentReceiveDTO.entries, 'paymentAmount');
// Retreive the next invoice number.
const autoNextNumber =
@@ -52,17 +55,29 @@ export class PaymentReceiveDTOTransformer {
this.validators.validatePaymentNoRequire(paymentReceiveNo);
const hasUnearnedPayment = appliedAmount < paymentReceiveDTO.amount;
const unearnedRevenueAccount = hasUnearnedPayment
? await accountRepository.findOrCreateUnearnedRevenue()
: null;
const unearnedRevenueAccountId =
hasUnearnedPayment && unearnedRevenueAccount
? paymentReceiveDTO.unearnedRevenueAccountId ??
unearnedRevenueAccount?.id
: paymentReceiveDTO.unearnedRevenueAccountId;
const initialDTO = {
...formatDateFields(omit(paymentReceiveDTO, ['entries', 'attachments']), [
'paymentDate',
]),
amount,
appliedAmount,
currencyCode: customer.currencyCode,
...(paymentReceiveNo ? { paymentReceiveNo } : {}),
exchangeRate: paymentReceiveDTO.exchangeRate || 1,
entries: paymentReceiveDTO.entries.map((entry) => ({
...entry,
})),
unearnedRevenueAccountId,
};
return R.compose(
this.branchDTOTransform.transformDTO<IPaymentReceive>(tenantId)

View File

@@ -1,19 +1,14 @@
import { Service, Inject } from 'typedi';
import { sumBy } from 'lodash';
import { Knex } from 'knex';
import Ledger from '@/services/Accounting/Ledger';
import TenancyService from '@/services/Tenancy/TenancyService';
import {
IPaymentReceive,
ILedgerEntry,
AccountNormal,
IPaymentReceiveGLCommonEntry,
} from '@/interfaces';
import { IPaymentReceive, ILedgerEntry, AccountNormal } from '@/interfaces';
import LedgerStorageService from '@/services/Accounting/LedgerStorageService';
import { TenantMetadata } from '@/system/models';
import { PaymentReceivedGLCommon } from './PaymentReceivedGLCommon';
@Service()
export class PaymentReceiveGLEntries {
export class PaymentReceiveGLEntries extends PaymentReceivedGLCommon {
@Inject()
private tenancy: TenancyService;
@@ -22,9 +17,9 @@ export class PaymentReceiveGLEntries {
/**
* Writes payment GL entries to the storage.
* @param {number} tenantId
* @param {number} paymentReceiveId
* @param {Knex.Transaction} trx
* @param {number} tenantId - Tenant id.
* @param {number} paymentReceiveId - Payment received id.
* @param {Knex.Transaction} trx - Knex transaction.
* @returns {Promise<void>}
*/
public writePaymentGLEntries = async (
@@ -34,14 +29,19 @@ export class PaymentReceiveGLEntries {
): Promise<void> => {
const { PaymentReceive } = this.tenancy.models(tenantId);
// Retrieves the given tenant metadata.
const tenantMeta = await TenantMetadata.query().findOne({ tenantId });
// Retrieves the payment receive with associated entries.
const paymentReceive = await PaymentReceive.query(trx)
.findById(paymentReceiveId)
.withGraphFetched('entries.invoice');
// Cannot continue if the received payment is unearned revenue type,
// that type of transactions have different type of GL entries.
if (paymentReceive.unearnedRevenueAccountId) {
return;
}
// Retrieves the given tenant metadata.
const tenantMeta = await TenantMetadata.query().findOne({ tenantId });
// Retrives the payment receive ledger.
const ledger = await this.getPaymentReceiveGLedger(
tenantId,
@@ -53,25 +53,6 @@ export class PaymentReceiveGLEntries {
await this.ledgerStorage.commit(tenantId, ledger, trx);
};
/**
* Reverts the given payment receive GL entries.
* @param {number} tenantId
* @param {number} paymentReceiveId
* @param {Knex.Transaction} trx
*/
public revertPaymentGLEntries = async (
tenantId: number,
paymentReceiveId: number,
trx?: Knex.Transaction
) => {
await this.ledgerStorage.deleteByReference(
tenantId,
paymentReceiveId,
'PaymentReceive',
trx
);
};
/**
* Rewrites the given payment receive GL entries.
* @param {number} tenantId
@@ -92,10 +73,10 @@ export class PaymentReceiveGLEntries {
/**
* Retrieves the payment receive general ledger.
* @param {number} tenantId -
* @param {IPaymentReceive} paymentReceive -
* @param {string} baseCurrencyCode -
* @param {Knex.Transaction} trx -
* @param {number} tenantId -
* @param {IPaymentReceive} paymentReceive -
* @param {string} baseCurrencyCode -
* @param {Knex.Transaction} trx -
* @returns {Ledger}
*/
public getPaymentReceiveGLedger = async (
@@ -126,100 +107,9 @@ export class PaymentReceiveGLEntries {
return new Ledger(ledgerEntries);
};
/**
* Calculates the payment total exchange gain/loss.
* @param {IBillPayment} paymentReceive - Payment receive with entries.
* @returns {number}
*/
private getPaymentExGainOrLoss = (
paymentReceive: IPaymentReceive
): number => {
return sumBy(paymentReceive.entries, (entry) => {
const paymentLocalAmount =
entry.paymentAmount * paymentReceive.exchangeRate;
const invoicePayment = entry.paymentAmount * entry.invoice.exchangeRate;
return paymentLocalAmount - invoicePayment;
});
};
/**
* Retrieves the common entry of payment receive.
* @param {IPaymentReceive} paymentReceive
* @returns {}
*/
private getPaymentReceiveCommonEntry = (
paymentReceive: IPaymentReceive
): IPaymentReceiveGLCommonEntry => {
return {
debit: 0,
credit: 0,
currencyCode: paymentReceive.currencyCode,
exchangeRate: paymentReceive.exchangeRate,
transactionId: paymentReceive.id,
transactionType: 'PaymentReceive',
transactionNumber: paymentReceive.paymentReceiveNo,
referenceNumber: paymentReceive.referenceNo,
date: paymentReceive.paymentDate,
userId: paymentReceive.userId,
createdAt: paymentReceive.createdAt,
branchId: paymentReceive.branchId,
};
};
/**
* Retrieves the payment exchange gain/loss entry.
* @param {IPaymentReceive} paymentReceive -
* @param {number} ARAccountId -
* @param {number} exchangeGainOrLossAccountId -
* @param {string} baseCurrencyCode -
* @returns {ILedgerEntry[]}
*/
private getPaymentExchangeGainLossEntry = (
paymentReceive: IPaymentReceive,
ARAccountId: number,
exchangeGainOrLossAccountId: number,
baseCurrencyCode: string
): ILedgerEntry[] => {
const commonJournal = this.getPaymentReceiveCommonEntry(paymentReceive);
const gainOrLoss = this.getPaymentExGainOrLoss(paymentReceive);
const absGainOrLoss = Math.abs(gainOrLoss);
return gainOrLoss
? [
{
...commonJournal,
currencyCode: baseCurrencyCode,
exchangeRate: 1,
debit: gainOrLoss > 0 ? absGainOrLoss : 0,
credit: gainOrLoss < 0 ? absGainOrLoss : 0,
accountId: ARAccountId,
contactId: paymentReceive.customerId,
index: 3,
accountNormal: AccountNormal.CREDIT,
},
{
...commonJournal,
currencyCode: baseCurrencyCode,
exchangeRate: 1,
credit: gainOrLoss > 0 ? absGainOrLoss : 0,
debit: gainOrLoss < 0 ? absGainOrLoss : 0,
accountId: exchangeGainOrLossAccountId,
index: 3,
accountNormal: AccountNormal.DEBIT,
},
]
: [];
};
/**
* Retrieves the payment deposit GL entry.
* @param {IPaymentReceive} paymentReceive
* @param {IPaymentReceive} paymentReceive
* @returns {ILedgerEntry}
*/
private getPaymentDepositGLEntry = (
@@ -238,8 +128,8 @@ export class PaymentReceiveGLEntries {
/**
* Retrieves the payment receivable entry.
* @param {IPaymentReceive} paymentReceive
* @param {number} ARAccountId
* @param {IPaymentReceive} paymentReceive
* @param {number} ARAccountId
* @returns {ILedgerEntry}
*/
private getPaymentReceivableEntry = (
@@ -262,15 +152,15 @@ export class PaymentReceiveGLEntries {
* Records payment receive journal transactions.
*
* Invoice payment journals.
* --------
* - Account receivable -> Debit
* - Payment account [current asset] -> Credit
* ------------
* - Account Receivable -> Debit
* - Payment Account [current asset] -> Credit
*
* @param {number} tenantId
* @param {IPaymentReceive} paymentRecieve - Payment receive model.
* @param {number} ARAccountId - A/R account id.
* @param {number} exGainOrLossAccountId - Exchange gain/loss account id.
* @param {string} baseCurrency - Base currency code.
* @param {number} tenantId
* @param {IPaymentReceive} paymentRecieve - Payment receive model.
* @param {number} ARAccountId - A/R account id.
* @param {number} exGainOrLossAccountId - Exchange gain/loss account id.
* @param {string} baseCurrency - Base currency code.
* @returns {Promise<ILedgerEntry>}
*/
public getPaymentReceiveGLEntries = (

View File

@@ -107,7 +107,6 @@ export class PaymentReceiveValidators {
const invoicesIds = paymentReceiveEntries.map(
(e: IPaymentReceiveEntryDTO) => e.invoiceId
);
const storedInvoices = await SaleInvoice.query().whereIn('id', invoicesIds);
const storedInvoicesMap = new Map(

View File

@@ -0,0 +1,123 @@
import { Knex } from 'knex';
import { sumBy } from 'lodash';
import {
AccountNormal,
ILedgerEntry,
IPaymentReceive,
IPaymentReceiveGLCommonEntry,
} from '@/interfaces';
import LedgerStorageService from '@/services/Accounting/LedgerStorageService';
export class PaymentReceivedGLCommon {
private ledgerStorage: LedgerStorageService;
/**
* Retrieves the common entry of payment receive.
* @param {IPaymentReceive} paymentReceive
* @returns {IPaymentReceiveGLCommonEntry}
*/
protected getPaymentReceiveCommonEntry = (
paymentReceive: IPaymentReceive
): IPaymentReceiveGLCommonEntry => {
return {
debit: 0,
credit: 0,
currencyCode: paymentReceive.currencyCode,
exchangeRate: paymentReceive.exchangeRate,
transactionId: paymentReceive.id,
transactionType: 'PaymentReceive',
transactionNumber: paymentReceive.paymentReceiveNo,
referenceNumber: paymentReceive.referenceNo,
date: paymentReceive.paymentDate,
userId: paymentReceive.userId,
createdAt: paymentReceive.createdAt,
branchId: paymentReceive.branchId,
};
};
/**
* Retrieves the payment exchange gain/loss entry.
* @param {IPaymentReceive} paymentReceive -
* @param {number} ARAccountId -
* @param {number} exchangeGainOrLossAccountId -
* @param {string} baseCurrencyCode -
* @returns {ILedgerEntry[]}
*/
protected getPaymentExchangeGainLossEntry = (
paymentReceive: IPaymentReceive,
ARAccountId: number,
exchangeGainOrLossAccountId: number,
baseCurrencyCode: string
): ILedgerEntry[] => {
const commonJournal = this.getPaymentReceiveCommonEntry(paymentReceive);
const gainOrLoss = this.getPaymentExGainOrLoss(paymentReceive);
const absGainOrLoss = Math.abs(gainOrLoss);
return gainOrLoss
? [
{
...commonJournal,
currencyCode: baseCurrencyCode,
exchangeRate: 1,
debit: gainOrLoss > 0 ? absGainOrLoss : 0,
credit: gainOrLoss < 0 ? absGainOrLoss : 0,
accountId: ARAccountId,
contactId: paymentReceive.customerId,
index: 3,
accountNormal: AccountNormal.CREDIT,
},
{
...commonJournal,
currencyCode: baseCurrencyCode,
exchangeRate: 1,
credit: gainOrLoss > 0 ? absGainOrLoss : 0,
debit: gainOrLoss < 0 ? absGainOrLoss : 0,
accountId: exchangeGainOrLossAccountId,
index: 3,
accountNormal: AccountNormal.DEBIT,
},
]
: [];
};
/**
* Calculates the payment total exchange gain/loss.
* @param {IBillPayment} paymentReceive - Payment receive with entries.
* @returns {number}
*/
private getPaymentExGainOrLoss = (
paymentReceive: IPaymentReceive
): number => {
return sumBy(paymentReceive.entries, (entry) => {
const paymentLocalAmount =
entry.paymentAmount * paymentReceive.exchangeRate;
const invoicePayment = entry.paymentAmount * entry.invoice.exchangeRate;
return paymentLocalAmount - invoicePayment;
});
};
/**
* Reverts the given payment receive GL entries.
* @param {number} tenantId
* @param {number} paymentReceiveId
* @param {Knex.Transaction} trx
*/
public revertPaymentGLEntries = async (
tenantId: number,
paymentReceiveId: number,
trx?: Knex.Transaction
) => {
await this.ledgerStorage.deleteByReference(
tenantId,
paymentReceiveId,
'PaymentReceive',
trx
);
};
}

View File

@@ -0,0 +1,252 @@
import * as R from 'ramda';
import { Knex } from 'knex';
import { Inject, Service } from 'typedi';
import { flatten } from 'lodash';
import HasTenancyService from '@/services/Tenancy/TenancyService';
import { TenantMetadata } from '@/system/models';
import { PaymentReceivedGLCommon } from './PaymentReceivedGLCommon';
import {
AccountNormal,
ILedgerEntry,
IPaymentReceive,
IPaymentReceiveEntry,
} from '@/interfaces';
import LedgerStorageService from '@/services/Accounting/LedgerStorageService';
import Ledger from '@/services/Accounting/Ledger';
@Service()
export class PaymentReceivedUnearnedGLEntries extends PaymentReceivedGLCommon {
@Inject()
private tenancy: HasTenancyService;
@Inject()
private ledgerStorage: LedgerStorageService;
/**
* Writes payment GL entries to the storage.
* @param {number} tenantId
* @param {number} paymentReceiveId
* @param {Knex.Transaction} trx
* @returns {Promise<void>}
*/
public writePaymentGLEntries = async (
tenantId: number,
paymentReceiveId: number,
trx?: Knex.Transaction
): Promise<void> => {
const { PaymentReceive } = this.tenancy.models(tenantId);
// Retrieves the payment receive with associated entries.
const paymentReceive = await PaymentReceive.query(trx)
.findById(paymentReceiveId)
.withGraphFetched('entries.invoice');
// Stop early if
if (!paymentReceive.unearnedRevenueAccountId) {
return;
}
// Retrieves the given tenant metadata.
const tenantMeta = await TenantMetadata.query().findOne({ tenantId });
const ledger = await this.getPaymentReceiveGLedger(
tenantId,
paymentReceive
);
// Commit the ledger entries to the storage.
await this.ledgerStorage.commit(tenantId, ledger, trx);
};
/**
* Rewrites the given payment receive GL entries.
* @param {number} tenantId
* @param {number} paymentReceiveId
* @param {Knex.Transaction} trx
*/
public rewritePaymentGLEntries = async (
tenantId: number,
paymentReceiveId: number,
trx?: Knex.Transaction
) => {
// Reverts the payment GL entries.
await this.revertPaymentGLEntries(tenantId, paymentReceiveId, trx);
// Writes the payment GL entries.
await this.writePaymentGLEntries(tenantId, paymentReceiveId, trx);
};
/**
* Retrieves the payment received GL entries.
* @param {number} tenantId
* @param {IPaymentReceive} paymentReceive
* @returns {Promise<Ledger>}
*/
private getPaymentReceiveGLedger = async (
tenantId: number,
paymentReceive: IPaymentReceive
) => {
const { accountRepository } = this.tenancy.repositories(tenantId);
// Retrieve the A/R account of the given currency.
const receivableAccount =
await accountRepository.findOrCreateAccountReceivable(
paymentReceive.currencyCode
);
// Retrieve the payment GL entries.
const entries = this.getPaymentGLEntries(
receivableAccount.id,
paymentReceive
);
const unearnedRevenueEntries =
this.getUnearnedRevenueEntries(paymentReceive);
const combinedEntries = [...unearnedRevenueEntries, ...entries];
return new Ledger(combinedEntries);
};
/**
* Retrieve the payment received GL entries.
* @param {number} ARAccountId - A/R account id.
* @param {IPaymentReceive} paymentReceive -
* @returns {Array<ILedgerEntry>}
*/
private getPaymentGLEntries = R.curry(
(
ARAccountId: number,
paymentReceive: IPaymentReceive
): Array<ILedgerEntry> => {
const getPaymentEntryGLEntries = this.getPaymentEntryGLEntries(
ARAccountId,
paymentReceive
);
const entriesGroup = paymentReceive.entries.map((paymentEntry) => {
return getPaymentEntryGLEntries(paymentEntry);
});
return flatten(entriesGroup);
}
);
/**
* Retrieve the payment entry GL entries.
* @param {IPaymentReceiveEntry} paymentReceivedEntry -
* @param {IPaymentReceive} paymentReceive -
* @returns {Array<ILedgerEntry>}
*/
private getPaymentEntryGLEntries = R.curry(
(
ARAccountId: number,
paymentReceive: IPaymentReceive,
paymentReceivedEntry: IPaymentReceiveEntry
): Array<ILedgerEntry> => {
const unearnedRevenueEntry = this.getDebitUnearnedRevenueGLEntry(
paymentReceivedEntry.paymentAmount,
paymentReceive
);
const AREntry = this.getPaymentReceivableEntry(
paymentReceivedEntry.paymentAmount,
paymentReceive,
ARAccountId
);
return [unearnedRevenueEntry, AREntry];
}
);
/**
* Retrieves the payment deposit GL entry.
* @param {IPaymentReceive} paymentReceive
* @returns {ILedgerEntry}
*/
private getDebitUnearnedRevenueGLEntry = (
amount: number,
paymentReceive: IPaymentReceive
): ILedgerEntry => {
const commonJournal = this.getPaymentReceiveCommonEntry(paymentReceive);
return {
...commonJournal,
debit: amount,
accountId: paymentReceive.unearnedRevenueAccountId,
accountNormal: AccountNormal.CREDIT,
index: 2,
indexGroup: 20,
};
};
/**
* Retrieves the payment receivable entry.
* @param {IPaymentReceive} paymentReceive
* @param {number} ARAccountId
* @returns {ILedgerEntry}
*/
private getPaymentReceivableEntry = (
amount: number,
paymentReceive: IPaymentReceive,
ARAccountId: number
): ILedgerEntry => {
const commonJournal = this.getPaymentReceiveCommonEntry(paymentReceive);
return {
...commonJournal,
credit: amount,
contactId: paymentReceive.customerId,
accountId: ARAccountId,
accountNormal: AccountNormal.DEBIT,
index: 1,
indexGroup: 20,
};
};
/**
* Retrieves the unearned revenue entries.
* @param {IPaymentReceive} paymentReceived -
* @returns {Array<ILedgerEntry>}
*/
private getUnearnedRevenueEntries = (
paymentReceive: IPaymentReceive
): Array<ILedgerEntry> => {
const depositEntry = this.getDepositPaymentGLEntry(paymentReceive);
const unearnedEntry = this.getUnearnedRevenueEntry(paymentReceive);
return [depositEntry, unearnedEntry];
};
/**
* Retrieve the payment deposit entry.
* @param {IPaymentReceive} paymentReceived -
* @returns {ILedgerEntry}
*/
private getDepositPaymentGLEntry = (
paymentReceive: IPaymentReceive
): ILedgerEntry => {
const commonJournal = this.getPaymentReceiveCommonEntry(paymentReceive);
return {
...commonJournal,
debit: paymentReceive.amount,
accountId: paymentReceive.depositAccountId,
accountNormal: AccountNormal.DEBIT,
indexGroup: 10,
index: 1,
};
};
/**
* Retrieve the unearned revenue entry.
* @param {IPaymentReceive} paymentReceived -
* @returns {ILedgerEntry}
*/
private getUnearnedRevenueEntry = (
paymentReceived: IPaymentReceive
): ILedgerEntry => {
const commonJournal = this.getPaymentReceiveCommonEntry(paymentReceived);
return {
...commonJournal,
credit: paymentReceived.amount,
accountId: paymentReceived.unearnedRevenueAccountId,
accountNormal: AccountNormal.CREDIT,
indexGroup: 10,
index: 1,
};
};
}

View File

@@ -0,0 +1,34 @@
import { Inject, Service } from 'typedi';
import events from '@/subscribers/events';
import { AutoApplyUnearnedRevenue } from '../AutoApplyUnearnedRevenue';
@Service()
export class AutoApplyUnearnedRevenueOnInvoiceCreated {
@Inject()
private autoApplyUnearnedRevenue: AutoApplyUnearnedRevenue;
/**
* Constructor method.
*/
public attach(bus) {
bus.subscribe(
events.saleInvoice.onCreated,
this.handleAutoApplyUnearnedRevenueOnInvoiceCreated.bind(this)
);
}
/**
* Handles the auto apply unearned revenue on invoice creating.
* @param
*/
private async handleAutoApplyUnearnedRevenueOnInvoiceCreated({
tenantId,
saleInvoice,
trx,
}) {
await this.autoApplyUnearnedRevenue.autoApplyUnearnedRevenueToInvoice(
tenantId,
saleInvoice.id,
trx
);
}
}

View File

@@ -3,15 +3,20 @@ import {
IPaymentReceiveCreatedPayload,
IPaymentReceiveDeletedPayload,
IPaymentReceiveEditedPayload,
PaymentReceiveUnearnedRevenueAppliedEventPayload,
} from '@/interfaces';
import events from '@/subscribers/events';
import { PaymentReceiveGLEntries } from '@/services/Sales/PaymentReceives/PaymentReceiveGLEntries';
import { PaymentReceivedUnearnedGLEntries } from '@/services/Sales/PaymentReceives/PaymentReceivedUnearnedGLEntries';
@Service()
export default class PaymentReceivesWriteGLEntriesSubscriber {
@Inject()
private paymentReceiveGLEntries: PaymentReceiveGLEntries;
@Inject()
private paymentReceivedUnearnedGLEntries: PaymentReceivedUnearnedGLEntries;
/**
* Attaches events with handlers.
*/
@@ -32,6 +37,7 @@ export default class PaymentReceivesWriteGLEntriesSubscriber {
/**
* Handle journal entries writing once the payment receive created.
* @param {IPaymentReceiveCreatedPayload} payload -
*/
private handleWriteJournalEntriesOnceCreated = async ({
tenantId,
@@ -43,14 +49,21 @@ export default class PaymentReceivesWriteGLEntriesSubscriber {
paymentReceiveId,
trx
);
await this.paymentReceivedUnearnedGLEntries.writePaymentGLEntries(
tenantId,
paymentReceiveId,
trx
);
};
/**
* Handle journal entries writing once the payment receive edited.
* @param {IPaymentReceiveEditedPayload} payload -
*/
private handleOverwriteJournalEntriesOnceEdited = async ({
tenantId,
paymentReceive,
paymentReceiveId,
trx,
}: IPaymentReceiveEditedPayload) => {
await this.paymentReceiveGLEntries.rewritePaymentGLEntries(
@@ -58,10 +71,16 @@ export default class PaymentReceivesWriteGLEntriesSubscriber {
paymentReceive.id,
trx
);
await this.paymentReceivedUnearnedGLEntries.rewritePaymentGLEntries(
tenantId,
paymentReceiveId,
trx
);
};
/**
* Handles revert journal entries once deleted.
* @param {IPaymentReceiveDeletedPayload} payload -
*/
private handleRevertJournalEntriesOnceDeleted = async ({
tenantId,

View File

@@ -40,13 +40,6 @@ export default {
baseCurrencyUpdated: 'onOrganizationBaseCurrencyUpdated',
},
/**
* User subscription events.
*/
subscription: {
onSubscribed: 'onOrganizationSubscribed',
},
/**
* Tenants managment service.
*/
@@ -149,6 +142,8 @@ export default {
onMailReminderSend: 'onSaleInvoiceMailReminderSend',
onMailReminderSent: 'onSaleInvoiceMailReminderSent',
onUnearnedRevenueApplied: 'onSaleInvoiceUnearnedRevenue',
},
/**
@@ -237,6 +232,8 @@ export default {
onPreMailSend: 'onPaymentReceivePreMailSend',
onMailSend: 'onPaymentReceiveMailSend',
onMailSent: 'onPaymentReceiveMailSent',
onUnearnedRevenueApplied: 'onPaymentReceivedUnearnedRevenue',
},
/**
@@ -257,6 +254,8 @@ export default {
onOpening: 'onBillOpening',
onOpened: 'onBillOpened',
onPrepardExpensesApplied: 'onBillPrepardExpensesApplied'
},
/**
@@ -274,6 +273,8 @@ export default {
onPublishing: 'onBillPaymentPublishing',
onPublished: 'onBillPaymentPublished',
onPrepardExpensesApplied: 'onBillPaymentPrepardExpensesApplied'
},
/**
@@ -658,11 +659,6 @@ export default {
onUnexcluded: 'onBankTransactionUnexcluded',
},
bankAccount: {
onDisconnecting: 'onBankAccountDisconnecting',
onDisconnected: 'onBankAccountDisconnected',
},
// Import files.
import: {
onImportCommitted: 'onImportFileCommitted',

View File

@@ -2,6 +2,6 @@
import { Position, Toaster, Intent } from '@blueprintjs/core';
export const AppToaster = Toaster.create({
position: Position.TOP,
position: Position.RIGHT_BOTTOM,
intent: Intent.WARNING,
});

View File

@@ -7,6 +7,7 @@ import { DashboardPageContent } from '@/components';
import { transformTableStateToQuery, compose } from '@/utils';
import { ManualJournalsListProvider } from './ManualJournalsListProvider';
import ManualJournalsViewTabs from './ManualJournalsViewTabs';
import ManualJournalsDataTable from './ManualJournalsDataTable';
import ManualJournalsActionsBar from './ManualJournalActionsBar';
import withManualJournals from './withManualJournals';
@@ -28,6 +29,7 @@ function ManualJournalsTable({
<ManualJournalsActionsBar />
<DashboardPageContent>
<ManualJournalsViewTabs />
<ManualJournalsDataTable />
</DashboardPageContent>
</ManualJournalsListProvider>

View File

@@ -2,15 +2,15 @@
import React, { useEffect } from 'react';
import '@/style/pages/Accounts/List.scss';
import { DashboardPageContent, DashboardContentTable } from '@/components';
import { AccountsChartProvider } from './AccountsChartProvider';
import AccountsViewsTabs from './AccountsViewsTabs';
import AccountsActionsBar from './AccountsActionsBar';
import AccountsDataTable from './AccountsDataTable';
import withAccounts from '@/containers/Accounts/withAccounts';
import withAccountsTableActions from './withAccountsTableActions';
import { transformAccountsStateToQuery } from './utils';
import { compose } from '@/utils';
@@ -41,6 +41,8 @@ function AccountsChart({
<AccountsActionsBar />
<DashboardPageContent>
<AccountsViewsTabs />
<DashboardContentTable>
<AccountsDataTable />
</DashboardContentTable>

View File

@@ -11,19 +11,13 @@ import {
MenuItem,
PopoverInteractionKind,
Position,
Intent,
Tooltip,
MenuDivider,
} from '@blueprintjs/core';
import { useHistory } from 'react-router-dom';
import { isEmpty } from 'lodash';
import {
Icon,
DashboardActionsBar,
DashboardRowsHeightButton,
FormattedMessage as T,
AppToaster,
If,
} from '@/components';
import { CashFlowMenuItems } from './utils';
@@ -39,13 +33,6 @@ import withSettings from '@/containers/Settings/withSettings';
import withSettingsActions from '@/containers/Settings/withSettingsActions';
import { compose } from '@/utils';
import {
useDisconnectBankAccount,
useUpdateBankAccount,
useExcludeUncategorizedTransactions,
useUnexcludeUncategorizedTransactions,
} from '@/hooks/query/bank-rules';
import { withBanking } from '../withBanking';
function AccountTransactionsActionsBar({
// #withDialogActions
@@ -56,27 +43,17 @@ function AccountTransactionsActionsBar({
// #withSettingsActions
addSetting,
// #withBanking
uncategorizedTransationsIdsSelected,
excludedTransactionsIdsSelected,
}) {
const history = useHistory();
const { accountId, currentAccount } = useAccountTransactionsContext();
const { accountId } = useAccountTransactionsContext();
// Refresh cashflow infinity transactions hook.
const { refresh } = useRefreshCashflowTransactionsInfinity();
const { mutateAsync: disconnectBankAccount } = useDisconnectBankAccount();
const { mutateAsync: updateBankAccount } = useUpdateBankAccount();
// Retrieves the money in/out buttons options.
const addMoneyInOptions = useMemo(() => getAddMoneyInOptions(), []);
const addMoneyOutOptions = useMemo(() => getAddMoneyOutOptions(), []);
const isFeedsActive = !!currentAccount.is_feeds_active;
const isSyncingOwner = currentAccount.is_syncing_owner;
// Handle table row size change.
const handleTableRowSizeChange = (size) => {
addSetting('cashflowTransactions', 'tableSize', size);
@@ -105,92 +82,11 @@ function AccountTransactionsActionsBar({
const handleBankRulesClick = () => {
history.push(`/bank-rules?accountId=${accountId}`);
};
// Handles the bank account disconnect click.
const handleDisconnectClick = () => {
disconnectBankAccount({ bankAccountId: accountId })
.then(() => {
AppToaster.show({
message: 'The bank account has been disconnected.',
intent: Intent.SUCCESS,
});
})
.catch((error) => {
AppToaster.show({
message: 'Something went wrong.',
intent: Intent.DANGER,
});
});
};
// handles the bank update button click.
const handleBankUpdateClick = () => {
updateBankAccount({ bankAccountId: accountId })
.then(() => {
AppToaster.show({
message: 'The transactions of the bank account has been updated.',
intent: Intent.SUCCESS,
});
})
.catch(() => {
AppToaster.show({
message: 'Something went wrong.',
intent: Intent.DANGER,
});
});
};
// Handle the refresh button click.
const handleRefreshBtnClick = () => {
refresh();
};
const {
mutateAsync: excludeUncategorizedTransactions,
isLoading: isExcludingLoading,
} = useExcludeUncategorizedTransactions();
const {
mutateAsync: unexcludeUncategorizedTransactions,
isLoading: isUnexcludingLoading,
} = useUnexcludeUncategorizedTransactions();
// Handles the exclude uncategorized transactions in bulk.
const handleExcludeUncategorizedBtnClick = () => {
excludeUncategorizedTransactions({
ids: uncategorizedTransationsIdsSelected,
})
.then(() => {
AppToaster.show({
message: 'The selected transactions have been excluded.',
intent: Intent.SUCCESS,
});
})
.catch(() => {
AppToaster.show({
message: 'Something went wrong',
intent: Intent.DANGER,
});
});
};
// Handles the unexclude categorized button click.
const handleUnexcludeUncategorizedBtnClick = () => {
unexcludeUncategorizedTransactions({
ids: excludedTransactionsIdsSelected,
})
.then(() => {
AppToaster.show({
message: 'The selected excluded transactions have been unexcluded.',
intent: Intent.SUCCESS,
});
})
.catch((error) => {
AppToaster.show({
message: 'Something went wrong',
intent: Intent.DANGER,
});
});
};
return (
<DashboardActionsBar>
<NavbarGroup>
@@ -233,45 +129,6 @@ function AccountTransactionsActionsBar({
onChange={handleTableRowSizeChange}
/>
<NavbarDivider />
<If condition={isSyncingOwner}>
<Tooltip
content={
isFeedsActive
? 'The bank syncing is active'
: 'The bank syncing is disconnected'
}
minimal={true}
position={Position.BOTTOM}
>
<Button
className={Classes.MINIMAL}
icon={<Icon icon="feed" iconSize={16} />}
intent={isFeedsActive ? Intent.SUCCESS : Intent.DANGER}
/>
</Tooltip>
</If>
{!isEmpty(uncategorizedTransationsIdsSelected) && (
<Button
icon={<Icon icon="disable" iconSize={16} />}
text={'Exclude'}
onClick={handleExcludeUncategorizedBtnClick}
className={Classes.MINIMAL}
intent={Intent.DANGER}
disabled={isExcludingLoading}
/>
)}
{!isEmpty(excludedTransactionsIdsSelected) && (
<Button
icon={<Icon icon="disable" iconSize={16} />}
text={'Unexclude'}
onClick={handleUnexcludeUncategorizedBtnClick}
className={Classes.MINIMAL}
intent={Intent.DANGER}
disabled={isUnexcludingLoading}
/>
)}
</NavbarGroup>
<NavbarGroup align={Alignment.RIGHT}>
@@ -284,15 +141,7 @@ function AccountTransactionsActionsBar({
}}
content={
<Menu>
<If condition={isSyncingOwner && isFeedsActive}>
<MenuItem onClick={handleBankUpdateClick} text={'Update'} />
<MenuDivider />
</If>
<MenuItem onClick={handleBankRulesClick} text={'Bank rules'} />
<If condition={isSyncingOwner && isFeedsActive}>
<MenuItem onClick={handleDisconnectClick} text={'Disconnect'} />
</If>
</Menu>
}
>
@@ -315,13 +164,4 @@ export default compose(
withSettings(({ cashflowTransactionsSettings }) => ({
cashflowTansactionsTableSize: cashflowTransactionsSettings?.tableSize,
})),
withBanking(
({
uncategorizedTransationsIdsSelected,
excludedTransactionsIdsSelected,
}) => ({
uncategorizedTransationsIdsSelected,
excludedTransactionsIdsSelected,
}),
),
)(AccountTransactionsActionsBar);

View File

@@ -33,7 +33,6 @@ function AccountTransactionsDataTable({
// #withBankingActions
setUncategorizedTransactionIdForMatching,
setUncategorizedTransactionsSelected,
}) {
// Retrieve table columns.
const columns = useAccountUncategorizedTransactionsColumns();
@@ -74,19 +73,12 @@ function AccountTransactionsDataTable({
});
};
// Handle selected rows change.
const handleSelectedRowsChange = (selected) => {
const _selectedIds = selected?.map((row) => row.original.id);
setUncategorizedTransactionsSelected(_selectedIds);
};
return (
<CashflowTransactionsTable
noInitialFetch={true}
columns={columns}
data={uncategorizedTransactions || []}
sticky={true}
selectionColumn={true}
loading={isUncategorizedTransactionsLoading}
headerLoading={isUncategorizedTransactionsLoading}
expandColumnSpace={1}
@@ -107,7 +99,6 @@ function AccountTransactionsDataTable({
'There is no uncategorized transactions in the current account.'
}
className="table-constrant"
onSelectedRowsChange={handleSelectedRowsChange}
payload={{
onExclude: handleExcludeTransaction,
onCategorize: handleCategorizeBtnClick,

View File

@@ -2,7 +2,7 @@
import React from 'react';
import styled from 'styled-components';
import { Intent } from '@blueprintjs/core';
import * as R from 'ramda';
import {
DataTable,
TableFastCell,
@@ -19,20 +19,11 @@ import { useExcludedTransactionsBoot } from './ExcludedTransactionsTableBoot';
import { ActionsMenu } from './_components';
import { useUnexcludeUncategorizedTransaction } from '@/hooks/query/bank-rules';
import {
WithBankingActionsProps,
withBankingActions,
} from '../../withBankingActions';
interface ExcludeTransactionsTableProps extends WithBankingActionsProps {}
/**
* Renders the recognized account transactions datatable.
*/
function ExcludedTransactionsTableRoot({
// #withBankingActions
setExcludedTransactionsSelected,
}: ExcludeTransactionsTableProps) {
export function ExcludedTransactionsTable() {
const { excludedBankTransactions } = useExcludedTransactionsBoot();
const { mutateAsync: unexcludeBankTransaction } =
useUnexcludeUncategorizedTransaction();
@@ -64,12 +55,6 @@ function ExcludedTransactionsTableRoot({
});
};
// Handle selected rows change.
const handleSelectedRowsChange = (selected) => {
const _selectedIds = selected?.map((row) => row.original.id);
setExcludedTransactionsSelected(_selectedIds);
};
return (
<CashflowTransactionsTable
noInitialFetch={true}
@@ -95,8 +80,6 @@ function ExcludedTransactionsTableRoot({
onColumnResizing={handleColumnResizing}
noResults={'There is no excluded bank transactions.'}
className="table-constrant"
selectionColumn={true}
onSelectedRowsChange={handleSelectedRowsChange}
payload={{
onRestore: handleRestoreClick,
}}
@@ -104,10 +87,6 @@ function ExcludedTransactionsTableRoot({
);
}
export const ExcludedTransactionsTable = R.compose(withBankingActions)(
ExcludedTransactionsTableRoot,
);
const DashboardConstrantTable = styled(DataTable)`
.table {
.thead {

View File

@@ -1,27 +1,8 @@
// @ts-nocheck
import { useEffect } from 'react';
import * as R from 'ramda';
import {
WithBankingActionsProps,
withBankingActions,
} from '../../withBankingActions';
import { ExcludedTransactionsTable } from '../ExcludedTransactions/ExcludedTransactionsTable';
import { ExcludedBankTransactionsTableBoot } from '../ExcludedTransactions/ExcludedTransactionsTableBoot';
import { AccountTransactionsCard } from './AccountTransactionsCard';
interface AccountExcludedTransactionsProps extends WithBankingActionsProps {}
function AccountExcludedTransactionsRoot({
// #withBankingActions
resetExcludedTransactionsSelected,
}: AccountExcludedTransactionsProps) {
useEffect(
() => () => {
resetExcludedTransactionsSelected();
},
[resetExcludedTransactionsSelected],
);
import { ExcludedTransactionsTable } from "../ExcludedTransactions/ExcludedTransactionsTable";
import { ExcludedBankTransactionsTableBoot } from "../ExcludedTransactions/ExcludedTransactionsTableBoot";
import { AccountTransactionsCard } from "./AccountTransactionsCard";
export function AccountExcludedTransactions() {
return (
<ExcludedBankTransactionsTableBoot>
<AccountTransactionsCard>
@@ -30,7 +11,3 @@ function AccountExcludedTransactionsRoot({
</ExcludedBankTransactionsTableBoot>
);
}
export const AccountExcludedTransactions = R.compose(withBankingActions)(
AccountExcludedTransactionsRoot,
);

View File

@@ -1,26 +1,8 @@
import * as R from 'ramda';
import { useEffect } from 'react';
import AccountTransactionsUncategorizedTable from '../AccountTransactionsUncategorizedTable';
import { AccountUncategorizedTransactionsBoot } from '../AllTransactionsUncategorizedBoot';
import { AccountTransactionsCard } from './AccountTransactionsCard';
import {
WithBankingActionsProps,
withBankingActions,
} from '../../withBankingActions';
interface AccountUncategorizedTransactionsAllRootProps
extends WithBankingActionsProps {}
function AccountUncategorizedTransactionsAllRoot({
resetUncategorizedTransactionsSelected,
}: AccountUncategorizedTransactionsAllRootProps) {
useEffect(
() => () => {
resetUncategorizedTransactionsSelected();
},
[resetUncategorizedTransactionsSelected],
);
export function AccountUncategorizedTransactionsAll() {
return (
<AccountUncategorizedTransactionsBoot>
<AccountTransactionsCard>
@@ -29,7 +11,3 @@ function AccountUncategorizedTransactionsAllRoot({
</AccountUncategorizedTransactionsBoot>
);
}
export const AccountUncategorizedTransactionsAll = R.compose(
withBankingActions,
)(AccountUncategorizedTransactionsAllRoot);

View File

@@ -13,11 +13,6 @@ export const withBanking = (mapState) => {
reconcileMatchingTransactionPendingAmount:
state.plaid.openReconcileMatchingTransaction.pending,
uncategorizedTransationsIdsSelected:
state.plaid.uncategorizedTransactionsSelected,
excludedTransactionsIdsSelected: state.plaid.excludedTransactionsSelected,
};
return mapState ? mapState(mapped, state, props) : mapped;
};

View File

@@ -4,10 +4,6 @@ import {
setUncategorizedTransactionIdForMatching,
openReconcileMatchingTransaction,
closeReconcileMatchingTransaction,
setUncategorizedTransactionsSelected,
resetUncategorizedTransactionsSelected,
resetExcludedTransactionsSelected,
setExcludedTransactionsSelected,
} from '@/store/banking/banking.reducer';
export interface WithBankingActionsProps {
@@ -17,12 +13,6 @@ export interface WithBankingActionsProps {
) => void;
openReconcileMatchingTransaction: (pendingAmount: number) => void;
closeReconcileMatchingTransaction: () => void;
setUncategorizedTransactionsSelected: (ids: Array<string | number>) => void;
resetUncategorizedTransactionsSelected: () => void;
setExcludedTransactionsSelected: (ids: Array<string | number>) => void;
resetExcludedTransactionsSelected: () => void;
}
const mapDipatchToProps = (dispatch: any): WithBankingActionsProps => ({
@@ -38,40 +28,6 @@ const mapDipatchToProps = (dispatch: any): WithBankingActionsProps => ({
dispatch(openReconcileMatchingTransaction({ pending: pendingAmount })),
closeReconcileMatchingTransaction: () =>
dispatch(closeReconcileMatchingTransaction()),
/**
* Sets the selected uncategorized transactions.
* @param {Array<string | number>} ids
*/
setUncategorizedTransactionsSelected: (ids: Array<string | number>) =>
dispatch(
setUncategorizedTransactionsSelected({
transactionIds: ids,
}),
),
/**
* Resets the selected uncategorized transactions.
*/
resetUncategorizedTransactionsSelected: () =>
dispatch(resetUncategorizedTransactionsSelected()),
/**
* Sets excluded selected transactions.
* @param {Array<string | number>} ids
*/
setExcludedTransactionsSelected: (ids: Array<string | number>) =>
dispatch(
setExcludedTransactionsSelected({
ids,
}),
),
/**
* Resets the excluded selected transactions
*/
resetExcludedTransactionsSelected: () =>
dispatch(resetExcludedTransactionsSelected()),
});
export const withBankingActions = connect<

View File

@@ -6,6 +6,7 @@ import '@/style/pages/Customers/List.scss';
import { DashboardPageContent } from '@/components';
import CustomersActionsBar from './CustomersActionsBar';
import CustomersViewsTabs from './CustomersViewsTabs';
import CustomersTable from './CustomersTable';
import { CustomersListProvider } from './CustomersListProvider';
@@ -41,6 +42,7 @@ function CustomersList({
<CustomersActionsBar />
<DashboardPageContent>
<CustomersViewsTabs />
<CustomersTable />
</DashboardPageContent>
</CustomersListProvider>

View File

@@ -160,6 +160,13 @@ export function useCustomersTableColumns() {
width: 85,
clickable: true,
},
{
id: 'credit_balance',
Header: 'Credit Balance',
accessor: 'formatted_unused_credit',
width: 100,
align: 'right',
},
{
id: 'balance',
Header: intl.get('receivable_balance'),

View File

@@ -3,14 +3,15 @@ import React from 'react';
import intl from 'react-intl-universal';
import { Formik } from 'formik';
import { Intent } from '@blueprintjs/core';
import { omit } from 'lodash';
import { pick } from 'lodash';
import { AppToaster } from '@/components';
import { CreateQuickPaymentMadeFormSchema } from './QuickPaymentMade.schema';
import { useQuickPaymentMadeContext } from './QuickPaymentMadeFormProvider';
import QuickPaymentMadeFormContent from './QuickPaymentMadeFormContent';
import withDialogActions from '@/containers/Dialog/withDialogActions';
import { defaultPaymentMade, transformBillToForm, transformErrors } from './utils';
import { defaultPaymentMade, transformErrors } from './utils';
import { compose } from '@/utils';
/**
@@ -20,24 +21,31 @@ function QuickPaymentMadeForm({
// #withDialogActions
closeDialog,
}) {
const { bill, dialogName, createPaymentMadeMutate } =
useQuickPaymentMadeContext();
const {
bill,
dialogName,
createPaymentMadeMutate,
} = useQuickPaymentMadeContext();
// Initial form values.
// Initial form values
const initialValues = {
...defaultPaymentMade,
...transformBillToForm(bill),
...bill,
};
// Handles the form submit.
const handleFormSubmit = (values, { setSubmitting, setFieldError }) => {
const entries = [
{
payment_amount: values.amount,
bill_id: values.bill_id,
},
];
const entries = [values]
.filter((entry) => entry.id && entry.payment_amount)
.map((entry) => ({
bill_id: entry.id,
...pick(entry, ['payment_amount']),
}));
const form = {
...omit(values, ['bill_id']),
...values,
vendor_id: values?.vendor?.id,
entries,
};

View File

@@ -124,7 +124,7 @@ function QuickPaymentMadeFormFields({
</Col>
</Row>
{/*------------ Amount Received -----------*/}
<FastField name={'amount'}>
<FastField name={'payment_amount'}>
{({
form: { values, setFieldValue },
field: { value },
@@ -135,7 +135,7 @@ function QuickPaymentMadeFormFields({
labelInfo={<FieldRequiredHint />}
className={classNames('form-group--payment_amount', CLASSES.FILL)}
intent={inputIntent({ error, touched })}
helperText={<ErrorMessage name="amount" />}
helperText={<ErrorMessage name="payment_amount" />}
>
<ControlGroup>
<InputPrependText text={values.currency_code} />
@@ -144,7 +144,7 @@ function QuickPaymentMadeFormFields({
value={value}
minimal={true}
onChange={(amount) => {
setFieldValue('amount', amount);
setFieldValue('payment_amount', amount);
}}
intent={inputIntent({ error, touched })}
inputRef={(ref) => (paymentMadeFieldRef.current = ref)}

View File

@@ -1,5 +1,5 @@
// @ts-nocheck
import React, { useMemo } from 'react';
import React from 'react';
import { DialogContent } from '@/components';
import {
useBill,
@@ -11,6 +11,7 @@ import { Features } from '@/constants';
import { useFeatureCan } from '@/hooks/state';
import { pick } from 'lodash';
const QuickPaymentMadeContext = React.createContext();
/**
@@ -39,14 +40,13 @@ function QuickPaymentMadeFormProvider({ query, billId, dialogName, ...props }) {
isSuccess: isBranchesSuccess,
} = useBranches(query, { enabled: isBranchFeatureCan });
const paymentBill = useMemo(
() => pick(bill, ['id', 'due_amount', 'vendor_id', 'currency_code']),
[bill],
);
// State provider.
const provider = {
bill: paymentBill,
bill: {
...pick(bill, ['id', 'due_amount', 'vendor', 'currency_code']),
vendor_id: bill?.vendor?.display_name,
payment_amount: bill?.due_amount,
},
accounts,
branches,
dialogName,

View File

@@ -2,25 +2,24 @@
import React from 'react';
import moment from 'moment';
import intl from 'react-intl-universal';
import { first, pick } from 'lodash';
import { useFormikContext } from 'formik';
import { first } from 'lodash';
import { Intent } from '@blueprintjs/core';
import { AppToaster } from '@/components';
import { useFormikContext } from 'formik';
import { useQuickPaymentMadeContext } from './QuickPaymentMadeFormProvider';
import { PAYMENT_MADE_ERRORS } from '@/containers/Purchases/PaymentMades/constants';
// Default initial values of payment made.
export const defaultPaymentMade = {
bill_id: '',
vendor_id: '',
payment_account_id: '',
payment_date: moment(new Date()).format('YYYY-MM-DD'),
reference: '',
payment_number: '',
amount: '',
// statement: '',
exchange_rate: 1,
branch_id: '',
entries: [{ bill_id: '', payment_amount: '' }],
};
export const transformErrors = (errors, { setFieldError }) => {
@@ -59,11 +58,3 @@ export const useSetPrimaryBranchToForm = () => {
}
}, [isBranchesSuccess, setFieldValue, branches]);
};
export const transformBillToForm = (bill) => {
return {
...pick(bill, ['vendor_id', 'currency_code']),
amount: bill.due_amount,
bill_id: bill.id,
};
}

View File

@@ -3,7 +3,7 @@ import React from 'react';
import intl from 'react-intl-universal';
import { Formik } from 'formik';
import { Intent } from '@blueprintjs/core';
import { defaultTo, omit } from 'lodash';
import { pick, defaultTo, omit } from 'lodash';
import { AppToaster } from '@/components';
import { useQuickPaymentReceiveContext } from './QuickPaymentReceiveFormProvider';
@@ -12,11 +12,7 @@ import QuickPaymentReceiveFormContent from './QuickPaymentReceiveFormContent';
import withSettings from '@/containers/Settings/withSettings';
import withDialogActions from '@/containers/Dialog/withDialogActions';
import {
defaultInitialValues,
transformErrors,
transformInvoiceToForm,
} from './utils';
import { defaultInitialValues, transformErrors } from './utils';
import { compose, transactionNumber } from '@/utils';
/**
@@ -30,10 +26,14 @@ function QuickPaymentReceiveForm({
paymentReceiveAutoIncrement,
paymentReceiveNumberPrefix,
paymentReceiveNextNumber,
preferredDepositAccount,
preferredDepositAccount
}) {
const { dialogName, invoice, createPaymentReceiveMutate } =
useQuickPaymentReceiveContext();
const {
dialogName,
invoice,
createPaymentReceiveMutate,
} = useQuickPaymentReceiveContext();
// Payment receive number.
const nextPaymentNumber = transactionNumber(
@@ -48,22 +48,24 @@ function QuickPaymentReceiveForm({
payment_receive_no: nextPaymentNumber,
}),
deposit_account_id: defaultTo(preferredDepositAccount, ''),
...transformInvoiceToForm(invoice),
...invoice,
};
// Handles the form submit.
const handleFormSubmit = (values, { setSubmitting, setFieldError }) => {
const entries = [
{
invoice_id: values.invoice_id,
payment_amount: values.amount,
},
];
const entries = [values]
.filter((entry) => entry.id && entry.payment_amount)
.map((entry) => ({
invoice_id: entry.id,
...pick(entry, ['payment_amount']),
}));
const form = {
...omit(values, ['payment_receive_no', 'invoice_id']),
...omit(values, ['payment_receive_no']),
...(!paymentReceiveAutoIncrement && {
payment_receive_no: values.payment_receive_no,
}),
customer_id: values.customer.id,
entries,
};

View File

@@ -128,7 +128,7 @@ function QuickPaymentReceiveFormFields({
</Col>
</Row>
{/*------------ Amount Received -----------*/}
<FastField name={'amount'}>
<FastField name={'payment_amount'}>
{({
form: { values, setFieldValue },
field: { value },
@@ -139,7 +139,7 @@ function QuickPaymentReceiveFormFields({
labelInfo={<FieldRequiredHint />}
className={classNames('form-group--payment_amount', CLASSES.FILL)}
intent={inputIntent({ error, touched })}
helperText={<ErrorMessage name="amount" />}
helperText={<ErrorMessage name="payment_amount" />}
>
<ControlGroup>
<InputPrependText text={values.currency_code} />
@@ -148,7 +148,7 @@ function QuickPaymentReceiveFormFields({
value={value}
minimal={true}
onChange={(amount) => {
setFieldValue('amount', amount);
setFieldValue('payment_amount', amount);
}}
intent={inputIntent({ error, touched })}
inputRef={(ref) => (paymentReceiveFieldRef.current = ref)}

View File

@@ -1,5 +1,5 @@
// @ts-nocheck
import React, { useContext, createContext, useMemo } from 'react';
import React, { useContext, createContext } from 'react';
import { pick } from 'lodash';
import { DialogContent } from '@/components';
import { Features } from '@/constants';
@@ -47,16 +47,15 @@ function QuickPaymentReceiveFormProvider({
isSuccess: isBranchesSuccess,
} = useBranches(query, { enabled: isBranchFeatureCan });
const invoicePayment = useMemo(
() => pick(invoice, ['id', 'due_amount', 'customer_id', 'currency_code']),
[invoice],
);
// State provider.
const provider = {
accounts,
branches,
invoice: invoicePayment,
invoice: {
...pick(invoice, ['id', 'due_amount', 'customer', 'currency_code']),
customer_id: invoice?.customer?.display_name,
payment_amount: invoice.due_amount,
},
isAccountsLoading,
isSettingsLoading,
isBranchesSuccess,

View File

@@ -2,7 +2,7 @@
import React from 'react';
import moment from 'moment';
import intl from 'react-intl-universal';
import { first, pick } from 'lodash';
import { first } from 'lodash';
import { Intent } from '@blueprintjs/core';
import { AppToaster } from '@/components';
@@ -10,16 +10,15 @@ import { useFormikContext } from 'formik';
import { useQuickPaymentReceiveContext } from './QuickPaymentReceiveFormProvider';
export const defaultInitialValues = {
invoice_id: '',
customer_id: '',
deposit_account_id: '',
payment_receive_no: '',
payment_date: moment(new Date()).format('YYYY-MM-DD'),
reference_no: '',
amount: '',
// statement: '',
exchange_rate: 1,
branch_id: '',
entries: [{ invoice_id: '', payment_amount: '' }],
};
export const transformErrors = (errors, { setFieldError }) => {
@@ -45,9 +44,7 @@ export const transformErrors = (errors, { setFieldError }) => {
}
if (getError('PAYMENT_ACCOUNT_CURRENCY_INVALID')) {
AppToaster.show({
message: intl.get(
'payment_Receive.error.payment_account_currency_invalid',
),
message: intl.get('payment_Receive.error.payment_account_currency_invalid'),
intent: Intent.DANGER,
});
}
@@ -67,11 +64,3 @@ export const useSetPrimaryBranchToForm = () => {
}
}, [isBranchesSuccess, setFieldValue, branches]);
};
export const transformInvoiceToForm = (invoice) => {
return {
...pick(invoice, ['customer_id', 'currency_code']),
amount: invoice.due_amount,
invoice_id: invoice.id,
};
};

View File

@@ -6,6 +6,7 @@ import '@/style/pages/Expense/List.scss';
import { DashboardPageContent } from '@/components';
import ExpenseActionsBar from './ExpenseActionsBar';
import ExpenseViewTabs from './ExpenseViewTabs';
import ExpenseDataTable from './ExpenseDataTable';
import withExpenses from './withExpenses';
@@ -41,6 +42,7 @@ function ExpensesList({
<ExpenseActionsBar />
<DashboardPageContent>
<ExpenseViewTabs />
<ExpenseDataTable />
</DashboardPageContent>
</ExpensesListProvider>

View File

@@ -8,6 +8,7 @@ import { DashboardPageContent } from '@/components';
import { ItemsListProvider } from './ItemsListProvider';
import ItemsActionsBar from './ItemsActionsBar';
import ItemsViewsTabs from './ItemsViewsTabs';
import ItemsDataTable from './ItemsDataTable';
import withItems from './withItems';
@@ -40,6 +41,7 @@ function ItemsList({
<ItemsActionsBar />
<DashboardPageContent>
<ItemsViewsTabs />
<ItemsDataTable />
</DashboardPageContent>
</ItemsListProvider>

View File

@@ -7,6 +7,7 @@ import '@/style/pages/Bills/List.scss';
import { BillsListProvider } from './BillsListProvider';
import BillsActionsBar from './BillsActionsBar';
import BillsViewsTabs from './BillsViewsTabs';
import BillsTable from './BillsTable';
import withBills from './withBills';
@@ -41,6 +42,7 @@ function BillsList({
<BillsActionsBar />
<DashboardPageContent>
<BillsViewsTabs />
<BillsTable />
</DashboardPageContent>
</BillsListProvider>

View File

@@ -5,6 +5,7 @@ import '@/style/pages/VendorsCreditNote/List.scss';
import { DashboardPageContent } from '@/components';
import VendorsCreditNoteActionsBar from './VendorsCreditNoteActionsBar';
import VendorsCreditNoteViewTabs from './VendorsCreditNoteViewTabs';
import VendorsCreditNoteDataTable from './VendorsCreditNoteDataTable';
import withVendorsCreditNotes from './withVendorsCreditNotes';
@@ -36,6 +37,7 @@ function VendorsCreditNotesList({
>
<VendorsCreditNoteActionsBar />
<DashboardPageContent>
<VendorsCreditNoteViewTabs />
<VendorsCreditNoteDataTable />
</DashboardPageContent>
</VendorsCreditNoteListProvider>

View File

@@ -1,16 +1,32 @@
// @ts-nocheck
import * as R from 'ramda';
import React from 'react';
import * as Yup from 'yup';
import React, { useMemo } from 'react';
import { Button, Classes, Intent } from '@blueprintjs/core';
import { Form, Formik, FormikHelpers, useFormikContext } from 'formik';
import { FormatNumber } from '@/components';
import { AccountsSelect, FFormGroup, FormatNumber } from '@/components';
import { usePaymentMadeFormContext } from '../../PaymentMadeFormProvider';
import withDialogActions from '@/containers/Dialog/withDialogActions';
import { ACCOUNT_TYPE } from '@/constants';
import { usePaymentMadeExcessAmount } from '../../utils';
interface ExcessPaymentValues {}
interface ExcessPaymentValues {
accountId: string;
}
const initialValues = {
accountId: '',
} as ExcessPaymentValues;
const Schema = Yup.object().shape({
accountId: Yup.number().required(),
});
const DEFAULT_ACCOUNT_SLUG = 'prepaid-expenses';
function ExcessPaymentDialogContentRoot({ dialogName, closeDialog }) {
const {
setFieldValue,
submitForm,
values: { currency_code: currencyCode },
} = useFormikContext();
@@ -21,6 +37,7 @@ function ExcessPaymentDialogContentRoot({ dialogName, closeDialog }) {
values: ExcessPaymentValues,
{ setSubmitting }: FormikHelpers<ExcessPaymentValues>,
) => {
setFieldValue(values.accountId);
setSubmitting(true);
setIsExcessConfirmed(true);
@@ -33,10 +50,20 @@ function ExcessPaymentDialogContentRoot({ dialogName, closeDialog }) {
const handleCloseBtn = () => {
closeDialog(dialogName);
};
// Retrieves the default excess account id.
const defaultAccountId = useDefaultExcessPaymentDeposit();
const excessAmount = usePaymentMadeExcessAmount();
return (
<Formik initialValues={{}} onSubmit={handleSubmit}>
<Formik
initialValues={{
...initialValues,
accountId: defaultAccountId,
}}
validationSchema={Schema}
onSubmit={handleSubmit}
>
<Form>
<ExcessPaymentDialogContentForm
excessAmount={
@@ -63,6 +90,7 @@ function ExcessPaymentDialogContentForm({
onClose,
}: ExcessPaymentDialogContentFormProps) {
const { submitForm, isSubmitting } = useFormikContext();
const { accounts } = usePaymentMadeFormContext();
const handleCloseBtn = () => {
onClose && onClose();
@@ -72,8 +100,26 @@ function ExcessPaymentDialogContentForm({
<div className={Classes.DIALOG_BODY}>
<p style={{ marginBottom: 20 }}>
Would you like to record the excess amount of{' '}
<strong>{excessAmount}</strong> as credit payment from the vendor.
<strong>{excessAmount}</strong> as advanced payment from the customer.
</p>
<FFormGroup
name={'accountId'}
label={'The excessed amount will be deposited in the'}
helperText={
'Only other current asset and non current asset accounts will show.'
}
>
<AccountsSelect
name={'accountId'}
items={accounts}
buttonProps={{ small: true }}
filterByTypes={[
ACCOUNT_TYPE.OTHER_CURRENT_ASSET,
ACCOUNT_TYPE.NON_CURRENT_ASSET,
]}
/>
</FFormGroup>
</div>
<div className={Classes.DIALOG_FOOTER}>
@@ -83,7 +129,7 @@ function ExcessPaymentDialogContentForm({
loading={isSubmitting}
onClick={() => submitForm()}
>
Save Payment as Credit
Save Payment
</Button>
<Button onClick={handleCloseBtn}>Cancel</Button>
</div>
@@ -91,3 +137,11 @@ function ExcessPaymentDialogContentForm({
</>
);
}
const useDefaultExcessPaymentDeposit = () => {
const { accounts } = usePaymentMadeFormContext();
return useMemo(() => {
return accounts?.find((a) => a.slug === DEFAULT_ACCOUNT_SLUG)?.id;
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
};

View File

@@ -7,6 +7,7 @@ import { DashboardPageContent } from '@/components';
import { PaymentMadesListProvider } from './PaymentMadesListProvider';
import PaymentMadeActionsBar from './PaymentMadeActionsBar';
import PaymentMadesTable from './PaymentMadesTable';
import PaymentMadeViewTabs from './PaymentMadeViewTabs';
import withPaymentMades from './withPaymentMade';
import withPaymentMadeActions from './withPaymentMadeActions';
@@ -40,6 +41,7 @@ function PaymentMadeList({
<PaymentMadeActionsBar />
<DashboardPageContent>
<PaymentMadeViewTabs />
<PaymentMadesTable />
</DashboardPageContent>
</PaymentMadesListProvider>

View File

@@ -5,6 +5,7 @@ import '@/style/pages/CreditNote/List.scss';
import { DashboardPageContent } from '@/components';
import CreditNotesActionsBar from './CreditNotesActionsBar';
import CreditNotesViewTabs from './CreditNotesViewTabs';
import CreditNotesDataTable from './CreditNotesDataTable';
import withCreditNotes from './withCreditNotes';
@@ -35,8 +36,8 @@ function CreditNotesList({
tableStateChanged={creditNoteTableStateChanged}
>
<CreditNotesActionsBar />
<DashboardPageContent>
<CreditNotesViewTabs />
<CreditNotesDataTable />
</DashboardPageContent>
</CreditNotesListProvider>

View File

@@ -1,10 +1,11 @@
// @ts-nocheck
import React from 'react';
import { DashboardPageContent } from '@/components';
import { DashboardContentTable, DashboardPageContent } from '@/components';
import '@/style/pages/SaleEstimate/List.scss';
import EstimatesActionsBar from './EstimatesActionsBar';
import EstimatesViewTabs from './EstimatesViewTabs';
import EstimatesDataTable from './EstimatesDataTable';
import withEstimates from './withEstimates';
@@ -40,6 +41,7 @@ function EstimatesList({
<EstimatesActionsBar />
<DashboardPageContent>
<EstimatesViewTabs />
<EstimatesDataTable />
</DashboardPageContent>
</EstimatesListProvider>

View File

@@ -6,6 +6,7 @@ import '@/style/pages/SaleInvoice/List.scss';
import { DashboardPageContent } from '@/components';
import { InvoicesListProvider } from './InvoicesListProvider';
import InvoiceViewTabs from './InvoiceViewTabs';
import InvoicesDataTable from './InvoicesDataTable';
import InvoicesActionsBar from './InvoicesActionsBar';
@@ -42,6 +43,7 @@ function InvoicesList({
<InvoicesActionsBar />
<DashboardPageContent>
<InvoiceViewTabs />
<InvoicesDataTable />
</DashboardPageContent>
</InvoicesListProvider>

View File

@@ -1,21 +1,37 @@
// @ts-nocheck
import { useMemo } from 'react';
import * as Yup from 'yup';
import * as R from 'ramda';
import { Button, Classes, Intent } from '@blueprintjs/core';
import { Form, Formik, FormikHelpers, useFormikContext } from 'formik';
import { FormatNumber } from '@/components';
import { AccountsSelect, FFormGroup, FormatNumber } from '@/components';
import { usePaymentReceiveFormContext } from '../../PaymentReceiveFormProvider';
import withDialogActions from '@/containers/Dialog/withDialogActions';
import { usePaymentReceivedTotalExceededAmount } from '../../utils';
import { ACCOUNT_TYPE } from '@/constants';
interface ExcessPaymentValues {}
interface ExcessPaymentValues {
accountId: string;
}
const initialValues = {
accountId: '',
} as ExcessPaymentValues;
const Schema = Yup.object().shape({
accountId: Yup.number().required(),
});
const DEFAULT_ACCOUNT_SLUG = 'unearned-revenue';
export function ExcessPaymentDialogContentRoot({ dialogName, closeDialog }) {
const {
setFieldValue,
submitForm,
values: { currency_code: currencyCode },
} = useFormikContext();
const { setIsExcessConfirmed } = usePaymentReceiveFormContext();
const initialAccountId = useDefaultExcessPaymentDeposit();
const exceededAmount = usePaymentReceivedTotalExceededAmount();
const handleSubmit = (
@@ -24,6 +40,7 @@ export function ExcessPaymentDialogContentRoot({ dialogName, closeDialog }) {
) => {
setSubmitting(true);
setIsExcessConfirmed(true);
setFieldValue('unearned_revenue_account_id', values.accountId);
submitForm().then(() => {
closeDialog(dialogName);
@@ -35,7 +52,14 @@ export function ExcessPaymentDialogContentRoot({ dialogName, closeDialog }) {
};
return (
<Formik initialValues={{}} onSubmit={handleSubmit}>
<Formik
initialValues={{
...initialValues,
accountId: initialAccountId,
}}
validationSchema={Schema}
onSubmit={handleSubmit}
>
<Form>
<ExcessPaymentDialogContentForm
exceededAmount={
@@ -53,6 +77,7 @@ export const ExcessPaymentDialogContent = R.compose(withDialogActions)(
);
function ExcessPaymentDialogContentForm({ onClose, exceededAmount }) {
const { accounts } = usePaymentReceiveFormContext();
const { submitForm, isSubmitting } = useFormikContext();
const handleCloseBtn = () => {
@@ -64,8 +89,27 @@ function ExcessPaymentDialogContentForm({ onClose, exceededAmount }) {
<div className={Classes.DIALOG_BODY}>
<p style={{ marginBottom: 20 }}>
Would you like to record the excess amount of{' '}
<strong>{exceededAmount}</strong> as credit payment from the customer.
<strong>{exceededAmount}</strong> as advanced payment from the
customer.
</p>
<FFormGroup
name={'accountId'}
label={'The excessed amount will be deposited in the'}
helperText={
'Only other other current liability and non current liability accounts will show.'
}
>
<AccountsSelect
name={'accountId'}
items={accounts}
buttonProps={{ small: true }}
filterByTypes={[
ACCOUNT_TYPE.OTHER_CURRENT_LIABILITY,
ACCOUNT_TYPE.NON_CURRENT_LIABILITY,
]}
/>
</FFormGroup>
</div>
<div className={Classes.DIALOG_FOOTER}>
@@ -76,7 +120,7 @@ function ExcessPaymentDialogContentForm({ onClose, exceededAmount }) {
disabled={isSubmitting}
onClick={() => submitForm()}
>
Save Payment as Credit
Save Payment
</Button>
<Button onClick={handleCloseBtn}>Cancel</Button>
</div>
@@ -84,3 +128,11 @@ function ExcessPaymentDialogContentForm({ onClose, exceededAmount }) {
</>
);
}
const useDefaultExcessPaymentDeposit = () => {
const { accounts } = usePaymentReceiveFormContext();
return useMemo(() => {
return accounts?.find((a) => a.slug === DEFAULT_ACCOUNT_SLUG)?.id;
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
};

View File

@@ -48,6 +48,7 @@ export const defaultPaymentReceive = {
exchange_rate: 1,
entries: [],
attachments: [],
unearned_revenue_account_id: '',
};
export const defaultRequestPaymentEntry = {
@@ -298,9 +299,10 @@ export const resetFormState = ({ initialValues, values, resetForm }) => {
});
};
export const getExceededAmountFromValues = (values) => {
const totalApplied = sumBy(values.entries, 'payment_amount');
const totalAmount = values.amount;
return totalAmount - totalApplied;
};
}

View File

@@ -5,6 +5,7 @@ import '@/style/pages/PaymentReceive/List.scss';
import { DashboardPageContent } from '@/components';
import { PaymentReceivesListProvider } from './PaymentReceiptsListProvider';
import PaymentReceiveViewTabs from './PaymentReceiveViewTabs';
import PaymentReceivesTable from './PaymentReceivesTable';
import PaymentReceiveActionsBar from './PaymentReceiveActionsBar';
@@ -40,6 +41,7 @@ function PaymentReceiveList({
<PaymentReceiveActionsBar />
<DashboardPageContent>
<PaymentReceiveViewTabs />
<PaymentReceivesTable />
</DashboardPageContent>
</PaymentReceivesListProvider>

View File

@@ -7,6 +7,7 @@ import { DashboardPageContent } from '@/components';
import { VendorsListProvider } from './VendorsListProvider';
import VendorActionsBar from './VendorActionsBar';
import VendorViewsTabs from './VendorViewsTabs';
import VendorsTable from './VendorsTable';
import withVendors from './withVendors';
@@ -41,6 +42,7 @@ function VendorsList({
<VendorActionsBar />
<DashboardPageContent>
<VendorViewsTabs />
<VendorsTable />
</DashboardPageContent>
</VendorsListProvider>

View File

@@ -183,6 +183,13 @@ export function useVendorsTableColumns() {
width: 85,
clickable: true,
},
{
id: 'credit_balance',
Header: 'Credit Balance',
accessor: 'formatted_unused_credit',
width: 100,
align: 'right',
},
{
id: 'balance',
Header: intl.get('receivable_balance'),

View File

@@ -3,6 +3,7 @@ import React from 'react';
import { DashboardPageContent } from '@/components';
import WarehouseTransfersActionsBar from './WarehouseTransfersActionsBar';
import WarehouseTransfersViewTabs from './WarehouseTransfersViewTabs';
import WarehouseTransfersDataTable from './WarehouseTransfersDataTable';
import withWarehouseTransfers from './withWarehouseTransfers';
import withWarehouseTransfersActions from './withWarehouseTransfersActions';
@@ -32,8 +33,8 @@ function WarehouseTransfersList({
tableStateChanged={warehouseTransferTableStateChanged}
>
<WarehouseTransfersActionsBar />
<DashboardPageContent>
<WarehouseTransfersViewTabs />
<WarehouseTransfersDataTable />
</DashboardPageContent>
</WarehouseTransfersListProvider>

View File

@@ -61,76 +61,6 @@ export function useCreateBankRule(
);
}
interface DisconnectBankAccountRes {}
interface DisconnectBankAccountValues {
bankAccountId: number;
}
/**
* Disconnects the given bank account.
* @param {UseMutationOptions<DisconnectBankAccountRes, Error, DisconnectBankAccountValues>} options
* @returns {UseMutationResult<DisconnectBankAccountRes, Error, DisconnectBankAccountValues>}
*/
export function useDisconnectBankAccount(
options?: UseMutationOptions<
DisconnectBankAccountRes,
Error,
DisconnectBankAccountValues
>,
): UseMutationResult<
DisconnectBankAccountRes,
Error,
DisconnectBankAccountValues
> {
const queryClient = useQueryClient();
const apiRequest = useApiRequest();
return useMutation<
DisconnectBankAccountRes,
Error,
DisconnectBankAccountValues
>(
({ bankAccountId }) =>
apiRequest.post(`/banking/bank_accounts/${bankAccountId}/disconnect`),
{
...options,
onSuccess: (res, values) => {
queryClient.invalidateQueries([t.ACCOUNT, values.bankAccountId]);
},
},
);
}
interface UpdateBankAccountRes {}
interface UpdateBankAccountValues {
bankAccountId: number;
}
/**
* Update the bank transactions of the bank account.
* @param {UseMutationOptions<UpdateBankAccountRes, Error, UpdateBankAccountValues>}
* @returns {UseMutationResult<UpdateBankAccountRes, Error, UpdateBankAccountValues>}
*/
export function useUpdateBankAccount(
options?: UseMutationOptions<
UpdateBankAccountRes,
Error,
UpdateBankAccountValues
>,
): UseMutationResult<UpdateBankAccountRes, Error, UpdateBankAccountValues> {
const queryClient = useQueryClient();
const apiRequest = useApiRequest();
return useMutation<DisconnectBankAccountRes, Error, UpdateBankAccountValues>(
({ bankAccountId }) =>
apiRequest.post(`/banking/bank_accounts/${bankAccountId}/update`),
{
...options,
onSuccess: () => {},
},
);
}
interface EditBankRuleValues {
id: number;
value: any;
@@ -265,20 +195,6 @@ export function useGetBankTransactionsMatches(
);
}
const onValidateExcludeUncategorizedTransaction = (queryClient) => {
// Invalidate queries.
queryClient.invalidateQueries(QUERY_KEY.EXCLUDED_BANK_TRANSACTIONS_INFINITY);
queryClient.invalidateQueries(
t.CASHFLOW_ACCOUNT_UNCATEGORIZED_TRANSACTIONS_INFINITY,
);
// Invalidate accounts.
queryClient.invalidateQueries(t.ACCOUNTS);
queryClient.invalidateQueries(t.ACCOUNT);
// invalidate bank account summary.
queryClient.invalidateQueries(QUERY_KEY.BANK_ACCOUNT_SUMMARY_META);
};
type ExcludeUncategorizedTransactionValue = number;
interface ExcludeUncategorizedTransactionRes {}
@@ -312,7 +228,19 @@ export function useExcludeUncategorizedTransaction(
),
{
onSuccess: (res, id) => {
onValidateExcludeUncategorizedTransaction(queryClient);
// Invalidate queries.
queryClient.invalidateQueries(
QUERY_KEY.EXCLUDED_BANK_TRANSACTIONS_INFINITY,
);
queryClient.invalidateQueries(
t.CASHFLOW_ACCOUNT_UNCATEGORIZED_TRANSACTIONS_INFINITY,
);
// Invalidate accounts.
queryClient.invalidateQueries(t.ACCOUNTS);
queryClient.invalidateQueries(t.ACCOUNT);
// invalidate bank account summary.
queryClient.invalidateQueries(QUERY_KEY.BANK_ACCOUNT_SUMMARY_META);
},
...options,
},
@@ -353,83 +281,19 @@ export function useUnexcludeUncategorizedTransaction(
),
{
onSuccess: (res, id) => {
onValidateExcludeUncategorizedTransaction(queryClient);
},
...options,
},
);
}
// Invalidate queries.
queryClient.invalidateQueries(
QUERY_KEY.EXCLUDED_BANK_TRANSACTIONS_INFINITY,
);
queryClient.invalidateQueries(
t.CASHFLOW_ACCOUNT_UNCATEGORIZED_TRANSACTIONS_INFINITY,
);
// Invalidate accounts.
queryClient.invalidateQueries(t.ACCOUNTS);
queryClient.invalidateQueries(t.ACCOUNT);
type ExcludeBankTransactionsValue = { ids: Array<number | string> };
interface ExcludeBankTransactionsResponse {}
/**
* Excludes the uncategorized bank transactions in bulk.
* @param {UseMutationResult<ExcludeBankTransactionsResponse, Error, ExcludeBankTransactionValue>} options
* @returns {UseMutationResult<ExcludeBankTransactionsResponse, Error, ExcludeBankTransactionValue>}
*/
export function useExcludeUncategorizedTransactions(
options?: UseMutationOptions<
ExcludeBankTransactionsResponse,
Error,
ExcludeBankTransactionsValue
>,
): UseMutationResult<
ExcludeBankTransactionsResponse,
Error,
ExcludeBankTransactionsValue
> {
const queryClient = useQueryClient();
const apiRequest = useApiRequest();
return useMutation<
ExcludeBankTransactionsResponse,
Error,
ExcludeBankTransactionsValue
>(
(value: { ids: Array<number | string> }) =>
apiRequest.put(`/cashflow/transactions/exclude`, { ids: value.ids }),
{
onSuccess: (res, id) => {
onValidateExcludeUncategorizedTransaction(queryClient);
},
...options,
},
);
}
type UnexcludeBankTransactionsValue = { ids: Array<number | string> };
interface UnexcludeBankTransactionsResponse {}
/**
* Excludes the uncategorized bank transactions in bulk.
* @param {UseMutationResult<UnexcludeBankTransactionsResponse, Error, ExcludeBankTransactionValue>} options
* @returns {UseMutationResult<UnexcludeBankTransactionsResponse, Error, ExcludeBankTransactionValue>}
*/
export function useUnexcludeUncategorizedTransactions(
options?: UseMutationOptions<
UnexcludeBankTransactionsResponse,
Error,
UnexcludeBankTransactionsValue
>,
): UseMutationResult<
UnexcludeBankTransactionsResponse,
Error,
UnexcludeBankTransactionsValue
> {
const queryClient = useQueryClient();
const apiRequest = useApiRequest();
return useMutation<
UnexcludeBankTransactionsResponse,
Error,
UnexcludeBankTransactionsValue
>(
(value: { ids: Array<number | string> }) =>
apiRequest.put(`/cashflow/transactions/unexclude`, { ids: value.ids }),
{
onSuccess: (res, id) => {
onValidateExcludeUncategorizedTransaction(queryClient);
// Invalidate bank account summary.
queryClient.invalidateQueries(QUERY_KEY.BANK_ACCOUNT_SUMMARY_META);
},
...options,
},

View File

@@ -635,11 +635,4 @@ export default {
],
viewBox: '0 0 16 16',
},
feed: {
path: [
'M1.99,11.99c-1.1,0-2,0.9-2,2s0.9,2,2,2s2-0.9,2-2S3.1,11.99,1.99,11.99zM2.99,7.99c-0.55,0-1,0.45-1,1s0.45,1,1,1c1.66,0,3,1.34,3,3c0,0.55,0.45,1,1,1s1-0.45,1-1C7.99,10.23,5.75,7.99,2.99,7.99zM2.99,3.99c-0.55,0-1,0.45-1,1s0.45,1,1,1c3.87,0,7,3.13,7,7c0,0.55,0.45,1,1,1s1-0.45,1-1C11.99,8.02,7.96,3.99,2.99,3.99zM2.99-0.01c-0.55,0-1,0.45-1,1s0.45,1,1,1c6.08,0,11,4.92,11,11c0,0.55,0.45,1,1,1s1-0.45,1-1C15.99,5.81,10.17-0.01,2.99-0.01z',
],
viewBox: '0 0 16 16',
},
};

View File

@@ -5,9 +5,6 @@ interface StorePlaidState {
openMatchingTransactionAside: boolean;
uncategorizedTransactionIdForMatching: number | null;
openReconcileMatchingTransaction: { isOpen: boolean; pending: number };
uncategorizedTransactionsSelected: Array<number | string>;
excludedTransactionsSelected: Array<number | string>;
}
export const PlaidSlice = createSlice({
@@ -20,8 +17,6 @@ export const PlaidSlice = createSlice({
isOpen: false,
pending: 0,
},
uncategorizedTransactionsSelected: [],
excludedTransactionsSelected: [],
} as StorePlaidState,
reducers: {
setPlaidId: (state: StorePlaidState, action: PayloadAction<string>) => {
@@ -57,46 +52,6 @@ export const PlaidSlice = createSlice({
state.openReconcileMatchingTransaction.isOpen = false;
state.openReconcileMatchingTransaction.pending = 0;
},
/**
* Sets the selected uncategorized transactions.
* @param {StorePlaidState} state
* @param {PayloadAction<{ transactionIds: Array<string | number> }>} action
*/
setUncategorizedTransactionsSelected: (
state: StorePlaidState,
action: PayloadAction<{ transactionIds: Array<string | number> }>,
) => {
state.uncategorizedTransactionsSelected = action.payload.transactionIds;
},
/**
* Resets the selected uncategorized transactions.
* @param {StorePlaidState} state
*/
resetUncategorizedTransactionsSelected: (state: StorePlaidState) => {
state.uncategorizedTransactionsSelected = [];
},
/**
* Sets excluded selected transactions.
* @param {StorePlaidState} state
* @param {PayloadAction<{ ids: Array<string | number> }>} action
*/
setExcludedTransactionsSelected: (
state: StorePlaidState,
action: PayloadAction<{ ids: Array<string | number> }>,
) => {
state.excludedTransactionsSelected = action.payload.ids;
},
/**
* Resets the excluded selected transactions
* @param {StorePlaidState} state
*/
resetExcludedTransactionsSelected: (state: StorePlaidState) => {
state.excludedTransactionsSelected = [];
},
},
});
@@ -107,10 +62,6 @@ export const {
closeMatchingTransactionAside,
openReconcileMatchingTransaction,
closeReconcileMatchingTransaction,
setUncategorizedTransactionsSelected,
resetUncategorizedTransactionsSelected,
setExcludedTransactionsSelected,
resetExcludedTransactionsSelected,
} = PlaidSlice.actions;
export const getPlaidToken = (state: any) => state.plaid.plaidToken;

View File

@@ -124,18 +124,22 @@
}
}
.bp4-control.bp4-checkbox .bp4-control-indicator {
.bp4-control.bp4-checkbox .bp4-control-indicator {
cursor: auto;
&,
&::before {
&:hover {
height: 15px;
width: 15px;
}
}
.bp4-control.bp4-checkbox input:not(:checked):not(:indeterminate) ~ .bp4-control-indicator{
box-shadow: inset 0 0 0 1px #C5CBD3;
.bp4-control.bp4-checkbox {
input:checked~.bp4-control-indicator,
input:indeterminate~.bp4-control-indicator {
border-color: #0052ff;
}
}
.skeleton {

View File

@@ -208,16 +208,12 @@ $dashboard-views-bar-height: 44px;
}
&.#{$ns}-minimal.#{$ns}-intent-danger {
color: rgb(194, 48, 48);
color: #c23030;
&:not(.bp4-disabled)
&:hover,
&:focus {
background: rgba(219, 55, 55, 0.1);
}
&.bp4-disabled{
color: rgb(194, 48, 48, 0.6);
}
}
&.#{$ns}-minimal.#{$ns}-intent-success{
color: #1c6e42;