refactor: financial statements to nestjs

This commit is contained in:
Ahmed Bouhuolia
2025-01-29 00:55:53 +02:00
parent 9a5110aa38
commit 7b81d0c8e5
52 changed files with 2438 additions and 31 deletions

View File

@@ -67,6 +67,7 @@ import { PostHogModule } from '../EventsTracker/postHog.module';
import { EventTrackerModule } from '../EventsTracker/EventTracker.module';
import { MailModule } from '../Mail/Mail.module';
import { FinancialStatementsModule } from '../FinancialStatements/FinancialStatements.module';
import { StripePaymentModule } from '../StripePayment/StripePayment.module';
@Module({
imports: [
@@ -158,6 +159,7 @@ import { FinancialStatementsModule } from '../FinancialStatements/FinancialState
PostHogModule,
EventTrackerModule,
FinancialStatementsModule,
StripePaymentModule,
],
controllers: [AppController],
providers: [

View File

@@ -8,9 +8,11 @@ import { TransactionsByVendorModule } from './modules/TransactionsByVendor/Trans
import { TransactionsByCustomerModule } from './modules/TransactionsByCustomer/TransactionsByCustomer.module';
import { TransactionsByReferenceModule } from './modules/TransactionsByReference/TransactionByReference.module';
import { ARAgingSummaryModule } from './modules/ARAgingSummary/ARAgingSummary.module';
// import { APAgingSummaryModule } from './modules/APAgingSummary/APAgingSummary.module';
import { APAgingSummaryModule } from './modules/APAgingSummary/APAgingSummary.module';
import { InventoryItemDetailsModule } from './modules/InventoryItemDetails/InventoryItemDetails.module';
import { InventoryValuationSheetModule } from './modules/InventoryValuationSheet/InventoryValuationSheet.module';
import { SalesTaxLiabilityModule } from './modules/SalesTaxLiabilitySummary/SalesTaxLiability.module';
import { JournalSheetModule } from './modules/JournalSheet/JournalSheet.module';
@Module({
providers: [],
@@ -24,9 +26,11 @@ import { InventoryValuationSheetModule } from './modules/InventoryValuationSheet
TransactionsByCustomerModule,
TransactionsByReferenceModule,
ARAgingSummaryModule,
// APAgingSummaryModule,
APAgingSummaryModule,
InventoryItemDetailsModule,
InventoryValuationSheetModule,
SalesTaxLiabilityModule,
JournalSheetModule,
],
})
export class FinancialStatementsModule {}

View File

@@ -7,18 +7,22 @@ import { APAgingSummaryPdfInjectable } from './APAgingSummaryPdfInjectable';
import { APAgingSummaryRepository } from './APAgingSummaryRepository';
import { APAgingSummaryApplication } from './APAgingSummaryApplication';
import { APAgingSummaryController } from './APAgingSummary.controller';
import { APAgingSummaryMeta } from './APAgingSummaryMeta';
import { FinancialSheetCommonModule } from '../../common/FinancialSheetCommon.module';
import { TenancyContext } from '@/modules/Tenancy/TenancyContext.service';
@Module({
imports: [AgingSummaryModule],
imports: [AgingSummaryModule, FinancialSheetCommonModule],
providers: [
APAgingSummaryService,
APAgingSummaryMeta,
APAgingSummaryTableInjectable,
APAgingSummaryExportInjectable,
APAgingSummaryPdfInjectable,
APAgingSummaryRepository,
APAgingSummaryApplication
APAgingSummaryApplication,
TenancyContext,
],
controllers: [APAgingSummaryController],
})
export class APAgingSummaryModule {}

View File

@@ -1,10 +1,10 @@
import { isEmpty } from 'lodash';
import { Inject } from '@nestjs/common';
import { isEmpty, groupBy } from 'lodash';
import { Bill } from '@/modules/Bills/models/Bill';
import { Vendor } from '@/modules/Vendors/models/Vendor';
import { IAPAgingSummaryQuery } from './APAgingSummary.types';
import { Inject } from '@nestjs/common';
import { TenancyContext } from '@/modules/Tenancy/TenancyContext.service';
import { groupBy } from 'ramda';
import { IAPAgingSummaryQuery } from './APAgingSummary.types';
import { ModelObject } from 'objection';
export class APAgingSummaryRepository {
@Inject(Vendor.name)
@@ -32,19 +32,19 @@ export class APAgingSummaryRepository {
* Due bills by vendor id.
* @param {Record<string, Bill[]>} dueBillsByVendorId
*/
dueBillsByVendorId: Record<number, Bill[]>;
dueBillsByVendorId: Record<string, Bill[]>;
/**
* Overdue bills.
* @param {Bill[]} overdueBills
* @param {Bill[]} overdueBills - overdue bills.
*/
overdueBills: Bill[];
overdueBills: ModelObject<Bill>[];
/**
* Overdue bills by vendor id.
* @param {Record<string, Bill[]>} overdueBillsByVendorId
* @param {Record<string, Bill[]>} overdueBillsByVendorId - Overdue bills by vendor id.
*/
overdueBillsByVendorId: Record<number, Bill[]>;
overdueBillsByVendorId: ModelObject<Bill>[];
/**
* Vendors.

View File

@@ -1,3 +1,4 @@
import { I18nService } from 'nestjs-i18n';
import {
IAPAgingSummaryQuery,
IAPAgingSummaryTable,
@@ -8,18 +9,21 @@ import { Injectable } from '@nestjs/common';
@Injectable()
export class APAgingSummaryTableInjectable {
constructor(private readonly APAgingSummarySheet: APAgingSummaryService) {}
constructor(
private readonly APAgingSummarySheet: APAgingSummaryService,
private readonly i18nService: I18nService,
) {}
/**
* Retrieves A/P aging summary in table format.
* @param {IAPAgingSummaryQuery} query -
* @param {IAPAgingSummaryQuery} query -
* @returns {Promise<IAPAgingSummaryTable>}
*/
public async table(
query: IAPAgingSummaryQuery,
): Promise<IAPAgingSummaryTable> {
const report = await this.APAgingSummarySheet.APAgingSummary(query);
const table = new APAgingSummaryTable(report.data, query, {});
const table = new APAgingSummaryTable(report.data, query, this.i18nService);
return {
table: {

View File

@@ -0,0 +1,55 @@
import { Controller, Get, Headers, Query, Res } from '@nestjs/common';
import { IJournalReportQuery } from './JournalSheet.types';
import { Response } from 'express';
import { AcceptType } from '@/constants/accept-type';
import { JournalSheetApplication } from './JournalSheetApplication';
@Controller('/reports/journal')
export class JournalSheetController {
constructor(private readonly journalSheetApp: JournalSheetApplication) {}
@Get('/')
async journalSheet(
@Query() query: IJournalReportQuery,
@Res() res: Response,
@Headers('accept') acceptHeader: string,
) {
// Retrieves the json table format.
if (acceptHeader.includes(AcceptType.ApplicationJsonTable)) {
const table = await this.journalSheetApp.table(query);
return res.status(200).send(table);
// Retrieves the csv format.
} else if (acceptHeader.includes(AcceptType.ApplicationCsv)) {
const buffer = await this.journalSheetApp.csv(query);
res.setHeader('Content-Disposition', 'attachment; filename=output.csv');
res.setHeader('Content-Type', 'text/csv');
return res.send(buffer);
// Retrieves the xlsx format.
} else if (acceptHeader.includes(AcceptType.ApplicationXlsx)) {
const buffer = await this.journalSheetApp.xlsx(query);
res.setHeader('Content-Disposition', 'attachment; filename=output.xlsx');
res.setHeader(
'Content-Type',
'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
);
return res.send(buffer);
// Retrieves the json format.
} else if (acceptHeader.includes(AcceptType.ApplicationPdf)) {
const pdfContent = await this.journalSheetApp.pdf(query);
res.set({
'Content-Type': 'application/pdf',
'Content-Length': pdfContent.length,
});
res.send(pdfContent);
} else {
const sheet = await this.journalSheetApp.sheet(query);
return res.status(200).send(sheet);
}
}
}

View File

@@ -0,0 +1,28 @@
import { Module } from '@nestjs/common';
import { JournalSheetController } from './JournalSheet.controller';
import { JournalSheetApplication } from './JournalSheetApplication';
import { JournalSheetPdfInjectable } from './JournalSheetPdfInjectable';
import { JournalSheetExportInjectable } from './JournalSheetExport';
import { JournalSheetService } from './JournalSheetService';
import { JournalSheetTableInjectable } from './JournalSheetTableInjectable';
import { JournalSheetRepository } from './JournalSheetRepository';
import { JournalSheetMeta } from './JournalSheetMeta';
import { FinancialSheetCommonModule } from '../../common/FinancialSheetCommon.module';
import { TenancyContext } from '@/modules/Tenancy/TenancyContext.service';
import { AccountsModule } from '@/modules/Accounts/Accounts.module';
@Module({
imports: [FinancialSheetCommonModule, AccountsModule],
controllers: [JournalSheetController],
providers: [
JournalSheetApplication,
JournalSheetTableInjectable,
JournalSheetService,
JournalSheetExportInjectable,
JournalSheetPdfInjectable,
JournalSheetRepository,
JournalSheetMeta,
TenancyContext,
],
})
export class JournalSheetModule {}

View File

@@ -0,0 +1,135 @@
import { I18nService } from 'nestjs-i18n';
import { sumBy, chain, get, head } from 'lodash';
import moment from 'moment';
import {
IJournalReportEntriesGroup,
IJournalReportQuery,
IJournalSheetEntry,
IJournalTableData,
} from './JournalSheet.types';
import { FinancialSheet } from '../../common/FinancialSheet';
import { JournalSheetRepository } from './JournalSheetRepository';
import { Ledger } from '@/modules/Ledger/Ledger';
import { ILedgerEntry } from '@/modules/Ledger/types/Ledger.types';
export class JournalSheet extends FinancialSheet {
readonly ledger: Ledger;
readonly query: IJournalReportQuery;
readonly repository: JournalSheetRepository;
readonly i18n: I18nService;
/**
* Constructor method.
*/
constructor(
query: IJournalReportQuery,
repository: JournalSheetRepository,
i18n: I18nService,
) {
super();
this.query = query;
this.repository = repository;
this.numberFormat = {
...this.numberFormat,
...this.query.numberFormat,
};
this.i18n = i18n;
}
/**
* Entry mapper.
* @param {ILedgerEntry} entry
*/
entryMapper(entry: ILedgerEntry): IJournalSheetEntry {
const account = this.repository.accountsGraph.getNodeData(entry.accountId);
const contact = this.repository.contactsById.get(entry.contactId);
return {
entryId: entry.id,
index: entry.index,
note: entry.note,
contactName: get(contact, 'displayName'),
contactType: get(contact, 'contactService'),
accountName: account.name,
accountCode: account.code,
transactionNumber: entry.transactionNumber,
currencyCode: this.baseCurrency,
formattedCredit: this.formatNumber(entry.credit),
formattedDebit: this.formatNumber(entry.debit),
credit: entry.credit,
debit: entry.debit,
createdAt: entry.createdAt,
};
}
/**
* maps the journal entries.
* @param {IJournalEntry[]} entries -
*/
entriesMapper(entries: ILedgerEntry[]): Array<IJournalSheetEntry> {
return entries.map(this.entryMapper.bind(this));
}
/**
* Mapping journal entries groups.
* @param {ILedgerEntry[]} entriesGroup -
* @param {ILedgerEntry} key -
* @return {IJournalReportEntriesGroup}
*/
entriesGroupsMapper(
entriesGroup: ILedgerEntry[],
groupEntry: ILedgerEntry,
): IJournalReportEntriesGroup {
const totalCredit = sumBy(entriesGroup, 'credit');
const totalDebit = sumBy(entriesGroup, 'debit');
return {
date: moment(groupEntry.date).toDate(),
dateFormatted: moment(groupEntry.date).format('YYYY MMM DD'),
transactionType: groupEntry.transactionType,
referenceId: groupEntry.transactionId,
referenceTypeFormatted: this.i18n.t(groupEntry.transactionType),
entries: this.entriesMapper(entriesGroup),
currencyCode: this.baseCurrency,
credit: totalCredit,
debit: totalDebit,
formattedCredit: this.formatTotalNumber(totalCredit),
formattedDebit: this.formatTotalNumber(totalDebit),
};
}
/**
* Mapping the journal entries to entries groups.
* @param {IJournalEntry[]} entries
* @return {IJournalReportEntriesGroup[]}
*/
entriesWalker(entries: ILedgerEntry[]): IJournalReportEntriesGroup[] {
return chain(entries)
.groupBy((entry) => `${entry.referenceId}-${entry.referenceType}`)
.map((entriesGroup: ILedgerEntry[], key: string) => {
const headEntry = head(entriesGroup);
return this.entriesGroupsMapper(entriesGroup, headEntry);
})
.value();
}
/**
* Retrieve journal report.
* @return {IJournalReport}
*/
reportData(): IJournalTableData {
return this.entriesWalker(this.ledger.entries);
}
}

View File

@@ -0,0 +1,82 @@
import { IFinancialSheetCommonMeta } from '../../types/Report.types';
import { IFinancialTable } from '../../types/Table.types';
export interface IJournalReportQuery {
fromDate: Date | string;
toDate: Date | string;
numberFormat: {
noCents: boolean;
divideOn1000: boolean;
};
transactionType: string;
transactionId: string;
accountsIds: number | number[];
fromRange: number;
toRange: number;
}
export interface IJournalSheetEntry {
entryId: number;
index: number;
credit: number;
debit: number;
formattedDebit: string;
formattedCredit: string;
contactType: string;
contactName: string;
currencyCode: string;
accountName: string;
accountCode: string;
transactionNumber: string;
note: string;
createdAt: Date | string;
}
export interface IJournalReportEntriesGroup {
date: Date;
dateFormatted: string;
entries: IJournalSheetEntry[];
currencyCode: string;
credit: number;
debit: number;
formattedCredit: string;
formattedDebit: string;
transactionType: string;
referenceId: number;
referenceTypeFormatted: string;
}
export interface IJournalReport {
entries: IJournalReportEntriesGroup[];
}
export interface IJournalSheetMeta extends IFinancialSheetCommonMeta {
formattedDateRange: string;
formattedFromDate: string;
formattedToDate: string;
}
export interface IJournalTable extends IFinancialTable {
query: IJournalReportQuery;
meta: IJournalSheetMeta;
}
export type IJournalTableData = IJournalReportEntriesGroup[];
export interface IJournalSheet {
data: IJournalTableData;
query: IJournalReportQuery;
meta: IJournalSheetMeta;
}

View File

@@ -0,0 +1,59 @@
import { JournalSheetService } from './JournalSheetService';
import { JournalSheetTableInjectable } from './JournalSheetTableInjectable';
import { JournalSheetExportInjectable } from './JournalSheetExport';
import { JournalSheetPdfInjectable } from './JournalSheetPdfInjectable';
import { IJournalReportQuery, IJournalTable } from './JournalSheet.types';
export class JournalSheetApplication {
constructor(
private readonly journalSheetTable: JournalSheetTableInjectable,
private readonly journalSheet: JournalSheetService,
private readonly journalExport: JournalSheetExportInjectable,
private readonly journalPdf: JournalSheetPdfInjectable,
) {}
/**
* Retrieves the journal sheet.
* @param {IJournalReportQuery} query
* @returns {}
*/
public sheet(query: IJournalReportQuery) {
return this.journalSheet.journalSheet(query);
}
/**
* Retrieves the journal sheet in table format.
* @param {IJournalReportQuery} query
* @returns {Promise<IJournalTable>}
*/
public table(query: IJournalReportQuery): Promise<IJournalTable> {
return this.journalSheetTable.table(query);
}
/**
* Retrieves the journal sheet in xlsx format.
* @param {IJournalReportQuery} query
* @returns {Promise<Buffer>}
*/
public xlsx(query: IJournalReportQuery) {
return this.journalExport.xlsx(query);
}
/**
* Retrieves the journal sheet in csv format.
* @param {IJournalReportQuery} query
* @returns {Promise<string>}
*/
public csv(query: IJournalReportQuery) {
return this.journalExport.csv(query);
}
/**
* Retrieves the journal sheet in pdf format.
* @param {IJournalReportQuery} query
* @returns {Promise<Buffer>}
*/
public pdf(query: IJournalReportQuery) {
return this.journalPdf.pdf(query);
}
}

View File

@@ -0,0 +1,39 @@
import { IJournalReportQuery } from './JournalSheet.types';
import { JournalSheetTableInjectable } from './JournalSheetTableInjectable';
import { Injectable } from '@nestjs/common';
import { TableSheet } from '../../common/TableSheet';
@Injectable()
export class JournalSheetExportInjectable {
constructor(
private readonly journalSheetTable: JournalSheetTableInjectable,
) {}
/**
* Retrieves the trial balance sheet in XLSX format.
* @param {IJournalReportQuery} query - Journal report query.
* @returns {Promise<Buffer>}
*/
public async xlsx(query: IJournalReportQuery) {
const table = await this.journalSheetTable.table(query);
const tableSheet = new TableSheet(table.table);
const tableCsv = tableSheet.convertToXLSX();
return tableSheet.convertToBuffer(tableCsv, 'xlsx');
}
/**
* Retrieves the trial balance sheet in CSV format.
* @param {IJournalReportQuery} query - Journal report query.
* @returns {Promise<Buffer>}
*/
public async csv(query: IJournalReportQuery): Promise<string> {
const table = await this.journalSheetTable.table(query);
const tableSheet = new TableSheet(table.table);
const tableCsv = tableSheet.convertToCSV();
return tableCsv;
}
}

View File

@@ -0,0 +1,31 @@
import moment from 'moment';
import { Injectable } from '@nestjs/common';
import { FinancialSheetMeta } from '../../common/FinancialSheetMeta';
import { IJournalReportQuery, IJournalSheetMeta } from './JournalSheet.types';
@Injectable()
export class JournalSheetMeta {
constructor(private readonly financialSheetMeta: FinancialSheetMeta) {}
/**
* Retrieves the journal sheet meta.
* @param {IJournalReportQuery} query -
* @returns {Promise<IJournalSheetMeta>}
*/
public async meta(
query: IJournalReportQuery,
): Promise<IJournalSheetMeta> {
const common = await this.financialSheetMeta.meta();
const formattedToDate = moment(query.toDate).format('YYYY/MM/DD');
const formattedFromDate = moment(query.fromDate).format('YYYY/MM/DD');
const formattedDateRange = `From ${formattedFromDate} | To ${formattedToDate}`;
return {
...common,
formattedDateRange,
formattedFromDate,
formattedToDate,
};
}
}

View File

@@ -0,0 +1,30 @@
import { Injectable } from '@nestjs/common';
import { JournalSheetTableInjectable } from './JournalSheetTableInjectable';
import { HtmlTableCustomCss } from './constant';
import { TableSheetPdf } from '../../common/TableSheetPdf';
import { IJournalReportQuery } from './JournalSheet.types';
@Injectable()
export class JournalSheetPdfInjectable {
constructor(
private readonly journalSheetTable: JournalSheetTableInjectable,
private readonly tableSheetPdf: TableSheetPdf,
) {}
/**
* Converts the given journal sheet table to pdf.
* @param {number} tenantId - Tenant ID.
* @param {IBalanceSheetQuery} query - Balance sheet query.
* @returns {Promise<Buffer>}
*/
public async pdf(query: IJournalReportQuery): Promise<Buffer> {
const table = await this.journalSheetTable.table(query);
return this.tableSheetPdf.convertToPdf(
table.table,
table.meta.sheetName,
table.meta.formattedDateRange,
HtmlTableCustomCss,
);
}
}

View File

@@ -0,0 +1,132 @@
import { AccountTransaction } from '@/modules/Accounts/models/AccountTransaction.model';
import { AccountRepository } from '@/modules/Accounts/repositories/Account.repository';
import { Contact } from '@/modules/Contacts/models/Contact';
import { Ledger } from '@/modules/Ledger/Ledger';
import { TenancyContext } from '@/modules/Tenancy/TenancyContext.service';
import { transformToMap } from '@/utils/transform-to-key';
import { Inject } from '@nestjs/common';
import { ModelObject } from 'objection';
export class JournalSheetRepository {
@Inject(TenancyContext)
private tenancyContext: TenancyContext;
@Inject(AccountRepository)
private accountRepository: AccountRepository;
@Inject(Contact.name)
private contactModel: typeof Contact;
@Inject(AccountTransaction.name)
private accountTransaction: typeof AccountTransaction;
@Inject(AccountTransaction.name)
private accountTransactions: Array<ModelObject<AccountTransaction>>;
/**
*
*/
public filter: any;
/**
*
*/
public accountsGraph: any;
/**
*
*/
public contacts: Array<ModelObject<Contact>>
/**
* Contacts by id map.
*/
public contactsById: Map<number, ModelObject<Contact>>;
/**
*
*/
public ledger: Ledger;
public baseCurrency: string;
setFilter(filter: any) {
this.filter = filter;
}
/**
* Loads the journal sheet data.
*/
async load() {
await this.initBaseCurrency();
await this.initAccountsGraph();
await this.initAccountTransactions();
await this.initContacts();
await this.initLedger();
}
/**
* Initialize base currency.
*/
async initBaseCurrency () {
const metadata = await this.tenancyContext.getTenantMetadata();
this.baseCurrency = metadata.baseCurrency;
}
/**
* Initialize accounts graph.
*/
async initAccountsGraph() {
// Retrieve all accounts on the storage.
const accountsGraph = await this.accountRepository.getDependencyGraph();
this.accountsGraph = accountsGraph;
}
/**
* Initialize account transactions.
*/
async initAccountTransactions() {
// Retrieve all journal transactions based on the given query.
const transactions = await this.accountTransaction
.query()
.onBuild((query) => {
if (this.filter.fromRange || this.filter.toRange) {
query.modify(
'filterAmountRange',
this.filter.fromRange,
this.filter.toRange,
);
}
query.modify(
'filterDateRange',
this.filter.fromDate,
this.filter.toDate,
);
query.orderBy(['date', 'createdAt', 'indexGroup', 'index']);
if (this.filter.transactionType) {
query.where('reference_type', this.filter.transactionType);
}
if (this.filter.transactionType && this.filter.transactionId) {
query.where('reference_id', this.filter.transactionId);
}
});
this.accountTransactions = transactions;
}
/**
* Initialize contacts.
*/
async initContacts() {
const contacts = await this.contactModel.query();
this.contacts = contacts;
this.contactsById = transformToMap(contacts, 'id');
}
async initLedger(){
this.ledger = Ledger.fromTransactions(this.accountTransactions);
}
}

View File

@@ -0,0 +1,56 @@
import { Injectable } from '@nestjs/common';
import { EventEmitter2 } from '@nestjs/event-emitter';
import { I18nService } from 'nestjs-i18n';
import { JournalSheet } from './JournalSheet';
import { JournalSheetMeta } from './JournalSheetMeta';
import { getJournalSheetDefaultQuery } from './constant';
import { IJournalReportQuery, IJournalSheet } from './JournalSheet.types';
import { events } from '@/common/events/events';
import { JournalSheetRepository } from './JournalSheetRepository';
@Injectable()
export class JournalSheetService {
constructor(
private readonly journalSheetMeta: JournalSheetMeta,
private readonly journalRepository: JournalSheetRepository,
private readonly eventPublisher: EventEmitter2,
private readonly i18n: I18nService
) {}
/**
* Journal sheet.
* @param {IJournalReportQuery} query - Journal sheet query.
* @returns {Promise<IJournalSheet>}
*/
async journalSheet(query: IJournalReportQuery): Promise<IJournalSheet> {
const filter = {
...getJournalSheetDefaultQuery(),
...query,
};
this.journalRepository.setFilter(query);
await this.journalRepository.load();
// Journal report instance.
const journalSheetInstance = new JournalSheet(
filter,
this.journalRepository,
this.i18n,
);
// Retrieve journal report columns.
const journalSheetData = journalSheetInstance.reportData();
// Retrieve the journal sheet meta.
const meta = await this.journalSheetMeta.meta(filter);
// Triggers `onJournalViewed` event.
await this.eventPublisher.emitAsync(events.reports.onJournalViewed, {
query,
});
return {
data: journalSheetData,
query: filter,
meta,
};
}
}

View File

@@ -0,0 +1,240 @@
import * as R from 'ramda';
import { first } from 'lodash';
import { I18nService } from 'nestjs-i18n';
import {
IJournalReportEntriesGroup,
IJournalReportQuery,
IJournalSheetEntry,
IJournalTableData,
} from './JournalSheet.types';
import { ROW_TYPE } from './types';
import { FinancialTable } from '../../common/FinancialTable';
import { FinancialSheetStructure } from '../../common/FinancialSheetStructure';
import { FinancialSheet } from '../../common/FinancialSheet';
import {
IColumnMapperMeta,
ITableColumn,
ITableColumnAccessor,
ITableRow,
} from '../../types/Table.types';
import { tableRowMapper } from '../../utils/Table.utils';
import { ILedgerEntry } from '@/modules/Ledger/types/Ledger.types';
export class JournalSheetTable extends R.pipe(
FinancialTable,
FinancialSheetStructure,
)(FinancialSheet) {
data: IJournalTableData;
query: IJournalReportQuery;
i18n: any;
/**
* Constructor method.
* @param {IJournalTableData} data -
* @param {IJournalReportQuery} query -
* @param {I18nService} i18n - I18n service.
*/
constructor(
data: IJournalTableData,
query: IJournalReportQuery,
i18n: I18nService,
) {
super();
this.data = data;
this.query = query;
this.i18n = i18n;
}
/**
* Retrieves the common table accessors.
* @returns {ITableColumnAccessor[]}
*/
private groupColumnsAccessors = (): ITableColumnAccessor[] => {
return [
{ key: 'date', accessor: 'dateFormatted' },
{ key: 'transaction_type', accessor: 'referenceTypeFormatted' },
{ key: 'transaction_number', accessor: 'entry.transactionNumber' },
{ key: 'description', accessor: 'entry.note' },
{ key: 'account_code', accessor: 'entry.accountCode' },
{ key: 'account_name', accessor: 'entry.accountName' },
{ key: 'debit', accessor: 'entry.formattedDebit' },
{ key: 'credit', accessor: 'entry.formattedCredit' },
];
};
/**
* Retrieves the group entry accessors.
* @returns {ITableColumnAccessor[]}
*/
private entryColumnsAccessors = (): ITableColumnAccessor[] => {
return [
{ key: 'date', accessor: '_empty_' },
{ key: 'transaction_type', accessor: '_empty_' },
{ key: 'transaction_number', accessor: 'transactionNumber' },
{ key: 'description', accessor: 'note' },
{ key: 'account_code', accessor: 'accountCode' },
{ key: 'account_name', accessor: 'accountName' },
{ key: 'debit', accessor: 'formattedDebit' },
{ key: 'credit', accessor: 'formattedCredit' },
];
};
/**
* Retrieves the total entry column accessors.
* @returns {ITableColumnAccessor[]}
*/
private totalEntryColumnAccessors = (): ITableColumnAccessor[] => {
return [
{ key: 'date', accessor: '_empty_' },
{ key: 'transaction_type', accessor: '_empty_' },
{ key: 'transaction_number', accessor: '_empty_' },
{ key: 'description', accessor: '_empty_' },
{ key: 'account_code', accessor: '_empty_' },
{ key: 'account_name', accessor: '_empty_' },
{ key: 'debit', accessor: 'formattedDebit' },
{ key: 'credit', accessor: 'formattedCredit' },
];
};
/**
* Retrieves the total entry column accessors.
* @returns {IColumnMapperMeta[]}
*/
private blankEnrtyColumnAccessors = (): IColumnMapperMeta[] => {
return [
{ key: 'date', value: '' },
{ key: 'transaction_type', value: '' },
{ key: 'transaction_number', value: '' },
{ key: 'description', value: '' },
{ key: 'account_code', value: '' },
{ key: 'account_name', value: '' },
{ key: 'debit', value: '' },
{ key: 'credit', value: '' },
];
};
/**
* Retrieves the common columns.
* @returns {ITableColumn[]}
*/
private commonColumns(): ITableColumn[] {
return [
{ key: 'date', label: 'Date' },
{ key: 'transaction_type', label: 'Transaction Type' },
{ key: 'transaction_number', label: 'Num.' },
{ key: 'description', label: 'Description' },
{ key: 'account_code', label: 'Acc. Code' },
{ key: 'account_name', label: 'Account' },
{ key: 'debit', label: 'Debit' },
{ key: 'credit', label: 'Credit' },
];
}
/**
* Maps the group and first entry to table row.
* @param {IJournalReportEntriesGroup} group
* @returns {ITableRow}
*/
private firstEntryGroupMapper = (
group: IJournalReportEntriesGroup,
): ITableRow => {
const meta = {
rowTypes: [ROW_TYPE.ENTRY],
};
const computedGroup = { ...group, entry: first(group.entries) };
const columns = this.groupColumnsAccessors();
return tableRowMapper(computedGroup, columns, meta);
};
/**
* Maps the given group entry to table rows.
* @param {IJournalEntry} entry
* @returns {ITableRow}
*/
private entryMapper = (entry: IJournalSheetEntry): ITableRow => {
const columns = this.entryColumnsAccessors();
const meta = {
rowTypes: [ROW_TYPE.ENTRY],
};
return tableRowMapper(entry, columns, meta);
};
/**
* Maps the given group entries to table rows.
* @param {IJournalReportEntriesGroup} group
* @returns {ITableRow[]}
*/
private entriesMapper = (group: IJournalReportEntriesGroup): ITableRow[] => {
const entries = R.remove(0, 1, group.entries);
return R.map(this.entryMapper, entries);
};
/**
* Maps the given group entry to total table row.
* @param {IJournalReportEntriesGroup} group
* @returns {ITableRow}
*/
public totalEntryMapper = (group: IJournalReportEntriesGroup): ITableRow => {
const total = this.totalEntryColumnAccessors();
const meta = {
rowTypes: [ROW_TYPE.TOTAL],
};
return tableRowMapper(group, total, meta);
};
/**
* Retrieves the blank entry row.
* @returns {ITableRow}
*/
private blankEntryMapper = (): ITableRow => {
const columns = this.blankEnrtyColumnAccessors();
const meta = {};
return tableRowMapper({} as ILedgerEntry, columns, meta);
};
/**
* Maps the entry group to table rows.
* @param {IJournalReportEntriesGroup} group -
* @returns {ITableRow}
*/
private groupMapper = (group: IJournalReportEntriesGroup): ITableRow[] => {
const firstRow = this.firstEntryGroupMapper(group);
const lastRows = this.entriesMapper(group);
const totalRow = this.totalEntryMapper(group);
const blankRow = this.blankEntryMapper();
return [firstRow, ...lastRows, totalRow, blankRow];
};
/**
* Maps the given group entries to table rows.
* @param {IJournalReportEntriesGroup[]} entries -
* @returns {ITableRow[]}
*/
private groupsMapper = (
entries: IJournalReportEntriesGroup[],
): ITableRow[] => {
return R.compose(R.flatten, R.map(this.groupMapper))(entries);
};
/**
* Retrieves the table data rows.
* @returns {ITableRow[]}
*/
public tableData(): ITableRow[] {
return R.compose(this.groupsMapper)(this.data);
}
/**
* Retrieves the table columns.
* @returns {ITableColumn[]}
*/
public tableColumns(): ITableColumn[] {
const columns = this.commonColumns();
return R.compose(this.tableColumnsCellIndexing)(columns);
}
}

View File

@@ -0,0 +1,33 @@
import { JournalSheetService } from './JournalSheetService';
import { IJournalReportQuery, IJournalTable } from './JournalSheet.types';
import { JournalSheetTable } from './JournalSheetTable';
import { I18nService } from 'nestjs-i18n';
export class JournalSheetTableInjectable {
constructor(
private readonly journalSheetService: JournalSheetService,
private readonly i18nService: I18nService,
) {}
/**
* Retrieves the journal sheet in table format.
* @param {IJournalReportQuery} query - Journal report query.
* @returns {Promise<IJournalTable>}
*/
public async table(query: IJournalReportQuery): Promise<IJournalTable> {
const journal = await this.journalSheetService.journalSheet(query);
const table = new JournalSheetTable(
journal.data,
journal.query,
this.i18nService,
);
return {
table: {
columns: table.tableColumns(),
rows: table.tableData(),
},
query: journal.query,
meta: journal.meta,
};
}
}

View File

@@ -0,0 +1,29 @@
export const HtmlTableCustomCss = `
table tr.row-type--total td{
font-weight: 600;
}
table tr td:not(:first-child) {
border-left: 1px solid #ececec;
}
table tr:last-child td {
border-bottom: 1px solid #ececec;
}
table .cell--credit,
table .cell--debit,
table .column--credit,
table .column--debit{
text-align: right;
}
`;
export const getJournalSheetDefaultQuery = () => ({
fromDate: moment().startOf('month').format('YYYY-MM-DD'),
toDate: moment().format('YYYY-MM-DD'),
fromRange: null,
toRange: null,
accountsIds: [],
numberFormat: {
noCents: false,
divideOn1000: false,
},
});

View File

@@ -0,0 +1,5 @@
export enum ROW_TYPE {
ENTRY = 'ENTRY',
TOTAL = 'TOTAL'
};

View File

@@ -0,0 +1,25 @@
import { Module } from '@nestjs/common';
import { SalesTaxLiabiltiySummaryPdf } from './SalesTaxLiabiltiySummaryPdf';
import { SalesTaxLiabilitySummaryTableInjectable } from './SalesTaxLiabilitySummaryTableInjectable';
import { SalesTaxLiabilitySummaryExportInjectable } from './SalesTaxLiabilitySummaryExportInjectable';
import { SalesTaxLiabilitySummaryService } from './SalesTaxLiabilitySummaryService';
import { SalesTaxLiabilitySummaryApplication } from './SalesTaxLiabilitySummaryApplication';
import { SalesTaxLiabilitySummaryController } from './SalesTaxLiabilitySummary.controller';
import { FinancialSheetCommonModule } from '../../common/FinancialSheetCommon.module';
import { SalesTaxLiabilitySummaryRepository } from './SalesTaxLiabilitySummaryRepository';
import { SalesTaxLiabilitySummaryMeta } from './SalesTaxLiabilitySummaryMeta';
@Module({
imports: [FinancialSheetCommonModule],
providers: [
SalesTaxLiabiltiySummaryPdf,
SalesTaxLiabilitySummaryTableInjectable,
SalesTaxLiabilitySummaryExportInjectable,
SalesTaxLiabilitySummaryService,
SalesTaxLiabilitySummaryRepository,
SalesTaxLiabilitySummaryMeta,
SalesTaxLiabilitySummaryApplication,
],
controllers: [SalesTaxLiabilitySummaryController],
})
export class SalesTaxLiabilityModule {}

View File

@@ -0,0 +1,61 @@
import { IFinancialSheetCommonMeta } from '../../types/Report.types';
import { IFinancialTable } from '../../types/Table.types';
export interface SalesTaxLiabilitySummaryQuery {
fromDate: Date;
toDate: Date;
basis: 'cash' | 'accrual';
}
export interface SalesTaxLiabilitySummaryAmount {
amount: number;
formattedAmount: string;
currencyCode: string;
}
export interface SalesTaxLiabilitySummaryTotal {
taxableAmount: SalesTaxLiabilitySummaryAmount;
taxAmount: SalesTaxLiabilitySummaryAmount;
collectedTaxAmount: SalesTaxLiabilitySummaryAmount;
}
export interface SalesTaxLiabilitySummaryRate {
id: number;
taxName: string;
taxableAmount: SalesTaxLiabilitySummaryAmount;
taxAmount: SalesTaxLiabilitySummaryAmount;
taxPercentage: any;
collectedTaxAmount: SalesTaxLiabilitySummaryAmount;
}
export enum SalesTaxLiabilitySummaryTableRowType {
TaxRate = 'TaxRate',
Total = 'Total',
}
export interface SalesTaxLiabilitySummaryReportData {
taxRates: SalesTaxLiabilitySummaryRate[];
total: SalesTaxLiabilitySummaryTotal;
}
export type SalesTaxLiabilitySummaryPayableById = Record<
string,
{ taxRateId: number; credit: number; debit: number }
>;
export type SalesTaxLiabilitySummarySalesById = Record<
string,
{ taxRateId: number; credit: number; debit: number }
>;
export interface SalesTaxLiabilitySummaryMeta
extends IFinancialSheetCommonMeta {
formattedFromDate: string;
formattedToDate: string;
formattedDateRange: string;
}
export interface ISalesTaxLiabilitySummaryTable extends IFinancialTable {
query: SalesTaxLiabilitySummaryQuery;
meta: SalesTaxLiabilitySummaryMeta;
}

View File

@@ -0,0 +1,54 @@
import { Controller, Get, Headers, Query, Res } from '@nestjs/common';
import { SalesTaxLiabilitySummaryQuery } from './SalesTaxLiability.types';
import { AcceptType } from '@/constants/accept-type';
import { SalesTaxLiabilitySummaryApplication } from './SalesTaxLiabilitySummaryApplication';
import { Response } from 'express';
import { PublicRoute } from '@/modules/Auth/Jwt.guard';
@Controller('/reports/sales-tax-liability-summary')
@PublicRoute()
export class SalesTaxLiabilitySummaryController {
constructor(
private readonly salesTaxLiabilitySummaryApp: SalesTaxLiabilitySummaryApplication,
) {}
@Get()
public async getSalesTaxLiabilitySummary(
@Query() query: SalesTaxLiabilitySummaryQuery,
@Res() res: Response,
@Headers('accept') acceptHeader: string,
) {
// Retrieves the json table format.
if (acceptHeader.includes(AcceptType.ApplicationJsonTable)) {
const table = await this.salesTaxLiabilitySummaryApp.table(query);
return res.status(200).send(table);
// Retrieves the xlsx format.
} else if (acceptHeader.includes(AcceptType.ApplicationXlsx)) {
const buffer = await this.salesTaxLiabilitySummaryApp.xlsx(query);
res.setHeader('Content-Disposition', 'attachment; filename=output.xlsx');
res.setHeader(
'Content-Type',
'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
);
return res.send(buffer);
// Retrieves the csv format.
} else if (acceptHeader.includes(AcceptType.ApplicationCsv)) {
const buffer = await this.salesTaxLiabilitySummaryApp.csv(query);
res.setHeader('Content-Disposition', 'attachment; filename=output.csv');
res.setHeader('Content-Type', 'text/csv');
return res.send(buffer);
// Retrieves the json format.
} else if (acceptHeader.includes(AcceptType.ApplicationPdf)) {
const pdfContent = await this.salesTaxLiabilitySummaryApp.pdf(query);
res.set({
'Content-Type': 'application/pdf',
'Content-Length': pdfContent.length,
});
return res.status(200).send(pdfContent);
} else {
const sheet = await this.salesTaxLiabilitySummaryApp.sheet(query);
return res.status(200).send(sheet);
}
}
}

View File

@@ -0,0 +1,125 @@
import * as R from 'ramda';
import { isEmpty, sumBy } from 'lodash';
import {
SalesTaxLiabilitySummaryQuery,
SalesTaxLiabilitySummaryRate,
SalesTaxLiabilitySummaryReportData,
SalesTaxLiabilitySummaryTotal,
} from './SalesTaxLiability.types';
import { FinancialSheet } from '../../common/FinancialSheet';
import { ModelObject } from 'objection';
import { TaxRateModel } from '@/modules/TaxRates/models/TaxRate.model';
import { SalesTaxLiabilitySummaryRepository } from './SalesTaxLiabilitySummaryRepository';
export class SalesTaxLiabilitySummary extends FinancialSheet {
private query: SalesTaxLiabilitySummaryQuery;
private repository: SalesTaxLiabilitySummaryRepository;
/**
* Sales tax liability summary constructor.
* @param {SalesTaxLiabilitySummaryQuery} query
* @param {ITaxRate[]} taxRates
* @param {SalesTaxLiabilitySummaryPayableById} payableTaxesById
* @param {SalesTaxLiabilitySummarySalesById} salesTaxesById
*/
constructor(
query: SalesTaxLiabilitySummaryQuery,
repository: SalesTaxLiabilitySummaryRepository,
) {
super();
this.query = query;
this.repository = repository;
}
/**
* Retrieves the tax rate liability node.
* @param {ITaxRate} taxRate
* @returns {SalesTaxLiabilitySummaryRate}
*/
private taxRateLiability = (
taxRate: ModelObject<TaxRateModel>,
): SalesTaxLiabilitySummaryRate => {
const payableTax = this.repository.taxesPayableByTaxRateId[taxRate.id];
const salesTax = this.repository.accountTransactionsByTaxRateId[taxRate.id];
const payableTaxAmount = payableTax
? payableTax.credit - payableTax.debit
: 0;
const salesTaxAmount = salesTax ? salesTax.credit - salesTax.debit : 0;
// Calculates the tax percentage.
const taxPercentage = R.compose(
R.unless(R.equals(0), R.divide(R.__, salesTaxAmount)),
)(payableTaxAmount);
// Calculates the payable tax amount.
const collectedTaxAmount = payableTax ? payableTax.debit : 0;
return {
id: taxRate.id,
taxName: `${taxRate.name} (${taxRate.rate}%)`,
taxableAmount: this.getAmountMeta(salesTaxAmount),
taxAmount: this.getAmountMeta(payableTaxAmount),
taxPercentage: this.getPercentageTotalAmountMeta(taxPercentage),
collectedTaxAmount: this.getAmountMeta(collectedTaxAmount),
};
};
/**
* Filters the non-transactions tax rates.
* @param {SalesTaxLiabilitySummaryRate[]} nodes
* @returns {SalesTaxLiabilitySummaryRate[]}
*/
private filterNonTransactionsTaxRates = (
nodes: SalesTaxLiabilitySummaryRate[],
): SalesTaxLiabilitySummaryRate[] => {
return nodes.filter((node) => {
const salesTrxs = this.repository.accountTransactionsByTaxRateId[node.id];
const payableTrxs = this.repository.taxesPayableByTaxRateId[node.id];
return !isEmpty(salesTrxs) || !isEmpty(payableTrxs);
});
};
/**
* Retrieves the tax rates liability nodes.
* @returns {SalesTaxLiabilitySummaryRate[]}
*/
private taxRatesLiability = (): SalesTaxLiabilitySummaryRate[] => {
return R.compose(
this.filterNonTransactionsTaxRates,
R.map(this.taxRateLiability),
)(this.repository.taxRates);
};
/**
* Retrieves the tax rates total node.
* @param {SalesTaxLiabilitySummaryRate[]} nodes
* @returns {SalesTaxLiabilitySummaryTotal}
*/
private taxRatesTotal = (
nodes: SalesTaxLiabilitySummaryRate[],
): SalesTaxLiabilitySummaryTotal => {
const taxableAmount = sumBy(nodes, 'taxableAmount.amount');
const taxAmount = sumBy(nodes, 'taxAmount.amount');
const collectedTaxAmount = sumBy(nodes, 'collectedTaxAmount.amount');
return {
taxableAmount: this.getTotalAmountMeta(taxableAmount),
taxAmount: this.getTotalAmountMeta(taxAmount),
collectedTaxAmount: this.getTotalAmountMeta(collectedTaxAmount),
};
};
/**
* Retrieves the report data.
* @returns {SalesTaxLiabilitySummaryReportData}
*/
public reportData = (): SalesTaxLiabilitySummaryReportData => {
const taxRates = this.taxRatesLiability();
const total = this.taxRatesTotal(taxRates);
return { taxRates, total };
};
}

View File

@@ -0,0 +1,62 @@
import { SalesTaxLiabilitySummaryTableInjectable } from './SalesTaxLiabilitySummaryTableInjectable';
import { SalesTaxLiabilitySummaryExportInjectable } from './SalesTaxLiabilitySummaryExportInjectable';
import { SalesTaxLiabilitySummaryService } from './SalesTaxLiabilitySummaryService';
import { SalesTaxLiabiltiySummaryPdf } from './SalesTaxLiabiltiySummaryPdf';
import { Injectable } from '@nestjs/common';
import { SalesTaxLiabilitySummaryQuery } from './SalesTaxLiability.types';
@Injectable()
export class SalesTaxLiabilitySummaryApplication {
constructor(
private readonly salesTaxLiabilitySheet: SalesTaxLiabilitySummaryService,
private readonly salesTaxLiabilityExport: SalesTaxLiabilitySummaryExportInjectable,
private readonly salesTaxLiabilityTable: SalesTaxLiabilitySummaryTableInjectable,
private readonly salesTaxLiabiltiyPdf: SalesTaxLiabiltiySummaryPdf,
) {}
/**
* Retrieves the sales tax liability summary in json format.
* @param {SalesTaxLiabilitySummaryQuery} query -
* @returns {Promise<Buffer>}
*/
public sheet(query: SalesTaxLiabilitySummaryQuery) {
return this.salesTaxLiabilitySheet.salesTaxLiability(query);
}
/**
* Retrieves the sales tax liability summary in table format.
* @param {SalesTaxLiabilitySummaryQuery} query
* @return {Promise<Buffer>}
*/
public table(query: SalesTaxLiabilitySummaryQuery) {
return this.salesTaxLiabilityTable.table(query);
}
/**
* Retrieves the sales tax liability summary in XLSX format.
* @param {SalesTaxLiabilitySummaryQuery} query
* @returns {Promise<Buffer>}
*/
public xlsx(query: SalesTaxLiabilitySummaryQuery): Promise<Buffer> {
return this.salesTaxLiabilityExport.xlsx(query);
}
/**
* Retrieves the sales tax liability summary in CSV format.
* @param {SalesTaxLiabilitySummaryQuery} query
* @returns {Promise<string>}
*/
public csv(query: SalesTaxLiabilitySummaryQuery): Promise<string> {
return this.salesTaxLiabilityExport.csv(query);
}
/**
* Retrieves the sales tax liability summary in PDF format.
* @param {SalesTaxLiabilitySummaryQuery} query
* @returns {Promise<Buffer>}
*/
public pdf(query: SalesTaxLiabilitySummaryQuery): Promise<Buffer> {
return this.salesTaxLiabiltiyPdf.pdf(query);
}
}

View File

@@ -0,0 +1,39 @@
import { TableSheet } from '../../common/TableSheet';
import { SalesTaxLiabilitySummaryQuery } from './SalesTaxLiability.types';
import { SalesTaxLiabilitySummaryTableInjectable } from './SalesTaxLiabilitySummaryTableInjectable';
import { Injectable } from '@nestjs/common';
@Injectable()
export class SalesTaxLiabilitySummaryExportInjectable {
constructor(
private readonly salesTaxLiabilityTable: SalesTaxLiabilitySummaryTableInjectable,
) {}
/**
* Retrieves the cashflow sheet in XLSX format.
* @param {SalesTaxLiabilitySummaryQuery} query
* @returns {Promise<Buffer>}
*/
public async xlsx(query: SalesTaxLiabilitySummaryQuery): Promise<Buffer> {
const table = await this.salesTaxLiabilityTable.table(query);
const tableSheet = new TableSheet(table.table);
const tableCsv = tableSheet.convertToXLSX();
return tableSheet.convertToBuffer(tableCsv, 'xlsx');
}
/**
* Retrieves the cashflow sheet in CSV format.
* @param {SalesTaxLiabilitySummaryQuery} query
* @returns {Promise<string>}
*/
public async csv(query: SalesTaxLiabilitySummaryQuery): Promise<string> {
const table = await this.salesTaxLiabilityTable.table(query);
const tableSheet = new TableSheet(table.table);
const tableCsv = tableSheet.convertToCSV();
return tableCsv;
}
}

View File

@@ -0,0 +1,31 @@
import * as moment from 'moment';
import { Injectable } from '@nestjs/common';
import { FinancialSheetMeta } from '../../common/FinancialSheetMeta';
import { SalesTaxLiabilitySummaryQuery } from './SalesTaxLiability.types';
@Injectable()
export class SalesTaxLiabilitySummaryMeta {
constructor(private readonly financialSheetMeta: FinancialSheetMeta) {}
/**
* Retrieves the report meta.
* @param {number} tenantId
* @param {SalesTaxLiabilitySummaryQuery} filter
*/
public async meta(query: SalesTaxLiabilitySummaryQuery) {
const commonMeta = await this.financialSheetMeta.meta();
const formattedToDate = moment(query.toDate).format('YYYY/MM/DD');
const formattedFromDate = moment(query.fromDate).format('YYYY/MM/DD');
const formattedDateRange = `From ${formattedFromDate} | To ${formattedToDate}`;
const sheetName = 'Sales Tax Liability Summary';
return {
...commonMeta,
sheetName,
formattedFromDate,
formattedToDate,
formattedDateRange,
};
}
}

View File

@@ -0,0 +1,132 @@
import { ACCOUNT_TYPE } from '@/constants/accounts';
import {
SalesTaxLiabilitySummaryPayableById,
SalesTaxLiabilitySummarySalesById,
} from './SalesTaxLiability.types';
import { Inject, Injectable, Scope } from '@nestjs/common';
import { keyBy } from 'lodash';
import { TaxRateModel } from '@/modules/TaxRates/models/TaxRate.model';
import { AccountTransaction } from '@/modules/Accounts/models/AccountTransaction.model';
import { Account } from '@/modules/Accounts/models/Account.model';
import { ModelObject } from 'objection';
@Injectable({ scope: Scope.TRANSIENT })
export class SalesTaxLiabilitySummaryRepository {
@Inject(TaxRateModel.name)
private readonly taxRateModel: typeof TaxRateModel;
@Inject(AccountTransaction.name)
private readonly accountTransactionModel: typeof AccountTransaction;
@Inject(Account.name)
private readonly accountModel: typeof Account;
/**
* @param {SalesTaxLiabilitySummarySalesById}
*/
accountTransactionsByTaxRateId: SalesTaxLiabilitySummarySalesById;
/**
* @param {SalesTaxLiabilitySummaryPayableById}
*/
taxesPayableByTaxRateId: SalesTaxLiabilitySummaryPayableById;
/**
* @param {Array<ModelObject<TaxRateModel>>}
*/
taxRates: Array<ModelObject<TaxRateModel>>;
/**
* Load data.
*/
async load() {
await this.initTaxRates();
await this.initTaxesPayableByTaxRateId();
await this.initAccountTransactionsByTaxRateId();
}
/**
* Initialize tax rates.
*/
async initTaxRates() {
const taxRates = await this.getTaxRates();
this.taxRates = taxRates;
}
/**
* Initialize account transactions by tax rate id.
*/
async initAccountTransactionsByTaxRateId() {
const transactionsByTaxRateId = await this.taxesSalesSumGroupedByRateId();
this.accountTransactionsByTaxRateId = transactionsByTaxRateId;
}
/**
* Initialize taxes payable by tax rate id.
*/
async initTaxesPayableByTaxRateId() {
const payableTaxes = await this.getTaxesPayableSumGroupedByRateId();
this.taxesPayableByTaxRateId = payableTaxes;
}
/**
* Retrieve tax rates.
* @param {number} tenantId
* @returns {Promise<TaxRate[]>}
*/
public getTaxRates = () => {
return this.taxRateModel.query().orderBy('name', 'desc');
};
/**
* Retrieve taxes payable sum grouped by tax rate id.
* @returns {Promise<SalesTaxLiabilitySummaryPayableById>}
*/
public async getTaxesPayableSumGroupedByRateId(): Promise<SalesTaxLiabilitySummaryPayableById> {
// Retrieves tax payable accounts.
const taxPayableAccounts = await this.accountModel
.query()
.whereIn('accountType', [ACCOUNT_TYPE.TAX_PAYABLE]);
const payableAccountsIds = taxPayableAccounts.map((account) => account.id);
const groupedTaxesById = await this.accountTransactionModel
.query()
.whereIn('account_id', payableAccountsIds)
.whereNot('tax_rate_id', null)
.groupBy('tax_rate_id')
.select(['tax_rate_id'])
.sum('credit as credit')
.sum('debit as debit');
return keyBy(groupedTaxesById, 'taxRateId');
}
/**
* Retrieve taxes sales sum grouped by tax rate id.
* @returns {Promise<SalesTaxLiabilitySummarySalesById>}
*/
public taxesSalesSumGroupedByRateId =
async (): Promise<SalesTaxLiabilitySummarySalesById> => {
const incomeAccounts = await this.accountModel
.query()
.whereIn('accountType', [
ACCOUNT_TYPE.INCOME,
ACCOUNT_TYPE.OTHER_INCOME,
]);
const incomeAccountsIds = incomeAccounts.map((account) => account.id);
const groupedTaxesById = await this.accountTransactionModel
.query()
.whereIn('account_id', incomeAccountsIds)
.whereNot('tax_rate_id', null)
.groupBy('tax_rate_id')
.select(['tax_rate_id'])
.sum('credit as credit')
.sum('debit as debit');
return keyBy(groupedTaxesById, 'taxRateId');
};
}

View File

@@ -0,0 +1,34 @@
import { SalesTaxLiabilitySummaryRepository } from './SalesTaxLiabilitySummaryRepository';
import { SalesTaxLiabilitySummary } from './SalesTaxLiabilitySummary';
import { SalesTaxLiabilitySummaryMeta } from './SalesTaxLiabilitySummaryMeta';
import { Injectable } from '@nestjs/common';
import { SalesTaxLiabilitySummaryQuery } from './SalesTaxLiability.types';
@Injectable()
export class SalesTaxLiabilitySummaryService {
constructor(
private readonly repository: SalesTaxLiabilitySummaryRepository,
private readonly salesTaxLiabilityMeta: SalesTaxLiabilitySummaryMeta,
) {}
/**
* Retrieve sales tax liability summary.
* @param {SalesTaxLiabilitySummaryQuery} query
* @returns
*/
public async salesTaxLiability(query: SalesTaxLiabilitySummaryQuery) {
await this.repository.load();
const taxLiabilitySummary = new SalesTaxLiabilitySummary(
query,
this.repository,
);
const meta = await this.salesTaxLiabilityMeta.meta(query);
return {
data: taxLiabilitySummary.reportData(),
query,
meta,
};
}
}

View File

@@ -0,0 +1,161 @@
import * as R from 'ramda';
import {
SalesTaxLiabilitySummaryQuery,
SalesTaxLiabilitySummaryRate,
SalesTaxLiabilitySummaryReportData,
SalesTaxLiabilitySummaryTotal,
} from './SalesTaxLiability.types';
import { AgingReport } from '../AgingSummary/AgingReport';
import { IROW_TYPE } from './_constants';
import { FinancialTable } from '../../common/FinancialTable';
import { FinancialSheetStructure } from '../../common/FinancialSheetStructure';
import { ITableRow } from '../../types/Table.types';
import { ITableColumn } from '../../types/Table.types';
import { tableRowMapper } from '../../utils/Table.utils';
export class SalesTaxLiabilitySummaryTable extends R.pipe(
FinancialTable,
FinancialSheetStructure,
)(AgingReport) {
private data: SalesTaxLiabilitySummaryReportData;
private query: SalesTaxLiabilitySummaryQuery;
/**
* Sales tax liability summary table constructor.
* @param {SalesTaxLiabilitySummaryReportData} data
* @param {SalesTaxLiabilitySummaryQuery} query
*/
constructor(
data: SalesTaxLiabilitySummaryReportData,
query: SalesTaxLiabilitySummaryQuery,
) {
super();
this.data = data;
this.query = query;
}
/**
* Retrieve the tax rate row accessors.
* @returns {ITableColumnAccessor[]}
*/
private get taxRateRowAccessor() {
return [
{ key: 'taxName', accessor: 'taxName' },
{ key: 'taxPercentage', accessor: 'taxPercentage.formattedAmount' },
{ key: 'taxableAmount', accessor: 'taxableAmount.formattedAmount' },
{ key: 'collectedTax', accessor: 'collectedTaxAmount.formattedAmount' },
{ key: 'taxAmount', accessor: 'taxAmount.formattedAmount' },
];
}
/**
* Retrieve the tax rate total row accessors.
* @returns {ITableColumnAccessor[]}
*/
private get taxRateTotalRowAccessors() {
return [
{ key: 'taxName', value: 'Total' },
{ key: 'taxPercentage', value: '' },
{ key: 'taxableAmount', accessor: 'taxableAmount.formattedAmount' },
{ key: 'collectedTax', accessor: 'collectedTaxAmount.formattedAmount' },
{ key: 'taxAmount', accessor: 'taxAmount.formattedAmount' },
];
}
/**
* Maps the tax rate node to table row.
* @param {SalesTaxLiabilitySummaryRate} node
* @returns {ITableRow}
*/
private taxRateTableRowMapper = (
node: SalesTaxLiabilitySummaryRate,
): ITableRow => {
const columns = this.taxRateRowAccessor;
const meta = {
rowTypes: [IROW_TYPE.TaxRate],
id: node.id,
};
return tableRowMapper(node, columns, meta);
};
/**
* Maps the tax rates nodes to table rows.
* @param {SalesTaxLiabilitySummaryRate[]} nodes
* @returns {ITableRow[]}
*/
private taxRatesTableRowsMapper = (
nodes: SalesTaxLiabilitySummaryRate[],
): ITableRow[] => {
return nodes.map(this.taxRateTableRowMapper);
};
/**
* Maps the tax rate total node to table row.
* @param {SalesTaxLiabilitySummaryTotal} node
* @returns {ITableRow}
*/
private taxRateTotalRowMapper = (node: SalesTaxLiabilitySummaryTotal) => {
const columns = this.taxRateTotalRowAccessors;
const meta = {
rowTypes: [IROW_TYPE.Total],
};
return tableRowMapper(node, columns, meta);
};
/**
* Retrieves the tax rate total row.
* @returns {ITableRow}
*/
private get taxRateTotalRow(): ITableRow {
return this.taxRateTotalRowMapper(this.data.total);
}
/**
* Retrieves the tax rates rows.
* @returns {ITableRow[]}
*/
private get taxRatesRows(): ITableRow[] {
return this.taxRatesTableRowsMapper(this.data.taxRates);
}
/**
* Retrieves the table rows.
* @returns {ITableRow[]}
*/
public tableRows(): ITableRow[] {
return R.compose(
R.unless(R.isEmpty, R.append(this.taxRateTotalRow)),
R.concat(this.taxRatesRows),
)([]);
}
/**
* Retrieves the table columns.
* @returns {ITableColumn[]}
*/
public tableColumns(): ITableColumn[] {
return R.compose(this.tableColumnsCellIndexing)([
{
label: 'Tax Name',
key: 'taxName',
},
{
label: 'Tax Percentage',
key: 'taxPercentage',
},
{
label: 'Taxable Amount',
key: 'taxableAmount',
},
{
label: 'Collected Tax',
key: 'collectedTax',
},
{
label: 'Tax Amount',
key: 'taxRate',
},
]);
}
}

View File

@@ -0,0 +1,36 @@
import {
ISalesTaxLiabilitySummaryTable,
SalesTaxLiabilitySummaryQuery,
} from './SalesTaxLiability.types';
import { SalesTaxLiabilitySummaryTable } from './SalesTaxLiabilitySummaryTable';
import { SalesTaxLiabilitySummaryService } from './SalesTaxLiabilitySummaryService';
import { Injectable } from '@nestjs/common';
@Injectable()
export class SalesTaxLiabilitySummaryTableInjectable {
constructor(
private readonly salesTaxLiability: SalesTaxLiabilitySummaryService,
) {}
/**
* Retrieve sales tax liability summary table.
* @param {SalesTaxLiabilitySummaryQuery} query
* @returns {Promise<ISalesTaxLiabilitySummaryTable>}
*/
public async table(
query: SalesTaxLiabilitySummaryQuery,
): Promise<ISalesTaxLiabilitySummaryTable> {
const report = await this.salesTaxLiability.salesTaxLiability(query);
// Creates the sales tax liability summary table.
const table = new SalesTaxLiabilitySummaryTable(report.data, query);
return {
table: {
rows: table.tableRows(),
columns: table.tableColumns(),
},
query: report.query,
meta: report.meta,
};
}
}

View File

@@ -0,0 +1,28 @@
import { Injectable } from '@nestjs/common';
import { SalesTaxLiabilitySummaryTableInjectable } from './SalesTaxLiabilitySummaryTableInjectable';
import { TableSheetPdf } from '../../common/TableSheetPdf';
import { SalesTaxLiabilitySummaryQuery } from './SalesTaxLiability.types';
@Injectable()
export class SalesTaxLiabiltiySummaryPdf {
constructor(
private readonly salesTaxLiabiltiySummaryTable: SalesTaxLiabilitySummaryTableInjectable,
private readonly tableSheetPdf: TableSheetPdf,
) {}
/**
* Converts the given sales tax liability summary table to pdf.
* @param {ISalesByItemsReportQuery} query - Balance sheet query.
* @returns {Promise<Buffer>}
*/
public async pdf(query: SalesTaxLiabilitySummaryQuery): Promise<Buffer> {
const table = await this.salesTaxLiabiltiySummaryTable.table(
query,
);
return this.tableSheetPdf.convertToPdf(
table.table,
table.meta.sheetName,
table.meta.formattedDateRange,
);
}
}

View File

@@ -0,0 +1,4 @@
export enum IROW_TYPE {
TaxRate = 'TaxRate',
Total = 'Total',
}

View File

@@ -13,7 +13,7 @@ import { TransactionsByContactRepository } from './TransactionsByContactReposito
export class TransactionsByContact extends FinancialSheet {
public readonly filter: ITransactionsByContactsFilter;
public readonly i18n: I18nService
public readonly i18n: I18nService;
public readonly repository: TransactionsByContactRepository;
/**
@@ -22,7 +22,7 @@ export class TransactionsByContact extends FinancialSheet {
* @return {Omit<ITransactionsByContactsTransaction, 'runningBalance'>}
*/
protected contactTransactionMapper(
entry: ILedgerEntry
entry: ILedgerEntry,
): Omit<ITransactionsByContactsTransaction, 'runningBalance'> {
const account = this.repository.accountsGraph.getNodeData(entry.accountId);
const currencyCode = this.baseCurrency;
@@ -37,6 +37,7 @@ export class TransactionsByContact extends FinancialSheet {
// @ts-ignore
// transactionType: this.i18n.t(entry.referenceTypeFormatted),
transactionType: '',
// @ts-ignore
date: entry.date,
createdAt: entry.createdAt,
};
@@ -51,7 +52,7 @@ export class TransactionsByContact extends FinancialSheet {
protected contactTransactionRunningBalance(
openingBalance: number,
accountNormal: 'credit' | 'debit',
transactions: Omit<ITransactionsByContactsTransaction, 'runningBalance'>[]
transactions: Omit<ITransactionsByContactsTransaction, 'runningBalance'>[],
): any {
let _openingBalance = openingBalance;
@@ -69,10 +70,10 @@ export class TransactionsByContact extends FinancialSheet {
const runningBalance = this.getTotalAmountMeta(
_openingBalance,
transaction.currencyCode
transaction.currencyCode,
);
return { ...transaction, runningBalance };
}
},
);
}
@@ -85,7 +86,7 @@ export class TransactionsByContact extends FinancialSheet {
protected getContactClosingBalance(
customerTransactions: ITransactionsByContactsTransaction[],
contactNormal: 'credit' | 'debit',
openingBalance: number
openingBalance: number,
): number {
const closingBalance = openingBalance;
@@ -124,7 +125,7 @@ export class TransactionsByContact extends FinancialSheet {
*/
protected getContactAmount(
amount: number,
currencyCode: string
currencyCode: string,
): ITransactionsByContactsAmount {
return {
amount,
@@ -153,7 +154,7 @@ export class TransactionsByContact extends FinancialSheet {
* @returns {boolean}
*/
private filterContactByNoneTransaction = (
transactionsByContact: ITransactionsByContactsContact
transactionsByContact: ITransactionsByContactsContact,
): boolean => {
return transactionsByContact.transactions.length > 0;
};
@@ -164,7 +165,7 @@ export class TransactionsByContact extends FinancialSheet {
* @returns {boolean}
*/
private filterContactNoneZero = (
transactionsByContact: ITransactionsByContactsContact
transactionsByContact: ITransactionsByContactsContact,
): boolean => {
return transactionsByContact.closingBalance.amount !== 0;
};
@@ -190,7 +191,7 @@ export class TransactionsByContact extends FinancialSheet {
* @returns {ICustomerBalanceSummaryCustomer[]}
*/
protected contactsFilter = (
nodes: ITransactionsByContactsContact[]
nodes: ITransactionsByContactsContact[],
): ITransactionsByContactsContact[] => {
return nodes.filter(this.contactNodeFilter);
};

View File

@@ -1,4 +1,5 @@
import { Knex } from 'knex';
import * as moment from 'moment';
export interface ILedger {
entries: ILedgerEntry[];
@@ -41,7 +42,7 @@ export interface ILedgerEntry {
accountId?: number;
accountNormal: string;
contactId?: number;
date: Date | string;
date: moment.MomentInput;
transactionType: string;
transactionSubType?: string;

View File

@@ -5,7 +5,7 @@ export const transformLedgerEntryToTransaction = (
entry: ILedgerEntry
): Partial<AccountTransaction> => {
return {
date: entry.date,
date: moment(entry.date).toDate(),
credit: entry.credit,
debit: entry.debit,

View File

@@ -59,7 +59,7 @@ import { MailNotificationModule } from '../MailNotification/MailNotification.mod
MailModule,
MailNotificationModule,
InventoryCostModule,
DynamicListModule
DynamicListModule,
],
controllers: [SaleInvoicesController],
providers: [
@@ -94,7 +94,8 @@ import { MailNotificationModule } from '../MailNotification/MailNotification.mod
SendSaleInvoiceMail,
GetSaleInvoicesService,
GetSaleInvoiceMailState,
SendSaleInvoiceMailCommon
SendSaleInvoiceMailCommon,
],
exports: [GetSaleInvoice],
})
export class SaleInvoicesModule {}

View File

@@ -0,0 +1,50 @@
import { Knex } from 'knex';
import { GetSaleInvoice } from '../SaleInvoices/queries/GetSaleInvoice.service';
import { CreatePaymentReceivedService } from '../PaymentReceived/commands/CreatePaymentReceived.serivce';
import { UnitOfWork } from '../Tenancy/TenancyDB/UnitOfWork.service';
import { Injectable } from '@nestjs/common';
import { AccountRepository } from '../Accounts/repositories/Account.repository';
@Injectable()
export class CreatePaymentReceiveStripePayment {
constructor(
private readonly getSaleInvoiceService: GetSaleInvoice,
private readonly createPaymentReceivedService: CreatePaymentReceivedService,
private readonly uow: UnitOfWork,
private readonly accountRepository: AccountRepository,
) {}
/**
* Creates a payment received transaction associated to the given invoice.
* @param {number} saleInvoiceId - Sale invoice id.
* @param {number} paidAmount - Paid amount.
*/
async createPaymentReceived(saleInvoiceId: number, paidAmount: number) {
// Create a payment received transaction under UOW envirement.
return this.uow.withTransaction(async (trx: Knex.Transaction) => {
// Finds or creates a new stripe payment clearing account (current asset).
const stripeClearingAccount =
await this.accountRepository.findOrCreateStripeClearing({}, trx);
// Retrieves the given invoice to create payment transaction associated to it.
const invoice =
await this.getSaleInvoiceService.getSaleInvoice(saleInvoiceId);
const paymentReceivedDTO = {
customerId: invoice.customerId,
paymentDate: new Date(),
amount: paidAmount,
exchangeRate: 1,
referenceNo: '',
statement: '',
depositAccountId: stripeClearingAccount.id,
entries: [{ invoiceId: saleInvoiceId, paymentAmount: paidAmount }],
};
// Create a payment received transaction associated to the given invoice.
await this.createPaymentReceivedService.createPaymentReceived(
paymentReceivedDTO,
trx,
);
});
}
}

View File

@@ -0,0 +1,15 @@
import { StripePaymentService } from './StripePaymentService';
import { Injectable } from '@nestjs/common';
@Injectable()
export class CreateStripeAccountLinkService {
constructor(private readonly stripePaymentService: StripePaymentService) {}
/**
* Creates a new Stripe account id.
* @param {string} stripeAccountId - Stripe account id.
*/
public createAccountLink(stripeAccountId: string) {
return this.stripePaymentService.createAccountLink(stripeAccountId);
}
}

View File

@@ -0,0 +1,50 @@
import { CreateStripeAccountDTO } from './types';
import { Inject, Injectable } from '@nestjs/common';
import { EventEmitter2 } from '@nestjs/event-emitter';
import { StripePaymentService } from './StripePaymentService';
import { events } from '@/common/events/events';
import { PaymentIntegration } from './models/PaymentIntegration.model';
@Injectable()
export class CreateStripeAccountService {
constructor(
private readonly stripePaymentService: StripePaymentService,
private readonly eventPublisher: EventEmitter2,
@Inject(PaymentIntegration.name)
private readonly paymentIntegrationModel: typeof PaymentIntegration,
) {}
/**
* Creates a new Stripe account.
* @param {CreateStripeAccountDTO} stripeAccountDTO
* @returns {Promise<string>}
*/
async createStripeAccount(
stripeAccountDTO?: CreateStripeAccountDTO,
): Promise<string> {
const stripeAccount = await this.stripePaymentService.createAccount();
const stripeAccountId = stripeAccount.id;
const parsedStripeAccountDTO = {
name: 'Stripe',
...stripeAccountDTO,
};
// Stores the details of the Stripe account.
await this.paymentIntegrationModel.query().insert({
name: parsedStripeAccountDTO.name,
accountId: stripeAccountId,
active: false, // Active will turn true after onboarding.
service: 'Stripe',
});
// Triggers `onStripeIntegrationAccountCreated` event.
await this.eventPublisher.emitAsync(
events.stripeIntegration.onAccountCreated,
{
stripeAccountDTO,
stripeAccountId,
},
);
return stripeAccountId;
}
}

View File

@@ -0,0 +1,65 @@
import { StripePaymentService } from './StripePaymentService';
import { Knex } from 'knex';
import { StripeOAuthCodeGrantedEventPayload } from './types';
import { Inject, Injectable } from '@nestjs/common';
import { UnitOfWork } from '../Tenancy/TenancyDB/UnitOfWork.service';
import { EventEmitter2 } from '@nestjs/event-emitter';
import { PaymentIntegration } from './models/PaymentIntegration.model';
import { events } from '@/common/events/events';
@Injectable()
export class ExchangeStripeOAuthTokenService {
constructor(
private readonly stripePaymentService: StripePaymentService,
private readonly eventPublisher: EventEmitter2,
private readonly uow: UnitOfWork,
@Inject(PaymentIntegration.name)
private readonly paymentIntegrationModel: typeof PaymentIntegration,
) {}
/**
* Exchange stripe oauth authorization code to access token and user id.
* @param {string} authorizationCode
*/
public async excahngeStripeOAuthToken(authorizationCode: string) {
const stripe = this.stripePaymentService.stripe;
const response = await stripe.oauth.token({
grant_type: 'authorization_code',
code: authorizationCode,
});
// const accessToken = response.access_token;
// const refreshToken = response.refresh_token;
const stripeUserId = response.stripe_user_id;
// Retrieves details of the Stripe account.
const account = await stripe.accounts.retrieve(stripeUserId, {
expand: ['business_profile'],
});
const companyName = account.business_profile?.name || 'Unknown name';
const paymentEnabled = account.charges_enabled;
const payoutEnabled = account.payouts_enabled;
return this.uow.withTransaction(async (trx: Knex.Transaction) => {
// Stores the details of the Stripe account.
const paymentIntegration = await this.paymentIntegrationModel
.query(trx)
.insert({
name: companyName,
service: 'Stripe',
accountId: stripeUserId,
paymentEnabled,
payoutEnabled,
});
// Triggers `onStripeOAuthCodeGranted` event.
await this.eventPublisher.emitAsync(
events.stripeIntegration.onOAuthCodeGranted,
{
paymentIntegrationId: paymentIntegration.id,
trx,
} as StripeOAuthCodeGrantedEventPayload,
);
});
}
}

View File

@@ -0,0 +1,16 @@
import { Injectable } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
@Injectable()
export class GetStripeAuthorizationLinkService {
constructor(private readonly config: ConfigService) {}
public getStripeAuthLink() {
const clientId = this.config.get('stripePayment.clientId');
const redirectUrl = this.config.get('stripePayment.redirectTo');
const authorizationUri = `https://connect.stripe.com/oauth/v2/authorize?response_type=code&client_id=${clientId}&scope=read_write&redirect_uri=${redirectUrl}`;
return authorizationUri;
}
}

View File

@@ -0,0 +1,55 @@
import { Body, Controller, Get, Injectable, Post } from '@nestjs/common';
import { StripePaymentApplication } from './StripePaymentApplication';
@Controller('/stripe')
export class StripeIntegrationController {
constructor(private readonly stripePaymentApp: StripePaymentApplication) {}
/**
* Retrieves Stripe OAuth2 connect link.
* @returns {Promise<Response|void>}
*/
@Get('/link')
public async getStripeConnectLink() {
const authorizationUri = this.stripePaymentApp.getStripeConnectLink();
return { url: authorizationUri };
}
/**
* Exchanges the given Stripe authorization code to Stripe user id and access token.
* @returns {Promise<void>}
*/
@Post('/callback')
public async exchangeOAuth(@Body('code') code: string) {
await this.stripePaymentApp.exchangeStripeOAuthToken(code);
return {};
}
/**
* Creates a new Stripe account.
* @returns {Promise<void>}
*/
public async createAccount() {
const accountId = await this.stripePaymentApp.createStripeAccount();
return {
accountId,
message: 'The Stripe account has been created successfully.',
};
}
/**
* Creates a new Stripe account session.
* @returns {Promise<void>}
*/
@Post('/account_link')
public async createAccountLink(
@Body('stripeAccountId') stripeAccountId: string,
) {
const clientSecret =
await this.stripePaymentApp.createAccountLink(stripeAccountId);
return { clientSecret };
}
}

View File

@@ -0,0 +1,38 @@
import { Module } from '@nestjs/common';
import { CreateStripeAccountLinkService } from './CreateStripeAccountLink';
import { CreateStripeAccountService } from './CreateStripeAccountService';
import { StripePaymentApplication } from './StripePaymentApplication';
import { ExchangeStripeOAuthTokenService } from './ExchangeStripeOauthToken';
import { PaymentIntegration } from './models/PaymentIntegration.model';
import { SeedStripeAccountsOnOAuthGrantedSubscriber } from './subscribers/SeedStripeAccounts';
import { StripeWebhooksSubscriber } from './subscribers/StripeWebhooksSubscriber';
import { StripeIntegrationController } from './StripePayment.controller';
import { StripePaymentService } from './StripePaymentService';
import { InjectSystemModel } from '../System/SystemModels/SystemModels.module';
import { GetStripeAuthorizationLinkService } from './GetStripeAuthorizationLink';
import { AccountsModule } from '../Accounts/Accounts.module';
import { CreatePaymentReceiveStripePayment } from './CreatePaymentReceivedStripePayment';
import { SaleInvoicesModule } from '../SaleInvoices/SaleInvoices.module';
import { PaymentsReceivedModule } from '../PaymentReceived/PaymentsReceived.module';
import { TenancyContext } from '../Tenancy/TenancyContext.service';
const models = [InjectSystemModel(PaymentIntegration)];
@Module({
imports: [AccountsModule, SaleInvoicesModule, PaymentsReceivedModule],
providers: [
...models,
StripePaymentService,
GetStripeAuthorizationLinkService,
CreateStripeAccountLinkService,
CreateStripeAccountService,
StripePaymentApplication,
ExchangeStripeOAuthTokenService,
CreatePaymentReceiveStripePayment,
SeedStripeAccountsOnOAuthGrantedSubscriber,
StripeWebhooksSubscriber,
TenancyContext,
],
controllers: [StripeIntegrationController],
})
export class StripePaymentModule {}

View File

@@ -0,0 +1,19 @@
export interface StripePaymentLinkCreatedEventPayload {
paymentLinkId: string;
saleInvoiceId: number;
stripeIntegrationId: number;
}
export interface StripeCheckoutSessionCompletedEventPayload {
event: any;
}
export interface StripeInvoiceCheckoutSessionPOJO {
sessionId: string;
publishableKey: string;
redirectTo: string;
}
export interface StripeWebhookEventPayload {
event: any;
}

View File

@@ -0,0 +1,58 @@
import { CreateStripeAccountService } from './CreateStripeAccountService';
import { CreateStripeAccountLinkService } from './CreateStripeAccountLink';
import { CreateStripeAccountDTO } from './types';
import { ExchangeStripeOAuthTokenService } from './ExchangeStripeOauthToken';
import { GetStripeAuthorizationLinkService } from './GetStripeAuthorizationLink';
import { Injectable } from '@nestjs/common';
@Injectable()
export class StripePaymentApplication {
constructor(
private readonly createStripeAccountService: CreateStripeAccountService,
private readonly createStripeAccountLinkService: CreateStripeAccountLinkService,
private readonly exchangeStripeOAuthTokenService: ExchangeStripeOAuthTokenService,
private readonly getStripeConnectLinkService: GetStripeAuthorizationLinkService,
) {}
/**
* Creates a new Stripe account.
* @param {number} createStripeAccountDTO
*/
public createStripeAccount(
createStripeAccountDTO: CreateStripeAccountDTO = {},
) {
return this.createStripeAccountService.createStripeAccount(
createStripeAccountDTO,
);
}
/**
* Creates a new Stripe account link of the given Stripe account.
* @param {string} stripeAccountId
* @returns {}
*/
public createAccountLink(stripeAccountId: string) {
return this.createStripeAccountLinkService.createAccountLink(
stripeAccountId,
);
}
/**
* Retrieves Stripe OAuth2 connect link.
* @returns {string}
*/
public getStripeConnectLink() {
return this.getStripeConnectLinkService.getStripeAuthLink();
}
/**
* Exchanges the given Stripe authorization code to Stripe user id and access token.
* @param {string} authorizationCode
* @returns
*/
public exchangeStripeOAuthToken(authorizationCode: string) {
return this.exchangeStripeOAuthTokenService.excahngeStripeOAuthToken(
authorizationCode,
);
}
}

View File

@@ -0,0 +1,80 @@
import { Injectable, Scope } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import stripe from 'stripe';
const origin = 'https://cfdf-102-164-97-88.ngrok-free.app';
@Injectable({ scope: Scope.DEFAULT })
export class StripePaymentService {
public stripe: stripe;
/**
* Constructor method.
* @param {ConfigService} config - ConfigService instance
*/
constructor(private readonly config: ConfigService) {
const secretKey = this.config.get('stripePayment.secretKey');
this.stripe = new stripe(secretKey, {
apiVersion: '2024-06-20',
});
}
/**
* Creates a new Stripe account session.
* @param {number} accountId
* @returns {Promise<string>}
*/
public async createAccountSession(accountId: string): Promise<string> {
try {
const accountSession = await this.stripe.accountSessions.create({
account: accountId,
components: {
account_onboarding: { enabled: true },
},
});
return accountSession.client_secret;
} catch (error) {
throw new Error(
'An error occurred when calling the Stripe API to create an account session',
);
}
}
/**
* Creates a new Stripe account link.
* @param {number} accountId - Account id.
* @returns {Promise<stripe.Response<stripe.AccountLink>}
*/
public async createAccountLink(accountId: string) {
try {
const accountLink = await this.stripe.accountLinks.create({
account: accountId,
return_url: `${origin}/return/${accountId}`,
refresh_url: `${origin}/refresh/${accountId}`,
type: 'account_onboarding',
});
return accountLink;
} catch (error) {
throw new Error(
'An error occurred when calling the Stripe API to create an account link:',
);
}
}
/**
* C
* @returns {Promise<stripe.Response<stripe.Account>>}
*/
public async createAccount() {
try {
const account = await this.stripe.accounts.create({
type: 'standard',
});
return account;
} catch (error) {
throw new Error(
'An error occurred when calling the Stripe API to create an account',
);
}
}
}

View File

@@ -0,0 +1,67 @@
import { BaseModel } from '@/models/Model';
import { Model } from 'objection';
export class PaymentIntegration extends BaseModel {
paymentEnabled!: boolean;
payoutEnabled!: boolean;
service?: string;
name?: string;
slug?: string;
accountId?: string;
options?: Record<string, any>;
active?: boolean;
static get tableName() {
return 'payment_integrations';
}
static get idColumn() {
return 'id';
}
static get virtualAttributes() {
return ['fullEnabled'];
}
static get jsonAttributes() {
return ['options'];
}
get fullEnabled() {
return this.paymentEnabled && this.payoutEnabled;
}
static get modifiers() {
return {
/**
* Query to filter enabled payment and payout.
*/
fullEnabled(query) {
query.where('paymentEnabled', true).andWhere('payoutEnabled', true);
},
};
}
static get jsonSchema() {
return {
type: 'object',
required: ['name', 'service'],
properties: {
id: { type: 'integer' },
service: { type: 'string' },
paymentEnabled: { type: 'boolean' },
payoutEnabled: { type: 'boolean' },
accountId: { type: 'string' },
options: {
type: 'object',
properties: {
bankAccountId: { type: 'number' },
clearingAccountId: { type: 'number' },
},
},
createdAt: { type: 'string', format: 'date-time' },
updatedAt: { type: 'string', format: 'date-time' },
},
};
}
}

View File

@@ -0,0 +1,43 @@
import { OnEvent } from '@nestjs/event-emitter';
import { Inject, Injectable } from '@nestjs/common';
import { events } from '@/common/events/events';
import { AccountRepository } from '@/modules/Accounts/repositories/Account.repository';
import { PaymentIntegration } from '../models/PaymentIntegration.model';
import { StripeOAuthCodeGrantedEventPayload } from '../types';
@Injectable()
export class SeedStripeAccountsOnOAuthGrantedSubscriber {
constructor(
private readonly accountRepository: AccountRepository,
@Inject(PaymentIntegration.name)
private readonly paymentIntegrationModel: typeof PaymentIntegration,
) {}
/**
* Seeds the default integration settings once oauth authorization code granted.
* @param {StripeCheckoutSessionCompletedEventPayload} payload -
*/
@OnEvent(events.stripeIntegration.onOAuthCodeGranted)
async handleSeedStripeAccount({
paymentIntegrationId,
trx,
}: StripeOAuthCodeGrantedEventPayload) {
const clearingAccount =
await this.accountRepository.findOrCreateStripeClearing({}, trx);
const bankAccount = await this.accountRepository.findBySlug('bank-account');
// Patch the Stripe integration default settings.
await this.paymentIntegrationModel
.query(trx)
.findById(paymentIntegrationId)
.patch({
options: {
// @ts-ignore
bankAccountId: bankAccount.id,
clearingAccountId: clearingAccount.id,
},
});
}
}

View File

@@ -0,0 +1,76 @@
import { CreatePaymentReceiveStripePayment } from '../CreatePaymentReceivedStripePayment';
import {
StripeCheckoutSessionCompletedEventPayload,
StripeWebhookEventPayload,
} from '../StripePayment.types';
// import { initalizeTenantServices } from '@/api/middleware/TenantDependencyInjection';
// import { initializeTenantSettings } from '@/api/middleware/SettingsMiddleware';
import { OnEvent } from '@nestjs/event-emitter';
import { Inject, Injectable } from '@nestjs/common';
import { events } from '@/common/events/events';
import { PaymentIntegration } from '../models/PaymentIntegration.model';
@Injectable()
export class StripeWebhooksSubscriber {
constructor(
private readonly createPaymentReceiveStripePayment: CreatePaymentReceiveStripePayment,
@Inject(PaymentIntegration.name)
private readonly paymentIntegrationModel: typeof PaymentIntegration,
) {}
/**
* Handles the checkout session completed webhook event.
* @param {StripeCheckoutSessionCompletedEventPayload} payload -
*/
@OnEvent(events.stripeWebhooks.onCheckoutSessionCompleted)
async handleCheckoutSessionCompleted({
event,
}: StripeCheckoutSessionCompletedEventPayload) {
const { metadata } = event.data.object;
const tenantId = parseInt(metadata.tenantId, 10);
const saleInvoiceId = parseInt(metadata.saleInvoiceId, 10);
// await initalizeTenantServices(tenantId);
// await initializeTenantSettings(tenantId);
// Get the amount from the event
const amount = event.data.object.amount_total;
// Convert from Stripe amount (cents) to normal amount (dollars)
const amountInDollars = amount / 100;
// Creates a new payment received transaction.
await this.createPaymentReceiveStripePayment.createPaymentReceived(
saleInvoiceId,
amountInDollars,
);
}
/**
* Handles the account updated.
* @param {StripeWebhookEventPayload}
*/
@OnEvent(events.stripeWebhooks.onAccountUpdated)
async handleAccountUpdated({ event }: StripeWebhookEventPayload) {
const { metadata } = event.data.object;
const account = event.data.object;
const tenantId = parseInt(metadata.tenantId, 10);
if (!metadata?.paymentIntegrationId || !metadata.tenantId) return;
// Find the tenant or throw not found error.
// await Tenant.query().findById(tenantId).throwIfNotFound();
// Check if the account capabilities are active
if (account.capabilities.card_payments === 'active') {
// Marks the payment method integration as active.
await this.paymentIntegrationModel
.query()
.findById(metadata?.paymentIntegrationId)
.patch({
active: true,
});
}
}
}

View File

@@ -0,0 +1,9 @@
import { Knex } from 'knex';
export interface CreateStripeAccountDTO {
name?: string;
}
export interface StripeOAuthCodeGrantedEventPayload {
paymentIntegrationId: number;
trx?: Knex.Transaction;
}