add server to monorepo.

This commit is contained in:
a.bouhuolia
2023-02-03 11:57:50 +02:00
parent 28e309981b
commit 80b97b5fdc
1303 changed files with 137049 additions and 0 deletions

View File

@@ -0,0 +1,64 @@
import { Service, Inject } from 'typedi';
import TenancyService from '@/services/Tenancy/TenancyService';
import { transactionIncrement, parseBoolean } from 'utils';
/**
* Auto increment orders service.
*/
@Service()
export default class AutoIncrementOrdersService {
@Inject()
tenancy: TenancyService;
autoIncrementEnabled = (tenantId: number, settingsGroup: string): boolean => {
const settings = this.tenancy.settings(tenantId);
const group = settingsGroup;
// Settings service transaction number and prefix.
const autoIncrement = settings.get({ group, key: 'auto_increment' }, false);
return parseBoolean(autoIncrement, false);
}
/**
* Retrieve the next service transaction number.
* @param {number} tenantId
* @param {string} settingsGroup
* @param {Function} getMaxTransactionNo
* @return {Promise<string>}
*/
getNextTransactionNumber(tenantId: number, settingsGroup: string): string {
const settings = this.tenancy.settings(tenantId);
const group = settingsGroup;
// Settings service transaction number and prefix.
const autoIncrement = settings.get({ group, key: 'auto_increment' }, false);
const settingNo = settings.get({ group, key: 'next_number' }, '');
const settingPrefix = settings.get({ group, key: 'number_prefix' }, '');
return parseBoolean(autoIncrement, false) ? `${settingPrefix}${settingNo}` : '';
}
/**
* Increment setting next number.
* @param {number} tenantId -
* @param {string} orderGroup - Order group.
* @param {string} orderNumber -Order number.
*/
async incrementSettingsNextNumber(tenantId: number, group: string) {
const settings = this.tenancy.settings(tenantId);
const settingNo = settings.get({ group, key: 'next_number' });
const autoIncrement = settings.get({ group, key: 'auto_increment' });
// Can't continue if the auto-increment of the service was disabled.
if (!autoIncrement) { return; }
settings.set(
{ group, key: 'next_number' },
transactionIncrement(settingNo)
);
await settings.save();
}
}

View File

@@ -0,0 +1,217 @@
import { Service, Inject } from 'typedi';
import moment from 'moment';
import events from '@/subscribers/events';
import HasTenancyService from '@/services/Tenancy/TenancyService';
import SaleNotifyBySms from '../SaleNotifyBySms';
import SmsNotificationsSettingsService from '@/services/Settings/SmsNotificationsSettings';
import SMSClient from '@/services/SMSClient';
import {
ICustomer,
IPaymentReceiveSmsDetails,
ISaleEstimate,
SMS_NOTIFICATION_KEY,
} from '@/interfaces';
import { Tenant, TenantMetadata } from '@/system/models';
import { formatNumber, formatSmsMessage } from 'utils';
import { ServiceError } from '@/exceptions';
import { EventPublisher } from '@/lib/EventPublisher/EventPublisher';
const ERRORS = {
SALE_ESTIMATE_NOT_FOUND: 'SALE_ESTIMATE_NOT_FOUND',
};
@Service()
export default class SaleEstimateNotifyBySms {
@Inject()
tenancy: HasTenancyService;
@Inject()
saleSmsNotification: SaleNotifyBySms;
@Inject()
eventPublisher: EventPublisher;
@Inject()
smsNotificationsSettings: SmsNotificationsSettingsService;
/**
*
* @param {number} tenantId
* @param {number} saleEstimateId
* @returns {Promise<ISaleEstimate>}
*/
public notifyBySms = async (
tenantId: number,
saleEstimateId: number
): Promise<ISaleEstimate> => {
const { SaleEstimate } = this.tenancy.models(tenantId);
// Retrieve the sale invoice or throw not found service error.
const saleEstimate = await SaleEstimate.query()
.findById(saleEstimateId)
.withGraphFetched('customer');
// Validates the estimate transaction existance.
this.validateEstimateExistance(saleEstimate);
// Validate the customer phone number existance and number validation.
this.saleSmsNotification.validateCustomerPhoneNumber(
saleEstimate.customer.personalPhone
);
// Triggers `onSaleEstimateNotifySms` event.
await this.eventPublisher.emitAsync(events.saleEstimate.onNotifySms, {
tenantId,
saleEstimate,
});
await this.sendSmsNotification(tenantId, saleEstimate);
// Triggers `onSaleEstimateNotifySms` event.
await this.eventPublisher.emitAsync(events.saleEstimate.onNotifiedSms, {
tenantId,
saleEstimate,
});
return saleEstimate;
};
/**
*
* @param {number} tenantId
* @param {ISaleEstimate} saleEstimate
* @returns
*/
private sendSmsNotification = async (
tenantId: number,
saleEstimate: ISaleEstimate & { customer: ICustomer }
) => {
const smsClient = this.tenancy.smsClient(tenantId);
const tenantMetadata = await TenantMetadata.query().findOne({ tenantId });
// Retrieve the formatted sms notification message for estimate details.
const formattedSmsMessage = this.formattedEstimateDetailsMessage(
tenantId,
saleEstimate,
tenantMetadata
);
const phoneNumber = saleEstimate.customer.personalPhone;
// Runs the send message job.
return smsClient.sendMessageJob(phoneNumber, formattedSmsMessage);
};
/**
* Notify via SMS message after estimate creation.
* @param {number} tenantId
* @param {number} saleEstimateId
* @returns {Promise<void>}
*/
public notifyViaSmsNotificationAfterCreation = async (
tenantId: number,
saleEstimateId: number
): Promise<void> => {
const notification = this.smsNotificationsSettings.getSmsNotificationMeta(
tenantId,
SMS_NOTIFICATION_KEY.SALE_ESTIMATE_DETAILS
);
// Can't continue if the sms auto-notification is not enabled.
if (!notification.isNotificationEnabled) return;
await this.notifyBySms(tenantId, saleEstimateId);
};
/**
*
* @param {number} tenantId
* @param {ISaleEstimate} saleEstimate
* @param {TenantMetadata} tenantMetadata
* @returns {string}
*/
private formattedEstimateDetailsMessage = (
tenantId: number,
saleEstimate: ISaleEstimate,
tenantMetadata: TenantMetadata
): string => {
const notification = this.smsNotificationsSettings.getSmsNotificationMeta(
tenantId,
SMS_NOTIFICATION_KEY.SALE_ESTIMATE_DETAILS
);
return this.formateEstimateDetailsMessage(
notification.smsMessage,
saleEstimate,
tenantMetadata
);
};
/**
* Formattes the estimate sms notification details message.
* @param {string} smsMessage
* @param {ISaleEstimate} saleEstimate
* @param {TenantMetadata} tenantMetadata
* @returns {string}
*/
private formateEstimateDetailsMessage = (
smsMessage: string,
saleEstimate: ISaleEstimate & { customer: ICustomer },
tenantMetadata: TenantMetadata
) => {
const formattedAmount = formatNumber(saleEstimate.amount, {
currencyCode: saleEstimate.currencyCode,
});
return formatSmsMessage(smsMessage, {
EstimateNumber: saleEstimate.estimateNumber,
ReferenceNumber: saleEstimate.reference,
EstimateDate: moment(saleEstimate.estimateDate).format('YYYY/MM/DD'),
ExpirationDate: saleEstimate.expirationDate
? moment(saleEstimate.expirationDate).format('YYYY/MM/DD')
: '',
CustomerName: saleEstimate.customer.displayName,
Amount: formattedAmount,
CompanyName: tenantMetadata.name,
});
};
/**
* Retrieve the SMS details of the given payment receive transaction.
* @param {number} tenantId
* @param {number} saleEstimateId
* @returns {Promise<IPaymentReceiveSmsDetails>}
*/
public smsDetails = async (
tenantId: number,
saleEstimateId: number
): Promise<IPaymentReceiveSmsDetails> => {
const { SaleEstimate } = this.tenancy.models(tenantId);
// Retrieve the sale invoice or throw not found service error.
const saleEstimate = await SaleEstimate.query()
.findById(saleEstimateId)
.withGraphFetched('customer');
this.validateEstimateExistance(saleEstimate);
// Retrieve the current tenant metadata.
const tenantMetadata = await TenantMetadata.query().findOne({ tenantId });
// Retrieve the formatted sms message from the given estimate model.
const formattedSmsMessage = this.formattedEstimateDetailsMessage(
tenantId,
saleEstimate,
tenantMetadata
);
return {
customerName: saleEstimate.customer.displayName,
customerPhoneNumber: saleEstimate.customer.personalPhone,
smsMessage: formattedSmsMessage,
};
};
/**
* Validates the sale estimate existance.
* @param {ISaleEstimate} saleEstimate -
*/
private validateEstimateExistance(saleEstimate: ISaleEstimate) {
if (!saleEstimate) {
throw new ServiceError(ERRORS.SALE_ESTIMATE_NOT_FOUND);
}
}
}

View File

@@ -0,0 +1,77 @@
import { Service } from 'typedi';
import { ISaleEstimate } from '@/interfaces';
import { Transformer } from '@/lib/Transformer/Transformer';
import { formatNumber } from 'utils';
export default class SaleEstimateTransfromer extends Transformer {
/**
* Include these attributes to sale invoice object.
* @returns {Array}
*/
public includeAttributes = (): string[] => {
return [
'formattedAmount',
'formattedEstimateDate',
'formattedExpirationDate',
'formattedDeliveredAtDate',
'formattedApprovedAtDate',
'formattedRejectedAtDate',
];
};
/**
* Retrieve formatted estimate date.
* @param {ISaleEstimate} invoice
* @returns {String}
*/
protected formattedEstimateDate = (estimate: ISaleEstimate): string => {
return this.formatDate(estimate.estimateDate);
};
/**
* Retrieve formatted estimate date.
* @param {ISaleEstimate} invoice
* @returns {String}
*/
protected formattedExpirationDate = (estimate: ISaleEstimate): string => {
return this.formatDate(estimate.expirationDate);
};
/**
* Retrieve formatted estimate date.
* @param {ISaleEstimate} invoice
* @returns {String}
*/
protected formattedDeliveredAtDate = (estimate: ISaleEstimate): string => {
return this.formatDate(estimate.deliveredAt);
};
/**
* Retrieve formatted estimate date.
* @param {ISaleEstimate} invoice
* @returns {String}
*/
protected formattedApprovedAtDate = (estimate: ISaleEstimate): string => {
return this.formatDate(estimate.approvedAt);
};
/**
* Retrieve formatted estimate date.
* @param {ISaleEstimate} invoice
* @returns {String}
*/
protected formattedRejectedAtDate = (estimate: ISaleEstimate): string => {
return this.formatDate(estimate.rejectedAt);
};
/**
* Retrieve formatted invoice amount.
* @param {ISaleEstimate} estimate
* @returns {string}
*/
protected formattedAmount = (estimate: ISaleEstimate): string => {
return formatNumber(estimate.amount, {
currencyCode: estimate.currencyCode,
});
};
}

View File

@@ -0,0 +1,36 @@
import { Inject, Service } from 'typedi';
import PdfService from '@/services/PDF/PdfService';
import { templateRender } from 'utils';
import HasTenancyService from '@/services/Tenancy/TenancyService';
import { Tenant } from '@/system/models';
@Service()
export default class SaleEstimatesPdf {
@Inject()
pdfService: PdfService;
@Inject()
tenancy: HasTenancyService;
/**
* Retrieve sale invoice pdf content.
* @param {} saleInvoice -
*/
async saleEstimatePdf(tenantId: number, saleEstimate) {
const i18n = this.tenancy.i18n(tenantId);
const organization = await Tenant.query()
.findById(tenantId)
.withGraphFetched('metadata');
const htmlContent = templateRender('modules/estimate-regular', {
saleEstimate,
organizationName: organization.metadata.name,
organizationEmail: organization.metadata.email,
...i18n,
});
const pdfContent = await this.pdfService.pdfDocument(htmlContent);
return pdfContent;
}
}

View File

@@ -0,0 +1,109 @@
export const ERRORS = {
SALE_ESTIMATE_NOT_FOUND: 'SALE_ESTIMATE_NOT_FOUND',
SALE_ESTIMATE_NUMBER_EXISTANCE: 'SALE_ESTIMATE_NUMBER_EXISTANCE',
SALE_ESTIMATE_CONVERTED_TO_INVOICE: 'SALE_ESTIMATE_CONVERTED_TO_INVOICE',
SALE_ESTIMATE_NOT_DELIVERED: 'SALE_ESTIMATE_NOT_DELIVERED',
SALE_ESTIMATE_ALREADY_REJECTED: 'SALE_ESTIMATE_ALREADY_REJECTED',
CUSTOMER_HAS_SALES_ESTIMATES: 'CUSTOMER_HAS_SALES_ESTIMATES',
SALE_ESTIMATE_NO_IS_REQUIRED: 'SALE_ESTIMATE_NO_IS_REQUIRED',
SALE_ESTIMATE_ALREADY_DELIVERED: 'SALE_ESTIMATE_ALREADY_DELIVERED',
SALE_ESTIMATE_ALREADY_APPROVED: 'SALE_ESTIMATE_ALREADY_APPROVED'
};
export const DEFAULT_VIEW_COLUMNS = [];
export const DEFAULT_VIEWS = [
{
name: 'Draft',
slug: 'draft',
rolesLogicExpression: '1',
roles: [
{ index: 1, fieldKey: 'status', comparator: 'equals', value: 'draft' },
],
columns: DEFAULT_VIEW_COLUMNS,
},
{
name: 'Delivered',
slug: 'delivered',
rolesLogicExpression: '1',
roles: [
{
index: 1,
fieldKey: 'status',
comparator: 'equals',
value: 'delivered',
},
],
columns: DEFAULT_VIEW_COLUMNS,
},
{
name: 'Approved',
slug: 'approved',
rolesLogicExpression: '1',
roles: [
{
index: 1,
fieldKey: 'status',
comparator: 'equals',
value: 'approved',
},
],
columns: DEFAULT_VIEW_COLUMNS,
},
{
name: 'Rejected',
slug: 'rejected',
rolesLogicExpression: '1',
roles: [
{
index: 1,
fieldKey: 'status',
comparator: 'equals',
value: 'rejected',
},
],
columns: DEFAULT_VIEW_COLUMNS,
},
{
name: 'Invoiced',
slug: 'invoiced',
rolesLogicExpression: '1',
roles: [
{
index: 1,
fieldKey: 'status',
comparator: 'equals',
value: 'invoiced',
},
],
columns: DEFAULT_VIEW_COLUMNS,
},
{
name: 'Expired',
slug: 'expired',
rolesLogicExpression: '1',
roles: [
{
index: 1,
fieldKey: 'status',
comparator: 'equals',
value: 'expired',
},
],
columns: DEFAULT_VIEW_COLUMNS,
},
{
name: 'Closed',
slug: 'closed',
rolesLogicExpression: '1',
roles: [
{
index: 1,
fieldKey: 'status',
comparator: 'equals',
value: 'closed',
},
],
columns: DEFAULT_VIEW_COLUMNS,
},
];

View File

@@ -0,0 +1,30 @@
import { difference, omit } from 'lodash';
import { Service, Inject } from 'typedi';
import TenancyService from '@/services/Tenancy/TenancyService';
import { ItemEntry } from 'models';
@Service()
export default class HasItemEntries {
@Inject()
tenancy: TenancyService;
filterNonInventoryEntries(entries: [], items: []) {
const nonInventoryItems = items.filter((item: any) => item.type !== 'inventory');
const nonInventoryItemsIds = nonInventoryItems.map((i: any) => i.id);
return entries
.filter((entry: any) => (
(nonInventoryItemsIds.indexOf(entry.item_id)) !== -1
));
}
filterInventoryEntries(entries: [], items: []) {
const inventoryItems = items.filter((item: any) => item.type === 'inventory');
const inventoryItemsIds = inventoryItems.map((i: any) => i.id);
return entries
.filter((entry: any) => (
(inventoryItemsIds.indexOf(entry.item_id)) !== -1
));
}
}

View File

@@ -0,0 +1,203 @@
import * as R from 'ramda';
import {
ISaleInvoice,
IItemEntry,
ILedgerEntry,
AccountNormal,
ILedger,
} from '@/interfaces';
import { Knex } from 'knex';
import { Service, Inject } from 'typedi';
import Ledger from '@/services/Accounting/Ledger';
import LedgerStorageService from '@/services/Accounting/LedgerStorageService';
import HasTenancyService from '@/services/Tenancy/TenancyService';
@Service()
export class SaleInvoiceGLEntries {
@Inject()
private tenancy: HasTenancyService;
@Inject()
private ledegrRepository: LedgerStorageService;
/**
* Writes a sale invoice GL entries.
* @param {number} tenantId
* @param {number} saleInvoiceId
* @param {Knex.Transaction} trx
*/
public writeInvoiceGLEntries = async (
tenantId: number,
saleInvoiceId: number,
trx?: Knex.Transaction
) => {
const { SaleInvoice } = this.tenancy.models(tenantId);
const { accountRepository } = this.tenancy.repositories(tenantId);
const saleInvoice = await SaleInvoice.query(trx)
.findById(saleInvoiceId)
.withGraphFetched('entries.item');
// Find or create the A/R account.
const ARAccount = await accountRepository.findOrCreateAccountReceivable(
saleInvoice.currencyCode
);
// Retrieves the ledger of the invoice.
const ledger = this.getInvoiceGLedger(saleInvoice, ARAccount.id);
// Commits the ledger entries to the storage as UOW.
await this.ledegrRepository.commit(tenantId, ledger, trx);
};
/**
* Rewrites the given invoice GL entries.
* @param {number} tenantId
* @param {number} saleInvoiceId
* @param {Knex.Transaction} trx
*/
public rewritesInvoiceGLEntries = async (
tenantId: number,
saleInvoiceId: number,
trx?: Knex.Transaction
) => {
// Reverts the invoice GL entries.
await this.revertInvoiceGLEntries(tenantId, saleInvoiceId, trx);
// Writes the invoice GL entries.
await this.writeInvoiceGLEntries(tenantId, saleInvoiceId, trx);
};
/**
* Reverts the given invoice GL entries.
* @param {number} tenantId
* @param {number} saleInvoiceId
* @param {Knex.Transaction} trx
*/
public revertInvoiceGLEntries = async (
tenantId: number,
saleInvoiceId: number,
trx?: Knex.Transaction
) => {
await this.ledegrRepository.deleteByReference(
tenantId,
saleInvoiceId,
'SaleInvoice',
trx
);
};
/**
* Retrieves the given invoice ledger.
* @param {ISaleInvoice} saleInvoice
* @param {number} ARAccountId
* @returns {ILedger}
*/
public getInvoiceGLedger = (
saleInvoice: ISaleInvoice,
ARAccountId: number
): ILedger => {
const entries = this.getInvoiceGLEntries(saleInvoice, ARAccountId);
return new Ledger(entries);
};
/**
* Retrieves the invoice GL common entry.
* @param {ISaleInvoice} saleInvoice
* @returns {Partial<ILedgerEntry>}
*/
private getInvoiceGLCommonEntry = (
saleInvoice: ISaleInvoice
): Partial<ILedgerEntry> => ({
credit: 0,
debit: 0,
currencyCode: saleInvoice.currencyCode,
exchangeRate: saleInvoice.exchangeRate,
transactionType: 'SaleInvoice',
transactionId: saleInvoice.id,
date: saleInvoice.invoiceDate,
userId: saleInvoice.userId,
transactionNumber: saleInvoice.invoiceNo,
referenceNumber: saleInvoice.referenceNo,
createdAt: saleInvoice.createdAt,
indexGroup: 10,
branchId: saleInvoice.branchId,
});
/**
* Retrieve receivable entry of the given invoice.
* @param {ISaleInvoice} saleInvoice
* @param {number} ARAccountId
* @returns {ILedgerEntry}
*/
private getInvoiceReceivableEntry = (
saleInvoice: ISaleInvoice,
ARAccountId: number
): ILedgerEntry => {
const commonEntry = this.getInvoiceGLCommonEntry(saleInvoice);
return {
...commonEntry,
debit: saleInvoice.localAmount,
accountId: ARAccountId,
contactId: saleInvoice.customerId,
accountNormal: AccountNormal.DEBIT,
index: 1,
} as ILedgerEntry;
};
/**
* Retrieve item income entry of the given invoice.
* @param {ISaleInvoice} saleInvoice -
* @param {IItemEntry} entry -
* @param {number} index -
* @returns {ILedgerEntry}
*/
private getInvoiceItemEntry = R.curry(
(
saleInvoice: ISaleInvoice,
entry: IItemEntry,
index: number
): ILedgerEntry => {
const commonEntry = this.getInvoiceGLCommonEntry(saleInvoice);
const localAmount = entry.amount * saleInvoice.exchangeRate;
return {
...commonEntry,
credit: localAmount,
accountId: entry.sellAccountId,
note: entry.description,
index: index + 2,
itemId: entry.itemId,
itemQuantity: entry.quantity,
accountNormal: AccountNormal.CREDIT,
projectId: entry.projectId || saleInvoice.projectId
};
}
);
/**
* Retrieves the invoice GL entries.
* @param {ISaleInvoice} saleInvoice
* @param {number} ARAccountId
* @returns {ILedgerEntry[]}
*/
public getInvoiceGLEntries = (
saleInvoice: ISaleInvoice,
ARAccountId: number
): ILedgerEntry[] => {
const receivableEntry = this.getInvoiceReceivableEntry(
saleInvoice,
ARAccountId
);
const transformItemEntry = this.getInvoiceItemEntry(saleInvoice);
const creditEntries = saleInvoice.entries.map(transformItemEntry);
return [receivableEntry, ...creditEntries];
};
}

View File

@@ -0,0 +1,56 @@
import { Transformer } from '@/lib/Transformer/Transformer';
import { formatNumber } from 'utils';
export class InvoicePaymentTransactionTransformer extends Transformer {
/**
* Include these attributes to sale credit note object.
* @returns {Array}
*/
public includeAttributes = (): string[] => {
return ['formattedPaymentAmount', 'formattedPaymentDate'];
};
/**
* Retrieve formatted invoice amount.
* @param {ICreditNote} credit
* @returns {string}
*/
protected formattedPaymentAmount = (entry): string => {
return formatNumber(entry.paymentAmount, {
currencyCode: entry.payment.currencyCode,
});
};
protected formattedPaymentDate = (entry): string => {
return this.formatDate(entry.payment.paymentDate);
};
/**
*
* @param entry
* @returns
*/
public transform = (entry) => {
return {
invoiceId: entry.invoiceId,
paymentReceiveId: entry.paymentReceiveId,
paymentDate: entry.payment.paymentDate,
formattedPaymentDate: entry.formattedPaymentDate,
paymentAmount: entry.paymentAmount,
formattedPaymentAmount: entry.formattedPaymentAmount,
currencyCode: entry.payment.currencyCode,
paymentNumber: entry.payment.paymentReceiveNo,
paymentReferenceNo: entry.payment.referenceNo,
invoiceNumber: entry.invoice.invoiceNo,
invoiceReferenceNo: entry.invoice.referenceNo,
depositAccountId: entry.payment.depositAccountId,
depositAccountName: entry.payment.depositAccount.name,
depositAccountSlug: entry.payment.depositAccount.slug,
};
};
}

View File

@@ -0,0 +1,76 @@
import { Knex } from 'knex';
import async from 'async';
import HasTenancyService from '@/services/Tenancy/TenancyService';
import { Inject, Service } from 'typedi';
import { PaymentReceiveGLEntries } from '../PaymentReceives/PaymentReceiveGLEntries';
@Service()
export class InvoicePaymentsGLEntriesRewrite {
@Inject()
public tenancy: HasTenancyService;
@Inject()
public paymentGLEntries: PaymentReceiveGLEntries;
/**
* Rewrites the payment GL entries task.
* @param {{ tenantId: number, paymentId: number, trx: Knex?.Transaction }}
* @returns {Promise<void>}
*/
public rewritePaymentsGLEntriesTask = async ({
tenantId,
paymentId,
trx,
}) => {
await this.paymentGLEntries.rewritePaymentGLEntries(
tenantId,
paymentId,
trx
);
};
/**
* Rewrites the payment GL entries of the given payments ids.
* @param {number} tenantId
* @param {number[]} paymentsIds
* @param {Knex.Transaction} trx
*/
public rewritePaymentsGLEntriesQueue = async (
tenantId: number,
paymentsIds: number[],
trx?: Knex.Transaction
) => {
// Initiate a new queue for accounts balance mutation.
const rewritePaymentGL = async.queue(this.rewritePaymentsGLEntriesTask, 10);
paymentsIds.forEach((paymentId: number) => {
rewritePaymentGL.push({ paymentId, trx, tenantId });
});
if (paymentsIds.length > 0) {
await rewritePaymentGL.drain();
}
};
/**
* Rewrites the payments GL entries that associated to the given invoice.
* @param {number} tenantId
* @param {number} invoiceId
* @param {Knex.Transaction} trx
* @returns {Promise<void>}
*/
public invoicePaymentsGLEntriesRewrite = async (
tenantId: number,
invoiceId: number,
trx?: Knex.Transaction
) => {
const { PaymentReceiveEntry } = this.tenancy.models(tenantId);
const invoicePaymentEntries = await PaymentReceiveEntry.query().where(
'invoiceId',
invoiceId
);
const paymentsIds = invoicePaymentEntries.map((e) => e.paymentReceiveId);
await this.rewritePaymentsGLEntriesQueue(tenantId, paymentsIds, trx);
};
}

View File

@@ -0,0 +1,34 @@
import { Service, Inject } from 'typedi';
import HasTenancyService from '@/services/Tenancy/TenancyService';
import { InvoicePaymentTransactionTransformer } from './InvoicePaymentTransactionTransformer';
import { TransformerInjectable } from '@/lib/Transformer/TransformerInjectable';
@Service()
export default class InvoicePaymentsService {
@Inject()
private tenancy: HasTenancyService;
@Inject()
private transformer: TransformerInjectable;
/**
* Retrieve the invoice assocaited payments transactions.
* @param {number} tenantId - Tenant id.
* @param {number} invoiceId - Invoice id.
*/
public getInvoicePayments = async (tenantId: number, invoiceId: number) => {
const { PaymentReceiveEntry } = this.tenancy.models(tenantId);
const paymentsEntries = await PaymentReceiveEntry.query()
.where('invoiceId', invoiceId)
.withGraphJoined('payment.depositAccount')
.withGraphJoined('invoice')
.orderBy('payment:paymentDate', 'ASC');
return this.transformer.transform(
tenantId,
paymentsEntries,
new InvoicePaymentTransactionTransformer()
);
};
}

View File

@@ -0,0 +1,146 @@
import { Service, Inject } from 'typedi';
import * as R from 'ramda';
import { Knex } from 'knex';
import { AccountNormal, IInventoryLotCost, ILedgerEntry } from '@/interfaces';
import { increment } from 'utils';
import HasTenancyService from '@/services/Tenancy/TenancyService';
import Ledger from '@/services/Accounting/Ledger';
import LedgerStorageService from '@/services/Accounting/LedgerStorageService';
import { groupInventoryTransactionsByTypeId } from '../../Inventory/utils';
@Service()
export class SaleInvoiceCostGLEntries {
@Inject()
private tenancy: HasTenancyService;
@Inject()
private ledgerStorage: LedgerStorageService;
/**
* Writes journal entries from sales invoices.
* @param {number} tenantId - The tenant id.
* @param {Date} startingDate - Starting date.
* @param {boolean} override
*/
public writeInventoryCostJournalEntries = async (
tenantId: number,
startingDate: Date,
trx?: Knex.Transaction
): Promise<void> => {
const { InventoryCostLotTracker } = this.tenancy.models(tenantId);
const inventoryCostLotTrans = await InventoryCostLotTracker.query()
.where('direction', 'OUT')
.where('transaction_type', 'SaleInvoice')
.where('cost', '>', 0)
.modify('filterDateRange', startingDate)
.orderBy('date', 'ASC')
.withGraphFetched('invoice')
.withGraphFetched('item');
const ledger = this.getInventoryCostLotsLedger(inventoryCostLotTrans);
// Commit the ledger to the storage.
await this.ledgerStorage.commit(tenantId, ledger, trx);
};
/**
* Retrieves the inventory cost lots ledger.
* @param {IInventoryLotCost[]} inventoryCostLots
* @returns {Ledger}
*/
private getInventoryCostLotsLedger = (
inventoryCostLots: IInventoryLotCost[]
) => {
// Groups the inventory cost lots transactions.
const inventoryTransactions =
groupInventoryTransactionsByTypeId(inventoryCostLots);
const entries = inventoryTransactions
.map(this.getSaleInvoiceCostGLEntries)
.flat();
return new Ledger(entries);
};
/**
*
* @param {IInventoryLotCost} inventoryCostLot
* @returns {}
*/
private getInvoiceCostGLCommonEntry = (
inventoryCostLot: IInventoryLotCost
) => {
return {
currencyCode: inventoryCostLot.invoice.currencyCode,
exchangeRate: inventoryCostLot.invoice.exchangeRate,
transactionType: inventoryCostLot.transactionType,
transactionId: inventoryCostLot.transactionId,
date: inventoryCostLot.date,
indexGroup: 20,
costable: true,
createdAt: inventoryCostLot.createdAt,
debit: 0,
credit: 0,
branchId: inventoryCostLot.invoice.branchId,
};
};
/**
* Retrieves the inventory cost GL entry.
* @param {IInventoryLotCost} inventoryLotCost
* @returns {ILedgerEntry[]}
*/
private getInventoryCostGLEntry = R.curry(
(
getIndexIncrement,
inventoryCostLot: IInventoryLotCost
): ILedgerEntry[] => {
const commonEntry = this.getInvoiceCostGLCommonEntry(inventoryCostLot);
const costAccountId =
inventoryCostLot.costAccountId || inventoryCostLot.item.costAccountId;
// XXX Debit - Cost account.
const costEntry = {
...commonEntry,
debit: inventoryCostLot.cost,
accountId: costAccountId,
accountNormal: AccountNormal.DEBIT,
itemId: inventoryCostLot.itemId,
index: getIndexIncrement(),
};
// XXX Credit - Inventory account.
const inventoryEntry = {
...commonEntry,
credit: inventoryCostLot.cost,
accountId: inventoryCostLot.item.inventoryAccountId,
accountNormal: AccountNormal.DEBIT,
itemId: inventoryCostLot.itemId,
index: getIndexIncrement(),
};
return [costEntry, inventoryEntry];
}
);
/**
* Writes journal entries for given sale invoice.
* -------
* - Cost of goods sold -> Debit -> YYYY
* - Inventory assets -> Credit -> YYYY
* --------
* @param {ISaleInvoice} saleInvoice
* @param {JournalPoster} journal
*/
public getSaleInvoiceCostGLEntries = (
inventoryCostLots: IInventoryLotCost[]
): ILedgerEntry[] => {
const getIndexIncrement = increment(0);
const getInventoryLotEntry =
this.getInventoryCostGLEntry(getIndexIncrement);
return inventoryCostLots.map(getInventoryLotEntry).flat();
};
}

View File

@@ -0,0 +1,36 @@
import { Inject, Service } from 'typedi';
import events from '@/subscribers/events';
import { IInventoryCostLotsGLEntriesWriteEvent } from '@/interfaces';
import { SaleInvoiceCostGLEntries } from '../SaleInvoiceCostGLEntries';
@Service()
export class InvoiceCostGLEntriesSubscriber {
@Inject()
invoiceCostEntries: SaleInvoiceCostGLEntries;
/**
* Attaches events.
*/
public attach(bus) {
bus.subscribe(
events.inventory.onCostLotsGLEntriesWrite,
this.writeInvoicesCostEntriesOnCostLotsWritten
);
}
/**
* Writes the invoices cost GL entries once the inventory cost lots be written.
* @param {IInventoryCostLotsGLEntriesWriteEvent}
*/
private writeInvoicesCostEntriesOnCostLotsWritten = async ({
trx,
startingDate,
tenantId,
}: IInventoryCostLotsGLEntriesWriteEvent) => {
await this.invoiceCostEntries.writeInventoryCostJournalEntries(
tenantId,
startingDate,
trx
);
};
}

View File

@@ -0,0 +1,37 @@
import { Service, Inject } from 'typedi';
import events from '@/subscribers/events';
import { ISaleInvoiceEditingPayload } from '@/interfaces';
import { InvoicePaymentsGLEntriesRewrite } from '../InvoicePaymentsGLRewrite';
@Service()
export class InvoicePaymentGLRewriteSubscriber {
@Inject()
private invoicePaymentsRewriteGLEntries: InvoicePaymentsGLEntriesRewrite;
/**
* Attaches events with handlers.
*/
public attach = (bus) => {
bus.subscribe(
events.saleInvoice.onEdited,
this.paymentGLEntriesRewriteOnPaymentEdit
);
return bus;
};
/**
* Writes associated invoiceso of payment receive once edit.
* @param {ISaleInvoiceEditingPayload} -
*/
private paymentGLEntriesRewriteOnPaymentEdit = async ({
tenantId,
oldSaleInvoice,
trx,
}: ISaleInvoiceEditingPayload) => {
await this.invoicePaymentsRewriteGLEntries.invoicePaymentsGLEntriesRewrite(
tenantId,
oldSaleInvoice.id,
trx
);
};
}

View File

@@ -0,0 +1,36 @@
import { Service, Inject } from 'typedi';
import JournalPoster from '@/services/Accounting/JournalPoster';
import TenancyService from '@/services/Tenancy/TenancyService';
import JournalCommands from '@/services/Accounting/JournalCommands';
import Knex from 'knex';
@Service()
export default class JournalPosterService {
@Inject()
tenancy: TenancyService;
/**
* Deletes the journal transactions that associated to the given reference id.
* @param {number} tenantId - The given tenant id.
* @param {number} referenceId - The transaction reference id.
* @param {string} referenceType - The transaction reference type.
* @return {Promise}
*/
async revertJournalTransactions(
tenantId: number,
referenceId: number|number[],
referenceType: string|string[],
trx?: Knex.Transaction
): Promise<void> {
const journal = new JournalPoster(tenantId, null, trx);
const journalCommand = new JournalCommands(journal);
await journalCommand.revertJournalEntries(referenceId, referenceType);
await Promise.all([
journal.deleteEntries(),
journal.saveBalance(),
journal.saveContactsBalance(),
]);
}
}

View File

@@ -0,0 +1,37 @@
import { Inject, Service } from 'typedi';
import PdfService from '@/services/PDF/PdfService';
import { templateRender } from 'utils';
import HasTenancyService from '@/services/Tenancy/TenancyService';
import { Tenant } from '@/system/models';
@Service()
export default class GetPaymentReceivePdf {
@Inject()
pdfService: PdfService;
@Inject()
tenancy: HasTenancyService;
/**
* Retrieve sale invoice pdf content.
* @param {} saleInvoice -
*/
async getPaymentReceivePdf(tenantId: number, paymentReceive) {
const i18n = this.tenancy.i18n(tenantId);
const organization = await Tenant.query()
.findById(tenantId)
.withGraphFetched('metadata');
const htmlContent = templateRender('modules/payment-receive-standard', {
organization,
organizationName: organization.metadata.name,
organizationEmail: organization.metadata.email,
paymentReceive,
...i18n,
});
const pdfContent = await this.pdfService.pdfDocument(htmlContent);
return pdfContent;
}
}

View File

@@ -0,0 +1,299 @@
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 LedgerStorageService from '@/services/Accounting/LedgerStorageService';
import { TenantMetadata } from '@/system/models';
@Service()
export class PaymentReceiveGLEntries {
@Inject()
private tenancy: TenancyService;
@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 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');
// Retrives the payment receive ledger.
const ledger = await this.getPaymentReceiveGLedger(
tenantId,
paymentReceive,
tenantMeta.baseCurrency,
trx
);
// Commit the ledger entries to the storage.
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
* @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 receive general ledger.
* @param {number} tenantId -
* @param {IPaymentReceive} paymentReceive -
* @param {string} baseCurrencyCode -
* @param {Knex.Transaction} trx -
* @returns {Ledger}
*/
public getPaymentReceiveGLedger = async (
tenantId: number,
paymentReceive: IPaymentReceive,
baseCurrencyCode: string,
trx?: Knex.Transaction
): Promise<Ledger> => {
const { Account } = this.tenancy.models(tenantId);
const { accountRepository } = this.tenancy.repositories(tenantId);
// Retrieve the A/R account of the given currency.
const receivableAccount =
await accountRepository.findOrCreateAccountReceivable(
paymentReceive.currencyCode
);
// Exchange gain/loss account.
const exGainLossAccount = await Account.query(trx).modify(
'findBySlug',
'exchange-grain-loss'
);
const ledgerEntries = this.getPaymentReceiveGLEntries(
paymentReceive,
receivableAccount.id,
exGainLossAccount.id,
baseCurrencyCode
);
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
* @returns {ILedgerEntry}
*/
private getPaymentDepositGLEntry = (
paymentReceive: IPaymentReceive
): ILedgerEntry => {
const commonJournal = this.getPaymentReceiveCommonEntry(paymentReceive);
return {
...commonJournal,
debit: paymentReceive.localAmount,
accountId: paymentReceive.depositAccountId,
index: 2,
accountNormal: AccountNormal.DEBIT,
};
};
/**
* Retrieves the payment receivable entry.
* @param {IPaymentReceive} paymentReceive
* @param {number} ARAccountId
* @returns {ILedgerEntry}
*/
private getPaymentReceivableEntry = (
paymentReceive: IPaymentReceive,
ARAccountId: number
): ILedgerEntry => {
const commonJournal = this.getPaymentReceiveCommonEntry(paymentReceive);
return {
...commonJournal,
credit: paymentReceive.localAmount,
contactId: paymentReceive.customerId,
accountId: ARAccountId,
index: 1,
accountNormal: AccountNormal.DEBIT,
};
};
/**
* Records payment receive journal transactions.
*
* Invoice payment journals.
* --------
* - 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.
* @returns {Promise<ILedgerEntry>}
*/
public getPaymentReceiveGLEntries = (
paymentReceive: IPaymentReceive,
ARAccountId: number,
exGainOrLossAccountId: number,
baseCurrency: string
): ILedgerEntry[] => {
// Retrieve the payment deposit entry.
const paymentDepositEntry = this.getPaymentDepositGLEntry(paymentReceive);
// Retrieves the A/R entry.
const receivableEntry = this.getPaymentReceivableEntry(
paymentReceive,
ARAccountId
);
// Exchange gain/loss entries.
const gainLossEntries = this.getPaymentExchangeGainLossEntry(
paymentReceive,
ARAccountId,
exGainOrLossAccountId,
baseCurrency
);
return [paymentDepositEntry, receivableEntry, ...gainLossEntries];
};
}

View File

@@ -0,0 +1,211 @@
import { Service, Inject } from 'typedi';
import HasTenancyService from '@/services/Tenancy/TenancyService';
import events from '@/subscribers/events';
import SMSClient from '@/services/SMSClient';
import {
IPaymentReceiveSmsDetails,
SMS_NOTIFICATION_KEY,
IPaymentReceive,
IPaymentReceiveEntry,
} from '@/interfaces';
import PaymentReceiveService from './PaymentsReceives';
import SmsNotificationsSettingsService from '@/services/Settings/SmsNotificationsSettings';
import { formatNumber, formatSmsMessage } from 'utils';
import { TenantMetadata } from '@/system/models';
import SaleNotifyBySms from '../SaleNotifyBySms';
import { EventPublisher } from '@/lib/EventPublisher/EventPublisher';
@Service()
export default class PaymentReceiveNotifyBySms {
@Inject()
paymentReceiveService: PaymentReceiveService;
@Inject()
tenancy: HasTenancyService;
@Inject()
eventPublisher: EventPublisher;
@Inject()
smsNotificationsSettings: SmsNotificationsSettingsService;
@Inject()
saleSmsNotification: SaleNotifyBySms;
/**
* Notify customer via sms about payment receive details.
* @param {number} tenantId - Tenant id.
* @param {number} paymentReceiveid - Payment receive id.
*/
public async notifyBySms(tenantId: number, paymentReceiveid: number) {
const { PaymentReceive } = this.tenancy.models(tenantId);
// Retrieve the payment receive or throw not found service error.
const paymentReceive = await PaymentReceive.query()
.findById(paymentReceiveid)
.withGraphFetched('customer')
.withGraphFetched('entries.invoice');
// Validate the customer phone number.
this.saleSmsNotification.validateCustomerPhoneNumber(
paymentReceive.customer.personalPhone
);
// Triggers `onPaymentReceiveNotifySms` event.
await this.eventPublisher.emitAsync(events.paymentReceive.onNotifySms, {
tenantId,
paymentReceive,
});
// Sends the payment receive sms notification to the given customer.
await this.sendSmsNotification(tenantId, paymentReceive);
// Triggers `onPaymentReceiveNotifiedSms` event.
await this.eventPublisher.emitAsync(events.paymentReceive.onNotifiedSms, {
tenantId,
paymentReceive,
});
return paymentReceive;
}
/**
* Sends the payment details sms notification of the given customer.
* @param {number} tenantId
* @param {IPaymentReceive} paymentReceive
* @param {ICustomer} customer
*/
private sendSmsNotification = async (
tenantId: number,
paymentReceive: IPaymentReceive
) => {
const smsClient = this.tenancy.smsClient(tenantId);
const tenantMetadata = await TenantMetadata.query().findOne({ tenantId });
// Retrieve the formatted payment details sms notification message.
const message = this.formattedPaymentDetailsMessage(
tenantId,
paymentReceive,
tenantMetadata
);
// The target phone number.
const phoneNumber = paymentReceive.customer.personalPhone;
await smsClient.sendMessageJob(phoneNumber, message);
};
/**
* Notify via SMS message after payment transaction creation.
* @param {number} tenantId
* @param {number} paymentReceiveId
* @returns {Promise<void>}
*/
public notifyViaSmsNotificationAfterCreation = async (
tenantId: number,
paymentReceiveId: number
): Promise<void> => {
const notification = this.smsNotificationsSettings.getSmsNotificationMeta(
tenantId,
SMS_NOTIFICATION_KEY.PAYMENT_RECEIVE_DETAILS
);
// Can't continue if the sms auto-notification is not enabled.
if (!notification.isNotificationEnabled) return;
await this.notifyBySms(tenantId, paymentReceiveId);
};
/**
* Formates the payment receive details sms message.
* @param {number} tenantId -
* @param {IPaymentReceive} payment -
* @param {ICustomer} customer -
*/
private formattedPaymentDetailsMessage = (
tenantId: number,
payment: IPaymentReceive,
tenantMetadata: TenantMetadata
) => {
const notification = this.smsNotificationsSettings.getSmsNotificationMeta(
tenantId,
SMS_NOTIFICATION_KEY.PAYMENT_RECEIVE_DETAILS
);
return this.formatPaymentDetailsMessage(
notification.smsMessage,
payment,
tenantMetadata
);
};
/**
* Formattes the payment details sms notification messafge.
* @param {string} smsMessage
* @param {IPaymentReceive} payment
* @param {ICustomer} customer
* @param {TenantMetadata} tenantMetadata
* @returns {string}
*/
private formatPaymentDetailsMessage = (
smsMessage: string,
payment: IPaymentReceive,
tenantMetadata: any
): string => {
const invoiceNumbers = this.stringifyPaymentInvoicesNumber(payment);
// Formattes the payment number variable.
const formattedPaymentNumber = formatNumber(payment.amount, {
currencyCode: payment.currencyCode,
});
return formatSmsMessage(smsMessage, {
Amount: formattedPaymentNumber,
ReferenceNumber: payment.referenceNo,
CustomerName: payment.customer.displayName,
PaymentNumber: payment.paymentReceiveNo,
InvoiceNumber: invoiceNumbers,
CompanyName: tenantMetadata.name,
});
};
/**
* Stringify payment receive invoices to numbers as string.
* @param {IPaymentReceive} payment
* @returns {string}
*/
private stringifyPaymentInvoicesNumber(payment: IPaymentReceive) {
const invoicesNumberes = payment.entries.map(
(entry: IPaymentReceiveEntry) => entry.invoice.invoiceNo
);
return invoicesNumberes.join(', ');
}
/**
* Retrieve the SMS details of the given invoice.
* @param {number} tenantId - Tenant id.
* @param {number} paymentReceiveid - Payment receive id.
*/
public smsDetails = async (
tenantId: number,
paymentReceiveid: number
): Promise<IPaymentReceiveSmsDetails> => {
const { PaymentReceive } = this.tenancy.models(tenantId);
// Retrieve the payment receive or throw not found service error.
const paymentReceive = await PaymentReceive.query()
.findById(paymentReceiveid)
.withGraphFetched('customer')
.withGraphFetched('entries.invoice');
// Current tenant metadata.
const tenantMetadata = await TenantMetadata.query().findOne({ tenantId });
// Retrieve the formatted sms message of payment receive details.
const smsMessage = this.formattedPaymentDetailsMessage(
tenantId,
paymentReceive,
tenantMetadata
);
return {
customerName: paymentReceive.customer.displayName,
customerPhoneNumber: paymentReceive.customer.personalPhone,
smsMessage,
};
};
}

View File

@@ -0,0 +1,28 @@
import { Container } from 'typedi';
import { On, EventSubscriber } from 'event-dispatch';
import events from '@/subscribers/events';
import SaleReceiptNotifyBySms from '@/services/Sales/SaleReceiptNotifyBySms';
import PaymentReceiveNotifyBySms from './PaymentReceiveSmsNotify';
@EventSubscriber()
export default class SendSmsNotificationPaymentReceive {
paymentReceiveNotifyBySms: PaymentReceiveNotifyBySms;
constructor() {
this.paymentReceiveNotifyBySms = Container.get(PaymentReceiveNotifyBySms);
}
/**
*
*/
@On(events.paymentReceive.onNotifySms)
async sendSmsNotificationOnceInvoiceNotify({
paymentReceive,
customer,
}) {
await this.paymentReceiveNotifyBySms.sendSmsNotification(
paymentReceive,
customer
);
}
}

View File

@@ -0,0 +1,58 @@
import { IPaymentReceive, IPaymentReceiveEntry } from '@/interfaces';
import { Transformer } from '@/lib/Transformer/Transformer';
import { formatNumber } from 'utils';
import { SaleInvoiceTransformer } from '../SaleInvoiceTransformer';
export class PaymentReceiveTransfromer extends Transformer {
/**
* Include these attributes to payment receive object.
* @returns {Array}
*/
public includeAttributes = (): string[] => {
return [
'formattedPaymentDate',
'formattedAmount',
'formattedExchangeRate',
'entries',
];
};
/**
* Retrieve formatted payment receive date.
* @param {ISaleInvoice} invoice
* @returns {String}
*/
protected formattedPaymentDate = (payment: IPaymentReceive): string => {
return this.formatDate(payment.paymentDate);
};
/**
* Retrieve formatted payment amount.
* @param {ISaleInvoice} invoice
* @returns {string}
*/
protected formattedAmount = (payment: IPaymentReceive): string => {
return formatNumber(payment.amount, { currencyCode: payment.currencyCode });
};
/**
* Retrieve the formatted exchange rate.
* @param {IPaymentReceive} payment
* @returns {string}
*/
protected formattedExchangeRate = (payment: IPaymentReceive): string => {
return formatNumber(payment.exchangeRate, { money: false });
};
/**
* Retrieves the
* @param {IPaymentReceive} payment
* @returns {IPaymentReceiveEntry[]}
*/
protected entries = (payment: IPaymentReceive): IPaymentReceiveEntry[] => {
return payment?.entries?.map((entry) => ({
...entry,
invoice: this.item(entry.invoice, new SaleInvoiceTransformer()),
}));
};
}

View File

@@ -0,0 +1,112 @@
import { Inject, Service } from 'typedi';
import { omit } from 'lodash';
import {
ISaleInvoice,
IPaymentReceivePageEntry,
IPaymentReceive,
ISystemUser,
} from '@/interfaces';
import TenancyService from '@/services/Tenancy/TenancyService';
import { ServiceError } from '@/exceptions';
import { ERRORS } from './constants';
/**
* Payment receives edit/new pages service.
*/
@Service()
export default class PaymentReceivesPages {
@Inject()
tenancy: TenancyService;
@Inject('logger')
logger: any;
/**
* Retrive page invoices entries from the given sale invoices models.
* @param {ISaleInvoice[]} invoices - Invoices.
* @return {IPaymentReceivePageEntry}
*/
private invoiceToPageEntry(invoice: ISaleInvoice): IPaymentReceivePageEntry {
return {
entryType: 'invoice',
invoiceId: invoice.id,
invoiceNo: invoice.invoiceNo,
amount: invoice.balance,
dueAmount: invoice.dueAmount,
paymentAmount: invoice.paymentAmount,
totalPaymentAmount: invoice.paymentAmount,
currencyCode: invoice.currencyCode,
date: invoice.invoiceDate,
};
}
/**
* Retrieve payment receive new page receivable entries.
* @param {number} tenantId - Tenant id.
* @param {number} vendorId - Vendor id.
* @return {IPaymentReceivePageEntry[]}
*/
public async getNewPageEntries(tenantId: number, customerId: number) {
const { SaleInvoice } = this.tenancy.models(tenantId);
// Retrieve due invoices.
const entries = await SaleInvoice.query()
.modify('delivered')
.modify('dueInvoices')
.where('customer_id', customerId)
.orderBy('invoice_date', 'ASC');
return entries.map(this.invoiceToPageEntry);
}
/**
* Retrieve the payment receive details of the given id.
* @param {number} tenantId - Tenant id.
* @param {Integer} paymentReceiveId - Payment receive id.
*/
public async getPaymentReceiveEditPage(
tenantId: number,
paymentReceiveId: number,
): Promise<{
paymentReceive: Omit<IPaymentReceive, 'entries'>;
entries: IPaymentReceivePageEntry[];
}> {
const { PaymentReceive, SaleInvoice } = this.tenancy.models(tenantId);
// Retrieve payment receive.
const paymentReceive = await PaymentReceive.query()
.findById(paymentReceiveId)
.withGraphFetched('entries.invoice');
// Throw not found the payment receive.
if (!paymentReceive) {
throw new ServiceError(ERRORS.PAYMENT_RECEIVE_NOT_EXISTS);
}
const paymentEntries = paymentReceive.entries.map((entry) => ({
...this.invoiceToPageEntry(entry.invoice),
dueAmount: entry.invoice.dueAmount + entry.paymentAmount,
paymentAmount: entry.paymentAmount,
index: entry.index,
}));
// Retrieves all receivable bills that associated to the payment receive transaction.
const restReceivableInvoices = await SaleInvoice.query()
.modify('delivered')
.modify('dueInvoices')
.where('customer_id', paymentReceive.customerId)
.whereNotIn(
'id',
paymentReceive.entries.map((entry) => entry.invoiceId)
)
.orderBy('invoice_date', 'ASC');
const restReceivableEntries = restReceivableInvoices.map(
this.invoiceToPageEntry
);
const entries = [...paymentEntries, ...restReceivableEntries];
return {
paymentReceive: omit(paymentReceive, ['entries']),
entries,
};
}
}

View File

@@ -0,0 +1,847 @@
import { omit, sumBy, difference } from 'lodash';
import { Service, Inject } from 'typedi';
import * as R from 'ramda';
import { Knex } from 'knex';
import events from '@/subscribers/events';
import {
IAccount,
IFilterMeta,
IPaginationMeta,
IPaymentReceive,
IPaymentReceiveCreateDTO,
IPaymentReceiveEditDTO,
IPaymentReceiveEntry,
IPaymentReceiveEntryDTO,
IPaymentReceivesFilter,
IPaymentsReceiveService,
IPaymentReceiveCreatedPayload,
ISaleInvoice,
ISystemUser,
IPaymentReceiveEditedPayload,
IPaymentReceiveDeletedPayload,
IPaymentReceiveCreatingPayload,
IPaymentReceiveDeletingPayload,
IPaymentReceiveEditingPayload,
ICustomer,
} from '@/interfaces';
import JournalPosterService from '@/services/Sales/JournalPosterService';
import TenancyService from '@/services/Tenancy/TenancyService';
import DynamicListingService from '@/services/DynamicListing/DynamicListService';
import { formatDateFields, entriesAmountDiff } from 'utils';
import { ServiceError } from '@/exceptions';
import ItemsEntriesService from '@/services/Items/ItemsEntriesService';
import { ACCOUNT_TYPE } from '@/data/AccountTypes';
import AutoIncrementOrdersService from '../AutoIncrementOrdersService';
import { ERRORS } from './constants';
import { EventPublisher } from '@/lib/EventPublisher/EventPublisher';
import { PaymentReceiveTransfromer } from './PaymentReceiveTransformer';
import UnitOfWork from '@/services/UnitOfWork';
import { BranchTransactionDTOTransform } from '@/services/Branches/Integrations/BranchTransactionDTOTransform';
import { TenantMetadata } from '@/system/models';
import { TransformerInjectable } from '@/lib/Transformer/TransformerInjectable';
/**
* Payment receive service.
* @service
*/
@Service('PaymentReceives')
export default class PaymentReceiveService implements IPaymentsReceiveService {
@Inject()
itemsEntries: ItemsEntriesService;
@Inject()
tenancy: TenancyService;
@Inject()
journalService: JournalPosterService;
@Inject()
dynamicListService: DynamicListingService;
@Inject()
autoIncrementOrdersService: AutoIncrementOrdersService;
@Inject('logger')
logger: any;
@Inject()
uow: UnitOfWork;
@Inject()
eventPublisher: EventPublisher;
@Inject()
branchDTOTransform: BranchTransactionDTOTransform;
@Inject()
transformer: TransformerInjectable;
/**
* Validates the payment receive number existance.
* @param {number} tenantId -
* @param {string} paymentReceiveNo -
*/
async validatePaymentReceiveNoExistance(
tenantId: number,
paymentReceiveNo: string,
notPaymentReceiveId?: number
): Promise<void> {
const { PaymentReceive } = this.tenancy.models(tenantId);
const paymentReceive = await PaymentReceive.query()
.findOne('payment_receive_no', paymentReceiveNo)
.onBuild((builder) => {
if (notPaymentReceiveId) {
builder.whereNot('id', notPaymentReceiveId);
}
});
if (paymentReceive) {
throw new ServiceError(ERRORS.PAYMENT_RECEIVE_NO_EXISTS);
}
}
/**
* Validates the payment receive existance.
* @param {number} tenantId - Tenant id.
* @param {number} paymentReceiveId - Payment receive id.
*/
async getPaymentReceiveOrThrowError(
tenantId: number,
paymentReceiveId: number
): Promise<IPaymentReceive> {
const { PaymentReceive } = this.tenancy.models(tenantId);
const paymentReceive = await PaymentReceive.query()
.withGraphFetched('entries')
.findById(paymentReceiveId);
if (!paymentReceive) {
throw new ServiceError(ERRORS.PAYMENT_RECEIVE_NOT_EXISTS);
}
return paymentReceive;
}
/**
* Validate the deposit account id existance.
* @param {number} tenantId - Tenant id.
* @param {number} depositAccountId - Deposit account id.
* @return {Promise<IAccount>}
*/
async getDepositAccountOrThrowError(
tenantId: number,
depositAccountId: number
): Promise<IAccount> {
const { accountRepository } = this.tenancy.repositories(tenantId);
const depositAccount = await accountRepository.findOneById(
depositAccountId
);
if (!depositAccount) {
throw new ServiceError(ERRORS.DEPOSIT_ACCOUNT_NOT_FOUND);
}
// Detarmines whether the account is cash, bank or other current asset.
if (
!depositAccount.isAccountType([
ACCOUNT_TYPE.CASH,
ACCOUNT_TYPE.BANK,
ACCOUNT_TYPE.OTHER_CURRENT_ASSET,
])
) {
throw new ServiceError(ERRORS.DEPOSIT_ACCOUNT_INVALID_TYPE);
}
return depositAccount;
}
/**
* Validates the invoices IDs existance.
* @param {number} tenantId -
* @param {number} customerId -
* @param {IPaymentReceiveEntryDTO[]} paymentReceiveEntries -
*/
async validateInvoicesIDsExistance(
tenantId: number,
customerId: number,
paymentReceiveEntries: { invoiceId: number }[]
): Promise<ISaleInvoice[]> {
const { SaleInvoice } = this.tenancy.models(tenantId);
const invoicesIds = paymentReceiveEntries.map(
(e: { invoiceId: number }) => e.invoiceId
);
const storedInvoices = await SaleInvoice.query()
.whereIn('id', invoicesIds)
.where('customer_id', customerId);
const storedInvoicesIds = storedInvoices.map((invoice) => invoice.id);
const notFoundInvoicesIDs = difference(invoicesIds, storedInvoicesIds);
if (notFoundInvoicesIDs.length > 0) {
throw new ServiceError(ERRORS.INVOICES_IDS_NOT_FOUND);
}
// Filters the not delivered invoices.
const notDeliveredInvoices = storedInvoices.filter(
(invoice) => !invoice.isDelivered
);
if (notDeliveredInvoices.length > 0) {
throw new ServiceError(ERRORS.INVOICES_NOT_DELIVERED_YET, null, {
notDeliveredInvoices,
});
}
return storedInvoices;
}
/**
* Validates entries invoice payment amount.
* @param {Request} req -
* @param {Response} res -
* @param {Function} next -
*/
async validateInvoicesPaymentsAmount(
tenantId: number,
paymentReceiveEntries: IPaymentReceiveEntryDTO[],
oldPaymentEntries: IPaymentReceiveEntry[] = []
) {
const { SaleInvoice } = this.tenancy.models(tenantId);
const invoicesIds = paymentReceiveEntries.map(
(e: IPaymentReceiveEntryDTO) => e.invoiceId
);
const storedInvoices = await SaleInvoice.query().whereIn('id', invoicesIds);
const storedInvoicesMap = new Map(
storedInvoices.map((invoice: ISaleInvoice) => {
const oldEntries = oldPaymentEntries.filter((entry) => entry.invoiceId);
const oldPaymentAmount = sumBy(oldEntries, 'paymentAmount') || 0;
return [
invoice.id,
{ ...invoice, dueAmount: invoice.dueAmount + oldPaymentAmount },
];
})
);
const hasWrongPaymentAmount: any[] = [];
paymentReceiveEntries.forEach(
(entry: IPaymentReceiveEntryDTO, index: number) => {
const entryInvoice = storedInvoicesMap.get(entry.invoiceId);
const { dueAmount } = entryInvoice;
if (dueAmount < entry.paymentAmount) {
hasWrongPaymentAmount.push({ index, due_amount: dueAmount });
}
}
);
if (hasWrongPaymentAmount.length > 0) {
throw new ServiceError(ERRORS.INVALID_PAYMENT_AMOUNT);
}
}
/**
* Retrieve the next unique payment receive number.
* @param {number} tenantId - Tenant id.
* @return {string}
*/
getNextPaymentReceiveNumber(tenantId: number): string {
return this.autoIncrementOrdersService.getNextTransactionNumber(
tenantId,
'payment_receives'
);
}
/**
* Increment the payment receive next number.
* @param {number} tenantId
*/
incrementNextPaymentReceiveNumber(tenantId: number) {
return this.autoIncrementOrdersService.incrementSettingsNextNumber(
tenantId,
'payment_receives'
);
}
/**
* Validate the payment receive number require.
* @param {IPaymentReceive} paymentReceiveObj
*/
validatePaymentReceiveNoRequire(paymentReceiveObj: IPaymentReceive) {
if (!paymentReceiveObj.paymentReceiveNo) {
throw new ServiceError(ERRORS.PAYMENT_RECEIVE_NO_IS_REQUIRED);
}
}
/**
* Validate the payment receive entries IDs existance.
* @param {number} tenantId
* @param {number} paymentReceiveId
* @param {IPaymentReceiveEntryDTO[]} paymentReceiveEntries
*/
private async validateEntriesIdsExistance(
tenantId: number,
paymentReceiveId: number,
paymentReceiveEntries: IPaymentReceiveEntryDTO[]
) {
const { PaymentReceiveEntry } = this.tenancy.models(tenantId);
const entriesIds = paymentReceiveEntries
.filter((entry) => entry.id)
.map((entry) => entry.id);
const storedEntries = await PaymentReceiveEntry.query().where(
'payment_receive_id',
paymentReceiveId
);
const storedEntriesIds = storedEntries.map((entry: any) => entry.id);
const notFoundEntriesIds = difference(entriesIds, storedEntriesIds);
if (notFoundEntriesIds.length > 0) {
throw new ServiceError(ERRORS.ENTRIES_IDS_NOT_EXISTS);
}
}
/**
* Validates the payment receive number require.
* @param {string} paymentReceiveNo
*/
validatePaymentNoRequire(paymentReceiveNo: string) {
if (!paymentReceiveNo) {
throw new ServiceError(ERRORS.PAYMENT_RECEIVE_NO_REQUIRED);
}
}
/**
* Validate the payment customer whether modified.
* @param {IPaymentReceiveEditDTO} paymentReceiveDTO
* @param {IPaymentReceive} oldPaymentReceive
*/
validateCustomerNotModified(
paymentReceiveDTO: IPaymentReceiveEditDTO,
oldPaymentReceive: IPaymentReceive
) {
if (paymentReceiveDTO.customerId !== oldPaymentReceive.customerId) {
throw new ServiceError(ERRORS.PAYMENT_CUSTOMER_SHOULD_NOT_UPDATE);
}
}
/**
* Validates the payment account currency code. The deposit account curreny
* should be equals the customer currency code or the base currency.
* @param {string} paymentAccountCurrency
* @param {string} customerCurrency
* @param {string} baseCurrency
* @throws {ServiceError(ERRORS.PAYMENT_ACCOUNT_CURRENCY_INVALID)}
*/
public validatePaymentAccountCurrency = (
paymentAccountCurrency: string,
customerCurrency: string,
baseCurrency: string
) => {
if (
paymentAccountCurrency !== customerCurrency &&
paymentAccountCurrency !== baseCurrency
) {
throw new ServiceError(ERRORS.PAYMENT_ACCOUNT_CURRENCY_INVALID);
}
};
/**
* Transformes the create payment receive DTO to model object.
* @param {number} tenantId
* @param {IPaymentReceiveCreateDTO|IPaymentReceiveEditDTO} paymentReceiveDTO - Payment receive DTO.
* @param {IPaymentReceive} oldPaymentReceive -
* @return {IPaymentReceive}
*/
async transformPaymentReceiveDTOToModel(
tenantId: number,
customer: ICustomer,
paymentReceiveDTO: IPaymentReceiveCreateDTO | IPaymentReceiveEditDTO,
oldPaymentReceive?: IPaymentReceive
): Promise<IPaymentReceive> {
const paymentAmount = sumBy(paymentReceiveDTO.entries, 'paymentAmount');
// Retreive the next invoice number.
const autoNextNumber = this.getNextPaymentReceiveNumber(tenantId);
// Retrieve the next payment receive number.
const paymentReceiveNo =
paymentReceiveDTO.paymentReceiveNo ||
oldPaymentReceive?.paymentReceiveNo ||
autoNextNumber;
this.validatePaymentNoRequire(paymentReceiveNo);
const initialDTO = {
...formatDateFields(omit(paymentReceiveDTO, ['entries']), [
'paymentDate',
]),
amount: paymentAmount,
currencyCode: customer.currencyCode,
...(paymentReceiveNo ? { paymentReceiveNo } : {}),
exchangeRate: paymentReceiveDTO.exchangeRate || 1,
entries: paymentReceiveDTO.entries.map((entry) => ({
...entry,
})),
};
return R.compose(
this.branchDTOTransform.transformDTO<IPaymentReceive>(tenantId)
)(initialDTO);
}
/**
* Transform the create payment receive DTO.
* @param {number} tenantId
* @param {ICustomer} customer
* @param {IPaymentReceiveCreateDTO} paymentReceiveDTO
* @returns
*/
private transformCreateDTOToModel = async (
tenantId: number,
customer: ICustomer,
paymentReceiveDTO: IPaymentReceiveCreateDTO
) => {
return this.transformPaymentReceiveDTOToModel(
tenantId,
customer,
paymentReceiveDTO
);
};
/**
* Transform the edit payment receive DTO.
* @param {number} tenantId
* @param {ICustomer} customer
* @param {IPaymentReceiveEditDTO} paymentReceiveDTO
* @param {IPaymentReceive} oldPaymentReceive
* @returns
*/
private transformEditDTOToModel = async (
tenantId: number,
customer: ICustomer,
paymentReceiveDTO: IPaymentReceiveEditDTO,
oldPaymentReceive: IPaymentReceive
) => {
return this.transformPaymentReceiveDTOToModel(
tenantId,
customer,
paymentReceiveDTO,
oldPaymentReceive
);
};
/**
* Creates a new payment receive and store it to the storage
* with associated invoices payment and journal transactions.
* @async
* @param {number} tenantId - Tenant id.
* @param {IPaymentReceive} paymentReceive
*/
public async createPaymentReceive(
tenantId: number,
paymentReceiveDTO: IPaymentReceiveCreateDTO,
authorizedUser: ISystemUser
) {
const { PaymentReceive, Contact } = this.tenancy.models(tenantId);
const tenantMeta = await TenantMetadata.query().findOne({ tenantId });
// Validate customer existance.
const paymentCustomer = await Contact.query()
.modify('customer')
.findById(paymentReceiveDTO.customerId)
.throwIfNotFound();
// Transformes the payment receive DTO to model.
const paymentReceiveObj = await this.transformCreateDTOToModel(
tenantId,
paymentCustomer,
paymentReceiveDTO
);
// Validate payment receive number uniquiness.
await this.validatePaymentReceiveNoExistance(
tenantId,
paymentReceiveObj.paymentReceiveNo
);
// Validate the deposit account existance and type.
const depositAccount = await this.getDepositAccountOrThrowError(
tenantId,
paymentReceiveDTO.depositAccountId
);
// Validate payment receive invoices IDs existance.
await this.validateInvoicesIDsExistance(
tenantId,
paymentReceiveDTO.customerId,
paymentReceiveDTO.entries
);
// Validate invoice payment amount.
await this.validateInvoicesPaymentsAmount(
tenantId,
paymentReceiveDTO.entries
);
// Validates the payment account currency code.
this.validatePaymentAccountCurrency(
depositAccount.currencyCode,
paymentCustomer.currencyCode,
tenantMeta.baseCurrency
);
// Creates a payment receive transaction under UOW envirment.
return this.uow.withTransaction(tenantId, async (trx: Knex.Transaction) => {
// Triggers `onPaymentReceiveCreating` event.
await this.eventPublisher.emitAsync(events.paymentReceive.onCreating, {
trx,
paymentReceiveDTO,
tenantId,
} as IPaymentReceiveCreatingPayload);
// Inserts the payment receive transaction.
const paymentReceive = await PaymentReceive.query(
trx
).insertGraphAndFetch({
...paymentReceiveObj,
});
// Triggers `onPaymentReceiveCreated` event.
await this.eventPublisher.emitAsync(events.paymentReceive.onCreated, {
tenantId,
paymentReceive,
paymentReceiveId: paymentReceive.id,
authorizedUser,
trx,
} as IPaymentReceiveCreatedPayload);
return paymentReceive;
});
}
/**
* Edit details the given payment receive with associated entries.
* ------
* - Update the payment receive transactions.
* - Insert the new payment receive entries.
* - Update the given payment receive entries.
* - Delete the not presented payment receive entries.
* - Re-insert the journal transactions and update the different accounts balance.
* - Update the different customer balances.
* - Update the different invoice payment amount.
* @async
* @param {number} tenantId -
* @param {Integer} paymentReceiveId -
* @param {IPaymentReceive} paymentReceive -
*/
public async editPaymentReceive(
tenantId: number,
paymentReceiveId: number,
paymentReceiveDTO: IPaymentReceiveEditDTO,
authorizedUser: ISystemUser
) {
const { PaymentReceive, Contact } = this.tenancy.models(tenantId);
const tenantMeta = await TenantMetadata.query().findOne({ tenantId });
// Validate the payment receive existance.
const oldPaymentReceive = await this.getPaymentReceiveOrThrowError(
tenantId,
paymentReceiveId
);
// Validate customer existance.
const customer = await Contact.query()
.modify('customer')
.findById(paymentReceiveDTO.customerId)
.throwIfNotFound();
// Transformes the payment receive DTO to model.
const paymentReceiveObj = await this.transformEditDTOToModel(
tenantId,
customer,
paymentReceiveDTO,
oldPaymentReceive
);
// Validate customer whether modified.
this.validateCustomerNotModified(paymentReceiveDTO, oldPaymentReceive);
// Validate payment receive number uniquiness.
if (paymentReceiveDTO.paymentReceiveNo) {
await this.validatePaymentReceiveNoExistance(
tenantId,
paymentReceiveDTO.paymentReceiveNo,
paymentReceiveId
);
}
// Validate the deposit account existance and type.
const depositAccount = await this.getDepositAccountOrThrowError(
tenantId,
paymentReceiveDTO.depositAccountId
);
// Validate the entries ids existance on payment receive type.
await this.validateEntriesIdsExistance(
tenantId,
paymentReceiveId,
paymentReceiveDTO.entries
);
// Validate payment receive invoices IDs existance and associated
// to the given customer id.
await this.validateInvoicesIDsExistance(
tenantId,
oldPaymentReceive.customerId,
paymentReceiveDTO.entries
);
// Validate invoice payment amount.
await this.validateInvoicesPaymentsAmount(
tenantId,
paymentReceiveDTO.entries,
oldPaymentReceive.entries
);
// Validates the payment account currency code.
this.validatePaymentAccountCurrency(
depositAccount.currencyCode,
customer.currencyCode,
tenantMeta.baseCurrency
);
// Creates payment receive transaction under UOW envirement.
return this.uow.withTransaction(tenantId, async (trx: Knex.Transaction) => {
// Triggers `onPaymentReceiveEditing` event.
await this.eventPublisher.emitAsync(events.paymentReceive.onEditing, {
trx,
tenantId,
oldPaymentReceive,
paymentReceiveDTO,
} as IPaymentReceiveEditingPayload);
// Update the payment receive transaction.
const paymentReceive = await PaymentReceive.query(
trx
).upsertGraphAndFetch({
id: paymentReceiveId,
...paymentReceiveObj,
});
// Triggers `onPaymentReceiveEdited` event.
await this.eventPublisher.emitAsync(events.paymentReceive.onEdited, {
tenantId,
paymentReceiveId,
paymentReceive,
oldPaymentReceive,
authorizedUser,
trx,
} as IPaymentReceiveEditedPayload);
return paymentReceive;
});
}
/**
* Deletes the given payment receive with associated entries
* and journal transactions.
* -----
* - Deletes the payment receive transaction.
* - Deletes the payment receive associated entries.
* - Deletes the payment receive associated journal transactions.
* - Revert the customer balance.
* - Revert the payment amount of the associated invoices.
* @async
* @param {number} tenantId - Tenant id.
* @param {Integer} paymentReceiveId - Payment receive id.
* @param {IPaymentReceive} paymentReceive - Payment receive object.
*/
public async deletePaymentReceive(
tenantId: number,
paymentReceiveId: number,
authorizedUser: ISystemUser
) {
const { PaymentReceive, PaymentReceiveEntry } =
this.tenancy.models(tenantId);
// Retreive payment receive or throw not found service error.
const oldPaymentReceive = await this.getPaymentReceiveOrThrowError(
tenantId,
paymentReceiveId
);
// Delete payment receive transaction and associate transactions under UOW env.
return this.uow.withTransaction(tenantId, async (trx: Knex.Transaction) => {
// Triggers `onPaymentReceiveDeleting` event.
await this.eventPublisher.emitAsync(events.paymentReceive.onDeleting, {
tenantId,
oldPaymentReceive,
trx,
} as IPaymentReceiveDeletingPayload);
// Deletes the payment receive associated entries.
await PaymentReceiveEntry.query(trx)
.where('payment_receive_id', paymentReceiveId)
.delete();
// Deletes the payment receive transaction.
await PaymentReceive.query(trx).findById(paymentReceiveId).delete();
// Triggers `onPaymentReceiveDeleted` event.
await this.eventPublisher.emitAsync(events.paymentReceive.onDeleted, {
tenantId,
paymentReceiveId,
oldPaymentReceive,
authorizedUser,
trx,
} as IPaymentReceiveDeletedPayload);
});
}
/**
* Retrieve payment receive details.
* @param {number} tenantId - Tenant id.
* @param {number} paymentReceiveId - Payment receive id.
* @return {Promise<IPaymentReceive>}
*/
public async getPaymentReceive(
tenantId: number,
paymentReceiveId: number
): Promise<IPaymentReceive> {
const { PaymentReceive } = this.tenancy.models(tenantId);
const paymentReceive = await PaymentReceive.query()
.withGraphFetched('customer')
.withGraphFetched('depositAccount')
.withGraphFetched('entries.invoice')
.withGraphFetched('transactions')
.withGraphFetched('branch')
.findById(paymentReceiveId);
if (!paymentReceive) {
throw new ServiceError(ERRORS.PAYMENT_RECEIVE_NOT_EXISTS);
}
return this.transformer.transform(
tenantId,
paymentReceive,
new PaymentReceiveTransfromer()
);
}
/**
* Retrieve sale invoices that assocaited to the given payment receive.
* @param {number} tenantId - Tenant id.
* @param {number} paymentReceiveId - Payment receive id.
* @return {Promise<ISaleInvoice>}
*/
public async getPaymentReceiveInvoices(
tenantId: number,
paymentReceiveId: number
) {
const { SaleInvoice } = this.tenancy.models(tenantId);
const paymentReceive = await this.getPaymentReceiveOrThrowError(
tenantId,
paymentReceiveId
);
const paymentReceiveInvoicesIds = paymentReceive.entries.map(
(entry) => entry.invoiceId
);
const saleInvoices = await SaleInvoice.query().whereIn(
'id',
paymentReceiveInvoicesIds
);
return saleInvoices;
}
/**
* Parses payments receive list filter DTO.
* @param filterDTO
*/
private parseListFilterDTO(filterDTO) {
return R.compose(this.dynamicListService.parseStringifiedFilter)(filterDTO);
}
/**
* Retrieve payment receives paginated and filterable list.
* @param {number} tenantId
* @param {IPaymentReceivesFilter} paymentReceivesFilter
*/
public async listPaymentReceives(
tenantId: number,
filterDTO: IPaymentReceivesFilter
): Promise<{
paymentReceives: IPaymentReceive[];
pagination: IPaginationMeta;
filterMeta: IFilterMeta;
}> {
const { PaymentReceive } = this.tenancy.models(tenantId);
// Parses filter DTO.
const filter = this.parseListFilterDTO(filterDTO);
// Dynamic list service.
const dynamicList = await this.dynamicListService.dynamicList(
tenantId,
PaymentReceive,
filter
);
const { results, pagination } = await PaymentReceive.query()
.onBuild((builder) => {
builder.withGraphFetched('customer');
builder.withGraphFetched('depositAccount');
dynamicList.buildQuery()(builder);
})
.pagination(filter.page - 1, filter.pageSize);
// Transformer the payment receives models to POJO.
const transformedPayments = await this.transformer.transform(
tenantId,
results,
new PaymentReceiveTransfromer()
);
return {
paymentReceives: transformedPayments,
pagination,
filterMeta: dynamicList.getResponseMeta(),
};
}
/**
* Saves difference changing between old and new invoice payment amount.
* @async
* @param {number} tenantId - Tenant id.
* @param {Array} paymentReceiveEntries
* @param {Array} newPaymentReceiveEntries
* @return {Promise<void>}
*/
public async saveChangeInvoicePaymentAmount(
tenantId: number,
newPaymentReceiveEntries: IPaymentReceiveEntryDTO[],
oldPaymentReceiveEntries?: IPaymentReceiveEntryDTO[],
trx?: Knex.Transaction
): Promise<void> {
const { SaleInvoice } = this.tenancy.models(tenantId);
const opers: Promise<void>[] = [];
const diffEntries = entriesAmountDiff(
newPaymentReceiveEntries,
oldPaymentReceiveEntries,
'paymentAmount',
'invoiceId'
);
diffEntries.forEach((diffEntry: any) => {
if (diffEntry.paymentAmount === 0) {
return;
}
const oper = SaleInvoice.changePaymentAmount(
diffEntry.invoiceId,
diffEntry.paymentAmount,
trx
);
opers.push(oper);
});
await Promise.all([...opers]);
}
/**
* Validate the given customer has no payments receives.
* @param {number} tenantId
* @param {number} customerId - Customer id.
*/
public async validateCustomerHasNoPayments(
tenantId: number,
customerId: number
) {
const { PaymentReceive } = this.tenancy.models(tenantId);
const paymentReceives = await PaymentReceive.query().where(
'customer_id',
customerId
);
if (paymentReceives.length > 0) {
throw new ServiceError(ERRORS.CUSTOMER_HAS_PAYMENT_RECEIVES);
}
}
}

View File

@@ -0,0 +1,18 @@
export const ERRORS = {
PAYMENT_RECEIVE_NO_EXISTS: 'PAYMENT_RECEIVE_NO_EXISTS',
PAYMENT_RECEIVE_NOT_EXISTS: 'PAYMENT_RECEIVE_NOT_EXISTS',
DEPOSIT_ACCOUNT_NOT_FOUND: 'DEPOSIT_ACCOUNT_NOT_FOUND',
DEPOSIT_ACCOUNT_INVALID_TYPE: 'DEPOSIT_ACCOUNT_INVALID_TYPE',
INVALID_PAYMENT_AMOUNT: 'INVALID_PAYMENT_AMOUNT',
INVOICES_IDS_NOT_FOUND: 'INVOICES_IDS_NOT_FOUND',
ENTRIES_IDS_NOT_EXISTS: 'ENTRIES_IDS_NOT_EXISTS',
INVOICES_NOT_DELIVERED_YET: 'INVOICES_NOT_DELIVERED_YET',
PAYMENT_RECEIVE_NO_IS_REQUIRED: 'PAYMENT_RECEIVE_NO_IS_REQUIRED',
PAYMENT_RECEIVE_NO_REQUIRED: 'PAYMENT_RECEIVE_NO_REQUIRED',
PAYMENT_CUSTOMER_SHOULD_NOT_UPDATE: 'PAYMENT_CUSTOMER_SHOULD_NOT_UPDATE',
CUSTOMER_HAS_PAYMENT_RECEIVES: 'CUSTOMER_HAS_PAYMENT_RECEIVES',
PAYMENT_ACCOUNT_CURRENCY_INVALID: 'PAYMENT_ACCOUNT_CURRENCY_INVALID'
};
export const DEFAULT_VIEWS = [];

View File

@@ -0,0 +1,148 @@
import { Service, Inject } from 'typedi';
import * as R from 'ramda';
import { Knex } from 'knex';
import { AccountNormal, IInventoryLotCost, ILedgerEntry } from '@/interfaces';
import { increment } from 'utils';
import HasTenancyService from '@/services/Tenancy/TenancyService';
import Ledger from '@/services/Accounting/Ledger';
import LedgerStorageService from '@/services/Accounting/LedgerStorageService';
import { groupInventoryTransactionsByTypeId } from '../../Inventory/utils';
@Service()
export class SaleReceiptCostGLEntries {
@Inject()
private tenancy: HasTenancyService;
@Inject()
private ledgerStorage: LedgerStorageService;
/**
* Writes journal entries from sales invoices.
* @param {number} tenantId - The tenant id.
* @param {Date} startingDate - Starting date.
* @param {boolean} override
*/
public writeInventoryCostJournalEntries = async (
tenantId: number,
startingDate: Date,
trx?: Knex.Transaction
): Promise<void> => {
const { InventoryCostLotTracker } = this.tenancy.models(tenantId);
const inventoryCostLotTrans = await InventoryCostLotTracker.query()
.where('direction', 'OUT')
.where('transaction_type', 'SaleReceipt')
.where('cost', '>', 0)
.modify('filterDateRange', startingDate)
.orderBy('date', 'ASC')
.withGraphFetched('receipt')
.withGraphFetched('item');
const ledger = this.getInventoryCostLotsLedger(inventoryCostLotTrans);
// Commit the ledger to the storage.
await this.ledgerStorage.commit(tenantId, ledger, trx);
};
/**
* Retrieves the inventory cost lots ledger.
* @param {} inventoryCostLots
* @returns {Ledger}
*/
private getInventoryCostLotsLedger = (
inventoryCostLots: IInventoryLotCost[]
) => {
// Groups the inventory cost lots transactions.
const inventoryTransactions =
groupInventoryTransactionsByTypeId(inventoryCostLots);
//
const entries = inventoryTransactions
.map(this.getSaleInvoiceCostGLEntries)
.flat();
return new Ledger(entries);
};
/**
*
* @param {IInventoryLotCost} inventoryCostLot
* @returns {}
*/
private getInvoiceCostGLCommonEntry = (
inventoryCostLot: IInventoryLotCost
) => {
return {
currencyCode: inventoryCostLot.receipt.currencyCode,
exchangeRate: inventoryCostLot.receipt.exchangeRate,
transactionType: inventoryCostLot.transactionType,
transactionId: inventoryCostLot.transactionId,
date: inventoryCostLot.date,
indexGroup: 20,
costable: true,
createdAt: inventoryCostLot.createdAt,
debit: 0,
credit: 0,
branchId: inventoryCostLot.receipt.branchId,
};
};
/**
* Retrieves the inventory cost GL entry.
* @param {IInventoryLotCost} inventoryLotCost
* @returns {ILedgerEntry[]}
*/
private getInventoryCostGLEntry = R.curry(
(
getIndexIncrement,
inventoryCostLot: IInventoryLotCost
): ILedgerEntry[] => {
const commonEntry = this.getInvoiceCostGLCommonEntry(inventoryCostLot);
const costAccountId =
inventoryCostLot.costAccountId || inventoryCostLot.item.costAccountId;
// XXX Debit - Cost account.
const costEntry = {
...commonEntry,
debit: inventoryCostLot.cost,
accountId: costAccountId,
accountNormal: AccountNormal.DEBIT,
itemId: inventoryCostLot.itemId,
index: getIndexIncrement(),
};
// XXX Credit - Inventory account.
const inventoryEntry = {
...commonEntry,
credit: inventoryCostLot.cost,
accountId: inventoryCostLot.item.inventoryAccountId,
accountNormal: AccountNormal.DEBIT,
itemId: inventoryCostLot.itemId,
index: getIndexIncrement(),
};
return [costEntry, inventoryEntry];
}
);
/**
* Writes journal entries for given sale invoice.
* -------
* - Cost of goods sold -> Debit -> YYYY
* - Inventory assets -> Credit -> YYYY
* --------
* @param {ISaleInvoice} saleInvoice
* @param {JournalPoster} journal
*/
public getSaleInvoiceCostGLEntries = (
inventoryCostLots: IInventoryLotCost[]
): ILedgerEntry[] => {
const getIndexIncrement = increment(0);
const getInventoryLotEntry =
this.getInventoryCostGLEntry(getIndexIncrement);
return inventoryCostLots.map(getInventoryLotEntry).flat();
};
}

View File

@@ -0,0 +1,44 @@
import { Service } from 'typedi';
import { ISaleReceipt } from '@/interfaces';
import { Transformer } from '@/lib/Transformer/Transformer';
import { formatNumber } from 'utils';
@Service()
export class SaleReceiptTransformer extends Transformer {
/**
* Include these attributes to sale invoice object.
* @returns {Array}
*/
public includeAttributes = (): string[] => {
return ['formattedAmount', 'formattedReceiptDate', 'formattedClosedAtDate'];
};
/**
* Retrieve formatted receipt date.
* @param {ISaleReceipt} invoice
* @returns {String}
*/
protected formattedReceiptDate = (receipt: ISaleReceipt): string => {
return this.formatDate(receipt.receiptDate);
};
/**
* Retrieve formatted estimate closed at date.
* @param {ISaleReceipt} invoice
* @returns {String}
*/
protected formattedClosedAtDate = (receipt: ISaleReceipt): string => {
return this.formatDate(receipt.closedAt);
};
/**
* Retrieve formatted invoice amount.
* @param {ISaleReceipt} estimate
* @returns {string}
*/
protected formattedAmount = (receipt: ISaleReceipt): string => {
return formatNumber(receipt.amount, {
currencyCode: receipt.currencyCode,
});
};
}

View File

@@ -0,0 +1,36 @@
import { Inject, Service } from 'typedi';
import PdfService from '@/services/PDF/PdfService';
import { templateRender } from 'utils';
import HasTenancyService from '@/services/Tenancy/TenancyService';
import { Tenant } from '@/system/models';
@Service()
export default class SaleReceiptsPdf {
@Inject()
pdfService: PdfService;
@Inject()
tenancy: HasTenancyService;
/**
* Retrieve sale invoice pdf content.
* @param {} saleInvoice -
*/
async saleReceiptPdf(tenantId: number, saleReceipt) {
const i18n = this.tenancy.i18n(tenantId);
const organization = await Tenant.query()
.findById(tenantId)
.withGraphFetched('metadata');
const htmlContent = templateRender('modules/receipt-regular', {
saleReceipt,
organizationName: organization.metadata.name,
organizationEmail: organization.metadata.email,
...i18n,
});
const pdfContent = await this.pdfService.pdfDocument(htmlContent);
return pdfContent;
}
}

View File

@@ -0,0 +1,31 @@
export const ERRORS = {
SALE_RECEIPT_NOT_FOUND: 'SALE_RECEIPT_NOT_FOUND',
DEPOSIT_ACCOUNT_NOT_FOUND: 'DEPOSIT_ACCOUNT_NOT_FOUND',
DEPOSIT_ACCOUNT_NOT_CURRENT_ASSET: 'DEPOSIT_ACCOUNT_NOT_CURRENT_ASSET',
SALE_RECEIPT_NUMBER_NOT_UNIQUE: 'SALE_RECEIPT_NUMBER_NOT_UNIQUE',
SALE_RECEIPT_IS_ALREADY_CLOSED: 'SALE_RECEIPT_IS_ALREADY_CLOSED',
SALE_RECEIPT_NO_IS_REQUIRED: 'SALE_RECEIPT_NO_IS_REQUIRED',
CUSTOMER_HAS_SALES_INVOICES: 'CUSTOMER_HAS_SALES_INVOICES',
};
export const DEFAULT_VIEW_COLUMNS = [];
export const DEFAULT_VIEWS = [
{
name: 'Draft',
slug: 'draft',
rolesLogicExpression: '1',
roles: [
{ index: 1, fieldKey: 'status', comparator: 'equals', value: 'draft' },
],
columns: DEFAULT_VIEW_COLUMNS,
},
{
name: 'Closed',
slug: 'closed',
rolesLogicExpression: '1',
roles: [
{ index: 1, fieldKey: 'status', comparator: 'equals', value: 'closed' },
],
columns: DEFAULT_VIEW_COLUMNS,
},
];

View File

@@ -0,0 +1,36 @@
import { Inject, Service } from 'typedi';
import events from '@/subscribers/events';
import { IInventoryCostLotsGLEntriesWriteEvent } from '@/interfaces';
import { SaleReceiptCostGLEntries } from '../SaleReceiptCostGLEntries';
@Service()
export class SaleReceiptCostGLEntriesSubscriber {
@Inject()
saleReceiptCostEntries: SaleReceiptCostGLEntries;
/**
* Attaches events.
*/
public attach(bus) {
bus.subscribe(
events.inventory.onCostLotsGLEntriesWrite,
this.writeJournalEntriesOnceWriteoffCreate
);
}
/**
* Writes the receipts cost GL entries once the inventory cost lots be written.
* @param {IInventoryCostLotsGLEntriesWriteEvent}
*/
writeJournalEntriesOnceWriteoffCreate = async ({
trx,
startingDate,
tenantId,
}: IInventoryCostLotsGLEntriesWriteEvent) => {
await this.saleReceiptCostEntries.writeInventoryCostJournalEntries(
tenantId,
startingDate,
trx
);
};
}

View File

@@ -0,0 +1,258 @@
import { Service, Inject } from 'typedi';
import moment from 'moment';
import HasTenancyService from '@/services/Tenancy/TenancyService';
import events from '@/subscribers/events';
import SaleInvoicesService from './SalesInvoices';
import SMSClient from '@/services/SMSClient';
import {
ISaleInvoice,
ISaleInvoiceSmsDetailsDTO,
ISaleInvoiceSmsDetails,
SMS_NOTIFICATION_KEY,
InvoiceNotificationType,
ICustomer,
} from '@/interfaces';
import SmsNotificationsSettingsService from '@/services/Settings/SmsNotificationsSettings';
import { formatSmsMessage, formatNumber } from 'utils';
import { TenantMetadata } from '@/system/models';
import SaleNotifyBySms from './SaleNotifyBySms';
import { ServiceError } from '@/exceptions';
import { ERRORS } from './constants';
import { EventPublisher } from '@/lib/EventPublisher/EventPublisher';
@Service()
export default class SaleInvoiceNotifyBySms {
@Inject()
invoiceService: SaleInvoicesService;
@Inject()
tenancy: HasTenancyService;
@Inject()
eventPublisher: EventPublisher;
@Inject()
smsNotificationsSettings: SmsNotificationsSettingsService;
@Inject()
saleSmsNotification: SaleNotifyBySms;
/**
* Notify customer via sms about sale invoice.
* @param {number} tenantId - Tenant id.
* @param {number} saleInvoiceId - Sale invoice id.
*/
public notifyBySms = async (
tenantId: number,
saleInvoiceId: number,
invoiceNotificationType: InvoiceNotificationType
) => {
const { SaleInvoice } = this.tenancy.models(tenantId);
// Retrieve the sale invoice or throw not found service error.
const saleInvoice = await SaleInvoice.query()
.findById(saleInvoiceId)
.withGraphFetched('customer');
// Validate the customer phone number existance and number validation.
this.saleSmsNotification.validateCustomerPhoneNumber(
saleInvoice.customer.personalPhone
);
// Transformes the invoice notification key to sms notification key.
const notificationKey = this.transformDTOKeyToNotificationKey(
invoiceNotificationType
);
// Triggers `onSaleInvoiceNotifySms` event.
await this.eventPublisher.emitAsync(events.saleInvoice.onNotifySms, {
tenantId,
saleInvoice,
});
// Formattes the sms message and sends sms notification.
await this.sendSmsNotification(tenantId, notificationKey, saleInvoice);
// Triggers `onSaleInvoiceNotifySms` event.
await this.eventPublisher.emitAsync(events.saleInvoice.onNotifiedSms, {
tenantId,
saleInvoice,
});
return saleInvoice;
};
/**
* Notify invoice details by sms notification after invoice creation.
* @param {number} tenantId
* @param {number} saleInvoiceId
* @returns {Promise<void>}
*/
public notifyDetailsBySmsAfterCreation = async (
tenantId: number,
saleInvoiceId: number
): Promise<void> => {
const notification = this.smsNotificationsSettings.getSmsNotificationMeta(
tenantId,
SMS_NOTIFICATION_KEY.SALE_INVOICE_DETAILS
);
// Can't continue if the sms auto-notification is not enabled.
if (!notification.isNotificationEnabled) return;
await this.notifyBySms(tenantId, saleInvoiceId, 'details');
};
/**
* Sends SMS notification.
* @param {ISaleInvoice} invoice
* @param {ICustomer} customer
* @returns {Promise<void>}
*/
private sendSmsNotification = async (
tenantId: number,
notificationType:
| SMS_NOTIFICATION_KEY.SALE_INVOICE_DETAILS
| SMS_NOTIFICATION_KEY.SALE_INVOICE_REMINDER,
invoice: ISaleInvoice & { customer: ICustomer }
): Promise<void> => {
const smsClient = this.tenancy.smsClient(tenantId);
const tenantMetadata = await TenantMetadata.query().findOne({ tenantId });
// Formates the given sms message.
const message = this.formattedInvoiceDetailsMessage(
tenantId,
notificationType,
invoice,
tenantMetadata
);
const phoneNumber = invoice.customer.personalPhone;
// Run the send sms notification message job.
await smsClient.sendMessageJob(phoneNumber, message);
};
/**
* Formates the invoice details sms message.
* @param {number} tenantId
* @param {ISaleInvoice} invoice
* @param {ICustomer} customer
* @returns {string}
*/
private formattedInvoiceDetailsMessage = (
tenantId: number,
notificationKey:
| SMS_NOTIFICATION_KEY.SALE_INVOICE_DETAILS
| SMS_NOTIFICATION_KEY.SALE_INVOICE_REMINDER,
invoice: ISaleInvoice,
tenantMetadata: TenantMetadata
): string => {
const notification = this.smsNotificationsSettings.getSmsNotificationMeta(
tenantId,
notificationKey
);
return this.formatInvoiceDetailsMessage(
notification.smsMessage,
invoice,
tenantMetadata
);
};
/**
* Formattees the given invoice details sms message.
* @param {string} smsMessage
* @param {ISaleInvoice} invoice
* @param {ICustomer} customer
* @param {TenantMetadata} tenantMetadata
*/
private formatInvoiceDetailsMessage = (
smsMessage: string,
invoice: ISaleInvoice & { customer: ICustomer },
tenantMetadata: TenantMetadata
) => {
const formattedDueAmount = formatNumber(invoice.dueAmount, {
currencyCode: invoice.currencyCode,
});
const formattedAmount = formatNumber(invoice.balance, {
currencyCode: invoice.currencyCode,
});
return formatSmsMessage(smsMessage, {
InvoiceNumber: invoice.invoiceNo,
ReferenceNumber: invoice.referenceNo,
CustomerName: invoice.customer.displayName,
DueAmount: formattedDueAmount,
DueDate: moment(invoice.dueDate).format('YYYY/MM/DD'),
Amount: formattedAmount,
CompanyName: tenantMetadata.name,
});
};
/**
* Retrieve the SMS details of the given invoice.
* @param {number} tenantId - Tenant id.
* @param {number} saleInvoiceId - Sale invoice id.
*/
public smsDetails = async (
tenantId: number,
saleInvoiceId: number,
invoiceSmsDetailsDTO: ISaleInvoiceSmsDetailsDTO
): Promise<ISaleInvoiceSmsDetails> => {
const { SaleInvoice } = this.tenancy.models(tenantId);
// Retrieve the sale invoice or throw not found service error.
const saleInvoice = await SaleInvoice.query()
.findById(saleInvoiceId)
.withGraphFetched('customer');
// Validates the sale invoice existance.
this.validateSaleInvoiceExistance(saleInvoice);
// Current tenant metadata.
const tenantMetadata = await TenantMetadata.query().findOne({ tenantId });
// Transformes the invoice notification key to sms notification key.
const notificationKey = this.transformDTOKeyToNotificationKey(
invoiceSmsDetailsDTO.notificationKey
);
// Formates the given sms message.
const smsMessage = this.formattedInvoiceDetailsMessage(
tenantId,
notificationKey,
saleInvoice,
tenantMetadata
);
return {
customerName: saleInvoice.customer.displayName,
customerPhoneNumber: saleInvoice.customer.personalPhone,
smsMessage,
};
};
/**
* Transformes the invoice notification key DTO to notification key.
* @param {string} invoiceNotifKey
* @returns {SMS_NOTIFICATION_KEY.SALE_INVOICE_DETAILS
* | SMS_NOTIFICATION_KEY.SALE_INVOICE_REMINDER}
*/
private transformDTOKeyToNotificationKey = (
invoiceNotifKey: string
):
| SMS_NOTIFICATION_KEY.SALE_INVOICE_DETAILS
| SMS_NOTIFICATION_KEY.SALE_INVOICE_REMINDER => {
const invoiceNotifKeyPairs = {
details: SMS_NOTIFICATION_KEY.SALE_INVOICE_DETAILS,
reminder: SMS_NOTIFICATION_KEY.SALE_INVOICE_REMINDER,
};
return (
invoiceNotifKeyPairs[invoiceNotifKey] ||
SMS_NOTIFICATION_KEY.SALE_INVOICE_DETAILS
);
};
/**
* Validates the sale invoice existance.
* @param {ISaleInvoice|null} saleInvoice
*/
private validateSaleInvoiceExistance(saleInvoice: ISaleInvoice | null) {
if (!saleInvoice) {
throw new ServiceError(ERRORS.SALE_INVOICE_NOT_FOUND);
}
}
}

View File

@@ -0,0 +1,37 @@
import { Inject, Service } from 'typedi';
import PdfService from '@/services/PDF/PdfService';
import { templateRender } from 'utils';
import HasTenancyService from '@/services/Tenancy/TenancyService';
import { Tenant } from '@/system/models';
@Service()
export default class SaleInvoicePdf {
@Inject()
pdfService: PdfService;
@Inject()
tenancy: HasTenancyService;
/**
* Retrieve sale invoice pdf content.
* @param {} saleInvoice -
*/
async saleInvoicePdf(tenantId: number, saleInvoice) {
const i18n = this.tenancy.i18n(tenantId);
const organization = await Tenant.query()
.findById(tenantId)
.withGraphFetched('metadata');
const htmlContent = templateRender('modules/invoice-regular', {
organization,
organizationName: organization.metadata.name,
organizationEmail: organization.metadata.email,
saleInvoice,
...i18n,
});
const pdfContent = await this.pdfService.pdfDocument(htmlContent);
return pdfContent;
}
}

View File

@@ -0,0 +1,91 @@
import { Transformer } from '@/lib/Transformer/Transformer';
import { formatNumber } from 'utils';
export class SaleInvoiceTransformer extends Transformer {
/**
* Include these attributes to sale invoice object.
* @returns {Array}
*/
public includeAttributes = (): string[] => {
return [
'formattedInvoiceDate',
'formattedDueDate',
'formattedAmount',
'formattedDueAmount',
'formattedPaymentAmount',
'formattedBalanceAmount',
'formattedExchangeRate',
];
};
/**
* Retrieve formatted invoice date.
* @param {ISaleInvoice} invoice
* @returns {String}
*/
protected formattedInvoiceDate = (invoice): string => {
return this.formatDate(invoice.invoiceDate);
};
/**
* Retrieve formatted due date.
* @param {ISaleInvoice} invoice
* @returns {string}
*/
protected formattedDueDate = (invoice): string => {
return this.formatDate(invoice.dueDate);
};
/**
* Retrieve formatted invoice amount.
* @param {ISaleInvoice} invoice
* @returns {string}
*/
protected formattedAmount = (invoice): string => {
return formatNumber(invoice.balance, {
currencyCode: invoice.currencyCode,
});
};
/**
* Retrieve formatted invoice due amount.
* @param {ISaleInvoice} invoice
* @returns {string}
*/
protected formattedDueAmount = (invoice): string => {
return formatNumber(invoice.dueAmount, {
currencyCode: invoice.currencyCode,
});
};
/**
* Retrieve formatted payment amount.
* @param {ISaleInvoice} invoice
* @returns {string}
*/
protected formattedPaymentAmount = (invoice): string => {
return formatNumber(invoice.paymentAmount, {
currencyCode: invoice.currencyCode,
});
};
/**
* Retrieve the formatted invoice balance.
* @param {ISaleInvoice} invoice
* @returns {string}
*/
protected formattedBalanceAmount = (invoice): string => {
return formatNumber(invoice.balanceAmount, {
currencyCode: invoice.currencyCode,
});
};
/**
* Retrieve the formatted exchange rate.
* @param {ISaleInvoice} invoice
* @returns {string}
*/
protected formattedExchangeRate = (invoice): string => {
return formatNumber(invoice.exchangeRate, { money: false });
};
}

View File

@@ -0,0 +1,166 @@
import { Service, Inject } from 'typedi';
import { Knex } from 'knex';
import {
ISaleInvoice,
ISaleInvoiceWriteoffCreatePayload,
ISaleInvoiceWriteoffDTO,
ISaleInvoiceWrittenOffCanceledPayload,
ISaleInvoiceWrittenOffCancelPayload,
} from '@/interfaces';
import HasTenancyService from '@/services/Tenancy/TenancyService';
import events from '@/subscribers/events';
import { ServiceError } from '@/exceptions';
import JournalPosterService from './JournalPosterService';
import UnitOfWork from '@/services/UnitOfWork';
import { EventPublisher } from '@/lib/EventPublisher/EventPublisher';
const ERRORS = {
SALE_INVOICE_ALREADY_WRITTEN_OFF: 'SALE_INVOICE_ALREADY_WRITTEN_OFF',
SALE_INVOICE_NOT_WRITTEN_OFF: 'SALE_INVOICE_NOT_WRITTEN_OFF',
};
@Service()
export default class SaleInvoiceWriteoff {
@Inject()
tenancy: HasTenancyService;
@Inject()
eventPublisher: EventPublisher;
@Inject()
journalService: JournalPosterService;
@Inject()
uow: UnitOfWork;
/**
* Writes-off the sale invoice on bad debt expense account.
* @param {number} tenantId
* @param {number} saleInvoiceId
* @param {ISaleInvoiceWriteoffDTO} writeoffDTO
* @return {Promise<ISaleInvoice>}
*/
public writeOff = async (
tenantId: number,
saleInvoiceId: number,
writeoffDTO: ISaleInvoiceWriteoffDTO
): Promise<ISaleInvoice> => {
const { SaleInvoice } = this.tenancy.models(tenantId);
// Validate the sale invoice existance.
// Retrieve the sale invoice or throw not found service error.
const saleInvoice = await SaleInvoice.query()
.findById(saleInvoiceId)
.throwIfNotFound();
// Validate the sale invoice whether already written-off.
this.validateSaleInvoiceAlreadyWrittenoff(saleInvoice);
// Saves the invoice write-off transaction with associated transactions
// under unit-of-work envirmenet.
return this.uow.withTransaction(tenantId, async (trx: Knex.Transaction) => {
const eventPayload = {
tenantId,
saleInvoiceId,
saleInvoice,
writeoffDTO,
trx,
} as ISaleInvoiceWriteoffCreatePayload;
// Triggers `onSaleInvoiceWriteoff` event.
await this.eventPublisher.emitAsync(
events.saleInvoice.onWriteoff,
eventPayload
);
// Mark the sale invoice as written-off.
const newSaleInvoice = await SaleInvoice.query(trx)
.patch({
writtenoffExpenseAccountId: writeoffDTO.expenseAccountId,
writtenoffAmount: saleInvoice.dueAmount,
writtenoffAt: new Date(),
})
.findById(saleInvoiceId);
// Triggers `onSaleInvoiceWrittenoff` event.
await this.eventPublisher.emitAsync(
events.saleInvoice.onWrittenoff,
eventPayload
);
return newSaleInvoice;
});
};
/**
* Cancels the written-off sale invoice.
* @param {number} tenantId
* @param {number} saleInvoiceId
* @returns {Promise<ISaleInvoice>}
*/
public cancelWrittenoff = async (
tenantId: number,
saleInvoiceId: number
): Promise<ISaleInvoice> => {
const { SaleInvoice } = this.tenancy.models(tenantId);
// Validate the sale invoice existance.
// Retrieve the sale invoice or throw not found service error.
const saleInvoice = await SaleInvoice.query()
.findById(saleInvoiceId)
.throwIfNotFound();
// Validate the sale invoice whether already written-off.
this.validateSaleInvoiceNotWrittenoff(saleInvoice);
// Cancels the invoice written-off and removes the associated transactions.
return this.uow.withTransaction(tenantId, async (trx) => {
// Triggers `onSaleInvoiceWrittenoffCancel` event.
await this.eventPublisher.emitAsync(
events.saleInvoice.onWrittenoffCancel,
{
tenantId,
saleInvoice,
trx,
} as ISaleInvoiceWrittenOffCancelPayload
);
// Mark the sale invoice as written-off.
const newSaleInvoice = await SaleInvoice.query(trx)
.patch({
writtenoffAmount: null,
writtenoffAt: null,
})
.findById(saleInvoiceId);
// Triggers `onSaleInvoiceWrittenoffCanceled`.
await this.eventPublisher.emitAsync(
events.saleInvoice.onWrittenoffCanceled,
{
tenantId,
saleInvoice,
trx,
} as ISaleInvoiceWrittenOffCanceledPayload
);
return newSaleInvoice;
});
};
/**
* Should sale invoice not be written-off.
* @param {ISaleInvoice} saleInvoice
*/
private validateSaleInvoiceNotWrittenoff(saleInvoice: ISaleInvoice) {
if (!saleInvoice.isWrittenoff) {
throw new ServiceError(ERRORS.SALE_INVOICE_NOT_WRITTEN_OFF);
}
}
/**
* Should sale invoice already written-off.
* @param {ISaleInvoice} saleInvoice
*/
private validateSaleInvoiceAlreadyWrittenoff(saleInvoice: ISaleInvoice) {
if (saleInvoice.isWrittenoff) {
throw new ServiceError(ERRORS.SALE_INVOICE_ALREADY_WRITTEN_OFF);
}
}
}

View File

@@ -0,0 +1,104 @@
import { Service } from 'typedi';
import { ISaleInvoice, AccountNormal, ILedgerEntry, ILedger } from '@/interfaces';
import Ledger from '@/services/Accounting/Ledger';
@Service()
export class SaleInvoiceWriteoffGLEntries {
/**
* Retrieves the invoice write-off common GL entry.
* @param {ISaleInvoice} saleInvoice
*/
private getInvoiceWriteoffGLCommonEntry = (saleInvoice: ISaleInvoice) => {
return {
date: saleInvoice.invoiceDate,
currencyCode: saleInvoice.currencyCode,
exchangeRate: saleInvoice.exchangeRate,
transactionId: saleInvoice.id,
transactionType: 'InvoiceWriteOff',
transactionNumber: saleInvoice.invoiceNo,
referenceNo: saleInvoice.referenceNo,
branchId: saleInvoice.branchId,
};
};
/**
* Retrieves the invoice write-off receiveable GL entry.
* @param {number} ARAccountId
* @param {ISaleInvoice} saleInvoice
* @returns {ILedgerEntry}
*/
private getInvoiceWriteoffGLReceivableEntry = (
ARAccountId: number,
saleInvoice: ISaleInvoice
): ILedgerEntry => {
const commontEntry = this.getInvoiceWriteoffGLCommonEntry(saleInvoice);
return {
...commontEntry,
credit: saleInvoice.localWrittenoffAmount,
accountId: ARAccountId,
contactId: saleInvoice.customerId,
debit: 0,
index: 1,
indexGroup: 300,
accountNormal: saleInvoice.writtenoffExpenseAccount.accountNormal,
};
};
/**
* Retrieves the invoice write-off expense GL entry.
* @param {ISaleInvoice} saleInvoice
* @returns {ILedgerEntry}
*/
private getInvoiceWriteoffGLExpenseEntry = (
saleInvoice: ISaleInvoice
): ILedgerEntry => {
const commontEntry = this.getInvoiceWriteoffGLCommonEntry(saleInvoice);
return {
...commontEntry,
debit: saleInvoice.localWrittenoffAmount,
accountId: saleInvoice.writtenoffExpenseAccountId,
credit: 0,
index: 2,
indexGroup: 300,
accountNormal: AccountNormal.DEBIT,
};
};
/**
* Retrieves the invoice write-off GL entries.
* @param {number} ARAccountId
* @param {ISaleInvoice} saleInvoice
* @returns {ILedgerEntry[]}
*/
public getInvoiceWriteoffGLEntries = (
ARAccountId: number,
saleInvoice: ISaleInvoice
): ILedgerEntry[] => {
const creditEntry = this.getInvoiceWriteoffGLExpenseEntry(saleInvoice);
const debitEntry = this.getInvoiceWriteoffGLReceivableEntry(
ARAccountId,
saleInvoice
);
return [debitEntry, creditEntry];
};
/**
* Retrieves the invoice write-off ledger.
* @param {number} ARAccountId
* @param {ISaleInvoice} saleInvoice
* @returns {Ledger}
*/
public getInvoiceWriteoffLedger = (
ARAccountId: number,
saleInvoice: ISaleInvoice
): ILedger => {
const entries = this.getInvoiceWriteoffGLEntries(ARAccountId, saleInvoice);
return new Ledger(entries);
};
}

View File

@@ -0,0 +1,88 @@
import { Knex } from 'knex';
import LedgerStorageService from '@/services/Accounting/LedgerStorageService';
import HasTenancyService from '@/services/Tenancy/TenancyService';
import { Service, Inject } from 'typedi';
import { SaleInvoiceWriteoffGLEntries } from './SaleInvoiceWriteoffGLEntries';
@Service()
export class SaleInvoiceWriteoffGLStorage {
@Inject()
private invoiceWriteoffLedger: SaleInvoiceWriteoffGLEntries;
@Inject()
private ledgerStorage: LedgerStorageService;
@Inject()
private tenancy: HasTenancyService;
/**
* Writes the invoice write-off GL entries.
* @param {number} tenantId
* @param {number} saleInvoiceId
* @param {Knex.Transaction} trx
* @returns {Promise<void>}
*/
public writeInvoiceWriteoffEntries = async (
tenantId: number,
saleInvoiceId: number,
trx?: Knex.Transaction
) => {
const { SaleInvoice } = this.tenancy.models(tenantId);
const { accountRepository } = this.tenancy.repositories(tenantId);
// Retrieves the sale invoice.
const saleInvoice = await SaleInvoice.query(trx)
.findById(saleInvoiceId)
.withGraphFetched('writtenoffExpenseAccount');
// Find or create the A/R account.
const ARAccount = await accountRepository.findOrCreateAccountReceivable(
saleInvoice.currencyCode,
{},
trx
);
// Retrieves the invoice write-off ledger.
const ledger = this.invoiceWriteoffLedger.getInvoiceWriteoffLedger(
ARAccount.id,
saleInvoice
);
return this.ledgerStorage.commit(tenantId, ledger, trx);
};
/**
* Rewrites the invoice write-off GL entries.
* @param {number} tenantId
* @param {number} saleInvoiceId
* @param {Knex.Transactio} actiontrx
* @returns {Promise<void>}
*/
public rewriteInvoiceWriteoffEntries = async (
tenantId: number,
saleInvoiceId: number,
trx?: Knex.Transaction
) => {
await this.revertInvoiceWriteoffEntries(tenantId, saleInvoiceId, trx);
await this.writeInvoiceWriteoffEntries(tenantId, saleInvoiceId, trx);
};
/**
* Reverts the invoice write-off GL entries.
* @param {number} tenantId
* @param {number} saleInvoiceId
* @param {Knex.Transaction} trx
* @returns {Promise<void>}
*/
public revertInvoiceWriteoffEntries = async (
tenantId: number,
saleInvoiceId: number,
trx?: Knex.Transaction
) => {
await this.ledgerStorage.deleteByReference(
tenantId,
saleInvoiceId,
'InvoiceWriteOff',
trx
);
};
}

View File

@@ -0,0 +1,58 @@
import { Inject, Service } from 'typedi';
import events from '@/subscribers/events';
import {
ISaleInvoiceWriteoffCreatePayload,
ISaleInvoiceWrittenOffCanceledPayload,
} from '@/interfaces';
import { SaleInvoiceWriteoffGLStorage } from './SaleInvoiceWriteoffGLStorage';
@Service()
export default class SaleInvoiceWriteoffSubscriber {
@Inject()
writeGLStorage: SaleInvoiceWriteoffGLStorage;
/**
* Attaches events.
*/
public attach(bus) {
bus.subscribe(
events.saleInvoice.onWrittenoff,
this.writeJournalEntriesOnceWriteoffCreate
);
bus.subscribe(
events.saleInvoice.onWrittenoffCanceled,
this.revertJournalEntriesOnce
);
}
/**
* Write the written-off sale invoice journal entries.
* @param {ISaleInvoiceWriteoffCreatePayload}
*/
private writeJournalEntriesOnceWriteoffCreate = async ({
tenantId,
saleInvoice,
trx,
}: ISaleInvoiceWriteoffCreatePayload) => {
await this.writeGLStorage.writeInvoiceWriteoffEntries(
tenantId,
saleInvoice.id,
trx
);
};
/**
* Reverts the written-of sale invoice jounral entries.
* @param {ISaleInvoiceWrittenOffCanceledPayload}
*/
private revertJournalEntriesOnce = async ({
tenantId,
saleInvoice,
trx,
}: ISaleInvoiceWrittenOffCanceledPayload) => {
await this.writeGLStorage.revertInvoiceWriteoffEntries(
tenantId,
saleInvoice.id,
trx
);
};
}

View File

@@ -0,0 +1,35 @@
import { ServiceError } from '@/exceptions';
import parsePhoneNumber from 'libphonenumber-js';
import { Service } from 'typedi';
const ERRORS = {
CUSTOMER_HAS_NO_PHONE_NUMBER: 'CUSTOMER_HAS_NO_PHONE_NUMBER',
CUSTOMER_SMS_NOTIFY_PHONE_INVALID: 'CUSTOMER_SMS_NOTIFY_PHONE_INVALID',
};
@Service()
export default class SaleNotifyBySms {
/**
* Validate the customer phone number.
* @param {ICustomer} customer
*/
public validateCustomerPhoneNumber = (personalPhone: string) => {
if (!personalPhone) {
throw new ServiceError(ERRORS.CUSTOMER_HAS_NO_PHONE_NUMBER);
}
this.validateCustomerPhoneNumberLocally(personalPhone);
};
/**
*
* @param {string} personalPhone
*/
public validateCustomerPhoneNumberLocally = (personalPhone: string) => {
const phoneNumber = parsePhoneNumber(personalPhone, 'LY');
if (!phoneNumber || !phoneNumber.isValid()) {
throw new ServiceError(ERRORS.CUSTOMER_SMS_NOTIFY_PHONE_INVALID);
}
};
}

View File

@@ -0,0 +1,184 @@
import { Knex } from 'knex';
import { Service, Inject } from 'typedi';
import * as R from 'ramda';
import HasTenancyService from '@/services/Tenancy/TenancyService';
import LedgerStorageService from '@/services/Accounting/LedgerStorageService';
import {
AccountNormal,
ILedgerEntry,
ISaleReceipt,
IItemEntry,
} from '@/interfaces';
import Ledger from '@/services/Accounting/Ledger';
@Service()
export class SaleReceiptGLEntries {
@Inject()
private tenancy: HasTenancyService;
@Inject()
private ledgerStorage: LedgerStorageService;
/**
* Creates income GL entries.
* @param {number} tenantId
* @param {number} saleReceiptId
* @param {Knex.Transaction} trx
*/
public writeIncomeGLEntries = async (
tenantId: number,
saleReceiptId: number,
trx?: Knex.Transaction
): Promise<void> => {
const { SaleReceipt } = this.tenancy.models(tenantId);
const saleReceipt = await SaleReceipt.query()
.findById(saleReceiptId)
.withGraphFetched('entries.item');
// Retrieve the income entries ledger.
const incomeLedger = this.getIncomeEntriesLedger(saleReceipt);
// Commits the ledger entries to the storage.
await this.ledgerStorage.commit(tenantId, incomeLedger, trx);
};
/**
* Reverts the receipt GL entries.
* @param {number} tenantId
* @param {number} saleReceiptId
* @param {Knex.Transaction} trx
* @returns {Promise<void>}
*/
public revertReceiptGLEntries = async (
tenantId: number,
saleReceiptId: number,
trx?: Knex.Transaction
): Promise<void> => {
await this.ledgerStorage.deleteByReference(
tenantId,
saleReceiptId,
'SaleReceipt',
trx
);
};
/**
* Rewrites the receipt GL entries.
* @param {number} tenantId
* @param {number} saleReceiptId
* @param {Knex.Transaction} trx
* @returns {Promise<void>}
*/
public rewriteReceiptGLEntries = async (
tenantId: number,
saleReceiptId: number,
trx?: Knex.Transaction
): Promise<void> => {
// Reverts the receipt GL entries.
await this.revertReceiptGLEntries(tenantId, saleReceiptId, trx);
// Writes the income GL entries.
await this.writeIncomeGLEntries(tenantId, saleReceiptId, trx);
};
/**
* Retrieves the income GL ledger.
* @param {ISaleReceipt} saleReceipt
* @returns {Ledger}
*/
private getIncomeEntriesLedger = (saleReceipt: ISaleReceipt): Ledger => {
const entries = this.getIncomeGLEntries(saleReceipt);
return new Ledger(entries);
};
/**
* Retireves the income GL common entry.
* @param {ISaleReceipt} saleReceipt -
*/
private getIncomeGLCommonEntry = (saleReceipt: ISaleReceipt) => {
return {
currencyCode: saleReceipt.currencyCode,
exchangeRate: saleReceipt.exchangeRate,
transactionType: 'SaleReceipt',
transactionId: saleReceipt.id,
date: saleReceipt.receiptDate,
transactionNumber: saleReceipt.receiptNumber,
referenceNumber: saleReceipt.referenceNo,
createdAt: saleReceipt.createdAt,
credit: 0,
debit: 0,
userId: saleReceipt.userId,
branchId: saleReceipt.branchId,
};
};
/**
* Retrieve receipt income item GL entry.
* @param {ISaleReceipt} saleReceipt -
* @param {IItemEntry} entry -
* @param {number} index -
* @returns {ILedgerEntry}
*/
private getReceiptIncomeItemEntry = R.curry(
(
saleReceipt: ISaleReceipt,
entry: IItemEntry,
index: number
): ILedgerEntry => {
const commonEntry = this.getIncomeGLCommonEntry(saleReceipt);
const itemIncome = entry.amount * saleReceipt.exchangeRate;
return {
...commonEntry,
credit: itemIncome,
accountId: entry.item.sellAccountId,
note: entry.description,
index: index + 2,
itemId: entry.itemId,
itemQuantity: entry.quantity,
accountNormal: AccountNormal.CREDIT,
};
}
);
/**
* Retrieves the receipt deposit GL deposit entry.
* @param {ISaleReceipt} saleReceipt
* @returns {ILedgerEntry}
*/
private getReceiptDepositEntry = (
saleReceipt: ISaleReceipt
): ILedgerEntry => {
const commonEntry = this.getIncomeGLCommonEntry(saleReceipt);
return {
...commonEntry,
debit: saleReceipt.localAmount,
accountId: saleReceipt.depositAccountId,
index: 1,
accountNormal: AccountNormal.DEBIT,
};
};
/**
* Retrieves the income GL entries.
* @param {ISaleReceipt} saleReceipt -
* @returns {ILedgerEntry[]}
*/
private getIncomeGLEntries = (saleReceipt: ISaleReceipt): ILedgerEntry[] => {
const getItemEntry = this.getReceiptIncomeItemEntry(saleReceipt);
const creditEntries = saleReceipt.entries.map(getItemEntry);
const depositEntry = this.getReceiptDepositEntry(saleReceipt);
return [depositEntry, ...creditEntries];
};
}

View File

@@ -0,0 +1,211 @@
import { Service, Inject } from 'typedi';
import HasTenancyService from '@/services/Tenancy/TenancyService';
import events from '@/subscribers/events';
import SMSClient from '@/services/SMSClient';
import {
ISaleReceiptSmsDetails,
ISaleReceipt,
SMS_NOTIFICATION_KEY,
ICustomer,
} from '@/interfaces';
import SalesReceiptService from './SalesReceipts';
import SmsNotificationsSettingsService from '@/services/Settings/SmsNotificationsSettings';
import { formatNumber, formatSmsMessage } from 'utils';
import { TenantMetadata } from '@/system/models';
import SaleNotifyBySms from './SaleNotifyBySms';
import { ServiceError } from '@/exceptions';
import { ERRORS } from './Receipts/constants';
import { EventPublisher } from '@/lib/EventPublisher/EventPublisher';
@Service()
export default class SaleReceiptNotifyBySms {
@Inject()
receiptsService: SalesReceiptService;
@Inject()
tenancy: HasTenancyService;
@Inject()
eventPublisher: EventPublisher;
@Inject()
smsNotificationsSettings: SmsNotificationsSettingsService;
@Inject()
saleSmsNotification: SaleNotifyBySms;
/**
* Notify customer via sms about sale receipt.
* @param {number} tenantId - Tenant id.
* @param {number} saleReceiptId - Sale receipt id.
*/
public async notifyBySms(tenantId: number, saleReceiptId: number) {
const { SaleReceipt } = this.tenancy.models(tenantId);
// Retrieve the sale receipt or throw not found service error.
const saleReceipt = await SaleReceipt.query()
.findById(saleReceiptId)
.withGraphFetched('customer');
// Validates the receipt receipt existance.
this.validateSaleReceiptExistance(saleReceipt);
// Validate the customer phone number.
this.saleSmsNotification.validateCustomerPhoneNumber(
saleReceipt.customer.personalPhone
);
// Triggers `onSaleReceiptNotifySms` event.
await this.eventPublisher.emitAsync(events.saleReceipt.onNotifySms, {
tenantId,
saleReceipt,
});
// Sends the payment receive sms notification to the given customer.
await this.sendSmsNotification(tenantId, saleReceipt);
// Triggers `onSaleReceiptNotifiedSms` event.
await this.eventPublisher.emitAsync(events.saleReceipt.onNotifiedSms, {
tenantId,
saleReceipt,
});
return saleReceipt;
}
/**
* Sends SMS notification.
* @param {ISaleReceipt} invoice
* @param {ICustomer} customer
* @returns
*/
public sendSmsNotification = async (
tenantId: number,
saleReceipt: ISaleReceipt & { customer: ICustomer }
) => {
const smsClient = this.tenancy.smsClient(tenantId);
const tenantMetadata = await TenantMetadata.query().findOne({ tenantId });
// Retrieve formatted sms notification message of receipt details.
const formattedSmsMessage = this.formattedReceiptDetailsMessage(
tenantId,
saleReceipt,
tenantMetadata
);
const phoneNumber = saleReceipt.customer.personalPhone;
// Run the send sms notification message job.
return smsClient.sendMessageJob(phoneNumber, formattedSmsMessage);
};
/**
* Notify via SMS message after receipt creation.
* @param {number} tenantId
* @param {number} receiptId
* @returns {Promise<void>}
*/
public notifyViaSmsAfterCreation = async (
tenantId: number,
receiptId: number
): Promise<void> => {
const notification = this.smsNotificationsSettings.getSmsNotificationMeta(
tenantId,
SMS_NOTIFICATION_KEY.SALE_RECEIPT_DETAILS
);
// Can't continue if the sms auto-notification is not enabled.
if (!notification.isNotificationEnabled) return;
await this.notifyBySms(tenantId, receiptId);
};
/**
* Retrieve the formatted sms notification message of the given sale receipt.
* @param {number} tenantId
* @param {ISaleReceipt} saleReceipt
* @param {TenantMetadata} tenantMetadata
* @returns {string}
*/
private formattedReceiptDetailsMessage = (
tenantId: number,
saleReceipt: ISaleReceipt & { customer: ICustomer },
tenantMetadata: TenantMetadata
): string => {
const notification = this.smsNotificationsSettings.getSmsNotificationMeta(
tenantId,
SMS_NOTIFICATION_KEY.SALE_RECEIPT_DETAILS
);
return this.formatReceiptDetailsMessage(
notification.smsMessage,
saleReceipt,
tenantMetadata
);
};
/**
* Formattes the receipt sms notification message.
* @param {string} smsMessage
* @param {ISaleReceipt} saleReceipt
* @param {TenantMetadata} tenantMetadata
* @returns {string}
*/
private formatReceiptDetailsMessage = (
smsMessage: string,
saleReceipt: ISaleReceipt & { customer: ICustomer },
tenantMetadata: TenantMetadata
): string => {
// Format the receipt amount.
const formattedAmount = formatNumber(saleReceipt.amount, {
currencyCode: saleReceipt.currencyCode,
});
return formatSmsMessage(smsMessage, {
ReceiptNumber: saleReceipt.receiptNumber,
ReferenceNumber: saleReceipt.referenceNo,
CustomerName: saleReceipt.customer.displayName,
Amount: formattedAmount,
CompanyName: tenantMetadata.name,
});
};
/**
* Retrieve the SMS details of the given invoice.
* @param {number} tenantId -
* @param {number} saleReceiptId - Sale receipt id.
*/
public smsDetails = async (
tenantId: number,
saleReceiptId: number
): Promise<ISaleReceiptSmsDetails> => {
const { SaleReceipt } = this.tenancy.models(tenantId);
// Retrieve the sale receipt or throw not found service error.
const saleReceipt = await SaleReceipt.query()
.findById(saleReceiptId)
.withGraphFetched('customer');
// Validates the receipt receipt existance.
this.validateSaleReceiptExistance(saleReceipt);
// Current tenant metadata.
const tenantMetadata = await TenantMetadata.query().findOne({ tenantId });
// Retrieve the sale receipt formatted sms notification message.
const formattedSmsMessage = this.formattedReceiptDetailsMessage(
tenantId,
saleReceipt,
tenantMetadata
);
return {
customerName: saleReceipt.customer.displayName,
customerPhoneNumber: saleReceipt.customer.personalPhone,
smsMessage: formattedSmsMessage,
};
};
/**
* Validates the receipt receipt existance.
* @param {ISaleReceipt|null} saleReceipt
*/
private validateSaleReceiptExistance(saleReceipt: ISaleReceipt | null) {
if (!saleReceipt) {
throw new ServiceError(ERRORS.SALE_RECEIPT_NOT_FOUND);
}
}
}

View File

@@ -0,0 +1,718 @@
import { omit, sumBy } from 'lodash';
import { Service, Inject } from 'typedi';
import * as R from 'ramda';
import { Knex } from 'knex';
import {
IEstimatesFilter,
IFilterMeta,
IPaginationMeta,
ISaleEstimate,
ISaleEstimateApprovedEvent,
ISaleEstimateCreatedPayload,
ISaleEstimateCreatingPayload,
ISaleEstimateDeletedPayload,
ISaleEstimateDeletingPayload,
ISaleEstimateDTO,
ISaleEstimateEditedPayload,
ISaleEstimateEditingPayload,
ISaleEstimateEventDeliveredPayload,
ISaleEstimateEventDeliveringPayload,
ISaleEstimateApprovingEvent,
ISalesEstimatesService,
ICustomer,
} from '@/interfaces';
import { formatDateFields } from 'utils';
import TenancyService from '@/services/Tenancy/TenancyService';
import DynamicListingService from '@/services/DynamicListing/DynamicListService';
import ItemsEntriesService from '@/services/Items/ItemsEntriesService';
import events from '@/subscribers/events';
import { ServiceError } from '@/exceptions';
import moment from 'moment';
import AutoIncrementOrdersService from './AutoIncrementOrdersService';
import SaleEstimateTransformer from './Estimates/SaleEstimateTransformer';
import { ERRORS } from './Estimates/constants';
import UnitOfWork from '@/services/UnitOfWork';
import { EventPublisher } from '@/lib/EventPublisher/EventPublisher';
import { WarehouseTransactionDTOTransform } from '@/services/Warehouses/Integrations/WarehouseTransactionDTOTransform';
import { BranchTransactionDTOTransform } from '@/services/Branches/Integrations/BranchTransactionDTOTransform';
import { TransformerInjectable } from '@/lib/Transformer/TransformerInjectable';
/**
* Sale estimate service.
* @Service
*/
@Service('SalesEstimates')
export default class SaleEstimateService implements ISalesEstimatesService {
@Inject()
tenancy: TenancyService;
@Inject()
itemsEntriesService: ItemsEntriesService;
@Inject('logger')
logger: any;
@Inject()
dynamicListService: DynamicListingService;
@Inject()
eventPublisher: EventPublisher;
@Inject()
autoIncrementOrdersService: AutoIncrementOrdersService;
@Inject()
uow: UnitOfWork;
@Inject()
branchDTOTransform: BranchTransactionDTOTransform;
@Inject()
warehouseDTOTransform: WarehouseTransactionDTOTransform;
@Inject()
transformer: TransformerInjectable;
/**
* Retrieve sale estimate or throw service error.
* @param {number} tenantId
* @return {ISaleEstimate}
*/
async getSaleEstimateOrThrowError(tenantId: number, saleEstimateId: number) {
const { SaleEstimate } = this.tenancy.models(tenantId);
const foundSaleEstimate = await SaleEstimate.query().findById(
saleEstimateId
);
if (!foundSaleEstimate) {
throw new ServiceError(ERRORS.SALE_ESTIMATE_NOT_FOUND);
}
return foundSaleEstimate;
}
/**
* Validate the estimate number unique on the storage.
* @param {Request} req
* @param {Response} res
* @param {Function} next
*/
async validateEstimateNumberExistance(
tenantId: number,
estimateNumber: string,
notEstimateId?: number
) {
const { SaleEstimate } = this.tenancy.models(tenantId);
const foundSaleEstimate = await SaleEstimate.query()
.findOne('estimate_number', estimateNumber)
.onBuild((builder) => {
if (notEstimateId) {
builder.whereNot('id', notEstimateId);
}
});
if (foundSaleEstimate) {
throw new ServiceError(ERRORS.SALE_ESTIMATE_NUMBER_EXISTANCE);
}
}
/**
* Validates the given sale estimate not already converted to invoice.
* @param {ISaleEstimate} saleEstimate -
*/
validateEstimateNotConverted(saleEstimate: ISaleEstimate) {
if (saleEstimate.isConvertedToInvoice) {
throw new ServiceError(ERRORS.SALE_ESTIMATE_CONVERTED_TO_INVOICE);
}
}
/**
* Retrieve the next unique estimate number.
* @param {number} tenantId - Tenant id.
* @return {string}
*/
getNextEstimateNumber(tenantId: number): string {
return this.autoIncrementOrdersService.getNextTransactionNumber(
tenantId,
'sales_estimates'
);
}
/**
* Increment the estimate next number.
* @param {number} tenantId -
*/
incrementNextEstimateNumber(tenantId: number) {
return this.autoIncrementOrdersService.incrementSettingsNextNumber(
tenantId,
'sales_estimates'
);
}
/**
* Retrieve estimate number to object model.
* @param {number} tenantId
* @param {ISaleEstimateDTO} saleEstimateDTO
* @param {ISaleEstimate} oldSaleEstimate
*/
transformEstimateNumberToModel(
tenantId: number,
saleEstimateDTO: ISaleEstimateDTO,
oldSaleEstimate?: ISaleEstimate
): string {
// Retreive the next invoice number.
const autoNextNumber = this.getNextEstimateNumber(tenantId);
if (saleEstimateDTO.estimateNumber) {
return saleEstimateDTO.estimateNumber;
}
return oldSaleEstimate ? oldSaleEstimate.estimateNumber : autoNextNumber;
}
/**
* Transform create DTO object ot model object.
* @param {number} tenantId
* @param {ISaleEstimateDTO} saleEstimateDTO - Sale estimate DTO.
* @return {ISaleEstimate}
*/
async transformDTOToModel(
tenantId: number,
estimateDTO: ISaleEstimateDTO,
paymentCustomer: ICustomer,
oldSaleEstimate?: ISaleEstimate
): Promise<ISaleEstimate> {
const { ItemEntry, Contact } = this.tenancy.models(tenantId);
const amount = sumBy(estimateDTO.entries, (e) => ItemEntry.calcAmount(e));
// Retreive the next invoice number.
const autoNextNumber = this.getNextEstimateNumber(tenantId);
// Retreive the next estimate number.
const estimateNumber =
estimateDTO.estimateNumber ||
oldSaleEstimate?.estimateNumber ||
autoNextNumber;
// Validate the sale estimate number require.
this.validateEstimateNoRequire(estimateNumber);
const initialDTO = {
amount,
...formatDateFields(omit(estimateDTO, ['delivered', 'entries']), [
'estimateDate',
'expirationDate',
]),
currencyCode: paymentCustomer.currencyCode,
exchangeRate: estimateDTO.exchangeRate || 1,
...(estimateNumber ? { estimateNumber } : {}),
entries: estimateDTO.entries.map((entry) => ({
reference_type: 'SaleEstimate',
...entry,
})),
// Avoid rewrite the deliver date in edit mode when already published.
...(estimateDTO.delivered &&
!oldSaleEstimate?.deliveredAt && {
deliveredAt: moment().toMySqlDateTime(),
}),
};
return R.compose(
this.branchDTOTransform.transformDTO<ISaleEstimate>(tenantId),
this.warehouseDTOTransform.transformDTO<ISaleEstimate>(tenantId)
)(initialDTO);
}
/**
* Validate the sale estimate number require.
* @param {ISaleEstimate} saleInvoiceObj
*/
validateEstimateNoRequire(estimateNumber: string) {
if (!estimateNumber) {
throw new ServiceError(ERRORS.SALE_ESTIMATE_NO_IS_REQUIRED);
}
}
/**
* Creates a new estimate with associated entries.
* @async
* @param {number} tenantId - The tenant id.
* @param {EstimateDTO} estimate
* @return {Promise<ISaleEstimate>}
*/
public async createEstimate(
tenantId: number,
estimateDTO: ISaleEstimateDTO
): Promise<ISaleEstimate> {
const { SaleEstimate, Contact } = this.tenancy.models(tenantId);
// Retrieve the given customer or throw not found service error.
const customer = await Contact.query()
.modify('customer')
.findById(estimateDTO.customerId)
.throwIfNotFound();
// Transform DTO object ot model object.
const estimateObj = await this.transformDTOToModel(
tenantId,
estimateDTO,
customer
);
// Validate estimate number uniquiness on the storage.
await this.validateEstimateNumberExistance(
tenantId,
estimateObj.estimateNumber
);
// Validate items IDs existance on the storage.
await this.itemsEntriesService.validateItemsIdsExistance(
tenantId,
estimateDTO.entries
);
// Validate non-sellable items.
await this.itemsEntriesService.validateNonSellableEntriesItems(
tenantId,
estimateDTO.entries
);
// Creates a sale estimate transaction with associated transactions as UOW.
return this.uow.withTransaction(tenantId, async (trx: Knex.Transaction) => {
// Triggers `onSaleEstimateCreating` event.
await this.eventPublisher.emitAsync(events.saleEstimate.onCreating, {
estimateDTO,
tenantId,
trx,
} as ISaleEstimateCreatingPayload);
// Upsert the sale estimate graph to the storage.
const saleEstimate = await SaleEstimate.query(trx).upsertGraphAndFetch({
...estimateObj,
});
// Triggers `onSaleEstimateCreated` event.
await this.eventPublisher.emitAsync(events.saleEstimate.onCreated, {
tenantId,
saleEstimate,
saleEstimateId: saleEstimate.id,
saleEstimateDTO: estimateDTO,
trx,
} as ISaleEstimateCreatedPayload);
return saleEstimate;
});
}
/**
* Edit details of the given estimate with associated entries.
* @async
* @param {number} tenantId - The tenant id.
* @param {Integer} estimateId
* @param {EstimateDTO} estimate
* @return {void}
*/
public async editEstimate(
tenantId: number,
estimateId: number,
estimateDTO: ISaleEstimateDTO
): Promise<ISaleEstimate> {
const { SaleEstimate, Contact } = this.tenancy.models(tenantId);
const oldSaleEstimate = await this.getSaleEstimateOrThrowError(
tenantId,
estimateId
);
// Retrieve the given customer or throw not found service error.
const customer = await Contact.query()
.modify('customer')
.findById(estimateDTO.customerId)
.throwIfNotFound();
// Transform DTO object ot model object.
const estimateObj = await this.transformDTOToModel(
tenantId,
estimateDTO,
oldSaleEstimate,
customer
);
// Validate estimate number uniquiness on the storage.
if (estimateDTO.estimateNumber) {
await this.validateEstimateNumberExistance(
tenantId,
estimateDTO.estimateNumber,
estimateId
);
}
// Validate sale estimate entries existance.
await this.itemsEntriesService.validateEntriesIdsExistance(
tenantId,
estimateId,
'SaleEstimate',
estimateDTO.entries
);
// Validate items IDs existance on the storage.
await this.itemsEntriesService.validateItemsIdsExistance(
tenantId,
estimateDTO.entries
);
// Validate non-sellable items.
await this.itemsEntriesService.validateNonSellableEntriesItems(
tenantId,
estimateDTO.entries
);
// Edits estimate transaction with associated transactions
// under unit-of-work envirement.
return this.uow.withTransaction(tenantId, async (trx) => {
// Trigger `onSaleEstimateEditing` event.
await this.eventPublisher.emitAsync(events.saleEstimate.onEditing, {
tenantId,
oldSaleEstimate,
estimateDTO,
trx,
} as ISaleEstimateEditingPayload);
// Upsert the estimate graph to the storage.
const saleEstimate = await SaleEstimate.query(trx).upsertGraphAndFetch({
id: estimateId,
...estimateObj,
});
// Trigger `onSaleEstimateEdited` event.
await this.eventPublisher.emitAsync(events.saleEstimate.onEdited, {
tenantId,
estimateId,
saleEstimate,
oldSaleEstimate,
trx,
} as ISaleEstimateEditedPayload);
return saleEstimate;
});
}
/**
* Deletes the given estimate id with associated entries.
* @async
* @param {number} tenantId - The tenant id.
* @param {IEstimate} estimateId
* @return {void}
*/
public async deleteEstimate(
tenantId: number,
estimateId: number
): Promise<void> {
const { SaleEstimate, ItemEntry } = this.tenancy.models(tenantId);
// Retrieve sale estimate or throw not found service error.
const oldSaleEstimate = await this.getSaleEstimateOrThrowError(
tenantId,
estimateId
);
// Throw error if the sale estimate converted to sale invoice.
if (oldSaleEstimate.convertedToInvoiceId) {
throw new ServiceError(ERRORS.SALE_ESTIMATE_CONVERTED_TO_INVOICE);
}
// Deletes the estimate with associated transactions under UOW enivrement.
return this.uow.withTransaction(tenantId, async (trx: Knex.Transaction) => {
// Triggers `onSaleEstimatedDeleting` event.
await this.eventPublisher.emitAsync(events.saleEstimate.onDeleting, {
trx,
tenantId,
oldSaleEstimate,
} as ISaleEstimateDeletingPayload);
// Delete sale estimate entries.
await ItemEntry.query(trx)
.where('reference_id', estimateId)
.where('reference_type', 'SaleEstimate')
.delete();
// Delete sale estimate transaction.
await SaleEstimate.query(trx).where('id', estimateId).delete();
// Triggers `onSaleEstimatedDeleted` event.
await this.eventPublisher.emitAsync(events.saleEstimate.onDeleted, {
tenantId,
saleEstimateId: estimateId,
oldSaleEstimate,
trx,
} as ISaleEstimateDeletedPayload);
});
}
/**
* Retrieve the estimate details with associated entries.
* @async
* @param {number} tenantId - The tenant id.
* @param {Integer} estimateId
*/
public async getEstimate(tenantId: number, estimateId: number) {
const { SaleEstimate } = this.tenancy.models(tenantId);
const estimate = await SaleEstimate.query()
.findById(estimateId)
.withGraphFetched('entries.item')
.withGraphFetched('customer')
.withGraphFetched('branch');
if (!estimate) {
throw new ServiceError(ERRORS.SALE_ESTIMATE_NOT_FOUND);
}
// Transformes sale estimate model to POJO.
return this.transformer.transform(
tenantId,
estimate,
new SaleEstimateTransformer()
);
}
/**
* Parses estimates list filter DTO.
* @param filterDTO
*/
private parseListFilterDTO(filterDTO) {
return R.compose(this.dynamicListService.parseStringifiedFilter)(filterDTO);
}
/**
* Retrieves estimates filterable and paginated list.
* @param {number} tenantId -
* @param {IEstimatesFilter} estimatesFilter -
*/
public async estimatesList(
tenantId: number,
filterDTO: IEstimatesFilter
): Promise<{
salesEstimates: ISaleEstimate[];
pagination: IPaginationMeta;
filterMeta: IFilterMeta;
}> {
const { SaleEstimate } = this.tenancy.models(tenantId);
// Parses filter DTO.
const filter = this.parseListFilterDTO(filterDTO);
// Dynamic list service.
const dynamicFilter = await this.dynamicListService.dynamicList(
tenantId,
SaleEstimate,
filter
);
const { results, pagination } = await SaleEstimate.query()
.onBuild((builder) => {
builder.withGraphFetched('customer');
builder.withGraphFetched('entries');
dynamicFilter.buildQuery()(builder);
})
.pagination(filter.page - 1, filter.pageSize);
const transformedEstimates = await this.transformer.transform(
tenantId,
results,
new SaleEstimateTransformer()
);
return {
salesEstimates: transformedEstimates,
pagination,
filterMeta: dynamicFilter.getResponseMeta(),
};
}
/**
* Converts estimate to invoice.
* @param {number} tenantId -
* @param {number} estimateId -
* @return {Promise<void>}
*/
async convertEstimateToInvoice(
tenantId: number,
estimateId: number,
invoiceId: number,
trx?: Knex.Transaction
): Promise<void> {
const { SaleEstimate } = this.tenancy.models(tenantId);
// Retrieve details of the given sale estimate.
const saleEstimate = await this.getSaleEstimateOrThrowError(
tenantId,
estimateId
);
// Marks the estimate as converted from the givne invoice.
await SaleEstimate.query(trx).where('id', estimateId).patch({
convertedToInvoiceId: invoiceId,
convertedToInvoiceAt: moment().toMySqlDateTime(),
});
// Triggers `onSaleEstimateConvertedToInvoice` event.
await this.eventPublisher.emitAsync(
events.saleEstimate.onConvertedToInvoice,
{}
);
}
/**
* Unlink the converted sale estimates from the given sale invoice.
* @param {number} tenantId -
* @param {number} invoiceId -
* @return {Promise<void>}
*/
async unlinkConvertedEstimateFromInvoice(
tenantId: number,
invoiceId: number,
trx?: Knex.Transaction
): Promise<void> {
const { SaleEstimate } = this.tenancy.models(tenantId);
await SaleEstimate.query(trx)
.where({
convertedToInvoiceId: invoiceId,
})
.patch({
convertedToInvoiceId: null,
convertedToInvoiceAt: null,
});
}
/**
* Mark the sale estimate as delivered.
* @param {number} tenantId - Tenant id.
* @param {number} saleEstimateId - Sale estimate id.
*/
public async deliverSaleEstimate(
tenantId: number,
saleEstimateId: number
): Promise<void> {
const { SaleEstimate } = this.tenancy.models(tenantId);
// Retrieve details of the given sale estimate id.
const oldSaleEstimate = await this.getSaleEstimateOrThrowError(
tenantId,
saleEstimateId
);
// Throws error in case the sale estimate already published.
if (oldSaleEstimate.isDelivered) {
throw new ServiceError(ERRORS.SALE_ESTIMATE_ALREADY_DELIVERED);
}
// Updates the sale estimate transaction with assocaited transactions
// under UOW envirement.
return this.uow.withTransaction(tenantId, async (trx: Knex.Transaction) => {
// Triggers `onSaleEstimateDelivering` event.
await this.eventPublisher.emitAsync(events.saleEstimate.onDelivering, {
oldSaleEstimate,
trx,
tenantId,
} as ISaleEstimateEventDeliveringPayload);
// Record the delivered at on the storage.
const saleEstimate = await SaleEstimate.query(trx).patchAndFetchById(
saleEstimateId,
{
deliveredAt: moment().toMySqlDateTime(),
}
);
// Triggers `onSaleEstimateDelivered` event.
await this.eventPublisher.emitAsync(events.saleEstimate.onDelivered, {
tenantId,
saleEstimate,
trx,
} as ISaleEstimateEventDeliveredPayload);
});
}
/**
* Mark the sale estimate as approved from the customer.
* @param {number} tenantId
* @param {number} saleEstimateId
*/
public async approveSaleEstimate(
tenantId: number,
saleEstimateId: number
): Promise<void> {
const { SaleEstimate } = this.tenancy.models(tenantId);
// Retrieve details of the given sale estimate id.
const oldSaleEstimate = await this.getSaleEstimateOrThrowError(
tenantId,
saleEstimateId
);
// Throws error in case the sale estimate still not delivered to customer.
if (!oldSaleEstimate.isDelivered) {
throw new ServiceError(ERRORS.SALE_ESTIMATE_NOT_DELIVERED);
}
// Throws error in case the sale estimate already approved.
if (oldSaleEstimate.isApproved) {
throw new ServiceError(ERRORS.SALE_ESTIMATE_ALREADY_APPROVED);
}
// Triggers `onSaleEstimateApproving` event.
return this.uow.withTransaction(tenantId, async (trx: Knex.Transaction) => {
// Triggers `onSaleEstimateApproving` event.
await this.eventPublisher.emitAsync(events.saleEstimate.onApproving, {
trx,
tenantId,
oldSaleEstimate,
} as ISaleEstimateApprovingEvent);
// Update estimate as approved.
const saleEstimate = await SaleEstimate.query(trx)
.where('id', saleEstimateId)
.patch({
approvedAt: moment().toMySqlDateTime(),
rejectedAt: null,
});
// Triggers `onSaleEstimateApproved` event.
await this.eventPublisher.emitAsync(events.saleEstimate.onApproved, {
trx,
tenantId,
oldSaleEstimate,
saleEstimate,
} as ISaleEstimateApprovedEvent);
});
}
/**
* Mark the sale estimate as rejected from the customer.
* @param {number} tenantId
* @param {number} saleEstimateId
*/
public async rejectSaleEstimate(
tenantId: number,
saleEstimateId: number
): Promise<void> {
const { SaleEstimate } = this.tenancy.models(tenantId);
// Retrieve details of the given sale estimate id.
const saleEstimate = await this.getSaleEstimateOrThrowError(
tenantId,
saleEstimateId
);
// Throws error in case the sale estimate still not delivered to customer.
if (!saleEstimate.isDelivered) {
throw new ServiceError(ERRORS.SALE_ESTIMATE_NOT_DELIVERED);
}
// Throws error in case the sale estimate already rejected.
if (saleEstimate.isRejected) {
throw new ServiceError(ERRORS.SALE_ESTIMATE_ALREADY_REJECTED);
}
//
return this.uow.withTransaction(tenantId, async (trx: Knex.Transaction) => {
// Mark the sale estimate as reject on the storage.
await SaleEstimate.query(trx).where('id', saleEstimateId).patch({
rejectedAt: moment().toMySqlDateTime(),
approvedAt: null,
});
// Triggers `onSaleEstimateRejected` event.
await this.eventPublisher.emitAsync(events.saleEstimate.onRejected, {});
});
}
/**
* Validate the given customer has no sales estimates.
* @param {number} tenantId
* @param {number} customerId - Customer id.
*/
public async validateCustomerHasNoEstimates(
tenantId: number,
customerId: number
) {
const { SaleEstimate } = this.tenancy.models(tenantId);
const estimates = await SaleEstimate.query().where(
'customer_id',
customerId
);
if (estimates.length > 0) {
throw new ServiceError(ERRORS.CUSTOMER_HAS_SALES_ESTIMATES);
}
}
}

View File

@@ -0,0 +1,799 @@
import { Service, Inject } from 'typedi';
import { omit, sumBy } from 'lodash';
import * as R from 'ramda';
import moment from 'moment';
import { Knex } from 'knex';
import composeAsync from 'async/compose';
import {
ISaleInvoice,
ISaleInvoiceCreateDTO,
ISaleInvoiceEditDTO,
ISalesInvoicesFilter,
IPaginationMeta,
IFilterMeta,
ISystemUser,
ISalesInvoicesService,
ISaleInvoiceCreatedPayload,
ISaleInvoiceDeletePayload,
ISaleInvoiceDeletedPayload,
ISaleInvoiceEventDeliveredPayload,
ISaleInvoiceEditedPayload,
ISaleInvoiceCreatingPaylaod,
ISaleInvoiceEditingPayload,
ISaleInvoiceDeliveringPayload,
ICustomer,
ITenantUser,
} from '@/interfaces';
import events from '@/subscribers/events';
import InventoryService from '@/services/Inventory/Inventory';
import TenancyService from '@/services/Tenancy/TenancyService';
import { formatDateFields } from 'utils';
import DynamicListingService from '@/services/DynamicListing/DynamicListService';
import { ServiceError } from '@/exceptions';
import ItemsEntriesService from '@/services/Items/ItemsEntriesService';
import SaleEstimateService from '@/services/Sales/SalesEstimate';
import AutoIncrementOrdersService from './AutoIncrementOrdersService';
import { ERRORS } from './constants';
import { SaleInvoiceTransformer } from './SaleInvoiceTransformer';
import UnitOfWork from '@/services/UnitOfWork';
import { EventPublisher } from '@/lib/EventPublisher/EventPublisher';
import { BranchTransactionDTOTransform } from '@/services/Branches/Integrations/BranchTransactionDTOTransform';
import { WarehouseTransactionDTOTransform } from '@/services/Warehouses/Integrations/WarehouseTransactionDTOTransform';
import { TransformerInjectable } from '@/lib/Transformer/TransformerInjectable';
/**
* Sales invoices service
* @service
*/
@Service('SalesInvoices')
export default class SaleInvoicesService implements ISalesInvoicesService {
@Inject()
tenancy: TenancyService;
@Inject()
inventoryService: InventoryService;
@Inject()
itemsEntriesService: ItemsEntriesService;
@Inject('logger')
logger: any;
@Inject()
dynamicListService: DynamicListingService;
@Inject()
private saleEstimatesService: SaleEstimateService;
@Inject()
private autoIncrementOrdersService: AutoIncrementOrdersService;
@Inject()
private eventPublisher: EventPublisher;
@Inject()
private uow: UnitOfWork;
@Inject()
private branchDTOTransform: BranchTransactionDTOTransform;
@Inject()
private warehouseDTOTransform: WarehouseTransactionDTOTransform;
@Inject()
private transformer: TransformerInjectable;
/**
* Validate whether sale invoice number unqiue on the storage.
*/
async validateInvoiceNumberUnique(
tenantId: number,
invoiceNumber: string,
notInvoiceId?: number
) {
const { SaleInvoice } = this.tenancy.models(tenantId);
const saleInvoice = await SaleInvoice.query()
.findOne('invoice_no', invoiceNumber)
.onBuild((builder) => {
if (notInvoiceId) {
builder.whereNot('id', notInvoiceId);
}
});
if (saleInvoice) {
throw new ServiceError(ERRORS.INVOICE_NUMBER_NOT_UNIQUE);
}
}
/**
* Validate the sale invoice has no payment entries.
* @param {number} tenantId
* @param {number} saleInvoiceId
*/
async validateInvoiceHasNoPaymentEntries(
tenantId: number,
saleInvoiceId: number
) {
const { PaymentReceiveEntry } = this.tenancy.models(tenantId);
// Retrieve the sale invoice associated payment receive entries.
const entries = await PaymentReceiveEntry.query().where(
'invoice_id',
saleInvoiceId
);
if (entries.length > 0) {
throw new ServiceError(ERRORS.INVOICE_HAS_ASSOCIATED_PAYMENT_ENTRIES);
}
return entries;
}
/**
* Validate the invoice amount is bigger than payment amount before edit the invoice.
* @param {number} saleInvoiceAmount
* @param {number} paymentAmount
*/
validateInvoiceAmountBiggerPaymentAmount(
saleInvoiceAmount: number,
paymentAmount: number
) {
if (saleInvoiceAmount < paymentAmount) {
throw new ServiceError(ERRORS.INVOICE_AMOUNT_SMALLER_THAN_PAYMENT_AMOUNT);
}
}
/**
* Validate whether sale invoice exists on the storage.
* @param {Request} req
* @param {Response} res
* @param {Function} next
*/
async getInvoiceOrThrowError(tenantId: number, saleInvoiceId: number) {
const { saleInvoiceRepository } = this.tenancy.repositories(tenantId);
const saleInvoice = await saleInvoiceRepository.findOneById(
saleInvoiceId,
'entries'
);
if (!saleInvoice) {
throw new ServiceError(ERRORS.SALE_INVOICE_NOT_FOUND);
}
return saleInvoice;
}
/**
* Retrieve the next unique invoice number.
* @param {number} tenantId - Tenant id.
* @return {string}
*/
getNextInvoiceNumber(tenantId: number): string {
return this.autoIncrementOrdersService.getNextTransactionNumber(
tenantId,
'sales_invoices'
);
}
/**
* Increment the invoice next number.
* @param {number} tenantId -
*/
incrementNextInvoiceNumber(tenantId: number) {
return this.autoIncrementOrdersService.incrementSettingsNextNumber(
tenantId,
'sales_invoices'
);
}
/**
* Transformes edit DTO to model.
* @param {number} tennatId -
* @param {ICustomer} customer -
* @param {ISaleInvoiceEditDTO} saleInvoiceDTO -
* @param {ISaleInvoice} oldSaleInvoice
*/
private tranformEditDTOToModel = async (
tenantId: number,
customer: ICustomer,
saleInvoiceDTO: ISaleInvoiceEditDTO,
oldSaleInvoice: ISaleInvoice,
authorizedUser: ITenantUser
) => {
return this.transformDTOToModel(
tenantId,
customer,
saleInvoiceDTO,
authorizedUser,
oldSaleInvoice
);
};
/**
* Transformes create DTO to model.
* @param {number} tenantId -
* @param {ICustomer} customer -
* @param {ISaleInvoiceCreateDTO} saleInvoiceDTO -
*/
private transformCreateDTOToModel = async (
tenantId: number,
customer: ICustomer,
saleInvoiceDTO: ISaleInvoiceCreateDTO,
authorizedUser: ITenantUser
) => {
return this.transformDTOToModel(
tenantId,
customer,
saleInvoiceDTO,
authorizedUser
);
};
/**
* Transformes the create DTO to invoice object model.
* @param {ISaleInvoiceCreateDTO} saleInvoiceDTO - Sale invoice DTO.
* @param {ISaleInvoice} oldSaleInvoice - Old sale invoice.
* @return {ISaleInvoice}
*/
private async transformDTOToModel(
tenantId: number,
customer: ICustomer,
saleInvoiceDTO: ISaleInvoiceCreateDTO | ISaleInvoiceEditDTO,
authorizedUser: ITenantUser,
oldSaleInvoice?: ISaleInvoice
): Promise<ISaleInvoice> {
const { ItemEntry } = this.tenancy.models(tenantId);
const balance = sumBy(saleInvoiceDTO.entries, (e) =>
ItemEntry.calcAmount(e)
);
// Retreive the next invoice number.
const autoNextNumber = this.getNextInvoiceNumber(tenantId);
// Invoice number.
const invoiceNo =
saleInvoiceDTO.invoiceNo || oldSaleInvoice?.invoiceNo || autoNextNumber;
// Validate the invoice is required.
this.validateInvoiceNoRequire(invoiceNo);
const initialEntries = saleInvoiceDTO.entries.map((entry) => ({
referenceType: 'SaleInvoice',
...entry,
}));
const entries = await composeAsync(
// Sets default cost and sell account to invoice items entries.
this.itemsEntriesService.setItemsEntriesDefaultAccounts(tenantId)
)(initialEntries);
const initialDTO = {
...formatDateFields(
omit(saleInvoiceDTO, ['delivered', 'entries', 'fromEstimateId']),
['invoiceDate', 'dueDate']
),
// Avoid rewrite the deliver date in edit mode when already published.
balance,
currencyCode: customer.currencyCode,
exchangeRate: saleInvoiceDTO.exchangeRate || 1,
...(saleInvoiceDTO.delivered &&
!oldSaleInvoice?.deliveredAt && {
deliveredAt: moment().toMySqlDateTime(),
}),
// Avoid override payment amount in edit mode.
...(!oldSaleInvoice && { paymentAmount: 0 }),
...(invoiceNo ? { invoiceNo } : {}),
entries,
userId: authorizedUser.id,
} as ISaleInvoice;
return R.compose(
this.branchDTOTransform.transformDTO<ISaleInvoice>(tenantId),
this.warehouseDTOTransform.transformDTO<ISaleInvoice>(tenantId)
)(initialDTO);
}
/**
* Validate the invoice number require.
* @param {ISaleInvoice} saleInvoiceObj
*/
validateInvoiceNoRequire(invoiceNo: string) {
if (!invoiceNo) {
throw new ServiceError(ERRORS.SALE_INVOICE_NO_IS_REQUIRED);
}
}
/**
* Creates a new sale invoices and store it to the storage
* with associated to entries and journal transactions.
* @async
* @param {number} tenantId - Tenant id.
* @param {ISaleInvoice} saleInvoiceDTO - Sale invoice object DTO.
* @return {Promise<ISaleInvoice>}
*/
public createSaleInvoice = async (
tenantId: number,
saleInvoiceDTO: ISaleInvoiceCreateDTO,
authorizedUser: ITenantUser
): Promise<ISaleInvoice> => {
const { SaleInvoice, Contact } = this.tenancy.models(tenantId);
// Validate customer existance.
const customer = await Contact.query()
.modify('customer')
.findById(saleInvoiceDTO.customerId)
.throwIfNotFound();
// Validate the from estimate id exists on the storage.
if (saleInvoiceDTO.fromEstimateId) {
const fromEstimate =
await this.saleEstimatesService.getSaleEstimateOrThrowError(
tenantId,
saleInvoiceDTO.fromEstimateId
);
// Validate the sale estimate is not already converted to invoice.
this.saleEstimatesService.validateEstimateNotConverted(fromEstimate);
}
// Validate items ids existance.
await this.itemsEntriesService.validateItemsIdsExistance(
tenantId,
saleInvoiceDTO.entries
);
// Validate items should be sellable items.
await this.itemsEntriesService.validateNonSellableEntriesItems(
tenantId,
saleInvoiceDTO.entries
);
// Transform DTO object to model object.
const saleInvoiceObj = await this.transformCreateDTOToModel(
tenantId,
customer,
saleInvoiceDTO,
authorizedUser
);
// Validate sale invoice number uniquiness.
if (saleInvoiceObj.invoiceNo) {
await this.validateInvoiceNumberUnique(
tenantId,
saleInvoiceObj.invoiceNo
);
}
// Creates a new sale invoice and associated transactions under unit of work env.
return this.uow.withTransaction(tenantId, async (trx: Knex.Transaction) => {
// Triggers `onSaleInvoiceCreating` event.
await this.eventPublisher.emitAsync(events.saleInvoice.onCreating, {
saleInvoiceDTO,
tenantId,
trx,
} as ISaleInvoiceCreatingPaylaod);
// Create sale invoice graph to the storage.
const saleInvoice = await SaleInvoice.query(trx).upsertGraph(
saleInvoiceObj
);
const eventPayload: ISaleInvoiceCreatedPayload = {
tenantId,
saleInvoice,
saleInvoiceDTO,
saleInvoiceId: saleInvoice.id,
authorizedUser,
trx,
};
// Triggers the event `onSaleInvoiceCreated`.
await this.eventPublisher.emitAsync(
events.saleInvoice.onCreated,
eventPayload
);
return saleInvoice;
});
};
/**
* Edit the given sale invoice.
* @async
* @param {number} tenantId - Tenant id.
* @param {Number} saleInvoiceId - Sale invoice id.
* @param {ISaleInvoice} saleInvoice - Sale invoice DTO object.
* @return {Promise<ISaleInvoice>}
*/
public async editSaleInvoice(
tenantId: number,
saleInvoiceId: number,
saleInvoiceDTO: ISaleInvoiceEditDTO,
authorizedUser: ISystemUser
): Promise<ISaleInvoice> {
const { SaleInvoice, Contact } = this.tenancy.models(tenantId);
// Retrieve the sale invoice or throw not found service error.
const oldSaleInvoice = await this.getInvoiceOrThrowError(
tenantId,
saleInvoiceId
);
// Validate customer existance.
const customer = await Contact.query()
.findById(saleInvoiceDTO.customerId)
.modify('customer')
.throwIfNotFound();
// Validate items ids existance.
await this.itemsEntriesService.validateItemsIdsExistance(
tenantId,
saleInvoiceDTO.entries
);
// Validate non-sellable entries items.
await this.itemsEntriesService.validateNonSellableEntriesItems(
tenantId,
saleInvoiceDTO.entries
);
// Validate the items entries existance.
await this.itemsEntriesService.validateEntriesIdsExistance(
tenantId,
saleInvoiceId,
'SaleInvoice',
saleInvoiceDTO.entries
);
// Transform DTO object to model object.
const saleInvoiceObj = await this.tranformEditDTOToModel(
tenantId,
customer,
saleInvoiceDTO,
oldSaleInvoice,
authorizedUser
);
// Validate sale invoice number uniquiness.
if (saleInvoiceObj.invoiceNo) {
await this.validateInvoiceNumberUnique(
tenantId,
saleInvoiceObj.invoiceNo,
saleInvoiceId
);
}
// Validate the invoice amount is not smaller than the invoice payment amount.
this.validateInvoiceAmountBiggerPaymentAmount(
saleInvoiceObj.balance,
oldSaleInvoice.paymentAmount
);
// Edit sale invoice transaction in UOW envirment.
return this.uow.withTransaction(tenantId, async (trx: Knex.Transaction) => {
// Triggers `onSaleInvoiceEditing` event.
await this.eventPublisher.emitAsync(events.saleInvoice.onEditing, {
trx,
oldSaleInvoice,
tenantId,
saleInvoiceDTO,
} as ISaleInvoiceEditingPayload);
// Upsert the the invoice graph to the storage.
const saleInvoice: ISaleInvoice =
await SaleInvoice.query().upsertGraphAndFetch({
id: saleInvoiceId,
...saleInvoiceObj,
});
// Edit event payload.
const editEventPayload: ISaleInvoiceEditedPayload = {
tenantId,
saleInvoiceId,
saleInvoice,
saleInvoiceDTO,
oldSaleInvoice,
authorizedUser,
trx,
};
// Triggers `onSaleInvoiceEdited` event.
await this.eventPublisher.emitAsync(
events.saleInvoice.onEdited,
editEventPayload
);
return saleInvoice;
});
}
/**
* Deliver the given sale invoice.
* @param {number} tenantId - Tenant id.
* @param {number} saleInvoiceId - Sale invoice id.
* @return {Promise<void>}
*/
public async deliverSaleInvoice(
tenantId: number,
saleInvoiceId: number,
authorizedUser: ISystemUser
): Promise<void> {
const { SaleInvoice } = this.tenancy.models(tenantId);
// Retrieve details of the given sale invoice id.
const oldSaleInvoice = await this.getInvoiceOrThrowError(
tenantId,
saleInvoiceId
);
// Throws error in case the sale invoice already published.
if (oldSaleInvoice.isDelivered) {
throw new ServiceError(ERRORS.SALE_INVOICE_ALREADY_DELIVERED);
}
// Update sale invoice transaction with assocaite transactions
// under unit-of-work envirement.
return this.uow.withTransaction(tenantId, async (trx: Knex.Transaction) => {
// Triggers `onSaleInvoiceDelivering` event.
await this.eventPublisher.emitAsync(events.saleInvoice.onDelivering, {
tenantId,
oldSaleInvoice,
trx,
} as ISaleInvoiceDeliveringPayload);
// Record the delivered at on the storage.
const saleInvoice = await SaleInvoice.query(trx)
.where({ id: saleInvoiceId })
.update({ deliveredAt: moment().toMySqlDateTime() });
// Triggers `onSaleInvoiceDelivered` event.
await this.eventPublisher.emitAsync(events.saleInvoice.onDelivered, {
tenantId,
saleInvoiceId,
saleInvoice,
} as ISaleInvoiceEventDeliveredPayload);
});
}
/**
* Deletes the given sale invoice with associated entries
* and journal transactions.
* @param {number} tenantId - Tenant id.
* @param {Number} saleInvoiceId - The given sale invoice id.
* @param {ISystemUser} authorizedUser -
*/
public async deleteSaleInvoice(
tenantId: number,
saleInvoiceId: number,
authorizedUser: ISystemUser
): Promise<void> {
const { ItemEntry, SaleInvoice } = this.tenancy.models(tenantId);
// Retrieve the given sale invoice with associated entries
// or throw not found error.
const oldSaleInvoice = await this.getInvoiceOrThrowError(
tenantId,
saleInvoiceId
);
// Validate the sale invoice has no associated payment entries.
await this.validateInvoiceHasNoPaymentEntries(tenantId, saleInvoiceId);
// Validate the sale invoice has applied to credit note transaction.
await this.validateInvoiceHasNoAppliedToCredit(tenantId, saleInvoiceId);
// Deletes sale invoice transaction and associate transactions with UOW env.
return this.uow.withTransaction(tenantId, async (trx: Knex.Transaction) => {
// Triggers `onSaleInvoiceDelete` event.
await this.eventPublisher.emitAsync(events.saleInvoice.onDeleting, {
tenantId,
saleInvoice: oldSaleInvoice,
saleInvoiceId,
trx,
} as ISaleInvoiceDeletePayload);
// Unlink the converted sale estimates from the given sale invoice.
await this.saleEstimatesService.unlinkConvertedEstimateFromInvoice(
tenantId,
saleInvoiceId,
trx
);
await ItemEntry.query(trx)
.where('reference_id', saleInvoiceId)
.where('reference_type', 'SaleInvoice')
.delete();
await SaleInvoice.query(trx).findById(saleInvoiceId).delete();
// Triggers `onSaleInvoiceDeleted` event.
await this.eventPublisher.emitAsync(events.saleInvoice.onDeleted, {
tenantId,
oldSaleInvoice,
saleInvoiceId,
authorizedUser,
trx,
} as ISaleInvoiceDeletedPayload);
});
}
/**
* Records the inventory transactions of the given sale invoice in case
* the invoice has inventory entries only.
*
* @param {number} tenantId - Tenant id.
* @param {SaleInvoice} saleInvoice - Sale invoice DTO.
* @param {number} saleInvoiceId - Sale invoice id.
* @param {boolean} override - Allow to override old transactions.
* @return {Promise<void>}
*/
public async recordInventoryTranscactions(
tenantId: number,
saleInvoice: ISaleInvoice,
override?: boolean,
trx?: Knex.Transaction
): Promise<void> {
// Loads the inventory items entries of the given sale invoice.
const inventoryEntries =
await this.itemsEntriesService.filterInventoryEntries(
tenantId,
saleInvoice.entries
);
const transaction = {
transactionId: saleInvoice.id,
transactionType: 'SaleInvoice',
transactionNumber: saleInvoice.invoiceNo,
exchangeRate: saleInvoice.exchangeRate,
warehouseId: saleInvoice.warehouseId,
date: saleInvoice.invoiceDate,
direction: 'OUT',
entries: inventoryEntries,
createdAt: saleInvoice.createdAt,
};
await this.inventoryService.recordInventoryTransactionsFromItemsEntries(
tenantId,
transaction,
override,
trx
);
}
/**
* Reverting the inventory transactions once the invoice deleted.
* @param {number} tenantId - Tenant id.
* @param {number} billId - Bill id.
* @return {Promise<void>}
*/
public async revertInventoryTransactions(
tenantId: number,
saleInvoiceId: number,
trx?: Knex.Transaction
): Promise<void> {
// Delete the inventory transaction of the given sale invoice.
const { oldInventoryTransactions } =
await this.inventoryService.deleteInventoryTransactions(
tenantId,
saleInvoiceId,
'SaleInvoice',
trx
);
}
/**
* Retrieve sale invoice with associated entries.
* @param {Number} saleInvoiceId -
* @param {ISystemUser} authorizedUser -
* @return {Promise<ISaleInvoice>}
*/
public async getSaleInvoice(
tenantId: number,
saleInvoiceId: number,
authorizedUser: ISystemUser
): Promise<ISaleInvoice> {
const { SaleInvoice } = this.tenancy.models(tenantId);
const saleInvoice = await SaleInvoice.query()
.findById(saleInvoiceId)
.withGraphFetched('entries.item')
.withGraphFetched('customer')
.withGraphFetched('branch');
return this.transformer.transform(
tenantId,
saleInvoice,
new SaleInvoiceTransformer()
);
}
/**
* Parses the sale invoice list filter DTO.
* @param filterDTO
* @returns
*/
private parseListFilterDTO(filterDTO) {
return R.compose(this.dynamicListService.parseStringifiedFilter)(filterDTO);
}
/**
* Retrieve sales invoices filterable and paginated list.
* @param {Request} req
* @param {Response} res
* @param {NextFunction} next
*/
public async salesInvoicesList(
tenantId: number,
filterDTO: ISalesInvoicesFilter
): Promise<{
salesInvoices: ISaleInvoice[];
pagination: IPaginationMeta;
filterMeta: IFilterMeta;
}> {
const { SaleInvoice } = this.tenancy.models(tenantId);
// Parses stringified filter roles.
const filter = this.parseListFilterDTO(filterDTO);
// Dynamic list service.
const dynamicFilter = await this.dynamicListService.dynamicList(
tenantId,
SaleInvoice,
filter
);
const { results, pagination } = await SaleInvoice.query()
.onBuild((builder) => {
builder.withGraphFetched('entries');
builder.withGraphFetched('customer');
dynamicFilter.buildQuery()(builder);
})
.pagination(filter.page - 1, filter.pageSize);
// Retrieves the transformed sale invoices.
const salesInvoices = await this.transformer.transform(
tenantId,
results,
new SaleInvoiceTransformer()
);
return {
salesInvoices,
pagination,
filterMeta: dynamicFilter.getResponseMeta(),
};
}
/**
* Retrieve due sales invoices.
* @param {number} tenantId
* @param {number} customerId
*/
public async getPayableInvoices(
tenantId: number,
customerId?: number
): Promise<ISaleInvoice> {
const { SaleInvoice } = this.tenancy.models(tenantId);
const salesInvoices = await SaleInvoice.query().onBuild((query) => {
query.modify('dueInvoices');
query.modify('delivered');
if (customerId) {
query.where('customer_id', customerId);
}
});
return salesInvoices;
}
/**
* Validate the given customer has no sales invoices.
* @param {number} tenantId
* @param {number} customerId - Customer id.
*/
public async validateCustomerHasNoInvoices(
tenantId: number,
customerId: number
) {
const { SaleInvoice } = this.tenancy.models(tenantId);
const invoices = await SaleInvoice.query().where('customer_id', customerId);
if (invoices.length > 0) {
throw new ServiceError(ERRORS.CUSTOMER_HAS_SALES_INVOICES);
}
}
/**
* Validate the sale invoice has no applied to credit note transaction.
* @param {number} tenantId
* @param {number} invoiceId
* @returns {Promise<void>}
*/
public validateInvoiceHasNoAppliedToCredit = async (
tenantId: number,
invoiceId: number
): Promise<void> => {
const { CreditNoteAppliedInvoice } = this.tenancy.models(tenantId);
const appliedTransactions = await CreditNoteAppliedInvoice.query().where(
'invoiceId',
invoiceId
);
if (appliedTransactions.length > 0) {
throw new ServiceError(ERRORS.SALE_INVOICE_HAS_APPLIED_TO_CREDIT_NOTES);
}
};
}

View File

@@ -0,0 +1,151 @@
import { Container, Service, Inject } from 'typedi';
import { chain } from 'lodash';
import moment from 'moment';
import { Knex } from 'knex';
import InventoryService from '@/services/Inventory/Inventory';
import TenancyService from '@/services/Tenancy/TenancyService';
import {
IInventoryCostLotsGLEntriesWriteEvent,
IInventoryTransaction,
} from '@/interfaces';
import UnitOfWork from '@/services/UnitOfWork';
import { SaleInvoiceCostGLEntries } from './Invoices/SaleInvoiceCostGLEntries';
import { EventPublisher } from '@/lib/EventPublisher/EventPublisher';
import events from '@/subscribers/events';
@Service()
export default class SaleInvoicesCost {
@Inject()
private inventoryService: InventoryService;
@Inject()
private uow: UnitOfWork;
@Inject()
private costGLEntries: SaleInvoiceCostGLEntries;
@Inject()
private eventPublisher: EventPublisher;
/**
* Schedule sale invoice re-compute based on the item
* cost method and starting date.
* @param {number[]} itemIds - Inventory items ids.
* @param {Date} startingDate - Starting compute cost date.
* @return {Promise<Agenda>}
*/
async scheduleComputeCostByItemsIds(
tenantId: number,
inventoryItemsIds: number[],
startingDate: Date
): Promise<void> {
const asyncOpers: Promise<[]>[] = [];
inventoryItemsIds.forEach((inventoryItemId: number) => {
const oper: Promise<[]> = this.inventoryService.scheduleComputeItemCost(
tenantId,
inventoryItemId,
startingDate
);
asyncOpers.push(oper);
});
await Promise.all([...asyncOpers]);
}
/**
* Retrieve the max dated inventory transactions in the transactions that
* have the same item id.
* @param {IInventoryTransaction[]} inventoryTransactions
* @return {IInventoryTransaction[]}
*/
getMaxDateInventoryTransactions(
inventoryTransactions: IInventoryTransaction[]
): IInventoryTransaction[] {
return chain(inventoryTransactions)
.reduce((acc: any, transaction) => {
const compatatorDate = acc[transaction.itemId];
if (
!compatatorDate ||
moment(compatatorDate.date).isBefore(transaction.date)
) {
return {
...acc,
[transaction.itemId]: {
...transaction,
},
};
}
return acc;
}, {})
.values()
.value();
}
/**
* Computes items costs by the given inventory transaction.
* @param {number} tenantId
* @param {IInventoryTransaction[]} inventoryTransactions
*/
async computeItemsCostByInventoryTransactions(
tenantId: number,
inventoryTransactions: IInventoryTransaction[]
) {
const asyncOpers: Promise<[]>[] = [];
const reducedTransactions = this.getMaxDateInventoryTransactions(
inventoryTransactions
);
reducedTransactions.forEach((transaction) => {
const oper: Promise<[]> = this.inventoryService.scheduleComputeItemCost(
tenantId,
transaction.itemId,
transaction.date
);
asyncOpers.push(oper);
});
await Promise.all([...asyncOpers]);
}
/**
* Schedule writing journal entries.
* @param {Date} startingDate
* @return {Promise<agenda>}
*/
scheduleWriteJournalEntries(tenantId: number, startingDate?: Date) {
const agenda = Container.get('agenda');
return agenda.schedule('in 3 seconds', 'rewrite-invoices-journal-entries', {
startingDate,
tenantId,
});
}
/**
* Writes cost GL entries from the inventory cost lots.
* @param {number} tenantId -
* @param {Date} startingDate -
* @returns {Promise<void>}
*/
public writeCostLotsGLEntries = (tenantId: number, startingDate: Date) => {
return this.uow.withTransaction(tenantId, async (trx: Knex.Transaction) => {
// Triggers event `onInventoryCostLotsGLEntriesBeforeWrite`.
await this.eventPublisher.emitAsync(
events.inventory.onCostLotsGLEntriesBeforeWrite,
{
tenantId,
startingDate,
trx,
} as IInventoryCostLotsGLEntriesWriteEvent
);
// Triggers event `onInventoryCostLotsGLEntriesWrite`.
await this.eventPublisher.emitAsync(
events.inventory.onCostLotsGLEntriesWrite,
{
tenantId,
startingDate,
trx,
} as IInventoryCostLotsGLEntriesWriteEvent
);
});
};
}

View File

@@ -0,0 +1,629 @@
import { omit, sumBy } from 'lodash';
import { Service, Inject } from 'typedi';
import moment from 'moment';
import * as R from 'ramda';
import { Knex } from 'knex';
import composeAsync from 'async/compose';
import events from '@/subscribers/events';
import {
IFilterMeta,
IPaginationMeta,
ISaleReceipt,
ISaleReceiptDTO,
ISalesReceiptsService,
ISaleReceiptCreatedPayload,
ISaleReceiptEditedPayload,
ISaleReceiptEventClosedPayload,
ISaleReceiptEventDeletedPayload,
ISaleReceiptCreatingPayload,
ISaleReceiptDeletingPayload,
ISaleReceiptEditingPayload,
ISaleReceiptEventClosingPayload,
ICustomer,
} from '@/interfaces';
import JournalPosterService from '@/services/Sales/JournalPosterService';
import TenancyService from '@/services/Tenancy/TenancyService';
import { formatDateFields } from 'utils';
import DynamicListingService from '@/services/DynamicListing/DynamicListService';
import { ServiceError } from '@/exceptions';
import ItemsEntriesService from '@/services/Items/ItemsEntriesService';
import { ItemEntry } from 'models';
import InventoryService from '@/services/Inventory/Inventory';
import { ACCOUNT_PARENT_TYPE } from '@/data/AccountTypes';
import AutoIncrementOrdersService from './AutoIncrementOrdersService';
import { ERRORS } from './Receipts/constants';
import { SaleReceiptTransformer } from './Receipts/SaleReceiptTransformer';
import UnitOfWork from '@/services/UnitOfWork';
import { EventPublisher } from '@/lib/EventPublisher/EventPublisher';
import { WarehouseTransactionDTOTransform } from '@/services/Warehouses/Integrations/WarehouseTransactionDTOTransform';
import { BranchTransactionDTOTransform } from '@/services/Branches/Integrations/BranchTransactionDTOTransform';
import { TransformerInjectable } from '@/lib/Transformer/TransformerInjectable';
@Service('SalesReceipts')
export default class SalesReceiptService implements ISalesReceiptsService {
@Inject()
tenancy: TenancyService;
@Inject()
dynamicListService: DynamicListingService;
@Inject()
journalService: JournalPosterService;
@Inject()
itemsEntriesService: ItemsEntriesService;
@Inject()
inventoryService: InventoryService;
@Inject()
eventPublisher: EventPublisher;
@Inject('logger')
logger: any;
@Inject()
autoIncrementOrdersService: AutoIncrementOrdersService;
@Inject()
uow: UnitOfWork;
@Inject()
branchDTOTransform: BranchTransactionDTOTransform;
@Inject()
warehouseDTOTransform: WarehouseTransactionDTOTransform;
@Inject()
transformer: TransformerInjectable;
/**
* Validate whether sale receipt exists on the storage.
* @param {number} tenantId -
* @param {number} saleReceiptId -
*/
async getSaleReceiptOrThrowError(tenantId: number, saleReceiptId: number) {
const { SaleReceipt } = this.tenancy.models(tenantId);
const foundSaleReceipt = await SaleReceipt.query()
.findById(saleReceiptId)
.withGraphFetched('entries');
if (!foundSaleReceipt) {
throw new ServiceError(ERRORS.SALE_RECEIPT_NOT_FOUND);
}
return foundSaleReceipt;
}
/**
* Validate whether sale receipt deposit account exists on the storage.
* @param {number} tenantId - Tenant id.
* @param {number} accountId - Account id.
*/
async validateReceiptDepositAccountExistance(
tenantId: number,
accountId: number
) {
const { accountRepository } = this.tenancy.repositories(tenantId);
const depositAccount = await accountRepository.findOneById(accountId);
if (!depositAccount) {
throw new ServiceError(ERRORS.DEPOSIT_ACCOUNT_NOT_FOUND);
}
if (!depositAccount.isParentType(ACCOUNT_PARENT_TYPE.CURRENT_ASSET)) {
throw new ServiceError(ERRORS.DEPOSIT_ACCOUNT_NOT_CURRENT_ASSET);
}
}
/**
* Validate sale receipt number uniquiness on the storage.
* @param {number} tenantId -
* @param {string} receiptNumber -
* @param {number} notReceiptId -
*/
async validateReceiptNumberUnique(
tenantId: number,
receiptNumber: string,
notReceiptId?: number
) {
const { SaleReceipt } = this.tenancy.models(tenantId);
const saleReceipt = await SaleReceipt.query()
.findOne('receipt_number', receiptNumber)
.onBuild((builder) => {
if (notReceiptId) {
builder.whereNot('id', notReceiptId);
}
});
if (saleReceipt) {
throw new ServiceError(ERRORS.SALE_RECEIPT_NUMBER_NOT_UNIQUE);
}
}
/**
* Validate the sale receipt number require.
* @param {ISaleReceipt} saleReceipt
*/
validateReceiptNoRequire(receiptNumber: string) {
if (!receiptNumber) {
throw new ServiceError(ERRORS.SALE_RECEIPT_NO_IS_REQUIRED);
}
}
/**
* Retrieve the next unique receipt number.
* @param {number} tenantId - Tenant id.
* @return {string}
*/
getNextReceiptNumber(tenantId: number): string {
return this.autoIncrementOrdersService.getNextTransactionNumber(
tenantId,
'sales_receipts'
);
}
/**
* Increment the receipt next number.
* @param {number} tenantId -
*/
incrementNextReceiptNumber(tenantId: number) {
return this.autoIncrementOrdersService.incrementSettingsNextNumber(
tenantId,
'sales_receipts'
);
}
/**
* Transform create DTO object to model object.
* @param {ISaleReceiptDTO} saleReceiptDTO -
* @param {ISaleReceipt} oldSaleReceipt -
* @returns {ISaleReceipt}
*/
async transformDTOToModel(
tenantId: number,
saleReceiptDTO: ISaleReceiptDTO,
paymentCustomer: ICustomer,
oldSaleReceipt?: ISaleReceipt
): Promise<ISaleReceipt> {
const amount = sumBy(saleReceiptDTO.entries, (e) =>
ItemEntry.calcAmount(e)
);
// Retreive the next invoice number.
const autoNextNumber = this.getNextReceiptNumber(tenantId);
// Retreive the receipt number.
const receiptNumber =
saleReceiptDTO.receiptNumber ||
oldSaleReceipt?.receiptNumber ||
autoNextNumber;
// Validate receipt number require.
this.validateReceiptNoRequire(receiptNumber);
const initialEntries = saleReceiptDTO.entries.map((entry) => ({
reference_type: 'SaleReceipt',
...entry,
}));
const entries = await composeAsync(
// Sets default cost and sell account to receipt items entries.
this.itemsEntriesService.setItemsEntriesDefaultAccounts(tenantId)
)(initialEntries);
const initialDTO = {
amount,
...formatDateFields(omit(saleReceiptDTO, ['closed', 'entries']), [
'receiptDate',
]),
currencyCode: paymentCustomer.currencyCode,
exchangeRate: saleReceiptDTO.exchangeRate || 1,
receiptNumber,
// Avoid rewrite the deliver date in edit mode when already published.
...(saleReceiptDTO.closed &&
!oldSaleReceipt?.closedAt && {
closedAt: moment().toMySqlDateTime(),
}),
entries,
};
return R.compose(
this.branchDTOTransform.transformDTO<ISaleReceipt>(tenantId),
this.warehouseDTOTransform.transformDTO<ISaleReceipt>(tenantId)
)(initialDTO);
}
/**
* Creates a new sale receipt with associated entries.
* @async
* @param {ISaleReceipt} saleReceipt
* @return {Object}
*/
public async createSaleReceipt(
tenantId: number,
saleReceiptDTO: any
): Promise<ISaleReceipt> {
const { SaleReceipt, Contact } = this.tenancy.models(tenantId);
// Retireves the payment customer model.
const paymentCustomer = await Contact.query()
.modify('customer')
.findById(saleReceiptDTO.customerId)
.throwIfNotFound();
// Transform sale receipt DTO to model.
const saleReceiptObj = await this.transformDTOToModel(
tenantId,
saleReceiptDTO,
paymentCustomer
);
// Validate receipt deposit account existance and type.
await this.validateReceiptDepositAccountExistance(
tenantId,
saleReceiptDTO.depositAccountId
);
// Validate items IDs existance on the storage.
await this.itemsEntriesService.validateItemsIdsExistance(
tenantId,
saleReceiptDTO.entries
);
// Validate the sellable items.
await this.itemsEntriesService.validateNonSellableEntriesItems(
tenantId,
saleReceiptDTO.entries
);
// Validate sale receipt number uniuqiness.
if (saleReceiptDTO.receiptNumber) {
await this.validateReceiptNumberUnique(
tenantId,
saleReceiptDTO.receiptNumber
);
}
// Creates a sale receipt transaction and associated transactions under UOW env.
return this.uow.withTransaction(tenantId, async (trx: Knex.Transaction) => {
// Triggers `onSaleReceiptCreating` event.
await this.eventPublisher.emitAsync(events.saleReceipt.onCreating, {
saleReceiptDTO,
tenantId,
trx,
} as ISaleReceiptCreatingPayload);
// Inserts the sale receipt graph to the storage.
const saleReceipt = await SaleReceipt.query().upsertGraph({
...saleReceiptObj,
});
// Triggers `onSaleReceiptCreated` event.
await this.eventPublisher.emitAsync(events.saleReceipt.onCreated, {
tenantId,
saleReceipt,
saleReceiptId: saleReceipt.id,
trx,
} as ISaleReceiptCreatedPayload);
return saleReceipt;
});
}
/**
* Edit details sale receipt with associated entries.
* @param {Integer} saleReceiptId
* @param {ISaleReceipt} saleReceipt
* @return {void}
*/
public async editSaleReceipt(
tenantId: number,
saleReceiptId: number,
saleReceiptDTO: any
) {
const { SaleReceipt, Contact } = this.tenancy.models(tenantId);
// Retrieve sale receipt or throw not found service error.
const oldSaleReceipt = await this.getSaleReceiptOrThrowError(
tenantId,
saleReceiptId
);
// Retrieves the payment customer model.
const paymentCustomer = await Contact.query()
.findById(saleReceiptId)
.modify('customer')
.throwIfNotFound();
// Transform sale receipt DTO to model.
const saleReceiptObj = await this.transformDTOToModel(
tenantId,
saleReceiptDTO,
paymentCustomer,
oldSaleReceipt
);
// Validate receipt deposit account existance and type.
await this.validateReceiptDepositAccountExistance(
tenantId,
saleReceiptDTO.depositAccountId
);
// Validate items IDs existance on the storage.
await this.itemsEntriesService.validateItemsIdsExistance(
tenantId,
saleReceiptDTO.entries
);
// Validate the sellable items.
await this.itemsEntriesService.validateNonSellableEntriesItems(
tenantId,
saleReceiptDTO.entries
);
// Validate sale receipt number uniuqiness.
if (saleReceiptDTO.receiptNumber) {
await this.validateReceiptNumberUnique(
tenantId,
saleReceiptDTO.receiptNumber,
saleReceiptId
);
}
// Edits the sale receipt tranasctions with associated transactions under UOW env.
return this.uow.withTransaction(tenantId, async (trx: Knex.Transaction) => {
// Triggers `onSaleReceiptsEditing` event.
await this.eventPublisher.emitAsync(events.saleReceipt.onEditing, {
tenantId,
oldSaleReceipt,
saleReceiptDTO,
trx,
} as ISaleReceiptEditingPayload);
// Upsert the receipt graph to the storage.
const saleReceipt = await SaleReceipt.query(trx).upsertGraphAndFetch({
id: saleReceiptId,
...saleReceiptObj,
});
// Triggers `onSaleReceiptEdited` event.
await this.eventPublisher.emitAsync(events.saleReceipt.onEdited, {
tenantId,
oldSaleReceipt,
saleReceipt,
saleReceiptId,
trx,
} as ISaleReceiptEditedPayload);
return saleReceipt;
});
}
/**
* Deletes the sale receipt with associated entries.
* @param {Integer} saleReceiptId
* @return {void}
*/
public async deleteSaleReceipt(tenantId: number, saleReceiptId: number) {
const { SaleReceipt, ItemEntry } = this.tenancy.models(tenantId);
const oldSaleReceipt = await this.getSaleReceiptOrThrowError(
tenantId,
saleReceiptId
);
return this.uow.withTransaction(tenantId, async (trx: Knex.Transaction) => {
// Triggers `onSaleReceiptsDeleting` event.
await this.eventPublisher.emitAsync(events.saleReceipt.onDeleting, {
trx,
oldSaleReceipt,
tenantId,
} as ISaleReceiptDeletingPayload);
//
await ItemEntry.query(trx)
.where('reference_id', saleReceiptId)
.where('reference_type', 'SaleReceipt')
.delete();
// Delete the sale receipt transaction.
await SaleReceipt.query(trx).where('id', saleReceiptId).delete();
// Triggers `onSaleReceiptsDeleted` event.
await this.eventPublisher.emitAsync(events.saleReceipt.onDeleted, {
tenantId,
saleReceiptId,
oldSaleReceipt,
trx,
} as ISaleReceiptEventDeletedPayload);
});
}
/**
* Retrieve sale receipt with associated entries.
* @param {Integer} saleReceiptId
* @return {ISaleReceipt}
*/
async getSaleReceipt(tenantId: number, saleReceiptId: number) {
const { SaleReceipt } = this.tenancy.models(tenantId);
const saleReceipt = await SaleReceipt.query()
.findById(saleReceiptId)
.withGraphFetched('entries.item')
.withGraphFetched('customer')
.withGraphFetched('depositAccount')
.withGraphFetched('branch');
if (!saleReceipt) {
throw new ServiceError(ERRORS.SALE_RECEIPT_NOT_FOUND);
}
return this.transformer.transform(
tenantId,
saleReceipt,
new SaleReceiptTransformer()
);
}
/**
* Parses the sale receipts list filter DTO.
* @param filterDTO
*/
private parseListFilterDTO(filterDTO) {
return R.compose(this.dynamicListService.parseStringifiedFilter)(filterDTO);
}
/**
* Retrieve sales receipts paginated and filterable list.
* @param {number} tenantId
* @param {ISaleReceiptFilter} salesReceiptsFilter
*/
public async salesReceiptsList(
tenantId: number,
filterDTO: ISaleReceiptFilter
): Promise<{
data: ISaleReceipt[];
pagination: IPaginationMeta;
filterMeta: IFilterMeta;
}> {
const { SaleReceipt } = this.tenancy.models(tenantId);
// Parses the stringified filter roles.
const filter = this.parseListFilterDTO(filterDTO);
// Dynamic list service.
const dynamicFilter = await this.dynamicListService.dynamicList(
tenantId,
SaleReceipt,
filter
);
const { results, pagination } = await SaleReceipt.query()
.onBuild((builder) => {
builder.withGraphFetched('depositAccount');
builder.withGraphFetched('customer');
builder.withGraphFetched('entries');
dynamicFilter.buildQuery()(builder);
})
.pagination(filter.page - 1, filter.pageSize);
// Transformes the estimates models to POJO.
const salesEstimates = await this.transformer.transform(
tenantId,
results,
new SaleReceiptTransformer()
);
return {
data: salesEstimates,
pagination,
filterMeta: dynamicFilter.getResponseMeta(),
};
}
/**
* Mark the given sale receipt as closed.
* @param {number} tenantId
* @param {number} saleReceiptId
* @return {Promise<void>}
*/
async closeSaleReceipt(
tenantId: number,
saleReceiptId: number
): Promise<void> {
const { SaleReceipt } = this.tenancy.models(tenantId);
// Retrieve sale receipt or throw not found service error.
const oldSaleReceipt = await this.getSaleReceiptOrThrowError(
tenantId,
saleReceiptId
);
// Throw service error if the sale receipt already closed.
if (oldSaleReceipt.isClosed) {
throw new ServiceError(ERRORS.SALE_RECEIPT_IS_ALREADY_CLOSED);
}
// Updates the sale recept transaction under unit-of-work envirement.
return this.uow.withTransaction(tenantId, async (trx: Knex.Transaction) => {
// Triggers `onSaleReceiptClosing` event.
await this.eventPublisher.emitAsync(events.saleReceipt.onClosing, {
tenantId,
oldSaleReceipt,
trx,
} as ISaleReceiptEventClosingPayload);
// Mark the sale receipt as closed on the storage.
const saleReceipt = await SaleReceipt.query(trx)
.findById(saleReceiptId)
.patch({
closedAt: moment().toMySqlDateTime(),
});
// Triggers `onSaleReceiptClosed` event.
await this.eventPublisher.emitAsync(events.saleReceipt.onClosed, {
saleReceiptId,
saleReceipt,
tenantId,
trx,
} as ISaleReceiptEventClosedPayload);
});
}
/**
* Records the inventory transactions from the given bill input.
* @param {Bill} bill - Bill model object.
* @param {number} billId - Bill id.
* @return {Promise<void>}
*/
public async recordInventoryTransactions(
tenantId: number,
saleReceipt: ISaleReceipt,
override?: boolean,
trx?: Knex.Transaction
): Promise<void> {
// Loads the inventory items entries of the given sale invoice.
const inventoryEntries =
await this.itemsEntriesService.filterInventoryEntries(
tenantId,
saleReceipt.entries
);
const transaction = {
transactionId: saleReceipt.id,
transactionType: 'SaleReceipt',
transactionNumber: saleReceipt.receiptNumber,
exchangeRate: saleReceipt.exchangeRate,
date: saleReceipt.receiptDate,
direction: 'OUT',
entries: inventoryEntries,
createdAt: saleReceipt.createdAt,
warehouseId: saleReceipt.warehouseId,
};
return this.inventoryService.recordInventoryTransactionsFromItemsEntries(
tenantId,
transaction,
override,
trx
);
}
/**
* Reverts the inventory transactions of the given bill id.
* @param {number} tenantId - Tenant id.
* @param {number} billId - Bill id.
* @return {Promise<void>}
*/
public async revertInventoryTransactions(
tenantId: number,
receiptId: number,
trx?: Knex.Transaction
) {
return this.inventoryService.deleteInventoryTransactions(
tenantId,
receiptId,
'SaleReceipt',
trx
);
}
/**
* Validate the given customer has no sales receipts.
* @param {number} tenantId
* @param {number} customerId - Customer id.
*/
public async validateCustomerHasNoReceipts(
tenantId: number,
customerId: number
) {
const { SaleReceipt } = this.tenancy.models(tenantId);
const receipts = await SaleReceipt.query().where('customer_id', customerId);
if (receipts.length > 0) {
throw new ServiceError(ERRORS.CUSTOMER_HAS_SALES_INVOICES);
}
}
}

View File

@@ -0,0 +1,32 @@
import { Service, Inject } from 'typedi';
import TransactionsLockingValidator from '@/services/TransactionsLocking/TransactionsLockingGuard';
import { TransactionsLockingGroup } from '@/interfaces';
@Service()
export default class SalesTransactionsLocking {
@Inject()
transactionLockingValidator: TransactionsLockingValidator;
/**
* Validates the all and partial sales transactions locking.
* @param {number} tenantId
* @param {Date} transactionDate
*/
public validateTransactionsLocking = (
tenantId: number,
transactionDate: Date
) => {
// Validates the all transcation locking.
this.transactionLockingValidator.validateTransactionsLocking(
tenantId,
transactionDate,
TransactionsLockingGroup.All
);
// Validates the partial sales transcation locking.
// this.transactionLockingValidator.validateTransactionsLocking(
// tenantId,
// transactionDate,
// TransactionsLockingGroup.Sales
// );
};
}

View File

@@ -0,0 +1,16 @@
import { difference } from "lodash";
export default class ServiceItemsEntries {
static entriesShouldDeleted(storedEntries, entries) {
const storedEntriesIds = storedEntries.map((e) => e.id);
const entriesIds = entries.map((e) => e.id);
return difference(
storedEntriesIds,
entriesIds,
);
}
}

View File

@@ -0,0 +1,75 @@
export const ERRORS = {
INVOICE_NUMBER_NOT_UNIQUE: 'INVOICE_NUMBER_NOT_UNIQUE',
SALE_INVOICE_NOT_FOUND: 'SALE_INVOICE_NOT_FOUND',
SALE_INVOICE_ALREADY_DELIVERED: 'SALE_INVOICE_ALREADY_DELIVERED',
ENTRIES_ITEMS_IDS_NOT_EXISTS: 'ENTRIES_ITEMS_IDS_NOT_EXISTS',
NOT_SELLABLE_ITEMS: 'NOT_SELLABLE_ITEMS',
SALE_INVOICE_NO_NOT_UNIQUE: 'SALE_INVOICE_NO_NOT_UNIQUE',
INVOICE_AMOUNT_SMALLER_THAN_PAYMENT_AMOUNT:
'INVOICE_AMOUNT_SMALLER_THAN_PAYMENT_AMOUNT',
INVOICE_HAS_ASSOCIATED_PAYMENT_ENTRIES:
'INVOICE_HAS_ASSOCIATED_PAYMENT_ENTRIES',
SALE_INVOICE_NO_IS_REQUIRED: 'SALE_INVOICE_NO_IS_REQUIRED',
CUSTOMER_HAS_SALES_INVOICES: 'CUSTOMER_HAS_SALES_INVOICES',
SALE_INVOICE_HAS_APPLIED_TO_CREDIT_NOTES: 'SALE_INVOICE_HAS_APPLIED_TO_CREDIT_NOTES',
PAYMENT_ACCOUNT_CURRENCY_INVALID: 'PAYMENT_ACCOUNT_CURRENCY_INVALID'
};
export const DEFAULT_VIEW_COLUMNS = [];
export const DEFAULT_VIEWS = [
{
name: 'Draft',
slug: 'draft',
rolesLogicExpression: '1',
roles: [
{ index: 1, fieldKey: 'status', comparator: 'equals', value: 'draft' },
],
columns: DEFAULT_VIEW_COLUMNS,
},
{
name: 'Delivered',
slug: 'delivered',
rolesLogicExpression: '1',
roles: [
{
index: 1,
fieldKey: 'status',
comparator: 'equals',
value: 'delivered',
},
],
columns: DEFAULT_VIEW_COLUMNS,
},
{
name: 'Unpaid',
slug: 'unpaid',
rolesLogicExpression: '1',
roles: [
{ index: 1, fieldKey: 'status', comparator: 'equals', value: 'unpaid' },
],
columns: DEFAULT_VIEW_COLUMNS,
},
{
name: 'Partially paid',
slug: 'partially-paid',
rolesLogicExpression: '1',
roles: [
{
index: 1,
fieldKey: 'status',
comparator: 'equals',
value: 'partially-paid',
},
],
columns: DEFAULT_VIEW_COLUMNS,
},
{
name: 'Paid',
slug: 'paid',
rolesLogicExpression: '1',
roles: [
{ index: 1, fieldKey: 'status', comparator: 'equals', value: 'paid' },
],
columns: DEFAULT_VIEW_COLUMNS,
},
];