diff --git a/packages/server-nest/package.json b/packages/server-nest/package.json index b1e54a507..079aa991a 100644 --- a/packages/server-nest/package.json +++ b/packages/server-nest/package.json @@ -80,6 +80,7 @@ "rxjs": "^7.8.1", "serialize-interceptor": "^1.1.7", "strategy": "^1.1.1", + "stripe": "^16.10.0", "uniqid": "^5.2.0", "uuid": "^10.0.0", "xlsx": "^0.18.5", diff --git a/packages/server-nest/src/common/config/index.ts b/packages/server-nest/src/common/config/index.ts index 464a4578b..7a4a940ce 100644 --- a/packages/server-nest/src/common/config/index.ts +++ b/packages/server-nest/src/common/config/index.ts @@ -7,6 +7,7 @@ import lemonsqueezy from './lemonsqueezy'; import s3 from './s3'; import openExchange from './open-exchange'; import posthog from './posthog'; +import stripePayment from './stripe-payment'; export const config = [ systemDatabase, @@ -18,4 +19,5 @@ export const config = [ s3, openExchange, posthog, + stripePayment, ]; diff --git a/packages/server-nest/src/modules/App/App.module.ts b/packages/server-nest/src/modules/App/App.module.ts index 81c4f8631..2efa6e7f1 100644 --- a/packages/server-nest/src/modules/App/App.module.ts +++ b/packages/server-nest/src/modules/App/App.module.ts @@ -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: [ diff --git a/packages/server-nest/src/modules/FinancialStatements/FinancialStatements.module.ts b/packages/server-nest/src/modules/FinancialStatements/FinancialStatements.module.ts index f912b1ac0..3303cbb25 100644 --- a/packages/server-nest/src/modules/FinancialStatements/FinancialStatements.module.ts +++ b/packages/server-nest/src/modules/FinancialStatements/FinancialStatements.module.ts @@ -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 {} diff --git a/packages/server-nest/src/modules/FinancialStatements/modules/APAgingSummary/APAgingSummary.module.ts b/packages/server-nest/src/modules/FinancialStatements/modules/APAgingSummary/APAgingSummary.module.ts index 0d7e72bf0..7df7d36c1 100644 --- a/packages/server-nest/src/modules/FinancialStatements/modules/APAgingSummary/APAgingSummary.module.ts +++ b/packages/server-nest/src/modules/FinancialStatements/modules/APAgingSummary/APAgingSummary.module.ts @@ -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 {} - diff --git a/packages/server-nest/src/modules/FinancialStatements/modules/APAgingSummary/APAgingSummaryRepository.ts b/packages/server-nest/src/modules/FinancialStatements/modules/APAgingSummary/APAgingSummaryRepository.ts index 9b4c28643..2f64795b3 100644 --- a/packages/server-nest/src/modules/FinancialStatements/modules/APAgingSummary/APAgingSummaryRepository.ts +++ b/packages/server-nest/src/modules/FinancialStatements/modules/APAgingSummary/APAgingSummaryRepository.ts @@ -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} dueBillsByVendorId */ - dueBillsByVendorId: Record; + dueBillsByVendorId: Record; /** * Overdue bills. - * @param {Bill[]} overdueBills + * @param {Bill[]} overdueBills - overdue bills. */ - overdueBills: Bill[]; + overdueBills: ModelObject[]; /** * Overdue bills by vendor id. - * @param {Record} overdueBillsByVendorId + * @param {Record} overdueBillsByVendorId - Overdue bills by vendor id. */ - overdueBillsByVendorId: Record; + overdueBillsByVendorId: ModelObject[]; /** * Vendors. diff --git a/packages/server-nest/src/modules/FinancialStatements/modules/APAgingSummary/APAgingSummaryTableInjectable.ts b/packages/server-nest/src/modules/FinancialStatements/modules/APAgingSummary/APAgingSummaryTableInjectable.ts index d83655205..0bec38699 100644 --- a/packages/server-nest/src/modules/FinancialStatements/modules/APAgingSummary/APAgingSummaryTableInjectable.ts +++ b/packages/server-nest/src/modules/FinancialStatements/modules/APAgingSummary/APAgingSummaryTableInjectable.ts @@ -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} */ public async table( query: IAPAgingSummaryQuery, ): Promise { 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: { diff --git a/packages/server-nest/src/modules/FinancialStatements/modules/JournalSheet/JournalSheet.controller.ts b/packages/server-nest/src/modules/FinancialStatements/modules/JournalSheet/JournalSheet.controller.ts new file mode 100644 index 000000000..d5dcfb2cb --- /dev/null +++ b/packages/server-nest/src/modules/FinancialStatements/modules/JournalSheet/JournalSheet.controller.ts @@ -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); + } + } +} diff --git a/packages/server-nest/src/modules/FinancialStatements/modules/JournalSheet/JournalSheet.module.ts b/packages/server-nest/src/modules/FinancialStatements/modules/JournalSheet/JournalSheet.module.ts new file mode 100644 index 000000000..8d72a513d --- /dev/null +++ b/packages/server-nest/src/modules/FinancialStatements/modules/JournalSheet/JournalSheet.module.ts @@ -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 {} diff --git a/packages/server-nest/src/modules/FinancialStatements/modules/JournalSheet/JournalSheet.ts b/packages/server-nest/src/modules/FinancialStatements/modules/JournalSheet/JournalSheet.ts new file mode 100644 index 000000000..5696a6a9d --- /dev/null +++ b/packages/server-nest/src/modules/FinancialStatements/modules/JournalSheet/JournalSheet.ts @@ -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 { + 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); + } +} diff --git a/packages/server-nest/src/modules/FinancialStatements/modules/JournalSheet/JournalSheet.types.ts b/packages/server-nest/src/modules/FinancialStatements/modules/JournalSheet/JournalSheet.types.ts new file mode 100644 index 000000000..fb0b46d76 --- /dev/null +++ b/packages/server-nest/src/modules/FinancialStatements/modules/JournalSheet/JournalSheet.types.ts @@ -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; +} diff --git a/packages/server-nest/src/modules/FinancialStatements/modules/JournalSheet/JournalSheetApplication.ts b/packages/server-nest/src/modules/FinancialStatements/modules/JournalSheet/JournalSheetApplication.ts new file mode 100644 index 000000000..dd82aeef6 --- /dev/null +++ b/packages/server-nest/src/modules/FinancialStatements/modules/JournalSheet/JournalSheetApplication.ts @@ -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} + */ + public table(query: IJournalReportQuery): Promise { + return this.journalSheetTable.table(query); + } + + /** + * Retrieves the journal sheet in xlsx format. + * @param {IJournalReportQuery} query + * @returns {Promise} + */ + public xlsx(query: IJournalReportQuery) { + return this.journalExport.xlsx(query); + } + + /** + * Retrieves the journal sheet in csv format. + * @param {IJournalReportQuery} query + * @returns {Promise} + */ + public csv(query: IJournalReportQuery) { + return this.journalExport.csv(query); + } + + /** + * Retrieves the journal sheet in pdf format. + * @param {IJournalReportQuery} query + * @returns {Promise} + */ + public pdf(query: IJournalReportQuery) { + return this.journalPdf.pdf(query); + } +} diff --git a/packages/server-nest/src/modules/FinancialStatements/modules/JournalSheet/JournalSheetExport.ts b/packages/server-nest/src/modules/FinancialStatements/modules/JournalSheet/JournalSheetExport.ts new file mode 100644 index 000000000..c9d62d74d --- /dev/null +++ b/packages/server-nest/src/modules/FinancialStatements/modules/JournalSheet/JournalSheetExport.ts @@ -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} + */ + 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} + */ + public async csv(query: IJournalReportQuery): Promise { + const table = await this.journalSheetTable.table(query); + + const tableSheet = new TableSheet(table.table); + const tableCsv = tableSheet.convertToCSV(); + + return tableCsv; + } +} diff --git a/packages/server-nest/src/modules/FinancialStatements/modules/JournalSheet/JournalSheetMeta.ts b/packages/server-nest/src/modules/FinancialStatements/modules/JournalSheet/JournalSheetMeta.ts new file mode 100644 index 000000000..5b68c2ded --- /dev/null +++ b/packages/server-nest/src/modules/FinancialStatements/modules/JournalSheet/JournalSheetMeta.ts @@ -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} + */ + public async meta( + query: IJournalReportQuery, + ): Promise { + 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, + }; + } +} diff --git a/packages/server-nest/src/modules/FinancialStatements/modules/JournalSheet/JournalSheetPdfInjectable.ts b/packages/server-nest/src/modules/FinancialStatements/modules/JournalSheet/JournalSheetPdfInjectable.ts new file mode 100644 index 000000000..23e88f196 --- /dev/null +++ b/packages/server-nest/src/modules/FinancialStatements/modules/JournalSheet/JournalSheetPdfInjectable.ts @@ -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} + */ + public async pdf(query: IJournalReportQuery): Promise { + const table = await this.journalSheetTable.table(query); + + return this.tableSheetPdf.convertToPdf( + table.table, + table.meta.sheetName, + table.meta.formattedDateRange, + HtmlTableCustomCss, + ); + } +} diff --git a/packages/server-nest/src/modules/FinancialStatements/modules/JournalSheet/JournalSheetRepository.ts b/packages/server-nest/src/modules/FinancialStatements/modules/JournalSheet/JournalSheetRepository.ts new file mode 100644 index 000000000..369ac366d --- /dev/null +++ b/packages/server-nest/src/modules/FinancialStatements/modules/JournalSheet/JournalSheetRepository.ts @@ -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>; + + /** + * + */ + public filter: any; + + /** + * + */ + public accountsGraph: any; + + /** + * + */ + public contacts: Array> + + /** + * Contacts by id map. + */ + public contactsById: Map>; + + /** + * + */ + 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); + } + +} diff --git a/packages/server-nest/src/modules/FinancialStatements/modules/JournalSheet/JournalSheetService.ts b/packages/server-nest/src/modules/FinancialStatements/modules/JournalSheet/JournalSheetService.ts new file mode 100644 index 000000000..86f94cd22 --- /dev/null +++ b/packages/server-nest/src/modules/FinancialStatements/modules/JournalSheet/JournalSheetService.ts @@ -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} + */ + async journalSheet(query: IJournalReportQuery): Promise { + 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, + }; + } +} diff --git a/packages/server-nest/src/modules/FinancialStatements/modules/JournalSheet/JournalSheetTable.ts b/packages/server-nest/src/modules/FinancialStatements/modules/JournalSheet/JournalSheetTable.ts new file mode 100644 index 000000000..ad4b29ceb --- /dev/null +++ b/packages/server-nest/src/modules/FinancialStatements/modules/JournalSheet/JournalSheetTable.ts @@ -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); + } +} diff --git a/packages/server-nest/src/modules/FinancialStatements/modules/JournalSheet/JournalSheetTableInjectable.ts b/packages/server-nest/src/modules/FinancialStatements/modules/JournalSheet/JournalSheetTableInjectable.ts new file mode 100644 index 000000000..e7d8dfae6 --- /dev/null +++ b/packages/server-nest/src/modules/FinancialStatements/modules/JournalSheet/JournalSheetTableInjectable.ts @@ -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} + */ + public async table(query: IJournalReportQuery): Promise { + 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, + }; + } +} diff --git a/packages/server-nest/src/modules/FinancialStatements/modules/JournalSheet/constant.ts b/packages/server-nest/src/modules/FinancialStatements/modules/JournalSheet/constant.ts new file mode 100644 index 000000000..e93dfdc62 --- /dev/null +++ b/packages/server-nest/src/modules/FinancialStatements/modules/JournalSheet/constant.ts @@ -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, + }, +}); diff --git a/packages/server-nest/src/modules/FinancialStatements/modules/JournalSheet/types.ts b/packages/server-nest/src/modules/FinancialStatements/modules/JournalSheet/types.ts new file mode 100644 index 000000000..6eff84957 --- /dev/null +++ b/packages/server-nest/src/modules/FinancialStatements/modules/JournalSheet/types.ts @@ -0,0 +1,5 @@ + +export enum ROW_TYPE { + ENTRY = 'ENTRY', + TOTAL = 'TOTAL' +}; \ No newline at end of file diff --git a/packages/server-nest/src/modules/FinancialStatements/modules/SalesTaxLiabilitySummary/SalesTaxLiability.module.ts b/packages/server-nest/src/modules/FinancialStatements/modules/SalesTaxLiabilitySummary/SalesTaxLiability.module.ts new file mode 100644 index 000000000..cdbc35df8 --- /dev/null +++ b/packages/server-nest/src/modules/FinancialStatements/modules/SalesTaxLiabilitySummary/SalesTaxLiability.module.ts @@ -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 {} diff --git a/packages/server-nest/src/modules/FinancialStatements/modules/SalesTaxLiabilitySummary/SalesTaxLiability.types.ts b/packages/server-nest/src/modules/FinancialStatements/modules/SalesTaxLiabilitySummary/SalesTaxLiability.types.ts new file mode 100644 index 000000000..d71502c59 --- /dev/null +++ b/packages/server-nest/src/modules/FinancialStatements/modules/SalesTaxLiabilitySummary/SalesTaxLiability.types.ts @@ -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; +} diff --git a/packages/server-nest/src/modules/FinancialStatements/modules/SalesTaxLiabilitySummary/SalesTaxLiabilitySummary.controller.ts b/packages/server-nest/src/modules/FinancialStatements/modules/SalesTaxLiabilitySummary/SalesTaxLiabilitySummary.controller.ts new file mode 100644 index 000000000..01cd1467c --- /dev/null +++ b/packages/server-nest/src/modules/FinancialStatements/modules/SalesTaxLiabilitySummary/SalesTaxLiabilitySummary.controller.ts @@ -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); + } + } +} diff --git a/packages/server-nest/src/modules/FinancialStatements/modules/SalesTaxLiabilitySummary/SalesTaxLiabilitySummary.ts b/packages/server-nest/src/modules/FinancialStatements/modules/SalesTaxLiabilitySummary/SalesTaxLiabilitySummary.ts new file mode 100644 index 000000000..666c34956 --- /dev/null +++ b/packages/server-nest/src/modules/FinancialStatements/modules/SalesTaxLiabilitySummary/SalesTaxLiabilitySummary.ts @@ -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, + ): 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 }; + }; +} diff --git a/packages/server-nest/src/modules/FinancialStatements/modules/SalesTaxLiabilitySummary/SalesTaxLiabilitySummaryApplication.ts b/packages/server-nest/src/modules/FinancialStatements/modules/SalesTaxLiabilitySummary/SalesTaxLiabilitySummaryApplication.ts new file mode 100644 index 000000000..804da12f8 --- /dev/null +++ b/packages/server-nest/src/modules/FinancialStatements/modules/SalesTaxLiabilitySummary/SalesTaxLiabilitySummaryApplication.ts @@ -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} + */ + public sheet(query: SalesTaxLiabilitySummaryQuery) { + return this.salesTaxLiabilitySheet.salesTaxLiability(query); + } + + /** + * Retrieves the sales tax liability summary in table format. + * @param {SalesTaxLiabilitySummaryQuery} query + * @return {Promise} + */ + public table(query: SalesTaxLiabilitySummaryQuery) { + return this.salesTaxLiabilityTable.table(query); + } + + /** + * Retrieves the sales tax liability summary in XLSX format. + * @param {SalesTaxLiabilitySummaryQuery} query + * @returns {Promise} + */ + public xlsx(query: SalesTaxLiabilitySummaryQuery): Promise { + return this.salesTaxLiabilityExport.xlsx(query); + } + + /** + * Retrieves the sales tax liability summary in CSV format. + * @param {SalesTaxLiabilitySummaryQuery} query + * @returns {Promise} + */ + public csv(query: SalesTaxLiabilitySummaryQuery): Promise { + return this.salesTaxLiabilityExport.csv(query); + } + + /** + * Retrieves the sales tax liability summary in PDF format. + * @param {SalesTaxLiabilitySummaryQuery} query + * @returns {Promise} + */ + public pdf(query: SalesTaxLiabilitySummaryQuery): Promise { + return this.salesTaxLiabiltiyPdf.pdf(query); + } +} diff --git a/packages/server-nest/src/modules/FinancialStatements/modules/SalesTaxLiabilitySummary/SalesTaxLiabilitySummaryExportInjectable.ts b/packages/server-nest/src/modules/FinancialStatements/modules/SalesTaxLiabilitySummary/SalesTaxLiabilitySummaryExportInjectable.ts new file mode 100644 index 000000000..acb75d330 --- /dev/null +++ b/packages/server-nest/src/modules/FinancialStatements/modules/SalesTaxLiabilitySummary/SalesTaxLiabilitySummaryExportInjectable.ts @@ -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} + */ + public async xlsx(query: SalesTaxLiabilitySummaryQuery): Promise { + 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} + */ + public async csv(query: SalesTaxLiabilitySummaryQuery): Promise { + const table = await this.salesTaxLiabilityTable.table(query); + + const tableSheet = new TableSheet(table.table); + const tableCsv = tableSheet.convertToCSV(); + + return tableCsv; + } +} diff --git a/packages/server-nest/src/modules/FinancialStatements/modules/SalesTaxLiabilitySummary/SalesTaxLiabilitySummaryMeta.ts b/packages/server-nest/src/modules/FinancialStatements/modules/SalesTaxLiabilitySummary/SalesTaxLiabilitySummaryMeta.ts new file mode 100644 index 000000000..258a11ff9 --- /dev/null +++ b/packages/server-nest/src/modules/FinancialStatements/modules/SalesTaxLiabilitySummary/SalesTaxLiabilitySummaryMeta.ts @@ -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, + }; + } +} diff --git a/packages/server-nest/src/modules/FinancialStatements/modules/SalesTaxLiabilitySummary/SalesTaxLiabilitySummaryRepository.ts b/packages/server-nest/src/modules/FinancialStatements/modules/SalesTaxLiabilitySummary/SalesTaxLiabilitySummaryRepository.ts new file mode 100644 index 000000000..985f64b40 --- /dev/null +++ b/packages/server-nest/src/modules/FinancialStatements/modules/SalesTaxLiabilitySummary/SalesTaxLiabilitySummaryRepository.ts @@ -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>} + */ + taxRates: Array>; + + /** + * 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} + */ + public getTaxRates = () => { + return this.taxRateModel.query().orderBy('name', 'desc'); + }; + + /** + * Retrieve taxes payable sum grouped by tax rate id. + * @returns {Promise} + */ + public async getTaxesPayableSumGroupedByRateId(): Promise { + // 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} + */ + public taxesSalesSumGroupedByRateId = + async (): Promise => { + 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'); + }; +} diff --git a/packages/server-nest/src/modules/FinancialStatements/modules/SalesTaxLiabilitySummary/SalesTaxLiabilitySummaryService.ts b/packages/server-nest/src/modules/FinancialStatements/modules/SalesTaxLiabilitySummary/SalesTaxLiabilitySummaryService.ts new file mode 100644 index 000000000..92cd9233e --- /dev/null +++ b/packages/server-nest/src/modules/FinancialStatements/modules/SalesTaxLiabilitySummary/SalesTaxLiabilitySummaryService.ts @@ -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, + }; + } +} diff --git a/packages/server-nest/src/modules/FinancialStatements/modules/SalesTaxLiabilitySummary/SalesTaxLiabilitySummaryTable.ts b/packages/server-nest/src/modules/FinancialStatements/modules/SalesTaxLiabilitySummary/SalesTaxLiabilitySummaryTable.ts new file mode 100644 index 000000000..e6fc75c2e --- /dev/null +++ b/packages/server-nest/src/modules/FinancialStatements/modules/SalesTaxLiabilitySummary/SalesTaxLiabilitySummaryTable.ts @@ -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', + }, + ]); + } +} diff --git a/packages/server-nest/src/modules/FinancialStatements/modules/SalesTaxLiabilitySummary/SalesTaxLiabilitySummaryTableInjectable.ts b/packages/server-nest/src/modules/FinancialStatements/modules/SalesTaxLiabilitySummary/SalesTaxLiabilitySummaryTableInjectable.ts new file mode 100644 index 000000000..8f5821944 --- /dev/null +++ b/packages/server-nest/src/modules/FinancialStatements/modules/SalesTaxLiabilitySummary/SalesTaxLiabilitySummaryTableInjectable.ts @@ -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} + */ + public async table( + query: SalesTaxLiabilitySummaryQuery, + ): Promise { + 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, + }; + } +} diff --git a/packages/server-nest/src/modules/FinancialStatements/modules/SalesTaxLiabilitySummary/SalesTaxLiabiltiySummaryPdf.ts b/packages/server-nest/src/modules/FinancialStatements/modules/SalesTaxLiabilitySummary/SalesTaxLiabiltiySummaryPdf.ts new file mode 100644 index 000000000..f50b30a97 --- /dev/null +++ b/packages/server-nest/src/modules/FinancialStatements/modules/SalesTaxLiabilitySummary/SalesTaxLiabiltiySummaryPdf.ts @@ -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} + */ + public async pdf(query: SalesTaxLiabilitySummaryQuery): Promise { + const table = await this.salesTaxLiabiltiySummaryTable.table( + query, + ); + return this.tableSheetPdf.convertToPdf( + table.table, + table.meta.sheetName, + table.meta.formattedDateRange, + ); + } +} diff --git a/packages/server-nest/src/modules/FinancialStatements/modules/SalesTaxLiabilitySummary/_constants.ts b/packages/server-nest/src/modules/FinancialStatements/modules/SalesTaxLiabilitySummary/_constants.ts new file mode 100644 index 000000000..f030fd16c --- /dev/null +++ b/packages/server-nest/src/modules/FinancialStatements/modules/SalesTaxLiabilitySummary/_constants.ts @@ -0,0 +1,4 @@ +export enum IROW_TYPE { + TaxRate = 'TaxRate', + Total = 'Total', +} diff --git a/packages/server-nest/src/modules/FinancialStatements/modules/TransactionsByContact/TransactionsByContact.ts b/packages/server-nest/src/modules/FinancialStatements/modules/TransactionsByContact/TransactionsByContact.ts index 0361d2d9d..d1624d2a6 100644 --- a/packages/server-nest/src/modules/FinancialStatements/modules/TransactionsByContact/TransactionsByContact.ts +++ b/packages/server-nest/src/modules/FinancialStatements/modules/TransactionsByContact/TransactionsByContact.ts @@ -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} */ protected contactTransactionMapper( - entry: ILedgerEntry + entry: ILedgerEntry, ): Omit { 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[] + transactions: Omit[], ): 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); }; diff --git a/packages/server-nest/src/modules/Ledger/types/Ledger.types.ts b/packages/server-nest/src/modules/Ledger/types/Ledger.types.ts index 15cc269b0..8a400349d 100644 --- a/packages/server-nest/src/modules/Ledger/types/Ledger.types.ts +++ b/packages/server-nest/src/modules/Ledger/types/Ledger.types.ts @@ -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; diff --git a/packages/server-nest/src/modules/Ledger/utils.ts b/packages/server-nest/src/modules/Ledger/utils.ts index 756d90145..ca7dcda72 100644 --- a/packages/server-nest/src/modules/Ledger/utils.ts +++ b/packages/server-nest/src/modules/Ledger/utils.ts @@ -5,7 +5,7 @@ export const transformLedgerEntryToTransaction = ( entry: ILedgerEntry ): Partial => { return { - date: entry.date, + date: moment(entry.date).toDate(), credit: entry.credit, debit: entry.debit, diff --git a/packages/server-nest/src/modules/SaleInvoices/SaleInvoices.module.ts b/packages/server-nest/src/modules/SaleInvoices/SaleInvoices.module.ts index 372a7775d..30df3e959 100644 --- a/packages/server-nest/src/modules/SaleInvoices/SaleInvoices.module.ts +++ b/packages/server-nest/src/modules/SaleInvoices/SaleInvoices.module.ts @@ -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 {} diff --git a/packages/server-nest/src/modules/StripePayment/CreatePaymentReceivedStripePayment.ts b/packages/server-nest/src/modules/StripePayment/CreatePaymentReceivedStripePayment.ts new file mode 100644 index 000000000..9c18d059c --- /dev/null +++ b/packages/server-nest/src/modules/StripePayment/CreatePaymentReceivedStripePayment.ts @@ -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, + ); + }); + } +} diff --git a/packages/server-nest/src/modules/StripePayment/CreateStripeAccountLink.ts b/packages/server-nest/src/modules/StripePayment/CreateStripeAccountLink.ts new file mode 100644 index 000000000..ddba0de52 --- /dev/null +++ b/packages/server-nest/src/modules/StripePayment/CreateStripeAccountLink.ts @@ -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); + } +} diff --git a/packages/server-nest/src/modules/StripePayment/CreateStripeAccountService.ts b/packages/server-nest/src/modules/StripePayment/CreateStripeAccountService.ts new file mode 100644 index 000000000..da3c194b3 --- /dev/null +++ b/packages/server-nest/src/modules/StripePayment/CreateStripeAccountService.ts @@ -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} + */ + async createStripeAccount( + stripeAccountDTO?: CreateStripeAccountDTO, + ): Promise { + 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; + } +} diff --git a/packages/server-nest/src/modules/StripePayment/ExchangeStripeOauthToken.ts b/packages/server-nest/src/modules/StripePayment/ExchangeStripeOauthToken.ts new file mode 100644 index 000000000..3a8d0c258 --- /dev/null +++ b/packages/server-nest/src/modules/StripePayment/ExchangeStripeOauthToken.ts @@ -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, + ); + }); + } +} diff --git a/packages/server-nest/src/modules/StripePayment/GetStripeAuthorizationLink.ts b/packages/server-nest/src/modules/StripePayment/GetStripeAuthorizationLink.ts new file mode 100644 index 000000000..56b34adb8 --- /dev/null +++ b/packages/server-nest/src/modules/StripePayment/GetStripeAuthorizationLink.ts @@ -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; + } +} diff --git a/packages/server-nest/src/modules/StripePayment/StripePayment.controller.ts b/packages/server-nest/src/modules/StripePayment/StripePayment.controller.ts new file mode 100644 index 000000000..01ba95180 --- /dev/null +++ b/packages/server-nest/src/modules/StripePayment/StripePayment.controller.ts @@ -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} + */ + @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} + */ + @Post('/callback') + public async exchangeOAuth(@Body('code') code: string) { + await this.stripePaymentApp.exchangeStripeOAuthToken(code); + + return {}; + } + + /** + * Creates a new Stripe account. + * @returns {Promise} + */ + 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} + */ + @Post('/account_link') + public async createAccountLink( + @Body('stripeAccountId') stripeAccountId: string, + ) { + const clientSecret = + await this.stripePaymentApp.createAccountLink(stripeAccountId); + + return { clientSecret }; + } +} diff --git a/packages/server-nest/src/modules/StripePayment/StripePayment.module.ts b/packages/server-nest/src/modules/StripePayment/StripePayment.module.ts new file mode 100644 index 000000000..19d86d290 --- /dev/null +++ b/packages/server-nest/src/modules/StripePayment/StripePayment.module.ts @@ -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 {} diff --git a/packages/server-nest/src/modules/StripePayment/StripePayment.types.ts b/packages/server-nest/src/modules/StripePayment/StripePayment.types.ts new file mode 100644 index 000000000..1e1430c99 --- /dev/null +++ b/packages/server-nest/src/modules/StripePayment/StripePayment.types.ts @@ -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; +} diff --git a/packages/server-nest/src/modules/StripePayment/StripePaymentApplication.ts b/packages/server-nest/src/modules/StripePayment/StripePaymentApplication.ts new file mode 100644 index 000000000..62303dd39 --- /dev/null +++ b/packages/server-nest/src/modules/StripePayment/StripePaymentApplication.ts @@ -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, + ); + } +} diff --git a/packages/server-nest/src/modules/StripePayment/StripePaymentService.ts b/packages/server-nest/src/modules/StripePayment/StripePaymentService.ts new file mode 100644 index 000000000..4ccc9e6e1 --- /dev/null +++ b/packages/server-nest/src/modules/StripePayment/StripePaymentService.ts @@ -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} + */ + public async createAccountSession(accountId: string): Promise { + 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} + */ + 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>} + */ + 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', + ); + } + } +} diff --git a/packages/server-nest/src/modules/StripePayment/models/PaymentIntegration.model.ts b/packages/server-nest/src/modules/StripePayment/models/PaymentIntegration.model.ts new file mode 100644 index 000000000..972e116ef --- /dev/null +++ b/packages/server-nest/src/modules/StripePayment/models/PaymentIntegration.model.ts @@ -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; + 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' }, + }, + }; + } +} diff --git a/packages/server-nest/src/modules/StripePayment/subscribers/SeedStripeAccounts.ts b/packages/server-nest/src/modules/StripePayment/subscribers/SeedStripeAccounts.ts new file mode 100644 index 000000000..7e5b33f36 --- /dev/null +++ b/packages/server-nest/src/modules/StripePayment/subscribers/SeedStripeAccounts.ts @@ -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, + }, + }); + } +} diff --git a/packages/server-nest/src/modules/StripePayment/subscribers/StripeWebhooksSubscriber.ts b/packages/server-nest/src/modules/StripePayment/subscribers/StripeWebhooksSubscriber.ts new file mode 100644 index 000000000..3c4323705 --- /dev/null +++ b/packages/server-nest/src/modules/StripePayment/subscribers/StripeWebhooksSubscriber.ts @@ -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, + }); + } + } +} diff --git a/packages/server-nest/src/modules/StripePayment/types.ts b/packages/server-nest/src/modules/StripePayment/types.ts new file mode 100644 index 000000000..e6aed1a66 --- /dev/null +++ b/packages/server-nest/src/modules/StripePayment/types.ts @@ -0,0 +1,9 @@ +import { Knex } from 'knex'; + +export interface CreateStripeAccountDTO { + name?: string; +} +export interface StripeOAuthCodeGrantedEventPayload { + paymentIntegrationId: number; + trx?: Knex.Transaction; +}